- 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
Hacker News yorumu
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ı
mapgibi 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ıyorDropbox'ta yaşanan bu sorunun detaylarını duymak 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
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ı
mapya dasliceerişim sorunlarının çoğu aynı slice üzerinde yaşanıyor ve “torn read” oluşması gerektiğinden pratikte çok yaygın değilBuna 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,channelvemutexvar; bu yüzden pratikte eşzamanlı erişimde yanlış kullanım çok sık görülmüyor verace detectorda olduğu için böyle sorunlar varsa çabuk bulunabiliyorPerformans 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
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
perfkullanarak 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
Örneğin şu issue, derleyicide büyük bir refactor gerektirdiği için uzun sürüyor
Send/Synctürlerine benzer bir kavramı yokPratikte 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üyorumReleaseSafeile 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ğilElbette 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
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
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
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
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
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
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'ı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::scopegibi araçlarla yapılandırılmış eşzamanlılık kullanılabildiği için thread işleri çok rahatgoroutine'ler arası iletişim (channelvb.) daha çok teşvik edilirŞu belgeye bakılabilir
channelilegoroutine'ler arasında nesne aktarsanız bile,sendabletürler, sahiplik ya da salt okunur referanslar gibi kavramlar olmadığından bunu güvenli biçimde yapmak kolay değilGerçek bir örnek: Bu kodda
buf.Bytes(), iç belleğe doğrudan referans vererek aktarılıyor veReset()çağrısıyla backing memory yeniden kullanıldığı içinprocessDatavemainaynı belleğe aynı anda erişiyor; böylece data race oluşuyorRust'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 riskliRust'ı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üyorgolangprogramları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 geliyorYani “güvenli” race'ler ya da “güvenli” deadlock'lar aslında daha sık görülüyor
PL teorisinde Rust'ın race-freedom yaklaşımı çekici olabilir, ama gerçek uygulamalarda önemli verilerin tamamı zaten RDBMS içindedir ve örneğin
SELECTileFOR UPDATEkullanılmazsa race her türlü ortaya çıkabilirRust uygulaması hiç
unsafekullanmasa bile, veritabanına bağlı olarak race'ler yine var olurGo'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
Yazıdaki örnek, bir
int'in yanlışlıkla pointer sanılmasıyla memory corruption'ın kolayca oluşabileceğini gösteriyorDemoda kasıtlı olarak
42kullanıldığı için segfault oluyor, ama gerçek bir adres kullanılsaydı gerçekten corruption oluşurduSIGSEGVile zorla sonlanma) sokabildiğinden memory safety ihlalidirBu nedenle data race oluşabilen bir dil, memory-safe sayılamaz
Böyle durumlarda buna memory-safe denip denemeyeceği ciddi biçimde sorgulanmalı
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
Somut örnek istiyorum
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
purelysözcüğünün bile tanımı yokGo'nun
race detectorbelgelerinde 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
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