Protocol Analysis 8: Reflection
Protocol Analysis 8: Reflection
Platform: CUSTOM | Category: Crypto | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: identity_reflection, nonce_reuse_abuse, reflection_attack, role_confusion, signature_relay
Summary
Walkthrough of the CUSTOM Crypto Protocol Analysis 8: Reflection. 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 A
title: "Protocol Analysis 8: Reflection" id: 20260410_metactf_protocol_analysis_8_reflection category: crypto difficulty: medium event: metactf tags: ["rsa", "authentication", "certificate", "protocol_analysis", "dawgctf", "nonce_exchange", "signature_relay", "reflection_attack", "role_confusion"] techniques: ["identity_reflection", "nonce_reuse_abuse", "reflection_attack", "role_confusion", "signature_relay"] tools: ["python3", "requests"] indicators: ["both parties start in the same protocol role and send symmetric opener messages", "Bob reveals a nonce and signature before receiving Alice's final proof", "Alice accepts Bob's certified identity as the peer in her recv step", "util/asym_verify only succeeds over literal typed text such as t:n:bob|d:<nB>|d:<nA2>", "Bob accepts Alice's signature only when the signed identity field is bob rather than alice"]
Protocol Analysis 8: Reflection — metactf
Description
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.
Analysis
The PDF excerpt says both Alice and Bob begin by sending their own public key, identity, and certificate:
text read-only • copy allowedAlice: send pubA, A, certA Bob: send pubB, B, certB
Then Bob expects:
text read-only • copy allowedpubX, X, certX, nX1
and responds with:
text read-only • copy allowednA, {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/8creates a fresh instance and returnsconn_id.POST /aliceandPOST /bobare stateful endpoints that must share that sameconn_id.- Sending JSON
{"conn_id": ..., "content": ""}triggers the participant's initial send. - Messages are pipe-delimited typed fields such as
k:<hex>,n:<name>,d:<hex>,t:<text>.
Observed openers:
text read-only • copy allowedAlice: 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:
text read-only • copy allowedk:<pubA>|n:alice|d:<certA>|d:<n1>
Bob replies with two data fields:
text read-only • copy allowedd:<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:
text read-only • copy allowedk:<pubB>|n:bob|d:<certB>|d:<nB>
Alice answers with:
text read-only • copy allowedd:<nA2>|d:<sigA>
Verification probes showed that sigA is over the literal text:
text read-only • copy allowedt:n:bob|d:<nB>|d:<nA2>
not the PDF-looking variant with alice in the first field. Bob then accepts the final relay:
text read-only • copy allowedd:<nA2>|d:<sigA>
and returns:
text read-only • copy allowedt: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.
Solution
- Create a fresh protocol instance with
POST /model/8. - Trigger Alice and Bob with empty content to collect their opener messages.
- Send Bob Alice's opener plus an attacker-chosen nonce
n1. - Parse Bob's response to recover Bob's nonce
nB. - Send Alice Bob's opener plus Bob's nonce
nB. - Alice returns
nA2and a signature over the literal textt:n:bob|d:<nB>|d:<nA2>. - Relay
d:<nA2>|d:<sigA>to Bob. - Bob accepts and returns the flag.
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.
python read-only • copy allowed#!/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()
Key Takeaways / Lessons
- Mastering identity_reflection, nonce_reuse_abuse, reflection_attack, role_confusion, signature_relay was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-04-10 • CUSTOM • Protocol Analysis 8: Reflection • Difficulty: Medium