$ cat writeup.md…
$ cat writeup.md…
metactf
Task: analyze a certificate-based Alice/Bob protocol exposed over HTTP and recover Bob's flag. Solution: exploit a reflection and role-confusion flaw by feeding Bob's identity and nonce back through Alice, then relaying Alice's signature on Bob's own tuple to satisfy Bob.
Bob and Alice are talking again, but this time they messed up and are both in the same roles of the protocol. Bob still has a flag though, can you get it?
English summary: the challenge provides a live Alice/Bob authentication protocol at https://protocols.live plus a PDF transcript. We need to drive both state machines, understand what each side actually signs, and make Bob accept a forged-looking but valid reflected authentication flow.
The PDF excerpt says both Alice and Bob begin by sending their own public key, identity, and certificate:
Alice: send pubA, A, certA Bob: send pubB, B, certB
Then Bob expects:
pubX, X, certX, nX1
and responds with:
nA, {X, nX1, nA}priv
Later Alice expects a similar structure and signs another tuple, after which Bob checks the final message and releases the flag.
On the live service, the interaction details are:
POST /model/8 creates a fresh instance and returns conn_id.POST /alice and POST /bob are stateful endpoints that must share that same conn_id.{"conn_id": ..., "content": ""} triggers the participant's initial send.k:<hex>, n:<name>, d:<hex>, t:<text>.Observed openers:
Alice: k:<pubA>|n:alice|d:<certA> Bob: k:<pubB>|n:bob|d:<certB>
If we send Bob Alice's opener plus our own chosen nonce n1:
k:<pubA>|n:alice|d:<certA>|d:<n1>
Bob replies with two data fields:
d:<nB>|d:<sigB>
At this point the obvious idea is to reflect Alice's identity back to Alice. That fails. The important implementation quirk came from testing /util/asym_verify: Alice's reflected signature is not accepted by Bob unless the signed literal text uses Bob's identity in the first field.
The winning message to Alice is therefore:
k:<pubB>|n:bob|d:<certB>|d:<nB>
Alice answers with:
d:<nA2>|d:<sigA>
Verification probes showed that sigA is over the literal text:
t:n:bob|d:<nB>|d:<nA2>
not the PDF-looking variant with alice in the first field. Bob then accepts the final relay:
d:<nA2>|d:<sigA>
and returns:
t:DawgCTF{REDACTED}
So the core bug is a reflection / role-confusion vulnerability. Because both parties operate in the same role family, Bob can be tricked into accepting Alice's valid signature on a tuple containing Bob's own identity and Bob's own nonce, even though that signature was produced in a different peer context.
POST /model/8.n1.nB.nB.nA2 and a signature over the literal text t:n:bob|d:<nB>|d:<nA2>.d:<nA2>|d:<sigA> to Bob.The main trap is step 5: reflecting Alice's own identity back to Alice looks natural from the PDF, but the real service only works when the identity field is bob. This is exactly what makes the exploit a role-confusion bug rather than a simple nonce reflection.
#!/usr/bin/env python3 import json import os import time import requests BASE = "https://protocols.live" TIMEOUT = 60 def raw_post(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 stateful(path, conn_id, content, retries=15): for _ in range(retries): r = raw_post(path, {"conn_id": conn_id, "content": content}) if r is None: time.sleep(2) continue if r.status_code == 200: return r.json()["content"] if "DB access error" in r.text or r.status_code >= 500: time.sleep(2) continue raise RuntimeError(f"{path} failed: HTTP {r.status_code} {r.text}") raise RuntimeError(f"{path} retries exhausted") def new_conn(retries=10): for _ in range(retries): r = raw_post("/model/8", {}) if r is not None and r.status_code == 200: return r.json()["conn_id"] time.sleep(2) raise RuntimeError("failed to create conn_id") def parse(content): return [tuple(x.split(":", 1)) for x in content.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(): conn = new_conn() time.sleep(2) a0 = stateful("/alice", conn, "") pa0 = parse(a0) pub_a = first(pa0, "k") name_a = first(pa0, "n") cert_a = first(pa0, "d") print(f"[+] alice opener: {a0}") b0 = stateful("/bob", conn, "") pb0 = parse(b0) pub_b = first(pb0, "k") name_b = first(pb0, "n") cert_b = first(pb0, "d") print(f"[+] bob opener: {b0}") n1 = os.urandom(32).hex() b1 = stateful("/bob", conn, f"k:{pub_a}|n:{name_a}|d:{cert_a}|d:{n1}") print(f"[+] bob challenge: {b1}") pb1 = parse(b1) n_b, sig_b = all_of(pb1, "d") print(f"[+] bob nonce: {n_b}") print(f"[+] bob sig: {sig_b[:32]}...") a1 = stateful("/alice", conn, f"k:{pub_b}|n:{name_b}|d:{cert_b}|d:{n_b}") print(f"[+] alice reflected reply: {a1}") pa1 = parse(a1) n_a2, sig_a = all_of(pa1, "d") print(f"[+] reflected nonce from alice: {n_a2}") print(f"[+] reflected sig from alice: {sig_a[:32]}...") out = stateful("/bob", conn, f"d:{n_a2}|d:{sig_a}") print(f"[+] final bob response: {out}") return first(parse(out), "t") def main(): for i in range(8): print(f"=== Attempt {i + 1} ===") try: flag = attempt() print(f"FLAG: {flag}") return except Exception as e: print(f"[!] retrying after error: {e}") time.sleep(3) raise SystemExit("failed") if __name__ == "__main__": main()
$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar