ZygiskNext 源码解析(三):zygiskd 的模块管理、memfd 与 companion

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++ 侧协议如下:

  1. 写动作码 ReadModules。
  2. 读 usize 模块数量。
  3. 对每个模块读 name。
  4. 对每个模块通过 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 时可以围绕几个问题检查:

  1. daemon 是如何知道自己应该监听 cp32.sock 还是 cp64.sock 的?
  2. 为什么 PATH_MODULES_DIR 是相对路径 ".."?
  3. 模块 so 从磁盘到 app 进程,中间经过了哪些 fd?
  4. RequestCompanionSocket 成功时,为什么 C++ client 收到的仍是原来连 daemon 的 socket fd?
  5. 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 实现存在的关键。

相关推荐
miofly几秒前
GitHub 日榜趋势速报 | 2026-09-26
开源·github
冬奇Lab1 分钟前
一天一个开源项目(第228篇):AX —— Google 开源的「Kubernetes for Agents」,用声明式 YAML 编排十亿级 Agent 任务
人工智能·开源·资讯
天天向上2912 分钟前
从零用 Spring AI 搭建 RAG + Tool Calling 岗位分析系统:全流程实战与踩坑记录
开源
冬奇Lab3 分钟前
一天一个开源项目(第 227 篇):Strands Agents Harness SDK —— 从「手写 Agent 循环」到「一行代码拿到生产级 Agent」
人工智能·开源
mmsx6 分钟前
Android 一个设备多个 App 抢:跨进程共享服务的架构设计
android·kotlin·app
青Cheng序员石头18 分钟前
失控之前 | AI 安全到底在保护什么?
后端·安全·aigc
miofly19 分钟前
GitHub 日榜趋势速报 | 2026-09-25
开源·github
OpenTiny社区19 分钟前
HC 2026 回顾|OpenTiny NEXT 解锁 Web 应用智能化新范式
前端·开源·github
Erishen20 分钟前
纯本地只读视频索引:blake3 内容去重与 Range 预览,我如何做到绝不改动一个原文件
架构·开源
mmsx21 分钟前
Android GIS系列 栅格怎么存、格网怎么归算:两个容易混淆的 GIS 链路
android·性能优化·app