上两篇文章里,我们的”容器”有了独立的
PID、主机名、独立的网络栈。但有个致命的问题:它还踩在宿主机的文件系统上。容器进程能看到宿主机的
/etc/shadow、/root/.ssh/、一切。
你可能会说:用 chroot 呀,这不是 Unix
古老的把戏吗?
问题是,chroot 的”隔离”比你想象的脆弱得多。10 行 C 就能逃出去。
本文所有代码在
examples/containers/03-rootfs/目录,make即可编译。测试环境:Linux 6.x, x86_64。
一、chroot 的谎言:10 行代码越狱
chroot()
做的事情很简单:改变进程看到的根目录。但它只改变了一个指针,不修改当前工作目录,不隔离挂载表,不阻止特权操作。
经典的逃逸方法只需要 4 步:
#include <unistd.h>
#include <sys/stat.h>
int main(void) {
// 1. 在 chroot 环境内创建一个临时目录
mkdir(".escape", 0755);
// 2. 再次 chroot 到这个目录
// 关键:cwd 不变!它仍然在旧的 chroot 根 "/"
// 但新的 chroot 根变成了 .escape
// 于是 cwd 现在"在 chroot 根之外"
chroot(".escape");
// 3. chdir("..") 向上走,穿越 chroot 边界
// 因为 cwd 在根之外,内核允许这个操作
for (int i = 0; i < 64; i++)
chdir("..");
// 4. 把当前目录设为新的根 — 回到真实的 /
chroot(".");
// 现在我们在宿主机的真实根目录了
execl("/bin/sh", "sh", NULL);
}就这么简单。第二次 chroot()
之后,cwd 和 root
不在同一棵子树里了,chdir("..")
就能一路向上走到真实的根。
完整可编译的逃逸演示见
examples/containers/03-rootfs/chroot_escape.c。
这就是为什么 chroot 的 man page 里写着:“This call does not change the current working directory, so that after the call ‘.’ can be outside the tree rooted at ‘/’.” — 这不是 bug,是 feature。一个 1979 年设计的 feature。
结论:chroot 不是安全边界。从来不是,将来也不是。
容器需要更硬的手段。
二、Mount Namespace 回顾
在第一篇文章里我们已经见过
Mount namespace(CLONE_NEWNS)。简单回顾:
CLONE_NEWNS创建独立的挂载点表副本- 子 namespace 里的 mount/umount 不影响父 namespace(前提:propagation 设对了)
- 这是 Linux 最早的 namespace(2002 年,所以 flag 名叫
NEWNS而不是NEWMNT)
Mount namespace 是 pivot_root 的前提条件。没有独立的挂载表,你做的任何挂载操作都会影响宿主机。
但 mount namespace 本身不够。它只给你一个独立的挂载表副本,你还得亲手把根目录换成容器的文件系统。这就是 pivot_root 的工作。
三、pivot_root vs chroot:真正的换根
pivot_root 和 chroot
都能让进程看到不同的根目录,但机制完全不同:
| chroot | pivot_root | |
|---|---|---|
| 做了什么 | 只改变进程的 / 指针 |
交换整个挂载树的根 |
| 旧根去哪了 | 还在,只是进程”看不见” | 变成一个子挂载点,可以 umount |
| 能逃逸吗 | 能,见上文 | 旧根被 umount 后,无处可逃 |
| 需要 mount namespace | 不需要 | 需要 |
| 适用场景 | 临时改变视角(构建系统等) | 容器隔离 |
pivot_root 的签名:
int pivot_root(const char *new_root, const char *put_old);它做的事情:
- 把
new_root变成挂载树的根/ - 把原来的根挂载到
put_old(必须在new_root之下) - 进程的
/现在指向容器的文件系统
然后你可以 umount(put_old) —
旧的宿主机根就被彻底移除了。连 chdir("..")
都没用,因为挂载点不存在了。
注意 glibc 没有 pivot_root 的封装函数,得用
syscall():
#include <sys/syscall.h>
static int pivot_root(const char *new_root, const char *put_old)
{
return syscall(SYS_pivot_root, new_root, put_old);
}还有一个容易踩的坑:pivot_root 要求
new_root
必须是一个挂载点,不能只是一个普通目录。解决方法是先
bind mount 到自身:
// 让 rootfs 目录成为挂载点
mount(rootfs, rootfs, NULL, MS_BIND | MS_REC, NULL);四、动手:从零构建容器 rootfs
说了这么多理论,来写代码。我们的目标是:
- 下载 Alpine minirootfs 作为容器文件系统
- 创建 namespace
- 用 pivot_root 切换根
- 挂载 /proc、/sys、/dev
- Exec /bin/sh
4.1 准备 Alpine minirootfs
Alpine Linux 提供了极小的根文件系统 tarball(约 3MB),非常适合做容器 rootfs:
# 下载 Alpine 3.21 minirootfs
ALPINE_URL="https://dl-cdn.alpinelinux.org/alpine/v3.21/releases/x86_64"
curl -fSL -o alpine-minirootfs.tar.gz \
"${ALPINE_URL}/alpine-minirootfs-3.21.3-x86_64.tar.gz"
# 解压到 rootfs 目录
mkdir -p rootfs
tar xzf alpine-minirootfs.tar.gz -C rootfs
# 看看里面有什么
ls rootfs/
# bin dev etc home lib media mnt opt proc root run sbin
# srv sys tmp usr var这就是一个完整的 Linux 用户空间。没有内核,不需要 — 容器共享宿主机的内核。
完整的准备脚本见
examples/containers/03-rootfs/prepare_rootfs.sh。
4.2 pivot_root 的完整流程
整个流程用代码表示:
static int child_fn(void *arg)
{
const char *rootfs = (const char *)arg;
// 1. 切断挂载传播(下一节详细讨论)
mount("", "/", "", MS_PRIVATE | MS_REC, NULL);
// 2. bind mount rootfs 到自身(pivot_root 要求 new_root 是挂载点)
mount(rootfs, rootfs, NULL, MS_BIND | MS_REC, NULL);
// 3. 创建 put_old 目录
char old_root[256];
snprintf(old_root, sizeof(old_root), "%s/.old_root", rootfs);
mkdir(old_root, 0700);
// 4. pivot_root!
pivot_root(rootfs, old_root);
chdir("/");
// 5. 挂载 /proc, /sys, /dev(下面详述)
mount("proc", "/proc", "proc", 0, NULL);
mount("sysfs", "/sys", "sysfs", MS_RDONLY, NULL);
// 6. 卸载旧根 — 这是关键的安全步骤
umount2("/.old_root", MNT_DETACH);
rmdir("/.old_root");
// 7. 启动 shell
execv("/bin/sh", (char *[]){"/bin/sh", NULL});
return 1;
}
int main(void)
{
char *stack = malloc(1024 * 1024);
int flags = CLONE_NEWPID | CLONE_NEWUTS | CLONE_NEWNS |
CLONE_NEWIPC | SIGCHLD;
pid_t pid = clone(child_fn, stack + 1024 * 1024,
flags, "/path/to/rootfs");
waitpid(pid, NULL, 0);
return 0;
}运行效果:
$ sudo ./pivot_root_demo ./rootfs mycontainer
[host] 创建容器 (rootfs=./rootfs, hostname=mycontainer)
[host] 容器进程 PID = 42019
[container] pivot_root("./rootfs", "./rootfs/.old_root")
[container] umount("/.old_root")
========================================
Container ready!
PID: 1
Hostname: mycontainer
Root FS: Alpine minirootfs
========================================
/ # ls /
bin dev etc home lib media mnt opt proc root
run sbin srv sys tmp usr var
/ # cat /etc/os-release
NAME="Alpine Linux"
/ # ps aux
PID USER TIME COMMAND
1 root 0:00 /bin/sh
2 root 0:00 ps aux干净的 Alpine 环境,PID 1,看不到宿主机的任何东西。
完整可编译版本:
examples/containers/03-rootfs/pivot_root_demo.c
五、Mount Propagation:那条你不能少的语句
记得第一篇文章里这行代码吗?
mount("", "/", "", MS_PRIVATE | MS_REC, NULL);当时我说”切断挂载传播”。现在来详细解释为什么这行不能少。
5.1 四种传播类型
Linux 内核为每个挂载点维护一个 propagation 类型:
| 类型 | 行为 | 典型用途 |
|---|---|---|
| shared | 挂载事件双向传播:peer 组内任一成员的 mount/umount 都会传播给其他成员 | systemd 默认 |
| slave | 单向传播:master → slave。slave 的变更不回传 | 容器看到宿主新挂载 |
| private | 完全隔离,不传播也不接收 | 容器运行时的标准选择 |
| unbindable | 和 private 一样,但额外不能被 bind mount | 防止递归 bind |
查看当前挂载的 propagation 类型:
$ cat /proc/self/mountinfo | head -5
# ... shared:1 ... ← 注意 "shared:N" 标记5.2 不设 MS_PRIVATE 会怎样
现代 Linux 发行版(使用 systemd)默认把根挂载设为 shared。这意味着:
- clone(CLONE_NEWNS) 创建了新的 mount namespace
- 新 namespace 是父 namespace 的副本,所有挂载点也被复制了
- 因为是 shared,子 namespace 里的 mount/umount 会传播回父 namespace
后果?你在容器里 mount("proc", "/proc", ...)
— 宿主机的 /proc 也被重新挂载了。你在容器里
umount("/.old_root") —
宿主机的根也被卸载了。
这不是理论上的风险。早期的容器实现真的遇到过这个问题。runc 的代码里就有一段注释:
Make the parent mount private to make sure nothing propagates back.
解决方法就是在子进程一开始就设为 private:
// MS_PRIVATE: 设为 private propagation
// MS_REC: 递归应用到所有子挂载点
mount("", "/", "", MS_PRIVATE | MS_REC, NULL);MS_REC 很重要 — 没有它,只有根挂载点变成
private,其他挂载点(/proc、/sys、/dev
等)可能仍然是 shared。
5.3 容器运行时怎么做的
看看 runc 的做法(Go 代码,但逻辑一样):
# 在容器初始化阶段:
# 1. 先把所有挂载设为 slave(接收宿主机的新挂载)
mount("", "/", "", MS_SLAVE | MS_REC, NULL);
# 2. 再设为 private(完全隔离)
mount("", "/", "", MS_PRIVATE | MS_REC, NULL);先 slave 后 private 是为了处理一些 edge case — 某些内核版本在直接从 shared 转 private 时可能出问题。
六、/dev 的秘密:最小化设备节点
容器里的 /dev 不能直接用宿主机的 —
那暴露了所有块设备、磁盘分区、GPU。我们需要一个最小化的
/dev。
6.1 必要的设备节点
一个功能正常的容器至少需要这些:
| 设备 | Major:Minor | 用途 |
|---|---|---|
/dev/null |
1:3 | 吞噬一切。> /dev/null |
/dev/zero |
1:5 | 无限零字节。dd if=/dev/zero |
/dev/random |
1:8 | 随机数(可能阻塞) |
/dev/urandom |
1:9 | 随机数(不阻塞) |
/dev/tty |
5:0 | 控制终端 |
用 mknod 创建:
#include <sys/stat.h>
#include <sys/sysmacros.h>
// mount tmpfs 作为 /dev 的基础
mount("tmpfs", "/dev", "tmpfs", MS_NOSUID | MS_STRICTATIME, "mode=755");
// 创建设备节点
mknod("/dev/null", S_IFCHR | 0666, makedev(1, 3));
mknod("/dev/zero", S_IFCHR | 0666, makedev(1, 5));
mknod("/dev/random", S_IFCHR | 0444, makedev(1, 8));
mknod("/dev/urandom", S_IFCHR | 0444, makedev(1, 9));
mknod("/dev/tty", S_IFCHR | 0666, makedev(5, 0));6.2 符号链接和伪终端
还需要一些符号链接让标准 I/O 工作:
// /dev/fd → /proc/self/fd
symlink("/proc/self/fd", "/dev/fd");
symlink("/proc/self/fd/0", "/dev/stdin");
symlink("/proc/self/fd/1", "/dev/stdout");
symlink("/proc/self/fd/2", "/dev/stderr");如果容器需要 PTY(伪终端,docker exec -it
需要),还需要挂载 devpts:
mkdir("/dev/pts", 0755);
mount("devpts", "/dev/pts", "devpts", MS_NOSUID | MS_NOEXEC,
"newinstance,ptmxmode=0666");6.3 Docker 的 /dev 策略
实际的容器运行时(runc)用的是 bind mount 策略,而不是 mknod:
// runc 的做法:从宿主机 bind mount 设备节点
// 好处:不需要 CAP_MKNOD 权限
mount("/dev/null", container_path("/dev/null"), NULL, MS_BIND, NULL);这种方式更安全 — mknod 需要 CAP_MKNOD
capability,而在 user namespace 里可能没有这个权限。第九篇 rootless
容器会详细讨论这个问题。
七、tmpfs:/tmp 和 /run
容器的 /tmp 和 /run 应该是
tmpfs — 内存文件系统,容器退出后自动清理:
// /tmp — 所有用户可写,sticky bit
mkdir("/tmp", 01777);
mount("tmpfs", "/tmp", "tmpfs",
MS_NOSUID | MS_NODEV | MS_STRICTATIME,
"mode=1777,size=65536k");
// /run — 运行时数据(PID 文件、socket 等)
mkdir("/run", 0755);
mount("tmpfs", "/run", "tmpfs",
MS_NOSUID | MS_NODEV | MS_STRICTATIME,
"mode=755,size=65536k");注意 size=65536k — 不限制大小的 tmpfs
可以吃光内存。在没有 cgroup 内存限制的情况下(第四篇会加上),至少在
tmpfs 层面做个限制。
/tmp 的权限 01777 中的
0 前缀是八进制标记,1 是 sticky
bit — 防止用户删除其他用户的文件。
八、只读根文件系统:最后一道防线
容器根文件系统应该是只读的。理由:
- 容器镜像是不可变的 — 运行时不应该修改
- 攻击者即使获得了容器内的 root,也写不了文件系统
- 保证了容器的可重复性
实现只读根很简单:
// 在 pivot_root 之后,把根文件系统重新挂载为只读
mount("", "/", "", MS_REMOUNT | MS_RDONLY | MS_BIND, NULL);但完全只读会有问题 — 很多程序需要写
/tmp、/run、/var/log。解决方案是选择性地挂载可写
tmpfs:
// 根是只读的
mount("", "/", "", MS_REMOUNT | MS_RDONLY | MS_BIND, NULL);
// 但 /tmp, /run 是可写的 tmpfs
mount("tmpfs", "/tmp", "tmpfs", MS_NOSUID | MS_NODEV, "mode=1777");
mount("tmpfs", "/run", "tmpfs", MS_NOSUID | MS_NODEV, "mode=755");Docker 的 --read-only flag
就是这么实现的。Kubernetes 的
readOnlyRootFilesystem: true 也是如此。
更进一步,在第五篇 OverlayFS 里我们会看到,容器镜像本身就是只读层 + 可写层的叠加。只读根文件系统只是顶层的额外保护。
九、完整的挂载流程
把所有东西拼起来,容器启动时的挂载操作是这样的顺序:
// === 在 clone(CLONE_NEWNS | ...) 之后的子进程中 ===
// 1. 切断挂载传播
mount("", "/", "", MS_PRIVATE | MS_REC, NULL);
// 2. bind mount rootfs(让它成为挂载点)
mount(rootfs, rootfs, NULL, MS_BIND | MS_REC, NULL);
// 3. 创建 .old_root,执行 pivot_root
mkdir(rootfs "/.old_root", 0700);
pivot_root(rootfs, rootfs "/.old_root");
chdir("/");
// 4. 挂载伪文件系统
mount("proc", "/proc", "proc", MS_NOSUID | MS_NODEV | MS_NOEXEC, NULL);
mount("sysfs", "/sys", "sysfs", MS_NOSUID | MS_NODEV | MS_NOEXEC | MS_RDONLY, NULL);
// 5. 设置 /dev
mount("tmpfs", "/dev", "tmpfs", MS_NOSUID | MS_STRICTATIME, "mode=755");
mknod("/dev/null", S_IFCHR | 0666, makedev(1, 3));
mknod("/dev/zero", S_IFCHR | 0666, makedev(1, 5));
mknod("/dev/random", S_IFCHR | 0444, makedev(1, 8));
mknod("/dev/urandom", S_IFCHR | 0444, makedev(1, 9));
mknod("/dev/tty", S_IFCHR | 0666, makedev(5, 0));
// 6. tmpfs
mount("tmpfs", "/tmp", "tmpfs", MS_NOSUID | MS_NODEV, "mode=1777");
mount("tmpfs", "/run", "tmpfs", MS_NOSUID | MS_NODEV, "mode=755");
// 7. 卸载旧根
umount2("/.old_root", MNT_DETACH);
rmdir("/.old_root");
// 8. (可选) 只读根
mount("", "/", "", MS_REMOUNT | MS_RDONLY | MS_BIND, NULL);
// 9. exec
execve("/bin/sh", argv, envp);注意顺序很重要:
- 步骤 1 必须在任何其他 mount 之前
- 步骤 7 必须在步骤 4-6 之后(否则 /proc 等还挂在旧根上)
- 步骤 8 必须在步骤 6 之后(否则 tmpfs 也会变成只读)
十、踩坑备忘
10.1 pivot_root: Invalid argument
最常见的错误。原因通常是:
# new_root 不是挂载点 → 先 bind mount
mount(rootfs, rootfs, NULL, MS_BIND, NULL);
# new_root 和 put_old 不在同一个文件系统
# → put_old 必须在 new_root 之下
# 没在 mount namespace 里
# → 确保 clone() 加了 CLONE_NEWNS10.2 umount: Device or resource busy
旧根可能有文件描述符还在引用它。用
MNT_DETACH 做 lazy umount:
// 不用等所有引用释放,立即从挂载表移除
umount2("/.old_root", MNT_DETACH);10.3 /proc 里还是宿主机的进程
要么忘了 CLONE_NEWPID,要么忘了重新挂载
/proc。两个都需要。
10.4 容器内 DNS 不工作
别忘了复制宿主机的 /etc/resolv.conf 到容器
rootfs:
cp /etc/resolv.conf rootfs/etc/resolv.conf十一、和真正的容器运行时比较
我们这个 demo 和 runc(Docker / Kubernetes 使用的 OCI 运行时)相比还差什么?
| 特性 | 我们的 demo | runc |
|---|---|---|
| pivot_root | ✓ | ✓ |
| Mount propagation | MS_PRIVATE | MS_SLAVE → MS_PRIVATE |
| /dev 设备 | mknod | bind mount from host |
| /proc hidepid | ✗ | ✓ (hidepid=2) |
| Masked paths | ✗ | ✓ (/proc/kcore 等) |
| Read-only paths | ✗ | ✓ (/proc/sys 等) |
| Cgroup namespace | ✗ | ✓ |
| OverlayFS rootfs | ✗ | ✓ |
| Seccomp filter | ✗ | ✓ |
runc 还会把一些敏感的 /proc 路径设为只读或用
tmpfs 遮蔽:
# runc 会 bind mount /dev/null 到这些路径,防止信息泄露
/proc/kcore # 内核内存映像——泄露整个内核地址空间
/proc/keys # 内核密钥环——可能包含其他容器的密钥
/proc/timer_list # 内核定时器——可用于侧信道攻击
/proc/sched_debug # 调度器调试信息——泄露宿主机进程列表此外,runc 会把
/proc/sys、/proc/bus、/proc/irq、/proc/acpi
等路径设为只读,防止容器通过 /proc
接口修改内核参数。完整列表见 runc 的
libcontainer/specconv/spec_linux.go。
踩坑备忘:常见错误与排查
| 症状 | 原因 | 解决方法 |
|---|---|---|
pivot_root: Invalid argument |
new_root 不是挂载点 | 先 mount --bind /newroot /newroot |
pivot_root: Device or resource busy |
有进程的 cwd 还在旧 root | 确保所有进程先 chdir 到新 root |
mount: permission denied 挂载
/proc |
没有 MS_PRIVATE 导致传播到宿主机后被拒 |
先执行
mount("", "/", "", MS_PRIVATE \| MS_REC, NULL) |
容器内 ls /dev 什么都没有 |
忘记挂载 /dev 为 tmpfs 并创建设备节点 |
参考本文第五节的设备节点清单 |
exec format error |
rootfs 架构不匹配(比如 ARM rootfs 在 x86 上跑) | 确认 rootfs 与宿主机 CPU 架构一致 |
这些细节我们会在第八篇安全加固中详细讨论。
十二、代码和文件清单
本文的所有代码:
| 文件 | 说明 |
|---|---|
examples/containers/03-rootfs/pivot_root_demo.c |
完整的 pivot_root 容器演示 |
examples/containers/03-rootfs/chroot_escape.c |
chroot 逃逸演示 |
examples/containers/03-rootfs/prepare_rootfs.sh |
Alpine rootfs 准备脚本 |
examples/containers/03-rootfs/Makefile |
编译和运行 |
快速开始:
cd examples/containers/03-rootfs
make # 编译
make setup # 下载并准备 Alpine rootfs(需要 root 和网络)
make run # 运行容器(需要 root)
make run-escape # 运行 chroot 逃逸演示文件系统是容器的地基。有了 mount namespace + pivot_root,容器进程终于真正”脚踏实地”了 — 踩在自己的 rootfs 上,看不到宿主机的任何文件。
但光有隔离还不够。现在的容器没有资源限制 — 一个容器可以吃掉所有 CPU、所有内存、所有 I/O。
下一篇,我们用 cgroups v2 给容器戴上枷锁 — Cgroups v2:给容器的资源账本。