ZygiskNext 源码解析(一):总体架构与启动链路

ZygiskNext 源码解析(一):总体架构与启动链路

ZygiskNext 的定位很明确:它不是 Magisk 本体的一个补丁,而是一个独立的 Zygisk API 实现。它为 KernelSU 补上 Zygisk 能力,也可以在 Magisk 关闭内置 Zygisk 时接管 Zygisk 模块的加载和回调流程。

从源码看,项目的核心设计是把"安装与启动""Zygote 注入""模块管理与 root companion"拆成三个相对独立的部分:

  • module/ 负责被 Magisk 或 KernelSU 安装、启动、设置 SELinux 规则,并把运行时需要的二进制放到合适的位置。
  • loader/ 负责 native 注入和 Zygote 内部 Hook。它会生成 libzgn.sozygisk-ptrace32/64
  • zygiskd/ 是 Rust 写的守护进程,负责模块扫描、memfd 分发、进程状态查询和 companion 生命周期管理。

这三个部分组合起来,形成了一条完整链路:

text 复制代码
开机脚本
  -> 启动 zygisk-ptrace64 monitor
  -> monitor 追踪 init fork/exec
  -> 发现 app_process32/64
  -> 启动对应 ABI 的 zygiskd
  -> ptrace 注入 libzgn.so 到 zygote
  -> libzgn.so Hook Zygote specialize 流程
  -> 每个 app/system_server fork 后加载 Zygisk 模块并派发回调

0. 阅读前需要知道的几个概念

如果之前没有接触过 Zygisk、Magisk 或 Android native 注入,先把几个概念放清楚,后面的源码会容易很多。

Zygote 是 Android 创建 Java app 进程的"母进程"。普通 app 不是从零启动一个虚拟机,而是由 Zygote fork 出子进程,再对子进程做 specialize。specialize 可以理解成"把一个刚 fork 出来的通用进程,改造成某个具体 app 的进程":设置 uid/gid、SELinux context、进程名、挂载命名空间、能力集、fd 清理等。

system_server 也是从 Zygote 创建出来的特殊进程。它承载 ActivityManager、PackageManager、WindowManager 等系统服务。Zygisk 模块通常关心两类目标:普通 app 进程和 system_server。

Zygisk 是 Magisk 提供的一套模块 API。它让模块可以在 app 或 system_server specialize 前后运行 native 代码。pre 阶段发生在安全沙箱完全落下之前,post 阶段发生在进程已经变成目标 app 或 system_server 之后。

KernelSU 和 Magisk 都是 root 方案,但架构不同。Magisk 的很多能力在用户态 daemon 和模块挂载层;KernelSU 侧有内核接口,ZygiskNext 通过 prctl(0xdeadbeef, ...) 查询相关状态。

ptrace 注入 是一种从外部暂停目标进程、修改目标寄存器或内存、让目标调用指定函数的技术。ZygiskNext 用它让 zygote 自己执行 dlopen("/sbin/lib64/libzgn.so")

daemon 在本文中指长期运行的后台进程。ZygiskNext 的 daemon 是 zygiskd,它不是 Android 系统服务,而是模块自己启动的 root 后端。

把这些概念合起来看,ZygiskNext 实际在做这件事:它先把自己的 native 库放进 Zygote,再借 Zygote fork app 的时机,把第三方 Zygisk 模块加载进目标进程。

1. 根工程:三个子模块的分工

根目录的 settings.gradle.kts 只包含三个子项目:

kotlin 复制代码
include(
    ":loader",
    ":module",
    ":zygiskd",
)

build.gradle.kts 则统一了版本信息和 Android 编译参数。版本号来自 git commit 数和短 hash:

kotlin 复制代码
val gitCommitCount = "git rev-list HEAD --count".execute().toInt()
val gitCommitHash = "git rev-parse --verify --short HEAD".execute()

同时这里定义了关键的兼容边界:

  • minKsuVersion = 10940
  • minKsudVersion = 11425
  • maxKsuVersion = 20000
  • minMagiskVersion = 26402
  • androidMinSdkVersion = 26
  • androidCompileNdkVersion = "26.0.10792818"

也就是说,项目在构建期就把支持范围固化进了安装脚本和 daemon 编译环境。后续 module 会把这些值替换进 shell 脚本,zygiskd 则通过环境变量在 Rust 编译期读取。

从初学者视角看,可以把根工程理解成一个"打包协调器":loader 负责产出 native 可执行文件和 so,zygiskd 负责产出 Rust daemon 二进制,module 负责把这些产物整理成 Magisk/KernelSU 可安装 ZIP。

源码阅读时建议先不陷入 Gradle 细节,只抓住依赖方向:

text 复制代码
module:zipDebug / module:zipRelease
  -> loader:assembleDebug / loader:assembleRelease
  -> zygiskd:buildAndStrip
  -> 整理 module/src 脚本和 native 产物
  -> 输出模块 ZIP

这解释了为什么文章后面总是在 moduleloaderzygiskd 三个目录之间来回跳:它们不是三个独立程序,而是同一个运行链路的不同阶段。

2. module:安装包和启动 glue

module/ 的产物是最终可安装的 Magisk/KernelSU 模块 ZIP。它不只是把 so 和二进制打包起来,还承担三个重要职责:

  1. 安装时检查 root 环境、Android 版本、CPU 架构和 SELinux patch 能力。
  2. 开机时复制 libzgn.so 到 zygote 能加载的位置。
  3. 启动 zygisk-ptrace64 monitor,让注入链路开始工作。

2.1 安装阶段

安装逻辑在 module/src/customize.sh。它首先判断安装环境:

  • KernelSU:检查 kernel 侧版本和 ksud 版本,并禁止同时存在 Magisk。
  • Magisk:检查 Magisk 版本。
  • recovery:直接拒绝安装。

这和 README 中的要求一致:ZygiskNext 不支持多个 root 实现并存。

接着脚本按设备架构解压对应产物:

text 复制代码
bin/zygiskd32
bin/zygiskd64
bin/zygisk-ptrace32
bin/zygisk-ptrace64
lib/libzgn.so
lib64/libzgn.so

这里有一个工程细节:loader 的 ptrace 程序在 CMake 中是 executable target,但 Android Gradle 打包 native 产物时仍走 lib*.so 路径,因此最终先生成 libzgn_ptrace.so,安装脚本再重命名为 zygisk-ptrace32/64

这几个文件的职责可以用表格记忆:

文件 运行位置 作用
bin/zygisk-ptrace64 模块目录中,由脚本启动 monitor 常驻,追踪 init 和调度注入
bin/zygisk-ptrace32 模块目录中,由 monitor 调用 注入 32 位 zygote
bin/zygiskd64 模块目录中,由 monitor 启动 64 位 daemon,服务 64 位 zygote/app
bin/zygiskd32 模块目录中,由 monitor 启动 32 位 daemon,服务 32 位 zygote/app
lib64/libzgn.so 复制到 $TMP_PATH/lib64 后被 zygote 加载 64 位注入库
lib/libzgn.so 复制到 $TMP_PATH/lib 后被 zygote 加载 32 位注入库

为什么所有东西都要分 32/64 位?因为 Android 设备可能同时存在 64 位 zygote 和 32 位 zygote。一个 64 位进程不能 dlopen 32 位 so,一个 32 位进程也不能加载 64 位 so。Zygisk 模块、注入库、daemon companion 都必须和目标进程 ABI 匹配。

2.2 开机阶段

真正启动链路在 module/src/post-fs-data.sh

sh 复制代码
export TMP_PATH=/sbin
[ -d /sbin ] || export TMP_PATH=/debug_ramdisk

脚本选择 /sbin,不存在时退回 /debug_ramdisk。这个目录之后会成为注入库、控制 socket 和临时 module.prop 的运行时位置。

这里的 $TMP_PATH 很重要,它是 ZygiskNext 运行时世界的"根目录"。后续会出现这些路径:

text 复制代码
$TMP_PATH/lib64/libzgn.so
$TMP_PATH/lib/libzgn.so
$TMP_PATH/cp64.sock
$TMP_PATH/cp32.sock
$TMP_PATH/init_monitor
$TMP_PATH/module.prop

其中 libzgn.so 是给 zygote 加载的库,cp*.sock 是注入侧和 daemon 通信的 stream socket,init_monitor 是 daemon 和 monitor 通信的 datagram socket,module.prop 是 monitor 写运行状态的临时文件。

随后脚本复制库:

sh 复制代码
cp $MODDIR/lib64/libzgn.so $TMP_PATH/lib64/libzgn.so
chcon u:object_r:system_file:s0 $TMP_PATH/lib64/libzgn.so

32 位同理复制到 $TMP_PATH/lib/libzgn.so。这里设置 system_file context 是为了让 zygote 后续 dlopen 这份库时不被 SELinux 阻拦。

最后启动 monitor:

sh 复制代码
./bin/zygisk-ptrace64 monitor &

注意这里固定启动 64 位 monitor。后续 32 位 zygote 的注入由 monitor 在发现 /system/bin/app_process32 时再调用 zygisk-ptrace32 trace <pid>

2.3 兼容其它 Zygisk 模块脚本

post-fs-data.shservice.sh 都有一段 Magisk 兼容逻辑:如果当前环境存在 Magisk,它会遍历其它模块目录,手动触发那些模块自己的 post-fs-data.shservice.sh

原因是 ZygiskNext 在替代 Magisk 内置 Zygisk 时,部分 Zygisk 模块仍然假设 Magisk 会执行它们的脚本。ZygiskNext 通过补跑这些脚本降低兼容风险。

3. loader:ptrace 程序和注入库

loader/ 生成两个关键产物。

第一个是 zgn_ptrace,安装后改名为 zygisk-ptrace32/64。它有几个子命令:

text 复制代码
monitor
trace <pid>
ctl start|stop|exit
version

入口在 loader/src/ptracer/main.cppmonitor 启动常驻事件循环,trace <pid> 对某个 zygote 执行一次注入,ctl 通过 Unix datagram socket 向 monitor 发控制命令。

第二个是 libzgn.so。它被 ptrace 注入到 zygote 后,会调用导出符号:

cpp 复制代码
extern "C" [[gnu::visibility("default")]]
void entry(void* handle, const char* path)

这个入口在 loader/src/injector/entry.cpp。它做四件事:

  1. 记录自身 dlopen handle,后续用于自卸载。
  2. 初始化 zygiskd socket 的 tmp path。
  3. 通过 PingHeartbeat() 确认 daemon 存活并通知 monitor "zygote injected"。
  4. 调用 hook_functions() 安装 Zygote 内部 Hook。

真正复杂的逻辑集中在 loader/src/injector/hook.cpp。这里会 Hook forkunsharestrdup__android_log_close,并在 JVM 初始化后替换 Zygote 的 native 方法,如 nativeForkAndSpecializenativeSpecializeAppProcessnativeForkSystemServer

从读代码的顺序看,loader 可以分成两条线:

text 复制代码
ptracer 线:
  main.cpp
  -> monitor.cpp
  -> ptracer.cpp
  -> utils.cpp

injector 线:
  entry.cpp
  -> hook.cpp
  -> jni_hooks.hpp
  -> module.hpp
  -> common/daemon.cpp

ptracer 线解决"怎么进 zygote",injector 线解决"进去后做什么"。这两条线通过 entry(handle, path) 汇合。

4. zygiskd:为什么需要一个 daemon

如果只把 libzgn.so 注入进 zygote,理论上也能扫描模块并 dlopen。但 ZygiskNext 没有这样做,而是把模块管理放到 Rust daemon zygiskd 中。

这个拆分有几个直接收益。

第一,zygote 进程内逻辑更少。Zygote 是 Android 进程创建的中心,任何复杂 IO、目录遍历、root 查询都不适合长期放在它里面。

第二,root companion 需要一个真正运行在 root 环境中的进程。Zygisk 模块的 preAppSpecialize 虽然发生在 sandbox 生效前,但它仍然不是一个长期 root 服务。zygiskd 可以统一创建和维护每个模块的 companion。

第三,模块 so 通过 memfd 传给注入侧,避免每个 app 进程直接从磁盘路径读取模块文件。daemon 在启动时扫描模块,把 so 写入 memfd 并加 seal,之后只通过 fd passing 分发。

第四,KernelSU 和 Magisk 的 root 状态查询差异较大。把这些逻辑集中在 daemon 的 root_impl 层,可以让注入侧只关心统一的 ProcessFlags

也可以把 zygiskd 理解成一个"zygote 外部的能力代理"。Zygote 内部代码不适合长期持有复杂状态,而 daemon 可以长期保存模块列表、memfd、companion socket 和 root 查询能力。每次 app fork 时,注入库只要问 daemon:"有哪些模块?这个 uid 的 flags 是什么?这个模块 companion 在哪里?"即可。

5. 运行时 socket 与状态上报

ZygiskNext 有两类 socket。

第一类是注入侧和 daemon 之间的 stream socket:

text 复制代码
$TMP_PATH/cp32.sock
$TMP_PATH/cp64.sock

协议定义在 C++ 的 loader/src/include/daemon.h 和 Rust 的 zygiskd/src/constants.rs。动作包括:

  • PingHeartbeat
  • RequestLogcatFd
  • GetProcessFlags
  • ReadModules
  • RequestCompanionSocket
  • GetModuleDir
  • ZygoteRestart
  • SystemServerStarted

第二类是 daemon 和 monitor 之间的 datagram socket:

text 复制代码
$TMP_PATH/init_monitor

daemon 会向 monitor 汇报 zygote 注入成功、daemon 信息、daemon 错误和 system_server 启动事件。monitor 再把这些状态写进临时 module.prop,并在 system_server 启动后 bind mount 到模块目录的 module.prop,让管理器界面能看到动态状态。

这里容易混淆 stream socket 和 datagram socket:

  • stream socket 类似一条连接,适合传一组请求和响应,也适合通过 SCM_RIGHTS 传 fd。cp32.sock/cp64.sock 用的就是这种。
  • datagram socket 类似投递消息包,不需要保持长连接。monitor 只需要收到"状态变化"消息,因此 init_monitor 用 datagram。

后面看协议代码时,只要记住:libzgn.so <-> zygiskd 是请求/响应,zygiskd -> monitor 是状态通知。

6. 一次完整启动的代码路径

把代码路径串起来,可以得到这样一条链:

  1. module/src/post-fs-data.sh 设置 TMP_PATH,复制 libzgn.so,启动 zygisk-ptrace64 monitor
  2. loader/src/ptracer/monitor.cpp::init_monitor() 初始化 datagram socket、signalfd 和 epoll。
  3. monitor 追踪 init fork/exec,发现 /system/bin/app_process64/system/bin/app_process32
  4. ensure_daemon_created() fork/exec zygiskd64zygiskd32
  5. monitor fork 一个短生命周期 ptracer,执行 zygisk-ptrace64 trace <zygote_pid> --restart
  6. loader/src/ptracer/ptracer.cpp::trace_zygote() 远程注入 $TMP_PATH/lib64/libzgn.so
  7. loader/src/injector/entry.cpp::entry() ping daemon,并调用 hook_functions()
  8. Zygote fork app 或 system_server 时,hook.cpp 创建 ZygiskContext,向 daemon 请求模块 memfd,加载模块并派发 Zygisk 回调。

如果把上面的代码路径映射到进程,可以得到更清楚的图:

text 复制代码
root shell / boot script
  |
  | start
  v
zygisk-ptrace64 monitor  --------------------+
  |                                          |
  | fork/exec daemon                         | datagram status
  v                                          |
zygiskd64 / zygiskd32 <---- stream socket ---+
  ^
  | ReadModules / GetProcessFlags / companion
  |
zygote64 / zygote32
  |
  | fork + specialize
  v
app process / system_server

注意 libzgn.so 不会作为一个单独进程出现。它是被加载进 zygote 和子进程地址空间的一段 native 代码。

7. 建议的源码阅读路线

如果想配合本文阅读源码,可以按这个顺序走:

  1. 先看 module/src/post-fs-data.sh,确认启动入口。
  2. 再看 loader/src/ptracer/main.cpp,理解 ptrace 程序有哪些命令。
  3. 接着看 loader/src/ptracer/monitor.cppinit_monitor()SigChldHandler
  4. 然后看 loader/src/ptracer/ptracer.cpp::inject_on_main(),理解远程 dlopen
  5. 跳到 loader/src/injector/entry.cpp,看注入库入口。
  6. zygiskd/src/zygiskd.rs::main(),理解 socket 协议和模块分发。
  7. 最后读 loader/src/injector/hook.cpp,理解 Zygisk API 如何被实现。

这个顺序和运行时顺序一致,适合第一次阅读。等熟悉之后,再反过来从 API 或模块加载点追调用链。

8. 这一篇的结论

ZygiskNext 的设计重点是"把 Magisk 内置 Zygisk 的能力拆出来,重建一套外部可控的运行链路"。它不依赖 Magisk 在 zygote 内预埋能力,而是通过 ptrace 注入 libzgn.so;它也不把模块管理塞进 zygote,而是通过 zygiskd 统一扫描、分发和管理 companion。

理解这个三段式结构之后,后续分析就可以分层展开:

  • ptrace monitor 如何稳定找到并注入 zygote;
  • zygiskd 如何用 socket 和 fd passing 提供后端能力;
  • libzgn.so 如何 Hook Zygote specialize 并实现 Zygisk API;
  • KernelSU/Magisk 的兼容边界在哪里。
相关推荐
weixin_394758034 小时前
直播间小程序码生成问题修复代码清单
android·小程序·apache
苦瓜花11 小时前
【Android】活动
android
yv_3011 小时前
XXE漏洞
android
冬奇Lab11 小时前
一天一个开源项目(第84篇):free-claude-code —— 零费用运行 Claude Code 的代理黑魔法
人工智能·开源·claude
小脑斧12317 小时前
安卓专属|青禾去水印 APP 免费无广告 多媒体素材处理工具
android
xlecho17 小时前
从单一语言到全域全栈,AI凭全能实力,淘汰旧时代语言工程师
人工智能·后端·开源
菜鸟国国19 小时前
一步到位学 Compose + Paging3:从 0 到 1 实现分页加载(超详细新手教程)
android
TO_ZRG19 小时前
Android Service基础
android