バッファオーバーフローは二十年間にわたりCWE Top 25に名を連ねている。スタックカナリア、ASLR、DEP、制御フロー整合性、メモリ安全性を保証する言語といった対策が存在するにもかかわらず、クリティカルなコードにおいて依然として発生し続けている。その理由は単純明瞭である。これらの緩和策はいずれもソフトウェア上で実行されており、ソフトウェアは迂回されたり、誤って構成されたり、あるいは単に使用されない可能性を孕んでいるのである。
ハードウェアそのものが、配列の終端を超えての読み取りを拒否したらどうだろうか。
なぜソフトウェア緩和策は敗北し続けるのか
バッファオーバーフローとは、割り当てられたメモリ領域の境界を超えてプログラムが書き込みを行う事象を指す。C言語においては、ポインタは単なるアドレスに過ぎないため、これは自明のことである。コンパイラおよびCPUは、プログラマが自身の行為を理解していると信頼する。六十四バイトを割り当ててオフセット八十に書き込もうとした場合、CPUは黙って実行する。バッファがどこで終わるかなど、CPUは一切知らないのである。
ソフトウェア緩和策は事後的にこれを検出しようと試みる。スタックカナリアはリターンアドレスの直前に既知の値を配置し、復帰前に検証する。ASLRはメモリ配置をランダム化し、攻撃コードの構築を困難にする。制御フロー整合性は間接分岐の到達可能箇所を制限する。これらは有効ではあるが、確率的あるいは不完全な性質を持つ。十分な時間と情報漏出を得た執拗な攻撃者は、通常これらを迂回することができる。
Rustのようなメモリ安全性を保証する言語は言語レベルで問題を解決するが、既に生産環境で稼働する数十億行のCおよびC++コードを救済することはできない。Linuxカーネル、OpenSSL、あるいはレガシーバックエンドをRustで書き直すなど、今後十年のうちに実現する見込みはない。書き換えを要せず、既存のコードを保護する防御手段が必要なのである。
CHERIがポインタに対して実際に行うこと
CHERI(Capability Hardware Enhanced RISC Instructionsの略)は、ケンブリッジ大学で開発され、現在はArmによってMorelloの形で支持されているISA拡張である。これはポインタの本質を変革する。
従来の六十四ビットシステムにおいて、ポインタは六十四ビット、すなわち単なるアドレスである。CHERIシステムでは、ポインタは百二十八ビットまたは二百五十六ビットのcapabilityとなる。追加のビット群はメタデータを保持する。割り当てのベースアドレス、境界(どこまで及ぶか)、および権限(読み取り、書き込み、実行)である。この capability は hardware integrity tag によって保護されており、metadata の改竄は即座に無効化される。
CHERI向けにコードをコンパイルする際、mallocは単なるアドレスを返すのではない。要求されたサイズと正確に一致する境界を持つcapabilityを返す。そのポインタをインクリメントすると、ハードウェアはすべてのアクセスをその境界に対して検証する。境界外への読み書きを試みた場合、CPUは同期例外を発生させる。より広い境界を持つcapabilityを捏造する術は存在しない。ハードウェアは単純にそれを許容しない。
これが決定的な相違点である。ソフトウェア境界検査はメモリ操作の周囲に検査ロジックを挿入するが、これはコンパイラによって最適化により消去されたり、プログラマが見落としたり、攻撃者が迂回したりする可能性がある。ハードウェア境界検査はすべてのロードおよびストアにおいて、無条件に、かつプログラムに一切の追加命令を必要とせずに実行される。
命令レベルでの境界強制の仕組み
C言語における典型的なバッファオーバーフローは以下のように表現される:
#include <string.h>
void vulnerable(char *input) {
char buf[64];
strcpy(buf, input); // No bounds check. Classic overflow.
}
通常のアーキテクチャでは、これはスタックを破壊する。CHERI上では、bufは生のアドレスではない。境界が正確に六十四バイトであるcapabilityである。strcpyが六十四バイト目を超えて書き込もうとした際、ハードウェアはCapability Bounds Violation例外を発生させる。プログラムは、過ちを犯した正確な命令において即座にクラッシュする。
同様の保護はヒープ割り当てにも適用される:
#include <stdlib.h>
#include <cheri.h> // CHERI intrinsics header
void heap_example(void) {
char *buf = malloc(32);
// buf is a capability with base=buf, length=32
// This works fine
buf[0] = 'A';
buf[31] = 'Z';
// This traps at the hardware level
buf[32] = 'X'; // Capability bounds violation
}
クラッシュは精密である。正確な命令、正確なcapability、および侵害された正確な境界が得られる。三フレーム後の破損したスタックフレームを追跡するよりも、これをデバッグする方が遥かに容易である。
CHERIはポインタの捏造も防止する。整数をポインタにキャストし、任意のメモリをデリファレンスすることはできない。ハードウェアはmalloc、stack allocation、あるいは明示的なcapability派生といった正当な命令によって生成されたcapabilityのみを認識する。整数からポインタへのキャストは未タグ付きの値を生じ、使用時にトラップする。
互換性問題は現実のものである
CHERIがこれほど優れているならば、なぜすべてのサーバがこれを実行していないのか。それはポインタの本質を変更することが、数十年にわたるCコードが依存する前提を破壊するからである。
第一の問題はサイズである。capabilityは生のポインタより大きい。Morelloにおいて、capabilityは百二十八ビットに加え、ハードウェアが別途格納する一ビットのタグである。これはポインタ密集のデータ構造におけるメモリ使用量を増大させる。ポインタで満たされた連結リストや木構造は著しく大きくなる。多くのアプリケーションではオーバーヘッドは一桁パーセント程度であるが、ポインタ追跡型ワークロードではより深刻になることがある。
第二の問題はキャストである。Cコードはポインタをuintptr_tにキャストし、演算を行い、再びキャストし戻す。CHERI上では、uintptr_tは実際には整数型ではなくcapability型である。ポインタ値に対して任意の整数演算が可能であると仮定するコードは、コンパイルに失敗するか、実行時にトラップする。修正策としては通常、オフセットにはptrdiff_tを使用し、cheri_address_setで適用するが、これにはソースコードの変更を要する。
第三の問題はエコシステムである。CHERIハードウェアは希少である。Arm Morelloボードは存在するが、コモディティサーバではない。CheriBSDおよびCHERI対応Linuxは実際のソフトウェアを実行するに十分成熟しているが、多くのLinuxディストリビューションはCHERIパッケージを配布していない。コンテナレジストリ、CIランナー、およびクラウドプロバイダは、まだこれをサポートしていない。
カスタムシリコンなしで今日から試せる
CHERIを実験するためにMorelloボードを必要としない。CHERIプロジェクトはQEMUサポートを維持しており、あらゆるLinuxまたはmacOSホスト上でCHERIユーザ空間を実行できる。
CHERIツールチェインを取得し、QEMUで簡単な例を実行する方法は以下の通り:
# Clone the CHERI SDK setup
$ git clone https://github.com/CTSRD-CHERI/cheribuild.git
$ cd cheribuild
# Build the CheriBSD disk image and QEMU
$ ./cheribuild.py cheribsd-riscv64-purecap -d
$ ./cheribuild.py qemu -d
# Boot CheriBSD in QEMU
$ ./cheribuild.py run-cheribsd-riscv64
CheriBSD内部に入ると、コンパイラはCHERIターゲットサポートを備えたclangである。デフォルトで境界検査が強制された状態でコンパイルする:
$ clang -o test test.c
$ ./test
# Buffer overflows trap immediately with a clear message
既存のソフトウェアをテストしたい場合、cheribuildはFreeBSD portsから人気のパッケージをCHERI対応でコンパイルできる。CHERIチームはOpenSSH、nginx、PostgreSQL、およびFreeBSDベースシステムの大部分を移植している。多くのプログラムは変更なしで動作する。破損するものは、通常、安全でないポインタキャストやポインタサイズに関する仮定が原因である。
QEMUを使用せずに迅速に開始したい場合、ケンブリッジ大学はCHERI LLVMツールチェインをプリインストールしたDockerイメージも公開している。完全なOSを起動することなく、CHERIバイナリをコンパイルし、生成されたcapability対応アセンブリを検査できる。
既存のコードベースにとってハードウェアメモリ安全性が意味するもの
CHERIは安全なコードを記述する代替手段ではない。既に所有するコードに対する安全網である。レガシーCを実行するCHERIシステムは依然として論理バグ、競合状態、およびuse-after-freeエラーを抱えるだろう。しかし、リターンアドレスを上書きしたり、隣接するヒープメタデータを破損したり、割り当て境界を越えて機密を漏出させたりするバッファオーバーフローは発生しない。
進展はすでに見える。ArmはCHERI由来の機能をMemory Tagging Extension(MTE)に組み込んでおり、これは現在生産環境のAndroidデバイスに搭載されている。MTEはCHERIより粗い粒度である(個別の割り当てではなく十六バイトの粒度でタグ付けするが)、より低いオーバーヘッドで同様の多くのバグを捕捉する。エコシステムが成熟すれば、フルCHERIが追随する可能性が高い。
信頼できない入力を処理するCまたはC++コードを保守しているなら、問われるべきはハードウェアメモリ安全性が到来するかどうかではない。到来した際に準備ができているかどうかである。ポインタから整数へのキャスト、無関係なオブジェクトに対するポインタ演算、およびsizeof(void*)に関する仮定について、コードの監査を開始せよ。これらはCHERI下で破損するパターンであり、修正することは従来のハードウェア上でもコードをより清潔にする。
ハードウェアはついに否を唱える覚悟をした。我々はそれを容認すべきである。