Em abril de 2026, a Canonical divulgou 44 CVEs na uutils, a implementação em Rust do GNU coreutils, que vem por padrão a partir da versão 25.10. A maioria das vulnerabilidades foi descoberta durante uma auditoria externa realizada antes do lançamento do 26.04 LTS. Ao analisar essa lista, é possível extrair lições valiosas. Notavelmente, todos esses bugs estavam na base de código Rust, escrita por desenvolvedores experientes, e nenhum deles foi detectado pelo verificador de empréstimos (borrow checker), pelos lints do clippy ou pelo cargo audit.
Esta análise não visa criticar a equipe de desenvolvimento da uutils, mas sim agradecer pela publicação dos resultados da auditoria com detalhes que permitem a todos nós aprendermos. Se você desenvolve código de sistema em Rust, este artigo oferece uma análise concisa sobre os limites atuais da segurança proporcionada pelo Rust. É crucial não confiar cegamente no caminho entre duas chamadas de sistema. Este é o maior conjunto de bugs encontrados na auditoria e a razão pela qual cp, mv e rm ainda são implementados pela GNU no Ubuntu 26.04 LTS. O padrão é sempre o mesmo: uma chamada de sistema é usada para verificar informações sobre um caminho, e outra é usada para executar uma ação nesse mesmo caminho. Entre essas duas chamadas, um atacante com acesso de escrita na pasta pai pode substituir um componente do caminho por um link simbólico. Na segunda chamada, o kernel resolverá o caminho novamente do zero, e a ação privilegiada será executada no alvo escolhido pelo atacante. A biblioteca padrão do Rust facilita a ocorrência desse erro. APIs ergonômicas como fs::metadata, File::create, fs::remove_file e fs::set_permissions resolvem o caminho a cada vez, em vez de operar a partir de um descritor de arquivo. Para um programa comum, isso é aceitável, mas para ferramentas com privilégios elevados que precisam ser protegidas contra atacantes locais, é preciso ter cautela. Por exemplo, o CVE-2026-35355 demonstra um bug simplificado onde a remoção de um arquivo seguida pela sua recriação com File::create permite a inserção de um link simbólico, como /etc/shadow, que seria sobrescrito. A correção envolve o uso de OpenOptions::create_new(true), que garante que o arquivo seja novo e não permita links simbólicos pendentes.
A regra fundamental aqui é: ao trabalhar com caminhos em Rust, lembre-se que &Path é um valor, mas para o kernel, é apenas um nome que pode mudar entre chamadas de sistema. É essencial vincular operações a um descritor de arquivo. O create_new() ajuda apenas na criação de novos arquivos. Para outras operações, abra a pasta pai uma vez e trabalhe relativamente a esse descritor. Se você executa uma ação duas vezes no mesmo caminho, assuma a existência de um bug TOCTOU (Time Of Check To Time Of Use) até prova em contrário. Outra questão importante é definir permissões durante a criação, não depois. Criar um diretório com permissões padrão e depois alterá-las deixa uma janela de tempo onde outros usuários podem acessá-lo. Utilize OpenOptions::mode() e DirBuilderExt::mode() para garantir que arquivos e pastas sejam criados com as permissões desejadas. A igualdade de strings em caminhos não é equivalente à identidade no sistema de arquivos. Uma verificação inicial de --preserve-root em chmod que se baseava em comparações de strings podia ser contornada por caminhos que resolvessem para / mas não fossem literalmente /, como /../ ou links simbólicos. A correção envolve o uso de fs::canonicalize antes de comparar caminhos para resolver .., . e links simbólicos em caminhos absolutos reais. Para comparar dois caminhos arbitrários em busca de identidade no sistema de arquivos, é necessário abri-los e comparar seus pares (dev, inode), como faz o GNU coreutils. O bug CVE-2026-35363, onde rm . e rm .. falhavam, mas rm ./ e rm ./// eram aceitos e removiam o diretório atual, exemplifica essa falha de lógica.
É vital permanecer dentro dos limites de bytes nas fronteiras do Unix. String e &str em Rust são sempre UTF-8, o que é ótimo na maioria dos casos. No entanto, caminhos, variáveis de ambiente e entradas Unix podem estar em um estado confuso de bytes. Ao cruzar essa fronteira, um programa Rust tem três opções: conversão com perda (from_utf8_lossy), que substitui bytes inválidos por U+FFFD sem notificação; conversão rigorosa (unwrap ou ?), que causa falhas; ou permanecer com bytes usando OsStr ou &[u8]. A auditoria encontrou bugs nas duas primeiras categorias. Por exemplo, o comm (CVE-2026-35346) usava from_utf8_lossy em bytes brutos de arquivos de entrada, corrompendo silenciosamente a saída ao substituir caracteres inválidos por U+FFFD. A correção é permanecer com bytes, usando Write::write_all em vez de print!. A regra é escolher o tipo apropriado para a situação: em código de sistema estilo Unix, use Path e PathBuf para caminhos, OsString para variáveis de ambiente e Vec<u8> ou &[u8] para conteúdo de fluxo. Tentar convertê-los para String para facilitar a formatação pode levar à corrupção. UTF-8 é uma excelente escolha padrão para strings de aplicativos, mas não deve ser a padrão para operações com bytes brutos em ferramentas Unix. Cada panic!, unwrap, expect, índice de slice, operação aritmética não verificada ou from_utf8 em dados de entrada controlados por um atacante é um potencial ataque de negação de serviço (DoS), pois panic! encerra o processo. Se o seu programa roda em cron jobs, pipelines de CI ou scripts de shell, isso pode levar à paralisação completa do sistema. O exemplo canônico é o sort --files0-from (CVE-2026-35348), que chamava expect() para cada conversão de nome de arquivo para UTF-8, falhando em todo o processo se um único caminho não fosse UTF-8. A regra é transformar entradas ruins em erros, não em pânicos. Em código que lida com dados não confiáveis, use ?, get, checked_*, try_from para sinalizar erros reais. A propagação de erros, em vez de descartá-los, é crucial. Bugs como chmod -R e chown -R retornando o código de saída do último arquivo processado, em vez do pior, podem mascarar falhas. O dd chamando Result::ok() em set_len() para simular o comportamento do GNU /dev/null resultou na criação de arquivos de destino parcialmente gravados quando o disco estava cheio. A regra é não descartar informações de erro significativas. Para evitar isso, um padrão simples é registrar o pior código de saída e sair com ele. Se você usa .ok() para descartar um Result, deixe um comentário explicando por que essa falha específica pode ser ignorada com segurança.
É surpreendente que um número considerável de CVEs não se refira a código inseguro, mas sim a um comportamento diferente do GNU, no qual scripts de shell se baseiam. O exemplo mais claro é kill -1 (CVE-2026-35369). O GNU interpreta -1 como "sinal 1" e solicita um PID. A uutils interpreta como "enviar sinal padrão para PID -1", o que no Linux significa todos os processos visíveis. Um erro de digitação se transforma em um desligamento de sistema. A regra é: compatibilidade de bugs é um recurso de segurança. Ao escrever uma nova implementação de uma ferramenta estabelecida, a compatibilidade de bugs em códigos de saída, mensagens de erro, casos de borda e semântica de opções é um recurso de segurança. Se o comportamento do seu programa difere do original, algum script de shell inevitavelmente tomará a decisão errada. Para a uutils, agora é executado um conjunto de testes GNU coreutils no CI, o que é a escala correta de proteção contra essa classe de bugs. O CVE-2026-35368, o pior bug da auditoria, é a execução remota de código root em chroot. O bug é notável se você souber onde procurar (um chroot seguido por uma chamada de função que carrega uma biblioteca dinâmica), mas é completamente não óbvio na primeira leitura do código. O padrão simplificado da utilidade chroot envolve chamar get_user_by_name após o chroot, o que carrega bibliotecas compartilhadas da nova raiz do sistema de arquivos, permitindo que um atacante execute código como uid 0. O GNU chroot resolve o usuário antes de chamar chroot. A regra é: resolva as entradas antes de cruzar limites de confiança. Após cruzar um limite, qualquer chamada de biblioteca pode executar código do atacante. A compilação estática não ajuda, pois get_user_by_name usa NSS, que executa dlopen de módulos libnss_* no ambiente de execução.
Talvez você esteja pensando: "Nossa, isso é um monte de bugs! Talvez Rust não seja tão seguro quanto eu pensava?". Essa seria uma conclusão equivocada. É importante notar que nada do seguinte aconteceu: estouro de buffer, uso após liberação, liberação dupla, corridas de dados para estado mutável compartilhado, desreferenciamento de ponteiro nulo, leitura de memória não inicializada. Graças a isso, embora as ferramentas estivessem (e provavelmente ainda estejam) com bugs, elas nunca tiveram um bug explorável para ler memória arbitrária. O GNU coreutils apresentava CVEs em todas essas categorias. A diferença é notável quando se compara o mesmo tempo de atividade de desenvolvimento. O tipo de bug mais interessante vive na fronteira entre nosso ambiente Rust controlado e o mundo externo confuso e caótico, onde caminhos, bytes, strings e chamadas de sistema se misturam em um triste emaranhado. E essa se tornou a nova fronteira de segurança para o código de sistema moderno. Se você escreve código de sistema em Rust, considere esta lista de CVEs como um checklist. Procure em sua base de código por from_utf8_lossy, chamadas unwrap() aleatórias, Result ignorados, File::create e comparações de string com /. Além disso, sobre um tópico relacionado, escrevi um post sobre Padrões para Programação Defensiva em Rust. Rust correto é Rust idiomático. Quando penso em "Rust idiomático", não penso primeiro em correção. Afinal, não é tarefa do compilador garantir isso? Penso primeiro em padrões elegantes de iteradores, assinaturas de métodos ergonômicas, imutabilidade ou trabalho inteligente com expressões. Mas nada disso importa se o código estiver incorreto, e o compilador está longe de ser perfeito em garantir a correção. É por isso que temos idiomas não apenas para escrever código mais elegante, mas também para escrever código correto. Eles se tornaram a essência da experiência da comunidade, que aprendeu, muitas vezes dolorosamente, qual código sobrevive ao contato com a realidade e qual não. A realidade raramente é tão ideal quanto as abstrações que associamos a ela. Um sinal de sistemas confiáveis em qualquer idioma é a disposição de refletir essa imperfeição em vez de tentar escondê-la. Rust nos fornece ferramentas incríveis para isso, e o compilador faz uma grande parte do trabalho por nós. Mas a parte que ele não pode alcançar, a fronteira entre nosso programa e todo o resto, ainda precisa ser implementada corretamente por nós. O sistema de tipos pode codificar muito, mas não as condições que não estão sob seu controle, como o tempo decorrido entre duas chamadas de sistema. Assim, Rust idiomático não é apenas código que o verificador de empréstimos aprova ou que o clippy aceita. É código cujos tipos, nomes e fluxo de controle comunicam a verdade sobre o sistema em que está sendo executado. E essa verdade às vezes é feia. Pode implicar o uso de descritores de arquivo em vez de caminhos, OsStr em vez de String, ? em vez de unwrap, e a necessidade de compatibilidade de bugs em vez de semântica pura. Tudo isso não é tão bonito quanto a versão que você desenhou no quadro branco, mas é mais honesto. É preciso ser honesto com o GNU: o GNU coreutils tem quarenta anos, e os desenvolvedores tiveram muito tempo para descobrir e corrigir essa classe de bugs. E não sabemos se não há bugs de segurança de memória no código reescrito em Rust, apenas que nenhum foi encontrado na auditoria. No entanto, a diferença é perceptível ao comparar o mesmo tempo de atividade de desenvolvimento. Vale a pena notar que a classe de bugs TOCTOU Path/PathBuf é, de certa forma, mais fácil de evitar em C do que em Rust. O código C usa naturalmente um descritor de arquivo aberto e a família de chamadas de sistema *at (openat, fstatat, unlinkat, mkdirat), e a maioria das chamadas de sistema de criação aceita um argumento mode diretamente. As APIs de alto nível std::fs do Rust abstraem o descritor de arquivo e operam em valores &Path, tornando a chamada com resolução de caminho repetida o caminho de menor resistência. As APIs que usam descritores estão disponíveis em todas as plataformas Unix, o Rust simplesmente não as prioriza.








