Skip to content

MASTG-TECH-0031: Depuração

Até agora, você tem utilizado técnicas de análise estática sem executar os aplicativos-alvo. No mundo real, especialmente ao reverter malware ou aplicativos mais complexos, a análise estática pura é muito difícil. Observar e manipular um aplicativo durante a execução torna muito, muito mais fácil decifrar seu comportamento. A seguir, veremos métodos de análise dinâmica que ajudam você a fazer exatamente isso.

Aplicativos Android suportam dois tipos diferentes de depuração: Depuração no nível do runtime Java com o Java Debug Wire Protocol (JDWP) e depuração no estilo Linux/Unix baseada em ptrace na camada nativa, ambas valiosas para engenheiros reversos.

Depurando Aplicativos de Release

Dalvik e ART suportam o JDWP, um protocolo para comunicação entre o depurador e a máquina virtual Java (VM) que está sendo depurada. JDWP é um protocolo de depuração padrão suportado por todas as ferramentas de linha de comando e IDEs Java, incluindo jdb, IntelliJ e Eclipse. A implementação do JDWP no Android também inclui hooks para suportar funcionalidades extras implementadas pelo Dalvik Debug Monitor Server (DDMS).

Um depurador JDWP permite que você execute passo a passo o código Java, defina breakpoints em métodos Java e inspecione e modifique variáveis locais e de instância. Você usará um depurador JDWP na maior parte do tempo em que depurar aplicativos Android "normais" (ou seja, aplicativos que não fazem muitas chamadas para bibliotecas nativas).

Na seção a seguir, mostraremos como resolver Android UnCrackable L1 apenas com jdb. Note que esta não é uma maneira eficiente de resolver este crackme. Na verdade, você pode fazê-lo muito mais rápido com Frida e outros métodos, que introduziremos mais adiante no guia. No entanto, isso serve como uma introdução às capacidades do depurador Java.

Depurando com jdb

A ferramenta de linha de comando adb foi introduzida no capítulo "Teste Básico de Segurança Android))". Você pode usar seu comando adb jdwp para listar os IDs de processo de todos os processos depuráveis em execução no dispositivo conectado (ou seja, processos que hospedam um transporte JDWP). Com o comando adb forward, você pode abrir um socket de escuta no seu computador host e encaminhar as conexões TCP de entrada deste socket para o transporte JDWP de um processo escolhido.

$ adb jdwp
12167
$ adb forward tcp:7777 jdwp:12167

Agora você está pronto para anexar o jdb. No entanto, anexar o depurador faz com que o aplicativo retome a execução, o que você não deseja. Você quer mantê-lo suspenso para poder explorar primeiro. Para evitar que o processo retome, pipe o comando suspend para o jdb:

$ { echo "suspend"; cat; } | jdb -attach localhost:7777
Inicializando jdb ...
> Todos os threads suspensos.
>

Agora você está anexado ao processo suspenso e pronto para prosseguir com os comandos jdb. Digitando ? é impressa a lista completa de comandos. Infelizmente, a VM Android não suporta todos os recursos disponíveis do JDWP. Por exemplo, o comando redefine, que permitiria redefinir o código de uma classe, não é suportado. Outra restrição importante é que breakpoints de linha não funcionarão porque o bytecode de release não contém informações de linha. No entanto, breakpoints de método funcionam. Comandos úteis que funcionam incluem:

  • classes: lista todas as classes carregadas
  • class/methods/fields id da classe: Exibe detalhes sobre uma classe e lista seus métodos e campos
  • locals: exibe variáveis locais no frame de stack atual
  • print/dump expr: exibe informações sobre um objeto
  • stop in método: define um breakpoint de método
  • clear método: remove um breakpoint de método
  • set lvalue = expr: atribui novo valor a campo/variável/elemento de array

Vamos revisar o código decompilado do Android UnCrackable L1 e pensar em possíveis soluções. Uma boa abordagem seria suspender o aplicativo em um estado onde a string secreta é mantida em uma variável em texto claro para que você possa recuperá-la. Infelizmente, você não chegará tão longe a menos que lide primeiro com a detecção de root/adulteração.

Revise o código e você verá que o método sg.vantagepoint.uncrackable1.MainActivity.a exibe a caixa de mensagem "Isso é inaceitável...". Este método cria um AlertDialog e define uma classe listener para o evento onClick. Esta classe (chamada b) tem um método de callback que termina o aplicativo assim que o usuário toca no botão OK. Para evitar que o usuário simplesmente cancele a caixa de diálogo, o método setCancelable é chamado.

  private void a(final String title) {
        final AlertDialog create = new AlertDialog$Builder((Context)this).create();
        create.setTitle((CharSequence)title);
        create.setMessage((CharSequence)"Isso é inaceitável. O aplicativo agora vai sair.");
        create.setButton(-3, (CharSequence)"OK", (DialogInterface$OnClickListener)new b(this));
        create.setCancelable(false);
        create.show();
    }

Você pode contornar isso com uma pequena adulteração em tempo de execução. Com o aplicativo ainda suspenso, defina um breakpoint de método em android.app.Dialog.setCancelable e retome o aplicativo.

> stop in android.app.Dialog.setCancelable
Breakpoint definido em android.app.Dialog.setCancelable
> resume
Todos os threads retomados.
>
Breakpoint atingido: "thread=main", android.app.Dialog.setCancelable(), line=1,110 bci=0
main[1]

O aplicativo agora está suspenso na primeira instrução do método setCancelable. Você pode imprimir os argumentos passados para setCancelable com o comando locals (os argumentos são mostrados incorretamente em "variáveis locais").

main[1] locals
Argumentos do método:
Variáveis locais:
flag = true

setCancelable(true) foi chamado, então esta não pode ser a chamada que estamos procurando. Retome o processo com o comando resume.

main[1] resume
Breakpoint atingido: "thread=main", android.app.Dialog.setCancelable(), line=1,110 bci=0
main[1] locals
flag = false

Agora você atingiu uma chamada para setCancelable com o argumento false. Defina a variável para true com o comando set e retome.

main[1] set flag = true
 flag = true = true
main[1] resume

Repita este processo, definindo flag para true cada vez que o breakpoint for atingido, até que a caixa de alerta seja finalmente exibida (o breakpoint será atingido cinco ou seis vezes). A caixa de alerta agora deve ser cancelável! Toque na tela ao lado da caixa e ela fechará sem terminar o aplicativo.

Agora que a anti-adaulteração está resolvida, você está pronto para extrair a string secreta! Na seção de "análise estática", você viu que a string é descriptografada com AES e então comparada com a string inserida na caixa de mensagem. O método equals da classe java.lang.String compara a string inserida com a string secreta. Defina um breakpoint de método em java.lang.String.equals, insira um texto arbitrário no campo de edição e toque no botão "verify". Quando o breakpoint for atingido, você pode ler o argumento do método com o comando locals.

> stop in java.lang.String.equals
Breakpoint definido em java.lang.String.equals
>
Breakpoint atingido: "thread=main", java.lang.String.equals(), line=639 bci=2

main[1] locals
Argumentos do método:
Variáveis locais:
other = "radiusGravity"
main[1] cont

Breakpoint atingido: "thread=main", java.lang.String.equals(), line=639 bci=2

main[1] locals
Argumentos do método:
Variáveis locais:
other = "I want to believe"
main[1] cont

Esta é a string em texto claro que você está procurando!

Depurando com uma IDE

Configurar um projeto em uma IDE com as fontes decompiladas é um truque interessante que permite definir breakpoints de método diretamente no código-fonte. Na maioria dos casos, você deve conseguir executar passo a passo o aplicativo e inspecionar o estado das variáveis com a GUI. A experiência não será perfeita, afinal não é o código-fonte original, então você não conseguirá definir breakpoints de linha e às vezes as coisas simplesmente não funcionarão corretamente. Por outro lado, reverter código nunca é fácil, e navegar e depurar código Java simples de maneira eficiente é uma forma bastante conveniente de fazê-lo. Um método similar foi descrito no blog da NetSPI.

Para configurar a depuração na IDE, primeiro crie seu projeto Android no IntelliJ e copie as fontes Java decompiladas para a pasta de origem, conforme descrito acima no Revisão de Código Java Descompilado. No dispositivo, escolha o aplicativo como aplicativo de depuração nas "Opções do desenvolvedor" ( Android UnCrackable L1 neste tutorial) e certifique-se de ter ativado o recurso "Wait For Debugger" (Aguardar Depurador).

Assim que você tocar no ícone do aplicativo no launcher, ele será suspenso no modo "Wait For Debugger".

Agora você pode definir breakpoints e anexar ao processo do aplicativo com o botão "Attach Debugger" (Anexar Depurador) na barra de ferramentas.

Note que apenas breakpoints de método funcionam ao depurar um aplicativo a partir de fontes decompiladas. Quando um breakpoint de método é atingido, você terá a chance de executar passo a passo durante a execução do método.

Depois de escolher o aplicativo na lista, o depurador será anexado ao processo do aplicativo e você atingirá o breakpoint definido no método onCreate. Este aplicativo aciona controles de anti-depuração e anti-adaulteração dentro do método onCreate. Por isso, definir um breakpoint no método onCreate logo antes que as verificações de anti-adaulteração e anti-depuração sejam realizadas é uma boa ideia.

Em seguida, execute passo a passo pelo método onCreate clicando em "Force Step Into" (Forçar Entrar) na visualização do Depurador. A opção "Force Step Into" permite depurar funções do framework Android e classes Java principais que normalmente são ignoradas pelos depuradores.

Assim que você "Force Step Into", o depurador parará no início do próximo método, que é o método a da classe sg.vantagepoint.a.c.

Este método procura pelo binário "su" dentro de uma lista de diretórios (/system/xbin e outros). Como você está executando o aplicativo em um dispositivo/emulador root, você precisa derrotar esta verificação manipulando variáveis e/ou valores de retorno de funções.

Você pode ver os nomes dos diretórios dentro da janela "Variables" (Variáveis) clicando em "Step Over" (Passar por Cima) na visualização do Depurador para entrar e percorrer o método a.

Entre no método System.getenv com o recurso "Force Step Into".

Depois de obter os nomes de diretórios separados por dois-pontos, o cursor do depurador retornará ao início do método a, não para a próxima linha executável. Isso acontece porque você está trabalhando no código decompilado em vez do código-fonte. Este salto torna crucial seguir o fluxo do código para depurar aplicativos decompilados. Caso contrário, identificar a próxima linha a ser executada se tornaria complicado.

Se você não quiser depurar classes principais Java e Android, você pode sair da função clicando em "Step Out" (Sair) na visualização do Depurador. Usar "Force Step Into" pode ser uma boa ideia uma vez que você atinja as fontes decompiladas e "Step Out" das classes principais Java e Android. Isso ajudará a acelerar a depuração enquanto você mantém um olho nos valores de retorno das funções das classes principais.

Depois que o método a obtém os nomes dos diretórios, ele procurará pelo binário su dentro desses diretórios. Para derrotar esta verificação, percorra o método de detecção e inspecione o conteúdo da variável. Uma vez que a execução atinja um local onde o binário su seria detectado, modifique uma das variáveis que contém o nome do arquivo ou do diretório pressionando F2 ou clicando com o botão direito e escolhendo "Set Value" (Definir Valor).

Assim que você modificar o nome do binário ou o nome do diretório, File.exists deve retornar false.

Isso derrota o primeiro controle de detecção de root do aplicativo. Os controles restantes de anti-adaulteração e anti-depuração podem ser derrotados de maneiras similares para que você finalmente alcance a funcionalidade de verificação da string secreta.

O código secreto é verificado pelo método a da classe sg.vantagepoint.uncrackable1.a. Defina um breakpoint no método a e "Force Step Into" quando atingir o breakpoint. Então, execute passo a passo até atingir a chamada para String.equals. É aqui que a entrada do usuário é comparada com a string secreta.

Você pode ver a string secreta na visualização "Variables" quando atingir a chamada do método String.equals.

Depurando Código Nativo

O código nativo no Android é empacotado em bibliotecas compartilhadas ELF e é executado como qualquer outro programa nativo Linux. Consequentemente, você pode depurá-lo com ferramentas padrão (incluindo GDB e depuradores de IDE integrados como IDA Pro), desde que suportem a arquitetura do processador do dispositivo (a maioria dos dispositivos é baseada em chipsets ARM, então isso geralmente não é um problema).

Agora você configurará seu aplicativo de demonstração JNI, HelloWorld-JNI.apk, para depuração. É o mesmo APK que você baixou em "Analisando Estaticamente Código Nativo". Use adb install para instalá-lo no dispositivo ou em um emulador.

adb install HelloWorld-JNI.apk

Se você seguiu as instruções no início deste capítulo, já deve ter o Android NDK. Ele contém versões pré-compiladas do gdbserver para várias arquiteturas. Copie o binário gdbserver para seu dispositivo:

adb push $NDK/prebuilt/android-arm/gdbserver/gdbserver /data/local/tmp

O comando gdbserver --attach faz com que o gdbserver anexe ao processo em execução e se ligue ao endereço IP e porta especificados em comm, que neste caso é um descritor HOST:PORTA. Inicie o HelloWorldJNI no dispositivo, então conecte-se ao dispositivo e determine o PID do processo HelloWorldJNI (sg.vantagepoint.helloworldjni). Então alterne para o usuário root e anexe o gdbserver:

$ adb shell
$ ps | grep helloworld
u0_a164   12690 201   1533400 51692 ffffffff 00000000 S sg.vantagepoint.helloworldjni
$ su
# /data/local/tmp/gdbserver --attach localhost:1234 12690
Anexado; pid = 12690
Escutando na porta 1234

O processo agora está suspenso, e gdbserver está escutando por clientes de depuração na porta 1234. Com o dispositivo conectado via USB, você pode encaminhar esta porta para uma porta local no host com o comando adb forward:

adb forward tcp:1234 tcp:1234

Agora você usará a versão pré-compilada do gdb incluída no toolchain do NDK.

$ $TOOLCHAIN/bin/gdb libnative-lib.so
GNU gdb (GDB) 7.11
(...)
Lendo símbolos de libnative-lib.so...(nenhum símbolo de depuração encontrado)...done.
(gdb) target remote :1234
Depuração remota usando :1234
0xb6e0f124 em ?? ()

Você anexou com sucesso ao processo! O único problema é que você já está tarde demais para depurar a função JNI StringFromJNI; ela é executada apenas uma vez, na inicialização. Você pode resolver este problema ativando a opção "Wait for Debugger". Vá para Opções do desenvolvedor -> Selecionar aplicativo de depuração e escolha HelloWorldJNI, então ative a opção Aguardar depurador. Então termine e reinicie o aplicativo. Ele deve ser suspenso automaticamente.

Nosso objetivo é definir um breakpoint na primeira instrução da função nativa Java_sg_vantagepoint_helloworldjni_MainActivity_stringFromJNI antes de retomar o aplicativo. Infelizmente, isso não é possível neste ponto da execução porque libnative-lib.so ainda não está mapeado na memória do processo, ele é carregado dinamicamente durante o tempo de execução. Para fazer isso funcionar, você primeiro usará jdb para gentilmente mudar o processo para o estado desejado.

Primeiro, retome a execução da VM Java anexando jdb. Você não quer que o processo retome imediatamente, então pipe o comando suspend para jdb:

$ adb jdwp
14342
$ adb forward tcp:7777 jdwp:14342
$ { echo "suspend"; cat; } | jdb -attach localhost:7777

Em seguida, suspenda o processo onde o runtime Java carrega libnative-lib.so. No jdb, defina um breakpoint no método java.lang.System.loadLibrary e retome o processo. Depois que o breakpoint for atingido, execute o comando step up, que retomará o processo até loadLibrary retornar. Neste ponto, libnative-lib.so foi carregado.

> stop in java.lang.System.loadLibrary
> resume
Todos os threads retomados.
Breakpoint atingido: "thread=main", java.lang.System.loadLibrary(), line=988 bci=0
> step up
main[1] step up
>
Step completed: "thread=main", sg.vantagepoint.helloworldjni.MainActivity.<clinit>(), line=12 bci=5

main[1]

Execute gdbserver para anexar ao aplicativo suspenso. Isso fará com que o aplicativo seja suspenso tanto pela VM Java quanto pelo kernel Linux (criando um estado de "dupla suspensão").

$ adb forward tcp:1234 tcp:1234
$ $TOOLCHAIN/arm-linux-androideabi-gdb libnative-lib.so
GNU gdb (GDB) 7.7
Copyright (C) 2014 Free Software Foundation, Inc.
(...)
(gdb) target remote :1234
Depuração remota usando :1234
0xb6de83b8 em ?? ()