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.shcd "$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 创建后会被 chconu: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.soentry() 会调用:

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_extANDROID_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)

模块在 preAppSpecializepreServerSpecialize 中调用 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 对 PingHeartbeatZygoteRestartSystemServerStarted 没有开新线程,而是在 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 启动后不再增删,线程之间可以共享读取。唯一会变化的是每个 Modulecompanion 状态,所以它单独放进 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 实现存在的关键。

相关推荐
码云数智-园园1 小时前
PHP 8.x 命名的参数与属性(Attribute):告别注释,构建真正的元数据
android·ide·android studio
Android_xiong_st1 小时前
(原创)2026安卓面试复盘
android·面试·职场和发展
码点2 小时前
Android 9休眠时任意键唤醒屏幕
android·linux·运维
andr_gale2 小时前
05_aosp12中init进程解析rc文件流程分析
android·aosp·framwork
不灭锦鲤2 小时前
网络安全学习第98天
学习·安全
CyL_Cly2 小时前
Appteka下载 最新版18.4下载安装
android
Hello__77772 小时前
开源鸿蒙 Flutter 实战|徽章组件全流程实现
flutter·开源·harmonyos
INosdfgs2 小时前
HAProxy 入门:高性能开源负载均衡
运维·其他·开源·负载均衡
张风捷特烈2 小时前
状态管理大乱斗#05 | Riverpod 源码评析 (中) - 上层建筑
android·前端·flutter