ReZygisk 注入机制:monitor 与 ptracer
本文是 ReZygisk 源码解读系列第 2 篇。全系列:
00总览与整体架构01守护进程 zygiskd 与 Root 适配02注入机制:monitor 监控与 ptracer 注入(本篇)03注入体 injector 与反检测基石04外围:IPC 协议、模块脚本、WebUI 与构建代码引用格式
文件:行号。本篇代码位于loader/src/ptracer/。
一、本篇定位
Zygisk 的本质是:在 Android 的孵化器进程 zygote 里插入自己的代码,这样之后由 zygote fork 出来的每一个 APP / system_server 进程都天然带上模块逻辑。
问题是——怎么把代码插进 zygote?ReZygisk 选择的是 ptrace 路线(与基于 LD_PRELOAD/riru 的方案不同):
- 一个常驻的 monitor 进程用 ptrace 追踪 init(pid 1),坐等 init
fork+exec出 zygote。 - 一旦发现 zygote 启动,monitor 立刻
fork+exec出一个 ptracer(zygisk-ptrace trace <pid>)去 attach 那个 zygote。 - ptracer 在 zygote 里远程完成"分配内存 → 加载
libzygisk.so→ 调用其入口",全程绕过系统 linker(用自定义加载器 CSOLoader),以规避基于 linker 的检测。
本篇就讲这两个角色。对应文件:
| 文件 | 角色 |
|---|---|
ptracer/main.c |
二进制 zygisk-ptrace 的入口,分发 monitor/trace/ctl/info 子命令 |
ptracer/monitor.c |
monitor 主体:追踪 init、捕获 zygote、派发 ptracer、状态上报 |
ptracer/monitor.h |
monitor 与 ptracer 共享的命令枚举与声明 |
ptracer/ptracer.c |
ptracer 主体:trace_zygote() 远程注入 |
ptracer/remote_csoloader.c/h |
远程 ELF 加载器(CSOLoader 的远程化) |
ptracer/utils.c/h |
远程过程调用原语(读写内存/寄存器、远程 call/syscall、跨架构抽象) |
二、整体注入链路
先看一次完整注入从头到尾发生了什么:
注意第 ⑱ 步:"注入完成"不是 ptracer 通知的,而是注入体 libzygisk.so 在 zygote 内通过 zygiskd 回流给 monitor 的。ptracer 的职责到"恢复寄存器+detach"就结束了。
三、monitor:追踪 init,捕获 zygote
3.1 启动:claim init tracer
init_monitor(monitor.c:992-1051)是 zygisk-ptrace monitor 的落点。启动序列:
void init_monitor() {
prepare_environment(); // 读取 module.prop 模板,准备状态写回
claim_init_tracer(); // PTRACE_SEIZE init
monitor_events_init(); // epoll_create
rezygiskd_listener_init(); // 创建 init_monitor datagram socket
monitor_events_register_event(rezygiskd_listener_callback, monitor_sock_fd, EPOLLIN|EPOLLET);
sigchld_listener_init(); // signalfd 捕获 SIGCHLD
monitor_events_register_event(sigchld_listener_callback, sigchld_signal_fd, EPOLLIN|EPOLLET);
monitor_events_loop(); // 主循环
...
}
claim_init_tracer(monitor.c:544-562)是关键的第一步:
static bool claim_init_tracer() {
if (ptrace(PTRACE_SEIZE, 1, 0, PTRACE_O_TRACEFORK) == -1) {
if (errno == EPERM) {
// 已有另一个进程在追踪 init(例如重复启动了 ReZygisk)
update_status("❌ Multiple Zygisks functioning");
} else PLOGE("failed to seize init");
return false;
}
return true;
}
PTRACE_SEIZE而非PTRACE_ATTACH:SEIZE 不会向被追踪者发停止信号,对 init 这种关键进程更安全。PTRACE_O_TRACEFORK:让 init 每次fork都通知 monitor——zygote 正是 init 的子进程。EPERM处理:一个进程只能被一个 tracer 追踪,若已被占用说明重复运行,直接退出,避免冲突。
3.2 双事件源的 epoll 循环
monitor 用 epoll 同时监听两个事件源(monitor.c:117-146):
signalfd(monitor.c:564-585):把SIGCHLD转成可 epoll 的 fd。被 ptrace 追踪的进程发生停顿时,tracer 会收到SIGCHLD,于是这里统一处理所有 ptrace 事件。init_monitordatagram socket(monitor.c:150-173):路径<rezygiskd_path>/init_monitor,既接收用户控制命令(ctl),也接收 zygiskd 的状态上报。
3.3 controller socket:命令与状态上报
rezygiskd_listener_callback(monitor.c:175-396)处理 init_monitor socket 上收到的字节命令。命令枚举见 monitor.h:
| 命令 | 值 | 来源 | 含义 |
|---|---|---|---|
START |
1 | 用户 ctl start |
恢复/开始追踪 init |
STOP |
2 | 用户 ctl stop |
停止追踪(PTRACE_INTERRUPT init) |
EXIT |
3 | 用户 ctl exit |
退出 monitor |
ZYGOTE64_INJECTED |
4 | zygiskd | 64 位 zygote 注入完成 |
ZYGOTE32_INJECTED |
5 | zygiskd | 32 位 zygote 注入完成 |
DAEMON64_SET_INFO |
6 | zygiskd | 上报 root 实现 + 模块清单(64) |
DAEMON32_SET_INFO |
7 | zygiskd | 同上(32) |
DAEMON64_SET_ERROR_INFO |
8 | zygiskd | 上报错误信息(64) |
DAEMON32_SET_ERROR_INFO |
9 | zygiskd | 同上(32) |
这正好与 01 篇里
constants.h的LP_SELECT编码对应:ZYGOTE_INJECTED = LP_SELECT(5,4)(32 位发 5、64 位发 4),DAEMON_SET_INFO = LP_SELECT(7,6),DAEMON_SET_ERROR_INFO = LP_SELECT(9,8)。zygiskd 用unix_datagram_sendto发,monitor 在这里收。
SET_INFO 分支(monitor.c:240-351)会读取 root 实现名、模块数、各模块名,存进 environment_information64/32,随后 update_status 把它们写进 state.json(供 WebUI 读取,见 04 篇)。
3.4 捕获 zygote:PTRACE_EVENT_EXEC 匹配程序名
真正的"捕获"在 sigchld_listener_callback(monitor.c:587-804)。它 waitpid 收割所有 ptrace 停顿,对 init 的子进程做这样的处理:
捕获逻辑要点:
- init
fork出子进程时(PTRACE_EVENT_FORK),monitor 把子进程纳入追踪并设PTRACE_O_TRACEEXEC(monitor.c:687-692),这样子进程execve时会再次停下。 - 子进程
execve触发PTRACE_EVENT_EXEC,monitor 用get_program(pid)(读/proc/<pid>/exe)拿到它 exec 的程序路径(monitor.c:694-702)。 - 三个匹配目标(
monitor.c:481-535):/system/bin/app_process64→ 64 位 zygote/system/bin/app_process32→ 32 位 zygote/system_ext/bin/tango_translator→ Tango(AArch64 上运行 ARM32 的翻译器,需特殊注入)
3.5 PRE_INJECT:拉起 daemon + 防过度重启
PRE_INJECT 是个宏(monitor.c:485-509),在确认要注入前做两件事:
#define PRE_INJECT(abi, is_64) \
if (strcmp(program, APP_PROCESS_##abi) == 0) { \
tracer = "./bin/zygisk-ptrace" #abi; \
if (should_stop_inject##abi()) { /* 防过度重启 */ \
tracing_state = STOPPING; monitor_stop_reason = "Zygote crashed"; \
ptrace(PTRACE_INTERRUPT, 1, 0, 0); break; \
} \
if (!ensure_daemon_created(is_64)) { /* 确保 zygiskd 在跑 */ \
tracing_state = STOPPING; monitor_stop_reason = "ReZygiskd not running"; \
ptrace(PTRACE_INTERRUPT, 1, 0, 0); break; \
} \
}
- 防过度重启(
monitor.c:405-423的CREATE_ZYGOTE_START_COUNTER宏):用CLOCK_MONOTONIC记录上次 zygote 启动时刻,若 30 秒内连续启动达到MAX_RETRY_COUNT(5)次,判定 zygote 在崩溃循环,停止注入——否则会陷入"注入→崩溃→重启→再注入"的死循环把设备拖垮。 ensure_daemon_created(monitor.c:428-459):fork+execl ./bin/zygiskd32/64,把对应位宽的守护进程拉起来(这就是 01 篇里 zygiskd 的来源)。
3.6 handoff:把注入交给独立的 ptracer 进程
确认要注入后(monitor.c:707-779),monitor 并不亲自注入,而是:
// 先让目标停稳(非 tango 路径)
kill(pid, SIGSTOP);
ptrace(PTRACE_CONT, pid, 0, 0);
waitpid(pid, &sigchld_status, __WALL);
ptrace(PTRACE_DETACH, pid, 0, SIGSTOP); // monitor 自己 detach,让 ptracer 来接管
// fork 出一个"不用 reap"的子进程去 exec ptracer
int p = fork_dont_care();
if (p == 0) {
execl(tracer, basename(tracer), "trace", pid_str, [--restart] [--tango], NULL);
...
}
为什么要交给独立进程?因为一个进程只能被一个 tracer 追踪。monitor 要继续追踪 init,就不能同时追踪 zygote,于是 monitor 先 detach,再 fork 出专门的 ptracer 去 SEIZE zygote。fork_dont_care()(utils.c:343-357,双重 fork)让 ptracer 成为 init 的孩子,monitor 无需 waitpid 它。
--restart / --tango 的传递(monitor.c:752-766):
--tango:目标是tango_translator时附加。--restart:仅在"非首次"启动时附加(count_zygote > 1),用于通知 zygiskd 重置 companion(zygote 重启了,旧 companion 连接失效)。
3.7 状态机与状态上报
monitor 维护一个 4 态状态机(monitor.c:38-45):TRACING / STOPPING / STOPPED / EXITING。update_status(monitor.c:838-961)把当前状态写到两个地方:
module.prop的 description:用 emoji 拼出形如[Monitor: ✅, ReZygisk 64-bit: ✅, ReZygisk 32-bit: ✅]的状态串(monitor.c:818-836的WRITE_STATUS_ABI宏),这就是用户在 root 管理器模块列表里看到的描述。/data/adb/rezygisk/state.json:结构化状态(root 实现、monitor 状态、daemon 32/64 运行情况、模块清单、zygote 注入标志),供 WebUI 读取(monitor.c:879-956)。
3.8 ctl 命令
zygisk-ptrace ctl <start|stop|exit>(main.c:38-58)通过 send_control_command(monitor.c:1053-1072)向 init_monitor socket 发一个命令字节,由 3.3 节的 callback 处理。这给了用户运行时开关注入的能力(无需重启)。
四、ptracer:trace_zygote 远程注入
zygisk-ptrace trace <pid> [--restart] [--tango](main.c:15-37)最终调用 trace_zygote(pid, is_tango)(ptracer.c:453)。
一个时序细节(
main.c:24-35):非 tango 的重启会先调rezygiskd_zygote_restart()再 trace;tango 因注入窗口极窄,必须先尽快 trace,所以把 restart 通知推迟到 trace 成功之后。
下面以标准路径(非 tango,对应 inject_on_main,ptracer.c:232-442)讲解,这是理解 ReZygisk 注入的核心。
4.1 SEIZE 与内核版本判定
trace_zygote 先按内核版本决定 SEIZE 选项(ptracer.c:463-500):内核 ≥ 3.8 才支持带选项的 SEIZE。标准路径用 PTRACE_O_EXITKILL | PTRACE_O_TRACESECCOMP,然后等待目标进入第一个停顿(期望 SIGSTOP + PTRACE_EVENT_STOP,即 monitor 交接前让它停住的那个点)。
4.2 精确停顿:破坏 AT_ENTRY,等一个 SIGSEGV
这是整个注入最巧妙的技巧。注入需要满足两个矛盾的条件:
- linker 必须已经初始化完成(否则远程没法做符号解析、内存分配等);
- 但 zygote 的业务逻辑还没开始跑(否则注入晚了,错过 fork)。
ReZygisk 的解法(ptracer.c:232-353):
核心代码(ptracer.c:329-330):
// 把入口地址改成一个必定 fault 的无效地址(保留最低位以兼容 ARM thumb 判定)
uintptr_t break_addr = (uintptr_t)((intptr_t)(-0x0F & ~1) | (intptr_t)((uintptr_t)entry_addr & 1));
write_proc(pid, (uintptr_t)addr_of_entry_addr, &break_addr, sizeof(break_addr));
KernelArgumentBlock 的解析(ptracer.c:232-319):从 REG_SP(栈顶)开始,依次读 argc → argv → 跳过 envp → 定位 auxv,在 auxv 里找 AT_ENTRY(程序真正入口)及其内存地址。所有读取走 read_proc(process_vm_readv)。
这个手法的优雅之处:不需要硬件断点、跨架构通用——让进程自己跑到入口再因为地址非法而崩,崩点就是我们要的注入时机。
4.3 远程加载 libzygisk.so(CSOLoader)
停稳后,调用 remote_csoloader_load_and_resolve_entry(ptracer.c:378)在 zygote 内加载注入体。这是 ReZygisk 防检测的关键,单独在第六节展开。它返回 libzygisk 在目标进程的基址 remote_base、大小 remote_size、以及入口符号 entry 的地址 injector_entry。
4.4 远程调用 entry()
// ptracer.c:390-395
long args[3] = { remote_base, remote_size, 0 /* tango_flag */ };
remote_call(pid, ®s, injector_entry, (uintptr_t)libc_return_addr, args, 3);
remote_call 怎么在目标进程里"调用一个函数"?见第五节。这里关键是怎么知道调用结束了——ReZygisk 用了一个统一技巧:给函数一个必定崩溃的返回地址(libc_return_addr,取自 libc 一个不可执行段的起始)。函数返回时跳到这个地址 → 触发 SIGSEGV → 控制回到 ptracer。
// ptracer.c:401-403 成败判定
// 停下来时 REG_IP 恰好等于 libc_return_addr → 说明 entry() 正常返回
if (停在 libc_return_addr) { /* 成功 */ }
else { /* entry() 内部崩了 */ }
4.5 恢复现场,detach
entry() 成功后,把备份的寄存器恢复(REG_IP 设回真实 entry_addr),set_regs(ptracer.c:427-431)。随后 trace_zygote 经过一串 CONT/WAIT 握手后 PTRACE_DETACH(ptracer.c:566-614)。zygote 像什么都没发生一样从入口继续执行——但它的地址空间里已经多了一个 libzygisk.so,且 JNI 方法表已被 hook(hook 在 entry() 里完成,见 03 篇)。
五、远程过程调用原语(utils.c)
trace_zygote 依赖一组"在别的进程里操作内存、寄存器、调用函数"的原语。它们是理解 ptracer 的基础。
5.1 跨架构寄存器抽象
utils.h:18-39 把四种架构(aarch64 / arm / x86_64 / i386)的寄存器统一成 4 个宏:
| 宏 | 含义 |
|---|---|
REG_SP |
栈指针 |
REG_IP |
指令指针(PC) |
REG_RET |
返回值寄存器 |
REG_SYSNR |
系统调用号寄存器 |
整个 ptracer 的跨架构能力都建立在这层抽象上。
5.2 内存与寄存器读写
| 函数 | 机制 |
|---|---|
read_proc / write_proc(utils.c:26-62) |
process_vm_readv / process_vm_writev 批量读写远程内存 |
get_regs / set_regs(utils.c:64-120) |
x86 用 PTRACE_GETREGS;ARM 系用 PTRACE_GETREGSET, NT_PRSTATUS(iovec) |
5.3 远程符号解析:本地求偏移 + 远程基址
find_func_addr(utils.c:174-215)解决"目标进程里某个 libc 函数在哪"。它利用一个事实:ptracer 自己也加载了同名的系统库(libc/linker),于是可以在本进程解析符号偏移,再换算到目标的加载基址:
// utils.c:209 跨进程符号定位的核心等式
uintptr_t addr = (uintptr_t)(sym - local_base) + (uintptr_t)remote_base;
本地偏移用 elf_util 的 ElfImg_create + getSymbAddress 求出(见 03 篇)。这避免了在目标进程里跑 dlsym(既慢又留痕)。
5.4 remote_call:构造调用栈
remote_call(utils.c:225-341)按架构 ABI 装配寄存器和栈:
关键设计(与 4.4 呼应):把返回地址设成一个会 fault 的地址,靠"停在该地址的 SIGSEGV"判定调用正常结束并取回返回值(utils.c:325-332)。
5.5 remote_syscall:裸 syscall gadget
remote_call 调用的是用户态函数,而加载库需要的 mmap/mprotect 等系统调用则用 remote_syscall(utils.c:891-1013)实现。它不调用 libc 的封装,而是:
find_syscall_gadget(utils.c:359-448)在目标可执行内存里搜索一条 syscall 指令(aarch64svc #0、x86_64syscall、i386int 0x80、arm 的 ARM/Thumb 变体),优先在[vdso]里找。- 把系统调用号和参数装进寄存器,
REG_IP指向这条 gadget。 - 用两次
PTRACE_SYSCALL(进入 + 退出 syscall-stop)确保系统调用真正执行完,取REG_RET,最后恢复原寄存器。
为什么用裸 gadget 而非跳进 libc 的
mmap?因为新版 Android 启用了 IBT(间接分支跟踪)/ GCS(受保护影子栈),"跳进 libc 函数中间"会被 CPU 拦截。直接复用进程里已有的 syscall 指令则不受影响。aarch64 上还会清pstate的BTYPE位以让单步 vDSO 的svc被接受(utils.c:915-916)。
六、CSOLoader:绕过系统 linker 的远程加载
remote_csoloader.c 是 README 中提到的 CSOLoader("SOTA Linux custom linker")的远程化部分。它在目标进程里完成 libzygisk.so 的加载,完全不经过系统 linker / dlopen。
6.1 为什么要自己写加载器
系统 dlopen 会把新库登记进 linker 的内部链表(soinfo),并使其出现在 dl_iterate_phdr 与 /proc/self/maps 的可识别项里。很多 root 检测正是遍历 linker 的库列表或扫描 maps 找可疑映射来发现注入。CSOLoader 自己 mmap 的映射不进入 linker 列表,从根本上躲开这类检测。
6.2 加载步骤
remote_csoloader_load_and_resolve_entry(remote_csoloader.c:684-1106)的流程:
几个防检测细节:
- 高地址放置(
remote_csoloader.c:793-837):LP64 上要求映射基址>= 0x100000000(4GiB+),远离目标进程自然产生的 VMA,降低被"相邻可疑映射"启发式发现的概率。 - 裸 syscall:所有
mmap/mprotect/openat/close都走remote_syscall(同 5.5 的理由)。 - 抹除栈上路径串(
remote_csoloader.c:764-791):把临时写到目标栈上的.so路径字符串用 0 覆盖,不留痕迹。 - 重定位时解析
dl*到 linker 内部符号(remote_csoloader.c:483-499):对未定义的dlopen/dlsym等,直接解析/system/bin/linker(64)导出的__dl_dlopen/__dl_dlsym,既可用又不走正常符号绑定。
重定位实现按架构分(remote_csoloader.c:514-649):aarch64 处理 R_AARCH64_RELATIVE/GLOB_DAT/JUMP_SLOT/ABS64,x86_64 处理 R_X86_64_*,arm/i386 类似。入口符号约定为 entry(remote_csoloader.c:1080-1090),正对应注入体 injector/entry.c 的 void entry(void*, size_t, int)。
七、Tango 路径:ARM32-on-ARM64 的特殊注入
Tango 是让 AArch64-only 设备运行 32 位 ARM 程序的二进制翻译器。注入 Tango 需要完全不同的策略(inject_tango,ptracer.c:27-229,仅 ARM32 编译),因为:
- Tango 在用户态拦截
BKPT/UDF断点指令,会吞掉SIGTRAP,标准的断点式停顿失效; - Tango 维护内存侧状态,不能简单用
set_regs回退。
ReZygisk 的解法是 GOT hook:
要点:
ptrace_poke_u32(utils.c:765-788)用PTRACE_PEEKDATA/POKEDATA做非对齐写,可绕过 RELRO(只读 GOT 页通过 ptrace 仍可写)。- 用
kill(getpid(), SIGTRAP)(而非断点指令)走内核正常信号投递路径,绕过 Tango 对断点的拦截。 - 清理阶段用一段 tail-call stub 让目标自然恢复执行(因不能用
set_regs)。
八、防检测设计小结
把本篇散落的防检测点汇总,这是 ReZygisk 相对系统 Zygisk / 旧方案的核心价值:
| 手段 | 位置 | 防的是什么 |
|---|---|---|
| ptrace SEIZE init(不发停止信号) | monitor.c:545 |
对 init 干扰最小,避免异常 |
| 破坏 AT_ENTRY 精确停顿 | ptracer.c:329 |
无需硬件断点,跨架构、低痕迹 |
| CSOLoader 自加载(不进 linker 列表) | remote_csoloader.c |
遍历 linker 库列表的检测 |
| 高地址放置映射 | remote_csoloader.c:793 |
"相邻可疑映射"启发式 |
| 裸 syscall gadget(优先 vdso) | utils.c:359 |
IBT/GCS CPU 缓解;libc hook 检测 |
| 抹除栈上路径串 | remote_csoloader.c:764 |
内存扫描残留字符串 |
| Tango GOT hook + kill(SIGTRAP) | ptracer.c:27 |
Tango 吞断点信号 |
| ptrace 痕迹清理(seccomp event) | injector/ptrace_clear.c(见 03 篇) |
注入后残留的 ptrace event 信息 |
九、小结
- monitor 用
PTRACE_SEIZE追踪 init,靠epoll(signalfd + 控制 socket)双事件循环,在PTRACE_EVENT_EXEC时按程序名(app_process64/32、tango_translator)捕获 zygote,拉起 zygiskd 并fork+exec出独立的 ptracer,自身则继续守着 init。 - ptracer 的
trace_zygote用"破坏AT_ENTRY等 SIGSEGV"精确停在 linker 就绪、业务未跑之时;再用 CSOLoader 远程加载libzygisk.so(绕过系统 linker、高地址放置、裸 syscall),最后remote_call其entry()并恢复现场 detach。 - 远程调用的统一技巧是"给一个必定 fault 的返回地址,靠 SIGSEGV 收口";系统调用则复用进程内已有的 syscall 指令(gadget),规避现代 CPU 缓解。
- 注入完成的确认是注入体经 zygiskd 回流给 monitor 的
ZYGOTE*_INJECTED,而非 ptracer 直接发出。
下一篇(03)进入被注入的 libzygisk.so 内部:它如何 hook JNI 方法、如何驱动模块的 preAppSpecialize/postAppSpecialize 生命周期,以及 ELF 解析、痕迹清理等反检测基石。
评论
- 还没有评论,来说点什么吧。