Setup

mkdir v8
fetch v8
cd v8
./build/install-build-deps.sh
git reset --hard 7e4fdca2949570363b68db08adbbf17ee375d15c
gclient sync
patch -p1 < ../../promise.diff
tools/dev/gm.py x64.debug

Patch

--- src/builtins/promise-jobs.tq
+++ src/builtins/promise-jobs.tq
@@ -23,10 +23,8 @@ PromiseResolveThenableJob(implicit context: Context)(
   // debugger is active, to make sure we expose spec compliant behavior.
   const nativeContext = LoadNativeContext(context);
   const promiseThen = *NativeContextSlot(ContextSlot::PROMISE_THEN_INDEX);
-  const thenableMap = thenable.map;
-  if (TaggedEqual(then, promiseThen) && IsJSPromiseMap(thenableMap) &&
-      !IsPromiseHookEnabledOrDebugIsActiveOrHasAsyncEventDelegate() &&
-      IsPromiseSpeciesLookupChainIntact(nativeContext, thenableMap)) {
+  if (TaggedEqual(then, promiseThen) &&
+      !IsPromiseHookEnabledOrDebugIsActiveOrHasAsyncEventDelegate()) {
     // We know that the {thenable} is a JSPromise, which doesn't require
     // any special treatment and that {then} corresponds to the initial
     // Promise.prototype.then method. So instead of allocating a temporary

POC and Analysis

In the following code we can see the if statement which calls the TaggedEqual function to check if then and promiseThen are equal. If we bypass this check, PerformPromiseThen function is called where UnsafeCast is performed on thenable. thenable is our JSArray object as seen in the POC, which will be crafted as fake JSPromise object.

// src/builtins/promise-jobs.tq
// https://tc39.es/ecma262/#sec-promiseresolvethenablejob
transitioning builtin
PromiseResolveThenableJob(implicit context: Context)(
    promiseToResolve: JSPromise, thenable: JSReceiver, then: JSAny): JSAny {
  // We can use a simple optimization here if we know that {then} is the
  // initial Promise.prototype.then method, and {thenable} is a JSPromise
  // whose
  // @@species lookup chain is intact: We can connect {thenable} and
  // {promise_to_resolve} directly in that case and avoid the allocation of a
  // temporary JSPromise and the closures plus context.
  //
  // We take the generic (slow-)path if a PromiseHook is enabled or the
  // debugger is active, to make sure we expose spec compliant behavior.
  const nativeContext = LoadNativeContext(context);
  const promiseThen = *NativeContextSlot(ContextSlot::PROMISE_THEN_INDEX);
  if (TaggedEqual(then, promiseThen) &&
      !IsPromiseHookEnabledOrDebugIsActiveOrHasAsyncEventDelegate()) {
    // [...]
    // This is the same as just doing
    //
    //   PerformPromiseThen(thenable, undefined, undefined,
    //   promise_to_resolve)
    //
    // which performs exactly the same (observable) steps.
    return PerformPromiseThen(
        UnsafeCast<JSPromise>(thenable), UndefinedConstant(),
        UndefinedConstant(), promiseToResolve);
  } else {
    // [...]
  }
}

PromiseResolveThenableJob function takes 3 arguments promiseToResolve, thenable, and then. The question is what are these 3 arguments.

Here, the calling convention is: $rax, $rbx, $rcx, $rdx

Let’s put the breakpoint and run the d8 shell in gbd. I found 2 ways to set the breakpoints on the builtin functions:

  1. Setting breakpoint from the gdb, for instance:

    br Builtins_PromiseResolveThenableJob

  2. Putting breakpoint directly on the source code by adding DebugBreak() function on .tq file:

    // src/builtins/promise-abstract-operations.tq
    @export
    transitioning macro PerformPromiseThenImpl(implicit context: Context)(
        promise: JSPromise, onFulfilled: Callable|Undefined,
        onRejected: Callable|Undefined,
        resultPromiseOrCapability: JSPromise|PromiseCapability|Undefined): void {
        DebugBreak();
        if (promise.Status() == PromiseState::kPending) {
    
    

Lets inspect the arguments passed to the PromiseResolveThenableJob inside gdb:

Arguments

1. promiseToResolve = $rax = JSPromise
2. thenable = $rbx = JSArray (our fake crafted JSPromise)
3. then = $rcx = Function (Promise.prototype.then) which is also written in the source code.

Now in the if checks, TaggedEqual function is called which is to be bypassed to reach to the UnsafeCast or PerformPromiseThen function. In TaggedEqual 2 arguments were passed. then which is JSAny or Function (Promise.prototype.then) and promiseThen which is still unknown. Lets find out what the promiseThen is. As we can see the promiseThen is set with the following code:

const promiseThen = *NativeContextSlot(ContextSlot::PROMISE_THEN_INDEX)

With the simple guess we can say that, whatever the value in the slot i.e., NativeContextSlot with index ContextSlot::PROMISE_THEN_INDEX is the promiseThen.

cmp_then

As we can see cmp dword ptr [r8 + 0x3f], ecx, where dword ptr [r8 + 0x3f] is promiseThen and ecx is then. The comparision is equal if we run our POC.

cmp_promiseThen

if we dig into the $rsi (NativeContext) we can see that the ContextSlot::PROMISE_THEN_INDEX is index number 14 which is JSFunction then.

# dword ptr [r8 + 0x3f] = 0x082096f1 = promiseThen
# ecx = 0x082096f1 = then
cmp dword ptr [r8 + 0x3f], ecx 

If we bypass this comparision, PerformPromiseThen function is called where the first parameter thenable is UnsafeCasted to JSPromise. This basically means that it’ll typecast thenable to JSPromise without checking type. If we bypass the TaggedEqual check and craft the fake JSPromise object (thenable) it’ll cause the type confusion which we’ll confirm in the later section.

PerformPromiseThen will call another function PerformPromiseThenImpl where the first parameter i.e., typecasted thenable as JSPromise is passed as the promise.

// src/builtins/promise-abstract-operations.tq
// https://tc39.es/ecma262/#sec-performpromisethen
transitioning builtin
PerformPromiseThen(implicit context: Context)(
    promise: JSPromise, onFulfilled: Callable|Undefined,
    onRejected: Callable|Undefined, resultPromise: JSPromise|Undefined): JSAny {
  PerformPromiseThenImpl(promise, onFulfilled, onRejected, resultPromise);
  return resultPromise;
}

Because the promise is still in the “Pending” state, promise.Status() == PromiseState::kPending returns true where promise.reactions_or_result is overwritten with reaction i.e., New PromiseReaction. In this case the promise is our array i.e. fake JSPromise object, so the offset at the reactions_or_results is length of the array. As a result, the length of the array is overwritten by the pointer (reaction).

// src/builtins/promise-abstract-operations.tq
@export
transitioning macro PerformPromiseThenImpl(implicit context: Context)(
    promise: JSPromise, onFulfilled: Callable|Undefined,
    onRejected: Callable|Undefined,
    resultPromiseOrCapability: JSPromise|PromiseCapability|Undefined): void {
    DebugBreak();
  if (promise.Status() == PromiseState::kPending) {
    // The {promise} is still in "Pending" state, so we just record a new
    // PromiseReaction holding both the onFulfilled and onRejected callbacks.
    // Once the {promise} is resolved we decide on the concrete handler to
    // push onto the microtask queue.
    const handlerContext = ExtractHandlerContext(onFulfilled, onRejected);
    const promiseReactions =
        UnsafeCast<(Zero | PromiseReaction)>(promise.reactions_or_result);
    const reaction = NewPromiseReaction(
        handlerContext, promiseReactions, resultPromiseOrCapability,
        onFulfilled, onRejected);
    promise.reactions_or_result = reaction;
  } else {
    // [...]
  }
  promise.SetHasHandler();
}

As we can see in GDB, the assembly code mov dword ptr [rdi + 0xb], r11d] is moving r11d (PromiseReaction) to $rdi + 0xb where $rdi is our array and the offset 0xb is where the length of array resides. If this line of code is executed, the length of the array is overwritten by the PromiseReaction. As we can see in the following image rdi + 0xb is the length of the array.

reactions_or_results

We can confirm that the register r11 holds the pointer to the PromiseReaction.

reactions_or_results

As we can see the length of the array is overwritten by the pointer (PromiseReaction). As a result we got the huge oob.

array_length_reactions_or_results

If we run the poc:

oob

Writing Exploit