It’s been a while since I did any CTFs. I figured it would be fun to revisit the scene and see if I could learn something new.
Here’s a Web challenge from DiceCTF 2022. I really need to brush up and my web skills and I’ve never even looked at NodeJS before.
Challenge
web/knock-knock
BrownieInMotion
107 points
Knock knock? Who’s there? Another pastebin!!
Investigation
Following the link led to a fairly plain web page with a PasteBin type interface.
There really isn’t much to see in the source of the page, just the target for the form.
<div class="container">
<h1>Create Paste</h1>
<form method="POST" action="/create">
<textarea name="data"></textarea>
<input type="submit" value="Create" />
</form>
</div>
Filling in the form and hitting the create button redirects to a page that displays the content that we submitted.
The URL for the display page is interesting as it appears that we have an ID value and some sort of hashed token.
https://knock-knock.mc.ax/note?id=245&token=c51c22c9fb7612885180e666bdbbd68b2073b8242c2b1a4e5ad45e740aadd35f
There is no other content in the page and no cookies, so the URL appears to be a unique link to the data.
Adding a few notes via the paste screen confirmed that the ID value increments with each submitted message. The ID did not always increment in single steps, but I figured that there were other people poking the site at the same time as me so that’s to be expected.
This looks like a classic Insecure Direct Object Reference (IDOR) problem. If data is referenced using a predictable value which can be controlled by the viewer, an attacker can access the items by tweaking the values in the request.
Messing with the ID value here results in an ‘Invalid Token’ message. Messing with the token for one of my message IDs also got the same error message.
It appears that if we can work out how to generate the right token for a given ID, we should be able to view any of the pasted messages.
Digging Into The Source
The challenge provided two files:
The Dockerfile is pretty simple, but handy to setup an environment that is exactly like the server. I’ll come back to that later.
The index.js file gives us a good idea of how the backend works, so let’s dig into that.
I’m not that familiar with NodeJS, but at the start of the file we appear to have an array of notes and a secret value that is built from a random UUID value.
const crypto = require('crypto');
class Database {
constructor() {
this.notes = [];
this.secret = `secret-${crypto.randomUUID}`;
}
Further down the file, we find the code that generates the tokens:
generateToken(id) {
return crypto
.createHmac('sha256', this.secret)
.update(id.toString())
.digest('hex');
}
The ID value of the post is added to the secret value from the constructor and then the hex output of the digest becomes our token.
It should be able to reconstruct any token if we know what the UUID value is.
Building The Token
In PseudoRandom Number Generator (PRNG) systems, the same sequence of numbers will be generated if a known starting value is used. This is why a seed value based on time or some other external factor is used to kickstart the PRNG.
Looking at the code, I didn’t see a random seed initialisation before the UUID call, so my first thought was that maybe the UUID will always be the same value.
This is where the Dockerfile comes in. If we can create the same environment as the server, we should be able to work out what the secret string is.
Let’s build the container.
mkdir dice-knock
cp Downloads/Dockerfile dice-knock
docker build dice-knock -t knockknock
The -t switch tags the image with the friendly name ‘knockknock’ we can refer to it later.
First problem, the build fails because ‘package.json’ is missing. I have no idea what that is or how it works, so I just commented it out of the Dockerfile and tried the build again.
docker build dice-knock -t knockknock
This time we get a successful build. Let’s run it.
docker run --rm -d --name knock knockknock
The -d switch detaches us from the container so we can do other stuff while it runs. The –name switch provides a handy reference to the running instance for future operations.
Second problem, our container won’t start as it seems to be missing some files. My normal technique here is to change the container’s start command to a simple idle and then connect in to have a poke around.
Update the entrypoint at the end of the Dockerfile.
#CMD ["node", "index.js"]
CMD["tail", "-f", "/dev/null"]
Rebuild the image and run up the new dumb version.
docker build dice-knock -t knockknock
docker run -d --rm --name knock knockknock
This time it works. We can see it running in docker’s container view.
docker ps
OK, lets jump into the container and have a look around.
docker exec -it knock bash
This command gives us a bash terminal inside the running container.
After digging around a bit it is clear that this is an empty NodeJS server, but we do have a working NodeJS interpreter.
node
> require('crypto')
> crypto.randomUUID()
'c4212425-d3e2-42ee-91ac-29b06e71ae7f'
We can run NodeJS code. Result!
Typing ‘.exit’ gets you out of the NodeJS prompt and typing ‘exit’ will drop you out of the container.
I tried restarting the container and generating a few UUIDs, but my theory about uninitialised PRNGs seemed to fall flat. The function created a different UUID each time, even from a cold start.
docker kill knock
docker run -d --rm --name knock knockknock
docker exec -it knock bash
node
> require('crypto')
> crypto.randomUUID()
'e56cd7f9-536b-4eda-a252-53b77b7440b6'
One thing that was bothering me was the odd way that the UUID call was made in index.js. I tried copying that into the NodeJS prompt to see what was going on.
node
> secret = `secret-${crypto.randomUUID}`
The output was a surprise.
'secret-function randomUUID(options) {\n' +
' if (options !== undefined)\n' +
" validateObject(options, 'options');\n" +
' const {\n' +
' disableEntropyCache = false,\n' +
' } = options || {};\n' +
'\n' +
" validateBoolean(disableEntropyCache, 'options.disableEntropyCache');\n" +
'\n' +
' return disableEntropyCache ? getUnbufferedUUID() : getBufferedUUID();\n' +
'}'
This is a dump of the function’s code with ‘secret-’ concatenated onto the front. The code does not generate a UUID at all, this will always be the string which is passed to the HMAC function.
To test the theory I built a test token for ID 1 copying the code from index.js.
> secret = `secret-${crypto.randomUUID}`
> crypto.createHmac('sha256', secret).update("1").digest('hex')
'b17c0954c3768ef5c7579c01eb3f4fc8d31b52ab86fa76e21d9d2c6ad5e83bac'
Adding this token to a URL with ID=1 worked perfectly. I could see the content of the paste! We’re almost there, we just need to find the right ID.
After generating a bunch of tokens, and crawling through all the IDs up to 50, I still couldn’t find the flag.
Going back to index.js for clues, I realised that the ID is based on the length of the notes array. The flag was the first entry at ID 0.
> const db = new Database();
> db.createNote({ data: process.env.FLAG });
So lets generate a token generation for ID 0.
> secret = `secret-${crypto.randomUUID}`
> crypto.createHmac('sha256', secret).update("1").digest('hex')
'7bd881fe5b4dcc6cdafc3e86b4a70e07cfd12b821e09a81b976d451282f6e264'
Using this token in a URL for ID 0 finally reveals the flag.
https://knock-knock.mc.ax/note?id=0&token=7bd881fe5b4dcc6cdafc3e86b4a70e07cfd12b821e09a81b976d451282f6e264
dice{1_d00r_y0u_d00r_w3_a11_d00r_f0r_1_d00r}