Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

3 沙箱:进程、文件、网络、资源限制

本章回答

一个能执行代码的 agent,最小可信边界是什么?rlimit 与 cgroup 各挡哪一类事故?子进程退出码 0x2a00 到底怎么读?只杀父进程为什么会留下继续写文件的孤儿?

读完你会

为一个代码工具画出进程/文件/网络/资源四层边界;用 waitpid + WEXITSTATUS 正确判定失败;用 openat2 与默认拒绝的 netns 收紧路径和出口;写出一个能超时、能限额、能收尸的 runner,并把策略映射到 OCI config.json。

第 2 章把动作变成了协议,但协议只说明「能调用什么」,不说明「调用之后能碰到什么」。一个代码 agent 的工具调用,最终会落到宿主的真实对象上:一个 PID、一个目录项、一条 socket、一页内存。模型在自然语言里说「我只读一个文件」,内核看到的却是一次 openat;说「我跑一下测试」,内核看到的是 fork、execve、若干次 write,以及一个可能比父进程活得更久的进程组。

沙箱要处理的正是这层落差。它不是一条命令,也不是一个「安全模式」开关,而是四层边界的组合:进程边界决定谁能被创建、谁能被杀死;文件边界决定路径能解析到哪里;网络边界决定包能发到哪里;资源边界决定一次失控最多能烧掉多少。四层挡的事故不同,缺一层就有对应的逃逸路径。第 4 章会把「丢权」的手段(capability、seccomp、landlock、user namespace)补上;这一章先把边界本身画清楚。

先给结论:沙箱的价值不在于「限制得多」,而在于每一层都能说出它挡的是哪一类事故,以及它挡不住什么。一个只设了 30 秒超时的 runner,挡不住 fork 炸弹,也挡不住子进程在父进程死后继续写文件;一个只读的 rootfs,挡不住把读到的内容从网络发出去。下面从 agent 能做什么开始,逐层收紧。

代码 agent 的四层沙箱边界 四层不是四个可选项,而是四种失败模式的一一对应。只做进程超时、不做进程组,超时后子进程仍在写文件;只做文件只读、不做网络隔离,agent 仍可把读到的内容发出去。共同前提那一行是第 4 章的主题:边界要靠「丢权」而不是「加限制」来实现。 代码 agent 的四层边界:每层挡不同的逃逸 进程 fork/exec · 进程组 · 超时 · 收尸 + 文件 rootfs · pivot_root · openat2 · 临时目录 + 网络 netns · egress allowlist · DNS 固定 + 资源 rlimit · cgroup v2 · pids.max · 墙钟 共同前提 低权限 UID + no_new_privs + 最小 capability + seccomp 白名单 + landlock 路径规则 顺序:userns → mount/rootfs → drop caps → no_new_privs → seccomp → landlock(细节见第 4 章) 四条事故路径(每层一条) ① 不设进程组 → 超时只杀父进程,子进程继续跑 ② 不换根 → 读到宿主 ~/.ssh ③ 不隔离网络 → 把数据 POST 出去 ④ 不设 pids.max → fork 炸弹拖垮宿主
读图:四层不是四个可选项,而是四种失败模式的一一对应。只做进程超时、不做进程组,超时后子进程仍在写文件;只做文件只读、不做网络隔离,agent 仍可把读到的内容发出去。共同前提那一行是第 4 章的主题:边界要靠「丢权」而不是「加限制」来实现。

读图:四层是四种失败模式的一一对应,不是四个可选的加固项。图下方的「共同前提」属于第 4 章:低权限 UID、no_new_privs、最小 capability、seccomp 与 landlock。没有这一行,四层边界只是「建议」;有了这一行,越界才会变成 EPERM 而不是「下次注意」。

3.1 威胁模型:代码 agent 能做什么

先把「代码 agent」拆成动作。一个能读文件、改文件、跑命令、装依赖、访问 URL 的 agent,其工具面通常包含下面几类。每一类都对应一个真实的系统对象,而不是一段文本。

agent 动作真实系统对象典型事故靠哪层挡
read_file(path)目录项、inode、已打开的 fd读到宿主 ~/.ssh、云凭证、其他租户数据文件
write_file(path)inode、挂载点、磁盘块覆盖系统文件;写满共享磁盘文件、资源
run(command)fork/execve、进程组、会话子进程逃逸;孤儿继续跑;fork 炸弹进程、资源
http_get(url)socket、DNS、路由表SSRF 打云元数据;DNS 外带数据网络
install(pkg)包管理器、网络、rootfs供应链投毒;写入只读层文件、网络
serve(port)监听 socket、端口暴露宿主端口;被外部当成跳板网络

这张表说明什么:agent 的「能力」不是工具描述里的几句话,而是这些对象上的操作权限。工具描述可以写「仅用于读取项目文件」,但内核只认路径和 fd;如果路径能解析到 /etc/shadow,描述不会替你拦住。

定义 3.1.1 · 沙箱边界

一个代码执行沙箱是四元组

S=(P, F, N, R) \mathcal{S} = (P,\ F,\ N,\ R)

其中:

  • PP 是进程边界:允许创建哪些进程、它们属于哪个进程组/会话/PID 命名空间、超时后如何终止与回收;
  • FF 是文件边界:进程能解析到的路径集合,以及这些路径上的读写/执行权限;
  • NN 是网络边界:进程能创建的 socket、能解析的名字、能到达的地址与端口;
  • RR 是资源边界:CPU、地址空间、fd、进程数、文件大小、磁盘、墙钟的上界。

一次动作 aa 如果产生的作用超出 S\mathcal{S} 允许的集合,称为越界(escape)。沙箱的强度不是四层的数量,而是「每一层都能独立拒绝越界」:任何一层被绕过,其余层仍然成立。

这个定义故意把「进程数」放在 RR 而不是 PP。进程组解决的是「怎么杀」,pids.max 解决的是「能生多少」;两者经常被混为一谈,后果是一个能杀干净但已经被 fork 炸弹拖垮的宿主。

沙箱是减法,不是加法

给 agent 加一个「安全模式」开关,本质是在原有权限上叠一条规则;只要有一条路径没经过这条规则,权限就还在。沙箱的方向相反:先假设 agent 拥有完整权限,再一层层减掉不需要的。减完之后,即使 agent 被注入、模型犯错、工具被投毒,它能造成的后果也被限制在剩下的集合里。减法比加法难做,因为你要证明「减干净了」;但只有减法能给出可验证的边界。

关于沙箱的五个误解
  1. 「用了容器就是沙箱」——容器只是 namespace、cgroup、capability、mount 的一组配置。默认配置下它仍可能共享网络、保留 capability、挂载宿主目录;「容器」是手段,不是结论。
  2. 「rootfs 只读就够了」——只读挡住写,不挡住读,也不挡住把读到的内容发出去。文件边界与网络边界必须同时存在。
  3. 「有超时就能停」——subprocess.run(timeout=...) 默认只杀直接子进程;孙进程可能留在原进程组里继续跑,也可能 setsid 逃到新会话。
  4. 「配了 HTTP 代理就是网络隔离」——代理是应用层约定,raw socket、DNS 查询、ICMP、非 HTTP 协议都可以绕开它;代理自己还可能被 SSRF 利用。
  5. 「rlimit 能限制总资源」——rlimit 是每进程的;100 个进程各自 256 MiB,合计仍是 25 GiB。聚合限制要靠 cgroup 或 PID 命名空间。

把四层画成一张机制图。注意箭头不是「依次执行」,而是「同一动作同时受四层约束」;任何一层缺失,动作就从那个缺口出去。

                    ┌──────────────────────────────────────────┐
                    │        agent 的一次 tool call             │
                    │   run("python3 main.py") / read(path)     │
                    └───────────────────┬──────────────────────┘
                                        │
          ┌──────────────┬──────────────┼──────────────┬──────────────┐
          ▼              ▼              ▼              ▼
   ┌────────────┐ ┌────────────┐ ┌────────────┐ ┌────────────┐
   │  进程边界   │ │  文件边界   │ │  网络边界   │ │  资源边界   │
   │ fork/exec  │ │ rootfs     │ │ netns      │ │ rlimit     │
   │ pgid/setsid│ │ mount ns   │ │ veth       │ │ cgroup v2  │
   │ wait/pidfd │ │ openat2    │ │ DNS/egress │ │ wall clock │
   └─────┬──────┘ └─────┬──────┘ └─────┬──────┘ └─────┬──────┘
         │              │              │              │
         ▼              ▼              ▼              ▼
   超时只杀父进程    读到宿主密钥     把数据 POST 出去   fork 炸弹/磁盘满
   孤儿继续写文件    符号链接逃逸     打 169.254.169.254  cgroup OOM

读图:四个缺口对应四类事故,而不是四种「加固等级」。一个系统可以文件只读、网络全通,此时它挡的是「改坏文件」,不是「泄露文件」;也可以网络默认拒绝、文件全通,此时它挡的是「外发」,不是「删库」。评估一个沙箱,先问「它声称挡哪一类」,再问「这一类的最小复现能不能穿过去」。

层主要机制挡什么挡不住什么
进程进程组/会话、PID ns、pidfd、subreaper超时后残留、PID 复用误杀已 setsid 的孙进程(除非 cgroup/PID ns)
文件rootfs、mount ns、openat2、O_NOFOLLOW路径穿越、符号链接竞态、写系统文件已打开 fd 的读写、内核漏洞
网络netns、veth、默认拒绝、DNS 固定任意 egress、元数据访问、DNS 外带代理自身被滥用、共享内核侧信道
资源rlimit、cgroup v2、墙钟、磁盘配额fork 炸弹、OOM、写满磁盘、长跑共享内核的 CPU 争抢、宿主级 I/O 抖动

这张表说明什么:每一层都有明确的「挡不住」。工程上不是追求「全部挡住」,而是把挡不住的部分放到下一层(例如进程组挡不住 setsid,就加 cgroup kill;openat2 挡不住已打开 fd,就在 exec 前关闭 fd)。下一节从进程层开始。

3.2 进程边界:fork/exec/wait、进程组与退出状态

进程边界要回答两个问题:谁能被创建,以及谁能被杀死。第一个问题靠 PID 命名空间、UID、pids.max;第二个问题靠进程组、会话、pidfd 与 cgroup。很多 runner 只做了「创建」,没做「杀死」,于是超时之后留下一个继续写文件的孤儿。

先看一次工具调用的真实路径。subprocess.Popen 在 POSIX 上通常走 fork(或 posix_spawn)+ execve:子进程先复制父进程的地址空间与 fd 表,再把自己替换成目标程序。waitpid 负责回收已退出的子进程;不回收就留下僵尸(Z),僵尸不占 CPU,但占 PID 表项。

import os
import subprocess
import sys

proc = subprocess.Popen(
    [sys.executable, "main.py"],
    cwd="/tmp/agent-7f/workspace",
    stdin=subprocess.DEVNULL,
    stdout=subprocess.PIPE,
    stderr=subprocess.PIPE,
    start_new_session=True,   # 子进程调用 setsid(),成为新会话/新进程组的首领
    close_fds=True,
)

start_new_session=True 是关键行。它让子进程成为新会话的首领,进程组 ID 等于子进程 PID;之后父进程可以用 os.killpg(proc.pid, SIGKILL) 一次杀掉整组。没有这一行,子进程继承 runner 的进程组,killpg 会误伤 runner 自己,而只 kill(proc.pid) 又杀不到孙进程。

3.2.1 进程组、会话与「谁能被杀死」

进程组(process group)是一组可以一起接收信号的进程;会话(session)是一组进程组的集合,通常与一个控制终端关联。setpgid(0, 0) 把当前进程放进以自己为组长的新组;setsid() 创建新会话并成为组长,前提是调用者不是组长。shell 的作业控制、kill %1、kill(-pgid, sig) 都建立在这两个概念上。

操作作用典型用途失败长什么样
setpgid(0, 0)当前进程另起一组子进程 exec 前自建组EPERM:会话首领,或跨会话
setsid()新会话 + 新组daemon、start_new_sessionEPERM:调用者已是组长
kill(-pgid, sig)发给整组超时收尸ESRCH:组不存在;EPERM:不同 UID
kill(pid, sig)只发一个进程精确终止杀不到已 setsid 的孙进程

这张表说明什么:kill(-pgid) 的覆盖范围是「仍在组里的进程」,不是「整棵进程树」。一个孙进程只要调用 setsid(),就脱离了原组;此时 kill(-pgid) 返回成功(组还在),但那个孙进程毫发无伤。要覆盖它,需要 cgroup cgroup.kill、PID 命名空间(杀 PID 1)、或 subreaper + 遍历 /proc。

runner (pid 400, pgid 400)
  │
  ├── python main.py (pid 401, pgid 400)      ← start_new_session 后 pgid=401
  │     ├── sh -c "pytest" (pid 402, pgid 401)
  │     │     └── pytest (pid 403, pgid 401)
  │     └── server (pid 404, pgid 404)        ← 自己调了 setsid,逃出 401
  │
  └── 另一个 tool (pid 405, pgid 405)

超时处理:
  kill(401)        → 只死 python,402/403/404 继续跑
  kill(-401)       → 死 401/402/403,404 继续跑
  cgroup.kill      → 死 cgroup 内全部,包括 404

读图:三种杀法的覆盖范围逐层扩大,但只有第三种能处理「自己 setsid 的孙进程」。这不是说每次都要上 cgroup:如果工具只跑一个短命令,kill(-pgid) 足够;如果工具会启动服务、包管理器、测试框架,就必须假设它会创建新会话。判断标准不是「我们的代码不会这么做」,而是「被注入的代码会不会这么做」。

定理 3.2.1 · 单 PID 终止的覆盖上界

设超时处理只向根进程 pp 发送信号,DD 是 pp 的后代集合。若存在 d∈Dd \in D 满足 pgid⁡(d)≠pgid⁡(p)\operatorname{pgid}(d) \ne \operatorname{pgid}(p),则 dd 不会收到该信号;即使改为向 pgid⁡(p)\operatorname{pgid}(p) 发送,任何已调用 setsid() 的 dd 仍不在覆盖范围内。

因此,单 PID 或单进程组的终止,不能给出「进程树已全部停止」的保证;该保证只能来自 cgroup、PID 命名空间,或「subreaper + 显式遍历并确认」。

证明

信号通过 kill(pid, sig) 只投递给指定 PID;通过 kill(-pgid, sig) 只投递给进程组 ID 等于 pgid 的成员。若 dd 在 setsid() 后拥有新的会话与进程组,则 pgid⁡(d)≠pgid⁡(p)\operatorname{pgid}(d) \ne \operatorname{pgid}(p),两种调用都不会命中 dd。cgroup 的 cgroup.kill 按 cgroup 成员投递,PID 命名空间的 init 退出会向命名空间内所有进程发信号,二者都不依赖进程组关系。∎

∎

3.2.2 wait status:0x2a00 不是退出码 42 的「低字节」

这是本章最容易写错、也最容易在代码里埋 bug 的地方。waitpid 返回的 status 是内核编码,不是退出码。编码规则是:

  • 低 7 位(bit 0–6)是终止信号;若进程正常退出,这 7 位为 0;
  • 第 8 位(bit 7,掩码 0x80)是 core dump 标志;
  • 高字节(bit 8–15)才是退出码,且只在 WIFEXITED(status) 为真时有效。

所以 status = 0x2a00 的解读是:

0x2a00 = 0010 1010 0000 0000
         └─ 高字节 0x2a = 42
                    └─ 低字节 0x00:低 7 位为 0,表示「正常退出」,不是退出码 0

WIFEXITED(0x2a00) 为真;若 status = 0x2a00,则 WEXITSTATUS(status) = 0x2a = 42(即 (status >> 8) & 0xff)。如果代码写 status & 0xff,得到的是 0x00,会把一次失败当成成功。低字节 0x00 只表示「正常退出」,不是退出码 0;它只说明低 7 位终止信号为 0。

import os

pid, status = os.waitpid(child_pid, 0)
if os.WIFEXITED(status):
    code = os.WEXITSTATUS(status)          # 正确:0x2a00 -> 42
elif os.WIFSIGNALED(status):
    sig = os.WTERMSIG(status)              # 信号编号,不含 core 位
    core = os.WCOREDUMP(status)            # 第 8 位
else:
    code = None                            # stopped/continued,按场景处理

把四种常见 status 列成表。注意 0x0100 与 0x2a00 的低字节都是 0x00,但退出码分别是 1 和 42;只看低字节无法区分。

status二进制低 16 位WIFEXITEDWEXITSTATUSWIFSIGNALEDWTERMSIG含义
0x00000000 0000 0000 0000true0false—正常退出,码 0
0x01000000 0001 0000 0000true1false—正常退出,码 1
0x2a000010 1010 0000 0000true42false—正常退出,码 42
0x000f0000 0000 0000 1111false—true15被 SIGTERM 终止
0x008b0000 0000 1000 1011false—true11SIGSEGV,且 core dump

这张表说明什么:判断「成功/失败」不能看低字节,也不能看 status == 0;必须先 WIFEXITED,再 WEXITSTATUS。被信号终止时没有退出码,只有信号编号;把 WTERMSIG 当退出码,会把 SIGKILL 记成 9,和真正的 exit(9) 混在一起。

下面是在 macOS 上实际跑出的输出(os.fork + os.waitpid,与 Linux 的编码一致):

python3 - <<'PY'
import os, signal

def show(label, status):
    print(f"{label}: status={status:#06x} WIFEXITED={os.WIFEXITED(status)} "
          f"WEXITSTATUS={os.WEXITSTATUS(status) if os.WIFEXITED(status) else '-'} "
          f"WIFSIGNALED={os.WIFSIGNALED(status)} "
          f"WTERMSIG={os.WTERMSIG(status) if os.WIFSIGNALED(status) else '-'}")

pid = os.fork()
if pid == 0:
    os._exit(42)
_, status = os.waitpid(pid, 0)
show("exit42", status)

pid = os.fork()
if pid == 0:
    os.kill(os.getpid(), signal.SIGTERM)
    os._exit(1)
_, status = os.waitpid(pid, 0)
show("sigterm", status)

pid = os.fork()
if pid == 0:
    os.abort()
_, status = os.waitpid(pid, 0)
show("abort", status)
PY
exit42: status=0x2a00 WIFEXITED=True WEXITSTATUS=42 WIFSIGNALED=False WTERMSIG=-
sigterm: status=0x000f WIFEXITED=False WEXITSTATUS=- WIFSIGNALED=True WTERMSIG=15
abort: status=0x0006 WIFEXITED=False WEXITSTATUS=- WIFSIGNALED=True WTERMSIG=6

第三行 abort 在这台机器上得到 0x0006,没有 core 位;core 是否产生取决于 RLIMIT_CORE 与系统配置,不能假设 SIGSEGV 一定带 0x80。判断 core 要用 WCOREDUMP(status),不要手写 status & 0x80——后者在「正常退出且退出码高位为 1」时会误判。

subprocess 已经把 waitpid 的编码解码成 returncode:正常退出是非负数,信号终止是负数(如 -9)。所以用 subprocess 时不要再次 status & 0xff;只有直接调用 os.waitpid、os.waitid 或 C 的 wait 时,才需要自己解码。

3.2.3 /proc/PID/stat 的 exit_code 也不能直接当低 8 位

Linux 的 /proc/<pid>/stat 有一个 exit_code 字段。proc_pid_stat(5) 把它列在第 52 个字段(since Linux 3.5),并明确说明:它是「the thread’s exit status in the form reported by waitpid(2)」。也就是说,它同样是 wait 编码,不是「低 8 位退出码」。awk '{print $52}' /proc/<pid>/stat 能取到字段,但不应该作为契约:

  • 字段位置不是稳定 ABI,内核版本与文档可能有差异;用固定 $52 的脚本会在升级后静默读错列。
  • 该字段标记为 [PT],受 ptrace 访问检查影响;权限不足时可能显示 0,不能据此判断「正常退出」。
  • 即使读到了值,也要按 WIFEXITED / WEXITSTATUS 解码,不能 & 0xff。

优先用 waitid 或 waitpid。waitid 的 siginfo_t.si_status 已经是「退出码或信号编号」本身,不再编码:si_code == CLD_EXITED 时它是退出码,CLD_KILLED / CLD_DUMPED 时它是信号编号。这比解析 /proc 更可靠,也不依赖字段位置。

import os

pid = os.fork()
if pid == 0:
    os._exit(42)

info = os.waitid(os.P_PID, pid, os.WEXITED | os.WNOWAIT)
print(info.si_code, info.si_status)   # CLD_EXITED, 42
os.waitpid(pid, 0)                    # WNOWAIT 只观察,仍需回收
si_code 1 CLD_EXITED 1 si_status 42

这段输出说明 waitid 的 si_status 是「42」,不是 0x2a00。信号终止时 si_code 为 CLD_KILLED,si_status 是信号编号(如 15),不是编码后的 status;WNOWAIT 让父进程先观察再回收,适合「先记录退出原因,再清理」的 runner,不回收仍是僵尸。

3.2.4 孤儿、僵尸与 subreaper

父进程先死、子进程还活着,子进程成为孤儿,被 reparent 到 init(PID 1)或最近的 subreaper。孤儿不是错误状态,它会继续运行、继续写文件、继续占资源。僵尸是另一种状态:子进程已死,父进程还没 wait,留下一个 Z 表项;僵尸不执行代码,但占 PID。

状态ps 的 STAT谁还活着怎么清理风险
运行/睡眠R/S进程本身正常 wait 或 kill超时后继续跑
僵尸Z只剩表项父进程 waitpid占 PID;大量僵尸拖垮 PID 表
孤儿S,PPID=1进程本身找到并 kill;或 cgroup.kill继续写文件、继续联网
孤儿进程组S,PGID 不变整组kill(-pgid)只杀父进程时整组存活

这张表说明什么:ps 里的 PPID=1 是孤儿的最直接证据;STAT=Z 是僵尸的最直接证据。两者经常同时出现:父进程被 kill 后,孙进程变孤儿;孙进程退出后,如果 init 没及时回收,会短暂变僵尸。

Linux 可以用 prctl(PR_SET_CHILD_SUBREAPER, 1) 把当前进程声明为 subreaper:它的后代成为孤儿时,优先 reparent 给它,而不是 init。这样 runner 可以 waitpid(-1, ...) 逐个回收,避免「孤儿被 init 收养后失联」。PR_SET_PDEATHSIG 只能在「父线程死亡」时给子进程发一个信号,不解决孙进程,也有父进程在 prctl 之前死亡的竞态,不能当进程树管理方案。

关于 pidfd 的版本

Linux 5.3 引入 pidfd_open(2),5.1 引入 pidfd_send_signal(2),5.4 起 waitid(2) 支持 P_PIDFD。pidfd 是一个指向「某个进程」的 fd,不受 PID 复用影响:poll 它可等待退出,pidfd_send_signal 可定向发信号。它解决的是「PID 被回收后误杀新进程」的竞态,不解决「孙进程逃出进程组」;两者要配合 cgroup 使用。macOS 没有 pidfd,对应能力要用 kqueue 的 EVFILT_PROC 或 proc_pidinfo,可移植性差。

例题 3.2.1 · 一次「退出码 0」的假成功

某 runner 用 os.waitpid 收子进程,判断逻辑是:

_, status = os.waitpid(pid, 0)
ok = (status & 0xff) == 0

一个测试进程执行 sys.exit(42),status 是 0x2a00,status & 0xff 是 0x00,runner 记成成功。agent 看到「测试通过」,继续合并代码。修复分三步:

  1. 用 WIFEXITED + WEXITSTATUS 解码;信号终止单独记 signal;
  2. 在测试里固定加入 sys.exit(42) 用例,断言 exit_code == 42 且 ok is False;
  3. 如果用了 subprocess,直接读 returncode,不要再碰原始 status。

修复后,同一个用例返回 {"ok": false, "exit_code": 42, "signal": null}。这不是「模型更聪明」,而是 runner 终于把内核编码读对了。

3.3 文件边界:rootfs、挂载与 openat2

文件边界要回答一个比「读还是写」更具体的问题:这个路径最终解析到哪个 inode? agent 可以传 ../../etc/passwd,可以传一个指向宿主目录的符号链接,可以在检查与打开之间把链接换掉,也可以继承一个早已打开的目录 fd。只在字符串层面做 startswith("/workspace"),挡不住其中任何一种。

3.3.1 chroot 不是安全边界

chroot("/newroot") 改变进程的根目录,但不改变三样东西:当前工作目录、已打开的 fd、进程的 capability。三条经典逃逸路径:cwd 在根外(chroot 不改 cwd,相对路径仍可访问根外);已打开的目录 fd(子进程 fchdir(fd) 后相对路径落在宿主);保留 CAP_SYS_CHROOT/CAP_SYS_ADMIN(能再次 chroot、mount、mknod)。

所以 chroot 只能作为「路径命名空间」的粗粒度整形,不能单独当沙箱。真正的 rootfs 切换要在 mount namespace 里做 pivot_root,并在 exec 前关闭无关 fd、丢掉 capability(第 4 章)。

3.3.2 pivot_root 与 mount namespace

pivot_root(new_root, put_old) 把当前进程的根切换到 new_root,并把旧根挂到 put_old 下;随后 umount2(put_old, MNT_DETACH) 把旧根摘掉。它要求调用者拥有 CAP_SYS_ADMIN(通常在 user namespace 内),new_root 是一个挂载点,put_old 在 new_root 之下。

/* Linux 6.8 / Ubuntu 24.04;示意,未在本机编译 */
unshare(CLONE_NEWNS);
mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL);   /* 阻止挂载传播到宿主 */
mount("rootfs", "/newroot", NULL, MS_BIND | MS_REC, NULL);
chdir("/newroot");
pivot_root(".", ".");
umount2(".", MNT_DETACH);                            /* 摘掉旧根 */
chdir("/");

mount(NULL, "/", NULL, MS_REC | MS_PRIVATE, NULL) 这一行经常被省略。没有它,容器内的挂载可能传播回宿主 mount namespace;一个 mount --bind 就能把宿主目录映射进来。挂载传播是文件边界的一部分,不是「容器启动细节」。

pivot_root(".", ".") 是常见技巧:先把 cwd 切到新根,再把新旧根都设为 .,调用后旧根挂在当前目录上,直接 umount2(".", MNT_DETACH) 即可。它比「先建 put_old 目录再挂」少一次路径操作,也少一个可被替换的目录项。

3.3.3 bind mount、overlayfs 与只读 rootfs

一个 agent 的文件布局通常由三层组成:

路径类型典型选项作用
/只读镜像(ext4/erofs)ro运行时、库、工具链
/workspacebind mount 或独立卷rw,nosuid,nodevagent 的代码与产物
/tmptmpfsrw,nosuid,nodev,noexec,size=64m临时文件,重启即清

这张表说明什么:只读 rootfs 只保护「系统文件不被改」,不保护「工作区不被写满」,也不保护「临时目录不成为逃逸通道」。/tmp 用 tmpfs 并设 size=,既限制容量,又避免落到宿主磁盘;noexec 防止把可执行文件写进 /tmp 再运行,但如果测试需要执行工作区里的二进制,就不能对 /workspace 一律 noexec。

bind mount 把宿主的一个目录映射进容器。它很方便,也很危险:如果 bind 的源是 /var/run/docker.sock、/proc、/sys 或宿主 home,容器内的进程就获得了对应能力。实践中的规则是「只 bind 工作区,且源目录由 runner 独占创建」;不要 bind 宿主的 /tmp,因为宿主 /tmp 里可能有其他进程的 socket 与临时凭证。

overlayfs 用「只读下层 + 可写上层的合并视图」实现「镜像不变、容器可写」:

# Linux 6.8 / Ubuntu 24.04;需要 CAP_SYS_ADMIN(可在 user namespace 内)
mount -t overlay overlay \
  -o lowerdir=/images/base,upperdir=/run/agent/upper,workdir=/run/agent/work \
  /run/agent/merged

upperdir 与 workdir 必须在同一个文件系统上;upperdir 建议放在 tmpfs 或独立卷上,避免写满宿主根分区。overlayfs 的 whiteout 机制会让「删除下层文件」变成「在上层写一个白字符设备」,因此上层不是普通的「差异目录」,不能随便让 agent 直接操作。对 agent 来说,更简单的做法是「只读镜像 + 可写 workspace」,而不是把整个 / 做成 overlay。

定义 3.3.1 · 路径包含

给定根目录 rr 与路径 pp,称 pp 在解析后包含于 rr,当且仅当:

  1. pp 的每一个分量(包括符号链接展开后的分量)都不使当前解析位置离开 rr;
  2. .. 在 rr 处不再上升(等价于把 rr 当作 /);
  3. 不解析 magic link(如 /proc/self/fd/*、/proc/self/exe),除非显式允许;
  4. 不跨越未授权的挂载点。

只在字符串上做前缀判断,不满足以上任何一条。

3.3.4 openat2:把「检查」变成「原子解析」

openat2(2) 是 Linux 5.6 引入的扩展,核心是 struct open_how 里的 resolve 位掩码。它把「路径解析策略」交给内核,在一次系统调用里完成,避免用户态「先 realpath 再 open」的 TOCTOU 竞态。

标志作用典型错误适用场景
RESOLVE_BENEATH拒绝任何离开 dirfd 子树的解析,包括绝对路径与绝对符号链接EXDEV工作区内的相对路径
RESOLVE_IN_ROOT把 dirfd 当作 /,绝对路径也相对它解析EXDEV模拟 chroot 的按次解析
RESOLVE_NO_SYMLINKS所有路径分量都拒绝符号链接(隐含 NO_MAGICLINKS)ELOOP不允许链接的写入路径
RESOLVE_NO_MAGICLINKS拒绝 /proc/self/fd/* 等 magic linkELOOP任何不可信路径
RESOLVE_NO_XDEV拒绝跨越挂载点EXDEV防止 bind mount 绕过
RESOLVE_CACHED只使用已缓存的分量,否则 EAGAINEAGAIN快速路径,失败再回退

这张表说明什么:RESOLVE_BENEATH 与 RESOLVE_IN_ROOT 不是同义词。前者要求路径不能向上逃,绝对路径直接拒绝;后者把 dirfd 当根,绝对路径会被解释为「根下的路径」。如果 agent 传入 /workspace/a.txt,用 BENEATH 会失败,用 IN_ROOT 会解析成 <dirfd>/workspace/a.txt;选哪个取决于你的 API 接受相对路径还是绝对路径。

/* Linux 5.6+;glibc 无 wrapper,需要 syscall(SYS_openat2, ...) */
#include <fcntl.h>
#include <linux/openat2.h>
#include <sys/syscall.h>
#include <unistd.h>

int safe_open(int dirfd, const char *path) {
    struct open_how how = {
        .flags = O_RDONLY | O_CLOEXEC,
        .resolve = RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS | RESOLVE_NO_MAGICLINKS,
    };
    return (int)syscall(SYS_openat2, dirfd, path, &how, sizeof(how));
}

这段代码的关键不是 O_RDONLY,而是 resolve 三个标志:BENEATH 挡住 .. 与绝对路径,NO_SYMLINKS 挡住链接替换,NO_MAGICLINKS 挡住 /proc/self/fd。O_NOFOLLOW 只能管最后一个分量;如果路径中间有一个符号链接,它照样会跟过去。openat2 的 NO_SYMLINKS 管所有分量,这是它与 O_NOFOLLOW 的核心差别。

openat2 的错误码也要读对:EXDEV 表示「解析试图逃出根」,ELOOP 表示「遇到不允许的链接」,EAGAIN 表示「RESOLVE_BENEATH/IN_ROOT 在竞态下无法保证,可重试」,EINVAL 表示「标志未知或组合非法」。把 EXDEV 当成「文件不存在」会让 agent 以为路径写错,反复重试;正确做法是把它翻译成 EPATH,并终止这次工具调用。

3.3.5 路径穿越与符号链接竞态

最常见的错误实现是:

from pathlib import Path

root = Path("/tmp/agent-7f/workspace").resolve()
target = (root / user_path).resolve()
if not str(target).startswith(str(root) + "/"):
    raise PermissionError("escape")
fd = open(target, "rb")          # ← 检查与打开之间,链接可能被换掉

这段代码有四个问题:

  1. resolve() 之后再 open() 不是原子的;攻击者可以在两步之间把 target 换成指向 /etc/shadow 的符号链接。
  2. startswith(str(root) + "/") 对 root 本身、大小写不敏感文件系统、.. 归一化后的边界都要额外处理;用 Path.relative_to 更稳,但仍不是原子。
  3. 如果 user_path 是绝对路径,root / user_path 会得到绝对路径,直接跳出 root;Path 的 / 运算符对绝对右操作数不做「拼接」。
  4. 没有处理 magic link;/proc/self/fd/N 可以指向任意已打开对象。

正确的顺序是「用内核的解析器做边界」:打开 root 的 fd,用 openat2(root_fd, rel, RESOLVE_BENEATH|...);或者逐段 openat(..., O_NOFOLLOW|O_DIRECTORY),每段都校验 fstat 的 st_dev/st_ino。检查与使用必须是同一个 fd,而不是同一个字符串。

O_NOFOLLOW 只拒绝最后一段是符号链接;如果最后一段是普通文件,但它的父目录是符号链接,O_NOFOLLOW 不会拦。要逐段拒绝,必须对每个中间目录也用 O_NOFOLLOW | O_DIRECTORY,或者直接用 openat2 的 NO_SYMLINKS。

3.3.6 fd 继承与 close-on-exec

fork 复制整个 fd 表;exec 默认保留所有没有 FD_CLOEXEC 的 fd。一个被泄漏的目录 fd 可以绕过 chroot:子进程 fchdir(fd) 后,相对路径就落在宿主目录里。一个被泄漏的 socket 可以让子进程绕过网络策略:它不需要新建连接,直接写继承来的 fd。

机制作用默认注意
FD_CLOEXECexec 时关闭 fdC 默认不设(exec 后保留);Python 3.4+ 默认非继承显式 os.set_inheritable(fd, True) 才会传
O_CLOEXECopen 时原子设置 close-on-exec推荐总是加避免「open 后 fork」的竞态
close_fds=Truesubprocess 关闭 3 以外的 fdPopen 默认 True与 pass_fds 配合
close_range(2)批量关闭/标记 fdLinux 5.9+CLOSE_RANGE_CLOEXEC 只标记不关闭
pass_fds显式传递 fd默认空只传工具真正需要的 fd

这张表说明什么:close-on-exec 是「默认不传」,不是「默认传」。Python 的 subprocess 默认 close_fds=True,但父进程自己 os.open 的 fd 如果没有设 non-inheritable,仍可能被 fork 后的子进程继承;PEP 446 让 Python 3.4+ 打开的 fd 默认 non-inheritable,但 os.dup、C 扩展、第三方库可能改变这一点。runner 应该在 preexec_fn 或 close_range 里做一次兜底关闭,而不是相信每个库都守规矩。

3.4 网络边界:netns、DNS 与 egress

网络边界要回答:这个进程能把包发到哪里,能把哪些名字解析成地址? 只配一个 HTTP_PROXY 环境变量不算边界,因为环境变量只约束「愿意遵守它的 HTTP 客户端」;agent 可以 socket() 直接连 IP,可以发 DNS 查询,可以用 ICMP,可以走 IPv6。网络隔离必须落在内核的路由与过滤上。

3.4.1 netns + veth:先把「没有路由」做出来

Linux 的 network namespace 给进程一套独立的网卡、路由表、iptables/nftables 规则与 /proc/net。新建的 netns 只有 loopback,且默认是 down 的;没有 veth 就没有任何出口。这是最强的默认拒绝:不是「先放行再过滤」,而是「根本没有路」。

# Linux 6.8 / Ubuntu 24.04;需要 root 或 CAP_NET_ADMIN
ip netns add agent-7f
ip link add veth-host type veth peer name veth-agent
ip link set veth-agent netns agent-7f

ip addr add 10.7.0.1/24 dev veth-host
ip link set veth-host up

ip netns exec agent-7f ip link set lo up
ip netns exec agent-7f ip addr add 10.7.0.2/24 dev veth-agent
ip netns exec agent-7f ip link set veth-agent up
ip netns exec agent-7f ip route add default via 10.7.0.1

这段脚本建了一对 veth,把一端放进 netns,给两端配地址,并在 netns 内加默认路由。此时 agent 的包会走到宿主侧的 veth-host;宿主是否转发、是否 NAT、是否过滤,决定了它能不能出网。不要配默认路由,除非你确实要让它出网;只配到代理地址的 /32 路由,是更常见的做法。

配置能到宿主 127.0.0.1能到公网能到云元数据适用
共享宿主 netns是是是本地开发,不可用于不可信代码
新 netns,无 veth否否否纯计算,不需要网络
新 netns + veth + NAT否(除非显式路由)是是(除非过滤)需要装包,但出口不可控
新 netns + veth + 默认拒绝 + 代理 /32否仅代理否代码 agent 的常用形态

这张表说明什么:netns 本身只提供「独立的网络栈」,不自动提供「安全的出口」。第三行有 NAT,公网与元数据都通;第四行才把「能到哪」收缩到代理一个地址。代理仍然是可达的,所以代理自身的 SSRF 防护不能省。

3.4.2 默认拒绝的 egress 规则

在 netns 内用 nftables 做默认拒绝,只放行代理地址与已建立连接。规则顺序很重要:ct state established,related accept 要放在 drop 之前,否则回包也被丢。

# Linux 6.8 / Ubuntu 24.04;在 agent netns 内执行
nft add table inet agent
nft add chain inet agent out '{ type filter hook output priority 0; policy drop; }'
nft add rule inet agent out oifname "lo" accept
nft add rule inet agent out ip daddr 10.7.0.1 tcp dport 3128 accept
nft add rule inet agent out ct state established,related accept
nft add rule inet agent out ip daddr 169.254.169.254 drop
nft add rule inet agent out ip6 daddr fd00:ec2::254 drop

这段规则的意思是:默认丢弃所有出站,只允许 loopback、代理 10.7.0.1:3128、已建立连接的回包;元数据地址显式丢弃(即使前面的规则漏了,也有兜底)。inet 表同时覆盖 IPv4 与 IPv6;如果只用 ip 表,IPv6 流量会从另一条路出去。policy drop 是核心,不是最后那条 drop。

代理地址最好用独立的 bridge/veth,不要用宿主默认网关。否则 agent 可以扫 10.7.0.1 上的其他端口,找到宿主的内部服务。代理只监听一个端口,并且只接受来自 agent 网段的连接;代理进程本身以低权限运行,不能访问云元数据。

3.4.3 DNS:固定解析器,解析结果要钉住

DNS 是网络边界里最容易被忽略的一层。如果沙箱为了装包放行了 DNS(很多镜像需要),agent 就可以把数据编码进查询:<base32-secret>.attacker.example 这样的查询会到达攻击者的权威服务器。允许任意 DNS 就等于允许一条低速外带通道。

三种做法,强度递增:

  1. 固定 /etc/resolv.conf:只指向一个受控 resolver,禁止 agent 修改;resolver 只解析 allowlist 内的名字。
  2. 本地 resolver + 固定映射:在 netns 内跑一个只读的 DNS 代理,把 allowlist 名字映射到固定 IP,其他名字返回 NXDOMAIN;同时只允许到该 resolver 的 53 端口。
  3. 不做 DNS,直接连 IP:代理地址用 IP 写死,agent 只能访问代理;代理负责对外的名字解析与 allowlist。这是最强、也最容易审计的形态。

DNS rebinding 是「固定解析器」也挡不住的一类攻击:攻击者控制一个域名,第一次解析返回公网 IP,通过你的 allowlist 检查;TTL 设得很短,第二次解析返回 127.0.0.1 或 169.254.169.254,客户端重新解析后就连到了内网。防御不是「再查一次 DNS」,而是:

  • 在受控 resolver 里解析一次,把 IP 钉住,后续连接直接用 IP;
  • 连接后 getpeername() 再校验一次对端地址,拒绝 loopback、link-local、private、multicast;
  • 在 netns 层默认拒绝这些地址段,即使应用层被骗也出不去;
  • 代理不要跟随任意重定向;重定向目标也要重新做地址校验。

SSRF 的入口不止「用户给了一个 URL」。agent 读到的网页、工具返回的 JSON、MCP server 的描述,都可能包含 URL。常见目标包括:

目标地址为什么危险
云元数据(AWS/GCP/Azure/Oracle)169.254.169.254临时凭证、实例身份、用户数据
AWS IPv6 元数据fd00:ec2::254只封 IPv4 时从 IPv6 绕过
阿里云元数据100.100.100.200类似凭证泄露
宿主本地服务127.0.0.1、[::1]代理管理口、数据库、Docker socket
私网10/8、172.16/12、192.168/16、fc00::/7内网横向移动
链路本地169.254/16、fe80::/10元数据、邻居发现

这张表说明什么:只封 169.254.169.254 不够。IPv6 有独立的元数据地址,私网有大量等价目标,URL 还可以用十进制/八进制/十六进制 IP、IPv4-mapped IPv6(::ffff:169.254.169.254)、DNS rebinding、HTTP 重定向来绕过字符串匹配。正确做法是「解析成 IP 后按地址段拒绝」,而不是「URL 里不包含某个字符串」。

3.4.4 为什么 HTTP 代理不能单独当隔离

代理是应用层组件,它只能约束「愿意把请求交给它」的客户端。下面每一条都能绕过只配了 HTTP_PROXY 的沙箱:

  • raw socket:socket.connect(("169.254.169.254", 80)) 不读 HTTP_PROXY;
  • DNS:getaddrinfo 走系统 resolver,查询本身就能外带数据;
  • 非 HTTP 协议:ICMP、gRPC、Redis、Postgres、WebSocket 都可以直接连;
  • 代理的 CONNECT:如果代理允许任意 CONNECT,HTTPS 只是把目标藏在隧道里;
  • 代理自身:代理运行在宿主网络里,可能能访问元数据;它被 SSRF 利用时,沙箱的 netns 也救不了;
  • IPv6:只给代理配了 IPv4 规则,IPv6 可能仍然可路由。

所以网络边界要「两层一起用」:netns + 默认拒绝把「能到达的地址」收缩到代理;代理再做「允许哪些域名/路径/方法」的应用层策略。前者是内核强制,后者是业务策略;只有前者,代理被绕过;只有后者,raw socket 不受约束。

网络边界是路由表,不是字符串过滤器

「禁止访问 169.254.169.254」是一条规则;「这个 netns 没有到 169.254.169.254 的路由」是一个事实。规则可以被编码绕过,事实不能。把边界做进路由、netns、nftables,应用层的 URL 检查只是第二道;反过来,只做 URL 检查,就是在字符串上建沙箱。

关于网络隔离的四个误解
  1. 「设了 HTTP_PROXY 就隔离了」——只约束遵守它的 HTTP 客户端;raw socket、DNS、IPv6 都能绕。
  2. 「DNS 白名单能防外带」——白名单只限制「能查哪些名字」,不限制「查询内容」;允许的域名仍可被编码利用。要限制查询长度、频率,并记录异常。
  3. 「封了 169.254.169.254 就安全了」——还有 fd00:ec2::254、100.100.100.200、127.0.0.1、私网、DNS rebinding 与重定向。
  4. 「localhost 一定安全」——共享 netns 时,agent 的 127.0.0.1 就是宿主的 127.0.0.1;代理管理口、数据库、Docker socket 都可能在那里。

3.5 资源边界:rlimit、cgroup v2 与墙钟

资源边界要回答:一次失控最多能烧掉多少? 这里的「资源」至少包括 CPU 时间、地址空间、fd 数、进程数、单文件大小、磁盘总量、墙钟。它们由三套不同的机制管理:rlimit 管每进程,cgroup v2 管聚合,墙钟由外部计时器管。混用这三套机制,会出现「设了限制但没生效」的假象。

定义 3.5.1 · 资源预算

一次执行的资源预算是七元组

BR=(Bcpu, Bas, Bfd, Bpids, Bfsize, Bdisk, Bwall) B_R = (B_{\text{cpu}},\ B_{\text{as}},\ B_{\text{fd}},\ B_{\text{pids}},\ B_{\text{fsize}},\ B_{\text{disk}},\ B_{\text{wall}})

每个分量是有限上界,并标明作用域:cpu、as、fd、fsize 通常是每进程;pids、disk 与内存总量必须是聚合;wall 由外部计时器执行。预算的执行者必须独立于被限制的进程:子进程不能自己延长自己的墙钟,也不能自己提高硬限。

3.5.1 rlimit:软限、硬限与「每进程」

getrlimit 返回 (soft, hard):soft 是当前生效的限制,hard 是 soft 的上界。非特权进程可以降低 soft 或 hard,可以把 soft 提高到不超过 hard,但不能提高 hard;提高 hard 需要 CAP_SYS_RESOURCE。setrlimit 在越界时返回 EPERM 或 EINVAL。

限制限制什么触顶表现聚合?备注
RLIMIT_CPU累计 CPU 秒数软限 SIGXCPU,硬限 SIGKILL否睡眠不消耗 CPU
RLIMIT_AS虚拟地址空间字节数mmap/brk 失败,ENOMEM否macOS 不可靠
RLIMIT_NOFILE每进程 fd 数open 返回 EMFILE否不是系统总 fd 数
RLIMIT_NPROC每个真实 UID 的进程/线程数fork 返回 EAGAIN按 UID共享账号会互相影响
RLIMIT_FSIZE单个文件的最大字节数EFBIG 或 SIGXFSZ否不限制文件总数/总磁盘
RLIMIT_COREcore dump 大小超过部分被截断否设 0 可避免写盘

这张表说明什么:六个限制里没有一个能单独给出「总资源上限」。RLIMIT_NPROC 按 UID 计数,在共享账号上会误伤;RLIMIT_FSIZE 只管单文件,agent 可以写一万个 1 MiB 文件;RLIMIT_AS 管虚拟地址空间,不管 RSS,也不管 page cache。真正的聚合限制要靠 cgroup。

在 Python 里设置 rlimit,要放在 preexec_fn 里,让它在 fork 之后、exec 之前生效:

import resource

def apply_limits(cpu_s: int, fsize: int, nofile: int) -> None:
    resource.setrlimit(resource.RLIMIT_CORE, (0, 0))
    resource.setrlimit(resource.RLIMIT_CPU, (cpu_s, cpu_s + 1))
    resource.setrlimit(resource.RLIMIT_FSIZE, (fsize, fsize))
    resource.setrlimit(resource.RLIMIT_NOFILE, (nofile, nofile))
    # macOS 上 RLIMIT_AS 可能直接 ValueError;Linux 上才设
    # resource.setrlimit(resource.RLIMIT_AS, (as_bytes, as_bytes))

RLIMIT_CPU 的软限设为 cpu_s、硬限设为 cpu_s + 1:软限到时先发 SIGXCPU,进程可以捕获并做清理;如果它继续跑,硬限到时 SIGKILL。只设软限不设硬限,进程可以忽略 SIGXCPU 继续烧 CPU;只设硬限不设软限,进程没有清理机会。

实际跑一次,观察 RLIMIT_NOFILE 与 RLIMIT_CPU 的效果:

python3 experiments/ch03/sandbox_runner.py \
  --code 'import resource; print(resource.getrlimit(resource.RLIMIT_NOFILE))' \
  --nofile 32
{"ok": true, "exit_code": 0, "signal": null, "timed_out": false,
 "stdout": "(32, 32)\n", "stderr": ""}
python3 experiments/ch03/sandbox_runner.py \
  --code 'while True: pass' --cpu 1 --timeout 5
{"ok": false, "exit_code": null, "signal": 24, "signal_name": "SIGXCPU",
 "timed_out": false, "duration_ms": 1013}

第二行说明 RLIMIT_CPU 到点时进程收到 SIGXCPU(24),不是 SIGKILL;它在约 1 秒后死掉,早于 5 秒墙钟。如果进程捕获并忽略 SIGXCPU,硬限 2 秒会补一个 SIGKILL。这也是为什么 RLIMIT_CPU 不能替代墙钟:一个 sleep(3600) 的进程 CPU 时间为 0,永远不会触发 RLIMIT_CPU。

ulimit 是 shell 对 rlimit 的封装,但它的默认行为经常被记错。bash 5.3 的 help ulimit 与 man bash 都写明:不带 -S/-H 时,同时设置软限和硬限。下面是在本机实际跑出的序列:

bash -c '
printf "start soft=%s hard=%s\n" "$(ulimit -Sn)" "$(ulimit -Hn)"
ulimit -Sn 4096
printf "after -S 4096: soft=%s hard=%s\n" "$(ulimit -Sn)" "$(ulimit -Hn)"
ulimit -Hn 8192
printf "after -H 8192: soft=%s hard=%s\n" "$(ulimit -Sn)" "$(ulimit -Hn)"
ulimit -Hn 4096
ulimit -Hn unlimited
'
start soft=1048575 hard=unlimited
after -S 4096: soft=4096 hard=unlimited
after -H 8192: soft=4096 hard=8192
bash: line 8: ulimit: open files: cannot modify limit: Operation not permitted

这段输出说明三件事:-S 只改软限,-H 只改硬限;硬限不能低于软限,所以 -Hn 4096 与软限相等时可以,低于 4096 会 EINVAL;非特权进程不能把硬限从 8192 抬回 unlimited,报 Operation not permitted。如果写成 ulimit -n 512,硬限也会被一起改成 512:

bash -c 'printf "before soft=%s hard=%s\n" "$(ulimit -Sn)" "$(ulimit -Hn)"; ulimit -n 512; printf "after soft=%s hard=%s\n" "$(ulimit -Sn)" "$(ulimit -Hn)"'
before soft=1048575 hard=unlimited
after soft=512 hard=512

所以「ulimit 默认只改软限」对 POSIX shell 与 zsh 成立,对 bash 不成立:bash 不带 -S/-H 时同时改两者。要可移植地只改软限,写 ulimit -S;要只改硬限,写 ulimit -H;软限不得超过硬限,跨 shell 的脚本不要依赖默认值。

3.5.2 rlimit 是每进程,不是聚合

定理 3.5.1 · rlimit 的聚合上界

设每个进程的地址空间限制为 LL,同时存活的进程数为 NN,且没有 cgroup memory 限制。则所有进程的虚拟地址空间之和至多为 N⋅LN \cdot L。若 NN 只受 PID 上限 Nmax⁡N_{\max} 约束,则总上界为 Nmax⁡⋅LN_{\max} \cdot L;当 Nmax⁡N_{\max} 很大时,这个上界远大于宿主内存,不能防止 OOM。

对 RLIMIT_NOFILE、RLIMIT_FSIZE 同理:它们分别给出「每进程 fd 数」与「每文件字节数」,不给出「系统总 fd 数」与「总磁盘占用」。

证明

每个进程独立持有自己的地址空间,RLIMIT_AS 只约束该进程的 mmap/brk 总量,进程之间不共享这个计数器。因此总用量是各进程用量之和,最多为 N⋅LN \cdot L。进程数 NN 由 pids.max 或 PID 上限约束;若没有这个约束,NN 可以增长到内核的 PID 上限,乘积随 NN 线性增长。要得到与 NN 无关的上界,必须在聚合层(cgroup memory.max)计数,而不是在每进程层计数。∎

∎

这就是为什么「给每个子进程设 256 MiB」不等于「这个 agent 最多用 256 MiB」。一个 fork 炸弹不需要每个进程都吃满 256 MiB;它只需要制造足够多的进程,让页表、内核栈、调度器一起把宿主拖慢。聚合限制要放在 cgroup 的 memory.max 与 pids.max 上。

3.5.3 cgroup v2:memory.max、pids.max、cpu.max

cgroup v2 是统一的层级:一个进程属于一个 cgroup,资源限制写在 cgroup 目录的文件里。对 agent 沙箱,最常用的是四个文件:

文件写入示例作用触顶表现
memory.max268435456内存硬限(含 page cache)OOM killer;memory.events 的 oom_kill 增加
pids.max64cgroup 内任务数(含线程)fork 返回 EAGAIN;pids.events 的 max 增加
cpu.max50000 100000每 100 ms 最多 50 ms CPUCPU 被限流;cpu.stat 的 nr_throttled 增加
memory.oom.group1OOM 时杀掉整个 cgroup不会只杀一个进程留下半死状态

这张表说明什么:memory.max 与 pids.max 是聚合限制,正好补上 rlimit 的缺口;cpu.max 是带宽限制,不是「CPU 时间配额」,它按周期限流,进程不会被杀,只会被 throttled。memory.oom.group=1 很重要:默认 OOM 只杀一个进程,agent 可能留下一个「父进程死了、子进程还在写」的半死状态;设 1 让整个 cgroup 一起死。

# Linux 6.8 / Ubuntu 24.04;需要 cgroup v2 且已挂载 /sys/fs/cgroup
CG=/sys/fs/cgroup/agent-7f
mkdir -p "$CG"
echo 268435456 > "$CG/memory.max"
echo 64 > "$CG/pids.max"
echo "50000 100000" > "$CG/cpu.max"
echo 1 > "$CG/memory.oom.group"
echo $$ > "$CG/cgroup.procs"

这段脚本把当前 shell 放进 cgroup,并设 256 MiB 内存、64 个任务、0.5 CPU。cpu.max 的两个数字是「配额 周期」,单位微秒;50000 100000 表示每 100 ms 最多跑 50 ms,即 0.5 个 CPU。pids.max 统计的是任务(线程也算),不是进程;一个多线程 Python 进程可能一个进程就占十几个任务。

观测 cgroup 的状态。下面为便于阅读,把文件名标在值前;cat 本身只输出值,不输出这些标签:

cat "$CG/memory.current" "$CG/memory.max"
cat "$CG/memory.events"
cat "$CG/pids.current" "$CG/pids.max" "$CG/pids.events"
cat "$CG/cgroup.events"
cat "$CG/cpu.stat"
memory.current=184549376
memory.max=268435456
max 3
oom 1
oom_kill 1
oom_group_kill 1
pids.current=7
pids.max=64
max 0
populated 1
frozen 0
nr_periods 21
nr_throttled 4
throttled_usec 82031

这段输出是 Linux-only 的形态(本机 macOS 没有 cgroup v2);数值会随负载变化,字段名是稳定的。memory.events 的 max 3 表示「三次接近上限」,oom 1/oom_kill 1 表示真的发生了 OOM;pids.events 的 max 0 表示没有触发进程数上限;cpu.stat 的 nr_throttled 4 表示被限流四次。排障时先看事件计数,再看 current,否则只能看到「现在没事」,看不到「刚刚差点出事」。

cgroup.kill 是收尸的最后一招:向 cgroup.kill 写 1,内核会杀掉 cgroup 及其子 cgroup 的所有进程,且能处理并发 fork 与进程迁移。它比 kill(-pgid) 更强,因为不依赖进程组关系;比遍历 /proc 更可靠,因为没有「遍历时又 fork 一个」的竞态。代价是需要 cgroup v2 与写权限。

echo 1 > "$CG/cgroup.kill"

如果需要「先冻结、再检查、再决定」,用 cgroup.freeze:写 1 后进程被停止,cgroup.events 的 frozen 变为 1;检查完再解冻或 kill。冻结不是终止,冻结的进程仍然占着内存与 PID;它适合「取现场」而不是「省资源」。

3.5.4 墙钟与 CPU 时间不是一回事

维度计什么谁触发能停什么停不下什么
CPU 时间进程真正占用 CPU 的时间RLIMIT_CPU、cpu.max忙循环、死算sleep、阻塞 I/O、等锁
墙钟从开始到现在的真实时间外部 timer、timeout、runner任何长时间不返回的调用已经逃出进程组的子进程
内存RSS/页缓存memory.max内存炸弹磁盘写满、fd 泄漏
磁盘文件系统用量配额、tmpfs size写满已删除但仍被打开的文件

这张表说明什么:四类限制互补,不能互相替代。RLIMIT_CPU=2 对一个 sleep(3600) 完全无效;墙钟超时对一个 200 ms 的忙循环可能太晚;memory.max 管不住磁盘;磁盘配额管不住 fd 泄漏。一个可靠的 runner 至少要有「墙钟 + CPU + 内存/进程数 + 输出上限」四道。

墙钟超时的实现要避免两个坑:一是只在循环里检查 time.time() > deadline,当子进程阻塞在 read() 时循环根本没机会跑;二是用 signal.alarm,它只作用于当前线程,且阻塞系统调用可能被 SA_RESTART 重启。正确做法是「父进程 poll + 独立计时器 + 到点 killpg」,或者用 pidfd/cgroup.kill;时间检查必须在不会被子进程阻塞的地方执行。

3.5.5 磁盘:RLIMIT_FSIZE 之外的总量

RLIMIT_FSIZE 限制单个文件,不限制文件数量,也不限制目录总大小。agent 可以写一万个 1 MiB 文件,绕过单文件限制,把磁盘写满。总磁盘限制要靠:

  • tmpfs size=:把 /tmp 与工作区放在 tmpfs,超出 size 直接 ENOSPC,且不落宿主磁盘;
  • 文件系统配额:XFS project quota、ext4 的 quota,按目录/项目限制总量;
  • 独立卷:给每个 run 一个固定大小的卷,run 结束就删;
  • 预检 + 监控:启动前检查可用空间,运行中定期 du,超过阈值就终止;
  • 输出上限:stdout/stderr 也要限,否则一个 print 循环就能把日志盘写满。

ENOSPC 应该被当作不可重试错误:重试只会继续写,可能把宿主其他服务也拖垮。runner 要把 ENOSPC 翻译成 EDISK,保留现场(哪些文件、多大),清理临时目录后再报告失败。

关于 macOS 上的资源限制

macOS 有 rlimit,但没有 cgroup v2。RLIMIT_AS 在 macOS 上不可靠:设置可能直接抛 ValueError: current limit exceeds maximum limit,或者不按预期限制 mmap;不要把它当可移植的内存上限。RLIMIT_NPROC 按用户计数,在共享账号上设它会限制整个用户,可能让 runner 自己无法 fork。RLIMIT_CPU 与 RLIMIT_NOFILE/RLIMIT_FSIZE 可用,但强隔离仍要靠容器/虚拟机。本文的 runner 在 macOS 上跳过 RLIMIT_AS,并在结果里标明 as_enforced=false。

3.6 组合:从 rlimit 到 OCI config.json

前面五节是「每一层怎么做」。真实系统不会为每一层写一套独立的启动代码;它把这些策略交给一个容器运行时,运行时再翻译成 clone/unshare/mount/setrlimit/cgroup 写入。OCI Runtime Spec 就是这层翻译的契约:一个 bundle 里有 config.json 和 rootfs,运行时读 config.json,创建容器并执行 process.args。

agent 沙箱不必自己实现 OCI。你可以用 runc/crun/youki,也可以用云厂商的沙箱服务;但你必须理解 config.json 的每个字段对应哪一层,否则「配了容器」仍然可能留下缺口。下面是一份最小但完整的配置,字段按 1.3.0 版本写。

{
  "ociVersion": "1.3.0",
  "process": {
    "terminal": false,
    "user": {"uid": 65534, "gid": 65534},
    "args": ["python3", "/workspace/main.py"],
    "env": ["PATH=/usr/local/bin:/usr/bin:/bin", "HOME=/workspace", "LANG=C.UTF-8"],
    "cwd": "/workspace",
    "capabilities": {"bounding": [], "effective": [], "inheritable": [], "permitted": [], "ambient": []},
    "rlimits": [
      {"type": "RLIMIT_CPU", "soft": 2, "hard": 3},
      {"type": "RLIMIT_NOFILE", "soft": 64, "hard": 64},
      {"type": "RLIMIT_FSIZE", "soft": 8388608, "hard": 8388608}
    ],
    "noNewPrivileges": true
  },
  "root": {"path": "rootfs", "readonly": true},
  "mounts": [
    {"destination": "/workspace", "type": "bind", "source": "/tmp/agent-7f/workspace",
     "options": ["rbind", "rw", "nosuid", "nodev"]},
    {"destination": "/tmp", "type": "tmpfs", "source": "tmpfs",
     "options": ["nosuid", "nodev", "noexec", "size=64m"]}
  ],
  "linux": {
    "namespaces": [{"type": "pid"}, {"type": "network"}, {"type": "mount"},
                   {"type": "ipc"}, {"type": "uts"}, {"type": "user"}, {"type": "cgroup"}],
    "uidMappings": [{"containerID": 0, "hostID": 100000, "size": 65536}],
    "gidMappings": [{"containerID": 0, "hostID": 100000, "size": 65536}],
    "resources": {
      "memory": {"limit": 268435456, "swap": 0, "disableOOMKiller": false},
      "cpu": {"quota": 50000, "period": 100000},
      "pids": {"limit": 64}
    },
    "seccomp": {"defaultAction": "SCMP_ACT_ERRNO", "architectures": ["SCMP_ARCH_X86_64"]}
  },
  "hooks": {
    "createRuntime": [{"path": "/usr/bin/agent-hook", "args": ["agent-hook", "setup"], "timeout": 5}],
    "poststop": [{"path": "/usr/bin/agent-hook", "args": ["agent-hook", "cleanup"], "timeout": 5}]
  },
  "annotations": {"org.example.agent.run_id": "run-7f"}
}

这份 JSON 里没有一行是「装饰」:root.readonly 是文件边界,linux.namespaces 是进程/文件/网络边界,linux.resources 是资源边界。示例按 x86_64 写;ARM 主机把 SCMP_ARCH_X86_64 改成 SCMP_ARCH_AARCH64。seccomp 只有默认拒绝,真实配置还要按镜像生成 syscalls 白名单;空 allowlist 连 execve 都会被拒。下面逐项映射。

agent 边界OCI 字段内核机制注意
执行什么、参数、环境process.args、process.envexecveargs[0] 是可执行文件;不是 shell 字符串
工作目录process.cwdchdir必须是容器内绝对路径
以谁运行process.usersetuid/setgid低 UID;additionalGids 也要收
capabilityprocess.capabilitiescapset五个集合都空,才是「全丢」
rlimitprocess.rlimitssetrlimittype 必须是内核认识的 RLIMIT_*
不提权process.noNewPrivilegesprctl(PR_SET_NO_NEW_PRIVS)挡住 setuid/file cap 提权
系统调用过滤linux.seccompseccomp-bpfdefaultAction 应为拒绝,再放行白名单
根文件系统只读root.readonlymount/pivot_root只读根不等于工作区只读
挂载mountsmount顺序敏感;rbind 会递归
命名空间linux.namespacesclone/unshare不写就继承宿主
资源聚合linux.resourcescgroup v2memory.limit、pids.limit、cpu.quota/period
生命周期钩子hooks运行时在宿主/容器执行钩子不是沙箱边界,必须可信

这张表说明什么:OCI 字段与内核机制不是一一对应的「配置项」,而是「策略 → 机制」的翻译。process.rlimits 翻译成 setrlimit,linux.resources.memory.limit 翻译成 memory.max,root.readonly 翻译成 mount 的只读重挂载。运行时可能跳过它不支持的字段;配置写完必须用 runc spec/crun spec 生成基线,再用 jq 检查关键字段是否真的存在。

3.6.1 字段级的坑

process.args 不是 shell 命令。写 "args": ["sh", "-c", "pytest -q"] 会启动一个 shell,shell 会做词法展开、通配、变量替换;agent 传入的参数里如果含 ;、$()、反引号,就会被 shell 解释。要么直接 ["pytest", "-q"],要么把用户输入作为单独的参数传给一个不做 shell 解释的程序。这个坑与第 2 章的工具参数校验是同一个问题的两层:schema 管语法,OCI 管执行。

process.capabilities 的五个集合要一起清空。只清 effective,permitted/inheritable 还在,进程仍可能通过 capset 把能力提回来;bounding 是能力的上界,不清 bounding,后续 exec 仍可能获得文件 capability。第 4 章会展开五集合与降权顺序,这里先记住「空数组不是省略,是显式清空」。

linux.namespaces 不写 network,容器就继承宿主的网络栈;不写 pid,容器内能看到宿主进程;不写 user,容器内的 root 就是宿主 root(除非运行时另有映射)。很多人以为「容器默认隔离所有 namespace」,实际上 OCI 只创建你列出的 namespace;没列的继承运行时。把七个都列出来,再按需要去掉,不要反过来。

linux.resources.memory.limit 与 process.rlimits 的 RLIMIT_AS 不是一回事:前者是 cgroup 聚合内存,后者是每进程地址空间。memory.limit 到顶会触发 OOM killer;RLIMIT_AS 到顶让 mmap 返回 ENOMEM。两者都配,才有「单进程不超、总量也不超」的效果。

hooks 是最容易被忽视的提权面。createRuntime 在宿主 namespace 执行,createContainer 在容器 namespace 执行,poststop 在容器删除后执行;它们的 path 是宿主路径,不是容器内路径。一个可写的 hook 脚本等于一个宿主执行入口。生产环境应把 hook 路径设为只读、由 runner 独占,且不要把 agent 可写的目录放进 hook 的搜索路径。

方法 3.6.1 · 容器配置审查清单

拿到一份 config.json,按顺序检查:

  1. ociVersion 是否为运行时支持的版本;不要用「最新」当版本。
  2. process.user 是否低 UID;additionalGids 是否为空;umask 是否过宽。
  3. process.capabilities 五个集合是否都为空;有没有 CAP_SYS_ADMIN/CAP_NET_ADMIN 残留。
  4. process.noNewPrivileges 是否为 true;linux.seccomp.defaultAction 是否为拒绝。
  5. process.rlimits 是否覆盖 CPU/NOFILE/FSIZE;hard 是否大于等于 soft。
  6. root.readonly 是否为 true;mounts 里有没有宿主敏感路径(/proc、/sys、docker.sock、home)。
  7. linux.namespaces 是否包含 pid/network/mount/user;缺哪个,继承哪个。
  8. linux.resources 是否同时有 memory.limit 与 pids.limit;memory.oom.group 是否设 1。
  9. hooks 的路径是否在 agent 可写目录之外;timeout 是否为正数。
  10. annotations 是否只放元数据,没有把策略/密钥写进去。

十项里任何一项缺失,都对应前面某一层的一个缺口。审查不是「看一眼有没有 readonly」,而是把每一层都指到具体字段。

3.6.2 版本与兼容性

OCI Runtime Spec 1.3.0 于 2025-11-04 发布,延续 1.x 的向后兼容;prestart 钩子已废弃,推荐 createRuntime/createContainer/startContainer。不同运行时(runc、crun、youki)对字段的支持程度不同,尤其是 linux.resources.unified、idmap 挂载、seccomp 的 listenerPath。不要假设「写了就一定生效」;用运行时的 spec 命令生成基线,再在目标内核上做一次 strace -f 验证关键系统调用。

3.7 失败案例:fork 炸弹、磁盘写满与孤儿进程

三类事故在代码 agent 上反复出现,因为它们的触发条件都很普通:一个死循环、一次大文件写入、一个超时。下面每个案例都按「触发条件 → 观测证据 → 修复」写;能在本机复现的给出真实输出,只能在 Linux 上复现的明确标注。

3.7.1 fork 炸弹:先耗尽 PID,再拖垮调度器

shell 里的经典写法是 :(){ :|:& };::定义一个名为 : 的函数,函数体把自己通过管道喂给自己并在后台运行。每次调用产生两个新进程,进程数指数增长。Python 版本更直白:

import os

while True:
    try:
        os.fork()
    except OSError:
        break

这段代码不要在没有 pids.max 的宿主上运行。它的目的不是「算得快」,而是让 PID 表、内核栈、调度器一起过载;即使每个进程只睡一秒,宿主也会因为上下文切换而失去响应。RLIMIT_NPROC 能挡一部分,但它按真实 UID 计数:runner 和 agent 共用一个账号时,限制 agent 也会限制 runner 自己;多租户下更不可靠。

Linux 上的观测证据是 pids.current 贴近 pids.max,pids.events 的 max 持续增加,应用层看到 fork: Resource temporarily unavailable(EAGAIN)。这些命令只能在 cgroup v2 环境跑:

# Linux 6.8 / Ubuntu 24.04
cat /sys/fs/cgroup/agent-7f/pids.current
cat /sys/fs/cgroup/agent-7f/pids.max
cat /sys/fs/cgroup/agent-7f/pids.events

修复不是「让 agent 别写 fork 炸弹」,而是让它写了也炸不穿:

  • pids.max 设成一个够用的小值(例如 64),fork 到上限直接 EAGAIN;
  • memory.oom.group=1,内存到顶时整组一起死,不留下半死状态;
  • 超时后写 cgroup.kill,不依赖进程组;
  • 监控 pids.events 的 max,突增就告警并终止 run。

pids.max 统计任务数,线程也算。一个 16 线程的 Python 进程会占 17 个任务;把 pids.max 设成 8 会让正常的多线程工具直接失败。限制值要按「最大合法并发 + 余量」定,不是越小越好。

3.7.2 磁盘写满:ENOSPC 不是「再试一次」

代码 agent 很容易写满磁盘:下载依赖、生成构建产物、把测试日志追加到同一个文件、在循环里 print。如果工作区与宿主根分区共享,受害的不只是这个 run,而是整台机器。下面是在 macOS 上用 8 MiB 磁盘映像复现的真实现场:

hdiutil create -size 8m -fs HFS+ -volname TinySandbox /tmp/tiny.dmg
hdiutil attach /tmp/tiny.dmg -nobrowse -mountpoint /tmp/tinymnt
dd if=/dev/zero of=/tmp/tinymnt/fill.bin bs=1048576
echo "dd_rc=$?"
df -h /tmp/tinymnt
dd: /tmp/tinymnt/fill.bin: No space left on device
8+0 records in
7+0 records out
7340032 bytes transferred in 0.000857 secs (8564798133 bytes/sec)
dd_rc=1
/dev/disk10s1   8.0Mi   7.2Mi   788Ki    91%       3  4.3G    0%   /private/tmp/tinymnt

这段输出有三个关键点:写入在 7 MiB 处失败,文件停在 7340032 字节(不是「要么全写、要么不写」);df 显示 788 KiB 可用,但 HFS+ 的元数据与保留块让实际可用空间更小;dd 返回 1,但很多程序会忽略 write 的短写,继续报告成功。agent 如果在 write 后不检查返回值,就会把一个截断的文件当成完整产物。

修复要分三层:

  1. 隔离:工作区放在独立卷或 tmpfs 上,设 size=;不要与宿主根分区共享。
  2. 限额:RLIMIT_FSIZE 限制单文件;XFS project quota 或卷大小限制总量;输出重定向到有上限的日志文件。
  3. 语义:把 ENOSPC 翻译成不可重试的 EDISK,保留「哪些文件、写了多少、剩余空间」的证据,清理后终止;不要自动重试同一个写操作。

RLIMIT_FSIZE 只能限制单文件。agent 可以写一万个 1 MiB 文件,仍然把卷写满;所以「单文件限制 + 卷配额」要一起用。df 的可用空间也不是安全余量:ext4 默认给 root 保留 5%,HFS+/APFS 有元数据开销,容器镜像层还有写时复制。

3.7.3 孤儿进程:父进程死了,写文件的还在

这是本章最值得亲手复现的案例。experiments/ch03/orphan_demo.py 启动一个父进程,父进程再启动一个子进程;子进程睡 1.5 秒后写 late.txt。演示脚本先只 kill 父进程:

python3 experiments/ch03/orphan_demo.py
parent pid=91455 pgid=91455
sent SIGKILL to parent only
late.txt exists: True
late.txt content: 'still alive'

父进程收到 SIGKILL 后立即死亡,子进程被 reparent 到 PID 1,继续睡完 1.5 秒并写文件。此时 runner 已经认为「超时,进程已终止」,但磁盘上多了一个 late.txt。如果这个文件是构建产物、迁移脚本或上传标记,下游会基于一个「不该存在」的结果继续。

修复只改一行:向进程组发信号,而不是向父 PID 发信号。

python3 experiments/ch03/orphan_demo.py --kill-group
parent pid=91475 pgid=91475
sent SIGKILL to process group
late.txt exists: False

os.killpg(pgid, SIGKILL) 覆盖父进程与仍在同一进程组的子进程。它仍然挡不住 setsid() 的孙进程;那需要 cgroup cgroup.kill 或 PID 命名空间。修复分四层:killpg 覆盖同组;PR_SET_CHILD_SUBREAPER + waitpid(-1) 回收被 reparent 的后代;cgroup.kill 覆盖逃组后代;PID 命名空间把「杀 PID 1」变成「杀整棵树」。

例题 3.7.1 · 一次超时之后的三重残留

某代码 agent 执行 pytest,超时 60 秒。runner 用 Popen + wait(timeout=60),捕获 TimeoutExpired 后调用 proc.kill(),记录 timed_out=true,删除工作区,返回失败。三小时后监控发现:同一台机器上仍有 12 个 pytest 进程,占用 3.2 GiB 内存,磁盘上还有 4 个未清理的 .pytest_cache。

复盘证据:

$ ps -o pid,ppid,pgid,stat,etime,command -p 81234 -p 81235
  PID  PPID  PGID STAT ELAPSED COMMAND
81234     1 81234 Ss     03:12:41 /usr/bin/python3 -m pytest
81235 81234 81234 S      03:12:40 /usr/bin/python3 -m pytest

PPID=1 说明父进程早死;PGID=81234 说明它们仍在原组;ELAPSED=03:12:41 说明超时后跑了三个多小时。根因有三个:

  1. proc.kill() 只杀直接子进程,pytest 的 worker 留在组里;
  2. runner 在超时后立即 rmtree 工作区,但 worker 仍持有 cwd 与打开的文件,删除只是 unlink,空间没释放;
  3. 失败结果只写了 timed_out=true,没有记录 pgid 与子进程列表,事后无法定位。

修复:超时路径改为 os.killpg(proc.pid, SIGKILL);等待 2 秒确认 waitpid 返回;再读一次 /proc 或 ps 确认没有同组残留;最后才删除工作区。修复后同一用例的 duration_ms 从 60 秒降到 0.6 秒,残留进程为 0。

事故触发条件观测证据第一修复
fork 炸弹无限 fork / 递归管道pids.current≈pids.max;pids.events max 增长;EAGAINpids.max + cgroup.kill
磁盘写满大文件、日志循环、共享卷ENOSPC;df 可用接近 0;文件截断独立卷 + 配额 + RLIMIT_FSIZE
孤儿进程只 kill 父进程;子进程 setsidPPID=1;同 PGID;超时后仍有写入killpg → subreaper → cgroup.kill

这张表说明什么:三个事故的修复都不在模型侧,而在 runner 的收尸、限额与清理顺序。把它们写成回归用例,比在提示词里写「不要运行危险命令」有效得多。

3.8 观测:/proc、waitid、cgroup.events 与 shell 语义

沙箱出问题时,最危险的不是「进程还在跑」,而是「你以为它已经停了」。观测要回答三个问题:进程树现在是什么形状、退出原因到底是什么、资源是在哪一层触顶的。下面按「先看进程,再看退出,再看资源,最后看 shell 封装」的顺序给出可跑命令。

3.8.1 进程树:ps 的五个列

ps -o pid,ppid,pgid,stat,etime,cmd 是 Linux 上的常用组合;macOS 的 ps 不认 cmd,要用 command 或 comm。下面是在本机跑出的真实输出(父子两行,父进程用 start_new_session=True 启动):

ps -o pid,ppid,pgid,stat,etime,comm -p 92251 -p 92256
  PID  PPID  PGID STAT ELAPSED COMM
92251 92248 92251 Ss     00:00 /Users/juanwang/miniconda3/bin/python3
92256 92251 92251 S      00:00 /Users/juanwang/miniconda3/bin/python3

读法:两行的 PGID 都是 92251,说明子进程与父进程同组;STAT 的 Ss 表示「可中断睡眠 + 会话首领」,S 表示「可中断睡眠」。如果子进程的 PPID 变成 1,它已经是孤儿;如果 STAT 是 Z,它是僵尸;如果 PGID 与父进程不同,它已经 setsid 或 setpgid。这三个信号比「进程还在不在」更有信息量。

3.8.2 退出与资源:/proc、waitid、cgroup.events

Linux 上,/proc/<pid>/status 给出进程的凭证与安全状态,重点看这些字段:

字段含义危险值
StateR/S/D/Z/TZ 僵尸;D 不可中断睡眠
PPid父进程1 说明孤儿
Uid真实/有效/保存/文件 UID四个都是 0 说明 root
CapEff有效 capability 位图非空,尤其 CAP_SYS_ADMIN
NoNewPrivs是否禁止提权0 说明未设
Seccompseccomp 模式0 说明未启用

这张表说明什么:/proc/<pid>/status 是「进程现在有什么权」的直接证据,比读配置文件可靠。容器里如果 CapEff 不是 0,agent 可能通过 mount、ptrace、chroot 绕过文件边界;第 4 章会讲怎么把五集合清空并验证。

Linux 上再补四条命令:cat /proc 看权,findmnt 看挂载,ss 看连接,strace -f 看子进程实际发起的系统调用。最后一条需要 ptrace 权限,生产内核常禁用。

# Linux 6.8 / Ubuntu 24.04;本机 macOS 没有 /proc、ss、strace
PID=12345  # 换成目标子进程的 pid
cat "/proc/$PID/status" | grep -E '^(Name|State|PPid|Uid|CapEff|NoNewPrivs|Seccomp):'
findmnt -T /workspace -o TARGET,SOURCE,FSTYPE,OPTIONS
ss -ltnp
strace -f -e trace=process,openat2,connect -p "$PID"

waitid 在 3.2.3 已经给出 si_status 的真实输出:CLD_EXITED 时是 42,CLD_KILLED 时是 15,不需要再解码。排障时优先用它而不是 /proc/<pid>/stat 的 exit_code 字段:后者是 wait 编码,字段位置还可能随内核/文档变化。

cgroup v2 的 cgroup.events 与 memory.events 回答「这个 cgroup 现在有没有活进程、有没有被冻结、有没有 OOM 过」:

populated 1
frozen 0

populated 1 表示还有活进程;frozen 0 表示没被冻结。如果超时后 populated 仍是 1,说明收尸没完成;如果 frozen 1,进程被停住了但还没死,仍占内存与 PID。配合 memory.events 的 oom_kill、pids.events 的 max,可以区分「被 OOM 杀」「被进程数限制挡住」「被墙钟杀」三种终止原因。

3.8.3 shell 语义:pipefail、PIPESTATUS、ulimit、jobs

runner 经常用 shell 包一层命令,于是 shell 的语义变成了沙箱语义的一部分。四个最常写错的点,下面都给出本机实际输出。

pipefail 取最右非零,PIPESTATUS 保留每一段。 默认情况下,管道状态是最后一段的状态;set -o pipefail 后,状态是「最右边那个非零」;PIPESTATUS 数组保留每一段。

bash -c '
false | true | false
printf "no pipefail: status=%s parts=%s\n" "$?" "${PIPESTATUS[*]}"
set -o pipefail
(exit 3) | (exit 5) | true
printf "pipefail:    status=%s parts=%s\n" "$?" "${PIPESTATUS[*]}"
'
no pipefail: status=1 parts=1 0 1
pipefail:    status=5 parts=3 5 0

第二行说明:pipefail 取的是最右边的非零 5,不是最左边的 3;每段状态仍在 PIPESTATUS 里(3 5 0)。要判断「哪一段失败」,必须读 PIPESTATUS;只看 $? 会把「第三段失败」和「第一段失败」混为一谈。注意 PIPESTATUS 会被下一条命令覆盖,必须在管道之后立刻保存。

ulimit 的软/硬限。 3.5.1 已经给出完整输出:-S 只改软限,-H 只改硬限;bash 不带选项时同时改两者,所以「默认只改软限」是误记;软限不得超过硬限;非特权不能抬高硬限。写脚本时显式写 ulimit -S 或 ulimit -H,不要依赖默认值。

jobs 是当前 shell 的作业表。 父 shell 能看到后台作业,显式子 shell 看不到:

bash -c '
sleep 30 &
echo "parent jobs -p:"
jobs -p
echo "explicit subshell jobs -p:"
( jobs -p )
kill %1 2>/dev/null
wait 2>/dev/null
'
parent jobs -p:
92008
explicit subshell jobs -p:

jobs -p 在父 shell 输出 92008,在 ( ... ) 子 shell 输出为空。原因不是「子 shell 没继承变量」,而是作业表属于 shell 进程本身,子 shell 是另一个进程。bash 对 $(jobs -p) 这类单内建命令替换有优化,可能仍能读到父表;不要把命令替换当作隔离检查,要确定作业表边界就用显式子 shell 或直接读 ps。

症状命令读法
超时后还有进程ps -o pid,ppid,pgid,stat,etime,commandPPID=1 孤儿;PGID 不同说明已换组
退出原因不明waitid / waitpid + WIFEXITEDsi_status 是码或信号;不要 & 0xff
资源在哪层触顶cat .../memory.events、pids.events、cpu.stat看 oom_kill、max、nr_throttled
脚本报成功但管道失败set -o pipefail + PIPESTATUS取最右非零;每段单独看

这张表说明什么:观测命令要对应「哪一层」;ps 看进程关系,waitid 看退出语义,cgroup 文件看资源事件,PIPESTATUS 看管道语义。四类混在一起看,容易把「进程还在」误判成「资源没超」,或把「管道最后一段成功」误判成「整个命令成功」。

3.9 实验:Python 沙箱 runner

experiments/ch03/sandbox_runner.py 把前面四层里「应用层能自己做」的部分压成一个可运行的最小实现。它不替代容器,也不假装自己是安全边界;它要证明的是:超时、限额、收尸、输出截断、路径拒绝这五件事,用标准库就能做对,而且可以被测试固定下来。

能力实现对应边界失败时的结果
独立进程组start_new_session=True进程超时后 killpg 整组
墙钟超时monotonic 轮询 + SIGKILL进程/资源timed_out=true, signal=9
rlimitpreexec_fn + setrlimit资源RLIMIT_CPU 触发 SIGXCPU
输出上限stdout/stderr 落文件 + 截断资源/可观测性output_limited=true
路径包含resolve() + relative_to()文件error=EPATH,不启动子进程

这张表说明什么:五件事分别落在不同层,但共享一个结果结构;agent 拿到的是 exit_code/signal/timed_out/output_limited/error,而不是一段需要再解析的 stderr。结果结构比实现更重要,因为它让上层能稳定决策。

核心循环只有十几行:

proc = subprocess.Popen(
    [sys.executable, "-I", str(script)],
    cwd=root, start_new_session=True, close_fds=True,
    stdout=out_fh, stderr=err_fh, stdin=subprocess.DEVNULL,
    preexec_fn=lambda: apply_limits(cpu_s, fsize, nofile),
)
deadline = time.monotonic() + timeout_s
while proc.poll() is None:
    if time.monotonic() >= deadline:
        timed_out = True
        os.killpg(proc.pid, signal.SIGKILL)
        break
    if out_size() + err_size() > max_output_bytes * 2:
        output_limited = True
        os.killpg(proc.pid, signal.SIGKILL)
        break
    time.sleep(0.01)
returncode = proc.wait(timeout=2.0)

三个细节:-I 让子进程忽略 PYTHON* 环境与用户 site,减少「环境变量注入」;close_fds=True 防止父进程 fd 泄漏;proc.wait(timeout=2.0) 在 killpg 后再等一次,确认收尸完成,而不是发完信号就走。

实际运行(输出用 jq 只保留关键字段):

python3 experiments/ch03/sandbox_runner.py --code 'print(6*7)' \
  | jq -c '{ok,exit_code,signal,timed_out,stdout}'
{"ok":true,"exit_code":0,"signal":null,"timed_out":false,"stdout":"42\n"}
python3 experiments/ch03/sandbox_runner.py --code 'import sys; sys.exit(42)' \
  | jq -c '{ok,exit_code,signal,timed_out}'
python3 experiments/ch03/sandbox_runner.py --code 'import time; time.sleep(10)' \
  --timeout 0.5 | jq -c '{ok,exit_code,signal,timed_out,duration_ms}'
python3 experiments/ch03/sandbox_runner.py --code 'print("x"*200000)' \
  --max-output 4096 | jq -c '{ok,output_limited,stdout_truncated,bytes}'
{"ok":false,"exit_code":42,"signal":null,"timed_out":false}
{"ok":false,"exit_code":null,"signal":9,"timed_out":true,"duration_ms":503}
{"ok":false,"output_limited":true,"stdout_truncated":true,"bytes":{"stdout":200001,"stderr":0}}

路径逃逸的拒绝发生在启动子进程之前:

python3 experiments/ch03/sandbox_runner.py \
  --file /tmp/ch03-outside/escape.py --workspace /tmp/ch03-ws \
  | jq -c '{ok,exit_code,error,detail}'
{"ok":false,"exit_code":null,"error":"EPATH","detail":"/tmp/ch03-outside/escape.py -> /private/tmp/ch03-outside/escape.py is outside /private/tmp/ch03-ws"}

测试覆盖七条不变量:正常退出、非零退出、超时杀进程组、输出超限、路径逃逸(含符号链接)、NOFILE 限额、wait status 解码。真实输出:

python3 -m unittest discover -s experiments/ch03 -v
test_decode_wait_status ... ok
test_nofile_limit_is_applied ... ok
test_nonzero_exit_is_not_success ... ok
test_normal_exit ... ok
test_output_limit_truncates ... ok
test_path_escape_is_rejected ... ok
test_timeout_kills_process_group ... ok

----------------------------------------------------------------------
Ran 7 tests in 2.571s

OK

experiments/ch03/README.md 给出完整命令。runner 本身是跨平台的(macOS/Linux),但它只覆盖应用层边界;cgroup、netns、OCI 都是 Linux-only,正文中的命令标注为「Linux 6.8 / Ubuntu 24.04」,没有在本机 macOS 上运行过。RLIMIT_AS 在 macOS 上跳过,并在结果的 rlimits.as_enforced 里标 false。

例题 3.9.1 · 一条测试如何钉住 wait status 的 bug

假设 runner 的实现把 status & 0xff 当退出码。正常退出 0 的用例会通过;sys.exit(1) 会得到 0x0100 & 0xff = 0,测试会失败;但 sys.exit(42) 更隐蔽:0x2a00 & 0xff = 0,如果测试只断言「失败」,它仍会通过——runner 报告失败,只是原因写成了「退出码 0」。

修复测试要同时断言三件事:ok is False、exit_code == 42、signal is None。这样 status & 0xff 的错误实现无法通过:它给出 exit_code=0,第二项断言失败。再加一条信号用例(SIGTERM → signal=15, exit_code=None),把「信号不是退出码」也钉住。

这也是本章实验的设计原则:每个边界都要有一个「错误实现会失败」的测试,而不只是「正确实现会通过」的测试。

母本对应

母本章节本书落点程度
《Linux 系统与内核》第 2 篇2.7 进程创建与终止3.2 fork/exec/wait交叉引用
《Linux 系统与内核》第 2 篇2.8 程序执行与子进程监控3.2 waitpid/waitid、3.8 观测交叉引用
《Linux 系统与内核》第 2 篇2.10 信号、2.19 作业控制3.2 进程组、3.8 jobs交叉引用
《Linux 系统与内核》第 3 篇4 进程管理与调度3.5 RLIMIT_CPU/cpu.max背景
《Linux 系统与内核》第 3 篇6 内存管理3.5 RLIMIT_AS/memory.max背景
《Linux 系统与内核》第 3 篇9 虚拟文件系统、10 文件系统3.3 挂载/overlayfs交叉引用
《Linux 系统与内核》第 5 篇1 容器基础与运行时3.6 OCI config.json交叉引用
《Linux 系统与内核》第 5 篇2 namespace 与隔离3.3/3.4 mount/netns交叉引用
《Linux 系统与内核》第 5 篇3 cgroup 与资源限制3.5 cgroup v2交叉引用
《Linux 系统与内核》第 5 篇4 容器网络、5 容器存储3.4 egress、3.3 rootfs背景
《Linux man》13 openat2(2)、14 cgroup v2、16 pidfd_open(2)3.3、3.5、3.2交叉引用
OCI Runtime Spec 1.3.0config.md、config-linux.md3.6 字段映射背景

这张表说明什么:本书对 OS 只做「边界引用」,不重写 fork、信号、VFS、cgroup 的原理;真正需要展开的是「这些机制在 agent 的哪一步挡住哪一类事故」。第 5 篇的容器章节与本章重叠最多,但母本讲「怎么实现运行时」,本章讲「agent 需要运行时保证什么」。

本章陷阱

#陷阱症状正确做法去哪
1只杀父进程,不杀进程组超时后子进程继续写文件start_new_session + killpg3.2、3.7
2把 wait status 低字节当退出码0x2a00 被读成 0,42 丢失WIFEXITED + WEXITSTATUS3.2
3把 /proc/PID/stat 的 exit_code 直接 & 0xff字段位置变化或权限不足时读错用 waitid/waitpid3.2、3.8
4把 chroot 当安全边界通过 cwd/已打开 fd 逃逸pivot_root + mount ns + 丢 capability3.3
5realpath 检查后再 open检查与打开之间符号链接被换openat2 或逐段 O_NOFOLLOW3.3
6只读 rootfs 就以为文件安全/tmp 仍可写满;fd 仍可读宿主只读根 + tmpfs 限额 + 关闭 fd3.3、3.5
7只配 HTTP_PROXY 当网络隔离raw socket/DNS/IPv6 绕过netns + 默认拒绝 + 代理 allowlist3.4
8只封 169.254.169.254IPv6/私网/重定向仍可达元数据按地址段拒绝,解析后钉住 IP3.4
9用 RLIMIT_AS 当总内存上限100 个进程各自 256 MiBmemory.max 聚合限制3.5
10用 RLIMIT_CPU 当墙钟sleep(3600) 永不触发外部 timer + killpg/cgroup.kill3.5
11以为 ulimit 默认只改软限bash 把硬限一起改了,后续无法抬高显式 ulimit -S / -H3.5、3.8
12在子 shell 里查父 shell 的 jobs输出为空,误判没有后台任务jobs 是每 shell 作业表;用 ps3.8

这张表说明什么:十二个陷阱里,只有第 2、3 条是「编码读错」,其余都是「边界缺了一层」。编码读错会让好进程被记成成功;边界缺一层会让坏进程逃出去。两类都要用测试钉住。

本章出口检查

检查项达标线没达标去哪
能画出四层边界每层说出机制、挡住的事故、挡不住的事故3.1
能正确解码 wait status对 0x2a00 说出 WIFEXITED=true、退出码 42、低字节 0 的含义3.2
能解释只杀父进程为什么不够给出 PPID=1 的孤儿证据与 killpg/cgroup 修复3.2、3.7
能用 openat2 约束路径说出 BENEATH/IN_ROOT/NO_SYMLINKS 的差别与错误码3.3
能配置默认拒绝的 egressnetns + 默认 drop + 代理 /32 + DNS 固定3.4
能区分 rlimit 与 cgroup每进程 vs 聚合;CPU 时间 vs 墙钟3.5
能把策略映射到 OCIprocess/root/mounts/namespaces/resources 各指到一层3.6
能跑通实验7 个测试全过;超时用例无残留进程3.9
能用观测命令定位ps/waitid/cgroup.events/PIPESTATUS 各给出一个读法3.8

这张表说明什么:出口检查不是「读过」,而是「能对每个边界给出一个可验证的证据」。做不到的项目,回到对应小节做一次最小复现;复现失败,说明边界还没有真正建立。

即时自测 · 3

Q1 子进程执行 sys.exit(42),父进程用 os.waitpid 得到 status = 0x2a00。WIFEXITED(status) 是什么?WEXITSTATUS(status) 是多少?低字节 0x00 说明什么?

Q2 一个进程执行 sleep(3600),RLIMIT_CPU=2 会在两秒后杀掉它吗?为什么?应该用什么机制补上?

Q3 沙箱只配置了 HTTP_PROXY=http://proxy:3128,没有 netns 与防火墙。给出两条绕过路径,并说明对应的边界应该加在哪一层。

答案

A1 WIFEXITED(0x2a00) 为真;WEXITSTATUS(0x2a00) = (0x2a00 >> 8) & 0xff = 0x2a = 42。低字节 0x00 只说明「低 7 位终止信号为 0,进程正常退出」,不是退出码 0。若代码写 status & 0xff,会得到 0,把 42 误判成成功。

A2 不会。RLIMIT_CPU 计的是进程真正占用 CPU 的时间;sleep(3600) 在睡眠,CPU 时间为 0,不会触发软限或硬限。要补墙钟:父进程用 monotonic 计时,到点 killpg;或者用 timeout(1)、cgroup.kill、pidfd 等待。CPU 限制适合忙循环,墙钟适合阻塞/睡眠。

A3 两条路径:(1) 直接 socket.connect(("169.254.169.254", 80)),不读 HTTP_PROXY;(2) 发 DNS 查询把数据编码进域名,或走 IPv6 绕过只配了 IPv4 的代理。对应边界:netns + 默认拒绝的 nftables 规则(网络层),代理只做应用层 allowlist;DNS 用固定 resolver 并限制查询,元数据/私网地址按段拒绝。只靠代理环境变量不构成隔离。

下一站:4 权限 把边界收紧到 capability、seccomp、landlock 与 user namespace:先丢权,再执行。