Disclosure: CVE-2026-91117 (CWE-20, CWE-248) affects ServeStatic up to 4.3.3. I reported it to the maintainer, Archmonger, as
jankesec. It is fixed in 4.3.4 by commit7227d98(PR #114) and published as GHSA-q353-2cxp-ppfh. 4.3.4 is on PyPI.
A request path that contains a NUL byte (%00) makes os.path.realpath() raise ValueError inside ServeStaticBase.path_within_root(), and nothing catches it. The affected request returns an HTTP 500. With Django’s DEBUG=True, the response is Django’s technical error page, which shows file paths, installed apps and settings to whoever sent the request.
The attacker sends one unauthenticated GET request. The code path is only reached when ServeStatic runs with autorefresh=True, and Django’s integration defaults that setting to the value of DEBUG.
This is not a path traversal. The containment checks held against the 17 traversal payload classes in the advisory’s tests. The NUL byte makes the check raise instead of return.
Evidence matrix
| Question | What I checked | Confidence | What remains unknown |
|---|---|---|---|
| Which versions are affected? | The upstream advisory lists <=4.3.3. Its proof of concept names commit ea6828f, which sits two commits after the 4.3.3 tag. The only change to base.py between 4.3.3 and 4.3.4 is the fix. | High | The first affected historical release was not independently established here. |
| What attacker position is required? | The advisory reports an unauthenticated GET request containing %00 through a live server. | High | Path decoding and rejection can vary with the front-end server and proxy. |
| Why does traversal fail but the request return 500? | The 4.3.3 source first checks lexical containment, then calls realpath() without catching ValueError. | High | No traversal bypass is claimed. The advisory reports 17 negative traversal tests. |
| When is the vulnerable lookup reachable? | The advisory identifies autorefresh=True. Django’s integration defaults this setting to DEBUG. | High | Exposure and operational impact in a particular deployment depend on configuration and telemetry. |
| What changed in the fix? | The 4.3.4 source wraps the two realpath() calls in try/except ValueError and returns False. | High | This review did not rerun the released package against every server configuration. |
Where the exception escapes
A request reaches the failing call like this:
GET /static/hello.txt%00.jpg (the server decodes %00 to \x00)
-> find_file(url) only runs when autorefresh=True
-> candidate_paths_for_url(url)
-> path_within_root(root, path)
-> os.path.normpath(path) the NUL byte stays in the string
-> os.path.realpath(normalized_path) raises ValueError
-> nothing catches ValueError the request ends with HTTP 500
path_within_root() first checks containment lexically with os.path.normpath(), then resolves symlinks with os.path.realpath(). This is the 4.3.3 code:
def path_within_root(self, root: str | os.PathLike[str], path: str | os.PathLike[str]) -> bool:
normalized_root = os.path.normpath(os.fspath(root))
normalized_path = os.path.normpath(os.fspath(path))
if not self._path_is_within(normalized_root, normalized_path):
return False
if getattr(self, "allow_unsafe_symlinks", False):
return True
resolved_root = os.path.realpath(normalized_root)
resolved_path = os.path.realpath(normalized_path)
return self._path_is_within(resolved_root, resolved_path)
normpath() only collapses . and .., so a NUL byte survives it. Python strings can hold \x00, but a C string ends at the first NUL, so CPython refuses such a path before it calls the C library. For os.lstat(), which realpath() uses, the error is ValueError: lstat: embedded null character in path.
CPython runtime mechanics: why ValueError is raised
The failure stems from CPython’s internal filesystem argument converter (PyUnicode_FSConverter in Modules/posixmodule.c). When Python passes a path string to a POSIX system call (lstat, stat, or open), it converts the immutable Python unicode object into a null-terminated C char * buffer:
// CPython posixmodule.c path converter (conceptual mechanism)
if (PyUnicode_READY(unicode) < 0) return 0;
length = PyUnicode_GET_LENGTH(unicode);
if (strlen(bytes) != length) {
PyErr_SetString(PyExc_ValueError, "embedded null character in path");
return 0;
}
If the string length recorded by Python exceeds the length reported by C’s strlen(bytes), a NUL byte (\0) is present inside the payload. CPython treats this as a programming contract violation and raises ValueError. Because it is an input validation error raised before entering the kernel, it bypasses ordinary OSError (e.g. ENOENT) handlers.
Reproduction
I verified this against commit ea6828f.
1. Direct call
Calling the internal method with a NUL byte in the path raises the exception, because nothing handles it:
from servestatic import ServeStatic
ss = ServeStatic(None, root="<served-dir>")
ss.path_within_root("<served-dir>", "<served-dir>/hello.txt\x00.evil")
# ValueError: lstat: embedded null character in path -- uncaught
2. Over HTTP
Against a Django development server running ServeStatic:
curl -i "http://127.0.0.1:8000/static/hello.txt%00.jpg"
HTTP/1.1 500 Internal Server Error
Content-Type: text/html; charset=utf-8
With DEBUG=True the body is Django’s technical error page, titled ValueError at /static/hello.txt%00.jpg. Every such request also writes a full traceback to the log.
The probe that did not catch it
The repository’s own scripts/security_probe.py has a null-byte probe:
response = client.get("/static/app.js%00.txt")
ok = response.status_code != 200
outcomes["null_byte_encoded_path"] = (ok, f"status={response.status_code}")
It passes when the status is not 200. As the advisory notes, against 4.3.3 the request crashes before the probe has a response to evaluate, so the probe never reached a verdict. A probe that only checks status codes cannot tell a clean rejection from a crash.
Impact
- Log flood and failed requests. Each request with
%00fails with a 500 and writes a full traceback to the log. The advisory describes this as a single-request denial of service and log flood. The exception ends the request that raised it, not necessarily the worker process. - Information disclosure with
DEBUG=True. Django answers with its technical 500 page, which lists:- absolute file paths on the host
- Python and Django versions
- the installed apps
- URL routes and middleware order
- local variables from each stack frame
The advisory suggests CVSS 3.1 AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L (about 5.3, Medium) for the crash alone, and a higher rating wherever DEBUG=True is reachable from an untrusted network.
The fix
Commit 7227d98 (PR #114), released as 4.3.4, catches ValueError around the two realpath() calls and returns False. A path with a NUL byte now counts as outside the root instead of raising. This is the change to src/servestatic/base.py between the 4.3.3 and 4.3.4 tags:
--- a/src/servestatic/base.py
+++ b/src/servestatic/base.py
@@ -162,8 +162,15 @@
if getattr(self, "allow_unsafe_symlinks", False):
return True
- resolved_root = os.path.realpath(normalized_root)
- resolved_path = os.path.realpath(normalized_path)
+ try:
+ resolved_root = os.path.realpath(normalized_root)
+ resolved_path = os.path.realpath(normalized_path)
+ except ValueError:
+ # A raw NUL byte (e.g. from a request path containing %00) survives
+ # normpath() but makes realpath() raise. Treat such a path as being
+ # outside the root (i.e. not found/forbidden) rather than letting an
+ # unhandled ValueError propagate and crash the request.
+ return False
return self._path_is_within(resolved_root, resolved_path)
Takeaways
os.path.normpath()only collapses.and... It leaves a NUL byte in the path.- Any
osfunction that reaches the C library can raiseValueErrorfor a NUL byte. Where the path comes from a request, catch it next toOSError. - Keep
DEBUG=Falseanywhere a requester can reach, staging included. The debug page turns a 500 into an information leak. - Treat an exception or a 5xx from a security probe as a finding, not as test noise.
Reverse proxy filtering
Upgrading to 4.3.4 or later is the fix. Until you can upgrade, a reverse proxy can reject NUL bytes before the request reaches Python.
Nginx
Add this to the server or location block in front of the application:
# Reject any URI containing raw or URL-encoded null bytes
if ($request_uri ~* "(%00|\x00)") {
return 400 "Bad Request: Illegal null byte in URI\n";
}
HAProxy
# Block requests with null-byte encoding
http-request deny deny_status 400 if { path -m reg -i (%00|\x00) }
Caddy
@null_byte {
path_regexp nullbyte (?i)(%00|\x00)
}
respond @null_byte "Bad Request" 400
Detection engineering and telemetry
Defenders can detect exploit attempts at the perimeter WAF, in web server access logs, and through application APM monitoring.
1. Web server and WAF detection
A ModSecurity rule identifies unauthenticated reconnaissance or exploitation probes attempting NUL-byte injection in path segments:
# ModSecurity rule for CVE-2026-91117 probe detection
SecRule REQUEST_URI "@rx (?i)(%00|\x00)" \
"id:1000101,\
phase:1,\
deny,\
status:400,\
log,\
msg:'NUL byte injection attempt in URI path (CVE-2026-91117)',\
tag:'application-dos',\
tag:'cve-2026-91117'"
In standard Nginx or Envoy access logs, look for bursts of HTTP 500 status codes originating from requests containing %00 or raw control characters:
198.51.100.42 - - [07/Sep/2026:01:45:12 +0000] "GET /static/app.js%00.txt HTTP/1.1" 500 14820 "-" "curl/8.5.0"
2. Application logging and APM telemetry
When unhandled ValueError crashes occur, Python WSGI/ASGI workers log full tracebacks. Sentry, Datadog, or Elasticsearch queries should monitor for the following signature:
Exception Type: ValueError
Exception Value: embedded null character in path
Stack Trace:
File "servestatic/base.py", line 85, in path_within_root
resolved_path = os.path.realpath(normalized_path)
Any occurrence of this exception in production signals either active scanning or an untrusted request payload escaping input normalization.
ATT&CK technique mapping
| Tactic | Technique ID | Technique Name | Exploitation Mechanism | Defensive Control |
|---|---|---|---|---|
| Impact | T1499.004 | Endpoint DoS: Application Exhaustion | Injected NUL byte triggers uncaught ValueError in realpath(), terminating the request with HTTP 500. | Catch ValueError in path normalization; return boolean false. |
| Credential Access / Discovery | T1212 | Exploitation for Credential Access | With DEBUG=True, unhandled exceptions render technical error pages exposing database keys and environment vars. | Enforce DEBUG=False in all production and staging deployments. |
| Discovery | T1082 | System Information Discovery | Detailed tracebacks reveal absolute server paths, Django versions, installed packages, and WSGI middleware chains. | Deploy custom 500 error handlers stripping debug details. |
Disclosure timeline
- Before 2026-09-07: I verified the issue against commit
ea6828fand reported it to the maintainer through a GitHub security advisory. - 2026-09-07, 01:51 UTC: The maintainer merged the fix (
7227d98, PR #114). - 2026-09-07, 02:09 UTC: Version 4.3.4 was tagged (#115). It reached PyPI at 02:13 UTC.
- 2026-09-07, 02:20 UTC: GHSA-q353-2cxp-ppfh was published and CVE-2026-91117 was assigned.
Vulnerability Scoring & CVSS Vector Breakdown
Base score and individual metric vector breakdown calculated by GitHub Advisory.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:LCoordinated Disclosure & Remediation Timeline
Verified chronological audit trail for CVE-2026-91117: from reproduction to upstream fix, backport, and official advisory release.
Security advisory GHSA-q353-2cxp-ppfh published on GitHub and CVE-2026-91117 assigned.
Source / Reference ↗
Evidence used for this analysis
Version and exploitation status can change; follow the linked vendor records.
The author completed a technical review. This does not, by itself, claim lab reproduction.
