レースコンディションとは?起きるパターンと対策法

レースコンディションとは?起きるパターンと対策法

#セキュリティ#Web開発#脆弱性対策

レースコンディションは、並行して届いたリクエストが同じデータを同時に扱うことで、金額や在庫などの不変条件が壊れる脆弱性です。 対策の勘所は、共有状態に対する一連の操作を、原子的な更新や排他制御で守れているかどうかです。

レースコンディションとは

レースコンディション(競合状態):複数の処理が同じデータにほぼ同時にアクセスし、実行順序によって想定外の結果が生まれる問題です。 共有状態に対する一連の操作の原子性や排他性が保証されず、結果が実行順序に依存することが根本原因になります。

たとえば残高を確認してから引き落とす処理では、確認と引き落としの間に別のリクエストが割り込むと、残高以上の引き出しが通ります。 クーポンの二重適用、在庫を超える購入、ポイントの多重付与など、数量や金額の不整合による直接の損失に至ります。 逐次実行する単体テストではすり抜けやすい一方、2本の処理が同時に走るだけでも発生し、並行実行テストによって開発環境でも再現できます。

レースコンディションが起きるパターン

典型的な弱点は次のとおりです。

  • 残高や在庫を読み取ってからアプリ側で判定し、別クエリで更新している(check-then-act)
  • 同じ操作を二重に送っても弾けず、決済やポイント付与が複数回成立する
  • 一意であるべきデータの重複チェックと登録が分かれ、同時登録で重複が生まれる
  • 複数のリクエストが同じレコードを更新し、後勝ちで一方の更新が消える
  • 出金や送信の申請後に、確定前のわずかな時間差を突いて残高や上限を回避される
  • 高速な連打や自動化されたリクエストで、意図的に同時実行を起こされる

共通するのは「共有状態に対する一連の操作が原子的でも排他的でもなく、別の処理が割り込むと結果が実行順序に左右される」という点です。

対策法

原則は「確認と更新を分けず、データベースの機構で一貫性を保証する」ことです。

  • 在庫や残高の判定と更新を、条件付きの原子的な1文で行う(UPDATE ... WHERE stock > 0など)。これが本命の対策になる
  • 必要な範囲に排他ロックをかけ、同じレコードへの同時更新を直列化する
  • 同じ操作を重複して受けても結果が変わらないよう、冪等キーで二重処理を防ぐ
  • 一意であるべき値にはデータベースのユニーク制約を付け、重複登録を根本で弾く
  • トランザクションの分離レベルを、業務の要件に合わせて適切に選ぶ

アプリ側での確認は割り込みに弱く、本命はデータベースの原子的更新や制約です。 金額や在庫など不整合が損失に直結する処理から、原子的更新と一意制約を入れるのが費用対効果の高い進め方になります。

Rails での事例

危険なのは、確認と更新が別々のクエリに分かれた check-then-act の形です。

# 危険:確認と更新の間に別リクエストが割り込める
def withdraw
  if current_user.balance >= amount
    current_user.update!(balance: current_user.balance - amount)
  end
end

残高100に対して80を引くリクエストが同時に2本来ると、両方が確認を通過して20を書き込むため、データベース上の最終残高は20になります。 このコードだけで残高が負になるわけではありませんが、両方のリクエストが成功扱いになり、一方の減算が記録から失われるlost updateが起きます。 成功後に送金や出金台帳の作成を行う処理が続けば、残高には1回分しか反映されていないのに、外部への出金は2回成立し得ます。

直し方の一つ目は、条件と更新を1つの SQL にまとめる原子的更新です。

def withdraw
  amount = Integer(params[:amount], exception: false)
  raise InvalidAmountError unless amount&.positive?

  updated = User.where(id: current_user.id)
                .where("balance >= ?", amount)
                .update_all(["balance = balance - ?", amount])
  raise InsufficientBalanceError if updated.zero?
end

条件を満たす行だけが更新され、更新件数で成否を判定できます。 割り込む隙がそもそもありません。

二つ目は、行ロックで処理を直列化する方法です。 前後に複数の処理を伴う場合はこちらが書きやすくなります。

def withdraw
  amount = Integer(params[:amount], exception: false)
  raise InvalidAmountError unless amount&.positive?

  current_user.with_lock do
    raise InsufficientBalanceError if current_user.balance < amount
    current_user.update!(balance: current_user.balance - amount)
  end
end

with_lock はトランザクション内で SELECT ... FOR UPDATE により行を取得し直すため、同じユーザーへの同時処理はロック待ちで直列になります。

もう一つ、見落とされやすいのが一意性の検証です。 Rails の validates :xxx, uniqueness: true は「SELECT で重複を確認してから INSERT する」実装であり、それ自体が check-then-act です。 同時登録のレースには勝てないため、必ずデータベースのユニーク制約を併用します。

# マイグレーション:クーポンの二重適用を根本で弾く
add_index :coupon_redemptions, [:user_id, :coupon_id], unique: true

制約違反時は ActiveRecord::RecordNotUnique を捕まえて、重複適用として扱います。

残高にはデータベースのCHECK制約も置き、別の更新経路に検証漏れがあっても負の値を保存できないようにします。

add_check_constraint :users,
                     "balance >= 0",
                     name: "users_balance_nonnegative"

まとめ

レースコンディション対策の要点を整理します。

  • 根本原因は「共有状態に対する一連の操作の原子性や排他性が保証されず、結果が実行順序に依存すること」。check-then-actはその代表例になる
  • 本命の対策は条件付きの原子的更新。UPDATE ... WHERE balance >= ?のように条件と更新を1文にまとめ、更新件数で成否を判定すれば割り込む隙がない
  • 前後に複数の処理を伴うなら行ロック(with_lock)で直列化する。SELECT ... FOR UPDATEで取得し直すため、同じレコードへの同時処理はロック待ちになる
  • 金額は最小通貨単位の正の整数として検証し、残高にはデータベースのCHECK制約を置く。 原子的更新とは別の経路から不正な値が保存される場合も防ぐ
  • Railsのvalidates :xxx, uniqueness: trueはそれ自体がcheck-then-actであり、同時登録のレースには勝てない。データベースのユニーク制約を必ず併用する
  • 同じ操作を重複して受けても結果が変わらないよう、決済やポイント付与には冪等キーを持たせる

逐次実行する単体テストではすり抜けやすいものの、2本の処理を同期させる並行実行テストで開発環境でも再現できます。 金額や在庫など、不整合がそのまま損失になる処理から順に原子的更新とデータベース制約を入れてください。

関連して読める記事

「サーバ側で確認したつもりが、実際には確認になっていない」という点で、IDORや権限昇格と同じ系統の失敗です。 IDORが確認そのものの欠落、権限昇格が確認する主体の誤り、レースコンディションは共有状態への一連の操作が同時実行から保護されていない問題という違いがあります。 少数の細工したリクエストでも可用性や整合性を崩せる点はReDoSと共通しています。

IDOR(安全でない直接オブジェクト参照)とは?起きるパターンと対策法

IDOR(安全でない直接オブジェクト参照)の仕組みと起きるパターン、所有者チェックを軸にした対策を整理します。RailsのOrder.find(params[:id])がそのまま穴になる理由と、current_user起点の取得への直し方を具体的なコード例で示します。OWASP API Security Top 10で1位のBOLAに相当する脆弱性です。

2026年7月23日

権限昇格とは?起きるパターンと対策法

権限昇格(水平方向・垂直方向)の仕組みと起きるパターン、サーバ側での権限判定とデフォルト拒否を軸にした対策を整理します。画面でメニューを隠すだけの危険な実装と、管理系コントローラを共通の基底クラスへ集約する方法、Pundit の verify_authorized による掛け忘れ検出までを具体的なコードで示します。

2026年7月25日

ReDoSとは?起きるパターンと対策法

ReDoS(正規表現によるDoS)の仕組みと起きるパターン、破滅的なバックトラッキングを避ける書き換えと入力長の制限を軸にした対策を整理します。入れ子の繰り返しパターンの危険例、Rails の length バリデーションが防御にならない理由、Ruby 3.2 の Regexp.timeout の位置づけを具体的なコードで示します。

2026年7月25日
この記事をシェア