---
title: "CVE-2026-91117: Uncaught ValueError on a NUL Byte in ServeStatic"
description: "Why a NUL byte that survives ServeStatic's path normalization makes its containment check raise an uncaught ValueError, so the request returns a 500 and, with Django DEBUG enabled, the technical error page."
date: 2026-09-23
analysisType: "Root Cause Analysis"
cves: ["CVE-2026-91117"]
author: Sevban Dönmez (@jankesec)
canonical: https://jankesec.com/research/cve-2026-91117-servestatic-nul-byte-crash/
---

> **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`](https://github.com/Archmonger/ServeStatic/commit/7227d989998d6a125a83c2b300d248adcf9d6b8b) (PR #114) and published as [GHSA-q353-2cxp-ppfh](https://github.com/Archmonger/ServeStatic/security/advisories/GHSA-q353-2cxp-ppfh). [4.3.4 is on PyPI](https://pypi.org/project/servestatic/4.3.4/).

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:

```bash
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:

```python
    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:

```c
// 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.

<figure class="diagram">
<svg viewBox="0 0 760 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="A request path with a NUL byte survives normalization, reaches os.path.realpath, and raises a ValueError that nothing catches, so the request ends in an HTTP 500.">
<defs><marker id="ss-nul-arrow" viewBox="0 0 8 8" refX="7" refY="4" markerWidth="7" markerHeight="7" orient="auto"><path d="M0,0 L8,4 L0,8 z" class="dg-arrow-crit" /></marker></defs>
<text x="0" y="24" class="dg-muted">SERVESTATIC: UNCAUGHT EXCEPTION IN THE CONTAINMENT CHECK</text>
<rect x="0" y="52" width="140" height="78" rx="8" class="dg-box" />
<text x="16" y="78" class="dg-label">HTTP request</text><text x="16" y="100" class="dg-muted">/static/app%00.png</text>
<line x1="142" y1="91" x2="182" y2="91" class="dg-line-crit" marker-end="url(#ss-nul-arrow)" />
<rect x="186" y="52" width="168" height="78" rx="8" class="dg-box" />
<text x="202" y="78" class="dg-label">ServeStatic</text><text x="202" y="100" class="dg-accent">autorefresh=True</text>
<line x1="356" y1="91" x2="396" y2="91" class="dg-line-crit" marker-end="url(#ss-nul-arrow)" />
<rect x="400" y="52" width="168" height="78" rx="8" class="dg-box-danger" />
<text x="416" y="78" class="dg-label">os.path.realpath()</text><text x="416" y="100" class="dg-danger">rejects the NUL byte</text>
<line x1="570" y1="91" x2="610" y2="91" class="dg-line-crit" marker-end="url(#ss-nul-arrow)" />
<rect x="614" y="52" width="146" height="78" rx="8" class="dg-box-danger" />
<text x="630" y="78" class="dg-label">ValueError</text><text x="630" y="100" class="dg-danger">HTTP 500</text>
<line x1="484" y1="132" x2="484" y2="198" class="dg-line dg-dash" />
<rect x="360" y="202" width="248" height="58" rx="8" class="dg-box-accent" />
<text x="376" y="226" class="dg-label">No try/except ValueError</text><text x="376" y="246" class="dg-accent">The request ends here</text>
<text x="24" y="220" class="dg-muted">DEBUG=True renders Django's technical</text>
<text x="24" y="242" class="dg-muted">500 page with configuration context.</text>
</svg>
<figcaption>The lexical containment check still runs. The exception is raised later, by the symlink-resolving check, and ends only the request that triggered it.</figcaption>
</figure>

## 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:

```python
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:

```bash
curl -i "http://127.0.0.1:8000/static/hello.txt%00.jpg"
```

```http
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:

```python
    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`](https://github.com/Archmonger/ServeStatic/commit/7227d989998d6a125a83c2b300d248adcf9d6b8b) (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:

```diff
--- 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:

```nginx
# 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

```bash
# Block requests with null-byte encoding
http-request deny deny_status 400 if { path -m reg -i (%00|\x00) }
```

### Caddy

```bash
@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:

```apache
# 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:

```text
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:

```text
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 `ea6828f` and reported it to the maintainer through a GitHub security advisory.
- **2026-09-07, 01:51 UTC:** The maintainer merged the fix ([`7227d98`](https://github.com/Archmonger/ServeStatic/commit/7227d989998d6a125a83c2b300d248adcf9d6b8b), 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.