Pular para o conteúdo
← Voltar para o Skalablog

Artigo publicado

Injeção de comando em Rust seguro: o furo real

Engenharia de Software

Injeção de comando em Rust seguro continua possível: o borrow checker valida ponteiros, lifetimes e aliasing, mas não pergunta se um texto veio de um atacante. Uma string UTF-8 válida entregue a sh -c ou cmd.exe vira instrução executada. Este guia mostra onde a segurança do compilador termina, como a consulta rust/command-line-injection do CodeQL rastreia esse fluxo e quais quatro perguntas de revisão fecham a brecha.

Injeção de comando em Rust seguro: onde o borrow checker para

Injeção de comando em Rust seguro acontece porque o borrow checker valida memória, não autoridade: ele garante que toda referência aponta para um valor vivo, mas nunca pergunta se aquele texto merecia decidir algo no sistema operacional. O resultado é um binário sem nenhum bloco unsafe que ainda executa o comando escolhido pelo atacante.

Em Rust, ownership dá a cada valor um único dono, e largar esse dono libera os recursos. Referências compartilhadas não mutam dados comuns, e referências mutáveis excluem concorrentes enquanto vivem. É isso que elimina boa parte dos bugs de corrupção de memória — e só isso.

O Rustonomicon trata exatamente desse escopo: código seguro protege type safety e memory safety, sem prometer que cada escrita em arquivo, requisição de rede ou processo filho seja a que você pretendia. A Referência do Rust lista data races, ponteiros pendentes ou desalinhados e aliasing quebrado como comportamentos indefinidos — nada ali menciona intenção do usuário.

A confusão é fácil porque os dois fracassos terminam com a máquina comprometida. Um corrompe memória para tomar o controle; o outro pede que o programa use a autoridade que já tem. Rust bloqueia a primeira rota e deixa a segunda inteiramente para o seu design.

Segurança de memória não é autorização: o exemplo que compila

Segurança de memória e autorização de comando respondem perguntas distintas, e o exemplo vulnerável do próprio GitHub demonstra isso: um valor de requisição entra como texto UTF-8 válido, vira argumento de Command::new e chega a sh -c sem um único unsafe no caminho.

Considere uma rota que aceita a ação rebuild-index. O texto é UTF-8 válido, o empréstimo é válido, o dono é claro. Nada disso prova que quem chamou pode reconstruir qualquer índice. O compilador enxerga uma string correta; a aplicação ainda deve uma decisão de autorização.

O pequeno exemplo mantém invariantes de memória intactas: o input é uma string slice válida, o builder Command tem estado próprio, a saída retorna um Result e até o caminho de erro é tipado. O programa pode ser perfeitamente seguro em memória e catastrófico em efeito.

Um status de sucesso só informa que o sistema operacional fez o que o programa pediu — que é justamente o problema quando um atacante influenciou o pedido. CodeQL e um compilador aprovado não transformam "o processo rodou" em "a ação era permitida".

Como o CodeQL rastreia injeção de comando em Rust

O CodeQL é o motor de análise estática do GitHub, e a consulta rust/command-line-injection trata injeção de comando em Rust como um problema de caminho: ela declara um source, um sink e um barrier, e pergunta se o rastreamento global de taint acha um fluxo do primeiro ao segundo sem cruzar o terceiro.

Taint é um rótulo preso a dados de origem suspeita. Atribuições, empréstimos, formatações, retornos e chamadas carregam esse rótulo pelo grafo de fluxo de dados. Nenhum exploit precisa rodar: a análise só verifica se a influência sobrevive até a interpretação.

O source pode ser argumento de linha de comando, entrada remota, variável de ambiente, arquivo ou stdin, conforme o modelo de ameaça ativo. O sink é a execução de comando. A pergunta que importa não é o tipo do valor, e sim quem pôde escolhê-lo.

Na release 2.27.0, o CodeQL passou a marcar essa consulta como high precision, com severidade de segurança 9.8 e vínculo a CWE-078 e CWE-088. Essa nota descreve a consequência potencial, não a exploração instantânea de qualquer alerta.

Source, sink e o que realmente cruza a fronteira do shell

O que transforma dado em controle é a fronteira do processo: Command::arg entrega texto como dado, mas uma opção que pede interpretação transforma esse mesmo argumento em programa. A documentação de std::process::Command confirma o caso seguro.

Segundo a biblioteca padrão, Command::arg não passa texto por um shell. Quoting, globbing, expansão de variável e word splitting não têm efeito. Argumentos separados preservam a fronteira de dados — desde que o programa receptor trate o argumento como dado.

Se você escolhe deliberadamente um shell, um interpretador ou outra ferramenta com opção do tipo "avalie isto", o argumento separado volta a cruzar para dentro de uma linguagem. A API cumpriu o contrato; a aplicação mudou o significado.

A mesma documentação carrega um aviso para Windows: a maioria dos programas segue a convenção padrão de argumentos, mas cmd.exe e arquivos em lote decodificam de outra forma, e um argumento malicioso pode executar comandos arbitrários ali. O mesmo cuidado vale para o modo one-shot do tokei, que aceita padrões de arquivo a partir da linha de comando.

PATH, ambiente herdado e o que o processo filho recebe

Caminho de binário e ambiente herdado são vetores independentes da injeção clássica: se o caminho do programa não é absoluto, o sistema operacional busca no PATH, e um atacante que influencie o PATH ou o diretório de trabalho pode redirecionar a execução sem tocar em memória.

A documentação do Command recomenda caminho absoluto ou PATH explicitamente controlado quando você precisa de resolução previsível. Vale para binários instalados, wrappers internos e qualquer chamada em que o nome do executável venha de configuração.

Processos filhos herdam o ambiente do pai por padrão. Métodos como env_remove e env_clear permitem limpar entradas que você não precisa, o que reduz o que um shell comprometido consegue ler — tokens, credenciais de nuvem, variáveis de build.

Diretório de trabalho também é decisão de segurança, não detalhe. Um cwd fixo e uma política de privilégio mínimo no sistema operacional limitam o dano quando alguma suposição anterior falha.

Barriers: por que enum sem campo e allowlist bloqueiam o alerta

Barriers são onde a análise aprende que um caminho perigoso foi restringido. A extensão Rust do CodeQL trata valores numéricos, booleanos e enums sem campo como barriers, porque esses tipos não conseguem carregar uma string de comando arbitrária.

A consulta também reconhece verificações de pertencimento e de igualdade bem-sucedidas como guardas de sanitização. Nos testes do GitHub, uma allowlist com cat, git e ls deixa o ramo dentro da verificação limpo, enquanto o ramo fora dela continua gerando alerta.

O ramo importa muito. Uma função de validação que calcula true ou false mas não controla o caminho até o sink é decoração: o CodeQL modela o lado bem-sucedido da guarda porque o comando precisa estar restringido em toda rota capaz de alcançar a execução.

Essa é a correção mais forte que os próprios testes sugerem. Em vez de limpar um comando de formato livre torcendo para cobrir todo metacaractere, converta texto não confiável em uma escolha pequena e confiável e mapeie essa escolha para programas e argumentos fixos. A autoridade passa a ser explícita.

Checklist de defesa para Command::new em produção

A defesa eficaz vive em camadas, e nenhuma delas substitui as outras: o borrow checker protege a memória, o formato da API mantém argumentos separados, a validação limita escolhas, a configuração do processo limita influência ambiente e as permissões do sistema operacional limitam o dano residual.

Para cada chamada de Command::new no seu código, responda quatro perguntas antes de aprovar o pull request.

  1. Quem escolhe o executável? Se a resposta inclui qualquer dado externo, fixe o binário e resolva o caminho de forma absoluta.
  2. Quem escolhe cada argumento? Prefira argumentos literais ou valores vindos de um enum fechado.
  3. Algum valor alcança um parser de shell? Se sim, existe um -c, um eval ou um arquivo em lote no meio.
  4. O que o processo filho acessa? Ambiente herdado, diretório de trabalho e privilégio precisam ser decididos, não aceitos por padrão.

Use uma tabela mental simples para escolher o nível de risco antes de escrever a chamada:

Forma da chamadaInterpretaçãoRisco
Command::new("cat").arg(path)nenhuma, argumento como dadobaixo
sh -c "cat $path"shell interpreta o argumentoalto
cmd.exe /c "..."decodificação própria de argumentosalto
enum → binário fixo + literaisescolha enumerada, sem shellbaixo

Em muitos casos, a melhor saída é não criar processo nenhum. Uma biblioteca Rust que faz a operação substitui o interpretador inteiro e elimina uma classe de confusão de parser. Se precisar lançar processo, mantenha o executável fixo, passe cada argumento separado e rode com o menor privilégio disponível.

Um exemplo concreto: um endpoint de download de relatório aceita identificador e formato, não comando. O enum de formato mapeia para uma operação fixa de biblioteca, o valor desconhecido é rejeitado e o caminho do relatório vira argumento literal depois de canonicalizado dentro de um diretório permitido.

O que o CodeQL não vê e como usar o alerta na revisão

Um scan limpo não prova ausência de injeção de comando em Rust: significa que o analisador não encontrou caminho com a extração e os modelos atuais. Wrappers internos, frameworks próprios, sanitizadores caseiros e lançadores de comando podem precisar de modelos declarados como dados.

Um modelo ausente esconde um caminho; um modelo frouxo adiciona ruído. Vale manter code review e testes automatizados justamente em volta da fronteira de confiança, sobretudo para wrappers que a consulta ainda não entende.

Na prática, trate cada alerta como um caminho a inspecionar, não como veredito. Confirme a origem do input, a API de processo, a existência de parsing por shell, a validação que domina aquele ramo e a identidade do processo filho.

O CWE-078 do MITRE descreve a classe de falha sem depender de linguagem. Vale lembrar que o CodeQL não é a única camada estática disponível — o Clippy cobre outra faixa de problemas e não substitui taint tracking.

FAQ sobre injeção de comando em Rust

  • Rust seguro impede injeção de comando? Não. O borrow checker garante validade de referências, lifetimes e aliasing, mas não verifica se um texto tinha autorização para virar ação no sistema operacional. Um caminho sem unsafe pode entregar entrada não confiável a um shell e executar o comando do atacante.
  • O que faz o CodeQL detectar injeção de comando em Rust? A consulta rust/command-line-injection faz rastreamento de taint de um source, como linha de comando ou entrada remota, até um sink de execução, e só deixa de alertar quando um barrier interrompe esse fluxo. O resultado é high precision, com severidade 9.8 e vínculo a CWE-078 e CWE-088 na release 2.27.0.
  • Command::arg é seguro então? Argumentos separados não passam por shell, então quoting, globbing e expansão de variável não têm efeito. A proteção depende do receptor tratar o argumento como dado: se você escolhe sh -c ou cmd.exe /c, a interpretação volta e o risco retorna.
  • Qual a correção mais robusta? Trocar comando de formato livre por uma escolha enumerada que mapeia para binários e argumentos fixos, com caminho absoluto, ambiente limpo e privilégio mínimo. Quando existir biblioteca Rust para a operação, dispensar o processo filho elimina a classe inteira de confusão de parser.
  • Posso confiar que um scan limpo significa que não há vulnerabilidade? Não. O resultado depende dos modelos e da extração vigentes: wrappers e sanitizadores próprios podem não estar modelados. Mantenha revisão de código, testes na fronteira de confiança e restrições do sistema operacional como camadas adicionais.

Source video