中秋假期,我拿伟大的 deepseek harness 接入 dsv4f,随后惊喜地发现了 WyOJ 的两个重大 bug,都与“复制题目”相关。
下面是 dsv4f 撰写的 bug 报告。
WyOJ 缺陷报告
- 审查对象:
3b6cabd4(老布局:仓库根即 web 根) - 修复补丁:
wyoj-security-fixes.patch(22 个文件,已应用到线上) - 说明:下面"复现"针对打补丁之前的代码;每条都标了修复位置。本文件只收录已修复的问题。
摘要
| No | 严重度 | 类型 | 位置 | 状态 |
|---|---|---|---|---|
| 1 | 高 | 未认证 SQL 注入 + 命令注入 + 反射型 XSS | app/controllers/problem_set.php |
已修 |
| 2 | 高 | judger 上报字段 SQL 注入 | app/controllers/judge/submit.php |
已修 |
| 3 | 中 | 存储型 XSS(4 类数据 / 20 处输出) | 多处 | 已修 |
| 4 | 中 | CSRF(赞踩 / 私信 / 切换展示勋章) | 3 个裸 POST | 已修 |
| 5 | 中 | HTML::escape() 不转义单引号 |
app/models/HTML.php:5 |
已修 |
1. [高] problem_set.php:未认证 SQL 注入 + 命令注入 + 反射型 XSS
位置:"复制题目"分支(修复前 :39-64)与"迁移题目"分支(修复前 :16-19)。
影响
- 未登录即可盲注数据库(同一个 $pid 同时流向 SQL 和 shell);
- 有题目控制权的人可让 web 容器执行任意命令(即 web 主机 RCE);
- 附带一处反射型 XSS。
复现(修复前)
1. SQL 注入:POST /problems,copy_problem_submit=submit©_problem_list=0 or sleep(3) → 响应延迟约 3 秒(对照 copy_problem_list=1 不延迟)。原因:queryProblemBrief($pid) 把 $pid 直接插进 select * from problems where id = $pid,而它作为权限判断的参数先被求值,因此在 becomeMsgPage() 终止请求之前就已执行。
命令注入:同一字段填
1; id >/tmp/pwned(需先通过第一轮权限检查,即出题人/超管),$pid进入dataCopyProblemData()→exec("cp -r /var/uoj_data/$src …")。反射型 XSS:
POST /problems,migrate_problem_submit=submit&target_group=<script>alert(1)</script>&start_pid=1&end_pid=1→ "未找到组 …" 原样回显。附带 bug:
if (!group)把group当常量(求值为真值字符串'group'),这个"组不存在"守卫从未生效;PHP 8 下直接 fatal。
根因:外部输入未校验就进入 SQL 与 shell 命令;错误消息未转义。
修复
- problem_set.php:52-61:每个 pid 在使用前 validateUInt()(一处同时堵掉 SQL 注入与命令注入,因为同一变量流向两处);:20 转义 $_POST['target_group'];两个 POST 分支加登录门槛。
- app/libs/uoj-data-lib.php:5-41:data*Problem() 系列改用 escapeshellarg() + dataCheckId()(纵深防御,防未来调用方再引入)。
2. [高] judge/submit.php:judger 上报字段 SQL 注入
位置:app/controllers/judge/submit.php(修复前 :32-38、:63-69、:75-77)。
影响:通过 authenticateJudger()(口令比对 judger_info,而仓库预置的是公开占位符 _judger_password_)后,result JSON 里的 status / error 被直接拼进 SQL,score / time / memory 连引号都没有 → 完整 SQL 注入,可改任意表。$_POST['id'] 本身在派发处有 validateUInt,不是注入点。
复现(修复前,需一个有效 judger 账号):POST /judge/submit,带 submit=1&id=1,把 result 的 status 填成 Judged', score=0 where 1=1 -- 即可改变写入语义。
根因:把不可信 JSON 当作 SQL 字面量拼接。
修复:新增 judgeResultFromPost()(:12)统一解码并拒绝非数组;status/error 走 DB::escape()(:48、:50、:84),score/time/memory/success 强制 (int)(列类型本就是 int(11)/tinyint(1),与 judger 端的 int() 一致)。
3. [中] 存储型 XSS:4 类数据、20 处输出未转义
| 数据 | 谁能写 | 修复前的输出点 |
|---|---|---|
motto |
任何注册用户 | index.php:699/730/757、uoj-html-lib.php:1240、views/blog-preview.php:37 |
组昵称(存于 user_info.email) |
组管理员 | group_info.php:9,27、user_info.php:16-18、uoj-html-lib.php:432/496、groups_manage.php:87、problem.php:295、problem_set.php:98 |
| 题目标题 / 远端题目标题 | 出题人 | getProblemLink/getContestProblemLink、echoGroupProblemList/SizeList、problem.php:220/230、problem_statistics.php:62、rps.php:11、4 处管理页标题 |
| 勋章显示名 | 组管理员 / 超管 | echoCertsList 的 $text |
复现(修复前):个人设置里把格言设为 <img src=x onerror=alert(1)>(28 字符,validateMotto 只校验长度)→ 打开首页,脚本在所有访客浏览器中执行。组昵称、题目标题同理(写入端分别是"validator 直接 return '';"和"只做 DB::escape")。
根因:输入端只做长度/存在性校验,输出端不转义。
修复:20 处输出统一 HTML::escape()。
注意(易踩):blog 标题不要在输出端转义——它在写入时已被 UOJBlogEditor.php:96 转义,再转一次会双重转义。因此 getBlogLink() 保持原样。这个仓库混用了"存储时转义"和"输出时转义"两种风格。
4. [中] CSRF:三个无 token 的裸 POST
| 端点 | 后果 | 修复 |
|---|---|---|
click_zan.php |
冒用受害者身份刷赞/踩 | :20 加 crsf_defend();js/uoj.js 带 _token |
user_msg.php |
以受害者名义发私信 | :102 加 crsf_defend();页面 JS 带 _token |
certs_manage.php |
改掉受害者展示的勋章 | :36 加 crsf_defend();表单加 HTML::hiddenToken() |
复现(修复前):在任意第三方页面放置自动提交的表单,指向 https://<host>/click-zan,字段 id=1&delta=1&type=P;受害者登录状态下访问该页即被记账。
根因:UOJForm 生成的表单内部会校验 token(uoj-form-lib.php:49,277),但这三处是手写表单/AJAX,绕过了它。
修复:端点加 crsf_defend();page-header.php 暴露 uojCsrfToken 给 JS,并 bump js/uoj.js?v=。bump 版本号是必须的——否则浏览器缓存的旧 JS 不带 token,赞踩会全部失效。
5. [中] HTML::escape() 不转义单引号
位置:app/models/HTML.php:5(修复前是裸 htmlspecialchars($str),PHP 7.4 默认 ENT_COMPAT)。
影响:user_info.php:24 把 bgurl 放进单引号 CSS 字符串 url('<…>'),而 bgurl 存储时只做 DB::escape(certs_manage.php:53)→ 一个 ' 就能闭合字符串注入 CSS 声明(视觉劫持、借 url() 外带信息;现代浏览器不会执行 JS)。
复现(修复前):设置背景地址为 x');background:url(//attacker/),查看自己主页的 style 属性。
修复:htmlspecialchars($str, ENT_QUOTES, 'UTF-8')。已确认 HTML::escape 的输出没有任何一处落在 <script> 块内,不会破坏 JS。
回归验证清单
php -l全部改动文件(本机无 php,未过 lint)。- 首页 / 用户主页 / 题库页 / 题目页渲染正常。
- 格言填
<img src=x onerror=alert(1)>→ 显示字面文本,不弹窗。 - 点赞一次 → 计数变化;不带 token 的裸 POST → 返回
This page has expired. - 发私信、切换展示勋章 → 正常。
- 复制题目 / 迁移题目(有出题权限)→ 正常。
- 提交一次评测 → 结果正常写回(确认
judge/submit.php的转义没有破坏协议)。
