有了之前Windows-Pwn-First-Blood的基础,趁热打铁把之前存下来介绍JS engine exploitation的文章给一起学习了。
Introduction to SpiderMonkey exploitation.
SpiderMonkey也就是Firefox使用的JavaScript引擎,之前在CTF比赛中也碰到过几次。力荐这篇文章,作者写得非常用心,可以说是手把手教了,从js引擎的编译到windbg的使用,其中对于spidermonkey数据的存储形式(JSValue)以及对象的结构(JSObjects)讲得非常详细。更加难得的是作者把原本在Linux上面的Blaze CTF题目迁移到Windows 10 RS5上面来练习,循序渐进给出了三个不同程度的exploit,借助一道题目发散思维学习,真正把题目利用到了极致。
有了@0vercl0k这篇教科书式的文章,自不敢画蛇添足,以下仅对学习过程中的一些思考作下记录。
TDD
作者提到在debug时候可以使用Windbg Preview提供的Time Travel Debugging(TDD)功能。
在Win10的应用商店里面下载Windbg Preview以后用管理员权限打开就能够选择TDD调试。
其实就跟linux下面的qira很相似,在运行的时候把每条指令的运行结果都保存起来,调试的时候就可以自由地选择向前执行和向后执行。控制执行的命令也非常简单,比如
1 | p: 步过下一条指令; p-: 步过上一条指令 |
直观明了,想要往前翻加一个减号就行了。
断点与传参方式
在调试的时候有个绕不开的问题就是观察某个对象在内存中的布局。常见的做法是选择一个比较少用到的函数下断点(如js!js::math_atan2
),把需要观察的对象作为参数传递给该函数。
1 | const A = 0x1337; |
然而如果到SpiderMonkey源码中观察js!js::math_atan2
的实现,就会觉得一头雾水。这也是我在读这篇文章之前调试js引擎的时候觉得无从下手的地方。
1 | bool |
从C++代码里面根本看不出传递给js::math_atan2
的参数放在什么地方,作者给出的解释是函数签名中的第三个参数Value *vp
会存放所有的参数,函数的内部会用JS::CallArgs
的方法来处理vp
进行参数的提取等操作。Javascript代码中的参数会存放在vp[2]开始的内存中。而断点在js::math_atan2
函数的时候,vp作为第三个参数按照Windows的函数调用规则会位于r8寄存器,所以通过下面的命令就可以找到参数0x1337。
1 | 0:000> dqs @r8 l3 |
至于这里为什么0x1337
会表示为fff8800000001337
,简单说就是JS::Value
中把一个8字节分为两部分来存放数据。高位的17个bits作为JSVAL_TAG
用来表示数据的类型,低位的47bits作为payload_
,会用来存放具体值(比如这里的0x1337)或者是一个指向JSObject
的指针。原文中有详细的分析讲解。
Vulnerability and Exploit idea
题目设计了一个新的方法blaze()
,Array
对象调用这个方法后长度被该为420,是一个明显的Out-Of-Bound(OOB)漏洞。
1 | js> blz = [] |
利用的思路也非常直接,在长度被改写的Array
后面放置一个Uint8Array
,需要特别留意Uint8Array
对象的结构,因为其继承关系比较复杂:Uint8Array
-> js::TypedArrayObject
-> js::ArrayBufferViewObject
-> js::NativeObject
。我尝试直接用dt
命令来找ArrayBufferViewObject
相关变量的偏移地址,但是或出现成员变量缺失的情况:
1 | 0:000> dt js!js::ArrayBufferViewObject |
而作者采用的是直接读源码的方法,先找到源代码中ArrayBufferViewObject
的实现:
1 | class ArrayBufferViewObject : public NativeObject |
然后结合NativeObject
的结构信息来计算DATA_SLOT
等重要成员变量的偏移:
1 | 0:000> ?? vp[2] |
能够确定DATA_SLOT
相对于blz数组(OOB攻击对象)的偏移以后就好办了,此时只要改写DATA_SLOT
为我们想要读写的地址,再结合LENGTH_SLOT
就可以实现通过Uint8Array
来进行任意地址读写操作了。因为往blz数组写值的时候实际上写入的是js::Value
,作者继续介绍了内存映射地址和double型的js::Value
的对应关系。
最后作者很贴心地给出了一幅图来说明这个利用的思路。
在大多数Exp中,有了任意地址读写原语就基本上是为所欲为了,最直接的方法就是通过扫面内存的方式来找到攻击对象,改写某些函数指针来达成劫持控制流的目的。作者这里则是构造了一中更加实用的原语——对象地址泄露原语(object address leak primitive)。具体做法是把想要泄露的对象指针直接写到Uint8Array
inline buffer的位置,然后通过访问Uint8Array
头8个元素来读取对象的地址。
1 | js> c = new Uint8Array(8) |
Hijacking Control-Flow
就像上文所说的,有了任意读写地址漏洞以后,可以通过往栈的返回地址写ROP chain的方式,或者劫持Virtual-Table,甚至是通过劫持JIT函数的方式来劫持控制流。作者这里采用了直接改写对象方法函数指针的技巧,来自How to kill a (Fire)fox - en。
简单说起来就是上文用到Uint8Array
的addProperty
函数指针改写成gadget地址。但是由于js对象复杂的继承关系,这件事情做起来会比较绕。步骤如下:
Uint8Array
继承自js::NativeObject
,其第一个成员变量为group_
,是指向js::ObjectGroup
对象的指针。1
2
3
4
50:000> dt js::NativeObject 0x016c8d201cc0
+0x000 group_ : js::GCPtr<js::ObjectGroup *> <--
+0x008 shapeOrExpando_ : 0x0000016c`8daac970 Void
+0x010 slots_ : (null)
+0x018 elements_ : 0x00007ff7`f7ecdac0 js::HeapSlotjs::ObjectGroup
的第一个成员变量为clasp_
,是指向js::Class
对象的指针。1
2
3
4
5
6
70:000> dt js!js::ObjectGroup 0x0000016c`8da7ad30
+0x000 clasp_ : 0x00007ff7`f7edc510 js::Class <==
+0x008 proto_ : js::GCPtr<js::TaggedProto>
+0x010 realm_ : 0x0000016c`8d92a800 JS::Realm
+0x018 flags_ : 1
+0x020 addendum_ : (null)
+0x028 propertySet : (null)js::Class
的第三个成员变量是cOps
,是指向js::ClassOps
对象的指针。1
2
3
4
5
6
70:000> dt js!js::Class 0x00007ff7`f7edc510
+0x000 name : 0x00007ff7`f7f8e0e8 "Uint8Array"
+0x008 flags : 0x65200303
+0x010 cOps : 0x00007ff7`f7edc690 js::ClassOps <==
+0x018 spec : 0x00007ff7`f7edc730 js::ClassSpec
+0x020 ext : 0x00007ff7`f7edc930 js::ClassExtension
+0x028 oOps : (null)终于看到希望了,观察
js::ClassOps
就可以看到addProperty
的指针了。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
210:000> dt js!js::ClassOps 0x00007ff7`f7edc690
+0x000 addProperty : (null) <--
+0x008 delProperty : (null)
+0x010 enumerate : (null)
+0x018 newEnumerate : (null)
+0x020 resolve : (null)
+0x028 mayResolve : (null)
+0x030 finalize : 0x00007ff7`f7961000 void js!js::TypedArrayObject::finalize+0
+0x038 call : (null)
+0x040 hasInstance : (null)
+0x048 construct : (null)
+0x050 trace : 0x00007ff7`f780a330 void js!js::ArrayBufferViewObject::trace+0
0:000> !address 0x00007ff7`f7edc690
Usage: Image
Base Address: 00007ff7`f7e9a000
End Address: 00007ff7`f7fd4000
Region Size: 00000000`0013a000 ( 1.227 MB)
State: 00001000 MEM_COMMIT
Protect: 00000002 PAGE_READONLY
Type: 01000000 MEM_IMAGE
很可惜这个地址段是只读的,我们要往前找到一个可以改写的指针,最早能被改写的是js::ObjectGroup
的clasp_
,让其指向一个伪造的js::Class
,最终导致c0ps.addProperty
的指针能被我们随意操控。
Stack Pivot
接下来作者以一种神奇的方式在ntdll当中找到一个合适的ROP gadget,能够把栈转移到Uint8Array
上面。个人觉得找gadget是非常痛苦的工作,尤其在ntdll这么庞大的文件中找到一个如此隐蔽的gadget实在是非常不容易。
1 | 0:000> u ntdll+000bfda2 l10 |
实际作用的语句:
1 | 00007fff`b8c4fda3 ff33 push qword ptr [rbx] |
作者在这里也没说到底是怎样找到这样一个恰好合适的gadget,所以这里也不多赘述。接下来就是通过这个神奇的gadget把栈pivot到Uint8Array上,而Uint8Array上放好了一个调用kernel32!VirtualProtect
来打开Shellcode所在页执行权限的gadget。而这里用于弹计算器的Shellcode使用Binary Ninja的Shellcode Compiler来生成的。(我正好剁手买了BInary Ninja的个人版,感觉还是挺不错的)
至此,首个能弹计算器的Exp已经完成了,通过这个例子可以了解到针对这种js引擎的OOB漏洞利用方法的流程。其实之前比赛碰到的js引擎漏洞好像都是类似的OOB类型的,以后至少知道大概的方向了。
JIT & Bring Your Own Gadgets (BYOG)
在最基础的攻击Exploit(basic.js)完成以后,作者总结除了几点不足:
- 偏移地址的硬编码。最好当然是能够动态解析出各个函数的偏移地址。
- 神来之笔般的stack pivot gadget。其实这也算不上什么缺点,只是复现起来比较困难而且不够可靠。
- 攻击完成后Shell不能继续执行。
第一个问题纯粹就是对PE文件结构解析的问题,可以类比于对ELF文件的GOT表做解析获取函数位置。比较有意思的是对第二个问题的解决方案,这里提到了一种借助JIT引擎的特性把gadget带入到可执行段内存映射当中的技巧(Bring Your Own Gadget)。
既然JIT的目的是把需要多次执行的JavaScript代码编译成asm来提高执行效率,那么这里其实隐藏着两个要素:
- JavaScript代码和asm代码之间必然存在一种对应关系
- 生成的asm代码肯定是放在可执行段的
因此我们要做的事情就很明确了:
- 找到JavaScript代码和asm之间的对应关系(并不用建立完全的模型,只需迫使JIT引擎生成要用到gadget的字节码)
- 找到生成字节码所在的地址
有了我们可以随意生成且位于可执行段的字节码,我们就可以用它来代替上文神来之笔的pivot gadget,节省掉了找gadget的时间之余也大大提高了exp的可靠性。
作者这里的思路是通过浮点数来构造任意字节码,首先定义了一个8byte序列转浮点数的函数:
1 | function b2f(A) { |
调用把0xdeadbeefbaadc0de转换为浮点数的效果
1 | js> b2f([0xde, 0xc0, 0xad, 0xba, 0xef, 0xbe, 0xad, 0xde]) |
接着把浮点数写进一个JavaScript函数,并通过多次调用来使JIT引擎转换其为字节码(“变热”)。
1 | const BringYourOwnGadgets = function () { |
接下来就是通过JSFunction
的jitInfo_
字段来找到jit字节码的所在页。
1 | 0:005> g |
在JSJitInfo
第一个成员变量的位置就找到了字节码所在页,权限为可读可执行。
1 | 0:000> dt JSJitInfo 0x000001b8`2ff93420 |
在所在页往下翻的位置就可以看到我们想要的0xdeadbeefbaadc0de字节码了。
1 | 0:000> u 000003ed`90971c18 l200 |
总结思路起来就是先写好想要使用的gadget,控制好每个在8字节以内(少于8字节用nop补,多于8字节拆分后用short jmp连接)。然后用b2f
转换成浮点数,将浮点数写进js函数然后通过多次调用使函数“变热”生成字节码。
这里还有一个小细节,在通过JSFunction函数对象顺藤摸瓜找到jit字节码所在页以后,还需要通过扫描内存的方法来搜索gadget的具体位置,所以作者在gadget之前放置一个magic number来确定gadget的开头
1 | const Magic = '0vercl0k'.split('').map(c => c.charCodeAt(0)); |
看到这里觉得利用JIT来生成所需字节码的BYOG技巧还是相当有意思的。在后续的第三个exp中,作者更进一步,干脆想直接让JIT引擎生成整段能够弹计算器的shellcode。但无疑这种方法需要耗费大量的时间来构造合适的代码,看得出来作者在研究到此处的时候已经十分蛋疼,目前来说这种方法通用性还不算强,感觉还是通过简单的BYOG加上调用库函数的方法来的更加可靠。