导语:作者在空闲时间研究V8漏洞利用,将三个公开CVE串联成攻击链,成功在Google的v8CTF挑战中读取到flag。然而,因使用了n-day漏洞链且可靠性未能达到80%的要求,最终与万美元悬赏失之交臂。这是一个关于耐心、调试和AI辅助研究的真实故事。
TLDR
作者目前没有全职工作,这意味着有危险的空闲时间。故事的起点是读完一篇V8博客文章,最终结局是用ROP链让Chrome打开/flag/flag并打印flag。
核心攻击链:
- 第一个漏洞泄漏压缩对象地址
- 第二个漏洞将GC错误转化为伪造的JavaScript数组,获得V8 cage内的读写权限
- 第三个漏洞利用JSPI和JS Dispatch Table不匹配,将原生栈 pivot 到cage外部
最终flag:v8CTF{1785916837:02b9910f32b5064c14c693a910748736031da940}
战果统计:
- 真实的Google flag ✓
- V8沙箱逃逸 ✓
- 10万美元赏金 ✗
拿到flag和赢得比赛是两件完全不同的事。
空闲时间,总是危险的
读完三篇浏览器漏洞利用文章后,你会获得一种非常特定的自信。你不足以独立构建exploit,但你足够相信自己能行。
这就是故事的开始。
作者在应用安全领域摸爬滚打多年,熟悉古老的IE漏洞利用、heap spray、ROP、ASLR、DEP那套东西。但现代V8漏洞利用看起来完全是另一个星球的故事。每个有用的指针都藏在各种表后面,每个对象都有压缩表示。每次你以为理解了heap,垃圾回收器就把它移动了。
所以作者选择了一种唯一有效的方式来学习:选一个具体目标,定义一个荒谬的终点线,然后一直前进,直到终点线或者你的耐心先崩溃。
Google已经提供了终点线。
什么是v8CTF?
v8CTF是Google持续的V8漏洞挑战。Google在其基础设施上运行一个固定的Chrome构建版本。你连接到服务,解决一个工作量证明,然后给出一个HTTPS URL。Chrome打开你的页面,页面上存在一个flag在/flag/flag。
你的任务是让Chrome打印它。
这听起来很简单,因为句子隐藏了几乎所有的工作。协议很小,但从第一个框到最后一个框之间的浏览器内部知识量可一点都不小。
官方规则说符合条件的提交可以获得10万美元。但”符合条件”这个词承载了太多:
- 漏洞利用必须从Google的基础设施中恢复真实的flag。
- 只有针对同一初始内存破坏漏洞的第一次提交才有资格获得奖金。
- 通常只有针对已部署V8版本的第一次提交才能获得该版本的槽位。
- 0day漏洞不受单独版本限制。
- n-day flag必须在Google打开nday窗口后捕获。
- 平均运行时间必须保持在5分钟以下。
- 成功率必须至少达到80%。
如果你发现并报告了初始漏洞,Google可以将该链视为0day。如果漏洞已经公开或被其他人发现,则该链是n-day。
本文作者的链显然是一个n-day链,因为他没有发现原始漏洞,而是结合了公开的工作成果,针对确切目标进行调整,并构建了漏洞之间缺失的部分。
仍然很困难,但不是0day。
确切的Chrome版本很关键
目标版本是Linux x86-64上的Chrome for Testing 150.0.7871.46。其中包含V8 15.0.245.13,提交版本为968f19a8970f8d91702d86f0ec1522f3909781b7。
浏览器漏洞利用可不是”Chrome 150左右”这么简单。
map值、对象布局、内置函数偏移、ROP gadget或指针表规则在不同构建版本之间可能发生变化。针对错误的小版本编写的exploit通常只是一种昂贵的崩溃报告启动方式。挑战也禁用了崩溃报告,所以有时候只是一种昂贵的凝视沉默的方式。
Chrome使用以下flags启动:
chrome
--headless=new
--no-sandbox
--disable-crashpad
--disable-breakpad
--enable-logging=stderr
--user-data-dir=/home/user
"${url}"
--no-sandbox flag需要解释一下。
Chrome有多个安全边界。进程沙箱在这次挑战中被禁用了,但进程内V8 heap沙箱仍然存在。外层的kCTF和nsjail隔离也保留了。在渲染器中执行原生代码足以读取挑战flag,但在V8 heap内部获得普通内存破坏还不够。
作者从V8沙箱逃逸到原生渲染器。没有逃逸Chrome的进程沙箱(因为挑战禁用了它),也没有逃逸外层jail。
这个区别很重要。”Chrome沙箱逃逸”会是一个更好的标题。但它也是错误的。
现代V8漏洞利用,像老IE那样解释
如果你还记得古老的浏览器漏洞利用,大致计划是这样的:
- 触发一个use-after-free或溢出
- spray直到受控数据落在旧对象所在的位置
- 替换一个指针或vtable
- 泄漏模块地址以击败ASLR
- 构建ROP以绕过DEP
- 跳转到有用的地方
现代V8有相同的家族相似性。但也在每一步之间设置了几道锁着的门。
| 老浏览器的思路 | 现代V8版本 |
|---|---|
| spray the heap | groom young space, old space and large-object space |
| reclaim a freed object | reclaim a stale tagged value with a fake JS object |
| overwrite a raw pointer | first obtain read/write inside the 4 GB V8 cage |
| leak a module address | cross from caged metadata into a native resource leak |
| replace a code pointer | deal with trusted pointer and dispatch tables |
| run shellcode | often reuse existing code because W^X is still active |
现代exploit不是用一个神奇的漏洞立即产生原生代码执行。它是一系列更小能力的阶梯。
每个框都有一个测试。如果一个阶段没有产生可观察的结果,它就不存在,无论理论看起来多么令人信服。

一点点V8架构知识
JavaScript值需要表示整数、双精度数、字符串、对象、数组和函数。V8使用tagged values来快速区分小整数和堆对象引用。
在64位指针压缩构建中,许多堆引用以32位偏移的形式存储在一个叫做cage的4GB区域内。V8使用cage base重建完整指针。
compressed heap pointer
31 1 0
+--------------------------------+-+
| offset inside the 4 GB cage |1| object tag
+--------------------------------+-+
small integer (Smi)
+--------------------------------+-+
| signed integer bits |0|
+--------------------------------+-+
这节省了内存,并限制了被破坏的压缩指针可以指向的位置。但这意味着泄漏一个压缩指针虽然有用,但不能揭示Chrome的原生的image base。
V8沙箱走得更远。它假设攻击者可能已经能够破坏heap cage内的内存,并试图将重要的原生指针保持在外面。
简化的障碍列表如下:
| 保护机制 | 保护对象 |
|---|---|
| External Pointer Table | JavaScript对象引用的原生资源 |
| Trusted Pointer Table | 普通cage外部的受信任V8对象 |
| Code Pointer Tables | 可执行目标 |
| JS Dispatch Table | JavaScript调用目标和受信任的调用元数据 |
| W^X | 页面不应同时可写和可执行 |
| ASLR | 原生Chrome地址 |
| CFI | 无效的控制流传输 |
所以即使在获得cage内的任意读写之后,exploit还没有完成。它只是获得了开始与下一层战斗的权利。

选择三个互相补全的漏洞
目标版本包含了在Chrome 150后续版本中修复的几个问题。作者研究了V8、Blink、PDFium、Skia、ANGLE和其他路径。大多数产生了崩溃、部分能力或在技术上令人着迷的浪费时间方式。
最终有效的链使用了三个公开CVE。
漏洞1:CVE-2026-15903给了我地址预言机
CVE-2026-15903是一个优化编译器漏洞。当V8将一个类似数字的值降低为32位机器字时,一个安全整数假设丢失了。TurboFan的推理认为值保持在安全范围内。生成的机器代码则不同意。
这种分歧可以使String.prototype.charCodeAt在其预期的字符串之外读取。
这很有用,因为它影响了确切的目标,可以从普通网页触发,并提供了一个受控的字节读取预言机。如果我在附近放置对同一对象的许多引用,预言机可以扫描内存并找到重复的压缩指针。
它可以读取,但无法给我所需的写入能力。
漏洞2:CVE-2026-15776给了我写入能力
CVE-2026-15776是一个涉及lastIndex和最大正Smi(1073741823)的RegExp表示错误。
漏洞路径在该值跨越Smi边界时递增该值。结果变成了一个堆分配的HeapNumber,但一条快速路径足够像Smi一样处理它,以至于旧到新的引用没有被正确记录。
在minor垃圾回收之后,RegExp仍然可以指向收集器已经回收的分配。
Salvatore Gulizia,也被称为Serotav,发表了出色的工作,展示了如何将陈旧slot reclaim为伪造数组。他的公开exploit依赖于在他的目标中不够稳定的布局假设。第一个漏洞的地址预言机提供了缺失的位置信息。
漏洞一知道对象在哪里但无法写入,而漏洞二可以替换对象但需要帮助找到正确的布局。
漏洞3:JSPI和JDT给了我原生控制
前两个漏洞在V8 cage内产生了任意读写。V8沙箱仍然保护原生进程内存。
对于这个边界,作者重用了公开的JSPI和JS Dispatch Table问题,跟踪为Chromium issue 537948358,由Jihyeon Jeong(p0-tato)在commit 752405a中修复。
简而言之,一个隐藏的固定arity WasmResume内置函数及其JDT条目可能对需要清理的原生栈参数数量存在分歧。关于栈清理的分歧不是无害的元数据混淆。它可以将返回slot移动到受攻击者影响的值范围内。
这就是走出cage的路径。
行动1:让浏览器优化有漏洞的函数
最终exploit在普通网页中运行。它不能使用%OptimizeFunctionOnNextCall或其他d8 shell辅助函数。
作者通过普通的DOM事件分发来训练有漏洞的函数:
async function tierViaNativeEvents(listener, name) {
const dispatcher = document.createElement("span");
const event = new Event(name);
dispatcher.addEventListener(name, listener);
for (let i = 0; i < 2000; i++) dispatcher.dispatchEvent(event);
await sleep(250);
for (let i = 0; i < 30000; i++) dispatcher.dispatchEvent(event);
await sleep(250);
}
优化的读取器相信其最后一个索引保持在256字节字符串内。生成的代码可以选择其外部的256字节窗口。我在附近放置包含64个对同一victim引用的数组,并扫描重复的奇数32位值。
奇数很重要,因为压缩堆指针在低位携带对象标签。重复很重要,因为随机内存中也包含大量奇数。64个相同候选副本是一个比”这个数字感觉像指针”好得多的信号。
实际运行最终打印:
V8CTF-CAGE-RW:15903-addrof:victim=0x012c80f9:count=64:page=0x1000
那个地址属于那个进程。这是证据,不是可重用的魔法常数。
行动2:让垃圾回收背叛自己
V8使用分代垃圾回收器。新对象通常从young space开始。存活的对象可以移动到old space。当一个旧对象指向一个年轻对象时,写入屏障记录关系,以便minor回收知道年轻对象仍然存活。
RegExp漏洞破坏了那个簿记。
旧的JSRegExp保留了对年轻HeapNumber的指针,但收集器没有正确记住边。Minor回收回收了该数字。指针保留了下来。作者spray了攻击者形状的分配并试图赢得释放的slot。
相同地址,完全不同的对象。收集器看到可重用的内存。RegExp仍然看到lastIndex。作者看到了一个伪造的数组。
触发器大致如下:
RegExp.prototype[Symbol.matchAll].call(pseudoRe, "").next();
sprayFakeArrays(cycle0, cycle1); // scavenge
sprayFakeArrays(cycle0, cycle1); // scavenge again
sprayFakeArrays(cycle0, cycle1); // try to reclaim
const master = re.lastIndex;
if (!Array.isArray(master)) throw new Error("reclaim miss");
回收的字节描述了一个伪造的packed-double数组:
+0x00 map
+0x04 properties
+0x08 elements
+0x0c length
有了攻击者选择的elements指针和非常大的length,普通的JavaScript索引可以读取和写入原始数组之外。
这就是古老的heap-spray理念穿着现代垃圾回收器 costume。

行动3:证明读写能力,不要欺骗自己
渲染器崩溃不是任意读写原语。存活一次属性访问的伪造对象也不是稳定的原语。
作者使用了一个牺牲性的double,并要求一个完整的往返:
read original value: 6.625
write through primitive: 42.424242
observe from JavaScript: 42.424242
read the same raw bits back
restore original value: 6.625
只有所有这些都通过后,才调用原语读写。
还有一个四字节对齐问题。压缩对象字段位于四字节网格上。JavaScript double占用八个字节。有些值适合一个double内部。其他的则跨越两个相邻的double。这听起来像是一个小的实现细节,因为句子很小。
调试时间可不小。
回收的master数组也很脆弱。exploit的其余部分需要Promises、WebAssembly模块、外部字符串和许多分配。任何回收都可能检查或移动作者迫切希望保持不动的东西。
有用的修复是V8的大对象空间。大型后备存储不像普通小对象那样压缩。作者准备了三个稳定的分配:
| 分配 | 任务 |
|---|---|
| large double backing | 稳定的、广泛的caged读写 |
| large tagged backing | 一个合法的GC跟踪的addrof和fakeobj slot |
| large carrier backing | 后来成为ROP栈的内存 |
教训不是”禁用GC”。教训是”给GC它知道如何维护的合法引用”。
行动4:泄漏原生Chrome地址
此时作者控制了V8 cage内的内存。ASLR仍然隐藏着Chrome的原生image base。
External Pointer Table阻止了明显的攻击。ExternalString不存储原始原生资源指针,caged破坏可以简单替换。它存储一个受保护的句柄。
但JavaScript字符串在cage内仍然有一个可写的length。
作者在保留合法EPT句柄的同时扩大了那个length。charCodeAt然后信任被破坏的length足够读取原生external-string分配之外。重复的外部资源对象暴露了Chrome二进制文件内重复的vtable指针。
对于这个确切构建:
leaked resource vtable - 0x101c7588 = randomized Chrome image base
这击败了当前渲染器的ASLR。它没有提供不受限制的原生写入。它不需要。下一个漏洞提供了控制流。
这个分离很重要:一个原语泄露原生地址,另一个原语控制原生栈。作者一直在寻找一个完美的原生读写原语,而两个更窄的工具就足够了。
行动5:说服JSPI丢失栈
JavaScript Promise Integration(JSPI)允许WebAssembly在JavaScript导入返回Promise时挂起,以后再恢复。V8保存原生执行状态以实现这个异步技巧。
作者使用WebAssembly.Suspending和WebAssembly.promising创建了两个挂起的WebAssembly计算。它们的Promises包含指向真正的隐藏WasmResume处理程序的内部反应。使用caged读写和fakeobj,作者将这些处理程序恢复为JavaScript值。
有漏洞的构建允许隐藏的内置函数及其JDT元数据对栈清理存在分歧。
caller / JDT expectation: clean N stack values
WasmResume reality: clean receiver + one argument
-----------------------------
return slot disagreement
作者提供了一个伪造的receiver和一个故意不匹配的调用帧。在受控返回处,一个pop rsp; ret gadget将原生栈指针移动到作者在V8内准备的大carrier backing中。
调用者和被调用者对调用帧在哪里结束存在分歧。CPU最终向作者的carrier数组询问方向。
这从被破坏的JavaScript对象交叉到了原生渲染器控制。
这就是V8沙箱逃逸。

行动6:没有shellcode,只有一个文件
作者不需要反向shell、计算器或可执行内存页。他只需要读取一个文件。
ROP链重用了已经存在于确切Chrome二进制文件中的代码:
open64("/flag/flag", O_RDONLY);
read(fd, writable_buffer, 0x100);
write(2, writable_buffer, bytes_read);
_exit(42);
打开、读取、写入。ORW。
W^X保持完整。exploit使用了简短的指令序列和已经存在于Chrome中的PLT调用。
还有最后一段废话。V8堆指针是有标签的,所以原生receiver指针落在caged writer自然写入的对齐位置之后的一个字节处。ROP流必须移动一个字节而不破坏其邻居。
这就是现代浏览器漏洞利用的一句话总结:在穿越了几个严重的安全边界之后,你又会因为一个字节失去另一个晚上。
它实际工作的那一刻
本地测试使用Docker内部的确切Chrome构建、一个只读的合成/flag/flag、每次尝试的新配置文件,以及没有Docker网络。作者在完整的ORW链打印本地合成flag之前没有触及Google的服务。
实际客户端解决了kCTF工作量证明,发送了托管exploit URL并捕获了Chrome的输出。
重要的部分是:
Version: Google Chrome for Testing 150.0.7871.46
V8CTF-CAGE-RW:15903-feedback:ordinary-oob-isNaN=true
V8CTF-CAGE-RW:15903-addrof:victim=0x012c80f9:count=64:page=0x1000
V8CTF-CAGE-RW:jspi-prepared:moduleA=1:moduleB=1:promises=2
v8CTF{1785916837:02b9910f32b5064c14c693a910748736031da940}
作者盯着最后一行看了一会儿。
一个网页进入了无头Chrome。编译器漏洞泄漏了一个压缩地址。RegExp漏洞让作者回收了一个陈旧的分配。伪造数组产生了caged读写。ExternalString泄漏了原生镜像。JSPI不匹配使原生栈失去平衡。现有的Chrome代码打开了一个文件并打印了一行。
作者拿到了flag。
那部分不是理论上的。
ChatGPT没有按下pwn V8按钮
作者不是独自构建的,也不打算假装是。
作者选择了目标,运行了实验,调试了确切的Chrome构建,并决定什么算证据。ChatGPT使用Sol模型做了大量的源码导航、实验设计和代码起草工作。作者使用其他模型来研究替代路径并在信任之前挑战声明。
LLM帮助了:
- 将公开修复映射到确切有漏洞的V8修订版
- 比较对象布局和源码路径
- 生成小型诊断页面和本地测试工具
- 阅读长调试器转录
- 在路线死亡时建议替代方案
- 跟踪安全边界原语实际穿越了哪个
- 根据exploit日志和官方规则检查这篇文章的事实
没有一个提示说”pwn V8″然后返回一个可工作的exploit。
真正的循环是这样的:
human chooses a target and decides what evidence means
-> LLM reads source and proposes a small experiment
-> harness runs against the exact Chrome binary
-> experiment usually fails
-> inspect the last trustworthy marker
-> update the theory
-> try again
-> repeat an unreasonable number of times
模型有用,但也经常自信地出错。它们发明对象布局,将无关的崩溃视为进展,并提出立即崩溃的美丽exploit链。
如果一个想法没有产生标记、受控值或成功运行,它就不算。
这项工作使作者对AI辅助安全研究更加乐观,对没有日志的AI生成的exploit声明不太感兴趣。
所以为什么没有10万美元?
现在是较不戏剧性的部分。
这并不是一个可信的10万美元声明,即使它恢复了flag。两个独立的资格问题很明显。
首先,这是一个n-day链。初始漏洞来自其他研究人员。Google的公开表格已经显示了在8月5日捕获此flag之前,7月13日已确认的M150 n-day。规则通常只允许已部署版本的第一个符合条件的n-day。
其次,exploit远未达到要求的80%可靠性。
最终直接包在5次新鲜本地运行中成功1次。另一个隧道托管调优批次达到10次中5次。这些是不同的交付条件,所以作者保持数字分开,而不是将它们合并成一个看起来更好的谎言。
一次成功的flag证明漏洞可利用性。它不会神奇地将其他失败的运行变成成功。
一些旧笔记说3/5。保存的原始摘要说1/5。原始日志赢得了那场争论。即使3/5也只有60%。
回收依赖于GC时序、heap占用率、分配顺序、JIT分层、原生资源放置和启动噪声。成功的运行很快,通常在四到七秒左右。它们只是没有足够频繁地发生。
所以诚实的记分牌是:
| 项目 | 结果 |
|---|---|
| 真实的Google flag已恢复 | 是 |
| V8堆沙箱逃逸 | 是 |
| 原生渲染器控制 | 是 |
| Chrome进程沙箱逃逸 | 否(挑战禁用了) |
| 外层kCTF/nsjail逃逸 | 否 |
| 发现新的初始漏洞 | 否 |
| 达到80%可靠性 | 否 |
| 收到10万美元赏金 | 否 |
失望吗?当然。作者是一个安全研究员,不是僧侣。
但”拿到flag”和”赢得比赛”不是可以互换的句子。这个项目以一种非常昂贵的JavaScript方言教会了作者这一点。

漏洞利用代码
完整的exp.html PoC和复现说明可在GitHub上获得。
一切都绑定在Linux x86-64上的Chrome 150.0.7871.46。这是一个确切的构建历史漏洞利用,不是粘贴到当前Chrome的脚本。
为什么这在Chrome挑战之外很重要
当人们听到”V8漏洞利用”时,他们通常会想象在Chrome中打开一个恶意网站。这是戏剧性版本,但V8存在的地方比一个浏览器标签多得多:
- Chrome和其他基于Chromium的浏览器
- Node.js服务器应用
- Electron桌面应用
- 围绕V8 isolates构建的多租户系统
- 嵌入式Chromium环境、CEF应用程序和WebViews
周围的安全边界在每个环境中都不同。浏览器有渲染器和进程沙箱。Electron应用可能暴露特权preload API。服务器运行时可能持有云凭证。isolate平台可能将来自多个租户的代码放置在一个进程中。
这个确切的历史链不会破坏所有这些系统。但更广泛的问题仍然存在:
- 不受信任的JavaScript能否破坏其运行时?
- cage元数据能否影响原生资源?
- 一个isolate能否到达另一个租户?
- 调度元数据能否与生成的代码不一致?
- 原生控制后存在什么秘密?
V8漏洞利用很有趣,因为语言语义、编译器优化、垃圾回收、对象表示、WebAssembly、原生ABI细节和操作系统安全都在一个进程中碰撞。
这是几个不同的安全学科穿着一件 trench coat。
下次会做不同的什么
首先固定确切的二进制。不是里程碑。不是附近的补丁。确切的二进制、源码修订、平台和启动flags。
一次证明一个原语。地址泄漏标记比可能在六个阶段后发生的浏览器崩溃要好。
将每次破坏后的分配视为敌对的。日志分配。错误分配。编译分配。第一次使用类型化数组可以分配。任何这些都可能触发破坏伪造对象的回收。
尽可能使用合法的运行时机制。GC知道如何更新的真实标记slot比希望一个格式错误的内部对象永远存活要稳定得多。
分离泄露和控制。ExternalString泄漏击败了ASLR。JSPI提供了原生控制。作者不需要一个完美的原生任意读写原语。
计算每次失败。重试直到出现一个flag证明漏洞可利用性。它说明不了可靠性。
让LLM通过证据门。要求源码路径、假设、测试工具和竞争解释。不要让一个流畅的段落取代成功的运行。
最终想法
作者不断回到实际转录。
JavaScript
-> compiler confusion
-> garbage-collector confusion
-> fake object
-> caged read/write
-> native disclosure
-> stack control
-> ORW ROP
-> v8CTF flag
没有赏金 没有新的CVE 没有Chrome进程沙箱逃逸。
仍然是我构建的最酷的东西之一。
拥有空闲时间的最好的部分是终于学习了你一直推迟的事情。危险的部分是学习项目偶尔开始打印签名的Google flags。
如果你正在学习V8漏洞利用,不要从记住每个指针表开始。选择一个边界。构建一个可观察的能力。读源码。读其他研究人员的作品。使用LLM,但要它们展示工作。然后继续向右移动一个框。
如果你还在读这篇文章,你很棒。谢谢你陪作者走完!
出处:原文来自 Himanshu Anand 的博客 I had some free time, so I tried to pwn V8














暂无评论内容