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

競合状態(Race Condition)の深淵:なぜ「残高チェック」はすり抜けるのか

「残高が1,000円しかないのに、なぜか2回連続でリクエストを送ったら2,000円分買えてしまった」。

現場のインシデント対応でこの手の相談を受けるとき、私はいつも苦笑いします。これは単なるバグではなく、並列処理の隙間を突く「TOCTOU(Time-of-Check to Time-of-Use)」という古典的かつ最も凶悪な攻撃の一つだからです。

多くのエンジニアは「DBでチェックしているから大丈夫」と過信します。しかし、CPUのクロック単位で物事を見れば、DBの SELECT と UPDATE の間には明確な「隙間」が存在します。この隙間に、ミリ秒単位で同期させた100個のリクエストを同時に叩き込んだらどうなるか。今回はそのメカニズムと、泥臭いまでの対策手法を伝授します。

—

1. 攻撃のメカニズム:TOCTOUの解剖

典型的な脆弱性のあるコードは、以下のような構造をしています。

// 脆弱な例:チェックと更新が分断されている
$balance = $db->query("SELECT balance FROM users WHERE id = 1");
if ($balance >= $price) {
    // 割り込み可能!
    $db->execute("UPDATE users SET balance = balance - $price WHERE id = 1");
    // 商品付与処理
}

このコードの問題点は明白です。SELECT で残高を確認してから UPDATE が完了するまでの間に、別のプロセス(別のリクエスト)が割り込む余地があること。攻撃者はツールを使って、この隙間に数ミリ秒間隔でリクエストを「絨毯爆撃」します。サーバーがマルチスレッドで動いている場合、この数個のリクエストが同時にこのコードを通過し、残高不足を検知する前に全リクエストが「更新処理」へと進んでしまいます。

—

2. 実践的防御:トランザクションと排他制御

この問題を解決する最も確実な方法は、DBの機能を正しく使うことです。「アプリケーション側で頑張らない」ことが鉄則です。

解決策A:悲観的ロック (SELECT … FOR UPDATE)

最も堅牢なのは、SELECT の時点でその行をロックし、処理が終わるまで他のトランザクションを待機させる手法です。

// セキュアな例:悲観的ロックを活用する
$pdo->beginTransaction();
try {
    // FOR UPDATE を付けることで、他のリクエストをこの行で待機させる
    $stmt = $pdo->prepare("SELECT balance FROM users WHERE id = :id FOR UPDATE");
    $stmt->execute(['id' => $userId]);
    $balance = $stmt->fetchColumn();

    if ($balance >= $price) {
        $pdo->prepare("UPDATE users SET balance = balance - :price WHERE id = :id")
            ->execute(['price' => $price, 'id' => $userId]);
        // ここでコミットされるまで他のリクエストはブロックされる
        $pdo->commit();
    } else {
        throw new Exception("残高不足です");
    }
} catch (Exception $e) {
    $pdo->rollBack();
    // エラーハンドリング
}

解決策B:アトミック・アップデート

そもそも「チェック」と「更新」を分ける必要がないなら、SQLの条件句に含めてしまうのが最強です。

-- アトミックな更新(これならロック不要で整合性が保たれる)
UPDATE users 
SET balance = balance - 1000 
WHERE id = 1 AND balance >= 1000;

-- このクエリの実行結果(影響を受けた行数)が 1 であれば成功、0 なら残高不足

—

3. インフラレベルでの防御(WAF / レートリミット)

アプリケーションのロジックだけでなく、外堀を埋めることも重要です。競合攻撃は短時間に大量の同一リクエストを送るため、Nginxレベルで制限をかけるのが有効です。

/etc/nginx/conf.d/rate_limit.conf に設定を追加し、特定のIPからの異常なリクエストを遮断します。

# 1秒間に10リクエストまでを許可(バースト耐性を調整)
limit_req_zone $binary_remote_addr zone=api_limit:10m rate=10r/s;

server {
    location /api/purchase {
        limit_req zone=api_limit burst=5 nodelay;
        # ...
    }
}

—

4. セキュリティチーフからの「泥臭い」アドバイス

最後に、教科書には載っていない現場の知見を一つ。

競合状態を狙う攻撃者は、しばしばAPIのレスポンス速度を監視しています。サーバーの応答速度が一定で、かつ 500 Internal Server Error が散発し始めたら、それは彼らが「ロックの競合」を引き起こそうとしているサインです。

1. ログの監視: 短期間に同じユーザーIDで発生しているDBロックエラーや、デッドロックエラーを監視アラートに組み込んでください。
2. 冪等性(Idempotency)の確保: クライアント側に Idempotency-Key(リクエスト識別子)を持たせ、同じリクエストがサーバーに届いた場合は初回のみ処理するように実装しましょう。これができていれば、競合以前に「二重実行」を完璧に防げます。

セキュリティは「完璧な設計」よりも、「攻撃者が嫌がる泥臭い多重防御」の積み重ねです。まずは、あなたのコードの SELECT と UPDATE の間に、どれだけの「隙間」があるか確認することから始めてみてください。それが、堅牢なシステムへの第一歩です。

コメント

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