İfşa: CVE-2026-91117 (CWE-20, CWE-248), ServeStatic’in 4.3.3’e kadar olan sürümlerini etkiler. Zafiyeti
jankesecolarak bakımcı Archmonger’a bildirdim. 4.3.4 sürümünde commit7227d98(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.
| 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:
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.
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.
%00iç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=Trueiken 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.
os.path.normpath()yalnızca.ve..parçalarını sadeleştirir. Bir NUL baytını yolda bırakır.- C kütüphanesine ulaşan herhangi bir
osfonksiyonu, NUL baytı içinValueErrorfırlatabilir. Yol bir istekten geliyorsa, bu hatayıOSErrorile birlikte yakalayın. - Bir istek sahibinin erişebildiği her yerde, staging dahil,
DEBUG=Falsetutun. Debug sayfası bir 500’ü bilgi sızıntısına dönüştürür. - 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
| 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
ea6828fcommit’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ı.
Zafiyet Puanlaması & CVSS Vektör Analizi
GitHub Advisory tarafından hesaplanan taban puan ve vektör bileşenlerinin teknik dökümü.
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:LKoordineli İ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.
Security advisory GHSA-q353-2cxp-ppfh published on GitHub and CVE-2026-91117 assigned.
Kaynak / Taahhüt ↗
Bu analiz için kullanılan kanıtlar
Sürüm ve sömürü durumu değişebilir; üretici kayıtlarını takip edin.
Yazar teknik incelemeyi tamamladı. Bu etiket tek başına laboratuvar yeniden üretimi iddiası taşımaz.
