蝴蝶效应与程序错误---一个渣洞的利用
1.介绍
一只南美洲亚马孙河流域热带雨林中的蝴蝶,偶尔扇动几下翅膀,可能在美国德克萨斯引起一场龙卷风吗?这我不能确定,我能确定的是程序中的任意一个细微错误经过放大后都可能对程序产生灾难性的后果。在11月韩国首尔举行的PwnFest比赛中,我们利用了V8的一个逻辑错误(CVE-2016-9651)来实现Chrome的远程任意代码执行,这个逻辑错误非常微小,可以说是一个品相比较差的渣洞,但通过组合一些奇技淫巧,我们最终实现了对这个漏洞的稳定利用。这个漏洞给我的启示是:“绝不要轻易放弃一个漏洞,绝不要轻易判定一个漏洞不可利用”。
本文将按如下结构进行组织:第二节介绍V8引擎中”不可见的”对象私有属性;第三节将引出我们所利用的这个细微的逻辑错误;第四节介绍如何将这个逻辑错误转化为一个越界读的漏洞;第五节会介绍一种将越界读漏洞转化为越界写漏洞的思路,这一节是整个利用流程中最巧妙的一环;第六节是所有环节中最难的一步,详述如何进行全内存空间风水及如何将越界写漏洞转化为任意内存地址读写;第七节介绍从任意内存地址读写到任意代码执行。
2.隐形的私有属性
在JavaScript中,对象是一个关联数组,也可以看做是一个键值对的集合。这些键值对也被称为对象的属性。属性的键可以是字符串也可以是符号,如下所示:
代码片段1:对象属性
上述代码片段先定义了一个对象normalObject ,然后给这个对象增加了两个属性。这种可以通过JavaScript读取和修改的属性我把它们称作公有属性。可以通过JavaScript的Object对象提供的两个方法得到一个对象的所有公有属性的键,如下JavaScript语句可以得到代码1中normalObject 对象的所有公有属性的键。
执行结果:ownPublicKeys的值为["string", Symbol(d)]
在V8引擎中,除公有属性外,还有一些特殊的JavaScript 对象存在一些特殊的属性,这些属性只有引擎可以访问,对于用户JavaScript则是不可见的,我将这种属性称作私有属性。在V8引擎中,符号(Symbol)也包括两种,公有符号和私有符号,公有符号是用户JavaScript可以创建和使用的,私有符号则只有引擎可以创建,仅供引擎内部使用。私有属性通常使用私有符号作为键,因为用户JavaScript不能得到私有符号,所有也不能以私有符号为键访问私有属性。既然私有属性是隐形的,那如何才能观察到私有属性呢?d8 是V8引擎的Shell程序,通过d8调用运行时函数DebugPrint可以查看一个对象的所有属性。比如我们可以通过如下方法查看代码1中定义的对normalObject的所有属性:
从上示d8输出结果可知,normalObject仅有两个公有属性,没有私有属性。现在我们来查看一个特殊对象错误对象的属性情况。
对比一下specialObject对象的公有属性和所有属性可以发现所有属性比公有属性多出了一个键为stack_trace_symbol的属性,这个属性就是specialObject的一个私有属性。下一节将介绍与私有属性有关的一个v8引擎的逻辑错误。
3.微小的逻辑错误
在介绍这个逻辑错误之前,先了解下Object.assign这个方法,根据ECMAScript/262的解释[1]:
The assign function is used to copy the values of all of the enumerable own properties from one or more source objects to a target object
那么问题来了,私有属性是v8引擎内部使用的属性,其他JavaScript引擎可能根本就不存在私有属性,私有属性是否应该是可枚举的,私有属性应不应该在赋值时被拷贝,ECMAScript根本就没有做规定。我猜v8的开发人员在实现Object.assign时也没有很周密的考虑过这个问题。私有属性是供v8引擎内部使用的属性,一个对象的私有属性不应该能被赋给另一个对象,否则会导致私有属性的值被用户JavaScript修改。v8是一个高性能的JavaScript引擎,为了追求高性能, 很多函数的实现都有两个通道,一个快速通道和一个慢速通道,当一定的条件被满足时,v8引擎会采用快速通道以提高性能,因为使用快速通道出现漏洞的情况有不少先例,如CVE-2015-6764[2]、 CVE-2016-1646都是因为走快速通道而出现的问题。同样,在实现Object.assign时,v8也对其实现了快速通道,如下代码所示[3]:
代码片段2:逻辑错误
在Object.assign的快速通道的实现中,首先会判断当前赋值是否满足走快速通道的条件,如果不满足,则直接返回失败走慢速通道,如果满足则会简单的将源对象的所有属性都赋给目标对象,并没有过滤那些键是私有符号并且具有可枚举特性的属性。如果目标对象也具有相同的私有属性,则会造成私有属性重新赋值。这就是本文要讨论的逻辑错误。Google对这个错误的修复很简单[4],给对象增加任何属性时,如果此属性是私有属性,则给此属性增加不可枚举特性。现在蝴蝶已经找到了,那它如何扇动翅膀可以实现远程任意代码执行呢,我们从第一扇开始,将逻辑错误转化为越界读漏洞。
4.从逻辑错误到越界读
现在我们有了将对象的可枚举私有属性重赋值的能力,为了利用这种能力,我遍历了v8中所有的私有符号[5],尝试给以这些私有符号为键的私有属性重新赋值,希望能能搅乱v8引擎的内部执行流程,令人失望的是我并没有多大收获,不过有两个私有符号引起了我的注意,它们是class_start_position_symbol和class_end_position_symbol,从这两个符号的前缀我们猜测这两个私有符号可能与JavaScript中的class有关。于是我们定义了一个class来观察它的所有属性。
果不其然,新定义的class中确实存在这两个私有属性。从键的名字和值可以猜测这两个属性决定了class的定义在源码中的起止位置。现在我们可以通过给这两个属性重新赋值来实现越界读。
上图是在Chrome 54.0.2840.99 的console中的运行输出结果,最后一行等同于short.toString()的结果,我们可以看到,最后一行的最后两个字符不正常,它们是发生了越界读的结果。可以通过substr方法得到越界字符串的一个子串,使这个子串完全是未初始化内存或者部分是初始化内存部分是未初始化化内存都是可行的。
5.从越界读到越界写
在检查了所有其他私有符号后,并没有发现其他有意义的私有属性重赋值可被利用,现在我们唯一的收获是有了一个越界读漏洞,那么一个越界读漏洞可以转换为越界写吗?听起来匪夷所思,但在一定条件下是可以的。第四节的最后我们得到了一个可越界读的字符串short.toString(),而在JavaScript中,字符串是不可变的,每次对它的修改(如append)都会返回一个新的字符串,那么如何使用这个可越界读的字符串实现越界写呢?首先我们需要了解这样一个事实,因为这是一个越界的字符串,而在程序执行时垃圾回收,内存分配操作是随机,所以越界部分的字符是不确定的,多次访问同一个越界的字符串返回的字符串内容可能是不一样的,这就间接使得字符串是可变的。然后需要了解JavaScript中的一组函数,escape和unescape,他们分别实现对字符串的编码和解码。unescape在v8中的内部实现如下[6]:
代码片段3:unescape的内部实现
unescape的v8内部实现可以分为三步,假设输入参数string是我们前面构造的越界字符串,第一步是计算这个字符串解码后需要的存储空间大小;第二步分配空间用来存储解码后的字符串;第三步进行真正的解码操作。第一步和第三步都扫描了整个输入串,但因为输入是一个越界串,第一步和第三步扫描的字符串的内容可能不一样,从而导致第一步计算出的长度并不是第三步所需要的长度,从而使第三步解码时发生越界写。需要注意的是,这个函数的实现并没有问题,根本原因是输入的字符串是一个越界串,这个越界串的内容是不确定的。我们举例来说明越界到底是如何发生的。因为v8新分配的对象都位于New Space[7], New Space采用的垃圾回收算法是Cheney's algorithm[8],所以New Space中对象的分配是顺序分配的。假设我们已经将New Space喷满字符串”%a”,越界写的执行流程示意如下:
a)下图为初始内存状态,全是未分配内存,内容为喷满的”%a”字符串;
b)下图为在创建了越界串之后,在执行unescape之前的内存状态,假设创建的越界串的内容为“dd%a”,其中”dd”位于已初始化的内存空间中,”%a”位于未分配的内存中;
c)下图为在执行了代码片段3的第二步后的内存状态,r代表随机值。分配的RawOneByteString为16字节,包括12字节的头部和4字节的解码后的字符(因为第一次访问越界字符串时内容为”dd%a”,所以计算的解码后的字符串应该是“dd%a”,为四个字节)
d)下图为执行完代码片段3的第三步后的内存状态,也就是完成unescape后的内存状态,因为在执行完第二步后越界字符串的内容已经变为”ddrrrr”,r是随机值,一般不会是字符’%’,所以解码后的字符串仍然是”ddrrrr”,导致两个字符的越界写。
6.从越界写到任意地址读写
从越界读到越界写是整个利用过程中最巧妙的一环,但从越界写到任意地址读写却是最难的一步。一个越界写漏洞要能被利用必须有三个必要条件,长度可控,写的源内容可控,被覆盖的目的内容可控。对这个漏洞而言,前两个条件很容易满足,但要满足第三个条件颇费周折。
从上一节的最后一个图中可以看到,越界写覆盖的两个字节是未分配的内存。因为v8中在New Space中分配对象是顺序分配的,而在代码片段3的第二步和第三步之间没有分配任何对象,所有RawOneByteString后总是未分配的内存空间,改写未分配的内存数据没有任何意义。那么如何使RawOneByteString对象后的内容是有意义的数据就成了从越界写到任意地址写的关键。
首先想到的是能不能控制在分配RawOneByteString时触发一次GC,使得分配的RawOneByteString被重新拷贝,从而使得它之后的内存是已分配的其它对象,经过深入分析后发现此路不通,因为一个新分配的对象的第一次GC拷贝只是在两个半空间(from space 和 to space)之间移动,拷贝后还是在New Space内部,拷贝后RawOneByteString之后的内存依然是未分配的内存数据。
第二种思路是越界写时写过New Space的边界,改写非New Space内存的数据。这需要跟在New Space后的内存区间是被映射的内存并且是可写的。New Space的内存范围是不连续的,它的基本块的大小为1MB,最大可以达到16MB,所以越界写时可以选择写过任意一个基本块的边界。我们需要通过地址空间布局将我们需要被覆盖的内容被映射到一个New Space基本块之后。将一个Large Space[7]的基本块映射到NewSpace基本块之后是一个比较好的选择,这样可以能覆盖Large Space中的堆对象。不过这里有个障碍,我们应该记得,当第一个参数为NULL时,mmap映射内存是总是返回mm->mmap_base到TASK_SIZE 之间能够满足映射大小范围的最高地址,也就是说一般多次mmap时返回的地址应该是连续的,这样的特性很有利于操纵内存空间布局,但很不幸的是,chrome在分配堆的基本块时,第一个参数给的是随机值,如下代码所示[9]:
这使得New Space和Large Space分配的基本块总是随机的,Large Space的基本块刚好位于New Space之后后几率很小。我们采取了两个技巧来保证Large Space基本块刚好分配在New Space基本块之后。
第一个技巧是使用web worker绕开不能进行地址空间布局的情形;New Space起始保留地址是1MB,为一个基本块,随着分配的对象的增加,最大可以增加到16MB,这16个基本块是不连续的,但一旦增加到16MB,它的地址范围就已经确定了,不能再修改,如果此时New Space的内存布局如下图所示:
即每一个New Space的基本块后都映射了一个只读的内存空间,这样无论怎样进行地址空间布局都不能在New Space之后映射Large Space,我们采用了web worker来避免产生这种状态,因为web worker是一个单独的JS实例,每一个web worker的New Space的地址空间都不一样,如果当前web worker处于上图所示状态,我们将结束此次利用,重新启动一个新的webworker来进行利用,期望新的web worker内存布局处于以下状态,至少有一个New Space基本块之后是没有映射的内存地址空间:
现在使用第二个技巧,我将它称为暴力风水,这与堆喷射不太一样,堆喷是指将地址空间喷满,但chrome对喷射有一定的限制,它对分配的v8对象和dom对象的总内存大小有限制,往往是还没将地址空间喷满,chrome就已经自动崩溃退出了。暴力风水的方法如下:先得到16个New Space 基本块的地址,然后触发映射一个Large Space基本块,我们通过分配一个超长字符串来分配一个Large Space基本块;判断此Large Space基本块是否位于某一New Space基本块之后,若不是,则释放此Large Space基本块,重新分配一个Large Space基本块进行判断,直到条件满足,记住满足条件的Large Space基本块之上的New Space基本块的地址,在此New Space基本块中触发越界写,覆盖紧随其后的Large Space基本块。
当在v8中分配一个特别大(大于kMaxRegularHeapObjectSize==507136)的JS对象时,这个对象会分配在Large Space中,在Large Space基本块中,分配的v8对象离基本块的首地址的偏移是0x8100,基本块的前0x8100个字节是基本块的头,要实现任意地址读写,我们只需要将Large Space中的超长字符串对象修改成JSArrayBuffer对象即可,但在改写前需要保存基本块的头,在改写后恢复,这样才能保证改写只修改了对象,没有破坏基本块的元数据。要精确的覆盖Large Space基本块中的超长字符串,根据unescape的解码规则有个较复杂的数学计算,下图是执行unescap前的内存示意图:
假设Large Space基本块的起始地址为border address,border address 之上是New Space,之下是Large Space, 需要被覆盖的超长字符串对象位于border+0x8100位置,我们构造一个越界串,它的起始地址为border-0x40000,结束地址为border-0x2018,其中border-0x40000到border-0x20000范围是已分配并已初始化的内存,存储了编码后的JSArrayBuffer对象和辅助填充数据”a”, border-0x20000到border-0x2018是未分配内存,存取的数据为堆喷后的残留数据” a”, 整个越界串的内容都是以”%xxy”的形式存在,y不是字符%,整个越界串的长度为(0x40000-0x2018),所以unescape代码片段3中第一步计算出的目的字符串的长度为(0x40000-0x2018)/2,起始地址为border-0x20000,执行完unescape后的内存示意图如下:
在执行完代码片段3第二步后,Write Point指向border-0x20000+0xc,因为NewRawOneByteString创建的对象的起始地址为border-0x20000,对象头为12个字节。 我们将代码片段3的第三步人为地再分成三步,第一步,解码从border-0x40000到border-0x20000的内容,因为此区间的内容为”%xxy”形式,所以解码后长度会减半,解码后写的地址范围为border-0x20000+0xc到border-0x10000+0xc,解码后的JSArrayBuffer位于此区间的border-0x17f18;第二步,解码从border-0x20000到border-0x10000的内容,因为此时此区间不含%号,所以解码只是简单拷贝,解码后长度不变,解码后写的地址范围为border-0x10000+0xc到border+0xc,解码后的JSArrayBuffer位于此区间的border-0x7f0c,第三步,解码从border-0x10000到border-0x2018(越界串的边界)的内容,这步解码还是简单拷贝,解码后写的地址范围为border+0xc到border+0xdfe8,解码后的JSArrayBuffer正好位于border+0x8100,覆盖了在Large Space中的超长字符串对象。在JavaScript空间引用此字符串其实是引用了一个恶意构造的JSArrayBuffer对象,通过这个JSArrayBuffer对象可以很容易实现任意地址读写,就不再赘述。
7.任意地址读写到任意代码执行
现在已经有了任意地址读写的能力,要将这种能力转为任意代码执行非常容易,这一步也是所有步骤中最容易的一步。Chrome中的JIT代码所在的页具有rwx属性,我们只需找到这样的页,覆盖JIT代码即可以执行ShellCode。找到JIT代码也很容易,下图是JSFunction对象的内存布局,其中kCodeEnryOffset所指的地址既是JSFucntion对象的JIT代码的地址。
8.总结
这篇文章从一个微小的逻辑漏洞出发,详细介绍了如何克服重重阻碍,利用这个漏洞实现稳定的任意代码执行。文中所述的将一个越界读漏洞转换为越界写漏洞的思路,应该也可以被一些其他的信息泄露漏洞所使用,希望对大家有所帮助。
对于漏洞的具体利用,此文中还有很多细节没有提及,真正的利用流程远比文中所述复杂,感兴趣的可以去看这个漏洞的详细利用。
上一篇: 如何做好网站关键词定位问题
下一篇: sqlmap注入