$ cat writeup.md…
$ cat writeup.md…
metactf
Task: a DawgCTF protocol analysis challenge implementing the classic Needham-Schroeder Public Key Protocol between Sneed (initiator) and Chuck (responder), with the flag sent as {FLAG}h(nA+nB) at the end. Solution: Lowe's man-in-the-middle attack — use our own attacker identity 'mallory' as Alice's X, relay nA to Bob as-if from sneed, bounce {nA,nB}pubA back to Alice so she encrypts nB to us under pubM, then re-encrypt {nB}pubB to trigger the flag send. The flag is symmetric-encrypted with ChaCha20-Poly1305 under key=SHA256(nA_hex+nB_hex) and nonce=key[:12].
Chuck needs to send Sneed an urgent message regarding the name of their store, but he doesn't want any city slickers listening in. There may be a flag abound.
Source: https://github.com/UMBCCyberDawgs/dawgctf-sp26/blob/main/Protocol%20Analysis%20(1-9)/Protocol_Analysis_chals.pdf
Sixth entry in the DawgCTF Protocol Analysis series. The task description is a direct hint to "Sneed's Feed and Seed (Formerly Chuck's)" from The Simpsons episode Lemon of Troy — the "name of their store" is the key plot point of the running internet joke.
From the PDF manual:
Protocol key: A = sneed B = chuck X = any name nX = nonce of entity X h(x + y) = hash of data x and y (+ means concatenation without pipes or colons) Alice (sneed) Bob (chuck) send: pubB, B, certB recv: pubX, X, certX send: {nA, pubA, A, certA}pubX recv: {nA, pubA, A, certA}pubB send: {nA, nB}pubA recv: {nA, nX}pubA send: {nX}pubX recv: {nB}pubB send: {[FLAG]}h(nA+nB) recv: {[FLAG]}h(nA+nX)
This is the textbook Needham-Schroeder Public Key Protocol with certificates tacked onto the identity announcements, plus a symmetrically encrypted payload at the end. Chuck is the initiator (Bob column — sends his cert first), Sneed is the responder (Alice column).
All operations are HTTP POST against https://protocols.live.
POST /model/6 → {"conn_id": ...} creates a fresh instance (fresh keys and nonces).POST /alice / POST /bob with {"conn_id": ..., "content": ...} drives that entity's state machine one step. Each call consumes the next recv in the script (matching the current content) and returns the next send. If the entity's next action is a send with no prior recv, sending empty content triggers it.POST /util/<name> is a crypto helper; conn_id is ignored. Relevant endpoints:
gen_asym_key_pair → t:public|k:<pubHex>|t:private|k:<privHex> (RSA)get_cert (k:<pub>|n:<name>) → d:<cert> — restricted names: alice, bob, chuck, sneedasym_encrypt (k:<pub>|t:<text>) → d:<ciphertext> (PDF lies: k is actually the public key)asym_decrypt (k:<priv>|d:<ciphertext>) → plaintext (PDF lies: k is actually the private key)sym_encrypt (k:<32-byte key>|d:<12-byte nonce>|t:<text>) → d:<ct+tag>sym_decrypt (k|d|d) → plaintexthash_data (d:<hex>) → d:<sha256(ascii(hex))> — hashes the hex STRING, not the decoded bytes!The content field is a pipe-separated list of typed items type:value. Types are t text, n name, k key hex, d data hex. Concatenation and colons inside values break parsing, which is why the protocol's + for hash concatenation is defined as "without pipes or colons".
The classic Lowe attack (Gavin Lowe, 1995) breaks NS-PK because Alice's first recv template accepts any public key and name with a valid cert. Here the protocol key even spells it out: X = any name. We can inject our own attacker identity into that slot and become the "peer" Alice thinks she's talking to, while simultaneously relaying forged messages to Bob in Alice's name.
Attack outline (single conn_id, alternating POSTs between /alice and /bob):
Attacker (M) Chuck (Bob) Sneed (Alice) ----------------- -------------------- ---------------------- 1. POST /bob "" ---------------------------> (sends pubB, B, certB) 2. <-- pubB,chuck,certB received 3. POST /alice pubM,mallory,certM -----------------------------------------------------> (treats X = mallory) 4. <-- {nA,pubA,sneed,certA}pubM from Alice (she encrypts to OUR mallory pubkey!) 5. decrypt with privM -> learn nA, pubA, certA 6. asym_encrypt {nA,pubA,sneed,certA}pubB 7. POST /bob d:<enc> -----------------------> (decrypts, trusts certA, thinks it's from sneed) 8. <-- {nA,nB}pubA from Bob (we can't decrypt) 9. POST /alice d:<enc> (unmodified!) -------------------------------------------------> (decrypts, nA matches, nX := nB) 10. <-- {nX}pubX = {nB}pubM from Alice 11. decrypt with privM -> learn nB 12. asym_encrypt {nB}pubB 13. POST /bob d:<enc> ----------------------> (decrypts, nB matches, completes protocol) 14. <-- {[FLAG]}h(nA+nB) from Bob 15. key = SHA256(ascii(nA+nB)); nonce = key[:12] 16. ChaCha20-Poly1305 decrypt -> flag
The crucial trick at step 9 is that Alice doesn't (and cannot) verify who wrapped {nA, nX}pubA — anyone with pubA could have built that blob. So we relay Bob's reply untouched and Alice obligingly encrypts the paired nonce back to our attacker pubkey at step 10.
We never break RSA, never forge a certificate, and never need Alice's or Chuck's private keys. The "any name" hole in Alice's recv is the whole attack surface.
Several details were non-obvious and cost me a lot of debugging time. Collecting them here because future challenges in this PDF (Mediation, Reflection, Oracle) will share the same server quirks.
asym_encrypt / asym_decryptThe PDF says asym_encrypt takes a private key and asym_decrypt takes a public key. This is wrong. The service behaves like normal public-key crypto: asym_encrypt with pubX produces ciphertext only privX can decrypt. Verified empirically:
$ util.asym_encrypt(pub, "hello") -> d:<ct> $ util.asym_decrypt(priv, ct) -> "hello" # works $ util.asym_decrypt(pub, ct) -> error
content triggers the next sendTo get Chuck's first send (pubB, B, certB) you POST /bob with content: "". For any stateful endpoint, the server matches your input against the next recv in the script; when the next action is already a send, an empty message just drives it.
When encrypting {nA, pubA, A, certA}pubB we must pass the INNER items as the plaintext to asym_encrypt:
plaintext_for_asym_encrypt = "d:" + nA + "|k:" + pubA + "|n:" + A + "|d:" + certA
The server will decrypt this on the other side and parse it back into four typed items, matching the expected {d,k,n,d} shape.
hash_data hashes ASCII, not raw bytes$ util.hash_data("d:deadbeef") -> SHA256(b"deadbeef") # hashes the HEX STRING $ util.hash_data("d:deadbeef") != SHA256(bytes.fromhex("deadbeef"))
So h(nA + nB) in the protocol = SHA256((nA_hex + nB_hex).encode()). I lost ~20 minutes on that one because python crypto library decodes hex by default.
sym_encrypt returns exactly plaintext_bytes * 2 + 32 hex chars (the +32 is the 16-byte Poly1305 tag). No nonce is embedded in the output. Verified by testing against a fixed key+nonce with inputs of 1, 2, 4, 13, 26, 28 bytes:
| plaintext bytes | enc hex length | expected (2*N + 32) |
|---|---|---|
| 1 | 34 | 34 |
| 13 | 58 | 58 |
| 26 | 84 | 84 |
| 28 | 88 | 88 |
The 26-byte and 28-byte ciphertexts share their first 52 hex chars, confirming stream-cipher / CTR-mode encryption (ChaCha20-Poly1305 or AES-GCM).
key[:12]The single hardest part. The server encrypts {[FLAG]}h(nA+nB) without transmitting a nonce. I collected 5 complete (nA, nB, enc_flag) tuples via the Lowe attack and bruteforced offline with python-cryptography:
# Found: cipher = ChaCha20-Poly1305 # key = SHA256((nA_hex + nB_hex).encode()) # nonce = key[:12] <-- deterministic from the key
All five captures decrypted to the same plaintext, confirming the scheme. The same (key, nonce) pair is reused every time h(...) produces the same value — not great for real crypto, perfect for a CTF solver.
t: prefixBecause the server stores flags as typed text items, the actual bytes inside the AEAD ciphertext are "t:DawgCTF{...}". Strip the leading t: to recover the flag.
protocols.live frequently returns {"detail":"DB access error, possible invalid conn id ..."} on perfectly valid requests — this appears to be a race between the fastapi handler and the backing store. Retry strategy:
conn_id.The server also went fully offline (connection refused) for several minutes at a time during the event — plan for retry loops.
#!/usr/bin/env python3 """Lowe MITM on Needham-Schroeder. Flag = DawgCTF{REDACTED}.""" import hashlib, json, sys, time import requests from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305 BASE = "https://protocols.live" TIMEOUT = 180 def _raw(path, body): try: return requests.post( f"{BASE}{path}", headers={"Content-Type": "application/json"}, data=json.dumps(body), timeout=TIMEOUT, ) except requests.exceptions.RequestException: return None def util(name, content): for _ in range(8): r = _raw(f"/util/{name}", {"conn_id": 0, "content": content}) if r is not None and r.status_code == 200: return r.json()["content"] time.sleep(4) sys.exit(f"util {name} failed") def new_conn(): for _ in range(8): r = _raw("/model/6", {}) if r is not None and r.status_code == 200: return r.json()["conn_id"] time.sleep(4) sys.exit("conn failed") def stateful(path, conn_id, content): """Retry only on DB errors / 5xx (state wasn't touched).""" for _ in range(15): time.sleep(1) r = _raw(path, {"conn_id": conn_id, "content": content}) if r is None: time.sleep(3); continue if r.status_code == 200: return r.json()["content"] if "DB access error" in r.text or r.status_code >= 500: time.sleep(4); continue sys.exit(f"[{path}] HTTP {r.status_code}: {r.text}") return None def parse(c): return [tuple(x.split(":", 1)) for x in c.split("|")] def first(items, t): return next(v for tt, v in items if tt == t) def all_of(items, t): return [v for tt, v in items if tt == t] def attempt(): # ----- setup: our attacker identity "mallory" ----- kp = parse(util("gen_asym_key_pair", "")) pub_m, priv_m = kp[1][1], kp[3][1] cert_m = first(parse(util("get_cert", f"k:{pub_m}|n:mallory")), "d") conn = new_conn() time.sleep(3) # let the DB commit # 1. /bob "" -> Chuck's first send (pubB, chuck, certB) r1 = stateful("/bob", conn, "") if r1 is None: return None pub_b = first(parse(r1), "k") # 2. /alice (pubM, mallory, certM) -> Alice sends {nA,pubA,A,certA}pubM r2 = stateful("/alice", conn, f"k:{pub_m}|n:mallory|d:{cert_m}") if r2 is None: return None enc_to_m = first(parse(r2), "d") # 3. decrypt with privM to learn nA, pubA, certA dec = parse(util("asym_decrypt", f"k:{priv_m}|d:{enc_to_m}")) n_a = all_of(dec, "d")[0] pub_a = first(dec, "k") a_name = first(dec, "n") # "sneed" cert_a = all_of(dec, "d")[1] # 4. re-encrypt for Bob and forward: {nA,pubA,sneed,certA}pubB pt = f"d:{n_a}|k:{pub_a}|n:{a_name}|d:{cert_a}" enc_for_b = first(parse(util("asym_encrypt", f"k:{pub_b}|t:{pt}")), "d") r5 = stateful("/bob", conn, f"d:{enc_for_b}") if r5 is None: return None enc_nAnB_pubA = first(parse(r5), "d") # {nA,nB}pubA — we can't decrypt # 5. bounce {nA,nB}pubA unchanged to Alice — she treats nB as nX r6 = stateful("/alice", conn, f"d:{enc_nAnB_pubA}") if r6 is None: return None enc_nB_pubM = first(parse(r6), "d") # {nX}pubX = {nB}pubM — OURS to decrypt dec_nB = parse(util("asym_decrypt", f"k:{priv_m}|d:{enc_nB_pubM}")) n_b = first(dec_nB, "d") # 6. re-encrypt {nB}pubB and trigger Chuck's flag send enc_nB_pubB = first(parse(util("asym_encrypt", f"k:{pub_b}|t:d:{n_b}")), "d") r8 = stateful("/bob", conn, f"d:{enc_nB_pubB}") if r8 is None: return None enc_flag = first(parse(r8), "d") # 7. compute key = SHA256(ASCII(nA_hex + nB_hex)), nonce = key[:12] key = hashlib.sha256((n_a + n_b).encode()).digest() nonce = key[:12] pt = ChaCha20Poly1305(key).decrypt(nonce, bytes.fromhex(enc_flag), None) text = pt.decode() return text[2:] if text.startswith("t:") else text def main(): for i in range(6): print(f"=== Attempt {i + 1} ===") try: flag = attempt() except Exception as e: print(f"ERR: {e}") flag = None if flag: print(f"\nFLAG: {flag}") return time.sleep(8) print("Failed.") if __name__ == "__main__": main()
Typical successful run:
=== Attempt 1 === conn = 740338213037998362 pubB ok nA = 49e460d2815b82290a4658e393b033f51523de9d4ccf34a89e1c1e5a4b8f692c nB = 516b21fa08a1a37427cc3a1bb97c262ee9c1adbb087b59e1a7d21316da929563 enc_flag = c62650d3e0b40c08e66114117bbc42f3aec44c909e8edb0fb3eee4ee5c308e8e2058f13fbaedc1a2fc46 FLAG: DawgCTF{REDACTED}
$ cat /etc/motd
Liked this one?
Pro unlocks every writeup, every flag, and API access. $9/mo.
$ cat pricing.md$ grep --similar