容器 04:换了根目录,为什么仍能看到旧文件
/ 是进程解析路径的起点,不是磁盘的物理边界。仅改变根目录无法证明进程已失去对旧文件系统的引用;父进程打开的文件描述符、当前工作目录和挂载传播均可能影响结果。此前02只改变 UTS/IPC 视图,这一篇把根、挂载树与已打开 FD 分开。
三种不同操作
mount namespace 让进程拥有自己的挂载点集合;新建它时通常复制原先的挂载列表,不能说新 namespace 自动生成了全新的文件系统。挂载传播定义一侧后续挂载事件是否能影响另一侧,因此先检查 /proc/self/mountinfo 的 shared/private 信息,再只在专用私有挂载树做写入实验。chroot 主要改变调用者的根目录路径解析起点;已经打开的旧根 FD 不会因此自动关闭,也不能把它当成安全沙箱。pivot_root 移动根挂载并把旧根移到 put_old,需要符合 mount point 和传播约束;切换之后还要决定何时卸载旧根,清理所有仍引用旧树的 FD。
路径、挂载和文件描述符不是同一个东西
调用 open("/etc/issue") 时,绝对路径从调用进程的 root 起点做名称解析;相对路径则从当前工作目录或传入的目录 FD 开始。chroot(2) 修改了前一个起点,却不会自动 chdir("/"),也不关闭已有的 FD。读入一个文件后即便路径后来被移走,已经打开的文件对象仍有自己的引用;用旧目录 FD 通过 fchdir 回到原位置,是判断“只换根目录能否撤回已取得的引用”的受控方法。文件目录的权限检查还依调用进程的凭据、路径上各级权限以及具体挂载属性而定。不能因为一次可读就推断所有原宿主文件都能读,更不能把这个实验作为向不可信程序开放的沙箱设计。
mount namespace 则隔离的是挂载点的列表及后续操作视图:创建时视图基于原挂载列表,普通文件并不会复制一遍。两个进程处在不同 mount namespace 仍可能通过指向同一底层文件系统的挂载读写相同数据;绑定挂载尤其容易让人误以为“两个名字必然是两份文件”。判断写入路径时,应在两侧检查设备/文件对象的对应关系、挂载源、目标路径和实测读写;仅比较 readlink /proc/self/ns/mnt 不足以证明存储隔离。这里的对象对应与 16 篇的“容器可写层还是卷”不是一回事:mount namespace 决定进程从哪里走到数据,持久化机制决定那些数据在进程结束后是否仍由外部保存。
传播规则为何决定实验安全
mount_namespaces(7) 区分 shared、private、slave 与 unbindable:shared 的 peer group 会双向传播相应子挂载事件;private 不向 peers 传播也不接收;slave 可从 master 接收事件但不反向传播;unbindable 还禁止被 bind mount。隔离了 mount namespace 并不足以保证新的挂载动作不会影响外侧:如果复制出的根挂载仍参加 shared peer group,操作有机会传播。运行任何教学挂载命令前,应先在专用 VM 中检查 /proc/self/mountinfo 的 optional fields(如 shared:N、master:N),证明已经进入自己的 mount namespace,并在其中单独设置私有实验挂载树,核验外侧视图未发生变化。不能为了让示例成功,在共享宿主直接执行 mount --make-rprivate /。
“将挂载改为 private”与“卸载宿主根”完全不同,但误把当前 namespace 当成独立实验环境时都可能影响其他任务。验证入口先保留外侧 mountinfo 的摘要,再记录实验目录范围内的挂载;命令失败也在同一个受控环境中检查残留。若权限不足导致 mount 返回 EPERM,结论只针对该动作没有建立相应挂载,不说明内核根本不支持 mount namespace;同时也不能因为 unshare -Ur 可运行就假定 unshare -m、挂载新 procfs 或执行 pivot_root 都已成功。当前云端的拒绝日志正是后一种约束。
pivot_root 与 chroot 各自解决什么
pivot_root(new_root, put_old) 的对象是 mount namespace 中的根挂载:把原根移到新根下的 put_old,将 new_root 挂载设为新根。pivot_root(2) 要求调用进程在该 mount namespace 的拥有者 user namespace 中有 CAP_SYS_ADMIN;new_root 必须是符合条件的挂载点,put_old 是其中目录,挂载传播等前提也必须满足。试图对一个只创建了目录、尚未成为挂载点的路径 pivot,可能得到 EINVAL;这并不证明 rootfs 的文件内容有错。chroot(2) 则只改路径解析起点,权限要求是其 user namespace 中的 CAP_SYS_CHROOT;两个调用不可用一个代替另一个。在上面的旧 FD 案例里 chroot 成功恰好暴露它不会取消已有的路径引用。
成功 pivot 后仍不能直接宣布旧根不可达。需要先 chdir("/"),确定旧根放到了约定位置,再处理进程留在旧根上的工作目录和打开 FD,卸载 put_old 并检查返回值。umount -l 看似立刻从路径树移除挂载,但仍有引用的对象不会因此自动消失;若目标是证明清理完整,要核对挂载树和占用进程,不能用“命令返回 0”跳过对象生命周期。为了验证失败清理,故障注入也只发生在私有新根与专用 VM,命令应打印进入挂载 namespace 前后的 inode、根挂载及每一步返回值。
一个真实反例
本机允许 unshare -Ur。以自建空目录作参数,root_probe.py 在隔离进程内先打开旧根 FD,再 os.chroot(空目录) 并 chdir('/');它随后通过旧 FD 的 fchdir 读到旧根 /etc/os-release 的首行。原始命令、环境基线、返回 0 和 rmdir 清理结果见原始日志;最小源码、运行步骤见附件及入口。读到旧文件并非访问控制漏洞的通用利用说明,而是刻意保留已有权限和 FD 后对“chroot 就完成隔离”的反证。该案例没有挂载新文件系统,不能代替 pivot_root 验收。
对照如果改为在 chroot 后尝试普通 open("/etc/os-release"),新根里没有这个路径,应预期路径解析失败;通过旧 FD 读取成功和通过新绝对路径失败可以同时成立。它们测的是起始引用不同,不能只保留成功读到旧内容的一支就归因于 chroot 全盘失效。最小 probe 是可信自编程序,它故意保留 FD 来展示不可信进程可尝试的边界,不能据此声称教学启动器能安全执行任意第三方二进制。若旧 FD 已关闭,还要检查程序是否有其他工作目录或子进程继续持有旧根;删掉一个变量名不等于撤销所有内核引用。
当前环境 --mount-proc 返回 EPERM,不能在共享云端尝试 mount propagation 或旧根卸载。专用 Linux VM 补跑顺序:自建临时根文件系统;记录进程和挂载树;进入私有 mount namespace;把挂载树改为 private,执行 bind mount 和 pivot_root;检查旧根 FD、mountinfo、umount 结果;退出后用宿主 findmnt 确认实验目录无残留。失败时只卸载自己创建的挂载,绝不对共享宿主根使用 mount --make-rprivate /。
若换根后程序无法启动,也要分层判断:execve 返回 ENOENT 可能是可执行文件路径缺失,也可能是 ELF 动态加载器或脚本 shebang 指向的解释器在新根中缺失;EACCES 则另查路径权限、挂载选项及执行权限。先从新根内部验证最小二进制及依赖,再检查 /proc、/dev 是否按应用需求挂载,而不要通过把宿主整棵目录 bind 进去掩盖缺文件。教学例子只需要能返回固定内容的可信进程,不需要在共享宿主上运行完整发行版 init。最终验收必须同时出现工作程序在新根运行、旧根卸载、两侧挂载列表与自建临时目录清理;当前缺任意一项都记 NOT_RUN,不借本机 chroot 的成功码代替。
读一次 mountinfo 应回答哪些问题
/proc/<pid>/mountinfo 一行的 mount ID、parent ID 与挂载点共同描述层级;在分隔符 - 前还可出现传播相关字段,分隔符之后包含文件系统类型、源和挂载选项。采样时先固定目标 PID 及其 mount namespace inode,再找新根与旧根对应的挂载点行,确认新根确实是单独挂载而不只是普通目录。某行未写 shared:N 只能说明这行没有该 peer group 标记,不能在未查看其他挂载点与父级关系前推断整棵树都已私有。比较外侧与内侧也要检查相同目录是否通过 bind mount 以不同挂载点出现,不能仅凭路径字符串不同判断数据来源不同。
例如在 VM 的独立 mount namespace 中先在实验目录 bind mount 一个自建只读源,再做 pivot:若新根下仍读取到旧根某处同名文件,先查是不是该 bind mount 正好指向原始源,而不是立刻归因于 pivot 没生效。正例是进程报告新根、可以执行探针、旧根目标卸载且挂载记录不再出现;失败例是 umount 返回 EBUSY,检查是否存在仍在旧根的工作目录或 FD。两种情况要同时保留输入配置与挂载前后列表,避免为了让失败例“按预期失败”而删去异常证据。
这套核查方法可迁移到容器的 bind mount 与数据卷:先从运行的那个进程取得根和 mount namespace,再从实际挂载点追到源,最后用受控写入观察持久位置。镜像层的文件内容、docker inspect 的预期配置和应用实际执行时的 mountinfo 是三种不同证据;如果可写卷覆盖了镜像中的路径,镜像检查再正确也不能回答应用这一次读取到的字节来自哪里。
最后限定清理范围:在实验进程退出前采集内侧旧根和新根两份挂载记录,退出后只对实验目录检查宿主挂载,不对系统其他挂载做递归卸载。若出现残留,先停止自己启动的进程并复核目录是否确属本次创建,再从最深层实验挂载点向上逐个卸载;不应按“看见同名路径就全部清理”的方式影响其他任务。实验目录带唯一标识,运行入口保留它的绝对路径与进程 PID;若命令失败到尚未创建挂载,清理日志应写“未创建”,不是为了凑验收项填“卸载成功”。实际云端缺少执行这一段的权限,所以这些动作只是专用 VM 的复现实验步骤,页面生成后仍只能标为 NOT_RUN。
同样不能把空目录创建成功当作 rootfs 已就绪:缺少解释器、库或必需的设备文件时,程序根本无法运行;反过来,程序能运行也不能证明旧根已不可达。前者检查构建输入与 execve 错误,后者检查旧根 FD、挂载树和旧根卸载,两组验收要各自有独立结果。
| 现象 | 看什么 | 为什么 |
|---|---|---|
chroot 后还能读旧文件 |
父进程留下的 FD、工作目录 | 路径解析起点改变,不会自动撤销引用 |
pivot_root 返回 EINVAL |
mount point、传播类型 | 新根和父挂载有前提 |
| 清理后还有挂载 | 私有挂载树和占用 FD | 进程退出不保证外部挂载记录消失 |
练习
- 预测关闭旧根 FD 再运行会怎样:修改自建程序,在
chroot后关闭original_root,比较返回值与错误路径,不要打开或更改敏感宿主文件。 - 在专用 VM 预测 shared 挂载与 private 挂载对父 namespace 的影响,分别记录两侧
mountinfo再操作;当前环境此实验NOT_RUN,不能借前面的chroot结果推断。
上一篇:03:PID 1;下一篇:05:user namespace。
系列总目录:从进程隔离到运行时与编排。





