7 puan yazan GN⁺ 2025-07-25 | 1 yorum | WhatsApp'ta paylaş
  • Bellek güvenliği ve thread güvenliği birbirinden ayrılabilecek kavramlar değildir; thread güvenliği yoksa gerçek anlamda bellek güvenliğine ulaşılamaz
  • Go gibi thread-safe olmayan dillerde, yalnızca thread kaynaklı sorunlar bile bellek güvenliğini bozabilir
  • Java gibi bazı diller, eşzamanlılık bellek modeli sayesinde veri yarışlarını bile tanımlı davranış olarak ele alıp dil düzeyinde güvenlik sağlar
  • Go, data race durumlarına karşı kırılgandır ve gerçek bellek güvenliği ihlali örnekleri vardır
  • Asıl önemli olan özellik, Undefined Behavior (tanımsız davranış) bulunmamasıdır

Thread güvenliği olmadan bellek güvenliği garanti edilemez

Kavram karmaşası: bellek güvenliği vs thread güvenliği

  • Son dönemde bellek güvenliği büyük ilgi görüyor, ancak bunun pratikte tam olarak ne anlama geldiği net biçimde tanımlanmış değil
  • Geleneksel olarak bellek güvenliği, use-after-free veya out-of-bounds bellek erişimini engelleyen dilleri ifade eder
  • Buna karşılık thread güvenliği, eşzamanlılık hataları olmayan programları ifade eder ve bu iki kavram çoğu zaman ayrı ele alınır
  • Yazar, bu ayrımın pratikte pek faydalı olmadığını savunuyor ve aslında istediğimiz şeyin Undefined Behavior (UB) yokluğu olduğunu vurguluyor

Data race nedeniyle bellek güvenliği ihlali: Go örneği

  • Bellek güvenliği ile thread güvenliğinin ayrı ele alınmasının sorunlarını göstermek için Go dili örneği veriliyor
  • Go, bellek güvenli bir dil olarak sınıflandırılsa da, aşağıdaki gibi bir programda yalnızca veri yarışı yüzünden bile bellek hatası oluşabiliyor
globalVar를 반복적으로 다른 타입 값(Int, Ptr)으로 변경하면서 동시에 별도 고루틴에서 이를 읽어 메서드를 호출
  • İki thread çakıştığında globalVar içindeki iki dahili işaretçi (veri, vtable) ayrı ayrı güncellendiği için, arada okuma yapılırsa karışık bir durum oluşuyor ve hatalı bellek erişimi ortaya çıkıyor
  • Sonuç olarak yanlış bir adrese (ör. 0x2a; onaltılık 42) erişilmeye çalışılıyor ve program hata vererek sonlanıyor
  • Benzer durum Go'nun interface, slice gibi yapılarında da görülüyor; çünkü birden fazla alan atomik olarak güncellenmiyor

Diğer dillerde eşzamanlılık yaklaşımı ve bellek güvenliği

  • Java gibi başka dillerde de veri yarışı ihtimali vardır, ancak tanımlı bir eşzamanlılık bellek modeli uygulanarak programın dilin kendisini bozması engellenir
    • Örnek: Java, çok thread'li ortamlarda bile çalışma zamanının çökmesine (ör. zorunlu segmentasyon hatası) yol açmamak için bellek modelini dikkatle tasarlamıştır
  • Çoğu dil, eşzamanlılık sorunlarını aşağıdaki iki yaklaşımdan biriyle kontrol eder
    • Tüm eşzamanlı programların tutarlı davranmasını garanti eden bir bellek modeli tanımlar (bunun karşılığında derleyici optimizasyonları kısıtlanır ve uygulama yükü artar)
      • Java, C#, OCaml, JavaScript, WebAssembly vb.
    • Güçlü bir tip sistemiyle veri yarışlarının çoğunu yasaklar, az sayıdaki istisnayı ise güvenli biçimde işler (Rust, Swift'in strict concurrency modeli)
  • Go bu iki seçeneğin hiçbirini tam olarak izlemez
    • Bellek güvenliğini yalnızca veri yarışı olmayan durumlarda garanti eder
    • Veri yarışı tespit araçları vardır, ancak gerçek programlarda tüm durumları testlerle doğrulamanın sınırları bulunur
    • Araştırma sonuçları ve saha deneyimleri, çok sayıda gerçek bellek güvenliği ihlali örneği bildirildiğini gösterir

Go'nun bellek modeli ve dokümantasyon sorunları

  • Go bellek modeline ilişkin resmi doküman, çoğu yarışın sonuçlarının sınırlı olduğunu söylese de, bazı veri yarışlarının sonuçlarının sınırsız olabileceğini açıkça anlatmaz
  • Java/JavaScript'e benzediği yönündeki iddialar da vardır, ancak bu iki dil, Go'ya kıyasla eşzamanlılık güvenliğini sağlamak için çok daha fazla çaba harcar
  • Dokümanın yalnızca bazı ayrıntılı bölümlerinde, kimi veri yarışlarının tam anlamıyla tanımsız davranışa yol açabileceğine sınırlı biçimde değinilir

Sonuç: Asıl hedef Undefined Behavior (UB) yokluğu

  • Pratikte kullanıcıların gerçekten istediği özellik, **programın dilin kendisini bozmaması (UB yokluğu)**dır
  • Bellek güvenliği ihlalinden doğan çeşitli güvenlik açıkları, UB gerçekten gerçekleştiği için ortaya çıkar
  • UB ortaya çıktığı anda sonrasındaki tüm davranışlar öngörülemez hale gelir ve saldırganlar bunu kötüye kullanabilir
  • 'Güvenli' ve 'güvensiz' dilleri ayıran temel fark, UB oluşma ihtimalidir
  • Bellek güvenliği, thread güvenliği, tip güvenliği gibi ayrıntılı sınıflandırmalardan daha önemli olan şey, UB oluşup oluşmamasıdır
  • Gerçekte güvenliğin de bir spektrumu vardır; Go, C'den daha güvenlidir ama tam güvenlik garantisi vermez
  • Veriye dayanarak Go'daki gerçek güvenliği 'kanıtlamak' çok zordur; önemli olan, her dilin yaptığı tercihlerin sezgisel olmayan sonuçlarını doğru anlamaktır

1 yorum

 
GN⁺ 2025-07-25
Hacker News yorumu
  • Dropbox ekibimde yaşanan bir olaydı; Go sunucusunda veri yapısına senkronizasyon olmadan yazı yazıldığında, yeni katılan mühendisin tekrar tekrar segfault üretmesi adeta bir geçiş ritüeliydi
    Swift'te de aynı sorun var; Swift'in paylaşılan veri yapılarına erişirken çok rahat segfault üretebildiğini gösteren bir program yazmıştım
    Go'nun Rust ya da Java gibi memory-safe olduğunu söylemek biraz abartı
  • Swift bu sorunu çözmeye çalışıyor, ancak gerçek dünyada zaten çok fazla güvensiz kod bulunduğundan değişim çok yavaş ve sancılı ilerliyor
  • Merak ettiğim bir nokta var: map gibi temel veri yapıları genelde thread-safe değil, bu yüzden değiştirirken dikkat edilmesi gerektiği Go spesifikasyonunda da açıkça yazıyor
    Dropbox'ta yaşanan bu sorunun detaylarını duymak isterim
  • Burada sözü edilen “Rust ya da Java anlamında memory safety” ifadesinin, terimin katı teknik tanımı olmadığını vurgulamak isterim
    Memory safety, PLT(programlama dili teorisi) kavramından çok yazılım güvenliği terimidir
    Sonuç olarak Go programcıları da bu farkı gayet iyi biliyor; bu yüzden Go, “paylaşarak iletişim kurma, iletişim kurarak paylaş” yaklaşımını temel bir öncül olarak alıyor
    Elbette pratikte bu kavram yeterince hayata geçmedi ve Go'da da modern yazılımda paylaşımın çok olduğu, dolayısıyla senkronizasyon gerektiği herkesçe anlaşılmış durumda
  • Perspektif kazanmak için, Go'da memory-safe olmayan bozulmuş durumların ne kadar sık görüldüğünü ya da bir Go programının pratikte memory-safe olmama ihtimalinin ne kadar olduğunu sormak isterim
  • Java da Rust'ın kastettiği anlamda memory-safe değil
  • Bu mesele bazen Rust'ın soundness hole sorununa benzer şekilde tekrar tekrar gündeme geliyor; gereksiz bir mesele kesinlikle değil ama rastlama olasılığı epey düşük
    Gerçekten de yıllarca Go çalıştırmış biri olarak bu tür bug'ların pratikte çok nadir ortaya çıktığını düşünüyorum
    Uber, Go kodunda ortaya çıkan bug'ları ayrıntılı biçimde derlemişti; bu yazıda sorunun gerçekte ne kadar sık yaşandığını tabloyla göstermişler
    Go'da eşzamanlı map ya da slice erişim sorunlarının çoğu aynı slice üzerinde yaşanıyor ve “torn read” oluşması gerektiğinden pratikte çok yaygın değil
    Buna rağmen insanların bu tür sorunlardan çoğunlukla kaçınabilmesinin sebebi muhtemelen yeterince dikkatli olmaları ve değişkenleri eşzamanlı erişim altında yeniden atamanın riskini iyi bilmeleri
    Dilin içinde atomics, channel ve mutex var; bu yüzden pratikte eşzamanlı erişimde yanlış kullanım çok sık görülmüyor ve race detector da olduğu için böyle sorunlar varsa çabuk bulunabiliyor
    Performans kaybı olsa bile, torn read sorunlarının genelde kolayca düzeltilebildiğini düşünüyorum; üretimde çalışan Go kodunda büyük bir problem olmadı
    İlgili video
  • Go'da bir data race bug'ını yakalamam aylar sürdü
    Race detector da hiçbir şey bulamadı, kimse ne olduğunu anlayamadı
    Sonunda döngü sayacı overflow yapıyormuş; aynı hesabı aşırı sayıda tekrar ediyor ve istekler bazen 100 ms yerine 3 dakika sürüyordu
    Sorunu production ortamında perf kullanarak dolaylı biçimde fark ettik; platform geliştiricisi olarak hata ayıklama deneyimim ekibe çok yardımcı oldu
    Çok farklı Go race durumlarına maruz kaldığım için, şahsen Rust'ın her yerde kullanılmasını isterdim
  • Rust bakımcıları da soundness hole'ları bug olarak kabul ediyor
    Örneğin şu issue, derleyicide büyük bir refactor gerektirdiği için uzun sürüyor
  • Uber, Go programlarının Java mikroservislerine kıyasla “8 kat daha fazla concurrency açığa çıkardığını” söylüyor; burada concurrency'yi sayılabilir bir isim gibi kullanmaları tam olarak ne anlama geliyor merak ediyorum
  • Zig de memory-safe olduğunu iddia ediyor ama Rust'taki Send/Sync türlerine benzer bir kavramı yok
    Pratikte henüz çok fazla eşzamanlı Zig kodu olmadığından bu sorunlar fazla öne çıkmadı, ancak ileride async özellikleri daha yaygın kullanılınca birçok sorunun aynı anda patlak verebileceğini düşünüyorum
  • ReleaseSafe ile derlenmiş tek thread'li bir Zig programı bile, örneğin ömrü bitmiş bir yerel değişkenin pointer'ını dereference ettiğinde, tüm optimizasyon modlarında memory corruption riskinden tamamen muaf değil
  • Zig'in memory safety iddiası şakaya yakın
    Elbette C'ye göre daha az bug oluyor ama bu C++ için de geçerli ve kimse C++'ın memory-safe olduğunu söylemiyor
  • Gerçek kodda, kötü niyetli tasarlanmış değilse, data race kaynaklı zafiyeti olan bir Go kodu hiç görmedim
    Tabii bu, riskin tamamen sıfır olduğu anlamına gelmiyor ama Go uygulamalarının güvenliği açısından bunun öncelikli bir mesele olmayabileceğine işaret ediyor
    Buna karşılık C/C++ kodlarında gerçek dünya zafiyetlerinin %60-75'i memory safety sorunlarından kaynaklanıyor
    Memory safety de bir spektrum; belli bir seviyeden sonra getirisi azalıyor diye düşünüyorum
  • Data race yüzünden zafiyetli Go kodu gördüğüm oldu
  • Bakım maliyetinin CVE'lerden çok daha büyük bir acı olduğunu düşünmeye başladım
    Exploit edilemeyen bir bug bile sonuçta düzeltilmek zorunda
    İlk geliştirmeden çok daha fazla zaman bakımda geçtiği için, bakım yükünü azaltabiliyorsa ilk çıkışın gecikmesine bile değer diye düşünüyorum
  • Memory safety'nin önemli olmasının sebebi, C programlarındaki CVE'lerin çoğunun memory safety bug'larından çıkması
    Buna karşılık Go'da thread safety, CVE'lerin başlıca kaynağı değil
    Teorik olarak bir dayanağı var ama pratikte çok öne çıkmıyor
  • Asıl önemli olan, thread'lerde ne yapılabildiği
    Bellek paylaşıldığında, veri yapısını bozarsanız diğer thread'lerde güvensiz ya da hatalı davranış oluşabilir
    Örneğin bir thread vektörün boyutunu değiştirirken başka bir thread erişirse, sıralı çalışmada güvenli olan bir işlem bile eşzamanlılık altında riskli hale gelir
    Go da bundan muaf değil
  • C'nin tipik memory safety sorunları genelde RCE'ye (uzaktan kod çalıştırma) dönüşebilir
    Buna karşılık bir thread safety sorunu segfault ile bitiyorsa, bu yalnızca basit bir DoS (hizmet reddi) saldırısı anlamına gelebilir
    Race condition daha güçlü saldırılara da dönüşebilir ama tetiklenmesi çok daha zordur
  • CVE'ler daha kritik olsa da, threading bug'larından kaynaklanan data corruption/crash sonuçta birilerinin triage etmesi, analiz etmesi ve düzeltmesi gereken bug'lardır
  • Thread kullanan dillerin çoğunun global değişkenler ve sınırsız paylaşımlı bellek erişimini varsayılan olarak sunması üzücü bir gerçek
    Data corruption ve race'lerin başlıca sebebi bu
    Pek çok durumda process tabanlı model, thread tabanlı modelden daha iyi bir concurrency modeli olabilir ama fazla ağır olma dezavantajı var
    Her thread'e gereken verilerin varsayılan olarak message passing ile aktarılması yaklaşımı benimsenseydi, bu sorunların çoğu ortadan kalkardı diye düşünüyorum
    Ne olursa olsun, platform bize global değişken ve paylaşımlı bellek kullanma özgürlüğü veriyor; istemiyorsak kullanmayız
  • Rust, thread safety'yi tür sistemine yerleştirebilen modern dillerin en belirgin örneği
    Rust'ın asıl hedefi memory-safe sistem dili olmak değil, thread-safe sistem dili olmaktı; memory safety bunun doğal sonucu olarak geldi
    Rust'ta thread::scope gibi araçlarla yapılandırılmış eşzamanlılık kullanılabildiği için thread işleri çok rahat
  • Message passing, bellek paylaşımına göre daha fazla mantıksal sorun (race condition/deadlock vb.) üretebilir; yani sihirli bir çözüm değil
  • Go'da doğrudan bellek paylaşmaktan ziyade, goroutine'ler arası iletişim (channel vb.) daha çok teşvik edilir
    Şu belgeye bakılabilir
  • Go'da channel ile goroutine'ler arasında nesne aktarsanız bile, sendable türler, sahiplik ya da salt okunur referanslar gibi kavramlar olmadığından bunu güvenli biçimde yapmak kolay değil
    Gerçek bir örnek:
    func processData(lines <-chan []byte) {
     for line := range lines {
      fmt.Printf("processing line: %v\n", line)
     }
    }
    
    func main() {
     lines := make(chan []byte)
     go processData(lines)
    
     var buf bytes.Buffer
     for range 3 {
      buf.WriteString("mock data, assume this got read into the buffer from a file or something")
      lines <- buf.Bytes()
      buf.Reset()
     }
    }
    
    Bu kodda buf.Bytes(), iç belleğe doğrudan referans vererek aktarılıyor ve Reset() çağrısıyla backing memory yeniden kullanıldığı için processData ve main aynı belleğe aynı anda erişiyor; böylece data race oluşuyor
    Rust'ta bu tür kod, iki mutable reference anlamına geldiğinden derlenmez bile; sahipliğin taşınmasını ya da kopyalanmasını zorunlu kılar
    Go'da bu kolayca kafa karıştırabiliyor; bytes.Buffer.ReadBytes("\n") ya da .String() kopya döndürdüğü için güvenli, ama .Bytes() bu örnekteki gibi riskli
    Rust'ın channel'ları bu sorunu sahiplik/aktarım kavramlarıyla kökten engellerken Go'da böyle bir güvenlik mekanizması yok
    Sonuç olarak mutex'ten daha yavaş ve Go'ya yeni başlayanlar için doğru kullanımı daha da zor bir deneyim yaratıyor gibi görünüyor
  • Gerçek golang programlarında “paylaşarak iletişim kurma” deseni çok sayıda mantıksal probleme yol açıyor ve sonuçta bellek paylaşımı yaygın hale geliyor
    Yani “güvenli” race'ler ya da “güvenli” deadlock'lar aslında daha sık görülüyor
  • Eşzamanlılık bug'ları tartışması, çoğu uygulamada gerçekten önemli olan bug'ların büyük bölümünün veritabanı içinde lock, transaction ve transaction isolation'ın yanlış uygulanmasından kaynaklandığı gerçeğini görmezden gelme eğiliminde
    PL teorisinde Rust'ın race-freedom yaklaşımı çekici olabilir, ama gerçek uygulamalarda önemli verilerin tamamı zaten RDBMS içindedir ve örneğin SELECT ile FOR UPDATE kullanılmazsa race her türlü ortaya çıkabilir
    Rust uygulaması hiç unsafe kullanmasa bile, veritabanına bağlı olarak race'ler yine var olur
  • “Memory safety” terimi başlangıçta karmaşık bir kavramı açıklamak için ortaya çıktı ama zamanla anlamı genişledi ya da daraldı
    Go'nun neredeyse hiç memory corruption bug'ına izin vermeyen bir yapıda olduğunu, gerçek exploit yokluğundan biliyoruz
    Bu yazıdaki iddiayı kabul edersek, çoğu high-level dil de (yazıda Java hariç tutuluyor) memory-safe sayılmayacaktır
    Rust, Go'dan “daha” güvenli olabilir; ama “memory safety” süreklilik gösteren bir spektrum değil, geçer/kalır türü bir kavramdır
    Bir dilin memory-unsafe olduğunu iddia ediyorsanız, bunun için mutlaka bir POC göstermelisiniz
  • Memory safety teriminde kilit nokta “type confusion” ise, Go burada da istisna değil
    Yazıdaki örnek, bir int'in yanlışlıkla pointer sanılmasıyla memory corruption'ın kolayca oluşabileceğini gösteriyor
    Demoda kasıtlı olarak 42 kullanıldığı için segfault oluyor, ama gerçek bir adres kullanılsaydı gerçekten corruption oluşurdu
  • Data race'ler, programı dil spesifikasyonunun tanımlamadığı bir duruma (örneğin SIGSEGV ile zorla sonlanma) sokabildiğinden memory safety ihlalidir
    Bu nedenle data race oluşabilen bir dil, memory-safe sayılamaz
  • Yazıda örneklenen şekilde, type confusion yoluyla bir fat pointer üzerinde torn read ya da bir slice üzerinde torn read sonucu out-of-bounds write üretmek gerçekten mümkün
    Böyle durumlarda buna memory-safe denip denemeyeceği ciddi biçimde sorgulanmalı
  • Terimlerin gelişip anlam değiştirmesi matematik ve fizikte de sık görülür
    Bu sorunları önlemek için bazen “Gaussian Curvature” ya da “Riemann Integrals” gibi kişi adı kullanılır
    “İlk anlamı dar biçimde korunup daha geniş anlama yayılma” durumu ise “Galois Group” örneğinde olduğu gibi görülebilir
    Memory safety de bundan farklı değil
  • Yazarın tanımına göre Java'nın neden memory-safe olmadığını merak ediyorum
    Somut örnek istiyorum
  • Go'nun kendisi de memory safety tanımında resmî olarak net değil
    FAQ'deki memory safety ifadesi ya da unions ile ilgili cevaplar, memory-safe olduğuna ima ediyor ama bunun tam olarak ne anlama geldiği açık değil
    Rob Pike'ın 2012 sunumunda “Not purely memory safe” dediği görülüyor ama purely sözcüğünün bile tanımı yok
    Go'nun race detector belgelerinde de “safe”in tanımı belirsiz (örnek belge)
    Dışarıda ise Go'nun “memory-safe programming language” olduğu çok daha güçlü şekilde sıkça iddia ediliyor
    Örneğin fly.io'nun güvenlik belgesi ya da memorysafety.org'un Go'yu memory safe sınıfına koyan belgesi buna örnek
    Ancak aynı belgelerde “Out of Bounds Reads and Writes” da memory safety sorunu olarak anlatılıyor ve yazıda işaret edilen Go hatası tam da bu kapsama giriyor
    En azından Go ve topluluğunun “memory safety”nin tam olarak ne anlama geldiğini daha açık ortaya koyması gerektiğini düşünüyorum
    Böyle örnekler varken, Go'yu açıklama yapmadan memory-safe bir dil diye nitelememek daha doğru olur
  • Memory safety tanımı da zamanla biraz değişti
    Go'nun tasarlandığı dönemde “garbage collector varsa memory-safe'tir” bakışı daha yaygındı ve C/C++ ile kıyaslandığında Go çok daha güvenliydi