Безопасность Rust приложений: топ-10 уязвимостей на практике

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Безопасность 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 требует инициализации

Блоги

Рекомендаций по книгам