fix(阶段4): 两份独立安全评审的 Critical/Important 全部修掉

## 权限边界评审(后台 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>
This commit is contained in:
2026-08-07 21:59:42 -06:00
parent e3faa689e7
commit 0f94e7ecbd
11 changed files with 859 additions and 67 deletions

View File

@@ -0,0 +1,130 @@
# 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 后 SIGKILL1s 限的题实测 26s 才 TLE25x 放大 + 判题池仅 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_student` authorizer
- **攻击手法**:查询题提交 `PRAGMA query_only=0; <任意 DML/DDL>; <正确 SELECT>`
- **实测完整提交链路query 题 id=43标准答案 `SELECT a FROM t ORDER BY a`**
攻击提交:
```sql
PRAGMA query_only=0; UPDATE t SET a=a; SELECT a FROM t ORDER BY a;
```
→ `RESULT 0`**ACCEPTED**)—— UPDATE 成功执行,只读约束失效。
对照提交(不带 PRAGMA
```sql
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``runSqlCase` budget = `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.ts` `runSqlCase`timeLimitMs=1000`elapsed_ms=25005`,返回 `{"ok":false,"result":1,"message":"SQL 执行超时"}`。
- **完整提交链路**1s 限的 query 题):从提交到出 `RESULT 1`TLE实测 **26 秒**。
- **判题池影响**`config.ts:68` `judgeConcurrency` 默认 **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 收紧、`runSqlCase` budget 别乘 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 bytes
```
`exec` 保留 rlimit`-d` 确实落到最终 bun 进程。`hex(zeroblob(3e8))` 实测被拦,返回 `RESULT 3`MEMORY_LIMIT_EXCEEDED"单个数据值超出内存限制"),完整链路复现一致。所以 `-d` 而非 `-v` 的选型与"作用到最终进程"的声明都成立。
- **不足**
1. 512MB 是**全局固定值**,与题目 `memoryLimit`(如 64MB无关。旧实现 `setlimit(SQLITE_LIMIT_LENGTH, memory_limit_mb*1MB)` 把单值上限贴着题目内存限64MB。新实现下 64MB 的题,学生单值/瞬时内存可到 ~512MB 才报 MLE8x
2. 题目内存限唯一的落点 `max_page_count` 是一条 **PRAGMA学生可自行 `PRAGMA max_page_count=1000000` 调大**(同 I-1 根因PRAGMA 不设防)。实测该 PRAGMA 被接受、无 authorizer 拒绝。因此题目 `memoryLimit` 对学生**不是权威约束**,真正的硬顶只有那 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。
- **旧实现表现**:同样基于"跑标准答案 + 多测试点比对",无"数据必须不同"的强校验,行为一致(非回归)。故 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`,宿主上**无文件生成**。WASMsql.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))` → 被 512MB `ulimit -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` 在子进程被 SIGKILLstdout 空)时按 `@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` 干净)。