Files
OJ2/docs/specs/phase4-review-sql-sandbox.md
yuetsh 0f94e7ecbd 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>
2026-08-07 21:59:42 -06:00

131 lines
14 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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` 干净)。