- Rust ve C/C++ için CVE sayılarını doğrudan karşılaştırmak, bellek güvenliği açıklarının “kütüphane sorunu” olarak değerlendirilmesindeki ölçüt farkını gözden kaçırmaya yol açabilir
- C/C++ tarafında yanlış API çağrıları UB ya da segfault üretse bile, bunlar çoğunlukla kullanıcı kodunun hatalı kullanımı olarak ele alınır ve bu olasılıkların tamamı CVE olarak kayda geçirilmez
libcurl içindeki curl_getenv(NULL) çağrısı uyarı vermeden derlenebilir ve çalışma anında segfault üretebilir, ancak bu genelde bir curl açığı olarak görülmez
- Rust’ta kullanıcı kodunda
unsafe yokken yalnızca güvenli API çağrılarıyla bir bellek hatası oluşuyorsa, bu kütüphanedeki bir soundness bug olarak kabul edilir
- Bu yüzden Rust’taki bazı CVE’ler C/C++’a kıyasla daha katı ölçütlerle kayda geçer ve ham CVE sayıları karşılaştırması tek başına bellek güvenliğini değerlendirmek için yeterli değildir
CVE sayılarını karşılaştırmak neden yanıltıcı olabilir?
- CVE, yazılım güvenlik açıklarını sınıflandıran ve raporlayan bir veritabanıdır
- Açıklar basit program mantığı hatalarından kaynaklanabileceği gibi, istismara daha elverişli bellek güvenliği sorunlarından da doğabilir
- Rust ve C/C++ için CVE sayıları karşılaştırılarak Rust’ın “aslında bellek güvenli olmadığı” ya da “benimsenmeye değmediği” gibi iddialar da ortaya atılır
- Ancak bellek güvenliğiyle ilişkili potansiyel açıkların iki ekosistemde ele alınış biçimi arasında büyük fark vardır
Rust’ta da açıklar mümkündür
- Rust programları da UB ve bellek güvenliği hataları üretebilir
- Çoğu durumda bunun için
unsafe anahtar sözcüğü gerekir
- Rust programlarının asla UB yaşamayacağı iddiası yanlıştır
- Bellek güvenliğinden bağımsız genel güvenlik açıkları da Rust’ta mümkündür
- Yönetici paneline erişim yetkisi kontrolünü atlamak gibi bir sorun herhangi bir dilde ortaya çıkabilir
C kütüphanesi örneği: curl_getenv(NULL)
curl, yaygın kullanılan ve iyi bakımı yapılan C tabanlı bir ağ kütüphanesidir
libcurl içindeki curl_getenv, çeşitli işletim sistemlerinde ortam değişkeni değerini almak için taşınabilir bir soyutlama işlevi sunar
- Aşağıdaki C programı,
curl_getenv fonksiyonuna NULL işaretçisi geçirir
#include <curl/curl.h>
int main(void) {
curl_getenv(NULL);
}
- Bu program
gcc test.c -otest -lcurl -Wall -Wextra ile uyarı vermeden derlenebilir
- Çalıştırıldığında segfault oluşabilir; bu da bir bellek güvenliği hatası ve potansiyel güvenlik açığı olarak görülebilir
- Ancak bu tür örnekler genellikle
curl açığı olarak raporlanmaz
C/C++ dünyasında yalnızca yanlış kullanım ihtimali CVE üretmez
curl_getenv(NULL) gibi sorunlar genel olarak API’nin hatalı kullanımı olarak değerlendirilir
- Kusurun yeri de kütüphane veya API değil, uygulama kodu tarafı olarak görülür
- Bu yaklaşımın iki nedeni vardır
- C’nin sınırlı tür sistemiyle API sözleşmelerini, invariant’ları, önkoşulları ve sonkoşulları hassas biçimde ifade etmek zordur
- Olası tüm yanlış kullanımları belgelendirmek de pratik değildir
- Nitekim
curl_getenv dokümantasyonu, NULL ile çağrının yasak olduğunu ve segfault’a yol açabileceğini söylemez
- C/C++’ta UB’yi istemeden tetiklemek çok kolay olduğundan, her potansiyel açık ihtimali CVE olarak raporlansa çoğu kütüphane çok büyük sayıda CVE ile karşı karşıya kalabilir
- Bu nedenle C/C++ tarafında CVE’ler genellikle “yanlış kullanılabilir API’nin varlığı” yerine belirli yanlış kullanım örnekleri etrafında oluşturulur
Rust’ta güvenli API’nin sorumluluk sınırı farklıdır
- Rust’ta
hyper::foo(None) gibi güvenli bir çağrıyla programın segfault verdiğini varsayarsak, bu durum hyper için bir CVE sayılabilir
- Kullanıcı programında
unsafe bloğu yokken bir bellek hatası oluştuysa, bunun nedeni ilgili kütüphanedeki bir soundness bug olmalıdır
- Rust’ta güvenli bir kütüphane API’si hangi şekilde kullanılırsa kullanılsın bellek hatası üretebiliyorsa, bu kullanıcı kodu değil kütüphane hatası olarak görülür
- Böyle bir API’nin unsound olduğu ya da bir soundness hole içerdiği söylenir
- Gerçek programlarda sorun henüz görülmemiş olsa bile, yalnızca güvenli API kullanımıyla bellek hatası üretmek mümkünse bir CVE oluşturulabilir
safe ve unsafe, sorumluluğu görünür kılar
- Rust’ta “bu işlev bellek güvenliği açısından doğru şekilde kullanılıyor mu?” sorusunun yanıtı C/C++’a kıyasla daha nettir
- Çağrılan işlev
unsafe olarak işaretlenmemişse güvenli şekilde kullanılabilmelidir
- Çağrılan işlev
unsafe ise çağrı noktasında unsafe bloğu gerekir; böylece kod incelemesinde ve kod tabanında riskli noktalar açıkça görünür
- Bu ayrım, Rust’ın bellek güvenliğini pratikte ölçeklenebilir kılan unsurlardan biridir
- Kullanıcı kodu
unsafe kullanmıyorsa ve derleyicide de hata yoksa, potansiyel bellek güvenliği nedenlerini kullanıcı kodunun sorumluluğu olarak görmek zordur
- Bir kütüphane
unsafe arayüzler sunmuyorsa, kullanıcıların o kütüphaneyi bellek hatası üretecek şekilde kullanamaması gerekir
- Kütüphane içeride
unsafe kullandığı için hata üretse bile, düzeltme kütüphane içinde yapılır ve kullanıcı yeniden bellek hatalarına karşı güvenli hâle gelir
Yalnızca ham CVE sayılarıyla bellek güvenliğini karşılaştırmak zordur
- Aynı mantık C’ye uygulanırsa
curl_getenv de curl için bir CVE olarak sayılmalıdır, ancak C’de Rust’taki safe ve unsafe ayrımına benzer bir yapı yoktur
- Pratikte neredeyse tüm C kodu örtük olarak
unsafea yakındır; bu yüzden Rust’taki ölçütleri doğrudan uygulamak zordur
- C/C++ kütüphane geliştiricileri güvenli ve sağlam kütüphaneler üretse bile, bunları kullanan sayısız C programı API’leri yanlış ele alarak kolayca bellek güvenliği sorunları yaratabilir
- Bu fark yalnızca
curl için değil, neredeyse tüm C/C++ kütüphaneleri ve her iki dilin standart kütüphaneleri için de geçerlidir
- Rust ve C/C++ için kod satırı başına CVE sayısı gibi ham sayı karşılaştırmaları, bellek güvenliğini değerlendirirken yanıltıcı olabilir
1 yorum
Lobste.rs görüşleri
Safça bir soru olabilir ama, C/C++’daki pek çok sorun tanımsız davranıştan kaynaklanıyorsa neden bunlar doğrudan tanımlanmıyor diye merak ediyorum
Birincisi, artık kimsenin önemsemediği tarihsel kalıntılar olduğu için “doğrudan tanımlanabilecek” şeyler var ve @fanf’ın dediği gibi bu konuda çalışmalar sürüyor. Örneğin sonlandırılmamış string literal içeren kaynak dosyaları C’de gerçekten tanımsız davranış sayılıyor
İkincisi, tanımlanabilen ama performans maliyeti getiren şeyler var. Bunun tipik örneği signed integer overflow; örneğin sadece wrap-around olacak şekilde tanımlarsanız artık tanımsız davranış olmaz, ancak derleyici “bu asla olmaz” varsayımına dayalı optimizasyonları yapamaz. Komitede derleyici tarafında çalışan çok kişi var ve bunların benchmark’lara takıntılı olma eğilimi bulunduğundan, bunun kolay kolay düzeleceğini sanmıyorum. Yine de hiç değişim yok değil; örneğin P2723, C++’ta başlatılmamış olabilecek tüm yerel değişkenlerin örtük olarak 0 ile başlatılmasını öneriyor
Üçüncüsü, makul biçimde tanımlanması zor olan şeyler var. İyi bir örnek use-after-free. Fil-C gibi ağır bir runtime capability sistemini herkese dayatmadan ya da Rust tarzı lifetime anotasyonlarını dilin geneline eklemeden, use-after-free durumunda ortaya çıkabilecek davranış aralığını nasıl sınırlayabileceğiniz belirsiz. “Use-after-free olduğunda o anda o adreste duran belleğe dokunur ya da segfault/abort olur” diye yazabilirsiniz, ama bunun kimseye faydası olmaz. Hâlâ tehlikelidir, CVE’ler yine aynı şekilde ortaya çıkar ve sonrasında programın ne yapabileceği ya da yapamayacağı hakkında anlamlı bir şey söyleyemezsiniz; yani sadece adı değişmiş tanımsız davranış olur
Ne yazık ki etkisi ezici biçimde büyük olan üçüncü kategori olduğu için, bazı şeyleri “artık sadece tanımlamak” iyi olsa da genel tabloyu çok değiştirmiyor
Bildiğim kadarıyla henüz kütüphane tarafının çoğuna girilmedi, ama boyut parametresi alan işlevler null pointer’larla makul davranacak şekilde değiştirildi. Bunun nedeni, null pointer’a 0 eklenmesine izin veren dil değişikliğiyle bağlantılı olmasıydı. Benzer şekilde düzeltilebilecek çok işlev var, ancak
getenv()değişikliğini POSIX ile koordineli yapmak daha iyi olur gibi görünüyorBu performans kazanımlarının neredeyse tamamı çok dar kapsamlı ve olsa olsa küçüktür.
rm -rf /çağıran ama gerçekte asla çağrılmayan bir fonksiyonunuz varsa ve tanımsız davranış içeren bir function pointer çağrısı oluşturursanız, derleyicinin teknik olarak diski silen o fonksiyonu koşulsuz çağıran kod üretmesine bile izin verilir. Sonuçta bu sadece kötü bir spesifikasyon tasarımı ve mirasıdırfor (int ii = 0; ii < something; ii++)ifadesinin signed integer overflow’un tanımsız olmasına dayanaraksomething == INT_MAXolasılığını göz ardı edebilmesi ve bunun çeşitli loop transformation’lara imkân vermesiRust’ta buna denk gelen işlevsellik güvenli fonksiyonlar ve
unsafefonksiyonlar arasında bölünmüş durumda. Güvenli fonksiyonlar biraz daha yavaş olabilir;unsafefonksiyonlar ise yanlış kullanıldığında tanımsız davranışa izin verir.i32::wrapping_add()vei32::unchecked_add()buna örnekEğer C’de bazı fonksiyonları
unsafeolarak işaretleyip belirli bölgelerdeunsafefonksiyon kullanımına izin veren bir gösterim eklenebilseydi, güvenli varyantları tanımlamaya başlanabilirdi. Ama bir noktadan sonra C’yi değiştirme çabası, daha da önemlisi C’yi yöneten insanların fikrini değiştirme çabası, hedefe kıyasla orantısız hale geliyor ve bunun yerine hedefe daha uygun bir dil bulmak daha kolay oluyorC’de heap nesnesini gösteren bir pointer’ı
free’ye verdikten sonra o nesneye erişmek tanımsız davranıştır. CHERIoT bunu trap üretilecek şekilde tanımlar, ama bunu mümkün kılan donanımı inşa ettiğimiz için bunu yapabiliyoruz. Standart ise çok çeşitli donanımları desteklemek zorunda olduğu için, bunun ne olarak tanımlanacağı sorun oluyorKabaca iki yaklaşım var. Biri, serbest bırakmayı geciktirmek ve nesneyi gösteren tüm pointer’lar ortadan kalkana kadar nesnenin yok olmayacağını söylemek. Bu, garbage collector benzeri bir şey gerektirir ve C’nin pek çok kullanım alanı için taşınamayacak kadar büyük bir overhead yaratır. Diğeri ise, nesneyi gösteren tüm pointer’ların nerede olduğunu bilebilen ve bunları geçersiz kılabilen bir type system tanımlamaktır. Rust ikinci yaklaşımı seçtiği için, Rust’ta ağaç olmayan veri yapılarını uygulamak için
unsafeya daunsafekullanan standart kütüphane özellikleri gerekir. Bu tür şeyler dil tasarım aşamasında eklenebilir, ama sonradan eklemek neredeyse imkânsızdırSınır hataları da benzer. CHERI sistemlerinde nesne ya da alt nesne sınırları pointer’ın doğal bir parçasıdır, bu yüzden sınır dışı erişimler trap olur. Diğer platformlarda ise pointer sadece adres içeren bir word’dür. Aritmetik yaptıktan sonra onu tekrar özgün nesneye eşlemenin bir yolu olmadığından, sınırların nereden alınacağı sorun olur. Address sanitizer gibi araçlar sınırları ayrı bir yapıda tutar ve pointer aritmetiğinde kontrol ister, ama bellek ve performans overhead’i büyük olduğundan üretim ortamında ASan açık C kullanmak yerine Java kullanmak çok daha mantıklıdır ve muhtemelen kod da daha hızlı yazılır
Null pointer dereference’in iyi tanımlanmış bir davranış olduğunu sanıyordum
Bu yazıda takıldığım bir nokta var
SEGFAULT, panic gibi bir hizmet reddi saldırısıdır
İkisi aynı hata kategorisindedir ve bellek güvenliğiyle bağlantılı olarak insanların genelde düşündüğü şeyler stack smashing, veri bozulması, kod bozulması gibi durumlardır. Bunları Rust’ta yapmak çok ama çok daha zordur ve belli ölçüde C’de de zorlaştırılabilir
Yazının bütünü daha çok C’nin type system’ının kötü olduğuna dair bir argüman gibi göründü. C++’ta bu tür hatalar önlenebilir ve C’de de GCC’nin
nonnullniteliği kullanılarak bir fonksiyonaNULLgeçirilmesi derleyici hatasına yükseltilebilirBence out-of-bounds access daha iyi ve daha temsilî bir örnek olurdu
Panic, programa yerleşik bir güvenlik kontrolüdür, güvenilir biçimde tetiklenir ve davranışı açıkça tanımlıdır
Segfault ise yanlış bir bellek işleminin işletim sistemi tarafından yakalanmasıdır ve yalnızca programın sanal bellek haritasındaki sayfaların dışındaki adreslerde olur. Bu nedenle birçok segfault hatası bir tür keyfi kod çalıştırmaya dönüştürülebilir
Normal durumda sonuçları benzer görünebilir, ama temelde çok farklı şeylerdir