09. セキュアプログラミングと開発プロセス

【午後対策】そのコード、穴だらけ?Java/C++の脆弱性パターン徹底解剖

2026年2月5日

Kenta Banno

元CIOの窓際サラリーマン(50代) 。プライム上場企業の片隅で、情報処理安全確保支援士の合格を目指し奮闘中。 最新AI(Gemini/Claude)を相棒に、記事を作成しています。

午後試験、特に記述式問題において、プログラミングコードの読解は避けて通れない壁です。「自分はインフラ担当だからコードは苦手だ」と感じている方も多いかもしれません。しかし、試験で求められるのは、高度なアルゴリズムを実装する力ではありません。求められているのは、「セキュリティ上の欠陥(脆弱性)」を見つけ出し、それを塞ぐ力です。

コードの中に潜む脆弱性は、実は「典型的なパターン」に集約されます。C++におけるメモリ管理の不備や、Javaにおける入力値検証の甘さなど、出るべきところは決まっているのです。このパターンさえ頭に入っていれば、数百行のソースコードも、チェックすべきポイントが輝いて見えるようになります。

本記事では、試験で頻出するC++とJavaの脆弱性コードを具体的に提示し、なぜそれが危険なのか、そしてどう修正すべきかを徹底的に解説します。プログラマー視点ではなく、セキュリティエンジニアの視点でコードを読む技術を身につけましょう。

C/C++:メモリ管理に潜む魔物「バッファオーバーフロー」

C言語やC++は、ハードウェアに近いメモリ操作が可能な反面、メモリ管理の責任が開発者に委ねられています。ここで最も頻繁に発生し、かつ試験でも問われやすいのが「バッファオーバーフロー」です。これは、コップに水を注ぎすぎて溢れさせるようなもので、溢れた水(データ)が隣の重要な書類(メモリ領域)を濡らしてダメにしてしまう現象に似ています。

脆弱なコード例(スタックバッファオーバーフロー)

以下のC++コードを見てください。ユーザーからの入力をバッファにコピーするだけの単純な処理です。

#include <cstring>
#include <iostream>

void copyInput(char* userInput) {
    char buffer[10]; // 10バイトの領域を確保
    
    // 【危険!】入力サイズのチェックを行わずにコピーしている
    strcpy(buffer, userInput); 
    
    std::cout << "Copied: " << buffer << std::endl;
}

int main(int argc, char* argv[]) {
    if (argc > 1) {
        copyInput(argv[1]);
    }
    return 0;
}

なぜ危険なのか:メモリ構造の破壊

このコードの致命的な問題は、strcpy関数がコピー先のバッファサイズ(ここでは10バイト)を気にせず、コピー元のデータがNULL文字(\0)で終わるまで書き込み続ける点にあります。

もし、攻撃者がuserInputとして100バイトのデータを渡したらどうなるでしょうか。bufferに入りきらないデータは、隣接するメモリ領域に溢れ出します。この「隣接する領域」には、関数の**戻り番地(リターンアドレス)**などの重要な制御情報が格納されていることが一般的です。

スタックバッファオーバーフローの仕組み。バッファの境界を超えたデータがリターンアドレスを上書きする様子

攻撃者はこの仕組みを悪用し、リターンアドレスを「攻撃者が用意した不正なプログラムコード(シェルコードなど)の場所」に書き換えます。すると、関数が終了した瞬間、プログラムの制御が正常なルートに戻らず、攻撃者のコードに移り、システムが乗っ取られてしまいます。これが、バッファオーバーフロー攻撃の原理です。試験では、この「境界チェックの欠如」を指摘させる問題が頻出します。

修正後のコード(セキュアな実装)

対策はシンプルです。「書き込むサイズを制限する」ことです。

void copyInputSecure(char* userInput) {
    char buffer[10];
    
    // 【対策】書き込む最大サイズを指定する(バッファサイズ - 1)
    // strncpy は指定サイズまでコピーするが、終端文字の扱いに注意が必要
    strncpy(buffer, userInput, sizeof(buffer) - 1);
    
    // 手動で必ず終端文字を入れる
    buffer[sizeof(buffer) - 1] = '\0'; 
    
    std::cout << "Copied: " << buffer << std::endl;
}

C++であれば、そもそも生のポインタや配列を使わず、std::stringを使用することで、このようなメモリ管理のミスを根本的に防ぐことができます。試験問題で「修正案を記述せよ」と問われた場合、関数の置き換え(strcpystrncpyなど)や、サイズチェックのif文追加が解答の軸になります。

C/C++:解放したはずのメモリへのアクセス「Use-After-Free」

次に紹介するのは、動的メモリ管理における重大な欠陥、「Use-After-Free(解放後使用)」です。近年のサイバー攻撃でも頻繁に悪用される高度な脆弱性ですが、原理は非常に原始的です。ホテルのチェックアウトをした後に、返却し忘れた鍵を使って部屋に入ろうとする行為に似ています。

脆弱なコード例

#include <iostream>
#include <cstring>

struct UserData {
    char name[20];
};

void processUser() {
    UserData* user = new UserData();
    strcpy(user->name, "Admin");

    // メモリの解放(チェックアウト)
    delete user;

    // ... 何らかの処理 ...

    // 【危険!】解放済みのポインタにアクセスしている(鍵をまだ持っている)
    strcpy(user->name, "Attacker"); 
}

なぜ危険なのか:ダングリングポインタの悪用

delete user;を実行した時点で、そのメモリ領域は「使用済み」としてOSに返却されます。しかし、userというポインタ変数自体には、まだそのメモリのアドレス(住所)が残っています。これをダングリングポインタ(ぶら下がりポインタ)と呼びます。

Use-After-Free(解放後使用)の概念図。解放されたメモリ領域に攻撃者が別のデータを配置し、元のポインタ経由でアクセスさせる流れ

もし、deleteの後、再アクセスする前に、攻撃者が意図的に別のデータをそのアドレスに割り当てさせることに成功していたらどうなるでしょうか?プログラムは「自分の管理下のUserData構造体」に書き込んでいるつもりでも、実際には攻撃者が配置したデータや、別の重要なオブジェクトを書き換えてしまうことになります。これにより、認証回避や任意コード実行に繋がります。このタイミングのズレを利用した攻撃は非常に検知が難しく、コードレビューでの発見が重要になります。

修正後のコード

メモリを解放したら、即座にポインタを無効化するのが鉄則です。

void processUserSecure() {
    UserData* user = new UserData();
    strcpy(user->name, "Admin");

    delete user;
    
    // 【対策】解放と同時にポインタにnullptrを代入する
    user = nullptr;

    // ...

    if (user != nullptr) {
        strcpy(user->name, "Attacker");
    } else {
        std::cout << "Error: User memory is already freed." << std::endl;
    }
}

nullptrを入れておけば、誤ってアクセスしたとしても、多くのシステムでは即座にクラッシュ(セグメンテーション違反)して停止します。サイバー攻撃を受けて乗っ取られるよりは、プログラムが停止する方が、セキュリティの観点では「安全な失敗(Fail Safe)」と言えます。

Java:文字列連結が招く「SQLインジェクション」

Javaはメモリ管理が自動化されているため、バッファオーバーフローの心配は少ないですが、Webアプリケーション開発で多用されるため、Web特有の脆弱性が顕著に現れます。その代表格がSQLインジェクションです。これは、命令文とデータを混ぜて扱ってしまうことで発生する、「言語の解釈ミス」を突いた攻撃です。

脆弱なコード例(JDBCによる直接連結)

public void getUserInfo(String userId) {
    try {
        Connection conn = DriverManager.getConnection(DB_URL, USER, PASS);
        Statement stmt = conn.createStatement();
        
        // 【危険!】外部からの入力(userId)を文字列連結でSQLに埋め込んでいる
        String sql = "SELECT * FROM users WHERE id = '" + userId + "'";
        
        ResultSet rs = stmt.executeQuery(sql);
        // ... 結果の処理
    } catch (SQLException e) {
        e.printStackTrace();
    }
}

なぜ危険なのか:データと命令の混同

このコードにおいて、もし悪意あるユーザーが userId' OR '1'='1 という文字列を入力したらどうなるでしょうか。生成されるSQL文は以下のようになります。

SELECT * FROM users WHERE id = '' OR '1'='1'

本来の意図は「IDが一致するユーザーを探す」ことでしたが、攻撃者によって「IDが空、または1=1(常に真)」という条件に書き換えられてしまいました。データベースはこの命令を忠実に実行し、テーブル内の全ユーザーのデータを返してしまいます。これは、データベースエンジンが「ユーザーが入力したデータ」と「開発者が書いたSQL命令」の区別がつかなくなるために発生します。

文字列連結によるSQLインジェクションと、プレースホルダを使用した安全なクエリの比較図

修正後のコード(プレースホルダの使用)

対策の基本は、PreparedStatement を使用し、プレースホルダ(?)を使うことです。これを「静的プレースホルダ」と呼びます。

public void getUserInfoSecure(String userId) {
    try {
        Connection conn = DriverManager.getConnection(DB_URL, USER, PASS);
        
        // 【対策】プレースホルダ(?)を使用してSQLの構造を固定する
        String sql = "SELECT * FROM users WHERE id = ?";
        
        PreparedStatement pstmt = conn.prepareStatement(sql);
        
        // 値をバインドする(ここで入力値は純粋な「データ」として扱われる)
        pstmt.setString(1, userId);
        
        ResultSet rs = pstmt.executeQuery();
        // ...
    } catch (SQLException e) {
        e.printStackTrace();
    }
}

プレースホルダを使うことで、データベースへの命令文はコンパイル(準備)され、後から入ってくる userId はどんな文字列であっても単なる「文字データ」として処理されます。したがって、SQLとしての意味を持つ記号('OR など)を含んでいても、攻撃は成立しません。

試験では、StatementPreparedStatement に書き換える問題や、静的プレースホルダの有効性を問う問題が頻出です。「エスケープ処理」も対策の一つですが、実装漏れのリスクがあるため、根本対策としてはプレースホルダが推奨されます。

Java:ファイル操作における「ディレクトリトラバーサル」

Webアプリケーションでは、アップロードされたファイルを保存したり、指定されたファイルを表示したりする機能がよくあります。ここで発生するのがディレクトリトラバーサル(パストラバーサル)です。これは、想定されたディレクトリの外側にあるファイルに不正にアクセスする手法です。

脆弱なコード例

public void downloadFile(String filename) {
    // ベースとなるディレクトリ
    File baseDir = new File("/var/www/html/uploads");
    
    // 【危険!】入力値をそのままファイルパスとして使用
    File targetFile = new File(baseDir, filename);
    
    if (targetFile.exists()) {
        // ファイルの読み込み処理...
    }
}

なぜ危険なのか:相対パスによる脱出

攻撃者が filename として ../../../../etc/passwd という文字列を送ってきたとします。File オブジェクトは、これらを連結して /var/www/html/uploads/../../../../etc/passwd というパスを生成します。OSのファイルシステムにおいて .. は「一つ上の階層」を意味するため、これは最終的に /etc/passwd(システムのパスワード関連ファイル)を指すことになります。アプリケーションが意図した公開ディレクトリ(/uploads)の外側にある重要ファイルにアクセスされてしまうのです。

ディレクトリトラバーサルの仕組み。相対パス「..」を使用して許可されたディレクトリの外側へアクセスする様子

修正後のコード(正規化とチェック)

対策は、ファイルを使用する前にパスを「正規化(Canonicalize)」し、意図したディレクトリ内に収まっているかを確認することです。

public void downloadFileSecure(String filename) throws IOException {
    File baseDir = new File("/var/www/html/uploads");
    
    // 入力値だけでFileオブジェクトを作るのではなく、一度結合してみる
    File targetFile = new File(baseDir, filename);
    
    // 【対策1】getCanonicalPath() で正規化された(..を含まない)絶対パスを取得
    String canonicalPath = targetFile.getCanonicalPath();
    String baseCanonicalPath = baseDir.getCanonicalPath();
    
    // 【対策2】正規化されたパスが、ベースディレクトリのパスで始まっているか確認
    if (!canonicalPath.startsWith(baseCanonicalPath)) {
        throw new SecurityException("Invalid file path detected.");
    }
    
    // 安全が確認されてから処理を行う
    if (targetFile.exists()) {
        // ...
    }
}

Javaの getCanonicalPath() は、... などの相対パス指定を解決した、一意の絶対パスを返します。これを利用して、「最終的に指している場所」が許可されたエリア内かどうかを厳密にチェックする必要があります。ファイル名から単に ../ を削除するだけの「ブラックリスト方式」は、URLエンコードやUnicodeエンコーディングなどの回避策があるため、不十分であると試験でも判断されます。この「正規化してからチェック」というロジックは、ファイル操作に限らずセキュリティ全般で通用する黄金律です。

Java/C++共通:例外処理からの「情報漏洩」

機能的なバグではありませんが、エラーハンドリングの不備も重大な脆弱性につながります。システムが出力するエラーメッセージは、開発者にとってはデバッグの手がかりですが、攻撃者にとってもシステム内部を知るための宝の地図となります。

脆弱なコード例(Java)

try {
    // データベース処理など
} catch (SQLException e) {
    // 【危険!】スタックトレースをそのままWeb画面に出力してしまう
    e.printStackTrace(response.getWriter());
}

なぜ危険なのか:攻撃者へのヒント提供

開発中は便利なエラー詳細(スタックトレース)ですが、本番環境でこれをユーザーに見せてしまうと、攻撃者に多大なヒントを与えることになります。具体的には以下のような情報が漏れる可能性があります。

  • 使用しているライブラリのバージョン: バージョンが分かれば、そのバージョンに含まれる既知の脆弱性(CVE)を検索できます。
  • データベースの構造(SQLエラーの場合): テーブル名やカラム名がエラーに含まれることがあり、SQLインジェクションの成功率を高めます。
  • サーバーの内部パス構造: インストールディレクトリなどが露呈し、ディレクトリトラバーサルのターゲット選定に利用されます。

修正方針

エラーハンドリングの鉄則は「情報はログへ、画面には定型文を」です。

  • ユーザーへの表示: 汎用的なエラーメッセージ(例:「システムエラーが発生しました。管理者へ連絡してください」)のみを表示する。
  • 詳細情報: ログファイルなど、管理者しかアクセスできない場所に記録する。

試験では、Webアプリケーションの設定ファイル(web.xmlなど)でエラーページの表示設定を問われることもありますが、コード修正問題としても「catchブロック内の処理」が問われることがあります。

効果的な学習方法:「自分の書いたコードを攻撃する」視点を持つ

試験対策として最も有効なのは、過去問のコード(午後試験の設問)をただ読むだけでなく、「ここに悪意ある文字列を入れたらどう動くか?」をトレースすることです。

脳内攻撃シミュレーションの実践手順

  • 入力: 変数に AAA... (長大な文字列) や ' OR 1=1../../ を代入してみる。
  • 処理: その変数が関数やメソッドに渡されたとき、内部でどう処理されるか脳内デバッグする。
  • 結果: メモリがあふれないか、意図しないファイルが開かれないかを確認する。

この「脳内攻撃シミュレーション」を繰り返すことで、セキュアコーディングの感覚が鋭くなります。特に、過去問の解説を読む際は、「なぜこの解答になるのか」を、コードの挙動レベルまで掘り下げて理解するようにしてください。単なる暗記ではなく、ロジックとして理解することで、見たことのないコードが出題されても対応できるようになります。

【演習】午後試験を攻略する!Java/C++セキュアコーディング練習問題(全10問)

記事で解説した「脆弱性の典型パターン」は定着しましたか? 頭では理解していても、実際の試験形式で問われると、細かな仕様や対策の記述で迷ってしまうものです。

そこで、本記事の重要ポイントである「C++のメモリ管理」から「JavaのWeb脆弱性」までを網羅した確認用練習問題を作成しました。 プログラミングに苦手意識がある方こそ、この「型」をマスターすることが午後試験突破への近道です。まずは1問ずつ、知識の抜け漏れがないかチェックしてみましょう。

【演習】セキュアコーディング(Java/C++)脆弱性パターン理解度チェック

まとめ:脆弱性は「型」で覚える

JavaやC++のコードに潜む脆弱性は、無限のパターンがあるわけではありません。今回紹介した以下の「型」を押さえておくだけで、試験問題の見え方は劇的に変わります。

  • C/C++: メモリの境界(バッファサイズ)と、ポインタのライフサイクル(解放済みか否か)を疑う。
  • Java (DB): SQL文の組み立て方(連結かプレースホルダか)を疑う。
  • Java (File): パスの解決方法(..が含まれていないか、正規化しているか)を疑う。
  • 共通: 入力値はすべて「悪意がある」前提で検証し、エラー情報はユーザーに見せない。

SC試験の午後問題では、問題文の中に「ヒント」が隠されています。「入力値の長さはチェックしていない」「古いライブラリを使用している」といった記述があれば、そこに対応するコード上の脆弱性が必ず存在します。それを見つけるプロセスは、まさにパズルのピースを埋める作業です。

コードは単なる文字列の羅列ではありません。システムを守るための防壁です。その防壁に空いた穴を見つけ、塞ぐことができるエンジニアこそが、情報処理安全確保支援士として認定されるのです。プログラミングに苦手意識を持たず、まずは「型」を当てはめて間違い探しをする感覚から始めてみてください。それが合格への近道です。

次回は、開発したアプリケーションを守るための「APIセキュリティ」について解説します。REST API特有の認証や認可の課題に切り込んでいきましょう。

  • この記事を書いた人

Kenta Banno

元CIOの窓際サラリーマン(50代) 。プライム上場企業の片隅で、情報処理安全確保支援士の合格を目指し奮闘中。 最新AI(Gemini/Claude)を相棒に、記事を作成しています。

-09. セキュアプログラミングと開発プロセス