本文首发于先知社区:https://xz.aliyun.com/news/18300
注:本文后续的内容主要围绕新型的通用利用链研究,并不探讨如何Sandbox Escape和构造addrOf()
、fakeObject()
这样的前置流程。因为在沙盒逃逸和内存损坏利用时,往往依靠不同的漏洞poc或是patch,所需要的手法大相径庭,需要具体问题具体对待。本文主要探讨已经能够通过漏洞实现addrOf()
、fakeObject()
这样的原语后,如何最终实现程序流完整控制的通用手法。 因此后续的环境以及%DebugPrint()
展示信息均是在去除Sandbox的条件下测试所得。额外: 如果对于Sandbox Escape感兴趣,也可以查看我之前的文章,有从Sandbox Escape issue POC到完整利用EXP的完成过程:【V8】Sandbox Escape Issue: 361862752复现
前言
在V8利用中,我们常常需要任意地址读写原语,并通过篡改RWX段内容或更改函数对象内的code指针,来实现控制执行流的作用。更通用的说,利用往往服从于这样的链路:
构造
addressOf()
,fakeObject()
函数借助
addressOf()
,fakeObject()
函数伪造fake_array利用fake_array实现堆内AAW和AAR原语
进一步获取原始指针范围的任意地址读写
- 有沙盒,则借助漏洞实现沙盒逃逸
- 无沙盒,借助
DataView()
等存在原始指针的对象
控制程序执行流
其中,为了实现更通用的堆内任意地址读写,构造fake_array
是一个常见的手法:
这里不做过多阐述,仅简单提及:
1 var fake_array=[double_array_map,int_to_float(0x4141414141414141n)]通过
addressOf()
获取 fake_array 的地址,然后就能够计算出 element 的元素地址;再通过fakeObject()
将这个地址伪造成一个对象数组。
构造完成后,通过更改fake_array的存储内容,即可让伪造内部结构的elements索引到其他地址,从而实现堆内任意地址访问。
elements结构校验
但是上述的利用逻辑潜在得包含了一个条件:就是elements的内部结构好像并不重要,只需要讲
JSArray
对象的elements
指针索引到对应地址即可实现对虚拟内存的读写操作,而不需要在目的地址伪造elements
所需要的map
和length
。
然而在最近的一个CTF比赛中,我在尝试解决其中的V8题目时,遇到了这样的问题:
对应的d8版本为13.5.0,commit为:c963fb98a204005df30553bec7bbbe1997e0ab5f
我已经成功的利用漏洞构造了addressOf()
函数和fakeObject()
函数,却在使用fake_array进行任意地址读写的构造时,出现了上述报错
在源码文件/src/objects/js-objects-inl.h
中,可以找到相关代码如下:
1 | ... |
直接说结论:当存在VERIFY_HEAP和DEBUG时,会校验elements的map域是否合理。也就是说如果此时需要借助fake_array,进行任意地址读写,需要提前在目标地址留下合适的elements结构。
代码逻辑分析
获取JSArray对象的map
1 | ElementsKind kind = map(cage_base)->elements_kind(); |
这里的map()
获取的是JSArray对象(也就是this对象)的map,elements_kind()
用于返回elements本应该具有的类型
获取elements中存储的map
1 | Tagged<FixedArrayBase> fixed_array = UncheckedCast<FixedArrayBase>( |
TaggedField<HeapObject, kElementsOffset>::load(...)
是从JSArray(this)的偏移量kElementsOffset
处加载.elements
指针,这里的fixed_array实际对应elements对象,之后的map变量对应elements中存储的map
CHECK()校验elements->map
1 | if (IsSmiOrObjectElementsKind(kind)) { |
最后根据JSArray对象的map进入对应分支,然后进一步对elements的map进行校验。
示例
以如下js代码为例
1 | let array = [1.1, 2.2]; |
其DebugPrint()
简要信息如下:
1 | DebugPrint: 0x3c9c0008bd41: [JSArray] |
那么在进行赋值语句时,便会进行上述的CHECK
流程。由于JSArray对象的类型为PACKED_DOUBLE_ELEMENTS,因此会进入如下分支
1 | else if (IsDoubleElementsKind(kind)) {...} |
然后进一步查看elements所存储的map类型是否为FIXED_DOUBLE_ARRAY或者elements指向一个空数组(empty_fixed_array)。
结论
考虑我们常用的fake_array结构,elements往往直接被索引到target_addr-0x8,而不考虑该地址是否恰好有合适的fixed_array_map
存在。进而就会导致CHECK
报错,那么在当前情况下是无法借助fake_array伪造DoubleArray
对象来实现堆内的任意地址写
target_addr-0x8是因为elements本身结构存在 32bit map 和 32bit length结构,因此实际数据索引是从elements_addr+0x8地址开始
JSTypedArray
这里先补充一个JSTypedArray
对象结构,在js中创建方式如下:
1 | let array1 = new Float64Array(1); |
其实就是我们最常用的数据类型转换时使用的结构,举例i2f()
函数
1 | var f64 = new Float64Array(1); |
inline
如果观察JSTypedArray
对象的结构,先来看看容量较小的array1
这里红框标出的data_ptr就是用来指向真实的数据存储内存空间。可以明显的看到,在array1
中data_ptr指向的空间就是elements
对象的数据存储位置,如果查看buffer的内容,会发现与此时的data_ptr并不直接相关,且backing_store指针直接为空
另外也能观察到data_ptr的实际取值是由base_pointer和external_pointer共同决定的,满足:
$$
data\_ptr = base\_poniter + external\_pointer
$$
最重要的一点,就是base_pointer和external_pointer居然直接存储在对象内存空间内:
- external_pointer: 存储在偏移0x30的位置,为8个字节的原始指针,这里的值为
0x26d900000007
- base_pointer: 存储在偏移0x38的位置,为4个字节的堆内压缩指针,这里的值为
0x55a41
- data_ptr: $0x26d900055a48 = 0x26d900000007 + 0x55a41$
对于小的 TypedArray,会采用 内联存储(inline elements),即:不从堆外单独分配一块 memory,而是把数据直接放在 TypedArray 的
elements
字段中。
external
我们再来看看容量较大一点的TypedArray结构,如图
此时就能明显的看到data_ptr正是buffer对象的backing_store指针内容,内存中的external_pointer变成了完整指针,而base_pointer为0
如何利用?
说到这里,已经比较明显了,如果我们能够控制base_pointer和external_pointer的值,就可以控制data_ptr指针指向任意64bit地址,实现在原始指针范围内的任意地址读写,而不仅仅是堆上的任意地址读写。另外,由于data_ptr直接指向内存存储区域,无需像现在的elements一样担心被检查目标地址的结构信息。
一旦真正控制了data_ptr,AAR和AAW只需通过JSTypedArray本身的索引访问便能够做到了。
信息补充:看到这里,有经验的pwner肯定会想到,在开启Sandbox后,如果external_pointer还是存储raw pointer,显然会导致沙盒溢出的问题。那么实际上,在开启沙盒的情况下,external_pointer并不会存储原始指针(防止沙盒逃逸),而是
<< 24bit
的去除高位的指针,因此计算式也变成了
$$
data\_ptr = (external\_pointer >> 24) + base\_pointer + Sandbox.base
$$
但是这里并不是完全没有问题,我们知道base_pointer是32位无符号整数,external_pointer在内存中是左移了24位的64位无符号数。当存在沙盒内的内存损坏漏洞是,若能将两个内容设置为最大值即:
- external_pointer: 0xFFFF_FFFF_FF00_0000
- base_pointer: 0xFFFF_FFFF
- 两者之和就会存在溢出为0x0100_FFFF_FFFE,即:存在一定范围内堆外越界访问的可能性
如何控制data_ptr
前提条件:这里我们假设,已经通过V8本身漏洞或是CTF题目的patch漏洞,成功构造了
addrOf()
和fakeObject()
原语
全新的fake_array
为了控制JSTypedArray的data_ptr,我将引入一个全新的fake_array构造方式,如下:
1 | let fake_array = [ |
总体可以分为两个部分: 伪造和覆盖写。这个完整的fake_array将通过越界读写的方式来控制,JSTypedArray的data_ptr,而非elements导向的方式。
伪造doubleArray和对应elements
1 | u2f(0x001882fd, 0x00000745), // 0 map , properties |
这里很明显对应了doubleArray的基本对象结构,map、properties、element、length部分。其中map&properties的压缩指针一般比较固定,直接复制粘贴一个现有的double_array的内存内容即可。elements的内容当前只做占位,后续用伪造的elements结构替换,length需要较大的值,以便于后续的越界读写操作。
1 | u2f(0x000008a1, 0x00001000), // elements_map, elements_length |
这两行则是不全了elements对象的map、length以及一个简短的buffer。同样的,elements.map的压缩指针依然比较固定,直接复制一个对应的即可。这样就完成了一个初步的伪造。
覆盖fakeobj.elements
剩余的部分也比较直接,用来覆盖elements的占位,将伪造的两个对象连接起来
1 | let fake_array_addr = addrof(fake_array); |
这里的0x6C偏移需要具体通过
%DebugPrint()
进行确认,不同的堆布局所对应偏移值也大不相同
完成这两部我们就伪造了一个可以越界访问的doubleArray,结构也比较明显
1 | fake_array.elements Memory 结构如下: |
其中elements存储覆盖后的值为 fake_double_array_addr + 0x10,也就是让fake double array的elements索引到了fake elements Object
fake_obj越界读写
接下来只需要在后续附近堆区域创建一个JSTypedArray,然后计算偏移进行越界读写即可
1 | let js_typed_array = new Float64Array(1); |
效果如图:
AAR & AAW
这里我以堆内任意地址写为例,将上面的poc封装为任意地址读写函数。
1 | function aar(addr) |
如果想要原始指针范围内的任意地址写,只需要覆盖external_pointer,然后清空base_pointer即可。
1 | let temp = f2i(fake_obj[base_pointer_offset]); |
简单测试一下代码逻辑,可以正常进行任意地址读写操作
1 | let array1 = [1.1, 2.2, 3.3]; |
程序流控制
在上一篇文章中,已经具体提到了如何通过覆盖WasmTrustedInstanceData.jump_table_start来控制执行流的具体流程,本文也将采用这种方法,并深入利用逻辑原理。
我们先把shellcode的前置拿过来
1 | let shell_wasm_code = new Uint8Array([ |
这里的WASM代码实际复制了使用JIT-Spary手法,并由Turbofan优化后的汇编代码,看图中关键部分即可知:
这种手法的基本目的就是尝试通过各种方式是的程序从浮点立即数返回的汇编码附近,错位执行即可
关于JIT Spary具体内容不做过多介绍,可以自行检索资料
在过去通过WASM的利用往往通过以下一些方式:
- 通过像WASM空间任意地址写入shellcode,从而劫持程序执行流
- 通过篡改Function ==> Code ==> instruction_start指针内容,以实现JIT Spary的手法
- 其他…
但随着版本的变化,这些手法都遇到许多不同程度的限制,因此本文将提出一种新的基于WASM的劫持程序流方式。
WASM函数的调用
在正式调用函数内容之前,首先会调用Function.Code.instruction_start的内容,而此时Code本身是- code: 0x107a002b05f1 <Code BUILTIN JSToWasmWrapper>
,也就是WASM外层的封装函数。
JSToWasmWrapper
大致的职责是:
- 检查/转换 JS 传参类型;
- 执行内联缓冲;
- 调用实际的 Wasm 函数;
- 处理返回值;
- 如果失败,还能抛 JS 异常等。
然后调用多次CheckObjectType()
后,进入函数:Builtins_JSToWasmWrapperAsm()
在JSToWasmWrapperAsm
内部很快便会正式开始调用wasm代码
如果查看调用地址的相关信息,能够很容易的看到wasm初次调用时的延迟编译Lazy Compilation
重点!重点!重点!这里的地址虽然是对应WasmInstanceObject => trusted_data => jump_start_table的内容,但是这里并非从jump_start_table中读取,而是从其他地方获取的,如果追踪r10和r13寄存器的来源,也能够注意到。
Lazy Compilation
注意观察上面截图的信息,可以注意到Lazy Compilation是一种自行实现的功能,逻辑与动态链接的LazyBinding流程非常相似。这里的Builtins_WasmCompileLazy
在执行完成后,会更改0x6116a603000: jmp 0x6116a603800
变为直接跳转真实代码地址,如0x6116a603000: jmp 0x6116a604040
上图的push 0
,就是函数在调用CompileLazy
时,所对应的wasm函数索引,这里由于是第一个wasm函数,索引自然为0。我们可以继续追踪程序流,该函数会完成下面的内容
- 将实际的wasm代码写入内存;
- 修改jump_start_table地址所存储的jmp命令,直接变为跳转真实的wasm代码地址。
在Builtins_WasmCompileLazy
函数结束处,才会从WasmInstanceObject => trusted_data => jump_start_table处读取内容信息,然后直接跳转过去
这里的add r15, qword ptr [rsi + 0x27]
可以直接job就能看到,对应就是从WasmTrustedInstanceData对象对应jump_table_start指针偏移处取出对应的内容,然后jmp r15
如何利用
由于Builtins_WasmCompileLazy
将真实代码放置的位置,相对于jump_table_start,的偏移是固定的。如这里是0x840
,我们可以在初次调用函数前,就将jump_table_start改为错位的shellcode地址。
比如这里的完整偏移为0x89c
,那么直接更改WasmInstanceObject对象内的jump_start_table指针即可
1 | let shell_wasm_instance_addr = addrof(shell_wasm_instance); |
我们来稍微总结分析下为什么在函数初次调用前直接更改jump_start_table,就能够劫持程序流,但却不影响JSToWasmWrapper
和Lazy Compilation的正常流程。
关键点在于:
JSToWasmWrapper
虽然会用到jump_start_table同一个地址,但是并不是从WasmInstanceObject中读取的,因此WASM的封装函数流程不会收到影响,而会正常的调用Lazy Compilation- 由于我们只更改了WasmInstanceObject内的指针,而Lazy Compilation的本身在生成汇编代码时,其实只需要
push 0
送入的索引,因此前期流程也不会受影响 - 当Lazy Compilation流程完成时,此时才会从WasmInstanceObject中读取jump_start_table并进行跳转,也就是我们已经篡改过的内容
- 此时,程序流便会跳转至我们想要让其执行的区域
若函数非初次调用,
Wrapper
的外层封装会直接将程序流导向至真实代码地址,而不经过jump_start_table,此时再做任何篡改已经无济于事了。
总结
不考虑Sandbox逃逸时,参考的模板代码可以如下所示:
1 | var f64 = new Float64Array(1); |