$ cat writeup.md…
$ cat writeup.md…
broncoctf2026
Task: netcat service authorizes commands by md5(input) in an allowlist but dispatches by string equality, printing the flag only in an unreachable else branch when blorgs==468. Solution: register a Wang MD5-collision block as the program name (adding its hash to the allowlist), then send the OTHER colliding block — same MD5 so it authorizes, but a different string that falls through to the flag branch after arithmetic path-planning hits exactly 468 in 3 edits.
Blorgs! Blorgs everywhere! Can you convince the Blorg Master to hand over his flag?
sc broncoctf-blorg.chals.ionc 0.cloud.chals.io 13758
A netcat service maintains an integer blorgs = 1 that doubles after each accepted
response. The goal is to reach exactly TARGET = 468 blorgs within MAX_EDITS = 3
edits, then get the service to print the flag.
The service (checker.py) authorizes each input by hashing it and looking the hash up
in an allowlist, then dispatches it by string equality:
valid = { "11198b294adbcf089f9d27990258fd22", # increase "b0fe2606af48e49fd3746844798eb6a0", # decrease "334c4a4c42fdb79d7ebc3e73b517e6f8", # none "dbd73c2b545209688ed794c0d5413d5a", # program "a9c449d4fa44e9e5a41c574ae55ce4d9", # quit "A7DD12B1DAB17D25467B0B0A4C8D4A92", # (intended) show } def handle_input(bytes_in: bytes): select = hashlib.md5(bytes_in).hexdigest() if select not in valid: print("That is not a real command!") return user_in = bytes_in.decode("latin-1") if user_in == "increase": blorgs = (blorgs + 1) * 2; edits += 1 elif user_in == "decrease": blorgs = (blorgs - 1) * 2; edits += 1 elif user_in == "none": blorgs *= 2 elif user_in == "program": # define program name + sub-commands, update valid ... elif user_in == program: # run each sub-command recursively ... elif user_in == "quit": exit() else: # <-- the ONLY place the flag is printed if blorgs == TARGET: print(f"Wow! You earned the flag: {FLAG}")
Two bugs stand out:
Bug 1 — the intended show command is unreachable. Its MD5 is stored in the
allowlist as UPPERCASE (A7DD12B1DAB17D25467B0B0A4C8D4A92), but
hashlib.md5(...).hexdigest() always returns lowercase. So show never passes the
select in valid check. Even if it did, there is no elif user_in == "show" branch, so
it would fall into the else.
Bug 2 — authorize-by-hash, dispatch-by-string mismatch. The flag is printed in the
else branch, which is reached only by an input that (a) has a hash in valid but
(b) is not equal to any literal command name (increase/decrease/none/program/quit)
and not equal to the current program name. Normally any input in valid is exactly
one of those known strings — so the else is dead code. But because authorization is by
hash and dispatch is by string, an MD5 collision breaks that invariant: two
distinct strings with the same MD5 let one string authorize while the other reaches the
else.
program mechanismelif user_in == "program": if program is not None: valid.remove(hashlib.md5(program.encode("latin-1")).hexdigest()) program = input("What is the name of the new command? ") program_cmd = input("Which (space separated) commands would you like it to run:") valid.add(hashlib.md5(program.encode("latin-1")).hexdigest())
Defining a program adds md5(name) to valid. We can therefore choose an arbitrary
program name, and its MD5 becomes authorized.
increase = (x+1)*2, decrease = (x-1)*2, none = x*2. Only increase/decrease
consume an edit; none is free. Starting from blorgs = 1, this 9-step sequence lands on
exactly 468 using exactly 3 edits (all decrease):
none none none none decrease none decrease decrease none
1 →none→ 2 →none→ 4 →none→ 8 →none→ 16
→decrease→ (16-1)*2 = 30 →none→ 60
→decrease→ (60-1)*2 = 118 →decrease→ (118-1)*2 = 234 →none→ 468
Exactly 468, exactly 3 edits.
Use the classic 128-byte Wang identical-prefix MD5 collision blocks (the well-known
79054025255fb1a26e4bc422aef54eb4 pair). c1 and c2 are two distinct byte blocks with
the same MD5. On the server, input() reads UTF-8 and later .encode("latin-1") is used,
so we decode the collision bytes as latin-1 and send them as UTF-8; the round-trip
reconstructs the original colliding bytes server-side.
program → set name to collision block c1, sub-commands to the 9-step doubling
sequence. This adds md5(c1) to valid and defines the sub-command list.c1 → matches program, runs the sequence → blorgs becomes 468.program again → set name to collision block c2 (same MD5 as c1). Now
program == c2, and valid still contains md5(c2) == md5(c1). Sub-commands none
(never executed).c1 one last time → md5(c1) is in valid (authorizes), but c1 != program
(program is now c2) and c1 matches no literal branch → falls into the else with
blorgs == 468 → flag printed.#!/usr/bin/env python3 from pwn import remote HOST, PORT = "0.cloud.chals.io", 13758 # Classic Wang identical-prefix MD5 collision pair (same MD5, different bytes). h1 = "d131dd02c5e6eec4693d9a0698aff95c2fcab58712467eab4004583eb8fb7f8955ad340609f4b30283e488832571415a085125e8f7cdc99fd91dbdf280373c5bd8823e3156348f5bae6dacd436c919c6dd53e2b487da03fd02396306d248cda0e99f33420f577ee8ce54b67080a80d1ec69821bcb6a8839396f9652b6ff72a70" h2 = "d131dd02c5e6eec4693d9a0698aff95c2fcab50712467eab4004583eb8fb7f8955ad340609f4b30283e4888325f1415a085125e8f7cdc99fd91dbd7280373c5bd8823e3156348f5bae6dacd436c919c6dd53e23487da03fd02396306d248cda0e99f33420f577ee8ce54b67080280d1ec69821bcb6a8839396f965ab6ff72a70" # Decode collision bytes as latin-1, send as UTF-8: server input()/encode("latin-1") # round-trips them back to the original colliding blocks. c1 = bytes.fromhex(h1).decode("latin-1").encode("utf-8") c2 = bytes.fromhex(h2).decode("latin-1").encode("utf-8") r = remote(HOST, PORT) # 1) define program c1 with the 468-doubling sequence, then run it r.sendlineafter(b"> ", b"program") r.sendlineafter(b"new command? ", c1) r.sendlineafter(b"run:", b"none none none none decrease none decrease decrease none") r.sendlineafter(b"> ", c1) # runs sequence -> blorgs == 468 # 2) redefine program name to c2 (same MD5 as c1) r.sendlineafter(b"> ", b"program") r.sendlineafter(b"new command? ", c2) r.sendlineafter(b"run:", b"none") # 3) send c1: passes valid (md5 collision) but c1 != program -> else branch -> flag r.sendlineafter(b"> ", c1) print(r.recvall(timeout=5).decode(errors="replace"))
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar