
Proof-of-concept for CVE-2026-9256, a heap buffer overflow in NGINX's ngx_http_rewrite_module. Demonstrates worker crash and denial of service via crafted URI with overlapping PCRE capture groups. Includes multi-stage verification and keep-alive probing.
Scope: Only for local ranges, authorized reproduction environments, vulnerability verification, and protection rule analysis. Do not use against unauthorized targets. The PoC ideas in this document only verify remotely observable NGINX worker crash behavior, and do not include RCE, ASLR bypass, or stable exploit chains.
CVE-2026-9256 is a heap buffer overflow vulnerability in the NGINX ngx_http_rewrite_module. The vulnerability is not triggered simply by accessing a fixed URI, but rather depends on a specific rewrite configuration pattern: overlapping PCRE capture groups in the rewrite regex, and multiple capture variables (e.g., $1, $2) referenced in the replacement part.
When an attacker constructs a special URI that causes the rewrite logic to enter a relevant path, NGINX may experience a discrepancy between the calculated length and the actual written data when processing captured content, concatenating rewrite results, or performing URI/parameter escaping. This ultimately results in heap memory corruption in the worker process.
Therefore, the key to this vulnerability is not the /api path itself, but whether the target NGINX configuration contains a vulnerable rewrite rule that can be hit by a request. The default used in the PoC is only an example path in the current reproduction environment. During actual testing, the request path must be adjusted based on the rewrite rules in the NGINX configuration that contain overlapping capture groups and reference multiple capture variables.
/apiThe current PoC aims to verify worker crash / denial of service behavior. It does not attempt to construct a precise heap layout, overwrite return addresses or function pointers, or demonstrate remote code execution. The stable evidence observable from the remote side is mainly: the trigger request connection is abnormally closed, followed by NGINX service resuming response, and keep-alive connections being interrupted by the worker crash after triggering.
After this vulnerability is triggered, it does not necessarily manifest as a fixed HTTP 500, 502, or 400. This is because NGINX's master-worker model allows the master to restart a new worker after a worker process crashes. The remote side typically does not see the entire service become completely unavailable; instead, a connection is abruptly closed, read times out, or the connection is reset, and then subsequently visiting / again yields a normal response.
Therefore, the PoC cannot determine whether the vulnerability exists based solely on the HTTP status code of a single request. If you send a long URI once, see a connection drop, and immediately conclude "vulnerability exists", the risk of false positives is high. Connection drops can also result from network jitter, proxy timeouts, request interception by intermediate devices, backend rate limiting, or the server actively closing the connection.
Thus, the PoC must be designed as a multi-stage verification:
Only when "trigger connection abnormal + subsequent service recovery + multiple keep-alive drops" all occur simultaneously can the existence of CVE-2026-9256-style worker crash behavior be judged more reliably.
The core trigger path of the current PoC is:
GET /api/++++++++++++++++++++++++++++++++... HTTP/1.1
Host: 127.0.0.1:19321
Where /api/ is the example route used to hit the rewrite rule in the current target, followed by a large number of + characters. The default count is 4096.
There are three main reasons for choosing +.
First, + is a legal URI character. When sent using a normal HTTP client, it is usually not truncated or forcefully rewritten like spaces, #, etc. Therefore, the current PoC does not need to use raw sockets to construct illegal request lines as in some request-target bypass vulnerabilities.
Second, a large number of repeated characters allow rewrite capture groups to obtain a sufficiently long input, expanding the output scale of subsequent replacement concatenation or escaping processing, making it easier to trigger inconsistencies between calculated length and actual written data.
Third, the payload structure of repeated + is simple, making it easy to observe in packet captures, logs, and IDS rules, and convenient for adjusting lengths for threshold testing.
However, note that + is not the only theoretically triggerable character. The real trigger condition is still "hitting a vulnerable rewrite configuration + input entering relevant capture groups + rewrite output processing triggering heap overflow." In different environments, the trigger route, character type, and length threshold may all need adjustment.
The current PoC defaults to using a large number of + as trigger characters, but this does not mean only + can trigger the issue. + is simply the most suitable character for writing into a general PoC because it is relatively stable in URIs, easily sent by normal HTTP clients, and its packet characteristics are clear.
From a vulnerability principle perspective, as long as a character enters the NGX_ESCAPE_ARGS escaping logic during NGINX rewrite processing and expands from the original 1 byte to the 3-byte %XX form, it can cause a discrepancy between the calculated length and the actual written data. That is, the trigger point is not essentially + itself, but "the dense appearance of characters that can be escaped in args mode."
In addition to +, characters that should be theoretically considered include:
Space: 0x20
#: 0x23
%: 0x25
&: 0x26
?: 0x3F
Control characters: 0x00-0x1F
High bytes: 0x7F-0xFF
If these characters enter the relevant captures and are treated as args content for escaping in rewrite replacement, they will produce similar expansion effects. For example:
+ -> %2B
& -> %26
% -> %25
# -> %23
? -> %3F
Space -> %20
Each occurrence of such a character theoretically expands from 1 byte to 3 bytes, increasing the actual written length by 2 bytes. If the input contains a large number of such characters, the actual written length may significantly exceed the buffer length that was incorrectly calculated earlier, making it easier to trigger a heap buffer overflow.
However, the usability of different characters in a PoC is not entirely the same.
+ is the most stable. It can usually appear directly in the HTTP request-target without being truncated by browsers or command-line tools, and it does not naturally change the path/query structure of the URI. Therefore, the current PoC uses 4096 + characters as the default payload.
& can also be a candidate character because it will be escaped to %26 in args mode. However, in shells, & has the meaning of background execution, and in URLs it is often used as a query parameter separator. Therefore, care must be taken with quoting and positioning during testing, otherwise the request may not be sent as intended.
% can also be a candidate character because it will be escaped to %25. However, % itself is also the prefix for URL encoding. Some clients, proxies, or frameworks may attempt to interpret %XX sequences. If constructed incorrectly, the target may not receive the raw % character but instead preprocessed content from the client.
? is theoretically an escapable character, but in the HTTP request-target, it separates path and query. If placed directly in the path, subsequent content may be parsed as a query string, thereby changing the rewrite capture scope. Therefore, it is more suitable as a supplementary test character, not as the default PoC primary character.
# can theoretically trigger escaping, but browsers will not send # and the fragment after it to the server. Many advanced HTTP clients will also encode or truncate it. Therefore, to test a literal #, raw sockets, Burp Repeater, or tools that preserve the original request-target are typically required; the browser address bar cannot be relied upon.
Space (0x20) is also an escape character, but in a normal HTTP/1.1 request line, space itself is a delimiter. Putting it directly into the request-target would break the request line structure. In actual testing, if written as %20, whether the server sees the encoded form or the decoded space depends on the specific parsing process and the rewrite location. Therefore, space is more suitable for theoretical explanation and auxiliary testing, not as the default payload.
Control characters (0x00-0x1F) and high bytes (0x7F-0xFF) are also within the escape range, but in real HTTP links, they are more likely to be intercepted, normalized, or rejected by clients, proxies, WAFs, or the NGINX HTTP parser. They can serve as explanations of escape objects at the source code level but are not recommended as default trigger characters for conventional PoCs.
Therefore, the current PoC uses + not because the vulnerability can only be triggered by +, but because + simultaneously satisfies three conditions: it can trigger args escape expansion, is easy to send stably, and does not significantly alter the URI structure. Protection rules or traffic analysis should not only match consecutive + characters; they should also consider high-density combinations of other escapable characters, especially when characters like +, &, %, ?, # appear in large numbers within long URIs.
From a detection perspective, a more reasonable generalization is not:
A large number of `+` after `/api/`
but rather:
A long URI containing a large number of special characters that will expand to `%XX` in NGX_ESCAPE_ARGS mode
If only ++++ is detected, the rule can only cover the default format of the current PoC. If an attacker replaces the payload with &&&&, %%%%, ????, or a mixture of +%&?#, a single + feature may miss them. A more reliable detection approach should combine URI length, special character density, consecutive repetition count, risky rewrite paths, and NGINX service exposure.
The PoC's normalize_target function handles command-line input and supports three forms:
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
If the user only inputs host:port without a scheme, the script automatically completes it to http://host:port. Then it uses urllib.parse.urlparse to parse the hostname and port, and generates a base, for example:
host = 127.0.0.1
port = 19321
base = http://127.0.0.1:19321
The current implementation mainly targets HTTP plaintext ranges. Although normalize_target accepts https:// forms, the subsequent keep-alive detection uses a plain TCP socket without a TLS layer, so the crash probe will be inaccurate for HTTPS scenarios. To support HTTPS, the socket needs to be wrapped with ssl.wrap_socket or ssl.create_default_context().wrap_socket().
The PoC first calls check_alive(base) to access the root path /:
GET /
If the target returns any HTTP status code, it indicates the service is basically alive and can proceed with further testing. If the connection fails, the script exits directly to avoid misinterpreting an unreachable target as a failure to trigger the vulnerability.
Then it calls check_rewrite(base) to access:
GET /api/test
This request is used to observe whether /api/* might hit the rewrite logic in the current target. If a redirect status code such as 301, 302, 303, 307, or 308 is returned, it indicates that the rewrite redirect behavior is quite noticeable, and the PoC will print the Location header as auxiliary evidence.
However, this step is not a mandatory success condition. In some reproduction configurations, /api/* itself may enter a problematic rewrite path, and even normal probing requests may time out or be handled abnormally. Therefore, even if the script does not obtain a normal rewrite response, it will still proceed to the trigger stage.
The trigger function is send_trigger(base, plus_count=4096). The core logic concatenates:
payload = "/api/" + ("+" * plus_count)
The default final request path is similar to:
/api/++++++++++++++++++++++++++++++++... 4096 + characters in total
Then the request is sent via requests.get(base + payload, timeout=10, allow_redirects=False).
Disabling automatic redirects here has two reasons.
First, the rewrite itself may return a redirect. If the HTTP client automatically follows the redirect, the original trigger request and subsequent redirect requests will be mixed together, making it difficult to determine what happened to the first request.
Second, the PoC focuses on the connection state during the trigger phase, not on the business page after the redirect. Keeping the original response facilitates analysis.
The trigger results are categorized as follows:
If a ConnectionError is caught, it indicates that the connection was abnormally closed during the trigger request, which may be a remote manifestation of a worker crash.
If a ReadTimeout is caught, it means no normal response was received for a long time after the request was sent, which could also be due to a worker freeze, a failure to return normally before a crash, or a network environment causing a timeout.
If a normal HTTP response is received, the status code and response body length are printed, but a normal response alone cannot negate the vulnerability, because in some environments the trigger conditions may not be fully met, or the payload length may be insufficient.
After the trigger request, the PoC waits 1 second and then calls follow_up(base) to access the root path / again.
The purpose of this step is not to prove the overflow itself, but to determine whether NGINX exhibits the characteristic of "worker crash followed by master restart".
If the trigger request connection is abnormally closed, but subsequent access to / returns 200 or another normal HTTP status code, it indicates that the service did not completely go down; rather, a single worker process was likely crashed and then recovered.
If the service remains unreachable for an extended period after the trigger, it may indicate a complete service stop, container crash, or network anomaly. Such a result cannot be directly equated with successful CVE-2026-9256 triggering.
Therefore, the judgment criterion of the current PoC is: remotely observable worker crash, not simply "service unavailable".
The most critical stability verification in the PoC is the keepalive_probe(host, port, rounds=5, plus_count=4096) function.
It does not use requests, but instead establishes a TCP connection directly using socket.create_connection and sends three requests consecutively on the same keep-alive connection.
The first request is a normal request:
GET / HTTP/1.1
Host: 127.0.0.1
Connection: keep-alive
This request is used to confirm that the current connection is available and to try to get the subsequent trigger request on the same connection.
The second request is the trigger request:
GET /api/++++++++++++++++++++++++++++++++... HTTP/1.1
Host: 127.0.0.1
Connection: keep-alive
If this request triggers a worker crash, the keep-alive connection maintained by that worker will likely be directly closed.
The third request is still a normal request:
GET / HTTP/1.1
Host: 127.0.0.1
Connection: close
If a response is still received for the third request, it means the connection was not interrupted by the trigger request, and this round is not considered a worker crash.
If the third request fails to send or no data is read, or the connection has already been closed, it is recorded as:
keepalive connection dropped
The PoC repeats this for 5 rounds by default. The significance of multiple rounds is to reduce false positives caused by occasional network errors. If keep-alive drops occur multiple times across 5 rounds and the service still resumes responding afterward, the remote evidence is more solid.
The PoC's final judgment is divided into three levels.
First level: Vulnerability confirmed.
The condition is:
crash_count > 0 and recovered == True
That is, at least one round of the keep-alive probe detected a connection drop, and the follow-up normal request proves that the service has resumed responding. The script outputs:
VULNERABILITY CONFIRMED - CVE-2026-9256 style crash behavior
Impact confirmed: worker crash / denial of service
RCE is not proven by this script
This indicates that the current environment exhibits CVE-2026-9256-style worker crash behavior, but does not prove remote code execution.
Second level: Suspected.
The condition is:
kind == "connection_error" and recovered == True
That is, the main trigger request experienced a connection drop, and the subsequent service recovered, but the keep-alive probe did not stably confirm a worker crash. The script outputs VULNERABILITY SUSPECTED.
This scenario indicates an anomaly, but the evidence is not stable enough. Further confirmation using server-side error.log, core dumps, container logs, or a debugger is required.
Third level: Not confirmed.
If there is neither a reliable keep-alive drop nor a combination of trigger connection anomaly and service recovery, the script outputs:
VULNERABILITY NOT CONFIRMED
This does not necessarily mean the target is completely not vulnerable; it could be that the path did not hit the rewrite, the payload length was insufficient, the chosen characters were not suitable, the target version was already patched, a front-end proxy altered the URI, or the current script does not support HTTPS.
The script execution flow can be summarized as:
/ to confirm the target service is alive./api/test to attempt to determine whether the example rewrite path is active./api/ plus 4096 + characters./ again to confirm whether the worker has recovered.Local target example:
python3 CVE-2026-9256-poc.py http://127.0.0.1:19321
Or:
python3 CVE-2026-9256-poc.py 127.0.0.1 19321
When successfully triggered, typical output will look like:
[+] 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
Such output indicates that relatively stable evidence of a worker crash was observed from the remote side.
The security boundaries of this PoC are clear:
First, it only performs crash verification, not RCE exploitation.
Second, it does not construct heap spray, ROP, ASLR bypass, shellcode, or command execution logic.
Third, its success criteria are worker connection drop and service recovery, not obtaining a shell or reading files.
Fourth, it is suitable for local reproduction, vulnerability verification, IDS/IPS rule creation, and pre/post-patch comparison testing.
To further enhance security, the following restrictions could be added:
127.0.0.1, localhost, private addresses, or explicitly authorized experimental network segments.--plus-count parameter to avoid sending an excessively large payload by default.--route parameter to allow users to explicitly specify the trigger path instead of hardcoding /api/.--rounds parameter to control the number of keep-alive probe rounds.--print-request debug parameter to print the actual HTTP request sent, facilitating comparison with packet capture results.From the PoC derivation perspective, detection rules should not focus solely on /api/, because /api is not a fixed vulnerability path, only the example in the current target. More valuable detection points should be:
+, or a mixture of special characters like +, &, %, ?, #.If rules only hardcode /api/++++, they will only cover the current PoC and the current target. To cover more general attack traffic, features should be extracted around "long URI + dense special characters + HTTP request direction + risky NGINX rewrite paths."
At the same time, since legitimate business may also involve long URLs or many encoded characters, rules need to reduce false positives through length thresholds, character density, repetition counts, and path context. A more reliable detection direction is:
Long URI
+
Large number of special characters that can be escaped and expanded by NGX_ESCAPE_ARGS
+
Request direction to_server
+
NGINX rewrite related exposure surface
Rather than simply detecting:
/api/++++
For Suricata / Snort rules, if only covering the public PoC, consecutive + can be used as one strong feature. If aiming to cover variants, the character range should be included in PCRE, such as +, %, #, &, ?, and other characters that may be escaped and expanded. However, such rules are also more prone to false positives and should be used in conjunction with urilen, character repetition thresholds, path constraints, and NGINX asset range.
The key to deriving a PoC for CVE-2026-9256 is not to find a fixed vulnerability path, but first to understand the trigger conditions: a vulnerable rewrite configuration, overlapping capture groups, multiple capture variable references, and special URI input that can cause abnormal expansion of rewrite processing results.
The current script chooses /api/ plus 4096 + characters because that path can hit the rewrite rule in the current reproduction environment, and a large number of + characters stably create long input pressure. The script does not implement RCE; instead, it proves worker crash through connection drops, service recovery, and multiple keep-alive drops.
At the same time, + is only the most stable and easiest default character to send; it is not the only character that can trigger the issue. Any character that expands to %XX in NGX_ESCAPE_ARGS mode should be considered in principle analysis and protection rule development. A more accurate understanding should be: Long URIs densely containing escapable expansion characters, when entering vulnerable rewrite captures and replacement processing, cause a discrepancy between calculated length and actual written data, ultimately leading to worker crash or more severe memory corruption.