Güvenli dosya ve yol işleme
- Dizin gezinmesini kanonik yol denetimiyle, dosya yüklemeyi imza ve arşiv kontrolüyle, ReDoS'u sade kalıp ve doğrusal regex motoruyla önle.
- İyi yazılmamış bir düzenli ifade de aynı derecede tehlikelidir; sunucuyu tek istekle kilitleyebilir.
- Bu derste hasar fotoğrafının güvenli kaydını ve arama kalıbının güvenli halini kuracaksın.
Puan bütün etkinlikleri cevaplayınca hesaba yazılır. Bu dersi tamamlayınca +55 XP alacaksın. Giriş yapmadan ilerlersen puan yalnız bu tarayıcıda durur.
AltayKargo iade servisi, müşterinin hasar fotoğrafı yüklemesini kabul eder. Kullanıcı dosya adını kendisi seçer ve sunucuya gönderir. Sunucu bu adı doğrudan disk yoluna eklerse tehlikeli olur.
Adın içine üst dizine çıkan bir yol parçası konursa dosya, yükleme klasörünün dışına yazılabilir ya da oradan okunabilir. Bu saldırıya dizin gezinmesi (path traversal) denir. Kök neden, kullanıcı girdisinin dosya sistemi yoluna doğrudan katılmasıdır.
Dizin gezinmesi CWE-22 kimliğiyle 2025 CWE Top 25 listesinde altıncı sıradadır. Tehlikeli türde dosyanın sınırsız yüklenmesi ise CWE-434 kimliğiyle on ikinci sırada durur. Dersin son iki bölümünde başka bir girdi tuzağına bakacaksın: kötü yazılmış bir düzenli ifade, sunucuyu tek istekle kilitleyebilir.
Dizin gezinmesi: yol nasıl dışarı taşar
app.post("/api/iadeler/:id/fotograf", (req, res) => {
const hedefYol = path.join(YUKLEME_KLASORU, req.file.originalname);
fs.writeFileSync(hedefYol, req.file.buffer);
res.json({ durum: "kaydedildi" });
});Bu kodda path.join, kullanıcının gönderdiği adı klasör yoluna ekler ve yolu normalleştirir, yani .. parçalarını çözer. Klasör /veri/yuklemeler, ad ../../etc/passwd ise sonuç /etc/passwd olur. Mutlak yolda davranış dile göre değişir.
Node'da path.join ikinci parça mutlak olsa da onu klasörün altına ekler. path.resolve ise mutlak parçada baştan başlar ve /etc/passwd döner.
Python'un os.path.join fonksiyonu da mutlak parçada öncekileri atar. Aynı saldırı bir dilde başarısız olurken ötekinde çalışabilir. Windows sunucularda ters bölü de ayırıcıdır. Engelleme listesi burada da kaybeder: ../ dizisini bir kez silen bir filtre, ....// girdisinden yeniden ../ üretir.
Saldırgan bu dizileri çoğu zaman kodlanmış gönderir, çünkü önündeki filtreler düz ../ arar. Erişim günlüğünde ..%2f, %2e%2e%2f ve çift kodlanmış ..%252f kalıpları yol gezinme denemesinin tipik izidir. Aynı kaynaktan kısa sürede gelen ve etc/passwd ya da win.ini gibi bilinen dosya adlarını hedefleyen istekler bir tarama aracına işaret eder.
Tespit kuralını kodu çözülmüş yol üzerine kur, çünkü ham satırda %2e arayan kural büyük harfli %2E yazımını kaçırır. Uygulama da reddettiği her yol isteğini günlüğe yazmalıdır: zaman, kaynak IP, istenen ad ve ret nedeni.
Etkinlik 1 / 7
Bu fonksiyon dizin gezinmesine açık. Hangi satır sorunu taşır?
Etkinlik 2 / 7
Erişim günlüğünde yol gezinme denemesi olan satırları işaretle.
Şüpheli satırlara tıkla. Birden fazla olabilir.
Kanonik yol denetimi ve güvenli sunum
app.post("/api/iadeler/:id/fotograf", (req, res) => {
const guvenliAd = crypto.randomUUID() + ".jpg";
const hedefYol = path.join(YUKLEME_KLASORU, guvenliAd);
if (!hedefYol.startsWith(YUKLEME_KLASORU + path.sep)) return res.status(400).end();
fs.writeFileSync(hedefYol, req.file.buffer);
res.json({ durum: "kaydedildi", ad: guvenliAd });
});Rastgele üretilen ad, kullanıcı girdisini yoldan tamamen çıkarır. startsWith kontrolü ikinci bir güvence katmanıdır; ad üretimi bir gün değişirse bile yol kök klasörün dışına çıkamaz. Uzantı da sabit olarak zorlanır. Orijinal adı görmezden gelmek, saldırganın adın içine ne yazdığını önemsiz kılar.
Kontroldeki path.sep ayrıntısı kritiktir. Kök /veri/yuklemeler ise ayırıcısız bir startsWith kontrolü /veri/yuklemeler-eski/rapor.pdf yolunu da geçirir, çünkü metin olarak aynı harflerle başlar. Kardeş klasör eski bir sürümün ya da başka bir müşterinin dosyalarını taşıyabilir. Kökün sonuna ayırıcı eklemek karşılaştırmayı klasör sınırında keser.
// Node.js
const KOK = path.resolve("/veri/iade-fotograflari");
const hedef = path.resolve(KOK, ad);
if (!hedef.startsWith(KOK + path.sep)) throw new Error("kok_disi_yol");
# Python
KOK = Path("/veri/iade-fotograflari").resolve()
hedef = (KOK / ad).resolve()
if not hedef.is_relative_to(KOK):
raise ValueError("kok_disi_yol")
// Java
Path kok = Paths.get("/veri/iade-fotograflari").toRealPath();
Path hedef = kok.resolve(ad).normalize();
if (!hedef.startsWith(kok)) throw new SecurityException("kok_disi_yol");Üç dilde de sıra aynıdır. Kökü bir kez kanonik hale getir, hedefi kökle birleştirip çöz, sonra karşılaştır. Kanonik yol, .. parçaları çözülmüş ve mümkünse sembolik bağlantıları izlenmiş mutlak yoldur.
Ayrıntılar dile göre değişir. Node'un path.resolve fonksiyonu yalnız metni çözer, sembolik bağlantıyı izlemez. Var olan bir dosyayı okurken gerçek yolu fs.realpathSync ile al.
Python'da Path.resolve bağlantıları da izler. is_relative_to karşılaştırmayı yol parçaları üzerinden yapar, bu yüzden kardeş klasör tuzağına düşmez. Java'da Path.startsWith de parça tabanlıdır. Ama File.getCanonicalPath() metin döner ve metnin startsWith kontrolü yine ayırıcı ister.
Dosyayı sunarken de dikkat et. Kullanıcı dosyası uygulamanın kendi alan adından sunulursa, tarayıcının HTML ya da SVG olarak yorumladığı bir dosya uygulamanın çerezleriyle aynı kökende çalışır. Ayrı bir alan adı, örneğin dosya.altaykargo.example, bu bağı koparır. Content-Disposition: attachment tarayıcıya dosyayı göstermek yerine indirmesini söyler.
X-Content-Type-Options: nosniff, tarayıcının bildirilen türü bırakıp içeriğe göre tahmin yürütmesini engeller. Dosyaya doğrudan yol vermek yerine bir kimlikle eriş. /api/dosya/3f2c9a1e gibi bir istek, uygulama içindeki eşlemeyle gerçek dosyaya gider. Bu eşleme, dosyayı isteyenin iadenin sahibi olup olmadığını denetleyeceğin yerdir.
Etkinlik 3 / 7
Kök /veri/yuklemeler olarak tanımlı. Ayırıcısız kontrol, ad olarak ../yuklemeler-eski/rapor.pdf gelince ne yapar?
Önce kodu oku, sonra çıktıyı seç.
const KOK = "/veri/yuklemeler"; const hedef = path.resolve(KOK, ad); if (!hedef.startsWith(KOK)) return res.status(400).end(); return fs.createReadStream(hedef).pipe(res);
Dosya içeriği ve arşivler
Güvenli ad ve yol işin yarısıdır, dosyanın içeriği de denetlenir. OWASP'ın dosya yükleme rehberi şu listeyi önerir. Uzantıyı izin listesiyle sınırla, hasar fotoğrafı için yalnız jpg, jpeg ve png.
İstemcinin gönderdiği Content-Type başlığına güvenme, çünkü onu da kullanıcı yazar. Dosyanın ilk baytlarındaki imzayı, yani sihirli baytları (magic bytes) kontrol et.
Adı sunucu üretsin. Dosyayı web kökünün dışında sakla ve boyut sınırı koy. Görseli yeniden kodla, böylece içine gizlenmiş fazladan veri atılır. Önceki bölümde gördüğün ayrı alan adı, attachment ve nosniff başlıkları da bu listenin parçasıdır.
$ file --mime-type hasar.jpg hasar.jpg: image/jpeg $ xxd -l 4 hasar.jpg 00000000: ffd8 ffe0 .... $ file --mime-type kabuk.jpg kabuk.jpg: text/x-php $ xxd -l 4 kabuk.jpg 00000000: 3c3f 7068 <?ph
Çıktıyı satır satır oku. file komutu uzantıya değil içeriğe bakar. hasar.jpg için image/jpeg der, xxd de ilk dört baytı ffd8 ffe0 olarak gösterir.
JPEG dosyaları ff d8 ff baytlarıyla başlar. kabuk.jpg ise uzantısı jpg olan bir PHP betiğidir. file onu text/x-php olarak tanır, ilk baytlar da <?ph harfleridir.
Sunucuda aynı denetimi bir kütüphaneyle yaparsın, örneğin Node'da file-type paketiyle. İmza kontrolü tek başına yetmez, çünkü geçerli bir JPEG başlığının arkasına betik eklenebilir. Yeniden kodlama ve dosyayı çalıştırılamayan bir yerden sunmak bu yüzden listede ayrı maddelerdir.
Arşiv kabul eden bir özellik aynı açığın başka bir kapısıdır. Zip ya da tar arşivindeki her girdinin bir adı vardır ve bu ad ../../ ile başlayabilir. Açma kodu girdi adını hedef klasörle birleştirip yazarsa dosya klasörün dışına düşer. Bu sınıfa Zip Slip denir.
Savunma dizin gezinmesiyle aynıdır: her girdinin nihai yolunu kanonik hale getir ve kökle karşılaştır. Python 3.12 tarfile modülüne bir çıkarma süzgeci (extraction filter) ekledi.
filter='data' mutlak yolları, hedef dışına çıkan adları ve bağlantıları, aygıt dosyalarını reddeder. Python 3.14'te bu süzgeç varsayılan oldu. Açılmış toplam boyutu ve dosya sayısını da sınırla, çünkü küçük bir arşiv açılınca diski doldurabilir.
# Python 3.12 ve sonrası: çıkarma süzgeci
with tarfile.open("iade-ekleri.tar") as arsiv:
arsiv.extractall("/veri/acilan", filter="data")
// Java: ZipInputStream girdi adını denetlemez, denetim senin işin
Path kok = Paths.get("/veri/acilan").toRealPath();
for (ZipEntry girdi; (girdi = zis.getNextEntry()) != null; ) {
Path hedef = kok.resolve(girdi.getName()).normalize();
if (!hedef.startsWith(kok)) throw new IOException("kok_disi_girdi");
// hedef'e yaz, açılan toplam boyutu say
}Derinleş:Zip Slip ve on beş yıllık bir uyarı
Arşivden dosya yazma açığı yeni değildir. Python'un tarfile modülündeki extractall fonksiyonu için 2007'de CVE-2007-4559 kaydı açıldı. Belgeler yıllarca güvenilmeyen arşivi incelemeden açmama uyarısı taşıdı, ama kontrol geliştiriciye bırakıldı.
Pek çok proje bu kontrolü hiç yazmadı ya da yanlış yazdı. 5 Haziran 2018'de bir güvenlik araştırma ekibi sorunu Zip Slip adıyla geniş bir sınıf olarak duyurdu. Açık tar, jar, war, cpio, apk, rar ve 7z biçimlerini etkiliyordu.
Binlerce projede, aralarında büyük yazılım kuruluşlarının kütüphanelerinin de olduğu açma kodu düzeltildi. Python tarafındaki kalıcı çözüm PEP 706 ile geldi. Python 3.12 extractall fonksiyonuna filter parametresini ekledi, süzgeç güvenlik güncellemeleriyle eski sürümlere de taşındı. 3.12 ve 3.13'te süzgeç verilmezse bir kullanımdan kaldırma uyarısı (DeprecationWarning) çıkar.
3.14'te varsayılan süzgeç data oldu. Ders şudur: güvenli davranış isteğe bağlı kaldıkça çoğu kod güvensiz yazılır. Varsayılanı güvenli yapmak, binlerce ayrı düzeltmeden daha etkilidir.
Etkinlik 4 / 7
Lab klasöründeki kabuk.jpg dosyasının gerçek türünü, içeriğine bakarak MIME biçiminde göster.
ReDoS: geri izleme nasıl patlar
Düzenli ifade motoru bazı kalıpları denerken geri izleme (backtracking) yapar. İç içe tekrar eden bir grup, sonu beklenen karakterle bitmeyen uzun bir girdide olası bütün bölünmeleri tek tek dener. Girdi birkaç karakter uzadıkça deneme sayısı katlanarak artar.
Otuz karakterlik zararsız görünen bir metin, sunucuyu saniyelerce kilitleyebilir. Buna düzenli ifade hizmet reddi denir, kısaca ReDoS.
AltayKargo'nun arama kutusu takip numarasını iç içe tekrarlı bir kalıpla ararsa aynı tuzağa düşer. Bu davranış yalnız eski motorlara özgü değildir. JavaScript, Python ve Java'nın standart regex motorları da geri izleme kullanır.
// Önce: iç içe tekrar, geri izleme patlar
const TAKIP_ARA = /^AK(\d+)+$/;
// Sonra: tek seviye, sabit uzunluk
const TAKIP_ARA = /^AK\d{6}$/;(\d+)+ kalıbında iki niceleyici iç içedir. İçteki \d+ bir rakam dizisini alır, dıştaki + bu grubu tekrarlar. 123 dizisi tek grup olabilir, 1 ve 23 olabilir, 1, 2 ve 3 olabilir. n rakamlık bir dizinin 2 üzeri n-1 farklı bölünmesi vardır.
Girdi rakamlardan sonra bir harfle bitiyorsa $ çapası hiçbir bölünmede eşleşmez. Motor ret kararı vermeden önce bölünmelerin hepsini dener. Sabit uzunluklu, tek seviyeli kalıpta ise motorun deneyecek alternatif bölünmesi kalmaz. Her karakter kalıpla tek bir yoldan eşleşir ya da hiç eşleşmez.
$ cat olcum.js
for (const n of [20, 24, 26, 28, 30, 32]) {
const girdi = "AK" + "1".repeat(n) + "X";
const bas = process.hrtime.bigint();
/^AK(\d+)+$/.test(girdi);
const ms = Number(process.hrtime.bigint() - bas) / 1e6;
console.log(n, "rakam:", Math.round(ms), "ms");
}
$ node olcum.js
20 rakam: 61 ms
24 rakam: 97 ms
26 rakam: 387 ms
28 rakam: 1558 ms
30 rakam: 6291 ms
32 rakam: 25799 msÖlçümü kendi makinende çalıştır. Sayılar donanıma göre değişir ama eğri değişmez. 24 rakamdan sonra her iki rakamlık artış süreyi yaklaşık dört katına çıkarır.
32 rakamlık tek bir istek yirmi altı saniye sürer. Node.js olay döngüsü tek iş parçacığında çalıştığı için bu sürede sunucu başka hiçbir isteğe yanıt vermez. Saldırganın binlerce istek atmasına gerek yoktur, birkaç istek yeter.
İlk satır beklenenden yavaştır, çünkü V8 kalıbı ilk çağrıda yorumlayıcıyla çalıştırır, sonraki çağrılarda makine koduna derler. Kalıbı yalnız kısa örnek girdilerle test etmek bu yüzden yetmez. Üretime çıkmadan önce uzun ve eşleşmeyen bir girdiyle dene ve süreyi ölç.
Etkinlik 5 / 7
TAKIP_ARA kalıbı /^AK\d{6}$/ olarak tanımlandı. Hangi satırlar bu kalıpla eşleşir?
Yazdıkça eşleşen satırlar işaretlenir. Hedef: yalnız doğru satırlar eşleşsin.
Etkinlik 6 / 7
Sunucu hâlâ önceki iç içe tekrarlı TAKIP_ARA kalıbını kullanıyor ve biri AK ile başlayıp otuz rakamla süren, sonu harfle biten bir takip numarası arıyor. En olası sonuç nedir?
Önce kodu oku, sonra çıktıyı seç.
const TAKIP_ARA = /^AK(\d+)+$/;
Doğrusal motor, girdi sınırı ve izleme
ReDoS'a karşı dört savunma birlikte çalışır. Birincisi kalıbı sade yazmaktır. İç içe niceleyiciden ve birbiriyle çakışan seçeneklerden, örneğin (a|aa)+ ya da ([a-zA-Z]+)* yapısından kaçın. İkincisi girdi uzunluğunu regex'ten önce sınırlamaktır.
Takip numarası sekiz karakterse daha uzun girdi kalıba hiç gitmez. Üçüncüsü doğrusal zamanlı bir motordur. RE2 eşleşme süresini girdi uzunluğuyla doğru orantılı tutar, karşılığında geri başvuru (backreference) ve çevre bakışı (lookaround) desteklemez. Node.js için re2, Python için google-re2 paketi vardır.
Go'nun regexp paketi ve Rust'ın regex kütüphanesi zaten böyle çalışır. Dördüncüsü zaman aşımıdır. .NET bir regex çağrısına süre sınırı verebilir, sınır aşılınca istisna fırlar.
const RE2 = require("re2");
const TAKIP_ARA = new RE2("^AK\\d{6}$");
function takipAra(girdi) {
if (typeof girdi !== "string" || girdi.length > 8) return false; // önce uzunluk
return TAKIP_ARA.test(girdi); // sonra doğrusal motor
}Kodda sıra önemlidir. typeof kontrolü bir dizinin ya da nesnenin kalıba gitmesini önler. Uzunluk kontrolü ucuzdur ve saldırganın uzun girdisini motor hiç görmeden eler.
RE2 nesnesi kalıbı JavaScript'in yerleşik motoru yerine C++ ile yazılmış doğrusal motorda çalıştırır. Kalıp bir string olarak verildiği için ters bölü iki kez yazılır. Aynı kalıp artık kaç karakter gelirse gelsin girdinin uzunluğuyla orantılı sürede sonuç verir.
Tespit tarafında ReDoS sessiz ilerler, çünkü istek hata vermez, yalnız uzun sürer. Tek bir uç noktanın yanıt süresindeki ani sıçrama ve işlemci kullanımının yüzde yüze yapışması ilk belirtidir. İstek günlüğüne yanıt süresini yaz ve arama ucunda birkaç saniyeyi aşan istekleri uyarıya bağla.
Yavaş isteklerin gövdesine bak. Aynı karakterin uzun tekrarı ve sonda uymayan tek bir karakter tipik ReDoS girdisidir. Statik analiz de yardım eder.
Bazı SAST kuralları ve linter eklentileri iç içe niceleyicileri daha kod yazılırken işaretler. İlk derste gördüğün Pydantic de alan kalıplarını varsayılan olarak Rust'ın geri izlemesiz motoruyla işler.
Derinleş:Tek bir kural, yirmi yedi dakika
ReDoS yalnız küçük uygulamaların sorunu değildir. 2 Temmuz 2019'da büyük bir içerik dağıtım ağı sağlayıcısı, web uygulama güvenlik duvarına (WAF) yeni bir kural ekledi.
Kural, XSS saldırılarını yakalamak için yazılmış uzun bir düzenli ifadeydi. İfadenin içinde .*(?:.*=.*) parçası vardı. İki ardışık .* yapısı, eşleşmeyen girdilerde aşırı geri izlemeye yol açtı.
Kural bütün dünyaya aynı anda dağıtıldı. İşlemciler yüzde yüze yakın çalışmaya başladı ve ağ yirmi yedi dakika boyunca 502 hatası döndü. Trafik küresel ölçekte yaklaşık yüzde 80 düştü.
Şirketin sonradan yayımladığı raporda düzeltme listesi dikkat çekiciydi. Kaldırılmış olan işlemci koruması geri getirildi. Test takımına performans ölçümü eklendi.
Kural değişiklikleri aşamalı dağıtıma bağlandı. En önemlisi, geri izleme yapan motor yerine çalışma süresi garantisi olan RE2 ya da Rust regex motoruna geçme kararı alındı. Vaka, sade kalıp, ölçüm ve doğrusal motor savunmalarının üçünü birden tek bir olayda gösterir.
Etkinlik 7 / 7
Doğru mu, yanlış mı?
Bir log arama aracında kullanıcının girdiği düzenli ifadeyi hiç sınır koymadan doğrudan çalıştırmak güvenlidir, çünkü yalnız arama yapar, veri değiştirmez.
Okumayı istediğin sırayla sürdürebilirsin. Ders, bütün etkinlikleri cevapladığında tamamlanır.
- Etkinlik 1· Dizin gezinmesi: yol nasıl dışarı taşar
- Etkinlik 2· Dizin gezinmesi: yol nasıl dışarı taşar
- Etkinlik 3· Kanonik yol denetimi ve güvenli sunum
- Etkinlik 4· Dosya içeriği ve arşivler
- Etkinlik 5· ReDoS: geri izleme nasıl patlar
- Etkinlik 6· ReDoS: geri izleme nasıl patlar
- Etkinlik 7· Doğrusal motor, girdi sınırı ve izleme