- Hubrisは、分離されたタスクがIPCで通信するOSであり、13番目のシステムコール
REPLY_FAULT によって、サーバーは不正なクライアント要求をエラー値ではなくfaultとして終了させられる
- クライアントから見るとIPCは関数呼び出しのように見えるが、タスクは 別個にコンパイル されるため、不正な操作コード、解釈不能なバイト、不適切なloaned memoryをコンパイラがすべて防げるわけではない
- 正常なHubrisプログラムでは、ビルド設定と生成されたRustコードのおかげでこうしたエラーにはほとんど遭遇しないため、すべての呼び出しに
Result<T, IpcError> と unwrap() を強制すると コードサイズ とランタイムコストが増える
- カーネルはシステムコールの前提条件を破ったタスクを、エラーコードなしでただちに殺し、
REPLY_FAULT はこの fail-fastポリシー をサーバー応答にまで拡張する
- この設計は誤ったAPI利用を素早く露呈させるが、ランダムなIPCやシステムコールを送る fuzz test やchaosタスクはほぼ即座に再起動されるため、テストが難しくなる
Hubris IPCとREPLY_FAULTの位置づけ
- Hubris は、小さなアプリケーション独立カーネルを持ち、ドライバ・アプリケーションロジック・ネットワークスタックのような大半のコードを 分離コンパイルされた隔離タスク に置く
- タスク間通信は、カーネルが実装したIPCシステムコールで行われる
RECV: 最も高い優先順位の受信メッセージを取得するか、メッセージが来るまでブロックする
SEND: 呼び出し元を停止し、メッセージと制御権を受信タスクに渡したうえで、応答を受け取るまで待機する
REPLY: 以前に SEND したタスクへ応答を返し、再び実行できるようにする
- Hubrisのクライアントとサーバーは固定された身分ではなく、タスクが果たす役割 である
SEND を使うタスクはクライアント役
RECV と REPLY を使うタスクはサーバー役
- 1つのタスクが、あるタスクに対してはサーバーであり、別のタスクに対してはクライアントであることもある
タスク境界でコンパイラが見逃すエラー
- 通常の関数呼び出しでは、コンパイラとリンカが型と呼び出し先をかなりの程度保証する
- Rust関数が
String 引数を受け取るなら、呼び出し側が bool を渡すことはコンパイラが防ぐ
pet_cat を呼ぶつもりで fire_missiles を呼んでしまうような対象の取り違えも、普通は起きない
- Hubris IPCはタスク境界をまたぎ、各タスクが 別個のプログラム としてコンパイルされるため、コンパイラはIPC関係全体を直接検証できない
- IPCサーバーが直面しうるエラーは大きく3種類ある
- インターフェースに合わない 操作コード。たとえば2つの操作しかないインターフェースに「operation number 48」が届く場合
- 想定したメッセージ型ではなく、解釈不能なバイト列が来る、またはメッセージが短すぎる・長すぎる場合
- 必要なloaned memoryがない、あるいは書き込み可能メモリが必要なのに読み取り専用で渡される場合
正常なプログラムにエラー処理を強制しない理由
- 正常なHubrisプログラムでは、この種のIPCエラーが発生しないように構成されている
- タスク接続はビルドシステム設定で構成され、互いを取り違えにくい
- クライアントは生成されたRustコードでIPCを構成して送信する
- サーバーも別途生成されたRustコードで結果を処理する
- すべてのIPC操作が
Result<T, IpcError> を返すようにすると、正常なプログラムでも実際には起こりえないエラーに対して unwrap() を書かなければならなくなる
unwrap() は コードサイズ の面で負担が大きい
- ランタイムでも、実際には起こらないエラーを検査するコストが発生する
- 生成コード内に
unwrap() や panic! を入れれば、panic位置を集中させてコードサイズへの影響は減らせるが、ランタイムコストはそのまま残る
- 汎用的なエラーコードをサポートするには、すべての操作が同じエンコード規則に従う必要がある
- すべての操作はエラーを返せなければならない
- すべての操作はそのエラーを同じ方法でエンコードしなければならない
- 失敗しえない操作も、失敗しうる形で表現しなければならない
- Hubrisベースのファームウェアでは、実際に失敗しえない操作が繰り返し見つかっており、GPIOピン設定 がその一例である
Hubrisカーネルの攻撃的なfaultポリシー
- 多くのOSでは、システムコールの前提条件を破ってもエラーコードを返したり、例外・シグナル処理の機会を与えたりする
- Unixで開いていないファイルディスクリプタを
close すると、エラーコードが返る
open にパス名の代わりにnull pointerを渡しても、エラーコードが返る
- Hubrisでは、システムコールの前提条件を破ると、そのタスクはただちに破壊される
- タスクはそれ以上命令を実行できない
- タスク自身には回復したり再開したりする機会がない
- アプリケーションのsupervisorタスクはfault通知を受け、通常はそのタスクを消して再起動する
- カーネルが生成するfaultは synthetic fault である
- null pointer参照や0除算のようにCPUが起こすハードウェアfaultに似ている
- ハードウェアfaultはプロセッサアーキテクチャの規則違反から生じ、synthetic faultはカーネル規則違反から生じる
- たとえば
SEND 呼び出しで、受信タスクのインデックスがアプリケーション範囲外だったり、メッセージポインタがアクセス権のないメモリを指していたりすると、synthetic faultが発生する
- Hubrisは、回復可能または再開可能なfaultを許容しない
- ハードウェアfaultでもsynthetic faultでも、faultを受けたタスクは死んだ状態になる
- この選択は微妙な失敗モードを避け、システムの推論を単純化するためのものだ
サーバーがクライアントへfaultで応答する仕組み
REPLY_FAULT は、サーバーがクライアントに通常の応答の代わりに faultを送る システムコールである
- 一般的な
REPLY の流れは次のとおり
- クライアントが
SEND を使うと、カーネルはクライアントタスクを受信タスクに対する「waiting to send」状態として記録する
- 受信タスクが
RECV を使うと、そのクライアントは「waiting for reply」状態になる
- サーバーが
REPLY を呼ぶと、クライアントはrunnable状態に戻る
REPLY_FAULT は REPLY に似ているが、メッセージを渡して実行可能状態に戻す代わりに、faultを送ってタスクを死亡状態 にする
- サーバーが任意のタスクを殺せるわけではない
REPLY_FAULT は、そのサーバーが RECV していて、まだ REPLY していないタスクにしか使えない
- 特定サーバーの応答を待っているクライアントに対してのみ動作する
- Hubrisは
REPLY_FAULT を次のエラー処理に使う
- 不正な操作コード
- 破損・切り詰め・無意味なメッセージ
- クライアントが正しい種類のloaned memoryを送っていない場合
アプリケーションエラーとfail-fastの実体験
REPLY_FAULT は、IPC形式エラーだけでなく アプリケーション固有のエラー にも使える
- HubrisのIPスタックは、IPポートをタスクに静的に割り当てる
- あるタスクが別のタスクのIPポートを触ろうとすると、IPスタックはそのタスクにfaultを与える
- この方式は、実際には起きるべきでない「理論上の」エラー処理を減らし、誤用を開発中に素早く露呈させる
REPLY_FAULT は、Rustの関数呼び出しで前提条件違反時に通常 panic! が起きるモデルに似ており、サーバーがクライアントプロセスに対して プロセス間 panic! を起こす手段になる
- クライアントはそのためのコードを含めたり、協調したりする必要がない
セキュリティ志向とテスト上の制約
- Eliza WeissmanはHubrisを「悪意あるプログラムに対して攻撃的に敵対的」だと表現している
- 悪用の試みはしばしばAPIエラーや誤用としてまず現れるため、誤動作したコンポーネントの状態を消し去るシステムは、悪用しにくい可能性がある
- この仮説はまだ検証されていない
- Hubris exploitの試みに興味があれば連絡してほしい、という呼びかけが含まれている
- 観測された欠点は、システムを fuzz test するのが非常に難しい点である
- ランダムなIPCやシステムコールを生成する小さなchaosタスクが実装されたが、ほぼ何をしても即座にリセットされる
- 有用に機能させるには、起動のたびに観測可能に変化するシステムuptime counterに判断を依存させる必要がある
REPLY_FAULT は、サーバーがクライアントをランダムに殺してchaosを強制する方法も提供するが、この選択肢はまだ完全には評価されていない
- 一般的なHubrisタスクは、意図的に不正なIPCメッセージを動的生成しないため、通常は
REPLY_FAULT の存在を意識せずに実行できる
1件のコメント
Hacker News の意見
REPLY_FAULT は、システムが小さく緊密で、アプリケーションもシステム全体を設計した人たちが主に書く場合には良さそうに見える
ただしアプリケーション開発者の立場では、別のサービスがいつでも自分のプロセスに即死の毒薬を返せる IPC モデルでサードパーティコードと接続するのは、かなり怖いように思う
他のアプリケーション開発者をそこまで信頼していない。世の中にはひどい運転手や、管理者からのプレッシャーに苦しむ開発者が作ったバックグラウンドプロセスがあふれていて、8時前に退勤できるなら、不適切かもしれないデフォルトの REPLY_FAULT を大量に入れる可能性がある
すべての事前条件を簡単に理解できるわけでもなく、デバイスや OS バージョンによって変わることもあった
スコープは増えていくものだが、ホスト側で処理したほうがよい作業を、わざわざ組み込みコントローラー内の Hubris タスクに押し込みたがるとは思えない
サーバーが「このクライアントが間違っている」と言えば、カーネルがそのクライアントを殺す。肝心なのは、両者が互いを理解できていなかったという点にある
また「プロセス」と言ったときに想像するものとは違うかもしれない。Hubris ではスレッドがすべて同じアドレス空間を共有する
REPLY_FAULT は連鎖するのだろうか? たとえば A が B に
SENDして待ち、B が C にSENDして待っているとき、C がREPLY_FAULTしたら A も B と一緒に死ぬのか気になるそうでないなら、悪意あるタスクは実験を補助タスクに委任すれば済む。逆にそうなら全体としてかなり脆そうに見える。Hubris をそれ以上よく知っているわけではないが
さらに
SENDが循環的または相互的になり得るなら、タスクが誤って自分自身を殺すこともあり得る。B → A → B のような場合には、REPLY_FAULT を使わないようにする動機にもなり得るサーバーがクライアントに撃ち返せる理由はセキュリティではなく信頼性だ。エラーは意図的な攻撃ではなくバグから生じる、という見方で、カーネルの極端な反応は開発者ができるだけ早く問題を見つけられるようにする
もちろんセキュリティと重なる部分はあり、プロセスがしてはいけないことをしようとしたときに有用な予備防御になり得る
Hubris とデバッガーの Humility は、時間があるか、やるべき任務があるなら深掘りしたい技術だ。残念ながら今は無理だ
1つのチームがすべてのコードを書くシステムでは、クライアントが変な目で見たというだけで軌道上から吹き飛ばす方式が、反復開発の速度を上げ得るという点が興味深い
代数的効果について読んでいる途中で寝落ちし、朝にこの記事を読むと面白い。少しひねって見ると、これはサーバーがクライアントでは処理できない効果を実行できるようにするカーネルだ
コードの再利用と合成はずっと難しくなりそうだが、実行モデルはずっと単純になる。静的な組み込みシステムでは明らかに正しいトレードオフだ。再利用が必要なら、いつでもタスクをベンダリングして修正すればよい
むしろ Unix には無視できるエラーが多すぎ、個人的にはその多くが 致命的シグナル を発生させるべきだったと思う。そうすれば全体的なソフトウェア品質はかなり良くなっていたはずだ
たとえば不正なファイルディスクリプタに対して
close()を呼ぶのは致命的ではないエラーなので、しばしば無視される。しかし実際には、特にマルチスレッドアプリでは非常に危険だ。ほとんどの場合、不正なファイルディスクリプタのクローズは harmless に失敗するが、1% はログ用ソケットやデータベースのロックファイル、無関係な IPC 接続を閉じてしまう。そうして誰もが嫌う不安定なソフトウェアが作られるErrand of Mercy のセリフ「いくつもの規則や規定があることを知るだろう。それらは掲示される。そのうち最も小さなものに違反しても、死をもって罰せられる」を思い出す
これを HTTP 用のエイプリルフール RFC にすべきだ
HTTP 499 “Shame on you.” を提案する。499 を受け取ったクライアントは、おそらく
Strict: trueのような特定のヘッダーで始まったリクエストに限り、そのリクエストを発行したタスクを言語ごとの方法で終了しなければならないこの文脈に見える「なんだこれは……でも実は、悪くないのでは?」というバランスを完璧に突いている
とても面白く読んだし、この単一 supervisor 方式は、以前のスタートアップでアプリケーションをすべて unwrap するよう構成していたやり方に似ている
好きな記事の1つである https://medium.com/@mattklein123/crash-early-and-crash-often... も思い出した
これが本当にそこまで攻撃的なのか気になる
Linuxでは、ソケットだけで通信中の別プログラムを直接クラッシュさせることはできない。ソケットに不正なデータを送る場合は別として
ただし、殺すことは確実にできる。rootで実行中のものは何でも他のものを殺せるし、再起動してシステム全体を落とすこともできる
もう少し難しく、一般的ではないが、少なくともコンテナではroot権限はよくある。もちろんcgroupがあるのでより制限はされるが、要点はそういうこと
「受け取るものには寛容に、送り出すものには保守的に」という一般的な知恵とも少し違う。ただ、それはネットワークシステムにより結び付いた話かもしれない
それでも、システムは受け入れるものに寛容であるべきなのは仕方ないのかもしれない。そうでないと既存プログラムを壊さずにAPIを少し変える方法がないのでは?
実行時に新しい種類のプロセスを許可することもないと理解している。可能な実行ファイルはすべてコンパイル時点で決まっていなければならない
「問題を修正してタスクを再開する方法はない。これは微妙な失敗モードを避け、システムの推論を単純化するための意識的な選択だった」という部分について、アインシュタインの有名な言葉「可能な限り単純に、しかし単純にしすぎてはいけない」を思い出す
この設計は後半の条件に反しているように見える。現実世界の混乱にまったく耐えられない運用環境には興味がないし、商業的に成立する領域の中でそういうものを受け入れる場所もよく分からない
結局、initシステムに戻して再試行し続けさせようということなのか? しかし、どんなメカニズムで発生したfaultを理解して、よりよい方法で再試行できるのか?
いずれにせよ、信念の純粋さには拍手を送りたい
実際、Cliffがブログで詳しく書いているように、REPLY_FAULTは当初は攻撃的すぎるかもしれないと考えていた機能だったが、システムを作り、デプロイし、率直に言えばデバッグする中で得た経験により、これが私たちのシステムを気まぐれに壊すのではなく、より堅牢にするものだと確信できた
ここでの考え方と実際の姿は[0]と[1]でさらに見られる
[0] https://www.mattkeeter.com/blog/2024-03-25-packing/
[1] https://cliffle.com/blog/who-killed-the-network-switch/
趣味プロジェクトでも、I2Cバスがプロトコルビット1つの乱れでよく停止し、システム全体を落としてしまうのを見てきたので、この設計はかなり示唆に富んでいると思う
理解したところでは、これは既知のエラーケース、つまり処理されるエラーではなく、プロトコル不一致や絶対に起きてはならないことを扱う話だ
他のコメントも指摘しているが、これは目的特化型OSだ。ErlangでUIを作らないのと同じように、Hubrisも自分が占める領域によく合っているように見える
原因はバグ、攻撃、壊れたハードウェアのいずれかであり、どの場合でも処理を続けるべきではない。呼び出し元に深刻な問題があり、続行すればより大きな被害を出すだけだ
Erlang/OTPの「let it crash」哲学に少し似て聞こえる。Erlangはかなり多くのミッションクリティカルなハードウェアで使われ、信頼性で有名なので、実際にはそれほど大きな欠点ではないのかもしれない
0xideサーバーラックの奥深くで動くように書かれたものだ
「悪用の試みはしばしば最初にAPIエラーや誤用として現れるため、どんな誤動作に対しても誤った動作をしたコンポーネントの状態を消去するシステムは、悪用しにくいはずだ」という部分について、ここではアプリケーションが受け入れるものを少し厳格に検査していることになる
そのためセキュリティ上の利点はあるが、考えている種類のものとは違う。攻撃者の進捗を破壊して後退させるのではなく、以前ならより望ましい不正状態へつなげられた特定の不正状態が、もはや通用しなくなるという点だ
そうなると攻撃者はそれを試すより、別の場所を探すようになる