-
-
[原创]starctf oob
-
发表于: 2026-9-17 22:18 6036
-
本文首发于我的个人博客
299K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6Y4K9r3!0K6N6s2y4Z5j5i4u0C8i4K6u0V1M7s2u0G2i4K6u0W2k6$3W2@1K9s2g2T1i4K6u0W2K9h3!0Q4x3V1j5J5x3o6t1$3i4K6u0r3x3o6W2Q4x3V1j5I4y4#2)9J5c8Y4y4@1j5i4u0U0N6r3k6Q4x3U0f1J5x3r3!0G2j5W2)9J5c8R3`.`.
现在根本不知道v8是怎么调试的,先通过知道创宇的starctf oob入门一下
原文作者选择在win上用WSL编译,笔者因为电脑比较得劲就开个ubuntu编译
分配了12G内存、16核,time autoninja看了一下大概9min就好了
这里得先装一个git
然后装depot_tools
我们用fetch v8会拉下v8源码后直接创一个v8目录,不用特地mkdir v8
随后
这个可以自动装一些小工具
gclient sync下载依赖

用autoninja -C out/x64.release d8编译,前面加个time可以算时间

如果题目给了commit,可以在gclient sync之前
git reset --hard 5a2307d0f2c5b650c6858e2b9b57b335a59946ff(这个会把修改切没掉,只切版本的话就用git checkout)
推荐用gclient sync -D,可以把不需要的依赖删掉

如果题目提供了patch就在gclient sync -D后打一下git apply < ./patch
原文出于方便编译任意版本的目的写了build.sh,不过对于初学者来说还是先手搓好
这部分属于额外环境封装,跳过
在正常做ctf时一般都有一个明确的目标,拿shell或者orw,原文认为在v8中的目标就是执行任意shellcode
首先把v8/tools/gdbinit加入到~/.gdbinit中

这样在用gdb启动d8时就能用v8的调试指令
接下来在d8目录中vim一个test.js


这里得加入--allow-natives-syntax,d8里没有%这种东西

%DebugPrint()打印对象在v8内的表示
这里的prototype表示原型对象,JavaScript 对象找不到某个属性时,会沿着 prototype 链继续往上找
[PACKED_SMI_ELEMENTS (COW)] COW表示copy on write,写时复制

map本身也是v8 heapobject,所以map也有map
普通 JS 对象的类型信息 → Map
Map 自己的类型信息 → MetaMap
properties = 按名字访问
elements = 按数字下标访问
tagged pointer : 因为堆对象地址按字节对齐,最低几位本来通常是 0,V8 就拿这些位来区分“这是整数还是堆对象指针”
最低位 = 0 → Smi 小整数
最低位 = 1 → HeapObject 指针

gdb进入d8后用r --allow-natives-syntax test.js运行js命令

这样就方便用gdb看内存了
前面提到了v8的gdbinit,里面给gdb加了一些辅助调试命令,job就跟%DebugPrint()差不多
不过这里得注意一下
因为 job 操作的是 V8 的 Tagged Pointer,而“真实地址”是去掉 tag 后的对象起始地址
所以job后面的参数为真实地址+1
如果启用了 Pointer Compression,还可能再多一层压缩指针到完整地址的转换

如果参数是真实地址,会被解析成Smi
Wasm(WebAssembly)是一种面向虚拟机的二进制指令格式,主要目的是让 C/C++、Rust 等现有代码能进入web,而不是得全部重写成JS
v8可以把这种二进制指令格式编译成机器码执行
JS调用wasm(一般就是通过浏览器提供的 WebAssembly API)常常是为了把高性能或者底层任务交给wasm
基本现代浏览器都支持wasm,老v8会生成一段rwx内存给wasm用,现代就复杂一点
vim一个js看看

我们先看看这个无符号8位整数array表示了什么
Uint8Array 里面每个数字都是一个字节,整体就是一个 .wasm 文件的原始二进制内容
等价于
memory()导出一块 Wasm Linear Memory
main()return 42
wasmModule这一步是将wasmCode转化成v8可以识别的程序模板(Module可便于创建多个独立运行的实例)
wasmInstance真正实例化
var f = wasmInstance.exports.main;
从 wasmInstance 导出的内容里,取出名为 main 的 Wasm 函数,然后保存到变量 f
wasmInstance.exports表示这个Wasm实例对JS暴露出来的所有东西(memory、main)

在第二个断点处我们发现它生成了一段rwx
现在的问题变成我们如何得到该地址
先看看%DebugPrint(f)输出了什么

可以看出这是一个函数对象

SharedFunctionInfo,简称 SFI,保存函数相对静态的元数据

用job查看shared_info

查看data结构

看看instance


注意到该地址与WasmInstanceObject重合
原文的rwx在instance+68,我这里编译出来的d8是12.8(开了sandbox),用jump table能找出来,原文instance那还真没有


在12.8.0中WasmTrustedInstanceData的trusted_data + 0x38处
现在的目的就是把shellcode写进wasm的rwx段然后执行
先看看两种变量类型的结构


内存大概长这样
注意到这里开了指针压缩,很多字段只有 4 bytes
注意到这一次a 的 FixedDoubleArray backing store 恰好位于 a 对象之前。如果漏洞允许对该 backing store 进行越界读写,那么越过其末尾后,就可能访问相邻堆对象的 map、properties、elements、length

这里是因为压缩指针下 Smi 只有 31 位且强制 值<<1(最低位必须为 0),塞不进任意 64 位值,读出来也被抹掉最低位
let b = {"a": 1};
let c = [b];
变量 c 是一个数组,数组里唯一的元素就是对象 b
变量 c 和变量 a 本身都是 JSArray,因此 JSArray 对象头的结构基本相同。区别主要在 elements 指向的 backing store。a 是 PACKED_DOUBLE_ELEMENTS,使用 FixedDoubleArray,其中每个元素直接保存一个 64-bit double;c 是 PACKED_ELEMENTS,使用 FixedArray,其中对象元素以 Tagged Pointer 保存。在开启 Pointer Compression 的情况下,这些 Tagged Pointer 被压缩为 32 bit,因此 c 的每个元素槽位是 32 bit
我们看看V8 利用里最经典的两个原语:
Map 中的 ElementsKind 决定 V8 用什么方式解释 elements 里的 bit
内存本身只是一堆 bit。真正决定这 32/64 bit 到底应该被当成 double,还是对象引用的是 Map → ElementsKind
如果把变量c的map地址改成变量a的,那么当执行c[0]的时候,获取到的就是变量b的地址
这就是addrof函数的目的
fakeobj:把浮点数组伪装成对象数组
漏洞本身只负责一件事:改写某个 JSArray 的 map 字段
具体漏洞(OOB / UAF / 类型混淆)唯一要求:能把某个 JSArray 的 map 改成另一种数组的 map
之后的利用链全部固定:map 混淆 → addrof/fakeObj 原语 → 任意读写 → 劫持 backing_store → 写 WASM RWX 页 → 触发执行
[触发层] 漏洞 → JSArray 元数据可控(map / length / elements)
[原语层] addrof ⇄ fakeobj(同一操作的镜像)→ fake_array+fake_object → read64/write64
[执行层] 改 ArrayBuffer.backing_store → DataView 写 shellcode → 调 wasm 导出函数
由fake_array和fake_object构造的write64不能直接把shellcode写入rwx
elements 字段只有 32 位,解压规则是固定拼接 cage base,最主要的原因就是rwx段在解压缩指针的4GB范围内不可达

write64 的目标地址前面 8 字节**必须可读且内容凑合合法,**而 RWX 段起始地址的前 8 字节(rwx−8)落在映射之外(未映射页/保护区域),第一次解引用就段错误
f() 的调用落点固定在 RWX 区域入口,rwx+8也不可取
先vim一下

程序先申请了一块 16 字节的小内存,叫 data_buf。
然后又创建了一个 DataView,专门用来读写这块内存。
接着程序通过这个工具,把数字 2.0 写到这块内存的最开头,占用前 8 个字节
这里有一个跟原文不一样的地方,原文没开sandbox,backing store是malloc 原生堆的裸指针
我这里编译开了sandbox


double型的2.0就是0x4000000000000000

可以看处data_buf的值存储在一段连续的地址中
原文的思路是通过修改backing_store字段的值为rwx内存地址,来达到写shellcode的目的

我们发现 backing_store 那 8 字节存的不是 0x1db100000000 本身
这里还想copy_shellcode_to_rwx 就得打sandbox escape了
通过f()执行shellcode
这里有完整的patch
这里编译的时候遇到一个问题,v8的版本太老得用到python2,但apt不提供了,只能开个docker了



还挺快

记得把没用的docker删掉
先看看patch

给所有 JavaScript 数组的原型 Array.prototype 新增了一个叫 oob (out of bounds)的方法,传入两个参数
也就是 JS 里能这么调

定义内置函数ArrayOob(),ArrayOob能调用isolate(当前实例)跟args(调用参数)变量
uint32_t len = args.length();取本次参数调用个数(args[0]->this也算一个),故arr.oob(x)->length == 2,len>2时直接return underfined
JSReceiver表示 V8 里“可以接收属性访问的 JS 对象”的基类
例如这些
Handle
[招生]科锐逆向工程师培训(2026年11月13日实地,远程教学同时开班, 第57期)
赞赏
- [原创]CVE-2020-6507利用链 5871
- [原创]starctf oob 6035
- [原创]polarisctf招新赛 Writeup-pwn 10774