Skip to main content

C++のバッファオーバーフロー攻撃:実践ガイド

著者
Headshot of Snyk Security Research Team

Snyk Security Research Team

hero buffer overflow

2022年7月28日

0 分で読めます

バッファオーバーフローは実行時エラーの一種で、プログラムがバッファや配列の末尾を越えて書き込みを行い(そのため「オーバーフロー」と呼ばれます)、隣接するメモリを破損させます。多くのバグと同様、バッファオーバーフローはプログラムを実行するたびに発生するわけではありません。予期しないユーザー入力など、特定の状況で脆弱性が顕在化します。

バッファオーバーフロー攻撃とは、バッファオーバーフローの脆弱性を悪用する攻撃です。通常は、アクセス権や情報を得ようとする悪意ある攻撃者によって実行されます。この記事では、バッファオーバーフローがどのように発生するのかを解説し、こうした攻撃からC++コードを保護する方法をご紹介します。

バッファオーバーフロー攻撃の例

バッファオーバーフローがどのように発生するかを理解するために、簡単なパスワードチェックを行う、バッファオーバーフロー攻撃に対して脆弱な次のコードを見てみましょう。

#include <cstdio>
#include <cstring>
#include <iostream>

const char *PASSWORD_FILE = "rictro";

int main()
{
  char input[8];
  char password[8];

  std::sscanf(PASSWORD_FILE, "%s", password);

  std::cout << "Enter password: ";
  std::cin >> input;

  // Debug prints:
  // std::cout << "Address of input: " << &input << "\n";
  // std::cout << "Address of password: " << &password << "\n";
  // std::cout << "Input: " << input << "\n";
  // std::cout << "Password: " << password << "\n";

  if (std::strncmp(password, input, 8) == 0)
    std::cout << "Access granted\n";
  else
    std::cout << "Access denied\n";

  return 0;
}

このコードはユーザーにパスワードの入力を促し(14行目)、入力内容を、あらかじめ読み込んでおいた保存済みのパスワード(12行目)と比較します(23行目)。一致すれば、ユーザーにアクセスが許可されます。

実際には、std::fscanfを使ってファイルからパスワードを読み込みますが、例を簡単にするため、ここでは文字列定数から読み込みます。また、本来はパスワードをそのまま保存せず、ソルトを加えてハッシュ化したものを保存しますが、説明を簡潔にするため、ここでは平文のパスワードを使います。

このような仕組みは、アプリケーションのシェアウェア版や試用版のロック解除、あるいは管理者パスワードの入力によるアプリケーション内の情報や機能へのアクセス許可に利用できます。

プログラムを実行して、どうなるか見てみましょう。

Enter password: rictro
Access granted

予想どおり、正しいパスワードを入力するとアクセスできます。一方、間違ったパスワードを入力するとアクセスは拒否されます。

Enter password: hello
Access denied

ここまでは問題なく動作しています。しかし、このアプリケーションにアクセスする方法は、正しいパスワードを入力することだけではありません。「sunshinesunshine」と入力するとどうなるか、見てみましょう。

Enter password: sunshinesunshine
Access granted

予想外の結果です。正しいパスワード「rictro」とはまったく異なる文字列を入力したのに、アクセスが許可されました。

これは、対象のプログラムに対するバッファオーバーフロー攻撃が成功したためです。何が起きたのかを理解するため、18~21行目のデバッグ出力のコメントを解除して、アプリケーションをもう一度実行してみましょう。

まず、正しい入力で実行します。

Enter password: rictro
Address of input: 0x7ffc5581e4a8
Address of password: 0x7ffc5581e4b0
Input: rictro
Password: rictro
Access granted

出力に特筆すべき点はありません。

次に、間違ったパスワードを試してみます。

Enter password: hello
Address of input: 0x7ffc5581e4a8
Address of password: 0x7ffc5581e4b0
Input: hello
Password: rictro
Access denied

今回も、アプリケーションは期待どおりに動作します。

では、特別なパスワード「sunshinesunshine」を入力するとどうなるか、見てみましょう。

Enter password: sunshinesunshine
Address of input: 0x7ffc5581e4a8
Address of password: 0x7ffc5581e4b0
Input: sunshinesunshine
Password: sunshine
Access granted

パスワードの値は「rictro」ではなく、「sunshine」になっています。

ここでは、8バイトの配列を2つ使っています。passwordにはアプリケーションのパスワードを、inputにはユーザー入力を格納します。この例で使っているコンパイラ(GCC 10.3)は、passwordをinputの8バイト後ろに配置するため(0x7ffc5581e4b0 - 0x7ffc5581e4a8 = 8)、配列はメモリ上で隣接しています。なお、別のコンパイラでは結果が異なる場合があります。

8文字未満のパスワードの場合、メモリブロックは次のようになります。

入力欄とパスワード欄を示す表。入力行には「hello\0」、パスワード行には「rictro\0」と表示されています。

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

バッファオーバーフローを示す表。入力値「sunshine」が隣接するパスワード欄にはみ出している。

ヌル終端文字\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

char*

std::vector

int array[15]

std::cin

scanf

<iostream>

<cstdio>

std::stringを使えば、この例のアプリケーションを修正できます。

修正版を見てみましょう。4行目、10行目、24行目が変更されています。

#include <cstdio>
#include <cstring>
#include <iostream>
#include <string>

const char *PASSWORD_FILE = "rictro";

int main()
{
  std::string input;
  char password[8];

  std::sscanf(PASSWORD_FILE, "%s", password);

  std::cout << "Enter password: ";
  std::cin >> input;

  // Debug prints:
  std::cout << "Address of input: " << &input << "\n";
  std::cout << "Address of password: " << &password << "\n";
  std::cout << "Input: " << input << "\n";
  std::cout << "Password: " << password << "\n";

  if (std::strncmp(password, input.c_str(), 8) == 0)
    std::cout << "Access granted\n";
  else
    std::cout << "Access denied\n";

  return 0;
}

std::stringは抽出演算子(>>)をオーバーロードしており、ストリームから安全に読み込めます。ストリームに入力サイズを問い合わせて必要なメモリを確保し、その後で読み込みを行う仕組みです。そのため、入力がどれほど長くてもバッファがオーバーフローすることはありません。最悪の場合でも、メモリが不足するだけです。

Enter password: hellooo…ooo
Address of input: 0x7ffc5581e4a8
Address of password: 0x7ffc5581e4c8
Input: hellooo…ooo
Password: rictro
Access denied

C形式のAPIを使う必要がある場合は、利用できるなら「安全な」代替APIを使いましょう。これらは境界チェックを行うバージョンで、追加のsizeパラメーターを受け取り、その範囲を越えて読み書きしないよう制限します。

推奨

避ける

scanf_s

scanf

fscanf_s

fscanf

sscanf_s

sscanf

strncmp

strcmp

strncpy

strcpy

strncat

strcat

アドレスサニタイザーでバッファオーバーフローを防ぐ

適切なコーディングの実践に加えて、バッファオーバーフローの検出に役立つ自動化ツールもあります。代表的なツールの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配列がメモリ上にどのように配置されるかを確認しました。アドレスサニタイザーを有効にすると、次のような配置に変わることがあります。

入力欄とパスワード欄を文字ごとのセルで示した表。「xhello\0」や「x x r i c t r o\0 x」などが含まれています。

✗印は、配列の周囲に配置されたポイズンメモリを表しています。

コンパイラが挿入するコードには、ポイズンメモリへのアクセスを検出するロジックが含まれます。変換前の簡単なコード例を見てみましょう。

array[i] = 5;

ASanによる処理後、コードは次のようになります。

if (is_poisoned(&array[i]))
{
  print_error();
  std::abort();
}
array[i] = 5;

アプリケーションがポイズンメモリにアクセスしようとしているかどうかを判定するため、つまりis_poisoned関数を実装するため、ASanは「シャドウメモリ」を使います。これは、アプリケーションが実際に使うメモリのメタデータを保存する、独立したメモリ領域です。

実際のアルゴリズムは、この記事で扱える範囲を超えるさまざまな要素を考慮しますが、この簡単なネイティブ実装で基本的な仕組みを示せます。

アプリケーションがアクセスする各バイトに対応するビットをシャドウメモリに用意し、そのバイトがポイズンされているかどうかを示す簡単なビットセットを考えてみましょう。

次のような簡単な宣言を考えてみましょう。

char password[6];

シャドウメモリを実装するため、この宣言は次のように変換されることがあります。

char temp[8];
char* password = &temp[1];
shadow_memory[&temp...&temp+7] = 0b10000001;

ユーザーはスタック上に6バイトのchar arrayを宣言します。コンパイラは代わりに8バイトの配列を作成し、その中央へのポインターを返します。両側には1バイトずつ余白を設けます。続いて、tempのアドレスをインデックスとしてbitset shadow_memoryに10000001というビットパターンを書き込みます。これにより、passwordの6バイトは正常で、両側の2バイトはポイズンされていることを示します。

これでコンパイラは、ポイズンメモリへのアクセスを簡単に判定できるようになります。次のコードを見てみましょう。

*pointer = 5;

After the compiler injects code, the snippet now looks like this:

if (shadow_memory[pointer] == 1)
{
  print_error();
  std::abort();
}
*pointer = 5;

アドレスサニタイザーの基本を理解したところで、脆弱性のある元のアプリケーションに悪意ある入力「sunshinesunshine」を与え、アドレスサニタイザーを有効にして実行するとどうなるか見てみましょう。

Enter password: sunshinesunshine
Address of input: 0x7ffc5581e4a8
Address of password: 0x7ffc5581e4c8
=============================
==15857==ERROR: AddressSanitizer: stack-buffer-overflow on address 0x7ffc5581e4b0

注目すべき点は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の課題の解き方を学びましょう。

続きを読む

blog feature snyk container custom base recommendations
Blog

Evo Agentic AppSecのご紹介:AI開発のスピードに対応するセキュリティ

AI駆動型開発のスピードに、従来のセキュリティワークフローは追いついていません。SnykのAgentic AppSecは、セキュリティエージェントが脆弱性を継続的に検出、修正、検証し、人が基準を定められるようにします。

blog feature ai violet
Blog

VulnBump:AIエージェントはビルドを壊さずに脆弱な依存関係を修正できるか?

VulnBumpは、実際の依存関係アップグレード307件を対象にAIエージェントをテストします。脆弱性が解消され、ビルドとテストが成功した場合にのみ、修正としてカウントされます。

Blog

フロンティアモデルは脆弱性を発見した。攻撃者だけがエクスプロイトチェーンを見つけた。

静的解析で欠陥は見つかりましたが、ライブ攻撃テストで侵害につながる連鎖を実証できたのは唯一でした。Evo COS、Claude Security、Claude Code Securityを比較します。