Java Code Analysis!?! picoCTF 2023 Solution

Published: April 26, 2023

Description

A Spring Boot REST API issues JWT tokens for authentication. The source code is provided. Find the JWT signing secret, forge a token with an elevated role claim, and use it to access the admin-only endpoint that reveals the flag.

Download and unzip the source code.

Start the web application as instructed or connect to the provided URL.

Optionally install the jwt-cli tool or use jwt.io to inspect tokens.

bash
wget https://artifacts.picoctf.net/c/494/bookshelf-pico.zip && unzip bookshelf-pico.zip
bash
pip3 install pyjwt

Solution

Want to try it yourself first?

The guided walkthrough reveals hints one step at a time.

Walk me through it
  1. Step 1
    Find the JWT secret in the source code
    Observation
    I noticed the challenge provided the full Spring Boot source code, which suggested doing a targeted grep for common JWT secret keywords across all Java and config files before anything else, since hardcoded credentials are the most likely vulnerability in a source-code challenge.
    Look in the security package for a SecretGenerator class. The code tries to read a secret from a file, but if that file does not exist it falls back to generating a random string. The catch: the fallback always returns the hardcoded value "1234". That is the JWT signing secret.
    bash
    grep -rn 'secret\|SECRET\|jwtKey\|signing' . --include='*.java' --include='*.properties' --include='*.yml'

    Expected output

    io/github/noxrepo/pico/security/SecretGenerator.java:12:    private static final String DEFAULT_SECRET = "1234";
    What didn't work first

    Tried: Grep only application.properties and application.yml for the JWT secret

    Those files come up empty in this project because the secret is not in any configuration file - it is hardcoded in a Java class (SecretGenerator.java). Limiting the grep to property files misses Java source entirely; the --include='*.java' flag is required to surface the constant.

    Tried: Assume the fallback secret is a randomly generated value and try to brute-force it

    The catch block does not call a random generator - it returns the literal string "1234". Spending time on offline dictionary or brute-force attacks wastes effort; reading the SecretGenerator source directly reveals the hardcoded value in under a minute.

    Learn more

    Spring Boot applications that use JSON Web Tokens (JWT) must sign each token with a secret key to prevent tampering. This secret is typically stored in application.properties, application.yml, or as a constant in a configuration class. Hardcoding secrets in source code is a critical security flaw: if the code is ever leaked or open-sourced, every token the application issues can be forged.

    In CTF challenges providing source code, a targeted grep for common secret-related keywords will quickly surface the value. Look for a literal assignment like private static final String SECRET = "verysecretkey"; or a properties line such as jwt.secret=verysecretkey. In real-world code reviews this is also the first thing security auditors check.

  2. Step 2
    Decode the existing JWT and verify the role claim
    Observation
    I noticed this app issues real JWTs on login, so decoding an existing token's payload was necessary to discover the exact custom claim names (role, userId, email) before forging, since using wrong claim names would make the server reject the forged token even with the correct secret.
    Log in with a regular account to obtain a token, then base64-decode the payload. Confirm the fields - the app uses role: "Free" (not "USER"), userId: 1, and email rather than the standard sub claim. Note these exact names before forging.
    bash
    curl -s -X POST http://<HOST>/login -H 'Content-Type: application/json' -d '{"username":"user","password":"user"}' | jq .
    bash
    base64 -d <<< '<JWT_PAYLOAD_BASE64>'
    What didn't work first

    Tried: Decode the entire raw JWT string with base64 -d in one shot

    The dots that separate the three JWT segments are not valid base64 characters, so decoding the full token produces garbled output or an error. You must split on '.' first and decode only the middle (payload) segment on its own.

    Tried: Forge the token immediately after finding the secret without decoding an existing token first

    This app uses non-standard claim names - role instead of a standard scope, and email instead of sub. Skipping the decode step leads to forging a token with standard claim names that the Spring filter does not recognize, so the server returns 403 even with the correct secret and HS256 algorithm.

    Learn more

    A JWT has three dot-separated base64url-encoded parts: header, payload, and signature. The payload contains claims like sub (subject/username), exp (expiration), and application-specific claims such as role.

    To decode manually: split the token on ., take the middle section (payload), pad it to a multiple of 4 characters, and base64-decode it. You will see JSON like { "role": "Free", "iss": "bookshelf", "exp": 1234567890, "iat": 1234567890, "userId": 1, "email": "user" }. Note that the application uses a custom userId claim and an email claim instead of the standard sub field. Two things to verify before forging: (1) confirm the exact claim names (role, userId, email) so you know what to change, and (2) note the exp value. Spring Security rejects tokens with bogus expirations, so your forged token must use a current Unix timestamp + a positive offset (e.g. now + 3600).

    You can also use jwt.io to inspect tokens visually, or read the JWT and cookie attacks for CTF guide for the full forge-and-replay workflow.

  3. Step 3
    Forge a JWT with role: Admin, userId: 2, and email: admin
    Observation
    I noticed from the decoded payload that the app uses a custom role claim for authorization and a userId field (not sub), which suggested that crafting a new HS256 token with role set to Admin, userId to 2, and email to admin using the discovered secret '1234' would grant admin-level access.
    Using the discovered secret (1234), sign a new token at jwt.io with role set to Admin, userId set to 2, and email set to admin. The payload uses a custom userId field (not the standard sub claim), and you must keep all three claims correct or the server will reject the token or deny access to the flag book.
    bash
    # Use jwt.io: paste your token, change secret to '1234'
    bash
    # In payload: set role to Admin, userId to 2, email to admin
    python
    python3 -c "
    import jwt, time
    payload = {'role': 'Admin', 'iss': 'bookshelf', 'exp': int(time.time())+3600, 'iat': int(time.time()), 'userId': 2, 'email': 'admin'}
    token = jwt.encode(payload, '1234', algorithm='HS256')
    print(token)
    "
    What didn't work first

    Tried: Set only the role claim to Admin and leave userId and email unchanged from the original token

    The server checks all three claims together - role, userId, and email - to grant access to the flag book endpoint. Keeping userId as 1 or email as 'user' causes Spring Security to authorize the wrong user context, and the Flag book (book ID 5) remains inaccessible even though the signature verifies correctly.

    Tried: Try the 'none' algorithm attack by removing the signature and setting alg to none in the header

    Modern Spring Boot JWT libraries reject the 'none' algorithm by default. The server will return a 401 or 500 error because the library enforces a required algorithm. This attack only works against very old or misconfigured libraries; in this challenge the secret is already known so HS256 with '1234' is the correct approach.

    Learn more

    JWT signatures use HMAC-SHA256 (HS256) or RSA (RS256). When the secret is known, forging a token with arbitrary claims is trivial: construct the header and payload JSON, sign with the secret, and concatenate. The server cannot distinguish your forged token from a legitimate one because it only verifies the signature - it trusts the claims inside.

    This is why secrets must be truly secret and preferably long random values. The OWASP JWT Security Cheat Sheet recommends at least 256-bit secrets for HS256. Additionally, important claims like role should ideally be looked up from the database on each request rather than trusted directly from the token.

  4. Step 4
    Inject the forged token and read the flag book
    Observation
    I noticed the app is a React single-page application that stores the JWT in localStorage under auth-token, which suggested replacing that value in DevTools (or sending the Bearer token directly via curl) would cause the frontend to request the restricted Flag book at /base/books/pdf/5.
    Open browser DevTools (F12), go to Application > Local Storage, find the auth-token key, and replace its value with your forged JWT. Refresh the page. The Flag book will now appear unlocked in the bookshelf - click it to reveal the flag. Alternatively, use curl to request the flag book PDF directly with the Bearer token.
    bash
    # Browser method: DevTools -> Application -> Local Storage -> auth-token -> paste forged JWT -> refresh
    bash
    # curl method (downloads flag.pdf):
    bash
    curl -H 'Authorization: Bearer <FORGED_TOKEN>' http://<HOST>/base/books/pdf/5 --output flag.pdf
    Learn more

    The application is a single-page React frontend backed by a REST API. The JWT lives in localStorage under the key auth-token. Replacing that value and refreshing causes the frontend to re-authenticate with your forged token, granting the admin view that shows all books including the restricted Flag book (book ID 5).

    There is no dedicated /admin/flag route. Instead, the flag is stored as a PDF accessible at /base/books/pdf/5. The Spring Security filter grants access to that endpoint only when the Bearer token carries role: Admin and the matching userId. Once you have the PDF (whether downloaded via curl or viewed in the browser), the flag string is printed inside it.

    This challenge demonstrates one of the most common JWT pitfalls: trusting claims inside the token without server-side authorization checks. The correct approach is to store roles and permissions in a database and look them up by the token's subject on every request, treating the JWT only as an authentication credential, not an authorization source.

Interactive tools
  • Strings ExtractorPull printable text from any binary, library, or image. ASCII and UTF-16 detection, configurable minimum length, flag-like highlight, no command line needed.
  • Hex ViewerView text or raw hex bytes as a xxd-style hex dump with byte offset, hex columns, and ASCII sidebar. Highlights printable characters and null bytes.

Flag

Reveal flag

picoCTF{w34k_jwt_n0t_g00d_...}

Per-instance flag. The prefix picoCTF{w34k_jwt_n0t_g00d_ is consistent across solvers but the hex suffix varies per instance (observed: ca4d9701, b19432c9, 7745dc02).

Key takeaway

JSON Web Tokens provide integrity guarantees only as strong as the secrecy of their signing key. When an application embeds a weak or hardcoded HMAC secret in its source code, any party who reads that code can forge tokens with arbitrary claims, including elevated roles. The deeper design flaw is storing authorization data (role, permissions) inside the client-held token rather than looking it up server-side on every request; even a properly secreted JWT signing key does not eliminate the need for server-side authorization checks.

Related reading

Want more picoCTF 2023 writeups?

Useful tools for Web Exploitation

What to try next