【漏洞分析】CVE-2025-9132 "Await Using" Can't Wait


by 科恩DF小队

既然这是Project Zero的BigSleep发现的第一个V8漏洞,Buff这么多,那就很难不来一窥究竟了。

背景

8月19日的 Google Chrome 更新修复了一个由 Google Big Sleep 发现的漏洞。

Chrome Releases: Stable Channel Update for Desktop

[436181695] High CVE-2025-9132: Out of bounds write in V8. Reported by Google Big Sleep on 2025-08-04

通过分析补丁,我们成功实现了 CVE-2025-9132 的利用。以下所有分析和利用都基于 v8 13.9.205.19,commit 505ec917b67c535519bebec58c62a34f145dd49f,即 v8 13.9 分支中漏洞修复前的 commit。

CVE-2025-9132 的补丁和补丁中附带的 PoC 如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
diff --git a/src/parsing/parser.cc b/src/parsing/parser.cc
index 26249aff4bb..7b5b2458b77 100644
--- a/src/parsing/parser.cc
+++ b/src/parsing/parser.cc
@@ -2408,7 +2408,10 @@ Statement* Parser::DesugarLexicalBindingsInForStatement(
// make statement: let/const x = temp_x.
for (int i = 0; i < for_info.bound_names.length(); i++) {
VariableProxy* proxy = DeclareBoundVariable(
- for_info.bound_names[i], for_info.parsing_result.descriptor.mode,
+ for_info.bound_names[i],
+ for_info.parsing_result.descriptor.mode == VariableMode::kAwaitUsing
+ ? VariableMode::kConst
+ : for_info.parsing_result.descriptor.mode,
kNoSourcePosition);
inner_vars.Add(proxy->var());
VariableProxy* temp_proxy = factory()->NewVariableProxy(temps.at(i));
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// Copyright 2025 the V8 project authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

var v = [];

(async () => {
for (let i = 0; i < 6; ++i) {
v.push(i);
await 0;
}
})();

async function TestCStyleForCountTicks() {
for (await using x = {
value: 42,
[Symbol.asyncDispose]() {
v.push(`asyncDispose`);
} // One tick is expected after calling asyncDispose to allow it to be
// asynchronous. It will be called after exiting the for-loop.
};
x.value < 44; x.value++) {
// These pushes are expected to be synchronous.
v.push(x.value);
}
v.push(`afterForLoop`);
}

async function RunTest() {
await TestCStyleForCountTicks();
assertArrayEquals([0, 42, 43, `asyncDispose`, 1, `afterForLoop`, 2], v);
}

RunTest();

使用 debug 版本的 d8 运行 PoC 会在 BytecodeArrayWriter::BindJumpTableEntry 中触发 DCHECK [1]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void BytecodeArrayWriter::BindJumpTableEntry(BytecodeJumpTable* jump_table,
int case_value) {
DCHECK(!jump_table->is_bound(case_value)); // [1] crash here

size_t current_offset = bytecodes()->size();
size_t relative_jump = current_offset - jump_table->switch_bytecode_offset();

constant_array_builder()->SetJumpTableSmi(
jump_table->ConstantPoolEntryFor(case_value),
Smi::FromInt(static_cast<int>(relative_jump)));
jump_table->mark_bound(case_value);

StartBasicBlock();
}

分析

补丁和 PoC 都显示漏洞与 await using 语法有关。await using 是 JavaScript 中的新特性。使用 await using 声明的变量离开其作用域时,它的 [asyncDispose] 会被异步调用。

The await using declaration declares block-scoped local variables that are asynchronously disposed.

1
2
3
4
5
6
7
8
9
10
async function foo() {
{
await using x = {
[Symbol.asyncDispose]() {
console.log("asyncDispose");
}
};
// await x[Symbol.asyncDispose]();
}
}

在 v8 中,如果 async function 中有 await 关键字,那么函数的开头会是一个 SwitchOnGeneratorState 字节码,每个 await 会产生一对 SuspendGenerator/ResumeGenerator 字节码。SuspendGenerator 会将函数的当前状态保存到 JSGeneratorObject 对象中,然后退出。当 await 完成,函数会重新从开头的 SwitchOnGeneratorState 处开始执行,SwitchOnGeneratorState 会根据 JSGeneratorObject 从对应的 ResumeGenerator 处恢复执行,ResumeGenerator 会从 JSGeneratorObject 导入函数状态。

SwitchOnGeneratorState 有一个 JumpTable,用于选择从哪一个 ResumeGenerator 执行。v8 先从源码产生 AST,再从 AST 生成字节码。为了确定 JumpTable 的大小,v8 在 parse 源码时会记录 awaitawait usingyield 等关键字的个数。例如 ParserBase<Impl>::ParseVariableDeclarations 第一次在一个作用域中遇到 await using 时会调用 AddSuspend 来增加计数。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40

template <typename Impl>
void ParserBase<Impl>::ParseVariableDeclarations(
VariableDeclarationContext var_context,
DeclarationParsingResult* parsing_result,
ZonePtrList<const AstRawString>* names) {
// VariableDeclarations ::
// ('var' | 'const' | 'let' | 'using' | 'await using') (Identifier ('='
// AssignmentExpression)?)+[',']

DCHECK_NOT_NULL(parsing_result);
parsing_result->descriptor.kind = NORMAL_VARIABLE;
parsing_result->descriptor.declaration_pos = peek_position();
parsing_result->descriptor.initialization_pos = peek_position();

Scope* target_scope = scope();

switch (peek()) {
// ...
case Token::kAwait:
// CoverAwaitExpressionAndAwaitUsingDeclarationHead[?Yield] [no
// LineTerminator here] BindingList[?In, ?Yield, +Await, ~Pattern];
Consume(Token::kAwait);
DCHECK(v8_flags.js_explicit_resource_management);
DCHECK_NE(var_context, kStatement);
DCHECK(is_using_allowed());
DCHECK(is_await_allowed());
Consume(Token::kUsing);
DCHECK(!scanner()->HasLineTerminatorBeforeNext());
DCHECK(peek() != Token::kLeftBracket && peek() != Token::kLeftBrace);
impl()->CountUsage(v8::Isolate::kExplicitResourceManagement);
parsing_result->descriptor.mode = VariableMode::kAwaitUsing;
if (!target_scope->has_await_using_declaration()) {
function_state_->AddSuspend(); // [1] AddSuspend()
}
break;
default:
UNREACHABLE(); // by current callers
break;
}

生成字节码时,BytecodeGenerator 会先 constant_pool 中预留 info()->literal()->suspend_count() 个位置(constant_pool 是一个数组),作为 JumpTable。JumpTable 会在生成字节码的过程中逐个被填充成实际的跳转偏移。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
void BytecodeGenerator::BuildGeneratorPrologue() {
DCHECK_GT(info()->literal()->suspend_count(), 0);
generator_jump_table_ =
builder()->AllocateJumpTable(info()->literal()->suspend_count(), 0); // [1] AllocateJumpTable

// If the generator is not undefined, this is a resume, so perform state
// dispatch.
builder()->SwitchOnGeneratorState(generator_object(), generator_jump_table_);

// Otherwise, fall-through to the ordinary function prologue, after which we
// will run into the generator object creation and other extra code inserted
// by the parser.
}

BytecodeGenerator 有自己的数据成员 suspend_count_,初始值为 0。 在根据 AST 生成字节码时,每当需要生成一个 SuspendGenerator ,就会以 suspend_count_ 字段作为 suspend_id,然后将 suspend_count_ 自增。suspend_id 被用作 BytecodeArrayWriter::BindJumpTableEntry 函数的 case_value 参数,作为索引填充 JumpTable。正常来说,suspend_id 的范围是 [0, info()->literal()->suspend_count() - 1]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
// Suspends the generator to resume at the next suspend_id, with output stored
// in the accumulator. When the generator is resumed, the sent value is loaded
// in the accumulator.
void BytecodeGenerator::BuildSuspendPoint(int position) {
// Because we eliminate jump targets in dead code, we also eliminate resumes
// when the suspend is not emitted because otherwise the below call to Bind
// would start a new basic block and the code would be considered alive.
if (builder()->RemainderOfBlockIsDead()) {
return;
}
const int suspend_id = suspend_count_++; // [1] suspend_count_++

RegisterList registers = register_allocator()->AllLiveRegisters();

// Save context, registers, and state. This bytecode then returns the value
// in the accumulator.
builder()->SetExpressionPosition(position);
builder()->SuspendGenerator(generator_object(), registers, suspend_id); // [2] suspend_id

// Upon resume, we continue here.
builder()->Bind(generator_jump_table_, suspend_id);

// Clobbers all registers and sets the accumulator to the
// [[input_or_debug_pos]] slot of the generator object.
builder()->ResumeGenerator(generator_object(), registers);
}

CVE-2025-9132 的根本原因是 Parser::DesugarLexicalBindingsInForStatement 对 AST进行变换时引入了额外的 await using 变量。DesugarLexicalBindingsInForStatement 的注释解释了变换的方式。for (let/const x = i; cond; next) body 中的 let/const x = i 变成了 [1] [2] 两处 let/const x = ...。当这种变换应用到 PoC 中的代码时,源码中的一个 await using x = ... 变成了 AST 中的两处await using x = ... 。因此 BytecodeGenerator 在按照 AST 生成字节码并填充 JumpTable 时会出现 suspend_id >= info()->literal()->suspend_count() 的情况, 造成越界写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// ES6 13.7.4.8 specifies that on each loop iteration the let variables are
// copied into a new environment. Moreover, the "next" statement must be
// evaluated not in the environment of the just completed iteration but in
// that of the upcoming one. We achieve this with the following desugaring.
// Extra care is needed to preserve the completion value of the original loop.
//
// We are given a for statement of the form
//
// labels: for (let/const x = i; cond; next) body
//
// and rewrite it as follows. Here we write {{ ... }} for init-blocks, ie.,
// blocks whose ignore_completion_value_ flag is set.
//
// {
// let/const x = i; [1]
// temp_x = x;
// first = 1;
// undefined;
// outer: for (;;) {
// let/const x = temp_x; [2]
// {{ if (first == 1) {
// first = 0;
// } else {
// next;
// }
// flag = 1;
// if (!cond) break;
// }}
// labels: for (; flag == 1; flag = 0, temp_x = x) {
// body
// }
// {{ if (flag == 1) // Body used break.
// break;
// }}
// }
// }

利用

Xion大佬说得对,漏洞容易利用,“大觉”(真是一个信达雅的翻译:D)也确实很有趣。大家也可以动动手了。

参考

[1] https://chromereleases.googleblog.com/2025/08/stable-channel-update-for-desktop_19.html

[2] https://chromium-review.googlesource.com/c/v8/v8/+/6853483

[3] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Statements/await_using