文章首发于安全KER https://www.anquanke.com/post/id/225443
0x00 前言
总结几道OOB类型的v8逃逸的利用方法,它们大多的利用手法都极为相似。
0x01 前置知识
OOB即缓冲区溢出,在v8中的OOB漏洞是比较容易利用的,一般的步骤就是利用OOB修改ArrayBuffer
的backing_store
和byteLength
实现任意地址读写,也可以直接OOB
读取和修改对象的MAP
,构造addressOf
和fakeObject
原语。
0x02 普通OOB
0x02.00 starctf2019-oob
patch分析
1 | diff --git a/src/bootstrapper.cc b/src/bootstrapper.cc |
可以看到,patch为Array
类型增加了一个新的函数叫oob
,其具体处理的逻辑在BUILTIN(ArrayOob)
函数里,当参数个数为1个时,进行读操作
1 | + //read |
可以看到读操作溢出了一个单位,因为下标是以0开始的,同理当参数个数为2个时,进行写操作
1 | elements.set(length,value->Number()); |
其中BUILTIN(ArrayOob)
的第一个参数为Array
本身,因此从js
层面来看,oob接收的参数要么为0个要么为1个。
漏洞利用
要利用该漏洞,我们考虑使用var a = [1.1,2.2,3.3]
这种DOUBLE_ELEMENTS
类型的数组,因为这种数组里的数据是unboxed
的,即没有包装为HeapNumber
,elements里存的就是真值。在大多数情况下,这种类型的数组其elements在内存里的位置正好位于Array
对象的上方,没有间隔。
测试以下代码,用gdb调试
1 | var a = [1.1,2.2]; |
查看elements里,2.2这个数据后方是什么,可以发现是Array
对象的MAP,而在v8里,如果能够控制对象MAP值,那么就可以造成类型混淆,轻松构造addressOf
和fakeObject
原语
并且可以看到这个版本的v8没有compression pointer
机制,因此addressOf
获得的就是对象的完整地址,然后可以轻松伪造一个ArrayBuffer
实现任意地址读写,写wasm的shellcode区域。
exp
1 | <!DOCTYPE html> |
0x02.01 xnuca2020-babyV8
patch分析
1 | diff --git a/src/codegen/code-stub-assembler.cc b/src/codegen/code-stub-assembler.cc |
查找该函数的上层调用,发现其在TF_BUILTIN(ArrayPrototypePush, CodeStubAssembler)
函数里被调用,而TF_BUILTIN(ArrayPrototypePush, CodeStubAssembler)
函数是js中的Array.prototype.push
函数的具体实现,因此该漏洞与push
操作有关。
patch以后,部分关键代码如下
1 | // Resize the capacity of the fixed array if it doesn't fit. |
其中看到,在存储数据之前,先进行了扩容,但这个扩容的计算是根据元素的个数来算的,而patch后,原本每次push一个数据,末尾指针加1,现在加了3
1 | Increment(&var_length, 3, mode); |
最后,数据都push完成后,将var_length的值作为Array的length,这就导致了数组的length大于其本身elements的大小,导致了oob。
漏洞利用
首先测试如下代码,用gdb调试
1 | var arr = []; |
可以看到arr的长度为19
为了验证是否溢出,我们用如下代码进一步测试
1 | var arr = []; |
结果如下
1 | 0x114d0808819d <JSArray[19]> |
计算arr可访问的范围
1 | 0x114d080881b8+19*8 = 0x114d08088250 |
可以看出,这个范围已经导入arr2的elements里,由此可以知道arr可以溢出,但是还溢出不到arr2对象那里,为了能够控制arr2对象,我们将arr2改为var arr2 = new Array(1.1,2.2);
可以发现,通过new创建的double Array对象,其elements位于对象下方,而不是上方。
1 | 0x0bd3080881a5 <JSArray[19]> |
这样,arr就可以溢出控制arr2对象的结构,改写arr2的length为更大,使得arr2也变为一个oob数组,然后后续利用就类似了。我们发现这个版本的v8开启了compression pointer
因此利用起来可能有些麻烦,于是我们直接用构造好的oob数组来改写下方的ArrayBuffer
以及直接从下方搜索数据,不再使用addressOf
和fakeObject
来伪造对象。
exp
1 | var buf = new ArrayBuffer(0x8); |
0x03 callback中的OOB
0x03.00 数字经济-final-browser
patch分析
1 | diff --git a/src/builtins/builtins-array.cc b/src/builtins/builtins-array.cc |
可以看到,patch为Array类型增加了一个coin
函数,该函数功能就是如果37 < array_length
成立,就往37位置写入我们传入的value。
这里就涉及到一个知识点了Object::ToNumber
会调用对象里的valueOf
函数,因此,当执行到这个函数时,还得回到js层去执行valueOf函数,然后再回来。然而,我们注意到一个顺序
1 | + FixedDoubleArray elements = FixedDoubleArray::cast(array->elements()); |
先是取了elements,然后再去js层回调valueOf函数。假如我们在js层里的valueOf函数里趁机把arr的length扩大,那么Array会申请新的elements,原来那个elements被释放了,然而会到native层时,elements仍然指向的是之前那个elements位置,这就造成了UAF,而uint32_t array_length = static_cast<uint32_t>(array->length().Number());
是在之后执行,因此,我们一开始构造一个很小的arr,然后在valueOf里将arr扩大,那么即能绕过if(37 < array_length){
的判断,从原来的elements处溢出。
漏洞利用
我们可以利用溢出,修改后方的array对象的length,从而构造一个可以自由oob的数组。
POC
1 | var val = { |
构造出oob数组以后,我们就可以利用之前介绍的方法利用了。
exp
1 | <!DOCTYPE html> |
0x03.01 plaidctf2018-roll_a_d8
patch分析
1 | diff --git a/src/builtins/builtins-array-gen.cc b/src/builtins/builtins-array-gen.cc |
这题并不是patch引入漏洞,而是patch修复了漏洞,这是一个真实存在于v8中的历史漏洞,并且从patch中可以知道其代号为821137
,我们在github上搜索一下代号找到一个commit,点击parent,获得其存在漏洞的那个commit为1dab065bb4025bdd663ba12e2e976c34c3fa6599
,于是使用git checkout 1dab065bb4025bdd663ba12e2e976c34c3fa6599
,然后编译v8即可。
从patch中可以看到,已经有POC了,我们来分析一下POC的原理。
首先,漏洞出在GenerateSetLength
函数,那么我们查找一下该函数的上层调用,发现其在
1 | // ES #sec-array.from |
函数中被调用,处bootstrapper.cc
中可以知道该函数是Array.from
的具体实现
1 | SimpleInstallFunction(array_function, "from", Builtins::kArrayFrom, 1, |
该函数的作用是通过一个迭代器为数组元素赋值,用法如下
1 | let arr = [6,6,6,6]; |
输出如下
1 | root@ubuntu:~/Desktop/plaidctf2018-roll_a_d8/x64.debug# ./d8 poc.js |
其中[Symbol.iterator]
是固定语法,表明这是一个迭代器,我们只需要重写迭代器里的next
函数即可实现自己的逻辑。
我们先看到TF_BUILTIN(ArrayFrom, ArrayPopulatorAssembler)
函数中迭代的逻辑
1 | BIND(&loop); |
可以看到当迭代完成也就是loop_done
的时候,将迭代次数index
赋值给了length
变量,然后最后,调用GenerateSetLength
函数将这个length设置到array对象里
1 | // Finally set the length on the output and return it. |
而GenerateSetLength
函数将迭代次数与原来的数组长度进行对比,如果比原来的小,就调用js层的SetProperty
函数将arr的length设置,否则直接将length值写入。这里看似没有什么问题,但是问题就发生在回调的逻辑里,这里是假设了array对象的length和迭代次数同步的递增
,我们可以在迭代回调函数里趁机把array对象的length给改小,然后进入GenerateSetLength(context, array.value(), length.value())
函数时就可以绕过 GotoIf(SmiLessThan(length_smi, old_length), &runtime);
函数,直接将迭代次数设置为array对象的length。调用SetProperty
和使用StoreObjectFieldNoWriteBarrier(fast_array, JSArray::kLengthOffset, length_smi);
来设置length的不同之处在于SetProperty
是js层的,调用它来设置会顺便将elements扩容或收缩,而StoreObjectFieldNoWriteBarrier(fast_array, JSArray::kLengthOffset, length_smi);
函数不回调,直接在内存里写上这个值。因此,不扩容,就造成了溢出。
漏洞利用
POC
1 | let arr = [1.1]; |
可以看到length为10,然而elements的长度值却为1
由此,我们利用溢出,改写ArrayBuffer的length和backing_store即可实现任意地址读写
exp
1 | var buf = new ArrayBuffer(0x8); |
0x03.02 issue 716044
patch分析
1 | diff --git a/src/builtins/builtins-array-gen.cc b/src/builtins/builtins-array-gen.cc |
这题与前一题类似,也是一个真实的v8历史漏洞,代号为716044
。从patch中,我们看到其中MapResultGenerator
函数的变化较大,我们查找其的上层调用,发现其在TF_BUILTIN(ArrayMap, ArrayBuiltinCodeStubAssembler)
函数中被调用
1 | GenerateIteratingArrayBuiltinBody( |
可以知道这是Array.prototype.map
函数的具体实现,该函数的作用是将键值进行映射
1 | var a = [1,2,3,4]; |
其输出为
1 | root@ubuntu:~/Desktop/issue_716044/x64.release# ./d8 t.js |
即该函数接收一个函数对象,作为映射的变换函数,映射的值来源于调用数组对象。继续分析GenerateIteratingArrayBuiltinBody函数
1 | void GenerateIteratingArrayBuiltinBody( |
而MapResultGenerator
函数调用了ArraySpeciesCreate
,继续跟踪
1 | Node* MapResultGenerator() { |
而ArraySpeciesCreate
函数如下
1 | Node* CodeStubAssembler::ArraySpeciesCreate(Node* context, Node* originalArray, |
回调了js层的SpeciesConstructor函数,目的是为了调用合适的构造函数,比如如下
1 | class MyArray extends Array { |
其中
1 | static get [Symbol.species]() |
是固定写法,该函数返回一个类型,那么下一步回调结束,程序就会从Array类里调用构造函数,从而创建了一个Array的对象,假如代码改为如下
1 | class Array1 extends Array { |
由于static get [Symbol.species]()
返回了Array1,因此map时会从Array1里调用构造函数,此时,我们可以控制super()
函数里的参数,如下
1 | lass Array1 extends Array { |
但是最后映射结果的时候,仍然使用的之前的len,因此在进行函数映射时,由于没有检查用于存放结果的数组的长度,便发生了越界写
。
1 | Node* result = |
漏洞利用
既然能越界写,那么我们就越界覆盖后方Array对象的length,进而构造一个oob的arr,然后利用手法就和前面一样了。
exp
1 | var buf = new ArrayBuffer(0x8); |
0x04 JIT中的OOB
0x04.00 qwb2019-final-groupupjs
patch分析
1 | diff --git a/src/compiler/machine-operator-reducer.cc b/src/compiler/machine-operator-reducer.cc |
该patch打在MachineOperatorReducer::Reduce
函数中,可以推测这个漏洞与JIT编译器有关。
JIT中的IR优化流程如下
其中MachineOperatorReducer发生在Reduce阶段也就是图中SimplifiedLoweringPhase
阶段,MachineOperatorReducer::Reduce
会将IR中间代码中的一些可以在编译时就计算出的条件直接优化为一个布尔值。
而我们的patch正好打在了IrOpcode::kInt32LessThan
分支,也就是如果IR代码中有kInt32LessThan
的代码调用,将会出现问题,可以溢出一个单位。
而数组的length则是Int32类型,尝试写出如下的测试代码
1 | function opt(x) { |
发现并没有发生溢出,为了追踪优化过程,我们v8自带的Turbolizer
来查看v8生成的IR图,执行
1 | ./d8 1.js --trace-turbo --trace-turbo-path ../ |
生成IR图,然后用Turbolizer
打开查看
发现其在LoadElimination Phase
阶段,直接使用CheckBounds
来进行检查了,也就是还未到达SimplifiedLoweringPhase
阶段时,JIT就已经知道这个为越界的访问。因此,我们可以将4包裹在一个字典对象里,这样在LoadElimination Phase
阶段,JIT就不知道越界了,因为后面还要进行Escape Analyse
才能知道值。
于是代码修改为这样
1 | function opt(x) { |
可以发现输出了一个double值
1 | root@ubuntu:~/Desktop/qwb2019-final-groupupjs/x64.debug# ./d8 1.js --trace-turbo --trace-turbo-path ../ |
这回由于信息不足,不能在LoadElimination Phase
阶段确定,因此仅检查了最大范围
然后在SimplifiedLoweringPhase
阶段,用了Uint32LessThan
,由于Uint32LessThan
被patch过,因此结果为True,那么就可以越界访问了。
漏洞利用
构造出一个oob数组后,改写数组对象的MAP,然后构造addressOf
和fakeObject
原语。
exp
1 | var buf = new ArrayBuffer(0x8); |
0x05 感想
oob类型的v8漏洞,其利用手法大多相似,不同点在于如何构造出oob数组。从一开始的直入主题到一般情况再到回调函数中的oob以及JIT中的oob,收获了许多知识。