Bir projeyi canlıya aldıktan birkaç gün sonra sunucu loglarına baktığımda, adres çubuğuna ' OR 1=1 -- yazıp duran botlar gördüm. O gün şunu anladım: Bir uygulama yayına girdiği andan itibaren biri onu deniyor demektir.

Bu yazıda, web uygulamalarında en sık karşılaştığım açıkları örneklerle anlatacağım. Önce OWASP Top 10'a kısaca bakacağız, ardından SQL enjeksiyonu, XSS ve web shell konularına geçeceğiz. En sonda da tarayıcıyı bizim yanımıza çeken CSP ve HSTS başlıklarını ele alacağız.

OWASP Top 10'a kısa bir bakış

OWASP Top 10, web uygulamalarındaki en kritik risk kategorilerini listeleyen, topluluk tarafından hazırlanan bir farkındalık belgesi. Belirli aralıklarla güncelleniyor. En çok başvurulan 2021 sürümündeki kategoriler şunlar:

  1. A01 Bozuk erişim kontrolü (Broken Access Control)
  2. A02 Kriptografik hatalar
  3. A03 Enjeksiyon (SQL enjeksiyonu ve XSS bu başlıkta)
  4. A04 Güvensiz tasarım
  5. A05 Güvenlik yapılandırma hataları
  6. A06 Savunmasız ve güncel olmayan bileşenler
  7. A07 Kimlik doğrulama hataları
  8. A08 Yazılım ve veri bütünlüğü hataları
  9. A09 Güvenlik loglama ve izleme eksiklikleri
  10. A10 Sunucu taraflı istek sahteciliği (SSRF)

Bu listeyi bir kontrol listesi gibi değil, bir harita gibi düşünüyorum. Yazının geri kalanındaki açıkların neredeyse hepsi bu kategorilerden birine ya da birkaçına denk geliyor.

SQL enjeksiyonu: Sorguya karışan veri

SQL enjeksiyonu, kullanıcıdan gelen verinin SQL sorgusunun bir parçası gibi yorumlanmasıyla ortaya çıkar. Sorun şudur: Veritabanı, verinin nerede bittiğini ve komutun nerede başladığını ayırt edemez.

Metin birleştirmeyle kurulan sorguda saldırgan girdisinin sorguya karışıp tüm kullanıcıları döndürdüğünü, hazırlanmış ifadede ise sorgu ile verinin ayrı gönderildiği için girdinin çalışmadığını göster
Aynı zararlı girdi, metin birleştirmede sorgunun parçası oluyor, hazırlanmış ifadede ise yalnızca veri olarak kalıyor.

Eskiden çok yazılan, hâlâ da karşıma çıkan bir örnek:

// YANLIŞ: Kullanıcı girdisi doğrudan sorguya ekleniyor
$email = $_POST['email'];
$sql = "SELECT * FROM users WHERE email = '$email'";
$sonuc = $pdo->query($sql);

Saldırgan e-posta alanına ' OR '1'='1 yazarsa sorgu WHERE email = '' OR '1'='1' hâline gelir ve tüm kullanıcılar döner. Daha kötü senaryolarda tablolar okunabilir, hatta silinebilir.

Çözüm, girdiyi temizlemeye çalışmak değil, hazırlanmış ifadeler (prepared statements) kullanmaktır:

// DOĞRU: Sorgu ve veri ayrı gönderiliyor
$pdo = new PDO($dsn, $kullanici, $sifre, [
    PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
    PDO::ATTR_EMULATE_PREPARES => false,
]);

$stmt = $pdo->prepare('SELECT id, ad FROM users WHERE email = :email');
$stmt->execute(['email' => $_POST['email']]);
$kullanici = $stmt->fetch();

Burada sorgunun yapısı önceden sabitlenir, veri sonradan yer tutucuya bağlanır. Girdi ne olursa olsun komut olarak çalışmaz.

Tablo ya da sütun adları parametre olarak bağlanamaz. Kullanıcıya sıralama sütunu seçtiriyorsanız, gelen değeri ['ad', 'tarih'] gibi bir izin listesiyle karşılaştırın; doğrudan sorguya eklemeyin.

Ek önlem olarak uygulamanın veritabanı kullanıcısına yalnızca ihtiyaç duyduğu yetkileri veriyorum. Bir açık olsa bile, DROP yetkisi olmayan bir kullanıcıyla verdiği zarar sınırlı kalır.

XSS: Tarayıcıda çalışan yabancı kod

Cross-Site Scripting (XSS), saldırganın kendi JavaScript kodunu başka kullanıcıların tarayıcısında çalıştırabilmesidir. Bu kodla oturum bilgisine erişilebilir, sayfa içeriği değiştirilebilir ya da kullanıcı adına istek gönderilebilir.

Üç temel türü var:

  • Yansıtılan (reflected): Zararlı kod URL'de gelir ve sayfaya anında yansır.
  • Kalıcı (stored): Kod bir yorum ya da profil alanı gibi yerlerde veritabanına kaydedilir ve her ziyaretçiye sunulur.
  • DOM tabanlı: Açık tamamen istemci tarafındaki JavaScript kodundadır.

PHP tarafında temel kural, çıktıyı bağlamına uygun şekilde kaçırmaktır:

// YANLIŞ
echo "<p>Merhaba " . $_GET['ad'] . "</p>";

// DOĞRU
$ad = htmlspecialchars($_GET['ad'] ?? '', ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
echo "<p>Merhaba {$ad}</p>";

JavaScript tarafında ise en sık gördüğüm hata innerHTML kullanımı:

const yorum = new URLSearchParams(location.search).get('yorum');

// YANLIŞ: HTML olarak yorumlanır, <img onerror=...> çalışır
kutu.innerHTML = yorum;

// DOĞRU: Düz metin olarak eklenir
kutu.textContent = yorum;

Kullanıcının HTML girmesine gerçekten izin vermeniz gerekiyorsa, kendi filtrenizi yazmak yerine DOMPurify gibi olgun bir temizleme kütüphanesi kullanmanızı öneririm. Modern şablon motorları (Twig, Blade, React gibi) çıktıyı varsayılan olarak kaçırır; asıl risk bu korumayı bilerek kapattığınız yerlerde ortaya çıkar.

Web shell ile sızma ve korunma

Web shell, sunucuya yerleştirilen ve tarayıcı üzerinden komut çalıştırmaya yarayan küçük bir betiktir. Çoğu zaman tek satırlık bir PHP dosyası saldırgana sunucuda kalıcı bir arka kapı sağlar.

Gördüğüm tipik saldırı akışı şöyle ilerliyor:

  1. Saldırgan bir dosya yükleme formu bulur (profil fotoğrafı, belge yükleme vb.).
  2. Uzantısı .php olan ya da resim.jpg.php gibi adlandırılmış bir dosya yükler.
  3. Dosya web kök dizini altındaki /uploads/ klasörüne kaydedilir.
  4. Saldırgan dosyanın adresini çağırır, sunucu dosyayı PHP olarak çalıştırır.
  5. Artık sunucuda komut çalıştırabilir, dosya okuyabilir ve sistemde başka hedeflere geçebilir.

Dosya yüklemenin dışında, güncellenmemiş eklentiler (A06) ve uzaktan kod çalıştırma açıkları da web shell için sık kullanılan kapılar.

Korunma için katmanlı düşünüyorum. Önce yükleme kodunun kendisi:

$izinli = ['image/jpeg' => 'jpg', 'image/png' => 'png'];

$finfo = new finfo(FILEINFO_MIME_TYPE);
$tur = $finfo->file($_FILES['dosya']['tmp_name']);

if (!isset($izinli[$tur])) {
    http_response_code(400);
    exit('Geçersiz dosya türü');
}

// Kullanıcının verdiği adı asla kullanma
$yeniAd = bin2hex(random_bytes(16)) . '.' . $izinli[$tur];

// Web kök dizininin DIŞINA kaydet
move_uploaded_file($_FILES['dosya']['tmp_name'], '/var/app/storage/' . $yeniAd);

Burada üç şey önemli: Dosya türünü istemcinin gönderdiği bilgiden değil içerikten kontrol ediyorum, dosya adını ve uzantıyı kendim belirliyorum ve dosyayı doğrudan erişilemeyen bir dizine kaydediyorum.

İkinci katman sunucu yapılandırması. Yükleme dizininde betik çalışmasını tamamen kapatıyorum:

location ^~ /uploads/ {
    location ~* \.(php|phtml|phar)$ {
        deny all;
    }
}

Son katman ise izleme. Web dizininde yeni oluşan ya da değişen .php dosyalarını takip etmek, PHP sürecinin dosya yazma yetkisini sınırlamak ve gereksizse php.ini içinde disable_functions ile exec, shell_exec, system gibi fonksiyonları kapatmak, sızma olsa bile zararı daraltır.

Bir web shell tespit ettiğinizde dosyayı silip geçmeyin. Saldırgan büyük olasılıkla başka arka kapılar da bırakmıştır. Logları inceleyin, şifreleri ve anahtarları yenileyin, mümkünse sistemi temiz bir yedekten yeniden kurun.

Güvenlik başlıkları: CSP ve HSTS

Kodu ne kadar dikkatli yazarsak yazalım, bir yerde hata kaçabilir. HTTP güvenlik başlıkları, tarayıcıya "bu sayfada neye izin var" bilgisini vererek ikinci bir savunma hattı oluşturur.

Content Security Policy (CSP)

CSP, sayfanın hangi kaynaklardan betik, stil, görsel ve çerçeve yükleyebileceğini belirler. Bir XSS açığı olsa bile, satır içi betikler ve bilinmeyen alan adlarından gelen kodlar engellenir.

HTTP Strict Transport Security (HSTS)

HSTS, tarayıcıya sitenin yalnızca HTTPS üzerinden açılması gerektiğini söyler. Böylece kullanıcı http:// yazsa bile bağlantı şifresiz başlamaz ve araya girme saldırılarının önü kesilir.

Nginx üzerinde kullandığım başlangıç yapılandırması:

add_header Content-Security-Policy "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;

CSP'yi mevcut bir projeye eklerken önce Content-Security-Policy-Report-Only başlığıyla başlıyorum. Bu modda hiçbir şey engellenmez, yalnızca ihlaller raporlanır. Neyin kırılacağını görüp politikayı düzelttikten sonra asıl başlığa geçiyorum. Satır içi betik zorunluysa 'unsafe-inline' yerine her istekte üretilen bir nonce değeri kullanmak çok daha güvenli.

HSTS'yi ilk kez açarken max-age değerini kısa tutun (örneğin 300 saniye). Tüm alt alan adlarınızın HTTPS ile çalıştığından emin olmadan includeSubDomains ve preload eklemeyin; tarayıcı bu kuralı uzun süre hatırlar ve geri almak zordur.

Sonuç

Bu açıkların ortak noktası şu: Hepsi bir yerde veriye fazla güvenmekten doğuyor. SQL enjeksiyonunda veritabanı, XSS'te tarayıcı, web shell'de sunucu kullanıcı verisini komut sanıyor.

Benim pratik özetim şöyle: Sorgularda hazırlanmış ifadeler, çıktıda bağlama uygun kaçırma, yüklemelerde izin listesi ve kök dizin dışı depolama, en sonda da CSP ve HSTS ile tarayıcıyı devreye almak. Tek bir önlem yetmez, ama katmanlar birleştiğinde saldırganın işi ciddi biçimde zorlaşır.

Sık sorulan sorular

ORM kullanıyorum, SQL enjeksiyonundan tamamen korunuyor muyum?

Büyük ölçüde evet, çünkü ORM'ler arka planda parametreli sorgu kullanır. Ancak ham SQL yazdığınız yerlerde ya da sorguya doğrudan metin birleştirdiğiniz durumlarda risk geri gelir.

CSP tek başına XSS'i engeller mi?

Hayır. CSP bir ek savunma katmanıdır. Asıl çözüm çıktıyı doğru kaçırmaktır; CSP ise gözden kaçan bir açığın sömürülmesini zorlaştırır.

Dosya uzantısını kontrol etmek web shell'e karşı yeterli mi?

Yeterli değil. Uzantı kolayca taklit edilebilir. İçerik tabanlı tür kontrolü, sunucu tarafında yeniden adlandırma, kök dizin dışında saklama ve yükleme dizininde betik çalıştırmayı kapatmak birlikte uygulanmalı.

Güvenlik başlıklarımı nasıl test edebilirim?

Tarayıcının geliştirici araçlarındaki Ağ sekmesinde yanıt başlıklarını görebilir, konsolda CSP ihlal uyarılarını takip edebilirsiniz. Komut satırında curl -I https://alanadiniz.com da hızlı bir kontrol sağlar.

Kaynaklar