- Jika angka CVE Rust dan C/C++ dibandingkan begitu saja, perbedaan standar dalam memandang kerentanan keamanan memori sebagai “masalah library” mudah terlewat
- Di C/C++, meski panggilan API yang salah bisa menyebabkan UB atau segfault, hal itu biasanya diperlakukan sebagai penyalahgunaan kode pengguna, dan tidak semua kemungkinannya didaftarkan sebagai CVE
- Pemanggilan
curl_getenv(NULL) pada libcurl bisa dibangun tanpa peringatan dan saat dijalankan dapat memicu segfault, tetapi biasanya tidak dianggap sebagai kerentanan curl
- Di Rust, jika kode pengguna tidak memiliki
unsafe namun bug memori muncul hanya lewat pemanggilan API aman, itu dianggap sebagai soundness bug pada library
- Karena itu, sebagian CVE di Rust dicatat dengan standar yang lebih ketat daripada di C/C++, sehingga sulit menilai keamanan memori hanya dari perbandingan jumlah CVE mentah
Mengapa perbandingan angka CVE bisa menyesatkan
- CVE adalah basis data untuk mengklasifikasikan dan melaporkan kerentanan keamanan perangkat lunak
- Kerentanan bisa muncul dari bug logika program biasa, atau dari masalah keamanan memori yang lebih mudah berkembang menjadi eksploitasi
- Saat membandingkan jumlah CVE Rust dan C/C++, muncul juga klaim bahwa Rust “sebenarnya tidak aman secara memori” atau “tidak layak diadopsi”
- Namun, ada perbedaan besar dalam cara dua ekosistem ini menangani potensi kerentanan yang terkait dengan keamanan memori
Kerentanan tetap mungkin terjadi di Rust
- Program Rust juga bisa memicu UB dan bug keamanan memori
- Dalam sebagian besar kasus, masalah semacam ini memerlukan kata kunci
unsafe
- Klaim bahwa program Rust sama sekali tidak mungkin mengalami UB adalah keliru
- Kerentanan umum yang tidak terkait keamanan memori juga bisa terjadi di Rust
- Misalnya, lupa memeriksa hak akses ke dashboard admin adalah masalah yang bisa terjadi di bahasa apa pun
Contoh library C: curl_getenv(NULL)
curl adalah library jaringan berbasis C yang banyak dipakai dan dikelola dengan baik
curl_getenv di libcurl adalah fungsi abstraksi portabel untuk mengambil nilai variabel lingkungan di berbagai sistem operasi
- Program C berikut meneruskan pointer
NULL ke curl_getenv
#include <curl/curl.h>
int main(void) {
curl_getenv(NULL);
}
- Program ini bisa dikompilasi dengan
gcc test.c -otest -lcurl -Wall -Wextra tanpa peringatan
- Saat dijalankan, program ini bisa mengalami segfault, dan itu dapat dipandang sebagai bug keamanan memori sekaligus potensi kerentanan
- Namun, contoh seperti ini biasanya tidak dianggap sebagai kerentanan pada
curl
Di C/C++, kemungkinan penyalahgunaan saja tidak dijadikan CVE
- Kasus bermasalah seperti
curl_getenv(NULL) umumnya dianggap sebagai penggunaan API yang keliru
- Lokasi cacatnya juga biasanya dianggap berada di kode aplikasi, bukan pada library atau API
- Ada dua alasan untuk praktik ini
- Sistem tipe C yang terbatas menyulitkan ekspresi kontrak API, invarian, prasyarat, dan pascakondisi secara presisi
- Mendokumentasikan semua kemungkinan penggunaan yang salah juga tidak praktis
- Bahkan, dokumentasi
curl_getenv tidak menyatakan bahwa pemanggilan dengan NULL dilarang dan bisa berujung segfault
- Di C/C++, UB sangat mudah terpicu tanpa sengaja, sehingga jika semua potensi kerentanan dilaporkan sebagai CVE, sebagian besar library bisa dibanjiri CVE dalam jumlah sangat besar
- Karena itu, di C/C++ CVE biasanya dibuat berdasarkan contoh penyalahgunaan tertentu, bukan sekadar “adanya API yang bisa disalahgunakan”
Di Rust, batas tanggung jawab API aman berbeda
- Di Rust, jika program mengalami segfault hanya lewat pemanggilan aman seperti
hyper::foo(None), itu bisa menjadi CVE untuk hyper
- Jika bug memori terjadi sementara program pengguna tidak memiliki blok
unsafe, berarti harus ada soundness bug pada library tersebut
- Di Rust, jika bug memori bisa terjadi melalui cara apa pun dalam menggunakan API library yang aman, itu dianggap sebagai bug library, bukan kode pengguna
- API semacam itu disebut unsound atau memiliki soundness hole
- Bahkan jika belum ada masalah yang ditemukan di program nyata, CVE bisa dibuat bila penggunaan API aman saja sudah cukup untuk memicu bug memori
safe dan unsafe memperjelas tanggung jawab
- Di Rust, jawaban atas pertanyaan “apakah fungsi ini dipakai dengan benar dari sudut pandang keamanan memori” lebih jelas daripada di C/C++
- Jika fungsi yang dipanggil tidak ditandai
unsafe, maka fungsi itu seharusnya aman digunakan
- Jika fungsi yang dipanggil adalah
unsafe, maka titik pemanggilannya memerlukan blok unsafe, sehingga titik berisiko menjadi jelas dalam code review maupun codebase
- Pemisahan ini adalah salah satu faktor yang membuat keamanan memori Rust bisa diskalakan secara praktis dalam pekerjaan nyata
- Jika kode pengguna tidak memakai
unsafe dan tidak ada bug compiler, sulit menyalahkan kode pengguna sebagai sumber potensial masalah keamanan memori
- Jika library tidak mengekspos antarmuka
unsafe, pengguna seharusnya tidak bisa memakai library itu dengan cara yang memicu bug memori
- Bahkan jika library menggunakan
unsafe secara internal lalu memunculkan bug, perbaikannya dilakukan di dalam library, dan pengguna kembali aman dari bug memori
Sulit membandingkan keamanan memori hanya dari jumlah CVE mentah
- Jika logika yang sama diterapkan ke C, maka
curl_getenv juga harus ditandai sebagai CVE pada curl, tetapi C tidak memiliki pembedaan seperti safe dan unsafe di Rust
- Pada praktiknya, hampir semua kode C secara implisit lebih dekat ke
unsafe, sehingga standar ala Rust sulit diterapkan begitu saja
- Bahkan jika pengembang library C/C++ membuat library yang aman dan tangguh, banyak program C yang memakainya tetap bisa dengan mudah menimbulkan masalah keamanan memori melalui penanganan API yang keliru
- Perbedaan ini berlaku bukan hanya untuk
curl, tetapi juga hampir semua library C/C++ serta standard library kedua bahasa tersebut
- Karena itu, perbandingan angka mentah seperti jumlah CVE per baris kode antara Rust dan C/C++ bisa menyesatkan saat dipakai untuk menilai keamanan memori
1 komentar
Opini di Lobste.rs
Mungkin ini pertanyaan naif, tetapi jika banyak masalah di C/C++ berasal dari perilaku tak terdefinisi, kenapa tidak langsung didefinisikan saja
Pertama, ada hal-hal yang sekadar sisa sejarah yang kini sudah tidak dipedulikan siapa pun, sehingga bisa saja “langsung didefinisikan”, dan seperti kata @fanf, pekerjaan ke arah itu sedang berlangsung. Misalnya, berkas sumber yang berisi literal string yang tidak diakhiri sebenarnya adalah perilaku tak terdefinisi di C.
Kedua, ada hal-hal yang bisa didefinisikan tetapi menimbulkan biaya performa. Contoh utamanya adalah signed integer overflow; jika cukup didefinisikan sebagai wraparound, itu memang tak lagi menjadi perilaku tak terdefinisi, tetapi compiler juga jadi tidak bisa melakukan optimisasi yang bergantung pada asumsi bahwa hal itu “tidak mungkin terjadi”. Banyak orang compiler ada di komite, dan mereka cenderung terobsesi pada benchmark, jadi sepertinya ini tidak akan mudah diperbaiki. Meski begitu, bukan berarti tidak ada perubahan sama sekali; misalnya, P2723 mengusulkan agar di C++ semua variabel lokal yang tadinya tidak terinisialisasi diinisialisasi implisit ke 0.
Ketiga, ada hal-hal yang sulit didefinisikan secara masuk akal. Contoh yang bagus adalah use-after-free. Kecuali kita memaksa semua orang memakai sistem capability runtime berat seperti Fil-C, atau menambahkan anotasi lifetime ala Rust ke seluruh bahasa, sulit membatasi rentang perilaku yang bisa muncul dari use-after-free. Kita memang bisa menulis “jika terjadi use-after-free maka memori di lokasi itu saat itu akan disentuh, atau akan segfault/abort”, tetapi itu tidak membantu siapa pun. Tetap berbahaya, CVE yang sama tetap muncul, dan kita tetap tidak bisa mengatakan secara bermakna apa yang boleh dan tidak boleh dilakukan program setelahnya, jadi pada dasarnya hanya perilaku tak terdefinisi dengan nama lain.
Sayangnya, kategori ketiga inilah yang dampaknya jauh paling besar, jadi meskipun bagus jika sebagian kasus “sekarang didefinisikan saja”, itu tidak akan banyak mengubah gambaran besar
Setahu saya, pustakanya sendiri sebagian besar belum benar-benar mulai dibahas, tetapi fungsi-fungsi yang menerima argumen ukuran telah diubah agar berperilaku masuk akal terhadap null pointer. Ini terkait dengan perubahan bahasa yang mengizinkan penambahan 0 ke null pointer. Ada banyak fungsi lain yang bisa diperbaiki dengan cara serupa, tetapi untuk perubahan
getenv()sebaiknya dikoordinasikan dengan POSIXKeuntungan performa seperti itu hampir semuanya sangat spesifik dan paling banter kecil. Jika ada fungsi yang memanggil
rm -rf /tetapi sebenarnya tidak pernah dipanggil, lalu Anda membuat pemanggilan function pointer dengan perilaku tak terdefinisi, compiler secara teknis bahkan diperbolehkan menghasilkan kode yang selalu memanggil fungsi penghapus disk itu. Pada akhirnya ini cuma desain spesifikasi yang buruk dan warisan masa lalufor (int ii = 0; ii < something; ii++)bergantung pada fakta bahwa signed integer overflow tidak terdefinisi, sehingga kemungkinansomething == INT_MAXbisa diabaikan, dan dari situ berbagai transformasi loop menjadi mungkin.Di Rust, kemampuan yang setara dipisahkan antara fungsi aman dan fungsi
unsafe. Fungsi aman mungkin sedikit lebih lambat, sementara fungsiunsafejika dipakai salah dapat mengizinkan perilaku tak terdefinisi. Lihati32::wrapping_add()dani32::unchecked_add().Jika C punya cara untuk menandai suatu fungsi sebagai
unsafedan menambahkan notasi untuk mengizinkan penggunaan fungsiunsafedi area tertentu, kita bisa mulai mendefinisikan varian yang aman. Tetapi pada titik tertentu, usaha mengubah C—dan yang lebih penting, mengubah pola pikir orang-orang yang mengendalikan C—menjadi tidak sebanding dengan tujuannya, dan akan lebih mudah mencari bahasa yang lebih cocok dengan tujuan ituDi C, jika pointer ke objek heap diberikan ke
freelalu objek itu diakses, itu adalah perilaku tak terdefinisi. Di CHERIoT, kasus ini didefinisikan menimbulkan trap, tetapi itu hanya mungkin karena kami membuat perangkat keras yang memungkinkan hal tersebut. Standar harus mendukung beragam perangkat keras, sehingga muncul pertanyaan: harus didefinisikan menjadi apa?Kira-kira ada dua pendekatan. Salah satunya adalah menunda pembebasan, dan menyatakan bahwa objek tidak benar-benar hilang sampai semua pointer yang menunjuk ke objek tersebut lenyap. Ini menuntut sesuatu yang mirip garbage collector, dan overhead-nya terlalu besar untuk banyak penggunaan C. Pendekatan lain adalah mendefinisikan sistem tipe yang mengetahui lokasi semua pointer ke objek itu dan dapat membatalkannya. Rust memilih pendekatan kedua, itulah sebabnya implementasi struktur data yang bukan pohon di Rust memerlukan
unsafeatau fitur pustaka standar yang menggunakanunsafe. Hal seperti ini bisa dimasukkan pada tahap desain bahasa, tetapi hampir mustahil ditambahkan belakangan.Out-of-bounds juga serupa. Pada sistem CHERI, batas objek atau subobjek merupakan bagian intrinsik dari pointer, sehingga akses di luar batas akan memicu trap. Di platform lain, pointer hanyalah sebuah word yang berisi alamat. Setelah dilakukan operasi aritmetika, tidak ada cara untuk memetakannya kembali ke objek asal, jadi pertanyaannya adalah dari mana batas itu didapat. Alat seperti AddressSanitizer menyimpan batas di struktur terpisah dan mensyaratkan pemeriksaan pada aritmetika pointer, tetapi overhead memori dan performanya besar, sehingga untuk lingkungan operasional nyata, jauh lebih masuk akal memakai Java daripada C dengan ASan aktif, dan kemungkinan besar kodenya juga bisa ditulis lebih cepat
Saya kira dereferensi null pointer itu perilaku yang terdefinisi dengan baik
Ada satu bagian dalam tulisan ini yang agak mengganggu saya.
SEGFAULT adalah serangan denial-of-service, sama seperti panic.
Keduanya berada dalam kategori kesalahan yang sama, dan biasanya ketika memikirkan memory safety yang terbayang adalah stack smashing, korupsi data, modifikasi kode, dan sejenisnya. Hal-hal semacam itu jauh, jauh lebih sulit dilakukan di Rust, dan sampai tingkat tertentu juga bisa dibuat sulit di C.
Secara keseluruhan, tulisan ini lebih terlihat seperti mengatakan bahwa sistem tipe C itu buruk. Di C++, kesalahan seperti ini bisa dicegah, dan bahkan di C, dengan atribut
nonnullmilik GCC, meneruskanNULLke suatu fungsi bisa dinaikkan menjadi error compiler.Menurut saya pribadi, akses di luar batas akan menjadi contoh yang lebih baik dan lebih representatif
Panic adalah pemeriksaan keselamatan yang tertanam di program, terjadi secara andal, dan perilakunya terdefinisi jelas.
Segfault adalah hasil dari sistem operasi yang menangkap operasi memori yang salah, dan itu hanya terjadi untuk alamat di luar halaman yang ada dalam peta memori virtual program. Karena itu, banyak bug segfault bisa dimanipulasi menjadi semacam eksekusi kode arbitrer.
Dalam kasus normal hasil akhirnya memang tampak sama, tetapi secara mendasar keduanya adalah hal yang berbeda