cr0wnair -- Union CTF 2021
Points: 100 (27 solves) Given: source.zip
TL;DR
- we are given a web app with outdated packages installed (jpv and jwt-simple)
- jwt token signing is blocked by a pattern verification but it is bypassable if the constructor of the object has the same
name
as[].constructor
- recover the public key from 2 jwt tokens
- sign a token using
HS256
(symmetric) instead ofRS256
(asymmetric) with the public key
The web app
We are given a node.js application with 2 routes: checkin
and upgrades
.
In the checkin
route there’s a function called createToken
and a endpoint /checkin
from where we can call that function if some requirements are met.
In order to call createToken
the request body must be a valid json that meets the following pattern requirements and the extras array must have a object with sssr
as the key and FQTU
as the value.
const pattern = {
firstName: /^\w{1,30}$/,
lastName: /^\w{1,30}$/,
passport: /^[0-9]{9}$/,
ffp: /^(|CA[0-9]{8})$/,
extras: [
{sssr: /^(BULK|UMNR|VGML)$/},
],
};
JPV vulnerability
By looking at the pattern above it’s obvious that the extras array can’t have a object with FQTU
as a value. That’s where I checked if the JPV library that was verifying the pattern was up to date.
Surprise, surprise, it wasn’t.
By googling a bit I found out that the way the library verifies if the object is an array was totally broken. Looking at this issue (https://github.com/manvel-khnkoyan/jpv/issues/6) it’s obvious that all it is needed to bypass the array verification is to include an object with the same constructor name as an array and it magically lets us put FQTU
inside a valid object with the sssr
key and therefore sign jwt tokens.
const payload = {
"firstName":"s3np41",
"lastName":"k1r1t0",
"passport":"123456789",
"ffp":"CA12345678",
"extras":{
"payload":{
"sssr":"FQTU"
},
"constructor":{
"name":"Array"
}
}
};
JWT-simple vulnerability
Analysing the upgrades route it becomes obvious where the flag is stored.
router.post('/flag', [getLoyaltyStatus], function(req, res, next) {
if (res.locals.token && res.locals.token.status == "gold") {
var response = {msg: config.flag};
} else {
var response = {msg: "You do not qualify for this upgrade at this time. Please fly with us more."};
}
res.json(response);
});
Looking at the middleware getLoyaltyStatus
the token is being decoded with the public key. However no algorithm is being specified.
function getLoyaltyStatus(req, res, next) {
if (req.headers.authorization) {
let token = req.headers.authorization.split(" ")[1];
try {
var decoded = jwt.decode(token, config.pubkey);
} catch {
return res.json({ msg: 'Token is not valid.' });
}
res.locals.token = decoded;
}
next()
}
Looking at the version of the jwt-simple
library, the decode function is known to be vulnerable to a very popular jwt attack that consists in changing the value of the alg
in the header of the jwt to a symmetric algorithm (HS256
) rather than the asymmetric RS256
algorithm used for signing.
By changing the algorithm used to sign/decode from an asymmetric algorithm to a symmetric one it means that instead of using the private key to sign and the public key to decode it changes to using the same key to sign and decode the jwt.
Bingo!
If it uses the public key to decode, it means that a token signed with the public key and the HS256
algorithm is a valid token.
Unfortunately, the public key is redacted from the source.
Recovering the public key
To recover the public key, one first needs to understand a simple overview of the RSA with SHA256
(or RS256
for short) algorithm.
The steps for signing the jwt are the following:
- Produce the digest of the base64 encoded header and the base64 encoded payload ->
dig = sha256(base64(header)+'.'+base64(payload))
- Pad the digest using
PKCS1 v1.5
to the byte size of the modulus (n) ->pt = PKCS1_v1.5_ENCODE(dig,byte_size(n))
- Modular exponentiation using the padded digest, the private exponent (d) and the modulus (n) ->
sig = pt ** d % n
The last equation can be rewritten as sig ** e = pt + k*n
. Since the padding is deterministic and the plaintext is known we can rewrite it once again as sig ** e - pt = k*n
.
By signing 2 tokens, 2 values for k*n
are retrievable and to obtain n
the only thing needed is the greatest common divisor between k1*n
and k2*n
.
Assuming e = 65537
: n = gcd(sig1**65537 - pt1, sig2**65537 - pt2)
From now on, it is only needed to sign a token with the PEM version of the public key (as a literal string) and with the HS256
algorithm and send it to the server.
Et voilá: union{I_<3_JS0N_4nD_th1ngs_wr4pp3d_in_JS0N}