メモリ破壊攻撃の原理:スタックオーバーフローから ROP まで
バッファ1つの溢れがなぜプログラム乗っ取りに化けるのか。戻りアドレス書き換えからシェルコード、DEP を回避する ROP まで、メモリ図で攻撃と防御の構造が分かる。
- 1.スタックバッファオーバーフローは、確保した領域を超えて書き込み、同じスタック上にある関数の戻りアドレスを攻撃者の値に書き換えて制御を奪う攻撃。
- 2.DEP/NX でスタック上のシェルコード実行が封じられると、攻撃者は既存コード片(gadget)を ret で数珠つなぎにする Return-Oriented Programming(ROP)に移行する。
- 3.緩和はスタックカナリア(戻りアドレス手前の番兵)と ASLR(配置のランダム化)。どちらも単体では破られ得るため、根治は境界チェックのある安全な実装。
なぜ「溢れ」が乗っ取りになるのか
メモリ破壊攻撃の核心は、プログラムの制御フロー(次にどの命令を実行するか)が、書き換え可能なメモリ上のデータとして存在する点にあります。C/C++ のような境界チェックのない言語では、配列やバッファへの書き込みが確保サイズを超えても止まりません。溢れた分は隣接するメモリを上書きし、そこに制御フローを決める値が置かれていれば、攻撃者はそれを奪えます。
スタックは関数呼び出しのたびに伸縮する領域で、ローカル変数・退避レジスタ・戻りアドレス(呼び出し元へ帰る先)を格納します。x86-64 では call 命令が戻りアドレスをスタックに積み、関数末尾の ret がそれを取り出して RIP(命令ポインタ)に入れます。つまり ret はスタック上の値を信じてジャンプするだけの命令です。ここを書き換えられれば、プログラムは攻撃者の指定した場所へ飛びます。
解説は教育・防御目的です。攻撃手順そのものではなく、なぜ成立し、どの緩和がどこを塞ぐかという原理に絞ります。検証は許可された環境でのみ行うべきで、無断の実行はペネトレーションテストの節で触れたとおり法的責任を伴います。
スタックの並びと戻りアドレス書き換え
典型的な脆弱関数を考えます。gets や境界を見ない strcpy のように、入力長を確認せずバッファへ書き込む実装です。
void vuln(const char *input) {
char buf[64]; /* 64 バイトのローカルバッファ */
strcpy(buf, input); /* input が 64 バイト超なら溢れる */
} /* 関数末尾の ret で戻りアドレスへジャンプ */
x86-64 のスタックは高位アドレスから低位アドレスへ伸びますが、strcpy による書き込みは低位から高位へ進みます。この向きの食い違いが攻撃の鍵です。buf を埋め尽くした書き込みは、より高位にある退避 RBP、そして戻りアドレスへと到達します。
高位アドレス
+---------------------+
| 呼び出し元のフレーム |
+---------------------+
| 戻りアドレス (RIP) | ← ここを書き換えると ret で任意の場所へ飛ぶ
+---------------------+
| 退避 RBP |
+---------------------+
| buf[63] ... buf[0] | ← strcpy はここから上へ書いていく
+---------------------+
低位アドレス
攻撃者は「埋め草(パディング)+上書きしたい戻りアドレス」という入力を作ります。buf と退避 RBP を埋める分のバイト数(オフセット)を合わせれば、続く 8 バイトがちょうど戻りアドレスに重なります。ret の瞬間、RIP は攻撃者の値になり、制御が奪われます。
古典手法:スタックへのシェルコード注入
最も古い完成形がシェルコード注入です。攻撃者は、シェルを起動するなどの機械語(シェルコード)を入力に含めてスタックへ流し込み、戻りアドレスをそのシェルコードの先頭アドレスに向けます。
着地点を正確に当てる必要を緩めるため、シェルコードの手前を NOP(何もしない命令、x86 では 0x90)で長く埋める **NOP スレッド(NOP sled)**を置きます。戻りアドレスが NOP 列のどこに飛んでも、NOP を滑り落ちて最終的にシェルコードへ到達します。
[ NOP NOP NOP ... NOP | シェルコード | 戻りアドレス→NOP 列内のどこか ]
↑ 着地点が多少ずれても sled で吸収
この手法が成立する前提は、スタックが実行可能であることです。歴史的に CPU はページに「読める/書ける」の区別しか持たず、データ領域のスタックでも命令として実行できてしまいました。次の DEP/NX はまさにこの前提を崩します。
DEP/NX:データ領域の実行を禁じる
**DEP(Data Execution Prevention)/ NX ビット(No-eXecute)**は、ページ単位で「実行可能」属性を分離するハードウェア機構です。スタックやヒープに **W^X(Write XOR eXecute:書き込みと実行を同時に許さない)**を課し、書き込めるページは実行不可にします。
これでスタックに置いたシェルコードへ RIP を飛ばしても、その領域は実行不可なので CPU が例外(フォルト)を出し、攻撃は失敗します。注入した命令を直接走らせる道が断たれるわけです。
DEP/NX が禁じるのは、データとして書き込んだバイト列を実行することです。一方で、プログラム本体や libc のようなもともと実行可能な領域のコードは正規に実行できます。攻撃者はここに目をつけ、「自前のコードを置く」のではなく「既存の正規コードを部品として再利用する」方向へ発想を転換します。これが次の ROP です。
ROP:既存コード片を ret で連鎖させる
Return-Oriented Programming(ROP)は、DEP/NX を回避する決定打です。新しいコードを注入する代わりに、プログラムや共有ライブラリにすでに存在する小さな命令列を寄せ集めて、攻撃者の望む処理を組み立てます。
部品となる断片を **gadget(ガジェット)**と呼びます。ガジェットは「いくつかの有用な命令+末尾の ret」という形をしています。たとえば次のような数バイトの並びです。
gadget A: pop rdi ; ret ← スタックの先頭値を RDI に入れて戻る
gadget B: pop rsi ; ret ← 同様に RSI へ
gadget C: mov [rdi], rsi ; ret ← RDI の指す先へ RSI を書き込んで戻る
鍵は ret の振る舞いです。ret はスタックトップの値へジャンプし、スタックポインタを 1 つ進めます。そこでスタック上に「ガジェットのアドレス」と「そのガジェットが pop する値」を交互に並べておけば、各ガジェットの末尾 ret が次のガジェットへ制御を渡し、ret が連鎖を駆動するミニ仮想マシンのように動きます。
スタック上に攻撃者が並べる ROP チェーン(低位→消費される順)
[ gadget A のアドレス ]
[ RDI に入れたい値 ] ← A の pop rdi が拾う
[ gadget B のアドレス ]
[ RSI に入れたい値 ] ← B の pop rsi が拾う
[ gadget C のアドレス ]
...
最初の ret が gadget A へ飛び、A の ret が B へ、B の ret が C へと進む
実戦では、いきなり任意計算を組むより、スタックを実行可能に戻す mprotect を呼ぶ、あるいは system("/bin/sh") を呼ぶといった「1 つの関数呼び出しを正しい引数で実現する」ことを目標にします。x86-64 の System V 呼び出し規約では第 1〜第 6 引数が RDI, RSI, RDX, RCX, R8, R9 に載るため、pop rdi ; ret などのガジェットで引数レジスタを整え、最後に目的の関数アドレスへ飛ばします。libc のガジェットだけで攻撃を完結させる手口は return-to-libc の発展形であり、ROP はその一般化です。
ガジェットは開発者が用意したものではありません。x86-64 は可変長命令で、命令の途中バイトから読み始めると別の命令列に解釈される(アンインテンデッドな ret が現れる)ため、十分大きなバイナリにはチューリング完全な計算を組めるほどのガジェットが揃ってしまいます。ツール(ROPgadget など)が自動でガジェットを抽出し、チェーンを構築します。「コードを注入させない」だけでは防御として不十分なのは、このためです。
緩和:スタックカナリアと ASLR
注入を封じる DEP に加え、書き換えと再利用そのものを難しくする緩和が広く使われます。
**スタックカナリア(stack canary)は、関数の入口で戻りアドレスの手前に秘密のランダム値(カナリア)**を置き、関数を抜ける直前にその値が変化していないか検査します。バッファ溢れで戻りアドレスへ到達するには、途中のカナリアも必ず踏むため、値が崩れていれば「溢れた」と検知してプログラムを停止できます。
[ buf ... | カナリア | 退避 RBP | 戻りアドレス ]
↑ 線形な溢れはここを必ず通過 → 不一致なら abort
ASLR(Address Space Layout Randomization)は、スタック・ヒープ・共有ライブラリ・(PIE なら)実行本体の配置アドレスを起動ごとにランダム化します。シェルコードの着地点も、ROP に使う libc ガジェットのアドレスも事前に分からなくなり、攻撃者は飛び先を確定できません。
| 緩和 | 狙い | 塞ぐ攻撃 | 回避・限界 |
|---|---|---|---|
| DEP / NX | データ領域の実行を禁止(W^X) | スタック等へのシェルコード注入 | 既存コード再利用(ROP)で回避される |
| スタックカナリア | 戻りアドレス手前の番兵で溢れ検知 | 線形なスタック溢れによる RIP 制御 | カナリア値の漏洩、非連続な書き換えで回避 |
| ASLR | 配置アドレスを起動ごとにランダム化 | 固定アドレス前提の着地・ROP | 情報漏洩で実アドレス取得、低エントロピー環境でブルートフォース |
| 安全な実装 | 境界チェック・所有権で溢れを根絶 | メモリ破壊そのもの | 既存 C/C++ 資産の移行コストが高い |
これらは互いに補完する多層防御である点が重要です。ASLR があってもアドレスを 1 つ漏らす別の脆弱性(情報リーク)があれば全配置が逆算され得ますし、カナリアも値を読み出せれば書き戻して回避されます。だから単独で「これで安全」とは言えません。緩和は攻撃の難度とコストを上げるものだと理解してください。発想はサイドチャネル攻撃で見た「理論と実装の隙間を突く」攻防と地続きです。
(1) スタックオーバーフローの本質は戻りアドレス(RIP)の書き換えで、ret が信用するスタック値を奪う点。(2) DEP/NX は注入コードの実行を止めるが既存コードの再利用は止めない。(3) ROP は末尾 ret を持つ gadget を ret 連鎖で数珠つなぎにし、引数レジスタを整えて関数を呼ぶ。(4) カナリアは戻りアドレス手前の番兵、ASLR は配置のランダム化で、いずれも情報漏洩で回避され得る。(5) 根治は境界チェックのある実装。
実務での向き合い方:根治は安全な実装
緩和を積んでも、攻撃者は情報リークと組み合わせて回避経路を探します。根治はメモリ破壊そのものを起こさないことであり、入口は実装の選択です。
- 安全な言語・API:Rust のように所有権と境界チェックで未初期化・領域外アクセスをコンパイル時/実行時に排除する言語へ移す。C/C++ でも
strcpyではなく長さ指定のstrncpy/snprintfを使い、固定長バッファへの無検査コピーを避ける。 - コンパイラ・OS の防御を全部有効化:スタックカナリア(
-fstack-protector-strong)、PIE+フル ASLR、DEP/NX、RELRO(GOT 保護)、CFI(制御フロー整合性)を既定で入れる。 - 検出を継続的に回す:AddressSanitizer や fuzzing(境界外アクセスを実行時に検出)でメモリバグを開発段階で潰す。設計レビューだけでは見落とすため、ペネトレーションテストによる実測検証と組み合わせる。
権限設計の観点も効きます。万一の制御奪取に備え、プロセスを最小権限の原則で動かしておけば、侵害が成立しても攻撃者が得る能力を狭められます。「理論上の堅牢さ」と「実装で本当に溢れないこと」は別物だ、というのがメモリ破壊攻撃から得るべき最大の教訓です。
セキュリティ Article
メモリ破壊攻撃の原理:スタックオーバーフローから ROP までを実務で読む
TL;DRは入口です。実際に選ぶ・使う段階では、何を解決するか、何と比較するか、導入後にどこで詰まるかまで見る必要があります。
解決すること
メモリ破壊
比較で見る軸
難易度: advanced / カテゴリ: セキュリティ / タグ数: 5
導入後に効く点
DEP/NX でスタック上のシェルコード実行が封じられると、攻撃者は既存コード片(gadget)を ret で数珠つなぎにする Return-Oriented Programming(ROP)に移行する。
先に潰すリスク
用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。
- 難易度
- advanced
- カテゴリ
- セキュリティ
- タグ数
- 5
判断チェックリスト
- 自社の用途が「メモリ破壊 / バッファオーバーフロー」に近いか確認する。
- 強みである「スタックバッファオーバーフローは、確保した領域を超えて書き込み、同じスタック上にある関数の戻りアドレスを攻撃者の値に書き換えて制御を奪う攻撃。」が本当に評価軸になるか確認する。
- 注意点の「用語だけ覚えても、設計・実装・運用でどこに効くかを確認しないと判断を誤る。」を運用で吸収できるか確認する。
- 公開値や仕様値は、対象プラン・対象機種・対象リージョンまで確認する。
- 既存システム、ID、ネットワーク、監視、バックアップとの接続方法を先に洗い出す。
- 小さく試してから、本番移行、権限設計、障害時手順、コスト監視を決める。