Значительная доля исторических CVE в системном ПО — не логические ошибки бизнес-логики, а низкоуровневые баги управления памятью: use-after-free, double-free, buffer overflow, data race, dangling pointer. По независимым исследованиям Microsoft и проекта Chromium, доля именно memory-safety уязвимостей в крупных C/C++ кодовых базах годами держится на уровне порядка двух третей от всех найденных security-багов — точная цифра гуляет от отчёта к отчёту, но порядок величины устойчив. Модель владения и заимствования Rust переводит целые классы этих ошибок из разряда "находим в проде/на фаззинге" в разряд "не компилируется".
// C: классический use-after-free — компилятор C не видит проблемы
// char *p = malloc(16); free(p); strcpy(p, "boom"); // UB, но компилируется
// Rust: тот же паттерн не компилируется — borrow checker ловит на этапе компиляции
fn use_after_move() {
let data = String::from("secret");
let moved = data; // владение передано
// println!("{}", data); // error[E0382]: value borrowed after move
println!("{moved}");
}
// Data race тоже ловится статически: &mut и & не могут сосуществовать
fn no_data_race(v: &mut Vec<i32>) {
let first = &v[0];
// v.push(4); // error[E0502]: cannot borrow `*v` as mutable because it is also borrowed as immutable
println!("{first}");
}
Rust не исключает всё: логические уязвимости (broken access control, injection, некорректная криптография, недоверие к вводу) полностью зависят от разработчика — язык лишь убирает целый пласт "случайных" ошибок, оставляя энергию на content-level безопасность.
| Класс уязвимости | C / C++ (manual memory) | Safe Rust |
|---|---|---|
| Use-after-free | возможен, находится в рантайме/фаззингом | исключён компилятором (borrow checker) |
| Buffer overflow | возможен без проверок границ | panic на выходе за границы (bounds checking), не UB |
| Double free | возможен | исключён — единственный владелец вызывает Drop один раз |
| Data race (потоки) | возможен, часто недетерминирован | исключён статически через Send/Sync |
| Null pointer dereference | возможен | Option<T> заставляет обработать отсутствие значения |
| Integer overflow | UB (signed) или wraparound (unsigned) молча | panic в debug, явный wrapping/checked/saturating в release |
| SQL injection, SSRF, broken auth | зависит от разработчика | зависит от разработчика — язык не спасает |
Блок unsafe не отключает проверки заимствований — он лишь разрешает пять дополнительных операций: разыменование сырых указателей, вызов unsafe fn/метода, доступ к мутируемым статикам, реализацию unsafe-трейтов и доступ к полям union. Именно в этих пяти операциях и живёт возможный UB, поэтому unsafe — естественная точка фокусировки security-ревью: grep -rn "unsafe" src/ даёт список мест, которые нужно аудировать в первую очередь.
// Плохо: unsafe "протекает" наружу без инкапсуляции инвариантов
pub fn first_byte(ptr: *const u8) -> u8 {
unsafe { *ptr } // вызывающий должен сам гарантировать валидность ptr — легко забыть
}
// Хорошо: safe-обёртка проверяет инварианты сама, unsafe спрятан внутри
pub struct SafeBuffer {
data: Vec<u8>,
}
impl SafeBuffer {
/// # Safety-инвариант, поддерживаемый этим типом:
/// `data` всегда валиден и полностью инициализирован — гарантируется тем,
/// что единственный способ создать SafeBuffer — через безопасный конструктор.
pub fn get(&self, index: usize) -> Option<u8> {
if index >= self.data.len() {
return None; // явная проверка границ на safe-уровне
}
// SAFETY: index < self.data.len() проверено строкой выше,
// указатель на data валиден, т.к. self.data живёт как минимум self.
let byte = unsafe { *self.data.get_unchecked(index) };
Some(byte)
}
}
&mut T и &T на один адрес, полученные через unsafe-приведение.MaybeUninit<T> до вызова assume_init().Send/Sync — ручная реализация этих unsafe-трейтов для типа, реально не потокобезопасного.Для поиска UB в unsafe-коде критически важен Miri (cargo +nightly miri test) — интерпретатор MIR, детектирующий большинство нарушений aliasing-модели, чтение неинициализированной памяти и выход за границы аллокации, которые в обычном запуске могут "случайно сработать" без видимых симптомов.
// SAFETY: ..., объясняющим, почему именно здесь инвариант выполнен — это требует clippy-lint undocumented_unsafe_blocks.
В экосистеме исторически сосуществуют два принципиально разных подхода к криптографии. ring — обёртка над кодом, унаследованным от BoringSSL/OpenSSL (значительная часть примитивов реализована на ассемблере и C), с намеренно узким и трудноиспользуемым-неправильно API: разработчик ring сознательно жертвует гибкостью ради снижения площади ошибок использования. RustCrypto — организация, поддерживающая набор pure-Rust крейтов (aes-gcm, chacha20poly1305, sha2, hmac, argon2) с более широким и композируемым API через общие трейты Digest/Cipher. Ранние версии некоторых pure-Rust реализаций страдали от таймингов, не являющихся строго constant-time на всех платформах — сейчас основные крейты RustCrypto используют subtle для constant-time сравнений и по умолчанию задействуют аппаратные инструкции (AES-NI) там, где они доступны.
// AEAD-шифрование: ChaCha20-Poly1305 (RustCrypto)
use chacha20poly1305::{
aead::{Aead, AeadCore, KeyInit, OsRng},
ChaCha20Poly1305, Nonce,
};
fn encrypt_payload(key_bytes: &[u8; 32], plaintext: &[u8]) -> Vec<u8> {
let cipher = ChaCha20Poly1305::new(key_bytes.into());
// Nonce ДОЛЖЕН быть уникален для каждого сообщения с одним ключом —
// повторное использование nonce полностью ломает AEAD-гарантии
let nonce = ChaCha20Poly1305::generate_nonce(&mut OsRng);
let ciphertext = cipher.encrypt(&nonce, plaintext)
.expect("encryption failure");
// nonce храним/передаём вместе с ciphertext — он не секретен
[nonce.as_slice(), &ciphertext].concat()
}
// Хеширование паролей: argon2 (победитель Password Hashing Competition)
use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString, rand_core::OsRng as PwOsRng},
Argon2,
};
fn hash_password(password: &str) -> String {
let salt = SaltString::generate(&mut PwOsRng);
Argon2::default()
.hash_password(password.as_bytes(), &salt)
.expect("hashing failed")
.to_string() // содержит алгоритм, соль и параметры — самодостаточная строка
}
fn verify_password(password: &str, stored_hash: &str) -> bool {
let Ok(parsed) = PasswordHash::new(stored_hash) else { return false; };
Argon2::default().verify_password(password.as_bytes(), &parsed).is_ok() // constant-time сравнение внутри
}
// HMAC для подписи webhook/API-запросов
use hmac::{Hmac, Mac};
use sha2::Sha256;
fn verify_webhook_signature(secret: &[u8], body: &[u8], signature: &[u8]) -> bool {
type HmacSha256 = Hmac<Sha256>;
let Ok(mut mac) = HmacSha256::new_from_slice(secret) else { return false; };
mac.update(body);
mac.verify_slice(signature).is_ok() // verify_slice — constant-time, никогда не сравнивайте signature == expected напрямую
}
== — обычное сравнение байт-в-байт прерывается на первом несовпадении и создаёт timing side-channel, позволяющий восстановить секрет по времени ответа. Всегда используйте constant-time сравнение (verify_slice, subtle::ConstantTimeEq) и никогда не пишите свой алгоритм шифрования/хеширования с нуля.
Секреты в памяти процесса — токены, пароли, приватные ключи — представляют риск даже при отсутствии сетевых утечек: swap на диск, core dump после паники, случайный {:?} в логах, чтение памяти другим процессом при компрометации хоста. Крейт zeroize обнуляет память при Drop, крейт secrecy добавляет типовую обёртку Secret<T>, которая не реализует Debug/Display и требует явного вызова expose_secret() для доступа к значению — это делает утечку в лог программной ошибкой, которую увидит компилятор, а не немой человеческий недосмотр.
use secrecy::{ExposeSecret, SecretString};
use zeroize::{Zeroize, ZeroizeOnDrop};
// secrecy::Secret — компилятор не даст случайно вывести секрет в лог
struct DbConfig {
host: String,
password: SecretString,
}
fn connect(config: &DbConfig) {
// println!("{:?}", config.password); // не скомпилируется: SecretString не реализует Debug
let conn_str = format!("postgres://user:{}@{}", config.password.expose_secret(), config.host);
// ... используем conn_str и не сохраняем его нигде подольше необходимого
}
// zeroize: гарантированное обнуление при выходе из области видимости
#[derive(ZeroizeOnDrop)]
struct SessionKey {
bytes: [u8; 32],
}
fn rotate_key(old_key: mut Vec<u8>) {
// работа с ключом ...
old_key.zeroize(); // явное обнуление до Drop, если нужно освободить память раньше
}
/proc/PID/environ любому с доступом к хосту/контейнеру и часто утекают в crash-репорты.Debug/Display/Serialize по умолчанию. Явный #[derive(Debug)] на структуре с паролем — самый частый источник утечки секретов в логи в реальных инцидентах.
cargo audit сверяет Cargo.lock с RustSec Advisory Database — курируемой базой известных уязвимостей в опубликованных крейтах — и падает с ненулевым кодом при совпадении, что делает его тривиальным для встраивания в CI. cargo deny — более широкий инструмент: помимо security-советов проверяет лицензии зависимостей, дублирующиеся версии одного крейта в дереве и явный deny-лист банов конкретных крейтов.
$ cargo install cargo-audit cargo-deny
$ cargo audit
Fetching advisory database from `https://github.com/RustSec/advisory-db.git`
Loaded 650 security advisories
Scanning Cargo.lock for vulnerabilities (312 crate dependencies)
error: 1 vulnerability found!
ID: RUSTSEC-2023-00XX
Crate: example-crate
Version: 0.4.1
Title: Use-after-free in buffer handling
Solution: Upgrade to >=0.4.2
# deny.toml
[advisories]
db-path = "~/.cargo/advisory-db"
db-urls = ["https://github.com/rustsec/advisory-db"]
yanked = "deny" # запретить yanked-версии крейтов
ignore = [
# "RUSTSEC-2020-0159", # пример временного игнора с обоснованием в комментарии
]
[licenses]
allow = ["MIT", "Apache-2.0", "BSD-3-Clause"]
deny = ["GPL-3.0"] # несовместимая лицензия для проприетарного продукта
[bans]
multiple-versions = "warn" # несколько версий одного крейта раздувают бинарник
deny = [
{ name = "openssl", reason = "используем rustls вместо openssl-sys" },
]
# .github/workflows/security.yml
- name: cargo audit
run: |
cargo install cargo-audit --locked
cargo audit --deny warnings
- name: cargo deny
uses: EmbarkStudios/cargo-deny-action@v2
Оба инструмента стоит запускать не только в CI на каждый PR, но и по расписанию (nightly cron) — Cargo.lock может не меняться неделями, а новая уязвимость в уже используемой версии зависимости появляется в advisory-базе независимо от ваших коммитов.
sqlx в режиме query!/query_as! проверяет SQL-запросы на этапе компиляции против реальной схемы БД (через подключение к dev-базе или офлайн-кэш .sqlx) — это устраняет не только классическую инъекцию через конкатенацию строк, но и целый класс ошибок в именах колонок и типах, которые в других языках всплывают только в рантайме.
// Плохо: динамическая конкатенация — классическая SQL-инъекция
async fn find_user_unsafe(pool: &PgPool, name: &str) -> sqlx::Result<Vec<User>> {
let query = format!("SELECT * FROM users WHERE name = '{name}'");
sqlx::query_as::<_, User>(&query).fetch_all(pool).await // name = "' OR '1'='1" ломает всё
}
// Хорошо: query! проверяется компилятором против схемы БД, параметры биндятся
async fn find_user(pool: &PgPool, name: &str) -> sqlx::Result<Vec<User>> {
sqlx::query_as!(User, "SELECT id, name, email FROM users WHERE name = $1", name)
.fetch_all(pool)
.await
}
serde сам по себе безопасен от memory-corruption атак (в отличие от небезопасной десериализации в некоторых других экосистемах), но остаётся уязвим к resource exhaustion: глубоко вложенный JSON может исчерпать стек при рекурсивном разборе, а огромный массив/строка — память. Всегда ограничивайте размер входа на границе сервиса.
use serde::Deserialize;
use axum::{extract::DefaultBodyLimit, Router};
#[derive(Deserialize)]
struct CreateOrderRequest {
#[serde(deserialize_with = "validate_item_count")]
items: Vec<OrderItem>,
}
fn validate_item_count<'de, D>(deserializer: D) -> Result<Vec<OrderItem>, D::Error>
where
D: serde::Deserializer<'de>,
{
let items = Vec::<OrderItem>::deserialize(deserializer)?;
if items.len() > 1000 {
return Err(serde::de::Error::custom("too many items"));
}
Ok(items)
}
// Лимит на размер тела запроса — защита от DoS через огромный payload
fn app() -> Router {
Router::new()
.layer(DefaultBodyLimit::max(1_048_576)) // 1 MiB
}
// SSRF: сервер делает исходящий запрос по адресу, присланному клиентом —
// без проверки атакующий достаёт метаданные облака (169.254.169.254) или внутренние сервисы
async fn fetch_webhook_safe(url: &str) -> Result<String, String> {
let parsed = url::Url::parse(url).map_err(|_| "invalid url".to_string())?;
match parsed.host_str() {
Some(host) if is_public_host(host) => { /* разрешаем */ }
_ => return Err("host not allowed".to_string()),
}
// + резолвим DNS и повторно проверяем итоговый IP не приватный (защита от DNS rebinding)
Ok("...".to_string())
}
// Path traversal: нормализация и проверка префикса ОБЯЗАТЕЛЬНЫ
use std::path::{Path, PathBuf};
fn safe_join(base: &Path, user_input: &str) -> Option<PathBuf> {
let candidate = base.join(user_input);
let canonical = candidate.canonicalize().ok()?; // разрешает ".." и симлинки
let base_canonical = base.canonicalize().ok()?;
canonical.starts_with(&base_canonical).then_some(canonical) // "../../etc/passwd" отсекается здесь
}
// SAFETY: ..., запускайте Miri на тестах, затрагивающих unsafe-код.Debug/логи; секреты боевого окружения — в Vault/Secrets Manager, а не в env-переменных.