Geçen ay bir bakım devri için incelediğimiz projede, arama kutusuna alışkanlıkla tek tırnak yazdım. Ekrana upuzun bir SQL hatası döküldü: tablo adları, sorgu metni, sürüm bilgisi — hepsi orada, vitrinde. O an ekipten bir arkadaş "hocam yıl kaç?" diye sordu. Yıl 2023. Saldırının tanımlanması 1998. Yani çeyrek asra yaklaşan bir açık türü, hâlâ önümüzdeki projede, canlı sistemde duruyor.
Açık konuşayım: SQL injection'ın 2023'te hâlâ en yaygın açıklar arasında olması, sektör olarak hepimizin utancı. Çözümü biliniyor, ucuz ve otuz yıldır elimizin altında. Bu yazı, o çözümü bir kez daha — ama bu sefer kenar durumlarıyla birlikte — anlatma çabası.
Tek tırnağın açtığı kapı
Sorunun kökü tek cümle: kullanıcıdan gelen veriyi doğrudan sorgu metnine yapıştırmak. Klasik örnek şu: "SELECT * FROM uye WHERE ad='" . $_GET['ad'] . "'". Burada kullanıcının yazdığı şey veri değil, sorgunun parçası oluyor. Saldırgan girdiye kendi SQL'ini ekliyor ve sizin sorgunuz onun sorgusuna dönüşüyor.
"Kim uğraşacak bizim siteyle" savunmasını duyar gibiyim. Kimse uğraşmıyor; araçlar uğraşıyor. sqlmap gibi ücretsiz araçlar bir formu veya URL parametresini otomatik tarayıp açığı saniyeler içinde buluyor, sonra da veritabanını tablo tablo dışarı çekiyor. Üye listeniz, şifre hash'leriniz, müşteri bilgileriniz, sipariş geçmişiniz — operatörün tek yaptığı komutu yazıp kahvesini yudumlamak. Başındaki kişinin yetenekli olmasına bile gerek yok; işin fabrikasyonu çıkmış durumda.
İşin bir de tazeleyen tarafı var: uygulamalar API'leştikçe aynı hata yeni kılıklarla dönüyor. Mobil uygulamanın arkasındaki endpoint'te, rapor ekranının filtre parametresinde, "içeriden çağrılıyor zaten" denilen admin servisinde... Form alanı görünmüyor diye girdi yok sanmak, bu yılın en sık düzelttiğimiz yanılgılarından. Saldırgan formu değil trafiği görür; parametre neredeyse, deneme oraya gelir.
Ve KVKK çağında bunun faturası artık sadece itibar değil. Veri ihlali bildirimi, idari para cezası, müşteri kaybı... Tek tırnaklık ihmalin zinciri uzun.
Veriyi komuttan ayırmak: meselenin tamamı bu
Çözümün adı parametreli sorgu — PHP dünyasında PDO ile prepare() + execute() ikilisi. Mantığı şu: sorgunun iskeletini önce gönderiyorsunuz, verileri ayrıca bağlıyorsunuz. Veritabanı motoru ikisini asla karıştırmıyor; kullanıcı girdiye ne yazarsa yazsın, o girdi komut olarak değil veri olarak işleniyor. Enjeksiyon zorlaşmıyor — yapısal olarak imkânsızlaşıyor.
Burada sık duyduğumuz bir itiraz var: "Biz girdileri escape ediyoruz zaten." Kaçışlama fonksiyonlarına güvenmeyin. Karakter seti oyunlarıyla escape'in aşıldığı vakalar literatüre geçti; kaçışlama, doğru yapılması insana emanet bir önlemdir ve insan unutur. Parametreli sorguda unutacak bir şey yok. Kuralımız net ve istisnasız: dinamik değer, string birleştirmeyle asla sorguya girmez. Code review'da bu kuralın ihlali, bizde tartışmasız geri çevirme sebebi.
(ORM kullanıyorsanız çoğu zaman bu iş sizin adınıza yapılıyor — ama ORM'lerin "raw query" kapıları var ve enjeksiyonların ORM'li projelerdeki adresi neredeyse hep orası. Raw sorgu yazan herkes bu yazının kapsamında.)
Bir de az bilinen bir kuzen var: ikinci derece (second-order) enjeksiyon. Girdi ilk kaydedilirken zararsızdır; ama başka bir sorguda, kayıtlı o değer güvenilir sanılıp string birleştirmeyle kullanıldığında bomba orada patlar. "Veritabanından gelen veri temizdir" varsayımı işte bu yüzden yanlış — kural, verinin kaynağına değil kullanımına bakar: sorguya giren her dinamik değer, nereden gelirse gelsin, parametre olarak bağlanır. İstisnasız.
"Stored procedure kullanıyoruz, bize işlemez" inancına da bir dipnot: prosedürün içinde dinamik SQL string'i kuruluyorsa, enjeksiyon sadece bir kat aşağı taşınmış olur. Prosedür, doğru parametrelendirilmiş sorgunun alternatifi değil; olsa olsa onu barındıran bir zarf. Zarfa değil, içindekine bakın.
Prepared statement'ın elinin uzanmadığı köşeler
Şimdi işin ustalık kısmı, çünkü "prepare kullandık, bitti" diyenlerin takıldığı üç köşe var.
Birincisi dinamik kolon ve tablo adları. "Kullanıcı hangi kolona göre sıralamak istiyorsa ona göre ORDER BY kur" senaryosunda kolon adı parametrelenemez — bu, SQL'in doğası gereği böyle. Çözüm: beyaz liste. İzin verilen kolon adlarını kodda sabit bir listede tutar, kullanıcıdan geleni bu listeyle eşleştirirsiniz. Listede yoksa varsayılana düşer. Girdiden gelen metni "temizleyip" kolon adı olarak kullanma çabası, er ya da geç delinir.
İkincisi LIMIT ve OFFSET değerleri. Bunları tam sayıya zorlayın (cast edin), sonra bağlayın. "Nasılsa sayıdır" varsayımı, sayı olmayan ilk girdide bozulur.
Üçüncüsü LIKE kalıpları — bu en gözden kaçanı. Yüzde ve alt çizgi karakterleri LIKE içinde joker anlamı taşır; kullanıcı girdisinde bunları ayrıca kaçışlamazsanız enjeksiyon olmasa bile başka bir dert doğar: arama kutusuna art arda yüzde işareti basan biri, veritabanınızı tablo taramasına zorlayıp sistemi yavaşlatabilir. Adına performans DoS'u diyoruz; güvenlik açığı sayılmaz belki ama gece yarısı çalan telefon aynı telefon.
İçeri girilirse hasarı kim sınırlayacak
Buraya kadar anlattıklarım ilk savunma hattı. İyi mühendislik, ilk hat delinirse ne olacağını da planlar — buna derinlemesine savunma deniyor.
En etkili adım, uygulamanın veritabanı kullanıcısının yetkilerini kısmak. Web uygulamasının bağlandığı kullanıcının DROP, ALTER, hatta çoğu tabloda DELETE yetkisine sahip olması için genellikle hiçbir sebep yok — ama devraldığımız projelerin çoğunda web kullanıcısı tam yetkili root'un kardeşi gibi geziyor. Yetki kısıldığında, en kötü senaryoda bile saldırganın hareket alanı daralıyor.
Bu işi bireysel dikkat meselesi olmaktan çıkarmak da mümkün. Statik analiz araçları, sorguya string birleştirmeyle giren değişkenleri otomatik yakalayabiliyor; CI hattına bir kural ekliyorsunuz, ihlal içeren kod daha review'a gelmeden kırmızı yanıyor. Bizde bu kural iki yıldır aktif ve itiraf edeyim, kıdemli arkadaşları bile ara sıra yakalıyor — acele ile yazılan "geçici" rapor sorgusu, en kıdemli klavyeden de çıkabiliyor. Alet işler, el övünür; ama alet olmadan el de yoruluyor.
İkincisi hata hijyeni: SQL hataları kullanıcıya değil, loga yazılır. Yazının başındaki proje gibi hatayı ekrana basmak, saldırgana veritabanı şemanızın haritasını hediye etmektir. Üçüncüsü WAF — web application firewall — ama doğru konumuyla: son savunma hattı olarak. WAF'ı ilk ve tek önlem yapan mimari, temeli çürük binaya çelik kapı takmaya benziyor.
Ve mutlaka kendinizi test edin. Kendi sitenize (kendi izninizle, test ortamında) sqlmap çalıştırmak yarım günlük iştir ve saldırgandan önce davranmanın en ucuz yoludur. Devraldığınız ya da yıllardır dokunulmamış bir sisteminiz varsa ve "bizde durum ne acaba" sorusu içinizi kemiriyorsa, bize ulaşın — bakım devri yaptığımız her projede bu taramayı standart olarak yapıyoruz ve neyle karşılaşacağımızı artık tahmin bile edebiliyoruz.
Tek tırnak testi bedava. Sonuçlarıyla yüzleşmek değil.