← Back to Writeups
CUSTOMMediumCrypto

Protocol Analysis 6: Sneedham-Chucker

XESXOR9/10/202610 min read
#crypto#custom#medium#lowe_mitm#needham_schroeder_public_key_a#re_encryption_relay#nonce_reflection#cross_session_key_reuse#aead_with_deterministic_nonce

Protocol Analysis 6: Sneedham-Chucker

Platform: metactf | Category: Crypto | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-04-10 | Status: Solved Techniques: lowe_mitm, needham_schroeder_public_key_attack, re_encryption_relay, nonce_reflection, cross_session_key_reuse, aead_with_deterministic_nonce

Summary

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

Protocol Analysis 6: Sneedham-Chucker — metactf

Description

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.

Protocol

From the PDF manual:

text
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).

Service primer

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, sneed
    • asym_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) → plaintext
    • hash_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".

Vulnerability — Lowe's attack on Needham-Schroeder

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):

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

Implementation details I had to figure out

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.

1. PDF lies about asym_encrypt / asym_decrypt

The 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:

text
$ util.asym_encrypt(pub, "hello") -> d:<ct>
$ util.asym_decrypt(priv, ct)     -> "hello"   # works
$ util.asym_decrypt(pub, ct)      -> error

2. Empty content triggers the next send

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

3. Content framing for re-encryption

When encrypting {nA, pubA, A, certA}pubB we must pass the INNER items as the plaintext to asym_encrypt:

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

4. hash_data hashes ASCII, not raw bytes

text
$ 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.

5. Symmetric-encrypt output format is pure ciphertext + tag

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 bytesenc hex lengthexpected (2*N + 32)
13434
135858
268484
288888

The 26-byte and 28-byte ciphertexts share their first 52 hex chars, confirming stream-cipher / CTR-mode encryption (ChaCha20-Poly1305 or AES-GCM).

6. Nonce for the flag encryption = 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:

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

7. The decrypted plaintext has a t: prefix

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

8. Server flakiness

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:

  • DB errors and HTTP 5xx → retry with backoff; the state wasn't touched.
  • HTTP 4xx (404/403) → the session is dead, start over with a new conn_id.
  • HTTP 200 with a bad content → do not retry; the state machine already advanced.

The server also went fully offline (connection refused) for several minutes at a time during the event — plan for retry loops.

Exploitation — full solver

python
#!/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:

text
=== Attempt 1 ===
conn = 740338213037998362
pubB ok
nA = 49e460d2815b82290a4658e393b033f51523de9d4ccf34a89e1c1e5a4b8f692c
nB = 516b21fa08a1a37427cc3a1bb97c262ee9c1adbb087b59e1a7d21316da929563
enc_flag = c62650d3e0b40c08e66114117bbc42f3aec44c909e8edb0fb3eee4ee5c308e8e2058f13fbaedc1a2fc46

FLAG: DawgCTF{REDACTED}