Bölüm 10:Bellek Güvenliği ve Sertleştirme
CPU'ya “Sen”i Katmak yazısının parçası: bilgisayarının programları nasıl çalıştırdığına doğru inen uzun bir teknik tavşan deliği.
Tüm bölümler
- Giriş
- Başlamadan Önce
- Temeller
- Bellek Hiyerarşisi
- Zamanı Dilimle
- Modern İşlemci Teknikleri
- Bir Program Nasıl Çalıştırılır?
- Shell'den Kernel'e
- Bir ELF Ustasına Dönüşmek
- Bilgisayarındaki Çevirmen
- Bellek Güvenliği ve Sertleştirme
- Container ve İzolasyon
- Fork'lar ve COW'lar Hakkında Konuşalım
- Dosya Sistemi ve I/O
- Sistemi Gözlemlemek
- Son Söz
Bir önceki bölümde sanal belleğin process’leri birbirinden nasıl ayırdığını gördük: her process kendi adres alanında yaşıyor, komşusunun belleğini okuyamıyor. Bu güçlü bir sınır, ama tek bir process’in kendi içinde olan biteni hiç denetlemiyor. Program yanlışlıkla kendi stack’ini ezdiğinde MMU bir şey söylemez; adres o process’e aittir, erişim meşrudur.
İşte saldırganların onlarca yıldır kullandığı boşluk tam burası. Bu bölümde, kernel ve derleyicinin bu boşluğu kapatmak için kurduğu savunma katmanlarını inceleyeceğiz.
Bu bölümde neyi çözüyoruz?
- Bir buffer overflow’un program akışını nasıl ele geçirdiğini adım adım göreceğiz.
- NX, stack canary, ASLR, PIE ve RELRO korumalarının her birinin hangi saldırı adımını kırdığını anlayacağız.
- Kendi sistemimizdeki bir binary’nin hangi korumalarla derlendiğini
checksecile okuyacağız.
Sorunun Kökeni: Sınır Kontrolü Olmayan Bellek#
C ve C++ gibi diller bellek erişiminde sınır kontrolü yapmaz. char buffer[64] diye ayırdığın alana 100 byte yazarsan derleyici seni durdurmaz, CPU da durdurmaz. Fazlalık, stack’te o tamponun hemen ardındaki ne varsa onun üzerine yazılır.
Peki tamponun ardında ne var? 2. bölümde gördüğümüz gibi bir fonksiyon çağrıldığında CPU, geri dönüş adresini stack’e iter. Yerel değişkenler de aynı stack çerçevesindedir. Tipik bir yerleşimde tampon aşağı, dönüş adresi yukarıdadır ve tampon taştığında yazma yönü tam olarak o adrese doğrudur.
#include <stdio.h>
#include <string.h>
void selamla(const char *ad) {
char tampon[64];
strcpy(tampon, ad); // ad 64 byte'tan uzunsa taşar
printf("Merhaba %s\n", tampon);
}
int main(int argc, char **argv) {
selamla(argv[1]);
return 0;
}strcpy kopyalamayı yalnızca kaynak dizede bir null byte gördüğünde bırakır; hedefin ne kadar büyük olduğu bilgisi ona hiç verilmemiştir. Saldırgan 64 byte’lık tamponu doldurup üzerine kendi seçtiği bir adresi yazarsa, fonksiyon ret yaptığında CPU o adrese atlar. Program akışı artık saldırganındır.
Klasik saldırı üç adımdan oluşur: çalıştırılacak kodu belleğe yerleştir, o kodun adresini öğren, dönüş adresini o adresle değiştir. Aşağıdaki korumaların her biri bu adımlardan birini hedef alır.
NX: Veri Sayfaları Yürütülemez#
İlk savunma en doğrudan olanı: saldırganın yazdığı byte’ların hiç çalıştırılamamasını sağlamak.
9. bölümde sayfa tablosu girdilerinin izin bitleri taşıdığını gördük. Bu bitlerden biri sayfanın yürütülebilir olup olmadığını söyler. AMD bu bite NX (No-eXecute), Intel XD (eXecute Disable) der; Linux dünyasında kavramın genel adı W^X’tir: bir sayfa ya yazılabilir ya yürütülebilir olsun, ikisi birden olmasın.
NX etkinken stack ve heap yazılabilir ama yürütülemez sayfalardır. Saldırgan stack’e makine kodu yazmayı başarsa bile CPU o adrese atladığı anda page fault üretir ve process sonlanır.
Bir process’in bellek haritasında bu ayrımı doğrudan görebilirsin:
cat /proc/self/maps | head -5Çıktıdaki dört karakterlik izin alanı r-xp ise okunabilir ve yürütülebilir (kod), rw-p ise okunabilir ve yazılabilir (veri). rwxp kombinasyonunu modern bir binary’de neredeyse hiç görmezsin.
NX neyi çözmez? Saldırgan kendi kodunu çalıştıramaz, ama programda zaten var olan kod parçalarını zincirleyebilir. Return-oriented programming (ROP) denen bu teknikte, libc gibi kütüphanelerde bulunan ve
retile biten küçük komut dizileri (“gadget”) arka arkaya çağrılarak istenen işlem kurulur. NX gerekli bir katmandır, yeterli değildir.
Stack Canary: Kanaryayı Madene İndir#
İkinci savunma taşmayı, zarar verdikten sonra ama iş işten geçmeden önce fark etmeye çalışır.
Derleyici, fonksiyon girişinde yerel değişkenlerle dönüş adresi arasına rastgele bir değer yerleştirir. Fonksiyon dönmeden hemen önce bu değeri kontrol eder: değişmişse aradaki bölge ezilmiş demektir ve program __stack_chk_fail ile derhal sonlandırılır. Madenciler zehirli gazı erken fark etmek için kafeste kanarya taşırdı; adı buradan gelir.
Kanarya değeri process başına üretilir ve fs segment register’ı üzerinden erişilen thread-local alanda tutulur, yani saldırganın onu tahmin etmesi kolay değildir. GCC ve Clang’de üç seviye vardır:
-fstack-protector— yalnızca dizi içeren fonksiyonları korur-fstack-protector-strong— yerel dizisi veya adresi alınan değişkeni olan fonksiyonları korur; dağıtımların çoğunun varsayılanı budur-fstack-protector-all— her fonksiyonu korur, en pahalısı
Kanarya neyi çözmez? Yalnızca ardışık (sıralı) taşmaları yakalar. Saldırgan bir indeks hatası sayesinde stack’in tam istediği noktasına yazabiliyorsa kanaryanın üstünden atlayabilir. Ayrıca koruma yalnızca dönüş anında devreye girer; fonksiyon o ana kadar ezilmiş verilerle çalışmaya devam etmiştir.
ASLR: Adresleri Her Çalıştırmada Karıştır#
Üçüncü savunma saldırının ikinci adımını hedefler: adres bilmek.
ASLR (Address Space Layout Randomization), process başlatılırken stack, heap ve paylaşılan kütüphanelerin sanal bellekteki başlangıç adreslerini rastgeleleştirir. Saldırgan artık “libc’deki system fonksiyonu şu adrestedir” diyemez; adres her çalıştırmada değişir.
Aynı ikili dosyayı iki kez çalıştırıp bellek haritasını karşılaştırarak bunu kendin görebilirsin:
cat /proc/self/maps | grep '\[stack\]'
cat /proc/self/maps | grep '\[stack\]'İki komut farklı stack adresleri yazdırır. Sistem genelindeki ayar /proc/sys/kernel/randomize_va_space dosyasındadır: 0 kapalı, 1 stack ve kütüphaneler için açık, 2 bunlara ek olarak heap (brk) için de açık. Modern dağıtımlar 2 ile gelir.
Kernel’ın kendi kod adreslerini rastgeleleştiren karşılığına KASLR denir ve kernel’a yönelik saldırıları aynı mantıkla zorlaştırır.
ASLR neyi çözmez? Rastgelelik bir bilgi sızıntısıyla çökebilir. Program bir bellek adresini hata mesajında yazdırıyorsa ya da format string açığı varsa, saldırgan tek bir adresi öğrenip modülün tamamının nereye yerleştiğini geri hesaplayabilir. 32-bit sistemlerde entropi de düşüktür; kaba kuvvetle denemek mümkün olabilir.
PIE: Programın Kendisi de Taşınabilsin#
ASLR kütüphaneleri ve stack’i karıştırır, ama programın kendi kodu sabit bir adrese yüklenmişse saldırgan gadget’larını oradan toplayabilir.
PIE (Position Independent Executable), 8. bölümde gördüğümüz paylaşılan kütüphane mantığını çalıştırılabilir dosyanın kendisine uygular. Kod mutlak adres kullanmak yerine instruction pointer’a göreli adresleme yapar (RIP-relative), böylece herhangi bir adrese yüklenebilir. ELF başlığındaki tür alanı bu durumda ET_EXEC yerine ET_DYN olur.
file /bin/lsÇıktıda “pie executable” ifadesini görüyorsan binary PIE olarak derlenmiştir. Bunun bir bedeli vardır: göreli adresleme küçük bir performans maliyeti getirir ve bu yüzden bazı performans-kritik yazılımlar hâlâ PIE’siz derlenir.
RELRO: Fonksiyon Tablosunu Kilitle#
- bölümde dinamik bağlamanın GOT (Global Offset Table) üzerinden çalıştığını görmüştük: kod,
printfgibi bir fonksiyonu çağırırken GOT’taki adrese dolaylı olarak atlar. Bu tablo yazılabilir bir bölgede durursa saldırgan için nefis bir hedeftir; oraya kendi adresini yazan biri, programın bir sonrakiprintfçağrısını ele geçirir.
RELRO (RELocation Read-Only) bu tabloyu yazılamaz hâle getirir. İki seviyesi vardır:
- Partial RELRO — ELF’in yeniden konumlandırma bölümleri salt okunur yapılır, ama GOT yazılabilir kalır. Çünkü lazy binding, sembolü ilk çağrıda çözüp adresi GOT’a yazmak ister.
- Full RELRO — tüm semboller program başlarken peşinen çözülür (
BIND_NOW), sonra GOT salt okunur işaretlenir. Açılış birkaç milisaniye yavaşlar, buna karşılık GOT saldırısı tamamen kapanır.
Full RELRO, lazy binding’den vazgeçmek anlamına gelir; hız ile güvenlik arasındaki bu takas, güvenliğin lehine çözülmüş sayılır ve dağıtımların çoğu paketlerini -Wl,-z,relro,-z,now ile derler.
FORTIFY_SOURCE: Derleyici Boyutu Biliyorsa Kontrol Etsin#
Bazı durumlarda derleyici, hedef tamponun boyutunu derleme anında bilir. _FORTIFY_SOURCE bu bilgiyi kullanır: memcpy, strcpy, sprintf gibi çağrıları, boyut kontrolü yapan __memcpy_chk gibi güvenli varyantlarıyla değiştirir. Taşma tespit edilirse program çalışma anında sonlandırılır.
Optimizasyon açıkken (-O2) etkinleşir ve -D_FORTIFY_SOURCE=2 ile daha agresif kontroller ister. Bedeli neredeyse sıfırdır; boyutun statik olarak bilinmediği çağrılarda ise devreye giremez.
Hepsini Birlikte Görmek#
Bir binary’nin hangi korumalarla derlendiğini checksec ile tek bakışta okuyabilirsin:
checksec --file=/bin/lsBeklenen çıktıya örnek:
RELRO STACK CANARY NX PIE
Full RELRO Canary found NX enabled PIE enabledAracın kurulu değilse readelf ile aynı bilgilere daha ham biçimde ulaşabilirsin:
readelf -l -d -W /bin/ls | grep -E 'GNU_STACK|GNU_RELRO|BIND_NOW'GNU_STACK satırındaki izinlerde E yoksa NX etkindir; GNU_RELRO segmentinin varlığı RELRO’yu, BIND_NOW bayrağı ise Full RELRO’yu gösterir.
Katmanlar Neden Hepsi Birden Gerekli#
Bu korumaların hiçbiri tek başına yeterli değil; her biri saldırı zincirinin farklı bir halkasını kesiyor:
| Koruma | Kırdığı adım | Kim uygular? |
|---|---|---|
| NX / W^X | Enjekte edilen kodun çalıştırılması | CPU + kernel |
| Stack canary | Dönüş adresinin sessizce ezilmesi | Derleyici |
| ASLR | Hedef adresin bilinmesi | Kernel |
| PIE | Program kodunun sabit adreste olması | Derleyici + linker |
| RELRO | GOT üzerinden akış kaçırma | Linker |
| FORTIFY_SOURCE | Bilinen boyutta taşma | Derleyici + libc |
Savunma derinliği fikri tam olarak budur: saldırganın başarılı olması için hepsini birden aşması gerekir. Buna karşılık hiçbiri bellek güvenliği açığının kendisini ortadan kaldırmaz; sadece sömürülmesini pahalılaştırır. Use-after-free, double-free ve tam sayı taşmasından doğan hatalar bu listedeki hiçbir korumanın doğrudan hedefi değildir.
Asıl çözüm sınıfın kökünü kurutmaktan geçiyor: Rust gibi sahiplik modeli olan diller, Go gibi çöp toplayıcılı diller ya da C/C++ tarafında std::span ve sınır kontrollü kapsayıcılar. Nitekim Linux kernel’ında Rust desteğinin gelişi de büyük ölçüde bu motivasyonla ilerliyor.
Bir yana: Spectre ve Meltdown bu tabloda nerede?
5. bölümde gördüğümüz speculative execution, tamamen farklı bir sınıf açar. Buradaki korumaların hepsi yazılımın belleğe yazmasını denetler; Spectre ailesi ise hiçbir şey yazmadan, yalnızca CPU’nun tahmin ederken bıraktığı cache izlerini ölçerek veri sızdırır. Bu yüzden savunması da farklıdır: mikrokod güncellemeleri, kernel sayfa tablosu izolasyonu (KPTI) ve derleyici tarafında retpoline gibi teknikler.
Özet#
- Sanal bellek process’leri birbirinden ayırır ama bir process’i kendi içindeki bellek hatalarından korumaz.
- Buffer overflow saldırısı üç adımdır: kodu yerleştir, adresi öğren, akışı oraya çevir.
- NX enjekte edilen kodun çalışmasını, stack canary sessiz ezilmeyi, ASLR ve PIE adres bilmeyi, RELRO GOT üzerinden kaçırmayı engeller.
checksecvereadelfile herhangi bir binary’nin hangi katmanlarla derlendiğini görebilirsin.- Hiçbiri açığın kendisini kapatmaz; bellek güvenli diller sorunu sınıf düzeyinde çözer.
Şimdiye kadar tek bir makinede tek bir process’in nasıl korunduğuna baktık. Bir sonraki bölümde ölçeği büyütüyoruz: aynı kernel üzerinde çalışan onlarca process’i birbirinden ayıran container teknolojisi, bu izolasyon fikrini nasıl paketliyor?
11. bölüme devam et: Container ve İzolasyon