宋凯(exp-sky):Chakra引擎的非JIT漏洞与利用

摘要:随着我们赢得2017年Pwn2Own比赛,赛场上包括我们的多只参赛队伍都使用了Chakra JIT相关的漏洞。所以近年来脚本引擎的JIT相关的漏洞引起了大家的广泛关注。但这并不代表Chakra脚本引擎中其它的逻辑就是安全的。宋凯将详细介绍一个Chakra脚本引擎,非JIT相关的漏洞,以及这个漏洞利用的详细过程。在最开始写这个漏洞利用的时候,曾一度认为其是不可用的(比如Array对象的size变大可能导致安全威胁,但是变小该如何利用呢?)。最后通过组合多种技巧,成功实现在Edge浏览器中的任意代码执行。

宋凯

宋凯(exp-sky)    腾讯安全玄武实验室高级安全研究员

今天和大家主要谈一下Charka引擎漏洞细节,在Edge浏览器里绕过所有系统保护机制的技术与技巧。现在各大厂商因为攻防对抗的不断升级,都在不断地加入这些musication(音108:33)来增加攻击者的攻击成本,这也应该是全球首发的,绕过迄今为止Edge浏览器,在Windows平台最新版本中有效的漏洞应对方案。

我首先介绍一下Charka漏洞,这是我们团队为了2017年Defcon所准备的备选方案之一。为什么说这个漏洞非常有趣呢?一开始我们写这个漏洞的时候,一度认为这个漏洞是不可用的。如果你能修改它的address让它可以越界访问,那么它是可以用的,可以产生越界的图或者写,都会对浏览器安全造成危害。如果我们只能变小怎么办?非但没有缓冲区越界访问,反而可访问的空间变小了,这样的情况下如何利用漏洞呢?后面针对性地介绍在IE浏览器里保护机制,设计方法和绕过思路。

这是我经历多个步骤之后才能实现屏幕中的效果,大家仔细看一下标红里的值,是完全可控的,现在所尝试的一个动作实际是个内存写,这实际上实现了大范围可控的越界写的能力,要实现这一步,我们要经历比较多的步骤。这个漏洞在2016年5月份被我们发现了,在2017年,它往前一个月的补丁被撞或者被修补。详细讲这个漏洞过程中,先看一下所需要的基础知识。

只要了解一个对象就可以了,就是NativelntArray,Array设计之初就是为了提高效率,与正常的,能存储数据以及对象组成区分开,它里面只能存储纯的、整形的数据。Array设计本身它是个始初数据,Array里所描述的空间范围不不一定全是被映射状态,可以存在空洞。我初始化了哪一部分就对哪一部分进行赋值。它的具体实现方式是用了Segment的结构,这几个域,Left表示当前Segment的位置,是从那里开始的,length是表示当前segment初始化元素的长度,Size是用当年segment的buffer用多大长度来进行初始化,就是我buffer的大小,后面用size来申请内存buffer。因为稀疏数组支持从任意位置开始,所以用Next segment指针维护单向列表。

现在我们就知道了这样的结构,在内存中大致是这样的状态,前面Array头里是虚函数表的指针,后面是segment指针,也就是Head,Head里有Left、segment length、segment size等等这样。这样的设计,其中有两个设计者所认为的约定,这两个违法会会产生问题的。第一个约定是,这个NativeInt的头里面有个lenghth,这个length表示的是我整个Array的空间到底有多大。里面每一个segment表示,我当前segment的空间有多大。第一个约定就是,我Array头里length所代表的空间(大小)一定要大于最后一个segment,也就是当前Array中初始化的的,最后一个segment的Left+Length,最后一个segment被初始化元素的位置。这是第一个假设。

第二个假设,在segment里,length一定要小于等于size,因为它用size初始化了缓冲区,length表示的意思是我往里面哪个位置,最后的位置赋过值。所以,length一定要小于size,否则它就越界了。这是两个设计者所遵循的假设。

我们这个漏洞最经典的POC是这几行代码,实际也就四五行。它能实现怎样的效果呢?首先,穿越一个1024长度的Array,默认就会创建成一个Native的Array,我们对它进行回调,创建了一个get回调,在回调里我们修改了Array的length,把它变成零,我们在0x2d处写了一个1,都是非常简单的操作。之后我们调用一个Array的reverse函数,reverse函数的意义也很简单,将我刚才所介绍的Array头里的length,以这个维空间来进行反转,将里面所有的segment以及segment里所有的元素进行翻转。

它会导致什么样的效果呢?违反了我刚才所介绍的两个约定其中的一个。刚才我在0x2d处设置了1,现在Array length变成了0x2e,这没有问题。现在看一下它的Head segment,这是一个未初始化默认的segment,left是0表示往里写过任何值,它的size是0x2e,这是默认的segment大小,buffer也是用0xe2×4这样的D-world(音)空间来申请内存。问题就出现在后面的next第二个segment里,Next是0x3d2,length是0x2e,size也是0x2e。这就违反了我刚才介绍的第一个约定,Array头的length所表示的空间,现在已经小于Left+length,现在我们并不能造成实际任何安全威胁,但它已经违反了第一个约定。

在内存中,NativeIntArray的头里,length是0x2e,是head segment的指针。在这个指针里,Head segment就是一位触发的,但是Next的segment指针,length是0x2(音)3d2,它的length以及size都是0x2。为什么会出现这样的状态,为什么会产生这样的不一致,这最起码违背了设计者的约定。在ReverseHelper函数里,typed函数里其实想要过渡到当前Array里所有回调所产生的动态值,把它们都整理好了放在自己的位置里。它比会回调获取值。问题就出现在,回调之前,外面闯进来一个length。后面在回调之后,它没有做任何检查,依然沿用了这个length。这就导致前面所产生的不一致,在回调里把Array length变小了,但它依然用之前大的length进行Array空间的翻转,这就导致Array头里的length要小于它最后面segment所认为的空间,产生这样的不一致。

这有什么用呢?它能产生什么危害呢?如何利用这样细微的威胁,细微的缺陷来是实现在Edge浏览器里整套的攻击。

第一步,非常简单,先把刚才所设计的回调删掉,防止影响到后面的利用。然后将Array0A处写个1,再调一次Reverse,会产生怎样的后果呢?它变成这样(图),可以看到,Head size由刚才的0x2e变成了0x23,刚才通过Array head length变小,现在可以把segment的size变小。

看一下Head next的Left也是0x23,也会变成0x23。这里回到一个segment的length如果大于size的话,这个漏洞怎么利用?看一下内存,已经由它的size从0x2e变成了0x23,变小了。最开始我们写漏洞的时候,甚至认为它是不可用的。一个size让它变大可以产生越界,变小我们应该怎么用?为什么会变小?首先在ReverseHelper函数里,segment的Head在计算Left的时候,会调用后面的EnsureSizeInBound函数,在这个函数里,它会先取Next,刚才的Next是0x23,在翻转的过程中发现,前面segment的left是0x23,到当前segment的size是0x2e,也就是两个segment出现了重合,它要抵消掉这样的重合怎么办呢?它把当前的segment size由0x2e变成了0x23,缩小了一点,实际是为安全来进行的考虑。最后调min,取一个最小的。

基于安全的考虑,但没有做好,在修改size的时候length没有改,所以我们实现了第二个不一致,segment的length小于了size,但现在不能造成任何的安全威胁,因为buffer还是由之前的0x2e来申请。怎么产生一个安全问题呢?创造一个OOB,只要一行就可以了。这行意义是什么呢?刚才我们所说的是NativeIntArray,它里面存储一个纯的整形数据,但我们往往赋值一个对象时会进行类型转化,把它converse为一个正常的JavaScriptArray。这种Array在进行类型转换过程当中,一定要重新申请segment的buffer,因为它的数据长度变了。之前NativeIntArray所存储的数据宽度是4个字节,但JavaScriptArray里所存储的数据宽度是以一个对象指针来存储,64位上有8个字节。所以,他一定要重新申请buffer,重新申请segment,这样就产生了一个真实的安全问题,它会用size来重新申请segment的buffer,但length依然没有动,就直接复制过来了。我们现在拥有了用0x23×0x08创建的缓冲区,但segment的length是0x2e,我们拥有了0x0b×0x08越界的能力,通过类型转换。这就是违反了设计约定所导致的后果,开发者觉得没问题,但实际上不是的。在内存中就会产生这样的状态,segment的length是0x2e,它的size是0x23,我后面标红的框是OOB可以访问的一部分。

第三步,怎么实际地越界写,我们只有写一个数据改变内存中某一个对象某一个域中的状态才能进一步扩大我们的控制力。

我们创建一个新的Array,并且也往0x2上的一个对象,通过创建一个segment,这个segment和能产生OOB的segment大小是一致的,一模一样,类型一模一样。所以,它一定会出现在刚才越界的segment后面,我现在拥有了越界的能力,又把一个Array segment布置在它后面。第二,我们就可以修改它的length和size来实现真正的越界。

第四步,怎么改?这里面还是有限制,我们应用过程中不难应用。我直接往0xffffffff这个数,其实是不行的(一会儿告诉大家为什么不行),我们所使用的方法是先调用一次Reverse函数,然后在0x09的位置上赋值上0,然后减1,在JavaScriptArray里,在Edge里,它赋上0再减1,可以让我们创造出一个32位最大的整形。如果直接赋值,超过0x7f的话,它会把它转成对象,数据前面会有一个flag,这是一个小技巧。再调用一次Reverse可以写后面的Array2的segment里的size。成功之后,我们发现终于可以让一个size变大了,通过我们最开始非常细微的小缺陷,可以看到32位是最大的整形。

为什么说直接赋值不行?稀疏数值是用segment指针来维持segment之间的关系,如果往0x24处上写一个值,它不会直接往Head的buffer后面写,因为他发现有Next的指针,我看一下当前的size与我写的Index是否相同,你大于它,那么我去nextsegment,他会没有问题地写到next segment里,另一个buffer里没有越界。这种直接写的方式不行就是因为这样。现在我们拥有了一个Array的segment size是非常大的segment,它实际已经拥有了越界的能力,我们写什么就很重要了。我们可以往非常大的边沿处写上一个有一定限制的值,而不是所有的值,大于CFF整形的值是不行的,但我们拥有一个非常大的范围空间写的能力。我们经过了五步,终于实现了在内存空间中一个大范围的,一定写入值限制的能力,这已经是很大的突破了。

第六步,要实现全内存的读写,在64位地址空间中如果没有全内存的读写,后面的Matevition(音127:43)非常难以绕过。我选了另一个为什么选择NativeIntArray,通过一些内存布局,可以让它出现我刚才所越界的,能访问到的一个内存中,我修改它的Array头上length、Head.length以及head.size。为什么选择NativeIntArray呢?这是一个非常重要的结构,并且它是要Inline head创建的这个NativeIntArray才行。当我把这三个域都修改了之后,通过它就可以进一步地实现一个的范围的全数据铣鞋,刚才我们说写的一点数据是有限制的,通过这个可以实现全数据写入。我们又扩大了一个可控的能力,改完之后就是这样。

NativeIntArray实际是相对位置,4个G的全数据写和读,但它并没有不能实现64位地址空间中所有内存的读写,还需要配合上Array buffer,这是可以用于读取基础数据的类型。我们只要将这两个域进行修改就可以了,一个是长度,二个是缓冲区的指针。把长度变到最大,指针的高32位改成我们想写入的地址,通过SetUint32这样的API,它的第一个参数就是Offset,通过它,我们就可以设置我们要写数据的低32位,后面就是32位完整的数据,至此,我们就拥有了在64位进程地址空间中任意地址的任意数据写的能力,这个能力就非常得重要。读就非常简单,我们用Get Uint32这样的API,也是传上我们低32位的地址,就可以读任意数据。

至此,我们通过6步将开始非常微小的缺陷变成了在Edge进程中64位地址空间中任意地址的读和写任意数据的能力。拥有这个能力的情况下,我们还有很困难要面临,实际在通常的漏洞利用过程中,我们会认为,现在已经达到了肯定能实现远程代码执行能力,但后面这些保护机制我们还是要有。

ASLR和DEP这两个保护机制是非常古老的保护机制,在早期的软件对抗过程中就已经出现了,一是地址空间随机化,二是数据不可执行的保护机制。为什么我刚才选择NativeInt就落在这儿,我们通常的信息泄露想要Bypass ASLR需要获得两种类型的地址,一种是模块的递减,可以通过泄露对象的虚函数表来实现;第二是对象的地址,它在堆里,我们最好拥有一个结构里,我所泄漏它里面数据的时候,它有个指向对象最终的指针。NativeIntArray就恰巧满足了这两个条件,会为我们后面利用带来非常大的方便。Show&表(音)的技术可以通过它来间接泄漏我们模块的能力,后面的segment head指针可以用来帮助我们泄露object的地址。我们拥有了全内存读写的能力可以解析进程中任意对象,这是在我后面所讲的保护机制出现之前,这实际是早期的应用过程中,我们可以通过多层解析找到一个ShellCode的地址。现在内存中可以找到任意对象,任意我们需要东西的地址。

DEP怎么过?早期非常简单,可以通过ROP,因为早期对这块没有保护,我们可以通过ROP调用Virtual protect或者VirtualAlloc函数来实现修改内存中的属性,加上S-build way(音132:40)。这是早期的,后来不行了,因为后面有新的保护机制,我回介绍到方法。早期的时候是可以通过ROP修改内存属性,再执行ShellCode,实际上在Windows10的RS2版本之前到这儿基本就行了。

微软随着不断推出新的meetcation(音)就实现了后面三种保护机制CFG(Control Flow Guard)。

刚才讲漏洞利用过程中讲实现执行ROP,必须要控制程序的可知性流程RIP,要想控制这个寄存器,通常早期比较简单的方法是,我修改虚函数表,调用它的虚函数进行间接控制。微软知道之后推出了相应的保护方案,用于保护程序间接跳转,防止攻击者控制程序的执行流程。因为间接跳转,早期的时候我们会套个虚函数,有上面一个代码。后面加上保护机制之后会出现后面的调用,这个调用会对我们所调用的函数进行验证。逻辑是这样,在我们程序编译的时候,编译器会将所有的刚才形式上的间接跳转所调用的函数来创建一个Bitmap表,这个表里每一位代表每一个函数是否允许调用。当你程序在运行过程中,刚才那个函数会通过这个算法来验证你这个函数是否允许调用,不允许的话直接抛出异常,当前进程就退出了。我们想用这种方式来保护浏览器进程的安全。

它的算法是这样的,32位过程中,如果我想要调用一个函数,它高三个字节,24个bit,会用作我们bitmap的index,最低字节高5个bit,会把它当作我dword里的位的index,就是哪一位。这里是01010,实际是个十进制的10,它表示第10位,在这个bitmap里,这个第10位如果是0的话是不允许你调用的,如果是1它允许调用。这是CFG验证的一个方式。

怎么过呢?CFG只保护了程序中间接调用,在编译过程中能分析出来的间接调用,但没有保护栈的返回技术,我们在调用栈的时候会把参数压栈,把当前函数的代码指针地址压栈,函数返回的时候它又会抽回来。我们所使用的方法是,通过泄漏浏览器进程中的栈地址,找到它的栈的范围,左边是我泄漏出来的,右边是我调试器里的,一模一样。把这个地址泄露出来之后,我们可以做这样的事情,找到一些特殊的函数,主动调它。在栈中一定会存在这样的函数内的返回机制。因为我们拥有了全内存读写信能力,可以在栈中搜索我们想要写的函数的地址。我们可以通过修改栈中某一个特殊我们设计好的函数返回值,在这个函数reture的时候间接控制这个程序的执行流程。最终Return的时候,Return的目的和代码我们就完全可控了。

通过前面一个非常微小的漏洞,实现了全内存读写,又进一步绕过了CFG,我们可以获得程序的控制流程,这已经进了一大步。因为浏览器都是在沙箱里的,沙箱的防护越来越强了。两种方案,要么找沙箱的漏洞Bypass sandbox,要么找conode的漏洞来提权。实际现在要想实现沙箱的穿透,所需要的代码量可能非常巨大大,特别是内核提权的漏洞利用的开发。早期我们都会用ShellCode去load一个library,在Library里写,这是一个比较方便的设计过程。现在微软也知道这种方式,所以,推出了两个新的保护方式,CIG以及ACG,就是要组织攻击者过于容易地提权,过于容易在浏览器内执行恶意代码,我们虽然进行了程序控制,但并不能非常方便地执行恶意代码。理论上,我们可以用ROP实现任意功能,但对纯沙箱漏洞或内核提权漏洞来说成本太高了,并且不便于移植,不同的版本更新都会有影响,包括漏洞利用的影响都会非常大。

CIG实际是个代码签名验证保护,当你加载到进程中,通过LoadLibray这个API加载到进程中,任意的模块,所有的模块都会对它进行签名验证,只有微软的有效签名模块才被允许加载到进程中。这个保护机制是通过这个函数SetProcessMitigationPolicy这样的函数来进行主动加入的。

CIG保护机制的实现,在用户态和内核态都有,用户态比较简单,在LoadLibray里来作为入口,内核态,到内核的时候它会检查,你所loadlibray镜像签名是不是合法,不是的话我就拒绝你。现在问题变成了,我们怎么样能loadlibray一个没有软签名的问题。刚才看到的实现非常简单,LoadLibray API为入口,我们可以创建一个ShellCode,我们不调用Libray,用ShellCode实现整个镜像在内存中的加载,通过解决它每个节,对齐,加载到内存,修复它的导入到处表以及资源的偏移,在创建新的线程,在UNkey中,实现我们不调用它的Libray也能加载镜像的的能力。这是我们为2017年Windows RSR准备的方案之一,通过前面相关保护机制,之后到ShellCode,动态的,任意的,非微软签名镜像加载到当前的内存中。微软也知道这样的攻击方法,所以紧接着又推出了ACG。这是一个比较强的保护。它实际是为了保护动态可执行代码的创建,为了防护这样的攻击手段。我实际一定还是需要ShellCode,所以需要一段可执行,并可写的内存,微软这个保护机制就是为了防御这样的攻击方式,让你没办法动态地创建可执行的内存,并且也不可以修改已经存在的这样的内存空间。

WINAPI也是这样,主动对进程进行设置的保护机制。它实现的方式是这样,我们修改Windows中的内存属性主要是为了API,workalong(音142:20)和workcontect(音),这些作为入口进入到内核时,会验证当前进程是否开了保护机制,如果开了,那么你所修改的内存是不是允许;如果他发现你创建了任何可执行属性的内存,它都会给你杀掉。

这样我们刚才的方案就不行了,我们想创建一个ShellCode,动态load包含了提权、沙箱穿越利用的DLL不行了,那么怎么办呢?现在我们还有一个能力ROP,不能创建可执行内存,但我们依然可以依赖于现有可执行代码来完成一部分功能,完成什么功能呢?这是我在XVCore(音143:20)随便找到的一些buget,通过rbx、rax、rcx,它能帮助实现什么功能?可以实现调用WindowsAPI的功能。为什么ROP实现完整的提权和穿沙箱的逻辑比较困难,因为Windows版本不断更新,新的保护机制又不断推出,可能我们的利用都需要不断地变化,如果你每次都通过修改ROP来实现的话,这是非常庞大的工作量,打Pwn2own比赛的前一天,微软会推出补丁,我们只有不到24个小时的时间,怎么办呢?我们一定需要通用的,在Windows推出新版本,代码有比较大修改时,我们依然能实现快速的适配解决方案,可以通过ROP来实现Windows API的调用,那一波的参数,那一类型的参数,以及我们能读取到它的返回技术,我们ROP只做这样简单的事情,别的什么都不干。实际在Windows平台中,我们拥有了调用任意系统API能力的时候,就相当于获得了远程代码执行的能力,其他的都没什么问题。

CFG的时候,我们可以控制程序的返回地址,ROP会切换到数据可控的内存空间中,这里面保存了函数参数,以及函数的返回,API函数的递减,以及我保存这个API所调用之后的值,在这一过程,API网络安全保存返回值之后再切回到原先的栈,所以,整个栈的影响非常小,什么数据都不被破坏,只是跳了一个栈堆而已,并且非常稳定。在我测试来看,连续调用几千个Windows系统API,不会造成任何的内存损坏。我们还是找到了一个任意比较特殊的函数,知道它在栈中就一份,并且这一份一定是由我们创建的,包括修改返回地址不只是ROP,处理函数的参数以及保存函数的返回值,最后我们再把栈切换回去,达到正常代码执行流程继续执行的效果。

接着还是需要做大量工作,我们要想实现调用系统API,每个API都需要单独实现,我们把刚才所说的跨API通用功能先实现了,通过简单的代码就可以实现ReFill(音146:45)的函数,Windows API函数的调用与调用正常的C++代码是一样的。我们通过JavaScript就可以实现。穿沙箱常见的是使用Com接口的缺陷,实际com这样复杂的,面向对象的调用依然没有问题,只要符合它的调用原理就可以了。(视频)这是我们今年2月份做出的整套应用,用的不是这个洞,因为这个漏洞已经被补上了,是另外一个漏洞。里面所有madecation bycose(音147:30)在现在依然是有效的,只不过漏洞我们已经提交给微软,现在已经修复了。之所以产生这个stress(音),是因为我们穿沙箱的漏洞需要,整个过程十几秒,不到二十秒的时间就可以实现在最新版的Windows10种,Edge浏览器也是最新的,只要你访问一个链接,在十几秒内,我就可以拿到当前用户的权限任意代码的能力。这是它的权限,我们已经穿透了它的沙箱,这是使用穿沙箱的漏洞,逻辑也非常复杂,可能需要几百行C++代码的能力,我们全是使用JavaScript调用任意系统,Windows系统中的API处理参数,保存返回值实现的能力。

我整个演讲到这里就结束了,大家有什么问题可以提问。

主持人潘柱廷:大家有没有什么问题,里面可能需要有一定浏览器研究基础的同学才能深入地听明白。

Q:ACG的时候,你最后其实不调H-lock(音149:35)?你用的ROP调任意API效果??(149:40,听不清)。

宋凯(exp-sky):我们穿沙箱只是举了一个API的例子,如果要是他们相关漏洞,需要指定特定的comp对象和接口功能,包括组织一些BSTR这种特殊结构的对象参数,这是一个非常复杂的过程,ROP所做的功能,我实现调用提前穿沙箱的能力,中间所需要用到的API我全都可以调用,它只是作为调用API的小工具。可以理解为是这样一个能力。

Q:ALSR内存随机化,除了看虚函数的返回地址,还有其他更好的方法吗?

宋凯(exp-sky):这实际是看你的漏洞利用场景,因为我们在这里面需要用ROP实现后面整套应用,如果没有泄露镜像地址能力的话,很难找到ROP的具体位置。如果你不适用ROP,早期一段应用时我们可以创建可执行内存,既然我们可以创建可执行内存,我们不需要泄漏??(151:29),也不需要这个表,直接调用ShellCode就实现所有功能,我们可以把提权代码放在ShellCode里。针对不同的软件和利用时的漏洞环境可能是不一样的,我举的这个例子只是在我这个漏洞环境中需要这样。在一些漏洞环境中可能不需要这样一种功能。

Q:刚才旁边的朋友想问一下,还要学多少年才能进玄武实验室?进玄武实验室,需要具备哪些方面的知识?

宋凯(exp-sky):实际我们实验室是做面向实际应用攻击的研究以及防御研究,刚毕业的同学,大家对比较感兴趣,踏踏实实地做一些真正自己喜欢的事情,这样可以大大提高你学习的速度,你越专注,你学习的东西越快,可以想到正常人想不到的一些点。要知道微软或Google这种大厂商他们所雇的开发者都是业内全球顶尖的,我们要找的漏洞实际就是要超越这些顶尖开发者所拥有的知识。我认为,最重要的是找到自己喜欢的(方向),当然安全所涉及到的领域太多了,我们实验室也包括硬件、移动、Windows平台、Linux平台都会有同事在研究,在这么多领域情况下找到自己最喜欢的,专注地研究下去,一定可以做到比较好的水平。

上一篇:冯继强(风宁):AI领域 对抗欺骗与安全防御

下一篇:张超:漏洞挖掘的艺术