← Назад к списку тем

26. Безопасность

Гарантии memory safety, криптография (ring/RustCrypto), secrets, cargo audit/deny, OWASP Top 10, unsafe как поверхность атаки.

Memory safety как фундамент секьюрити

Значительная доля исторических 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 overflowUB (signed) или wraparound (unsigned) молчаpanic в debug, явный wrapping/checked/saturating в release
SQL injection, SSRF, broken authзависит от разработчиказависит от разработчика — язык не спасает
🔑 Ключевая мысль: memory safety — это гигиенический минимум, а не полная security-модель. Rust переносит фокус ревью безопасности с "не забыли ли где free()" на бизнес-логику: авторизацию, криптографию, доверие к вводу — именно там теперь живут оставшиеся уязвимости.

unsafe как явная поверхность атаки

Блок 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)
    }
}

Типичные источники UB в unsafe-коде

  • Dangling pointers — сырой указатель пережил объект, на который указывал (частый баг при работе с FFI и self-referential структурами).
  • Нарушение aliasing-правил — одновременно существующие &mut T и &T на один адрес, полученные через unsafe-приведение.
  • Неинициализированная память — чтение MaybeUninit<T> до вызова assume_init().
  • Нарушение Send/Sync — ручная реализация этих unsafe-трейтов для типа, реально не потокобезопасного.
  • Integer-to-pointer casts с произвольной арифметикой указателей за пределами выделенного объекта.

Для поиска UB в unsafe-коде критически важен Miri (cargo +nightly miri test) — интерпретатор MIR, детектирующий большинство нарушений aliasing-модели, чтение неинициализированной памяти и выход за границы аллокации, которые в обычном запуске могут "случайно сработать" без видимых симптомов.

⚠️ unsafe не значит "проверок нет": это лишь означает, что компилятор больше не проверяет пять конкретных инвариантов за вас — их обязан поддерживать сам код. Каждый unsafe-блок должен сопровождаться комментарием // SAFETY: ..., объясняющим, почему именно здесь инвариант выполнен — это требует clippy-lint undocumented_unsafe_blocks.

Криптография: ring vs RustCrypto

В экосистеме исторически сосуществуют два принципиально разных подхода к криптографии. 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 напрямую
}
🚫 Никогда: сравнение MAC/хешей паролей через == — обычное сравнение байт-в-байт прерывается на первом несовпадении и создаёт timing side-channel, позволяющий восстановить секрет по времени ответа. Всегда используйте constant-time сравнение (verify_slice, subtle::ConstantTimeEq) и никогда не пишите свой алгоритм шифрования/хеширования с нуля.

Secrets management

Секреты в памяти процесса — токены, пароли, приватные ключи — представляют риск даже при отсутствии сетевых утечек: 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-репорты.
  • Файлы с ограниченными правами (0600) — лучше env, но требует ротации и контроля доступа вручную.
  • Vault / AWS Secrets Manager / GCP Secret Manager — секреты запрашиваются в рантайме по короткоживущим токенам, поддерживают ротацию, аудит доступа и динамическую генерацию учётных данных (например, временный пароль к БД на несколько минут).
  • KMS + envelope encryption — для секретов, хранящихся в БД/конфиге в зашифрованном виде.
🔑 Правило по умолчанию: любое поле с секретом должно иметь тип, который физически не может утечь в Debug/Display/Serialize по умолчанию. Явный #[derive(Debug)] на структуре с паролем — самый частый источник утечки секретов в логи в реальных инцидентах.

cargo audit и cargo deny

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-базе независимо от ваших коммитов.

OWASP Top 10 в контексте Rust-сервисов

Injection: SQL и не только

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
}

Insecure deserialization: недоверенный ввод в serde

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 и path traversal

// 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" отсекается здесь
}
⚠️ Типобезопасность не равна доверию к вводу: компилятор Rust гарантирует, что тип на входе соответствует объявленному, но не гарантирует, что значение безопасно использовать — размер, диапазон, доверенность источника (URL, путь, вложенность JSON) по-прежнему обязанность разработчика проверить явно.

Best practices — итоговый чеклист

  • Минимизируйте unsafe: изолируйте в отдельных модулях, каждый блок сопровождайте комментарием // SAFETY: ..., запускайте Miri на тестах, затрагивающих unsafe-код.
  • Не пишите собственную криптографию — используйте проверенные крейты (ring, RustCrypto) и алгоритмы верхнего уровня (AEAD, argon2), а не примитивы (raw AES-ECB, самодельные MAC).
  • Все секреты — через secrecy::Secret<T> или zeroize, никогда не выводите их в Debug/логи; секреты боевого окружения — в Vault/Secrets Manager, а не в env-переменных.
  • Подключите cargo audit и cargo deny в CI и в ночной cron — advisory-база пополняется независимо от ваших коммитов.
  • Все SQL-запросы — через sqlx::query!/query_as! с compile-time проверкой, никогда через конкатенацию строк.
  • На границе сервиса ограничивайте размер и глубину входного JSON/payload — resource exhaustion работает независимо от memory safety языка.
  • Для исходящих запросов по пользовательскому URL и файловых путей — явная валидация против allowlist/канонизации, а не только "это компилируется".
  • Помните: Rust устраняет память-safety классы CVE, но не устраняет логические уязвимости — SQL injection, SSRF, broken authorization остаются полностью на ответственности архитектуры и ревью.
✅ Итог: Rust даёт сильный фундамент — компилятор берёт на себя целый класс низкоуровневых ошибок, годами наполнявших CVE-базы C/C++ проектов. Но это освобождённое внимание нужно осознанно перенаправить на криптографию, secrets management и валидацию недоверенного ввода — именно там прячутся оставшиеся риски в Rust-сервисах.