The False Promise Plaidctf
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:
-
Setting breakpoint from the gdb, for instance:
br Builtins_PromiseResolveThenableJob
-
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:
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
.
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
.
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 UnsafeCast
ed 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.
We can confirm that the register r11
holds the pointer to the PromiseReaction
.
As we can see the length of the array is overwritten by the pointer (PromiseReaction)
. As a result we got the huge oob.
If we run the poc: