A Mozilla lançou o
Quantum CSS para Firefox no ano passado, culminando em oito anos de desenvolvimento do Rust, uma linguagem de programação de sistema amiga da memória. Demorou mais de um ano para reescrever o principal componente do navegador no Rust.
Até agora, todos os principais mecanismos de navegador são escritos em C ++, principalmente por razões de eficiência. Mas com grande desempenho, vem uma grande responsabilidade: os programadores de C ++ devem gerenciar manualmente a memória, o que abre a caixa de vulnerabilidades do Pandora. O Rust não apenas corrige esses erros, mas seus métodos também impedem
a corrida de dados , permitindo que os programadores implementem código paralelo com mais eficiência.
O que é segurança de memória?
Quando falamos em criar aplicativos seguros, geralmente mencionamos a segurança da memória. Não oficialmente, queremos dizer que em nenhum estado o programa pode acessar memória inválida. Causas de violações de segurança:
- salvar o ponteiro após liberar memória (usar após livre);
- desreferenciar um ponteiro nulo;
- uso de memória não inicializada;
- programa tenta liberar a mesma célula duas vezes (duplo-livre);
- estouro de buffer.
Para uma definição mais formal, consulte
O que é segurança de memória , de Michael Hicks, bem como um
artigo científico sobre esse tópico.
Tais violações podem levar a uma falha inesperada ou alteração no comportamento esperado do programa. Possíveis conseqüências: vazamento de informações, execução arbitrária de código e execução remota de código.
Gerenciamento de memória
O gerenciamento de memória é essencial para o desempenho e a segurança do aplicativo. Nesta seção, consideraremos o modelo de memória básico. Um dos principais conceitos são os
ponteiros . Essas são variáveis nas quais os endereços de memória são armazenados. Se formos para este endereço, veremos alguns dados lá. Portanto, dizemos que o ponteiro é uma referência a esses dados (ou aponta para eles). Assim como o endereço residencial diz às pessoas onde encontrá-lo, o endereço da memória mostra o programa onde encontrar os dados.
Tudo no programa está localizado em endereços de memória específicos, incluindo instruções de código. O uso incorreto de ponteiros pode levar a sérias vulnerabilidades, incluindo vazamento de informações e execução arbitrária de códigos.
Alocação / Liberação
Quando criamos uma variável, o programa deve alocar espaço suficiente na memória para armazenar os dados dessa variável. Como cada processo possui uma quantidade limitada de memória, é claro, você precisa de uma maneira de
liberar recursos. Quando a memória é liberada, ela fica disponível para armazenar novos dados, mas os dados antigos permanecem lá até que a célula seja substituída.
Buffers
Um buffer é uma área de memória contígua na qual várias instâncias do mesmo tipo de dados são armazenadas. Por exemplo, a frase "Meu gato é batman" será armazenada em um buffer de 16 bytes. Os buffers são determinados pelo endereço e comprimento iniciais. Para não danificar os dados na memória vizinha, é importante garantir que não lemos ou gravemos fora do buffer.
Controle de fluxo
Os programas consistem em rotinas que são executadas em uma ordem específica. No final da sub-rotina, o computador vai para o ponteiro armazenado para a próxima parte do código (chamado
endereço de retorno ). Quando você vai para o endereço de retorno, uma das três coisas acontece:
- O processo continua normalmente (o endereço de retorno não é alterado).
- O processo falha (o endereço foi alterado e aponta para a memória não executável).
- O processo continua, mas não conforme o esperado (o endereço de retorno foi alterado e o fluxo de controle foi alterado).
Como os idiomas fornecem segurança de memória
Todas as linguagens de programação pertencem a diferentes partes do
espectro . De um lado do espectro, estão linguagens como C / C ++. Eles são eficazes, mas requerem gerenciamento manual de memória. Por outro lado, linguagens interpretadas com gerenciamento automático de memória (por exemplo, contagem de referência e coleta de lixo), mas compensam com o desempenho. Mesmo idiomas com coleta de lixo bem otimizada não podem ser comparados no
desempenho com idiomas sem GC.
Gerenciamento manual de memória
Alguns idiomas (por exemplo, C) exigem que os programadores gerenciem manualmente a memória: quando e quanta memória alocar, quando liberá-la. Isso dá ao programador controle completo sobre como o programa usa recursos, fornecendo código rápido e eficiente. Mas essa abordagem é propensa a erros, especialmente em bases de código complexas.
Erros fáceis de cometer:
- esqueça que os recursos são gratuitos e tente usá-los;
- não aloque espaço suficiente para armazenamento de dados;
- leia a memória fora do buffer.
Instruções de segurança adequadas para quem gerencia a memória manualmentePonteiros inteligentes
Ponteiros inteligentes fornecem informações adicionais para evitar o gerenciamento inadequado da memória. Eles são usados para gerenciamento automático de memória e verificação de borda. Ao contrário de um ponteiro comum, um ponteiro inteligente é capaz de se autodestruir e não esperará que o programador o exclua manualmente.
Existem várias opções para essa construção, que agrupa o ponteiro original em várias abstrações úteis. Alguns ponteiros inteligentes
contam referências para cada objeto, enquanto outros implementam uma política de escopo para limitar a vida útil do ponteiro a determinadas condições.
Ao contar links, os recursos são liberados quando a última referência ao objeto é excluída. As implementações básicas de contagem de referência sofrem com desempenho ruim, aumento no consumo de memória e são difíceis de usar em ambientes com vários threads. Se os objetos se referirem um ao outro (links circulares), a contagem de referência para cada objeto nunca chegará a zero; portanto, são necessários métodos mais complexos.
Coleta de lixo
Algumas linguagens (por exemplo, Java, Go, Python) implementam a
coleta de lixo . Uma parte do ambiente de tempo de execução, chamada de coletor de lixo (GC), controla variáveis e identifica recursos inacessíveis no gráfico de links entre objetos. Assim que o objeto ficar indisponível, o GC libera memória de base para reutilização no futuro. Qualquer alocação e liberação de memória ocorre sem um comando explícito do programador.
Embora o GC garanta que a memória seja sempre usada corretamente, ele não libera a memória da maneira mais eficiente - às vezes o último uso de um objeto ocorre muito antes do coletor de lixo liberar a memória. Os custos de desempenho são proibitivos para aplicativos de missão crítica: às vezes você precisa usar 5 vezes mais memória para evitar a degradação do desempenho.
Posse
O Rust utiliza a propriedade para garantir alto desempenho e segurança da memória. Mais formalmente, este é um exemplo de
digitação por afinidade . Todo o código Rust segue certas regras que permitem ao compilador gerenciar memória sem perder o tempo de execução:
- Cada valor possui uma variável chamada proprietário.
- Somente um proprietário pode estar por vez.
- Quando o proprietário sai do escopo, o valor é excluído.
Os valores podem ser
transferidos ou
emprestados de uma variável para outra. Essas regras se aplicam a uma parte do compilador denominada verificador de empréstimo.
Quando uma variável sai do escopo, o Rust libera essa memória. No exemplo a seguir, as variáveis
s1
e
s2
vão além do escopo, ambas tentam liberar a mesma memória, o que leva a um erro de liberação dupla. Para evitar isso, ao transferir um valor de uma variável, o proprietário anterior se torna inválido. Se o programador tentar usar uma variável inválida, o compilador rejeitará o código. Isso pode ser evitado criando uma cópia profunda dos dados ou usando links.
Exemplo 1 : Transferência de propriedade
let s1 = String::from("hello"); let s2 = s1;
Outro conjunto de regras do verificador de empréstimos se refere ao tempo de vida das variáveis. O Rust proíbe o uso de variáveis não inicializadas e ponteiros pendentes para objetos inexistentes. Se você compilar o código do exemplo abaixo,
r
fará referência a uma memória que é liberada quando
x
sai do escopo: ocorre um ponteiro pendente. O compilador monitora todas as áreas e verifica a validade de todas as transferências, às vezes exigindo que o programador indique explicitamente o tempo de vida da variável.
Exemplo 2 : ponteiro suspenso
let r; { let x = 5; r = &x; } println!("r: {}", r);
O modelo de propriedade fornece uma base sólida para o acesso correto à memória, evitando comportamentos indefinidos.
Vulnerabilidades de memória
As principais conseqüências da memória vulnerável:
- Falha : acessar a memória inválida pode causar o encerramento inesperado do aplicativo.
- Vazamento de informações : fornecimento não intencional de dados privados, incluindo informações confidenciais, como senhas.
- Execução de código arbitrário (ACE) : permite que um invasor execute comandos arbitrários na máquina de destino. Se isso acontecer na rede, chamamos de RCE (Execução Remota de Código).
Outro problema é
um vazamento de memória quando a memória alocada não é liberada após o término do programa. Assim, você pode usar toda a memória disponível: as solicitações de recursos são bloqueadas, o que levará a uma negação de serviço. Este é um problema de memória que não pode ser resolvido no nível do PL.
Na melhor das hipóteses, com um erro de memória, o aplicativo falhará. Na pior das hipóteses, um invasor obtém o controle de um programa por meio de uma vulnerabilidade (que pode levar a novos ataques).
Abusos de memória liberada (use após livre, duplo grátis)
Essa subclasse de vulnerabilidades ocorre quando um recurso é liberado, mas um link para seu endereço ainda é preservado. Este é um
método hacker poderoso que pode levar a acesso fora de alcance, vazamento de informações, execução de código e muito mais.
Os idiomas com coleta de lixo e contagem de referência impedem o uso de ponteiros inválidos, destruindo apenas objetos inacessíveis (que podem levar à degradação do desempenho) e os idiomas controlados manualmente são vulneráveis a essa vulnerabilidade (especialmente em bases de código complexas). A ferramenta verificador de empréstimo no Rust não permite que objetos sejam destruídos enquanto é referenciada; portanto, esses erros são removidos no estágio de compilação.
Variáveis não inicializadas
Se a variável for usada antes da inicialização, esses dados poderão conter quaisquer dados, incluindo lixo aleatório ou dados descartados anteriormente, o que leva ao vazamento de informações (às vezes são chamadas de
ponteiros inválidos ). Para evitar esses problemas, os idiomas de gerenciamento de memória geralmente usam o procedimento de inicialização automática após alocar memória.
Como em C, a maioria das variáveis no Rust não é inicializada inicialmente. Mas, diferentemente de C, você não pode lê-los antes da inicialização. O código a seguir não compila:
Exemplo 3 : Usando uma variável não inicializada
fn main() { let x: i32; println!("{}", x); }
Ponteiros nulos
Quando um aplicativo desreferencia um ponteiro que acaba sendo nulo, geralmente apenas acessa o lixo e causa uma falha. Em alguns casos, essas vulnerabilidades podem levar à execução de código arbitrário (
1 ,
2 ,
3 ). Rust possui dois tipos de ponteiros:
links e ponteiros brutos. Os links são seguros, mas ponteiros brutos podem ser um problema.
A ferrugem impede a desreferenciação de um ponteiro nulo de duas maneiras:
- Evite ponteiros anuláveis.
- Evite remover referências de ponteiros brutos.
A ferrugem evita ponteiros nulos, substituindo-os pelo
Option
especial. Para alterar o valor possível-nulo no tipo
Option
, a linguagem requer que o programador manipule explicitamente o caso com um valor nulo, caso contrário, o programa não será compilado.
O que fazer se ponteiros que permitem um valor nulo não puderem ser evitados (por exemplo, ao interagir com código em outro idioma)? Tente isolar o dano. A desreferenciação de ponteiros brutos deve ocorrer em um bloco não seguro isolado. Ele
afrouxa as regras de Rust e resolve algumas operações que podem causar comportamento indefinido (por exemplo, desreferenciando um ponteiro bruto).
"Tudo sobre o chekcer emprestado ... e aquele lugar escuro?"
- Este é um bloco inseguro. Nunca vá lá, SimbaEstouro de buffer
Discutimos vulnerabilidades que podem ser evitadas restringindo o acesso à memória indefinida. Mas o problema é que o estouro de buffer não acessa corretamente a memória indefinida, mas legalmente alocada. Como o bug do uso após livre, esse acesso pode ser um problema porque acessa a memória liberada, que ainda contém informações confidenciais que não devem mais existir.
Estouros de buffer significam simplesmente acesso fora dos limites. Devido à maneira como os buffers são armazenados na memória, eles geralmente vazam informações que podem conter dados confidenciais, incluindo senhas. Em casos mais graves, as vulnerabilidades do ACE / RCE são possíveis substituindo o ponteiro de instrução.
Exemplo 4: estouro de buffer (código C)
int main() { int buf[] = {0, 1, 2, 3, 4};
A proteção mais simples contra estouros de buffer é sempre exigir verificações de borda ao acessar elementos, mas isso leva a
um desempenho ruim .
O que a ferrugem faz? Os tipos de buffer internos na biblioteca padrão requerem verificações de borda para qualquer acesso aleatório, mas também fornecem APIs de iterador para acelerar as chamadas seqüenciais. Isso garante que a leitura e gravação fora dos limites não seja possível para esses tipos. O Rust promove padrões que exigem verificações de borda apenas em locais onde você quase certamente precisa colocá-los manualmente em C / C ++.
A segurança da memória é apenas metade da batalha
As violações de segurança levam a vulnerabilidades, como vazamento de dados e execução remota de código. Existem várias maneiras de proteger a memória, incluindo ponteiros inteligentes e coleta de lixo. Você pode até
provar formalmente a segurança da memória . Embora alguns idiomas tenham concordado com a degradação do desempenho em prol da segurança da memória, o conceito de propriedade da Rust fornece segurança e minimiza a sobrecarga.
Infelizmente, erros de memória são apenas parte da história quando falamos em escrever código seguro. No próximo artigo, consideraremos a segurança do encadeamento e os ataques ao código paralelo.
Explorando vulnerabilidades de memória: recursos adicionais