hunt-race-condition について
hunt-race-conditionは、Webアプリケーションの競合状態を特定し検証するセキュリティテスト用スキルです。同期リクエストの手法、対象の発見、影響確認、再現性評価を扱います。
主な機能
- 01同一・異種リクエストの競合パターン
- 02同期した並列リクエスト手法
- 03確認対象となるエンドポイントとコードの兆候
- 04レスポンスと状態変化の分析
- 05悪用可能性と再現性の確認
活用シーン
- 01クーポン、クレジット、決済処理の二重実行テスト
- 02投票や利用枠の並行処理による回避の検査
- 03アカウントや権限フローのTOCTOU評価
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ elementalsouls/ claude-bughunter --skill hunt-race-conditionインストール後、Claude Code で/hunt-race-conditionを入力して読み込まれたか確認します。
詳細
競合の発生 — 2 つのプリミティブ(ツールに依存しない)
競合に勝つには、狭い同じ時間枠に到着するリクエストが必要です。順番に送信しても成功しません。単一パケット/同期送信ツールを使います。Burp Repeater の「Send group in parallel」(HTTP/2 single-packet attack)、Turbo Intruder(engine=Engine.BURP2、gate sync)、または N 個のリクエストを同時に送信できる任意のクライアントが使えます。パターンは 2 つです。
- 同一リクエストの競合 — 1 つのリクエストの同一コピーを N 個、同時に送信します(上限超過:クーポン/ギフトカードを二重使用する、ユーザーごとに 1 回の制限を超過する)。成功の判定基準は、N 個のうち 2 個以上が 2xx を返すことです。
- 異なるリクエストの競合(部分的な構築) — 異なるリクエストのリストを同期した時間枠で一括送信し、複数のラウンドにわたって繰り返します。オブジェクトの作成途中に利用可能な状態になる、登録後の確認/TOCTOU の競合で使います。例(メール認証のバイパス — 任意のメールアドレスを登録し、構築中の時間枠に空のトークンで確認する):
まずRequest A: POST /register body: csrf=<csrf>&username=hacker&[email protected]&password=pw Request B: GET /confirm params: token= (empty) Fire A and B together, repeat ~20 rounds.GET /registerから新しい CSRF を取得してから、一括送信します。成功したら、新しいアカウントにログインし、目的の操作(例:ユーザーの削除など、状態を変更する管理操作)を実行します。空のトークンによる確認は、ユーザー行が存在する一方で、認証トークンがまだ設定されていない時間枠に成功します。
最重要ターゲット
競合状態は、金銭、アクセス制御、整合性に関する前提を破るため、重大度の高い発見事項です。これらの前提は、防御側がほとんどストレステストを行いません。最高額の報奨金が期待できる対象は次のとおりです。
- 金銭/クレジットシステム — ギフトカード、クーポン、紹介ボーナス、プロモーションクレジット、ウォレット残高の二重使用
- 投票/評判の操作 — 同じコンテンツへの複数回の高評価、ランキングやトレンドアルゴリズムの不正操作
- アカウント制限のバイパス — 無料プランの割り当て量を超過する、招待、トライアルの有効化、API キーの生成に関する「ユーザーごとに 1 回」の制限をバイパスする
- 権限昇格 — ユーザーの作成/アップグレードのフローで、ロールの割り当てや権限チェックに競合を発生させる
- 削除のバイパス — 「削除対象としてマーク」されてから「実際に削除」されるまでの狭い時間枠に、データを読み取る、または持ち出す
- 決済フロー — カードへの請求は 1 回なのに、複数回のサービス提供を受ける
報奨金が最も高い資産の種類: フィンテックアプリ、クレジット/サブスクリプションモデルを採用する SaaS プラットフォーム、評判システムを持つソーシャルプラットフォーム、EC の購入手続きフロー、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
検索する 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();
技術スタックの兆候
- Ruby on Rails で
with_lock/lock!を使っていない — ActiveRecord はデフォルトではロックしません - Node.js の async/await チェーン — アトミックではない DB の読み取り後の書き込み
- PHP で
SELECT ... FOR UPDATEを使っていない — レガシーなコードベースでよく見られます - マイクロサービス — サービス間呼び出しによって、自然に TOCTOU の時間枠が生じます
- Lua スクリプトや
INCRのアトミック性チェックを使わない Redis カウンター - メッセージキュー — 冪等性キーが欠けていることがよくあります
手順に沿った調査方法
-
一度限り、または使用回数が限られた操作を列挙する — 「ユーザーごとに一度」「数量限定」「残高を差し引く」といった制約を適用しているすべてのエンドポイントを洗い出します。これらが主なターゲットです。
-
状態遷移を把握する — 各ターゲット操作について、(a)どの状態を読み取るか、(b)どの状態を書き込むか、(c)読み取りと書き込みの間にどのような検証があるかを特定します。読み取りと書き込みの間の隙間が、攻撃の機会になります。
-
正常なベースラインリクエストを取得する — Burp Suiteでインターセプトしながら、正規の方法で操作を一度実行します。期待される一度限りの動作(例:クーポンが使用済みとして記録される、投票が一度だけカウントされる)を確認します。
-
並列リクエスト用のツールを準備する — 次のいずれかを使用します。
- Burp Suite Repeater → 「Send group in parallel」(HTTP/2のシングルパケット攻撃にはTurbo Intruder)
- 最終バイト同期には
engine=Engine.BURP2を指定したTurbo Intruder - バックグラウンド実行に
&を使ったcurl - 接続を事前に確立する
threadingまたはasyncioを使ったPython
-
競合を実行する — 同一のリクエストを10~50件、同時に送信します。重要なテクニックは、すべてのリクエストを事前接続してバッファリングし、最後のバイトをすべて同時に送信することです(HTTP/2が利用できる場合はシングルパケット攻撃)。
-
レスポンスを分析する — 次の点を確認します。
- 成功するはずなのが1件だけなのに、複数の
200 OKが返される - 成功メッセージが重複する
- データベース制約エラー(競合は発生したものの、最後の防御策に阻まれたことを示します)
- レスポンス時間にばらつきがある(1件が高速で残りが遅い場合は直列処理、すべて同程度の速度なら並列処理)
- 成功するはずなのが1件だけなのに、複数の
-
影響を検証する — 実際の状態を確認します。クレジットが二重に適用されましたか?投票数が複数回増加しましたか?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 -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 -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でボタンを無効化すれば二重送信を防げると考えています。サーバー側のロジックは強化されていません。
-
トランザクション外でのカウンター増分 —
votes_count += 1; save()を使っており、アトミックなSQLのUPDATE SET votes = votes + 1 WHERE id = ?を使っていません。 -
非同期バックグラウンドジョブ — 利用資格は同期的に確認され、処理は非同期で実行されます。最初のジョブが完了する前に、2つ目のリクエストが確認を通過します。
-
無効化処理を伴わないキャッシュ — 「ユーザーが投票済みか」のキャッシュ済み確認が、最初の書き込みが反映される前のキャッシュミス期間中に、古い
falseを返します。
バイパス手法
防御側が実装する対策(およびその回避方法)
防御策:ユーザーごとのレート制限
- 回避方法:レート制限はアクションが実行される前にチェックされます。リクエストを同時に送信します。カウントされる前にすべてレート制限のチェックを通過します。
防御策:冪等性キー/一意のリクエストトークン
- 回避方法:サーバーがトークンを生成または再利用する場合は、トークンなしで並列リクエストを送信してみます。または、一意性のチェック自体に競合ウィンドウがないか確認します。
防御策:データベースの一意制約
- 回避方法:制約が重複を検出するのは、競合が起きた後です。データベースが強制する前に、最初の2件がどちらも成功する可能性があります。一部だけ実行されるケースを探します。片方は成功し、もう片方はエラーになっても、両方が反映されることがあります。
防御策:短い有効期間/有効期限付きトークン
- 回避方法:有効なトークンを使って、すべてのリクエストを事前に準備します。HTTP/2 のシングルパケット送信を使い、すべてを1つの TCP フレームで送信して、サーバーが同じスケジューラースロット内に処理するようにします。
防御策:キューベースの直列化
- 回避方法:複数のキュー(または同じキューを処理する複数のワーカー)が重複メッセージを取得する可能性があります。有効期間中にキューへ過剰な負荷をかけてテストします。
防御策:アプリケーション層のミューテックス/ロック
- 回避方法:複数のアプリケーションサーバーを実行する分散システムでは、プロセス内のロックは共有されません。異なる CDN ノードまたは負荷分散されたサーバーを経由して、同じエンドポイントにリクエストを送信します。
防御策:アプリケーションコード内の「使用済み」チェック
- 回避方法:チェックと更新は別々に行われます。競合するリクエストのどちらの更新も完了する前に、両方のチェックが通過します。これを真に防げるのは、アトミックな
UPDATE ... WHERE used=false RETURNING idだけです。
ゲート 0 の検証
レポートを作成する前に、次の3点すべてを確認します。
-
攻撃者は今、何を実行できますか? 同じ1回限りのアクションが複数回成功したことを、スクリーンショットまたはログで実証できますか?(例:1人のユーザーによって投票数が2増える、クレジット残高にクレジットが二重付与される、クーポンが2回使用済みになる)
-
被害者は何を失いますか? 具体的で測定可能な被害がありますか?金銭的損失(クレジットの過剰発行)、完全性の損失(ランキングや投票の操作)、またはセキュリティ上の損失(権限を超えたアクセス付与)が生じますか?「カウンターが2回増えた」ことが有効なのは、そのカウンターに現実世界での価値がある場合だけです。
-
ゼロから10分以内に再現できますか? 20行のスクリプトを書き、新しいテストアカウントで実行して、少なくとも5回中3回、重複効果を確実に実証できますか?確実に制御できない完璧なタイミングが必要なら、悪用可能性の主張には説得力がありません。
実際の影響の例
シナリオ 1:ソーシャルプラットフォームでの投票操作
バグ報奨金プラットフォームの「人気レポート」機能では、賛成票によってレポートの表示順位と研究者の評判スコアを上げることができました。HTTP/2 の単一接続を使って、同じレポートへの賛成票リクエストを約15件並列に送信することで(シングルパケット攻撃)、研究者は1つのアカウントから10~15票を登録できました。これにより、レポートのランキングを人為的に引き上げ、研究者の評判スコアを操作し、プラットフォームのクラウドソーシングによる優先順位付けシステムをゆがめることができました。これは、脆弱性レポートのトリアージというプラットフォームの中核機能への信頼を直接損なうものでした。
シナリオ 2:大手ソーシャルネットワーク — プロモーションアクションの重複
大手ソーシャルネットワーク(Facebook 規模)では、1回限りのセキュリティクレジットを得るための電話番号の追加や、1回限りのボーナスの獲得など、プロモーションまたは利用回数に制限のあるアクションが、同時並列リクエストに対して脆弱でした。攻撃者は獲得エンドポイントに対して競合を発生させ、プロモーション特典を複数回受け取ることができました。これにより、プラットフォームに直接的な金銭的損失をもたらし、プラットフォーム内通貨や特典を大規模に不正蓄積することが可能になりました。ユーザー数を考えると、修正までのわずかな時間であっても、大きな金銭的リスクが生じていました。
シナリオ 3:クラウドインフラプロバイダー — リソース上限の回避
あるクラウドホスティングプロバイダーは、無料枠のユーザーが作成できるリソース(例:ドロップレット、プロジェクト、API キー)の数に上限を設けていました。上限のチェックとリソースの作成は、アトミックではない操作でした。20 件のリクエストを同時に送信して作成エンドポイントを競合させることで、攻撃者は制限の適用ロジックを回避し、利用枠の上限をはるかに超えるリソースを作成しました。これにより、無許可のコンピューティングリソース消費、課金詐欺、インフラの悪用が直接引き起こされ、プロバイダーの収益と正規ユーザー向けシステムの安定性の両方に影響しました。
開示済みレポートの引用(補足 +9 — 2016〜2024)
以下の実在する検証済みのバグ報奨金/協調的開示事例を、このスキルに追加します。4 件(#4、#11、#12、およびボーナスの参考事例)では、現代の HTTP/2 シングルパケット攻撃手法(Kettle DEF CON 31、2023年;Flatt Security による拡張、2024年)が使われています。この手法により、現在、ほとんどの最新の競合状態の悪用が可能になっています。
-
GitLab — CVE-2022-4037 メール認証レース(Kettle DEF CON 31 のケーススタディ) (NVD · PortSwigger Research)
- サブクラス:パスワードリセット/メールアドレス変更トークンのレース(メール認証における TOCTOU)
- HTTP/2 シングルパケット:はい — 「Smashing the State Machine」の代表的なケーススタディ
- ペイロード:メールアドレスを異なる 2 つのアドレスに変更する、並行した
POST /-/profileリクエストを 2 件送信します。状態遷移がアトミックでなかったため、アドレス 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がデータベースレベルのロックなしで配列に追加していました。修正では、アトミックな UPSERT を行うnullifiersテーブルを追加しました - 年:2023年 — $3,000(高)
-
Stripe — 上限を超えて利用できるプロモーションコード (H1 #1717650)
- サブクラス:クーポンの二重利用
- ペイロード:利用上限を 1 に設定してプロモーションコードを作成します。同じ加盟店の決済リンクを 2 つのタブで開き、両方にクーポンを適用して同時に支払いを行うと、どちらも成功します
- 根本原因:利用回数カウンターは決済後に増加し、決済とアトミックに処理されていませんでした。
promotion_code.times_redeemedに対する行レベルのロックもありませんでした - 年:2022年 — $250
-
Stripe — 手数料割引を複数回利用 (H1 #1849626)
- サブクラス:ウォレット/残高の二重支払い(Connect の手数料割引を繰り返し利用できる)
- ペイロード:一度限りのプロモーションクレジットについて、クレジット消費済みフラグが切り替わる前に、利用エンドポイントへ並列 POST リクエストを送信します
- 根本原因:クレジット残高オブジェクトのチェックと減算がアトミックではありませんでした
- 年:2023年 — $5,000、利用 1 回あたり約 $600 のプラットフォーム手数料の損失
-
Reverb.com — ギフトカードの複数回利用 (H1 #759247)
- サブクラス:クーポンの二重利用(ギフトカード)
- ペイロード:
POST /gift_cards/redeemをキャプチャし、N 回複製して並列実行すると、1 枚のカードから残高が N 回分加算されます - 根本原因:ギフトカードの消費済みマーカーが残高の加算後に書き込まれ、利用処理の読み取り時に
SELECT…FOR UPDATEがありませんでした - 年:2019年 — $1,500(基礎的で広く引用されている事例)
-
Cosmos / Starport faucet — 二重ミントの競合 (H1 #1438052)
- サブクラス:ウォレット/残高の二重支出(暗号資産 faucet のトークン発行)
- ペイロード:同時に送信される
/faucet/transferリクエスト。TransferGo 関数は、リクエストごとに状態を変更する処理を2つ実行しますが、どちらもアトミックではありません - 根本原因:faucet ハンドラーが受取人ごとにロックしていませんでした。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 総当たり攻撃(1パケットで10,000リクエストに拡張) (Flatt Security Research)
- サブクラス:競合によるレート制限の回避/MFA OTP 検証の競合(6桁の PIN、試行上限5回)
- 1パケット HTTP/2:あり — TCP の最初のシーケンス番号を同期させたうえで IP フラグメントに分割し、Kettle の1パケット攻撃を約30リクエストから166 msで10,000リクエストに拡張します
- ペイロード:異なる4~6桁の推測値を使った約10,000件の並行
POST /verify-pinリクエストを166 msで送信し、すべてをレート制限の適用期間内に到達させます - 根本原因:レート制限カウンターがリクエストごとに非同期でインクリメントされていたため、バッチ全体を通じて「試行回数5回」の判定が古いカウンター値を読み取っていました
- 年:2024 — 現代の1パケット攻撃の例として必ず参照
-
nopCommerce — CVE-2024-58248 ギフトカードの二重利用 (NVD)
- サブクラス:クーポンの二重利用(eコマースのチェックアウトにおける TOCTOU)
- 1パケット HTTP/2:あり — 1パケット攻撃で安定して再現できます
- ペイロード:同じギフトカードを適用する並行した
POST /checkout/PlaceOrderリクエストを2件送信 → 両方の注文が完了し、ギフトカード残高の引き落としは1回だけ行われます - 根本原因:注文処理のコードパスでギフトカード残高の行がロックされておらず、確認と引き落としがアトミックではありませんでした
- 年:2024(4.80.0より前のバージョン)
HTTP/2 1パケット攻撃 — 詳細リファレンス
1パケット攻撃は、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 は、N 個のリクエストを1つの TCP 接続上で多重化し、リクエストのバッチごとに1つの TLS レコードとして送信します。
- TLS レコードには複数の HTTP/2
HEADERSフレームを含めることができ、各 HEADERS フレームを個別のストリームの最後のフレームにできます。
つまり、1つの HTTP/2 接続で N 個のリクエストを事前に準備し、各 HEADERS フレームの最後の1バイトを除いてすべて送信してから、最後のバイトをすべて1回の TCP 書き込みで解放すると、(MTU 未満、約 1500 バイトであれば)TCP スタックはそれらを1つの IP パケットで送信します。サーバーのカーネルは、同じスケジューラーのティックで N 個すべてのリクエストを HTTP/2 パーサーに渡します。競合ウィンドウはもはやネットワークではなく、アプリケーション自体のアトミック性の欠如によるウィンドウになります。
(2)のサーバー側のディスパッチ順序については、Kettle は、N 個の事前解析済みリクエストを含むパケットを受け取った場合、最新のバックエンド(Node.js、Go、async Python)はマイクロ秒以内に並行してディスパッチすることを示しました。古いブロッキング型バックエンド(デフォルトの 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 レコードを見つけます。1つの TLS レコード内の1つの TCP セグメントに、END_STREAM が設定された N 個の H2 DATA フレームが含まれているはずです。
- TLS レコードまたは TCP セグメントが N 個表示される場合、ツールは順番に送信しています。競合ウィンドウはセグメント間の間隔であり、通常は広すぎます。
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(...)を実行します。すべて MTU 未満であれば、30 個すべてが 1 つの IP パケットで送信されます。complete(timeout=10)はレスポンスと所要時間を収集します。
各レスポンスオブジェクトの req.code、req.length、req.time を確認してください。2 つ以上のリクエストが、本来なら相互に排他的な成功を返した場合(たとえば、引き換え上限が 1 件なのに両方のクーポン適用が成功した場合)、競合に「勝った」ことになります。
Flatt の first-sequence-sync 拡張(N > 30 が必要な場合)
Kettle のオリジナル版は、MTU と TLS レコードの制限により、単一パケットの上限がおよそ N=30 です。Flatt Security の RyotaK は、2024 年 8 月にこの拡張を公開しました。
- IP フラグメンテーションを利用します。IP 層では単一の「論理」パケットでも、複数の物理 IP フラグメントに分割できます。
- TLS ハンドシェイクを同時進行で完了させ、SYN と SYN-ACK のタイミングを揃えることで、複数の接続間で TCP SEQ 番号を同期させます。
- その結果、サーバーがアトミックだと考えていたレート制限ウィンドウ内に、10,000 件の同時リクエストを 166 ms でサーバーに到達させました。
用途:1 ウィンドウあたり 5 回までの試行制限内で、6 桁の PIN(候補は最大 10^6 件)をブルートフォースします。first-sequence-sync がなければ約 200,000 ウィンドウが必要ですが、これを使えば約 100 ウィンドウで済みます。
実装については、flatt.tech/research/posts/beyond-the-limit-... に動作する PoC があります。
オペレータ向け手引き(状況に応じた選択肢)
| シナリオ | ツール/バリアント |
|---|---|
最新の 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 の実行と、すべての N 個の END_STREAM フレームが 1 つの TCP セグメントに含まれていることを示す Wireshark キャプチャ、そして本来なら相互に排他的な成功レスポンスが N 件返ることを示す PoC 動画です。
- 実際には競合しないエンドポイント(たとえば、行レベルのロックとトランザクションを使用するエンドポイント)に単一パケット手法を使わないでください。 ウィンドウ推定の手順は、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)
関連スキルとチェーン
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— Statistical-Sampling ゲートを適用します。異常なレスポンスが 1 件だけではノイズです。報告する前に、1 回の成功と、重複/割り当て超過/古い状態を示す N 件の実証を、レスポンスのスクリーンショットとともに求めます。