编译了一个d8
程序用于验证和利用漏洞,相关附件下载
CheckBound优化流程
首先在原有的simplified-lowering
阶段,CheckBound
节点并不被消除,而是设置为kAbortOnOutOfBounds
模式,并替换为CheckedUint32Bounds
。
1 | void VisitCheckBounds(Node* node, SimplifiedLowering* lowering) { |
而在此之前,该位置如下,可见原先利用节点消除的漏洞利用方法不能使用了。
1 | if (lower()) { |
在Effect linearization
阶段,CheckedUint32Bounds
节点会被优化成Uint32LessThan
,并绑定上其True
和False
分支。
1 | Node* EffectControlLinearizer::LowerCheckedUint32Bounds(Node* node, |
而在lateoptimize
阶段,将其优化为左值<右值这个表达式,即一个永真或者永假条件。
1 | // Perform constant folding and strength reduction on machine operators. |
此后,另一个分支就变成了一个不可达的分支,最终在brancheliminate
中被剪掉,达到和早期未patch
版本同样的目的,但要求多了很多。
题目分析
而从题目来看,题目只patch
了两个字符,就是在上面
1 | return ReplaceBool(m.left().Value() < m.right().Value()); |
改为了
1 | return ReplaceBool(m.left().Value() < m.right().Value() + 1); |
这样的话,就算达到访问一个element
的下一个节点,这个checkBound
也会被优化掉,从而有个off-by-one
,如果能达到这一点,就和*ctf 2019
的oob
这题一模一样了,但那题的实现是增加了一个builtin
函数,不需要利用优化,而此题需要在优化的前提下才能用,而且必须使CheckBound
达到上述代码的位置。
测试样例分析
测试代码:
1 | var opt_me2 = () => { |
可以发现使用上述测试样例并不能触发OOB
,其原因也十分有趣,同样来源于优化过程。
首先通过--trace-turbo
对优化过程的IR
进行记录,发现在LoopPeeling
阶段,44
节点是一个值比较结点,而47
结点是从element
中读取数据,也就是实际执行arr[index]
的这个节点。
但在下一阶段loadelimination
中,比较44
和47
两个节点都消失了,最终结果将返回2
结点(undefined)。
可以查看一下loadelimination
都做了什么,从源码中可以看到主要以AddReducer
方法添加了10个reducer
1 | void Run(PipelineData* data, Zone* temp_zone) { |
而在graph_reducer.ReduceGraph
中将分别对每个节点调用上述添加的10个*::Reduce()
方法。
1 | Reduction GraphReducer::Reduce(Node* const node) { |
使用trace-turbo-reduction
对节点的修改和替换细节进行分析,可以发现在如下部分,首先是NumberLessThan(43, 16)
内容被TypeNarrowingReducer
更新,然后被ConstantFoldingReducer
替换成HeapConstant
固定值false
,最终导致45
节点True
的分支变成不可达的节点,最终被DeadCodeElimination
清理掉,造成没有触发OOB
1 | - In-place update of 44: NumberLessThan(43, 16) by reducer TypeNarrowingReducer |
首先跟踪TypeNarrowingReducer
,可以看到当opcode
是kNumberLessThan
时,如果左节点的最小值大于右节点的最大值时,类型会被op_typer_.singleton_false();
,是一个HeapConstant
1 | Reduction TypeNarrowingReducer::Reduce(Node* node) { |
从日志中可以发现其左节点是43
,从IR
可以发现其范围是[4,4]
,右节点是16
,是一个常量值[4]
1 | - Replacement of 41: LoadField[tagged base, 24, Range(0, 134217726), kRepTaggedSigned|kTypeInt32, NoWriteBarrier, mutable](68, 17, 12) with 16: NumberConstant[4] by reducer LoadElimination |
因此,在ConstantFoldingReducer::Reduce
中,44
节点将被生成的一个HeapConstant
节点替代。
1 | Reduction ConstantFoldingReducer::Reduce(Node* node) { |
因此,想要触发OOB
必须规避掉以上路径。可以从43
节点和16
节点两方面考虑。首先说16
节点,其来自于41
节点的优化
1 | - In-place update of 41: LoadField[tagged base, 24, Range(0, 134217726), kRepTaggedSigned|kTypeInt32, NoWriteBarrier, mutable](68, 17, 12) by reducer RedundancyElimination |
当op
搜索的参数field_index
不是0
时,到相应的object
中找到相关偏移的节点代替掉这个LoadField
节点,可见这个就是直接取出了要访问element
的长度,似乎无法改变。
1 |
|
而另一节点43 typer
的路径如下:
1 | Reduction Reduce(Node* node) override { |
SIMPLIFIED_OTHER_OP_LIST
定义如下
1 |
|
因此这个分支就变成了
1 | case IrOpcode::kCheckBounds: \ |
TypeCheckBounds
定义如下,取第一个和第二个输入节点的类型,调用CheckBounds
1 | Type Typer::Visitor::TypeCheckBounds(Node* node) { |
CheckBounds
定义如下,显然index
是一个实际的范围,而length
负责控制其最大边界,而最终取index
与mask
的交集。
1 | Type OperationTyper::CheckBounds(Type index, Type length) { |
1 |
|
对于测试demo
,其0、1
两个节点的范围如下:
显然就是取[4,4]和[0,2147483646]的交集,因此CheckBounds
的typer
结果是[4,4]。最终导致满足uintlessthan
的优化条件left_type.Min() >= right_type.Max()
,被优化成永假。
poc构造
综上,分析了测试样例不能触发OOB
的原因,首先要想办法绕过loadelimination
阶段对loadelement
节点的消除。
可以发现一个显然的途径是在CheckBounds
的typer
阶段做文章,如果让CheckBounds
节点的范围并非单一值而是一个范围,保证最小值小于要访问element
的范围,就不会满足消除的条件(left_type.Min() >= right_type.Max())
,而核心问题是对第一个输入的节点范围的扩展,因为CheckBounds
的范围基本由此确定。
长亭发表的一篇writeup中提到了两种解决方案,第一种是对index
增加一个and
操作idx &= 0xfff;
,这种方法会在原来NumberConstant[4]
下面增加一个SpeculativeNumberBitwiseAnd
节点。
而这个节点的typer
实现如下:
1 | Type OperationTyper::NumberBitwiseAnd(Type lhs, Type rhs) { |
其中lmin、lmax
为255
,rmin、rmax
为4
,因此最终该节点的范围(0,4)
,传递至CheckBounds
节点并不满足这消除条件,可以触发漏洞。
第二种,由于逃逸分析阶段在LoadElimination
后一阶段,因此在typer
时,无法直接分析出从array
中取出的index
具体值,只能将其分析为Signed32
,最终CheckBounds
的范围为(0,2147483646)
此外,还可以利用Phi
节点来达到同样的目的,当某个值存在分支时,Turbofan
会将增加一个phi
节点,并将这两个值都加入节点的范围去传递,那么poc
同样可以这样构造
1 | var opt_me = (x) => { |
则构造的IR
图如下
执行结果如下:
1 | # p4nda @ ubuntu in ~/chromium/v8/v8/out.gn/x64.debug/log on git:749f0727a2 x [10:39:33] C:130 |
addrof原语构造
现在在element
上存在一个off-by-one
。对于一个JSArray
,其数据结构本身与element
内存分布存在两种布局,一种是elememt
在低地址,一般用var a = [1.1,1.2,1.3]
这样的方式构建;另一种是element
在高地址,一般用var a = Array(4)
这样的方式构建。由于二者内存位置紧邻,因此,可以通过off-by-one
泄露或者修改一个对象的map
地址,从而造成type confuse
。
一个简单的想法就是将一个存放了obj
的JSArray
的map
改为全部存放double
类型的JSArray map
。
首先泄露比较简单,利用之前的poc
可以将arr
的map
,并将arr
加入一个全局的Array
防止map
被释放。
1 | function get_map_opt(x){ |
在拿到了一个PACKED_DOUBLE_ELEMENTS
类型的map
时,就可以对一个PACKED_ELEMENTS
类型的JSArray
造类型混淆了。这里有一个坑点,就是不能对一个PACKED_ELEMENTS
类型的map
位置直接写一个double
,因为element
一共有三种类型,并且是不可逆的改变,向PACKED_ELEMENTS
类型的element
写double
会将double
转换为一个HeapNumber
,也是一个HeapObject
,而非double
值本身。
例如:
1 | # p4nda @ ubuntu in ~/chromium/v8/v8/out.gn/x64.debug on git:749f0727a2 x [10:26:24] |
因此需要做一下转换,对一个写满double_map
的JSArray
(PACKED_DOUBLE_ELEMEMTS
类型)造类型混淆,使其混淆为PACKED_ELEMENT
类型,这样再去其中的一个变量向PACKED_ELEMENT
类型的JSArray
写入,即可将其混淆为PACKED_DOUBLE_ELEMENT
类型,从而读出其中object
的地址。
1 | function prepare_double_map_opt(x){ |
任意地址读写构造
JSArray
数据可以存放于三个位置,以数字下标访问的存放于elements
,以value:key
访问的如果是初始化的时定义的,直接存于数据结构中,其余后续加入的存于properties
,而对于键值对访问的数据,其键值查找方式存于map
中,那么如果可以对一个JSArray
的map
进行修改,通过键值对访问的方式,对后续数据进行修改。
首先,获取一个含有properties
很多的一个JSArray
的map
,
1 | function get_array_map_opt(x){ |
通过布局使一个JSArrayBuffer
恰好处于紧邻一个JSArray
的高地址位置,这样将JSArray
的map
修改为以上map
,就可以不断修改backing_store
了,由于这个布局相对稳定,因此可以重复使用。
1 | function get_victim_obj_opt(x){ |
通过访问victim_jsarray.a5
实际上读写的是victim_arraybuffer
的backing_store
成员,通过对victim_arraybuffer
读写达到任意地址读写的目的。
最终,通过wasm
对象,找到rwx-
区域,执行shellcode
。
EXP
1 | function gc() |
由于chromium
编译太慢了,用d8
代替结果: