路径翻译:canonicalize、binding 与 glue
内核只认宿主路径。所以 PRoot 接到 openat(AT FDCWD, /etc/passwd , ...) 时,必须先把它变成 /home/u/rootfs/etc/passwd ,再把指针换掉——而 guest 里那个 /etc/passwd 可能根本不存在,也可能是三层符号链接套出来的。
路径翻译:canonicalize、binding 与 glue
内核只认宿主路径。所以 PRoot 接到 openat(AT_FDCWD, "/etc/passwd", ...) 时,必须先把它变成 /home/u/rootfs/etc/passwd,再把指针换掉——而 guest 里那个 /etc/passwd 可能根本不存在,也可能是三层符号链接套出来的。
src/path/ 这 3,053 行就干这一件事。它是整个项目最绕、也最容易出 bug 的部分。
六步
translate_path() 是唯一入口,注释把契约写得很清楚:返回 root + canon(dir_fd + user_path)。
/* src/path/path.c:330 */
if (user_path[0] == '/') {
strcpy(result, "/"); /* 1. 绝对路径,base 就是 "/" */
} else if (dir_fd != AT_FDCWD) {
status = readlink_proc_pid_fd(tracee->pid, dir_fd, result);
if (result[0] != '/')
return -ENOTDIR; /* 管道/套接字,不属于文件系统 */
status = detranslate_path(tracee, result, NULL); /* 2. dirfd 反查 */
} else {
status = getcwd2(tracee, result); /* 3. 相对 cwd */
}
status = notify_extensions(tracee, GUEST_PATH, result, user_path);
if (status > 0)
goto skip; /* 扩展接管 */
status = join_paths(2, guest_path, result, user_path);
strcpy(result, "/");
status = canonicalize(tracee, guest_path, deref_final, result, 0);
status = substitute_binding(tracee, GUEST, result); /* 4. guest -> host */
归纳成六步:
- 确定 base:绝对路径用
/,相对路径用 cwd,*at系列用dir_fd反查。 - 拼接。
- 按 guest 语义规范化(不是
realpath)。 - 绑定替换,得到宿主路径。
- 通知扩展(
TRANSLATED_PATH),扩展可以再改。 - 返回,由调用方
set_sysarg_path()写回 tracee 内存。
为什么不能直接 realpath
因为 realpath() 在宿主命名空间里解析符号链接,而 PRoot 要的是在 guest 命名空间里解析。差别在于:guest 的 / 不是宿主的 /,符号链接的每一段都可能落在绑定上。
canonicalize() 因此自己走了一遍逐段解析:
/* src/path/canon.c:190 */
while (!IS_FINAL(finality)) {
finality = next_component(component, &cursor);
if (strcmp(component, ".") == 0) continue;
if (strcmp(component, "..") == 0) { pop_component(guest_path); continue; }
status = join_paths(2, scratch_path, guest_path, component);
status = substitute_binding_stat(tracee, finality, recursion_level,
scratch_path, host_path);
if (status <= 0 || (finality == FINAL_NORMAL && !deref_final)) {
/* 不是链接,或明确要求不解析最后一段(lstat/readlink) */
continue;
}
/* 是链接:readlink -> detranslate -> 递归 canonicalize */
}
三个关键点:
..不会越界。pop_component()作用在 guest 路径上,/..还是/,所以../../../../etc/passwd这种经典逃逸在 guest 里只会被夹回根目录。- 每一段都要
lstat。中间组件必须是目录或符号链接,否则返回-ENOTDIR;这顺带复刻了内核对路径中间段的检查。 deref_final决定最后一段。open传 true,lstat/readlink/unlink传 false——因为内核对这些系统调用本来就不跟随最后一段链接。
递归有上限:recursion_level > MAXSYMLINKS 直接 -ELOOP(MAXSYMLINKS 来自 sys/param.h,Linux 下是 40)。
binding:两张按长度排序的表
-b host:guest 不是挂载,只是一条记录。PRoot 为每个 tracee 维护两张循环链表:guest 侧和 host 侧,都按路径前缀从长到短排列(准确地说,是保证「包含关系在前」,而不是单纯比长度)。
/* src/path/binding.c:243 */
binding = get_binding(tracee, side, path);
if (!binding)
return -ENOENT;
if (!binding->need_substitution) /* host 和 guest 路径相同,无事可做 */
return 0;
substitute_path_prefix(path, ref->length, reverse_ref->path, reverse_ref->length);
get_binding() 从头扫,第一个前缀匹配的就是最长匹配——这就是为什么表要按长度排序。
-r /rootfs 只是 -b /rootfs:/ 的语法糖,源码注释写得很直接:
/* src/cli/proot.c:38 */
/* ``chroot $PATH`` is semantically equivalent to
* ``mount --bind $PATH /``. */
binding = new_binding(tracee, value, "/", true);
根绑定是唯一不做规范化的绑定——因为规范化本身需要先有根,这是个先有鸡还是先有蛋的问题,initialize_binding() 里专门为 "/" 开了后门。
glue:给不存在的目标造个壳
绑定目标在 guest 里可能不存在。比如 -b /dev/null,而 guest 的 /dev 是个空目录。PRoot 不能真的 mknod,也不该污染用户的 rootfs。
它的办法是造一个「glue rootfs」:在临时目录里按需 mkdir 出中间目录、open(O_CREAT) 出同类型的空文件,再把绑定指向这个临时文件。
/* src/path/glue.c:105 */
if (tracee->glue == NULL)
tracee->glue = create_temp_directory(NULL, tracee->tool_name);
...
if (S_ISDIR(type))
status = mkdir(host_path, mode);
PROOT_DONT_POLLUTE_ROOTFS=1 可以禁止它往真实 rootfs 里写,只在 glue 目录里造。
/proc 是个例外
/proc 下的内容是内核动态生成的,不能按普通路径规范化。PRoot 在 canonicalize() 里对 /proc 前缀单独走了 readlink_proc():
/proc/self→/proc/<tracee->pid>/proc/<pid>/exe→tracee->exe/proc/<pid>/cwd→tracee->fs->cwd/proc/<pid>/root→get_root(tracee)/proc/<pid>/fd/N→ 原样返回,标记DONT_CANONICALIZE(可能指向管道或套接字)
对 fd/N 不做规范化的理由写在注释里:它可能是匿名管道,此时任何「解析」都是错的;如果它确实指向文件,等 readlink 返回时再反向翻译。
detranslate:把答案翻译回去
readlink("/bin/echo") 在内核里返回的是宿主路径 /bin/busybox。直接交给用户就穿帮了,所以出口还要反向翻译:
/* src/path/path.c:426 */
switch (compare_paths(get_root(tracee), path)) {
case PATH1_IS_PREFIX:
prefix_length = strlen(get_root(tracee));
if (prefix_length == 1) prefix_length = 0; /* root 就是 "/" */
memmove(path, path + prefix_length, new_length);
break;
default:
if (sanity_check)
return -EPERM; /* 不在 root 里,拒绝暴露 */
else
return 0;
}
sanity_check 为真时(普通路径,不是符号链接),任何落在 root 之外的宿主路径都返回 -EPERM。这是 PRoot「不泄露宿主路径」的唯一一道闸门,也是 getcwd 这类系统调用必须拦截的原因。
实际长什么样
用 -v 2 跑一次,日志会把每次翻译打出来:
proot info: vpid 1: translate("/" + "/bin/echo")
proot info: vpid 1: -> "/bin/busybox"
proot info: vpid 1: translate("/" + "/bin/echo")
proot info: vpid 1: -> "/bin/echo"
同一个 /bin/echo,一次解析到 /bin/busybox(它是指向 busybox 的符号链接),一次原样保留。差别就是 deref_final:第一次是「找到要执行的文件」,第二次是 lstat 语义。一个参数,两种答案。
下一篇进入真正动手的一层:把翻译好的字符串写回别人的地址空间。