1 pontos por GN⁺ 2024-03-31 | 1 comentários | Compartilhar no WhatsApp
  • O backdoor do xz/liblzma não envolve apenas o payload binário final; a etapa Bash executada durante o build também fica escondida por várias camadas de extração e decodificação, o que eleva bastante a dificuldade de análise
  • As versões afetadas são xz/liblzma 5.6.0 e 5.6.1, e o script ofuscado junto com o payload binário está contido em dois arquivos que parecem ser arquivos de teste
  • O Stage 0 começa em m4/build-to-host.m4, recupera um fluxo xz que parece corrompido e extrai com xz -d o script Stage 1 para executá-lo
  • O Stage 1 recorta dados de good-large_compressed.lzma, aplica substituições e então executa o script Bash Stage 2; na 5.6.1 foi adicionado código que verifica 5 vezes se a execução está ocorrendo em Linux
  • O Stage 2 usa file carving, cifra de substituição e uma decodificação variante de RC4 baseada em AWK para inserir um arquivo .o no processo de build e linkedição; a 5.6.1 também inclui uma estrutura extensível para executar scripts adicionais no futuro

Escopo da etapa Bash do backdoor xz/liblzma

  • Andres Freund anunciou na mailing list oss-security a descoberta do backdoor no xz/liblzma, que afeta o servidor OpenSSH
  • O foco da análise não é o backdoor binário em si, mas as etapas iniciais em Bash executadas antes dele e o método de ofuscação usado
  • O fluxo em Bash vai do Stage 0 ao Stage 2, e também revela parcialmente um possível Stage 3 que parece não estar totalmente implementado
  • As etapas ofuscadas/criptografadas e o backdoor binário posterior estão escondidos em dois arquivos de teste
    • tests/files/bad-3-corrupt_lzma2.xz
    • tests/files/good-large_compressed.lzma
  • As versões afetadas são 5.6.0 e 5.6.1, com pequenas diferenças entre elas

Stage 0: recuperação de um fluxo xz que parece um arquivo de teste corrompido

  • O ponto de partida é um trecho de código em m4/build-to-host.m4, que aparentemente é executado em algum momento do processo de build
  • O pipeline principal conecta sed, tr e xz -d para extrair o script Stage 1
  • Depois de ler os bytes de tests/files/bad-3-corrupt_lzma2.xz, ele substitui alguns bytes com tr "\t \-_" " \t_\-"
    • 0x09 tab vira 0x20 espaço
    • 0x20 espaço vira 0x09 tab
    • 0x2d hífen vira 0x5f underscore
    • 0x5f underscore vira 0x2d hífen
  • Essa substituição serve para transformar bad-3-corrupt_lzma2.xz novamente em um fluxo xz válido
  • No fim, o fluxo recuperado é descompactado com xz, o erro é ignorado e o script Stage 1 obtido é executado imediatamente
    • O fluxo parece truncado, mas isso não causa problema porque a saída relevante já foi emitida

Stage 1: extraindo o Stage 2 de good-large_compressed.lzma

  • O Stage 1 é o pequeno arquivo Bash que começa com ####Hello#### mencionado no e-mail de Andres Freund
  • A primeira diferença entre as duas versões está em bytes aleatórios dentro do comentário da segunda linha
    • 5.6.0: 86 F9 5A F7 2E 68 6A BC
    • 5.6.1: E5 55 89 B7 24 04 D8 17
  • Na 5.6.1 foi adicionada uma verificação de Linux no formato [ ! $(uname) = "Linux" ] && exit 0, repetida 5 vezes
  • A extração do Stage 2 começa em uma longa linha export i=..., cujo valor depois é usado quase como uma função, chamada via eval $i
  • O fluxo de extração é um pipeline que encadeia continuamente stdout e stdin
    • Primeiro, good-large_compressed.lzma é descompactado com xz -dc
    • eval $i repete chamadas a head em um padrão que descarta 1024 bytes e produz 2048 bytes
    • No final do arquivo, a 5.6.0 produz 724 bytes e a 5.6.1 produz 939 bytes
  • Essa cadeia de head remove regiões vazias de baixa entropia e concatena apenas as áreas que contêm dados reais
  • Em seguida, tail -c +31233 descarta a parte inicial
    • Nessa parte inicial está escondido o backdoor binário que será extraído na etapa seguinte
    • Na 5.6.0, o efeito é descartar os primeiros 31264 bytes; na 5.6.1, os primeiros 31232 bytes
  • Depois, tr é usado como uma cifra de substituição simples, com chaves de mapeamento diferentes para 5.6.0 e 5.6.1
    • 5.6.0: tr "\5-\51\204-\377\52-\115\132-\203\0-\4\116-\131" "\0-\377"
    • 5.6.1: tr "\114-\321\322-\377\35-\47\14-\34\0-\13\50-\113" "\0-\377"
  • Os dados substituídos são então descompactados novamente com xz -F raw --lzma1 -dc, e o Stage 2 resultante é executado na hora

Stage 2: modificação do processo de build e mecanismo de expansão

  • O Stage 2 é o arquivo infected.txt anexado por Andres Freund no e-mail original, e é o script Bash em que ocorre a modificação real do processo de compilação
  • Do ponto de vista de ofuscação, o Stage 2 contém três trechos interessantes, dos quais dois aparecem apenas na 5.6.1
  • Os dois trechos da 5.6.1 são quase idênticos entre si e procuram, em tests/files/, arquivos com assinaturas específicas
    • O Fragment 1 procura ~!:_ W e |_!{ -
    • O Fragment 3 procura jV!.^% e %.R.1Z
  • Quando a assinatura é encontrada, ele extrai o nome do arquivo e o offset, e recorta o intervalo de bytes entre a primeira e a segunda assinatura
  • Os dados recortados passam por uma decodificação por substituição com a chave tr usada na 5.6.0, depois são descompactados com xz -F raw --lzma2 -dc e executados com eval
  • Nos arquivos TAR investigados das versões 5.6.0 e 5.6.1, não havia arquivos com essas assinaturas
  • Essa estrutura parece um sistema de expansão e patch, permitindo executar scripts futuros apenas adicionando novos arquivos de teste, sem precisar modificar novamente os arquivos de teste que já transportam o payload existente

Stage 2: extração do arquivo .o do backdoor binário

  • Outro ponto central do Stage 2 é o código que extrai um arquivo .o e o injeta no processo de compilação e linkedição
  • O artefato produzido pela extração é liblzma_la-crc64-fast.o, que corresponde ao backdoor binário final
  • Como nas etapas anteriores, a extração conecta várias ferramentas padrão em um pipeline
    • good-large_compressed.lzma é descompactado com xz -dc
    • A mesma cadeia de head usada via eval $i no Stage 1 é reutilizada
    • sed "s/\(.\)/\1\n/g" adiciona uma quebra de linha após cada byte, convertendo a entrada em uma linha por byte
  • O script AWK seguinte executa uma decodificação parecida com RC4 sobre o fluxo de entrada
    • O array de chaves é gerado com ((i * 7) + 5) % 256
    • A 5.6.0 pula os primeiros 4096 bytes do fluxo PRNG de saída
    • A 5.6.1 pula os primeiros 8192 bytes
    • O RC4 original usa XOR, mas este código usa soma no formato (v + k) % 256
  • Os dados decodificados são então descompactados com xz -dc --single-stream
  • Depois disso, head recorta apenas o intervalo de bytes necessário e salva o resultado como liblzma_la-crc64-fast.o
    • O tamanho do payload comprimido, porém parcialmente transformado, é de 88792 bytes na 5.6.0 e 88664 bytes na 5.6.1
    • O intervalo final extraído tem cerca de 86 KB

A natureza da ofuscação feita apenas com ferramentas padrão

  • Toda a etapa em Bash é composta apenas por ferramentas padrão de linha de comando
    • sed
    • tr
    • xz
    • head
    • tail
    • awk
    • grep
    • cut
    • expr
  • O payload fica escondido em arquivos binários que parecem arquivos de teste legítimos, e apenas as áreas necessárias são retiradas por file carving
  • A ofuscação combina uma cifra de substituição simples com uma decodificação variante de RC4 baseada em AWK
  • A execução se desenrola em 3 etapas, e a 5.6.1 inclui uma estrutura que permite no futuro executar scripts por meio da adição de arquivos de teste separados
  • A estrutura toda mostra um esforço grande para parecer inofensiva e permanecer bem escondida; se este caso foi descoberto por acaso, fica a dúvida de quantos casos semelhantes ainda não foram encontrados

1 comentários

 
GN⁺ 2024-03-31
Comentários do Hacker News
  • Graças à explicação simplificada e à comparação com a imagem de ruído, deu para entender o que as pessoas querem dizer com sofisticação
    Também vi no reddit que a abordagem de “sandboxing” foi quebrada por um único ponto, e dá para ver um ponto bem à esquerda na linha logo após o #include
    https://git.tukaani.org/?p=xz.git;a=commitdiff;h=328c52da8a2...
    https://old.reddit.com/r/linux/comments/1brhlur/xz_utils_bac...

    • É realmente perverso. No diff, parece só +, +., +, e aquele único ponto passa despercebido
    • Eu detesto muito o uso desse tipo de condicional em tempo de compilação/build. É difícil testar se está ativado quando deve estar e desativado quando deve estar, especialmente quando fica dentro de um sistema de build sem framework de testes unitários
      Só um erro simples que faça um teste sempre falhar ou sempre passar já é dor de cabeça; dá para ver por que isso vira um bom alvo para comportamento malicioso
    • É muito provável que isso tenha sido um simples erro, não algo intencional
      a) Na prática, quase ninguém compila esse pacote com cmake
      b) Se você compilar com cmake e -DENABLE_SANDBOX=landlock, o build simplesmente falha: https://i.imgur.com/7xbeWFx.png
      Esse ponto não desativa o sandboxing; ele apenas impede que seja compilado com cmake. Se alguém de fato tivesse tentado compilar com cmake, teria visto o erro e percebido que havia algo errado, então não faz sentido considerar isso uma tentativa maliciosa de reduzir a segurança
  • “Se isso foi descoberto por acaso, quanto ainda resta que não foi descoberto?” é a pergunta mais importante
    É improvável que Andres Freund tenha encontrado por acaso o único projeto open source popular com um backdoor implantado. Não seria surpreendente se já houvesse uma dúzia dessas coisas em circulação

    • Não foi exatamente descoberto por acaso; foi mais como algo que ele sentiu. São coisas diferentes
      O próximo atacante será muito menos descuidado em deixar rastros como aumento no tempo de execução
    • Pode ser, mas talvez na prática não existam tantos casos críticos assim. Se houvesse muitos, acho que teríamos nos deparado com situações como essa com mais frequência
  • A ideia desconfortável aqui é que os testes unitários abriram o caminho para o ataque. Sem testes, teria sido muito mais difícil esconder isso

    • O atacante até encobriu os rastros do payload inicial com um parágrafo inofensivo no README. Algo como “nada para ver aqui!”
      bad-3-corrupt_lzma2.xz tem três streams, e o primeiro e o terceiro são streams xz válidos. Está escrito que o stream do meio tem cabeçalho de stream, cabeçalho de bloco, índice e rodapé de stream corretos, apenas os dados LZMA2 estão corrompidos, e que ele deve ser descompactado ao usar --single-stream
      As strings ####Hello#### e ####World#### fazem com que um resultado normal plausível apareça quando as instruções do README são de fato seguidas
      $ cat tests/files/bad-3-corrupt_lzma2.xz | xz -d --single-stream
      ####Hello####
      Essas strings são comentários de shell, então não atrapalham a execução do payload
      Por fim, mais tarde elas servem como marcadores para que uma regex encontre o arquivo sem referenciar diretamente o nome do arquivo nem usar as strings Hello/World reais
      $ gl_am_configmake=\grep -aErls "#{4}[[:alnum:]]{5}#{4}$" $srcdir/ 2>/dev/null``
      $ echo $gl_am_configmake
      ./tests/files/bad-3-corrupt_lzma2.xz
    • Em um projeto importante para segurança, parece certo configurar a infraestrutura de build para gerar erro, ou ao menos aviso, quando arquivos binários forem incluídos no build
      Essa verificação precisaria ser aplicada transitivamente, de modo que, quando uma distribuição Linux tentasse atualizar para uma nova versão da liblzma, o build também falhasse ou gerasse aviso por causa de uma nova dependência binária
      Não sei quão comum é essa prática em builds de distribuições Linux. Se for comum, exigiria uma quantidade enorme de trabalho para organizar, e nem sei se isso é possível para começo de conversa. Com bazel parece possível, mas não sei quanto aos outros sistemas de build
  • Fico curioso se alguém já procurou no GitHub por um truque parecido com head | tail. É difícil acreditar que isso tenha sido inventado especificamente para este caso

    • Já vi bastante esse tipo de abordagem em instaladores Unix de fornecedores de software comercial
      Um arquivo .sh enorme mostra a licença, recebe a aceitação e depois dá cat em si mesmo, passando por um pipe de head/tail até o cpio para extrair os ativos reais
    • É inteligente, mas não é algo totalmente novo; está mais próximo do caso de uso pretendido dessas ferramentas
    • É uma oportunidade para escrever um artigo acadêmico
  • Não há uma resposta melhor, mas esse monte de bash obscuro em si já não parece suspeito?
    Como em outras áreas do mundo do desenvolvimento, não dava para escrever de forma menos opaca, para deixar mais claro o que está acontecendo?
    Eu sei que um mantenedor pode inserir código malicioso sem uma revisão tão rigorosa quanto a de um contribuidor externo, mas deveria haver um caminho melhor do que um monte de código “conciso”, algo que na prática parece uma ofuscação não intencional

    • Esse é um sinal suspeito muito antigo
      O problema central é que, nos anos 80 e 90, havia inúmeros sistemas do tipo Unix, cada um com seus próprios defeitos e recursos ausentes, e os autores de software queriam minimizar as dependências de build
      Por isso, muitas comunidades padronizaram a automação de build com scripts shell que funcionassem em qualquer lugar. Mas scripts shell eram dolorosos de escrever, e as pessoas passaram a gerar scripts shell com ferramentas como o pré-processador de macros M4
      O resultado é que muitos projetos acabaram com enormes blocos opacos de scripts shell, para o caso de alguém querer executar o código no AIX ou em algum Unix antigo quebrado
      Para se livrar desse matagal de shell quase impenetrável, seria preciso reduzir muito o número de plataformas suportadas, padronizar ferramentas de build mais limpas e construir mais da infraestrutura central em linguagens que não precisem de shell para builds portáveis
      Mas isso é um trabalho gigantesco, e uma boa parte das bibliotecas C centrais é mantida por um ou dois voluntários não remunerados. Abandonar o suporte ao “Obscurnix-1997” também costuma ser uma decisão bastante controversa
      Por isso, boa parte da infraestrutura central ainda está cercada por um pântano de scripts shell gerados por máquina de origem pouco clara
    • Aquele shell não foi escrito por uma pessoa; é código gerado. Como o autoconf é amplamente usado, há montanhas de código shell de configuração gerado, e o autoconf produz milhares de linhas de scripts shell portáveis difíceis de ler a partir de scripts do pré-processador de macros M4
      Um número considerável de ferramentas é construído dessa forma
    • À primeira vista, o simples fato de o bash parecer obscuro não me parece um sinal de alerta. Scripts bash escritos de forma densa às vezes ficam assim. Se deveriam ser escritos de forma tão densa é outro debate
      O que parece suspeito são as chamadas repetidas a tr. Quando vejo isso, penso que alguém está tentando ser esperto — e aqui “esperteza” tem sentido negativo. Se eu fosse mantenedor, teria pedido uma explicação do que aquele código faz ao recebê-lo. Quase sempre há uma solução melhor do que esse tipo de encadeamento
      O problema real é que não havia outro mantenedor para revisar esse código quando ele entrou. Um componente importante da stack dependia de uma pessoa só e, neste caso, essa pessoa era mal-intencionada
    • Não foi “não intencionalmente”; o ponto central é que foi ofuscado intencionalmente
    • Talvez eu tenha entendido mal o risco do backdoor do XV, mas há alguma forma de executar bash impedindo que ele dê exec em alguma coisa? Fico imaginando se o bash poderia ter algum tipo de “modo seguro”
      Dito isso, não consigo imaginar como o script configure do xv poderia executar bash nesse “modo seguro” hipotético, então retiro o que disse
  • https://github.com/tukaani-project/.github/issues/2

    • É bem engraçado. Isso não é só um backdoor intencional horrível; como o backdoor é uma obra derivada e o código-fonte não foi incluído nem distribuído na “forma preferida para modificação”, também é uma violação da GPL
  • Todo o ecossistema C, incluindo ferramentas de build e utilitários Unix antigos, é uma bagunça de segurança esperando para ser explorada — e acabará sendo
    Basta ver como é fácil quebrar tudo com um único ponto. Já passou da hora de as pessoas perceberem que não dá para apostar a segurança do mundo em C
    Espero que usem Ada ou Rust com toolchains modernas

    • Se adicionar um ponto em Rust, ele não quebra?
  • Não faço ideia de como isso passou por code review e foi mergeado. Se eu não estiver deixando algo passar, parece absurdamente descuidado

    • O agente malicioso era comantenedor do repositório, vinha sendo mais ativo que o mantenedor original havia algum tempo e tinha permissão total de commit. Foi commitado direto na master, sem PR nem revisão
      Além disso, estava fortemente ofuscado dentro de arquivos binários marcados como arquivos de teste, ou seja, arquivos de teste compactados com xz “good”/“bad”. Se você não soubesse o que procurar, não havia como perceber
    • A mensagem de commit dos arquivos de teste afirmava que eles tinham sido criados por um gerador de números aleatórios. A pessoa que criou o tarball de release inseriu a última linha no local apropriado, mas isso não foi registrado no repositório
    • Não existe code review em um pacote com um único mantenedor ativo
  • Usar uma distribuição LTS pode oferecer alguma proteção. O Slackware parece usar lzma, ou seja, tar.xz, nos pacotes, mas a última versão estável, exceto a -current, não tinha esse problema
    Se quiser dar mais um passo na direção do software livre, o Hyperbola GNU também não tinha esse problema
    Além disso, o Slackware -current também não linka o sshd ao xz e não usa systemd