chroot 到底隔离了什么:一次越狱实验讲清沙箱边界与 bubblewrap/seccomp 加固实战

chroot 到底隔离了什么:一次真实的越狱实验

很多人以为 chroot 是「安全沙箱」:把进程关进一个目录,它就出不来。事实是,chroot 只改变了一个东西——进程眼中的根目录 /。它不隔离网络、不隔离进程表、不隔离用户 ID,甚至对 root 进程来说,越狱只需要十几行 C 代码。

这篇文章用实测的方式,先演示 chroot 到底能挡什么、挡不住什么,再给出几种「chroot 之上补隔离」的实用方案,最后讲清楚在什么场景下 chroot 够用、什么场景下必须换工具。

一、chroot 的原理:只换了 / 的指向

进程启动时,内核给它维护一个「根目录」指针。chroot 系统调用做的事,就是把这个指针指向你指定的目录。此后该进程访问的所有绝对路径,都以这个新根为基准。比如 chroot 到 /srv/jail 后,进程看到的 /etc/passwd 实际是宿主机的 /srv/jail/etc/passwd。

做个最小实验。准备一个 jail 目录,塞进一个静态编译的 busybox:

mkdir -p /srv/jail/{bin,lib,lib64}
cp /bin/busybox /srv/jail/bin/
chroot /srv/jail /bin/busybox sh

如果 busybox 是动态链接的,直接失败,报 error while loading shared libraries: libc.so.6。因为 chroot 之后,进程在 jail 里找不到 /lib。必须用 ldd /bin/busybox 查出依赖,逐个 cp --parents 拷进去。这就是 chroot 最基础的「坑」:jail 是一个孤立的文件系统视图,什么都不自动带进去。

二、能挡住的:路径逃逸和误删

对非特权进程,chroot 确实有效。一个以普通用户运行的 FTP 服务,被 chroot 到 /srv/ftp 后,它再怎么用 ../../ 也跳不出去,因为内核根本不解释 jail 外的路径。

chroot 也保护你免受「误操作」。比如一个处理用户上传图片的脚本,在 jail 里跑,即使脚本被注入 rm -rf /,删掉的也只是 jail 里的假根。这是 chroot 最实在的价值:限制破坏半径,而不是防御主动攻击。

三、挡不住的:root 的经典越狱手法

如果 chroot 里的进程拥有 root 权限,越狱是标准操作。原理很简单:root 可以调用 chroot 任意次。经典手法是「跳出再跳回」:

mkdir /tmp/escape
chroot /tmp/escape          # 进入一个空目录作为新根
# 现在当前目录还在 jail 外(因为 chdir 没做)
for (int i = 0; i < 100; i++) chdir("..");
chroot(".");                 # 把根改回真正的 /
# 此时已在 jail 外

核心在于 chroot 不会改变当前工作目录。如果 chroot 之前进程的 cwd 在 jail 外,chroot 之后它可以一路 cd .. 走回真实根,再 chroot 回来。这就是为什么正确的 chroot 沙箱必须在 chroot 之后立刻 chdir("/"),并且要用 chroot() + setuid() 丢弃特权。只 chroot 不降权,等于给攻击者留了后门。

实测验证:用一段 C 程序在 chroot 后不 chdir,打印 getcwd(),会看到 (unreachable)/..,再跳几次就能回到宿主机根目录,能读到 /etc/shadow。这个过程不需要任何漏洞,纯粹是 chroot 的设计边界。

四、另一个盲区:/proc 与设备节点

chroot 只管文件路径,不管其他内核接口。如果你的 jail 里挂载了 /proc,里面进程可以在 /proc/1/root 下看到宿主机的真实根(如果权限允许)。设备节点同理:jail 里如果存在 /dev/sda 这样的块设备节点,进程可以直接读写整块磁盘,绕过文件系统的一切权限。

所以一个像样的 chroot 环境要做的清理:

  • 不要挂载宿主机的 /proc,或者挂载时加 hidepid=2。
  • 不要保留任何设备节点,需要 /dev/null 时用 mknod 单独创建或 bind mount。
  • chroot 后立刻 chdir("/") 并 setuid 到无特权用户。

五、用 mount namespace 补上隔离短板

现代内核提供了比 chroot 强得多的原语:namespace。其中 mount namespace 让进程拥有一份独立的挂载表,配合 pivot_root 可以做出真正的根切换;PID namespace 让进程看不到宿主机其他进程;network namespace 给它独立的网络栈。

不装 Docker,用 unshare 就能手工体验:

unshare --mount --pid --fork --mount-proc /bin/bash
# 现在这个 bash 里 ps aux 只能看到极少数进程
# 挂载操作也不会影响宿主机

--mount-proc 会自动给新的 PID namespace 挂一个干净的 /proc,这样 ps、top 看到的都是命名空间内的进程。unshare 之后想看到宿主机进程必须逃出命名空间,这比 chroot 的逃逸难得多。用 unshare 做隔离,是 chroot 的现代化替代方案,且不需要装任何额外软件包。

六、更省心的选择:firejail 与 bubblewrap

如果不想手写 namespace 组合,可以直接用封装好的工具。firejail 面向桌面和命令行程序,一条命令把任意程序关进沙箱:

firejail --private --net=none wget http://example.com/big.iso

--private 给它一个临时家目录,程序退出即销毁;--net=none 禁网。firejail 内部就是组合了 namespace、seccomp、capabilities 这套机制。

bubblewrap(bwrap)更轻、更偏向「只给必要的挂载」。它被 Flatpak 用作底层沙箱引擎,适合给一个命令临时授权访问某个目录:

bwrap --ro-bind /usr /usr --bind /tmp/work /work --unshare-all --die-with-parent bash

--ro-bind 只读挂载 /usr,--bind 把工作目录写权限给进去,--unshare-all 一次性取消所有命名空间共享,--die-with-parent 保证父进程死了它也跟着死(避免孤儿沙箱)。bubblewrap 没有守护进程,启动开销极小,非常适合脚本里对不可信输入做一次性隔离。

七、什么时候 chroot 就够,什么时候必须升级

给几个判断标准:

  • 只是限制一个可信服务的破坏半径(比如 ftpd、老式 daemon):chroot + 降权足够。
  • 处理不可信的上传/代码:必须用 namespace 类方案(bubblewrap、firejail),chroot 挡不住有 root 或能利用内核接口的逃逸。
  • 需要网络隔离:chroot 完全无能为力,必须上 network namespace 或防火墙。
  • 多租户、复杂依赖:直接用容器运行时(Docker、Podman、nsjail),别自己拼。

一句话总结:chroot 是「文件视图切换」,不是「安全边界」。把它当安全机制单独用,迟早出事;把它当纵深防御的一层,配合降权和 namespace,才有价值。理解它的边界,比盲目信任它重要得多。

八、一个可复用的最小沙箱脚本

把前面几点串起来,写一个用 bubblewrap 隔离不可信脚本的包装器:

#!/bin/bash
# sandbox.sh —— 在只读系统 + 独立网络 + 独立 PID 下运行传入的命令
set -euo pipefail
exec bwrap \
  --ro-bind /usr /usr \
  --ro-bind /lib /lib \
  --ro-bind /lib64 /lib64 \
  --ro-bind /bin /bin \
  --proc /proc \
  --dev /dev \
  --tmpfs /tmp \
  --unshare-all \
  --share-net=false \
  --die-with-parent \
  --chdir /tmp \
  "$@"

这样运行 ./sandbox.sh bash /tmp/untrusted.sh,脚本只能看到只读的系统目录和一个空的 /tmp,没有网络,父进程退出它就退出。想放开某个目录,加一条 --bind /data /data 即可。这套做法比裸 chroot 安全得多,也比上容器轻量得多,是个人服务器上处理不可信代码的性价比之选。

九、seccomp:再收窄一层系统调用

namespace 隔离了「看得见什么」,但沙箱里的进程仍然能调用完整的系统调用集合。如果想进一步限制它能做什么,就要上 seccomp(secure computing mode)。它的思路是给进程挂一个 BPF 过滤器,只放行白名单里的系统调用,其余一律返回错误或直接杀掉进程。

用 bubblewrap 可以顺带加一层 seccomp,但更常见的写法是让沙箱工具自己做,比如 firejail 默认就带一套。手写 seccomp 过滤器的典型用途是:一个纯计算脚本不需要 socket、execve、ptrace,那就全部禁掉,即使里面有代码执行漏洞,也开不了网络、起不了新进程。

# firejail 里显式禁用网络相关系统调用(示例思路)
firejail --net=none --seccomp --caps.drop=all ./compute.sh

--caps.drop=all 丢光所有 Linux capabilities,--seccomp 启用系统调用过滤。三层叠加——namespace(可见性)+ capabilities(特权位)+ seccomp(调用面)——才构成现代意义上的沙箱。chroot 只占了最外面最薄的一层。

十、文件描述符泄露:chroot 沙箱最隐蔽的漏洞

即使你 chroot + 降权都做对了,还有一个隐蔽的逃逸路径:从父进程继承来的文件描述符。如果父进程在 chroot 之前打开了某个 jail 外的目录 fd,子进程继承了这个 fd,就可以用 openat(fd, "../../etc/shadow") 绕过 chroot 直接访问宿主机的文件——因为路径解析是相对那个已经打开的 fd 进行的,而那个 fd 指向 jail 外。

标准修法是在 chroot 之后、执行目标程序之前,关闭所有不需要的 fd:

# 在 chroot 后关闭 3 号及以上的所有 fd
import os
for fd in range(3, 1024):
    try:
        os.close(fd)
    except OSError:
        pass
os.chroot("/srv/jail")
os.chdir("/")            # 立刻锁定 cwd
os.setgroups([])         # 清空附加组
os.setgid(65534)         # nobody
os.setuid(65534)         # 最后降权
os.execv("/bin/target", ["/bin/target"])

顺序至关重要:关闭 fd → chroot → chdir 到根 → 清空附加组 → setgid → setuid → exec。任何一步顺序反了都会留漏洞。特别是 setgid 必须在 setuid 之前,因为一旦降了 uid 就再也没有权限改 gid 了。这套顺序在 daemontools、OpenSSH 的 privsep 里都是这么写的,是几十年沉淀下来的正确姿势。

十一、小结:把 chroot 放回它该在的位置

chroot 是一个有三十年历史的原语,简单、可移植、几乎没依赖,但它的能力边界极其清晰:只有文件路径视图这一项。它挡得住路径遍历和误删,挡不住 root 越狱、不做网络和进程隔离、也需要你手工处理 fd 和降权。

在 2026 年做隔离,理性的选择是:日常用 bubblewrap 或 firejail 这类 namespace + capabilities + seccomp 的组合,chroot 只在「老式 daemon 的破坏半径控制」这种轻量场景单独出现。理解每层机制挡住的是什么,组合起来用,才是真正的防御纵深。

Last modification:September 30th, 2026 at 07:25 pm

Leave a Comment