https://nvd.nist.gov/vuln/detail/cve-2024-3833
写在前面,这个洞在一般通过互联网用户中的存量已经很少了,毕竟Chrome有自动更新机制,但是用在Electron和锁了版本的企业内网与专网机器是可行的。为什么Electron是重灾区?因为架构机制是静态打包,万年不更,几乎企业内部工具和某些开源项目,开发完成后几年都不更新底层Electron版本,只要它的Electron版本对应的是Chrome 124之前,V8引擎就是带毒的。
此外还有安卓电视和嵌入式设备,因为是定制的安卓系统,所以内置的WebView组件极少更新,以及国内一些基于chromium的修改版浏览器,维护团队跑路了或者更新不勤快,内核版本都是停留在很久以前的。

CVE-2024-3833
Chrome V8 JavaScript引擎中的对象损坏洞,允许攻击者通过诱导用户访问恶意网页,在渲染器进程中实现RCE。
漏洞的原因是是Chrome的Origin Trial机制存在逻辑缺陷,浏览器通过Token动态启用实验性功能时,V8会检查全局对象上是否已安装相关属性,通过替换/伪造全局对象欺骗了检查逻辑,导致V8错误地向真实的内部对象中再次写入同名属性,从而在内存中制造出带有重复属的畸形对象。
利用层面是攻击者利用对象克隆操作触发漏洞,V8在克隆带有重复属性的对象时会错误计算所需内存大小,导致堆越界写,利用这个原语覆盖相邻对象的关键指针可以构建任意读写能力,最后通过劫持WebAssembly的导入表或者JIT代码指针来RCE。
V8对象模型&属性存储机制
V8 为了实现极致的js执行性能,设计动态的对象存储系统在快速模式和字典模式之间进行切换。 V8的堆内存里面一个标准的JSObject由三个核心指针域组成:
- Map (Hidden Class):指向描述该对象结构及类型的元数据对象。
- Properties:指向存储命名属性(Named Properties)的后备存储区。
- Elements:指向存储索引属性(Indexed Properties,即数组元素)的后备存储区。
这种分离了结构信息和数据信息,让具有相同结构的对象可以共享同一个Map节省内存并加速属性访问。
绝大多数JS对象初始化时都处于快速模式,此模式下V8 极度依赖 Hidden Classes来管理属性。 Map对象内部包含一个指向描述符数组的指针,描述符数组存储了该对象所有属性的元数据,包括Name、Attributes和Offset。
- In-object Properties:为了减少内存访问次数,V8 会尝试将属性值直接存储在对象本身的内存块中。
- Backing Store Properties:当属性数量超过预分配的内部槽位时,超出的属性值会被存储在 Properties 指针指向的PropertyArray中。
每当开发者向对象添加一个新属性时,V8会创建一个新的Map,并通过转换树将旧 Map链接到新Map,来确保了Map中的属性名是严格唯一的,V8 的优化编译器和内联缓存高度依赖这一假设:即通过Map可以唯一确定属性的位置,且对象中不存在重复的属性名 。
当对象的结构变得过于复杂动态,或者经历了特定的操作时V8 会将对象降级为字典模式,在字典模式下对象的Map不再包含描述符数组,相反所有的属性信息都存储在 Properties 指针指向的 NameDictionary 中。NameDictionary是一个哈希表,底层实现基于FixedArray,在这个数组中,数据以Tuple的形式逻辑组织:
- Key:属性名
- Value:属性值
- Details:属性的详细信息
字典模式下的重复键风险
虽然NameDictionary设计为哈希表,理论上应该保证键的唯一性,但是底层物理存储是线性的数组,如果攻击者能够绕过上层的哈希查找逻辑,直接调用底层的插入原语或者利用某些初始化代码在未进行完整性检查的情况下写入数据,有可能在NameDictionary的物理存储中创建两个具有相同 Key 的条目。
在字典模式下,重复属性的存在主要是一个逻辑层面的异常,由于字典查找通常返回找到的第一个匹配项,第二个重复项可能在常规访问中被“Shadowed,但是当这个畸形的字典对象被转换回快速模式处理时,潜伏的重复项就会引发严重的内存布局问题。
Origin Trials
https://developer.chrome.com/docs/web-platform/origin-trials
这个洞是一个因为逻辑错误导致的状态破坏漏洞,主要还是Chrome的Origin Trials机制和V8 对象初始化逻辑之间的时序竞争。
Origin Trials是Chrome平台的功能,能允许开发者在特定网站上试用尚未正式发布的浏览器新特性。
Chrome控制台注册Origin,获取一个签名的 Token。
在网页中通过 标签声明这个Token,浏览器加载页面时解析这个Token。如果验证通过会在当前渲染进程的JavaScript上下文中启用对应的实验性特性。
但是关键问题在于Token的解析和特性的激活并不总是发生在用户JS执行之前,因为 标签可以由JS动态插入,或者脚本可以在 <head> 解析之前运行,这就创造了一个用户代码先于特性代码执行的窗口。
Chrome的某些特性启用代码会错误地假设,当特性被激活时环境是纯净的,相关对象没有被用户代码修改。
逻辑链
第一步是构造一个违规的V8对象,首先选取WebAssembly.Tag.prototype作为目标对象,通过删除构造函数,强制降级为字典模式,在字典模式下V8对属性的管理较为松散,允许手动预先写入一个名为type的属性。
然后是欺骗逻辑,将JS环境中的全局WebAssembly对象替换为一个空的伪造对象,然后通过动态插入标签激活Origin Trial。V8引擎在处理这一请求时犯了一个检查与使用不一致的错误,它检查的是那个伪造的空全局对象,因此认为没有type属性允许安装,但实际执行安装操作时却将新的type属性写入了埋好雷的真实内部对象中。
上面那一步操作直反了V8的数据一致性假设,导致真实对象中同时存在两个名为type的属性。
状态转换与越界写触发
拥有了重复属性的对象本身并不会立即导致崩溃,需要通过特定操作触发内存分配错误。 首先利用原型链机制,将该对象设为某新对象的 proto 并访问属性,触发V8的 MakePrototypesFast函数,强行将该对象从字典模式换回快速模式,在这一转换过程中重复的属性被保留了下来,但V8的Map与实际属性存储之间产生了隐蔽的不匹配。
接着对该对象执行对象克隆操作时,漏洞实现了,V8 的IC逻辑根据Map计算出新对象需要N个单位的存储空间,但在执行数据拷贝的循环中却老实地遍历了所有存储的属性,一共拷贝了N+1个数据。
多出来的这一个数据直接溢出,覆盖了堆内存中紧挨在后面的下一个对象的关键指针。
RCE
最后一步是将这个越界写转化为对计算机的控制权,利用堆风水技术安排内存布局,确保被溢出的对象后面紧邻着一个受害者对象。
通过溢出覆盖受害者对象的properties指针,攻击者将其指向自己控制的伪造内存区域,基于此构建了两个强大的利用原语:addrOf和fakeObj。为了绕过V8的沙箱保护,不选择攻击受保护较严的JIT代码页,而是WASM实例。
利用上述读写能力篡改了WASM实例的导入函数表,当JavaScript层调用这个WASM导入的函数时,执行流不再跳转到合法的函数地址,而是跳转到了攻击者预先写入的Shellcode地址,从而rce。
JIT Spray
基于上面的提到的补充一下JIT Spray和沙箱逃逸。
这个洞就是很典型的JIT Spray思想,但是在webAssembly环境下进行了变种:
在成功利用重复属性漏洞获得addrOf和fakeObj之后,我们会面临V8 Sandbox和W^X。前者是我们的fakeObj和内存读写只能在V8的堆里跑,真正存放机器码的内存区域在堆外是受保护,且指针被压缩/编码,不能直接修改。
W^X是因为现代操作系统和V8强制绑定,内存页要么只能写要么只能执行,不能Shellcode写进某个数组然后跳转过去执行。
JIT Spray就是为了打破这个僵局,既然我不能写代码,那就让编译器帮写(
mov rax, 0x9090909090909090 ; 一个非常大的常数
...
我们不需要关心mov指令,我们需要关心的是那个0x90…,如果指令指针能跳过mov操作码,直接落在这个常数中间,CPU就会把这个常数当成指令执行。
接下来是寻找跳板,因为我们的Shellcode已经混在编译好的代码页里了,现在的问题是怎么跳过去?这就用到了CVE-2024-3833提供的堆读写能力,瞄准了WASM的导入表。
在V8堆中,WASM实例有一 WasmTrustedInstanceData对象,这个对象里有一个 imported_function_targets数组。
关键在于这个数组存放着所有被导入函数的跳转目标地址,并且这个数组本身位于V8堆内受沙箱限制,但是它存储的地址是指向堆外的。虽然V8沙河限制了在堆内乱改指针,但 imported_function_targets作为一个数据数组是可以通过 OOB Write来修改的。
然后是实施劫持,利用漏洞修改 imported_function_targets 数组,先找到编译好的包含 Shellcode常数的那个WASM函数的内存地址,然后计算出Shellcode在该函数内部的精确偏移量,最后将某个导入函数的跳转地址,覆盖为 [Payload 函数地址 + 偏移量]。
触发执行是先在JS层调用那个被篡改的WASM导入函数,WASM引擎查找导入表获取跳转地址(就是已经被我们改成了指向常数中间),然后CPU 跳转过去,原本应该执行函数头现在直接跳进了常数堆。
最后的RCE是CPU开始将那些浮点数解析为机器指令,Shellcode执行反弹 Shell。 为什么叫复兴是因为经典JIT Spray是利用Flash 的漏洞,通过XOR等手段在堆上大面积喷射包含Shellcode的ActionScript代码,主要依靠还是概率击中NOP Sled。
现在的JIT Spray不再依赖概率,是利用内存读写原语来精确计算地址,并且从Flash变成了 WebAssembly,WASM的编译产物比JS更紧凑更可预测。还有对抗沙箱,它是为了专门绕过无法直接写入可执行内存这个限制而诞生的技巧,攻击者没有写入可执行内存,是V8编译器写入的,攻击者只是改了函数指针。
一些衍生思路
Origin Trials Fuzzing
在于V8在初始化新特性时假设了环境是纯净的,属于是TOCTOU逻辑变种。 挖掘思路可以从自动化的目标提取搞,不手动去猜哪些特性开启了,直接编写脚本解析 Chromium源码中的runtime_enabled_features.json5,重点关注带有 status: “experimental” 或 origin_trial_feature_name 的条目。
不仅是Global,CVE-2024-3833污染的是Global上的属性,但是实际上新特性可能安装在Prototype 链上。
实草的时候注意Timing,在Worker线程中进行这种测试更有效,因为可以并行触发加载逻辑增加竞争状态导致逻辑错误的概率。
const CONFIG = {
TOKENS: [
"A1...",
"B2...",
"C3..."
],
PROPERTIES: [
"type",
"Suspender",
"onentry",
"scheduler",
"js",
"func",
"source"
],
OBJECT_PROVIDERS: [
() => WebAssembly.Tag.prototype,
() => WebAssembly.Table.prototype,
() => WebAssembly.Exception.prototype,
() => WebAssembly.Global.prototype,
() => WebAssembly.Instance.prototype,
() => WebAssembly.Module.prototype,
() => Object.prototype
]
};
function forceDictionaryMode(obj) {
try {
const keys = Object.keys(obj);
for (let i = 0; i < keys.length; i++) {
delete obj[keys[i]];
}
delete obj.constructor;
for (let i = 0; i < 100; i++) {
delete obj["_force_dict_" + i];
}
} catch (e) {}
}
function forceFastMode(obj) {
try {
const p = {};
p.__proto__ = obj;
return p.any_property;
} catch (e) {}
}
function spoofGlobal(targetName) {
const real = globalThis[targetName];
globalThis[targetName] = {};
return real;
}
function restoreGlobal(targetName, original) {
globalThis[targetName] = original;
}
function activateTrial(token) {
const meta = document.createElement("meta");
meta.httpEquiv = "origin-trial";
meta.content = token;
document.head.appendChild(meta);
return meta;
}
function triggerCorruption(obj) {
try {
const clone = { ...obj };
return clone;
} catch (e) {}
try {
return Object.assign({}, obj);
} catch (e) {}
}
async function fuzzParams(objProvider, propName, token) {
let targetObj = null;
let realGlobal = null;
let metaTag = null;
try {
targetObj = objProvider();
forceDictionaryMode(targetObj);
targetObj[propName] = {};
realGlobal = spoofGlobal("WebAssembly");
metaTag = activateTrial(token);
await new Promise(r => setTimeout(r, 10));
restoreGlobal("WebAssembly", realGlobal);
forceFastMode(targetObj);
triggerCorruption(targetObj);
} catch (e) {
} finally {
if (metaTag) metaTag.remove();
if (realGlobal && globalThis.WebAssembly !== realGlobal) {
globalThis.WebAssembly = realGlobal;
}
}
}
async function startFuzzer() {
for (const token of CONFIG.TOKENS) {
for (const prop of CONFIG.PROPERTIES) {
for (const provider of CONFIG.OBJECT_PROVIDERS) {
await fuzzParams(provider, prop, token);
}
}
}
}
startFuzzer();
字典模式边缘测试
上面提到了V8的极速依赖于Hidden Classes和 Fast Properties,当对象退化 Dictionary Mode 时很多优化的假设就不成立了。
除了delete关键字,还可以通过添加大量属性&关注回归路径来迫使对象进入字典模式,作为Fuzzing的前置动作:
Fuzzer策略是创建对象-逼迫其退化为字典-污染/篡改内部结构 - 设为原型 (proto) 或传递给这就 JIT 优化过的函数,强迫引擎尝试将优化回来。
去同步化审计
⬆️导致OOB Write 的直接原因,Map与实际数据不一致。 实际操作的时候计算-执行分离模式审计,在V8源码中搜索此类模式:先调用 CalculateStorageSize(),然后分配内存最后 CopyData()。
漏洞点在于如果在 Calculate和Copy 之间,源对象发生了变化就会导致 OOB。
重点关注的 JS API:
- Spread Syntax (…): {…obj} 或 […arr]。这是对象克隆的高频触发点。
- Object.assign(): 涉及属性遍历和拷贝。
- Array.prototype.concat/splice: 涉及数组元素的批量移动。
- structuredClone(): 深拷贝函数,涉及复杂的序列化和反序列化逻辑,极易在处理特殊对象(如 SharedArrayBuffer, WASM Modules, Errors)时出现状态不同步。
侧信道攻击
利用JS的Proxy在对象拷贝的过程中修改对象自身的形状,虽然V8有很多防护,但这个领域依然是绕过检查的高发区。
沙箱逃逸新路径
Google试图将所有V8堆内指针的破坏限制在沙箱内,直接改写 JIT 代码页变得很难,但是WASM依然是突破口:
WebAssembly需要将字节码编译为机器码,虽然代码页受保护但是导入表、间接函数表|全局变量是连接堆内数据和堆外执行流的桥梁。利用方向是寻找逻辑漏洞来篡改WASM实例的 instance_object,欺骗WASM引擎调用错误的函数索引。
还有不要再试图覆盖指针,转而利用JIT优化阶段 Bug,原理是让JIT编译器错误地认为某个数组索引 i 永远是安全的从而移除边界检查,运行时利用这个被优化的代码进行OOB读写。这种攻击方式不需要修改任何指针,完全依赖生成的错误机器码,因此天然绕过V8沙箱。
总的来说就是得把我的 Fuzzer必须能够感知 Origin Trials,在生成测试用例时动态插入 标签应成为标准动作。
还有在自己语料库中增加大量涉及 proto 修改、delete 操作、以及对象克隆({…x})的组合来专门触发生僻的代码路径(?