【入門編】 Race Condition (競合状態) を利用したトランザクション操作 – オフェンシブセキュリティ & リバースエンジニアリング防御ガイド

こんにちは!日々の開発やインフラの管理、本当にお疲れ様です。新人のIT担当者や、これからセキュリティの勉強を始めようとしている方にとって、覚えることが山ほどあって大変な時期ですよね。

今回は、Webアプリケーションに潜むちょっと厄介な罠、「Race Condition(競合状態)」という脆弱性について、一緒に優しく紐解いていきたいと思います。「なんだか難しそうな名前だな……」と思ったかもしれませんが、安心してください。身近な例えを使いながら、一歩ずつ分かりやすく解説していきますね!

—

1. 「Race Condition(競合状態)」ってなに? 身近な例えで考えてみよう

まずは、この攻撃がどういうものなのかをイメージするために、こんなシチュエーションを想像してみてください。

あなたは人気のアパレルブランドのオンラインショップで、欲しかった限定Tシャツをセール価格で購入しようとしています。このTシャツの在庫は、システム上では「残り1個」となっています。

ここで、あなたと、もう一人のライバル(攻撃者)が、全く同じ瞬間に「買う!」というボタンをポチッと押したとしましょう。現実の世界なら、レジに並んだ順番か、システムが受け付けた「ミリ秒単位の早い者勝ち」になりますよね。

しかし、もしWebシステムの裏側の処理が「おっと、ちょっと待ってね」という確認(チェック)と、「じゃあ売り上げを減らすね」という処理(実行)の間に、ほんのわずかな「隙」を作ってしまっていたらどうなるでしょうか?

家の鍵に例えてみると…

これを「合鍵泥棒」に例えてみましょう。
あなたは家の玄関のドアを閉めるとき、「鍵がちゃんと閉まっているかな?」と確認してから、鍵をガチャリと回しますよね。

  • 確認ステップ: 「鍵が開いているから、閉めよう」
  • 実行ステップ: 「鍵を回してロックする」

もし、この「確認」と「実行」の間に、ものすごいスピードで同時に2人の人間がドアを押して入ろうとしたらどうなるでしょう? システムが「あ、まだ鍵が閉まっていないから入ってもいいよ!」と、両方の人にOKを出してしまうかもしれないのです。

これが、システムの世界で言うRace Condition(競合状態)です。本来なら「1回しか使えないはずのクーポン」や「1個しかない在庫」が、同時に大量のリクエストを送ることで、チェックの目をかいくぐって何回も処理されてしまうという恐ろしい現象なのです。

—

2. 攻撃者はどうやってこの「隙」を突くの?

セキュリティの現場(レッドチームのペネトレーションテスト)では、この「隙」を突くために、人間業とは思えないスピードで同時に何十個、何百個ものリクエストをサーバーに送りつけるスクリプトを使います。

例えば、1000円分のポイントを使って買い物をする処理を考えてみましょう。通常、システムは以下の順番で動きます。

1. 残高の確認: ユーザーのポイントが1000円以上あるか?(あるよ!)
2. 残高の減算: 1000ポイントをマイナスする。
3. 注文の確定: 商品を購入済みにする。

ここで、攻撃者は「1」の確認が終わって「2」の処理が完了するまでの数ミリ秒の間に、全く同じリクエストを50回同時に送りつけます。すると、サーバーが混乱し、「まだポイントが減らされていない状態」のまま50回分の注文処理を同時に通してしまうことがあるのです。結果として、1000ポイントしか持っていないのに、50個の商品が買えてしまうという大事故につながります。

—

3. 脆弱なPHPコードの例を見てみよう

実際に、どんなコードがこの脆弱性を生んでしまうのか、シンプルなPHPの例を見てみましょう。以下のコードは、データベースの残高を確認せずに、そのまま減算処理を行ってしまう危険な例です。

<?
// 【危険な実装例】データベースから現在の残高を取得する
$user_id = 1;
$current_balance = get_user_balance($user_id); // 残高を取得

// 残高が足りているかチェック
if ($current_balance >= 1000) {
    
    // ここでミリ秒単位の「隙(タイムラグ)」が発生すると危険!
    usleep(50000); // 処理をわざと0.05秒遅らせるシミュレーション
    
    // 残高を減算してデータベースを更新
    update_user_balance($user_id, $current_balance - 1000);
    
    echo "購入が完了しました!";
} else {
    echo "残高が足りません。";
}
?>

このコードの何が問題か分かりますか? get_user_balance() で残高を確認してから update_user_balance() で更新するまでの間に、別のリクエストが割り込む余地があるのが原因です。

—

4. どうやってこの脆弱性を防ぐの?(実務で使える対策)

「じゃあ、どうやってこの隙を埋めればいいの?」という疑問がわきますよね。安心してください。ちゃんとした防御の仕組みが用意されています。

一番確実な方法は、データベースの「トランザクションと排他制御(ロック)」を使うことです。

対策1: データベースの行ロック(SELECT … FOR UPDATE)

データベースからデータを読み込むときに、「今からこのデータを更新するから、他の人は触らないでね!」と鍵をかける(ロックする)方法です。

<?
// 【安全な実装例】トランザクションを開始
$pdo->beginTransaction();

try {
    // FOR UPDATE を使って、この行を排他ロックする
    $stmt = $pdo->prepare("SELECT balance FROM users WHERE id = ? FOR UPDATE");
    $stmt->execute([$user_id]);
    $user = $stmt->fetch();
    
    if ($user['balance'] >= 1000) {
        // 残高を減算
        $new_balance = $user['balance'] - 1000;
        $update_stmt = $pdo->prepare("UPDATE users SET balance = ? WHERE id = ?");
        $update_stmt->execute([$new_balance, $user_id]);
        
        // コミットして処理を確定
        $pdo->commit();
        echo "安全に購入が完了しました!";
    } else {
        $pdo->rollBack();
        echo "残高が足りません。";
    }
} catch (Exception $e) {
    // エラーが起きたらロールバック(なかったことにする)
    $pdo->rollBack();
    echo "エラーが発生しました。";
}
?>

このように、FOR UPDATE を使うことで、最初の処理が終わるまで他のリクエストを「待合室」で待たせることができます。これで、同時に処理が走ってしまうのを完全に防ぐことができますね!

対策2: 適切なHTTPヘッダーやキャッシュの制御

APIやWebアプリケーションのレスポンスにおいて、ブラウザや中間キャッシュサーバーが古いデータを使い回さないようにすることも大切です。例えば、重要なトランザクションを扱うページでは、以下のようなヘッダーを設定してキャッシュを無効化します。

Cache-Control: no-store, no-cache, must-revalidate, max-age=0
Pragma: no-cache

これにより、クライアント側が意図せず古い状態のままリクエストを再送するリスクを減らすことができます。

—

まとめ

いかがでしたでしょうか? Race Condition(競合状態)は、目に見えにくい「タイミングの隙」を突く攻撃ですが、仕組みを理解してしまえば、適切なデータベースのロック機構(排他制御)を使うことでしっかりと防ぐことができます。

  • 「確認」と「実行」の間に隙を作らないこと
  • データベースのトランザクションと FOR UPDATE などのロックを活用すること

この2つを意識するだけで、あなたの作るシステムはぐっと安全になります。一歩ずつ、確実に対策を学んでセキュアな開発者を目指していきましょう!応援しています!

コメント

タイトルとURLをコピーしました