İ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 (PR #114) ile düzeltildi ve GHSA-q353-2cxp-ppfh olarak yayımlandı. 4.3.4 PyPI üzerinde 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.

SoruNeyi denetledimGüvenBelirsiz 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üksekEtkilenen 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üksekYol 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üksekHiç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üksekBelirli 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üksekBu 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:

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:

    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:

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

SERVESTATIC: KÖK İÇİNDE KALMA DENETİMİNDE YAKALANMAYAN İSTİSNA HTTP isteği/static/app%00.png ServeStaticautorefresh=True os.path.realpath()NUL baytını reddeder ValueErrorHTTP 500 try/except ValueError yokİstek burada sonlanır DEBUG=True, Django'nun teknik 500 sayfasını yapılandırma bağlamıyla gösterir.
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.

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:

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şı:

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

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:

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

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

# 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

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:

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

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:

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

TaktikTeknik IDTeknik AdıZafiyetteki İşleyişSavunma Kontrolü
ImpactT1499.004Endpoint DoS: Application ExhaustionGö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 / DiscoveryT1212Exploitation for Credential AccessDEBUG=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.
DiscoveryT1082System Information DiscoveryHata 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, 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ı.
Şiddet Değerlendirmesi · MEDIUM

Zafiyet Puanlaması & CVSS Vektör Analizi

GitHub Advisory tarafından hesaplanan taban puan ve vektör bileşenlerinin teknik dökümü.

5.3Medium
CVSS Vektör DizgisiCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:L
AV · Saldırı VektörüAğ (Network)
AC · KarmaşıklıkDüşük (Low)
PR · Yetki SeviyesiYok (None)
UI · Kullanıcı EtkileşimiYok (None)
S · KapsamDeğişmez (Unchanged)
C · GizlilikYok (None)
I · BütünlükYok (None)
A · ErişilebilirlikDüşük (Low)
Denetim İzi · CVE-2026-91117

Koordineli İfşa ve Düzeltme Zaman Çizelgesi

CVE-2026-91117 için zafiyet tespiti, bakımcı bildirimi, upstream yama ve resmi danışma belgesi yayınının doğrulanmış kronolojisi.

  1. Güvenlik Danışma Belgesi (Advisory)Step 01

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

    Kaynak / Taahhüt ↗
Kaynaklar ve sınırlar

Bu analiz için kullanılan kanıtlar

Kontrol edilen kaynaklar24 Eylül 2026

Sürüm ve sömürü durumu değişebilir; üretici kayıtlarını takip edin.

İnceleme DurumuYazar incelemesi tamamlandı

Yazar teknik incelemeyi tamamladı. Bu etiket tek başına laboratuvar yeniden üretimi iddiası taşımaz.

◈ BU ARAŞTIRMAYI KAYNAK GÖSTER

Teknik raporlarda ve akademik yayınlarda atıf için:

@misc{jankesec_cve_2026_91117_servestatic_nul_byte_crash_2026,
  author = {Sevban Dönmez},
  title = {CVE-2026-91117: ServeStatic'te NUL Baytından Kaynaklanan Yakalanmayan ValueError},
  year = {2026},
  howpublished = {\url{https://jankesec.com/tr/arastirma/cve-2026-91117-servestatic-nul-byte-crash/}},
  note = {jankesec technical security research (CVE-2026-91117)}
}