Points: 100 (27 solves) Given: source.zip


  1. we are given a web app with outdated packages installed (jpv and jwt-simple)
  2. 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
  3. recover the public key from 2 jwt tokens
  4. sign a token using HS256 (symmetric) instead of RS256 (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 = {

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."};

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;

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:

  1. Produce the digest of the base64 encoded header and the base64 encoded payload -> dig = sha256(base64(header)+'.'+base64(payload))
  2. Pad the digest using PKCS1 v1.5 to the byte size of the modulus (n) -> pt = PKCS1_v1.5_ENCODE(dig,byte_size(n))
  3. 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}