- Keamanan memori dan keamanan thread bukanlah konsep yang bisa dipisahkan, dan tanpa keamanan thread, tidak mungkin mencapai keamanan memori yang sesungguhnya
- Dalam kasus bahasa yang tidak thread-safe seperti Go, masalah thread saja sudah cukup untuk merusak keamanan memori
- Beberapa bahasa seperti Java mengamankan keselamatan pada tingkat bahasa dengan menangani bahkan data race sebagai perilaku yang terdefinisi melalui model memori konkurensi
- Go rentan terhadap data race, dan ada contoh nyata pelanggaran keamanan memori yang benar-benar terjadi
- Sifat yang benar-benar penting untuk diperhatikan adalah tidak adanya Undefined Behavior (perilaku tak terdefinisi)
Tanpa keamanan thread, keamanan memori tidak bisa dijamin
Kebingungan konsep: keamanan memori vs keamanan thread
- Belakangan ini keamanan memori mendapat sorotan besar, tetapi definisi tepatnya sering kali tidak jelas
- Secara tradisional, keamanan memori merujuk pada bahasa yang mencegah akses memori use-after-free atau out-of-bounds
- Sementara itu, keamanan thread berarti program bebas dari bug konkurensi, dan kedua konsep ini sering diperlakukan terpisah
- Penulis berpendapat bahwa pemisahan ini pada praktiknya tidak berguna, dan menekankan bahwa yang sebenarnya kita inginkan adalah tidak adanya Undefined Behavior (UB)
Pelanggaran keamanan memori akibat data race: contoh Go
- Untuk menunjukkan masalah dari memperlakukan keamanan memori dan keamanan thread secara terpisah, ditunjukkan contoh dalam bahasa Go
- Go diklasifikasikan sebagai bahasa yang aman terhadap memori, tetapi pada program seperti berikut, kesalahan memori bisa terjadi hanya karena data race
Mengubah globalVar berulang kali ke nilai bertipe berbeda (Int, Ptr) sambil secara bersamaan membacanya dari goroutine lain dan memanggil metodenya
- Karena dua thread saling tumpang tindih saat memperbarui dua pointer internal
globalVar (data, vtable) secara terpisah, pembacaan di tengah proses dapat menghasilkan keadaan campuran yang memicu akses memori yang salah
- Akibatnya, program mencoba merujuk alamat yang keliru (misalnya
0x2a; heksadesimal 42) lalu berhenti dengan error
- Fenomena ini juga serupa pada interface, slice, dan struktur lain di Go, karena beberapa field tidak diperbarui secara atomik
Cara bahasa lain menangani konkurensi dan keamanan memori
- Bahasa lain seperti Java juga bisa mengalami data race, tetapi mereka menerapkan model memori konkurensi yang terdefinisi sehingga program tidak merusak bahasa itu sendiri
- Contoh: Java merancang model memorinya secara cermat agar bahkan di lingkungan multithread, program tidak jatuh ke error runtime seperti segment fault paksa
- Kebanyakan bahasa mengendalikan masalah konkurensi dengan salah satu dari dua pendekatan berikut
- Mendefinisikan model memori agar semua program konkurensi tetap memiliki perilaku yang konsisten (dengan konsekuensi pembatasan optimisasi compiler dan beban implementasi yang lebih besar)
- Java, C#, OCaml, JavaScript, WebAssembly, dan lain-lain
- Melarang sebagian besar data race melalui sistem tipe yang kuat, dan hanya menangani sedikit pengecualian secara aman (Rust, strict concurrency di Swift)
- Go tidak mengikuti kedua opsi tersebut
- Keamanan memori hanya dijamin jika tidak ada data race
- Ada alat pendeteksi data race, tetapi pada program nyata ada batasan untuk memverifikasi semua situasi hanya lewat pengujian
- Hasil riset dan pengalaman lapangan telah melaporkan banyak kasus nyata pelanggaran keamanan memori
Model memori Go dan masalah dokumentasi
- Dokumen resmi model memori Go memang menyebut bahwa kebanyakan race menghasilkan akibat yang terbatas, tetapi tidak menjelaskan dengan tegas bahwa beberapa data race dapat memiliki hasil yang tak terbatas
- Ada klaim bahwa perilakunya mirip Java/JavaScript, tetapi dua bahasa itu menginvestasikan jauh lebih banyak upaya dibanding Go untuk menjamin keamanan konkurensi
- Hanya pada beberapa bagian detail dokumen disebutkan secara terbatas bahwa sebagian data race dapat memicu perilaku yang sepenuhnya tak terdefinisi
Kesimpulan: tidak adanya Undefined Behavior (UB) adalah tujuan yang sebenarnya
- Pada praktiknya, sifat yang benar-benar diinginkan pengguna adalah program tidak merusak bahasa itu sendiri (tidak ada UB)
- Berbagai kerentanan keamanan yang muncul akibat pelanggaran keamanan memori terjadi karena UB benar-benar terjadi
- Begitu UB terjadi, semua perilaku setelahnya menjadi tak dapat diprediksi, dan penyerang bisa memanfaatkannya
- Perbedaan mendasar antara bahasa yang 'aman' dan 'tidak aman' adalah apakah UB bisa terjadi
- Daripada memisah-misahkan keamanan memori, keamanan thread, keamanan tipe, dan sebagainya, yang paling penting adalah apakah UB dapat terjadi
- Dalam kenyataannya, keamanan juga memiliki spektrum; Go lebih aman daripada C, tetapi tidak menjamin keamanan yang sepenuhnya utuh
- Berdasarkan data, sangat sulit untuk 'membuktikan' keamanan nyata Go, dan penting untuk benar-benar memahami konsekuensi yang tidak intuitif dari pilihan desain tiap bahasa
1 komentar
Komentar Hacker News
Swift juga punya masalah yang sama, dan saya pernah menulis program yang menunjukkan bahwa Swift bisa dengan sangat mudah memicu segfault saat mengakses struktur data bersama
Mengatakan Go itu memory-safe seperti Rust atau Java agak berlebihan
Saya ingin mendengar detail tentang situasi masalah yang terjadi di Dropbox
Memory safety, alih-alih konsep PLT (teori bahasa pemrograman), lebih merupakan istilah keamanan perangkat lunak
Pada akhirnya para programmer Go juga sangat memahami perbedaan ini, sehingga Go menjadikan pendekatan seperti “jangan berkomunikasi lewat berbagi, bagilah lewat komunikasi” sebagai premis dasarnya
Tentu saja di kenyataan konsep ini tidak sepenuhnya terwujud, dan semua orang paham bahwa Go modern juga banyak memakai shared state dan memerlukan sinkronisasi
Dalam praktiknya, setelah menjalankan Go selama bertahun-tahun, saya rasa hampir tidak pernah ada bug semacam ini yang benar-benar terjadi
Uber pernah merangkum secara detail bug yang terjadi di kode Go mereka, dan tulisan ini merapikan dalam tabel seberapa sering masalah itu benar-benar terjadi
Di Go, kebanyakan masalah akses concurrent ke map atau slice terjadi pada slice yang sama, dan perlu ada fenomena “torn read”, jadi dalam praktiknya tidak umum
Meski begitu, alasan orang biasanya berhasil menghindari masalah seperti ini mungkin karena mereka cukup berhati-hati dan sangat sadar akan bahaya melakukan reassignment variabel dalam situasi akses concurrent
Bahasa ini sendiri punya atomics, channel, dan mutex, jadi dalam praktiknya kasus salah pakai dalam situasi concurrent jarang terjadi, dan ada juga race detector sehingga masalah seperti ini cepat ditemukan
Walau ada penurunan performa, saya menganggap masalah torn read hanyalah isu yang bisa diperbaiki, dan di kode Go produksi ini bukan masalah besar
Video terkait
Race detector juga tidak menemukan apa pun, dan tak seorang pun paham apa yang sedang terjadi
Ternyata loop counter mengalami overflow, sehingga perhitungan yang sama diulang sangat banyak, dan request yang kadang seharusnya 100ms malah memakan waktu 3 menit
Di production saya mengetahui masalah ini secara tidak langsung lewat perf, dan sebagai platform engineer, pengalaman debugging saya sangat membantu tim
Karena begitu sering terpapar berbagai situasi race di Go, secara pribadi saya berharap Rust diadopsi di mana-mana
Misalnya issue ini membutuhkan refactor besar pada compiler sehingga lama terselesaikan
Dalam praktiknya, sampai sekarang kode Zig concurrent masih sedikit sehingga masalahnya belum benar-benar mencuat, tetapi saya pikir ketika fitur async makin luas dipakai, banyak masalah bisa meledak sekaligus
Tentu bug-nya lebih sedikit daripada C, tetapi itu juga berlaku untuk C++, dan tidak ada yang menyebut C++ memory-safe
Tentu ini bukan berarti risikonya benar-benar nol, tetapi mengisyaratkan bahwa dari sisi keamanan aplikasi Go, ini kemungkinan bukan isu prioritas
Sebaliknya, pada kode C/C++, 60~75% kerentanan nyata berasal dari masalah memory safety
Memory safety juga berada pada sebuah spektrum, dan saya pikir setelah tingkat tertentu manfaat tambahannya mulai menurun
Bug yang tidak bisa dieksploitasi pun tetap bug yang pada akhirnya harus diperbaiki
Karena waktu yang dihabiskan untuk maintenance jauh lebih besar daripada pengembangan awal, kalau maintenance bisa dikurangi maka meskipun peluncuran awal sedikit tertunda, saya rasa itu tetap sepadan
Sebaliknya, di Go, thread safety bukan penyebab utama CVE
Secara teoretis ada dasarnya, tetapi dalam kenyataan tidak terlalu menonjol
Saat memori dibagi, jika struktur data dirusak maka thread lain bisa berperilaku tidak aman atau salah
Misalnya ketika satu thread mengubah ukuran vector sementara thread lain mengaksesnya, operasi yang aman dalam eksekusi berurutan menjadi berbahaya dalam concurrency
Go juga tidak bisa bebas dari hal ini
Sebaliknya, jika isu thread safety hanya berakhir sebagai segfault, itu mungkin cuma serangan DoS (denial of service) biasa
Race condition bisa berujung pada serangan yang lebih kuat, tetapi jauh lebih sulit dipicu
Inilah penyebab utama data corruption dan race
Dalam berbagai situasi, model berbasis proses lebih baik daripada thread untuk concurrency, tetapi kelemahannya terlalu berat
Kalau sejak awal default-nya adalah mengirim semua data yang diperlukan tiap thread lewat message passing, saya rasa sebagian besar masalah ini akan hilang
Bagaimanapun, di platform ini kita punya kebebasan memakai global variable dan shared memory, jadi tinggal jangan dipakai
Tujuan awal Rust bukan sistem language yang memory-safe, melainkan sistem language yang thread-safe, dan memory safety datang sebagai konsekuensi alaminya
Di Rust, structured concurrency bisa dipakai lewat
thread::scopedan sejenisnya, sehingga pekerjaan terkait thread sangat nyamanLihat dokumen ini
Contoh nyata: Pada kode di atas,
buf.Bytes()mengoper referensi langsung ke memori internal, dan dengan pemanggilanReset(), backing memory digunakan ulang sehinggaprocessDatadanmainsama-sama mengakses memori yang sama secara bersamaan dan terjadilah data raceDi Rust, kode seperti ini bahkan tidak akan lolos kompilasi karena merupakan dua mutable reference, dan akan dipaksa memindahkan ownership atau melakukan copy
Di Go ini mudah membingungkan;
bytes.Buffer.ReadBytes("\n")atau.String()mengembalikan salinan sehingga aman, tetapi.Bytes()berbahaya seperti contoh di atasChannel di Rust secara mendasar mencegah masalah ini lewat konsep ownership/transfer, tetapi Go tidak punya pengaman seperti itu
Akibatnya, ini terasa lebih lambat daripada mutex, dan bagi pemula Go justru memberi pengalaman yang lebih sulit untuk digunakan dengan benar
Artinya, race yang “aman” atau deadlock yang “aman” justru jadi lebih sering
Dalam teori PL, pendekatan race freedom ala Rust mungkin menarik, tetapi dalam aplikasi nyata data penting bagaimanapun ada di RDBMS, dan misalnya kalau tidak memakai
FOR UPDATEpadaSELECT, race tetap bisa terjadi kapan sajaMeski aplikasi Rust sama sekali tidak memakai
unsafe, race tetap ada tergantung DB-nyaKita tahu Go dirancang untuk hampir tidak mengizinkan bug memory corruption, terlihat dari tidak adanya eksploit nyata
Jika mengikuti argumen tulisan ini, maka kebanyakan bahasa level tinggi (dalam tulisan ini Java saja yang dikecualikan) juga jadi tidak memory-safe
Rust mungkin “lebih” aman daripada Go, tetapi “memory safety” bukanlah spektrum kontinu, melainkan konsep lulus/gagal
Jika ingin mengklaim sebuah bahasa memory-unsafe, POC wajib ditunjukkan
Contoh dalam tulisan menunjukkan bahwa memory corruption bisa dengan mudah terjadi karena
intkeliru dianggap sebagai pointerDalam demo sengaja dipakai 42 sehingga muncul segfault, tetapi jika yang dipakai adalah alamat nyata, corruption sungguhan akan terjadi
SIGSEGV), sehingga itu merupakan pelanggaran memory safetyKarena itu, bahasa yang memungkinkan terjadinya data race tidak bisa disebut memory-safe
Sulit rasanya menyebut kasus seperti ini sebagai memory-safe
Untuk menghindari masalah seperti ini, kadang dipakai nama orang seperti “Gaussian Curvature” atau “Riemann Integrals”
Ada juga kasus “makna awal tetap dipertahankan secara sempit, lalu diperluas ke makna yang lebih luas” seperti pada “Galois Group”
Demikian pula memory safety bukan pengecualian
Saya meminta contoh konkretnya
Di FAQ dan sebagainya ada penyebutan memory safety atau jawaban terkait unions yang mengisyaratkan bahwa Go memory-safe, tetapi maknanya sebenarnya tidak jelas
Dalam presentasi tahun 2012 oleh Rob Pike, ia mengatakan “Not purely memory safe”, tetapi bahkan arti 'purely' pun tidak didefinisikan
Dokumentasi race detector Go juga tidak jelas soal definisi “safe” (contoh dokumen)
Dari luar, justru sering ada klaim kuat yang menyebut Go sebagai “memory-safe programming language”
Contohnya dokumen keamanan milik fly.io, di sini, atau dokumen memorysafety.org yang mengklasifikasikan Go sebagai memory safe
Namun di dokumen yang sama, “Out of Bounds Reads and Writes” juga dijelaskan sebagai masalah memory safety, padahal error Go yang ditunjukkan dalam pos tersebut termasuk dalam kondisi itu
Setidaknya, saya rasa Go dan komunitasnya perlu memperjelas makna persis dari “memory safety”
Selama kasus seperti ini ada, lebih baik tidak menyebut Go sebagai bahasa memory-safe tanpa penjelasan
Pada masa Go dibuat, pandangan dominan adalah “kalau ada garbage collector berarti memory-safe”, dan dibanding C/C++, itu memang jauh lebih aman