DiceCTF2022 memoryhole & V8沙箱绕过
DiceCTF2022 memoryhole 题目复现与两种 V8 沙箱绕过方式学习。
指针压缩
V8 在其堆中实现了指针压缩。通过指针压缩,从 V8 堆中的一个对象到 V8 堆中的另一个对象(“堆上”)的每个引用都会成为距堆基址的 32 位偏移量,只留下少数具有指向外部对象的原始指针的对象v8 堆(“堆外”)。压缩指针仅在 4GB 虚拟内存区域内有效,称为指针压缩。指针压缩可以通过内存中的 ArrayBuffer 实例来可视化。下面显示了没有指针压缩的假设 ArrayBuffer 对象的内存布局:
指针压缩后:
指针压缩比较好绕过,因为除了堆上指针,堆外指针都可以正常使用,如 backing store,所以如果能篡改 backing store 还是可以实现无限制的任意地址写。
Sandbox
为了保护同一进程的其他内存免受损害,V8 堆中的所有原始指针就 “sandboxified” ,将他们转换为相对于沙箱底部的偏移量或转换为索引进入外部指针表,以基址+偏移的形式访问,限定了可访问的范围,从而防止任意地址读写。沙箱的实现通过:
- 在 V8 初始化期间保留了一个大的(例如 1TB)虚拟地址空间区域——沙箱。该区域包含 pointer compression cage、所有 V8 堆以及 ArrayBuffer 的 backing store 和类似对象。
- 沙箱内但 V8 堆之外的所有对象都使用固定大小的偏移量(例如,在 1TB 沙箱的情况下为 40 位偏移量)而不是原始指针进行寻址。
- 所有剩余的堆外对象都必须通过外部指针表进行引用,该表包含指向对象的指针以及类型信息,以防止类型混淆攻击。然后通过索引从 v8 堆中的对象引用此表中的条目。
原始的堆外 backing store 指针(紫色)已被替换为距离沙箱底部的 40 位偏移量(偏移量为 0x45c00,向左移动 24 位以保证最高位为零)。另一方面,指向 ArrayBufferExtension 对象(橙色)的原始指针已替换为指向外部指针表的 32 位索引。
现在假设攻击者能够从多个线程任意破坏沙箱内的内存,还需要一个额外的漏洞来破坏沙箱外的内存,从而执行任意代码。
详细的沙箱细节在V8 Sandbox - High-Level Design Doc - Google 文档。
漏洞利用
漏洞是白给的数组长度任意修改。但是开启了沙箱。
1 | # Enable the experimental V8 sandbox. |
TypeArray 中的 backing store 指针限制被 data_ptr 代替,计算公式为 data_ptr = js_base + (external_pointer << 8) + base_pointer,%DebugPrint
会显示已经加完了 js_base 的完整的指针,所以要查看内存看真正的值,但是无论这两个指针的值是什么,data_ptr 都是被限制在了 40bit 内的空间。
结合前文知识,突破沙箱拿到 shell 有两种办法:一种是在 4 GB 范围内任意读写搞事情;另一种就是再寻找新的包含 64 位可用指针的对象。如下就是这两种方式的具体实现。
方式一:在 jsFunction 中构造包含 jit shellcode 的立即数
写一个 jsFunction:
1 | const foo = () => |
可以看到 code 字段在 rx 段。
在 JSFunction 中修改code字段为一个错误的地址:
按c继续运行(注意在 systembreak 后要调用 function):
可以看出这段的逻辑为:test dword ptr [rcx+0x1b], 0x20000000 后,如果不跳转,则将 rcx + 0x3f,然后 jmp rcx(前面的 jmp 是跳转到 jmp rcx 指令的地址,看地址与偏移),如果我们将 [rcx+0x1b] 处的值伪造好,与0x20000000与后为0,那么到 jne 的时候就会不跳转继续执行。而 rcx 中的值可控。那么就能劫持 rip。
这里补充一下正常的逻辑(猜测):
原本 rcx 中的值应为 code 的地址,由于是指针,值被+1,所以 +0x1b 便可以到 0x1c 这样的整 dword 地址处。这里 test 后不为0,那么 jne 就会跳转,应该是跳转到正常的逻辑去执行。
经过优化后的jit代码,存放在距离 code 字段值不远处。
那接下来就是要先将 shellcode 藏在 double 表示的立即数中。
通过 Online Assembler and Disassembler (shell-storm.org) 得到:
1 | jmp 0xe => "\xeb\x0c" |
1 | from pwn import * |
如上脚本得到 shellcode,然后再将十六进制转 IEEE754 浮点数(写到这又想起了今年 qwb 的那道 jit …)。通过工具,在下面 binary 中输入十六进制数:
1 | 1.95538254221075331056310651818E-246 |
接下来通过在 func1 的立即数中布置好 shellcode,篡改 func2 的 code 字段直接指向这段 shellcode,再调用 func2 即可触发。
1 | const func = () => |
找到 func 和 f 的地址:
再准备泄露 typearray 的 base_pointer。
在这过程中发现 typearray 中如果开辟数组长度小于等于16则 data_ptr 字段启用,否则为 nil 并且在内存中没有其值(以前都是直接开辟很小的数组并没有注意到这个问题):
为了方便计算,这里少开辟两个就行,由于他和elements类似,通过 oob 数组越界读 typearray 中的元素,进而确定 base_pointer 想对于 oob arr 的索引:
1 | ua[0] = 0xdeedbeef; |
可以修改 base_pointer 就可以实现在基址的 4GB 范围内的任意地址写了。
1 | function arbRead(off) { |
再查看 func1 code 中经过优化的代码,错位看 jit:
根据前面的逻辑,我们使 rcx+0x1b 为 0,rcx = rcx + 0x3f,那么此时的 code 就应伪造为 (0x2013000440bc-0x3f-0x201300044001) + 0x201300044001 = 0x201300044001 + 0x7c,那么此时 rcx 应为0x201300044001 + 0x7c,[rcx + 0x1b] 与 0x2000000 为0:
1 | 0xff418944000002b8 & 0x20000000 = 0L |
此处选择将 f 函数的 code 覆写为 func1 布置好的 jit 代码段,也就是 jitAddr + 0x7c,这样执行 f() 就会去执行jit了。
exp1.js
1 | const __buf8 = new ArrayBuffer(8); |
方式二:利用 wasmInstance 中的全局变量指针
想要突破限制实现任意地址写,就要找一些可用、可控的 64 位未被压缩的指针。使用 %DebugPrint
打印 WasmInstance:
其中的 imported_function_targets
和 imported_mutable_globals
都指向了完整的64 bit 的堆指针。
在 v8 源码中搜索,可以看到一些有用的信息:
再结合其他注释,大概了解到这是一个存放 wasm 全局变量的东西,且通过源码中的写法,imported_function_targets
指向的应该是个数组,数组的每个索引指向了全局变量的 address。那这时候就可以给我们常用的 wasm 代码加一个全局变量,找到 wasm global 的写法 WebAssembly.Global() constructor - WebAssembly | MDN (mozilla.org)。了解到了 WebAssembly Global 的构造方法以后,可以尝试给 Wasm Instance 加一个全局变量:
1 | var global = new WebAssembly.Global({value:'i64', mutable:true}, 1234n);// 注意i64即BigInt |
结合源码 wasm-objects.h - Chromium Code Search看到untagged_buffer
也是一种ArrayBuffer
,表示 Wasm 全局变量所用:
1 | // Representation of a WebAssembly.Global JavaScript-level object. |
可以看到全局变量的值存到了backing_store
指向的内存。但是回头看 Instance 发现 imported_mutable_globals
还是未指向添加的全局变量:
1 | job 0xc83081d2a09 |
原因是这段 Wasm code 中并未使用这个全局变量。那接下来就是去找一下如何写 Wasm code 并且使用全局变量。
在 webassembly-examples/js-api-examples at master · mdn/ 中找到 global.wat:
1 | (module |
(看着这语法总有一种说不出来的感觉)
这里就直接用 kylebot’s Blog 中的 wat,使用 WebAssembly/wabt 编译 wat 得到 wasmcode。
1 | // test.wat |
即得到 test.wasm:
1 | 0061 736d 0100 0000 010c 0360 0001 7e60 |
稍作处理:
1 | var global = new WebAssembly.Global({value:'i64', mutable:true}, 256n); |
调试看到 imported_mutable_globals
指向了存储 global 的内存,并且由于在 %DebugPrint
之前就调用了一次 inc,此时的值为 0x101:
1 | DebugPrint: 0x3771081d2a51: [WasmInstanceObject] in OldSpace |
此时我们手动修改 WasmInstance 中的 imported_mutable_globals
字段的值,再继续运行调用:
1 | set {long}0x3771081d2aa0=0x4141414141414141 |
发现 crash 到了这里,结合下面的两行汇编和前面的调试,可以得出 V8 将这个值作为一个指针的指针,解两层引用后取出真正的 global 的值(证实一开始看源码的猜测)。最重要的是这个过程的地址都是 64 位的,相当于没有了沙箱。所以接下来就是利用 oob 篡改 imported_mutable_globals
的值,通过这个类似以前经常使用的 backing_store 指针一样完成任意读写。有两个区别就是一:该字段并不是对象,伪造起来很容易,二就是两层引用才是真正任意写的地址。所以综上可以 fake 一个数组,数组的第一个元素是可读可写的地址(充当 backing_store
的角色 )。由于数组的地址还是由基址 + 偏移组成,偏移可以比较好泄露。重点是泄露 js 基址。
注意 js 的位运算符在运算时操作数被转换为 32bit 整數,以位序列(0 和 1 组成)表示。若超過 32bits,則取低位 32bit,如下所示:
1
2 Before: 11100110111110100000000000000110000000000001
After: 10100000000000000110000000000001
我这里直接修改了 base 为 1,与 externel 相加正好为 8,从 js_base + 8 处开始泄露,这附近有很多 js_base 相关的地址,随便选一个就好。
开辟的 Uint32Array 的长度会影响其地址是 4 的倍数还是从 8 的倍数,所以奇数达不到目的就尝试偶数
var leak_arr = new Uint32Array(0xe);
1 | var Uint32Array_len = 0xc; |
遇到一些玄学问题,跑两次 gc 尝试一下,尤其是内存不对齐。
修改完对应字段的数据,即可泄露 js_base。
接着是通过对象来实现 addressOf。这里借助了一个哨兵值:
1 | var leak = {guard: 0x2333, obj: fake_mutable_globals}; |
类似使用一些标记或哨兵值来寻找地址的方式比较通用,哪怕写到一半发现需要添加一些对象改变了内存布局也不需要再次调试来一个一个找要用的偏移
首先泄露 准备要用作 imported_mutable_globals
的 fake 数组的地址,然后拿到其真正的 elements 地址,然后泄露 wasmInstance 的地址,计算一下偏移就可以得到 imported_mutable_globals
字段在 wasmInstance 中的位置。然后通过 oob 数组的越界写,直接覆写 imported_mutable_globals
为 fake 数组的 elements 地址。泄露地址与篡改 imported_mutable_globals
:
但是 imported_mutable_globals
的地址与 oob 数组 elements 的偏移并不总是 8 的倍数,就会出现如下的情况:
所以针对这种情况还需要处理,在前一个字段的高位写目标地址的低字节,后一个字段的低位写目标地址的高字节,这样拼起来中间的 imported_mutable_globals
就合法了:
1 | if (!split) |
此时 imported_mutable_globals
的值已经为 fake 数组的 elements 的地址,那么arbRead 和 arbWrite 的实现就是对该数组的第一个字段赋值为要读写的地址。
1 | function arbRead(addr) { |
再写一个 wasmInstance_attack,即平时常用的,在 wasmInstance_attack + 0x60 处存放了 rwx 页,泄露该地址准备一会向其中写入 shellcode。
利用 arbWrite() 写 shellcode 到 rwx 页:
最终调用 f() 拿到shell:
exp2.js
1 | const __buf8 = new ArrayBuffer(8); |
文中如有错误和疑问,还望及时交流探讨。