1 poin oleh GN⁺ 4 jam lalu | 1 komentar | Bagikan ke WhatsApp
  • 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

 
GN⁺ 4 jam lalu
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

    • Menurut saya ada setidaknya tiga alasan mengapa suatu perilaku dibiarkan tak terdefinisi dalam standar.
      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
    • Dalam putaran revisi ini, komite C sedang mengurangi perilaku tak terdefinisi di bahasa tersebut. Lihat dokumen “slaying earthly demons” di https://open-std.org/jtc1/sc22/wg14/www/wg14_document_log.htm
      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 POSIX
    • Penjelasan yang paling sering diulang adalah bahwa sebagian perilaku harus dibiarkan tak terdefinisi agar optimisasi yang tadinya tidak diizinkan bisa dilakukan. Tetapi menurut saya ini kebanyakan hanya rasionalisasi diri.
      Keuntungan 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 lalu
    • Sebagian perilaku tak terdefinisi memang sudah didefinisikan seiring waktu, tetapi banyak yang harus tetap seperti itu demi optimisasi. Contoh yang terkenal, for (int ii = 0; ii < something; ii++) bergantung pada fakta bahwa signed integer overflow tidak terdefinisi, sehingga kemungkinan something == INT_MAX bisa 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 fungsi unsafe jika dipakai salah dapat mengizinkan perilaku tak terdefinisi. Lihat i32::wrapping_add() dan i32::unchecked_add().
      Jika C punya cara untuk menandai suatu fungsi sebagai unsafe dan menambahkan notasi untuk mengizinkan penggunaan fungsi unsafe di 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 itu
    • Ada contoh yang menunjukkan kenapa ini sulit.
      Di C, jika pointer ke objek heap diberikan ke free lalu 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 unsafe atau fitur pustaka standar yang menggunakan unsafe. 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

    • https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3220.pdf di halaman 4, atau halaman 18 pada penomoran PDF, tertulis seperti ini
      1. Istilah, definisi, dan simbol

      3.5.3 Perilaku tak terdefinisi

      Contoh: contoh perilaku tak terdefinisi mencakup perilaku saat melakukan dereferensi null pointer

    • Dari sudut pandang instruction set CPU mungkin benar, tetapi yang menjadi target pemrograman bukan itu, melainkan mesin abstrak C, dan mesin abstrak C menyebut ini sebagai perilaku tak terdefinisi
  • 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 nonnull milik GCC, meneruskan NULL ke suatu fungsi bisa dinaikkan menjadi error compiler.
    Menurut saya pribadi, akses di luar batas akan menjadi contoh yang lebih baik dan lebih representatif

    • Pernyataan “SEGFAULT adalah serangan denial-of-service, sama seperti panic” itu tidak tepat.
      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