0x00 前言
写这篇文章的目的主要是希望记录下自己学习的过程,也将自己的学习经历分享给希望学习JS引擎漏洞利用的初学者。
我刚开始学习的是JSC(JavaScriptCore),这是Safari浏览器所使用的内核WebKit的JS引擎。我选择这个目标入手的原因主要是这个比较简单,很多大佬给出的JS引擎的研究难度基本都是:
V8(Chrome)> Spidermonkey(Firefox)> JacaScriptCore(Safari)> Chakra(Edge)
因为Chakra快被微软抛弃了,所以我还是选择了JSC。
0x01 JS引擎的一些基础知识
在学习JS引擎的漏洞利用之前,对JS引擎的一些内部运行原理做一个了解肯定是必要的。当然,也不必把所有原理都了解,由于我是通过调试特定的漏洞来进行学习(大部分初学者都是如此),所以我们只需要了解到与这个漏洞相关的一些知识就足够了。推荐去看saelo在2016年发的paper:Attacking javascript engines
我在下面也引述文章中的部分内容。
JavaScript engine overview
总体来看,JavaScript引擎包括了三个部分:
- 一个基础的编译器,至少需要包含一个JIT(Just-in-time)。
- 一个JavaScript VM,用来运行JavaScript代码。
- 一个JavaScript Runtime,用来提供一些内置Objects和Functions。
我们不需要关心编译器的内部原理,因为跟我们的漏洞利用关系不大(至少看起来关系不大),我们只需要把编译器看作一个 “输入源码,输出字节码”的黑盒就行了。
The VM, Values, and NaN-boxing
VM通常都包含一个可以直接执行字节码的解释器。VM被实现为stack-based
的机器(相对于register-based
来讲),从而可以通过栈来对值进行操作。对于具体的操作码的实现可能看起来和下面一样:
CASE(JSOP_ADD)
{
MutableHandleValue lval = REGS.stackHandleAt(-2);
MutableHandleValue rval = REGS.stackHandleAt(-1);
MutableHandleValue res = REGS.stackHandleAt(-2);
if (!AddOperation(cx, lval, rval, res))
goto error;
REGS.sp--;
}
END_CASE(JSOP_ADD)
这段代码截取自Firefox的JS引擎Spidermonkey,而JSC使用汇编实现的类似功能,看起来就没有上面这么直观。对JSC的实现感兴趣的可以去看这个文件Webkit/Source/JavaScriptCore/llint/LowLevelInterpreter64.asm
。
通常初级JIT(first stage JIT or called baseline JIT)负责减轻一些解释器的调度开销,而高级JIT(higher stage JIT)则会做一些比较复杂的优化操作。有点类似于我们平常所使用的AOT(ahead-of-time)编译器,就比如gcc。优化型JIT(也就是前面提到的高级JIT)通常都是推测型的,意思就是它们会基于一些推测来进行优化,比如它会认为一个变量是而且一直是数字类型。当然这种推测也可能出错,当遇到出错的时候JIT就会回退到推测之前的状态。
JavaScript是动态类型语言,因此类型信息与运行时的变量有关,而不是编译时的变量。JavaScript类型系统定义了几个元类型(number, string, boolean, null, undefined, symbol)和对象(array, function)。需要注意的是JavaScript没有像其他语言一样包含‘类’的概念。取而代之的是JavaScript使用了所谓“基于原型的继承(prototype-based-inheritance)”,每个对象都有一个指向prototype
对象的引用,这个prototype对象包含了指向它的对象的属性。
出于性能考虑(快速拷贝,适应64位的寄存器架构等),所有主流的JavaScript引擎在表示一个Value的时候都不超过八字节。一些JS引擎,比如v8,会使用tagged pointers
来表示值,它会使用最低有效位来标识一个值是指针还是立即数。JSC和Spidermonkey则使用了另一种叫做NaN-boxing
的概念。在NaN-boxing
中使用了多种位模式来表示NaN(Not-a-Number),所以可以将这些位模式来编码其他的值,以下是IEEE 754的规则总结:
形式 | 指数 | 小数部分 |
---|---|---|
零 | 0 | 0 |
非规约形式 | 0 | 大于0小于1 |
规约形式 | 到 | 大于等于1小于2 |
无穷 | 0 | |
NaN | 非0 |
这些多余的位模式足够用来编码整型和指针了,也因为使用了NaN-boxing,对于64位平台而言,目前只有48位用于寻址。
JSC使用的这个方案在Webkit/Source/JavaScriptCore/runtime/JSCJSValue.h
中有很好的解释,引用如下:
/*
...
* The top 16-bits denote the type of the encoded JSValue:
*
* Pointer { 0000:PPPP:PPPP:PPPP
* / 0001:****:****:****
* Double { ...
* FFFE:****:****:****
* Integer { FFFF:0000:IIII:IIII
*
* The scheme we have implemented encodes double precision values by
* performing a 64-bit integer addition of the value 2^48 to the number.
* After this manipulation no encoded double-precision value will begin
* with the pattern 0x0000 or 0xFFFF. Values must be decoded by
* reversing this operation before subsequent floating point operations
* may be performed.
*
* 32-bit signed integers are marked with the 16-bit tag 0xFFFF.
*
* The tag 0x0000 denotes a pointer, or another form of tagged
* immediate. Boolean, null and undefined values are represented by
* specific, invalid pointer values:
*
* False: 0x06
* True: 0x07
* Undefined: 0x0a
* Null: 0x02
*
...
*/
总结:
- Pointer: [0000][xxxx:xxxx:xxxx](前两个字节为0,后六个字节寻址)
- Double: [0001~FFFE][xxxx:xxxx:xxxx]
- Intger: [FFFF][0000:xxxx:xxxx](只有低四个字节表示数字)
- False: [0000:0000:0000:0006]
- True: [0000:0000:0000:0007]
- Undefined: [0000:0000:0000:000a]
- Null: [0000:0000:0000:0002]
有意思的是0x0不是一个合法的JSValue,它会在在引擎中导致崩溃。
Objects and Arrays
JavaScript中的对象实际上就是属性的集合,这些属性都可用(key, value)的键值对来表示。可以使用点(foo.bar)或者方括号(foo[‘bar’])来访问属性。至少在理论上,在使用键来查找值之前都需要先将键转化为字符串的形式。
数组被描述为特殊的(“exotic”)对象,如果属性名称由32位整数来表示的话,这些属性也被称为元素。如今的大多数引擎都将这个概念扩展到了所有对象。然后,数组就是拥有length
属性的特殊对象,它的值始终等于最高元素的索引加一。这些规定的结果就是,每个对象都具有通过字符串或者符号键访问的属性,以及通过整数索引访问的属性。
在内部,JSC将属性和元素存储在同一片内存区域中,并且在对象内部存放指向这块内存的指针。这个指针指向这片内存的中间位置,左边存放对象的属性值,右边存放对象的元素值,而在左边最近的那个内存单元存放了一个header,这个header里包含了length
的值。这样的内存表现形式被称为Butterfly
,在下文我们都将这种内存和指向它的指针都称为Butterfly
,这样会使文章理解起来轻松一些。
--------------------------------------------------------
.. | propY | propX | length | elem0 | elem1 | elem2 | ..
--------------------------------------------------------
^
|
+---------------+
|
+-------------+
| Some Object |
+-------------+
实际上使用Butterfly来存储数据只是一个可选项(Optional),如果对象属性不多(不大于6个)而且不是数组的时候,对象的属性值将不会申请Butterfly,而是存储在对象内部,内存结构如下:
object : objectHeader butterfly(Null)
object+0x10 : prop_1 prop_2
object+0x20 : prop_3 prop_4
object+0x30 : prop_5 prop_6
虽然通常来讲,元素不需要线性地存储在内存中,但特别地:
a = [];
a[0] = 42;
a[10000] = 42;
这段代码可能会导致数组以某种分散的模式来存储,这种模式会根据给定的索引额外在数组的后备存储内存中映射一个索引出来。这样的话,这个数组就不需要请求10001个元素所需要的内存了。除了不同的数组存储模式,数组也拥有不同的表现形式来表示存储的数据。举个例子,一个32位的整型数组可能会以原生形式(native form)存储来避免NaN-boxing的解包和重打包操作,这样也节约了内存。因此JSC也在Webkit/Source/JavaScriptCore/runtime/IndexingType.h
中定义了一组不同的索引类型。最重要的部分有:
ArrayWithInt32 = IsArray | Int32Shape;
ArrayWithDouble = IsArray | DoubleShape;
ArrayWithContiguous = IsArray | ContiguousShape;
第三种存储的是JSValue,前两种存储的都是它们的原生类型。
到这里可能有读者会好奇在这种模式下,对象的属性是如何被索引到的,这点将会在后面深入讨论,简单来讲,有一种被称为structure
的特殊元对象通过给定的属性名,将每个对象的属性映射到对应内存位置。
Functions
Functions在Javascript十分重要,因此它也值得我们对它进行特别的讨论。
当执行一个函数体时,两个特殊的变量将可以访问。一个是arguments
,它提供对参数和调用者的访问,从而使得可以创建具有参数的Function。另一个就是this
,根据Function的调用情况,this
可以指向不同的对象:
- 如果调用的Function作为构造函数( new func(…) ),
this
指向新创建的对象,在Function定义期间,构造函数就已经为新对象设置了.prototype
属性。 - 如果Function作为某个对象的方法被调用(obj.func(…)),
this
将指向这个对象。 - 否则
this
只是指向当前的全局对象,因为它在Function之外使用。
因为Functions是JavaScript中十分重要的对象,它们同样具有属性。根据刚才的描述我们知道了.prototype
属性,另外每个Function(实际上是Function prototype)还有两个比较有趣的属性,.call
和.apply
函数,允许使用给定的this
和一些参数来调用Function。例如,可以使用它们来完成装饰器的功能:
function decorate(func) {
return function() {
for (var i = 0; i < arguments.length; i++) {
// do something with arguments[i]
}
return func.apply(this, arguments);
};
}
这同样也对Function在JS引擎的内部实现有一定的影响,这个导致了Function不能假定调用它们的对象的类型,因为这些对象可能是任意类型,因此所有的JavaScript内部Funciton不仅要检查参数的类型,也要对this
对象进行类型检查。
在内部,内置的函数或者方法通常以两种方法实现,C++的Native Function或者是JavaScript Function。可以看一个JSC中的Native Function示例,Math.pow()
的实现:
EncodedJSValue JSC_HOST_CALL mathProtoFuncPow(ExecState* exec){
// ECMA 15.8.2.1.13
double arg = exec->argument(0).toNumber(exec);
double arg2 = exec->argument(1).toNumber(exec);
return JSValue::encode(JSValue(operationMathPow(arg, arg2)));
}
我们可以看到:
- Native JavaScript Function的签名。
- 如何使用
argument
提取参数(如果参数不够就返回undefine)。 - 如何将参数转化为需要的类型,有一组特定的转换规则,这一点将在之后详细讨论。
- 如何对本地数据类型进行实际操作。
- 如何将结果返回给调用者,这里简单地将结果编码为了JSValue。
这里还有一个显而易见的部分,各种核心的操作(operationMathPow(arg, arg2)
)都是用单独的函数,这样它们可以直接被JIT编译了的代码调用。
The Structures
这个部分属于JavaScript对象模型的内容,关系到JavaScript引擎如何去访问对象的属性。因为访问属性在JavaScript中是一个十分频繁的操作,为了提高访问速度,每个主流的JavaScript引擎都对此做了优化,在不同的引擎中对这种技术的称呼也各不相同:
- 在学术论文中被称为 Hidden Classes
- 在V8中是 Maps
- 在Chakra中是 Types
- 在JavaScriptCore中是 Structures
- 在Spidermonkey中是 Shapes
根据ECMAScript的规范,所有JavaScript对象都被定义为一个由字符串键值映射到属性值的一个字典。引用MDN的描述:
An object is a collection of properties, and a property is an association between a name (or key) and a value.
既然如此,作为键值的字符串如果存储在对象的内存中将会十分浪费空间,因为这样的话每生成一个对象就出多出一份键值的拷贝。而在JavaScript中,多个对象具有相同的属性是经常发生的事情,从某个方面来讲,这些对象都具有相同的形状(Shapes),也可以说具有相同的结构(Structure)。比如:
const object1 = { x: 1, y: 2 };
const object2 = { x: 3, y: 4 };
object1
和object2
虽然是两个不同的对象,但是他们的键值都是一样的。这种情况下它们就具有相同的结构,在JavaScriptCore中也能看到它们具有相同的StructureID。
>>> describe(object1)
Object: 0x106ab0100 with butterfly 0x0 (Structure 0x106a500e0:[Object, {x:0, y:1}, NonArray, Proto:0x106ab4000, Leaf]), StructureID: 289
>>> describe(object2)
Object: 0x106ab0140 with butterfly 0x0 (Structure 0x106a500e0:[Object, {x:0, y:1}, NonArray, Proto:0x106ab4000, Leaf]), StructureID: 289
>>>
如果我们要访问对象的属性,JSC就会先根据StructureID找到对应的Structure,然后找到对应的属性名,读取属性在内联存储或者是butterfly中的偏移值,最后读取属性值。
如果此时给object2
增加一个属性z
,JavaScriptCore就会在Structure链中寻找有没有只拥有x,y,z
三个属性的Structure,如果没有则重新创建一个并分配一个新的StructureID。
>>> objectY.z = 0
0
>>> describe(objectY)
Object: 0x106ab0140 with butterfly 0x0 (Structure 0x106a50150:[Object, {x:0, y:1, z:2}, NonArray, Proto:0x106ab4000, Leaf]), StructureID: 290
>>>
Just-In-Time compiler
前面提到过JIT,但没有细说。其实JIT也是一个编译器,可以简单理解为和gcc一样的编译器,不过JS引擎中的JIT是将JavaScript代码编译成了机器码。JIT在JSC中一共分为四个等级:
- LLInt (LowLevelInterpreter)
- Baseline JIT compiler
- DFG JIT
- FTL JIT
LLInt
llint是JavaScriptCore虚拟机的基础组件,逻辑非常简单,可以理解为一个switch循环,传入对应的JavaScript机器码,然后执行对应的指令。
Baseline JIT compiler
当一个function被多次调用之后,它就会变得”hot”,这时候就需要使用JIT compiler对它进行优化。在Source/JavaScriptCore/jit/JIT.cpp
中:
// We can only do this optimization because we execute ProgramCodeBlock’s exactly once.
// This optimization would be invalid otherwise. When the LLInt determines it wants to*
// do OSR entry into the baseline JIT in a loop, it will pass in the bytecode offset it
// was executing at when it kicked off our compilation. We only need to compile code for
// anything reachable from that bytecode offset.
当function需要进一步优化的时候,JSC就会通过OSR(On Stack Replacement )从LLInt切换到Baseline JIT。
DFG JIT
引用WebKit官方文档WebKit JIT中的一段话:
The first execution of any function always starts in the interpreter tier. As soon as any statement in the function executes more than 100 times, or the function is called more than 6 times (whichever comes first), execution is diverted into code compiled by the Baseline JIT. This eliminates some of the interpreter’s overhead but lacks any serious compiler optimizations. Once any statement executes more than 1000 times in Baseline code, or the Baseline function is invoked more than 66 times, we divert execution again to the DFG JIT.
和前面从LLInt切换到Baseline JIT的条件类似,如果一个函数在Baseline JIT中执行次数过多,又会切换到DFG JIT中。
从文档中还可以看到一个关于DFG的细节:
The DFG starts by converting bytecode into the DFG CPS form, which reveals data flow relationships between variables and temporaries. Then profiling information is used to infer guesses about types, and those guesses are used to insert a minimal set of type checks. Traditional compiler optimizations follow. The compiler finishes by generating machine code directly from the DFG CPS form.
DFG会根据搜集到的信息去推测变量的类型,如果认定了一个变量的类型,在之后将不会对变量类型进行检查,这个对我们之后的利用会很有帮助。
FTL JIT
We reuse most of the DFG phases including its CPS-based optimizations. The new FTL pipeline is a drop-in replacement for the third-tier DFG backend. It involves additional JavaScript-aware optimizations over DFG SSA form, followed by a phase that lowers DFG IR (intermediate representation) to LLVM IR. We then invoke LLVM’s optimization pipeline and LLVM’s MCJIT backend to generate machine code.
其实FTL相对于其他三个JIT算是新加入的一个技术,设计它的目的是想让JavsScript的运行更加接近C的速度,事实证明确实非常接近了。值得一提的是FTL重用了DFG的一些部分,包括类型推理引擎。
0x02 搭建调试环境
官方文档:Building Webkit
我使用的系统是MacOS,不过WebKit同样可以在其他系统(Windows、Linux)编译运行,我看到大多数人会选择在Ubuntu 18.04上编译,不过我没编译成功过,不知道什么原因。我直接引用别人在Ubuntu 18.04的编译命令:
# sudo apt install libicu-dev python ruby bison flex cmake build-essential ninja-build git gperf
$ git clone git://git.webkit.org/WebKit.git && cd WebKit
$ Tools/gtk/install-dependencies
$ Tools/Scripts/build-webkit --jsc-only --debug
$ cd WebKitBuild/Debug
$ LD_LIBRARY_PATH=./lib bin/jsc
我说一下在MacOS的build流程。首先确保安装了Xcode和Xcode的命令行工具。
#我的系统版本 macOS Mojave
$ sw_vers
ProductName: Mac OS X
ProductVersion: 10.14.6
BuildVersion: 18G84
$ git clone git://git.webkit.org/WebKit.git WebKit
#这个说明已经安装好了
$ xcode-select --install
xcode-select: error: command line tools are already installed, use "Software Update" to install updates
#确定Xcode路径是否正确
$ xcode-select -p
/Applications/Xcode.app/Contents/Developer
#Xcode路径和上面不一样的,可以是用如下命令切换
$ sudo xcode-select --s /Applications/Xcode.app/Contents/Developer
$ xcodebuild -version
Xcode 10.3
Build version 10G8
#编译之前确定切换到漏洞分支
$ git checkout ...
#到WebKit根目录下执行这个指令就可以了
$ Tools/Scripts/build-webkit --jsc-only --debug
我可能运气比较好,到这里都没出现过什么问题。直接可以运行JSC的REPL(**Read Eval Print L**oop)。
$ WebKitBuild/Debug/bin/jsc
>>> a = 1
1
>>>
如果遇到DYLD_FRAMEWORK_PATH
路径的问题可以手动设置一下环境变量:
#补全为绝对路径就行了
export DYLD_FRAMEWORK_PATH=.../WebKitBuild/Debug
调试器我用的lldb,对比gdb的指令可以很快上手:GDB to LLDB command map
0x03 开始调试
可以直接用lldb载入jsc:
$ lldb ./WebKitBuild/Debug/bin/jsc
(lldb) target create "./WebKitBuild/Debug/bin/jsc"
Current executable set to './WebKitBuild/Debug/bin/jsc' (x86_64).
(lldb) run
Process 39132 launched: '/Users/7o8v/Documents/SecResearch/Browser/WebKit/WebKit.git/WebKitBuild/Debug/bin/jsc' (x86_64)
>>>
调试过程中可以配合JSC提供的调试函数进行调试,比如describe()
:
>>> a = [1,2,3]
1,2,3
>>> describe(a)
Object: 0x108ab4340 with butterfly 0x8000e4008 (Structure 0x108af2a00:[Array, {}, ArrayWithInt32, Proto:0x108ac80a0, Leaf]), StructureID: 97
>>> a[3] = 1.1
1.1
>>> describe(a)
Object: 0x108ab4340 with butterfly 0x8000e4008 (Structure 0x108af2a70:[Array, {}, ArrayWithDouble, Proto:0x108ac80a0, Leaf]), StructureID: 98
>>> a[4] = {}
[object Object]
>>> describe(a)
Object: 0x108ab4340 with butterfly 0x8000e4008 (Structure 0x108af2ae0:[Array, {}, ArrayWithContiguous, Proto:0x108ac80a0]), StructureID: 99
>>>
更多的调试技巧可以看这篇文章:WebKit JavaScriptCore的特殊调试技巧
过程中也可以使用Ctrl+C
中断,然后使用lldb命令。
也可以载入JSC之后,运行脚本文件:
$ lldb ./WebKitBuild/Debug/bin/jsc
(lldb) target create "./WebKitBuild/Debug/bin/jsc"
Current executable set to './WebKitBuild/Debug/bin/jsc' (x86_64).
(lldb) run -i ./poc.js
Process 39152 launched: ...
>>>
0x04 Reference
[1] https://github.com/m1ghtym0/browser-pwn#safari-webkit
[2] https://webkit.org/blog/6756/es6-feature-complete/
[3] https://mathiasbynens.be/notes/shapes-ics
[4] https://developer.mozilla.org/en-US/docs/Web/JavaScript/Guide/Working_with_Objects
[5] https://webkit.org/blog/3362/introducing-the-webkit-ftl-jit/
[6] http://phrack.org/papers/attacking_javascript_engines.html
[8] https://webkit.org/blog/7846/concurrent-javascript-it-can-work/