本文首发于安全客: https://www.anquanke.com/post/id/205572
这是去年XNUCA初赛中的一道题,本文首先会从源码的角度来分析漏洞的成因,并且详细跟进了漏洞利用中回调函数触发的根源,最后通过两种不同的利用技巧来对该漏洞进行利用。
相关exp和patch文件在这里
环境搭建
在学习P4nda师傅关于CVE-2018-17463
文章的时候,意识到该漏洞和这道题非常相似,所以本题的环境就直接在CVE-2018-17463
上搭建了(与题目本身的环境不一致,但不影响我们学习该题分析和利用的方法)。
1 | git clone https://chromium.googlesource.com/chromium/tools/depot_tools.git |
漏洞分析
patch
这道题的patch非常简洁,只是把BitwiseAnd
的属性从kNoProperties
变成了kNoWrite
。所以问题肯定出在对BitwiseAnd
操作的误判,即认为该操作不存在可见的副作用,既然这个推断有问题,那么副作用到底出现在什么地方呢?下面将从源码的角度来寻找副作用!
1 | diff --git a/src/compiler/js-operator.cc b/src/compiler/js-operator.cc |
寻找漏洞触发点
直接全局搜索BitwiseAnd
字符串便可找到所有可能操作该节点的地方。注意到在src\compiler\js-generic-lowering.cc
中有对该字符串的操作,对于为什么会关注这里,主要是因为这是Turbofan对sea of nodes
处理的一个阶段,Turbofan在对代码进行优化编译的时候主要就是经过多个阶段对节点的分析处理来得到更加底层的操作代码。
经过处理后,BitwiseAnd
节点被替换成了Builtins::k##Name
的Builtins调用,也既Builtins::kBitwiseAnd
。
1 | // src\compiler\js-generic-lowering.cc |
Builtins::kBitwiseAnd
定义在src\builtins\builtins-number-gen.cc
中,主要逻辑就是获取BitwiseAnd
节点的左右两个操作数
,利用TaggedToWord32OrBigInt
判断操作数是正常的数还是BigInt
,如果左右操作数都不是大整数,就会调用BitwiseOp
来进行处理,否则就会调用Runtime::kBigIntBinaryOp
的runtime函数。
1 | // src\builtins\builtins-number-gen.cc |
一开始的分析方向主要是放在BitwiseAnd
节点对两个操作数的改变上,但是我结合实际调试以及源码分析,没有找到哪个地方对操作数进行了改变,感兴趣的可以查看一下相关代码,由于篇幅原因,这里我只分析存在漏洞的地方,也就是TaggedToWord32OrBigInt
函数中的内容。
跟进TaggedToWord32OrBigInt
函数,函数又调用了TaggedToWord32OrBigIntImpl
,TaggedToWord32OrBigIntImpl
主要的逻辑是一个循环,会判断参数value
节点的类型是不是小整数Smi,不是的话就会依据其map来看value的类型,例如是不是HeapNumber、BigInt。如果都不是,那么会看value的instance_type
是不是ODDBALL_TYPE
,如果仍然不是,那么就会依据conversion
的类型来调用相应的Builtins函数,这里conversion
的类型为Object::Conversion::kToNumeric
,因此会调用Builtins::NonNumberToNumeric
函数,这就是问题所在了!!
1 | // src\code-stub-assembler.cc |
看到这里的kNonNumberToNumeric
,让我想起了数字经济线下赛的Browser Pwn,那道题利用的就是ToNumber
函数在调用的时候会触发valueOf
的回调函数,这道题是否也会触发相应的回调函数呢?在我测试后发现果然是这样!!也就是说我们找到了漏洞存在的地方,a&b
将生成一个BitwiseAnd
节点,该节点被判定为NoWrite
,实际情况却是在对BitwiseAnd
节点的输入操作数进行处理的时候会触发操作数中的valueOf
回调函数,所以认为该节点是NoWrite
是有问题的。
1 | function opt_me(a,b){ |
探寻回调函数-toPrimitive
虽然已经找到了触发漏洞的方式,但是我们的分析不能到此为止,接下来的目标是尝试跟踪到具体调用回调函数的地方。继续分析Builtins::NonNumberToNumeric
,该函数获取节点的context和input,并将其作为NonNumberToNumeric
的参数。NonNumberToNumeric
内部又调用了NonNumberToNumberOrNumeric
函数。
1 | // src\builtins\builtins-conversion-gen.cc |
NonNumberToNumberOrNumeric
函数的主要逻辑也是一个大循环,在循环里面对input的instance_type
进行判断,判断是否是String、BigInt、ODDBALL_TYPE、JSReceiver等,然后跳转到相应的分支处去执行。如果是String,就调用StringToNumber
把字符串转换为Number。我们要关注的是if_inputisreceiver
这个分支,该分支会调用NonPrimitiveToPrimitive
来把input转换为更原始的数据,如果转换结果是一个Number/Numeric
,说明转换完成退出循环,否则继续循环。
1 | // src\code-stub-assembler.cc |
NonPrimitiveToPrimitive
内部调用了Builtins
函数NonPrimitiveToPrimitive
,依据hint的类型调用相应的处理函数。这里的hint是kNumber
,因此调用的是NonPrimitiveToPrimitive_Number
函数,函数内部也仅仅是调用Generate_NonPrimitiveToPrimitive
来进一步对参数进行处理。
1 | // src\code-factory.cc |
Generate_NonPrimitiveToPrimitive
函数内部会查找input的@@toPrimitive
属性,如果存在相关属性便会通过CallJS
来调用我们的@@toPrimitive
属性exotic_to_prim
,那这个toPrimitive到底是个什么呢?查了一下发现这个属性是我们可以定义的,也就是说这个地方是我们可以设置的回调函数!!!
Symbol.toPrimitive 是一个内置的 Symbol 值,它是作为对象的函数值属性存在的,当一个对象转换为对应的原始值时,会调用此函数。
1 | // src\builtins\builtins-conversion-gen.cc |
所以按照上面的分析,我们可以设置对象的toPrimitive
属性,然后在处理过程中会调用该属性对应的回调函数,如下所示:
1 | let b = { |
探寻回调函数-valueOf
通过前面的分析我们找到了一处回调函数调用的地方toPrimitive
属性,这已经可以用来进行漏洞利用了,但是还是没有找到最开始发现的valueOf
回调函数调用的地方,所以还要继续分析!
我们开始定义的包含valueOf
的对象没有定义相应的toPrimitive
属性,所以在Generate_NonPrimitiveToPrimitive
中它应该会跳转到ordinary_to_primitive
分支处执行,也就是会调用OrdinaryToPrimitive
函数。这个函数的逻辑和前面分析的很相似,最后会跳转到Generate_OrdinaryToPrimitive
函数中执行。
1 | // src\code-factory.cc |
Generate_OrdinaryToPrimitive
函数中终于出现了我们所期望的内容,该函数依据hint的值来设置method_names
变量中的内容,主要是valueOf
和toString
。然后会尝试从input中获取valueOf/toString
属性,如果获取到的属性是callable
,那么就调用它,所以我们定义的valueOf属性对应的回调函数会被调用,至此源码分析结束!
1 | // src\builtins\builtins-conversion-gen.cc |
小结
本节以patch文件为切入点,从源码的角度分析了漏洞存在的地方,结合数字经济线下赛
的解题思路找到了触发漏洞的方式,然后以此探寻了回调函数最终被调用的根源,最终找到了三种定义回调函数的方法:
- Symbol.toPrimitive属性
- valueOf属性
- toString属性
漏洞利用 - Fake ArrayBuffer
该利用方法是从Sakura
师傅写的34c3 v9 writeup中学到的。最初我构造出addrOf和fakeObj之后被卡了很久,主要就是拿不到一个合法的map,从师傅的文章里面了解到伪造ArrayBuffer map
并进一步伪造出ArrayBuffer
是可行的。
addrOf原语
利用Turbofan对BitwiseAnd
节点影响的误判,我们可以消除掉对象属性访问的CheckMaps
节点,进而造成类型混淆。例如定义let c = {x:1.2,y:1.3};
,在两次属性访问c.x
和c.y
之间插入a&b
操作,c.y的ChekMaps
节点仍会被消除,如果在回调函数中把c.y
赋值为一个对象,那么return c.y;
仍然会按照之前的类型double
来返回数据,实现对象的地址信息泄露。由于正常写addrOf原语每调用一次之后就得重新写一个新的addrOf函数,因此我在addrOf
中加入了部分动态生成的代码片段,如下所示:
1 | function getObj(idx){ |
fakeObj原语
fakeObj原语的实现和addrOf类似,只需要第二次对属性的访问o.y1
是写操作即可,我们在回调函数中先把o.y1
赋值为一个对象,后续的写操作由于消掉了CheckMaps
节点仍会以double类型的方式往o.y1
写入数据,执行完后返回的o.y1
会按照对象来解析。因此我们可以指定任意的地址,该地址将作为对象被返回,实现fakeObj。
1 | function fakeObj(addr){ |
伪造ArrayBuffer map
有了addrOf可以泄露对象的地址,利用fakeObj可以伪造对象,但是面临的一个问题就是每个对象都有map字段,若是不能得到一个正确合法的map,我们的对象是不能被正常解析的。实现任意地址读写一个很直观的思路就是伪造ArrayBuffer对象,控制backing_store
字段即可任意地址读写,前提是得先伪造ArrayBuffer map
。
伪造map只需要按照下面这个形式来伪造就行了:
1 | var ab_map_obj = [ |
我们需要关注map对象的两个字段,prototype
和constructor
,其中prototype
的地址可以通过addrOf(ab.__proto__)
来获取,而constructor
的地址和prototype
的偏移是固定的(这里是0x1A0),因此可以算出constructor的地址。随便打印一个实际ArrayBuffer的map对象:
1 | 0x55fbd984371: [Map] |
需要注意的是伪造的map对象在后面的操作过程中由于触发GC,会被移动到old space
中,若是采用前面提到的数组形式来存放数据,在移动之后JSArray对象的elements
字段与该对象起始地址的偏移是不固定的,这使得我们的漏洞利用具有不稳定性,所以用什么方法可以让对象起始地址和数据之间的偏移固定呢?可以利用对象的属性信息来存储我们的fake map
数据,我们知道对象内属性是直接存放在对象内部的,其相对于对象起始地址偏移固定为0x18。
所以最后存放fake map
可以用这种形式:1
2
3
4// fake arraybuffer map
let fake_ab_map = {x1:-1.1263976280432204e+129,x2:2.8757499612354866e-188,x3:6.7349004654127717e-316,x4:-1.1263976280432204e+129,x5:-1.1263976280432204e+129,x6:0.0};
fake_ab_map.x4 = mem.u2d(ab_proto_addr);
fake_ab_map.x5 = mem.u2d(ab_construct_addr);
伪造ArrayBuffer
伪造出ArrayBuffer map
之后,伪造一个ArrayBuffer
便比较简单了,只需要按照下面这种形式来伪造即可,前面的三个字段只需要用我们fake map
的地址来填写即可,后面的是ArrayBuffer
的length和backing store。
1 | var fake_ab = [ |
这里需要注意最后一个字段,在34c3ctf v9
里面用mem.u2d(4)
可以的,但是在这里它会报如下错误:
1 | TypeError: Cannot perform DataView.prototype.getFloat64 on a detached ArrayBuffer |
依据这个错误,翻了一下源码,发现通过IsDetachedBuffer
来判断一个buffer是否是Detached
,判断的方式就是LoadJSArrayBufferBitField
加载JSArrayBuffer的bit_filed
,bit_filed
刚好就是我们fake_ab
的最后一个字段,所以我尝试把它从0x4改成0x8,结果就没有报错了。
最后伪造的ArrayBuffer数据可以是这样:1
2let fake_ab = {y1:mem.u2d(fake_ab_map_addr),y2:mem.u2d(fake_ab_map_addr),y3:mem.u2d(fake_ab_map_addr),y4:mem.u2d(0x2000000000),y5:mem.u2d(fake_ab_map_addr+0x20),y6:mem.u2d(0x8)};
gc();
getshell
有了伪造的ArrayBuffer,再结合DataView,通过不断地修改backing_store
也既fake_ab.y5
即可实现任意地址读写。按照wasm func addr(offset:0x18)
->SharedFunctionInfo(offset:0x8)
->WasmExportedFunctionData(offset:0x10)
->data_instance(offset:0xc8)
->imported_function_targets(offset:0)
->rwx addr
的顺序获取rwx的地址,写入shellcode即可。
漏洞利用 - Shrink object
基础知识
该漏洞利用技巧是从mem2019师傅34C3 CTF V9中学到的,从前面的分析我们知道了对象内(in-object)属性的存储方式,这种方式存储的是对象初始化时就有的属性,也就是我们所说的快速属性,然而还有一种存储属性的模式,就是dictionary mode
。在字典模式中属性的存储不同于fast mode
,不是直接存放在距离对象偏移为0x18的位置处,而是重新开辟了一块空间来存放。
我们用下面这个例子来实际分析一下:1
2
3
4
5
6
7
8
9
10
11
12
13
14function gc() {
for (var i = 0; i < 1024 * 1024 * 16; i++){
new String();
}
}
let obj = {a:1.1,b:1.2,c:1.3,d:1.4,e:1.5};
%DebugPrint(obj);
readline();
delete obj['d'];
%DebugPrint(obj);
readline();
gc();
%DebugPrint(obj);
readline();
最开始obj对象有5个in-object
属性,其直接存放在对象内部,相对于对象起始地址的偏移为0x18:
1 | pwndbg> job 0x758ca28fa29 |
接下来我们进行了delete obj['d']
操作,删除对象属性的操作将会把对象转换为dictionary mode
,转换后对象确实变成了dinctionary mode
,我们再看原来对象的内存数据,发现原来存放属性的地方值已经发生变化了,使用job命令查看偏移为0x18位置处,显示free space, size 40
。
1 | pwndbg> job 0x758ca28fa29 |
接下来调用gc函数,触发GC,对象obj由于在多次内存访问期间都存在,所以会被移至old space
,此时查看相对于obj偏移为0x18处的值,已经不是原来存放的in-object
属性了,而是其他的一些被移动到old space
的对象。
1 | pwndbg> job 0x2a376d0856f1 |
实际利用
现在回到这道题上,前面已经知道通过一些操作让对象属性从fast mode
变成dictionary mode
,在通过多次内存操作触发GC,我们的对象将被移至old space
,此时原来存放in-object
属性的偏移0x18处起始的地方存放的是其他被移动到old space
的对象,相当于对象已经发生了变化
,由于漏洞的存在,CheckMaps
节点被消除,无法检测出对象的改变,仍然按照原来访问in-object
的方式来读写对象数据,此时读写的就是其他对象中的数据了,也既越界读写。
将对象从fast mode
转化为dictionary mode
的方式目前知道的是:
- victim_obj.defineGetter(‘xx’,()=>2);
- delete victim_obj[‘d’];
如果紧跟着obj后面的是我们申请的一个JSArray
,那么越界修改数组的length字段是可以做到的,由此我们可以得到一个很大的数组越界,后续的利用就很简单了,查找Arraybuffer的backing_store
相对于越界数组的下标便可做到任意地址读写。
现在的问题是如何让两个对象在移动至old space
之后还能紧挨着呢?经过多次尝试发现,在trigger_vul中会有对victim_obj
的访问,若是我在foo4vul
中不加入对arr
的访问,那么这两个对象移动后相差的距离一定会很大,所以我猜测由于存在对victim_obj
的访问,所以它会先被移动到old space
,后续的arr虽然也会移动,但是这之间已经有多个对象移动到old space
了,因此导致二者的偏移很大。而在foo4vul
中加入arr
的访问,果然两个对象的地址是紧挨着的,而且非常稳定!
还有一个小问题:之前遇到了移动后victim_obj
位于arr
后面的情况,调换了一下foo4vul
的参数顺序a,b,o,arr->a,b,arr,o
即可。
拿到越界数组的部分代码如下:
1 | let victim_obj = {x:1,y:2,z:3,l:4,a:5,b:6,c:7,d:8,e:9}; |
后续只需要在申请一个ArrayBuffer、marker,让它也移动到old space
,依据特征查找处偏移即可做到任意地址读写,仍然按照上一种利用思路中获取wasm的rwx地址的方式,写入shellcode即可。
1 | // 0xdead and 0xbeef is special |
利用效果:
总结
我们通过对源码的分析结合其他题目的利用方式先找到了漏洞的触发方式,然后再从源码的角度详细的跟踪了触发漏洞的回调函数具体调用路径,并且以此找到了3中定义回调函数的方式,分别是:定义toPrimitive属性;定义valueOf;定义toString。利用该漏洞可以造成类型混淆,然后介绍了两种利用方式来对这道题进行利用,分别是:伪造ArrayBuffer map
,进一步伪造可用于任意地址读写的ArrayBuffer
;将对象从fast mode
转变成dictionary mode
,然后移至old space
,此时对象会发生收缩,但优化代码仍然会按照原来的方式读写对象的属性,也既越界读写其他对象的内容,构造合适的内存排布,越界写JSArray
对象的length字段来构造OOB,进而实现任意地址读写。
参考
http://p4nda.top/2019/06/11/%C2%96CVE-2018-17463/
https://mem2019.github.io/jekyll/update/2019/08/28/V8-Redundancy-Elimination.html