No artigo anterior, abordamos o mercado de aplicações Android, exploramos conceitos básicos de fuzzing, discutimos o funcionamento dos métodos nativos em aplicações Android e apresentamos a criação de um harness simples para demonstrar o funcionamento básico do AFL++. Caso você tenha perdido o conteúdo, o artigo está disponível através deste link. Neste novo artigo, vamos explorar uma aplicação real e discutir algumas das estratégias adotadas durante a construção do harness.
Inicialmente, para a escrita deste artigo, desenvolvemos um script para realizar o download de diversos arquivos APK, que serviram como base para nossos testes. A aplicação escolhida foi um simples conversor de imagens, selecionada por atender a critérios específicos. Destacamos os seguintes motivos para a escolha:
- A criação de um harness para a função alvo na biblioteca dinâmica foi relativamente simples, o que permitiu condensar todo o processo em um único artigo.
- Ao revisarmos o artigo anterior, recordamos que um método JNI requer dois parâmetros obrigatórios: JNIEnv* e jobject/jclass. Essas estruturas são inicializadas pelo Android Runtime, o que adiciona um nível de complexidade à criação do harness, uma vez que seria necessário instanciar a JVM e garantir que ela estivesse em um estado consistente, de modo que o acesso aos ponteiros em JNIEnv fossem válidos. Caso contrário, o harness apresentaria falhas ao tentar acessar esses ponteiros. Para evitar esse segundo cenário, a estratégia adotada foi selecionar funções nativas chamadas pelos métodos JNI como alvos para fuzzing, recriando os passos executados pelo método JNI no harness. Embora isso adicione uma camada extra de complexidade, elimina a dependência de ponteiros JNIEnv válidos. Essa limitação será abordada e superada no próximo artigo.
JNIEXPORT jstring JNICALL
Java_com_conviso_example_jni_HelloJni_stringFromJNI(
JNIEnv* env,
jobject thiz
)
{
return (*env)->NewStringUTF(env, "Hello from JNI!");
}
A motivação para a redação deste artigo foi a criação de uma espécie de diário de bordo, no qual relato os desafios enfrentados e as estratégias adotadas durante o desenvolvimento de um harness. O objetivo é documentar de forma detalhada os passos seguidos até a identificação de uma possível vulnerabilidade via fuzzing, proporcionando uma visão clara do processo, das dificuldades encontradas e das soluções implementadas ao longo da jornada. Então, vamos começar.
1. Obtenção de Dados Iniciais pela Camada Java
Após a extração do arquivo APK da aplicação, verifique a presença do diretório lib na estrutura de diretórios. No caso em questão, foi identificado um arquivo denominado libimagemagick.so, o que nos permitiu dar continuidade à análise. Caso exista uma biblioteca dinâmica, abra o APK em um descompilador Java, como o JADX, e inspecione as classes que contêm blocos de inicialização estática, conforme discutido no artigo anterior. Uma estratégia é procurar por invocações do método System.loadLibrary(), o que facilita a identificação das classes que fazem uso de métodos nativos. Abaixo, conseguimos identificar que a classe Magick declara três métodos nativos.
Dando continuidade à análise do código da aplicação, identificamos a presença da classe MagickImage, que herda da classe Magick e declara métodos nativos adicionais.
Bons alvos para fuzzing geralmente são funções nativas que processam estruturas de dados complexas ou obscuras, como parsers de arquivos, por exemplo. Idealmente, essas funções devem lidar com entradas que podem ser controladas por um atacante, como dados recebidos por meio de canais não confiáveis, como sockets ou arquivos. Essas funções frequentemente lidam com formatação ou validação de dados, tornando-as vulneráveis a falhas ao processar entradas inesperadas ou malformadas.
Durante a busca por métodos nativos para fuzzing, identificamos o método readImage, que parece ser um excelente candidato para análise. O próximo passo será examinar o código desse método em um disassembler para entender seu comportamento.
A identificação de métodos nativos chamados pela camada Java pode ser feita de forma estática, procurando por métodos com a palavra-chave native, ou de forma dinâmica, utilizando ferramentas como o jnitrace (baseada no Frida) para rastrear chamadas em tempo de execução.
2. Análise de Código Nativo com Disassembler
Após identificar um método nativo na camada Java, o próximo passo é abrir a biblioteca em um disassembler, como Ghidra ou IDA Pro, e analisar o código do método JNI. Neste artigo, utilizaremos o Ghidra. Como abordado no artigo anterior, um método JNI pode ser identificado normalmente pelo prefixo “Java”, seguido pelo nome da estrutura de pacotes Java, da classe e do método, tudo separado por underscores. O método que vamos procurar no Ghidra tem a assinatura: Java_magick_MagickImage_readImage. Ao descompilar o código, encontramos o seguinte trecho:
Ao realizar engenharia reversa, quanto maior a compreensão dos dados e estruturas utilizadas por uma função, melhor será a visualização e navegação do código, aumentando a precisão da análise. Isso também facilita a automação do processo e a identificação de vulnerabilidades. Disassemblers como IDA Pro e Ghidra permitem a importação de novos tipos de dados, desde tipos simples até estruturas complexas como JNIEnv, usadas por métodos JNI. No IDA Pro, basta importar o cabeçalho jni.h para adicionar os tipos necessários. Já no Ghidra, arquivos GDT (Ghidra Data Type) armazenam definições de tipos personalizados. Para analisar métodos JNI, podemos importar o arquivo jni_all.gdt e começar a usar os tipos definidos. Após a importação, é possível modificar a assinatura do método JNI para incluir os parâmetros JNIEnv env, jobject thiz e o imageInfo, obtido pela análise do código Java. Embora no método em análise a saída do descompilador não tenha mudado significativamente, em casos mais complexos, onde o uso de ponteiros na JNIEnv é intenso, a importação dos tipos pode melhorar consideravelmente a compreensão do código.
Podemos observar que o método JNI obtém a estrutura ImageInfo e a transmite diretamente para a função ReadImage. Com essa informação em mãos, vamos investigar mais detalhes sobre a biblioteca ImageMagick, a fim de compreender melhor o funcionamento da ReadImage e facilitar o desenvolvimento do harness.
3. Informações na Documentação e Diretórios de Testes dos Projetos
Se a biblioteca analisada for de código aberto, podemos explorar sua documentação, que geralmente inclui informações sobre suas funcionalidades, estruturas de dados e fluxos de execução. A documentação é essencial para compreender o comportamento esperado da biblioteca e identificar potenciais pontos de entrada para o fuzzing. Além disso, ela pode fornecer detalhes sobre configurações, dependências e exemplos de uso, o que facilita a criação de casos de teste mais eficazes e a análise dos resultados durante o processo de fuzzing. Para mais informações, recomendamos o excelente post escrito por Salim Largo: Harnessing Libraries for Effective Fuzzing.
O repositório do projeto pode incluir diretórios dedicados a casos de teste, onde é possível encontrar exemplos de harnesses, corpuses de entrada e outros artefatos úteis para o processo de fuzzing. Além disso, esses diretórios podem conter configurações de execução, projetadas para exercitar diferentes partes da biblioteca, e exemplos prontos de como integrar o harness com a biblioteca analisada.
Caso o repositório não contenha essas informações, uma alternativa é buscar na internet por exemplos de uso da biblioteca ou consultar tutoriais e discussões em fóruns especializados. Se necessário, também é possível recorrer à engenharia reversa para entender melhor o funcionamento da biblioteca, analisando seu comportamento por meio da exploração de seu código binário ou ferramentas de depuração.
Após realizar a pesquisa sobre a biblioteca ImageMagick, o código do harness desenvolvido encontra-se abaixo.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <magick/MagickCore.h>
int main(int argc, char **argv) {
InitializeMagick(*argv);
ExceptionInfo *exception = AcquireExceptionInfo();
ImageInfo *image_info = CloneImageInfo(NULL);
strcpy(image_info->filename, argv[1]);
Image *image = ReadImage(image_info, exception);
if (exception->severity != UndefinedException) {
CatchException(exception);
return EXIT_FAILURE;
}
printf("Image width: %lu\n", image->columns);
printf("Image height: %lu\n", image->rows);
image = DestroyImage(image);
image_info = DestroyImageInfo(image_info);
DestroyExceptionInfo(exception);
DestroyMagick();
return EXIT_SUCCESS;
}
4. Funções de Inicialização em Bibliotecas Android
Durante a análise de bibliotecas, é importante procurar por funções de inicialização, como por exemplo, lame_init() em libmp3lame ou FPDF_InitLibrary() no PDFium, pois a execução dessas funções é essencial para o funcionamento correto da biblioteca. Se essas funções de inicialização não forem chamadas antes de invocar outros métodos nativos, o harness pode falhar ou até mesmo causar um crash.
Na camada Java da aplicação Android, os construtores das classes que possuem métodos nativos podem indicar as funções de inicialização necessárias para garantir que a execução ocorra de maneira correta.
No caso da biblioteca ImageMagick, a função InitializeMagick deve ser chamada antes de invocar a função ReadImage.
InitializeMagick(*argv);
5. Versões de Cabeçalhos e Offsets das Estruturas na Criação do Harness
Caso a biblioteca seja de código aberto, é importante garantir que os cabeçalhos da biblioteca utilizados pelo harness correspondam exatamente à versão da biblioteca Android utilizada na aplicação. Dados inconsistentes, como versões diferentes de estruturas complexas, podem produzir resultados inesperados ou falhas na execução do harness.
Por exemplo, a figura abaixo ilustra uma estrutura que, após uma atualização, teve seus campos modificados. Se a função copy_transaction(DataTransfer*) da biblioteca Android espera que a estrutura seja passada conforme descrito no arquivo CorrectHeader.h, mas fornecermos a versão presente em WrongHeader.h, o cálculo do número de bytes na operação de memcpy será realizado incorretamente. Especificamente, quando a função tentar acessar o número de bytes (representado pelo campo num na estrutura DataTransfer), com base no cálculo de offset [x8 + 16] (onde x8 é o endereço base da estrutura), o valor de new_field será interpretado como o número de bytes a serem copiados. Isso pode causar corrupção de memória e levar a uma falha na execução do harness.
Os comandos abaixo detalham os passos para a compilação do harness e a execução de testes utilizando o afl-qemu-trace. Vale ressaltar que os cabeçalhos do ImageMagick estão na versão mais recente, o que, como será demonstrado a seguir, não corresponde à versão da biblioteca utilizada pela aplicação Android.
$ aarch64-linux-android35-clang -o harness harness.c -I/home/thiago/Conviso/ImageMagick-Latest/ -DMAGICKCORE_HDRI_ENABLE=1 -DMAGICKCORE_QUANTUM_DEPTH=16 -limagemagick -L .
$ export QEMU_SET_ENV=LD_LIBRARY_PATH="/home/thiago/Conviso/Fuzzing"
$ export QEMU_LD_PREFIX="/home/thiago/Conviso/qiling/examples/rootfs/arm64_android"
$ afl-qemu-trace ./harness ./afl_in/apple.png
A imagem abaixo ilustra o resultado da execução do afl-qemu-trace. É possível observar que, devido aos offsets estarem incorretos, o processo não consegue concluir sua execução, entrando em um estado de bloqueio.
Para obter informações sobre a versão da biblioteca, podemos consultar o arquivo ELF, buscar por strings específicas ou utilizar funções que retornam a versão diretamente. No caso dos nossos testes, a biblioteca ImageMagick oferece a função GetMagickVersion(), que pode ser utilizada para recuperar a versão da biblioteca. Mudando momentaneamente o código do harness, podemos obter a versão da biblioteca.
#include <stdio.h>
#include <stdlib.h>
#include <magick/MagickCore.h>
int main(int argc, char **argv) {
size_t length;
const char *version = GetMagickVersion(&length);
printf("ImageMagick version: %.*s\n", (int)length, version);
return EXIT_SUCCESS;
}
A saída do arquivo executável revelou que a versão do ImageMagick utilizada na biblioteca Android era a 6.7.3-0, uma versão desatualizada. Após restaurar o código do harness e recompilá-lo utilizando os cabeçalhos corretos, o harness funcionou corretamente.
Com a versão da biblioteca em mãos, podemos fazer o download dos cabeçalhos corretos, restaurar o código anterior do harness e recompilá-lo utilizando os comandos abaixo.
$ aarch64-linux-android35-clang -o harness harness.c -I/home/thiago/Conviso/Android-ImageMagick/jni/ImageMagick-6.7.3-0/ -DMAGICKCORE_HDRI_ENABLE=1 -DMAGICKCORE_QUANTUM_DEPTH=16 -limagemagick -L .
$ export QEMU_SET_ENV=LD_LIBRARY_PATH="/home/thiago/Conviso/Fuzzing"
$ export QEMU_LD_PREFIX="/home/thiago/Conviso/qiling/examples/rootfs/arm64_android"
$ afl-qemu-trace ./harness ./afl_in/apple.png
Após a execução do afl-qemu-trace, podemos confirmar que o harness está funcionando corretamente.
Quando a biblioteca não for de código aberto e a função trabalhar com estruturas complexas, é necessário verificar os offsets dos campos das estruturas utilizados pela função, por meio de engenharia reversa. Caso os offsets dos campos da estrutura não respeitem o alinhamento padrão da arquitetura, deve-se garantir que o atributo __attribute__((packed)) seja aplicado na declaração da estrutura no código do harness. Esse atributo instrui o compilador a não adicionar preenchimento (padding) entre os membros da estrutura — o qual é geralmente inserido pelo compilador para otimizar o desempenho no acesso à memória, entre outros fatores — evitando assim o cálculo incorreto dos offsets.
Por exemplo, suponha que a função que desejamos testar com fuzzing receba uma estrutura complexa como parâmetro, mas apenas faça uso do conteúdo do endereço localizado após 58 bytes do endereço base da estrutura. Nesse caso, podemos ignorar os bytes anteriores e concentrar nossa atenção apenas no campo que representa o dado. A estrutura seria definida da seguinte forma:
typedef struct {
char dummy[58];
void *ptr;
} RandomStruct;
Para verificar como o compilador irá definir a estrutura, vamos criar um exemplo e examinar o código de uma função f(RandomStruct*), que recebe um ponteiro para RandomStruct como parâmetro e retorna o campo ptr da estrutura, conforme mostrado abaixo.
void *f(RandomStruct *r) {
return r->ptr;
}
A figura abaixo ilustra a diferença no código gerado ao acessar o campo ptr, com e sem o atributo __attribute__((packed)) configurado na estrutura. Observa-se que, por questões de otimização, quando o atributo é omitido, o compilador ajusta o offset de ptr para 64 bytes a partir do endereço base da estrutura. Ao aplicar o atributo, o offset de ptr passa a ser de 58 bytes a partir do endereço base, conforme o desejado.
6. Paciência é Uma Virtude
É fundamental não interromper o processo de fuzzing prematuramente, especialmente enquanto o AFL++ continua a explorar novos caminhos de código. No caso da aplicação testada, configuramos o diretório de corpuses com algumas pequenas imagens e deixamos o fuzzer rodar por 5 horas. Durante esse período, o AFL++ foi capaz de identificar dois crashes.
Abaixo estão os comandos utilizados para iniciar o fuzzing com o AFL++. Caso seja necessário revisar algum comando, consulte o artigo anterior.
$ export QEMU_SET_ENV=LD_LIBRARY_PATH="/home/thiago/Conviso/Fuzzing"
$ export QEMU_LD_PREFIX="/home/thiago/Conviso/qiling/examples/rootfs/arm64_android"
$ AFL_INST_LIBS=1 AFL_QEMU_FORCE_DFL=1 afl-fuzz -Q -i afl_in/ -o afl_out/ -- ./harness @@
7. Logs do Sistema e Debugging
Após o AFL++ identificar crashes no harness, podemos confirmar a falha utilizando o afl-qemu-trace, passando como parâmetro a entrada que causou o crash. Na imagem abaixo, é possível verificar que essa entrada resultou em uma falha de segmentação no harness.
No momento de testar a entrada na aplicação real, podemos contar com o auxílio dos logs do sistema através da ferramenta logcat. O logcat é uma ferramenta do Android que exibe logs de sistema e aplicativos, auxiliando na depuração de erros e monitoramento de eventos em tempo real.
Para testar a entrada na aplicação, renomeamos o arquivo para uma extensão comum de imagens, como .PNG, e solicitamos que a aplicação convertesse a imagem para outro formato. Quando a conversão foi iniciada, a aplicação reiniciou. Ao analisar a saída no logcat, foi possível confirmar que ocorreu um crash na aplicação.
Na saída do backtrace, podemos verificar que a entrada causou um problema em memcpy, chamado pela função ReadImage, ou seja, a função que escolhemos como alvo para o AFL++.
Conclusão
Em conclusão, as dicas apresentadas ao longo deste artigo são úteis para a construção de harnesses eficazes para fuzzing de código nativo em aplicações Android, abrangendo desde a preparação do código até a análise dos resultados. Ao combinar o AFL++ com o QEMU, é possível otimizar a detecção de falhas e adotar uma abordagem mais completa na análise de segurança de aplicativos Android. Além disso, a integração de técnicas de depuração, como o uso do logcat e ferramentas de rastreamento, permite uma investigação mais detalhada dos crashes, contribuindo para a identificação de vulnerabilidades críticas.
No próximo artigo, vamos abordar questões relacionadas à inicialização da JVM, ao acesso consistente aos ponteiros JNIEnv e à comunicação com a camada Java no contexto de harnesses para fuzzing. Essas soluções ajudarão a eliminar a necessidade de recriar o comportamento do método JNI no código do harness, como foi necessário neste artigo.
