Безопасность Rust приложений: топ-10 уязвимостей на практике
Rust позиционируется как безопасный системный язык, способный предотвратить целые классы ошибок, типичных для C и C++. Однако это не означает, что Rust-приложения неуязвимы. Система владения (ownership) и borrow checker защищают от многих проблем, но unsafe-код, логические ошибки и внешние зависимости открывают новые векторы атак. В этой статье мы разберём 10 наиболее критичных уязвимостей, с которыми сталкиваются разработчики на Rust, — с реальными примерами кода и практическими советами по их устранению.
Материал будет полезен как новичкам, так и опытным инженерам, желающим углубить знания о безопасности в экосистеме Rust.
1. Небезопасное использование unsafe
Ключевое слово unsafe — самый частый источник уязвимостей в Rust. Оно отключает проверки borrow checker'а и позволяет работать с сырыми указателями, но ответственность за корректность полностью ложится на разработчика.
// Уязвимый код: разыменование нулевого указателя
fn dangerous() {
let ptr: *const i32 = std::ptr::null();
unsafe {
println!("{}", *ptr); // UB! Segfault
}
}
Решение: минимизируйте использование unsafe. Выносите опасные операции в отдельные модули с тщательным тестированием и документированием инвариантов. Используйте NonNull<T> вместо сырых указателей.
2. Гонки данных (Data Races)
Несмотря на то, что borrow checker предотвращает гонки в безопасном коде, unsafe и неправильное использование примитивов синхронизации могут привести к состоянию гонки.
// Уязвимый код: гонка данных через unsafe
static mut COUNTER: i32 = 0;
fn increment() {
unsafe {
COUNTER += 1; // Неатомарная операция
}
}
Решение: используйте AtomicI32, Mutex или RwLock. В безопасном коде Rust гонки данных невозможны — доверяйте компилятору.
3. Integer Overflow (переполнение целых чисел)
В debug-режиме Rust паникует при переполнении, но в release-режиме по умолчанию происходит wrapping (циклический перенос), что может привести к неожиданному поведению.
// Потенциальная уязвимость: логика переполнения
fn allocate_buffer(size: usize) {
let header_size = 64;
let total = size + header_size; // Может переполниться
let buf = vec![0u8; total]; // Паника или малый буфер
}
Решение: используйте методы checked_add(), saturating_add() или явную проверку. Для критичных вычислений применяйте крейт overflow-checks.
4. Утечки памяти в unsafe коде
Rust гарантирует отсутствие утечек в безопасном коде, но unsafe может нарушить этот принцип. Например, неправильное управление сырыми указателями или циклические ссылки с Rc.
// Утечка памяти: забыли вызвать drop
fn leak_memory() {
let ptr = Box::into_raw(Box::new(42));
// ptr не освобождён — утечка
}
Решение: используйте Box::from_raw() для корректного освобождения. Для циклических ссылок применяйте Weak или Arc.
5. Небезопасные FFI вызовы
Вызовы C-функций через FFI (Foreign Function Interface) — один из главных источников уязвимостей. Rust не может контролировать безопасность внешнего кода.
// Опасный FFI: передача некорректного указателя
extern "C" {
fn c_free(ptr: *mut c_void);
}
fn free_wrapper(ptr: *mut c_void) {
unsafe { c_free(ptr); } // Двойное освобождение?
}
Решение: используйте безопасные обёртки (wrappers), проверяйте границы буферов, применяйте std::ffi::CStr для строк. Рассмотрите крейты libffi или cbindgen.
6. Race Condition в асинхронном коде
Современный Rust активно использует async/await. Гонки данных в async-коде возникают при неправильном использовании разделяемого состояния без синхронизации.
// Уязвимый async-код: гонка данных
async fn update_counter(counter: &mut i32) {
*counter += 1; // Неатомарно в многопоточном контексте
}
Решение: используйте tokio::sync::Mutex или Arc<AtomicI32>. Избегайте &mut ссылок в async-контексте без явной синхронизации.
7. Path Traversal (обход путей)
Неправильная валидация пользовательского ввода при работе с файловой системой может привести к чтению/записи за пределами разрешённой директории.
// Уязвимость: path traversal
fn read_file(user_input: &str) -> String {
let path = Path::new(&format!("./uploads/{}", user_input));
std::fs::read_to_string(path).unwrap_or_default()
}
Решение: используйте canonicalize() для проверки, что путь находится внутри разрешённой директории. Отфильтровывайте символы .. и /.
8. Инъекции команд (Command Injection)
Передача непроверенного пользовательского ввода в std::process::Command может привести к выполнению произвольных команд.
// Опасный запуск команды
fn run_command(user_input: &str) {
let output = Command::new("sh")
.arg("-c")
.arg(format!("echo {}", user_input)) // Инъекция!
.output();
}
Решение: используйте arg() с отдельными аргументами вместо shell-строк. Применяйте крейты duct или shell-escape.
9. Uninitialized Memory (неинициализированная память)
Rust требует инициализации