Express notes - "OFFZONE" express file upload challenge
The next challenge we did with Varik Matevosyan on OFFZONE Moscow CTF was an express.js file upload application - Express Notes.
The challenge
The description of the challenge was:
Hi! Check my simple nodejs express app that allows you to store simple notes and file attachments. get Here is file
As we got the source code, we could see it’s an express
server with express-fileupload
middleware and using redis
as the data store. And it does what the challenge description says - you can register a user then create notes and attach files to that notes.
Reviewing the codebase
Looking through the server.js
we couldn’t find anything interesting until /notes
POST endpoint that was passing user-supplied body to db.createNote
with no sanitization or validation.
server.js line 146-152
|
|
And createNote
itself was just creating a redis record with that body.
db.js line 92-97
|
|
Then looking through /notes/:nid
GET endpoint we saw that it executed a javascript code with vm2 sandbox and set with require: { external: true }
option, which, as the name suggests, enables it to require external files. Also on line 15 (below), we can see that the “sandboxed” code executed note.file
which we most probably could supply, because, as said above in /notes
POST endpoint, we can store a note object with arbitrary properties.
server.js line 154-183
|
|
So we could control note.file
parameter and execute any javascript file. But we needed to upload a file to a known location to pass it to the sink. And /notes/:nid/upload
was the file upload endpoint we needed as it uploads the user-supplied file to /app/uploads
.
server.js line 185-209
|
|
But on line 20 (above), it also passes our file through ./dataParser
program before moving to the uploads
directory, which makes achieving code execution hard, escaping potentially dangerous characters and setting the payload as a string.
before
|
|
after ./dataParser
|
|
After playing a bit with the data processor we realized that we could not bypass the filtering, and we moved forward to finding other ways to supply our uploaded file. So we went through /notes/:nid/upload
once more and saw that if we provided other form parameter name than textFile
for the file, it would be uploaded to /tmp
and not be removed, because the check on line 7 (see above in server.js line 185-209) would fail and there would be no further processing of the file.
Let’s see what we wanted to do at this point.
- Upload a javascript file that would be kept in
/tmp
directory. - Create a note with an attachment of our uploaded file with the following body
{ "fileLoaded": true, "file": "/tmp/some_path" }
- Request
/note/:nid
GET endpoint and get our code executed from/tmp/some_path
To understand what name our uploaded file would have, we checked out express-fileupload. lib/utilites.js on line 34 sets the uploaded file name as ${prefix}-${tempCounter}-${Date.now()}
, where the prefix
is the temporary directory, e.g. /tmp
, tempCounter
is a simple counter starting from 1, and Date.now
is the current timestamp which we could get partially without milliseconds from Date
header on the upload request’s response.
So we knew the prefix
, we could bruteforce 1000 possible milliseconds for Date.now
, but we also needed the tempCounter
. /note/:nid
page responds with the file ID when the upload was successful.
As it was a CTF challenge and lots of players were uploading files, we could not precisely tell what file ID would /notes/:nid/upload
file have and the bruteforce range would get bigger. But reviewing the upload endpoint one more time and thus looking deeper into checkHashcash
function we found a possible unhandled error (line 3 below).
db.js line 108-117
|
|
If we would not send hashcash
it would be undefined and would throw an unhandled error.
This would effectively reset the counter, setting it to 1.
Exploit
Awesome! So here’s the full exploit chain.
- Create a note and get the note ID,
- Crash the server,
- Upload our script with
/notes/:nid/upload
to/tmp/tmp-1-$Date.now()
path, - Get the
Date
header from the upload response and convert it to milliseconds: in our case, it was1661424636000
, - Run the script below and get a reverse shell
exploit.py
|
|
Running the script above, we got a reverse shell connection to our server and got the flag