結論から言うと、5項目を実測しても、脅威モデルの範囲は変えませんでした。自社で試験的に開発しているターミナルアプリのプラグインシステムでは、脅威モデルを「バグった、または暴走したプラグイン」までとし、ヘルパープロセスが既に乗っ取られた前提は範囲外と明記してきました。この範囲が本当に妥当かを確かめるため、乗っ取られたヘルパーを模した別ターゲットHostileHelperを用意し、5項目でホスト側の耐性を実測しました。
脅威モデルの外側を、今回だけ実際に測る
前回と前々回の記事はどちらも、「まだ確かめていないこと」の節で同じ範囲を書いています。脅威モデルは「バグった、または暴走したプラグイン」までで、ヘルパープロセスが既に乗っ取られた前提での耐性は範囲外、というものです。
設計が固まってきたので、この範囲をこのまま確定させるか、広げるかを決めたくなりました。決めるには、外側を実際に測るしかありません。
同じPoCに、HostileHelperという別ターゲットを追加しました。既存のPluginHelperは、バグってハングすることはあってもプロトコルには従います。壊れたフレームや、想定外の頻度でのRPC送信を自分から作ることはありません。
HostileHelperはその制約を外したもので、FrameChannel.sendを経由せずに、fd 3へバイト列を直接書き込めます。FrameChannel.sendはJSONをエンコードし、長さプレフィックスを正しく計算してから送るので、プロトコルに従うヘルパーには壊れたフレームを作れません。HostileHelperはその層の外側から書くので、「乗っ取られたヘルパーにしか送れないバイト列」を初めて再現できます。
問いは5つです。壊れたフレームを送られてもホストは落ちないか、逆方向RPCを高頻度で連打されても他のヘルパーに影響しないか、一度渡したcapabilityをホスト側の判断だけで取り消せるか、plain bookmark設計に切り替えた場合に乗っ取られたときのリスクはどう変わるか、そしてfdやbookmarkで渡した権限をホストの想定を超えて広げられないか、です。
壊れたフレームを送っても、ホストは落ちない
HostileHelperに最初にやらせたのは、フレーミング層への攻撃です。4GiBを宣言する長さプレフィックスの後に数バイトしか送らない、上限の32MiBを1バイトだけ超える長さを宣言する、Nバイトを宣言してN−10バイトしか送らずソケットを開いたままにする、32MiBの上限いっぱいまで有効なJSONで埋める、UTF-8として不正なバイト列を送る、そしてJSONとしては正しいが中身がオブジェクトではなく配列である、の6種類です。
case "len-huge":
// 4GiBを宣言する長さプレフィックスの後に、実際には数バイトしか送らない
writeRaw(lengthPrefix(0xFFFF_FFFF) + [0x7b, 0x7d])
| # | 攻撃 | 何をするか | 結果 |
|---|---|---|---|
| 1 | len-huge | 4GiBを宣言する長さの後に2バイト | 即座に拒否 |
| 2 | len-just-over | 上限より1バイトだけ大きい長さ | 即座に拒否 |
| 3 | non-utf8 | 不正なUTF-8バイト列 | 即座に拒否 |
| 4 | not-object | 正しいJSONだがトップレベルが配列 | 即座に拒否 |
| 5 | truncated | Nバイトを宣言しN−10バイトで止める | デッドラインで打ち切り |
| 6 | huge-json | 32MiBいっぱいの正しいオブジェクト | 受け付ける、ホストのメモリが約144MiB増加 |
6種類とも、ホストはクラッシュしませんでした。宣言した長さより短いまま止まる攻撃だけは、5秒の受信デッドラインで打ち切っています。
32MiBいっぱいのJSONは正しく受け付けられ、パースもできますが、ホスト側のメモリ使用量が約144MiB増えます。上限で抑えられてはいるものの、フレームの4.5倍に当たるので、HostileHelperをN個同時に抱えればN倍の144MiBが積み上がる計算になります。
もう1つ、入れ子の深さだけは可否ではなくしきい値の問題でした。二分探索でJSONSerializationが受け付ける上限を探ると、深さ512までは受け付け、513で例外を投げて拒否に切り替わります。クラッシュではなく、例外としての拒否です。
実際のプラグインが誤って512を超える構造を返すことは起こり得るので、ホスト側で先に深さの上限を設けておけば、拒否が早まり、どこで拒否されるかも読みやすくなります。ただしクラッシュを防ぐための修正ではないので、入れるとしても追加の防御としてです。
RPCを5万発連打しても、他のヘルパーのtickは乱れない
次に試したのは、逆方向RPCの連打です。通常どおり動くヘルパーをもう1つ用意し、いつもどおりtickを200回送って基準の応答時間を測っておきます。そのうえでHostileHelperにhost.fetch呼び出しを5万発、応答を一切待たずに送らせました。
通常のヘルパーのtickの応答時間は、連打の最中も送信前とほとんど変わりませんでした。ホストは各ヘルパーからの呼び出しを、そのヘルパーのcallをブロックしているスレッド上で処理するので、連打で詰まるのはHostileHelper自身のスレッドだけで、通常のヘルパーのスレッドまでは届きません。連打を受け止めたホスト側のメモリ使用量も、無制限には増えずに収まりました。
ただしこれは、本当の意味で分離できているわけではありません。このホストは全ヘルパーを1本のスレッドで順番に処理しているので、通常のヘルパーのtickが乱れなかったのは、たまたま衝突しなかっただけで、スレッドを分けた結果ではありません。
本当に分離するには、ヘルパーごとに専用のスレッドを持たせる必要があり、これはまだ実装していません。応答を一切読まないHostileHelperが、ホスト側の同期送信をいつか塞ぐ可能性も残ったままです。
この実験では、測り方でも1つ失敗しています。最初に考えた設計は、連打の5万発すべてにホストが応答を返し、正しく応答できているかまで確認するというものでした。ところがHostileHelperは乗っ取られたヘルパーとして振る舞うため、応答を一切読みません。
ホストが応答を送ろうとすると、HostileHelper側が読まないソケットバッファがいずれ満杯になり、ホストの送信がブロックされます。ホストの受信ループはその送信待ちで止まるので、HostileHelperからの残りの連打も読まれないまま溜まり、HostileHelper側の送信バッファも満杯になります。双方が相手の読み出しを待ったまま、どちらも先に進めなくなるデッドロックです。
この問題は、応答を返す設計にする前に気づいて直しました。修正後は、連打が実際にホストへ届いて処理された件数を数えるためだけに読み続け、応答は一切返しません。これで確認したかった「連打は本当にホストまで届くか」には答えられ、かつ互いに待ち合う状態にはなりません。
capabilityは、ホスト側の判断だけで取り消せる
3つ目に試したのは、一度許可したcapabilityを途中で取り消せるかです。実験用のfetchハンドラーに、最初の3回だけ許可してそれ以降は拒否する、という単純な条件を持たせました。HostileHelperには同じtickを6回連続で送らせています。
結果は、最初の3回がok、残りの3回がdeniedでした。ホスト側の条件を変えただけで、セッションの途中から挙動が切り替わっています。
これが成立するのは、capabilityの状態がすべてホスト側にあるためです。前々回と前回で見たとおり、ポリシーゲートはヘルパー側ではなくホスト側に置いています。ヘルパーはcapabilityそのものを持たず、そのつど問い合わせているだけなので、乗っ取られていても書き換える対象がありません。
ここで分かったのは、この構造なら取り消しができるという点だけです。本番のfetchハンドラーには、取り消しの条件をまだ入れていません。今回のは実験用の簡単な条件で、ホストに取り消しの仕組みを入れれば効く、という確認です。
plain bookmarkは、乗っ取られたときには渡す範囲が広すぎる
前回の記事では、サブツリーをまとめて渡すplain bookmarkと、ファイルを開くたびにホストへ問い合わせる逆方向RPCを比較し、後者を維持する判断をしました。ただしその判断は、通常時の数字にもとづくものでした。乗っ取られた前提での数字は測っていませんでした。
今回、両方の設計をHostileHelperに渡して確かめました。plain bookmark側では、共有ディレクトリ全体を指す1個のbookmarkを渡すと、ヘルパーは以後ホストへの問い合わせを一切挟まずに、配下の実験用ファイル2件をすべて開きました。この2件は用意した実験用ファイルの数であって、上限ではありません。サブツリーの下にあるものは、同じ経路ですべて開けます。
逆方向RPC側には、最初の1回だけ許可して残りを拒否するゲートを持たせました。5回の要求のうち通ったのは1回だけで、残り4回はホストが拒否しています。
bookmarkは1回で範囲全体を渡してしまい、渡した後はホストが介入する手段がありません。逆方向RPCは要求のたびにホストが判断でき、乗っ取られた側が得られるのはホストが許した分だけです。乗っ取りを前提に置くと、前回の記事で選んだ逆方向RPCの方が、守りの堅い設計だったことになります。
有効な許可を渡しても、脱出はできない
最後に試したのは、渡した権限そのものを、想定より広く使われないかという問いです。ここで一度、テストの作り方を間違えました。
最初に試したのは、前々回渡していたディレクトリのfdを使い、共有範囲の外にあるcanaryファイルへopenatで届くかを見るテストでした。結果は拒否で、一見すると封じ込めが効いているように見えます。
ところが前々回の検証で、渡されたfd経由のopenatはApp Sandbox下では対象を問わず拒否されることが、既に分かっていました。つまりこのテストは、渡した権限を広げようとして拒否されたのではなく、この経路には最初から何も渡っていなかっただけです。拒否という結果を、封じ込めが効いた証拠として読み違えるところでした。
書き直したテストでは、前回渡ることが分かっているplain bookmarkを使いました。これは有効な許可で、許可されたinside.txtは実際に開きます。その有効な許可を渡したまま、4種類の脱出を試しました。共有範囲の親へ戻る相対パス、さらに深く/etc/hostsまでさかのぼる相対パス、canaryファイルを直接指す絶対パス、そして共有範囲の内側から外側を指すシンボリックリンクです。
4つとも、パス経由でも、渡されたfd経由でも拒否されました。しかも4つのどの試行でも、許可されていたinside.txtは問題なく開いています。権限が有効であることをそのつど確認したうえで、その権限が想定より広がらないことも確認できました。
脅威モデルの範囲は変えなかった
5項目のうち4項目は問題ありませんでした。フレーミングはどの攻撃でもクラッシュせず、capabilityの取り消しはホスト側の設計だけで成立し、bookmarkと逆方向RPCの比較は前回の判断を数字で裏付け、有効な権限は想定より広がりませんでした。直さないといけないものは見つかりませんでした。
残る1項目、RPCの連打だけは、まだ言い切れません。他のヘルパーへの影響はありませんでしたが、それは1本のスレッドで順番に処理しているために衝突しなかっただけで、本当に分離できていると証明したわけではないからです。ヘルパーごとの専用スレッドという、まだ実装していない変更が要ります。深さの上限も、入れてもいい追加の防御であって、必須ではありません。
前々回・前回で決めた範囲、つまりバグった・暴走したプラグインまでを脅威モデルに含め、既に乗っ取られたヘルパーへの耐性は範囲外とする、という範囲は変えませんでした。ただし今回は、外側を実際に測ったうえで残しています。
測る途中で2回、テストの組み方を間違えました。fdのテストは、渡した権限を広げる攻撃ではなく、渡していない権限を確認するだけのテストになっていましたし、連打の受け止め方は双方向の書き込みが絡み合って止まる組み方になっていました。どちらも結果を書く前に気づいて直しています。
HostileHelperがやるのは、あらかじめ決めておいた攻撃の再現に限られます。任意のコードを実行できる本物の乗っ取りではないので、今回分かったのは試した攻撃に対する耐性であって、乗っ取りが取り得るすべての攻撃を試したわけではありません。
