PlaidCTF 2018: roll a d8
PlaidCTF 2018: roll a d8
尝试我的第二个v8题目。
Description
1 | Roll a d8 and win your game. |
打开链接搜索v8.git找到对应的patch hash:b5da57a06de8791693c248b7aafc734861a3785d
看来是一个real world的洞咯。
Compile
切换到有漏洞的hash:(不是切换到上面图里的hash)
1 | git reset --hard 1dab065bb4025bdd663ba12e2e976c34c3fa6599 |
记得跑之前给终端和git设置好代理备用。命令见v8环境搭建的那篇博客。
sync卡住了就等等,代理配好了大概也得等一小会儿才会开始下,之后就快了。
如果遇到这样的错误:
1 | $ ninja -C ./out.gn/x64.release |
大概是你的本地的libc太新了,我本地的是libtinfo.so.6,复制粘贴一个libtinfo.so.5就可以正常编译了,目前来看没什么问题。
⚠️ 关于编译什么版本以及如何调试
目前来看的信息:
- release版本没有DCHECK,但是没有符号,也就是说用不了job。
- debug版本有符号,可以用job,但是DCHECK很难关(包括找到没有过check的检查的代码位置等)。
- 另外,由于靶机上用的release版本,用release版本写exp在攻击服务器的时候改动应该不是很大,debug版本可能差别会大一点。
- 如果cve有对应的poc,用debug版本运行可以看出来有没有触发,容易验证自己的编译结果有没有问题。
这里推荐的做法:
- patch之后,把release和debug版本都编译
- 如果有poc,用debug版本验证一下有没有crash
- 写exp的时候,用release来调试,主要用x和tele。遇到不熟悉的对象,在debug版本中用job来查看结构和偏移。最终的目标是攻击成功release版本。
Try Poc
cve提供了poc:
1 | let oobArray = [1.1]; |
debug版本运行触发crash:
1 | # |
通过failed信息可以看到应该是出现了数组访问越界。
我们来分析一下poc。
首先创建了一个数组oobArray。然后调用function() { return oobArray }
,将this指针换成了function传给Array.from.call。大概可以这样理解,Array在一般情况下,this指针被用于构造方法来构造返回值,一般来说会调用new Array。但是这里直接传了oobArray。那么之后的操作,都会在oobArray上进行。
Array.from的功能:
The
Array.from()
method creates a new, shallow-copiedArray
instance from an array-like or iterable object.
效果:
1 | console.log(Array.from("abcde")); |
输出:
1 | a,b,c,d,e |
poc之后用到了Symbol.iterator
,具体的功能样例:
1 | d8> Symbol.iterator |
Patch
1 | diff --git a/src/builtins/builtins-array-gen.cc b/src/builtins/builtins-array-gen.cc |
官方patch将SmiLessThan改成了SmiNotEqual,可以大概猜出问题出在length_smi>old_length的情况。
Vulnerability
现在来一步步分析漏洞是如何出现的。首先是一些基础知识。
Basics
一些基础知识。
Isolate
Isolate是v8隔离用的单位。一个v8运行实例对应一个Isolate。所有的运行时信息都不采用全局的方式存储,而是放在Isolate中。
JIT
即时编译。最开始的编译用的是基准编译器,Baseline Compiler。当一个函数调用很多次变成了Hot Function,就会用编译器来优化,叫做TurboFan。利用点在于JIT的代码一般是rwx的(由于需要优化,就会对代码进行修改),如果能够修改编译后的JIT代码改成shellcode,就可以pwn了。
Garbage Collection
之前的exp中出现的gc()函数,gc就是garbage collection的缩写。我们在写js的时候不需要手动free,内存释放交给垃圾收集器来处理。v8将对内存分成了很多区域。
- new space:新分配的对象
- old pointer space:存放“旧的”指针
- old data space:存放“旧的”数据
- large object space:存放占用比较大的对象
- code space:存放jit代码
- …
如果对象经历了两次回收都没有被回收,会被移入到old pointer space中。我们在写exp时要尽可能将利用的对象放在old space中,否则的话被移走就比较麻烦。(以后再体会吧)
Objects
在表示对象的时候的知识点。
- Tagged Pointer
js中number都是表示为double的。但是有时候为了计算得更快等需求会用小整型这个类型来算(Small Integer, smi)。除了小整型,其他都是指针类型,为了区别会加个1。这就是为啥之前看到的是1和9结尾的指针。
- Named Properties vs. Elements
主要是说大部分用gc处理的对象的第一个属性就是Map(Hidden Class)。Map用来识别对象的类型。
有些对象用数字offset来访问元素,有些用字符串来访问属性,存储的时候是不同的。
之后exp需要用到的对象结构:
1 | // The ArrayBuffers look like this in memory: |
CodeStubAssembler
CodeStubAssembler是一种平台无关的汇编程序(当作是中间代码即可),v8为了提升效率用CodeStubAssembler来写原生的js程序。
Source Code
需要读一下源码。
从1999行开始看一下Array.from的实现。
1 | // ES #sec-array.from |
这段是检查map_function的。
1 | TNode<Object> items = args.GetOptionalArgumentValue(0); /// 拿到this |
这段根据创建了array和length,根据iterator_method类型判断进入哪个分支。
1 | BIND(&iterable); |
到这里是检查方法是否可以调用。
1 | // Construct the output array with empty length. |
上面构造了存储迭代数据的数组,这个数组就是我们利用的点。之后是循环来处理数据。
1 | BIND(&loop_done); /// 循环结束 |
这里就要重点关注一下GenerateSetLength这个函数,也就是patch修改的函数。跟进之前确认一下:
- array是保存迭代数据的数组
- length是迭代的次数
1 | void GenerateSetLength(TNode<Context> context, TNode<Object> array, |
GenerateSetLength是用来设置长度的,设置有多少长度可以访问。
根据代码的逻辑,可以这么理解:
如果length_smi < old_length,迭代的次数小于array的长度,就跳转,缩减内存/重新分配,然后修改length。这里修改是有必要的,因为要减少可以访问的长度,此时array的长度是存在冗余的。
可以这样理解。如果迭代次数很少,说明前length_smi才是需要的,后面array存储的东西都没用。只需要访问前length_smi就可以。
如果length_smi >= old_length,说明迭代的次数比数组长度大。没有必要缩减内存/重新分配内存。直接修改长度。
⚠️注意:这里的array按照原本的功能是新建一个empty的array。修改length是修改这个新array的长度(浅复制的那个array),而不是原本的array。因为每次迭代的结果都是存储在新array里的,如果我们迭代了很多次,看起来是不需要减少新array的内存的。所以这个逻辑看起来没啥问题。
⚠️注意,新分配的array的长度和原本的array的长度没有什么必然的联系,新array的长度取决于迭代的次数?这和迭代器的实现有关。例如在poc中,迭代器是自己实现的,迭代的次数写死了成为1028*8这么长。
🤔️ 为什么会出现length_smi不等于fast_array的length的情况?难道不应该是迭代一次就存一个数?那样两者应该一定相等啊。
理解迭代次数和array长度不一定相等的问题
这是因为迭代器可以写的比较复杂,对不同的功能,的确是可以不同的。
Root Cause
以上删除的都是废话。关于length_smi和old_length为什么会不一样,看这里就好了。
🚨🚨🚨🚨🚨重点🚨🚨🚨🚨🚨
如果这里的Array.from在调用迭代器的时候,没有指定this,那么就会新建一个empty的数组作为存放输出结果的array。由于迭代的时候每次加入新的值都会index++,所以index=array长度=迭代次数=length_smi=old_length。
如果制定了this,例如这样的代码:
迭代器在保存输出结果的时候用的就是b数组了。这是预期的功能。这时候就涉及到三种情况:
- b的长度>a的长度
- b的长度=a的长度
- b的长度<a的长度
这里由于要复制的是a,所以a的长度=迭代次数=index=length_smi。
如果两者相等,修改length也没什么问题,不需要对内存空间进行操作。
如果b的长度>a的长度,也就是length_smi<old_length的情况。现在的b保存了迭代的结果,但是多存储了很多东西,需要把多余的空间释放掉,并修改b的可访问大小。
直接运行的结果:
如果b的长度<a的长度,在b的空间写完一部分a的值之后,每次会继续给b加空间,b的length加1,直到迭代结束。此时一定会有a和b的长度相等,等于迭代次数。
似乎可以得到这样的结论,由于b是存储a的迭代结果的,所以迭代之后b的长度一定大于等于a的长度,也就是说old_length一定大于等于length_smi。这也就是patch前为什么代码只考虑了length_smi<old_length的原因。
⚠️开发者忽略了:在迭代的过程中,b的长度也是可以通过代码修改的!
我们重新看一下poc:
1 | let oobArray = [1.1]; |
这里将迭代次数写死成1028*8
了。但是在迭代的最后一步,将oobArray的长度修改成了1。
举个例子:
修改了长度之后,js引擎的垃圾回收机制把a[0]之后的内容都释放了。同理,此时oobArray[0]之后的内存也都释放了。
这样就变成了,length_smi=1028*8
,而old_length=1。此时length_smi>old_length,没有对内存进行操作,直接将oobArray的长度改成了1028*8
,但其实oobArray的长度只有1。这样,我们就可以访问不属于oobArray的内存空间了。造成了oob。
Exploitation
利用的纲领:
- 怎么泄露对象的地址
- 怎么任意地址写
ArrayBuffer
1 | buffer = new ArrayBuffer(32); |
关注backing_store,指向的是分配的空间的地址。
1 | DebugPrint: 0x27673188d4d9: [JSArrayBuffer] |
- 如果可以控制backing_store指针,就可以任意地址读写。
- 如果可以控制length,就可以越界访问。
另外,ArrayBuffer的空间是分配在堆上的。泄露backing_store指针还可以泄露堆地址。
Arbitrary read and write
根据上面的分析,我们可以构造出一个访问范围大于length属性的数组。如果我们在这个数组的访问范围内,布置一个ArrayBuffer,通过数组修改其backing_store指针,就可以任意地址读写了。
addrof
想要泄露一个对象的地址,可以在可控区域内布置该对象,然后将该对象作为自己的属性(in-object),通过越界读取可以泄露地址。(暂时还没懂)
Type Constructor
1 |
|
参考的是2019的exp。
需要注意的是当处理高位的时候会出现问题。
Find what we controlled
1 | var bufs = []; |
我们在迭代的最后一次,向bufs和objs里面push了很多的ArrayBuffer和obj。我们希望有一个buf和obj进入到oobArray可控的区域。找到之后,我们修改一下一个属性,之后在bufs和objs中搜索,找到了对应的index。
addrof
将想要泄露的对象作为可控obj的属性,通过oobArray就可以打印出地址了。
1 | // leak object address |
Arbitrary read and write
1 | // arbitrary read with overwriting backing_store |
通过覆盖backing_store指针,达到任意地址读写的效果。感觉需要熟悉一下ArrayBuffer的语法。
在任意地址写的时候,通过TypedArray的set方法来写值的:
exp里的
1 | result.set([u2d(value)]); |
省略了offset = 0,其实就是result[0] = u2d(value);
Leaking heap address
1 | var buf0_addr = addrof(bufs[1]); |
通过backing_store泄露堆地址。
Leaking libc address
同样的存在稳定泄露和不稳定泄露的方法。
在之前的oob中,我们是通过泄露text段的地址,找到got表的地址。之后通过got来泄露libc的。至于泄露代码段的地址,既可以通过暴力搜索内存的方法,也可以通过泄露array构造函数的方法。前者比较不稳定,后者比较稳定。
当然还有另外一种,直接从堆地址开始遍历,找到形如0x7fxxxx的地址。一般来说会出现unsortedbin和largebin,我们可以找到像fd和bk的部分,也就是相邻的两个8字节,都是0x7fxxxx的地址。
实测的时候,通过array没有找到代码段的地址。通过暴力的方法一直都没有成功。看2019的wp,利用UAF泄露libc的方法:
- 在2019之前的exp中,找到了两个可控的ArrayBuffer。
- 通过任意地址写,将两个ArrayBuffer的buffer指向同一个(例如改A的buf,指向B的)
- 删除B,触发垃圾回收机制,使得B的buf释放掉了,上面存储了libc的地址
- 通过view A的数据,将libc泄露出来
还有一种getshell的方法是生成一段jit的代码,然后写shellcode。
不用jit的话,泄露wasm的地址,写shellcode。
Getshell with shellcode in wasm
⚠️注意:我们这次使用wasm写shellcode的方法。
在这个v8版本中,wasm的结构和之前oob版本的wasm结构不同,通过shared_pointer找不到WasmExportedFunctionData。
1 | DebugPrint: 0x377ebb127d51: [Function] in OldSpace |
但是这里的Code JS_TO_WASM_FUNCTION看起来很可疑。猜测是用来跳转到wasm代码的,那就应该会保存rwx的地址。
1 | pwndbg> job 0x9dd030e681 |
其中的0x7c6819cc000就是rwx开始的地址。偏移:
1 | pwndbg> x/4gx 0x9dd030e681-1+0x72 |
不过在赋值的时候,由于值过大,导致不能正确赋值(如果直接使用write64):
可以补充一个Uint8Array来赋值。(用oob的exp,也不能很好的赋值)
1 | function write8(addr, value) { |
成功!
Full exp
1 | // type converter |
照例还是要弹个计算器的:
弹计算器的shellcode:
1 |
|