Revisão de segurança em unsafe
LidoEsta aula revisa práticas essenciais para garantir segurança ao usar código unsafe em Rust, abordando a ferramenta Miri para detecção de UB, invariantes que devem ser mantidos, documentação adequada de contratos e processos de auditoria. O objetivo é consolidar o conhecimento para escrever e revisar código unsafe de forma responsável.
Escrever código unsafe em Rust é uma grande responsabilidade. Embora o compilador garanta segurança na maioria das situações, ao usar unsafe você assume manualmente a responsabilidade por manter invariantes que o compilador não pode verificar. Nesta aula, vamos revisar as principais ferramentas e práticas para garantir que seu código unsafe seja o mais seguro possível, minimizando riscos de comportamento indefinido (UB) e vulnerabilidades.
Ao final desta aula, você terá uma compreensão sólida de como usar Miri para detectar problemas, como definir e manter invariantes, como documentar contratos de segurança e como conduzir uma auditoria eficaz. Essas habilidades são essenciais para qualquer desenvolvedor que precise trabalhar com FFI, otimizações de baixo nível ou estruturas de dados complexas.
Miri (visão geral)
Miri é um interpretador de Rust que executa o código de forma simbólica, permitindo detectar comportamento indefinido (UB) em tempo de execução. Ele é uma ferramenta indispensável para testar código unsafe, pois muitas formas de UB não são detectadas pelo compilador, como desreferenciar ponteiros nulos, acessos fora dos limites de um buffer, violações de aliasing e uso de memória não inicializada.
Miri funciona interpretando o código em uma máquina virtual que rastreia o estado da memória e as operações realizadas. Ele é especialmente útil em testes, pois pode ser executado em um conjunto de testes com cargo miri test. Miri não é um substituto para testes tradicionais, mas um complemento que pode pegar bugs sutis que passariam despercebidos.
Para instalar Miri, você precisa usar uma versão nightly do Rust e instalar o componente:
rustup component add miriEm seguida, você pode executar seus testes com Miri:
cargo miri testExemplo de uso: considere o seguinte código unsafe que cria uma fatia a partir de um ponteiro:
fn main() {
let mut data = [1, 2, 3, 4, 5];
let ptr = data.as_mut_ptr();
let slice = unsafe { std::slice::from_raw_parts_mut(ptr, 5) };
slice[0] = 10;
println!("{:?}", data);
}Com Miri, se você criar uma fatia com tamanho maior que o buffer real, Miri detectará um erro de acesso fora dos limites. Isso é crucial para validar a segurança de código que manipula memória bruta.
Invariantes
Invariantes são condições que devem ser verdadeiras durante toda a execução de um programa ou para uma estrutura de dados específica. Em código unsafe, você é responsável por garantir que certos invariantes sejam mantidos, pois o compilador não pode verificá-los. Por exemplo, se você implementar uma estrutura de dados que usa ponteiros, deve garantir que os ponteiros sempre apontem para memória válida e que os índices estejam dentro dos limites.
Um invariante comum é a validade de um ponteiro: ele deve ser não nulo, alinhado e apontar para memória válida e inicializada. Outro invariante é o aliasing: se você possui uma referência mutável, não pode haver outra referência (mutável ou imutável) para o mesmo dado simultaneamente.
Ao projetar uma API unsafe, é fundamental documentar quais invariantes o chamador deve garantir. Por exemplo, a função std::slice::from_raw_parts exige que o ponteiro seja não nulo, alinhado e que o intervalo de memória esteja inicializado para o tamanho fornecido. Se o chamador violar esses invariantes, o comportamento é indefinido.
Vamos criar um exemplo de uma estrutura que usa unsafe para armazenar um buffer:
struct Buffer {
ptr: *mut u8,
len: usize,
}
impl Buffer {
fn new(len: usize) -> Self {
let layout = std::alloc::Layout::from_size_align(len, 1).unwrap();
let ptr = unsafe { std::alloc::alloc(layout) };
if ptr.is_null() {
panic!("allocation failed");
}
Buffer { ptr, len }
}
fn as_slice(&self) -> &[u8] {
unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
}
}
impl Drop for Buffer {
fn drop(&mut self) {
let layout = std::alloc::Layout::from_size_align(self.len, 1).unwrap();
unsafe { std::alloc::dealloc(self.ptr, layout) };
}
}Neste exemplo, os invariantes são: ptr é não nulo e aponta para um bloco de memória alocado de tamanho len. O método as_slice só pode ser chamado se esses invariantes forem mantidos. Como a estrutura garante isso internamente (a alocação é feita no construtor e desalocada no Drop), é seguro expor essa API sem unsafe ao chamador, desde que a estrutura não seja exposta de forma que possa ser violada.
Documentando unsafe
A documentação de código unsafe é crucial para que outros desenvolvedores (e você mesmo no futuro) entendam quais condições devem ser satisfeitas. O Rust tem uma convenção: sempre que uma função ou método for unsafe, você deve documentar os requisitos de segurança em uma seção chamada # Safety.
Essa seção deve listar todas as pré-condições que o chamador deve garantir. Por exemplo, se uma função aceita um ponteiro, você deve especificar que ele deve ser não nulo, alinhado e apontar para memória válida. Se a função cria uma fatia, você deve especificar o tamanho e a validade do intervalo.
Vamos documentar uma função unsafe:
/// Divide um slice em dois no índice `mid`.
///
/// # Safety
///
/// O chamador deve garantir que `slice` seja válido e que `mid` seja menor ou igual ao comprimento do slice.
unsafe fn split_at(slice: &[u8], mid: usize) -> (&[u8], &[u8]) {
let len = slice.len();
let ptr = slice.as_ptr();
unsafe {
(std::slice::from_raw_parts(ptr, mid),
std::slice::from_raw_parts(ptr.add(mid), len - mid))
}
}Além da documentação, é importante usar nomes de funções que deixem claro o contrato. Por exemplo, from_raw_parts já indica que se espera um ponteiro bruto. Mas a documentação é o que realmente garante que o chamador entenda os riscos.
Outra prática é usar SAFETY comments dentro do código unsafe para explicar por que cada operação é segura. Isso ajuda na revisão e manutenção.
Auditoria
Auditoria de código unsafe é o processo de revisar cuidadosamente o código para verificar se todas as invariantes são mantidas e se não há UB. Isso pode ser feito manualmente ou com ferramentas automatizadas, como Miri e sanitizers.
Uma auditoria eficaz deve seguir um roteiro: identificar todos os blocos unsafe, verificar se cada operação é justificada, se as pré-condições são atendidas, se não há vazamentos de memória, se o aliasing é respeitado, etc.
Ferramentas como cargo geiger podem ajudar a localizar onde unsafe é usado no projeto. Além disso, sanitizers como AddressSanitizer e ThreadSanitizer podem ser usados com Rust nightly para detectar erros de memória em tempo de execução.
Exemplo de auditoria manual: considere o seguinte código:
fn main() {
let mut v = vec![1, 2, 3];
let ptr = v.as_mut_ptr();
unsafe {
*ptr = 10;
}
println!("{:?}", v);
}Aqui, o ponteiro é obtido de um vetor, e a escrita é feita no primeiro elemento. Isso é seguro, pois o ponteiro permanece válido enquanto o vetor não for modificado. No entanto, se o vetor for realocado antes do uso do ponteiro, o ponteiro se torna inválido. A auditoria deve garantir que o ponteiro não seja usado após qualquer operação que possa realocar o vetor.
Além disso, é importante revisar o uso de unsafe em bibliotecas externas. Verifique se as funções FFI são declaradas corretamente e se os tipos correspondem aos da linguagem C.
Boas práticas e observações finais
Para finalizar, aqui estão algumas boas práticas ao trabalhar com unsafe:
- Minimize o uso de
unsafe: envolva-o em abstrações seguras sempre que possível. - Documente todos os contratos de segurança com
# Safety. - Use Miri e sanitizers em seus testes para detectar UB.
- Faça revisões de código com foco em
unsafe. - Mantenha o código
unsafeo mais simples e isolado possível.
Lembre-se de que unsafe não desativa todas as verificações do compilador; ele apenas permite operações que podem violar a segurança de memória. Portanto, o cuidado é sempre necessário.
Referências
- Documentação da std do Rust
- Repositório do Miri
- Unsafe Code Guidelines Working Group
- The Rustonomicon
- Lista de lints do rustc
- Crate Geiger (auditoria de unsafe)
Exercícios
- Explique o que é Miri e como ele ajuda a detectar comportamento indefinido em Rust. Dê um exemplo de um problema que Miri pode detectar.
- Quais são os invariantes que uma função como
std::slice::from_raw_partsexige do chamador? Liste pelo menos três e explique por que são importantes. - Escreva a documentação
# Safetypara uma funçãounsafe fn swap(a: *mut i32, b: *mut i32)que troca os valores apontados. - Dado o seguinte código, identifique pelo menos dois problemas de segurança que uma auditoria deveria encontrar:
fn main() { let mut data = [1, 2, 3]; let ptr = data.as_mut_ptr(); unsafe { std::ptr::write(ptr.offset(3), 4); // escreve fora dos limites } println!("{:?}", data); } - Explique como você conduziria uma auditoria de um módulo que contém vários blocos
unsafe. Descreva as etapas e ferramentas que usaria.
from_raw_parts; Miri detecta o acesso fora dos limites quando você tenta ler ou escrever além do tamanho real./// Troca os valores apontados por `a` e `b`.
///
/// # Safety
///
/// O chamador deve garantir que ambos os ponteiros sejam não nulos, alinhados e apontem para memória inicializada e válida. Além disso, os ponteiros não devem ser usados após a chamada até que a troca seja concluída, e não deve haver aliasing entre eles (ou seja, não podem apontar para o mesmo local).ptr.offset(3) cria um ponteiro fora do array (índice 3 é inválido, pois o array tem tamanho 3, índices 0..2), causando acesso fora dos limites e UB; (2) o ponteiro é usado após a operação? Não, mas o principal é o acesso fora dos limites. Além disso, se o array fosse realocado, o ponteiro se tornaria inválido, mas aqui não é o caso.unsafe usando cargo geiger ou busca textual; (2) revisar cada bloco, verificando se cada operação unsafe é justificada e se as pré-condições são atendidas; (3) usar Miri para executar testes e detectar UB; (4) usar sanitizers (como AddressSanitizer) em testes de estresse; (5) verificar a documentação # Safety e garantir que ela esteja completa; (6) revisar o gerenciamento de memória manual (alloc/dealloc) para garantir que não haja vazamentos ou uso após liberação; (7) verificar se não há aliasing indevido; (8) documentar as descobertas e corrigir problemas.