●
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]
エンジニアリング&システム堅牢化チェックリスト
- お金、在庫、クーポン、利用枠に対する「読む・確認する・書く」の手順を、1 つの条件付き
UPDATE ... WHERE ... RETURNINGか、トランザクション内のSELECT ... FOR UPDATEに置き換える。 - 1 回限りの操作はデータベースの制約(
UNIQUE、CHECK (balance_cents >= 0))で裏付け、競合に負けた処理が 2 回成功せずに失敗するようにする。 - 支払いと利用のエンドポイントで
Idempotency-Keyを受け付け、一意インデックス付きで保存する。 - 複数のワーカーやサーバーがリクエストを処理する場合、プロセス内のロックやフラグに頼らない。
- 1 枚のテスト用カードに対して 20 件の利用を並列に送り、成功がちょうど 1 件であることを確認する並行性テストを追加する。