首页
社区
课程
招聘
[原创]starctf oob
发表于: 2026-9-17 22:18 6036

[原创]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

c10K9s2c8@1M7s2y4Q4x3@1q4Q4x3V1k6Q4x3V1k6X3j5i4u0S2P5W2)9J5k6h3k6S2K9i4c8Z5i4K6u0r3x3U0l9I4z5g2)9J5k6o6p5J5i4K6u0V1x3e0y4Q4x3X3c8K6N6r3q4J5j5%4c8X3i4K6u0V1L8$3!0T1i4K6u0V1N6U0S2Q4x3X3c8A6L8X3c8W2M7s2c8Z5i4K6u0r3

这里有完整的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期)

最后于 2026-9-17 23:06 被shark_pro编辑 ,原因:
收藏
点赞 ・11
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回