CVE-2025-6554 分析
好久没有分析过 v8 漏洞了,挑一个比较新的,体验一下 2025 年利用 v8 漏洞的难度,看看最近几年有没有增加新的安全特性。
尝试 google 搜索一个比较新的但是又有一些资料的 v8 漏洞,发现了 https://ti.qianxin.com/blog/articles/a-brief-analysis-of-chrome-0day-cve-2025-6554-en/ 这篇,读了个开头,就找到了 DARKNAVY 发布的 POC https://github.com/DarkNavySecurity/PoC/blob/main/CVE-2025-6554/poc.js 。这样就有足够的信息可以先开始我自己的分析了,等卡住的时候,再回去看别人的分析。
先构造环境复现漏洞,根据 Chrome 发版记录,找到漏洞修复的版本在 , 那么前一个发版是 https://chromereleases.googleblog.com/2025/06/stable-channel-update-for-desktop_24.html 138.0.7204.49/50
从 https://chromiumdash.appspot.com/releases?platform=Windows 找到对应的备份
https://commondatastorage.googleapis.com/chromium-browser-snapshots/index.html?prefix=Win_x64/1465706/
源码也切换到对应的 tag https://chromium.googlesource.com/chromium/src/+/refs/tags/138.0.7204.50
> git fetch origin tag 138.0.7204.50
>
或者也可以只把 v8 的源码切换到对应的 tag https://chromium.googlesource.com/v8/v8.git/+/tags/13.8.258.19
复现
将 poc 转换成 html 的形式, 之后用 python http.server 运行一个 http 服务器,用有漏洞的 chrome 去访问,发现 chrome 崩溃了。
<html>
<body>
<script>
function f() {
let x;
delete x?.[y]?.a;
return y;
let y;
}
let hole = f();
// print(%StrictEqual(hole, %TheHole()));
let map = new Map();
map.delete(hole);
console.log('123');
</script>
</body>
</html>
崩溃显示,错误代码:STATUS_BREAKPOINT, 根据经验这里是因为 DCHECK 校验失败造成的崩溃,可以用 VS 调试一下,看看 DCHECK 的位置。
来分析一下代码,这段代码,明显是有问题的,let 声明的变量应该是在 let 之后才有效,在 let 之前是不能访问的,但是结合上下文分析,函数 f 中 return y 语句成功访问到了 y 的值,而且这个时候 y 是 v8 内部使用的值 %TheHole(),这个值是 v8 内部实现用来做哨兵值使用的。如果让 javascript 代码层面拿到这个值,那么就可能引发一些未定义的行为,进一步出发内存访问错误,从而实现 RCE。
https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/let#temporal_dead_zone_tdz
根据这个猜测,开始调试崩溃,运行 vs2022 选择 continue without code,用 debug attach to process 功能附加到 chromium 进程上,要根据命令行选择那个 browser 和 renderer 进程,并且安装 Microsoft Child Process Debugging Power Tool 并启用,这样 renderer 进程崩溃后,vs 会自动附加到 browser 创建的新 renderer 进程。
可以看到崩溃时的调用栈
chrome.dll!v8::base::OS::Abort() Line 1239 C++
chrome.dll!V8_Fatal(const char *) Line 215 C++
[Inline Frame] chrome.dll!v8::internal::Object::GetHash(v8::internal::Tagged<v8::internal::Object>) Line 1903 C++
chrome.dll!v8::internal::OrderedHashMap::GetHash(unsigned __int64) Line 441 C++
chrome.dll!Builtins_MapPrototypeDelete() C++
chrome.dll!Builtins_InterpreterEntryTrampoline() C++
chrome.dll!Builtins_JSEntryTrampoline() C++
chrome.dll!Builtins_JSEntry() C++
chrome.dll!v8::internal::`anonymous namespace'::Invoke(v8::internal::Isolate *) Line 441 C++
chrome.dll!v8::internal::Execution::CallScript(v8::internal::DirectHandle<v8::internal::JSFunction>) Line 542 C++
chrome.dll!v8::Script::Run(v8::Local<v8::Context>) Line 1964 C++
...
简单分析后发现 Builtins_MapPrototypeDelete 应该是 Map.prototype.delete 对应的函数实现,在执行 delete 的过程中,由于参数不满足条件导致了 CHECK 检查主动触发崩溃。
// static
Tagged<Object> Object::GetHash(Tagged<Object> obj) {
DisallowGarbageCollection no_gc;
Tagged<Object> hash = GetSimpleHash(obj);
if (IsSmi(hash)) return hash;
// Make sure that we never cast internal objects to JSReceivers.
CHECK(IsJSReceiver(obj)); // <<< 这里触发崩溃
Tagged<JSReceiver> receiver = Cast<JSReceiver>(obj);
return receiver->GetIdentityHash();
}
可以看到这里不是当前的重点,我们要先知道 f() 函数的返回值是怎么来的,先利用 v8 的输出字节码功能看一下 f() 函数对应的字节码。以命令 ./chrome.exe --enable-logging=stderr --js-flags="--print_bytecode" --no-sandbox 重新运行一个 chrome,访问 POC 页面,可以看到以下字节码输出。注意要先关闭已经打开的 chrome 进程。
[generated bytecode for function: f (0x01ef0016bff9 <SharedFunctionInfo f>)]
Bytecode length: 29
Parameter count 1
Register count 3
Frame size 24
0x536500100700 @ 0 : 10 LdaTheHole
0x536500100701 @ 1 : cf Star1
0x536500100702 @ 2 : 0e LdaUndefined
0x536500100703 @ 3 : d0 Star0
0x536500100704 @ 4 : 1b f9 f7 Mov r0, r2
0x536500100707 @ 7 : aa 12 JumpIfUndefinedOrNull [18] (0x536500100719 @ 25)
0x536500100709 @ 9 : 0b f8 Ldar r1
0x53650010070b @ 11 : b6 00 ThrowReferenceErrorIfHole [0]
0x53650010070d @ 13 : 35 f7 00 GetKeyedProperty r2, [0]
0x536500100710 @ 16 : aa 09 JumpIfUndefinedOrNull [9] (0x536500100719 @ 25)
0x536500100712 @ 18 : ce Star2
0x536500100713 @ 19 : 13 01 LdaConstant [1]
0x536500100715 @ 21 : 60 f7 DeletePropertySloppy r2
0x536500100717 @ 23 : 95 03 Jump [3] (0x53650010071a @ 26)
0x536500100719 @ 25 : 11 LdaTrue
0x53650010071a @ 26 : 0b f8 Ldar r1
0x53650010071c @ 28 : b5 Return
Constant pool (size = 2)
Handler Table (size = 0)
Source Position Table (size = 0)
分析这段字节码发现,关键点可能在 JumpIfUndefinedOrNull [18] (0x536500100719 @ 25) 这个地方跳过了后面的 ThrowReferenceErrorIfHole 指令,导致了最后的 Ldar r1 将 TheHole 作为返回值执行了。结合 Javascript 代码来看,很可能是 delete x?.[y]?.a; 这一行的引入导致了代码走向了未定义的路径上,可以注释掉这一行再看对应的字节码,输入变成了如下。
Source Position Table (size = 0)
[generated bytecode for function: f (0x015b0016bff9 <SharedFunctionInfo f>)]
Bytecode length: 9
Parameter count 1
Register count 2
Frame size 16
0x3709001006fc @ 0 : 10 LdaTheHole
0x3709001006fd @ 1 : cf Star1
26 S> 0x3709001006fe @ 2 : 0e LdaUndefined
0x3709001006ff @ 3 : d0 Star0
57 S> 0x370900100700 @ 4 : 0b f8 Ldar r1
0x370900100702 @ 6 : b6 00 ThrowReferenceErrorIfHole [0]
66 S> 0x370900100704 @ 8 : b5 Return
Constant pool (size = 1)
Handler Table (size = 0)
可以看到,这个版本的字节码就不存在有问题的代码路径,可以看到 v8 对在 let 声明以前就访问变量的情况是先存储一个 Hole 到变量中,在访问的位置使用 ThrowReferenceErrorIfHole 抛出异常,分析到这里,我就有了两个疑问:
- 为什么插入
delete x?.[y]?.a;后,产生了可以跳过 ThrowReferenceErrorIfHole 的控制流 - 为什么在 let 定义之前访问变量会生成使用 Hole 占位符的代码
先来分析问题1,这个可以说是漏洞的直接成因。我们可以编译一个 debug 版本的 d8, 调试并对比delete x?.[y]?.a; 语句存在与否,AST 和 字节码生成的区别
先把代码写入 poc1.js poc2.js 两个文件
// poc1.js
function f(){
let x;
return y;
let y;
}
f();
// poc2.js
function f() {
let x;
delete x?.[y]?.a;
return y;
let y;
}
f();
之后执行命令 d8.exe --print-ast --print-bytecode poc1.js/poc2.js 分别查看两个函数生成的 AST 和 字节码,结果如下(省略了无关的部分): poc1
[generating bytecode for function: f]
--- AST ---
FUNC at 10
. KIND 0
. LITERAL ID 1
. SUSPEND COUNT 0
. NAME "f"
. INFERRED NAME ""
. DECLS
. . VARIABLE (0000028CB4A3D890) (mode = LET, assigned = false) "x"
. . VARIABLE (0000028CB4A3D980) (mode = LET, assigned = false) "y"
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 19
. . . kInit at 19
. . . . VAR PROXY local[0] (0000028CB4A3D890) (mode = LET, assigned = false) "x"
. . . . LITERAL undefined
. RETURN at 23
. . VAR PROXY local[1] (0000028CB4A3D980) (mode = LET, assigned = false) "y"
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 38
. . . kInit at 38
. . . . VAR PROXY local[1] (0000028CB4A3D980) (mode = LET, assigned = false) "y"
. . . . LITERAL undefined
[generated bytecode for function: f (0x02d100063f4d <SharedFunctionInfo f>)]
Bytecode length: 9
Parameter count 1
Register count 2
Frame size 16
0x5a001000cc @ 0 : 10 LdaTheHole
0x5a001000cd @ 1 : cf Star1
19 S> 0x5a001000ce @ 2 : 0e LdaUndefined
0x5a001000cf @ 3 : d0 Star0
23 S> 0x5a001000d0 @ 4 : 0b f8 Ldar r1
0x5a001000d2 @ 6 : b6 00 ThrowReferenceErrorIfHole [0]
32 S> 0x5a001000d4 @ 8 : b5 Return
Constant pool (size = 1)
0x5a00100099: [TrustedFixedArray]
- map: 0x02d100000605 <Map(TRUSTED_FIXED_ARRAY_TYPE)>
- length: 1
0: 0x02d100003609 <String[1]: #y>
poc2
[generating bytecode for function: f]
--- AST ---
FUNC at 10
. KIND 0
. LITERAL ID 1
. SUSPEND COUNT 0
. NAME "f"
. INFERRED NAME ""
. DECLS
. . VARIABLE (0000021B587D63C0) (mode = LET, assigned = false) "x"
. . VARIABLE (0000021B587D6550) (mode = LET, assigned = false) "y"
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 20
. . . kInit at 20
. . . . VAR PROXY local[0] (0000021B587D63C0) (mode = LET, assigned = false) "x"
. . . . LITERAL undefined
. EXPRESSION STATEMENT at 24
. . kDelete at 24
. . . OPTIONAL_CHAIN at 0
. . . . PROPERTY at 37
. . . . . PROPERTY at 34
. . . . . . VAR PROXY local[0] (0000021B587D63C0) (mode = LET, assigned = false) "x"
. . . . . . KEY at 35
. . . . . . . VAR PROXY local[1] (0000021B587D6550) (mode = LET, assigned = false) "y"
. . . . . NAME a
. RETURN at 43
. . VAR PROXY local[1] (0000021B587D6550) (mode = LET, assigned = false) "y"
. BLOCK NOCOMPLETIONS at -1
. . EXPRESSION STATEMENT at 58
. . . kInit at 58
. . . . VAR PROXY local[1] (0000021B587D6550) (mode = LET, assigned = false) "y"
. . . . LITERAL undefined
[generated bytecode for function: f (0x022500063f4d <SharedFunctionInfo f>)]
Bytecode length: 29
Parameter count 1
Register count 3
Frame size 24
0x21b001000d0 @ 0 : 10 LdaTheHole
0x21b001000d1 @ 1 : cf Star1
0x21b001000d2 @ 2 : 0e LdaUndefined
0x21b001000d3 @ 3 : d0 Star0
0x21b001000d4 @ 4 : 1b f9 f7 Mov r0, r2
0x21b001000d7 @ 7 : aa 12 JumpIfUndefinedOrNull [18] (0x21b001000e9 @ 25)
0x21b001000d9 @ 9 : 0b f8 Ldar r1
0x21b001000db @ 11 : b6 00 ThrowReferenceErrorIfHole [0]
0x21b001000dd @ 13 : 35 f7 00 GetKeyedProperty r2, [0]
0x21b001000e0 @ 16 : aa 09 JumpIfUndefinedOrNull [9] (0x21b001000e9 @ 25)
0x21b001000e2 @ 18 : ce Star2
0x21b001000e3 @ 19 : 13 01 LdaConstant [1]
0x21b001000e5 @ 21 : 60 f7 DeletePropertySloppy r2
0x21b001000e7 @ 23 : 95 03 Jump [3] (0x21b001000ea @ 26)
0x21b001000e9 @ 25 : 11 LdaTrue
0x21b001000ea @ 26 : 0b f8 Ldar r1
0x21b001000ec @ 28 : b5 Return
Constant pool (size = 2)
0x21b00100099: [TrustedFixedArray]
- map: 0x022500000605 <Map(TRUSTED_FIXED_ARRAY_TYPE)>
- length: 2
0: 0x022500003609 <String[1]: #y>
1: 0x022500003489 <String[1]: #a>
仔细对比两个结果以后,发现了两点:
- Return 语句前的
Ldar1; ThrowReferenceErrorIfHole;被替换成了Ldar r1,推测是由于之前 delete 语句对应的字节码中已经有了对应的检查,后面的检查就被省略掉了 - 新增了一条控制流,可以跳过 Hole 检查,转到 return 语句对应的字节码去执行。
这两点结合起来,就刚刚好生成了可以产生漏洞的字节码。delete x?.[y]?.a; 被语法分析解析成了语法树的节点 EXPRESSION STATEMENT at 24, 关键处理流程如下:
template <typename Impl>
typename ParserBase<Impl>::ExpressionT
ParserBase<Impl>::ParseLeftHandSideContinuation(ExpressionT result) {
DCHECK(Token::IsPropertyOrCall(peek()));
if (V8_UNLIKELY(peek() == Token::kLeftParen && impl()->IsIdentifier(result) &&
scanner()->current_token() == Token::kAsync &&
!scanner()->HasLineTerminatorBeforeNext() &&
!scanner()->literal_contains_escapes())) {
// ...
}
bool optional_chaining = false;
bool is_optional = false;
int optional_link_begin;
do {
switch (peek()) {
case Token::kQuestionPeriod: {
if (is_optional) {
ReportUnexpectedToken(peek());
return impl()->FailureExpression();
}
// Include the ?. in the source range position.
optional_link_begin = scanner()->peek_location().beg_pos;
Consume(Token::kQuestionPeriod);
is_optional = true;
optional_chaining = true;
if (Token::IsPropertyOrCall(peek())) continue;
int pos = position();
ExpressionT key = ParsePropertyOrPrivatePropertyName();
result = factory()->NewProperty(result, key, pos, is_optional);
break;
}
/* Property */
case Token::kLeftBracket: {
Consume(Token::kLeftBracket);
int pos = position();
AcceptINScope scope(this, true);
ExpressionT index = ParseExpressionCoverGrammar();
result = factory()->NewProperty(result, index, pos, is_optional);
Expect(Token::kRightBracket);
break;
}
// ...
}
if (is_optional) {
SourceRange chain_link_range(optional_link_begin, end_position());
impl()->RecordExpressionSourceRange(result, chain_link_range);
is_optional = false;
}
} while (Token::IsPropertyOrCall(peek()));
if (optional_chaining) return factory()->NewOptionalChain(result);
return result;
}
static bool IsPropertyOrCall(Value token) {
return base::IsInRange(token, kTemplateSpan, kLeftParen);
}
// 相关的 TOKEN
#define TOKEN_LIST(T, K) \
\
/* BEGIN PropertyOrCall */ \
/* BEGIN Member */ \
/* BEGIN Template */ \
/* ES6 Template Literals */ \
T(kTemplateSpan, nullptr, 0) \
T(kTemplateTail, nullptr, 0) \
/* END Template */ \
\
/* Punctuators (ECMA-262, section 7.7, page 15). */ \
/* BEGIN Property */ \
T(kPeriod, ".", 0) \
T(kLeftBracket, "[", 0) \
/* END Property */ \
/* END Member */ \
T(kQuestionPeriod, "?.", 0) \
T(kLeftParen, "(", 0) \
/* END PropertyOrCall */ \
Delete 表达式前面的解析没有什么重要的内容我先略过了,我们直接从解析到第一个 ?. 操作符的时候开始看,代码执行到 case Token::kQuestionPeriod,由于后面紧跟的是 [ 所以 if (Token::IsPropertyOrCall(peek())) continue; 中的 continue 得到执行,下一轮循环来到 case Token::kLeftBracket, ParseExpressionCoverGrammar 函数解析到 index 为 y 的变量访问, result 为访问 x 的 y 属性的 Property 表达式,由于后面紧跟的是 ?. 所以循环继续处理,又来到 case Token::kQuestionPeriod,现在后面跟的是标识符 a, 所以直接解析成访问上一轮 result 变量的 a 属性的 Property 表达式。到这里循环结束,optional_chaining 为 true, 所以 if (optional_chaining) return factory()->NewOptionalChain(result); 返回一个 OptionalChain 表达式节点。
再来看一下对应的字节码生成,关键步骤如下:
void BytecodeGenerator::VisitDelete(UnaryOperation* unary) {
Expression* expr = unary->expression();
// ...
} 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 {
// ...
}
}
在字节码生成阶段,解析到 Delete 表达式的时候,发现操作数是 OptionalChain 表达式的时候就会走到对应的分支,expr_inner 这个时候是个 Property 表达式所以代码执行到 VisitForAccumulatorValue(property->obj()) 这个 obj 就是属性访问的对象,这里是 x?.[y], 下面还有对 property->key() 的访问,这个 key 是访问的属性值,这里是 a,这个 VisitForAccumulatorValue 调用很关键,我们跟进去分析。
// Visits the expression |expr| and places the result in the accumulator.
BytecodeGenerator::TypeHint BytecodeGenerator::VisitForAccumulatorValue(
Expression* expr) {
ValueResultScope accumulator_scope(this);
return VisitForAccumulatorValueImpl(expr, &accumulator_scope);
}
BytecodeGenerator::TypeHint BytecodeGenerator::VisitForAccumulatorValueImpl(
Expression* expr, ValueResultScope* accumulator_scope) {
Visit(expr); // <<<
// Record the type hint for the result of current expression in accumulator.
const TypeHint type_hint = accumulator_scope->type_hint();
BytecodeRegisterOptimizer* optimizer = builder()->GetRegisterOptimizer();
if (optimizer && type_hint != TypeHint::kUnknown) {
optimizer->SetTypeHintForAccumulator(type_hint);
}
return type_hint;
}
由于这里的 obj 对应的 x?.[y] 其实是个 Property 表达式节点,代码实际上会执行到 VisitProperty 函数
void BytecodeGenerator::VisitProperty(Property* expr) {
AssignType property_kind = Property::GetAssignType(expr);
if (property_kind != NAMED_SUPER_PROPERTY &&
property_kind != KEYED_SUPER_PROPERTY) {
Register obj = VisitForRegisterValue(expr->obj()); // <<< 这里
VisitPropertyLoad(obj, expr);
} else {
VisitPropertyLoad(Register::invalid_value(), expr);
}
}
void BytecodeGenerator::VisitPropertyLoad(Register obj, Property* property) {
if (property->is_optional_chain_link()) {
DCHECK_NOT_NULL(optional_chaining_null_labels_);
int right_range =
AllocateBlockCoverageSlotIfEnabled(property, SourceRangeKind::kRight);
builder()->LoadAccumulatorWithRegister(obj).JumpIfUndefinedOrNull(
optional_chaining_null_labels_->New()); // <<<
BuildIncrementBlockCoverageCounterIfEnabled(right_range);
}
AssignType property_kind = Property::GetAssignType(property);
switch (property_kind) {
case NON_PROPERTY:
UNREACHABLE();
case NAMED_PROPERTY: {
builder()->SetExpressionPosition(property);
const AstRawString* name =
property->key()->AsLiteral()->AsRawPropertyName();
BuildLoadNamedProperty(property->obj(), obj, name);
break;
}
case KEYED_PROPERTY: {
VisitForAccumulatorValueAsPropertyKey(property->key()); // <<<<
builder()->SetExpressionPosition(property);
BuildLoadKeyedProperty(obj, feedback_spec()->AddKeyedLoadICSlot());
break;
}
// ...
}
}
我们的属性访问类型很明显不是和 SUPER 相关的,所以会走入非 SUPER 分支,这里 expr->obj 是 x?.[y] 表达式对应的 obj, 也就是 x, VisitForRegisterValue 会生成把 obj 存储到寄存器的代码,之后执行 VisitPropertyLoad 生成属性访问的代码,由于表达式是 OptionalChain 所以代码执行到 builder()->LoadAccumulatorWithRegister(obj).JumpIfUndefinedOrNull(optional_chaining_null_labels_->New()); 生成检查 obj 是否为 undefined/null 的代码,如果为空则直接跳到下一条语句执行。之后程序执行到 case KEYED_PROPERTY:,VisitForAccumulatorValueAsPropertyKey 函数会生成加载 key 到 accumulator 的代码,后面的 LoadKeyedPropery 生成的代码会直接使用 accumulator 中的值。
BytecodeGenerator::TypeHint
BytecodeGenerator::VisitForAccumulatorValueAsPropertyKey(Expression* expr) {
ValueResultScope accumulator_scope(this,
ValueResultScope::kValueAsPropertyKey);
return VisitForAccumulatorValueImpl(expr, &accumulator_scope);
}
VisitForAccumulatorValueImpl 上面已经贴出的,这次调用使用的 expr 是 property->key() 也就是 y, 对应的节点 VariableProxy 表达式, 所以调用 Visit 执行到 VisitVariableProxy
void BytecodeGenerator::VisitVariableProxy(VariableProxy* proxy) {
builder()->SetExpressionPosition(proxy);
BuildVariableLoad(proxy->var(), proxy->hole_check_mode());
}
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;
}
//...
}
}
y 是用 let 声明的局部变量,所以执行到 case VariableLocation::LOCAL:,
bool BytecodeGenerator::VariableNeedsHoleCheckInCurrentBlock(
Variable* variable, HoleCheckMode hole_check_mode) {
return hole_check_mode == HoleCheckMode::kRequired &&
!variable->HasRememberedHoleCheck(hole_check_bitmap_);
}
bool Variable::HasRememberedHoleCheck(HoleCheckBitmap bitmap) const {
uint8_t index = HoleCheckBitmapIndex();
bool result = bitmap & (HoleCheckBitmap{1} << index);
DCHECK_IMPLIES(index == kUncacheableHoleCheckBitmapIndex, !result);
return result;
}
void Variable::ResetHoleCheckBitmapIndex() {
hole_check_analysis_bit_field_ = HoleCheckBitmapIndexField::update(
hole_check_analysis_bit_field_, kUncacheableHoleCheckBitmapIndex);
}
而且这里 y 的使用是在 let 声明之前的,所以 hole_check_mode 是 kRequired,而 HasRememberedHoleCheck 函数会检查位图,判断当前变量在代码块中是否已经做过 Hole 检查了。目前是 y 在本代码块内的第一次访问,所以还没被检查过,VariableNeedsHoleCheckInCurrentBlock 返回 true,程序执行到 BuildThrowIfHole(variable); 生成对应的异常处理字节码,之后调用 RememberHoleCheckInCurrentBlock 函数,记录这次检查到位图。
hole_check_mode 是在 DeclarationScope::Analyze 函数中分析出来的,感兴趣的同学可以自行调试一下。
Parser::ParseProgram
Parser::PostProcessParseResult
DeclarationScope::Analyze
DeclarationScope::AllocateVariables
Scope::AllocateVariablesRecursively
void BytecodeGenerator::BuildThrowIfHole(Variable* variable) {
if (variable->is_this()) {
DCHECK(variable->mode() == VariableMode::kConst);
builder()->ThrowSuperNotCalledIfHole();
} else {
builder()->ThrowReferenceErrorIfHole(variable->raw_name());
}
RememberHoleCheckInCurrentBlock(variable);
}
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_);
}
void RememberHoleCheckInBitmap(HoleCheckBitmap& bitmap,
ZoneVector<Variable*>& list) {
DCHECK(v8_flags.ignition_elide_redundant_tdz_checks);
uint8_t index = HoleCheckBitmapIndex();
if (V8_UNLIKELY(index == kUncacheableHoleCheckBitmapIndex)) {
index = list.size() + 1;
// The bitmap is full.
if (index == kHoleCheckBitmapBits) return;
AssignHoleCheckBitmapIndex(list, index);
}
bitmap |= HoleCheckBitmap{1} << index;
DCHECK_EQ(
0, bitmap & (HoleCheckBitmap{1} << kUncacheableHoleCheckBitmapIndex));
}
分析到这里,我们基本能看出问题所在了,VariableNeedsHoleCheckInCurrentBlock 的本意只是在同代码块中消除不必要的 Hole 检查,但是这个状态维护的代码一定是有 BUG,导致 x?.[y].a 这样会产生分支的语句中的 Hole 检查位图状态并没有被正确维护,后面 Return 语句中的 y 变量访问的地方认为同一个代码块中已进行了 Hole 检查,导致了 Hole 值,被泄露到了函数的返回值中。
下面继续分析,证实上面的猜测,继续调试后续流程,发现在 VisitDelete 函数中有一处 VisitInHoleCheckElisionScopeForAccumulatorValue(property->key());, 这个函数看名字就能猜出是在限定 HoleCheck 消除的范围。
BytecodeGenerator::TypeHint
BytecodeGenerator::VisitInHoleCheckElisionScopeForAccumulatorValue(
Expression* expr) {
HoleCheckElisionScope elider(this);
return VisitForAccumulatorValue(expr);
}
// 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_;
};
看完实现可以确认,这个函数就是用来保存 bitmap_ 值,在表达式解析完毕后再恢复的,这里只对最外层的进行了处理是不够的,每个 Optional 的 PropertyLoad 的 key 实际上都应该进行类似的处理。
后面的一些不重要的流程就忽略掉了,直接来看 Return 语句对应的字节码生成。
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);
}
}
关键点在于 VisitForAccumulatorValue(stmt->expression()); 就返回值加载到 accumulator 的处理,根据源码这里加载的就是局部变量 y, 而这个 y 是在 let 作用域外的所以他的加载应该有额外的 Hole 检查逻辑,我们调试一下 Hole 检查被消除的逻辑。
y 是变量的引用,所以 VisitForAccumulatorValue 还是和之前一样的处理流程, VisitVariableProxy -> BuildVariableLoad
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;
}
但是这次由于之前设置了 y 变量的对应的 HoleCheckBitmap_ 标志位, VariableNeedsHoleCheckInCurrentBlock 返回了 false 最终导致应有的 Hole 检查逻辑被当作荣誉操作忽略掉了。
漏洞修复
再来看一下漏洞的修复,从已修复版本的 DEPS 文件搜索 v8_revision 找对了对应的 v8 的 commit, https://chromium.googlesource.com/chromium/src/+/refs/tags/138.0.7204.96/DEPS e5b4c78b54e8b033b2701db3df0bf67d3030e4c1,然后和未修复版本做 diff 或者挨个看 commit 记录 https://chromium.googlesource.com/v8/v8.git/+log/e5b4c78b54e8b033b2701db3df0bf67d3030e4c1 可以发现 https://chromium.googlesource.com/v8/v8.git/+/069790710f28b00ff8d7b4c665eef6b4eb8768f6 这个 commit 描述是 [interpreter] don't elide hole checks across optional chain。
阅读修复的代码 diff,发现很简单,只是在 OptionalChaiNullLabelScope 中加入了用 HoleCheckElisionScope,来限定 HoleCheckElision 范围。OptionlaChainNullLabelScope 之前在 VisitDelete 中已经在使用的,这里只不过又给他增加了一些功能。
diff --git [a/src/interpreter/bytecode-generator.cc](https://chromium.googlesource.com/v8/v8.git/+/0ea9b0813581826a94b45324e746f9ab57f0f843/src/interpreter/bytecode-generator.cc) [b/src/interpreter/bytecode-generator.cc](https://chromium.googlesource.com/v8/v8.git/+/069790710f28b00ff8d7b4c665eef6b4eb8768f6/src/interpreter/bytecode-generator.cc)
index 9832d01..2861ab7 100644
--- a/src/interpreter/bytecode-generator.cc
+++ b/src/interpreter/bytecode-generator.cc
@@ -1221,7 +1221,8 @@
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_;
}
@@ -1236,6 +1237,9 @@
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_;
};
// LoopScope delimits the scope of {loop}, from its header to its final jump.
@@ -6476,9 +6480,6 @@
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());
漏洞利用
经过上面的分析,我们就得到一个能获取 Hole 值的函数。下面从这个 Hole 值作为起点,尝试达到任意代码执行。
漏洞利用可以参考 https://issues.chromium.org/issues/40057710 中的介绍,
Hole 值本身虽然没有什么可操作的地方,但是由于 Hole 值在很多地方作为哨兵值使用,我们可以用 Hole 值来配合其他内建对象/函数实现漏洞的利用,但是测试来看,目前的 v8 版本已经加入了缓解措施, Map 和 WeakMap 都不再支持 delete(hole) 操作了,git blame 找到了 https://chromium.googlesource.com/v8/v8/+/bf7d91ade4908529a6c7ebd18c650b9e8b19647b%5E%21/src/builtins/builtins-collections-gen.cc 这个 commit, 它是 2023 提交的,所以 2025 年的漏洞利用一定是有别的地方可以用 Hole 做到利用,或者是这个缓解措施可以被绕过。
TODO: 完成利用