はじめに
- レースコンディションは「複数の実行単位(スレッド/プロセス/リクエスト)が同じ共有資源を同時に扱うことで発生する、順序依存の不整合」
- 典型的な攻撃例:ギフトカードやクーポンを短時間に複数回適用して不正に割引を得る、残高以上の送金を成立させる(TOCTOU)
- 検出はログ解析+ペネトレーション(Burp Repeater の parallel/last-byte同期など)で行い、対策は同期(ロック)/原子操作/DBトランザクション/冪等化(idempotency)/一意制約などを組合せること
なぜ問題になるか
レストラン予約の例を想像してください。2人のホストが同じテーブル17を同時に「空いている」と見て予約を確定すると、誰が予約できたかわからなくなる——これがまさにレースコンディションです。Webアプリでは「クーポン適用」「残高チェック→送金」「ギフトカード消費」などの チェック(Time-of-Check)→使用(Time-of-Use) の間に短い時間窓があり、その間に別のリクエストが入り込むと不整合が起きます(TOCTOU)。
用語整理(おさらい)
- プログラム:静的な命令書(例:折り紙の説明書)。
- プロセス:実行中のプログラム(動的)。状態遷移あり(New → Ready → Running → Waiting → Terminated)。
- スレッド:軽量な実行単位。プロセス内でメモリを共有する。
- TOCTOU:Time of Check → Time of Use の間に状態が変わることによる脆弱性。
状態図(State diagram) — クーポン適用の例
(TryHackMe の教材では最初に 2 状態→更新で 3 状態→最終的に 5 状態へ拡張して説明しています。状態が増えるほど「窓(race window)」も生まれやすくなります。)
実例:簡単な Python スレッドのレース(再現コード)と修正
再現(脆弱)コード
# race.py
import threading
x = 0 # 共有変数
def increase_by_10():
global x
for i in range(1, 11):
x += 1
print(f"Thread {threading.current_thread().name}: {i}0% complete, x = {x}")
t1 = threading.Thread(target=increase_by_10, name="Thread-1")
t2 = threading.Thread(target=increase_by_10, name="Thread-2")
t1.start()
t2.start()
t1.join()
t2.join()
print("Both threads have finished completely.")
-
質問:このスクリプトはどちらのスレッドが先に 100% に到達するか保証するか?
→ Nay(保証しない)
対策:ロックで保護(簡単な修正)
import threading
x = 0
lock = threading.Lock()
def increase_by_10():
global x
for i in range(1, 11):
with lock:
x += 1
print(f"Thread {threading.current_thread().name}: {i}0% complete, x = {x}")
# ... スレッド生成・開始は同じ
-
lockを用いることでx += 1の読取→加算→書込の原子性を確保する。
Web レイヤーでの典型的な攻撃フロー(Burp Repeater を使った実演)
- 正常な
POST /transferリクエストを Burp Proxy で捕まえる。 - そのリクエストを Repeater に送る。
- Repeater で同一リクエストを複製(例:20回)し、
Send group in parallelを実行する。- HTTP/1 系では「最後の1バイト保留(last-byte sync)」でほぼ同時到着を狙う
- HTTP/2 では同一パケットで複数リクエストを同期的に送る仕組みを使うこともある
- サーバが脆弱な場合、並列リクエスト全てが承認され、残高以上の移転が多数回成立することがある。
TryHackMe の演習では THM{PHONE-RACE} / THM{BANK-RED-FLAG} のようなフラグを取得することで成功を確認します(教材内のサンプルフラグ)。
検出方法(オーナー視点/攻撃者視点)
- ペネトレーションテスト:Burp Repeater(並列送信)、JMeter や自作スクリプトでタイミングを詰める。
-
ログ解析:
- 同一クーポン/ギフトIDに対して短時間に複数の
applyがあったか - 同一ユーザーでの短時間多数リクエスト(burst pattern)
- DB 更新の競合ログ(deadlock、ロールバック)
- 同一クーポン/ギフトIDに対して短時間に複数の
- 監査ルール(SIEM):短時間内の同一資源アクセス回数が閾値超過ならアラート。
実務的な対策(優先順位つき)
-
DBレベルでの一意制約(最も強力)
- 例:
coupon_usesテーブルで(coupon_id, user_id)に UNIQUE 制約を設ける。並列挿入時に片方が失敗するので二重適用できない。
- 例:
-
トランザクション + 適切な隔離レベル
-
SERIALIZABLE(重いが安全) or 楽観的ロック(versionカラム) / 悲観的ロック(SELECT ... FOR UPDATE)
-
-
アプリケーションレベルのロック
- 分散ロック(Redis の RedLock 等)、DB row lock(
SELECT FOR UPDATE)
- 分散ロック(Redis の RedLock 等)、DB row lock(
-
原子操作/DB関数で処理を丸ごと完結させる
- 例:
UPDATE accounts SET balance = balance - :amt WHERE id=:id AND balance >= :amtのように条件付き更新を1文で。 - 結果行数が
1なら成功、0なら残高不足。
- 例:
-
冪等化(Idempotency)
- リクエストに
Idempotency-Keyを付与して、同一キーの再送は二度処理しない。
- リクエストに
-
キュー化(非同期で順次処理)
- クリティカルな処理はキューに入れて単一ワーカーで処理(スループット vs 整合性のトレードオフ)。
-
入力側の防御(短期)
- レート制限、CAPTCHA、短時間毎の制限(例:同一クーポンは5秒に一度しか適用できない)——ただし根本対策ではない。
実装例(素早く使えるスニペット)
SQL:原子アップデート(残高チェック+引落しを1ステートメントで)
-- params: :acct_id, :amt
UPDATE accounts
SET balance = balance - :amt
WHERE id = :acct_id AND balance >= :amt;
-- 変更行数が 1 なら成功、0 なら残高不足
PostgreSQL:トランザクション + FOR UPDATE(悲観ロック)
BEGIN;
SELECT balance FROM accounts WHERE id = :acct_id FOR UPDATE;
-- 残高チェックと更新を行う
UPDATE accounts SET balance = balance - :amt WHERE id = :acct_id;
COMMIT;
Idempotency(REST API): サーバサイドの考え方
- クライアントが
Idempotency-Key: <uuid>ヘッダを送る。 - サーバはそのキーを DB に保存して「処理済みレスポンス」を紐づける。
- 同じキーで再送が来たら、2回目は処理せず保存済みレスポンスを返す。
検査チェックリスト(ペンテスト/レビュー向け)
- 同一リソース(coupon_id, giftcard_id, transfer_id)に対する短時間の複数リクエストで挙動を確認したか?
- 重要処理は DB レベルで原子化されているか(1文での条件付き UPDATE 等)?
- トランザクションの隔離レベルは想定通りか?
- UNIQUE 制約・FOREIGN KEY など整合性制約は適切か?
- 冪等キー(Idempotency-Key)を導入できるエンドポイントはあるか?
- レース窓(Time-of-Check → Time-of-Use)を短くするために何をしているか(ロック/キュー/原子化)?
- ログで「短時間に同一資源アクセス」の検出ルールを用意しているか?
演習(TryHackMe の教材に触れたあとの課題)
- Burp Repeater で
Send group in parallelを試し、並列化がどの程度効果を出すか観察する(HTTP/1 vs HTTP/2 の違いを確認)。 - ラボアプリで残高を並列更新してみて、
SELECT FOR UPDATEやUPDATE ... WHERE balance >= amtの違いを検証。 - ローカルで上の Python race スクリプトを繰り返し実行して、
Lockの有無でログを比較する。
(TryHackMe の演習での成功フラグ例:THM{PHONE-RACE}、THM{BANK-RED-FLAG} — 教材での確認用)
まとめ
レースコンディションは「設計の匂い」を嗅ぎ分けられればかなり見つけやすい。
特に「チェック→複数ステップ→最終書込」のパターンがある処理は要注意。まずは DBで原子化できる箇所は原子化する、制約(UNIQUE)で守れるところは守る、そして必要なら分散ロックかキューで順序化する。性能と整合性は常にトレードオフなので、ビジネス要件(スループット vs 正確さ)を起点に設計しましょう。