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 commit 7227d98 (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

QuestionWhat I checkedConfidenceWhat 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.HighThe 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.HighPath 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.HighNo 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.HighExposure 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.HighThis 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.

SERVESTATIC: UNCAUGHT EXCEPTION IN THE CONTAINMENT CHECK HTTP request/static/app%00.png ServeStaticautorefresh=True os.path.realpath()rejects the NUL byte ValueErrorHTTP 500 No try/except ValueErrorThe request ends here DEBUG=True renders Django's technical 500 page with configuration context.
The lexical containment check still runs. The exception is raised later, by the symlink-resolving check, and ends only the request that triggered it.

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 %00 fails 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

  1. os.path.normpath() only collapses . and ... It leaves a NUL byte in the path.
  2. Any os function that reaches the C library can raise ValueError for a NUL byte. Where the path comes from a request, catch it next to OSError.
  3. Keep DEBUG=False anywhere a requester can reach, staging included. The debug page turns a 500 into an information leak.
  4. 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

TacticTechnique IDTechnique NameExploitation MechanismDefensive Control
ImpactT1499.004Endpoint DoS: Application ExhaustionInjected NUL byte triggers uncaught ValueError in realpath(), terminating the request with HTTP 500.Catch ValueError in path normalization; return boolean false.
Credential Access / DiscoveryT1212Exploitation for Credential AccessWith DEBUG=True, unhandled exceptions render technical error pages exposing database keys and environment vars.Enforce DEBUG=False in all production and staging deployments.
DiscoveryT1082System Information DiscoveryDetailed 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 ea6828f and 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.
Severity Assessment · MEDIUM

Vulnerability Scoring & CVSS Vector Breakdown

Base score and individual metric vector breakdown calculated by GitHub Advisory.

5.3Medium
CVSS Vector StringCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L
AV · VectorNetwork
AC · ComplexityLow
PR · PrivilegesNone
UI · User InteractionNone
S · ScopeUnchanged
C · ConfidentialityNone
I · IntegrityNone
A · AvailabilityLow
Audit Trail · CVE-2026-91117

Coordinated Disclosure & Remediation Timeline

Verified chronological audit trail for CVE-2026-91117: from reproduction to upstream fix, backport, and official advisory release.

  1. Security advisoryStep 01

    Security advisory GHSA-q353-2cxp-ppfh published on GitHub and CVE-2026-91117 assigned.

    Source / Reference ↗
Sources & limits

Evidence used for this analysis

Sources checkedSeptember 24, 2026

Version and exploitation status can change; follow the linked vendor records.

Review statusAuthor review complete

The author completed a technical review. This does not, by itself, claim lab reproduction.

◈ CITE THIS RESEARCH

Reference this analysis in technical reports or publications:

@misc{jankesec_cve_2026_91117_servestatic_nul_byte_crash_2026,
  author = {Sevban Dönmez},
  title = {CVE-2026-91117: Uncaught ValueError on a NUL Byte in ServeStatic},
  year = {2026},
  howpublished = {\url{https://jankesec.com/research/cve-2026-91117-servestatic-nul-byte-crash/}},
  note = {jankesec technical security research (CVE-2026-91117)}
}