TCFT-final 题量很多,难度很大,做得头痛。有一道JS引擎的pwn,刚好最近才开始入门v8,所以就和ver哥研究了一天。有点遗憾,比AAA的师傅晚一点点出,没拿一血。好像,QWB的kernel题当时也是比AAA的师傅晚一点点,没拿二血,缘分23333.
漏洞分析
题目给了diff
文件,漏洞点能很快找到:
1 | diff --git a/deps/quickjs/src/quickjs.c b/deps/quickjs/src/quickjs.c |
可以看到只对fulfill_or_reject_promise
函数的set_value
函数进行了修改,原本是是执行了JS_DupValue(ctx, value)
,将其修改为了value
。
题目还给了commit
:0a533445f256fb3a628371e24705d3a2532f60f1
,把项目拖下来看源码。
JS_Dupvalue
如下:
1 | static inline JSValue JS_DupValue(JSContext *ctx, JSValueConst v) |
可以看到,他是将我们传入的值的引用指针取到,然后将引用数 +1,然后将值返回。这里解释很简单,就是 JS
每次取值、赋值操作,都代表着对这个值的引用,他会将引用数加1。
而我们patch
后,就是在执行这个函数的赋值时,这个值的引用数不会被加1。
我们接着看一下set_value
:
1 | /* set the new value and free the old value after (freeing the value |
功能也很简单,就是将一个新的值赋值到对象。这里首先会将旧值保存,然后赋新值,最后对旧值执行JS_FreeValue
操作:
1 | static inline void JS_FreeValue(JSContext *ctx, JSValue v) |
JS_FreeValue
操作,首先会将值的引用指针拿到,将该指针减一后,判断该引用数是否小于等于0,若成立,则意味着该值已经没有被引用,所以会调用_JS_FreeValue
释放掉这个值。
漏洞总结
所以,这里总结一下,其实就是一个UAF
漏洞。
我们将一个值x
,首先赋值给r1
对象,此时x.ref_count = 1
。然后再通过漏洞函数赋值给promise
对象,此时x.ref_count = 1
。最后我们给promise
对象赋新值,那么x.ref_count =0
,所以x
的地址就会被释放掉。但是,我们的r1
仍然指向x
的地址。所以,就存在一个UAF
漏洞。
漏洞利用
V8
里常见的利用思路,是实现类型混淆,通过将同一块内存混淆成不同的类型,那么通过不同的类型就能实现对这块内存的不同操作。
这里,我们的利用思路,总体来说也是类型混淆。然后找到一篇解释很详细的quickJS
的UAF
漏洞的介绍文章:QuickJS uaf 漏洞分析
下面的利用思路,基本就是按照这篇文章实现的。
Double free实现JSString和JSArrayBuffer混淆
首先,构造了一个目标对象:
1 | a = [ |
a
是一个数组对象,a[0]
也是一个数组对象,结构体如下所示,会申请一个 0x51
的堆块来存储这个对象。该结构体最后存储的是value
数据地址。
1 | 667 struct JSObject { |
大概如下所示:
然后,我们将a[0]
赋值给refcopy
,此时a[0]
对象的ref_count
变为2。
1 | console.log("grabbing reference to target"); |
然后,去触发漏洞函数。我们将a[0]
赋值给Promise
对象,此时a[0]
的ref_count
仍然为 2。然后对Promise
对象重新赋值为1,此时a[0]
的ref_count
计数变为 1。
1 | console.log("triggering bug"); |
接下来就是去实现 JSString
和JSObject
类型混淆。
1 | console.log("freeing target twice and overlaping JSString and JSObject"); |
首先将a[0]
重新赋值为数字,那么此时原本的数组对象的ref_count
会变为0
,那么这个对象就会被释放。这里会释放出两个堆块,第一个堆块是 0x51
的JSArrayBuffer
结构体,另一个是存储数据的data
结构体,这里大小取决于我们输入的数据。
接着,将refill_0
赋值为一个字符串,这里输入的字符数为0x37
个。那么此时就会生成JSString
对象,结构体如下:
1 | 384 struct JSString { |
这里JSSting
堆块根据调试前0x10
是 各种变量,从0x10
开始是我们的数据,如下所示:
那么这里refill_0
会申请一个0x37+0x10
的堆块,也就是0x51
的堆块。那么这里就能将我们之前释放的a[0]
对空间取到,这里称为chunk0
,不过此时chunk0
是JSString
对象。
然后再将refcopy = 0
,由于此时refcopy
仍然指向a[0]
数组地址,所以其也会释放a[0]
,也就是又会产生chunk0
的空闲堆块。
然后对refill_1
赋值为与a[0]
相同大小的数组值,那么此时refill_1
也会申请一个0x51
的堆块来存储JSObject
结构体。那么这里就可以将chunk0
再次分配出来,此时对于refill_1
其为JSObject
对象。
总结:我们现在对chunk0
实现了JSString
和JSArrayBuffer
的混淆。那么,后续我们就可以通过refill_0
和refill_1
实现不同的操作。
泄漏地址
接下来,我们就可以利用类型混淆去泄漏地址。
1 | console.log("leaking JSObject data"); |
如果我们输出refill_0
的值,其会输出chunk1+0x10
开始处的数据。而这些地址此时存储的是refill_1
的JSObject
对象地址。我们可以依次输出shape
、prop
和values
地址。
JSArrayBuffer和JSObject混淆
这里最开始的思路是,想前面都已经实现JSString
和JSObject
混淆了,直接通过JSString
去修改JSObject
对象的value
指针,实现一个写。但是这里有一个问题: 修改一个JSString
对象,并不会直接在原堆块上修改数据,而是会重新申请一个结构体堆块。
然后,想的是再构造一次混淆,直接去修改value
指针,这里经过尝试也不行。因为我们的JSString
只能对chunk1+0x10
开始的数据可控,其会将chunk1
的前0x10改为非法,那么后面执行JSObject
时会报错。
所以,这里最后选择JSArrayBuffer
和JSObject
混淆。
1 | console.log("freeing target twice and refilling with two JSObjects"); |
首先将refill_0
重新赋值为一个数组对象,此时会将chunk1
释放掉再申请回来。再将refill_1
重新赋值为一个数组对象,此时会将chunk1
再释放并申请回来。
然后接着构造一个Uint32Array
数组,大小为 0x48,那么会申请一个0x51
的堆块来存储数组内容。再释放掉,这里是我调试时,发现后续对chunk1
利用时,从tcache
取出会出错,所以放了一个0x51
堆块到tcache
占位。
接着,将refill_1 = 0
,此时会释放掉chunk1
。
然后对chunk1
申请Uint32Array
,大小为 0x48,此时就会将我们释放的chunk1
取回来,当作JSArrayBuffer
,即数组的数据存储空间。
那么现在就相当于,chunk1
对于refill_0
是作为JSObject
对象,而对于refill_1
是作为JSArrayBuffer
。那么后续通过修改refil_1
的值就能去修改chunk1
。
这里我们就将其伪造,修改JSObject.value
指针到我们前面泄漏的value+0x2000
处。
为什么修改到这里,我们下面解释。
addressOf
在开头,我们就通过堆喷,创建了一大堆spray
数组。我们上面修改了refill_0
的value
指针后,其是有可能直接指向spray
空间的。
1 | console.log("finding overlap"); |
这里,我们先修改refill_0[0]
的值为0x11223344
。然后通过在spray
中搜索这个值,找到此时refill_0.value
具体指到了spray
哪里overlap_index
。
那么接着就能实现addressOf
函数:
1、 我们先将一个对象赋值给refill_0[0]
,那么此时该对象的地址会存储到refill_0[0]
处;
2、 我们通过读取spray
空间,也就能将该地址输出。
任意读写
1 | console.log("crafting master and slave typed arrays"); |
这里任意读写,主要是利用修改master
和value
两个数组的value
指针来实现。和v8
类似。
劫持控制流
已经实现任意读写后,就是要做劫持控制流。
1 | var libc_base = read64(values) - 0x3ec2b0; |
这里,首先需要泄漏libc
地址。因为我们最开始泄漏了一个value
堆地址,这个堆块是一个大块,会被放到largbin
中,所以读取这个堆块,能够泄漏libc
地址。
然后,直接去覆盖free_hook
。然后,在一个数组中布置值为/bin/sh
。然后去修改该数组的值,就会对这个堆块进行释放。但是这种方法,远程尝试会有问题,因为在执行释放我们的堆块前,程序会释放其他堆块。就会造成执行了一大堆system
,报错。
重点:这里最后卡了一个多小时。发现了一个有趣点,就是quickJS
是会将我们的代码存储到一个堆块中,执行完我们的代码后,会释放这个堆块。那么在exp
的开头处通过注释 写入了|./readflag
。最终成功输出flag
.
1 | //|./readflag |
EXP
1 | //|./readflag |
- 本文作者: A1ex
- 本文链接: http://yoursite.com/2021/09/27/TCTF-final-Promise-JSpwn题解/
- 版权声明: 本博客所有文章除特别声明外,均采用 MIT 许可协议。转载请注明出处!