
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ステータスコードが返る場合、サービス全体がダウンしたわけではなく、単一のワーカープロセスがクラッシュして復旧した可能性が高いことを示します。