
CVE-2026-9256の概念実証。NGINXのngx_http_rewrite_moduleにおけるヒープバッファオーバーフロー。重複するPCREキャプチャグループを含む細工されたURIによるワーカークラッシュとサービス拒否を実証。多段階の検証とキープアライブプロービングを含む。
適用範囲:ローカルテスト環境、許可された再現環境、脆弱性検証、および防御ルール分析にのみ使用。許可されていないターゲットには使用しないでください。この文書のPoCの考え方は、リモートから確認可能なNGINXワーカークラッシュ動作のみを検証するものであり、RCE、ASLR回避、安定したエクスプロイトチェーンは含みません。
CVE-2026-9256 は、NGINX ngx_http_rewrite_module におけるヒープバッファオーバーフロー脆弱性です。脆弱性のトリガーは、単に特定の固定URIにアクセスするだけではなく、特定のrewrite設定パターンに依存します。rewrite正規表現内に重複するPCREキャプチャグループが存在し、かつ replacement 部分で $1、$2 などの複数のキャプチャ変数が参照されている場合が該当します。
攻撃者が特別なURIを構築してrewriteロジックが該当パスに入った場合、NGINXはキャプチャ内容の処理、rewrite結果の連結、またはURI/パラメータエスケープ処理において、長さ計算と実際の書き込みの間に不一致が発生し、最終的にワーカープロセスのヒープメモリが破壊される可能性があります。
したがって、この脆弱性の鍵は /api というパス自体ではなく、対象のNGINX設定にリクエストがヒットする脆弱なrewriteルールが存在するかどうかにあります。PoCがデフォルトで使用する /api は現在の再現環境におけるサンプルパスに過ぎず、実際のテスト時には、NGINX設定内に重複するキャプチャグループと複数のキャプチャ変数を参照するrewriteルールが存在するかどうかに基づいて、リクエストパスを調整する必要があります。
現在のPoCの目標は、ワーカークラッシュ / サービス拒否 (DoS) 動作を検証することです。正確なヒープレイアウトの構築、リターンアドレスや関数ポインタの上書き、リモートコード実行の証明は試みません。リモート側で安定して観測できる証拠は、主に以下の通りです:トリガーリクエストの接続が異常に切断される、その後NGINXサービスが応答を再開する、keep-alive接続がトリガー後にワーカークラッシュによって中断される。
この脆弱性をトリガーした後、必ずしも固定のHTTP 500、502、400が返るとは限りません。理由は、NGINXのmaster-workerモデルでは、ワーカープロセスがクラッシュした後、masterが新しいワーカーを再起動するからです。攻撃者がリモート側で観測する現象は、通常、サービス全体が完全に利用不可になるのではなく、特定の接続が突然切断されたり、読み取りタイムアウトが発生したり、接続がリセットされたりし、その後再度 / にアクセスすると正常な応答が得られる、というものです。
したがって、PoCは1回のリクエストのHTTPステータスコードだけに基づいて脆弱性の有無を判断することはできません。長いURIを1回送信し、接続が切断されたのを見て「脆弱性あり」と判断すると、誤検出のリスクが高くなります。接続切断は、ネットワークの揺らぎ、プロキシのタイムアウト、中間デバイスによるリクエストの遮断、バックエンドのレート制限、サーバー側の能動的な接続クローズなど、他の原因でも発生します。
そのため、PoCは多段階の検証として設計する必要があります:
「トリガー接続の異常 + その後のサービス復旧 + keep-aliveの複数回の切断」が同時に発生した場合のみ、より確実にCVE-2026-9256スタイルのワーカークラッシュ動作が存在すると判断できます。
現在のPoCの中心的なトリガーパスは以下の通りです:
GET /api/++++++++++++++++++++++++++++++++... HTTP/1.1
Host: 127.0.0.1:19321
ここで、/api/ は現在のテスト環境でrewriteルールにヒットさせるためのサンプルルートであり、その後ろに多数の + 文字が連結されます。デフォルトの数は4096個です。
+ を選んだ理由は主に3つあります。
第一に、+ はURIの正当な文字であり、通常のHTTPクライアントで送信する場合、スペースや # のように切り詰められたり強制的に書き換えられたりすることがほとんどありません。そのため、現在のPoCは、rawソケットを使って不正なリクエスト行を構築する必要があるような、特定のrequest-targetバイパス脆弱性とは異なり、その必要がありません。
第二に、大量の繰り返し文字により、rewriteキャプチャグループに十分長い入力を与え、その後のreplacementの連結やエスケープ処理の出力サイズを拡大し、長さ計算と実際の書き込みの不一致を引き起こしやすくします。
第三に、+ の繰り返しペイロードは構造が単純で、パケットキャプチャ、ログ、IDSルールでの観察が容易であり、長さを調整してしきい値テストを行うのも便利です。
ただし、+ が理論上唯一のトリガー文字というわけではありません。真のトリガー条件は依然として「脆弱なrewrite設定にヒットする + 入力が関連キャプチャグループに入る + rewrite出力処理がヒープオーバーフローを引き起こす」です。環境によって、トリガールート、文字タイプ、長さしきい値は調整が必要になる可能性があります。
現在のPoCはデフォルトで多数の + をトリガー文字として選択していますが、これは + だけが問題を引き起こせるという意味ではありません。+ は汎用的なPoCに書き込みやすい文字であり、URI内で比較的安定しており、通常のHTTPクライアントで送信しやすく、パケットキャプチャの特徴も明確です。
脆弱性の原理から見ると、NGINXのrewrite処理中に NGX_ESCAPE_ARGS エスケープロジックに入り、元の1バイトから %XX 形式の3バイトに拡張される任意の文字が、長さ計算値が実際の書き込み値よりも小さくなる可能性を生み出します。つまり、トリガーポイントは本質的に + そのものではなく、「argsモードでエスケープ可能な文字が密集して現れること」です。
+ 以外に、理論上重点的に考慮すべき文字は以下の通りです:
スペース:0x20
#:0x23
%:0x25
&:0x26
?:0x3F
制御文字:0x00-0x1F
高位バイト:0x7F-0xFF
これらの文字が該当するキャプチャに入り、rewrite replacement内でargsコンテンツとしてエスケープ処理される場合、同様の拡張効果を生みます。例えば:
+ -> %2B
& -> %26
% -> %25
# -> %23
? -> %3F
スペース -> %20
このような文字が1つ現れるごとに、理論上1バイトから3バイトに拡張され、実際の書き込み長は2バイト増加します。入力中にこの種の文字が多数存在する場合、実際の書き込み長は以前に誤って計算されたバッファ長を大幅に超える可能性があり、ヒープバッファオーバーフローを引き起こしやすくなります。
ただし、異なる文字のPoCでの使用可能性は完全に同じではありません。
+ が最も安定しています。通常、HTTPのrequest-targetにそのまま現れることができ、ブラウザやコマンドラインツールで切り詰められにくく、URIのpath/query構造を自然に変更することもありません。そのため、現在のPoCは4096個の + をデフォルトのペイロードとして使用しています。
& も候補文字になり得ます。なぜなら、argsモードでは %26 にエスケープされるからです。しかし、シェルでは & にバックグラウンド実行の意味があるため、URL内でもクエリパラメータの区切り文字として扱われることが多く、テスト時には引用符と位置に注意しないと、リクエストが意図した通りに送信されない可能性があります。
% も同様に候補文字になり得ます。%25 にエスケープされるからです。ただし、% 自体もURLエンコードのプレフィックスであり、一部のクライアント、プロキシ、フレームワークは %XX シーケンスを解釈しようとすることがあります。不適切に構築すると、ターゲットが受信するのは生の % 文字ではなく、クライアント側で前処理された内容になる可能性があります。
? は理論上エスケープ可能な文字ですが、HTTPのrequest-target内ではpathとqueryを区切ります。パス内に直接配置すると、後続の内容がクエリ文字列として解析され、rewriteのキャプチャ範囲が変わってしまう可能性があります。そのため、これは補足テスト文字として適しており、デフォルトのPoCの主文字としては適しません。
# は理論上エスケープをトリガーできますが、ブラウザは # およびそれ以降のフラグメントをサーバーに送信しません。多くの高機能HTTPクライアントもこれをエンコードまたは切り詰めます。したがって、リテラル # をテストするには、通常 rawソケット、Burp Repeater、または生のrequest-targetを保持できるツールが必要であり、ブラウザのアドレスバーに直接依存することはできません。
スペース 0x20 もエスケープ文字ですが、通常のHTTP/1.1リクエスト行ではスペース自体が区切り文字であり、request-targetに直接入れるとリクエスト行の構造が壊れます。実際のテストで %20 と書いた場合、サーバー側の処理段階でエンコードされた形式を見るのかデコード後のスペースを見るのかは、具体的な解析フローとrewriteの位置に依存します。そのため、スペースは原理説明や補助テストには適していますが、デフォルトのペイロードとしては適しません。
0x00-0x1F の制御文字と 0x7F-0xFF の高位バイトもエスケープ範囲内ですが、実際のHTTPリンクではクライアント、プロキシ、WAF、NGINXのHTTPパーサーによって遮断、正規化、拒否される可能性が高くなります。これらはソースコードレベルでのエスケープ対象として説明できますが、通常のPoCのデフォルトトリガー文字としては推奨しません。
したがって、現在のPoCが + を使用する理由は、脆弱性が + でしかトリガーできないからではなく、+ が次の3つの条件を同時に満たすからです:argsエスケープ拡張をトリガーできる、安定して送信しやすい、URI構造を大きく変えない。防御ルールやトラフィック分析では、連続した + だけにマッチングするのではなく、他のエスケープ可能文字の高密度な組み合わせ、特に +、&、%、?、# などの文字が長いURI内に大量に現れる場合も考慮する必要があります。
検出の観点からは、より合理的な一般化は次の通りです:
/api/ の後に多数の + が現れる
ではなく:
長いURI内に、NGX_ESCAPE_ARGSモードで %XX に拡張される特殊文字が大量に現れる
もし ++++ だけを検出するルールであれば、現在のPoCのデフォルトの書き方しかカバーできません。攻撃者がペイロードを &&&&、%%%%、???? に変更したり、+%&?# を混合して使用した場合、単一の + 特徴では見逃す可能性があります。より安全な検出アプローチは、URIの長さ、特殊文字の密度、連続繰り返し回数、rewriteリスクパス、NGINXサービスの露出面を総合的に判断することです。
PoCの normalize_target 関数はコマンドライン入力を処理し、3つの形式をサポートします:
python3 CVE-2026-9256-poc.py 127.0.0.1:19321
python3 CVE-2026-9256-poc.py 127.0.0.1 19321
python3 CVE-2026-9256-poc.py http://127.0.0.1:19321
ユーザーが host:port のみを入力し scheme を指定しない場合、スクリプトは自動的に http://host:port を補完します。その後、urllib.parse.urlparse を使用してhostnameとportを解析し、base を生成します。例えば:
host = 127.0.0.1
port = 19321
base = http://127.0.0.1:19321
現在の実装は主にHTTP平文のテスト環境を対象としています。normalize_target は https:// 形式も受け付けますが、後述のkeep-alive検出は通常のTCPソケットを使用しており、TLS層は含まれていないため、HTTPSシナリオではクラッシュプローブが不正確になります。HTTPSをサポートするには、ソケットに ssl.wrap_socket または ssl.create_default_context().wrap_socket() を追加する必要があります。
PoCはまず check_alive(base) を呼び出し、ルートパス / にアクセスします:
GET /
ターゲットが任意のHTTPステータスコードを返せる場合、サービスは基本的に生存しており、テストを続行できます。接続が失敗した場合は直接終了し、到達不能なターゲットを脆弱性トリガー失敗と誤認するのを防ぎます。
次に、check_rewrite(base) を呼び出し、以下の内容にアクセスします:
GET /api/test
このリクエストは、/api/* が現在のテスト環境のrewriteロジックにヒットする可能性があるかどうかを観察するために使用されます。301、302、303、307、308などのリダイレクトステータスコードが返る場合、rewriteリダイレクト動作が顕著であり、PoCはLocationヘッダーを補助証拠として出力します。
ただし、このステップは必須の成功条件ではありません。一部の再現設定では、/api/* 自体が問題のあるrewriteパスに入る可能性があり、通常のプローブリクエストでもタイムアウトや異常処理が発生する可能性があるからです。そのため、スクリプトは正常なrewrite応答が得られなくても、トリガーフェーズに進みます。
トリガー関数は send_trigger(base, plus_count=4096) であり、コアロジックは以下の連結です:
payload = "/api/" + ("+" * plus_count)
デフォルトの最終リクエストパスは次のようになります:
/api/++++++++++++++++++++++++++++++++... 合計4096個の +
その後、requests.get(base + payload, timeout=10, allow_redirects=False) でリクエストを送信します。
ここで自動リダイレクトを無効にしている理由は2つあります。
第一に、rewrite自体がリダイレクトを返す可能性があります。HTTPクライアントが自動でリダイレクトに従うと、元のトリガーリクエストと後続のリダイレクトリクエストが混ざり、最初のリクエストで何が起こったのかを判断するのが難しくなります。
第二に、PoCが関心を持つのはトリガーフェーズの接続状態であり、リダイレクト後のビジネスページではありません。元の応答を保持することで分析が容易になります。
トリガー結果はいくつかのカテゴリに分類されます:
ConnectionError がキャッチされた場合、トリガーリクエスト中に接続が異常に閉じられたことを示し、これはワーカークラッシュのリモートでの現れである可能性があります。
ReadTimeout がキャッチされた場合、リクエスト送信後長時間正常な応答がなかったことを示し、ワーカーがハングアップした、クラッシュ前に正常に応答を返さなかった、またはネットワーク環境によるタイムアウトの可能性があります。
正常なHTTP応答を受信した場合、ステータスコードと応答本文の長さを出力しますが、正常な応答だけで脆弱性が存在しないと結論付けることはできません。なぜなら、一部の環境ではトリガー条件が完全に満たされていないか、ペイロードの長さが不足している可能性があるからです。
トリガーリクエストの後、PoCは1秒待機し、follow_up(base) を呼び出して再度ルートパス / にアクセスします。
このステップの目的はオーバーフローそのものを証明することではなく、NGINXが「ワーカークラッシュ後にマスターによって再起動される」という特徴を示すかどうかを判断することです。
トリガーリクエストで接続が異常に切断されたが、後続の / へのアクセスで200またはその他の正常なHTTPステータスコードが返る場合、サービス全体がダウンしたわけではなく、単一のワーカープロセスがクラッシュして復旧した可能性が高いことを示します。
トリガー後にサービスが長期にわたって到達不能になる場合、サービス全体が停止した、コンテナがクラッシュした、またはネットワーク異常が発生した可能性があり、この結果はCVE-2026-9256の正常なトリガーと直接同等にはなりません。
したがって、現在のPoCの判断基準は、リモートから確認可能なワーカークラッシュであり、単なる「サービス利用不可」ではありません。
PoCの中で最も重要な安定性検証は keepalive_probe(host, port, rounds=5, plus_count=4096) です。
これは requests を使わず、代わりに socket.create_connection を使用してTCP接続を確立し、同じkeep-alive接続上で3つのリクエストを連続して送信します。
最初のリクエストは通常のリクエストです:
GET / HTTP/1.1
Host: 127.0.0.1
Connection: keep-alive
このリクエストは、現在の接続が利用可能であることを確認し、後続のトリガーリクエストが可能な限り同じ接続に収まるようにします。
2番目のリクエストはトリガーリクエストです:
GET /api/++++++++++++++++++++++++++++++++... HTTP/1.1
Host: 127.0.0.1
Connection: keep-alive
このリクエストがワーカークラッシュをトリガーした場合、そのワーカーが維持していたkeep-alive接続は直接切断される可能性が高いです。
3番目のリクエストも通常のリクエストです:
GET / HTTP/1.1
Host: 127.0.0.1
Connection: close
3番目のリクエストが応答を受信できた場合、接続はトリガーリクエストによって中断されなかったことを意味し、このラウンドではワーカークラッシュと見なしません。
3番目のリクエストの送信に失敗した、データが読み取れなかった、または接続が既に閉じられていた場合は、次のように記録されます:
keepalive connection dropped
PoCはデフォルトで5ラウンド繰り返します。複数ラウンドを繰り返す意義は、偶発的なネットワークエラーによる誤判定を減らすことです。5ラウンド中でkeep-aliveの切断が複数回発生し、その後もサービスが応答を再開できる場合、リモート側の証拠はより強固になります。
PoCの最終判断は3段階に分かれます。
第一段階:脆弱性確認。
条件は次の通りです:
crash_count > 0 and recovered == True
つまり、keep-aliveプローブで少なくとも1回の接続切断が検出され、かつフォローアップの通常リクエストでサービスが応答を再開したことが証明された場合です。スクリプトは以下を出力します:
VULNERABILITY CONFIRMED - CVE-2026-9256 style crash behavior
Impact confirmed: worker crash / denial of service
RCE is not proven by this script
これは、現在の環境がCVE-2026-9256スタイルのワーカークラッシュ動作を示していることを示しますが、リモートコード実行を証明するものではありません。
第二段階:疑わしい存在。
条件は次の通りです:
kind == "connection_error" and recovered == True
つまり、メインのトリガーリクエストで接続切断が発生し、その後のサービスが復旧したが、keep-aliveプローブでワーカークラッシュが安定して確認できなかった場合です。スクリプトは VULNERABILITY SUSPECTED を出力します。
このケースは異常現象があるが、証拠が十分に安定していないことを示し、サーバー側の error.log、core dump、コンテナログ、デバッガなどを使ってさらに確認する必要があります。
第三段階:未確認。
信頼できるkeep-alive切断も、トリガー接続異常とサービス復旧の組み合わせも得られなかった場合、スクリプトは以下を出力します:
VULNERABILITY NOT CONFIRMED
これは、必ずしもターゲットが絶対に脆弱性を持たないことを意味するわけではありません。パスがrewriteにヒットしなかった、ペイロード長が不足している、文字の選択が適合しない、ターゲットのバージョンが既に修正されている、前方プロキシがURIを変更した、または現在のスクリプトがHTTPSに対応していないなどの理由が考えられます。
スクリプトの実行フローは次のようにまとめられます:
/ にリクエストし、ターゲットサービスの生存を確認。/api/test にリクエストし、サンプルrewriteパスがアクティブかどうかを判断。/api/ に4096個の + を加えた長いURIトリガーリクエストを送信。/ にリクエストし、ワーカーが復旧したか確認。ローカルテスト環境の例:
python3 CVE-2026-9256-poc.py http://127.0.0.1:19321
または:
python3 CVE-2026-9256-poc.py 127.0.0.1 19321
成功してトリガーされた場合、典型的な出力は次のようになります:
[+] Connection dropped during trigger request
[+] Worker is responding after trigger (HTTP 200)
round 1: worker likely crashed (keepalive connection dropped)
round 2: worker likely crashed (keepalive connection dropped)
...
[+] VULNERABILITY CONFIRMED - CVE-2026-9256 style crash behavior
[+] Impact confirmed: worker crash / denial of service
[*] RCE is not proven by this script
このような出力は、リモート側で比較的安定したワーカークラッシュの証拠が観測されたことを示します。
このPoCのセキュリティ境界は比較的明確です:
第一に、クラッシュ検証のみを行い、RCEの悪用は行いません。
第二に、ヒープスプレー、ROP、ASLR回避、シェルコード、コマンド実行ロジックは構築しません。
第三に、成功基準はワーカー接続の切断とサービス復旧であり、シェルを取得したりファイルを読み取ったりすることではありません。
第四に、ローカル再現、脆弱性検証、IDS/IPSルールの構築、修正前後の比較テストに適しています。
セキュリティをさらに強化するには、以下の制限を追加できます:
127.0.0.1、localhost、プライベートアドレス、または明示的に許可された実験ネットワークセグメントのみへのアクセスを許可。--plus-count パラメータを追加し、デフォルトで過度に大きなペイロードを送信しないようにする。--route パラメータを追加し、ユーザーがトリガーパスを明示的に指定できるようにし、/api/ に固定しない。--rounds パラメータを追加し、keep-aliveプローブの回数を制御。--print-request デバッグパラメータを追加し、実際に送信されたHTTPリクエストを出力して、パケットキャプチャ結果と比較できるようにする。PoCの導出から見ると、検出ルールは /api/ だけに注目すべきではありません。なぜなら、/api は脆弱性の固定パスではなく、現在のテスト環境のサンプルに過ぎないからです。真に価値のある検出ポイントは以下の通りです:
+、または +、&、%、?、# などの特殊文字が混合して現れる。ルールが /api/++++ だけに固定されている場合、現在のPoCと現在のテスト環境しかカバーできません。より一般的な攻撃トラフィックをカバーするには、「長いURI + 大量の特殊文字の高密度出現 + HTTPリクエスト方向 + NGINX rewriteリスクパス」を中心に特徴を抽出する必要があります。
同時に、正当な業務でも長いURLや多数のエンコード文字が存在する可能性があるため、ルールは長さしきい値、文字密度、繰り返し回数、パスコンテキストを組み合わせて誤検出を減らす必要があります。比較的安全な検出方向は次の通りです:
長いURI
+
NGX_ESCAPE_ARGSでエスケープ拡張される特殊文字が大量に存在
+
リクエスト方向 to_server
+
NGINX rewrite関連の露出面
単純に以下を検出するのではありません:
/api/++++
Suricata / Snortルールの場合、現在公開されているPoCのみをカバーしたいのであれば、連続した + を強い特徴の1つとして使用できます。亜種をカバーしたい場合は、特殊文字の範囲をPCREに含める必要があります。例えば +、%、#、&、?、およびその他のエスケープ拡張される可能性のある文字です。ただし、このようなルールは誤検出も発生しやすいため、urilen、文字繰り返ししきい値、パス制約、NGINXアセット範囲と組み合わせて使用する必要があります。
CVE-2026-9256のPoC導出の重点は、固定された脆弱性パスを探すことではなく、まず脆弱性のトリガー条件を理解することです:脆弱なrewrite設定、重複するキャプチャグループ、複数のキャプチャ変数の参照、およびrewrite処理結果を異常に拡張させる特別なURI入力。
現在のスクリプトが /api/ に4096個の + を加えたものを選択したのは、このパスが現在の再現環境のrewriteルールにヒットし、かつ大量の + が安定して長い入力負荷を生成できるからです。スクリプトはRCEを実装しておらず、接続切断、サービス復旧、keep-aliveの複数回の切断を通じてワーカークラッシュを証明します。
同時に、+ は最も安定していて送信しやすいデフォルト文字に過ぎず、唯一トリガー可能な文字ではありません。NGX_ESCAPE_ARGS モードで %XX に拡張されるすべての文字は、原理分析と防御ルールの検討範囲に含めるべきです。より正確な理解は次の通りです:長いURI内にエスケープ拡張可能な文字が高密度で現れ、脆弱なrewriteキャプチャとreplacement処理に入った後、長さ計算と実際の書き込みの不一致が発生し、最終的にワーカークラッシュまたはより深刻なメモリ破壊を引き起こす。