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
/tmpdirectory. - Create a note with an attachment of our uploaded file with the following body
{ "fileLoaded": true, "file": "/tmp/some_path" } - Request
/note/:nidGET 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/uploadto/tmp/tmp-1-$Date.now()path, - Get the
Dateheader 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