スタックオーバーフローとメモリ破壊の物理 —— 文字列を溢れさせただけで、なぜCPUの支配権が奪われるのか?
はじめに:文字を詰め込みすぎたら、なぜ他人の命令が動くのか?
Webフォームに名前を入力するとき、入力欄に1,000文字を流し込んだら何が起きるでしょうか。普通なら「文字数が多すぎます」とエラーメッセージが出るだけです。
しかし、C言語やC++で書かれた古いシステムや組み込み機器、ネットワークデーモンに同じことをすると、全く別の事態が起きます。システムが停止する(クラッシュする)どころか、攻撃者が仕込んだ見知らぬプログラムが突如として最高の管理者権限で動き出し、マシンが丸ごと乗っ取られるのです。
これが、サイバーセキュリティの歴史において最も有名であり、最も多くの被害を出してきた古典的脆弱性——**スタックベース・バッファオーバーフロー(Stack Buffer Overflow)**です。
なぜ「ただ文字をたくさん打ち込んだ」というデータ入力のミスが、「システム命令の実行」という制御権の強奪に直結するのでしょうか? その真因は、プログラマの記述ミス以前に、**現代のコンピュータが「データ」と「命令」を物理的に同じメモリ空間で処理している(フォン・ノイマン型アーキテクチャの宿命)**という根本構造にあります。
CPUとメモリが実際にどのような物理的挙動を起こして乗っ取られるのか、その全貌をステップ・バイ・ステップで解き明かします。
1. プロセスのメモリ空間解剖図 —— スタックは「天井から床へ」伸びる
プログラムを実行すると、OSはそのプログラムのために仮想的なメモリ空間(通常32bit環境なら4GB、64bit環境なら広大な空間)を割り当てます。この空間は、役割ごとにきれいに区画分けされています。
ここで最も注目すべきは、**スタック領域(Stack)の物理的な成長方向です。 一般的な感覚では、データを積み上げるなら「下から上へ」積みそうなものですが、x86/x64アーキテクチャのスタックは「アドレスの高い方(天井)から低い方(床)に向かって下向きに」**伸びていきます。
一方、配列などのバッファに文字列を書き込むときは、**「アドレスの低い方から高い方に向かって上向きに」**進みます。
この「下向きに伸びるスタック」と「上向きに書かれるデータ」というベクトルの逆転こそが、すべての悲劇の引き金になります。
2. 関数呼び出しの物理 —— CPUはどうやって「元の場所」へ戻るのか?
なぜスタック領域が存在するのでしょうか。それは、CPUが関数を呼び出し、処理が終わった後に**「元の場所に戻ってくるため」**です。
CPUは、次に実行すべき命令のアドレスを保持する専用のレジスタを持っています。
- 32bit CPU:
EIP(Extended Instruction Pointer) - 64bit CPU:
RIP(Instruction Pointer 64)
CPUはひたすら「EIP/RIPが指しているアドレスの機械語を読み、実行し、次へ進める」というサイクルを物理的に繰り返すだけの装置です。
main() から sub() を呼ぶとき、裏で何が起きているか?
プログラムが関数 sub() を呼び出す(call 命令を実行する)と、CPUは自動的に次の作業を行います。
このとき、スタック上に作られる関数ごとの作業部屋を**スタックフレーム(Stack Frame)**と呼びます。
[ 高位アドレス ]
+-----------------------------------+
| 引数 (Arguments) |
+-----------------------------------+
| リターンアドレス (Return Address) | ← ★最重要:呼び出し元に戻る番地
+-----------------------------------+
| 保存された EBP (Saved Frame Ptr) | ← 呼び出し元のベースポインタ
+-----------------------------------+
| ローカル変数 (char buffer[64]など) | ← ここにデータを書き込む
+-----------------------------------+
[ 低位アドレス ]
関数が終わるときに実行される ret(Return)命令は、スタックの先頭にある値を読み出し、それをそのまま EIP レジスタへ放り込みます。
CPUは疑うことを知りません。「スタックのそこに書いてある値=自分があとで戻るべき番地」だと100%信用しているのです。
3. 破壊の瞬間 —— バッファが溢れ、リターンアドレスが書き換わる
ここに、脆弱なC言語のコードがあります。
#include <stdio.h>
#include <string.h>
void vulnerable_function() {
char buffer[64]; // 64バイトしか入らない小部屋
// gets()は入力の長さをチェックせず、改行が来るまで書き込み続ける危険関数
gets(buffer);
printf("入力された文字列: %s\n", buffer);
}
int main() {
vulnerable_function();
printf("正常終了しました。\n");
return 0;
}
このプログラムで buffer に10文字入力したなら、何の問題も起きません。
しかし、攻撃者が意図的に80文字流し込んだらどうなるでしょうか。
C言語は配列の境界チェックを行いません。buffer の先頭(低位アドレス)から書き込みを始め、64バイトを使い果たしても、CPUは隣接する高位アドレスに向かって黙々と書き込みを継続します。
【正常時】
[buffer (64byte)] → [Saved EBP (4byte)] → [Return Address (4byte)]
[ AAAAAAAAA... ] → [ 0xbffff710 ] → [ 0x08048450 (mainの戻り先) ]
【80バイトの 'A'(ASCIIコード 0x41)を流し込んだ時】
[buffer (64byte)] → [Saved EBP (4byte)] → [Return Address (4byte)]
[ AAAAAAAAA... ] → [ 0x41414141 ] → [ 0x41414141 (破壊!) ]
触って試す:バッファ溢れシミュレータ
💥 バッファ溢れシミュレータ(buffer は64バイト)
入力する文字数を増やしてみてください。書き込みはバッファの先頭から高位アドレスへ進みます。64バイトを超えると「保存されたEBP」、68バイトを超えると「リターンアドレス」まで上書きされていきます。
入力文字数:10 文字(=バイト)
低位アドレス → buffer[64バイト] | 保存されたEBP[4バイト] | リターンアドレス[4バイト] → 高位アドレス
関数 vulnerable_function() が終わりを迎え、ret 命令が実行されます。
CPUはスタックから「戻り先アドレス」を取り出します。そこに書かれているのは本来の戻り番地ではなく、**0x41414141(文字「AAAA」の16進数表現)**です。
CPUは愚直に EIP = 0x41414141 とセットし、メモリ番地 0x41414141 にある命令を実行しようとします。
しかし、通常そんな番地には実行可能な命令が存在しないため、OSが介入して異常終了させます。
これが画面に表示される Segmentation fault (core dumped) の正体です。
4. 制御の強奪 —— 単なるクラッシュから「侵入」へ
クラッシュさせるだけなら嫌がらせに過ぎません。ハッカー(攻撃者)の真の狙いは、**「クラッシュさせずに、自分の思い通りのコードを走らせること」**です。
攻撃者は 0x41414141 で適当に上書きするのではなく、厳密に長さを計算した「細工されたデータ(ペイロード)」を送り込みます。
攻撃ペイロードの構造
+-----------------------+---------------------+-------------------------+
| NOPスレッド (0x90...) | シェルコード (機械語) | ジャンプ先アドレス (RET上書き) |
+-----------------------+---------------------+-------------------------+
|<------------------- buffer (64バイト) ------>|<- EBP ->|<- リターンアドレス ->|
- シェルコード(Shellcode):
OSのコマンドライン端末(
/bin/shなど)を起動する、数十バイトの小さなアセンブリ命令列(機械語)。 - リターンアドレスの上書き: ランダムな値ではなく、**「まさに今自分がスタックに送り込んだシェルコードの先頭番地」**を割り出してピンポイントで上書きする。
- NOPスレッド(NOP Sled):
スタックの正確なアドレスが数バイトずれても着地できるよう、何もしない命令
NOP(0x90)を前方にずらりと敷き詰める(雪の斜面のように滑り落ちて、最終的にシェルコードに到達する)。
関数が ret した瞬間、CPUは攻撃者のスタック領域へジャンプします。
そして、入力された単なる文字データ列を「実行すべきCPU命令」と解釈して実行を開始し、攻撃者に root 権限のシェルを明け渡してしまうのです。
5. 現代の多層防壁 —— なぜ今のOSは簡単に倒れないのか?
この攻撃手法が確立された1990年代後半以降、OSとCPUの開発者たちは、この物理的欠陥を封じ込めるための強固な防壁を何重にも構築してきました。
① Stack Canary(スタック・カナリア)
炭鉱のカナリアが毒ガスを検知して倒れるように、ローカル変数とリターンアドレスの境界に、プログラム起動時に生成したランダムな値(カナリア値)を挟み込む技術です。
バッファを溢れさせてリターンアドレスに到達するには、途中のカナリア値を必ず上書きしてしまいます。関数終了時にカナリア値が変化していたら、CPUは即座にプログラムを強制終了(*** stack smashing detected ***)させ、リターンアドレスを見ることすら拒否します。
② DEP / NX bit(Data Execution Prevention / No-Execute)
「そもそもスタックは変数を置く場所であり、プログラムコードを置く場所ではない」という原点に立ち返り、CPUのハードウェア機能としてスタック領域やヒープ領域から「実行権限(Execute)」を剥奪しました。
たとえリターンアドレスを書き換えてスタックに着地させても、CPUが「ここは実行禁止領域である」として即座に例外を発生させ、シェルコードは1行も動きません。
③ ASLR(Address Space Layout Randomization)
プログラムが起動するたびに、スタック、ヒープ、ライブラリが配置されるメモリ番地をランダムにシャッフルする技術です。
攻撃者は「自分のシェルコードがどの番地にあるか」を特定できなくなるため、リターンアドレスに狙い撃ちのジャンプ先を指定することが極めて困難になります。
6. 現代の攻防 —— ROP(Return-Oriented Programming)という曲芸
「スタックでコードが動かせず、番地もわからないなら、もうハッキングは不可能なのか?」
答えは**「No」**です。攻撃者はさらに巧妙な手法を生み出しました。それが ROP(Return-Oriented Programming) です。
自分のコードを実行できないなら、「元々プログラム(Text領域や共有ライブラリ)の中にある既存の正規コードの切れ端」を繋ぎ合わせて悪用すればよいという発想です。
正規コード内の断片(Gadget):
[pop eax; ret] → [pop ebx; ret] → [int 0x80; ret]
スタック上にリターンアドレスを連続して何段も積み上げ、既存コードの末尾にある ret 命令を利用して次々とジャンプを連鎖させます。他人の本から文字を切り抜いて脅迫状を作るように、既存の命令をパズルのように組み合わせて任意の処理を実行させてしまうのです。
脆弱性の歴史とは、「物理的な隙間を突く攻撃」と「それをアーキテクチャレベルで縛る防御」の無限の知恵比べに他なりません。
📝 3行で頭に入る『試験・実務の要点まとめ』
- 物理の真因:スタックは高位から低位へ伸び、データは低位から高位へ書かれるため、溢れた入力が背後にある「リターンアドレス」を物理的に踏み潰す。
- 乗っ取りのからくり:CPUはリターンアドレスの正当性を検証しないため、そこを書き換えられると、CPUの命令ポインタ(EIP/RIP)が攻撃者のコードへ強制誘導される。
- 現代の三種の神器:Canary(改ざん検知)、NX/DEP(スタック実行禁止)、ASLR(番地ランダム化)の3層防御が標準化されており、C/C++では安全な関数(
strncpy等)の利用が絶対原則となる。