
Presented at Recon Montreal 2018
ou Parsing Avançado com Espingarda para Tolos e Rebeldes
Era uma vez, caro leitor, em uma noite escura e tempestuosa, seu fiel autor tropeçou em uma situação intrigante e misteriosa - um sistema onde a única coisa que impedia a análise de memória chip-off e a engenharia reversa era um sistema de arquivos proprietário fragmentado e desconhecido, em conjunto com compressão devido ao uso de Java embarcado, reduzindo a eficácia das ferramentas existentes para file carving.
Na época, alguma solução ad hoc pode ter sido improvisada concatenando manualmente chunks que pareciam se encaixar, com um pouco de trabalho horrível no console do Python e scripts bash ad hoc feios. Era boa o suficiente, mas demorada.
Embora extrair dados simples não compactados em análise chip-off seja um trabalho bastante rotineiro no dia a dia de um hacker de hardware, a compressão impõe problemas significativos quando seus pedaços estão espalhados por toda parte de maneira irracional e desagradável, mesmo quando não há outras proteções reais contra extração.
Há algumas coisas interessantes sobre arquivos Zip em particular (que é o formato básico usado para arquivos JAR). Sendo um estudante dedicado do International Journal of PoC||GTFO há bastante tempo e particularmente seguindo o trabalho em truques de formatos de arquivo de Ange Albertini, imaginei que poderia haver dados suficientes dentro de um arquivo zip sobre o próprio arquivo zip para que você conseguisse fazer um bom trabalho de remendá-lo novamente.
Então, com as referências fora do caminho, vamos aos detalhes técnicos.
Antes de mais nada, podemos não saber (e, da perspectiva da minha pesquisa, independentemente do sistema no qual eu havia encontrado o problema, decidi que era simplesmente melhor não me importar com isso) os detalhes específicos do sistema de arquivos. Mas sabemos uma coisa ou duas sobre a forma como a maioria dos sistemas de arquivos é implementada. Em particular, sabemos que eles tendem a ser gravados em chunks. Os chunks têm um tamanho mínimo de algum tipo, conhecido como páginas, e podemos identificar esse tamanho de página examinando o dump e identificando o tamanho mínimo de bloco gravado.
Alguns desses podem ser contíguos, outros não, sem um padrão claro para quando os blocos são contíguos.
Ou seja, o problema que temos é como reordenar as páginas de dados de modo que elas nos forneçam imagens válidas (ou quase isso) dos arquivos que queremos extrair.
Arquivos Zip são gravados de uma maneira que implementa uma espécie de hierarquia reversa. Primeiro, os dados compactados do arquivo (envolvidos em cabeçalhos locais de arquivo que os descrevem). Depois, um diretório central (que lista os offsets dos cabeçalhos locais) e, então, um registro de fim de diretório central (que, entre outras coisas, descreve o número de arquivos armazenados no zip, o offset onde o diretório central começa e o tamanho do diretório central).
Vamos inverter isso, mergulhando em um pouco mais de detalhe:
O End of Central Directory nos diz:
Cada registro do Central Directory nos diz:
Cada registro Local File nos diz:
Tudo isso nos leva a ter reconstruída a grande maioria do arquivo.
Antes de tudo, precisamos de uma etapa para distinguir dados de firmwares diferentes. A razão para isso é que todos os offsets só são relevantes dentro de seus respectivos arquivos zip — qualquer conflito levará a fluxos zip incompatíveis e corrupção, e queremos enfaticamente extrair o máximo de dados não corrompidos possível. Também queremos boas garantias de que, por exemplo, quaisquer vulnerabilidades que diagnosticarmos no firmware alvo afetem aquele que normalmente vemos rodando, e não algum outro arquivo que ficou apenas jogado por aí.
A solução necessária aqui é o algoritmo kmeans (também conhecido como "Algoritmo de Lloyd"). Há um ótimo vídeo aqui explicando como ele funciona. O SciPy tinha uma boa versão pronta para uso, mas tive que identificar/ corrigir a única crate de clustering/análise que implementa o algoritmo para fazer isso funcionar na implementação em Rust. Felizmente, não precisei escrevê-lo do zero.
Depois disso, o serviço é uma beleza.
Podemos usar uma série de características para isso. Os campos Flags, Method e Version variam todos de acordo com a pilha Zip usada para comprimir o arquivo. Além disso, os cabeçalhos possuem timestamps, e geralmente é improvável que todos os firmwares tenham sido compilados e comprimidos exatamente ao mesmo tempo.
Como um aparte, vale notar que arquivos Zip usam timestamps no formato MS-DOS, que são shorts empacotados em bits representando ano-mês-dia e hora-minuto-dois-segundos. Se eles não fossem convertidos em um valor escalar absoluto antes de usar isso como dados de classificação, você poderia muito bem dar tanto peso à diferença de um ano quanto dá à de um segundo, e isso não é nada bom!
Nós os convertemos em um Vetor Euclidiano (que é uma palavra chique para um array n-dimensional de valores em ℝ, ou coordenadas de ponto flutuante, mas o vídeo linkado acima é provavelmente a explicação mais direta) e o algoritmo de clustering praticamente faz todo o resto por nós, reunindo todos os cabeçalhos analisados no número de buckets que esperamos.
Embora eu tenha escrito isso em um script Python bastante tosco para prototipar este método, tendo chegado a uma taxa de recuperação de conteúdo JAR de cerca de 70-80% com o PoC, decidi parar por aí e seguir para a implementação de uma versão rápida em Rust.
Rust tem uma crate chamada nom que é absolutamente fantástica para escrever
parsers-verificadores. Essa foi uma das principais atrações para reescrevê-lo
em Rust, pelo que vale. A capacidade de escrever parsers claros e extremamente
rígidos torna isso muito mais fácil em alguns aspectos do que tentar lidar com
tudo isso em Python (que tende a ser muito mais tolerante, tanto que às vezes é
um pouco desafiador ter certeza de que você não está ignorando falhas errôneas
para capturar um caso extremo).
Se a ideia de parsers rápidos, legíveis e incríveis parece interessante, vá conferir
Além de qualquer outra coisa, executar esse tipo de análise em Python é intrinsecamente um tanto lento, e nunca foi realmente pensado para ser muito mais do que um caminho para um PoC explorar a viabilidade dessa abordagem.
De qualquer forma, chega disso...
As abordagens para reconstruir os chunks restantes incluem primeiro filtrar as páginas restantes em busca de candidatos de alta entropia, preencher primeiro as lacunas menores (eliminando o maior número possível de páginas da lista de busca, pois testar permutações nelas é uma tarefa de tempo exponencial no pior caso; portanto, resolver rapidamente os casos mais fáceis é uma prioridade e simplifica exponencialmente nosso problema ao longo do caminho!).
Também podemos conseguir uma vitória rápida encontrando casos em que um local file header não pode ser analisado devido a um limite de página (devemos ser capazes de combiná-lo com uma contraparte com alinhamento idêntico, pelo menos nos casos em que alinhamentos semelhantes são únicos e não colidem com outros artefatos de corrupção).
Como verificamos candidatos para páginas ausentes? Bem, temos nossas somas de verificação CRC32 dos arquivos bem ali no nosso Central Directory! Em vez de calcular o CRC32 sobre o arquivo, provavelmente a melhor maneira de fazer isso é calcular o CRC32 sobre os chunks que já conhecemos (para frente a partir dos dados no final da página antes de nossa lacuna começar, e para trás a partir dos dados (ou do chunk DataDescriptor após o fluxo deflate) e descobrir a partir deles qual CRC32 intermediário devemos esperar para cada bloco de páginas ausentes.
Essencialmente, toda vez que escolhemos o problema mais simples/rápido para resolver, tornamos os problemas mais difíceis significativamente mais simples ao eliminar o joio. É essa a razão de usar a entropia de Shannon para simplesmente descartar páginas vazias ou quase vazias — não é garantido que teremos apenas páginas zip de alta entropia, mas mesmo que existam outliers, é uma aceleração enorme evitar lidar com essa complicação logo no início.
Se você quiser mexer com isso, instale o Rust (recomendado com o incrível rustup nightly). Então:
$ git clone [repo]
...
$ cd zipdefrag
...
$ cargo build --release
Você pode omitir a flag de release para habilitar a depuração.
Os artefatos de build estarão em /target/{debug,release}
Gere a documentação com cargo doc (Esta crate é muito documentada. Eu gosto de
escrever.)
Atualmente não há saída no terminal por padrão na versão Rust; se você quiser
executar o harness de CLI, precisa definir a variável de ambiente
RUST_LOG=zipdefrag, que ativa o log detalhado no terminal demonstrando a
análise até agora.
Um executável nativo rápido e portátil (com hooks em Python) para resolver quebra-cabeças de dumps zip de sistemas de arquivos desconhecidos.
Um dump de demonstração
O desempenho atualmente está ruim na implementação Rust devido ao comportamento ineficiente na busca por chunks LFH correspondentes. Vou consertar isso e aprender minha lição.
Esta técnica não funciona bem quando muitos dos arquivos no JAR são significativamente maiores que o tamanho da página. Como ela depende do uso intenso da estrutura inerente aos arquivos zip, arquivos com muitos dados simplesmente não funcionam tão bem.
Convenientemente, arquivos de classe tendem a ser bastante pequenos em geral para midlets J2ME, mas binários grandes empacotados dentro provavelmente serão irrecuperáveis.
Além disso, o PoC em Python contém uma série de bugs aritméticos.