NXビットはセキュリティのためだけではない
The NX bit is not just about security (purplesyringa.moe)
要約
この記事は、ARM64ハイパーバイザー開発中に発生した、NXビット(No-Execute bit)に関連するデバッグの困難な事例を紹介しています。当初はレジスタ操作やハードウェアの仕様外の動作を疑いましたが、最終的な原因は、実行可能コードの変更後にキャッシュの不整合が発生し、システムがランダムにハングアップすることでした。この問題は、コードの実行順序とキャッシュ管理の複雑さを示唆しています。
全文翻訳
NXビットはセキュリティのためだけではない
2026年9月4日 ゲスト投稿 Lobsters Hacker News Reddit
低レベルプログラミングから少し休憩を取っている間に、友人のSonyaがARMコードで発生した一見不可能に見えるバグをデバッグした話を紹介します。
このバグハンティングの物語は数ヶ月前に始まりました。postmarketOS用のARM64ベアメタルハイパーバイザーを開発中に、奇妙なバグに遭遇しました。ハイパーバイザーの目的そのものでありスキップできなかったCTR_EL0インターセプトを有効にすると、電話機がランダムにロックアップしました。数秒後、ウォッチドッグが作動してシステムをリセットしました。最初はブートが遅すぎてシステムが起動するのに十分な時間がなかったのだと思いましたが、ウォッチドッグを無効にしても解決しませんでした(幸いなことに、問題の電話機はバッテリーが取り外し可能で、放電するのに数時間待つ必要はありませんでした)。そこで、さらに深く掘り下げることにしました。
仮説 #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;
}
…これは機能しました。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つだけでした。バイナリバイセクトの時間です!
バイナリバイセクト、または貧者のICE
その時点で、問題のあるコードを別の関数に分離しました。
static __attribute__((noinline,optimize(3))) void handle_mrs_x3_ctr_el0(uint64_t* regs)
{
// asm volatile("mrs %0, ctr_el0":"=r"(regs[3])); // 動作する
// regs[3] = get_ctr_el0(); // これも動作する
regs[3] = ((uint32_t(*)(void))(msr_accessors+5))(); // 動作しない
}
そして、それをメインハンドラから呼び出しました。
if(esr == 0x6232c061)
{
handle_mrs_x3_ctr_el0(regs);
return 4;
}
問題はまだ再現したため、単一の関数に焦点を絞ることができました。
最初はコードサイズが原因かもしれないと思いましたが、関数の先頭に多くのNOPを追加しましたが、効果はありませんでした。それから逆アセンブルしました。
動作するバージョン:
0000000040204fc0 <handle_mrs_x3_ctr_el0>:
40204fc0: a9be7bfd stp x29, x30, [sp, #-32]!
40204fc4: 910003fd mov x29, sp
40204fc8: f9000bf3 str x19, [sp, #16]
40204fcc: aa0003f3 mov x19, x0
40204fd0: 94000ab6 bl 40207aa8 <get_ctr_el0>
40204fd4: 2a0003e0 mov w0, w0
40204fd8: f9000e60 str x0, [x19, #24]
40204fdc: f9400bf3 ldr x19, [sp, #16]
40204fe0: a8c27bfd ldp x29, x30, [sp], #32
40204fe4: d65f03c0 ret
動作しないバージョン:
0000000040204fc0 <handle_mrs_x3_ctr_el0>:
40204fc0: a9be7bfd stp x29, x30, [sp, #-32]!
40204fc4: f0000001 adrp x1, 40207000 <phys_ceiling_names>
40204fc8: f944f821 ldr x1, [x1, #2544]
40204fcc: 910003fd mov x29, sp
40204fd0: f9000bf3 str x19, [sp, #16]
40204fd4: aa0003f3 mov x19, x0
40204fd8: 9100a021 add x1, x1, #0x28
40204fdc: d63f0020 blr x1
40204fe0: 2a0003e0 mov w0, w0
40204fe4: f9000e60 str x0, [x19, #24]
40204fe8: f9400bf3 ldr x19, [sp, #16]
40204fec: a8c27bfd ldp x29, x30, [sp], #32
40204ff0: d65f03c0 ret
動作するバージョンをアセンブリで統一し、動作するバージョンがまだ動作し、動作しないバージョンがまだ動作しないことを確認するまで、作業を進めました。そして、これにたどり着きました。
handle_mrs_x3_ctr_el0:
stp x29, x30, [sp, #-32]!
mov x29, sp
str x19, [sp, #16]
mov x19, x0
adr x0, get_ctr_el0 // x0にget_ctr_el0のアドレスをロード
#if 0 // 動作しないバージョン
blr x0 // x0に格納されたアドレスの関数を呼び出す
#else // 動作するバージョン
bl get_ctr_el0 // get_ctr_el0関数を呼び出す
#endif
mov w0, w0
str x0, [x19, #24]
ldr x19, [sp, #16]
ldp x29, x30, [sp], #32
ret
両方のバージョンが意味論的に同等であることは明らかです。これは、誰かがその正確な瞬間にコードを中断し、レジスタを復元できないか、またはマイクロアーキテクチャのバグがあるかのどちらかであることを意味します。最初の可能性はすぐに却下しました。ARM64では、ハイパーバイザーよりも特権の高いものはトラストゾーンだけですが、割り込みを受け取る理由はありませんでした。そのため、後者を仮定しました。この電話のSoCはMediaTek MT6735で、Cortex-A53コアを搭載しています。AlisaがCortex-A53の誤り(errata)のリストを調べるのを手伝ってくれましたが、類似するものはありませんでした…
そして、Linuxソースの特定のコメントを思い出しました。一部のx86システムでは、一部のMMIOレジスタへの投機的なアクセスがシステムシャットダウンを引き起こす可能性がありました。そこで思考実験を考案しました。もしこれがここで起こったとしたら、どうすればそれを防ぐことができるでしょうか?物理アドレスへのあらゆるアクセス(投機的なものを含む)を防ぐ最も簡単な方法は、アドレスをマッピングしないことですが、それはハイパーバイザー自身のMMIO空間の使用を妨げるでしょう(設計上の選択として、アドレス空間全体の1:1マッピングを期待しています)。「レイジーページング」のシナリオを検討し始めました。これは、アーキテクチャ的にアクセスされたメモリの範囲を遅延的にマッピングするというものです(CPUは投機的なアクセスに対してページフォールトをシグナルしません)。そして、ひらめきました。
2つの命令はどう違うのでしょうか? blr x0は動的であり、bl get_ctr_el0は静的です。
動的ディスパッチと静的ブランチはどう違いますか?動的ディスパッチはブランチ予測を使用し、静的ブランチはどこにジャンプするかを事前に知っており、誤って予測することはありません。
命令が誤って予測される可能性のあるものは何でしょうか?…おそらくヌルポインタ、0x0でしょう。空からアドレスを生成することはできません。
私の1:1マッピングの0x0には何がありますか?…ええと…ブートROMです。これはブートローダー初期化中にロックアウトされます。
…そして気づきました。私が戦っていた投機的なアクセスはデータアクセスではなく、命令フェッチでした。そして、それは私がしなければならなかったすべては、ページを非実行可能としてマークすることでした。それを実行したところ…カーネルにパッチを当てることなく、半年ぶりに、システムはAndroidまで完全にブートしました!
何を探すべきかがわかった後、「arm64 speculative instruction fetch mmio」でGoogle検索すると、公式ARMドキュメントから次の宝石が見つかりました。
ここには見逃しやすい微妙な区別があります。領域をDeviceとしてマークすることは、投機的なデータアクセスのみを防ぎます。領域を非実行可能としてマークすることは、投機的な命令フェッチを防ぎます。これは、あらゆる投機的なアクセスを防ぐには、領域を全く実行不可能としてマークする必要があることを意味します。
セキュリティだけではない
私自身もハッカーであるため、データ実行防止(DEP)はスタックオーバーフロー攻撃と戦うために発明された純粋なセキュリティ対策だと常に考えており、実装しませんでした。ハイパーバイザーは本番環境で実行されることを意図していなかったため、防御の深さは過剰設計になります。
しかし、ARMではそれは全くセキュリティに関するものではなく、本質的には属性ビットであることが判明しました。ARMは、Deviceメモリ(MMIOの新しい言い方)としてマッピングされた領域に対しては、投機的なアクセスがないことを保証します。ただし、それはデータアクセスにのみ適用され、命令フェッチは実行可能なメモリをNormalメモリとして扱います。メモリ領域が投機的に実行されるのを防ぐ唯一の方法は、それを全く実行不可能にすることです。
また、非実行可能なメモリをマッピングする方法を実装する必要があったため、ペイロード以外のすべてのメモリを非実行可能としてマッピングすることにしました。これにより、私のハイパーバイザーもついに防御の深さを少し持つようになりました!