Ini adalah bagian kedua dari seri artikel Perlindungan Fearless. Yang pertama kita berbicara tentang keamanan memoriAplikasi modern bersifat multi-utas: alih-alih menjalankan tugas secara berurutan, program menggunakan utas untuk secara bersamaan melakukan beberapa tugas. Kita semua mengamati
kerja simultan dan
konkurensi setiap hari:
- Situs web dilayani oleh beberapa pengguna secara bersamaan.
- UI melakukan pekerjaan latar belakang yang tidak mengganggu pengguna (bayangkan bahwa setiap kali Anda mengetik karakter, aplikasi membeku untuk memeriksa ejaan).
- Komputer dapat menjalankan beberapa aplikasi sekaligus.
Aliran paralel mempercepat pekerjaan, tetapi memperkenalkan serangkaian masalah sinkronisasi, yaitu kebuntuan dan kondisi balapan. Dari sudut pandang keamanan, mengapa kita peduli dengan keselamatan benang? Karena keamanan memori dan utas memiliki satu dan masalah utama yang sama: penggunaan sumber daya yang tidak tepat. Serangan di sini memiliki efek yang sama dengan serangan memori, termasuk eskalasi hak istimewa, eksekusi kode arbitrer (ACE), dan memintas pemeriksaan keamanan.
Kesalahan konkurensi, seperti kesalahan implementasi, terkait erat dengan kebenaran program. Sementara kerentanan memori hampir selalu berbahaya, kesalahan implementasi / logika tidak selalu menunjukkan masalah keamanan jika tidak terjadi di bagian kode yang terkait dengan kepatuhan dengan kontrak keamanan (misalnya, izin untuk memintas pemeriksaan keamanan). Tetapi bug konkurensi memiliki kekhasan. Jika masalah keamanan karena kesalahan logis sering muncul di sebelah kode yang sesuai, maka kesalahan konkurensi sering terjadi
pada fungsi lain, dan bukan pada fungsi yang kesalahannya langsung dibuat , yang membuatnya sulit untuk melacak dan menghilangkannya. Kesulitan lain adalah tumpang tindih tertentu antara pemrosesan memori yang tidak tepat dan kesalahan konkurensi, yang kita lihat dalam balapan data.
Bahasa pemrograman telah mengembangkan berbagai strategi konkurensi untuk membantu pengembang mengelola masalah kinerja dan keamanan aplikasi multi-utas.
Masalah konkurensi
Secara umum diterima bahwa pemrograman paralel lebih sulit dari biasanya: otak kita lebih baik beradaptasi dengan penalaran berurutan. Kode paralel dapat memiliki interaksi tak terduga dan tidak diinginkan antara utas, termasuk deadlock, pertikaian, dan ras data.
Kebuntuan terjadi ketika beberapa utas berharap satu sama lain untuk melakukan tindakan tertentu untuk terus bekerja. Meskipun perilaku yang tidak diinginkan ini dapat menyebabkan penolakan serangan layanan, itu tidak akan menyebabkan kerentanan seperti ACE.
Kondisi lomba adalah situasi di mana waktu atau urutan tugas dapat memengaruhi kebenaran suatu program. Perlombaan data terjadi ketika beberapa aliran mencoba untuk secara bersamaan mengakses lokasi memori yang sama dengan setidaknya satu upaya penulisan. Kebetulan bahwa kondisi balapan dan perlombaan data
terjadi secara terpisah satu sama lain. Tetapi
data balapan selalu berbahaya .
Konsekuensi Potensi Kesalahan Konkurensi
- Jalan buntu
- Kehilangan informasi: utas lain menimpa informasi
- Kehilangan integritas: informasi dari beberapa aliran terjalin
- Kehilangan viabilitas: masalah kinerja karena akses yang tidak merata ke sumber daya bersama
Jenis serangan konkurensi yang paling terkenal disebut
TOCTOU (waktu pemeriksaan hingga waktu penggunaan): pada dasarnya, keadaan suatu ras adalah antara kondisi pemeriksaan (misalnya, kredensial keamanan) dan menggunakan hasilnya. Serangan TOCTOU mengakibatkan hilangnya integritas.
Penguncian bersama dan hilangnya kemampuan bertahan dianggap sebagai masalah kinerja, bukan masalah keamanan, sementara hilangnya informasi dan hilangnya integritas kemungkinan terkait dengan keamanan.
Artikel Keamanan Balon Merah membahas beberapa kemungkinan eksploitasi. Salah satu contohnya adalah korupsi pointer yang diikuti oleh peningkatan hak akses atau eksekusi kode jarak jauh. Dalam exploit, fungsi yang memuat pustaka bersama ELF (Executable and Linkable Format) dengan benar memulai semaphore hanya pada panggilan pertama, dan kemudian secara tidak benar membatasi jumlah utas, yang menyebabkan kerusakan memori kernel. Serangan ini adalah contoh kehilangan informasi.
Bagian tersulit dari pemrograman konkuren adalah pengujian dan debugging, karena kesalahan konkurensi sulit untuk direproduksi. Waktu kejadian, keputusan sistem operasi, lalu lintas jaringan dan faktor-faktor lain ... semua ini mengubah perilaku program pada setiap permulaan.
Terkadang lebih mudah untuk menghapus seluruh program daripada mencari bug. HeisenbugsPerilaku tidak hanya berubah setiap kali dimulai, tetapi bahkan menyisipkan output atau pernyataan debug dapat mengubah perilaku, menghasilkan “bug Heisenberg” (kesalahan non-deterministik, sulit untuk mereproduksi khas pemrograman paralel) yang muncul dan menghilang secara misterius.
Pemrograman paralel sulit. Sulit untuk memprediksi bagaimana kode paralel akan berinteraksi dengan kode paralel lainnya. Ketika kesalahan muncul, mereka sulit ditemukan dan diperbaiki. Alih-alih mengandalkan penguji, mari kita melihat cara untuk mengembangkan program dan penggunaan bahasa yang membuat penulisan kode paralel lebih mudah.
Pertama, kami merumuskan konsep "keamanan benang":
"Tipe data atau metode statis dianggap sebagai thread aman jika berperilaku dengan benar ketika dipanggil dari beberapa utas, terlepas dari bagaimana utas ini dijalankan, dan tidak memerlukan koordinasi tambahan dari kode panggilan." MIT
Bagaimana bahasa pemrograman bekerja dengan paralelisme
Dalam bahasa tanpa keamanan ulir statis, programmer harus terus-menerus memonitor memori yang dibagi dengan utas lain dan dapat berubah kapan saja. Dalam pemrograman berurutan, kita diajarkan untuk menghindari variabel global jika bagian lain dari kode diam-diam mengubahnya. Tidak mungkin untuk meminta programmer untuk menjamin perubahan aman dalam data bersama, serta manajemen memori manual.
"Kewaspadaan Konstan!"Biasanya, bahasa pemrograman dibatasi pada dua pendekatan:
- Batasan mutabilitas atau pembatasan berbagi
- Keamanan utas manual (mis. Kunci, semafor)
Bahasa dengan batasan utas baik menetapkan batas 1 utas untuk variabel yang dapat berubah, atau mengharuskan semua variabel umum tidak berubah. Kedua pendekatan mengatasi masalah dasar perlombaan data - data bersama yang dimodifikasi secara salah - tetapi batasannya terlalu berat. Untuk mengatasi masalah tersebut, bahasa membuat primitif sinkronisasi tingkat rendah, seperti mutex. Mereka dapat digunakan untuk membangun struktur data yang aman.
Python dan penguncian global oleh juru bahasa
Implementasi referensi dalam Python dan Cpython memiliki mutex aneh yang disebut Global Interpreter Lock (GIL), yang memblokir semua utas lainnya ketika satu utas mengakses suatu objek. Multithreaded Python terkenal karena
inefisiensi karena latensi GIL. Oleh karena itu, sebagian besar program Python bersamaan bekerja dalam beberapa proses sehingga masing-masing memiliki GIL sendiri.
Pengecualian Java dan runtime
Java mendukung pemrograman bersamaan melalui model memori bersama. Setiap utas memiliki jalur eksekusi sendiri, tetapi ia dapat mengakses objek apa pun dalam program: pemrogram harus menyinkronkan akses antara utas menggunakan primitif Java bawaan.
Meskipun Java memiliki blok penyusun untuk membuat program-program thread aman,
keamanan thread tidak dijamin oleh kompiler (sebagai lawan dari keamanan memori). Jika akses memori yang tidak disinkronkan terjadi (mis. Ras data), maka Java akan melempar pengecualian run-time, tetapi programmer harus menggunakan primitif concurrency yang tepat.
C ++ dan otak programmer
Sementara Python menghindari kondisi balapan dengan GIL dan Java melempar pengecualian pada saat run time, C ++ mengharapkan programmer untuk secara manual menyinkronkan akses memori. Sebelum C ++ 11, perpustakaan standar
tidak menyertakan primitif konkurensi .
Sebagian besar bahasa menyediakan alat untuk menulis kode aman, dan ada metode khusus untuk mendeteksi ras data dan status ras; tetapi tidak memberikan jaminan keamanan utas dan tidak melindungi terhadap perlombaan data.
Bagaimana cara mengatasi masalah Rust?
Rust mengambil pendekatan multi-sisi untuk menghilangkan kondisi balapan menggunakan aturan tenurial dan tipe aman untuk sepenuhnya melindungi terhadap kondisi balapan pada waktu kompilasi.
Pada
artikel pertama, kami memperkenalkan konsep kepemilikan, ini adalah salah satu konsep dasar Rust. Setiap variabel memiliki pemilik yang unik, dan kepemilikan dapat ditransfer atau dipinjam. Jika utas lain ingin mengubah sumber daya, maka kami mentransfer kepemilikan dengan memindahkan variabel ke utas baru.
Memindahkan melempar pengecualian: beberapa utas dapat menulis ke memori yang sama, tetapi tidak pernah secara bersamaan. Karena pemilik selalu sendirian, apa yang terjadi jika utas lain meminjam variabel?
Di Rust, Anda memiliki satu pinjaman yang bisa berubah, atau beberapa yang tidak berubah. Tidak mungkin untuk secara bersamaan memperkenalkan pinjaman yang dapat berubah dan tidak dapat diubah (atau beberapa pinjaman yang bisa berubah). Dalam keamanan memori, penting agar sumber daya dibebaskan dengan benar, dan dalam keselamatan utas penting bahwa hanya satu utas yang berhak mengubah variabel pada waktu tertentu. Selain itu, dalam situasi seperti itu, tidak ada aliran lain akan merujuk pada pinjaman usang: baik pencatatan atau berbagi dimungkinkan untuk itu, tetapi tidak keduanya.
Konsep kepemilikan dirancang untuk mengatasi kerentanan memori. Ternyata itu juga mencegah balap data.
Meskipun banyak bahasa memiliki metode keamanan memori (seperti penghitungan tautan dan pengumpulan sampah), mereka biasanya mengandalkan sinkronisasi manual atau larangan berbagi bersama untuk mencegah perlombaan data. Pendekatan Rust membahas kedua jenis keamanan, mencoba memecahkan masalah utama dalam menentukan penggunaan sumber daya yang dapat diterima dan memastikan validitas ini pada waktu kompilasi.
Tapi tunggu! Bukan itu saja!
Aturan kepemilikan mencegah beberapa utas dari menulis data ke lokasi memori yang sama dan melarang pertukaran data secara simultan antara utas dan mutabilitas, tetapi ini tidak serta merta menyediakan struktur data yang aman untuk thread. Setiap struktur data di Rust aman atau tidak. Ini diteruskan ke kompiler menggunakan sistem tipe.
"Program yang diketik dengan baik tidak bisa membuat kesalahan." - Robin Milner, 1978
Dalam bahasa pemrograman, ketik sistem menggambarkan perilaku yang dapat diterima. Dengan kata lain, program yang diketik dengan baik didefinisikan dengan baik. Selama tipe kami cukup ekspresif untuk menangkap makna yang dimaksud, program yang diketik dengan baik akan berperilaku sebagaimana dimaksud.
Karat adalah bahasa jenis-aman, di sini kompiler memeriksa konsistensi semua jenis. Misalnya, kode berikut ini tidak dikompilasi:
let mut x = "I am a string"; x = 6;
error[E0308]: mismatched types --> src/main.rs:6:5 | 6 | x = 6;
Semua variabel di Rust sering bertipe implisit. Kita juga dapat mendefinisikan tipe baru dan mendeskripsikan kemampuan masing-masing tipe menggunakan
sistem sifat . Ciri memberikan abstraksi antarmuka. Dua sifat bawaan yang penting adalah
Send
dan
Sync
, yang disediakan secara default oleh kompiler untuk setiap jenis:
Send
menunjukkan bahwa struktur dapat ditransfer dengan aman di antara utas (diperlukan untuk mentransfer kepemilikan)
Sync
menunjukkan bahwa utas dapat menggunakan struktur dengan aman.
Contoh di bawah ini adalah versi
kode yang disederhanakan dari
pustaka standar yang menumbuhkan utas:
fn spawn<Closure: Fn() + Send>(closure: Closure){ ... } let x = std::rc::Rc::new(6); spawn(|| { x; });
Fungsi
spawn
mengambil argumen tunggal,
closure
dan membutuhkan tipe untuk yang terakhir yang mengimplementasikan sifat-sifat
Send
dan
Fn
. Saat mencoba membuat aliran dan meneruskan nilai
closure
dengan variabel
x
kompiler melempar kesalahan:
kesalahan [E0277]: `std :: rc :: Rc <i32>` tidak dapat dikirim di antara utas dengan aman
-> src / main.rs: 8: 1
|
8 | spawn (move || {x;});
| ^^^^^ `std :: rc :: Rc <i32>` tidak dapat dikirim di antara utas dengan aman
|
= help: dalam `[closure@src/main.rs: 8: 7: 8:21 x: std :: rc :: Rc <i32>]`, sifat `std :: marker :: marker :: Send` tidak diterapkan untuk `std :: rc :: Rc <i32>`
= note: diperlukan karena muncul di dalam tipe `[closure@src/main.rs: 8: 7: 8:21 x: std :: rc :: Rc <i32>]`
Catatan: dibutuhkan oleh `spawn`
Karakter Send
dan
Sync
memungkinkan sistem tipe Rust untuk memahami data apa yang dapat dibagikan. Dengan memasukkan informasi ini dalam sistem tipe, keselamatan ulir menjadi bagian dari keamanan tipe. Alih-alih dokumentasi,
keamanan utas diterapkan oleh hukum kompiler .
Pemrogram dengan jelas melihat objek umum di antara utas, dan kompiler menjamin keandalan instalasi ini.
Meskipun alat pemrograman paralel tersedia dalam banyak bahasa, mencegah kondisi lomba tidak mudah. Jika Anda membutuhkan programmer untuk instruksi alternatif yang rumit dan berinteraksi di antara utas, maka kesalahan tidak bisa dihindari. Meskipun pelanggaran keamanan benang dan memori menyebabkan konsekuensi yang serupa, perlindungan memori tradisional, seperti penghitungan tautan dan pengumpulan sampah, tidak mencegah kondisi balapan. Selain jaminan statis keamanan memori, model kepemilikan Rust juga mencegah perubahan data yang tidak aman dan pembagian objek yang tidak benar di antara thread, sementara sistem tipe memberikan keamanan thread pada waktu kompilasi.