沙箱的负例构造法:正例全过不代表它在工作

权限审批与沙箱第 1 / 2 篇

本文的每个数字都是跑出来的。 本地对照跑在 Docker 29.5.2 / cgroup v2 / python:3.12-alpine,验证代码在仓库 experiments/sandbox-negative/。 生产判题机那一半引用既有实测结果(commit 17e2370),本文没有为了写它而重跑生产。

一套隔离措施上线之后,最容易做的验证是跑一遍正常提交,看它能不能正常判题。 全过,于是收工。

那个验证什么也没说。 正常提交不越界,所以它走不到任何一条限额上 —— 把 cgroup 全部关掉,它同样全过。

一、先说清两层验证各证明什么

这一篇有两层,都不能替对方说话:

在哪跑 证明什么
生产正例 真机(既有结果) 限额确实生效:五条超限用例全部被拦
本地对照 Docker cgroup v2 负例对限额状态敏感:关掉限额它们就突破

生产那一层去年做过(scripts/check-sandbox-limits.sh),五条全部生效, 顺带撞出一个意外:内存超限报的是 WA 不是 MLE。

它的判据设计里有一句话值得单独抄出来,因为下面整篇都建立在它上面:

⭐ 把「成功/失败」编码进判决,而不是看程序输出。 因为提交的是与题目无关的代码,输出一定不匹配 → 无论如何都是 WA, 分辨不出「限额拦住了」还是「程序自己跑完了」。 2026-09-01 第一版就是只看输出,四个用例全报 WA,什么也没测出来。

判定依据选错,负例就是废的 —— 这不是抽象的风险,是这个仓库里已经发生过一次的事。

而生产那一层有个做不到的事:你不能把生产沙箱关掉来验证负例。 所以本地这一层的全部意义就是「能关」。

二、把限额关掉再跑一遍

限额档尽量贴生产的隔离参数(非特权 uid、系统目录只读、限额 tmpfs、不通网):

--memory 256m --memory-swap 256m --pids-limit 64 --cpus 0.5 \
--network none --read-only --tmpfs /tmp:size=512m,nr_inodes=8k --user 1536:1536

放开档就是把上面全部拿掉。同一组负例,两档各跑一遍:

负例 有限额 没限额 有效
申请并触碰 320MB 被内核杀掉(137,OOM) 分配成功 ✅
fork 200 个 第 63 个失败(EAGAIN) 200 个全成 ✅
往 /tmp 写 600MB 被内核杀掉(137,OOM) 600MB 写完 ✅
有无非回环网络接口 只剩 lo 有 eth0 ✅
忙等 3 秒 45.4M 次自增 85.2M 次自增 限速比 ≈ 0.53

⭐ 「被拦住」有两种可观测形态,必须都认:程序自己捕获到错误并报告, 或者进程被内核直接杀掉 —— OOM 那两条什么都没来得及打印,只留下退出码 137。 判定里只认第一种,会把 OOM kill 误判成「负例没生效」。

最后一条要单独说:--cpus 是限速不是击杀,限额生效时进程照样跑完,只是慢。 所以它没有二值答案,只能比两档的工作量。而生产那边的 TLE 是判题机的挂钟限制, 和 cgroup 的 CPU 配额是两个机制,不能互相印证。

三、五条负例里有三条一开始什么都没测

这一篇的主题在自己身上发生了三次。三条都能跑、都不报错,其中两条还在表里显示为「有效」。

① 依赖了被测对象之外的前提

net 第一版是「连 1.1.1.1:443,连上算突破」。两档结果:

有限额:BLOCKED:net OSError: [Errno 101] Network unreachable
没限额:BLOCKED:net TimeoutError: timed out

两档都失败,只是原因不同 —— 这台 VM 到不了公网。 这条负例依赖「没限额时外网可达」,而那个前提不成立。 前提一旦不成立它就恒假, 和恒真式一样什么都不测。

改法是把判定挪回被测对象本身:看网络栈在不在(--network none 之后只剩 lo)。 沙箱的网络隔离做的就是移除网络栈,这个判定与外网可达性无关。

② 限额没有约束力

cpu 第一版限额写的是 --cpus 1。两档跑出 72.8M vs 84.4M 次自增 —— 差异落在噪声里。原因很简单:单线程负载本来就用不满一核, 限到一核等于没限。压到 0.5 才吃得到限速(45.4M vs 85.2M,比值 0.53)。

③ 测的不是它名字说的那件事

file 那条在表里一直是「有效」的 —— 关掉限额就写成功,开着就被拦。 但它报的是 OOM kill,不是「写满」。

因为 /tmp 是 tmpfs,写进去的数据占内存。256MB 的内存限额先于 512MB 的磁盘配额触发。也就是说 file 和「吃满内存」那条测的是同一个限额, 而表面上它们覆盖了两个方向。

分辨的办法只有一个:把其他限额放宽再跑一次。

file · 内存 1g + tmpfs 512m:BLOCKED:file 写到第 512MB 失败:No space left on device

内存放宽后换成了磁盘配额报错 —— 证明原来那档里触发的确实是内存限额。

⭐ 「负例覆盖了几个方向」取决于限额之间的相对大小,不取决于你写了几条负例。 stub 里我给自己列的「四个方向:跑满 CPU、吃满内存、写文件、连网」, 在第一版配置下实际只覆盖了三个。

四、报警路径上的代码,从来没被执行过

上面讲的是负例本身会不会失效。还有一层更隐蔽的:发现问题之后那段代码,从没跑过。

这个仓库的体检脚本里有一节盯判题沙箱的暴露面,8 条断言:

bad   开放注册被打开 · 冒出未登记账号 · 被判代码变回 root ·
      系统目录变成可写 · 沙箱监听非回环 · mount.yaml 漂移
warn  沙箱没在跑 · Guest 被授予权限 · tmpfs 限额少了

这 8 条全部只在 bad / warn 分支执行 —— 跑一遍全绿证明不了任何事。 于是用替身脚本(只回假事实,不碰生产)把每条断言逐个逼进报警分支, 抓出两个 bug:

① 一个全角引号杀掉整个体检

$JUDGE_KNOWN_UIDS」   # 裸写,没有大括号

bash 把全角 」 的字节吃进了变量名,变量名成了 JUDGE_KNOWN_UIDS」, set -u 报 unbound,set -e 杀掉整个体检进程。

⚠️ 时机是最坏的那一种:这一行只在「冒出未登记账号」时执行 —— 也就是说,体检恰好在检测到入侵信号的那一刻自己死掉。 而它的退出码是 1,看起来只像「体检发现了问题」。

修法是把这一节里所有变量引用一律改成大括号形式,让边界显式:

bad "…登记的是「${JUDGE_KNOWN_UIDS}」—— 有账号能提交代码而我不知道"

② || echo 把失败信号覆盖掉了

code=$(curl -s -o /dev/null -w '%{http_code}' "$URL" || echo 000)

curl 连不上时自己已经输出 000,|| echo 000 再补一个, 拼成 000000。于是「连不上」这个分支的判断永远匹配不到 000, 掉进 else 被报成别的原因。

修法是把兜底换成 || true —— 需要的只是「别让非零退出码触发 set -e」, 而输出那部分 curl 已经替你做了:

reg_code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$JUDGE_OJ_URL/register" 2>/dev/null || true)

两个 bug 的共同点,和hooks 那篇 测出的 fail-open 是同一个形状:它们只在出事的时候才生效, 而平时的绿色什么也不保证。 你唯一能观察到的现象,就是没有现象。

五、这一篇的判据

stub 原话:负例必须在「沙箱被故意关掉」时失败。不会失败的负例什么也没在测。

判据成立,但跑完这一轮之后得给它补三条,因为光靠它挡不住上面那三种失效:

  1. 关掉限额必须失败 —— 原判据。这一条能抓出恒真式。
  2. 失败的原因必须是你想测的那一个。 file 通过了第 1 条, 但它测的是内存不是磁盘。要验证这条,得把其他限额放宽再跑一次。
  3. 负例不能依赖被测对象之外的前提。 net 第一版依赖外网可达, 前提不成立时它恒假 —— 恒假和恒真一样没有信息量。
  4. 限额本身要有约束力。 --cpus 1 对单线程等于没限。 设了限额不等于施加了压力。

还有一条不在判据里、但顺序上更靠前的:报警路径上的代码要被单独执行过一次。 断言只在失败时才跑,所以「全绿」这个状态里不包含任何关于它们的信息。 造负例把每条断言逼进报警分支,成本很低,而它抓到的是那种 「在最需要它的时刻自己死掉」的 bug。


本文没有回答的两个问题:

一是 stub 的验证手段 ③ ——「量报警路径本身的资源占用,确认它不会成为 压死骆驼的那一根」。没做。 上面那两个 bug 是报警路径的正确性问题, 不是它的资源开销。要回答原问题得在接近满载时触发报警并量开销, 那需要在生产机上造压力,这一轮刻意没碰。

二是 go-judge 的 seccomp 白名单具体放行了哪些 syscall。 这是安全基线文档里列着的欠账,本地 Docker 这一层完全测不到它 —— 机制不同,不能外推。