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.
Setup
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.
wget https://artifacts.picoctf.net/c/494/bookshelf-pico.zip && unzip bookshelf-pico.zippip3 install pyjwtSolution
Want to try it yourself first?
The guided walkthrough reveals hints one step at a time.
Step 1Find the JWT secret in the source code
ObservationThe challenge hands over the full Spring Boot source. Grep it for JWT secret keywords across the Java and config files first, since a hardcoded credential is the likeliest bug when the source is given to you.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.bashgrep -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 here, because the secret is not in any configuration file at all: it is hardcoded in a Java class. Grep only the property files and you miss the source entirely, so include the Java files 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
grepfor common secret-related keywords will quickly surface the value. Look for a literal assignment likeprivate static final String SECRET = "verysecretkey";or a properties line such asjwt.secret=verysecretkey. In real-world code reviews this is also the first thing security auditors check.Step 2Decode the existing JWT and verify the role claim
ObservationThe app issues real JWTs on login, so decode one first to learn the exact custom claim names. Forge with the wrong names and the server rejects the token even when the secret is right.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.bashcurl -s -X POST http://<HOST>/login -H 'Content-Type: application/json' -d '{"username":"user","password":"user"}' | jq .bashbase64 -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 rather than a scope, email rather than a subject. Skip the decode and you forge a token with standard names the Spring filter does not recognize, so the server returns a 403 despite the right secret and 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 asrole.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 customuserIdclaim and anemailclaim instead of the standardsubfield. Two things to verify before forging: (1) confirm the exact claim names (role, userId, email) so you know what to change, and (2) note theexpvalue. 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.
Step 3Forge a JWT with role: Admin, userId: 2, and email: admin
ObservationThe decoded payload shows a custom role claim driving authorization and a userId field in place of the standard subject. Craft an HS256 token with role set to Admin, the matching userId and email, signed with the discovered secret.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 adminpythonpython3 -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, before granting access to the flag book. Leave the user id or email as the ordinary user's and Spring Security authorizes the wrong context, so the book stays out of reach even with a valid signature.
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 outright, so the server returns a 401 or a 500 because a real algorithm is required. That attack only lands on very old or misconfigured libraries. Here the secret is already known, so sign properly with HS256.
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
roleshould ideally be looked up from the database on each request rather than trusted directly from the token.Step 4Inject the forged token and read the flag book
ObservationThe app is a React single-page application keeping its JWT in localStorage under auth-token. Replace that value in DevTools, or send the bearer token with curl, and the frontend requests the restricted flag book.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 -> refreshbash# curl method (downloads flag.pdf):bashcurl -H 'Authorization: Bearer <FORGED_TOKEN>' http://<HOST>/base/books/pdf/5 --output flag.pdfLearn 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/flagroute. 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 carriesrole: Adminand the matchinguserId. 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.
- JWT DecoderDecode JSON Web Tokens and inspect the header, payload, and signature. Useful for web exploitation challenges.
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).