缓存时间:
2026/10/13 18:57
# 六天从零到计算器:XBOW 的 Chrome 全链漏洞内幕
来源:https://xbow.com/labs/research/zero-to-calc-in-six-days-inside-xbow-chrome-full-chain
最终的漏洞链结合了 V8 编译器漏洞、animated-WebP 解码器漏洞以及 Crashpad 中的内存安全漏洞。三者共同作用,将普通 Chrome 标签页中的 JavaScript 执行转化为渲染进程沙箱之外的代码执行。Google 授予该漏洞链 2026 年 4 月 VRP 新规则下的首个 Chrome 全链 250,000 美元奖励。
我们另有一篇公告(https://xbow.com/blog/xbow-built-a-chrome-full-chain-exploit)介绍了该奖项及其对 XBOW 的意义。本文聚焦于漏洞本身:它跨越了哪些边界、每个漏洞的根本原因,以及连接各环节的接口。
*一个已签名的 Chrome Beta 版网页通过 XBOW 的全链漏洞利用启动了计算器。*
谈论攻防加速的速度是一回事,亲身经历则是另一回事。在 LLM 出现之前,一个 Chrome 全链漏洞通常需要四人团队耗费约 6-9 个月才能完成,我们的首次提交耗时两个月。而这次获奖的这条链呢?六天。这个速度即使对我们自己来说,也让我们不得不重新审视安全领域正在发生的变化。
我们从 8 月 2 日(周日)到 8 月 7 日(周五)在六天内构建了这条漏洞链。我们并未让单一智能体端到端地完成整个漏洞利用,而是将工作拆分为多个目标更聚焦的并行任务:广泛的漏洞搜索、V8 内存破坏、V8 沙箱逃逸、渲染进程沙箱逃逸以及最终集成。使用 OpenAI 的 `gpt-5.6-sol` 模型的 AI 智能体搜索源代码、验证假设、构建概念验证并开发漏洞利用的各个阶段;XBOW 研究员则负责选定目标、提供上下文、质疑结果、调整搜索方向,并决定将哪些组件串联起来。我们还开发了备用路径,并为每个阶段设计了干净的接口,因此可以在不重建其余部分的情况下替换某个组件。
根据归档的 Codex 日志还原使用情况,六天的行动大约花费了 40,000 美元的 API 费用。我们将在十月底的 Unprompted 上详细介绍时间线和编排过程;本文聚焦于漏洞本身以及将它们连接成完整链条的利用机制。
## 背景:V8 与渲染进程沙箱
V8 首先将 JavaScript 引擎级别的破坏限制在一个**进程内沙箱**中。大多数 JavaScript 堆对象位于一个预留的 4 GiB 区域内,许多指针在该区域内以偏移量的形式表示。对原生对象和可执行代码的引用则受到额外保护。因此,一个 V8 内存破坏漏洞可以让攻击者在该笼子(cage)内部获得广泛的控制权,但不会立即获得原生代码执行能力。V8 在其博客文章 The V8 sandbox(https://v8.dev/blog/sandbox)中描述了这一设计。
Chrome 随后将渲染进程本身包含在一个**操作系统进程沙箱**中。即使攻击者已在渲染进程内获得原生代码执行能力,也无法自由访问机器的其余部分。Chromium 的沙箱设计文档(https://chromium.googlesource.com/chromium/src/+/main/docs/design/sandbox.md)描述了这一外部边界。
该漏洞链通过三个漏洞阶段跨越了这两个边界:
| 阶段 | 漏洞 | 获得的能力 |
|------|------|-----------|
| JavaScript 引擎 | Maglev 在去优化(deoptimization)期间复用了可变的装箱数字 | 在 V8 指针压缩笼子内的读写访问 |
| V8 沙箱 | Animated WebP 将缓存的几何信息与已更改的图像字节组合 | 笼子外一次选定的原生写入 |
| 渲染进程沙箱 | Crashpad 将对一个本应冻结的渲染进程的失败读取视为稳定观测 | 在未沙箱化的崩溃处理器中的代码执行 |
Animated WebP 阶段是一个 **V8 沙箱逃逸**,但其并非攻击沙箱本身,而是利用了 V8 沙箱刻意不强制执行、而 Blink 错误地认为可以信任的一项属性。最后,漏洞利用利用了 Crashpad 中的缓冲区溢出,跨越了最外层的进程边界,在主机上实现了未沙箱化的代码执行。
## 阶段一:改变不可变之物
第一个漏洞位于 Maglev 中,这是 V8 的中层优化编译器。要理解它,我们需要了解去优化机制。优化代码在运行时基于对其所处理的值和对象的假设。当某个假设在运行时不再成立时,V8 必须重建普通的 JavaScript 对象并在不太特化的代码中继续执行。其中一些对象在优化代码运行期间从未在内存中存在过;编译器仅将它们表示为值。去优化必须将它们具象化(materialize)。
一个使用 double 表示的 JavaScript 对象字段在 V8 具象化该对象时需要一个 `HeapNumber`。存在漏洞的 Maglev 路径为虚拟值创建了一个老生代(old-space)`HeapNumber`,并将对象本身存储在去优化字面量数组中:
```cpp
void BuildHeapNumber(const VirtualObject* vobject) {
DCHECK_EQ(vobject->object_type(), vobj::ObjectType::kHeapNumber);
ValueNode* value_node = vobject->get(offsetof(HeapNumber, value_));
return BuildHeapNumber(value_node->Cast()->value());
}
void BuildHeapNumber(Float64 number) {
DirectHandle value = local_isolate_->factory()->NewHeapNumberFromBits(
number.get_bits());
translation_array_builder_->StoreLiteral(GetDeoptLiteral(*value));
}
```
该代码来自**存在漏洞的** maglev-code-generator.cc(https://github.com/v8/v8/blob/b5a03cd89325ee7d958f1aee62400dec94c075ca/src/maglev/maglev-code-generator.cc#L1560-L1571)。
该漏洞的另一半位于通用具象化器(generic materializer)中。对于标记为 kStoreHeapObject 的字段,它将翻译槽(slot)所持有的对象直接写入重建的拥有者中:
```cpp
if (marker == kStoreHeapObject) {
DirectHandle field_value = slot->storage();
WRITE_FIELD(*object_storage, offset, *field_value);
WRITE_BARRIER(*object_storage, offset, *field_value);
continue;
}
```
这是 `translated-state.cc`(https://github.com/v8/v8/blob/b5a03cd89325ee7d958f1aee62400dec94c075ca/src/deoptimizer/translated-state.cc#L2629-L2634)中对应的代码。
第一个代码块将一个可变对象变成了字面量。第二个代码块将该对象的身份(identity),而不仅仅是其数值,复制到每个重建的拥有者中。通过嵌套激活,V8 因此可以创建不同的 JavaScript 对象,而它们的 double 字段共享同一个可变的 `HeapNumber`。这一行为违反了 V8 在其他地方声明的编译器不变量(https://github.com/v8/v8/blob/b5a03cd89325ee7d958f1aee62400dec94c075ca/src/compiler/heap-refs.h#L757-L761):
> `HeapNumberRef` 仅为不可变的 `HeapNumber` 创建。
存在漏洞的路径使得一个可变数字看起来恰好是编译器被允许复用的那类不可变常量。
### 对于极大值的"2"
漏洞利用利用了这一分歧,目标是 `Array.prototype.at()`。一个编译器观测点看到一个安全的索引并批准了一次边界内访问。另一个对名义上相同常量的观测点则提供了实际的加载偏移量。因此,更改一个对象的 double 字段可以在不使之前的边界推理失效的情况下,改变优化代码所使用的偏移量。
三个优化的加载函数足以泄露附近的 V8 指针、恢复紧凑 double 数组的布局,并获得对一个攻击者指定数组的引用。随后,这个伪造的数组被用作引导,重写一个真实 `Uint8Array` 的元数据,将普通的索引访问扩展到 V8 整个 4 GiB 笼子范围。
找到稳定的对象地址还需要一个额外步骤。漏洞利用在 V8 的大对象空间中存储了一个引用,指向任何需要定位的 JavaScript 对象。V8 在垃圾回收期间会自动更新该引用,因此漏洞利用无需固定可移动对象即可恢复一个当前的笼内地址。
至此,漏洞利用已从 JavaScript 中获得了对整个 V8 沙箱的可靠内存访问。
### 修复
V8 移除了字面量对象的特殊处理路径。虚拟 `HeapNumber` 现在遵循普通的捕获对象具象化路径,且 Maglev 显式地阻止了对它们的去重。每次去优化都会具象化一个全新的装箱对象:
```cpp
vobj::ObjectType object_type = object->object_type();
DCHECK_NOT_NULL(object->allocation());
// HeapNumbers may be mutable object fields; each materialization must
// create a fresh box, so they are never deduplicated.
// TODO(victorgomes): Constrain which objects may contain mutable
// HeapNumbers. Immutable HeapNumbers can be stored as a literal object
// instead of a captured object.
int dup_id = object_type == vobj::ObjectType::kHeapNumber
? CreateUnduplicatableId()
: GetDuplicatedId(reinterpret_cast(object->allocation()));
```
该代码来自修复后的版本(https://github.com/v8/v8/blob/7d5eb1df2a721c8fe3aa03b999464c4b86242080/src/maglev/maglev-code-generator.cc#L1616-L1626)。完整的补丁是 V8 change 8255554(https://chromium-review.googlesource.com/c/v8/v8/+/8255554),公开的根本原因报告是 Chromium issue 547819997(https://issues.chromium.org/issues/547819997)。
## 阶段二:两个 WebP 在 V8 笼子中分道扬镳
当我们着手寻找 V8 沙箱逃逸时,我们有意识地决定避免直接攻击沙箱本身。相反,我们思考了其威胁模型,并选择寻找那些"被允许"但可能存在问题的东西。我们聚焦于 V8 沙箱威胁模型对下游消费者施加的义务:即所有此类代码都必须防范笼内攻击者所拥有的能力,例如对笼内对象的并发修改、同类型指针之间的替换等。
我们发现的漏洞正是一个典型例子:WebCodecs 可以通过传输(transfer)将 `ArrayBuffer` 交给 `ImageDecoder`。按照正常的 JavaScript 语义,传输会使旧缓冲区及其视图脱离(detach)。Blink 随后可以不复制地直接包装底层存储,并将这些字节视为其独占输入。但 V8 沙箱的威胁模型不同:一旦攻击者控制了笼内内存,V8 假设攻击者可以破坏笼内的对象和簿记数据。它不承诺对这样的攻击者保证已脱离的 `ArrayBuffer` 在语义上不可变。强制执行该属性需要移动或复制底层存储,或者在热路径中添加检查——而沙箱设计上恰恰要避免这些。
在完整链条中,漏洞利用使用第一阶段提供的笼内写入,将一个活跃的 `Uint8Array` 视图从其 `JSArrayBuffer` 的脱离通知链中移除。当 `ImageDecoder` 传输缓冲区时,V8 仅脱离了它仍然知晓的视图。通过被遗漏的视图进行的优化写入仍然可用。Blink 认为它独占拥有这些字节;攻击者却仍可更改它们。
### 让一个 WebP 对另一个 WebP 实施 TOCTOU
这一被破坏的所有权假设在 WebP 解码中表现为一个检查时间/使用时间(TOCTOU)漏洞。概念验证将两个长度相同、各自独立有效的 animated WebP 文件放在同一个底层存储中:
1. WebP A 描述了一个 64x64 的画布。其第一帧宽 48 像素,起始于 `x = 16`,高 64 像素。
2. `ImageDecoder` 传输了缓冲区,其持久解复用器(demuxer)缓存了 A 的帧矩形和载荷位置。
3. 残存的视图用 WebP B 替换了字节,B 的当前载荷解码出一个完整的 64x64 帧。
4. 解码器从 B 读取压缩数据,但使用从 A 缓存的帧几何信息来描述输出缓冲区。
`WEBPImageDecoder::DecodeSingleFrame()` 使用缓存的矩形初始化外部缓冲区的步幅(stride)和大小。然后它独立地将输出指针推进到该矩形的左上角像素(代码见 https://github.com/chromium/chromium/blob/28f9e7569b6c3d069a658e818cb08bcc70c896e9/third_party/blink/renderer/platform/image-decoders/webp/webp_image_decoder.cc#L775-L793):
```cpp
const gfx::Rect& frame_rect = buffer.OriginalFrameRect();
if (!decoder_) {
// Set up decoder_buffer_ with output mode
if (!WebPInitDecBuffer(&decoder_buffer_)) {
return SetFailed();
}
decoder_buffer_.colorspace = RGBOutputMode();
decoder_buffer_.u.RGBA.stride = Size().width() * sizeof(ImageFrame::PixelData);
decoder_buffer_.u.RGBA.size = decoder_buffer_.u.RGBA.stride * frame_rect.height();
decoder_buffer_.is_external_memory = 1;
decoder_ = WebPINewDecoder(&decoder_buffer_);
if (!decoder_) {
return SetFailed();
}
}
decoder_buffer_.u.RGBA.rgba = reinterpret_cast(
buffer.GetAddr(frame_rect.x(), frame_rect.y()));
```
下一条语句(https://github.com/chromium/chromium/blob/28f9e7569b6c3d069a658e818cb08bcc70c896e9/third_party/blink/renderer/platform/image-decoders/webp/webp_image_decoder.cc#L795)将该描述符传递给 `WebPIUpdate()`。
对于 ASAN 复现器,位图步幅为 256 字节,完整分配为 16,384 字节。`GetAddr(16, 0)` 将输出指针前进了 64 字节,剩余 16,320 字节。这恰好够容纳 A 的缓存矩形:
```
256 * (64 - 1) + 48 * 4 = 16,320
```
然而 Blink 声称拥有全部 16,384 字节。因此 B 的 64 像素宽载荷通过了 libwebp 的外部缓冲区大小检查:
```
256 * (64 - 1) + 64 * 4 = 16,384
```
`WebPIUpdate()` 在 Blink 将解码尺寸与缓存矩形进行比较之前就写入了像素。解码的覆盖范围超出了原生 `SkBitmap` 分配边界 64 字节。ASAN 捕获到一个从分配之后第一个字节开始的 256 字节转换写入。两个 WebP 各自都是合法的。不安全的状态仅因 Blink 将 A 的缓存几何信息与 B 的当前字节组合在一起而产生。
### 从堆溢出到一次选定的原生写入
生产环境的漏洞利用使用了相同漏洞,但为 PartitionAlloc 选择了特定的几何参数。一个两像素帧起始于 126 像素宽画布的 `x = 124`。504 字节的像素分配和一个 496 字节的 `WebPIDecoder` 占据了 512 字节桶中的相邻槽位。将输出指针推进 496 字节后仅剩 8 字节,而解码器仍认为自己拥有 504 字节。像素写入将活跃解码器的原生字段替换为指向两个 `WebPDecBuffer` 记录的指针,这些记录已预先放置在原生网络缓冲区中。当解码完成时,真实的 `WebPCopyDecBufferPixels()` 例程按照伪造的源和目标描述符执行操作。它将一个选定的 64 位值复制到一个选定的原生地址。这一次写入就是图像漏洞利用与下一阶段之间的全部接口。
### 修复
第一个修复的补丁将 `rgba`、`stride` 和 `size` 一起设置,并将声明的范围限制在保留的矩形内。后续修复移除了一个更隐蔽的假设:解码器不再计算分配中应该包含什么,而是直接从 `ImageFrame` 位图中获取步幅和范围:
```cpp
const gfx::Rect& frame_rect = buffer.OriginalFrameRect();
CHECK(gfx::Rect(Size()).Contains(frame_rect));
CHECK(!frame_rect.IsEmpty());
// The top-left pixel of frame_rect.
ImageFrame::PixelData* frame_rect_front = buffer.GetAddr(frame_rect.x(), frame_rect.y());
// The bottom-right pixel of frame_rect.
ImageFrame::PixelData* frame_rect_back = buffer.GetAddr(frame_rect.right() - 1, frame_rect.bottom() - 1);
decoder_buffer_.u.RGBA.rgba = reinterpret_cast(frame_rect_front);
decoder_buffer_.u.RGBA.stride = buffer.Bitmap().rowBytes();
decoder_buffer_.u.RGBA.size = (frame_rect_back - frame_rect_front + 1) * sizeof(ImageFrame::PixelData);
```
该代码出现在后续修订版中(https://github.com/chromium/chromium/blob/a568a4de86aadeefa2ec15bd0c37915d51a87d3a/third_party/blink/renderer/platform/image-decoders/webp/webp_image_decoder.cc#L797-L809)。根本原因报告和 ASAN 复现器位于 Chromium issue 548509153(https://issues.chromium.org/issues/548509153)。两个已合入的修复分别是 Chromium change 8266257(https://chromium-review.googlesource.com/c/chromium/src/+/8266257)和 Chromium change 8352899(https://