沙箱的负例构造法:正例全过不代表它在工作
本文的每个数字都是跑出来的。 本地对照跑在 Docker
29.5.2/ cgroup v2 /python:3.12-alpine,验证代码在仓库experiments/sandbox-negative/。 生产判题机那一半引用既有实测结果(commit17e2370),本文没有为了写它而重跑生产。
一套隔离措施上线之后,最容易做的验证是跑一遍正常提交,看它能不能正常判题。 全过,于是收工。
那个验证什么也没说。 正常提交不越界,所以它走不到任何一条限额上 —— 把 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 原话:负例必须在「沙箱被故意关掉」时失败。不会失败的负例什么也没在测。
判据成立,但跑完这一轮之后得给它补三条,因为光靠它挡不住上面那三种失效:
- 关掉限额必须失败 —— 原判据。这一条能抓出恒真式。
- 失败的原因必须是你想测的那一个。
file通过了第 1 条, 但它测的是内存不是磁盘。要验证这条,得把其他限额放宽再跑一次。 - 负例不能依赖被测对象之外的前提。
net第一版依赖外网可达, 前提不成立时它恒假 —— 恒假和恒真一样没有信息量。 - 限额本身要有约束力。
--cpus 1对单线程等于没限。 设了限额不等于施加了压力。
还有一条不在判据里、但顺序上更靠前的:报警路径上的代码要被单独执行过一次。 断言只在失败时才跑,所以「全绿」这个状态里不包含任何关于它们的信息。 造负例把每条断言逼进报警分支,成本很低,而它抓到的是那种 「在最需要它的时刻自己死掉」的 bug。
本文没有回答的两个问题:
一是 stub 的验证手段 ③ ——「量报警路径本身的资源占用,确认它不会成为 压死骆驼的那一根」。没做。 上面那两个 bug 是报警路径的正确性问题, 不是它的资源开销。要回答原问题得在接近满载时触发报警并量开销, 那需要在生产机上造压力,这一轮刻意没碰。
二是 go-judge 的 seccomp 白名单具体放行了哪些 syscall。 这是安全基线文档里列着的欠账,本地 Docker 这一层完全测不到它 —— 机制不同,不能外推。