あなたには、Rustに rewrite するには大きすぎ、かつバッファオーバーフローのリスクを放置するには重要すぎるCのcodebaseがある。CHERIキャパビリティハードウェアは、メモリ安全性違反をCPUレベルで検出することを約束しているが、インターネット上ではCHERIポインタが128ビットであると言われ続けており、あなたのコードは64ビットを前提としている。
良い知らせは、CHERIが「全てか無か」の移行を強制しないということだ。ハイブリッドABIを使えば、既存のCコードを最小限の変更でコンパイルし、実際のCHERIハードウェア上で実行し、最も重要な部分から段階的にセキュリティを強化できる。最初の保護されたポインタを得るために、百万行のコードを移植する必要はない。
なぜ今、段階的な移植が可能なのか
初期のキャパビリティアーキテクチャは妥協を許さなかった。iAPX 432は、すべてのポインタをキャパビリティにする必要があり、それだけだった。これにより既存のすべてのコンパイラとオペレーティングシステムが破壊され、そのプロジェクトは消滅した。
CHERIはその失敗から学んだ。3つのコンパイルモードをサポートしている:ベースライン(従来の整数ポインタのみ)、ハイブリッド(整数ポインタとキャパビリティポインタが共存)、ピュアキャップ(すべてのポインタがキャパビリティ)。ハイブリッドABIはその橋渡しだ。既存のコードをほぼそのまま保持しつつ、キャパビリティが最も価値を発揮する場所に選択的に導入できる。
ハイブリッドモードでは、void*は依然として64ビットだ。キャパビリティポインタはvoid* __capabilityという区別された型であり、明示的に選択するものだ。構造体、リンクリスト、ハッシュテーブルは、明示的に変更しない限りサイズが変わらない。これは互換性レイヤではない。ハードウェアとコンパイラによってサポートされる第一級のABIだ。
CHERIハイブリッド向けにコンパイルしたときに実際に壊れるもの
最初のステップは、CHERIツールチェインでコードをコンパイルしてみて、何が爆発するか見ることだ。きちんと動作するほとんどのCコードは、変更なしでコンパイルできる。問題は、CHERIが違法にする一握りのパターンの周りに集積しており、それがまさに目的なのだ。
ポインタから整数へのキャスト。 ポインタをuintptr_tにキャストし、いくつかのビットをマスクしてから元に戻すコードは失敗する。CHERIでは、uintptr_tはプレーンな整数ではなくキャパビリティ型だ。キャパビリティに対するビット単位の操作はタグビットを剥ぎ取り、結果の値はデリファレンス時にトラップする。
ポインタサイズに関する前提。 sizeof(void*) == 8をハードコードしたり、ポインタを8バイトの値としてシリアライズするコードは、ピュアキャップモードで壊れる。ハイブリッドモードではこれはほとんど動作するが、同じ構造体にキャパビリティポインタと整数ポインタを混ぜた瞬間に問題になる。
無関係なオブジェクトをまたぐポインタ演算。 Cプログラマーは、時々2つの任意のポインタ間の距離を計算したり、異なる割り当てからのポインタを比較したりする。CHERIキャパビリティは境界メタデータを保持しており、異なる割り当てからのキャパビリティの減算は未定義だ。コンパイラは拒否するか、ハードウェアがトラップする。
インラインアセンブリ。 整数レジスタでポインタを移動する手書きのアセンブリはすべて更新が必要だ。CHERIには専用のキャパビリティレジスタがあり、コンパイラはどれに触れているか知る必要がある。
CHERI LLVMツールチェインは優れた診断機能を提供する。謎のリンカエラーが出るわけではない。「キャパビリティから整数へのキャストは許可されていない」や「異なる割り当てからのキャパビリティに対する算術演算」といった明確なメッセージが表示される。最初のエラーを修正し、再コンパイルして、次のエラーを追いかければよい。
具体例:パーサーをキャパビリティでラップする
信頼できない入力をバッファに読み込んでからヘッダーを解析するネットワークパケットパーサーがあると想像してほしい。これはまさにハードウェアによる境界チェックの恩恵を受ける種類のコードだ。パーサー自体を rewrite せずにラップする方法は以下の通りだ。
まず、パーサーをハイブリッドモードでコンパイルする。CHERIツールチェインはCHERIターゲットを持つ標準的なLLVMだ:
# ハイブリッドABIで単一ファイルをコンパイル
$ clang --target=riscv64-unknown-freebsd \
-march=rv64imafdcxcheri \
-mabi=lp64d \
-mno-relax \
-c parser.c -o parser.o
-mabi=lp64dフラグは整数ポインタを64ビットに保つ。既存のvoid*型とchar*型は変更されない。パーサーはそのままコンパイルできる。
次に、別ファイルでキャパビリティ対応ラッパーを追加する:
#include <cheriintrin.h>
#include <stddef.h>
#include <stdint.h>
// parser.cにある既存のパーサー
extern int parse_packet(const char *data, size_t len);
// キャパビリティ対応のエントリポイント
int parse_packet_safe(const char * __capability data, size_t len) {
// キャパビリティをバッファ長に正確に絞り込む。
// 呼び出し元がより大きな割り当てを渡したとしても、
// パーサーは'len'バイトを超えて読み取れない。
const char * __capability narrowed =
cheri_bounds_set(data, len);
// ハイブリッドモードでは、レガシーパーサーにプレーンなポインタを渡す。
// コンパイラはここでキャパビリティから整数への変換を挿入する。
// キャパビリティが64ビットに収まらない場合(大きなキャパビリティ
// アドレスではそうなる)、警告またはエラーが出る。
//
// ピュアキャップへの移行では、parse_packetをキャパビリティポインタを
// 受け入れるように変更する。
return parse_packet((const char *)narrowed, len);
}
この例では、parse_packetは依然としてレガシーな整数ポインタを使用している。ラッパーは呼び出し元のキャパビリティを入力の正確な長さに絞り込み、それを元に戻す。絞り込みのステップにより、呼び出し元が誤って64バイトを渡すつもりが4KBのバッファを渡したとしても、ハードウェアは絞り込まれたキャパビリティに対して64バイトの境界を強制する。
これは最終的な状態ではない。踏み石だ。今日API境界で bounds enforcement を得られ、後の refactor でparse_packetをピュアキャップに移行できる。
ハイブリッドからピュアキャップへの移行パス
ハイブリッドモードは目的地ではなく出発点だ。互換性を提供するが、キャパビリティの完全なセキュリティ上の利点は提供しない。長期的な目標は、すべてのポインタが境界を持つピュアキャップだ。
実践的な移行は以下のようになる:
-
ハイブリッドモードでコンパイルし、ビルドエラーを修正する。 これは通常、ポインタから整数へのキャストや
sizeof(void*)の前提を修正することを意味する。まだデータ構造は変更しない。クリーンなビルドを得るだけだ。 -
高価値なターゲットを特定する。 ネットワークパーサー、ファイルフォーマットデコーダー、デシリアライズルーチンが、capability enforcement を適用する最良の候補だ。これらは信頼できない入力を処理し、ほとんどのメモリ安全性バグが存在する場所だ。
-
そのターゲットを絞り込まれたキャパビリティでラップする。
cheri_bounds_setを使って、信頼境界で制限されたキャパビリティを作成する。それらを既存のコードに渡す。 -
リーフ関数をピュアキャップに移行する。 ポインタを割り当てて返すユーティリティ関数から始める。シグネチャを
__capabilityを使うように変更し、-mabi=purecapでコンパイルする。コールスタックを上に向かって進める。 -
最終的にmodule 全体をピュアキャップに切り替える。 コンパイル単位に整数ポインタがなくなったら、
-mabi=purecapでコンパイルし、残りのハイブリッドコードとリンクする。CHERIツールチェインは混合ABIリンクをサポートする。
大規模なcodebaseにとって、これは週末プロジェクトではない。しかし、全面的な rewrite でもない。最も脆弱なコードパスを数日で保護でき、残りは触れるたびに段階的に移行できる。
トレードオフの実際の姿
ハイブリッドABIには実際のコストがあり、コミットする前に知っておくべきだ。
第一に、混合ABIはビルドを複雑にする。同じバイナリに異なるポインタサイズでコンパイルされたオブジェクトファイルが存在するようになる。リンカはキャパビリティと整数の再配置を処理する必要がある。CHERIツールチェインはこれをサポートするが、あなたのビルドシステムはまだこれを知らない可能性が高い。教えてやる必要がある。
第二に、ハイブリッド境界でのキャパビリティから整数への変換は情報を失う。キャパビリティを絞り込んでからプレーンなポインタにキャストすると、境界を失う。基盤となるメモリは依然として開始時のキャパビリティによって保護されているが、レガシー関数には hardware enforcement が行われない。これがピュアキャップが目標である理由だ:コールチェーンのすべてのポインタが独自の境界を持つ。
第三に、デバッグが変わる。CHERI上のGDBはキャパビリティレジスタを理解し、境界メタデータを表示できる。LLDBのサポートも改善されている。現在のデバッグワークフローが生のポインタ値の検査に依存している場合は、CHERIのレジスタ名を学ぶ必要がある。
ハイブリッドモードのパフォーマンスオーバーヘッドは、整数ポインタを主に使用するコードでは通常無視できる。ピュアキャップのオーバーヘッドは、ほとんどのワークロードで通常一桁のパーセンテージであり、ポインタを多用するデータ構造では10〜15%に上る。これはASANのようなソフトウェア緩和策と競合力があり、ASANと異なり、CHERIは本番環境でフルスピードで動作する。
QEMUなら今日から始められる
実験するためにMorelloボードは必要ない。CHERIプロジェクトはQEMUサポートと、LLVMツールチェインがプリインストールされたDockerイメージを維持している。
# CHERIツールチェインのDockerイメージを取得
$ docker run --rm -it ctsrd/cheri-sdk:latest
# コンテナ内で、RISC-V CHERI向けにコードをコンパイル
$ clang --target=riscv64-unknown-freebsd \
-march=rv64imafdcxcheri \
-mabi=lp64d \
-o myapp myapp.c
ハイブリッドモードでプロジェクトをコンパイルし、エラーの数を数えることから始めよう。その数が、どれだけの作業が待っているかを教えてくれる。最初の試行でクリーンなビルドがなることは珍しくないが、不可能ではない。ポインタキャストが多い古いcodebaseでは、数百のエラーが典型的だ。
エラーをこの順序で修正する:まず整数からポインタへのキャスト、次に異なる割り当てをまたぐポインタ演算、最後にインラインアセンブリ。各カテゴリには機械的な修正がある。CHERIプロジェクトは、最も一般的なパターンの前後の例を含む移植ガイドを公開している。
キャパビリティハードウェアは理論的な未来ではない。動作するツールチェイン、サポートされたABI、そしてcodebaseを燃やす必要のない移行パスだ。1つのファイル、1つの関数、1つの絞り込まれたキャパビリティから始めよう。ハードウェアが残りをやってくれる。