Tampering e Engenharia Reversa de Aplicativos Móveis¶
As técnicas de engenharia reversa e tampering há muito pertencem ao domínio de crackers, modders, malware analysts, etc. Para testadores e pesquisadores de segurança "tradicionais", a engenharia reversa tem sido mais uma habilidade complementar. Mas as marés estão mudando: o teste de caixa-preta de aplicativos móveis exige cada vez mais a desmontagem de aplicativos compilados, aplicação de patches e tampering com código binário ou até mesmo com processos em execução. O fato de muitos aplicativos móveis implementarem defesas contra tampering indesejado não facilita a vida dos testadores de segurança.
A engenharia reversa de um aplicativo móvel é o processo de analisar o aplicativo compilado para extrair informações sobre seu código-fonte. O objetivo da engenharia reversa é compreender o código.
Tampering é o processo de alterar um aplicativo móvel (seja o aplicativo compilado ou o processo em execução) ou seu ambiente para afetar seu comportamento. Por exemplo, um aplicativo pode se recusar a ser executado em seu dispositivo de teste com root, impossibilitando a execução de alguns de seus testes. Nesses casos, você desejará alterar o comportamento do aplicativo.
Os testadores de segurança móvel se beneficiam ao entender conceitos básicos de engenharia reversa. Eles também devem conhecer profundamente dispositivos móveis e sistemas operacionais: arquitetura do processador, formato de executável, intricidades de linguagens de programação e assim por diante.
A engenharia reversa é uma arte, e descrever cada uma de suas facetas preencheria uma biblioteca inteira. A enorme variedade de técnicas e especializações é impressionante: pode-se passar anos trabalhando em um subproblema muito específico e isolado, como automatizar a análise de malware ou desenvolver novos métodos de desofuscação. Testadores de segurança são generalistas; para serem engenheiros reversos eficazes, eles devem filtrar a vasta quantidade de informações relevantes.
Não existe um processo genérico de engenharia reversa que sempre funcione. Dito isso, descreveremos métodos e ferramentas comumente usados posteriormente neste guia e daremos exemplos de como lidar com as defesas mais comuns.
Por Que Você Precisa Disso¶
O teste de segurança móvel requer pelo menos habilidades básicas de engenharia reversa por várias razões:
1. Para permitir o teste de caixa-preta de aplicativos móveis. Aplicativos modernos geralmente incluem controles que dificultam a análise dinâmica. SSL pinning e criptografia ponta a ponta (E2E) às vezes impedem que você intercepte ou manipule o tráfego com um proxy. A detecção de root pode impedir que o aplicativo seja executado em um dispositivo com root, impossibilitando o uso de ferramentas avançadas de teste. Você deve ser capaz de desativar essas defesas.
2. Para aprimorar a análise estática no teste de segurança de caixa-preta. Em um teste de caixa-preta, a análise estática do bytecode ou código binário do aplicativo ajuda você a entender a lógica interna do aplicativo. Também permite identificar falhas, como credenciais embutidas.
3. Para avaliar a resiliência contra engenharia reversa. Aplicativos que implementam as medidas de proteção de software listadas nos Controles Anti-Reversão do Padrão de Verificação de Segurança de Aplicativos Móveis (MASVS-R) devem resistir à engenharia reversa até certo ponto. Para verificar a eficácia de tais controles, o testador pode realizar uma avaliação de resiliência como parte do teste de segurança geral. Para a avaliação de resiliência, o testador assume o papel do engenheiro reverso e tenta contornar as defesas.
Antes de mergulharmos no mundo da reversão de aplicativos móveis, temos boas e más notícias. Comecemos com as boas notícias:
No final, o engenheiro reverso sempre vence.
Isso é especialmente verdadeiro na indústria móvel, onde o engenheiro reverso tem uma vantagem natural: a forma como os aplicativos móveis são implantados e isolados em sandbox é, por design, mais restritiva do que a implantação e o isolamento em sandbox de aplicativos clássicos de desktop, portanto, incluir mecanismos defensivos semelhantes a rootkits, frequentemente encontrados em software Windows (por exemplo, sistemas DRM), simplesmente não é viável. A abertura do Android permite que engenheiros reversos façam alterações favoráveis ao sistema operacional, auxiliando o processo de engenharia reversa. O iOS dá aos engenheiros reversos menos controle, mas as opções defensivas também são mais limitadas.
A má notícia é que lidar com controles anti-depuração multithread, white-boxes criptográficas, recursos anti-tampering furtivos e transformações de fluxo de controle altamente complexas não é para os fracos de coração. Os esquemas de proteção de software mais eficazes são proprietários e não serão vencidos com ajustes e truques padrão. Derrotá-los requer análise manual tediosa, codificação, frustração e, dependendo de sua personalidade, noites sem dormir e relacionamentos tensos.
É fácil para iniciantes ficarem sobrecarregados com o enorme escopo da reversão. A melhor maneira de começar é configurar algumas ferramentas básicas (consulte as seções relevantes nos capítulos de reversão do Android e iOS) e começar com tarefas simples de reversão e crackmes. Você precisará aprender sobre a linguagem assembly/bytecode, o sistema operacional, ofuscações que encontrar e assim por diante. Comece com tarefas simples e gradualmente avance para as mais difíceis.
Na seção a seguir, daremos uma visão geral das técnicas mais comumente usadas no teste de segurança de aplicativos móveis. Nos capítulos posteriores, nos aprofundaremos em detalhes específicos do sistema operacional, tanto do Android quanto do iOS.
Técnicas Básicas de Tampering¶
Binary Patching¶
Patching é o processo de alterar o aplicativo compilado, por exemplo, alterar o código em executáveis binários, modificar Java bytecode ou tampering com recursos. Esse processo é conhecido como modding na cena de hacking de jogos móveis. Patches podem ser aplicados de várias maneiras, incluindo editar arquivos binários em um editor hexadecimal e descompilar, editar e remontar um aplicativo. Daremos exemplos detalhados de patches úteis em capítulos posteriores.
Lembre-se de que os sistemas operacionais móveis modernos impõem estritamente a assinatura de código, portanto, executar aplicativos modificados não é tão simples como costumava ser em ambientes desktop. Os especialistas em segurança tinham uma vida muito mais fácil nos anos 90! Felizmente, patching não é muito difícil se você trabalhar em seu próprio dispositivo. Você simplesmente precisa reassinar o aplicativo ou desativar os recursos padrão de verificação de assinatura de código para executar código modificado.
Code Injection¶
Code injection é uma técnica muito poderosa que permite explorar e modificar processos em tempo de execução. A injeção pode ser implementada de várias maneiras, mas você se sairá bem sem conhecer todos os detalhes, graças a ferramentas gratuitas e bem documentadas que automatizam o processo. Essas ferramentas dão a você acesso direto à memória do processo e a estruturas importantes, como objetos em tempo real instanciados pelo aplicativo. Elas vêm com muitas funções utilitárias úteis para resolver bibliotecas carregadas, conectar métodos e funções nativas e muito mais. O tampering da memória do processo é mais difícil de detectar do que o file patching, portanto, é o método preferido na maioria dos casos.
ElleKit, Frida e Xposed são as estruturas de code injection e hooking mais amplamente usadas na indústria móvel. As três estruturas diferem em filosofia de design e detalhes de implementação: ElleKit e Xposed focam em code injection e/ou hooking, enquanto Frida visa ser uma "estrutura de instrumentação dinâmica" completa, incorporando code injection, vinculações de linguagem, e um JavaScript VM e console injetáveis.
Incluiremos exemplos de todas as três estruturas. Recomendamos começar com o Frida porque é o mais versátil dos três (por esse motivo, também incluiremos mais detalhes e exemplos do Frida). Notavelmente, o Frida pode injetar um JavaScript VM em um processo tanto no Android quanto no iOS, enquanto a injeção com ElleKit funciona apenas no iOS e o Xposed apenas no Android. No final, no entanto, você pode, é claro, alcançar muitos dos mesmos objetivos com qualquer estrutura.
Análise Binária Estática e Dinâmica¶
A engenharia reversa é o processo de reconstruir a semântica do código-fonte de um programa compilado. Em outras palavras, você desmonta o programa, executa-o, simula partes dele e faz outras coisas indizíveis para entender o que ele faz e como.
Usando Disassemblers e Decompilers¶
Disassemblers e decompilers permitem que você traduza o código binário ou bytecode de um aplicativo de volta para um formato mais ou menos compreensível. Ao usar essas ferramentas em binários nativos, você pode obter código assembly que corresponde à arquitetura para a qual o aplicativo foi compilado. Disassemblers convertem código de máquina em código assembly, que por sua vez é usado por decompilers para gerar código equivalente em linguagem de alto nível. Aplicativos Android Java podem ser desmontados para smali, que é uma linguagem assembly para o formato DEX usado pelo Dalvik, a Java VM do Android. O assembly smali também pode ser facilmente descompilado de volta para código Java equivalente.
Em teoria, o mapeamento entre assembly e código de máquina deve ser um para um e, portanto, pode dar a impressão de que a desmontagem é uma tarefa simples. Mas na prática, existem várias armadilhas, como:
- Distinção confiável entre código e dados.
- Tamanho variável de instrução.
- Instruções de desvio indireto.
- Funções sem instruções CALL explícitas dentro do segmento de código do executável.
- Sequências de código independente de posição (PIC).
- Código assembly feito à mão.
Da mesma forma, a descompilação é um processo muito complicado, envolvendo muitas abordagens determinísticas e baseadas em heurísticas. Como consequência, a descompilação geralmente não é muito precisa, mas mesmo assim é muito útil para obter uma compreensão rápida da função que está sendo analisada. A precisão da descompilação depende da quantidade de informação disponível no código que está sendo descompilado e da sofisticação do decompiler. Além disso, muitas ferramentas de compilação e pós-compilação introduzem complexidade adicional ao código compilado para aumentar a dificuldade de compreensão e/ou até mesmo da própria descompilação. Esse código é referido como código ofuscado)).
Nas últimas décadas, muitas ferramentas aperfeiçoaram o processo de desmontagem e descompilação, produzindo saída com alta fidelidade. Instruções avançadas de uso para qualquer uma das ferramentas disponíveis podem facilmente preencher um livro por si só. A melhor maneira de começar é simplesmente escolher uma ferramenta que atenda às suas necessidades e orçamento e obter um guia do usuário bem avaliado. Nesta seção, forneceremos uma introdução a algumas dessas ferramentas e, nos capítulos subsequentes de "Engenharia Reversa e Tampering" do Android e iOS, focaremos nas técnicas em si, especialmente naquelas específicas da plataforma em questão.
Obfuscation¶
Obfuscation é o processo de transformar código e dados para torná-los mais difíceis de compreender (e às vezes até difíceis de desmontar). Geralmente é uma parte integrante do esquema de proteção de software. Obfuscation não é algo que pode ser simplesmente ligado ou desligado; os programas podem ser tornados incompreensíveis, no todo ou em parte, de muitas maneiras e em diferentes graus.
Nota: Todas as técnicas apresentadas abaixo não impedirão alguém com tempo e orçamento suficientes de fazer engenharia reversa do seu aplicativo. No entanto, combinar essas técnicas tornará seu trabalho significativamente mais difícil. O objetivo é, portanto, desencorajar engenheiros reversos a realizar análises adicionais e não valer a pena o esforço.
As seguintes técnicas podem ser usadas para ofuscar um aplicativo:
- Obfuscação de nomes
- Substituição de instruções
- Achatamento de fluxo de controle
- Injeção de código morto
- Criptografia de strings
- Empacotamento
Obfuscação de Nomes¶
O compilador padrão gera símbolos binários com base nos nomes de classes e funções do código-fonte. Portanto, se nenhuma ofuscação for aplicada, os nomes dos símbolos permanecem significativos e podem ser facilmente extraídos do binário do aplicativo. Por exemplo, uma função que detecta jailbreak pode ser localizada pesquisando por palavras-chave relevantes (por exemplo, "jailbreak"). A listagem abaixo mostra a função desmontada JailbreakDetectionViewController.jailbreakTest4Tapped do DVIA-v2.
__T07DVIA_v232JailbreakDetectionViewControllerC20jailbreakTest4TappedyypF:
stp x22, x21, [sp, #-0x30]!
mov rbp, rsp
Após a ofuscação, podemos observar que o nome do símbolo não é mais significativo, como mostrado na listagem abaixo.
__T07DVIA_v232zNNtWKQptikYUBNBgfFVMjSkvRdhhnbyyFySbyypF:
stp x22, x21, [sp, #-0x30]!
mov rbp, rsp
No entanto, isso se aplica apenas aos nomes de funções, classes e campos. O código real permanece inalterado, então um invasor ainda pode ler a versão desmontada da função e tentar entender seu propósito (por exemplo, para recuperar a lógica de um algoritmo de segurança).
Substituição de Instruções¶
Esta técnica substitui operadores binários padrão, como adição ou subtração, por representações mais complexas. Por exemplo, uma adição x = a + b pode ser representada como x = -(-a) - (-b). No entanto, usar a mesma representação de substituição pode ser facilmente revertido, portanto, é recomendado adicionar múltiplas técnicas de substituição para um único caso e introduzir um fator aleatório. Esta técnica pode ser revertida durante a descompilação, mas dependendo da complexidade e profundidade das substituições, revertê-la ainda pode ser demorado.
Achatamento de Fluxo de Controle¶
O achatamento de fluxo de controle substitui o código original por uma representação mais complexa. A transformação quebra o corpo de uma função em blocos básicos e os coloca todos dentro de um único loop infinito com uma instrução switch que controla o fluxo do programa. Isso torna o fluxo do programa significativamente mais difícil de seguir, pois remove as construções condicionais naturais que geralmente tornam o código mais fácil de ler.

A imagem mostra como o achatamento de fluxo de controle altera o código. Consulte "Obfuscating C++ programs via control flow flattening" para mais informações.
Injeção de Código Morto¶
Esta técnica torna o fluxo de controle do programa mais complexo, injetando código morto no programa. Código morto é um trecho de código que não afeta o comportamento original do programa, mas aumenta a sobrecarga do processo de engenharia reversa.
Criptografia de Strings¶
Aplicativos são frequentemente compilados com chaves embutidas, licenças, tokens e URLs de endpoint. Por padrão, todos eles são armazenados em texto simples na seção de dados do binário de um aplicativo. Esta técnica criptografa esses valores e injeta trechos de código no programa que descriptografarão esses dados antes de serem usados pelo programa.
Empacotamento¶
Empacotamento é uma técnica de ofuscação de reescrita dinâmica que compacta ou criptografa o executável original em dados e o recupera dinamicamente durante a execução. Empacotar um executável altera a assinatura do arquivo na tentativa de evitar detecção baseada em assinatura.
Depuração e Rastreamento¶
No sentido tradicional, depuração é o processo de identificar e isolar problemas em um programa como parte do ciclo de vida do desenvolvimento de software. As mesmas ferramentas usadas para depuração são valiosas para engenheiros reversos, mesmo quando identificar bugs não é o objetivo principal. Depuradores permitem a suspensão do programa em qualquer ponto durante o tempo de execução, a inspeção do estado interno do processo e até a modificação de registradores e memória. Essas capacidades simplificam a inspeção do programa.
Depuração geralmente significa sessões interativas de depuração nas quais um depurador é anexado ao processo em execução. Em contraste, rastreamento refere-se ao registro passivo de informações sobre a execução do aplicativo (como chamadas de API). Rastreamento pode ser feito de várias maneiras, incluindo APIs de depuração, ganchos de função e recursos de rastreamento do kernel. Novamente, abordaremos muitas dessas técnicas nos capítulos específicos do sistema operacional "Engenharia Reversa e Tampering".
Técnicas Avançadas¶
Para tarefas mais complicadas, como desofuscar binários fortemente ofuscados, você não irá longe sem automatizar certas partes da análise. Por exemplo, entender e simplificar um grafo de fluxo de controle complexo com base em análise manual no disassembler levaria anos (e muito provavelmente enlouqueceria você muito antes de terminar). Em vez disso, você pode aumentar seu fluxo de trabalho com ferramentas personalizadas. Felizmente, disassemblers modernos vêm com APIs de script e extensão, e muitas extensões úteis estão disponíveis para disassemblers populares. Também existem motores de desmontagem de código aberto e estruturas de análise binária.
Como sempre no hacking, a regra do vale-tudo se aplica: simplesmente use o que for mais eficiente. Cada binário é diferente, e todos os engenheiros reversos têm seu próprio estilo. Muitas vezes, a melhor maneira de alcançar seu objetivo é combinar abordagens (como rastreamento baseado em emulador e execução simbólica). Para começar, escolha um bom disassembler e/ou estrutura de engenharia reversa e familiarize-se com seus recursos particulares e APIs de extensão. No final, a melhor maneira de melhorar é obter experiência prática.
Instrumentação Binária Dinâmica¶
Outra abordagem útil para binários nativos é a instrumentação binária dinâmica (DBI). Estruturas de instrumentação como Valgrind e PIN suportam rastreamento de instrução de nível granular de processos únicos. Isso é realizado inserindo código gerado dinamicamente em tempo de execução. O Valgrind compila bem no Android, e binários pré-construídos estão disponíveis para download.
O README do Valgrind inclui instruções específicas de compilação para Android.
Análise Dinâmica Baseada em Emulação¶
Emulação é uma imitação de uma certa plataforma de computador ou programa sendo executado em uma plataforma diferente ou dentro de outro programa. O software ou hardware que realiza essa imitação é chamado de emulador. Emuladores fornecem uma alternativa muito mais barata a um dispositivo real, onde um usuário pode manipulá-lo sem se preocupar em danificar o dispositivo. Existem múltiplos emuladores disponíveis para Android, mas para iOS praticamente não existem emuladores viáveis disponíveis. O iOS possui apenas um simulador, incluído no Xcode.
A diferença entre um simulador e um emulador frequentemente causa confusão e leva ao uso intercambiável dos dois termos, mas na realidade eles são diferentes, especialmente para o caso de uso do iOS. Um emulador imita tanto o ambiente de software quanto o de hardware de uma plataforma de destino. Por outro lado, um simulador apenas imita o ambiente de software.
Emuladores baseados em QEMU para Android levam em consideração a RAM, CPU, desempenho da bateria etc. (componentes de hardware) ao executar um aplicativo, mas em um simulador iOS esse comportamento de componente de hardware não é considerado. O simulador iOS até carece da implementação do kernel do iOS; como resultado, se um aplicativo estiver usando chamadas de sistema, ele não pode ser executado neste simulador.
Em palavras simples, um emulador é uma imitação muito mais próxima da plataforma de destino, enquanto um simulador imita apenas uma parte dela.
Executar um aplicativo no emulador oferece maneiras poderosas de monitorar e manipular seu ambiente. Para algumas tarefas de engenharia reversa, especialmente aquelas que requerem rastreamento de instrução de baixo nível, emulação é a melhor (ou única) escolha. Infelizmente, esse tipo de análise só é viável para Android, porque não existe um emulador gratuito ou de código aberto para iOS (o simulador iOS não é um emulador, e aplicativos compilados para um dispositivo iOS não são executados nele). O único emulador iOS disponível é uma solução SaaS comercial - Corellium.
Ferramentas Personalizadas com Estruturas de Engenharia Reversa¶
Embora a maioria dos disassemblers profissionais baseados em GUI possuam recursos de script e extensibilidade, eles simplesmente não são adequados para resolver problemas particulares. Estruturas de engenharia reversa permitem que você realize e automatize qualquer tipo de tarefa de reversão sem depender de uma GUI pesada. Notavelmente, a maioria das estruturas de reversão são de código aberto e/ou disponíveis gratuitamente. Estruturas populares com suporte para arquiteturas móveis incluem radare2 para iOS e Angr.
Exemplo: Análise de Programa com Execução Simbólica/Concolic¶
No final dos anos 2000, testes baseados em execução simbólica tornaram-se uma maneira popular de identificar vulnerabilidades de segurança. "Execução" simbólica refere-se, na verdade, ao processo de representar caminhos possíveis através de um programa como fórmulas em lógica de primeira ordem. Solucionadores de Teorias de Satisfatibilidade Modular (SMT) são usados para verificar a satisfatibilidade dessas fórmulas e fornecer soluções, incluindo valores concretos das variáveis necessárias para atingir um certo ponto de execução no caminho correspondente à fórmula resolvida.
Em palavras simples, execução simbólica é analisar matematicamente um programa sem executá-lo. Durante a análise, cada entrada desconhecida é representada como uma variável matemática (um valor simbólico) e, portanto, todas as operações realizadas nessas variáveis são registradas como uma árvore de operações (também conhecida como árvore de sintaxe abstrata (AST), da teoria de compiladores). Essas ASTs podem ser traduzidas em chamadas restrições que serão interpretadas por um solucionador SMT. No final desta análise, obtém-se uma equação matemática final, na qual as variáveis são as entradas cujos valores não são conhecidos. Solucionadores SMT são programas especiais que resolvem essas equações para dar valores possíveis para as variáveis de entrada, dado um estado final.
Para ilustrar isso, imagine uma função que recebe uma entrada (x) e a multiplica pelo valor de uma segunda entrada (y). Finalmente, há uma condição if que verifica se o valor calculado é maior que o valor de uma variável externa (z) e retorna "sucesso" se verdadeiro, caso contrário retorna "falha". A equação para esta operação será (x * y) > z.
Se quisermos que a função sempre retorne "sucesso" (estado final), podemos dizer ao solucionador SMT para calcular os valores de x e y (variáveis de entrada) que satisfazem a equação correspondente. Como é o caso de variáveis globais, seu valor pode ser alterado de fora desta função, o que pode levar a saídas diferentes sempre que esta função for executada. Isso adiciona complexidade adicional na determinação da solução correta.
Internamente, os solucionadores SMT usam várias técnicas de resolução de equações para gerar soluções para tais equações. Algumas das técnicas são muito avançadas e sua discussão está além do escopo deste livro.
Em uma situação real, as funções são muito mais complexas do que o exemplo acima. A complexidade aumentada das funções pode representar desafios significativos para a execução simbólica clássica. Alguns dos desafios são resumidos abaixo:
- Loops e recursões em um programa podem levar a árvore de execução infinita.
- Múltiplos ramos condicionais ou condições aninhadas podem levar a explosão de caminhos.
- Equações complexas geradas pela execução simbólica podem não ser solucionáveis por solucionadores SMT devido às suas limitações.
- O programa está usando chamadas de sistema, chamadas de biblioteca ou eventos de rede que não podem ser tratados pela execução simbólica.
Para superar esses desafios, normalmente, a execução simbólica é combinada com outras técnicas, como execução dinâmica (também chamada de execução concreta) para mitigar o problema de explosão de caminhos específico da execução simbólica clássica. Essa combinação de execução concreta (real) e simbólica é referida como execução concolic (o nome concolic vem de concreto e simbólico), às vezes também chamada de execução simbólica dinâmica.
Para visualizar isso, no exemplo acima, podemos obter o valor da variável externa realizando mais engenharia reversa ou executando dinamicamente o programa e alimentando essa informação em nossa análise de execução simbólica. Essa informação extra reduzirá a complexidade de nossas equações e pode produzir resultados de análise mais precisos. Juntamente com solucionadores SMT aprimorados e velocidades de hardware atuais, execução concolic permite explorar caminhos em módulos de software de tamanho médio (ou seja, na ordem de 10 KLOC).
Além disso, execução simbólica também é útil para apoiar tarefas de desofuscação, como simplificar grafos de fluxo de controle. Por exemplo, Jonathan Salwan e Romain Thomas mostraram como fazer engenharia reversa de proteções de software baseadas em VM usando Execução Simbólica Dinâmica [#salwan] (ou seja, usando uma mistura de traços de execução real, simulação e execução simbólica).
Na seção Android, você encontrará um passo a passo para quebrar uma verificação de licença simples em um aplicativo Android usando execução simbólica.
Referências¶
- [#vadla] Ole André Vadla Ravnås, Anatomy of a code tracer - https://medium.com/@oleavr/anatomy-of-a-code-tracer-b081aadb0df8
- [#salwan] Jonathan Salwan and Romain Thomas, How Triton can help to reverse virtual machine based software protections - https://drive.google.com/file/d/1EzuddBA61jEMy8XbjQKFF3jyoKwW7tLq/view?usp=sharing