XBOW 用 AI 智能体 6 天完成 Chrome 三段式全链漏洞利用,拿下 Google 25 万美元奖励
Zero to Calc in Six Days: Inside XBOW's Chrome Full Chain
XBOW 用 OpenAI 的 gpt-5.6-sol 智能体在 6 天内(8 月 2 日至 7 日)完成 Chrome 三段式全链漏洞利用,凭此拿到 Google VRP 25 万美元全链奖励。
XBOW 用 AI 智能体 6 天拼出 Chrome 三段式全链漏洞利用,模块化拆分与并行子任务的设计对攻防节奏与团队配置都有直接参考价值。
最终的这条链综合了 V8 编译器漏洞、动画 WebP 解码器漏洞以及 Crashpad 中的内存安全漏洞。它们共同把普通 Chrome 标签页中的 JavaScript 执行,变成了渲染进程沙箱之外的代码执行。
Google 在 2026 年 4 月颁布的 VRP 规则下,将首笔 25 万美元的 Chrome 全链奖金授予了这条链。我们的另一则公告介绍了该奖项及其对 XBOW 的意义。本文聚焦于漏洞利用:它跨越的边界、每个漏洞的根因,以及将它们串联起来的接口。
已签名 Chrome Beta 中的一网页,借助 XBOW 的全链漏洞利用启动了计算器。
谈论攻击性研究的速度以及它如何加速是一回事,亲身体验又是另一回事。在大语言模型出现之前,一条 Chrome 全链需要一个四人团队大约 6 到 9 个月才能完成;我们的首次提交用了 2 个月。而这条获奖的链?六天。这一速度甚至让我们也感到挑战,促使我们重新审视安全领域的变迁。
我们在六天内——从 8 月 2 日星期日一直到 8 月 7 日星期五——构建了这条链。我们没有让单个智能体端到端地完成整个漏洞利用,而是将工作拆分成多个目标更窄的并行任务:广泛的漏洞搜索、V8 内存破坏、V8 沙箱逃逸、渲染进程沙箱逃逸以及最终整合。使用 OpenAI gpt-5.6-sol 模型的 AI 智能体负责搜索源代码、验证假设、构建概念验证并开发漏洞利用的各个阶段;XBOW 的研究人员则负责选定目标、提供背景、质疑结果、调整搜索方向,并决定需要串联哪些组件。我们还开发了备用路径,并为每个阶段定义了清晰的接口,这样我们可以替换单个组件而无需重建整个漏洞利用。根据归档的 Codex 日志重建出的用量估算,这六天的活动产生了约 4 万美元的 API 费用。我们将在 10 月底的 Unprompted 上详细讲述时间线与协同编排;本文则聚焦于漏洞本身,以及将它们串联成完整链条的漏洞利用机制。
背景:V8 与渲染进程沙箱
V8 首先将 JavaScript 引擎内部的破坏行为限制在一个进程内沙箱中。大多数 JavaScript 堆对象位于一段预留的 4 GiB 区域内,许多指针以该区域内的偏移量来表示。对原生对象和可执行代码的引用还会获得额外的保护。因此,V8 的内存破坏漏洞虽能让攻击者在这道“牢笼”内部获得广泛的控制权,却并不能立即获得原生代码执行能力。V8 在 The V8 sandbox 中介绍了这种设计。
随后,Chrome 又将渲染进程本身限制在一个操作系统进程沙箱内。即便攻击者已在渲染进程中获得原生代码执行权限,仍不能自由访问机器的其余部分。Chromium 的沙箱设计文档描述了这一外层边界。
该链分三个漏洞阶段跨越了这两层边界:
| 阶段 | 漏洞 | 获得的能力 |
|---|---|---|
| JavaScript 引擎 | Maglev 在去优化过程中复用了可变装箱数值 | 在整个 V8 指针压缩沙箱中获得读写权限 |
| V8 沙箱 | 动画 WebP 将缓存的几何信息与已更改的图像字节相结合 | 在沙箱外获得一次选定的原生写入 |
| 渲染进程沙箱 | Crashpad 把对“看似已冻结”渲染进程的失败读取视作稳定观察 | 在未受沙箱保护的崩溃处理程序中获得代码执行 |
动画 WebP 阶段是一次 V8 沙箱逃逸,但我们并没有直接攻击沙箱本身,而是利用了 V8 沙箱故意不强制、而 Blink 又错误地认为可以信任的那一项属性。最后,利用 Crashpad 中的一个溢出,跨越最后一道外部进程边界,在主机上实现了非沙箱化的代码执行。
阶段一:改写不可变对象
第一个漏洞出现在 Maglev —— V8 的中层优化编译器中。要理解它,我们需要先看看去优化(deoptimization)。
优化代码在其所处理的数值和对象上基于各种假设运行。当某个假设在运行时不再成立,V8 必须重建出普通的 JavaScript 对象,并转入不那么特化的代码继续执行。其中一些对象在优化代码运行期间从未真实存在过;编译器只把它们表示成值。去优化必须把它们具象化。
一个采用 double 表示的 JavaScript 对象字段,在 V8 将对象具象化时需要一个 HeapNumber。存在漏洞的 Maglev 路径会为该虚拟值在老生代空间中分配一个 HeapNumber,而把对象自身存放在去优化的字面量数组中:
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<Float64Constant>()->value());
}
void BuildHeapNumber(Float64 number) {
DirectHandle<Object> value =
local_isolate_->factory()->NewHeapNumberFromBits<AllocationType::kOld>(
number.get_bits());
translation_array_builder_->StoreLiteral(GetDeoptLiteral(*value));
}上述代码来自 存在漏洞的 maglev-code-generator.cc。这个 bug 的另一半出在通用具象化器中:对于标记为 kStoreHeapObject 的字段,它会把翻译槽所引用的对象直接写入重建后的宿主对象:
if (marker == kStoreHeapObject) {
DirectHandle<HeapObject> field_value = slot->storage();
WRITE_FIELD(*object_storage, offset, *field_value);
WRITE_BARRIER(*object_storage, offset, *field_value);
continue;
}这是 translated-state.cc 中相对应的代码。
第一段代码把一个可变对象变成了字面量。第二段代码把这个对象的身份——而不仅仅是它的数值——复制到每一个重建后的宿主对象中。于是,借助嵌套激活,V8 就能创建出多个不同的 JavaScript 对象,让它们的 double 字段共享同一个可变的 HeapNumber。
这种行为违反了 V8 中别处声明的一条编译器不变式:
HeapNumberRef只能由不可变的HeapNumber创建。
存在漏洞的代码路径让一个可变数字看上去,恰好就是编译器被允许复用的一类不可变常量。
针对取值很大的 "2"
该利用利用了关于 Array.prototype.at() 的不一致。一次编译器观察看到一个安全的索引,便批准了一次在边界内的访问;对名义上同一常量的另一次观察则给出了实际的加载偏移。因此,仅修改某个对象的 double 字段,就可以改变优化代码所使用的偏移,而无需作废先前的边界推理。
三个经过优化的加载函数就足以泄露附近的 V8 指针、还原一个 packed-double 数组的布局,并获得一个由攻击者描述的数组的引用。随后,这个伪造的数组被用作跳板,去重写一个真实 Uint8Array 的元数据,将普通的索引访问扩展到 V8 整个 4 GiB 沙箱范围。
寻找稳定的对象地址还需要再走一步。该利用把一个引用存入 V8 的大对象空间,指向它需要定位的那个 JavaScript 对象。V8 自身在垃圾回收过程中会更新这个引用,因此无需钉死可移动对象,就能恢复出一个当前的沙箱内地址。
此时,漏洞利用已经可以从 JavaScript 在整个 V8 沙箱内进行可靠的内存访问。
修复方案
V8 去掉了字面量对象的特例。虚拟 HeapNumber 现在遵循普通的被捕获对象的具象化路径,Maglev 也明确禁止对它们去重。每次去优化都会具象化出一个全新的 box:
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<intptr_t>(object->allocation()));代码来自 已修复的修订版本。完整补丁是 V8 变更 8255554,公开的根本原因报告是 Chromium issue 547819997。
第二阶段:两个 WebP 在 V8 沙箱中分道扬镳
当我们寻找 V8 沙箱逃逸方式时,刻意决定不去攻击沙箱本身。相反,我们稍微思考了它的威胁模型,并选择寻找那些“被允许”但看起来可能存在问题的东西。我们重点关注了 V8 沙箱威胁模型对下游使用者施加的约束:即所有此类代码必须防范沙箱内攻击者所拥有的能力,例如对沙箱内对象的并发修改、相同类型指针之间的交换等。
我们发现的漏洞恰好是这样一个例子:WebCodecs 可以通过 transfer 的方式把一个 ArrayBuffer 交给 ImageDecoder。按照普通的 JavaScript 语义,transfer 会分离(旧)缓冲区及其视图。然后 Blink 可以不进行复制就把后备存储包装起来,并把这些字节当作它独占的输入。
但 V8 沙箱有不同的威胁模型:一旦攻击者控制了沙箱内的内存,V8 就假定攻击者能够损坏其中的对象和簿记信息。它并不承诺把已分离的 ArrayBuffer 在该攻击者面前做成语义上不可变的。要强制实现该属性,就需要移动或复制后备存储,或者在沙箱设计上本想避免的热路径上加入检查。
在完整的攻击链中,漏洞利用使用了第一阶段提供的沙箱内写入,把一个仍存活的 Uint8Array 视图从其 JSArrayBuffer 的分离通知链上移除。当 ImageDecoder 被 transfer 时,V8 分离了它仍然知道的视图。通过被遗漏的视图进行的优化写入仍然可用。Blink 相信它独占拥有这些字节;攻击者依然可以修改它们。
让一个 WebP 对另一个 WebP 发起 TOCTOU 攻击
上述被破坏的所有权假设在 WebP 解码中表现为一个检查时间/使用时间(TOCTOU)漏洞。
概念验证把两个长度相同、且各自有效的动画 WebP 文件放在同一个后备存储中:
- WebP A 描述了一个 64×64 的画布。它的第一帧宽 48 像素,从
x = 16开始,高 64 像素。 ImageDecoder被 transfer,并且其持久化解复用器缓存了 A 的帧矩形和有效负载位置。- 幸存的视图把这些字节替换成了 WebP B,其当前有效负载可以解码出一整个 64×64 的帧。
- 解码器从 B 中读取压缩数据,但用从 A 缓存下来的帧几何信息描述输出缓冲区。
WEBPImageDecoder::DecodeSingleFrame() 根据缓存的矩形初始化了外部缓冲区的跨距和大小。然后它独立地把输出指针推进到该矩形的左上角像素(代码):
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<uint8_t*>(
buffer.GetAddr(frame_rect.x(), frame_rect.y()));接下来的一条语句把该描述符传给了 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,384WebPIUpdate() 在 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 位图本身获取其跨距和范围:
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<uint8_t*>(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);代码出现在后续的 修订 中。根本原因报告与 ASAN 复现位于 Chromium issue 548509153。两项已落地的修复是 Chromium change 8266257 与 Chromium change 8352899。
WASM/WCP 的交接
阶段 2 在 V8 沙箱之外产生了一次受控的原生写入。但整条链仍需要把这次写入转化为任意的原生代码执行,同时不能修改受保护的代码指针条目。
一个热路径的 WebAssembly 函数通过 V8 进程级的 Wasm Code Pointer 表解析其调用目标。漏洞利用修改了用于定位该表的可写基址,并提供了一个类型正确的新条目。
我们借鉴了此前 完整链 中的“跳岛”技术。该漏洞利用曾通过短跳转,把编码在 JIT 双精度常量中的指令片段串联起来。这次,我们改用了 Liftoff 生成的 Wasm 代码中的 13 个 64 位立即数,并配合 Windows loader ABI。
在 WebPCopyDecBufferPixels() 改写了 Wasm Code Pointer 表的基址之后,下一次 Wasm 调用通过了签名检查,并解析到了替换后的条目。该条目指向 chrome.dll 中的一条 jmp rcx 指令;RCX 指向第一个指令岛。
每个岛最多为 loader 贡献 6 个字节,其后接一条短跳转,跳过编译器生成的指令后到达下一个岛。13 个岛共同组成了一个约 70 字节的 loader。它分配可写内存、复制 Crashpad 载荷、将映射改为可执行,然后调用它。
阶段 3:渲染进程拒绝休眠
Chrome 的 Crashpad 处理程序运行在渲染进程沙箱之外。当它收到快照请求时,会挂起渲染进程并读取其地址空间中的数据结构。处理程序创建 ScopedProcessSuspend,在通知快照机制目标已挂起之前,会调用 NtSuspendProcess()。
在 紧邻修复之前的修订 中,Crashpad 的一个远程内存助手 ProcessMemoryWin::ReadUpTo() 包含如下恢复路径:
SIZE_T size_out = 0;
BOOL success = ReadProcessMemory(
handle_, reinterpret_cast<void*>(address), buffer, size, &size_out);
if (success)
return base::checked_cast<ssize_t>(size_out);
if (GetLastError() == ERROR_PARTIAL_COPY) {
// If we can not read the entire section, perform a short read of the first
// page instead. This is necessary to support ReadCString().
size_t short_read =
base::GetPageSize() - (address & (base::GetPageSize() - 1));
success = ReadProcessMemory(handle_,
reinterpret_cast<void*>(address),
buffer,
short_read,
&size_out);
if (success)
return base::checked_cast<ssize_t>(size_out);
}乍一看,这个错误似乎只是 short_read 未被限制在 size 之内。但该计算在 Crashpad 自认为已经建立的一个假设下是安全的:被挂起进程的地址空间不会发生变化。
回退机制本用于从一页可读页开始、跨越到不可访问页的读取。若对 size 字节的请求跨越该边界,则第一页中剩余的字节数必须小于 size。仅重试这些字节因此能放入调用方的目标缓冲区。
在稳定的页面保护下,存在三种常见情况:
- 完全位于可读页内的请求会立即成功。
- 完全位于不可访问页内的请求在重试时仍然失败。
- 从可读页跨越到不可访问页的请求会产生比原始请求更短的重试。
因此代码将首次失败用作源映射几何结构的依据。当渲染进程能在两次读取之间更改映射时,这一推断便不再成立。
打破冻结
Windows 允许使用 THREAD_CREATE_FLAGS_BYPASS_PROCESS_FREEZE 标志创建的线程在 NtSuspendProcess() 期间继续运行。在请求快照之前,原生渲染进程载荷创建了这样一个线程。该漏洞利用使用了 0x42:BYPASS_PROCESS_FREEZE 与 SKIP_THREAD_ATTACH 这两个标志。Windows 究竟为何提供此功能仍是个谜,但它允许我们在读取与重试之间修改页面保护,从而造成了此处所需的假设违背。
当 Crashpad 将渲染进程视为已挂起时,该线程在 PAGE_NOACCESS 与 PAGE_READWRITE 之间反复切换由攻击者控制的源页面。获胜的顺序如下:
- Crashpad 发出了一个完全位于一个源页内的小型读取请求。
- 由于该页面不可访问,
ReadProcessMemory()返回了ERROR_PARTIAL_COPY。 - Crashpad 将该失败解释为请求已跨越页边界的证据。
- 免除冻结的渲染进程线程将该页变为可读。
- Crashpad 一直重试到该页末尾,复制的数据多于调用方所请求的量。
对于该漏洞利用所使用的调用点,相关数字为:
requested length = 0x0b8
source offset in page = 0x0e98
computed retry length = 0x1000 - 0x0e98 = 0x168
destination heap slot = 0x0c0
overwrite into next slot = 0x168 - 0x0c0 = 0x0a8原始的 0xb8 字节请求远在页边界前结束。它之所以能产生所需的错误,仅因为在该瞬间整页都不可访问。一旦渲染进程将该页变为可读,0x168 字节的重试便成功并越界写入了分配空间。
为使竞争可靠,载荷在一次快照请求中提供了 48 个候选读取。一个免除冻结的工作线程在可读与不可访问状态之间循环切换其页面。其他辅助线程则保持挂起,以维持线程数量——以及 Crashpad 与之相关的分配——的稳定。
改写一个活跃的 Crashpad writer
过大的重试仅能破坏紧邻其 0xc0 读缓冲区槽位之后的分配。载荷安排该分配包含一个活跃的 SnapshotMinidumpMemoryWriter,Crashpad 稍后会使用它从渲染进程读取一段内存范围并通过其输出路径发送这些字节。
为构造这一相邻关系,载荷暴露了一个包含按选定顺序排列的内存范围的可读进程与加载器图。Crashpad 为每个范围分配一个 writer,随后合并被包含的范围并释放其 writer。该顺序在仍保持活跃的 writer 之前留下一个被释放的 0xc0 writer 槽位;下一次 0xb8 字节的读缓冲区重用了该槽位。线程、TEB 和可读栈的 writer 会影响分配顺序,因此载荷对这些输入进行计数并调整范围数量,以保持同一桶位。
在我们测试的支持 CET 的系统上,处理程序使用了用户影子栈,而 CFG 将间接调用限制在经过批准的目标上。为避免触发这些缓解措施,我们使用了普通的调用和返回序列,并将替换的虚调用指向现有的通过 CFG 校验的函数。
存在漏洞的重试读取重写了活动写入器的前 0xa8 个字节。在偏移量 0x00 处,它写回了 SnapshotMinidumpMemoryWriter 虚表;在 0x70 处,它将写入器的状态设置为可写;在 0x78 处,它用一个移位后的 CopyingOutputStreamAdaptor 虚表替换了 MemorySnapshot::Delegate 虚表,以便委托回调进入适配器的缓冲写入路径。重写操作在 memory_snapshot_ 之前的偏移量 0xa8 处立即结束。
Crashpad 之后通过其第一个虚表进入了写入器的 WriteObject()。WriteObject() 通过保留的快照指针调用 Read(),该调用从渲染进程读取了一个受控的 0xd0 字节范围。通过第二个虚表的回调将这些字节复制到 chrome.exe 中的一个可写地址,并将同一地址视为适配器的基础输出流。
复制的字节同时构造了一个单方法输出流和一个后备启动器对象。当适配器刷新时,流的虚 Write() 进入了 Chrome 的 FallbackUnhandledExceptionFilter(),这是一个通过 CFG 校验的目标。这些字节还将 Chrome 的后备启动器全局指向复制的对象,并提供了其命令和启动字段。过滤器调用了 FallbackCrashHandlerLauncher::LaunchAndWaitForHandler(),它将 cmd /k calc.exe 传递给 CreateProcessW。这些调用维护了影子栈。
由于 Windows ASLR 对模块只进行一次随机化,而不是为每个进程都进行(这是选择使用重定位而非 PIC/PIE 代码带来的后果),它在渲染进程和处理进程中都将 chrome.exe 映射到相同的地址。这意味着渲染进程的有效载荷可以从其自身进程中的映像基址推导出处理进程的地址,并通过映像时间戳和大小来验证确切的构建版本,以便应用任何特定于构建的偏移量(鉴于 Chrome 快速的发布周期,我们必须能够在新版本发布时迅速重新定向攻击链)。
修复
修复后的 实现 移除了 Crashpad 对两次读取之间目标保持不变的依赖。重试现在受到源页边界和调用者原始请求两者的限制:
if (GetLastError() == ERROR_PARTIAL_COPY) {
// If we can not read the entire section, perform a short read of the first
// page instead. This is necessary to support ReadCString().
const size_t page_remainder =
base::GetPageSize() - (address & (base::GetPageSize() - 1));
const size_t short_read = std::min(size, page_remainder);
success = ReadProcessMemory(handle_,
reinterpret_cast<void*>(address),
buffer,
short_read,
&size_out);
if (success)
return base::checked_cast<ssize_t>(size_out);
}这种限制是必要的防御性编程,但最好将其理解为移除一个时间假设:即使源在失败的读取和重试之间发生变化,Crashpad 也不再能写入超出目标范围的内容。
同一补丁通过在推进目标指针之前拒绝任何大于剩余请求计数的 ReadUpTo() 结果来强化通用调用者。该检查出现在 process_memory.cc 中。
完整的修复是 Chromium 变更 8267322,公开报告是 Chromium issue 545820931。
替换中间环节
既然现在包括 Google 在内的所有人都在用 LLM 扫描像 Chrome 这样的代码库,漏洞撞车就成了一个重大风险。为了防范这一点,我们设计了模块化阶段的漏洞利用,并试图为每个部分准备备用漏洞。这个策略在我们攻击链的第一个版本上取得了成效——它使用了一个 Gin 漏洞作为其 V8 沙箱逃逸,却撞上了与 issue 542670566 重复的情况。在经历了紧张的几小时之后,我们成功用本文描述的动画 WebP 阶段替换了基于 Gin 的 V8 逃逸;这个替换阶段挽救了攻击链,并且 Google 确认该攻击链成功弹出了计算器。
结论
从这条漏洞链中(据我们所知,这是 LLM 时代首个获得奖励的完整 Chrome 漏洞链)我们能得出什么结论?我们有一些想法:
- 就漏洞本身而言,我们注意到一个事实:(巧合的是)三个漏洞本质上都是关于变更某些被认为不可变的东西:第 1 阶段中是一个
HeapNumber;第 2 阶段中是一个被传输的 WebP 缓冲区;第 3 阶段中则是一个所谓已被冻结进程的页面保护。这类不变量(invariant)破坏行为可能正是 LLM 擅长的地方:它们可以运用其关于操作系统 API 和开源代码的百科全书式知识,找出使不变量失效的场景。 - 作为 VRP 报告者,避免撞洞非常困难,除非你能带来超出基础 LLM 的新东西(而且往往仅凭这些也不足以让你免于撞洞)。我们的第一个完整漏洞链(Andrés Luksenberg 将在 OffensiveCon Tokyo 上讨论)在最终阶段与数月前 Google 内部发现的一个漏洞发生撞洞而宣告失败,此后我们也看到其他漏洞链遭遇同样的命运。请将精力投入到建立自己对目标的理解上,并做好失望的准备!
- 即使 LLM 完成了绝大部分工作,如果你能为智能体提供高质量的参考示例,成功率也会更高。在我们的案例中,智能体借鉴了 XBOW Native Team 成员最初编写的漏洞利用技术,例如“跳岛”(island hopping)加载器,从而构建出模块化且可靠的漏洞利用。
- 当回报是一个 Chrome 漏洞链时,六天时间和 4 万美元并不算多!当然,这并不意味着任何人在同样的时间和金钱下都能拿到 Chrome 漏洞链(如前所述,我们确实投入了相当多自己的人类专业知识和判断),但它确实意味着门槛已显著降低。请据此加强防御。
来源:Hacker News · xbow.com