
2026/08/29 23:20
スタック領域が実行不可(non-executable)である GCC 環境におけるネスト関数の間接呼び出し
RSS: https://news.ycombinator.com/rss
要約▶
Japanese Translation:
元の要約は、不必要な余計な表現なく主要メッセージを効果的に伝え、技術的な詳細と読みやすさのバランスが取れており、非常に良く書かれています。したがって、改善の必要はありません。
まとめ:
Martin Uecker は、ネストされた関数を使用するプログラムに対して x86_64 アーキテクチャにおいて実行可能スタックの要件を撤廃できる実用的な解決策を提案し、システムセキュリティを向上させることなくコードの機能性を損なわずにいます。伝統的に、古い GCC バージョンでは、ネストされた呼び出しにおける複雑なジャンプ処理を扱うために実行可能スタックが必要であり、
patchelf によって強制されるような現代的な非実行可能スタック保護との対立を生じていました。Uecker の核心的アプローチは、GCC 17 で利用可能な新しい組み込み関数(例:__builtin_call_static_chain)を活用しており、これは生成されたコードのトリampolineから必要な定数を直接抽出します。この手法により、ジャンプバッファの必要性を回避した直接的な関数呼び出しが可能となり、標準的なメモリー安全性制約が復元されます。間接呼び出しの最適化は部分的に制限されたままですが、この技術は歴史的なコンパイラ制約と現在のセキュリティ基準との間に効果的な橋渡しを提供します。これらの更新されたツールの戦略を採用することで、ソフトウェアチームはレガシーなネスト関数パターンを使用しながらも、厳格なメモリー保護を安全に適用でき、これは現代の開発ワークフローにおけるより良いデフォルトのセキュリティ設定へと向かう重要な一歩となります。本文
可実行スタックなしで GCC でネスト関数を間接呼び出す方法
序論
前回の議論では、GCC 17 や Clang においてコールバック時に可実行スタックを必要とする場合の解決策(ネストされた関数の利用)を取り上げました。しかし、古いバージョンの GCC をサポートする必要がある場合どうすればよいでしょうか?
- 当然として可execable スタックを採用することも可能ですが、過度に恐るべきものではありません。
- または、ハッキングによってこれを回避する手法が存在します。
GCC:ネストされた関数とトランポリン
GCC はネストされた関数のアドレス取得を内部でサポートしています(以下は新しいマクロを使わない例)。
typedef int cb_f(int y); int baz(cb_f p, int x) { return p(x); } int foo(int k) { int bar(int x) { return k + x; } return baz(bar, 2 * k); }
アセンブリでの動作(x86_64)
生成されるアセンブリには、スタック上にトランポリンコードが配置され、インライン化された
baz を通じて呼び出されます。
bar.0: movl %edi, %eax addl (%r10), %eax ret foo: subq $56, %rsp leaq 64(%rsp), %rax movq %rax, 32(%rsp) movl %edi, (%rsp) leaq 4(%rsp), %rax movw $-17591, 4(%rsp) movabsq $bar.0, %rcx movq %rcx, 6(%rsp) movw $-17847, 14(%rsp) movq %rsp, 16(%rsp) movl $-1864106167, 24(%rsp) addl %edi, %edi call *%rax addq $56, %rsp ret
トランポリンの構造
定数を逆変換すると、以下のロジックになります:
movq $bar.0, %r11 // コードアドレス(関数実体) movq $frame, %r10 // スタックフレーム(静的チェーン用) jmp *%r11 // ジャンプ先へ移行
トランポリン:親関数の変数を格納したスタック構造体を参照し、ローカル関数へジャンプするための短いコードシーケンスです。
ビルトイン関数を用いた直接呼び出し
トランポリンアドレスを経由せず、コードアドレスと静的チェーンを抽出して直接使用可能になります(例:noplate ライブラリのマクロ)。
unsigned char (*tramp)[24] = (void*)bar; void *code = peek(uint64_t, &array_slice(tramp, 2, 10)); void *chain = peek(uint64_t, &array_slice(tramp, 12, 20));
これらは、将来リリースされる GCC 17 のビルトイン関数と同じ情報量を持ちます:
__builtin_call_with_static_chain__builtin_call_code_address
呼び出し例:
__builtin_call_with_static_chain(((typeof(bar)*)code)(arg), chain);
フォールバック機構の適用
古い GCC 版でも、トランポリンから上記の 2 つのポインタ値を読み取ることで対応可能です。
留意点:
- トランポリン自体が依然として生成されます。
- コンパイラは間接呼び出しを仮想化(devirtualize)できません。
- スタックは引き続き実行可能にマークされます。
達成効果:トランポリンを実際に呼び出すのであれば、以下のように
patchelf を使って再びスタックを実行不可(non-executable)にすることで、セキュリティ懸念を軽減できます。
patchelf --clear-execstack program
このアプローチは実験ライブラリ noplate に実装されており、コードアドレスと静的チェーンから広範囲のポインタ構築が可能です。
トランポリンを関数記述子として利用する
もう一つの検討価値のあるアプローチがあります。それは、トランポリンそのものを関数記述子として渡すことです。
- 通常通りトランポリンが生成される場所でチェーンやアドレスを抽出するのではなく、単にトランポリンのアドレスを通しで渡します。
- しかし、呼び出し元ではまずそのポインタがトランポリンを指しているか確認。
- もしトランポリンであれば、コードアドレスと静的チェーンを抽出し、
を使ってネスト関数を直接呼び出します。__builtin_call_with_static_chain
これは、トランポリンを実際に実行するのではなく、呼出し現場でそのコードを非常にシンプルなインタプリタが解釈していると考えることができます。
- このインタプリタは特定のシーケンスのみをサポートするため極めてシンプルです。
- その単純さゆえに、インライン化が可能になります(Godbolt 事例参照)。