● CWE-362 · CWE-367

競合状態と TOCTOU(CWE-362、CWE-367):並列リクエストがチェックを破る仕組みと直し方

値を読み、確認し、そのあと書き戻すコードでは、同時に届いた 2 つのリクエストがどちらも確認を通過し、ギフトカードが 2 回使われたり残高がマイナスになったりします。確認してから実行するまでの時間の窓がどう生まれるか、アプリケーション側の確認ではなぜ閉じられないか、そして 1 つの条件付き UPDATE ... RETURNING、行ロック、データベースの制約で操作をアトミックにする方法を、Python と PostgreSQL の例で解説します。

わかりやすい解説 (ELI5)

ある映画館には窓口が 2 つあり、F 列の空席は 1 つだけです。2 人の係員が同じ瞬間に座席表を見て、どちらも F7 が空いていると確認し、どちらもそれを売ってしまいます。どちらも売る前に確認はしました。問題は、確認と販売が別々の瞬間で、その間にもう 1 人が動いたことです。対策は、2 人とも同じ 1 冊の券の束から切り取ることです。最後の F7 を切り取った人のものになり、もう 1 人は束が空だと気づきます。確認と取得が 1 つの動作になります。

主要な概念と専門用語

TOCTOU(確認時点と使用時点のずれ)
確認した状態が、使う前に変わってしまうことがあります。Web のコードでは確認はたいてい SELECT で、使用はその後の UPDATE であり、その間にほかのリクエストが実行されます。
競合の窓と並列リクエスト
窓はたいてい数ミリ秒ですが、攻撃者は数十のリクエストを同時に届くように揃えられます。PortSwigger の 2023 年の研究では、多数の HTTP/2 リクエストの最後の数バイトを 1 つのパケットで送ると、約 1 ミリ秒以内にまとめて届くことが示されました。
アトミックな条件付き更新
UPDATE ... WHERE code = %s AND NOT redeemed RETURNING ... は 1 つの文で行の確認と変更を行います。行を受け取れるのは 1 つの呼び出し元だけで、ほかは何も受け取りません。
Read Committed での行ロック
PostgreSQL では、同じ行への 2 つ目の UPDATE は最初のトランザクションを待ち、その後コミット済みの行に対して WHERE 句を評価し直します。この再確認があるので、分離レベルを上げなくても条件付き更新は安全です。
制約と冪等キー
UNIQUE インデックスや CHECK (balance_cents >= 0) があれば、競合に負けた処理は二重に使われる代わりにはっきりエラーになります。Idempotency-Key を一意インデックス付きで保存すれば、クライアントは支払いを二重実行せずに再試行できます。

ステップ・バイ・ステップの攻撃フロー

ステップ 1

攻撃者が有効なギフトカードを 1 枚持っている

決まった額のカードが 1 枚と、入金先のアカウントがあります。

ステップ 2

大量の利用リクエストを同時に送る

同じコードの 20 件のリクエストが 1 ミリ秒以内に届きます。テスト用コード TEST-0001 を使った 2 件の並列リクエストはこの場合を示しています。

ステップ 3

すべてのリクエストが確認を通過する

どのリクエストもカードを使用済みにする前に各 SELECT が実行されるため、すべてが redeemed = false を見ます。

ステップ 4

残高が何度も加算される

各リクエストがフラグの書き込み前にカードの額を加算します。2015 年に Egor Homakov は、スターバックスのギフトカード間で並列に送金すると残高を無から作れることを示しました。

ソースコード比較:脆弱 vs 堅牢化

脆弱な実装
# gift_cards.py: 確認と使用が別々の文になっている
from .db import pool


# どれかが書き込む前に、並列の各リクエストが redeemed = false を見られる
def redeem_gift_card(user_id: int, code: str) -> int:
    with pool.connection() as conn:
        card = conn.execute("SELECT id, balance_cents, redeemed FROM gift_cards WHERE code = %s", (code,)).fetchone()
        if card is None or card[2]:
            raise ValueError("invalid or used card")
        conn.execute("UPDATE accounts SET balance_cents = balance_cents + %s WHERE user_id = %s", (card[1], user_id))
        conn.execute("UPDATE gift_cards SET redeemed = true WHERE id = %s", (card[0],))
        return card[1]
堅牢化されたセキュアパッチ
# gift_cards.py: 確認と使用を 1 つの文で行う
from .db import pool


# 行を受け取るのは 1 件だけ。残りはロックを待ち、NOT redeemed が成り立たなくなる
def redeem_gift_card(user_id: int, code: str) -> int:
    with pool.connection() as conn, conn.transaction():
        card = conn.execute(
            "UPDATE gift_cards SET redeemed = true, redeemed_by = %s, redeemed_at = now() "
            "WHERE code = %s AND NOT redeemed RETURNING balance_cents",
            (user_id, code),
        ).fetchone()
        if card is None:
            raise ValueError("invalid or used card")
        # 同じトランザクションで加算するので、両方の変更がそろってコミットされるか、どちらもされない
        conn.execute("UPDATE accounts SET balance_cents = balance_cents + %s WHERE user_id = %s", (card[0], user_id))
        return card[0]

エンジニアリング&システム堅牢化チェックリスト

参考資料

← セキュリティ目録をすべて見る 脆弱性ガイド一覧 →