返回 TI 主页

Overview

The vulnerability was discovered in June 2025 Month 30 The vulnerability was fixed by Google and confirmed to exist in the wild. Two days later, the relevant POC By researcher @DarkNavyOrg In X. The results after running are as follows.


Detailed analysis

The proof of concept for the vulnerability is very simple. The core is the function f. Combined with the crash, we can see that what f returns is actually a hole. Hole is a special variable in v8 that is used to represent the empty variable position in the middle of the shape [1,,3].

function f() {
let x;
delete x?.[y]?.a;
return y;
let y;
}
let hole = f();
let map = new Map();
map.delete(hole);

The hole It is so special because the value is v8 A variable that should not be exposed to the user can often be used to construct an exploitable read-write atom through a hole , thereby achieving code execution. The most recent example is through a hole. The implementation of code execution is 2023 The wild vulnerability CVE-2023-3079 in 2023 , security researcher mistymntncop also disclosed the related exploit method https://github.com/mistymntncop/CVE-2023-3079 , and this time CVE- 2025-6554​​​​​ The emergence of a new hole The exploit has already been used by attackers.

Let's look at the core f function, which is passed through let Two variables x and y are defined in this way.

1. x Variables through let There is no assignment after the definition, so in this case x The value is undefined status.

2. y Variables are also defined through let defined, but here y is used before it is defined, so in fact y Is in a Temporary Dead Zone ( TDZ ) status.

Temporary Dead Zone ( TDZ ) Refers to javascript In , variables declared using let or const cannot be accessed before they are initialized. If you try to access them, a ReferenceError will be thrown. The error is as follows:

function g() {
  return x;
  let x = 1; // ReferenceError
}

In fact , The same is true in the above example, and the core of the vulnerability should be delete x?.[y]?.a ; after all, the entire POC There is no other code in the code, so the initial analysis is delete x?.[y]?.a ; and y The temporary dead zone causes return y What is returned is a hole.

Let's look at this core code delete x?.[y]?.a ;

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

Directly from the code level, x?.[y] uses optional chaining ( Optional Chaining ) operator ?. , If x Not null or undefined , access x[y], otherwise return undefined. x?.[y]?.a Similarly, if x[y] exists, access its property a , otherwise return undefined , because x yes undefined , so x?.[y] returns undefined , delete is used to delete properties on an object, since x?.[y] will return undefined , the entire expression becomes delete undefined .a , delete undefined .a is legal, and the result is true .

But due to let y is in return y After that, y is in the TDZ . Therefore , a ReferenceError is thrown and the function terminates without executing return y , so y At first glance, it will not be assigned. Of course, the above situation is a simple analysis without a vulnerability, but the actual situation is that return y The hole is returned , causing the vulnerability.

diff for this vulnerability As follows, in the function OptionalChainNullLabelScope The hole_check_scope_ has been added , which means that in the vulnerable version , it is indeed possible to parameterize the hole variable at this position of the function.

https://chromium.googlesource.com/v8/v8.git/+/22e9d9621de58ec6fe6581b56215059a48451b9f%5E%21/#F0

Check the reference location of the corresponding function and find that there are two:

Both functions are in BytecodeGenerator In this class, one of the functions is BuildOptiontChain.

Another function is VistDelete.

The core changes are summarized as follows: In the BuildOptionalChain function, the corresponding HoleCheckElisionScope is deleted from the BuildOptonalChain function , but between hole_check_scope_ The previous line of code deleted is the patch function OptionalChainNullLabelScope , which adds hole_check_scope_. Here, BuildOptonalChain is more like a code optimization, so our main focus will be on VistDelete.

Here our first question is, why does y return hole in the following function? At first I thought it was the same as delete .x?.[y]?.a; , However, this idea is actually wrong.

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

TDZ mechanism mentioned earlier. As mentioned above, the Temporary Dead Zone ( TDZ ) refers to the situation in JavaScript where variables declared using let or const are inaccessible before initialization. For example , y in the vulnerable function is actually such a case. y is used in the delete expression, but the definition of let However, after y is used, it is mentioned in the definition of ES6 that when a variable in the TDZ state is accessed, a ReferenceError will be caused. This is also mentioned in our previous analysis. When x?.[y] => x.[y] accesses y , the corresponding REferenceError will be thrown, and the corresponding function will return directly without executing further.

v8, this feature is implemented through hole , that is, a variable in a TDZ-like state has a value of hole in v8. When v8 detects the special value of hole , it will return ReferenceError through the function ThrowReferenceErrorIfHole. For details, see the following v8 document.

https://docs.google.com/document/d/1klT7-tQpxtYbwhssRDKfUMEgm-NS3iUeMuApuRgZnAw/edit?tab=t.0

At this point, we understand that in v8 , when the y defined in poc is in the TDZ state, its default value is hole. Our research question also starts from the previous delete x?.[y]?.a; How did y become hole and become delete x?.[y]?.a; to return y Why is the hole y not detected , resulting in the hole value being returned?

Here, we use d8 's --print-bytecode to output the bytecode generated by the POC. The bytecode corresponding to the leak_hole function is as follows. You can see that y is saved to the r1 register, and its initial value is a hole , while r2 is a let x , since x is not initialized, its value is undefined. Go down to the seventh line JumpIfUndefinedOrNull to determine whether x is undefined or null. If so, jump to line 25. In fact, our code here conforms to this jump logic, jumps to line 25, and then goes down to line 26 Ldar r1(y) and returns directly. You can see that hole returns directly, and no detection occurs when returning. The detection at line 9 in the bytecode is x?.[y] is generated. The premise here is that x cannot be undefined / null. In this case, line 11 will use ThrowReferenceErrorIfHole to determine whether y is a hole. If it is a hole , a ReferenceError will be thrown directly. Therefore, from the bytecode point of view, when the value of x is undefined or null , there is indeed no hole detection for y on the execution path , which directly leads to the return of the hole value.

Let's see why the bytecode stage does not call ThrowReferenceErrorIfHole on y when returning in the branch where the value of x is undefined or null ?

Search for the call of ThrowReferenceErrorIfHole function. Although there are many, through the previous related functions, we can know that the functions involved in this vulnerability are basically located in Ignition The interpreter's bytecode generation related code is in bytecode-generator.cc , and there is only one call to ThrowReferenceErrorIfHole in this file.

The function it calls is BytecodeGenerator::BuildThrowIfHole , which is used to insert an instruction in the bytecode generation stage to check whether a variable is a " hole " (that is, undefined or in TDZ , which is ThrowReferenceErrorIfHole at line 11 in our previous bytecode ). If so, a corresponding error is thrown. In the function detection, this must be specially handled first, because this uses the hole value to track whether supre is called in the derived class constructor. In addition to this , the function ThrowReferenceErrorIfHole is used to detect whether the variable is a hole. Finally , RememberHoleCheckInCurrentBlock is called. This function is very important, but we will explain it in detail later.

BytecodeGenerator::BuildThrowIfHole is called in the following places in bytecode-generator.cc :

One of the corresponding functions is used to generate assignment-related bytecodes, which is not very relevant to our POC.

The rest of the references are focused on BytecodeGenerator::BuildVariableLoad , which generates the corresponding Ignition bytecode for variable reading operations in JavaScript.

The core of the function is a switch statement that selects different loading methods according to the value of variable -> location().

The first local variable loading: VariableLocation::LOCAL corresponds to the following code, and you can see that it is exactly the same as the return of y.

function foo() {
  let x = 42;
  return x; // This will trigger LOCAL loading
}

In the code module of VariableLocation::LOCAL , we can see that the corresponding BuildThrowIfHole is called. However, through the previous bytecode analysis, we know that there is actually no ThrowReferenceErrorIfHole related generation after return , that is, our BuildThrowIfHole may not be called. In addition to BuildThrowIfHole itself, the function that affects the call of BuildThrowIfHole is VariableNeedsHoleCheckInCurrentBlock.

You can directly set a breakpoint on BuildThrowIfHole to see which variables are detected by the hole in the poc. After running, you can see that BuildThrowIfHole is only called once, which is in the VisitDelet mentioned above. The function is to generate the corresponding bytecode for the delete expression in JavaScript (when the value of x is not undefined or null , the ThrowReferenceErrorIfHole detection in line 11 bytecode ), that is, it is indeed When the return y related instructions are generated , there is no corresponding BuildThrowIfHole call to detect whether the y variable is a hole , which results in return returning a hole.

Combined with the above stack calls, by analyzing VisitDelete , we can see that it is just processing the optional chain ( Optional Chaining ), else if ( expr -> IsOptionalChain () ) After this branch (which mainly handles similar delete obj?. prop; In the VisitForAccumulatorValue operation , BuildThrowIfHole is finally called for hole detection of the y variable , and the previous line of code of VisitForAccumulatorValue is the main patch function of the vulnerability, OptionalChainNullLabelScope.

OptionalChainNullLabelScope function simply adds a hole_check_scope_ . The core of its definition is HoleCheckElisionScope. How does this function implement the patch to prevent the hole in return y from returning?

Interestingly, HoleCheckElisionScope is closely related to RememberHoleCheckInCurrentBlock mentioned earlier and the function VariableNeedsHoleCheckInCurrentBlock that affects the call of BuildThrowIfHole in BytecodeGenerator::BuildVariableLoad. To understand the relationship between them , we need to introduce hole_check_bitmap_ This is the key variable.

hole_check_bitmap_ , this variable is used in the BytecodeGenerator class to record a basic Whether the variables in the block have completed hole detection is mainly managed and used by the functions HoleCheckElisionScope and HoleCheckElisionMergeScope , which is somewhat similar to the bitmap in cfg in the Windows kernel.

As shown below, the HoleCheckElisionScope class is a scope class used to execute basic The hole check is ignored in the block. In simple terms, when entering a basic When a hole is detected , a HoleCheckElisionScope class is called to generate a hole_check_bitmap_ , which saves the previous hole_check_bitmap_ and generates a new blank hole_check_bitmap_. In this scope, once a variable has been hole checked , the record will be written to the hole_check_bitmap_. In order to ensure that in the same scope, a variable's hole only needs one hole check, so as to improve the execution efficiency of the engine. When a scope ends , the destructor of the corresponding HoleCheckElisionScope class is called , hole_check_bitmap_ Restore to the previous state.

hole_check_bitmap_​​​​​ It is usually used by RememberHoleCheckInCurrentBlock and VariableNeedsHoleCheckInCurrentBlock. RememberHoleCheckInCurrentBlock is mainly used in the previously mentioned function BuildThrowIfHole. After a variable is hole-checked through ThrowReferenceErrorIfHole , RememberHoleCheckInCurrentBlock is called to record in hole_check_bitmap_ that the variable has passed the hole check. No further check is required in this block. Its specific implementation is in the function RememberHoleCheckInBitmap.

VariableNeedsHoleCheckInCurrentBlock is usually used to determine whether a variable needs to be hole checked in the current block. It also depends on hole_check_bitmap_ The records in.

The above code, we can summarize as follows : V8 records a basic through hole_check_bitmap_ The variable hole in black detects the situation, and each new condition executes basic block will call the HoleCheckElisionScope class to record the previous hole_check_bitmap_privbitmap , and generate a blank hole_check_bitmap_currentbitmap to record the current basic The hole detection status of the variable of the block , in the current basic In black , VariableNeedsHoleCheckInCurrentBlock is called to check the currentbitmap to determine whether a variable has been checked for a hole state. If not, BuildThrowIfHole is called to check whether the variable is a hole. In essence, a ThrowReferenceErrorIfHole bytecode is generated for hole detection of the variable. If it is a hole , a ReferenceError is thrown , and then the variable is left as a hole in the currentbitmap through the function RememberHoleCheckInCurrentBlock. A checked record indicates that the variable does not need to be checked in this block. When the block returns, the current state in the currentbitmap is cleared and hole_check_bitmap_ Set to privbitmap.

That is to say, for a js function, except for the blank hole_check_bitmap_ generated at the beginning , each time a conditional basic block , an independent blank hole_check_bitmap_currentbitmap will be generated to record the hole of the variable checked state, and at the end of the basic When the block is restored , the previous hole_check_bitmap_privbitmap is restored. The purpose of this is to ensure that each basic The scope integrity of the block , if you enter a basic If HoleCheckElisionScope is not called to generate an independent hole_check_bitmap_currentbitmap when the block is executed , then in the basic Any modification to the hole_check_bitmap_ in the block may be carried out of the basic block , and thus cause a vulnerability.

The most typical example is to enter a function vul , generate the default basic hole_check_bitmap_basicbitmap , basicbitmap is blank , and enter a basic block , in which HoleCheckElisionScope is not called to generate an independent currentbitmap , but BuildThrowIfHole is called in this block , and ThrowReferenceErrorIfHole detection bytecode is generated. Since no independent currentbitmap is generated , the y variable is hole The checked state is actually written to the basicbitmap through RememberHoleCheckInCurrentBlock. At this time, since x is undefine , the bytecode level will jump directly to return y , then in the corresponding VariableNeedsHoleCheckInCurrentBlock function, check whether y has been detected as a hole state. basicbitmap , the y here has been detected, the BuildThrowIfHole function is skipped, and return directly returns the y value of hole , thus causing a vulnerability.

Here we set a breakpoint directly on BuildThrowIfHole for the vulnerable version. We can see that BuildThrowIfHole is only called once after the POC is triggered, and its source is the function VisitDelete.

In VisitDelete , the location where BuildThrowIfHole is finally called is VisitForAccumulatorValue. From the previous analysis, we can see that the patch for this vulnerability is the OptionalChainNullLabelScope in front of VisitForAccumulatorValue. HoleCheckElisionScope is added to this function , which ensures that the y of BuildThrowIfHole in the function VisitDelete is hole The checked flag is only maintained within the VisitDelete function to prevent the hole check of y from being skipped during the subsequent return , thus returning y as a hole.

Running the POC in the patched v8 version , you can see the following return Before y is returned, an additional ThrowReferenceErrorIfHole instruction is generated to ensure that the y value of the hole will not be returned.

Specifically, the first BuildThrowIfHole is returned in VisitDelet as before.

Continuing down, in the return operation of the patch version VisitReturnStatement , BuildVariableLoad calls BuildThrowIfHole to generate a ThrowReferenceErrorIfHole again , thereby preventing the return of the hole value. The root cause is that BuildThrowIfHole is called in the previous VisitDelete , and y is recorded by RememberHoleCheckInCurrentBlock. hole The checked state is patched by the OptionalChainNullLabelScope function in VisitDelete The HoleCheckElisionScope in is limited to the bytecode generated by VisitDelete.

When return When BuildVariableLoad corresponding to y is called, the hole_check_bitmap_ detected by VariableNeedsHoleCheckInCurrentBlock does not record that the y value has been holed The check is checked , so BuildThrowIfHole is called again , which confirms that y is a hole and throws a ReferenceError , preventing the value of y from being returned.

Specifically, at this time, VariableNeedsHoleCheckInCurrentBlock is already in a state that requires detection.

At the same time, the function HasRememberHoleCheck also returns false , indicating that the y value needs to be checked for holes , thus ensuring that BuildThrowIfHole is called.

The essence of this vulnerability is that HoleCheckElisionScope is not called in VisitDelete , which causes the error y when calling BuildThrowIfHole in this function. hole The check status is leaked to the parent basic In bitmap , let The setting of x makes x undefine , so that the bytecode execution level will not enter the BuildThrowIfHole generated to detect y The ThrowReferenceErrorIfHole of the hole state jumps directly to return y , in return When y is set, determine whether to perform y Hole detection is based on the basic of this parent bitmap , at this time basic There is a hole in the bitmap checked , so the test is skipped directly, resulting in y hole return.

HoleCheckElisionScope and BuildThrowIfHole must be called symmetrically. To put it more bluntly, the call of BuildThrowIfHole must be in an independent HoleCheckElisionScope. Otherwise, after BuildThrowIfHole is set, the hole status detection of the variable will be leaked to the parent level , causing a vulnerability.


Reference Links

[1].https://gist.github.com/mistymntncop/37c652c2bf7373b4aa33bb50f52ee0f2

[2].https://github.com/DarkNavySecurity/PoC/blob/main/CVE-2025-6554/poc.js

VULNERABILITY 0DAY CVE-2025-6554