Published on

DICE CTF 2022 – vm-calc

Authors
  • avatar
    Name
    lemon 蔡
    Twitter
    Parrot Fanclub

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:

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.