vs调试环境配置
编译
首先下载ChakraCore
然后git clone https://github.com/Microsoft/ChakraCore.git
在ChakraCore项目中搜索CVE-2017-0234,找到patch的commit,然后得到有漏洞的版本的hash
然后checkout,git checkout d8ef97d90c231e83db96dc4fdff4b39409f7a9b6
然后在VS2015中打开Build\Chakra.Core.sln
,并生成解决方案
调试
右键设置为启动项目
在命令参数写好绝对路径并执行
windbg调试环境配置
在windows store下载windbg preview
设置符号服务器SRV*c:\edgesymbol*http://msdl.microsoft.com/download/symbols
直接调试chakra
Windbg preview可以直接查看源码,在源码点击下断,很方便。
crash
PoC
1 | function write(begin, end, step, num) { |
1 | >k |
分析
JIT
关于JIT生成不是重点,于是我调试了一下并没有详细写出调用,只是说一下。
在循环的解释执行次数超出loopInterpretCount的值的时候,就会进入JIT代码生成,然后在JIT代码生成后就转到JIT中执行,不再解释执行。
在JIT优化之后,DoLoopBodyStart调用CallLoopBody,参数是循环体JIT代码的地址。
1 | if (fn->GetIsAsmJsFunction()) |
漏洞触发在循环体中
分析patch前汇编
1 | ... |
mov dword ptr [rsi+r9*4],r13d
是对view数组元素赋值,rsi是buffer的首地址,r9是数组索引值i,r13d即1851880825(hex:0x6E617579)是要赋的值
由汇编可以看出,缺少对索引值的边界检查(或者说优化后只剩下了检查begin是否小于end,但是没有检查索引上界end是否超出数组内存边界)
于是就访问到了不能访问的地址,crash。
patch分析
- patch
https://github.com/Microsoft/ChakraCore/commit/a1345ad48064921e8eb45fa0297ce405a7df14d31
2Too aggressive bound check removal
Don't eliminate bounds checks on virtual typed arrays if we can't guarantee that the accesses will be within 4Gb1
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
30
31
32
33
34- eliminatedLowerBoundCheck = true;
- eliminatedUpperBoundCheck = true;
- canBailOutOnArrayAccessHelperCall = false;
+ // Unless we're in asm.js (where it is guaranteed that virtual typed array accesses cannot read/write beyond 4GB),
+ // check the range of the index to make sure we won't access beyond the reserved memory beforing eliminating bounds
+ // checks in jitted code.
+ if (!GetIsAsmJSFunc())
+ {
+ IR::RegOpnd * idxOpnd = baseOwnerIndir->GetIndexOpnd();
+ if (idxOpnd)
+ {
+ StackSym * idxSym = idxOpnd->m_sym->IsTypeSpec() ? idxOpnd->m_sym->GetVarEquivSym(nullptr) : idxOpnd->m_sym;
+ Value * idxValue = FindValue(idxSym);
+ IntConstantBounds idxConstantBounds;
+ if (idxValue && idxValue->GetValueInfo()->TryGetIntConstantBounds(&idxConstantBounds))
+ {
+ BYTE indirScale = Lowerer::GetArrayIndirScale(baseValueType);
+ int32 upperBound = idxConstantBounds.UpperBound();
+ int32 lowerBound = idxConstantBounds.LowerBound();
+ if (lowerBound >= 0 && ((static_cast<uint64>(upperBound) << indirScale) < MAX_ASMJS_ARRAYBUFFER_LENGTH))
+ {
+ eliminatedLowerBoundCheck = true;
+ eliminatedUpperBoundCheck = true;
+ canBailOutOnArrayAccessHelperCall = false;
+ }
+ }
+ }
+ }
+ else
+ {
+ eliminatedLowerBoundCheck = true;
+ eliminatedUpperBoundCheck = true;
+ canBailOutOnArrayAccessHelperCall = false;
+ }
要分析patch,可以先看一下patch后现在的JIT代码是什么样,跟进JIT。
在这下个断点,跟到JIT里
再在JIT里下断点
继续执行到断点,并单步跟进
分析patch后汇编
1 | 000001E8AB960000 mov rax,1DFAB6B0A78h |
- 比较索引上界是否超出数组长度(检查上界)
1 | 000001E8AB96012C jg 000001E8AB9602F7 |
- 比较索引值是否到达索引上界
1
2
3
4
5
6
7
8
9
10000001E8AB960154 jge 000001E8AB96019A
000001E8AB960156 test r9d,r9d
000001E8AB960159 js 000001E8AB96033C
000001E8AB96015F mov rsi,r14
000001E8AB960162 mov r11,rsi
000001E8AB960165 shr r11,30h
000001E8AB960169 cmp r11,1
000001E8AB96016D jne 000001E8AB960358
000001E8AB960173 mov esi,esi
000001E8AB960175 mov dword ptr [rbx+r9*4],esi ----->数组赋值 - 数组赋值
关于patch的思考
1 | + if (lowerBound >= 0 && ((static_cast<uint64>(upperBound) << indirScale) < MAX_ASMJS_ARRAYBUFFER_LENGTH)) |
要绕过patch再次触发就要进入这个if body,(static_castvar buffer = new ArrayBuffer(0x10000);
能否在进入if body的同时,又能OOB超出数组长度,就是后面需要思考的问题。
1 | IntConstantBounds idxConstantBounds; |
该段代码表示了程序试图获取ConstantBounds来赋值给idxConstantBounds从而控制upperBound&lowerBound
因此PoC中需要构造常数边界来控制upperBound&upperBound从而控制下列判断:
1 | if (lowerBound >= 0 && ((static_cast<uint64>(upperBound) << indirScale) < MAX_ASMJS_ARRAYBUFFER_LENGTH)) |
1 | //回溯分析 |
经过测试,在patch了的函数那里下断,跟进到这个if判断,得到有限制的bypass patch PoC
1 | function write(j,number) |
更多的思考
chakra为什么这么优化,它涉及怎样的一个pattern,这样优化和buffer相关的点有哪些?
- 为什么JIT优化去掉边界?它为什么会去掉边界?(和4GB有关,这种特殊的buffer分配方式)
- PoC能否修改?怎么修改?思考如下:
- 不同的对象能否触发?举例:一定要是Uint32Array或者ArrayBuffer么?
- 是否一定用到循环?去掉循环行不行?怎么精简PoC?
JIT优化&&内存分配
经过进一步对内存分配的调试(首先我在windbg里对windows API下断,参考了这篇,然后回溯确实跟到了VirtualAlloc,不过和我找的不是同一个。
1 | 0:000> bp KERNEL32!VirtualAllocStub |
于是全局搜索MEM_COMMIT,在ArrayBuffer.h里找到线索,并调试确认。
结论如下:
在为ArrayBuffer进行内存分配时,会对长度有一个判断。
并根据这个判断的返回结果,决定使用Virtual Alloc(AllocWrapper是一个包装)还是malloc来分配内存。
这主要是根据length的长度和”标志位“。
如果是用Virtual Alloc分配(关于这种分配方式的参数,可以参考MSDN)
那么为ArrayBuffer分配的保留空间大小为4GB
随后COMMIT真正使用的大小,也就是PoC里的0x10000
JIT在优化的时候会因为我们给这个Buffer分配的内存足够大(4GB),就去掉了边界检查,但其实这是一个安全隐患。
Pattern匹配
我尝试着替换ArrayBuffer,寻找和此处bug逻辑相似的地方,在源码里搜索,不过暂时没有找到疑似的地方。
触发条件(修改PoC)
1 | if (baseValueType.IsLikelyOptimizedVirtualTypedArray() && !Js::IsSimd128LoadStore(instr->m_opcode) /*Always extract bounds for SIMD */) |
- 替换控制ArrayBuffer的对象
- 测试case
TypeView
PoC里用的是Uint32Array,其实TypedView只要宽度大于一个字节都是可以的
下面这些都测试成功。(其实主要常用的写exp的还是FloatArray)
1 | Uint8Array();- |
DataView
- JSObject
- JSArray
- JSArrayBuffer
- JSArrayBufferView
- JSTypedArray
- JSDataView
- JSArray
ArrayBuffer需要用TypedArray或DataView来实际访问。
而为了Exploit,最好不要做多余的事情(当发生意想不到的事情时很麻烦),因此比起DataView,我们更多的使用TypedArray。
不过我这里还是测试了一下DataView,没有成功。
1 | function write(begin, end, step, num) { |
1 | $ /d/chakracore/ChakraCore/Build/VcBuild/bin/x64_debug/ch.exe test3.js |
全局搜索报错字符串并查找引用寻找原因。
其他
在patch后,要触发需要构造常数边界(上面有分析)
1 | if (isProfilableStElem || |
isProfilableStElem显然是JIT优化时用来采集的一个标志,所以通过循环生成JIT的时候就可以走进if body。
但是其他和IR有关的“||”选项显然是不能放过的线索,可以测试如何走进这些路径。
开发者的assumption
assumption
如图可知,传入的length的最大长度为MaxArrayBufferLength,length的类型是uint32即最大值2^32-1
MaxArrayBufferLength 0x7fffffff const unsigned int
这里即是对要分配的buffer的空间大小的一次校验。
而采用VirtualAlloc一次分配的大小是4GB即2^32#define MAX_ASMJS_ARRAYBUFFER_LENGTH 0x100000000 //4GB
然而在通过索引访问buffer的时候,索引的类型也是uint32的。
于是若是数组索引,按照单个元素size同比扩容之后,则有可能超过4GB的虚拟内存(即length最大可以申请到4G,但是访问可以是4G*x)。
而对于访问超过分配的buffer但是在VirtualAlloc分配的4G内的越界读写会直接会由硬件进行捕获。
我推测开发者在写代码的时候,正是没有注意到这一点,于是只是简单的出于性能优化的考虑,错误的判断了索引无论如何都不可能超出保留的大空间越界访问(因为4G是“最大”了,而在4G内的越界访问都会被硬件捕获并终止)
于是就直接去掉了边界。
other
最简单也是最常见的可优化边界检查代码就是处在循环里的边界检查。
我们假设循环具有迭代变量i,且初值为init, 终值为fin,i++
只有循环控制代码修改i
假设必须要满足的范围是lo < i < hi
本来循环应该是这样的:
1 | i <- init |
最容易处理的情况是i为常量v,则只需要将检查lo < i < hi的代码外提到循环的前置块里即可
下一种稍微复杂一点的情况是i为变量,这样我们就要处理范围表达式lo < i < hi(比如说分配的数组最大空间不超过hi,最小不小于0)
其中i是循环控制变量,在这种情况下,只需要lo < init且 fin < hi , 就能满足范围表达式
于是我们可以这么做
1 | if lo > init trap (i的初始值为init,如果init 比 i需满足的最小值还小,则trap |
GC和VirtualAlloc在安全性上的区别
UAF
UAF(Use After Free):即释放后使用。将Dangling pointer所指向的内存重新分配回来,且尽可能使该内存中的内容可控
MemGC
如图,对象A申请了一个数据块,当释放这个数据块时,若还有其他对象引用这个数据块,那么MemGC不会回收利用,其他程序无法将数据写入这个数据块,从而阻止了UAF漏洞的利用;若没有其他对象引用这个数据块,就不存在UAF漏洞了。
标记清除法
标记清除(Mark and Sweep)是最早开发出的GC算法(1960年)。它的原理非常简单,首先从根开始将可能被引用的对象用递归的方式进行标记,然后将没有标记到的对象作为垃圾进行回收。
图显示了标记清除算法的大致原理。
图中(1)部分显示了随着程序的运行而分配出一些对象的状态,一个对象可以对其他的对象进行引用。
图中(2)部分中,GC开始执行,从根开始对可能被引用的对象打上标记,大多数情况下,这种标记是通过对象内部的标志(Flag)来实现的。于是,被标记的对象我们把它们涂黑。
图中(3)部分中,被标记的对象所能够引用的对象也被打上标记。重复这一步骤的话,就可以将从根开始可能被间接引用到的对象全部打上标记。到此为止的操作,称为标记阶段(Mark phase)。
标记阶段完成时,被标记的对象就被视为“存活”对象。
图中(4)部分中,将全部对象按顺序扫描一遍,将没有被标记的对象进行回收。这一操作被称为清除阶段(Sweep phase)。
在扫描的同时,还需要将存活对象的标记清除掉,以便为下一次GC操作做好准备。标记清除算法的处理时间,是和存活对象数与对象总数的总和相关的。
GC和VirtualAlloc在安全性上的区别
VirtualAlloc是裸的内存分配释放,并没有对UAF加以缓解。
而通过控制ArrayBuffer的length(>0x10000),我们可以选择通过VirtualAlloc来分配内存,于是就“绕开”了GC的保护,就有可能通过UAF来完成利用。
总结
通过分析Root Cause,并进一步通过对Assumption的思考,对patch的分析,相关知识由点及面的学习,并最后在脆弱性上进行考虑,找出可行的利用点,思考的深度有所提升。
Other
Windbg常用命令
- 寻找JIT代码,定位CallLoopBody,它的参数就是JIT代码地址。
- 寻找生成JIT代码的地方可以考虑在Func::Codegen那里下断。