
ZygiskNext 源码解析(三):zygiskd 的模块管理、memfd 与 companion
如果说 zygisk-ptrace 负责把 libzgn.so 放进 zygote,那么 zygiskd 就是 ZygiskNext 的后端。它不在 zygote 进程里工作,而是作为独立 root daemon 存在,给注入侧提供模块列表、模块 so fd、进程状态、模块目录 fd 和 companion socket。
这个设计决定了 ZygiskNext 的运行模型:zygote 内只负责 Hook 和回调,复杂的 root 环境交互交给 daemon。
0. 先理解 daemon 要解决的问题
如果把所有逻辑都塞进 zygote,看起来最直接:zygote 里扫描模块目录、读取 so、判断 uid、启动 companion。ZygiskNext 没有这么做,是因为 zygote 是系统关键进程,越少做复杂工作越好。
daemon 解决的是四类问题:
- 资源管理:模块 so、模块目录 fd、companion socket 这些资源要跨很多 app 进程复用。
- 权限隔离:zygote 和 app 进程不适合直接做所有 root 查询或目录访问。
- ABI 分离:32 位 app 要 32 位模块和 companion,64 位 app 要 64 位模块和 companion。
- 生命周期管理:zygote 重启时旧 companion 要清理,daemon 崩溃时 monitor 要能发现。
因此可以把 zygiskd 看成一个本地 RPC 服务。注入侧通过 Unix socket 发请求,daemon 返回普通数据或文件描述符。
本文中会频繁出现 fd passing。它指的是 Unix domain socket 的 SCM_RIGHTS 能力:一个进程可以把自己打开的 fd 发送给另一个进程。收到 fd 的进程不需要知道原始路径,就能使用同一个内核文件对象。
1. zygiskd 的启动入口
入口在 zygiskd/src/main.rs。它支持几个模式:
text
zygiskd companion <fd>
zygiskd version
zygiskd root
zygiskd
普通 daemon 模式执行:
rust
utils::switch_mount_namespace(1).expect("switch mnt ns");
root_impl::setup();
zygiskd::main().expect("zygiskd main");
这里先切到 pid 1 的 mount namespace。原因是 daemon 后续要访问 /data/adb/modules 这类模块目录和运行时路径,切到 init 的 mount namespace 可以减少被当前启动环境 mount namespace 影响的概率。
root_impl::setup() 会检测当前设备到底是 KernelSU、Magisk,还是不支持的异常状态。这个结果会影响后续 GetProcessFlags 的返回。
真正主循环在 zygiskd/src/zygiskd.rs::main()。
这里还有一个小工具模式值得注意:
text
zygiskd root
它会执行 root_impl::setup() 并打印当前 root impl。对调试来说,这个命令可以快速确认 daemon 看到的是 KernelSU、Magisk,还是 None/TooOld/Multiple/Abnormal。
2. 初始化运行时路径
daemon 从环境变量读取 TMP_PATH:
rust
TMP_PATH.init(std::env::var("TMP_PATH")?);
CONTROLLER_SOCKET.init(format!("{}/init_monitor", TMP_PATH.deref()));
PATH_CP_NAME.init(format!(
"{}/{}",
TMP_PATH.deref(),
lp_select!("/cp32.sock", "/cp64.sock")
));
这三个路径分别对应:
TMP_PATH:/sbin或/debug_ramdisk。CONTROLLER_SOCKET:daemon 向 monitor 上报状态的 datagram socket。PATH_CP_NAME:注入侧连接 daemon 的 stream socket,32 位是cp32.sock,64 位是cp64.sock。
lp_select! 是一个根据 target pointer width 选择字符串的宏。也就是说,同一份 Rust 代码会分别编译出 32 位和 64 位 daemon,运行时监听不同 socket。
LateInit<T> 是项目自己写的一个轻量延迟初始化封装,内部用 OnceLock。这些全局路径必须在 daemon 读取到 TMP_PATH 后才能构造,但后续很多函数又需要像全局变量一样访问它们。LateInit 让代码避免到处传 path 参数。
3. 模块扫描:从模块目录到 memfd
daemon 启动后先判断当前 ABI:
rust
let arch = get_arch()?;
let modules = load_modules(arch)?;
get_arch() 读取系统属性 ro.product.cpu.abi,再根据当前 daemon 是 32 位还是 64 位返回模块 so 的 ABI 路径:
text
arm -> armeabi-v7a / arm64-v8a
x86 -> x86 / x86_64
模块扫描逻辑在 load_modules():
rust
let so_path = entry.path().join(format!("zygisk/{arch}.so"));
let disabled = entry.path().join("disable");
if !so_path.exists() || disabled.exists() {
continue;
}
也就是说,一个模块只要在自己的模块目录下提供:
text
zygisk/arm64-v8a.so
zygisk/armeabi-v7a.so
zygisk/x86_64.so
zygisk/x86.so
并且模块目录没有 disable 文件,就会被 ZygiskNext 识别。
这里的 PATH_MODULES_DIR 是 ".."。daemon 的工作目录来自模块启动时的位置:post-fs-data.sh 先 cd "$MODDIR",monitor 再 exec ./bin/zygiskd64。因此 daemon 运行时的相对路径 .. 指向 /data/adb/modules 这一层,遍历到的每个 entry 就是一个模块目录。
模块是否启用只看两个条件:
text
存在 zygisk/<arch>.so
不存在 disable
这意味着它不会解析模块自己的 metadata,也不会检查模块声明。只要目录结构符合 Zygisk 模块约定,就会被加载。
3.1 为什么不是直接把路径交给 zygote
ZygiskNext 没有让注入侧直接 dlopen("/data/adb/modules/.../zygisk/arm64-v8a.so")。它会先调用 create_library_fd(),把 so 读入 memfd:
rust
let opts = memfd::MemfdOptions::default().allow_sealing(true);
let memfd = opts.create("jit-cache")?;
std::io::copy(&mut reader, &mut writer)?;
随后给 memfd 加 seal:
rust
seals.insert(memfd::FileSeal::SealShrink);
seals.insert(memfd::FileSeal::SealGrow);
seals.insert(memfd::FileSeal::SealWrite);
seals.insert(memfd::FileSeal::SealSeal);
memfd.add_seals(&seals)?;
这几个 seal 的含义是:
- 不能缩小。
- 不能扩展。
- 不能写入。
- 不能再修改 seal 集合。
因此,daemon 启动时读到的模块 so 内容在 memfd 层面被冻结。注入侧之后只拿 fd,不再依赖磁盘路径。
这有两个价值。第一,app 进程加载模块时不需要直接访问模块目录,降低路径、mount namespace 和权限干扰。第二,模块 so 一旦被 daemon 读取并 seal,后续内容不容易被运行时篡改。
memfd 可以理解成"没有普通文件路径的内存文件"。它是内核里的文件对象,有 fd,可以被 read/write/mmap/dlopen,但不一定有磁盘路径。ZygiskNext 给 memfd 起名 "jit-cache",之后注入侧用 /jit-cache 作为 linker 看到的名字。
完整生命周期是:
text
磁盘模块 so
-> daemon open/read
-> 写入 memfd
-> add seals
-> daemon 保存 OwnedFd
-> app specialize 时 send_fd 给 libzgn.so
-> libzgn.so 用 android_dlopen_ext 从 fd 加载
这里 OwnedFd 很重要。Rust 的 OwnedFd 在离开作用域时会自动关闭 fd,避免泄漏。daemon 把它放在 Context.modules 中,表示模块 memfd 的生命周期和 daemon 一样长。
4. daemon socket 协议
daemon 监听 stream socket:
rust
let listener = create_daemon_socket()?;
for stream in listener.incoming() {
...
}
create_daemon_socket() 会先设置 socket create context:
rust
utils::set_socket_create_context("u:r:zygote:s0")?;
let listener = utils::unix_listener_from_path(&PATH_CP_NAME)?;
socket 创建后会被 chcon 为 u:object_r:magisk_file:s0。这和 module/src/sepolicy.rule 中的规则配合,让 zygote/app 侧可以访问这个 socket。
协议动作定义在 Rust 的 DaemonSocketAction:
rust
pub enum DaemonSocketAction {
PingHeartbeat,
RequestLogcatFd,
GetProcessFlags,
ReadModules,
RequestCompanionSocket,
GetModuleDir,
ZygoteRestart,
SystemServerStarted,
}
C++ 注入侧的 loader/src/include/daemon.h 中有对应枚举,顺序必须一致。协议本身很简单:客户端先写一个 u8 动作码,daemon 按动作读取后续参数并返回结果。
协议可以整理成表:
| 动作 | 请求参数 | 返回值 | 主要用途 |
|---|---|---|---|
PingHeartbeat |
无 | 无,转发状态给 monitor | zygote 注入成功通知 |
RequestLogcatFd |
后续持续写日志三元组 | 无 | 注入侧日志转发 |
GetProcessFlags |
u32 uid |
u32 flags |
查询 root/denylist/manager 状态 |
ReadModules |
无 | 模块数、模块名、memfd | app/system_server 加载模块 |
RequestCompanionSocket |
usize index |
成功字节,连接被 companion 接管 | 连接模块 root companion |
GetModuleDir |
usize index |
模块目录 fd | 模块访问自己的目录 |
ZygoteRestart |
无 | 无 | 清理 companion |
SystemServerStarted |
无 | 无,转发状态给 monitor | 触发 module.prop bind mount |
普通整数和字符串通过 UnixStreamExt 读写,fd 通过 passfd crate 的 send_fd/recv_fd 传输。C++ 侧在 socket_utils.cpp 中用 recvmsg + SCM_RIGHTS 接收 fd。
需要注意一个细节:协议没有版本字段,也没有复杂握手。C++ 和 Rust 两边必须保持枚举顺序和字段读写顺序一致,否则会直接读错。
5. PingHeartbeat:注入成功的信号
libzgn.so 的 entry() 会调用:
cpp
zygiskd::PingHeartbeat()
C++ 侧只向 daemon 写入 PingHeartbeat 动作码。Rust daemon 收到后不回包,而是向 monitor 的 datagram socket 发送:
rust
let value = constants::ZYGOTE_INJECTED;
utils::unix_datagram_sendto(&CONTROLLER_SOCKET, &value.to_le_bytes())?;
因此 PingHeartbeat 的实际作用是:确认 daemon socket 可连接,并让 monitor 知道对应 ABI 的 zygote 已经注入成功。
这个动作不会检查模块是否加载成功。它只说明 libzgn.so 已经进入 zygote,并且能够连上 daemon。真正每个模块是否能被 dlopen,要等 app 或 system_server specialize 时才知道。
6. RequestLogcatFd:日志桥接
release 构建中,entry.cpp 会调用:
cpp
logging::setfd(zygiskd::RequestLogcatFd());
C++ 的 RequestLogcatFd() 建立到 daemon 的连接后,把这个 socket fd 交给 logging 系统。之后注入侧可以把日志级别、tag、message 写到该 socket。
daemon 的 RequestLogcatFd 分支是一个循环:
rust
let level = stream.read_u8()?;
let tag = stream.read_string()?;
let message = stream.read_string()?;
utils::log_raw(level as i32, &tag, &message)?;
也就是说,Zygote 内的日志最终由 root daemon 调用 Android log API 写出。这能减少注入侧在复杂进程状态下直接写日志带来的 fd 管理问题。
日志协议每条消息包含:
text
level: u8
tag: string
message: string
string 的格式不是 C 风格 \0 结尾,而是先写 usize 长度,再写原始字节。C++ 和 Rust 两边的 read_string/write_string 都遵守这个约定。
7. GetProcessFlags:把 root 差异统一成 bitmask
注入侧在 app specialize 前会调用:
cpp
info_flags = zygiskd::GetProcessFlags(args.app->uid);
daemon 收到 GetProcessFlags 后,会根据 uid 计算一组 bit:
rust
if root_impl::uid_is_manager(uid) {
flags |= ProcessFlags::PROCESS_IS_MANAGER;
} else {
if root_impl::uid_granted_root(uid) {
flags |= ProcessFlags::PROCESS_GRANTED_ROOT;
}
if root_impl::uid_should_umount(uid) {
flags |= ProcessFlags::PROCESS_ON_DENYLIST;
}
}
然后根据 root 实现补充:
rust
KernelSU => PROCESS_ROOT_IS_KSU
Magisk => PROCESS_ROOT_IS_MAGISK
这些 flags 对注入侧有三类用途:
- 对外提供
Api::getFlags()。 - 判断 manager 进程是否需要特殊处理。
- 判断
unshare(CLONE_NEWNS)后是否执行 denylist unmount revert。
这里的关键设计是注入侧不关心 KernelSU 和 Magisk 如何查询数据库或内核状态,它只读统一的 u32 flags。
GetProcessFlags 只传 uid,不传包名或进程名。这个设计简单,但也带来限制:如果某些场景下 uid 不能准确反推出 package,daemon 就无法准确判断 denylist。第五篇会分析 Magisk isolated process 的限制,就是这个设计边界的体现。
8. ReadModules:模块 fd 分发
每次 app 或 system_server 进入 pre specialize 时,注入侧会执行:
cpp
auto ms = zygiskd::ReadModules();
C++ 侧协议如下:
- 写动作码
ReadModules。 - 读
usize模块数量。 - 对每个模块读 name。
- 对每个模块通过
SCM_RIGHTS接收 memfd。
Rust daemon 分支如下:
rust
stream.write_usize(context.modules.len())?;
for module in context.modules.iter() {
stream.write_string(&module.name)?;
stream.send_fd(module.lib_fd.as_raw_fd())?;
}
这里每次请求都会把所有模块 fd 发给注入侧。注入侧再通过 android_dlopen_ext 的 ANDROID_DLEXT_USE_LIBRARY_FD 从 memfd 加载模块。
为什么每个 app 进程都要重新 ReadModules()?因为模块代码要运行在目标 app 进程自己的地址空间中。daemon 里保存的只是模块文件内容,不能替 app 执行模块逻辑。每次 app fork 后,子进程都要把模块 so 加载到自己的进程空间,模块的全局变量、Hook 状态和生命周期也都属于这个 app 进程。
这也解释了为什么模块 pre/post 回调发生在 app 子进程,而不是 daemon 中。
9. RequestCompanionSocket:root companion 的懒启动
Zygisk API 允许模块注册 companion:
cpp
REGISTER_ZYGISK_COMPANION(handler)
模块在 preAppSpecialize 或 preServerSpecialize 中调用 Api::connectCompanion() 时,注入侧实际会调用:
cpp
zygiskd::ConnectCompanion(id)
daemon 的 RequestCompanionSocket 分支读取模块 index,然后检查该模块是否已经有 companion:
rust
let mut companion = module.companion.lock().unwrap();
if companion.is_none() {
*companion = Some(spawn_companion(...)?);
}
这是懒启动:没有模块请求 companion,就不会创建 companion 进程。
companion: Mutex<Option<Option<UnixStream>>> 这个类型初看有点绕:
text
None
-> 还没有尝试创建 companion
Some(None)
-> 已经确认模块没有 zygisk_companion_entry
Some(Some(sock))
-> companion 已存在,并通过 sock 与 daemon 保持连接
外层 Mutex 是为了处理多个 app 进程同时请求同一个模块 companion 的情况。daemon 每个普通请求会开线程处理,如果没有锁,可能同时 spawn 多个 companion。
9.1 spawn_companion 的双阶段 fork/spawn
spawn_companion() 先创建一对 UnixStream:
rust
let (mut daemon, companion) = UnixStream::pair()?;
然后先 libc::fork()。fork 出来的短生命周期子进程清掉 companion fd 的 FD_CLOEXEC,再用 Command::new(&process).spawn() 启动真正的 companion 进程:
rust
Command::new(&process)
.arg0(format!("{}-{}", nice_name, name))
.arg("companion")
.arg(format!("{}", companion.as_raw_fd()))
.spawn()?;
原 daemon 进程会等待这个短生命周期子进程退出;如果退出状态为 0,再通过 socket 把模块名和模块 memfd 发给真正的 companion:
rust
daemon.write_string(name)?;
daemon.send_fd(lib_fd)?;
companion 进程入口是 zygiskd/src/companion.rs::entry(fd)。
这里不是在 fork 出来的子进程里直接 execve 替换自身,而是由该子进程再 spawn 一个运行当前 zygiskd 二进制的新进程,参数变成 companion <fd>,进程名通过 arg0 改成 zygiskd-<moduleName> 这种形式。这样每个模块的 companion 在进程列表中更容易区分,同时原 daemon 不会被替换。
9.2 companion 如何加载模块入口
companion 收到模块 memfd 后:
rust
let path = format!("/proc/self/fd/{fd}");
let handle = dl::dlopen(&path, libc::RTLD_NOW)?;
let entry = libc::dlsym(handle, "zygisk_companion_entry");
如果模块没有 zygisk_companion_entry,companion 回复 0 并退出。否则回复 1,进入循环等待 daemon 转发 client fd。
当某个 app 进程调用 connectCompanion() 时,daemon 会把这个 app 进程连接 daemon 的 socket fd 通过 send_fd 发送给 companion。companion 收到后先向 client 写入成功字节 1,再开线程调用模块的 companion entry:
rust
entry(stream.as_raw_fd());
这样模块 app 侧和 companion 侧就通过同一个 Unix socket 建立了 IPC。
companion 中还有一段防御性代码:
rust
let st0 = fstat(&stream)?;
entry(stream.as_raw_fd());
if let Ok(st1) = fstat(&stream) {
if st0.st_dev != st1.st_dev || st0.st_ino != st1.st_ino {
std::mem::forget(stream);
}
}
它的目的不是业务逻辑,而是避免重复关闭 fd。模块的 companion handler 可能自己关闭传入 fd,甚至 fd 号可能很快被系统复用。如果 Rust 的 UnixStream 析构时再关闭同一个数字,就可能误关一个新 fd。通过 fstat 对比 dev/inode,可以判断这个 fd 还是不是原来的 socket。
10. GetModuleDir:模块目录 fd
Api::getModuleDir() 最终调用 daemon 的 GetModuleDir:
rust
let dir = format!("{}/{}", constants::PATH_MODULES_DIR, module.name);
let dir = fs::File::open(dir)?;
stream.send_fd(dir.as_raw_fd())?;
它返回的是模块根目录 fd,而不是路径字符串。这个设计更适合 SELinux 和 namespace 受限环境:模块可以在 pre specialize 阶段拿到目录 fd,如果需要,还可以把 fd 发给 companion,让 root companion 继续访问。
对模块作者来说,目录 fd 可以配合 openat 系列系统调用使用。例如拿到模块目录 fd 后,可以 openat(dirfd, "config.json", O_RDONLY)。这样即使当前进程的 mount namespace 或路径可见性发生变化,也能通过 fd 访问 daemon 打开的目录对象。
11. ZygoteRestart 与 SystemServerStarted
ptracer 在注入时如果带 --restart,会先调用:
cpp
zygiskd::ZygoteRestart();
daemon 收到后清理所有 companion:
rust
for module in &context.modules {
let mut companion = module.companion.lock().unwrap();
companion.take();
}
因为 zygote 重启意味着之前 app 创建链路已经失效,旧 companion 连接也不应继续复用。
SystemServerStarted 则由注入侧在 system_server pre 阶段触发。daemon 收到后向 monitor 转发,monitor 再把动态 module.prop bind mount 到模块目录。
daemon 对 PingHeartbeat、ZygoteRestart、SystemServerStarted 没有开新线程,而是在 accept 循环里直接处理。其它动作才 thread::spawn。这是因为这三个动作很短,只是发状态或清理内存状态;日志、模块读取、companion 创建等可能阻塞,放到线程里可以避免卡住 daemon accept 新连接。
12. zygiskd 的并发模型
Context 被包装成 Arc<Context>,每个连接处理线程拿一个 clone:
rust
let context = Arc::new(context);
...
let context = Arc::clone(&context);
thread::spawn(move || {
handle_daemon_action(action, stream, &context)
});
Context.modules 本身在 daemon 启动后不再增删,线程之间可以共享读取。唯一会变化的是每个 Module 的 companion 状态,所以它单独放进 Mutex。
这个模型比较简单:
- 模块列表是只读的。
- 每个模块的 companion 状态独立加锁。
- 每个 socket 请求独立线程处理。
- daemon 没有 async runtime,虽然
Cargo.toml里有 tokio/futures 依赖,但当前主逻辑使用的是标准线程。
这种设计对代码可读性友好,也足够匹配请求量:daemon 的请求主要发生在进程创建和模块 companion 连接时,不是高 QPS 网络服务。
13. zygiskd 源码阅读检查点
读 zygiskd 时可以围绕几个问题检查:
- daemon 是如何知道自己应该监听
cp32.sock还是cp64.sock的? - 为什么
PATH_MODULES_DIR是相对路径".."? - 模块 so 从磁盘到 app 进程,中间经过了哪些 fd?
RequestCompanionSocket成功时,为什么 C++ client 收到的仍是原来连 daemon 的 socket fd?ZygoteRestart清理的是 companion 状态,不会重新扫描模块;模块列表只在 daemon 启动时确定。
这些点弄清楚后,daemon 的整体设计就比较稳定了。
14. 这一篇的结论
zygiskd 是 ZygiskNext 中"看不见但决定架构质量"的部分。它把复杂工作从 zygote 中移出来:
- 启动时扫描模块并把 so 冻结进 memfd。
- 通过 socket 协议向注入侧提供 fd、flags 和 companion。
- 把 KernelSU/Magisk 的 root 查询差异封装成统一 bitmask。
- 管理每个模块的 root companion 生命周期。
- 向 monitor 汇报运行时状态。
这种设计让 libzgn.so 在 zygote 里只需要做 Hook、加载 memfd、派发回调;而涉及 root、文件系统、模块枚举和 IPC 的工作都集中在 daemon 中。这也是 ZygiskNext 能作为独立 Zygisk 实现存在的关键。