ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

pivot_root 深入解析:容器根目录切换的核心机制与实战

pivot_root 深入解析:容器根目录切换的核心机制与实战 1. 为什么需要 pivot_rootchroot 的边界在哪里如果说容器技术里有一个既熟悉又陌生的系统调用pivot_root 绝对排得上号。很多人在看 Docker、runC 或者 Kubernetes 相关原理时都会碰到这个词但真正动手用过的人不多。它和 chroot 看起来都跟根目录切换有关但本质差距巨大可以说 chroot 解决的是怎么让进程看到一个新根而 pivot_root 解决的是怎么让进程彻底离开旧根。两者之间的差距恰恰是容器安全模型的基石。1.1 chroot 的两个致命缺陷先聊聊 chroot。chroot 的原理本质上只是把当前进程的根目录路径引用换掉内核里对应的操作是修改进程的fs_struct-root让路径解析从新的目录开始。听起来很直接但这里面埋了两个大坑。第一个坑进程仍然持有对旧根的引用。你在 chroot 之前打开了一个目录 fd或者你的当前工作目录还在旧根下那么即使在 chroot 之后你依然可以通过fchdir或..跳回旧根目录。这就是经典的 chroot 逃逸。攻击者拿到容器内任意一个进程的权限后只要有办法获得一个宿主目录的 fd就能绕过 chroot 的边界。第二个坑chroot 不改变挂载命名空间。它只是改变了路径解析的起点但那些已经挂载在旧根下的文件系统对进程来说仍然可见。比如宿主机的/proc、/sys、/dev等挂载点在 chroot 后如果新根目录里没有对应的挂载点进程就看不到但如果有办法通过 fd 访问旧根这些敏感信息照样能拿。所以 chroot 更像是一个视觉欺骗工具它让进程看到的目录结构变了但底层的挂载关系和文件系统引用都没变。这就好比你从宿舍搬到了操场但你衣柜钥匙还在宿舍门上随时可以回去拿东西。1.2 容器技术对根的硬性要求容器运行时面临的现实问题比逃逸更紧迫。当容器引擎启动一个容器时它要确保容器内的进程从一开始就看不到宿主机的挂载点、文件系统、设备节点。这靠 chroot 是做不到的因为 chroot 依赖进程的自觉——如果容器内没有旧根的 fd那确实看起来安全但这属于运气不是协议。更关键的是现代容器运行时通常会给容器建立全新的 mount namespace然后在里面挂载一份新的 rootfs。在 mount namespace 中这个新 rootfs 是唯一合理的根。这时你需要一个系统调用能把当前进程的根挂载点整体替换成新的挂载点并且把旧挂载点摘下来动作要原子、彻底、不可逆。pivot_root 就是干这个的。我在实际写容器运行时相关工具时对这一点感触特别深。你如果用 unshare 创建了新的 mount namespace然后只做一个 chroot你会发现宿主机上的某些挂载仍然能穿透进来比如通过打开的文件描述符。可在 pivot_root 之后这个 namespace 里的进程根挂载点已经换成了新 rootfs旧根在 mount table 上被彻底移走了连路径都找不回来。这个差别在安全审计里是硬件级的、不可绕过的。2. pivot_root 的系统调用语义与内核行为pivot_root 这个系统调用的名字很直白就是旋转根。它定义在 Linux 内核的 fs/namespace.c 里原型是int pivot_root(const char *new_root, const char *put_old);第一个参数是新根目录的路径第二个参数是存放旧根的路径。调用成功后当前挂载命名空间的根挂载点会被替换为new_root对应的挂载点而原来的根挂载点会被移动到put_old目录下。注意这里说的是挂载点不是目录——pivot_root 操作的对象是挂载点而不是简单的路径字符串。这是理解整个机制的关键。2.1 系统调用的参数与内核路径在内核实现中pivot_root 会先解析new_root对应的挂载点然后检查它是否满足前置条件后面会细讲接着把根挂载点相关的vfsmount结构重新链接再把旧根挂到put_old指定的路径下。整个动作在 VFS 层完成期间会有锁保护所以对用户态进程来说是原子的。调用成功后chdir(/)就变得重要了因为虽然根挂载点换了但进程的当前工作目录不一定自动跟着切到新根的/上。所以在跑完 pivot_root 之后紧接着几乎总是跟着一个chdir(/)让进程的工作目录也落到新根里。runC 代码里就是这么干的if err : unix.PivotRoot(rootfs, rootfs/.pivot_root); err ! nil { return err } if err : unix.Chdir(/); err ! nil { return err }顺序不能反先 pivot_root再 chdir(/)。如果你先 chdir 到旧根的某个目录再执行 pivot_root虽然也能切但那个目录可能已经被移动了存在混乱的风险。2.2 挂载点切换与 chroot 的本质区别chroot 修改的是进程的根目录指针pivot_root 修改的是整个 mount namespace 的根挂载点。这两者的区别可以类比为chroot 是给进程戴了一个 VR 眼镜看到的世界变了但脚下的地板还是原来的pivot_root 是直接把地板换成了新的旧地板被搬到仓库里去了。从内核数据结构来看chroot 作用于fs_struct-root只影响单个进程或者通过CLONE_FS共享 fs_struct 的一组线程。而 pivot_root 作用于mount_namespace-root影响的是整个 mount namespace 下的所有进程。这也是为什么 runC 在创建容器时要先通过 clone/unshare 隔离出新的 mount namespace再在这个 namespace 内执行 pivot_root——这样只有容器内的进程会受影响宿主进程完全无感。还有一个隐蔽的区别chroot 之后的进程其/proc/self/mountinfo里根挂载点仍然是宿主机的根设备而 pivot_root 之后根挂载点变成了新 rootfs 对应的设备或绑定的目录。做容器逃逸检测、取证分析时看/proc/PID/root和/proc/PID/mountinfo的差异就能判断出进程是经过 chroot 还是 pivot_root 隔离的。2.3 前置条件为什么内核要限制这么多pivot_root 不是你想调就能调内核设了一堆前置条件任何一个不满足直接返回错误码。我梳理了一下实践中最常见的几个new_root必须是一个挂载点不能只是一个普通目录。这意味着你要先对新 rootfs 目录执行 mount可以是 bind mount也可以是真实文件系统的 mount否则 pivot_root 返回 EINVAL。put_old必须位于new_root之下或者是其子目录这是为了保证旧根能被完整地收纳进新根下不会出现交叉引用。当前进程的根目录不能与new_root的父目录相同。这个条件有点绕简单说就是你不能把自己正在用的根再拿来切得先换个地方。要满足new_root和put_old不在同一个文件系统上的场景限制不过实际应用里通常都是 bind mount 出来的新根这个条件天然满足。如果当前挂载传播属性是 shared 且没有先改成 rprivatepivot_root 可能返回 EBUSY。这个坑我后面专门展开说。这些限制乍看很繁琐但每条都能对应到具体的防止滥用场景。内核这种严格检查的风格在安全敏感的系统调用上很常见。你在写相关代码时一定要把每个错误码都当回事尤其是 EINVAL 和 EBUSY 的出现频率最高。3. 一次完整的 pivot_root 实战从宿主到新根光讲理论很容易飘真正动手跑一遍才踏实。我下面给出一个在普通 Linux 宿主机上手动模拟容器切换根的流程不需要容器运行时只需要 root 权限和一个不算太老的内核。这一步做完你对 pivot_root 的理解会比看十篇文章还深。3.1 准备一个最小的新根目录首先准备新的根文件系统。最简单的方式是用 busybox 做一个 mini rootfs或者直接 bind mount 当前系统的一部分目录。我这里为了演示省事直接 bind mount 根目录mkdir -p /mnt/newroot mount --bind / /mnt/newroot注意 bind mount 之后/mnt/newroot就是一个挂载点了这满足了 pivot_root 的new_root 必须是挂载点要求。如果你线上环境里要用干净的最小 rootfs可以用 busybox 的静态编译版本丢进去再把proc和dev挂上去但演示阶段 bind 宿主根就够了。紧接着要处理挂载传播问题。在大多数发行版上/的挂载传播属性是 shared这种状态下 pivot_root 很可能失败。所以先改成 rprivatemount --make-rprivate /这一步是用 unshare 进入新 mount namespace 之后才需要做。如果你直接在宿主的原始 namespace 上改会影响整个系统。正确姿势是先unshare -m进入一个新的 mount namespace在这个 namespace 里再操作。3.2 演示脚本与逐步拆解下面这个脚本展示了一次完整的 pivot_root 切换过程。我用unshare进入新的 mount namespace然后在新 namespace 里执行 pivot_root#!/bin/bash # demo_pivot_root.sh set -euxo pipefail # 1. 进入新的 mount namespace unshare -m bash -c # 2. 让当前 namespace 内的挂载传播变为 rprivate mount --make-rprivate / # 3. 准备新根目录bind mount 根文件系统 mkdir -p /mnt/newroot mount --bind / /mnt/newroot # 4. 在新根下创建 put_old 目录用于收纳旧根 mkdir -p /mnt/newroot/oldroot # 5. 执行 pivot_root pivot_root /mnt/newroot /mnt/newroot/oldroot # 6. 切换工作目录到新根 chdir / # 在 bash 里用 cd / cd / # 7. 卸载旧根此时旧根挂在新根的 /oldroot 下 umount -l /oldroot rmdir /oldroot # 8. 验证 echo After pivot_root: cat /proc/self/mountinfo | head -5 ls / 每一步都值得拆开理解。第 2 步为什么要改成 rprivate因为在新 mount namespace 里挂载点默认继承了父 namespace 的传播关系。如果根是 sharedpivot_root 在新旧根之间做移动时内核会因为传播关系检查失败而返回 EBUSY。改成 rprivate 等于告诉内核这个 namespace 里挂载点之间的变更不要再向外面扩散了。第 4 步的/mnt/newroot/oldroot就是 put_old。pivot_root 执行前旧根是整个文件系统树的根挂载点执行后旧根被移动到这个目录下。你可以把整个过程想象成一次换血手术新根被抬上来当根旧根被塞到角落里等着被卸载。第 7 步的umount -l用了 lazy 卸载这很重要。因为当前进程的工作目录可能还在旧根上或者有打开的文件句柄引用旧根直接 umount 会返回 target is busy。lazy 卸载则会把旧根从挂载树上摘掉等引用全部释放后再真正回收资源。这在容器退出、删除容器根目录时也经常用到。3.3 验证是否真的切换成功脚本跑完之后最直接的验证方式是看 mountinfo 的第一行cat /proc/self/mountinfo | grep / / 如果 pivot_root 成功根挂载点的路径显示应该是指向/mnt/newroot对应的设备源。你再对比一下宿主机上的输出会发现两边根挂载点已经完全不同。另一个验证方式是ls -l /proc/self/root它显示的符号链接目标就是新根目录。如果你在新根里放了一个特殊的标记文件比如/marker那么ls /marker能看到而在宿主机上/marker可能不存在或者内容不同。这个标记法是我在调试不同容器 rootfs 时最常用的手段——简单直接一目了然。实际运行这段逻辑时需要注意unshare -m后 mount namespace 是新的但你仍然是宿主 root 用户所以权限上没问题。另外不要尝试把put_old放在new_root之外的地方内核直接会报 EINVAL。这个错误在开发初期我踩了不知道多少次。4. 高频踩坑与排查思路mount 传播、EBUSY 与 put_old 的处理pivot_root 的 API 本身只有两个参数但实战中几乎每个人都会在它附近栽跟头。我把这几年在容器运行时、沙箱方案和系统初始化脚本里遇到的坑分类整理一下这些都是文档里不一定写得明白、但排查时一定会用到的东西。4.1 MS_REC 与挂载传播的坑很多人在准备 new_root 时只 bind mount 了一个目录结果 pivot_root 之后发现新根里面空荡荡的。比如你 bind mount 了宿主机的/但新根里看不到/home、/var这些原先挂载出来的子挂载点。原因很简单bind mount 默认不递归。你要把整个文件系统树都带过去需要在 bind mount 时加上--rbind而不是--bindmount --rbind / /mnt/newroot在代码里对应MS_BIND | MS_REC。runC 里准备 rootfs 时如果涉及将宿主目录 bind 进容器同样要考虑要不要递归。递归与否直接决定了新根里的挂载拓扑完整性。不过加--rbind之后还会引入另一个问题新根里会带着宿主机上的一大堆挂载点比如/proc、/sys、/dev。这些如果在容器里没做隔离会造成安全问题。所以专业的容器运行时会在 pivot_root 之前先把这些敏感目录处理好要么卸载要么用 namespace 内的 tmpfs 重新挂载。我的建议是如果你是做沙箱类项目尽量用真实的最小 rootfs不要 bind 宿主根如果你只是想快速验证 pivot_root 行为--rbind /最省事但要知道它带了什么副作用。4.2 EBUSY 的根因分析EBUSY 可能是 pivot_root 最让人头疼的错误。触发它的原因不止一个我挨个说。第一个是前面提过的挂载传播问题。根挂载点是 shared 时pivot_root 会拒绝执行因为内核无法确定传播关系是否会引入环路。解决办法就是mount --make-rprivate /这一步几乎成了 pivot_root 的前置标准动作。如果你用了 systemd 系的发行版默认根挂载传播大概率是 shared所以这一步基本是必须的。第二个是 new_root 或 put_old 上有挂载。如果 put_old 目录本身是一个挂载点或者 put_old 下面还有 active 的挂载点内核也会返回 EBUSY。常见于你之前挂载了什么文件系统忘记卸载或者put_old目录被系统进程占用。排查方式是看 mountinfo 里 put_old 路径下还有没有挂载记录grep /oldroot /proc/self/mountinfo第三个是 new_root 与当前根指向同一个挂载点。如果你直接pivot_root / /或者pivot_root /mnt/newroot /mnt/newroot/oldroot但/mnt/newroot的挂载源就是/在没有改传播属性时也可能触发 EBUSY。对这个场景我建议先确认 mountinfo 里new_root的设备号、挂载源是否与当前根一致。排查 EBUSY 有一个通用的调试思路执行 pivot_root 之前先把 mountinfo 完整 dump 出来对照检查 root 挂载点的传播属性、new_root 和 put_old 的挂载状态。很多时候问题一目了然比盲目试错快得多。4.3 put_old 的两种经典姿势put_old 怎么放业界有两种主流做法各有应用场景。第一种是 create-and-umount 姿势在 new_root 下创建一个子目录比如/mnt/newroot/oldrootpivot_root 之后立刻卸载mkdir -p /mnt/newroot/oldroot pivot_root /mnt/newroot /mnt/newroot/oldroot umount -l /mnt/newroot/oldroot rmdir /mnt/newroot/oldroot这种姿势适合明确知道 new_root 路径的场景逻辑直白。但有一个小问题目录名会短暂暴露在容器内。某些安全审计工具可能会注意到.pivot_root或oldroot这种目录的存在。第二种是 runC 采用的优雅姿势先把当前根 bind mount 到一个临时目录然后让 new_root 就是当前根目录put_old 也是当前根目录mount --bind / /mnt/newroot pivot_root /mnt/newroot /mnt/newroot乍一看很奇怪把旧根放进旧根里。但内核允许 put_old 等于 new_root执行后旧根挂载点被移到/mnt/newroot这个目录下而/mnt/newroot本身就是新的根挂载点。这样 put_old 和 new_root 重叠省掉了卸载时对路径的依赖。runC 源码里用的路径是 rootfs 加一个隐藏目录本质逻辑一样。两种姿势没有绝对优劣。第一种对初学者更友好第二种更贴近生产环境。如果你在写自己的容器运行时我建议先实现第一种跑通后再切换到第二种这样对行为差异会有更具体的感知。4.4 在 systemd 和容器运行时环境下的特殊处理真实环境中你不会直接在一个干净的 shell 里调 pivot_root更多时候是要在 systemd 或者已经跑着容器进程的环境里操作。这就引出了两个特殊问题。第一个是 systemd 的 mount namespace 管理。systemd 会为大量服务创建独立的 mount namespace并用MountFlagsshared等参数控制传播。如果你在一个 systemd 管理的服务脚本里执行 pivot_root系统原有的一些挂载点比如/run、/tmp可能还在新根之外挂着造成奇怪的不可见问题。处理思路是先明确你所在 namespace 的挂载传播属性再决定是否要手动重挂关键目录。第二个是容器内 PID 1 的特殊性。容器的 PID 1 进程执行 pivot_root 后这个 namespace 里其他进程的根也跟着全换了因为挂载点是 namespace 级共享的。如果你的容器里跑着多个进程其中某个进程持有旧根的 fd 或 cwd 在旧根下它在 pivot_root 之后如果尝试访问旧根路径可能会触发非常诡异的行为。比如执行cd / ls看似正常但getcwd返回的路径和新根里的路径完全对不上。我遇到过的一个实际案例是某个 Go 程序在容器启动时后台协程还在往旧根的日志文件里写数据pivot_root 之后文件句柄依然有效但路径已经不可解析了。这类问题很难排查唯一的建议是在容器启动早期尽量让所有进程的打开文件句柄都基于新根不要在 init 阶段持有旧根的引用。5. 实际应用理解 runC 源码中 pivot_root 的调用方式讲完手工操作和踩坑最后落脚到真实生产代码。runC 是整个 OCI 容器运行时的事实标准它启动容器的核心路径里就有一段 pivot_root 调用代码量不多但每一行都有讲究。5.1 runC 的 rootfs 准备流程runC 启动容器时rootfs 的构建分几步从镜像解压出 rootfs 目录、挂载 proc/sys/dev 等虚拟文件系统、把 rootfs 做成一个挂载点、创建 put_old 目录然后调用 pivot_root。这中间有一个细节很多人没注意rootfs 本身要先通过 bind mount 成为一个挂载点。unix.Mount(rootfs, rootfs, , unix.MS_BIND|unix.MS_REC, )这一步对应了我前面说的new_root 必须是一个挂载点的条件而且用的是 MS_REC确保 rootfs 内部的子挂载点也被保留。接下来 runC 会设置挂载传播属性防止宿主机的挂载变化穿透容器边界然后再执行 pivot_root。整个顺序是有严格依赖的bind mount 必须在前rprivate 必须在 pivot_root 之前否则都会失败。5.2 关键代码中的边界处理runC 中执行 pivot_root 的函数在libcontainer/rootfs_linux.go里核心逻辑如下func pivotRoot(rootfs string) error { oldDir : filepath.Join(rootfs, .pivot_root) if err : os.MkdirAll(oldDir, 0700); err ! nil { return err } if err : unix.PivotRoot(rootfs, oldDir); err ! nil { return fmt.Errorf(pivot_root %s %s: %w, rootfs, oldDir, err) } if err : unix.Unmount(oldDir, unix.MNT_DETACH); err ! nil { return fmt.Errorf(unmount pivot_root dir %s: %w, oldDir, err) } if err : os.RemoveAll(oldDir); err ! nil { return err } return nil }这段代码有两个值得注意的地方。第一put_old 目录用了一个隐藏名字.pivot_root并且在执行完 pivot_root 后立即以MNT_DETACHlazy unmount方式卸载再用RemoveAll清理目录。这样容器内最终看不到任何旧根残留。第二如果 pivot_root 失败runC 会回退到 chroot。这是为了兼容那些不支持 pivot_root 或者内核禁止 pivot_root 的环境比如某些容器嵌套场景。但这个回退其实是安全性的降级runC 在容器配置文件里也会标注这一点。如果你在自己做运行时尽量不要提供 chroot 回退选项毕竟 chroot 的隔离强度差很多。5.3 这套机制对日常开发的启发pivot_root 不只在容器运行时用到。我自己在本地调试沙箱工具、做系统级测试、甚至做 PDF 分析用的隔离环境时都会用到这套新 mount namespace 新根 pivot_root的组合。它比 chroot 安全得多也比完整跑一个虚拟机轻量得多。另外一个启发是理解 pivot_root 之后你会发现很多容器不可见隔离其实不是魔法而是内核里几个路径解析和挂载操作的组合。把 mount namespace 和挂载点这两个概念拆清楚再去看 runC、containerd、LXC 的代码很多之前觉得抽象的参数比如CLONE_NEWNS、MS_PRIVATE、MNT_DETACH都会变得具体起来。就我个人的实际体会来说pivot_root 是那种原理五分钟、排错两小时的系统调用。如果你刚开始接触建议一定自己在虚拟机里跑一遍上面的脚本故意制造几个错误比如不改 rprivate、pv 到普通目录、put_old 放在 new_root 外把错误码和 mountinfo 对应起来。这套手感建立之后再看任何运行时源码里的 pivot_root 调用都不会再觉得神秘了。
返回列表