ReZygisk 注入体 injector 与反检测
本文是 ReZygisk 源码解读系列第 3 篇。全系列:
00总览与整体架构01守护进程 zygiskd 与 Root 适配02注入机制:monitor 监控与 ptracer 注入03注入体 injector 与反检测基石(本篇)04外围:IPC 协议、模块脚本、WebUI 与构建代码引用格式
文件:行号。本篇代码主要位于loader/src/injector/,并涉及loader/src/common/的elf_util.c、ifunc_shim.c、misc.c。
一、本篇定位
02 篇结束时,libzygisk.so 已经被远程加载进 zygote,并且 ptracer 调用了它的 entry()。从这一刻起,主动权交给了注入体自己。本篇回答:
entry()做了什么,之后注入体如何"潜伏"在 zygote 里;- 它怎么 hook 住 zygote 的关键函数,从而在每个 APP
fork+specialize前后插入模块逻辑; - Zygisk 模块 API 长什么样(
preAppSpecialize/postAppSpecialize/companion/option…),模块如何被加载、调用、卸载; - 完成使命后,注入体如何干净地自我卸载,以及为什么"卸载失败时反而要保留自己";
- 支撑这一切的反检测基石:ELF 解析、IFUNC 垫片、ART 结构探测、
std::string解析、ptrace 痕迹清理、反 atime 检测。
对应文件:
| 文件 | 职责 |
|---|---|
injector/entry.c |
注入体唯一入口 entry() |
injector/hook.c / hook.h |
hook 主体:PLT hook、JNI 方法替换、fork/specialize 编排、模块 API、自卸载 |
injector/jni_hooks.h |
生成代码:被拦截的 JNI 方法变体 + 方法表 + do_hook_zygote() |
injector/gen_jni_hooks.py |
jni_hooks.h 的生成器 |
injector/module.h |
Zygisk 模块 API/ABI 定义 |
injector/ptrace_clear.c/h |
ptrace 痕迹清理(seccomp event) |
injector/cpp_strings.c/h |
libc++ std::string 读取 |
injector/art_method.h |
ART ArtMethod 结构探测 |
common/elf_util.c |
ELF 解析与符号定位(hook 基础) |
common/ifunc_shim.c |
ARM32 libc 函数垫片 |
common/misc.c |
parse_maps_safe 等(反 atime 检测) |
二、entry():注入后的第一步
entry()(entry.c:9-32)是注入体唯一的导出符号(visibility("default")),由 ptracer 远程调用:
void entry(void *addr, size_t size, int tango_flag) {
start_addr = addr; // 保存 libzygisk 自身映射地址
block_size = size; // 供日后自我 munmap
hook_functions(); // ① 安装 PLT hook
struct kernel_version version = parse_kversion();
if (version.major > 3 || (version.major == 3 && version.minor >= 8))
perform_ptrace_message_clear(); // ② 清理 ptrace 痕迹
if (!rezygiskd_zygote_injected()) // ③ 向 zygiskd 上报"已注入"
LOGE("ReZygiskd is not running");
}
三步:
hook_functions()安装 PLT hook(第三节)。perform_ptrace_message_clear()抹掉 ptrace 注入残留(第八节),仅内核 ≥ 3.8 执行。rezygiskd_zygote_injected()发送ZygoteInjected——这就是 02 篇里 monitor 收到的ZYGOTE*_INJECTED的源头(zygiskd 收到后转发给 monitor)。
重要澄清:
entry()不读取模块列表、不在此加载模块.so。它只做"装钩子 + 清痕迹 + 报注入"。真正的模块加载发生在后面被 hook 触发的时机(第五节)。这是通读时极易误解的一点。
addr/size 立刻存入全局(hook.h:7-9 的 start_addr/block_size),是为将来自我卸载时 munmap 自己用。tango_flag 仅用于日志区分 [TANGO] 模式。
三、hook 机制:PLT hook + ART 方法表替换
ReZygisk 同时使用两种 hook,分工明确——这点必须理清,它不是单一的 inline hook。
- PLT hook(通过外部库
PLTI,PLT-Inject):改写调用方的 GOT/PLT 条目,把对某符号的调用重定向到自定义函数。用于 libc/runtime 的 C 函数。 - ART 方法表替换:不改函数本体,而是用 ART 自己的
RegisterNatives把 Java 类的 native 方法表项指向包装函数,并通过读ArtMethod拿到"原函数地址"。用于Zygote类的 native 方法。
3.1 hook_functions:装上 PLT 钩子
hook_functions()(hook.c:1321-1330):
void hook_functions(void) {
plti_init(&plti_ctx);
plti_add_lib(&plti_ctx, "libandroid_runtime.so");
PLT_HOOK_REGISTER("libandroid_runtime.so", fork, false);
PLT_HOOK_REGISTER("libandroid_runtime.so", strdup, false);
PLT_HOOK_REGISTER("libandroid_runtime.so", property_get, false);
PLT_HOOK_REGISTER_SYM("libandroid_runtime.so",
"_ZNK18FileDescriptorInfo14ReopenOrDetach",
_ZNK18FileDescriptorInfo14ReopenOrDetach, true);
}
四个钩子各有用途:
| 钩子 | 位置 | 作用 |
|---|---|---|
new_fork |
hook.c:237-239 |
fork 去重:ReZygisk 自己先 fork 并缓存 pid,ART 内部再 fork 时直接返回缓存值,避免重复 fork |
new_strdup |
hook.c:345-353 |
时机探针:当参数为 "com.android.internal.os.ZygoteInit" 时触发 initialize_jni_hook() |
new_property_get |
hook.c:369-373 |
时机探针:首次调用时触发 hook_unloader(),随后自卸载该钩子 |
new__ZNK...ReopenOrDetach |
hook.c:268-290 |
修复 fd 重开:root 模块卸载 overlay 后某些 fd 无法重开会让 zygote abort,这里对 socket、boot-image-methods.art、access() 失败的文件直接 close 跳过 |
3.2 JNI 方法表替换
hook_jni_methods()(hook.c:381-445)是替换 Zygote native 方法的核心,也是模块 API hook_jni_native_methods 的实现:
void hook_jni_methods(JNIEnv *env, const char *clz, JNINativeMethod *methods, int numMethods) {
jclass clazz = (*env)->FindClass(env, clz);
for (int i = 0; i < numMethods; i++) {
JNINativeMethod *nm = &methods[i];
jmethodID mid = ... GetMethodID / GetStaticMethodID ...;
jobject method = (*env)->ToReflectedMethod(env, clazz, mid, is_static);
jint modifier = (*env)->CallIntMethod(env, method, member_getModifiers);
if ((modifier & MODIFIER_NATIVE) == 0) { nm->fnPtr = NULL; continue; } // 只 hook native
void *art_method = amethod_from_reflected_method(env, method);
if (hooks_count < 32) hooks[hooks_count++] = *nm; // 收集"新方法"(fnPtr=包装函数)
void *orig = amethod_get_data((uintptr_t)art_method); // 从 ArtMethod 取原入口
nm->fnPtr = orig; // 回填原函数地址给调用方
}
(*env)->RegisterNatives(env, clazz, hooks, (jint)hooks_count); // 写入新方法表
}
机制:
- 用反射
Member.getModifiers()校验Modifier.NATIVE,只 hook 真正的 native 方法。 amethod_get_data(art_method)(art_method.h:103-105)读出ArtMethod的原生入口(即原函数地址),回填进nm->fnPtr——这样调用方(jni_hooks.h里的包装函数)就能拿到"原函数"去调用。- 真正的替换由
RegisterNatives完成:hooks[]里存的是新包装函数。
initialize_jni_hook()(hook.c:450-518)在 3.1 的 strdup 探针触发时运行:取得 JNIEnv、解析 Member.getModifiers 与 Modifier.NATIVE 常量、amethod_init(env) 探测 ArtMethod 布局(第九节)、置 can_hook_jni = true,最后调 do_hook_zygote(env)(在 jni_hooks.h 中)。
四、hook 的时机:两个探针串起整条链
注入体面临的难题是时序:JNI hook 需要 ART/JNIEnv 就绪,模块加载需要 libart 已载入且在 system_server fork 之前。ReZygisk 用两个 PLT 探针精确卡时机:
hook_unloader()(hook.c:1332-1349):
static void hook_unloader(void) {
plti_add_lib(&plti_ctx, "libart.so");
PLT_HOOK_REGISTER("libart.so", pthread_attr_setstacksize, false); // 自卸载用(第七节)
hook_unregister("libandroid_runtime.so", "property_get", false, &old_property_get);
load_modules_only(); // ← 模块在这里加载
}
load_modules_only()(hook.c:934-991):发送 ReadModules 拿到各模块 .so 路径,用 csoloader_load 逐个加载(仍是绕过系统 linker 的自定义加载器),取符号 zygisk_module_entry 作入口;某模块加载失败则发 RemoveModule 通知 zygiskd 同步移除,保持两端列表一致。
五、被拦截的 JNI 方法与生成器
5.1 拦截清单
所有被 hook 的方法都在类 com/android/internal/os/Zygote(jni_hooks.h:426):
| 方法 | 变体数 | 说明 |
|---|---|---|
nativeForkAndSpecialize |
12(jni_hooks.h:192-254) |
fork+特化 APP;变体覆盖 Android L→U、三星、GrapheneOS |
nativeSpecializeAppProcess |
6(jni_hooks.h:348-380) |
仅特化(已 fork 的进程) |
nativeForkSystemServer |
2(jni_hooks.h:408-420) |
fork system_server |
变体之多,是为应对碎片化的 ROM:不同 Android 版本、三星(在 se_info 后插入两个匿名 jint)、GrapheneOS(末尾多一个 jlongArray)的 JNI 签名各不相同。do_hook_zygote()(jni_hooks.h:422-458)在运行时对每类方法遍历整张表逐一尝试,选用第一个匹配成功(fnPtr 被回填)的变体。
5.2 gen_jni_hooks.py 生成了什么
gen_jni_hooks.py 是代码生成器,为每个变体生成(gen_jni_hooks.py:236-270):
static void *<base>_orig——保存原函数;typedef <ret> (*<base>_fn)(JNIEnv*, jclass, ...)——变参函数指针类型,让所有变体共用一个调用类型;- 每变体一个
no_stack_protector的包装函数; <base>_methods[]方法表 + 计数。
包装函数体的模板(gen_jni_hooks.py:97-111):
// 生成形如 nativeForkAndSpecialize_l 的函数体:
struct app_specialize_args_v5 args = { .uid = &uid, .gid = &gid, ... }; // 参数全部取地址
args.<新增字段> = &<参数>; // 仅该版本新增的字段才赋值
struct zygisk_context ctx;
rz_init(&ctx, env, &args);
rz_nativeForkAndSpecialize_pre(&ctx); // 前置回调
<orig>(env, clazz, <原参数列表>); // 调原函数
rz_nativeForkAndSpecialize_post(&ctx); // 后置回调
rz_cleanup(&ctx);
两个关键设计:
- 参数全部以指针收入
app_specialize_args_v5(如.uid = &uid),使模块的 pre 回调能修改 uid/gid/niceName 等特化参数。 no_stack_protector:这些函数被 ART 当 native 方法直接调用,栈布局必须严格可控,禁用栈 canary 避免干扰。
六、模块生命周期
6.1 fork/specialize 编排
以 nativeForkAndSpecialize 为例,pre/post 回调编排在 hook.c:
rz_run_modules_pre(hook.c:993-1000):对每个模块先rz_module_call_on_load(调zygisk_module_entry完成注册),再按模块声明的标志调pre_app_specialize或pre_server_specialize。rz_run_modules_post(hook.c:1002-1031):调 post 回调;之后按模块的unload标志决定csoloader_unload(卸载)或csoloader_abandon(保留)。
6.2 参数处理:isolated uid 与 fd 清理
- niceName:pre 阶段
GetStringUTFChars取出存入ctx->process,post 阶段释放(hook.c:1149/1154/1189)。 - isolated uid 修正(
hook.c:1048-1076):对隔离服务(IS_ISOLATED_SERVICE(uid),即90000 ≤ uid < 1000000)stat其 app 数据目录取真实 app uid 再传给 zygiskd,防止隔离进程绕过 DenyList。随后rezygiskd_get_process_flags(uid, process)拿 01 篇所述的进程标志。 - fd 清理(
rz_fork_pre记录 +rz_sanitize_fds,hook.c:824-927):记录 fork 前打开的 fd,合并模块通过exempt_fd申请豁免的 fd 进fds_to_ignore,关闭其余"泄漏" fd,防止特化后崩溃。
七、模块 API 与自卸载
7.1 模块 API/ABI(module.h)
API 版本 REZYGISK_API_VERSION = 5(module.h:12)。三个核心结构:
// module.h:120-128 对应 zygisk::ModuleBase 的 vtable
struct rezygisk_abi {
long api_version;
void *impl;
void (*pre_app_specialize)(void *, void *);
void (*post_app_specialize)(void *, const void *);
void (*pre_server_specialize)(void *, void *);
void (*post_server_specialize)(void *, const void *);
};
// module.h:130-149 对应 zygisk::Api
struct rezygisk_api {
void *impl;
bool (*register_module)(struct rezygisk_api *, struct rezygisk_abi const *);
void (*hook_jni_native_methods)(JNIEnv *, const char *, JNINativeMethod *, int);
union { /* v3- */ void (*plt_hook_register)(...); /* v4 */ void (*plt_hook_register_v4)(dev_t, ino_t, ...); };
union { /* v3- */ void (*plt_hook_exclude)(...); /* v4 */ void (*exempt_fd)(int); };
bool (*plt_hook_commit)();
int (*connect_companion)(void *);
void (*set_option)(void *, enum rezygisk_options opt);
int (*get_module_dir)(void *); // v2+
uint32_t (*get_flags)(); // v2+
};
union 用于在不同 API 版本间复用同一槽位(v3 的 plt_hook_register 与 v4 的 plt_hook_register_v4 占同一位置)。
模块 option(module.h:99-118):
| 选项 | 值 | 含义 |
|---|---|---|
FORCE_DENYLIST_UNMOUNT |
0 | 在 preSpecialize 设置:令 ReZygisk 为该进程卸载 root 挂载 |
DLCLOSE_MODULE_LIBRARY |
1 | postSpecialize 后卸载模块库(若模块还留有 hook 引用则危险) |
版本兼容:rezygisk_module_register(hook.c:782-811)按模块声明的 api_version 装配函数指针表;module.h:173-301 的内联分发函数把新版 app_specialize_args_v5 转换成模块期望的 v1/v4 结构,保持对旧模块的 ABI 兼容,且对缺失的回调判空跳过(原版 Zygisk 缺函数会空指针解引用,这里更稳健)。
模块 ID 用 ENCODE_ID/DECODE_ID(hook.c:724-726)编码成不透明指针,所有 API 入口都校验范围,防止模块乱传 impl。
7.2 自卸载:should_unmap_zygisk
注入体完成使命后要从 zygote 内消失(否则自身就是检测特征)。但卸载有个致命前提:所有 hook 必须先成功还原,否则方法表/PLT 还指向 libzygisk 内存,munmap 后再调用就 segfault。
ReZygisk 用一个全局标志 should_unmap_zygisk 把控:
- JNI 还原失败 →
should_unmap_zygisk = false(hook.c:1235)。 - PLT 还原失败 →
hook_unregister里置should_unmap_zygisk = false(hook.c:1299)。 - 真正的
munmap由 hook 在pthread_attr_setstacksize里执行(hook.c:302-342)——选这个函数是因为它在 VM 守护线程启动前后被调用,能在 APP 跑任何代码前完成卸载,且签名兼容[[clang::musttail]]尾调用(munmap 自己所在的内存,必须尾调用,否则返回时栈已不存在)。
// hook.c:316-339(节选)自卸载的核心保护
if (should_unmap_zygisk) {
unhook_functions(); // 还原剩余 PLT hook
csoloader_deinit();
if (!should_unmap_zygisk) { // unhook 期间可能被置 false
LOGW("Failed to unmap libzygisk.so, skipping munmap");
enable_unloader = false;
free(zygisk_modules); plti_deinit(&plti_ctx);
return res; // 放弃卸载,但进程继续存活
}
free(zygisk_modules); plti_deinit(&plti_ctx);
[[clang::musttail]] return munmap(start_addr, block_size); // 尾调用自我卸载
}
这正是仓库近期提交 "fix: not unload ReZygisk if unhook failed" 的含义:宁可让 libzygisk 残留在内存里(增加一点被检测的风险),也绝不能在 hook 没还原干净时 munmap 自己——后者会直接让 APP 崩溃。可用性 > 隐蔽性,这是个正确的权衡。
7.3 companion 连接
模块通过 api.connect_companion(impl)(hook.c:728-738)→ rezygiskd_connect_companion(index)(daemon.c,发送 RequestCompanionSocket)拿到与 companion 直连的 socket fd。这就接上了 01 篇第六节描述的 companion 接力流程。
八、反检测基石之一:ptrace_clear
注入用了 ptrace,内核会记录"最后一次 ptrace 事件信息",可能被检测。perform_ptrace_message_clear(ptrace_clear.c:39-108)主动触发一次受控的 seccomp 事件来覆盖它:
精妙之处:用一个带随机参数、必不会被正常代码触发的 exit_group 去命中自装的 seccomp 过滤器,从而刷新内核里的 ptrace event 信息,但因为 tracer 会跳过这个 syscall,进程并不会真的退出。内核 ≥ 5.10 时 seccomp 过滤器对外可见,此手法失效,于是直接放弃(ptrace_clear.c:17-44)。
九、反检测基石之二:ELF 解析与符号定位
hook 要改谁、远程要调用哪个函数,都依赖"在内存里找到符号地址"。elf_util.c 提供这个能力。
9.1 思路
运行时内存里通常看不到 .symtab、节头表,所以 ReZygisk 的做法是(elf_util.c):
9.2 三种符号查找
统一入口 getSymbOffset(elf_util.c:656-669)按 GNU 哈希 → SysV 哈希 → 线性扫描 顺序查找:
- GNU 哈希(
GnuLookup,elf_util.c:497-576):先用 bloom filter 快速排除,再沿 chain 比对。 - SysV 哈希(
ElfLookup,elf_util.c:578-596)。 - 线性查找(
LinearLookup,elf_util.c:598-654):.symtab通常无哈希表,只能逐个扫,支持前缀匹配(hook C++ mangled 符号时用)。
getSymbAddress(elf_util.c:774-787)把偏移换算成运行时地址:base + offset - bias,并对 IFUNC 符号调用其 resolver(handle_indirect_symbol,elf_util.c:746-772,按架构以不同签名调用,处理 AT_HWCAP 等)。
02 篇里 ptracer 的
find_func_addr正是基于此:本地解析偏移 + 远程基址 = 目标进程里的函数地址。
十、反检测基石之三:IFUNC 垫片、std::string 与 ArtMethod
10.1 ifunc_shim:ARM32 的 libc 垫片
ifunc_shim.c(仅 #ifdef __arm__ 编译)解决 Tango 场景的一个棘手问题:注入 32 位 app_process 时(从 64 位宿主),还无法解析 ARM32 的 IFUNC 符号(那需要在目标环境真正执行 resolver)。而现代 bionic 的 memcpy/strcmp/... 多是 STT_GNU_IFUNC。
解法:在本编译单元里用纯 C 提供这些函数的最小实现(visibility("hidden")),让链接器把调用解析到本地定义,从而完全避免从 libc 导入它们(ifunc_shim.c:19-27 等)。还覆写了 __strcpy_chk/__memset_chk 等 _FORTIFY_SOURCE 会生成的变体,并 #undef _FORTIFY_SOURCE 防止改写自身定义。
配合近期提交 "improve: only compile Tango-related code for AArch64",这类垫片只在相关构建里参与。
10.2 cpp_strings:读 libc++ std::string
注入体是纯 C,但要 hook 的 FileDescriptorInfo::ReopenOrDetach 参数里含 C++ std::string(文件路径)。cpp_strings.c 按 libc++ 的内存 ABI 手工解析(cpp_strings.c:20-42):
static inline bool is_short_string(const unsigned char *bytes) {
return (bytes[0] & 1) == 0; // 小端:首字节 LSB=0 即短模式(SSO)
}
const char *read_std_string(const void *p) {
const unsigned char *bytes = p;
if (is_short_string(bytes)) return (const char *)(bytes + 1); // 短:数据紧跟首字节
return *(const char **)((const char *)p + LONG_DATA_OFFSET); // 长:取指针字段
}
处理了 libc++ 的 SSO(小字符串优化)布局,假定 libc++ + 小端(对 Android 成立)。
10.3 art_method:探测 ArtMethod 布局
ART 里每个 Java 方法是一个 ArtMethod,其末尾字段含 native 入口。3.2 节的 hook_jni_methods 要读这个入口拿"原函数地址"。但 ArtMethod 的布局随 Android 版本变化,于是 amethod_init(art_method.h:27-101)用一个巧妙的运行时探测:
// 取 Throwable 的两个相邻构造器,用地址差推算结构尺寸
art_method_size = second_ctor_addr - first_ctor_addr;
entry_point_offset = art_method_size - sizeof(void *); // 末字段 = entry point
data_offset = entry_point_offset - sizeof(void *); // 倒数第二 = data(原入口)
amethod_get_data(art_method.h:103-105)据此读出原 native 入口。这种"用相邻对象地址差推断结构大小"的手法,免去了为每个 Android 版本硬编码偏移。
10.4 parse_maps_safe:反 atime 检测
读 /proc/<pid>/maps 会更新该文件的 access time,APP 可用 stat() 检测"有人窥探过我的内存布局"。parse_maps_safe(misc.c:58)的对策:fork 一个子进程,由子进程打开 maps 并经 SCM_RIGHTS 把 fd 传回父进程读取——更新的是子进程那份 maps 的 atime,父进程的不受影响,从而不可检测。父子用 1 字节握手协调生命周期,最后 waitpid 回收避免僵尸。
十一、小结
注入体 libzygisk.so 是 ReZygisk 在 zygote 内的"常驻特工",本篇要点:
- entry() 三步走:装 PLT hook → 清 ptrace 痕迹 → 报告注入。它本身不加载模块。
- 双 hook 体系:PLT hook(PLTI)改 C 函数调用,ART
RegisterNatives替换 Zygote native 方法;后者靠读ArtMethod拿原入口。 - 两个时机探针:
strdup("...ZygoteInit")触发 JNI hook 安装;property_get首次调用触发模块加载(在 system_server fork 前,确保扩散)。 - 碎片化适配:
gen_jni_hooks.py为 12+6+2 个 JNI 变体(含三星/GrapheneOS)生成包装,运行时按签名择一。 - 模块生命周期:pre/post × app/server specialize 四回调,参数以指针传入可被模块修改;companion 经 zygiskd 接力直连。
- 可用性优先的自卸载:只有所有 hook 还原成功才
munmap自己;任一失败则保留存活,宁可留痕也不让 APP 崩。 - 反检测基石:ELF 自解析定位符号、ARM32 IFUNC 垫片、ArtMethod 结构探测、libc++ string 解析、seccomp 事件清理、反 atime 的 maps 读取。
下一篇(04)走出 native 代码,看 ReZygisk 的外围:三类 socket 的 IPC 协议全景、模块安装/开机启动脚本链、WebUI 与构建系统。
评论
- 还没有评论,来说点什么吧。