## 权限边界评审(后台 86 个 handler) 守卫本身一个没漏,问题全在对象级归属校验: - C1 跨题单删奖章:先按 (id, problemsetId) 校验奖章归属再删, 否则拿自己的题单 id + 别人的奖章 id 就能把别人的 user_badge 删掉 - C2 make-public 无归属校验:补 canEdit,越权者拿不到题面 - I1 两个分析端点被 `/problems/:id` 遮蔽 —— Hono 按注册顺序匹配, 不是静态优先。挪到 `/problem-analytics/*` - I2 from-public 只校验目标比赛归属:源题也必须是公开题库题 - I3 克隆比赛回传原比赛明文密码:克隆一律 password: null - I4 upload-image 守卫比旧后端严,教师写题面会 403:收回 requireAdmin 两个互不可见的教师账号实跑复验,六条全部拦住。 ## SQL 判题沙箱评审 - I-1 查询题只读被一句 `PRAGMA query_only=0` 关掉,实测 DML 拿到 AC。 query_only 自己就是个 PRAGMA,旧实现靠 authorizer 把 SQLITE_PRAGMA 一律拒了才没这个洞。现在 runStudent 逐语句拦 PRAGMA(用 sqlite3_normalized_sql 判关键字,注释和大小写由 SQLite 抹平), 并在每条语句前重放 query_only 和 max_page_count 兜底。 顺带修掉 M-1 里 max_page_count 学生可自行调大的部分。 - I-2 单条语句进了 step() 就打断不了,只能等父进程 SIGKILL, 而兜底时限是整个作业一口价 25s —— 1s 限的题要 26s 才判 TLE, 判题池只有 2 个槽,几发死循环就能把所有人堵住。 改成分阶段:子进程用 stderr 报 prepare/student/display, 父进程边读边换表,一进学生 SQL 就把兜底收到「题目时限 + 2s」。 实测 26s → 3.06s。 归因也跟着修了:卡在受信脚本(出题人的初始化脚本、标准答案) 现在报 SYSTEM_ERROR,不再当成学生超时甩 TLE。 engine.ts 头部那张防护对照表按实测重写 —— 原来那版把 query_only 写成等价于 authorizer 白名单,是不成立的。另记一笔:stock sql.js 的 wasm 没导出 progress_handler / interrupt / set_authorizer / limit, 想要得自己编,别再去翻了。 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
14 KiB
Phase 4 安全评审 — SQL 判题引擎沙箱逃逸与资源耗尽
评审对象:apps/api/src/judge/sql/(engine.ts / child.ts / index.ts)+ 判题接线 judge/run.ts:judgeSqlSubmission + 测试点处理 services/test-case.ts。
参照旧实现:OnlineJudge/judge/sql_runner.py / sql_dispatcher.py(Python sqlite3 + authorizer / progress_handler / setlimit)。
评审方式:全部实跑。快速迭代喂 child.ts JSON 作业;关键结论走完整提交链路(建 SQL 题 → 提交 → 判题结果)与父进程 index.ts 逻辑。测试环境已还原(题目/提交/测试点目录已删,student 已改回 Regular User / None,无残留进程)。
修复记录(2026-08-07,本文档之后)
- I-1 已修:
runStudent加逐语句守卫,学生 SQL 里的 PRAGMA 一律拒(两种题型都拒, 对齐旧实现 authorizer 的_DENIED_ALWAYS)。关键字判定用sqlite3_normalized_sql, 注释/大小写/空白由 SQLite 自己抹平。另加兜底:每条语句前重放query_only与max_page_count,即便判定漏了也关不掉只读。M-1 的max_page_count可调大部分一并修掉。- I-2 已修:子进程用 stderr 报阶段(
prepare/student/display),父进程边读边换 兜底时限 —— 一进学生 SQL 就收到「题目时限 + 2s」。1s 限的题跑飞语句实测 26s → 3.06s。 顺带修正归因:卡在受信脚本(出题人的初始化/标准答案)现在报 SYSTEM_ERROR,不再甩给学生 TLE。- 未修:M-1 的「固定 512MB 与题目
memoryLimit脱钩」、M-2、M-3。engine.ts头部的防护对照表已按实测重写,两处削弱写在正文里。 另记:stock sql.js 的 wasm 没有导出sqlite3_progress_handler/sqlite3_interrupt/sqlite3_set_authorizer/sqlite3_limit(已核对导出表),要用得自己编 wasm。
结论速览
| # | 威胁 | 结果 | 级别 |
|---|---|---|---|
| 1 | 文件系统逃逸(ATTACH 等) | 打不穿。WASM 无宿主 FS 绑定,结构性隔离,比旧实现更强 | — |
| 2 | 超时挂住判题进程 | 单条长语句引擎拦不到,只靠父进程 25s 后 SIGKILL;1s 限的题实测 26s 才 TLE,25x 放大 + 判题池仅 2 并发 → 可拒绝服务 | Important |
| 3 | 内存耗尽 | 单值/瞬时内存被固定 512MB ulimit -d 兜住(已验证生效),但与题目 memoryLimit 脱钩,且 max_page_count 学生可自行调大 |
Minor |
| 4 | 查询题只读绕过 | 破防。PRAGMA query_only=0 一句即可恢复写权限,实测查询题用 DML 拿到 Accepted |
Important |
| 5 | 硬编码常量作弊 | 多测试点防线成立(前提:测试点数据确实不同,代码只强制"≥2 个"不强制"数据不同") | Minor |
| 6 | 出题人侧预览/生成 | 与学生同隔离,无宿主 FS,比旧实现更强;教师可让预览请求挂 25s | Minor |
对照表逐条核对结果(实现者声明 vs 实测):
| 旧防护 | 新做法 | 核对结果 |
|---|---|---|
| authorizer 禁 ATTACH | WASM 无宿主 FS,结构上够不到 | ✅ 成立且更强 |
| authorizer 白名单让查询题只读 | PRAGMA query_only=1 |
❌ 不成立:学生可 PRAGMA query_only=0 反手关掉 |
| progress_handler 墙钟超时 | 子进程外部 SIGKILL | ⚠️ 部分成立:只在父进程路径生效且慢 25x;单条长语句引擎内拦不到 |
setlimit(LIMIT_LENGTH) |
子进程 ulimit -d |
⚠️ 部分成立:-d 确实作用到最终进程,但固定 512MB、与题目内存限脱钩 |
Important
I-1 查询题只读可被 PRAGMA query_only=0 一句绕过
-
位置:
apps/api/src/judge/sql/engine.ts:199-200(if (mode === "query") db.run("PRAGMA query_only=1")),学生 SQL 随后在engine.ts:202 → executeStatements无任何语句级过滤地执行。 -
根因:新实现放弃了 authorizer,学生 SQL 的 PRAGMA 完全不设防。
query_only只是一个可写开关,学生自己就能把它关回去。旧实现把SQLITE_PRAGMA放进_DENIED_ALWAYS,学生连一条 PRAGMA 都跑不了(sql_runner.py_run_studentauthorizer)。 -
攻击手法:查询题提交
PRAGMA query_only=0; <任意 DML/DDL>; <正确 SELECT>。 -
实测(完整提交链路,query 题 id=43,标准答案
SELECT a FROM t ORDER BY a):攻击提交:
PRAGMA query_only=0; UPDATE t SET a=a; SELECT a FROM t ORDER BY a;→
RESULT 0(ACCEPTED)—— UPDATE 成功执行,只读约束失效。对照提交(不带 PRAGMA):
UPDATE t SET a=a; SELECT a FROM t ORDER BY a;→
RESULT 4(RUNTIME_ERROR)err_info: 本题为查询题,禁止修改数据或表结构…child.ts 层复现同样区分:带 PRAGMA 的
DELETE/INSERT走到 WA(写入生效),不带的报 readonly。 -
旧实现表现:
PRAGMA query_only=0会被 authorizer 直接 DENY(not authorized),学生无法改动只读态;且白名单模式下任何非 SELECT 授权码都拒绝。 -
实际危害边界:每个测试点都是独立的内存库、无宿主 FS、跨测试点不持久,因此不构成沙箱逃逸,也不构成作弊(学生仍需对所有测试点产出正确结果集,DML 帮不上)。危害限于:查询题的"只读"教学约束被破坏,学生可用增删改而非查询"混"过题目。但它直接证伪了对照表第 2 行的安全声明,且利用成本为零,故列 Important。
-
修复方向:学生 SQL 执行前用
iterateStatements预扫,遇到 PRAGMA(或至少query_only/max_page_count/writable_schema等敏感 PRAGMA)即拒;或在只读模式下用不可翻转的手段(如整库以 immutable/只读方式打开)替代可写开关。
I-2 单条长语句超时只能靠父进程 25s 后 SIGKILL,放大 25x 且可拖垮判题池
- 位置:
engine.ts:116-149(executeStatements的 deadline 检查在engine.ts:126,只在语句之间触发);index.ts:45(setTimeout(kill, budgetMs + HARD_TIMEOUT_SLACK_MS));index.ts:80(runSqlCasebudget =max(timeLimitMs*5, 10_000));index.ts:25(HARD_TIMEOUT_SLACK_MS = 15_000)。 - 根因:引擎的墙钟检查只发生在
iterateStatements的每条语句开头。一条长时间运行的语句(递归 CTE 死循环、大 CROSS JOIN 喂聚合)在单次step()内部执行,step()期间不检查 deadline,引擎无法中断。旧实现的progress_handler每 1000 条 VM 指令回调一次,在语句执行过程中就能按墙钟中断,约timeLimitMs(1s)即杀。 - 攻击手法:
WITH RECURSIVE r(i) AS (SELECT 1 UNION ALL SELECT i+1 FROM r) SELECT count(*) FROM r;(聚合,不向外层吐行,ROW_LIMIT也拦不到)。 - 实测:
- 直喂 child.ts(无父进程):
timeout 12强杀,子进程 12s 内从不自终(引擎 deadline 对单语句无效)。 - 走父进程
index.tsrunSqlCase(timeLimitMs=1000):elapsed_ms=25005,返回{"ok":false,"result":1,"message":"SQL 执行超时"}。 - 完整提交链路(1s 限的 query 题):从提交到出
RESULT 1(TLE)实测 26 秒。
- 直喂 child.ts(无父进程):
- 判题池影响:
config.ts:68judgeConcurrency默认 2;SQL 判题作业与所有语言共用同一 BullMQ 判题 worker。用户级限流capacity=20(throttling.ts:24)。单个学生一次性提交 20 条死循环 SQL ≈20×25s / 2 = 250s,两个判题槽被占满约 4 分钟,期间全体学生(含 Python/C)提交排队。考试/比赛期这是可用性风险。 - 旧实现表现:
progress_handler在 ~1s(timeLimitMs)即中断,worker 几乎立刻释放。新实现慢 25 倍且完全依赖父进程(子进程独立运行时永不自终)。 - 修复方向:把
HARD_TIMEOUT_SLACK_MS从 15s 收紧、runSqlCasebudget 别乘 5;或给 sql.js 编译进sqlite3_progress_handler等价的中断回调,在语句内按墙钟中断。
Minor
M-1 学生内存受固定 512MB ulimit -d 约束,与题目 memoryLimit 脱钩;max_page_count 学生可调大
- 位置:
index.ts:23(CHILD_DATA_LIMIT_KB = 512 * 1024,硬编码,不读题目memoryLimit);index.ts:38-41(sh -c "ulimit -d …; exec …");engine.ts:101-108(newDatabase:max_page_count = memoryLimitMb*256)。 - 核对"
ulimit -d是否真作用到最终 bun 进程"(重点关注项):成立。通过同款sh -c "ulimit -d 524288; exec bun …"包装起子进程后读/proc/self/limits:Max data size 536870912 536870912 bytesexec保留 rlimit,-d确实落到最终 bun 进程。hex(zeroblob(3e8))实测被拦,返回RESULT 3(MEMORY_LIMIT_EXCEEDED,"单个数据值超出内存限制"),完整链路复现一致。所以-d而非-v的选型与"作用到最终进程"的声明都成立。 - 不足:
- 512MB 是全局固定值,与题目
memoryLimit(如 64MB)无关。旧实现setlimit(SQLITE_LIMIT_LENGTH, memory_limit_mb*1MB)把单值上限贴着题目内存限(64MB)。新实现下 64MB 的题,学生单值/瞬时内存可到 ~512MB 才报 MLE(8x)。 - 题目内存限唯一的落点
max_page_count是一条 PRAGMA,学生可自行PRAGMA max_page_count=1000000调大(同 I-1 根因:PRAGMA 不设防)。实测该 PRAGMA 被接受、无 authorizer 拒绝。因此题目memoryLimit对学生不是权威约束,真正的硬顶只有那 512MB。
- 512MB 是全局固定值,与题目
- 危害边界:512MB 仍能兜住机器(单进程封顶、
ROW_LIMIT=10000兜结果集行数),不至于拖垮宿主;只是"每题内存限"名不副实。故 Minor。
M-2 测试点只强制"≥2 个",不强制"数据不同";作弊防线依赖出题人
- 位置:
services/test-case.ts:102-105(if (options.sql && selected.length < 2) throw …)。 - 核对作弊假设(题目要求至少 2 个数据不同的测试点,硬编码常量过不了):假设成立,但仅在测试点数据确实不同的前提下。
- 实测(TC1 数据
1,2,3,TC2 数据10,20,30,40):提交硬编码SELECT 1 UNION SELECT 2 UNION SELECT 3;→RESULT -1(WRONG_ANSWER)。多测试点防线有效。 - 但代码只校验
.sql文件个数 ≥2,不校验两个测试点跑出的期望结果是否不同。题目页展示的是测试点 1 的期望结果(buildDisplay取 test case 1,admin/problem.ts:160-173),学生能看到 TC1 的答案。若出题人不慎让 TC2 的数据产出与 TC1 相同的结果集,硬编码即可 AC。
- 实测(TC1 数据
- 旧实现表现:同样基于"跑标准答案 + 多测试点比对",无"数据必须不同"的强校验,行为一致(非回归)。故 Minor,属出题人操作风险。
M-3 出题人侧预览可挂 25s(教师自伤,有限)
- 位置:
admin/problem.ts:653(/sql-test-cases/preview→buildSqlDisplay),走同一子进程隔离,budget 10s + slack 15s。 - 实测:教师
refSql放死循环递归 CTE → 预览 HTTP 请求阻塞 25005ms 后返回 400生成展示数据超时或内存超限。占用一个 HTTP 处理 25s。教师半可信、且限于自身请求,Minor。
试过但没打穿(覆盖面界定)
- ATTACH 写宿主文件(query & modify & 受信 init 三种上下文):
ATTACH DATABASE '/tmp/…/evil.db' AS evil; CREATE TABLE evil.x…→unable to open database,宿主上无文件生成。WASM(sql.js)默认 MEMFS,无 NODEFS 绑定,结构性够不到宿主 FS。 - ATTACH 读已存在的宿主 sqlite 文件(modify 模式,目标是真实
secret.db):同样unable to open database,读不到。 - 受信脚本(出题人 init)里的 ATTACH:预览接口同样
unable to open database。旧实现靠_trusted_authorizer拒 ATTACH 且跑在有宿主 FS 的 Django 进程里;新实现结构性隔离,更强。 - 超大单值撑爆内存:
hex(zeroblob(3e8))→ 被 512MBulimit -d/ WASM 堆拦,MEMORY_LIMIT_EXCEEDED,未 OOM 宿主。 - 结果集撑爆:
ROW_LIMIT=10000在step()循环内计数,超限即 MLE(engine.ts:136-138)。 - 硬编码常量答案(跨数据不同的 2 测试点):WRONG_ANSWER。
- 多条语句的循环/超时:语句之间的
Date.now() > deadline(engine.ts:126)能在 ~timeLimit 处以interrupted→ TLE 拦下(仅单条长语句拦不到,见 I-2)。 - 子进程
finish()的 SIGKILL 自杀导致结果丢失/截断:未复现。child.ts:47-51用writeSync(1,…)循环写满全部字节再SIGKILL;父进程index.ts:49-52并发new Response(child.stdout).text()持续排空管道。判题结果 payload 仅数百字节,全部实测被完整解析。理论残余风险:若 stdout payload 超过管道缓冲(64KB)且父进程未及时排空、fd 1 为非阻塞,writeSync可能 EAGAIN 抛错丢输出——但当前 payload 体量下不触发,且父进程始终并发排空,判定低风险。设计中"跳过 Bun teardown 避免ulimit下 SIGILL panic 误判超时"的推理合理。 - 空 stdout → 超时误判:父进程
index.ts:58-66在子进程被 SIGKILL(stdout 空)时按@phase标记区分:卡在 display 报 SYSTEM_ERROR,卡在 judge 报 CPU_TIME_LIMIT_EXCEEDED。递归 CTE 死循环实测正确落到 TLE。
备注
- 未发现真正的沙箱逃逸(文件/进程/宿主)或能让错误答案判 Accepted 的作弊路径。最接近"严重"的是 I-2 的判题池拒绝服务(可用性),其次 I-1 的只读破防(安全控制失效但危害受限)。
- 环境已还原:题目 id=43 及其 5 条提交、
problem_tags关联、两个测试点目录(d6sps…、v3h9…)已删;data/test_case/仅剩原有79343208b704f6e75b4b6d885285280e;student已改回 Regular User / None;无残留child.ts/攻击进程。未改动任何 OJ2 / OnlineJudge / ojnext 源码(两旧仓git status干净)。