---
title: "CVE-2026-91117: ServeStatic'te NUL Baytından Kaynaklanan Yakalanmayan ValueError"
description: "ServeStatic'in yol normalizasyonundan sağ çıkan bir NUL baytının, kök içinde kalma denetiminde yakalanmayan bir ValueError fırlatması; isteğin 500 dönmesi ve Django DEBUG açıkken teknik hata sayfasının gösterilmesi."
date: 2026-09-23
author: Sevban Dönmez (@jankesec)
canonical: https://jankesec.com/tr/arastirma/cve-2026-91117-servestatic-nul-byte-crash/
---

> **İfşa:** CVE-2026-91117 (CWE-20, CWE-248), ServeStatic'in 4.3.3'e kadar olan sürümlerini etkiler. Zafiyeti `jankesec` olarak bakımcı Archmonger'a bildirdim. 4.3.4 sürümünde [commit `7227d98`](https://github.com/Archmonger/ServeStatic/commit/7227d989998d6a125a83c2b300d248adcf9d6b8b) (PR #114) ile düzeltildi ve [GHSA-q353-2cxp-ppfh](https://github.com/Archmonger/ServeStatic/security/advisories/GHSA-q353-2cxp-ppfh) olarak yayımlandı. [4.3.4 PyPI üzerinde](https://pypi.org/project/servestatic/4.3.4/) mevcut.

NUL baytı (`%00`) içeren bir istek yolu, `ServeStaticBase.path_within_root()` içinde `os.path.realpath()` çağrısının `ValueError` fırlatmasına neden olur ve bu hatayı yakalayan hiçbir kod yoktur. Etkilenen istek HTTP 500 döndürür. Django'nun `DEBUG=True` ayarıyla çalıştığı durumda yanıt, isteği gönderen kişiye dosya yollarını, kurulu uygulamaları ve ayarları gösteren Django teknik hata sayfası olur.

Saldırgan tek bir kimlik doğrulaması gerektirmeyen GET isteği gönderir. Bu kod yoluna yalnızca ServeStatic `autorefresh=True` ile çalışırken ulaşılır ve Django entegrasyonu bu ayarı varsayılan olarak `DEBUG` değerine eşitler.

Bu bir path traversal değildir. Güvenlik bildirisindeki testlerde kök içinde kalma denetimleri 17 farklı traversal payload sınıfına karşı dayandı. NUL baytı, denetimin `False` döndürmesi yerine istisna fırlatmasına yol açar; yani sorun bir kaçış değil, hata yolunun kendisidir. Bu ayrım önemlidir: savunmacılar "traversal testlerim geçiyor" diyerek rahatlayabilir, oysa zafiyet tam da bu testlerin kapsamadığı bir hata yolunda durur.

## Kanıt matrisi

Aşağıdaki tablo, zafiyetin hangi koşullarda doğrulandığını ve hangi soruların yanıtsız kaldığını özetler. Her satır, etkiyi daraltmaya veya genişletmeye yönelik tek bir soruya karşılık gelir.

| Soru | Neyi denetledim | Güven | Belirsiz kalan |
|---|---|---:|---|
| Hangi sürümler etkileniyor? | Upstream güvenlik bildirisi `<=4.3.3` listeliyor. Kavram kanıtı, 4.3.3 etiketinden iki commit sonraki `ea6828f` commit'ini adlandırıyor. 4.3.3 ile 4.3.4 arasında `base.py` dosyasındaki tek değişiklik düzeltmedir. | Yüksek | Etkilenen ilk tarihsel sürüm burada bağımsız olarak belirlenmedi. |
| Hangi saldırgan konumu gerekiyor? | Güvenlik bildirisi, canlı bir sunucuya `%00` içeren kimlik doğrulamasız bir GET isteği gönderildiğini bildiriyor. | Yüksek | Yol kod çözme ve reddetme davranışı ön uç sunucusuna ve proxy'ye göre değişebilir. |
| Traversal neden başarısız olurken istek neden 500 dönüyor? | 4.3.3 kaynak kodu önce sözdizimsel kök içinde kalma denetimi yapıyor, ardından `ValueError` yakalamadan `realpath()` çağırıyor. | Yüksek | Hiçbir traversal atlatması iddia edilmiyor. Güvenlik bildirisi 17 negatif traversal testi bildiriyor. |
| Zafiyetli arama ne zaman erişilebilir? | Güvenlik bildirisi `autorefresh=True` koşulunu belirtiyor. Django entegrasyonu bu ayarı `DEBUG` değerine eşitliyor. | Yüksek | Belirli bir kurulumdaki maruziyet ve operasyonel etki yapılandırmaya ve telemetriye bağlıdır. |
| Düzeltmede ne değişti? | 4.3.4 kaynak kodu iki `realpath()` çağrısını `try/except ValueError` içine alıyor ve `False` döndürüyor. | Yüksek | Bu incelemede yayımlanmış paket her sunucu yapılandırmasına karşı yeniden çalıştırılmadı. |

## İstisnanın kaçtığı yer

Bir istek, hataya yol açan çağrıya şöyle ulaşır:

```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()` önce `os.path.normpath()` ile sözdizimsel olarak kök içinde kalma denetimi yapar, ardından `os.path.realpath()` ile symlink'leri çözer. 4.3.3 sürümündeki kod şöyledir:

```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()` yalnızca `.` ve `..` parçalarını sadeleştirir; bu yüzden bir NUL baytı ondan sonra da dizgede kalır. Python dizgileri `\x00` karakterini tutabilir, ancak C dizgileri ilk NUL karakterinde bittiği için CPython böyle bir yolu C kütüphanesine iletmeden önce reddeder. `realpath()` fonksiyonunun kullandığı `os.lstat()` için hata `ValueError: lstat: embedded null character in path` olur.

## CPython çalışma zamanı mekanizması: ValueError neden fırlatılır?

Bu durum CPython'un iç dosya sistemi argüman dönüştürücüsünden (`Modules/posixmodule.c` içindeki `PyUnicode_FSConverter`) kaynaklanır. Python, bir yol dizgesini POSIX sistem çağrısına (`lstat`, `stat` veya `open`) aktarmadan önce değişmez (immutable) Python unicode nesnesini sıfır sonlandırmalı (null-terminated) bir C `char *` tamponuna çevirir:

```c
// CPython posixmodule.c yol dönüştürücüsü (kavramsal işleyiş)
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;
}
```

Python tarafından kaydedilen dizge uzunluğu, C'nin `strlen(bytes)` uzunluğundan büyükse yükün içinde bir NUL baytı (`\0`) var demektir. CPython bunu bir programlama sözleşmesi ihlali sayar ve doğrudan `ValueError` fırlatır. Çekirdek sistem çağrısına girmeden önce yükselen bir girdi doğrulama hatası olduğu için, standart `OSError` (örneğin `ENOENT`) yakalayıcıları bu hatayı yakalayamaz.

Önemli olan, denetimin tamamen atlanmamasıdır. Sözdizimsel denetim yine çalışır ve geçer; istisna daha sonra, symlink çözen ikinci denetimde ortaya çıkar. Yani saldırgan kök dışına çıkmaz, yalnızca denetim mekanizmasını bir hata ile sonlandırır. Bu davranış, güvenlik denetimlerinin yalnızca "doğru/yanlış" değil, "hata" sonucunu da açıkça ele alması gerektiğini gösterir.

<figure class="diagram">
<svg viewBox="0 0 760 280" xmlns="http://www.w3.org/2000/svg" role="img" aria-label="NUL baytı içeren bir istek yolu normalizasyondan sağ çıkar, os.path.realpath'e ulaşır ve hiçbir yerde yakalanmayan bir ValueError fırlatır; bu yüzden istek HTTP 500 ile sonlanır.">
<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: KÖK İÇİNDE KALMA DENETİMİNDE YAKALANMAYAN İSTİSNA</text>
<rect x="0" y="52" width="140" height="78" rx="8" class="dg-box" />
<text x="16" y="78" class="dg-label">HTTP isteği</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">NUL baytını reddeder</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">try/except ValueError yok</text><text x="376" y="246" class="dg-accent">İstek burada sonlanır</text>
<text x="24" y="220" class="dg-muted">DEBUG=True, Django'nun teknik</text>
<text x="24" y="242" class="dg-muted">500 sayfasını yapılandırma bağlamıyla gösterir.</text>
</svg>
<figcaption>Sözdizimsel kök içinde kalma denetimi yine çalışır. İstisna daha sonra, sembolik linki çözen denetimde fırlatılır ve yalnızca onu tetikleyen isteği sonlandırır.</figcaption>
</figure>

## Yeniden üretim

Bunu `ea6828f` commit'ine karşı doğruladım.

### 1. Doğrudan çağrı

Dahili metodu yolu içinde bir NUL baytı bulunan bir girdiyle çağırmak istisnayı fırlatır; çünkü onu işleyen hiçbir kod yoktur:

```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. HTTP üzerinden

ServeStatic çalıştıran bir Django geliştirme sunucusuna karşı:

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

`DEBUG=True` iken gövde, `ValueError at /static/hello.txt%00.jpg` başlıklı Django teknik hata sayfasıdır. Bu tür her istek ayrıca log'a tam bir yığın izi (traceback) yazar.

## Hatayı yakalamayan prob

Deponun kendi `scripts/security_probe.py` dosyasında bir null-byte probu vardır:

```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}")
```

Prob, durum kodu 200 değilse geçer. Güvenlik bildirisinin belirttiği gibi 4.3.3 sürümüne karşı istek, probun değerlendirebileceği bir yanıt oluşmadan çöker; bu yüzden prob hiçbir zaman bir sonuca ulaşamadı. Yalnızca durum kodunu denetleyen bir prob, temiz bir reddi bir çökmeden ayırt edemez. İyi bir güvenlik testi, beklenen ret kodunu ve beklenmeyen 5xx durumunu ayrı ayrı ele almalıdır.

## Etki

- **Log taşması ve başarısız istekler.** `%00` içeren her istek 500 ile başarısız olur ve log'a tam bir yığın izi yazar. Güvenlik bildirisi bunu tek istekle tetiklenen bir hizmet dışı bırakma ve log taşması olarak tanımlar. Etki, isteğin kendisiyle sınırlı görünse de tekrarlanan istekler log altyapısını ve disk alanını hızla tüketebilir. İstisna, onu fırlatan isteği sonlandırır; işçi sürecini mutlaka sonlandırmaz.
- **`DEBUG=True` iken bilgi ifşası.** Django teknik 500 sayfasıyla yanıt verir; bu sayfa şunları listeler:
  - sunucudaki mutlak dosya yolları
  - Python ve Django sürümleri
  - kurulu uygulamalar
  - URL rotaları ve middleware sırası
  - her yığın çerçevesindeki yerel değişkenler

Güvenlik bildirisi, yalnızca çökme için CVSS 3.1 `AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L` (yaklaşık 5.3, Orta) önerir; `DEBUG=True` ayarının güvenilmeyen bir ağdan erişilebilir olduğu her yerde daha yüksek bir derecelendirme önerir.

## Düzeltme

[Commit `7227d98`](https://github.com/Archmonger/ServeStatic/commit/7227d989998d6a125a83c2b300d248adcf9d6b8b) (PR #114), 4.3.4 olarak yayımlandı ve iki `realpath()` çağrısının etrafında `ValueError` yakalayıp `False` döndürüyor. NUL baytı içeren bir yol artık istisna fırlatmak yerine kök dışında sayılıyor. 4.3.3 ile 4.3.4 etiketleri arasında `src/servestatic/base.py` dosyasındaki değişiklik şudur. `False` döndürmek, geçersiz yolu "kök dışında" sayarak isteğin normal bir bulunamadı yanıtına dönüşmesini sağlar:

```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)
```

## Çıkarımlar

Bu zafiyet, güvenlik denetimlerinin yalnızca mutlu yol için değil, hata yolları için de tasarlanması gerektiğini gösterir. Aşağıdaki maddeler doğrudan uygulanabilir.

1. `os.path.normpath()` yalnızca `.` ve `..` parçalarını sadeleştirir. Bir NUL baytını yolda bırakır.
2. C kütüphanesine ulaşan herhangi bir `os` fonksiyonu, NUL baytı için `ValueError` fırlatabilir. Yol bir istekten geliyorsa, bu hatayı `OSError` ile birlikte yakalayın.
3. Bir istek sahibinin erişebildiği her yerde, staging dahil, `DEBUG=False` tutun. Debug sayfası bir 500'ü bilgi sızıntısına dönüştürür.
4. Bir güvenlik probundan gelen istisnayı veya 5xx yanıtını test gürültüsü değil, bulgu olarak değerlendirin.

## Ters vekil filtrelemesi

4.3.4 veya daha yeni bir sürüme yükseltmek asıl düzeltmedir. Yükseltme yapana kadar bir ters vekil, NUL baytlarını istek Python'a ulaşmadan reddedebilir. Bu, uygulama katmanındaki hatayı gizlemez; yalnızca dışarıdan tetiklenmesini zorlaştırır.

### Nginx

Uygulamanın önündeki `server` veya `location` bloğuna şunu ekleyin:

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

## Tespit mühendisliği ve telemetri

Savunma ekipleri istismar girişimlerini çevre WAF katmanında, web sunucusu erişim günlüklerinde ve uygulama performans izleme (APM) telemetrisinde izleyebilir.

### 1. Web sunucusu ve WAF tespiti

Bir ModSecurity kuralı, URI yollarında NUL baytı enjeksiyonu deneyen kimlik doğrulaması yapılmamış keşif veya istismar yoklamalarını engeller:

```apache
# CVE-2026-91117 zafiyeti için ModSecurity tespit kuralı
SecRule REQUEST_URI "@rx (?i)(%00|\x00)" \
    "id:1000101,\
    phase:1,\
    deny,\
    status:400,\
    log,\
    msg:'URI yolunda NUL bayti enjeksiyonu (CVE-2026-91117)',\
    tag:'application-dos',\
    tag:'cve-2026-91117'"
```

Standart Nginx veya Envoy erişim günlüklerinde, `%00` veya ham kontrol karakterleri içeren isteklerin HTTP 500 koduyla sonuçlandığı anomali patlamaları izlenir:

```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. Uygulama günlükleri ve APM telemetrisi

Yakalanmayan `ValueError` çökmeleri oluştuğunda Python WSGI/ASGI işçileri tam hata izini (traceback) günlüğe kaydeder. Sentry, Datadog veya Elasticsearch sorguları şu hata imzasını takip etmelidir:

```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)
```

Üretim ortamında bu istisnanın görülmesi, aktif bir zafiyet taramasını veya girdi normalizasyonundan kaçan güvenilmeyen bir istek yükünü gösterir.

## ATT&CK teknik eşleşmesi

| Taktik | Teknik ID | Teknik Adı | Zafiyetteki İşleyiş | Savunma Kontrolü |
| :--- | :--- | :--- | :--- | :--- |
| **Impact** | `T1499.004` | Endpoint DoS: Application Exhaustion | Gönderilen NUL baytı `realpath()` içinde yakalanmayan `ValueError` fırlatarak isteği 500 ile sonlandırır. | Yol çözümleme bloğunda `ValueError` yakala; mantıksal false döndür. |
| **Credential Access / Discovery** | `T1212` | Exploitation for Credential Access | `DEBUG=True` iken oluşan istisnalar veritabanı anahtarlarını ve çevre değişkenlerini teknik sayfada gösterir. | Üretim ve staging ortamlarının tamamında `DEBUG=False` uygula. |
| **Discovery** | `T1082` | System Information Discovery | Hata izleri sunucu mutlak yollarını, Django sürümlerini ve kurulu Python paketlerini açığa çıkarır. | Özel 500 hata yakalayıcıları devreye alarak ayrıntılı izleri temizle. |

## İfşa zaman çizelgesi

- **2026-09-07 öncesi:** Sorunu `ea6828f` commit'ine karşı doğruladım ve GitHub güvenlik bildirisi üzerinden bakımcıya ilettim.
- **2026-09-07, 01:51 UTC:** Bakımcı düzeltmeyi birleştirdi ([`7227d98`](https://github.com/Archmonger/ServeStatic/commit/7227d989998d6a125a83c2b300d248adcf9d6b8b), PR #114).
- **2026-09-07, 02:09 UTC:** 4.3.4 sürümü etiketlendi (#115). PyPI'ye 02:13 UTC'de ulaştı.
- **2026-09-07, 02:20 UTC:** GHSA-q353-2cxp-ppfh yayımlandı ve CVE-2026-91117 atandı.
