C++のバッファオーバーフロー攻撃:実践ガイド
Snyk Security Research Team
2022年7月28日
0 分で読めますバッファオーバーフローは実行時エラーの一種で、プログラムがバッファや配列の末尾を越えて書き込みを行い(そのため「オーバーフロー」と呼ばれます)、隣接するメモリを破損させます。多くのバグと同様、バッファオーバーフローはプログラムを実行するたびに発生するわけではありません。予期しないユーザー入力など、特定の状況で脆弱性が顕在化します。
バッファオーバーフロー攻撃とは、バッファオーバーフローの脆弱性を悪用する攻撃です。通常は、アクセス権や情報を得ようとする悪意ある攻撃者によって実行されます。この記事では、バッファオーバーフローがどのように発生するのかを解説し、こうした攻撃からC++コードを保護する方法をご紹介します。
バッファオーバーフロー攻撃の例
バッファオーバーフローがどのように発生するかを理解するために、簡単なパスワードチェックを行う、バッファオーバーフロー攻撃に対して脆弱な次のコードを見てみましょう。
このコードはユーザーにパスワードの入力を促し(14行目)、入力内容を、あらかじめ読み込んでおいた保存済みのパスワード(12行目)と比較します(23行目)。一致すれば、ユーザーにアクセスが許可されます。
実際には、std::fscanfを使ってファイルからパスワードを読み込みますが、例を簡単にするため、ここでは文字列定数から読み込みます。また、本来はパスワードをそのまま保存せず、ソルトを加えてハッシュ化したものを保存しますが、説明を簡潔にするため、ここでは平文のパスワードを使います。
このような仕組みは、アプリケーションのシェアウェア版や試用版のロック解除、あるいは管理者パスワードの入力によるアプリケーション内の情報や機能へのアクセス許可に利用できます。
プログラムを実行して、どうなるか見てみましょう。
予想どおり、正しいパスワードを入力するとアクセスできます。一方、間違ったパスワードを入力するとアクセスは拒否されます。
ここまでは問題なく動作しています。しかし、このアプリケーションにアクセスする方法は、正しいパスワードを入力することだけではありません。「sunshinesunshine」と入力するとどうなるか、見てみましょう。
予想外の結果です。正しいパスワード「rictro」とはまったく異なる文字列を入力したのに、アクセスが許可されました。
これは、対象のプログラムに対するバッファオーバーフロー攻撃が成功したためです。何が起きたのかを理解するため、18~21行目のデバッグ出力のコメントを解除して、アプリケーションをもう一度実行してみましょう。
まず、正しい入力で実行します。
出力に特筆すべき点はありません。
次に、間違ったパスワードを試してみます。
今回も、アプリケーションは期待どおりに動作します。
では、特別なパスワード「sunshinesunshine」を入力するとどうなるか、見てみましょう。
パスワードの値は「rictro」ではなく、「sunshine」になっています。
ここでは、8バイトの配列を2つ使っています。passwordにはアプリケーションのパスワードを、inputにはユーザー入力を格納します。この例で使っているコンパイラ(GCC 10.3)は、passwordをinputの8バイト後ろに配置するため(0x7ffc5581e4b0 - 0x7ffc5581e4a8 = 8)、配列はメモリ上で隣接しています。なお、別のコンパイラでは結果が異なる場合があります。
8文字未満のパスワードの場合、メモリブロックは次のようになります。

一方、「sunshinesunshine」と入力すると、メモリは次のようになります。

ヌル終端文字\0がpasswordの末尾を越えて書き込まれ、その時点でスタック上に存在するデータを上書きします。
これは、std::cin(15行目)が境界チェックを行わないためです。改行、つまりユーザーがEnterキーを押すまでコンソールから読み込みますが、受け取り側のバッファにユーザー入力が収まるかどうかは確認しません。
配列の末尾を越えて読み取らないよう、passwordとinputの先頭8文字だけをstd::strncmp(23行目)で比較するため、本来は一致しないはずの文字列が一致してしまいます。
C++でバッファオーバーフローを防ぐ方法
他の高水準プログラミング言語と比べて、C++はバッファオーバーフローに特に脆弱です。C++標準ライブラリの一部を含め、エコシステムの多くで生ポインターが今も使われているためです。独自のコードではstd::vectorやstd::stringのような管理されたバッファを使えますが、vector::dataやstring::c_strを渡さなければならないC形式のAPIと連携すると、境界チェックの機能が失われます。
C++のコーディングのベストプラクティスを実践する
バッファオーバーフローを防ぐ最善の方法は、脆弱性のないAPIを使うことです。C++では、生の配列やポインターではなく、管理されたバッファや文字列を使うことを意味します。
推奨 | 避ける |
|---|---|
|
|
|
|
|
|
|
|
std::stringを使えば、この例のアプリケーションを修正できます。
修正版を見てみましょう。4行目、10行目、24行目が変更されています。
std::stringは抽出演算子(>>)をオーバーロードしており、ストリームから安全に読み込めます。ストリームに入力サイズを問い合わせて必要なメモリを確保し、その後で読み込みを行う仕組みです。そのため、入力がどれほど長くてもバッファがオーバーフローすることはありません。最悪の場合でも、メモリが不足するだけです。
C形式のAPIを使う必要がある場合は、利用できるなら「安全な」代替APIを使いましょう。これらは境界チェックを行うバージョンで、追加のsizeパラメーターを受け取り、その範囲を越えて読み書きしないよう制限します。
推奨 | 避ける |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
|
アドレスサニタイザーでバッファオーバーフローを防ぐ
適切なコーディングの実践に加えて、バッファオーバーフローの検出に役立つ自動化ツールもあります。代表的なツールの1つがAddressSanitizer(ASan)です。Visual Studio(v16.9以降)、GCC(v4.8以降)、Clang(v3.1以降)、Xcode(v7.0以降)など、主要なコンパイラすべてでサポートされています。Visual Studioでは/fsanitize=addressオプション、GCC/Clangでは-fsanitize=addressオプションを使って、アドレスサニタイザーを有効にできます。
ASanには、次の2つの機能があります。
mallocを改変版に置き換えることで、スタック上のすべてのオブジェクトとヒープ上のすべての割り当て領域の周囲に、数バイトの「ポイズンメモリ」を追加します。コンパイラはアプリケーションにコードを挿入し、ポイズンメモリへのアクセスを検出します。
先ほど、GCC 10.3でコンパイルした場合にinput配列とpassword配列がメモリ上にどのように配置されるかを確認しました。アドレスサニタイザーを有効にすると、次のような配置に変わることがあります。

✗印は、配列の周囲に配置されたポイズンメモリを表しています。
コンパイラが挿入するコードには、ポイズンメモリへのアクセスを検出するロジックが含まれます。変換前の簡単なコード例を見てみましょう。
ASanによる処理後、コードは次のようになります。
アプリケーションがポイズンメモリにアクセスしようとしているかどうかを判定するため、つまりis_poisoned関数を実装するため、ASanは「シャドウメモリ」を使います。これは、アプリケーションが実際に使うメモリのメタデータを保存する、独立したメモリ領域です。
実際のアルゴリズムは、この記事で扱える範囲を超えるさまざまな要素を考慮しますが、この簡単なネイティブ実装で基本的な仕組みを示せます。
アプリケーションがアクセスする各バイトに対応するビットをシャドウメモリに用意し、そのバイトがポイズンされているかどうかを示す簡単なビットセットを考えてみましょう。
次のような簡単な宣言を考えてみましょう。
シャドウメモリを実装するため、この宣言は次のように変換されることがあります。
ユーザーはスタック上に6バイトのchar arrayを宣言します。コンパイラは代わりに8バイトの配列を作成し、その中央へのポインターを返します。両側には1バイトずつ余白を設けます。続いて、tempのアドレスをインデックスとしてbitset shadow_memoryに10000001というビットパターンを書き込みます。これにより、passwordの6バイトは正常で、両側の2バイトはポイズンされていることを示します。
これでコンパイラは、ポイズンメモリへのアクセスを簡単に判定できるようになります。次のコードを見てみましょう。
アドレスサニタイザーの基本を理解したところで、脆弱性のある元のアプリケーションに悪意ある入力「sunshinesunshine」を与え、アドレスサニタイザーを有効にして実行するとどうなるか見てみましょう。
注目すべき点は2つあります。
ポイズンメモリを配置するため、
input配列とpassword配列の間隔は8バイトから32バイトに広がっています(0x7ffc5581e4c8-0x7ffc5581e4a8= 32)。コンパイラは
0x7ffc5581e4b0のアドレスでメモリ破損を検出します。このアドレスはinputからちょうど8バイト後ろにあり、最初に検出されたポイズンバイトに相当します。
C++プロジェクトを保護する
この記事では、C++におけるバッファオーバーフロー攻撃の基本と、プロジェクトを保護する最善の方法を解説しました。要点をまとめると、バッファオーバーフローとは、プログラムがバッファの末尾を越えて書き込み、メモリを破損させる脆弱性です。この脆弱性が悪用されると、制限されたアプリケーションや情報へのアクセスを許してしまう可能性があります。
高水準言語の中でも、C++はバッファオーバーフローに特に脆弱です。多くのAPIが今も生ポインターを使い、境界チェックを行わないためです。C形式のAPIではなく管理されたバッファや文字列を使うことでリスクを軽減できます。C形式のAPIが必要な場合は、追加のsizeパラメーターを受け取る安全なバージョンを使用してください。また、アドレスサニタイザーを有効にしてメモリの欠陥や範囲外アクセスを検出するツールを使うことでも、バッファオーバーフロー攻撃を防げます。
C++のセキュリティについてさらに詳しく知りたい方は、C/C++の脆弱性をわかりやすく解説した入門記事をご覧ください。また、C/C++のディレクトリトラバーサル脆弱性についてもご紹介します。
CTFを始めよう
オンデマンドのバーチャル入門ワークショップを視聴して、CTFの課題の解き方を学びましょう。



