扩展机制:把「撒谎」做成插件
PRoot 的三个招牌功能—— 0 (假装 root)、 l (硬链接转符号链接)、 k (假装旧内核)——都不是核心代码,而是插件。核心只提供一串事件回调,插件决定在这些点上改什么。
扩展机制:把「撒谎」做成插件
PRoot 的三个招牌功能——-0(假装 root)、-l(硬链接转符号链接)、-k(假装旧内核)——都不是核心代码,而是插件。核心只提供一串事件回调,插件决定在这些点上改什么。
这一篇看这套事件总线,以及三个最有代表性的插件。最后一个插件会比较特别:PRoot 连 ptrace 都要模拟,因为 ptrace 不能嵌套。
十七个事件
ExtensionEvent 是一个枚举,每个事件带两个 intptr_t 参数。语义写得很克制,但足够用:
| 事件 | 时机 | 返回值语义 |
|---|---|---|
GUEST_PATH |
路径翻译前 | >0 跳过 PRoot 处理,<0 直接报错 |
HOST_PATH |
规范化过程中每个宿主路径 | <0 报错 |
TRANSLATED_PATH |
翻译完成 | 可原地改写 |
SYSCALL_ENTER_START |
系统调用入口,PRoot 之前 | >0 跳过,<0 取消 |
SYSCALL_ENTER_END |
入口,PRoot 之后 | <0 取消 |
SYSCALL_EXIT_START/END |
出口前后 | <0 改返回值 |
NEW_STATUS |
waitpid 拿到事件 |
!=0 跳过整个处理 |
INHERIT_PARENT/CHILD |
fork/clone 继承 | 决定子进程是否带上插件 |
INITIALIZATION / REMOVED |
装载/卸载 | — |
ALREADY_OPENED_FD |
启动时扫已打开的 fd | — |
分发就是一个链表遍历,第一个返回非 0 的插件说了算:
/* src/extension/extension.h:177 */
LIST_FOREACH(extension, tracee->extensions, link) {
int status = extension->callback(extension, event, data1, data2);
if (status != 0)
return status;
}
return 0;
插件不是白拿事件的:它还要把自己的系统调用名单合进 seccomp 过滤器。enable_syscall_filtering() 会把每个扩展的 filtered_sysnums 并进主表——所以 -0 才拦得到 getuid,虽然核心根本不关心它。
fake_id0:一套完整的假身份
-0 装的是 fake_id0。它维护四个假 id(ruid/euid/suid/fsuid)和四个假 gid,然后把它们注入到每个能看到身份的地方:
getuid/geteuid/getresuid:出口直接改返回值;setuid一族:set_sysnum(tracee, PR_void),系统调用根本不执行,直接返回成功;stat一族:内核填完struct stat后,按架构偏移改写st_uid/st_gid;chown:把「假 uid」换成进程的真实 uid 再交给内核,注释写得很实在——Swap actual and emulated ids to get a chance of success。
最麻烦的不是读,是写。一个 uid 为 1000 的进程要往 root 拥有的目录里写文件,内核会拒绝。fake_id0 的办法是临时改权限:
/* src/extension/fake_id0/fake_id0.c:166 */
new_mode = perms.st_mode & (S_IRWXU | S_IRWXG | S_IRWXO);
new_mode |= (S_IRUSR | S_IWUSR); /* 加 u+rw */
if (S_ISDIR(perms.st_mode))
new_mode |= S_IXUSR; /* 目录还要 +x */
...
(void) chmod(path, new_mode); /* 真的改,真的写盘 */
改完必须还回去,否则文件权限就被永久污染了。恢复用的是 talloc 析构函数:
node->path = talloc_strdup(node, path);
talloc_set_destructor(node, restore_mode);
注释解释了为什么这样能work:
The mode restoration works because Talloc destructors are called in reverse order.
也就是说,一次系统调用里被放宽的多个路径,会按相反顺序恢复,正好对应「先开后关」的栈式语义。用内存管理器的析构顺序当栈用,是这套代码里最巧的一处。
link2symlink:没有硬链接的硬链接
Android 的 /sdcard、FUSE、以及很多容器文件系统都不支持 link()。-l 的做法是把 link() 改写成 symlink():
/* src/extension/link2symlink/link2symlink.c:560 */
case PR_link:
status = move_and_symlink_path(tracee, SYSARG_1);
set_sysnum(tracee, PR_symlink); /* 系统调用号直接换掉 */
break;
但符号链接和硬链接语义不同:删掉一边,另一边不能失效;rename 要处理「伪硬链接」;unlink 要递减引用计数。所以它把真文件改名成 .l2s.<name>,让两个名字都指向它,并在文件名里维护计数;rename/unlink/linkat 全都要额外打补丁。
这是一个典型的 PRoot 式妥协:能在系统调用层伪造的语义就伪造,伪造不了的就改数据布局。
kompat:让新内核装老程序
-k 装的是 kompat。它解决的是另一个方向的问题:程序按老内核编译,用了新内核没有的系统调用(比如 2.6.27 上跑 dup3),或者反过来。
它的做法是「降级系统调用」:
/* src/extension/kompat/kompat.c:184 */
case PR_dup3:{
Modif modif = {
.expected_release = KERNEL_VERSION(2, 6, 27),
.new_sysarg_num = PR_dup2,
};
if (peek_reg(tracee, CURRENT, SYSARG_1) ==
peek_reg(tracee, CURRENT, SYSARG_2))
return -EINVAL; /* dup3 的特有语义,dup2 没有 */
modify_syscall(tracee, config, &modif);
}
accept4 → accept、epoll_create1 → epoll_create、pipe2 → pipe……每一处都要手工补回新调用独有的语义(标志位、参数校验)。这是全项目最「脏」但最实用的一块。
ptrace:连调试器都要模拟
ptrace(2) 有一个内核级限制:一个进程同时只能有一个 tracer。PRoot 已经是 tracee 的 tracer 了,如果 tracee 自己再 ptrace(比如在里面跑 gdb、strace),内核会拒绝。
PRoot 的选择是把这个系统调用整个吞掉:
/* src/ptrace/ptrace.c:100 */
int translate_ptrace_enter(Tracee *tracee)
{
/* The ptrace syscall have to be emulated since it can't be nested. */
set_sysnum(tracee, PR_void);
return 0;
}
然后它在用户态重建了一套 tracer/tracee 关系:attach_to_ptracer() 记账,PTRACE_TRACEME/ATTACH/SEIZE/INTERRUPT 逐个处理,wait4 被拦下来自己填 siginfo,事件在 handle_ptracee_event() 里转发。
代价前面提过:一旦有 tracee 开始当 tracer,seccomp 加速就必须关掉,因为 PRoot 无法预知那位调试器关心哪些系统调用。
CARE:把插件做成产品
仓库里还有个 care 子命令(src/extension/care/)。它不改系统调用语义,只是把每个系统调用、每个被打开的文件、每段输出录下来,最后打成一个自包含的包,可以在另一台机器上重放。
它能成立,全靠 SYSCALL_* 和 ALREADY_OPENED_FD 这几个事件——核心不需要为「录制回放」写一行代码。这是扩展机制设计得最成功的地方:PRoot 把自己变成了一个进程级插桩引擎,chroot 只是它的第一个用例。
下一篇是最后一篇:这套机制的代价,以及它不适合做什么。