Description
Use the JaWT Scratchpad application. The admin user has the flag.
Solution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1
Log in and capture the JWTObservationI noticed the challenge is named 'JaWT Scratchpad' and the description says the admin user has the flag, which suggested the app uses JSON Web Tokens for authentication and that capturing our own token after login would reveal the structure we need to forge.Register or log in as any user (e.g., 'guest'). After login, inspect your cookies in browser DevTools. You will find a JWT (JSON Web Token) cookie - it looks like three base64url segments separated by dots.Learn more
A JWT has three parts:
header.payload.signature. Each part is base64url-encoded. The header specifies the algorithm (e.g., HS256), the payload contains claims like the username, and the signature verifies integrity.The payload is not encrypted - only signed. Anyone can decode and read the claims, but modifying them invalidates the signature (unless you can forge it).
Step 2
Decode the JWTObservationI noticed the JWT cookie contained three dot-separated segments of base64url-looking text, which suggested the payload (the middle segment) held the username claim and needed to be decoded to confirm what field controlled admin access.Paste your JWT into jwt.io or use Python to base64-decode the payload. You will see something like {"user": "guest"}. Your goal is to change this to {"user": "admin"}.pythonpython3 -c " import base64, json token = 'YOUR.JWT.HERE' payload = token.split('.')[1] # Add padding if needed payload += '=' * (4 - len(payload) % 4) print(json.loads(base64.urlsafe_b64decode(payload))) "What didn't work first
Tried: Decode all three JWT segments at once by splitting on dots and decoding each with base64.b64decode.
The header and signature segments decode fine, but the payload uses base64url encoding where '+' is replaced by '-' and '/' by '_'. Using standard base64.b64decode without first swapping those characters causes a binascii.Error on tokens that contain those substituted characters. Use base64.urlsafe_b64decode and add '=' padding to the payload segment specifically.
Tried: Paste the JWT into a generic base64 decoder website to read the payload.
Generic decoders treat the entire dot-joined token as one blob and either refuse it or produce garbled output. JWT has three distinct segments; only the middle segment is the payload. Use jwt.io or split on '.' and decode just index 1 of the resulting list.
Learn more
Base64url is a URL-safe variant of base64 that replaces
+with-and/with_and omits padding=. You may need to add padding back when decoding manually.Step 3
Crack the HMAC secret with hashcatObservationI noticed the JWT header specified HS256 and the server rejected alg:none tokens, which meant the only viable path to forging an admin token was recovering the symmetric HMAC secret via an offline dictionary attack using hashcat mode 16500.Save your JWT to a file, then run hashcat in JWT mode (-m 16500) against the rockyou wordlist. The secret key turns out to be 'ilovepico'. The server enforces HS256 and does not accept unsigned (alg:none) tokens, so key cracking is the only working path.bash# Save your token to a file (one token per line) echo 'YOUR.JWT.HERE' > token.txtbashhashcat -a 0 -m 16500 token.txt /usr/share/wordlists/rockyou.txtbash# After hashcat finishes, confirm the result hashcat -a 0 -m 16500 token.txt /usr/share/wordlists/rockyou.txt --showExpected output
YOUR.JWT.HERE:ilovepico
What didn't work first
Tried: Try the alg:none attack by changing the header algorithm to 'none' and stripping the signature, then submitting the modified token.
The server explicitly validates the algorithm field and rejects tokens that claim alg:none, so the server returns an error rather than accepting the unsigned token. The hint in the challenge detail states the server enforces HS256 and does not accept unsigned tokens. The only working path is recovering the actual HMAC secret via dictionary attack.
Tried: Run hashcat with mode -m 1400 (plain SHA-256) instead of -m 16500 (JWT HS256).
Mode 1400 hashes the raw input as SHA-256 without any JWT-aware structure, so it never produces a candidate that matches the JWT signature regardless of which wordlist you use. Mode 16500 is specifically designed to handle the JWT format: it constructs HMAC-SHA256(base64url(header) + '.' + base64url(payload), candidate) and compares it to the embedded signature. Using the wrong mode produces zero results even when the correct key is in the wordlist.
Learn more
HS256 (HMAC-SHA256) signs the JWT by computing
HMAC-SHA256(base64url(header) + "." + base64url(payload), secret). The signature is valid only if you know the secret. Because the secret is symmetric and shared, a weak secret can be brute-forced offline: you just try every candidate word and check whether it reproduces the observed signature.Hashcat mode
16500handles the JWT format natively. Given the full token string, it will iterate through the wordlist and stop when it finds the key that makes the signature match. The recovered key here isilovepico.John the Ripper is an alternative: convert the JWT to John format with a helper script, then run
john jwt.john --wordlist=rockyou.txt.Step 4
Forge the admin token and get the flagObservationI noticed hashcat recovered the secret 'ilovepico', which meant I could now re-sign a modified payload setting user to admin using PyJWT or jwt.io, producing a valid token the server would accept.With the secret 'ilovepico' in hand, go to jwt.io, paste your original token, change the payload to {"user": "admin"}, and enter 'ilovepico' in the secret field. The debugger will produce a correctly signed admin token. Alternatively, use PyJWT in Python. Copy the resulting token, replace your jwt cookie value in DevTools (Application > Cookies), and reload the page to see the flag.pythonpython3 -m pip install PyJWTpythonpython3 -c " import jwt token = jwt.encode({'user': 'admin'}, 'ilovepico', algorithm='HS256') print(token) "What didn't work first
Tried: Use jwt.encode without specifying algorithm='HS256', relying on the PyJWT default.
In PyJWT 2.x the algorithm parameter is required; omitting it raises a TypeError and no token is produced. In older PyJWT 1.x the default was HS256, but the installed version may differ. Always pass algorithm='HS256' explicitly to ensure compatibility and to make it clear which scheme is being used.
Tried: Manually base64url-encode a modified payload and concatenate header.payload with the original signature instead of re-signing.
The original signature was computed over the old header+payload string. After changing the payload to set user=admin, the encoded payload bytes differ, so the old signature no longer matches. The server verifies the HMAC on every request and rejects the token with an invalid signature error. You must recompute the HMAC-SHA256 using the recovered secret 'ilovepico' over the new header+payload string.
Learn more
Re-signing with the recovered key proves that the entire security model of this app rests on the secrecy of that one word. A strong, randomly generated secret (at least 256 bits) would make an offline dictionary attack computationally infeasible.
This challenge illustrates why JWT secrets must be treated like passwords: long, random, and never reused. The countermeasure is straightforward: use a cryptographically random secret at deploy time and store it in an environment variable, not in source code.
Alternate Solution
Use the JWT Decoder tool on this site to decode and inspect the token's header and payload in one click. Once you confirm the payload claims, crack the HMAC secret with John the Ripper instead of hashcat: convert the token to John format with a helper script, then run john jwt.john --wordlist=rockyou.txt. The recovered secret ilovepico can then be plugged into jwt.io to re-sign the admin payload.
Flag
Reveal flag
picoCTF{jawt_was_just_what_you_thought_...}
Forge a JWT with user=admin by cracking the weak HMAC secret ('ilovepico') with hashcat or John the Ripper, then re-sign the token.