前言
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》