- Comparar diretamente a quantidade de CVEs em Rust e C/C++ pode ignorar a diferença de critério sobre tratar vulnerabilidades de segurança de memória como “problemas da biblioteca”
- Em C/C++, mesmo que uma chamada incorreta de API cause UB ou segfault, isso normalmente é tratado como mau uso do código pelo usuário, e a mera possibilidade em si não costuma ser registrada como CVE
- A chamada
curl_getenv(NULL) em libcurl pode ser compilada sem aviso e causar segfault em tempo de execução, mas normalmente isso não é visto como uma vulnerabilidade do curl
- Em Rust, se não houver
unsafe no código do usuário e um bug de memória ocorrer apenas com chamadas a APIs seguras, isso é considerado um soundness bug da biblioteca
- Por isso, alguns CVEs em Rust são registrados com critérios mais rigorosos do que em C/C++, e fica difícil avaliar segurança de memória apenas comparando a contagem bruta de CVEs
Por que a comparação do número de CVEs oscila
- CVE é um banco de dados que classifica e reporta vulnerabilidades de segurança em software
- Uma vulnerabilidade pode surgir tanto de um simples bug de lógica do programa quanto de um problema de segurança de memória com maior chance de levar a exploração
- Ao comparar a quantidade de CVEs entre Rust e C/C++, surgem também alegações de que Rust “na prática não é seguro em memória” ou “não vale a pena adotar”
- Mas há uma grande diferença na forma como os dois ecossistemas tratam potenciais vulnerabilidades relacionadas à segurança de memória
Vulnerabilidades também são possíveis em Rust
- Programas em Rust também podem causar UB e bugs de segurança de memória
- Na maioria dos casos, esse tipo de problema exige a palavra-chave
unsafe
- Está errado dizer que programas em Rust jamais podem sofrer UB
- Vulnerabilidades comuns sem relação com segurança de memória também são possíveis em Rust
- Um problema como esquecer a verificação de permissão de acesso a um painel administrativo pode ocorrer em qualquer linguagem
Exemplo de biblioteca em C: curl_getenv(NULL)
curl é uma biblioteca de rede baseada em C, amplamente usada e bem mantida
curl_getenv da libcurl é uma função de abstração portável para obter o valor de variáveis de ambiente em vários sistemas operacionais
- O programa em C a seguir passa um ponteiro
NULL para curl_getenv
#include <curl/curl.h>
int main(void) {
curl_getenv(NULL);
}
- Esse programa pode ser compilado sem avisos com
gcc test.c -otest -lcurl -Wall -Wextra
- Ao executar, pode ocorrer um segfault, o que pode ser visto como um bug de segurança de memória e uma vulnerabilidade potencial
- Mas um caso assim normalmente não é tratado como uma vulnerabilidade do
curl
Em C/C++, a mera possibilidade de mau uso não vira CVE
- Quando ocorre um problema como
curl_getenv(NULL), isso em geral é considerado uso incorreto da API
- A origem do defeito também costuma ser atribuída ao código da aplicação, e não à biblioteca ou à API
- Há duas razões para essa prática
- O sistema de tipos limitado de C dificulta expressar com precisão o contrato da API, invariantes, pré-condições e pós-condições
- Também não é prático documentar todas as formas possíveis de uso incorreto
- De fato, a documentação de
curl_getenv não diz que chamar com NULL é proibido nem que isso pode levar a segfault
- Em C/C++, é muito fácil provocar UB por acidente, e se toda possibilidade potencial de vulnerabilidade fosse reportada como CVE, a maioria das bibliotecas poderia acabar soterrada por uma quantidade enorme de CVEs
- Por isso, em C/C++ os CVEs normalmente são criados com foco em um caso específico de mau uso, e não na simples “existência de uma API sujeita a uso incorreto”
Em Rust, o limite de responsabilidade de APIs seguras é diferente
- Em Rust, se assumirmos que apenas uma chamada segura como
hyper::foo(None) já faz o programa sofrer segfault, isso pode virar um CVE de hyper
- Isso porque, se o programa do usuário não tem blocos
unsafe e mesmo assim ocorre um bug de memória, então a biblioteca precisa ter um soundness bug
- Em Rust, se um bug de memória pode acontecer por qualquer forma de uso de uma API segura de biblioteca, isso é visto como bug da biblioteca, e não do código do usuário
- Diz-se que essa API é unsound ou que contém um soundness hole
- Mesmo que o problema ainda não tenha sido observado em um programa real, se o simples uso de uma API segura puder causar um bug de memória, um CVE pode ser criado
safe e unsafe deixam a responsabilidade explícita
- Em Rust, a resposta para “esta função está sendo usada corretamente do ponto de vista de segurança de memória?” é mais clara do que em C/C++
- Se a função chamada não está marcada como
unsafe, ela deveria poder ser usada com segurança
- Se a função chamada é
unsafe, então o ponto de chamada exige um bloco unsafe, e isso torna claros os pontos de risco tanto na revisão de código quanto no codebase
- Essa distinção é um dos fatores que tornam a segurança de memória de Rust escalável na prática
- Se o código do usuário não usa
unsafe e também não há bug do compilador, fica difícil atribuir ao código do usuário a responsabilidade por uma causa potencial de falha de segurança de memória
- Se a biblioteca não expõe uma interface
unsafe, o usuário não deveria conseguir utilizá-la de forma a causar bugs de memória
- Mesmo que a biblioteca use
unsafe internamente e introduza um bug, a correção é feita dentro da própria biblioteca, e o usuário volta a ficar protegido contra bugs de memória
Só a contagem bruta de CVEs não basta para comparar segurança de memória
- Se aplicarmos a mesma lógica ao C, então
curl_getenv também deveria aparecer como um CVE do curl, mas C não tem uma distinção como safe e unsafe de Rust
- Na prática, quase todo código C está implicitamente mais próximo de
unsafe, então é difícil aplicar diretamente o critério de Rust
- Mesmo que desenvolvedores de bibliotecas em C/C++ criem bibliotecas seguras e robustas, os inúmeros programas em C que as utilizam podem facilmente introduzir problemas de segurança de memória ao usar mal a API
- Essa diferença se aplica não só ao
curl, mas a praticamente todas as bibliotecas em C/C++ e também às bibliotecas padrão das duas linguagens
- Comparações de números brutos como CVEs por linha de código entre Rust e C/C++ podem levar a conclusões enganosas ao avaliar segurança de memória
1 comentários
Opiniões do Lobste.rs
Talvez seja uma pergunta ingênua, mas se muitos problemas de C/C++ vêm de comportamento indefinido, fico me perguntando por que não simplesmente defini-lo
Primeiro, há coisas que são resquícios históricos com as quais ninguém mais se importa e que daria para “simplesmente definir”; como @fanf disse, já há trabalho em andamento nisso. Por exemplo, um arquivo-fonte com um literal de string não terminado é, de fato, comportamento indefinido em C
Segundo, há coisas que poderiam ser definidas, mas com custo de desempenho. O exemplo clássico é overflow de inteiro com sinal: se você simplesmente definir que ele dá wraparound, deixa de ser comportamento indefinido, mas os compiladores não poderiam mais fazer otimizações baseadas na premissa de que “isso nunca acontece”. Há muita gente de compiladores no comitê, e eles tendem a ser obcecados por benchmarks, então não parece algo que vá ser corrigido facilmente. Ainda assim, não é que não haja mudança nenhuma; por exemplo, P2723 propõe implicitamente inicializar com zero todas as variáveis locais que de outro modo ficariam não inicializadas em C++
Terceiro, há coisas cujo comportamento é difícil de definir de forma razoável. Um bom exemplo é use-after-free. A menos que você imponha a todos um sistema pesado de capabilities em runtime como o Fil-C, ou acrescente anotações de lifetime no estilo Rust à linguagem inteira, não está claro como limitar o conjunto de comportamentos possíveis em um use-after-free. Dá para especificar algo como “em um use-after-free, toca a memória que estiver ali naquele momento ou dá segfault/aborta”, mas isso não ajuda ninguém. Continua perigoso, continua produzindo CVEs do mesmo jeito, e você ainda não consegue dizer nada de útil sobre o que o programa pode ou não fazer depois disso, então é comportamento indefinido com outro nome
Infelizmente, a terceira categoria tem um impacto tão dominante que “agora simplesmente definir” parte disso é bom, mas não muda muito o quadro geral
Até onde eu sei, as bibliotecas ainda quase não começaram a ser tratadas, mas funções que recebem argumentos de tamanho passaram a se comportar de forma razoável com ponteiros nulos. Isso aconteceu por causa de uma mudança na linguagem para permitir somar 0 a um ponteiro nulo. Há muitas funções que poderiam ser corrigidas de modo parecido, mas a mudança em
getenv()provavelmente faria mais sentido se fosse coordenada com o POSIXQuase todos esses ganhos de desempenho são extremamente pontuais e, na melhor das hipóteses, mínimos. Se existe uma função que chama
rm -rf /, mas que na prática nunca seria chamada, e você cria uma chamada via ponteiro de função com comportamento indefinido, então o compilador está tecnicamente autorizado a gerar código que chame incondicionalmente a função que apaga o disco. No fim, isso é só especificação mal projetada e herança históricafor (int ii = 0; ii < something; ii++)pode ignorar a possibilidade desomething == INT_MAXpor depender do fato de que overflow de inteiro com sinal é indefinido, e isso viabiliza várias transformações de laçoEm Rust, a funcionalidade equivalente é separada entre funções seguras e funções
unsafe. Funções seguras podem ser um pouco mais lentas, e funçõesunsafepermitem comportamento indefinido se usadas incorretamente. Vejai32::wrapping_add()ei32::unchecked_add()Se C tivesse como marcar certas funções como
unsafee uma notação para permitir o uso de funçõesunsafeem regiões específicas, daria para começar a definir variantes seguras. Mas, em algum momento, o esforço para mudar C — e, mais importante, mudar a cabeça de quem controla C — deixa de fazer sentido em relação ao objetivo, e fica mais fácil procurar uma linguagem que combine melhor com esse objetivoEm C, se você passa para
freeum ponteiro para um objeto no heap e depois acessa esse objeto, isso é comportamento indefinido. No CHERIoT, esse caso é definido para gerar um trap, mas isso só é possível porque construímos hardware que torna isso viável. O padrão precisa dar suporte a uma ampla variedade de hardwares, então a questão é: definir como exatamente?Há basicamente duas abordagens. Uma é adiar a liberação e dizer que o objeto não desaparece até que todos os ponteiros para ele também tenham desaparecido. Isso exige algo parecido com um coletor de lixo, e o overhead é pesado demais para muitos usos de C. A outra é definir um sistema de tipos capaz de saber onde estão todos os ponteiros para o objeto e invalidá-los. Rust adotou essa segunda abordagem, e é por isso que, para implementar estruturas de dados que não sejam árvores em Rust, você precisa de
unsafeou de recursos da biblioteca padrão que usamunsafe. Esse tipo de coisa pode ser incorporado no projeto da linguagem desde o início, mas é quase impossível acrescentar depoisErros de limites são parecidos. Em sistemas CHERI, os limites do objeto ou subobjeto são parte intrínseca do ponteiro, então acessos fora dos limites geram trap. Em outras plataformas, o ponteiro é só uma palavra contendo um endereço. Depois de fazer aritmética, não há como remapear isso ao objeto original, então o problema é de onde tirar os limites. Ferramentas como AddressSanitizer guardam os limites em estruturas separadas e exigem verificações na aritmética de ponteiros, mas o overhead de memória e desempenho é tão grande que, em produção, faz muito mais sentido usar Java do que C com ASan ativado — e provavelmente você também escreveria o código mais rápido
Eu achava que desreferenciar ponteiro nulo era comportamento bem definido
Tem uma parte deste texto que me incomoda
SEGFAULT é um ataque de negação de serviço, como um panic
Ambos pertencem à mesma categoria de erro, e quando se fala de segurança de memória, o que normalmente vem à cabeça são coisas como stack smashing, corrupção de dados, corrupção de código etc. Essas coisas são muito, muito mais difíceis em Rust e até certo ponto também podem ser dificultadas em C
O texto inteiro me pareceu, em grande parte, uma discussão sobre como o sistema de tipos de C é ruim. Em C++, dá para evitar esse tipo de erro, e em C, com o atributo
nonnulldo GCC, dá para elevar passarNULLpara uma função ao nível de erro de compilaçãoPessoalmente, acho que acesso fora dos limites teria sido um exemplo melhor e mais representativo
Panic é uma verificação de segurança embutida no programa; acontece de forma confiável, e o comportamento é claramente definido
Segfault é a sistema operacional capturando uma operação inválida de memória, e só acontece para endereços fora das páginas que estão no mapa de memória virtual do programa. Por isso, muitos bugs de segfault podem ser manipulados para virar alguma forma de execução arbitrária de código
Em casos normais, o resultado pode até parecer o mesmo, mas fundamentalmente são coisas diferentes