サイバー攻撃の歴史において、最も古典的でありながら今なお重大な脅威であり続けているのが「バッファオーバーフロー(BoF)攻撃」です。OSやアプリケーションの脆弱性を突くこの攻撃は、ひとたび成功すれば任意のコード実行を許し、脆弱なプログラムが高い権限で動いていれば、システム管理者権限の奪取にもつながります。
この記事では、バッファオーバーフロー攻撃がどのようにしてメモリを侵食し、プログラムの制御を奪うのか、そのプロセスをCPUレジスタの動きやスタックフレームの構成から徹底解説します。さらに、現代の防御手法と、それを打破する高度な攻撃手法まで体系的に学んでいきましょう。
プログラムが利用するメモリ領域の四層構造
プログラムがOS上で実行される際、そのプロセスには「仮想アドレス空間」と呼ばれるメモリ領域が割り当てられます。この領域は、役割に応じて大きく4つのセグメントに分類されます。攻撃がどの領域の、どのデータを狙っているのかを理解することが、対策への第一歩となります。
メモリのレイアウトと各領域の役割
典型的な配置を単純化すると、メモリ空間は以下の4つで構成されています(実際には共有ライブラリやメモリマップ領域なども配置され、詳細はOSやメモリ管理の実装によって異なります)。
コード領域(テキスト領域)は、実行される機械語の命令(バイナリコード)が格納される場所です。基本的には読み取り専用であり、プログラム自体のロジックが配置されます。
データ領域には、グローバル変数や静的変数が格納されます。プログラムの開始から終了まで存続するデータが置かれる場所です。さらに細かく、主に0以外の初期値を持つ変数を置く「.data」セクションと、未初期化(または0で初期化)の変数を置く「.bss」セクションに分かれます。
ヒープ領域は、プログラムの実行中に動的に確保される領域です。C言語のmalloc()関数などで、実行時に必要なサイズだけメモリを割り当てます。典型的な模式図では、メモリの下位アドレスから上位アドレスに向かって成長するものとして描かれます(大きな確保を別のメモリマップ領域から行うなど、実際の振る舞いはアロケータの実装によって異なります)。
スタック領域には、関数の引数、戻り先アドレス、ローカル変数などが格納されます。「後入れ先出し(LIFO)」の構造を持ち、メモリの上位アドレスから下位アドレスに向かって積み上がっていくのが特徴です。この領域こそが、バッファオーバーフロー攻撃の主要ターゲットとなります。

境界チェックの欠如が招く事態
バッファオーバーフローとは、あらかじめ確保された「バッファ(一時的なデータ格納領域)」のサイズを超えたデータが書き込まれることで、隣接するメモリ領域を上書きしてしまう現象を指します。
特にC言語やC++は、ハードウェアに近い制御が可能で高速である一方、パフォーマンスを優先するために配列の境界チェック(入力データが配列のサイズに収まっているかの確認)を言語仕様として強制していません。この「自由度」が、プログラマの不注意と相まって脆弱性の温床となります。
脆弱性を生みやすい標準関数
脆弱性が生まれやすい代表例が、文字列操作を行う標準関数です。
strcpy()は、コピー先のサイズを確認せず、終端文字(\0)が見つかるまでコピーを続けます。gets()は、標準入力から改行が来るまで、際限なくデータを読み込みます(入力長を制限できないあまりに危険な関数であるため、C11で標準から削除されましたが、古いコードには残っていることがあります)。sprintf()は、フォーマットされた文字列をバッファに書き込む際、サイズ制限が困難です。
例えば、10バイト分しか確保していない配列に対して、外部から100バイトのデータを流し込むと、後ろの90バイト分が他の重要なデータを破壊しながらメモリを突き進んでいくことになります。これが攻撃の物理的な第一歩です。
+おまけ:毎日1通の無料メール講座つき。登録した日が「1日目」、図解と論理で16週間かけて基礎も固まります。
スタックベースのオーバーフロー(スタック・スマッシング)
バッファオーバーフローの中でも古典的かつ代表的なのが「スタック領域」を狙った攻撃です。これは「スタック・スマッシング」とも呼ばれます。なぜスタックが狙われるのか、その理由はスタックがプログラムの「実行順序」を管理しているからです。
スタックフレームとCPUレジスタの役割
関数が呼び出されると、スタック領域に「スタックフレーム」と呼ばれる情報の塊が積まれます。これを理解するには、CPU内のレジスタの動きを見る必要があります。
関数呼び出しのプロセスは以下の順序で進行します。まず、関数に渡すデータ(引数)がスタックに積まれます。次に、関数終了後に戻るべき命令のアドレス(リターンアドレス)が保存されます。これは、呼び出し元の次の命令を指しています。その後、前の関数の基準位置を示すEBP(ベースポインタ)が保存されます。最後に、関数内で使うローカル変数用のメモリが、スタックポインタ(ESP)を動かすことで確保されます。
(ここでは古典的な32ビットx86の呼出規約を例にしています。x86-64では引数の多くがレジスタで渡されますが、リターンアドレスがスタックに積まれる点は同じです。)
重要なのは、スタック自体は高位から低位アドレスへ伸びる一方、ローカル変数(配列などのバッファ)へ先頭から順にコピーされるデータは「低位アドレスから高位アドレス」に向かって書き込まれるという点です。そして、リターンアドレスはローカル変数の「後ろ(高位アドレス側)」に配置されています。つまり、ローカル変数という「バッファ」を溢れさせれば、その先にあるリターンアドレスを直撃できるのです。
制御フローを乗っ取るメカニズム
攻撃者は、バッファのサイズを大幅に超える入力データを送り込みます。データはバッファを埋め尽くし、隣接する「保存されたEBP」を潰し、さらにその先にある「リターンアドレス」まで到達します。
ここで攻撃者が、リターンアドレスを「自分が用意した不正なコードが存在するアドレス」に書き換えることに成功したとしましょう。関数が処理を終えてRET命令を実行した瞬間、CPUは本来の呼び出し元に戻るのではなく、攻撃者が指定したメモリ地点へとジャンプしてしまいます。これが「プログラムの制御を奪う」という現象の正体です。

シェルコードとNOPスレッド
攻撃者がジャンプ先に指定するのは、多くの場合、自分自身がバッファに送り込んだ「シェルコード」と呼ばれる機械語の命令群です。シェルコードは、システムシェルを起動したり、バックドアを開いたりする機能を持つ、極めて短いプログラムです。
シェルコードを実行させるために使われるテクニックに「NOPスレッド(NOP Sled)」があります(sledは「そり」の意味で、並行処理の「スレッド(thread)」とは無関係です。「NOPスレッド」は日本語資料での慣用表記です)。これは、何もしない命令である「NOP(x86では0x90)」を大量に並べ、その後にシェルコードを配置する手法です。リターンアドレスをNOPスレッドのどこかに的中させれば、CPUはNOPを滑るように実行していき、最終的にシェルコードに到達します。これにより、アドレスが多少ずれても攻撃を成功させることができる、攻撃者にとって都合の良い「誤差の許容範囲」が生まれるのです。
ヒープオーバーフローと高度な攻撃手法
スタック攻撃が「関数の出口」を狙うのに対し、動的にメモリを確保する「ヒープ領域」でのオーバーフローは、隣接するデータや、「データの管理構造」を破壊します。
ヒープ領域の管理構造とメタデータの破壊
ヒープ領域は、mallocやfreeといった関数によって管理されています。古典的なアロケータ(メモリ管理の実装)では、管理を効率化するため、メモリブロックに隣接して「メタデータ(管理用ヘッダ)」が付与されています。メタデータには、そのブロックのサイズや、前後のフリーブロック(空き領域)へのポインタが含まれています。
ヒープオーバーフローが発生すると、隣接するデータや、実装によってはこのメタデータが破壊されます。特に古典的なアロケータでは、メモリを解放(free)する際、隣接する空きブロックを結合しようとする処理を悪用して、任意のメモリ位置に任意の値を書き込む「Unlinkアタック」などが知られています。この攻撃では、細工されたポインタを使って、メモリ管理の内部処理を乗っ取り、最終的に関数ポインタやグローバル変数を上書きします。
関数ポインタの書き換え
ヒープ領域には、C++のオブジェクトや構造体などが配置されることがあります。これらの中に「関数ポインタ(実行する関数のアドレスを格納した変数)」が含まれている場合、攻撃ターゲットとなります。
攻撃の流れは次の通りです。まず、ヒープ上のバッファをオーバーフローさせます。次に、隣接するデータ内にある関数ポインタを、攻撃コードのアドレスに書き換えます。そして、プログラムがそのポインタを介して関数を呼び出した瞬間、攻撃コードが実行されます。
スタック保護機能が普及した現代では、スタック攻撃が困難な場合の選択肢として、このヒープ攻撃や、関数の戻り先以外を狙う手法が使われます。
整数オーバーフローとの連鎖
バッファオーバーフローを誘発する「きっかけ」としてよく使われるのが「整数オーバーフロー」です。例えば、ユーザーが指定したサイズに「+1」してメモリを確保するコードがあったとします。サイズを32ビットの符号なし整数で扱っている場合、ユーザーが最大値(4,294,967,295)を入力すると、計算結果が一周して「0」になってしまいます。
その結果、プログラムは必要量に見合わない「0バイト」の確保を要求することになります(malloc(0)がNULLを返すか、データを格納できない小さな領域を返すかは実装依存で、いずれにせよ通常のバッファとしては使えません)。それにもかかわらず元のサイズのままデータを書き込めば、確保した領域を大きく超える範囲外書き込み(ヒープオーバーフロー)につながります。この種の脆弱性は、サイズ計算のロジックが複雑になるほど発見が困難になり、見落とされやすい盲点となっています。
バッファオーバーフローを防ぐ多層の防御技術
かつては極めて容易だったバッファオーバーフロー攻撃ですが、現在ではOS、コンパイラ、ハードウェアが連携した強力な防御策が導入されています。
コンパイラによる対策:スタックカナリア(Stack Canary)
「カナリア」とは、かつて炭鉱で毒ガスを検知するために使われた鳥に由来します。コンパイラは、関数の開始時にスタック上の「ローカル変数」と「リターンアドレス」の間に、ランダムな数値(カナリア値)を挿入するコードを追加します。
関数が終了する際、このカナリアの値が書き換わっていないかチェックします。バッファから連続的に書き込んでリターンアドレスまで到達させる典型的なオーバーフローでは、その手前にあるカナリアも必ず上書きされます。値の変化を検知したプログラムは、RET命令でリターンアドレスを使う前に自ら異常終了し、攻撃の成立を阻止します(GCC/glibcでは__stack_chk_failが「stack smashing detected」を出力してabort()で終了します。セグメンテーションフォールトではありません)。
GCCコンパイラでは-fstack-protectorオプションで有効化でき、Visual Studioでは/GSオプションが対応します。ただし、カナリア値の生成にはランダム性が重要であり、予測可能な値を使用すると無効化されるリスクがあります。また、カナリア値が漏えいして元と同じ値で上書きされた場合や、カナリアを飛び越して書き込む攻撃、リターンアドレス以外のデータ(関数ポインタなど)の破壊は検知できません。保護対象となる関数の範囲もコンパイラのオプション(-fstack-protector-strong、-fstack-protector-allなど)によって異なります。

OSレベルの防御:ASLRとDEP(NXビット)
現代のOSには、以下の2つの強力な機能が標準搭載されています。
ASLR(Address Space Layout Randomization)は、スタック、ヒープ、共有ライブラリなどが配置されるメモリアドレスをランダム化します(Linuxでは原則としてプログラムの起動ごと、WindowsのDLLなどはOSの起動ごとにランダム化されるなど、対象とタイミングはOS・設定によって異なります)。攻撃者は「自分のコードがどこにあるか」「システム関数(system関数など)がどこにあるか」を予測しにくくなり、ジャンプ先の指定が困難になります。ただし、アドレス情報の漏えいなどによって回避される場合があり、単独で攻撃を防ぎ切る保証はありません。Windowsでは Vista以降、Linuxではカーネル2.6.12以降で段階的に導入されています(同バージョンではスタック・mmap領域が対象。ヒープ(brk領域)のランダム化は2.6.25以降。実行ファイル本体のベースアドレスのランダム化にはPIEコンパイルが別途必要)。なお、ASLRの先駆的実装はLinuxカーネルへのPaXパッチ(2001年頃)として先行して存在していました。
DEP(Data Execution Prevention)/ NX(No eXecute)ビットは、データ用のメモリページで命令の「実行」を禁止する仕組みです(読み書きの可否を含むアクセス権限全体はOSのページ保護機構が管理し、DEP/NXはそのうちの実行禁止を担います)。スタックやヒープなどのデータ用領域を「実行不可能」に設定することで、たとえシェルコードをメモリに注入できたとしても、CPUがその実行を拒否します。ハードウェア支援のDEPは、CPUの機能であるIntel XDビット、AMD NXビットを利用して実現されています。
高度な攻撃手法:ROP(Return-Oriented Programming)
DEPによって「自分でコードを持ち込む」攻撃が封じられたため、攻撃者は「既にあるコードを利用する」手法を編み出しました。これが「ROP(Return-Oriented Programming)」です。
プログラムや共有ライブラリ(libcなど)の中には、膨大な数の命令が存在します。その中から「特定の処理(レジスタへの代入など)を行い、最後にRET命令で終わる」という数バイトの断片(ガジェット)をかき集めます。
スタックにこれらガジェットのアドレスを順に並べることで、既存の正規のコードを飛び石のように繋ぎ合わせ、最終的に攻撃者の望む処理(シェルの起動など)を構成します。RET命令が次々と実行されることで、まるでプログラムが意図的に設計されたかのように振る舞います。これに対抗するには、ガジェットのアドレスを予測しにくくするASLRが重要な緩和策となります。さらに、シャドースタック(Intel CETなど)で戻り先アドレスの整合性を保護する対策も実用化されています。
さらに高度な手法として、「JOP(Jump-Oriented Programming)」や「BROP(Blind Return-Oriented Programming)」などの亜種も登場しています。BROPは、ソースコードやバイナリにアクセスできない状況でも、総当たり的にガジェットを探索して攻撃を構築する手法です。
実践的なセキュアプログラミングの要諦
脆弱性を根本から断つには、開発段階での対策が不可欠です。
安全な代替関数の使用
C言語での開発においては、コピー先のサイズを指定できない旧来の関数を避け、サイズを指定できる代替関数を使用するのが基本です。ただし、サイズを指定できる関数でも、その引数を実際のコピー先の容量に合わせて正しく設定しなければ安全にはなりません。関数名を置き換えるだけでは安全性は保証されない点に注意してください。
strcpyの代わりにstrncpyを使用する場合、コピー上限バイト数nにはコピー先バッファの容量以下の値を指定します(strncpyはコピー元が短くても残りをNULで埋めて合計nバイトを書き込むため、nが容量を超えるとオーバーフローします)。またstrncpyは、コピー元文字列の長さ(NULバイトを除く文字数)がn以上の場合、コピー先バッファにNUL終端が書き込まれない仕様のため、明示的にbuffer[n-1] = '\0'を設定する必要があります(nが1以上で、コピー先の容量以下であることが前提です)。getsの代わりにはfgetsを使用します。strcatの代わりにstrncatを使用する場合は、第3引数がコピー先の容量ではなく「追加する最大文字数」である点に注意し、既存の文字列の長さと終端NULの分を差し引いた残り容量を超えないように指定します。
さらに安全性を高めるため、C11標準の附属書K(Annex K、実装は任意のため対応していない処理系もある)で規定されたstrcpy_s、strncpy_sなどの「_s」サフィックス付き関数や、OpenBSD由来のstrlcpy、strlcatの使用も推奨されます。strcpy_s等の「_s」サフィックス付き関数は、正しいコピー先の容量を指定すれば、成功時にはNUL終端を保証し、成否をerrno_t(エラーコード)として返します(引数が不正な場合などの制約違反時にはハンドラが呼ばれ、実装や設定によってはその場で処理が終了します)。strlcpy・strlcatは、正しいコピー先の容量(1以上)を指定し、strlcatではコピー先が既にその容量内でNUL終端されていることを前提に、NUL終端を保証しつつ、作成しようとした文字列の長さ(strlcpyはコピー元の長さ、strlcatは連結前のコピー先の長さ+コピー元の長さ)をsize_tで返すため、戻り値がバッファサイズ以上かどうかで切り捨ての発生をプログラム側で検出できます。いずれも、プログラマが安全性をより明確にハンドリングできる点で優れています。
静的解析と動的解析
静的解析(SAST: Static Application Security Testing)は、ソースコードをスキャンし、バッファサイズの不整合や危険な関数の使用を自動的に検出します。代表的なツールとして、Coverity、Fortify、Flawfinderなどがあります。CoverityやFortifyのような高機能なツールは、コードレビューでは見落としがちな複雑な制御フローや、関数・ファイルを跨いだデータフローまで追跡できます。一方、Flawfinderは危険な関数の使用などをパターンで検出する軽量なツールで、制御フローやデータフローの解析は行いません。解析能力や検出範囲はツールによって異なります。
動的解析は、プログラムを実際に動かして検査する手法です。その代表的な手法であるファジング(Fuzzing)は、実行中のプログラムに大量のランダムなデータや細工したデータ(ファズ)を流し込み、クラッシュなどの異常が起きるかどうかを確認することで、未知のオーバーフロー箇所を見つけ出します。AddressSanitizerなどの実行時メモリ検査ツールと組み合わせると、クラッシュに至らない範囲外アクセスも検出しやすくなります。AFL(american fuzzy lop、現在は後継のAFL++が広く使われています)やlibFuzzerなどのファジングツールは、コードカバレッジが広がる入力を優先して自動生成し、効率的に脆弱性を発見します。なお、DAST(Dynamic Application Security Testing)は主に稼働中のWebアプリケーションを外部から検査する手法を指す用語で、ファジングと同義ではありません。
メモリ安全な言語への移行
Rustのような「メモリ安全性」を言語仕様として持つ言語の採用も進んでいます。Rustでは、配列やスライスに通常の添字(a[i])でアクセスすると境界チェックが行われ、範囲外のインデックスを指定すると(多くは実行時に)パニックを発生させ、範囲外のメモリへのアクセス自体は実行しません。パニックは既定ではそのスレッドの処理を停止させ、メインスレッドであれば通常はプログラム全体が停止します(範囲外をNoneとして扱えるget()メソッドもあり、安全と証明できるチェックはコンパイラの最適化で省かれることがあります)。さらに所有権・借用のルールをコンパイル時に検査するため、unsafeを使わない範囲(safe Rust)では、バッファオーバーフローという脆弱性を原理的に排除することができます(利用するライブラリ内部のunsafe実装などが安全性の約束を守っていることが前提です)。
Rustの所有権・借用のシステムは、同じデータへの可変参照を同時に1つしか持てない(共有参照と同時にも持てない)ことをコンパイル時に強制し、ダングリングポインタ(解放済みメモリを指すポインタ)による解放後使用(Use-After-Free)も防ぎます。LinuxカーネルやFirefoxなど、セキュリティが重視される大規模プロジェクトでもRustの導入が進んでいます。

入力検証とサニタイゼーション
外部からの入力を受け付ける全ての箇所で、厳格な検証(バリデーション)とサニタイゼーション(無害化)を実施する必要があります。
具体的には、入力データの長さ、形式、文字種を事前に定義し、これに適合しないデータは即座に拒否します。ホワイトリスト方式(許可するものだけを列挙)を採用し、ブラックリスト方式(禁止するものを列挙)は避けるべきです。また、数値入力では整数オーバーフローを防ぐため、範囲チェックを必ず実施します。
【練習問題】バッファオーバーフロー攻撃の仕組みと防御策|メモリ構造を理解できているか?
サイバー攻撃の歴史の中で最も古典的でありながら、今なお脅威であり続ける「バッファオーバーフロー」。 本記事で解説したスタックやヒープの構造、攻撃者が悪用するCPUの挙動、そしてROPなどの高度な攻撃手法について、正しく理解できていますか? 知識の定着を確認するための練習問題(全10問)を用意しました。メモリの「境界」を守るための重要ポイントを再確認しましょう。
まとめ
バッファオーバーフロー攻撃は、コンピュータがプログラムを処理する根源的な仕組み――「メモリ構造」の隙を突いた攻撃です。
スタック領域は、関数の戻り先を管理しているため、代表的な攻撃対象となる領域です。ヒープ領域は、複雑なデータ構造やオブジェクトを破壊することで制御を奪います。これらに対して、現代のシステムはスタックカナリア、ASLR、DEPといった多層防御で守られています。
しかし攻撃者も進化を続けており、ROPのような高度な手法が開発されています。だからこそ、開発段階での安全なコーディング、ツールによる継続的な検証、そしてメモリ安全な言語への移行が重要です。
これらの構造を深く理解することは、単に攻撃を防ぐ知識を得るだけでなく、コンピュータがいかにして命令を実行し、データを管理しているかというアーキテクチャの本質を知ることに他なりません。セキュアなシステムを設計・構築するために、常にメモリの「境界」を意識する視点を持ち続けましょう。
+おまけ:毎日1通の無料メール講座つき。登録した日が「1日目」、図解と論理で16週間かけて基礎も固まります。