Pustaka standar C tidak thread-safe, dan bahkan Rust yang aman pun tidak bisa mencegah crash
(edgedb.com)- Saat EdgeDB memindahkan I/O jaringan dari Python ke Rust, pengujian HTTP fetch baru berbasis
reqwesttampak seperti macet hanya di CI ARM64, tetapi sebenarnya mengalami crash - Analisis core dump menunjukkan titik masalahnya bukan pada kode HTTP baru, melainkan di dalam
getenv()miliklibc, yang gagal saat mencoba membaca pointer tidak valid0x220ketika menelusuri array variabel lingkungan - Thread lain menyetel
SSL_CERT_FILEdanSSL_CERT_DIRlewat jaluropenssl-probe, sehinggasetenv()mengalokasikan ulangenviron; pada saat yang sama jalur penanganan error Python memanggilgetenv(), memicu race condition - Tidak ada
unsafeeksplisit di kode Rust, tetapi saatstd::env::set_var()mengubah environment global, sinkronisasi dengan lock internal Rust tidak mencakup runtime lain atau pemanggilan langsung kelibc - Solusinya adalah beralih di Linux dari backend
rust-native-tls/opensslmilikreqwestkerustls, dan Rust edisi 2024 serta glibc juga sedang berubah untuk mengurangi kelas masalah ini
Crash yang hanya terlihat di CI ARM64
- EdgeDB sedang membuat fitur HTTP fetch baru sambil memindahkan sebagian besar kode I/O jaringan dari Python ke Rust
- Mereka menggunakan
reqwestsebagai pustaka klien HTTP, dan fitur itu lolos di mesin lokal maupun runner CI x86_64, tetapi gagal sesekali di runner CI ARM64 - Awalnya terlihat seperti test runner macet tanpa batas, dan di log CI satu pengujian terus tampak berjalan tanpa error hingga timeout beberapa jam kemudian
- Hipotesis awal adalah perbedaan model memori antara Intel dan ARM64
- Intel memiliki model memori yang relatif ketat, dengan urutan total yang disepakati semua prosesor untuk penulisan memori
- ARM memiliki model memori dengan urutan yang lebih lemah, sehingga penulisan bisa terlihat dalam urutan berbeda oleh thread yang berbeda
Melacak core dump di lingkungan Docker CI
- Mesin CI malam hari berjalan di Amazon AWS, sehingga bisa masuk sebagai pengguna root nyata di luar container untuk melihat
dmesgdan log sistem - PID yang tampak macet dicari dari dalam dan luar container, tetapi prosesnya tidak ada, sehingga terungkap bahwa ini bukan deadlock melainkan crash
- Karena container Docker memakai process namespace, core dump diteruskan ke host Docker, dan di
journalctlterlihat core dump dari prosespython3 - Saat pertama membuka core dengan
gdb, backtrace tidak berguna karena file.sodi dalam container tidak ada - Setelah menyalin
/lib,/usr, dan lainnya dari container serta mengatursolib-absolute-prefixdigdb, dipastikan bahwa titik crash ada digetenv()padalibc.so.6
Pointer variabel lingkungan rusak yang dibaca getenv()
- Backtrace lengkapnya adalah alur
getenv()→__dcigettext()→strerror_r()→strerror()→PyErr_SetFromErrnoWithFilenameObjects() - Bukan kode HTTP baru yang crash secara langsung, melainkan Python memanggil
getenv()lewat jalur terkait gettext saat sedang membuat exception berbasis errno - Implementasi
getenv()di GLIBC 2.17 menelusurienvironmilik POSIX sebagai daftarchar **, memeriksa pointer string variabel lingkungan hingga pointerNULLterakhir - Dari disassembly dan status register,
getenv()crash saat mencoba membaca byte dari alamatx19 = 0x2200x220jelas merupakan alamat memori yang tidak valid- Saat
environsendiri diperiksa, daftar variabel lingkungan saat ini tampak konsisten
Penyebab: race condition antara setenv() dan getenv()
setenv()bukan fungsi yang aman dipanggil di lingkungan multithread, dan masalah crash aneh digetenv()miliklibcsudah berulang kali ditemukan kembali- Dengan mencocokkan disassembly dan kode C, diketahui bahwa
x20sesuai dengan pointerepyang berjalan menelusuri arrayenviron - Saat crash,
x20bernilai0x248b5000, sedangkanenvironsaat ini berada di0x28655750, sekitar 60MB lebih jauh - Setelah membandingkan memori di sekitar array environment lama dengan
environsaat ini, perbedaan terakhir muncul pada entriSSL_CERT_FILE=/etc/ssl/certs/ca-certificates.crtdanSSL_CERT_DIR=/etc/ssl/certs - Thread lain tampaknya memindahkan
environsaat memanggilsetenv(), dangetenv()terus membaca array environment lama, sehingga terjadi use-after-free
Kaitan dengan openssl-probe dan backend TLS
- Dari issue lama terkait
rust-native-tls, ditemukan petunjuk bahwaopenssl-probemenyetel variabel lingkunganSSL_CERT_FILEdanSSL_CERT_DIRuntuk menemukan sertifikat sistem - Di Linux, jalur ini dipanggil saat memakai backend
openssldarirust-native-tls - Kode
openssl-probeyang bermasalah menyetel dua variabel lingkungan itu lewatenv::set_var()tanpaunsafeeksplisitSSL_CERT_FILESSL_CERT_DIR
- Kombinasi ini membuat kode Rust tanpa unsafe berinteraksi buruk dengan penggunaan
libclain dalam proses yang sama, hingga menyebabkan crash
Mengapa bisa direproduksi di Linux ARM64
- Crash ini hanya terjadi jika
setenv()memindahkan array environment denganrealloctepat ketika thread lain memanggilgetenv() - Untuk mereproduksinya, beberapa syarat harus terjadi bersamaan
- Jumlah variabel lingkungan harus pas sehingga memicu
realloc - Kegagalan I/O yang tidak terkait harus tertangkap oleh
asyncio - Jalur penanganan error Python harus memanggil
getenv()untuk mengambil variabel lingkunganLANGUAGEtepat pada saat yang sama
- Jumlah variabel lingkungan harus pas sehingga memicu
- Nilai salah
0x220dekat dengan ukuran environment lama dalam satuan word 64-bit0x220 / 8 = 68- Nilai ini tampaknya menimpa
NULLpenutup blok environment lama, kemungkinan sebagai penanda ukuran free block oleh malloc sistem
- Karena butuh banyak prasyarat, fakta bahwa bug ini cukup bisa direproduksi di satu platform justru merupakan keberuntungan tersendiri
Petunjuk yang terlihat dari disassembly ARM64
- Di disassembly
getenv(), sempat membingungkan karena titik perubahanx20tidak terlihat jelas - Kuncinya adalah mode pengalamatan pre-index pada AArch64
ldr x19, [x20, #8]!bekerja seperti inix19 = *(x20 + 8)x20 = x20 + 8
- Karena mode pengalamatan ini,
x20tetap menelusuri array pointer variabel lingkungan meski tidak ditulis eksplisit di sisi kiri
Perbaikan yang diterapkan dan perubahan di proyek terkait
- Pada akhirnya diputuskan untuk bermigrasi di Linux dari backend
rust-native-tls/opensslmilikreqwestkerustls - Alasan awal memilih backend TLS native adalah untuk menghindari membawa dua engine TLS sekaligus saat memindahkan kode Python ke Rust
- Setelah masalah ini, diputuskan bahwa membawa dua engine TLS dalam jangka pendek masih dapat diterima
- Alternatif lain adalah memastikan pemanggilan pertama
try_init_ssl_cert_env_vars()dilakukan saat memegang GIL milik Python- Rust memiliki lock internal untuk mencegah race saat kode Rust membaca dan menulis environment sesamanya
- Namun lock itu tidak melindungi kasus ketika kode bahasa lain langsung memakai
libc - Dengan memegang GIL, setidaknya race dengan thread Python bisa dicegah
- Proyek Rust sendiri sudah menyadari masalah ini, dan di edisi 2024 berencana menjadikan fungsi setter environment sebagai
unsafe - Proyek glibc juga baru-baru ini menambahkan perubahan untuk meningkatkan thread safety
getenv()dengan menghindarireallocdan membocorkan array environment lama
4 komentar
setenvtidak thread-safe, dan C tidak ingin memperbaikinyaFungsi
setenvbikin masalah lagi.Saya akan menulis judulnya sebagai, "Ketidakamanan thread pada stdlib C bahkan tidak bisa diselamatkan oleh Rust yang katanya aman." :)
Saya benar-benar memahaminya.
Komentar Hacker News
Poin terbesar di sini adalah bahwa pada edisi Rust berikutnya, fungsi pengaturan variabel lingkungan akan menjadi unsafe
Kalau beruntung, dampaknya mungkin akan menjalar sampai ke crate-crate yang memicu crash seperti ini, dan sementara itu issue upstream sudah diajukan di https://github.com/alexcrichton/openssl-probe/issues/30
getenvdansetenvatauunsetenvtidak bisa dipanggil dengan aman dari thread yang berbeda, sebenarnya belum diperbaikiSatu-satunya solusi yang tampak dapat diandalkan adalah mengubah fungsi-fungsi ini agar wajib mengambil mutex
Sayangnya ekosistemnya tidak demikian: https://github.com/seanmonstar/reqwest/blob/master/Cargo.tom...
____UNSAFE_payattention__nevermindthatthisappears50timesinthisfile___Hal serupa juga terjadi di framework web: Vue punya direktif
v-htmldan React punyadangerouslySetInnerHTML, dan dalam hal ini Vue jelas lebih baikset_vardanremove_vardi pustaka standar Rust akan benar-benar mewajibkan blokunsafe {}pada edisi 2024 berikutnyaDokumentasinya sekarang juga menyebut masalah keamanan, tetapi sejak awal menjadikan fungsi-fungsi ini sebagai safe adalah sebuah kesalahan, dan bahasa tingkat lebih tinggi pun pernah melakukan kesalahan yang sama
https://doc.rust-lang.org/stable/std/env/fn.set_var.html
glibc punya patch yang membuat
getenvaman dalam lebih banyak kasus ketika environment dimodifikasi, tetapi C tetap bisa mengaksesenvironsecara langsung, jadi dalam situasi ketika modifikasi terjadi, ini tidak akan pernah sepenuhnya aman: https://github.com/bminor/glibc/commit/7a61e7f557a97ab597d6f...Ini menimbulkan kebocoran memori berukuran konstan secara amortisasi untuk setiap variabel lingkungan yang aktif, tetapi variabel itu sendiri juga sudah memiliki kebocoran seperti itu, bahkan bergantung pada panjangnya dan termasuk nilai yang sudah tidak dipakai lagi
Rasanya pasti ada kasus penggunaan patologis yang, bahkan pada program yang benar menurut API, menyebabkan memori terus bertambah tanpa batas karena hal ini
Menarik tapi agak mengganggu bahwa demi memperbaiki program yang melanggar aturan dengan memakai API ini dari beberapa thread, bug pertumbuhan memori tanpa batas diperkenalkan ke program yang justru mengikuti API. Rasanya lebih pragmatis daripada dogmatis
unsafe?Walaupun para pemelihara pustaka standar C menentang menjadikan
setenvaman untuk multithread, setidaknya harus didefinisikan API baru yang thread-safe, entah di dalam POSIX atau dengan membuat standar de facto lebih dulu lalu POSIX menyusulJika waktu yang dipakai untuk menjelaskan mengapa tidak ada yang bisa dilakukan dipakai untuk memperbaiki masalah ini,
setenvlama seharusnya sudah bisa digantikan dan dihentikan pemakaiannya lalu dihapus dari banyak proyek perangkat lunakMelihat glibc membuat perubahan untuk pada dasarnya menghilangkan masalah ini, pernyataan pemelihara Musl bahwa ini tidak bisa diperbaiki di Musl juga terasa kurang meyakinkan
extern char **environ;Selama
environbisa diakses secara publik, tidak ada jaminan bahwasetenvdangetenvbenar-benar dipakai. Keduanya memang tidak wajibJika
environbisa dihapus, membuatsetenvdangetenvthread-safe cukup sederhana. Jika tidak bisa dihapus maka itu mustahil, tetapi tetap bisa dikatakan bahwa menjadikansetenvdangetenvthread-safe adalah perbaikan walaupun bukan solusi sempurnaexec()yang tidak menerima environment sebagai argumen atau yang mencari executable lewatPATHjuga akan memerlukan lockingDi Linux, terkena bug terkait variabel lingkungan rasanya seperti semacam ritus peralihan, dan anehnya di Unix lain biasanya lebih jarang jadi masalah
Linus dan kernel memperbaiki bug POSIX secara pragmatis dengan membuatnya tidak benar-benar meledak di dunia nyata, jadi agak lucu bahwa glibc masih tertinggal meski sudah puluhan tahun orang mencoba setidaknya meredakan masalah ini
Memang ada banyak sumber sakit kepala seperti
TZ, tetapi kalau saja mereka menyediakangetenv_r(), menyinkronkannya dengansetenv(), dan setidaknya memberi peringatan pada tahap kompilasi atau penautan saatgetenv()dipakai, banyak masalah ini mungkin sudah hilangBahkan bisa melangkah lebih jauh dengan pendekatan copy-on-write (COW) yang membuat pointer lingkungan tetap hanya-baca sebelum ditulis
Sebaliknya, masalah ini justru dilempar ke masing-masing aplikasi, padahal penulis aplikasi hampir tidak mungkin tahu apa yang dilakukan dependensinya, jadi ini kesalahan besar. Situasi yang pernah saya alami dulu juga seperti itu, dan vendor pustaka closed-source saat itu malah menyuruh saya berhenti memakai Linux, si klon Unix mainan itu
Masalahnya bukan implementasi, melainkan API itu sendiri.
setenv(),unsetenv(),putenv(), dan terutamaenvironpada dasarnya tidak aman dalam program multithreadBahkan
getenv_r()pun tidak bisa sepenuhnya menyelamatkan keadaan. Itu karena saat satu thread sedang menyalin nilai lama variabel lingkungan ke buffer yang disediakan, thread lain bisa memanggilsetenv()Tentu, kasus ketika setelah nilai diambil lewat
getenv(), thread lain memanggilsetenv()lalu membuat memori itu tidak valid memang diperbaiki olehgetenv_r(), tetapi tidak ada cara untuk mencegah panggilan lain yang juga merusak APIlibc memang bisa meredakan sebagian masalah dengan mengambil mutex di dalam
getenv()/setenv()/putenv()/unsetenv(), tetapi tetap tidak ada cara bagi libc untuk menjamin bahwa nilai yang dikembalikangetenv()akan tetap valid selama kode pemanggil membutuhkannyaTidak ada cara bagus juga untuk membuat akses langsung ke
environmenjadi aman.environbisa saja dibuat thread-local, tetapi itu bisa membuat sudut pandang lingkungan pada tiap thread menyimpang secara permanen, dan hasilgetenv_r()bisa berbeda dari hasil pemeriksaan langsung terhadapenvironMenjaga kompatibilitas mundur di sini memang sangat sulit, dan bahkan hanya menambahkan mutex untuk melindungi fungsi-fungsi ini saja bisa mengubah makna program lama hingga rusak
Dulu pernah ada tulisan bahwa
setenvitu mengerikan: https://www.evanjones.ca/setenv-is-not-thread-safe.htmlAda juga diskusinya, dan sejak komentar pertama sudah membahas bahwa ini menyebabkan masalah di Rust: https://news.ycombinator.com/item?id=38342642
Di sini sebagian besar sisa masalah tampaknya terkait lingkungan pengembangan. Pengujian dilakukan dengan Docker di mesin jarak jauh dalam data center Amazon, dan mesin itu gagal melaporkan proses yang crash
Selain itu, di dalam container juga tidak ada informasi simbol debug yang cukup untuk mendapatkan backtrace. Kalau saat kegagalan pertama mereka langsung mendapat backtrace yang bersih, penyebabnya pasti akan segera jelas
Dari awal pun muncul pertanyaan kenapa
setenvdipakai sama sekaliMembaca ini mengingatkan saya pada gerakan 12-factor app yang sangat dipercaya beberapa rekan kerja saya dulu. Salah satu “factor”-nya adalah konfigurasi aplikasi harus menggunakan variabel lingkungan
Saya selalu merasa itu agak bodoh, karena cara konfigurasi tersebut pada dasarnya menaruh nilai bertipe string ke dalam keranjang dengan namespace datar
Bahaya
getenv()/setenv()/environjuga menurut saya menjadi alasan kuat mengapa variabel lingkungan sebaiknya tidak dipakai untuk konfigurasiTentu saja tidak selalu ada alternatif yang hebat dan didukung dengan baik. Saya lebih suka file konfigurasi, dan bisa juga memakai konfigurasi templat yang hanya diisi nilai untuk development, staging, dan production. Biasanya saya tetap memakai YAML meskipun ada kelemahan dan jebakannya, karena walaupun mungkin ada format file konfigurasi yang lebih baik, YAML tetap jauh lebih baik daripada variabel lingkungan
Di NT, variabel lingkungan bisa dibuat bertipe dan ditemplatisasi, dan juga ada registry, yaitu basis data konfigurasi dengan namespace. Memang bertele-tele dan aneh, tetapi tetap ada
Selain itu, MSVC menyediakan versi thread-safe untuk hampir semua fungsi pustaka standar
Saya sering mendengar pengembang C/C++ baru mengeluhkan kurangnya kompatibilitas POSIX di MSVC, tetapi tampaknya mereka tidak terlalu memikirkan apa artinya itu dalam praktik. Itu lebih mirip keinginan agar tetap kompatibel silang dengan program C yang ditulis pada 1990-an
Itu mengganggu kemampuan untuk menebak perilaku hanya dari signature fungsi, dan membuat banyak fungsi yang seharusnya bisa murni menjadi tidak murni
Jika ada fitur bahasa yang bisa menandai aplikasi sehingga, dalam proses apa pun, variabel lingkungan tidak bisa ditulis dan hanya bisa dibaca sekali, bukan per variabel tetapi sekali baca secara keseluruhan, saya rasa saya akan memakainya di mana-mana
getenv()sendiri sepenuhnya baik-baik saja; masalahnya ada padasetenv()Secara teori, environment sudah disiapkan sebelum aplikasi misterius itu dimulai, jadi seharusnya tidak perlu memakai ini
Namun namespace datar, nilai string, dan fakta bahwa ini adalah ruang global bebas yang dibagi semua orang tanpa tahu pustaka dan modul apa saja yang akan ikut masuk, tetap bukan ide yang baik bahkan jika masalah keamanan
setenv()tidak adaSebagian besar masalah yang dibicarakan orang di sini tampaknya muncul dari penyalahgunaan environment seolah-olah itu penyimpanan key-value untuk status global yang dapat diubah. Saya tidak paham kenapa orang ingin melakukan itu
JVM pada praktiknya memperlakukan environment sebagai immutable, dan mungkin saja perusahaan pengguna Scala/Java seperti SoundCloud ikut memengaruhi gerakan 12-factor app. Saya sendiri belum pernah mengalami environment berubah atau menimbulkan masalah threading
Bahkan jika environment berubah, salinan immutable yang dibuat saat JVM dimulai tetap sama, dan kode yang berinteraksi dengan environment lewat API Java biasa tidak akan melihat perubahan itu
Masalah file konfigurasi adalah parsing dilakukan per proses. Itulah sebabnya Linux/Unix menjadi sangat semrawut. Tiap alat punya konvensi dan mekanisme konfigurasi sendiri, dan tidak ada standar
Dalam ekosistem Docker, apa pun yang dilakukan di dalam container, antarmuka ke dunia luar biasanya adalah memasang volume lalu mengikuti cara konfigurasi rumit tiap aplikasi, atau cukup memakai variabel lingkungan
Sebagian besar perangkat lunak modern yang dijalankan dengan Docker saat ini cukup ramah Docker sehingga perilakunya bisa dikendalikan sepenuhnya lewat environment, dan dalam banyak kasus itu sudah cukup
Jika memakai Docker Compose atau Kubernetes, Anda pada akhirnya punya daftar variabel lingkungan yang mendefinisikan cara proses dimulai dalam file YAML, jadi sampai batas tertentu struktur yang diinginkan memang muncul. Saya tidak suka YAML, tetapi itu cukup berfungsi, dan walaupun masalah sintaks bisa merusak hari Anda, alternatifnya juga bukan tanpa masalah
Saya juga memakai gaya 12-factor app, tetapi setelah masuk ke aplikasi, saya memvalidasi dan menyimpan variabel lingkungan serta datanya. Setelah itu tidak ada masalah sama sekali
Ini tulisan yang luar biasa, menggali bug yang sulit terlihat
Ada bug intermiten, kekhususan arsitektur, tersembunyi di dalam dependensi, Rust, Python GIL, sampai gettext
Laporan pemecahan masalah sedetail ini adalah bahan yang paling mendekati pengalaman mengalaminya langsung. Dalam situasi ketika dependensi Anda yang memakainya dan Anda tak mungkin tahu sebelumnya, sulit untuk sekadar bilang “ya tinggal jangan pakai X”
Katanya “mesin CI malam hari berjalan di Amazon AWS dan punya keuntungan bisa memakai pengguna root sungguhan, bukan container”, tetapi pada saat yang sama juga dikatakan “di luar container tidak ada file yang diperlukan dan container dibuat sangat minimal sehingga
gdbtidak bisa dipasang dengan mudah”Apakah sekarang orang-orang sudah kehilangan kemampuan untuk build dan debug secara lokal tanpa cloud dan container?
Untuk melakukan hal yang sangat sepele saja sekarang dibutuhkan segala macam kompleksitas cloud dan lapisan deployment. Seolah revolusi PC diputar balik 100% dan kita kembali ke era komputasi mainframe yang kaku dan mahal
Alasannya karena ada uang di cloud, dan cloud itu DRM. Jika perangkat lunak dipindahkan ke sana, Anda bisa menarik biaya langganan, pengguna tak bisa menghindarinya, dan ketergantungan total bisa dipertahankan selamanya. Sering kali pengguna bahkan tidak bisa mengambil data mereka sendiri
Analitik real-time untuk optimasi produk juga jadi mudah dilakukan
Arsitektur komputasi berada di hilir dari model bisnis. Mainframe dulu mati bukan hanya karena tidak ada internet dan PC lebih murah, tetapi juga karena vendor kehilangan banyak kekuatan untuk mengunci pelanggan
Sekarang ada cara untuk menghidupkan kembali model yang jauh lebih menguntungkan itu. Kebebasan pengguna yang merepotkan telah hilang, dan sejujurnya ketika pengguna punya kebebasan seperti itu mereka sering juga tidak membayar, sehingga bisnis perangkat lunak berkualitas tinggi jadi sulit dijalankan
Besar kemungkinan mereka tidak bisa mereproduksi crash itu secara lokal. Mesin pengembang kebanyakan x86, dan di sana crash-nya tidak terjadi
Penanganan crash memang seharusnya bisa lebih baik, tetapi mereka tampaknya juga menyadari itu, dan itu bukan inti utama yang dibahas di sini
Status global yang bisa diubah itu jahat. Kalau teman, jangan biarkan teman memakai status global yang bisa diubah
Saya benci variabel lingkungan. Ini memang “cara Linux”, tapi saya menghindarinya seperti wabah. Sangat direkomendasikan
libc itu mengerikan, dan dunia sekarang harus move on
Masalahnya bukan Linux, bukan status global yang bisa diubah atau resource, juga bukan libc
Masalahnya adalah di tempat kerja kita tidak diberi waktu untuk mengerjakan sesuatu dengan benar. Misalnya, untuk menangkap masalah dengan GDB sebelum meledak, atasan harus memberi waktu untuk dengan gigih men-debug dan menelusuri balik kode serta semua hal yang disentuh kode itu
Terlalu banyak uang mengalir ke kode yang masih setengah matang. Menyedihkan, tapi itu kenyataannya
Karena masalah seperti ini terlalu banyak, akhirnya saya menambal
getenv/setenv/putenvdenganLD_PRELOAD