一个补丁引发的RCE: 对CVE-2019-1208的深入分析
前言
CVE-2019-1208是趋势科技的@elli0tn0phacker在今年6月发现的一个vbscript漏洞,报告中提到这个漏洞是通过补丁比对发现的,这引起了笔者的兴趣。最近,笔者花了一些时间对该漏洞进行了比较详细的研究。在这篇文章中,笔者将从漏洞成因、修复方案、利用编写三个方面对该漏洞进行介绍。
读者将会看到,代码开发者是如何在修复旧漏洞时不经意间引入新漏洞。在这个例子中,引入的还是一个非常严重的远程代码执行漏洞。通过这个例子读者也会发现,有时候通过补丁比对就可以发现新漏洞。
该漏洞从2019年6月更新被引入,到2019年9月更新被修复,只存活了短短3个月,因此编写这个漏洞的利用并无价值,笔者写这个漏洞的利用只是为了概念验证。
尽管微软已经在2019年8月的IE更新中全面禁用了vbscript,但出于安全性考虑,完整利用代码不予公开。
漏洞成因
这是一个vbscript的UAF(Use After Free)漏洞,漏洞成因还要从微软今年6月的补丁说起。
漏洞成因
微软在2019年6月的vbscript更新中引入了下面几个函数:
•SafeArrayAddRef
•SafeArrayReleaseData
•SafeArrayReleaseDescriptor
引入SafeArrayAddRef的作用是为SafeArray提供一种类似引用计数的机制。
源码中通过使用STL的 map将一些对象/数据指针(如pSafeArray和pvData)与一个int型的计数器进行绑定。
在VbsFilter和VbsJoin这两个函数中,在调用实际的rtJoin和rtFilter前,会调用SafeArrayAddRef对相关指针的引用计数+1。调用完毕后,再调用SafeArrayReleaseData和SafeArrayReleaseDescriptor在map中将指针对应的计数-1,并将指针所对应的key从map中删除。
开发者应该是用这种方式修复了一些UAF问题。但修复方案中没有考虑到当Join/Filter传入的数组中有类对象时,在Public Default Property Get这一潜在的回调中可以对数组进行操作(比如ReDim)。这样,当调用完 rtJoin/rtFilter后返回VbsJoin/VbsFilter时,对应的pSafeArray/pvData指针已被更新,原先的设计是将之前已在Map中“注册”的指针传入后续的SafeArrayReleaseData/SafeArrayReleaseDescriptor进行引用计数减操作,但现在传入SafeArrayReleaseData/SafeArrayReleaseDescriptor的指针均不在map中(因为被重新创建了)。这导致在调用RefCountMap
具体地,开发者借助RefCountMap类实现了一个“伪引用计数机制”,通过一个map
相关操作函数的声明如下:
RefCountMap
了解了这些知识后,回过头去理解@elli0tn0phacker报告中的Figure 5就会容易多了。
PoC分析
@elli0tn0phacker给出的poc大致如下:
由于漏洞的存在,我们知道arr(0) = 1语句执行前arr已被释放,而且从代码中可以看到arr是在回调中被ReDim的。那么arr到底存在哪里?为什么arr(0) = 1索引的是ReDim后被释放的SafeArray,而不是Redim前的SafeArray?
这就涉及到 vbscript虚拟机的相关知识。
卡巴斯基实验室的Boris Larin曾写过一篇关于vbscript虚拟机的文章,并且开源了相关的调试插件。
在文章中,作者对vbscript虚拟机进行了比较细致的介绍。vbscript的所有代码都会先被编译为P-Code,随后通过CScriptRuntime::RunNoEH对所有P-Code进行解释执行,CScriptRuntime对象的成员变量中存储着解释所需的许多信息,比较重要的几个如下:
借助调试插件,我们可以得到 PoC代码编译后的P-Code:
以下是上述用到的部分指令对应的字节码(全部指令请参考Boris的插件源码):
从P-Code中可以看出, arr(0) = 1这句对应的指令索引的是本地变量栈(OP_CallLclSt, 0x25),Call Join(arr)这句对应的指令索引的也是本地变量栈(OP_LocalAdr, 0x19),从两个指令名称中我们可以猜测arr被存储在本地变量栈上。
在IDA Pro中对vbscript!CScriptRuntime::RunNoEH进行逆向,我们来看一下上述两个指令解释分支的汇编代码:
上述两个分支都调用了CScriptRuntime::PvarLocal方法,再来看一下CScriptRuntime::PvarLocal方法的实现:
可以看到CScriptRuntime::PvarLocal接收一个索引,并且基于CScriptRuntime对象+0x28或0x2C处的值进行偏移运算。调试时发现PoC两处对arr的操作索引均为1,所以存储arr的地址为:
poi(pCScriptRuntime + 0x28) - 0x10*1
上述分析验证了上面对于指令作用的猜想,PoC中每次使用arr变量时,都会传入对应的索引去本地变量栈中进行访问。
明白了arr的存取原理后,我们可以清晰地在调试器中观察arr的变化过程,从而理解整个UAF的过程。
笔者在开启页堆后对PoC进行了调试。我们先将断点下到OP_LocalAdr指令的解释分支,可以看到Join(arr)执行时访问到的arr,命中断点时ebx即为CScriptRuntime,调试时arr从本地变量栈(ebx+0x28)进行索引,读者请留意下图中蓝色高亮的指针,ReDim语句执行后它会发生变化。
我们对上图中高亮数据(SafeArray指针)所在的内存下一个写入断点,观察这个位置上数据的几次变化过程。
第一次是在ReDim(OP_ArrNamReDim)执行时,对之前arr的清理阶段(OP_ArrNamReDim指令的解释流程在后面“修复方案”一节中会进一步说明。):
第二次是在OP_ArrNamReDim执行时,将新创建的arr复制到本地变量栈的对应内存处,可以看到蓝色高亮处的指针已经发生变化,此时的SafeArray已经变为刚刚创建的二维数组。
最后,我们将断点下到OP_CallLclSt的解释分支,目的是断在arr(0) = 1这句对arr的访问过程,由于“漏洞成因”所描述的设计上的问题,此时本地变量栈上的arr已经被释放:
追踪到的释放栈回溯如下图,读者可以看到,这个不当的释放正是由于SafeArrayReleaseDescriptor传入了未在map注册的指针所导致。
通过以上调试,读者应该可以清晰感受到整个Use After Free过程。
修复方案
清楚漏洞成因后,我们来看一下微软在9月更新中是如何修复该漏洞的。笔者用Bidiff工具比对了8月更新和9月更新两个vbscript.dll,发现在rtJoin(rtFilter均类似,下面只以rtJoin进行说明)函数中,在对数组内的元素进行操作前后,加了一对SafeArrayLock/SafeArrayUnlock函数:
微软采用对SafeArray加锁的方式来修补这个由之前的补丁引入的问题。SafeArrayLock会令pSafeArr->cLocks的值+1。这样,当在安装9月补丁后再次打开PoC。由于前面的+1操作,就可以令ReDim指令无法得到正常执行,我们来看一下具体的逻辑。
这里再引述一下上面提到的P-Code,可以看到ReDim arr(1, 1)这句语句对应的P-Code如下:
笔者在调试器中跟了一遍OP_ArrNamReDim指令(0x0A) 的执行逻辑,发现有如下几个关键点:
有意思的是,调试前笔者以为这里的ReDim最终会调用oleaut32!SafeArrayRedim函数,结果并没有。
结合上述逻辑,当补丁中在操作Join传入的数组前,SafeArrayLock令pSafeArr->cLocks从0变为1,从而在执行ReDim arr(1, 1)对应的指令时,无法通过3.1.1这一步,新数组无法被创建,Join函数执行完后本地变量栈中的数组指针不会得到更新,之前的UAF问题也就无从谈起了。Filter函数的修复方案同上。
以下为上述过程中涉及到的函数调用及说明:
这个修复方案和CVE-2016-0189的修复方案思路一致。
利用编写
@elli0tn0phacker在他的报告中已经给出了这个漏洞的exploit编写思路,但没有公布完整代码。作为概念验证,笔者亲手编写了对应的exploit,以下对部分细节进行说明。
伪造超长数组
通过触发漏洞,可以得到一块大小为0x30的空闲内存。借助堆的特性,如果在Join函数执行完后立即申请一些字符串长度为(0x30 - 4)的BSTR对象,就可以实现对被释放内存的占位。减4是因为BSTR的字符串前面还有4字节的长度域,会一并申请。
实践证明这里的操作还是比较简单的,并不需要过多的堆风水技巧,下面是一个可以成功占位的代码示例:
占位后,因为笔者已经在字符串中构造了假的超长数组,当下次访问arr时,成功占位的字符串会被解释为SafeArray结构体,从而得到一个基地址为0,元素个数为0x7fffffff,元素大小为1的超长数组。
任意地址读取
这部分,以及如何构造一块可读写内存的步骤请参考@elli0tn0phacker的报告,相关步骤实现起来非常简单,这里不再重复叙述。
Bypass ASLR
在前面的基础上,就可以泄露一个指针对象以绕过ASLR,这里笔者采用的方法和和CVE-2019-0752一样,泄露一个Scripting.Dictionary对象的虚表指针,具体操作如下:
虚函数劫持
若PoC要在windows 10上执行,必须要绕过CFG。笔者最终采用了@elli0tn0phacker在他报告中提到的方法,即对CVE-2019-0752的利用方式稍作改动:
1.借助BSTR复制并伪造一个假的Dictionary虚表(fake_vtable),并改写Dictionary.Exists函数指针为kernel32!WinExec,由于kernel32!WinExec是系统自带函数,因此可以绕过CFG检测
2.借助BSTR复制并伪造一个假的Dictionary对象(fake_dict),将虚表替换为上述的假虚表,将WinExec的命令行参数写入虚表指针后4字节开始的地址
3.将假的Dictionary对象所对应BSTR的type设为0x09,使之成为一个对象(VT_DISPATCH)
4.调用fake_dict.Exists,使控制流导向WinExec函数,命令行参数在步骤2中已经构造好
这个过程的示例代码如下:
利用约束
这个漏洞利用在任意地址写上有一些受限条件,@elli0tn0phacker已在他的报告中提到,这里也不再重复叙述。
这里提一个笔者编写利用时遇到的问题,笔者一开始是在windows7 sp1 x86环境下写的利用,代码全部写完后发现计算器无法弹出,一番调试后发现,传入WinExec函数的命令行参数无法得到正常解释,原因也很简单,来看一下某次win7调试时最终传给WinExec的参数:
出于利用构造的约束条件,命令行参数的前4个字符是由前面伪造的虚表的地址解释而来,这种情况下很容易造成前4个字符里面有多余字符,因此WinExec也就不能按预期执行后续的命令行。笔者一开始想到的将虚表伪造到0x20202020这个地址,这样命令行参数的前4个字符可以被解释为空格,不会影响整个命令行的解释。但该漏洞中对指定地址的连续写是受限的,笔者最终放弃了这个思路。
后来笔者将未加修改的exploit在win10环境试了一下,发现计算器可以成功弹出,以下为某次在win10下调试得到的参数及伪造的虚函数表:
笔者推测win10和win7下进程创建相关函数对命令行参数的处理存在一些差异,win10上的容错性更高一点。
代码执行
最终,笔者成功在windows 10 1709 x86系统的2019年8月全补丁环境上弹出一个计算器:
参考资料
《Delving deep into VBScript》
《From BinDiff to Zero-Day: A Proof of Concept Exploiting CVE-2019-1208 in Internet Explorer》
《RCE WITHOUT NATIVE CODE: EXPLOITATION OF A WRITE-WHAT-WHERE IN INTERNET EXPLORER》