关于afl-fuzz的原理和各种基本使用方法,已经有各位机智的网友写得比较完整了。本文的主要内容是,根据afl的一些特性,进行针对性的部署以提高fuzz的效率。虽然afl-fuzz是一个开箱即用的工具,但直接部署使用基本上是无法获得漏洞产出的。
afl-fuzz在运行时的一个典型命令,形如:
1 | afl-fuzz -i in -o out -m none -t 2000 -- ffmpeg -i @@ |
给afl-fuzz指定参数时,@@在运行时实际被替换成out/.cur_input
也就是说每次执行的是:
1 | ffmpeg -i out/.cur_input |
可以看到这个文件在out目录下被不停地写入、删除,才支撑了afl-fuzz不停的变异尝试。在CPU、内存、硬盘中,硬盘运行速度是最慢的,因此在大多数情况下硬盘的读写速度才是afl-fuzz运行的瓶颈。
为了加快速度,可以把afl-fuzz的工作目录、可执行文件,放入/dev/shm,使用Linux的共享内存运行。这样类似RamDisk的操作把硬盘读写操作变成了内存读写操作。在一般的情况下,fuzz速度可以有二倍的提升。
但这个解决方案有一点局限性:
关闭Linux的图形化操作界面虽然不能提升fuzz运行速度,但能够显著增强操作系统运行的稳定性(毕竟Linux的图形化界面)。
关闭图形化界面的方法,例如Ubuntu 14/16:
1 | sudo service lightdm stop |
在原始界面可以使用Control + Shift + F1、Control + Shift + F2、Control + Shift + F3等切换tty,获得不同的bash shell。在原始界面最多可以有6个tty供使用,因而在CPU数量足够的时候可以显式跑6个afl-fuzz的实例。
afl-fuzz的变异策略包括确定性策略(deterministic)和随机性策略(random),afl界面的左下角标明了不同策略下、不同阶段中的fuzz产出:
随机性策略仅包括havoc和splice,而确定性策略包括:
确定性策略的含义如下:对于一个文件,未来所有可能发生的变异都是已经确定了的,均为字长+步长的形式。例如flip是进行0、1的翻转操作,arithmetic是对字节进行数学运算操作,dictionary是对一些token字符串进行插入或覆盖操作。
既然是字长+步长的形式,文件的大小就极为重要。假如有一个1MB大小的样本文件,总共有1*10^6*8个bit,那么第一步bit flip 1/1,字长为1、步长为1,那么每次翻转一个bit,从前往后所有bit依次翻转,总共需要1*10^6*8次,即使目标程序每秒钟能够进行100次,仍然需要超过20个小时才能遍历完成这第一个文件的第一步变异,bitflip 2/1时则又需要10个小时…这样的fuzz相当于什么都没有fuzz,效率极低。这就是为什么afl的官方文档中要求每个文件最大不要超过10KB,而最好的情况下,样本文件大小应该小于1KB。样本文件大小非常非常重要,甚至可能是使用afl-fuzz不出结果的最重要原因之一。
另外,deterministic模式的变异在整个afl-fuzz的变异中占用了大多数的时间,而实践表明在无法找到更多样本的情况下,random策略可以更快地发现更多路径,并一定程度上弥补样本质量问题。
另外是右上角的cycles done表明了所有文件在deterministc策略下fuzz的完成数。由于deterministic策略下各文件的变异都是确定的,理论上不应该让cycles done这个值大于1。
为了解决上述问题,同时保持样本的复杂度,afl-fuzz内置了一个叫做tmin的工具。使用afl-tmin的命令与afl-fuzz是相似的,但-i -o对应的参数是文件名,而不是文件夹名:
1 | $ afl-tmin -i 2 -o 2.min -- ~/parse |
tmin的原理也是基于字长+步长的形式,逐字节删除,然后通过插装反馈得出样本改变是否导致了程序运行路径发生了变化。若没有发生变化,可以认为删去的字节是冗余的,因此可以用来对单个样本精简。
由于tmin基于字长+步长的形式,它消耗的时间也是很多很多的,所以需要写一个脚本进行批量化。也有并行化tmin的第三方工具ptmin,它将afl-tmin并行化到一定数量的进程中,并证明在精简样本过程中显著地提升了速度:
1 |
|
为了增加覆盖率,样本需要有一定的多样性。人工增加样本多样性的方法中,最简单且明显的就是搜集下载样本,放进输入文件夹。这个过程是对样本进行丰富的过程,它非常重要,但这个过程也常常引入样本的冗余,降低fuzz的效率。为了解决这个问题,需要从大量的样本中筛掉无用的样本。
afl-cmin的命令与afl-fuzz、tmin也是相似的,但与tmin对比来看,tmin操作的是单个文件,输出单个文件;cmin操作的是文件集合,输出的也是文件集合。
曾经有过一些案例,辛苦找了200个文件、进行过tmin之后放入afl-fuzz的输入目录进行fuzz,对输入目录进行cmin后发现这些文件导致的程序执行路径是一样的。可以简单计算:如果不进行cmin,对这200个文件进行fuzz的结果相当于对1个文件进行fuzz200次,非常低效。
cmin也是成功使用afl-fuzz中必不可少的一步。
在afl-fuzz的工作目录中有一个queue文件夹,这个文件夹内部的每个文件代表了afl-fuzz在模糊测试过程中获得的所有路径,每条路径不重复并且每条新路径都被修剪(trim)过。根据前两点针对样本复杂度和样本多样性的阐述,显然可以知道这些文件是用afl-fuzz运行时间换来的除crash之外的最重要产出。这些文件应该被收集起来,在fuzz同样的目标或者类似目标的时候可以并且应该被放入初始输入文件夹。
使用这样的方法,本质上是借助历史积累消除afl-fuzz中路径发现过程的不确定性,把握更多确定性的样本,从而提高覆盖率。
在smb://192.168.69.115/RESEARCH1-Internal中,我放入了很多fuzz过程中生成、收集的样本,数量超过20000,后续也会不断更新:
afl-fuzz的一个实例仅使用一个CPU。使用多个CPU的时候可以使用afl-fuzz的并行模式加快对样本的处理进度。但随着CPU数量线性增加,afl-fuzz的处理速度并不是线性增长的。
afl-fuzz内置的并行模式使用-M、-S来指定,主、从fuzzer使用简单的文件夹分离、读文件的方法来实现同步。例如:
1 | afl-fuzz -i in -o out -m none -t 2000 -M master -- ffmpeg -i @@ |
afl-fuzz的代码中规定master fuzzer使用deterministic fuzz,只能有一个实例;slave fuzzer使用random fuzz(dirty模式),可以有多个实例。上述命令将会在out文件夹建立master、slave1、slave2、slave3三个文件夹,四个 fuzzer各使用一个CPU。在fuzz过程中,各 fuzzer在空闲时可以读取其他文件夹中的新文件,然后对自己的queue文件进行同步和更新。
个人认为这并不是一个很理想的解决方案,因为这样的条件下,运行前期master fuzzer的deterministic进程太慢,而slave随机产生新的样本后,master进程的deterministic变异进程总是来不及处理;而各slave进程重复概率大,需要的同步开销过大,导致afl-fuzz的处理速度并不是线性增长。
为了改善上述问题,afl官方文档提到代码实现了一种试验性功能的支持:
1 | there is anexperimental support for parallelizing the deterministic checks. To leveragethat, you need to create -M instances like so: |
这种试验性功能的思想是把deterministic fuzz分为不同的进程来进行。但是从实际代码来看,afl并没有实现这一功能,而仅仅解析了master_id。这里非常容易误导用户。
借鉴这个思想可以简单地实现这个功能。代码diff可以参考gitlab/wangxin/afl-wasm-d4/。更改的代码位于fuzz_one()函数,伪代码形如:
1 | if (master_id == 1){ |
通过master_id对deterministic的阶段进行划分,依据各阶段步长可以很容易计算出来划分依据。一个比较合理的划分可以让afl的进程分别执行以下过程:
1 | //master1 |
在这样的划分下,各master进程可以很快完成deterministic fuzz,配合少量的slave进程应该可以把fuzz效率在短时间内提升到较高。
另外,注意有一部分代码需要在if之外,所有的进程都需要处理这段代码:
1 | if (eff_cnt != EFF_ALEN(len) && |
对afl-fuzz的变异器加层是一种扩展方法,并不是用于提升afl-fuzz常规运行效率。
afl-fuzz适合基于字节粒度变异的fuzz,但并不是所有目标都可以直接进行字节粒度的fuzz。有些是因为文件解析部分的代码不能单独抽出来,有些是因为文件解析仅仅是逻辑的开始。那么为变异器加层就是在这方面扩展afl-fuzz的最简单方法。
一个示例的代码可以参考gitlab/wangxin/afl-wasm/,这是一个可以为WebAssembly的wasm文件变异加层的fuzzer。这个工具产生的样本如下所示,其中变异的部分是wasmCode中的字节码,而外围的JavaScript代码需要手动编写,而且要求外围代码应该尽量复杂。
1 | var importObject = { |
为了实现这一加层,需要修改的地方是afl-fuzz.c的write_to_testcase()函数,大意是把变异的字节序列封装成字符串,并加上前后缀字符串写入文件:
1 | static void write_to_testcase(void* mem, u32 len) { |
借助这样的加层,afl-fuzz可以有效地对WebKit等浏览器的JavaScript引擎的WebAssembly模块进行fuzz。
综上所述,使用afl-fuzz的高效的工作流实践并不复杂,完全是针对afl内部运行原理的直观反应。由于各阶段的耗时特点,可以总结出来高效的工作流:
(1)单例:
搜集样本 –> cmin –> 部分tmin –> dirty模式单例fuzz –> cmin –> 单例fuzz –> 回收queue
queue –> cmin –> 部分tmin –> dirty模式单例fuzz –> cmin–> 单例fuzz –> 回收queue
(2)并行:
搜集样本 –> cmin –> 部分tmin –> 并行fuzz –> 回收queue
queue –> cmin –> 部分tmin –> 并行fuzz –> 回收queue
本文围绕afl-fuzz暴力效率流,主要讲的是如何利用现有的afl-fuzz挖出漏洞。此外afl-fuzz本身还有很多可以改进的点,可以称之为”精细流”。例如CollAFL提出两点改进:
相关内容可参考:
http://www.cnetsec.com/article/26263.html
http://chao.100871.net/papers/oakland18.pdf
此外,围绕afl-fuzz,还有很多衍生fuzzer和衍生工具,其中一部分被记录在了afl-fuzz官方文档的doc/sister_projects.txt下。比较有名的衍生fuzzer包括afl-go、WinAFL、afl-cov、kafl、android-afl等。
]]>(1)直接编译All Source,然后切换schema到jsc运行 。
(2)不要使用parrellize build 。
(3)run exit with xx、file not found等问题可以通过重试来解决 。
(4)放置老版本SDK进入目录 ▸ 应用程序 ▸ Xcode.app ▸ Contents ▸ Developer ▸ Platforms ▸ MacOSX.platform ▸ Developer ▸ SDKs,重启XCode。
修改的参数为:
Base SDK
macOS Deployment Target
修改的目标有PAL、WebCore、WebKit、WebKitLegacy:
(5)对较老的版本使用legacy build system:例如2019年在MacOS 10.14上使用XCode 10编译2018年1月30日版本时,就需要切换到legacy build system。
(6)Treat warnings as errors设置为否。
另:用新的编译器、操作系统编译老板本时,更多的问题是WebCore包含的多媒体部分和其他内容,它们引用了很多macOS SDK,就是因为它们JSC才编译不过。JSC本身不需要太多的依赖,奈何需要有一个WebCore的动态库,所以构建All Soource是一种简单粗暴的可行方法。
老版本SDK的下载地址:
]]>jsc作为WebKit的JavaScriptCore的一个独立的可执行实体,其内置了一些特殊函数来帮助调试。这些函数位于JavaScriptCore/jsc.cpp,在finishCreation函数中通过addFunction函数注册:
1 | addFunction(vm, "debug", functionDebug, 1); |
这些函数不是ECMAScript标准涉及的函数,一般的JavaScript引擎实现中也不包含这些函数。可以认为这些函数是为了方便开发者而置入的。例如print函数用来打印变量到控制台,在nodejs等环境中对应console.log。这些函数很有用处,可以大大简化调试。例如函数describe、describeArray可以透露很多信息:
1 | let split = '------'; |
得到如下内容,从中甚至可以看到对象的transition变化:
1 | Object: 0x62d0000a4340 with butterfly 0x62d0001a8010 (Structure 0x62d00000ec30:[Array, {}, CopyOnWriteArrayWithInt32, Proto:0x62d00007c0a0, Leaf]), StructureID: 102 |
也可以获得JS的执行栈:
1 | function f(){ |
得到:
1 | --> Stack trace: |
另外,noDFG、noFTL等函数规定了特定js函数在JIT时的特定行为;gc、fullGC、edenGC可以控制垃圾回收等。由于依靠这些函数的漏洞触发不会被承认,所以这些函数看起来比较鸡肋,还是要手动触发jit、gc比较符合常理。因此这些函数在jsc中的高阶用法在这里不再讨论。
阅读这些函数可以理解很多基本类型的用法和关系,例如JSValue、jsNumber、jsString、Butterfly、Array、WTF::Vector。
类似于IE漏洞分析中的tan等函数,Array.prototype.slice也可以被用作断点。目标函数位于JavaScriptCore/runtime/ArrayPrototype.cpp:
1 | b arrayProtoFuncSlice |
在js中直接引用:
1 | Array.prototype.slice([]); |
断下后会停在该函数的开头。
可以使用x86 int3指令自定义一个断点函数dbg()进行调试。在jsc中自定义函数只需要三个步骤:
声明函数
定义函数
自定义的Native函数参数统一为ExecState类型,这是一个为了确定上下文的类型。在使用addFunction注册函数时,第二个参数是该函数参数个数,这些参数可以通过ExecState类型的exec参数取得,因此dbg()函数还可以定义为有参数的函数:
1 | static int dbgBrCnt = 0; |
因此实现了一个条件断点,这样可以让函数在循环到某确定次数的时候陷入int3,比如下面定义的是一个到达1000次之后触发的断点函数:
1 | dbg(1000); |
当然上述定义的断点适合简单的情况,只有一个断点的时候是可运行的。
functionDbg()的内容还可以更多样化地进行定制。这在调试JIT、垃圾回收等等复杂情况的时候尤其有用。
上述所有内容都是JSC_HOST_CALL类型的静态函数,在JIT当中使用这些函数会不可避免地使栈回溯发生变化,因为存在一个从JITed代码跳转出来的过程;有时候还会为DFG JIT添加OSRExit的退出点,使得本来应该触发的漏洞在dbg()函数提前回退,导致漏洞无法触发。
JavaScriptCore的运行是一个比较复杂的过程,可以参考《MOSEC2018分享》中的内容,程序运行的上下文会经常在Baseline JIT和DFG JIT生成的代码之间切换。于是上文中的functionDbg()函数:
因此,dbg()函数可以保证插入点之前的代码的执行情况是与不插入的情况吻合的,但其后的代码执行逻辑可能会发生改变。例如,在JavaScriptCore JIT系统中最典型的类型混淆漏洞,其中有:
1 | 前文 |
为了观察类型混淆漏洞,会在“类型混淆的赋值”之前插入一个DirectCall结点调用dbg():
1 | 前文 |
于是在CheckStructure之后程序就进行了OSRExit,无法到达“类型混淆的赋值”,因此发生所谓观察者效应,调试失败。
为了解决这个问题,可以把CheckStructure这一DFG IR的编译过程注释掉,这样就可以随心所欲地下断点了:
JavaScriptCore/dfg/DFGSpeculativeJIT64.cpp
1 | SpeculativeJIT::compile(Node * node) |
当然,这样的情况不适用CheckStructure本身出问题的情况。
在其他的情况下,为了去掉InvalidationPoint还通过可以修改DirectCall这一DFG IR的读写属性标识,将其指明的Side Effect去掉:
JavaScriptCore/dfg/DFGClobberize.h
1 | switch (node->op()){ |
更直观的方法,是在DFG代码生成的位置下断点:
需要参数:
1 | --dumpDFGDisassembly=true |
配合内存dump+反汇编器分析更容易。
]]>This bug was found by saelo(Thanks saelo for always being really great!).
https://bugs.chromium.org/p/project-zero/issues/detail?id=1775
https://github.com/WebKit/webkit/commit/62f770031bdb15b59041257e60ab93765d5ee6ca
1 | const v3 = [1337, 1337, 1337, 1337]; |
This bug crashes at “v8[-698666199]”, which corresponds to a dfg node GetByVal.
Before LICM phase, @70 GetByVal resides in Block#3, with a @137 CheckInBounds ahead of it.
After LICM, it resides in Block#1, with @137 CheckInBounds still in Block#3.
It means that @70 GetByVal is hoisted during LICM. So read operation happens before check. Thus crash happens.
After implementing the patch, @70 GetByVal is never hoisted because of it won’t pass edgesDominate in DFGLICMPhase.cpp:
1 | if (!edgesDominate(m_graph, node, data.preHeader)) { |
(jsc’s LICM does not strictly implement traditional fundamentals of compiling. It checks doesWrites, readsOverlap, safeToExecute and sort of things. It does not really rely on defs and uses but AbstractHeap.)
And here is the calling stack:
1 | if (!edgesDominate(m_graph, node, data.preHeader)) { |
So at function dominates
,
the vuln version(the index are the blocks’):
to 0 from 0
to 0 from 0
to 0 from 0
the fixed version:
to 0 from 0
to 0 from 0
to 0 from 0
to 0 from 3
m_data[to].preNumber 0 m_data[from].preNumber 3
m_data[to].postNumber 17 m_data[from].postNumber 0
That’s because an Edge is added in the Graph in the fixed version.
1 | before LICM |
So we could know that the core of the patch is as follows:
1 |
|
(The HasIndexedProperty stuff has nothing to do with this bug. It is modified in the patch because in lowerBoundsCheck is modified in DFGSSALowingPhase.cpp because it relies on that function.)
So there is a first one condition that a node should meet if the node would be hoisted during LICM phase:
In this case, Block#0 is definately loop’s preheader. And GetByVal has children:
Those children all belong to Block#0. And the 4th child in the fixed version:
What makes @137 CheckInBounds the 4th child of GetByVal is an artificial (because there isn’t actually any data dependency) dataflow edge between the CheckInBounds and the GetByVal.
So in the vuln verison, we have Block#0 not-strictly dominate Block#0, and in the vuln version Block#3 not dominate Block#0 which determines the existence of the vulnerability. Thus GetByVal can never be hoisted in front of the check.
(Thanks for saelo’s advice on emphasizing the artificial dataflow edge here. )
@137 CheckInBounds is never hoisted. Because before LICM phase(the 42th phase) it is like:
1 | 137:<!0:->CheckInBounds(Int32:@69, KnownInt32:Kill:@159, MustGen, Int32, Exits, bc#54, ExitValid) |
And @69 resides in Block#0 and @159 resides in Block#2. And Block#2 does not dominate Block#0. So.
There is a @224 CheckInBounds inserted before @70 GetByVal during DFGSSALoweringPhase (the 25th phase):
1 | 224:<!0:->CheckInBounds(Int32:@69, Check:KnownInt32:@256, JS|MustGen|PureInt, Int32, Exits, bc#54, ExitValid) |
And @224 CheckInBounds is then changed to @137 CheckInBounds during some modification on index of graph in DFGObjectAllocationSinkingPhase(DFG phase object allocation elimination):
1 | 137:<!1:->CheckInBounds(Int32:@69, KnownInt32:Kill:@159, JS|MustGen|PureInt, Int32, Exits, bc#54, ExitValid) |
vuln | fixed | |
---|---|---|
25th phase: DFGSSALoweringPhase | 224:<!0:-> CheckInBounds(Int32:@69, Check:KnownInt32:@256, MustGen, Int32, Exits, bc#54, ExitValid) | 224:<!0:-> CheckInBounds(Int32:@69, Check:KnownInt32:@256, JSMustGenPureInt, Int32, Exits, bc#54, ExitValid) |
70:<!0:-> GetByVal(KnownCell:@303, Int32:@69, Check:Untyped:@67, JSMustGen VarArgs PureInt, Int32, Int32+Array+InBounds+AsIs+Read, R:Butterfly_publicLength,IndexedInt32Properties, Exits, bc#54, ExitValid) predicting Other | 70:<!0:-> GetByVal(KnownCell:@303, Int32:@69, Check:Untyped:@67, **Check:Untyped:@224**, JS MustGen VarArgs PureInt, Int32, Int32+Array+InBounds+AsIs+Read, R:Butterfly_publicLength,IndexedInt32Properties, Exits, bc#54, ExitValid) predicting Other | |
36th phase: DFGObjectAlloactionSinkingPhase | 137:<!0:-> CheckInBounds(Int32:@69, KnownInt32:Kill:@159, MustGen, Int32, Exits, bc#54, ExitValid) | 137:<!1:-> CheckInBounds(Int32:@69, KnownInt32:Kill:@159, JS MustGen PureInt, Int32, Exits, bc#54, ExitValid) |
70:<!1:-> GetByVal(KnownCell:@22, Int32:@69, Check:Untyped:Kill:@4, JS MustGen VarArgs PureInt, Int32, Int32+Array+InBounds+AsIs+Read, R:Butterfly_publicLength,IndexedInt32Properties, Exits, bc#54, ExitValid) predicting Other | 70:<!1:-> GetByVal(KnownCell:@22, Int32:@69, Check:Untyped:Kill:@4, **Check:Untyped:Kill:@137**, JS MustGen VarArgs PureInt, Int32, Int32+Array+InBounds+AsIs+Read, R:Butterfly_publicLength,IndexedInt32Properties, Exits, bc#54, ExitValid) predicting Other |
CVE-2018-4233是Pwn2Own 2018上Samuel Groß团队用来攻破Safari浏览器的漏洞。这是一个JIT编译器中side effect导致IR建模失败而产生的一个类型混淆漏洞,通过这样的类型混淆漏洞可以得到addrof、fakeobj两个原语,从而直接获得沙箱内远程代码执行的效果。
CVE-2018-4233是JIT中的类型混淆漏洞中的一个经典,非常有代表性。
该漏洞的fix位于https://github.com/WebKit/webkit/commit/b602e9d167b2c53ed96a42ed3ee611d237f5461a,本文基于其parent commit
7996e60。该版本更新时间是2018年3月末,PoC:
1 | let someObject = {}; |
JIT:即时编译,边运行边产生机器代码,常见于浏览器等脚本执行环境,使用机器码替换高级语言的代码来运行,代替解释器以优化运行速度。
DFG:dfg在编译原理中用来代表数据流图,在WebKit中是第一层优化编译器:
side-effect:也被译为“副作用”,更新变量和数据结构的赋值语句。
IR建模:JIT优化编译器需要对中间表示(IR)的字节码进行建模,精确描述side effect等。在WebKit中有两处代码用于建模:DFGAbstractInterpreter、DFGClobberize,前者被简称为AI。
JSValue:JSValue是WebKit JavaScriptCore中用来表示和存储JS执行上下文中对象、整数、浮点数的而定义的一个类。其中重点关注:
ArrayWithDouble、ArrayWithContiguous中浮点数和指针的互相赋值是构造类型混淆漏洞的方法。
在JIT类型混淆漏洞中,IR建模错误往往是漏洞产生的根本原因。一般过程是:
本部分以fakeobj这个原语为例解析这个漏洞,addof原语的构造与其极其相似,不需要调试稍作修改即可。
设置调试目标为jsc,参数添加:
1 | --useConcurrentJIT=false |
开始运行,从输出中可见,assign、get产生了DFG JIT代码:
1 | Generated DFG JIT code for assign#EmOb1Y:[0x11cd78720->0x11cd78260->0x11cd98f20, DFGFunctionConstruct, 24], instruction count = 24: |
随后产生了Firing watchpoint,并丢弃get的dfg代码:
1 | Firing watchpoint 0x11c86f398 on get#CsfvpT:[0x11cd78980->0x11cd784c0->0x11cd98fd0, DFGFunctionCall, 60] |
栈回溯可见,它的产生是由于JIT::operationPutToScope,显然这个栈回溯来自代理中get handler中的赋值arr[0] = {};
。
之后是相应的JumpReplacement:
1 | Firing jump replacement watchpoint from 0x3729250003ee, to 0x37252900067f. |
Jump replacement位于DFG JIT code for get,大概位置是:
1 | SetArgument |
1 | Firing watchpoint 0x11c86f438 on get#CsfvpT:[0x11cd78980->0x11cd784c0->0x11cd98fd0, DFGFunctionCall, 60] |
此次的栈回溯为:
其中的重点是:
JSC::JSObject::convertDooubleToContiguousWhilePerformingSetIndex
JIT::operationPutByVal
0x3728e500268c baseline JIT code for get# [put_by_val]
JSC::ProxyObject::getOwnPropertySlot
JSC::JSObject::get 回调发生处
dfg::operationCreateThis(0x100d53cd0)
0x372925000c05 DFG JIT code for assign#
JIT::operationLinkCall
0x3728e50038d3 Baseline JIT code for primitiveFakeObj# [construct]
随后有:
1 | Firing watchpoint 0x11c86f208 on assign#EmOb1Y:[0x11cd78720->0x11cd78260->0x11cd98f20, DFGFunctionConstruct, 24] |
DFG jump 位于DFG JIT for assign:
1 | ... |
从打印数据里看CreateThis的执行调用operationCreateThis,然后从JSC::JSObject::get中回调触发assign赋值(ConvertDoubleToContiguous),然后触发Jump replacement,但显然Jump replacement的跳出点位于CreateThis之前,而CreateThis之后不再有跳出点。意即:虽然作出了OSRExit的动作,但并没有成功OSRExit,这里就是bug的直观表现了。
针对这个漏洞,补丁围绕对CreateThis字节码进行建模修改了两处内容clobberize、AbstractInterpreter。下面分别考察这两部分的作用。在这个过程中,可以多次修改代码、编译运行程序、dump内存、放入反汇编器,便于阅读JIT代码提高效率。
用于dump内存的lldb命令为:
1 | me read -o /Users/dwfault/Downloads/dump.bin -b 0x31fe33000500 0x31fe33001000 --force |
右图的字节码对应write(Heap)补丁之后的代码。可以看到IR中的CreateThis后面增加了一个InvalidationPoint,其他都没有改变。但由于此处相应会产生一个Jump replacement(参考DFGInvalidationPointInjectionPhase),导致CreateThis回调结束之后会被Jump replacement引到OSRExit,使漏洞不再触发。
按照同样的方法,现在为CreateThis只加上clobberWorld。
可以看到在GetButterfly之前增加了一个CheckStrucure,其x86机器码是:
1 | cmp dword [rax], 0x5b |
细致调试可以知道,arr变量的structureID之前是0x5b,现在成了0x5c,这也使得执行流程走向了OSRExit,使漏洞不再发生。
可以看到在5.1、5.2两节中,clobberize、AbstractInterpretrer两处对IR建模的描述直接导致了JIT编译器产生了不同的代码。其中5.1节clobber与InvalidationPoint的关系非常明确,在DFGInvalidationPointInjectPhase中可以很明确地知道:
(1) DFGClobberize把Abstract Heap以树型结构分为几类:
(2) writesOverlap函数检查目标opcode结点的write属性与WatchPoint_fire在树型结构中的父子关系,如果WatchPoint_fire在目标opcode结点的子树上,则插入InvalidationPoint。
而对于AbstractInterpreter,clobberWorld的作用不明显,需要加以更多调试。 回到漏洞版本,增加运行选项运行:
1 | --dumpDFGGraphAtEachPhase=true |
得到的输出节选如下:
1 | Beginning DFG phase structure check hoisting. |
CheckStructure这个IR有一个专门的提前阶段,叫DFGCheckStructureHoisting。这个过程把42 GetLocal、43 CheckStructure提前。对比clobberWorld版本这个阶段也是发生的。
随后,有:
1 | Beginning DFG phase constant folding. |
可以发现,GetButterfly之前的CheckStructure在常量折叠过程被优化掉了;对比clobberWorld版本,GetButterfly之前的CheckStructure仍保留。因此clobberWorld最终影响了constant folding phase。
跟踪constant folding phase,第187行的node->remove(m_graph)直接导致CheckStructure从IR的控制流图中被删除:
进入if结构需要满足185行的if表达式为真,那么value.m_structure.isSubsetOf(set)就很重要了。
重新调试,为了找到对clobberWorld的调用,在DFG::AbstractInterpreterInlines.h的executeEffects函数case CreateThis选项下断点。发现停在了CFA phase(Control Flow Analysis)阶段,而这几个phase的顺序是:
invalidationpoint injection -> structure check hoisting -> strength reduction -> cps rethreading -> cfa -> constant folding
executeEffects是在CFA阶段内发生的,那么CFA之前的阶段不需要关注。
跟进clobberWorld,在多层的调用中,有对m_set.setReservedFlag的赋值:
1 | static const uintptr_t reservedFlag = 2; |
之后,在constant folding phase:
跟进value.m_structure.isSubsetof(set):
由此解决了clobberWorld在AbstractInterpreter的前后呼应问题。
CVE-2018-4233可以总结为CreateThis side effect导致的类型混淆漏洞,补丁修改了两处位置,AbstractInterpreter、Clobberize,这两处的任意一个均可以使PoC失效。相比2017年Moblie Pwn2Own中Vulcan团队使用的GetPropertyEnumerator/HasGenericProperty side effect漏洞的补丁,这个补丁更加全面地展示了这个漏洞的模式和面貌。挖掘出这种类型的漏洞,只需要两步:
然后就可以尝试构造漏洞了。
]]>2018年4月,ZDI发表了《INVERTING YOUR ASSUMPTIONS: A GUIDE TO JIT COMPARISONS》,描述了JavaScriptCore DFG JIT中CompareEq IR的副作用问题。通过TenSec2018的ppt,可以知道这个漏洞编号为CVE-2018-4162。
从文章给出的补丁上看,这显然是一个副作用问题:
1 | function primitiveFakeObj(addr) { |
c == 1会触发一些回调,回调的内容就是{toString: () => { arr[0] = {}; return ‘1’; }}这个对象的toString函数。本次DFG代码中的回调调用了baseline JIT中的operationCompareEq:
1 | // v1 = object |
…
从此以后,go(arr, {})、go(arr, {toString: () => { arr[0] = {}; return ‘1’; }})将会走向不同的地方。前者会走向objectProtoFuncValueOf,后者会走向其定义的toString函数。
实际调试发现,除了DFGAbstractInterpreter的clobberWorld之外,还会插入InvalidationPoint,那么也与DFGClobberize有关了。该版本下CompareEq是这样的:
其中isBinaryUseKind意思就是isBothUseKind,而根据PoC,DFG生成的代码:
1 | 31:<!1:loc8> CompareEq(Untyped:@30, Untyped:@27, Boolean|MustGen|PureInt, Bool, R:World, W:Heap, Exits, ClobbersExit, bc#17, ExitValid) |
会插入InvalidationPoint和Jump replacement,显然无法复现。
翻近一年多的commit,看到关于CompareEq有这么几处补丁:
https://github.com/WebKit/webkit/commit/b6b0023ff9a7a327bcbd6c1badaaea459d650235
其中后两个修来修去没啥实际效果(后文提到),第一个是符合ZDI文章的补丁:
参考CVE-2018-4233,这个补丁导致的结果是CheckStructure/CheckArray等。究竟能不能利用,必须要求Invalidation不插入,也就是Clobberize定义出问题。但是在这个版本以及往前很多的版本下,DFGClobberize定义的CompareEq仍然是这样的:
于情于理,确实无法复现。
好在有同事在某不知名版本上复现了这个漏洞,反编译结果大概形如:
那么与之接近的源码应该为:
虽然仍然找不到具体哪个版本用了这样的代码,但漏洞终于可以复现了。
不管是DFGAbstractInterpreter还是DFGClobberize,出现问题的原因涉及CompareEq的children的UseKind。根据child1、child2的useKind,DFG在生成CompareEq的IR结点时,会有类似:
1 | 31:<!1:loc8> CompareEq(Untyped:@30, Untyped:@27, Boolean|MustGen|PureInt, Bool, R:World, W:Heap, Exits, ClobbersExit, bc#17, ExitValid) |
其中Untyped、Object对应两种UseKind。根据CompareEq的UseKind,会生成不同的机器码:
1 | bool SpeculativeJIT::compilePeepHoleBranch(Node* node, MacroAssembler::RelationalCondition condition, MacroAssembler::DoubleCondition doubleCondition, S_JITOperation_EJJ operation) |
可以把PoC改一下,针对性地修改UseKind,可以看到UseKind为Object、StringIdent时,生成的机器码不含有回调的:
1 | //ObjectUse -- no callback |
1 | //StringIdent -- no callback |
1 | bool SpeculativeJIT::compare(Node* node, MacroAssembler::RelationalCondition condition, MacroAssembler::DoubleCondition doubleCondition, S_JITOperation_EJJ operation) |
根据源码,CompareEq的默认UseKind都是Untyped,这一步由DFGByteCodeParser决定:
UseKind在fixup phase里可以被修改,寻找线索的三种方法:
其中泛型UseKind11是ObjectUse。
而代码要求,必须child1、child2的SpeculatedType均为object时才会设置UseKind为ObjectUse。
1 | bool shouldSpeculateObject() |
DFG JIT的实现代码犹如草蛇灰线,伏脉千里。安全研究员提出修补建议、官方修补漏洞时都可能忽略一些问题,造成修补的反复进行。
在3.1节中,IR结点的UseKind决定编译结果是否含有回调,因而不妨作为“DFG回调副作用”这一pattern挖掘的核心和Entry Point。
https://github.com/saelo/35c3ctf/blob/master/WebKid/webkid.patch
一个patch就足够了。patch的核心在于给操作
1 | thisObject->setStructure(vm, Structure::removePropertyTransition(vm, structure, propertyName, offset)); |
增加了一个fast path。在这个fast path中,假如delete的property是最后一个添加的就把它删去,然后把structureID恢复到上一个。
对比一下正常的行为:
patch之前的版本:
Object: 0x11cdc8580 with butterfly 0x18000fe5c8 (Structure 0x11cdf2760:[Array, {}, ArrayWithDouble, Proto:0x11cdb4090, Leaf]), StructureID: 91
Object: 0x11cdc8580 with butterfly 0x18000f8468 (Structure 0x11cda7c60:[Array, {outOfLineProperty:100}, ArrayWithDouble, Proto:0x11cdb4090, Leaf]), StructureID: 282
Object: 0x11cdc8580 with butterfly 0x18000f8468 (Structure 0x11cda7cd0:[Array, {}, ArrayWithDouble, Proto:0x11cdb4090, UncacheableDictionary, Leaf]), StructureID: 283
patch之后的版本:
Object: 0x11cdc8580 with butterfly 0xc000fe5c8 (Structure 0x11cdf2760:[Array, {}, ArrayWithDouble, Proto:0x11cdb4090, Leaf]), StructureID: 91
Object: 0x11cdc8580 with butterfly 0xc000f8468 (Structure 0x11cda7c60:[Array, {outOfLineProperty:100}, ArrayWithDouble, Proto:0x11cdb4090, Leaf]), StructureID: 282
Object: 0x11cdc8580 with butterfly 0xc000f8468 (Structure 0x11cdf2760:[Array, {}, ArrayWithDouble, Proto:0x11cdb4090]), StructureID: 91
一说到structureID的改变,就想到DFG JIT中watchpoint(CodeBlockJettisoningWatchpoint)的fire是靠不同的structure来触发的,一个最简单的原语:
1 | function primitiveMaybeNotExploitable(obj) { |
DFG生成的visit()函数在最后一次执行前,structureID为91,按理说已经不包含outOfLineProperty,但由于没有触发watchpoint,代码逻辑没有发生改变,而且butterfly指针保留,仍然能获得相应的引用。这是一个问题,但应该无法做到漏洞利用。
而正常版本的deleteProperty显然会触发watchpoint,最后得出结果是undefined:
1 | Firing watchpoint 0x11c84d0a0 on visit#ETPOUh:[0x11cd784c0->0x11cd78260->0x11cd98f20, DFGFunctionCall, 35] |
栈回溯的第12条是原版代码中被fast path越过的代码。removePropertyTransition才是用来触发WatchpointSet::fireAll的函数调用,而patch中只是简单地设置了:
1 | thisObject->setStructure(vm, previous); |
WatchPointSet是通过structure对象索引的,structure对象由structureID确定,因此一个structureID对应一个WatchPointSet。在第一部分的visit()函数中,DFG插入Watchpoint时,arr的类型是ArrayWithDouble并附加一个outOfLineProperty,structureID是282。也就是说WatchPointSet对应strucutre282,那么之后变成structure91之后是没有注册WatchPointSet的,如果再次进行类型转换,仍然不会fire。
(如果struture91被注册了WatchPointSet,在654行会进行fire)
因此再给代码增加一个transition,用传统的convertDoubleToContiguous:
1 | function primitiveAddrOf(obj) { |
于是我们有了两个原语,addrof、fakeobj。
addof、fakeobj之后,构造全局读写还是比较简单的,基本有模版可参考:
1 | let BASE32 = 0x100000000; |
漏洞利用围绕JSValue,细节此处略过。
WatchPoint在fire的时候,会找到InvalidationPoint的位置,然后通过JumpReplacement跳离不安全的DFG代码回到baseline JIT,而CheckStructure则是类似:
按照前三节的js代码,DFG IR中不存在CheckStruture。
改造一下PoC,使arr通过函数参数arg的形式传入:
1 | function primitiveAddrOf(obj) { |
生成的dfg代码稍微发生了变化,可以看到其中虽然没有CheckStructure产生,但有CheckArray:
1 | Generated DFG JIT code for visit#BFOcsO:[0x11cd784c0->0x11cd78260->0x11cd98f20, DFGFunctionCall, 15 (NeverInline)], instruction count = 15: |
1 | (lldb) register read $rax |
其中0x09这个字节对应的是IndexingType:
可以看到CheckArray与CheckStructure出现的位置类似,作用也类似。因此在这个PoC的visit()函数中,如果用参数来传递arr将会被检查出来,无法带来类型混淆。
]]>