プログラミング
NXビットはセキュリティのためだけではない
The NX bit is not just about security (purplesyringa.moe)
要約
この記事は、ARM64ハイパーバイザー開発中に発生した、NXビット(No-Execute bit)に関連するデバッグの困難な事例を紹介しています。当初はレジスタ操作やハードウェアの仕様外の動作を疑いましたが、最終的な原因は、実行可能コードの変更後にキャッシュの不整合が発生し、システムがランダムにハングアップすることでした。この問題は、コードの実行順序とキャッシュ管理の複雑さを示唆しています。
全文翻訳
NXビットはセキュリティのためだけではない
9月4日、2026年 ゲスト投稿 Lobsters
低レベルプログラミングから少し休憩している間に、友人のSonyaがARMコードで一見不可能に見えるバグをデバッグした話を紹介します。
このバグハンティングの物語は数ヶ月前に始まりました。postmarketOS用のARM64でベアメタルハイパーバイザーを開発中に、奇妙なバグに遭遇しました。CTR_EL0のインターセプトを有効にすると(それがHVの目的全体であり、スキップできなかった)、電話がランダムにロックアップしました。数秒後、ウォッチドッグが作動してシステムをリセットしました。最初は、ブートが遅すぎてシステムが起動するのに十分な時間がなかったと思ったのですが、ウォッチドッグを無効にしても助けにはなりませんでした(幸いなことに、問題の電話はバッテリーが取り外し可能だったので、放電するのに数時間待つ必要はありませんでした)。そこで、さらに深く掘り下げることにしました。
仮説1:MRSのエミュレーションが間違っている
Aarch64では、いわゆる特殊機能レジスタ(CTR_EL0はその一つ)はMRSとMSRマシン命令を使用してアクセスされます。
mrs x3, ctr_el0 // 読み取りアクセス
msr ctr_el0, x3 // 書き込みアクセス
これらの命令は、指定されたSFRと指定された汎用レジスタ(この場合はX3)の間でデータを移動し、他のすべてのレジスタは変更されません。したがって、失敗は、保持する必要のあるレジスタのいずれかを破損したか、または実際の出力レジスタを正しく書き込んでいないことを意味したはずです。そのため、最初に確認した2つのことは、例外ハンドラトランポリンとスタック割り当てでした。
trap_from_el1:
sub sp, sp, #256
stp x0, x1, [sp]
stp x2, x3, [sp, #16]
stp x4, x5, [sp, #32]
// ...
stp x28, x29, [sp, #224]
stp x30, xzr, [sp, #240]
mov x0, sp
bl handle_trap_from_el1
mrs x1, elr_el2
add x0, x0, x1
msr elr_el2, x0
ldp x0, x1, [sp]
ldp x2, x3, [sp, #16]
ldp x4, x5, [sp, #32]
// ...
ldp x28, x29, [sp, #224]
ldp x30, xzr, [sp, #240]
add sp, sp, #256
eret
.section .data.stack
.p2align 12
.long 0
.p2align 12
stack:
どちらも正しく、明白な問題の兆候はありませんでした。QEMUで例外ハンドラ全体をシングルステップで実行し、期待どおりに動作していることを確認しました。
実際のハードウェアでスムーズに動作することを確認するために、例外ハンドラの前後でデバッグプリントを追加したところ、実際のハードウェアでは、例外ハンドラが意図された出力レジスタでさえ、どのレジスタも変更していないことが判明しました。
原因は、この無邪気な呼び出しにあることが判明しました。
msr_accessor_sort(
msr_accessors,
((uintptr_t)msr_accessors_end - (uintptr_t)msr_accessors) / sizeof(struct msr_accessor)
);
ご存知かもしれませんが、ARMはIキャッシュ/Dキャッシュのコヒーレンシを持っていません。これは、データの変更が命令フェッチに自動的に伝播しないことを意味します。変更されたデータがまだRAMにコミットされていないか、命令キャッシュが書き込み前の古いキャッシュデータを保持している可能性があります。そして、バッファには実行可能なマシン命令が含まれていたため、配列をソートしてから実行しようとしても機能しませんでした。そこで、ソートをビルドフェーズに移動しました…
デバッグプリントは、ハンドラが意図したとおりに機能していることを示しました。しかし、システムはまだ起動しませんでした。
仮説2:仕様外のハードウェア
皆さんがご存知のように、x86(-64)ハードウェアはIntelとAMDの2つのベンダーによってのみ製造されているため、システム間で非常に一貫した動作を期待できます。
ARM側では状況が異なります。ARMはアーキテクチャのリファレンス実装を提供していますが、ベンダーは自由にカスタマイズしたり、独自の СPU を作成したりできます。これは、ARM CPUには微妙な(そしてそれほど微妙でない)バグが多く、そのいくつかは開発者によって機能と見なされていた可能性があることを意味します。そのため、次に当然考えられたのは、CPUが何らかの形で仕様外であり、期待どおりに動作していないということでした。
それを念頭に置いて、すべての例外が印刷されるように追加のハンドラを追加しました。
vbar_el2:
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
b trap_from_el1
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
b trap_from_el1
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
bl unknown_trap
.p2align 7
unknown_trapルーチンは、例外の原因を特定するためにX30レジスタ(Aarch32に慣れている人にはlr)、ELR_EL2、およびその他のSFRを印刷します。しかし、これらのどれも実際に発火しませんでした。
次に考えられたのは、カーネルが何らかの誤った処理のためにパニックを起こしているということだったので、次にやったことは、/proc/last_kmsgを使用してクラッシュしたカーネルのログを読み取ることでした。残念ながら、ログで得られた最後のメッセージは次のとおりでした。
[ 3.113676] (0)[153:init]fs_mgr: Running /system/bin/e2fsck on /dev/block/platform/mtk-msdc.0/11230000.msdc0/by-name/userdata
[ 3.125072] (0)[158:e2fsck]random: e2fsck urandom read with 12 bits of entropy available
これは、カーネルがクリーンにクラッシュしなかったことを意味します。私は/dev/urandomデバイスがクラッシュの原因であると疑い、その仮説をテストするためにカーネルからパッチを当てました。これにより、ブートは少し進みましたが、それほどではありませんでした。まだ行き詰まりでした。
その時点で、どこで問題が正確に発生しているのか全く分かりませんでしたが、インターセプトを無効にすると問題が解決したため、インターセプトに関連していることは分かっていました。そこで、objdumpを使用してカーネルで疑わしい命令をフィルタリングしました。
$ aarch64-unknown-linux-gnu-objdump -D -b binary -m aarch64 kernel.orig | grep ctr_el0
これは22件のヒットをもたらし、それらをバイナリで手動でパッチして正しい値を返すようにしました。パッチされたカーネルを起動した後もAndroidに到達できませんでしたが、adb shellは機能したので、パッチは(少なくともある程度)成功したことを意味します。その後、パッチを少数の「ホット」命令に減らすことができましたが、ハングアップの実際の理由についてはまだ不明でした。その時点で、ハイパーバイザーとカーネルが互いのメモリを上書きしているのではないかと疑い、メモリを使用しないアセンブリでmrs x3, ctr_el0(実際に重要なすべての「ホット」パッチ内の命令)の処理を書き直すことにしました。
// X0を保存します。スクラッチレジスタとして使用します
msr tpidr_el2, x0
// 例外シンドロームレジスタを読み取ります。関心のある値は0x6232c061です
// (https://esr.arm64.dev/#0x6232c061)
mrs x0, esr_el2
// x0を0x6232c061と比較します
// ARMは命令で32ビット即値をサポートしていないため、ビットグループを1つずつ比較します
sub x0, x0, #0x61
ror x0, x0, #12
sub x0, x0, #0x32c
ror x0, x0, #12
sub x0, x0, #0x62
cbnz x0, 1f // ESRが正しくない場合は、汎用ハンドラにジャンプします
// 保存されたPCを4増やします。命令の長さを考慮します
mrs x0, elr_el2
add x0, x0, #4
msr elr_el2, x0
// 保存されたX0を復元し、要求された読み取りを実行して、呼び出し元に戻ります
mrs x0, tpidr_el2
mrs x3, ctr_el0
eret
1:
// 汎用ハンドラにフォールスルーする前にx0を復元します
mrs x0, tpidr_el2
そして…うまくいきました!その時点で、ロジック自体は正しいことが分かり、Cハンドラが何らかの形で問題を引き起こしていたので、さらにバイセクトを開始しました。
if(esr == 0x6232c061) {
asm volatile("mrs %0, ctr_el0":"=r"(regs[3]));
return 4;
}
…うまくいきました。mrs CTR_EL0を読み取るために使用されたmsr_accessorのアドレスを調べたところ、msr_accessors+0x28にあることが分かりました。
if(esr == 0x6232c061) {
regs[3] = ((uint32_t(*)(void))(msr_accessors+5/*8バイト/要素*/))();
return 4;
}
…うまくいきませんでした。リロケーションが原因でしょうか?リロケーションコードをチェックし、リンケージベースを実際のロードアドレスと等しく設定しようとしましたが、効果はありませんでした。
そこで、リンカースクリプトを使用して、これを「新しい」シンボルに分離することにしました。
get_ctr_el0 = msr_accessors + 0x28;
if(esr == 0x6232c061) {
uint32_t get_ctr_el0(void);
regs[3] = get_ctr_el0();
return 4;
}
そして、これは正しく実行されました。これで、意味論的に同等な2つのコードバージョンがあり、そのうちの1つだけが機能しました。バイナリバイセクトの時間です!
バイナリバイセクト、または貧弱なギ