Skip to content
highCVSS 8.6CWE-943A03:2021 – Injection

NoSQL Injection

The same trust failure without the SQL. Operator injection in MongoDB, server-side JavaScript execution, and why 'we don't use SQL' is not a defence.

Same Bug, Different Syntax

NoSQL databases are frequently assumed immune to injection because they do not parse SQL. The underlying flaw is identical: user input crossing from the data channel into the query channel.

What changes is the shape of the payload. Instead of breaking out of a string literal with a quote, you change the type of a value — supplying an object where the application expected a scalar — and the query engine interprets your object as an operator.

This is included on a SQL injection site for two reasons. Applications commonly use both a relational store and a document store, so the same test session covers both. And the class is systematically under-tested precisely because of the assumption that it cannot happen.

CWE-943 covers this: improper neutralisation of special elements in a data query, independent of query language.

Operator Injection

MongoDB queries are documents. {username: "admin"} matches literally. But {username: {$ne: null}} uses the $ne operator and matches any non-null username.

If the application builds {username: req.body.username} and the request body is JSON, the attacker controls the type of that value — and can supply an operator object instead of a string.

This works with URL-encoded bodies too: Express's qs parser turns username[$ne]=null into a nested object, which is why the classic payload appears in both forms.

Authentication bypass

JavaScriptlogin.jsVulnerable
// The values are passed straight through. If the client sends
// objects instead of strings, they become query operators.
app.post('/login', async (req, res) => {
  const user = await db.collection('users').findOne({
    username: req.body.username,
    password: req.body.password,
  })
  if (user) res.json({ token: sign(user) })
})

// JSON body:
// {"username": "admin", "password": {"$ne": null}}
//   -> matches admin with ANY non-null password
//
// {"username": {"$gt": ""}, "password": {"$gt": ""}}
//   -> matches the first user in the collection
//
// Form-encoded, via the qs parser:
// username=admin&password[$ne]=null

Useful Operators

OperatorPayloadEffect
$ne{"$ne": null}Matches anything not null — the standard bypass.
$gt / $gte{"$gt": ""}Matches any string; useful when $ne is filtered.
$regex{"$regex": "^a"}Character-by-character extraction, like boolean-blind.
$in{"$in": ["admin","root"]}Try several values in one request.
$exists{"$exists": true}Field discovery.
$where{"$where": "1==1"}Server-side JavaScript. Often disabled.
$expr{"$expr": {"$eq": [1,1]}}Aggregation expressions inside a find().
$nin{"$nin": []}Matches everything; survives some $ne filters.

Blind Extraction with $regex

$regex gives you the same one-bit oracle as boolean-blind SQL injection, and the same binary-search approach applies.

Anchor the pattern and test one character at a time. Each request tells you whether the value starts with the prefix you guessed.

$regex character extraction

JSON
// Does the admin password hash start with 'a'?
{"username": "admin", "password": {"$regex": "^a"}}   -> 401
{"username": "admin", "password": {"$regex": "^b"}}   -> 200  <-- yes
{"username": "admin", "password": {"$regex": "^b1"}}  -> 401
{"username": "admin", "password": {"$regex": "^b2"}}  -> 200  <-- yes
// ...continue one character at a time

// Character classes narrow the search faster than a linear scan:
{"password": {"$regex": "^[a-m]"}}    -> halves the alphabet per request

// Length discovery:
{"password": {"$regex": "^.{60}$"}}

// A catastrophically backtracking regex also gives a timing oracle
// when the response is otherwise uniform:
{"password": {"$regex": "^(a+)+$"}}

$where and Server-Side JavaScript

$where evaluates a JavaScript expression against each document. If user input reaches it by concatenation, you get arbitrary JavaScript execution inside the database — the NoSQL analogue of stacked queries.

It is disabled by default from MongoDB 4.4 onward (--noscripting, and it is off in Atlas), so it is now mostly a legacy finding. Where it is enabled, it is critical.

// VULNERABLE
db.users.find({ $where: `this.age > ${req.query.age}` })

// ?age=0 || true          -> returns everything
// ?age=0; while(1){}      -> denial of service
// ?age=0 || this.password[0]=='a'   -> blind extraction

mapReduce and $accumulator have historically offered similar server-side execution and are worth checking on older deployments.

Beyond MongoDB

The pattern generalises to any query language assembled from strings:

Elasticsearch. Query DSL injection through a query_string field lets an attacker use Lucene syntax — * wildcards, field references, and boolean operators — to read documents outside the intended filter. Scripted fields (Painless) have offered code execution historically.

Redis. Not a query language, but EVAL with a concatenated Lua script is direct code execution, and CRLF injection into a command argument can inject additional commands entirely.

CouchDB. Map/reduce views defined from user input execute JavaScript server-side.

Cassandra (CQL). Close enough to SQL that ordinary SQL injection technique applies, minus UNION and subqueries. Parameterise the same way.

LDAP. A different grammar with the same failure mode — see filter injection with *)(uid=*.

In every case the fix is the same: parameterise or type-validate, never build the query by concatenation.

Testing

Change the type. The single highest-yield test is sending an object where a string is expected. {"$ne": null}, {"$gt": ""}, {"$nin": []} on every authentication and lookup parameter.

Try both encodings. Express's qs parser makes param[$ne]=null equivalent to the JSON form, and validation middleware frequently covers only one path.

Look for type-confusion in arrays. {"username": ["admin"]} sometimes matches where a string would not, depending on driver behaviour.

Check the error messages. Mongoose and the native driver produce distinctive errors (CastError, MongoServerError) that both confirm the backend and often reveal the query shape.

Do not assume the whole app is NoSQL. Mixed persistence is the norm. Test each endpoint for both classes.

Prevention

  • Validate types at the boundary. A schema validator (zod, joi, ajv, pydantic) that asserts string rejects every operator-injection payload on this page. This is the single most effective control.
  • Cast explicitly. String(req.body.username) turns an object into harmless text if you cannot add a validator.
  • Never compare secrets in the query. Fetch by identifier, then verify the hash in application code. This alone defeats the classic $ne login bypass regardless of typing.
  • Disable server-side JavaScript. Run with --noscripting; it is the default from 4.4.
  • Sanitise operator keys with express-mongo-sanitize or Mongoose's sanitizeFilter, as defence in depth rather than the primary control.
  • Use the ODM's typed schema. Mongoose casts values to the declared field type, which blocks operator objects in many — though not all — positions.