Ana içeriğe geç

Bölüm 15:Bellek Güvenliği ve Sertleştirme

12 dakikalık okumaGüncelleme:

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ı. Şimdi seninle bu boşluğun etrafına örülmüş savunma katmanlarını tek tek sökeceğiz.

Baştan söyleyeyim: bu katmanların hiçbiri açığı kapatmıyor. Hepsi yalnızca sömürmeyi pahalılaştırıyor. Her birinde neyi durdurduğunu değil, neyi durdurmadığını da göstereceğim — asıl öğretici olan kısım orası.

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 checksec ile 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 tampon[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.

savunmasiz.c
void selamla(const char *ad) {
  char tampon[64];
  strcpy(tampon, ad);           // ad 64 byte'tan uzunsa taşar
  printf("Merhaba %s\n", tampon);
}

strcpy kopyalamayı yalnızca kaynak dizede bir null byte gördüğünde bırakır. Hedefin ne kadar büyük olduğunu ise hiç bilmez — kimse ona söylememiştir, söyleyecek bir yer de yoktur. 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.

Gönderilen Byte’lar Tam Olarak Neye Benziyor?

“Dönüş adresini ez” cümlesi soyut kalıyor; somut yerleşimi görmeden mekanizma oturmuyor. 64 baytlık tamponu olan bir fonksiyonun stack çerçevesi, düşük adresten yükseğe doğru kabaca şöyle dizilir:

dusuk adres
+------------------------+
| tampon[0..63]          |  64 bayt
+------------------------+
| stack canary           |   8 bayt  (koruma aciksa)
+------------------------+
| kaydedilmis rbp        |   8 bayt
+------------------------+
| donus adresi           |   8 bayt  <- hedef bu
+------------------------+
yuksek adres

Saldırganın göndereceği dizi, bu haritanın üstüne birebir oturur: önce 64 bayt dolgu (içerik önemsiz, sadece yer doldurur), sonra kanarya alanı için 8 bayt, sonra rbp için 8 bayt ve nihayet gerçekten önemsediği son 8 bayt — atlamak istediği adres.

Yani “taşma” dediğimiz şey aslında dikkatlice ölçülmüş bir yerleştirme. Saldırganın bilmesi gereken tek sayı, tamponun başlangıcı ile dönüş adresi arasındaki mesafe. Bunu bulmak için genelde artan uzunlukta girdiler denenir ve programın hangi uzunlukta çöktüğüne bakılır.

Bir de NOP sled numarası var. Saldırgan atlayacağı adresi tam tutturamayabilir; birkaç bayt sapma ihtimali her zaman vardır. Bu yüzden kendi kodunun önüne yüzlerce nop talimatı koyar — nop hiçbir şey yapmayan tek baytlık bir talimattır. Bu bloğun herhangi bir yerine düşerse CPU boş boş ilerleyip asıl koda varır. Yani tek bir adresi tutturmak yerine geniş bir hedef tahtası oluşturur.

Bu son cümleyi aklında tut: saldırının tamamı bir adres tahmin etmeye dayanıyor. Birazdan geleceğimiz ASLR’nin varlık sebebi tam olarak bu tahmini imkânsız kılmak.

Bir taşmanın nereye varacağı, ne kadar taştığına bağlıdır. Aşağıdaki dört sahne aynı tamponun farklı taşma miktarlarını gösteriyor:

Aynı stack yerleşiminin dört hâli: normal durum; fark edilmeyen küçük taşma; komşu değişkenleri ezen taşma; dönüş adresini ezip kod enjeksiyonuna varan taşma.

Kaynak: Silberschatz, Galvin, Gagne — Operating System Concepts, 10. baskı, Ch. 16 — Security.

Soldan sağa giderek ciddiyet artıyor. İlkinde taşma yalnızca boş hizalama alanına (padding) düşüyor ve hiç fark edilmiyor — program normal çalışmaya devam ediyor, hata bir gün başka bir bağlamda patlamak üzere orada duruyor. İkincisinde komşu değişkenler eziliyor: program yanlış değerlerle çalışıyor ya da çöküyor. Sonuncusunda ise taşma dönüş adresine ulaşıyor ve iş tamamen değişiyor; fonksiyon döndüğünde CPU artık saldırganın seçtiği yere atlıyor.

Bu son sütun, klasik saldırının üç adımını da içinde barındırıyor: çalıştırılacak kodu belleğe yerleştir (enjekte edilen kod), o kodun adresini öğren, dönüş adresini o adresle değiştir. Bundan sonra göreceğimiz korumaların her biri bu üç adımdan birini kırmayı hedefler.

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.

13. 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; Politikanın genel adı ise W^X’tir (write XOR execute): bir sayfa ya yazılabilir ya yürütülebilir olsun, ikisi birden olmasın. Terim OpenBSD’den gelir; 2003’te bu politikayı sistem genelinde uygulayan ilk işletim sistemi oydu ve diğerleri sonradan izledi.

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. NX gerekli bir katmandır, yeterli değildir.

ROP: Kod Yazmadan Kod Çalıştırmak

NX’in neden yetmediğini anlamak, bölümün geri kalanının anahtarı — çünkü ASLR, PIE ve RELRO’nun tamamı bu boşluğu kapatmak için var.

Numara ret talimatının ne kadar basit olduğunu fark etmekle başlıyor. ret tek bir iş yapar: stack’in tepesindeki değeri al ve oraya atla. Yani stack’i kontrol eden, programın akışını kontrol eder. Saldırganın taşmayla ele geçirdiği şey de tam olarak stack’ti.

Şimdi ikinci gözlem: bir programda ve bağlı kütüphanelerinde milyonlarca bayt makine kodu var. Bu kodun içinde, birkaç faydalı talimat yapıp hemen ardından ret ile biten minik parçalar bulmak şaşırtıcı derecede kolay. Bu parçalara gadget deniyor. ret ile bitmeleri şart, çünkü zinciri ilerleten motor o.

Zincir şöyle kuruluyor. Saldırgan stack’e üst üste gadget adresleri diziyor:

donus adresi   -> gadget 1 adresi   ; pop rdi ; ret
                  "/bin/sh" adresi   ; gadget 1'in rdi'ye cekecegi deger
                  gadget 2 adresi    ; pop rsi ; ret
                  0                  ; gadget 2'nin rsi'ye cekecegi deger
                  system() adresi    ; artik argumanlar hazir

Fonksiyon ret yaptığında birinci gadget’a atlar. O gadget pop rdi yapıp ret eder — ve ret, stack’teki bir sonraki adrese, yani ikinci gadget’a atlar. Zincir kendiliğinden yürür. Stack artık veri değil, bir program: her satırı bir sonraki adımı söyleyen bir talimat listesi.

Kritik nokta şu: bu zincirin hiçbir aşamasında NX ihlal edilmiyor. Çalıştırılan her bayt, zaten yürütülebilir olarak işaretlenmiş sayfalarda duruyor — programın kendi kodu. Saldırgan tek bir yeni talimat yazmadı; var olanları yeni bir sırayla dizdi.

Bu tekniğin en basit hâline ret2libc denir: gadget zincirlemekle uğraşmak yerine doğrudan system() fonksiyonuna atlanır. ROP ise aynı fikrin genelleştirilmiş hâli ve pratikte neredeyse her şeyi yapabilecek kadar güçlü.

Buradan çıkan ders net: kodu yazılamaz yapmak yetmiyor, saldırganın kodun nerede olduğunu bilmesini de engellemek gerekiyor. Sıradaki koruma tam olarak bunu deniyor.

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.

Peki bu rastgele değer nereden geliyor ve saldırgan onu okuyamaz mı? Değer program başlarken bir kez üretilir ve process’in ömrü boyunca sabit kalır. Stack’ten ayrı, programın kendi verilerinin arasında özel bir köşede saklanır — kanarya karşılaştırması her fonksiyon dönüşünde yapıldığı için bu erişimin çok ucuz olması gerekir. Saldırgan taşma yoluyla oraya yazabilir ama okuyamaz; okuyamadığı için de doğru değeri taşmanın içine kopyalayamaz. Kanaryanın işini yapan şey tam olarak bu asimetri. GCC ve Clang’de üç seviye vardır:

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), 12. 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

12. bölümde dinamik bağlamanın GOT (Global Offset Table) üzerinden çalıştığını görmüştük: kod, printf gibi 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 sonraki printf çağrısını ele geçirir.

RELRO (RELocation Read-Only) bu tabloyu yazılamaz hâle getirir. İki seviyesi vardır:

Full RELRO, lazy binding’den vazgeçmek demektir. Bu takası tereddütsüz güvenliğin lehine kapatırım: açılışta kaybedilen birkaç milisaniye, programın fonksiyon tablosunu saldırganın kalemine açık bırakmaya değmez. Dağıtımların çoğu da aynı kararı verip paketlerini -Wl,-z,relro,-z,now ile derliyor.

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.

Şunu aklında tut: _FORTIFY_SOURCE kendiliğinden açılmaz. Optimizasyon açık olmalıdır (-O1 ve üstü), çünkü derleyici tampon boyutunu ancak optimize ederken hesaplar — ama korumayı asıl açan şey derlerken -D_FORTIFY_SOURCE=2 (yeni GCC’lerde =3) tanımlamandır. Dağıtımların çoğu bunu varsayılan derleme bayraklarına koyduğu için paket olarak kurduğun binary’lerde genellikle açıktır; kendi elinle gcc dosya.c dediğinde ise kapalıdır.

Bedeli neredeyse sıfır — çalışma anında yapılan fazladan iş, zaten bilinen bir sayıyı bir sayıyla karşılaştırmaktan ibaret. Ama sınırını da gör: derleyici hedefin boyutunu derleme anında bilemiyorsa, mesela tampon malloc ile ve çalışma anında hesaplanmış bir boyutla ayrıldıysa, koruma hiç devreye giremez. Bu yüzden FORTIFY_SOURCE’u bir ağ olarak değil, ucuz olduğu için hiç reddetmemen gereken bir bonus olarak düşün.

Hepsini Birlikte Görmek

Bir binary’nin hangi korumalarla derlendiğini checksec ile tek bakışta okuyabilirsin:

checksec --file=/bin/ls

Beklenen çıktıya örnek:

RELRO           STACK CANARY      NX            PIE
Full RELRO      Canary found      NX enabled    PIE enabled

Aracı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:

KorumaKırdığı adımKim uygular?
NX / W^XEnjekte edilen kodun çalıştırılmasıCPU + kernel
Stack canaryDönüş adresinin sessizce ezilmesiDerleyici
ASLRHedef adresin bilinmesiKernel
PIEProgram kodunun sabit adreste olmasıDerleyici + linker
RELROGOT üzerinden akış kaçırmaLinker
FORTIFY_SOURCEBilinen boyutta taşmaDerleyici + 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?

8. 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

Burada bir soru asılı kalıyor. Kitap boyunca hep tek bir process’i takip ettik: dosyadan doğdu, belleğe yerleşti, korundu. Peki o ilk process nereden geldi? Bilgisayarını açtığında birinci process’i kim başlattı ve o process kendinden sonrakileri nasıl doğurdu? Sıradaki bölümün konusu bu.

16. bölüme devam et: Fork'lar ve COW'lar Hakkında Konuşalım