← Back to Writeups
HTBMediumMisc

Blorg Multiplier

D3v0o0Nu119/2/20266 min read
#misc#htb#medium#arithmetic_path_planning#authorize_by_hash_dispatch_by_#else_branch_reach#latin1_roundtrip#md5_identical_prefix_collision

Blorg Multiplier

Platform: Broncoctf2026 | Category: Misc | Type: Challenge | Difficulty: Medium | OS: NA | Author: D3v0o0Nu11 | Date: 2026-07-11 | Status: Solved Techniques: arithmetic_path_planning, authorize_by_hash_dispatch_by_string, else_branch_reach, latin1_roundtrip, md5_identical_prefix_collision

Summary

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.

Recon

Port scan

bash
nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: broncoctf2026 | ID: 20260711_broncoctf2026_blorg_multiplier
  • Tags: md5_collision, hash_allowlist, authz_dispatch_mismatch, case_sensitivity_bug, fallthrough_branch, latin1
  • Indicators: command allowlist keyed by md5(input), dispatch via string == after hash check, uppercase hash in valid set vs lowercase hexdigest, exact numeric TARGET under an edit budget, flag only printed in the else/fallthrough branch
  • Source: 20260711_broncoctf2026_blorg_multiplier.md

Foothold

Vulnerability / Misconfiguration

  1. arithmetic_path_planning
  2. authorize_by_hash_dispatch_by_string
  3. else_branch_reach
  4. latin1_roundtrip
  5. md5_identical_prefix_collision
bash
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

bash
sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
bash
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • arithmetic_path_planning
  • authorize_by_hash_dispatch_by_string
  • else_branch_reach
  • latin1_roundtrip
  • md5_identical_prefix_collision
  • Tags: md5_collision, hash_allowlist, authz_dispatch_mismatch, case_sensitivity_bug, fallthrough_branch, latin1

Original Writeup

<details><summary>Click to expand original content</summary>

Description

Blorgs! Blorgs everywhere! Can you convince the Blorg Master to hand over his flag?

  • sc broncoctf-blorg.chals.io
  • nc 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.

Analysis

The service (checker.py) authorizes each input by hashing it and looking the hash up in an allowlist, then dispatches it by string equality:

python
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.

The program mechanism

python
elif 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.

Solution

Step 1 — plan the arithmetic to hit exactly 468 in 3 edits

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.

Step 2 — the MD5 collision pair

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.

Step 3 — exploit chain

  1. 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.
  2. Send c1 → matches program, runs the sequence → blorgs becomes 468.
  3. 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).
  4. Send 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.
python
#!/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"))
</details>

signed by XESXOR