MASTG-KNOW-0028: Anti-Debugging
A depuração é uma maneira altamente eficaz de analisar o comportamento de um aplicativo em tempo de execução. Ela permite que o engenheiro reverso percorra o código passo a passo, interrompa a execução do aplicativo em pontos arbitrários, inspecione o estado das variáveis, leia e modifique a memória, entre muitas outras ações.
Recursos anti-depuração podem ser preventivos ou reativos. Como o nome sugere, a anti-depuração preventiva impede que o depurador seja anexado desde o início; já a anti-depuração reativa envolve detectar depuradores e reagir a eles de alguma forma (por exemplo, encerrando o aplicativo ou acionando um comportamento oculto). A regra do "quanto mais, melhor" se aplica: para maximizar a eficácia, os defensores combinam múltiplos métodos de prevenção e detecção que operam em diferentes camadas da API e estão bem distribuídos por todo o aplicativo.
Conforme mencionado no capítulo "Engenharia Reversa e Manipulação", temos que lidar com dois protocolos de depuração no Android: podemos depurar no nível Java com JDWP ou na camada nativa por meio de um depurador baseado em ptrace. Um bom esquema de anti-depuração deve defender contra ambos os tipos de depuração.
Anti-Depuração JDWP¶
No capítulo "Engenharia Reversa e Manipulação", falamos sobre o JDWP, o protocolo usado para comunicação entre o depurador e a Java Virtual Machine. Mostramos que é fácil habilitar a depuração para qualquer aplicativo modificando seu arquivo de manifesto e alterando a propriedade do sistema ro.debuggable, que permite a depuração para todos os aplicativos. Vejamos algumas coisas que os desenvolvedores fazem para detectar e desabilitar depuradores JDWP.
Verificando a Flag Debuggable em ApplicationInfo¶
Já encontramos o atributo android:debuggable. Essa flag no Android Manifest determina se a thread JDWP é iniciada para o aplicativo. Seu valor pode ser determinado programaticamente por meio do objeto ApplicationInfo do aplicativo. Se a flag estiver definida, o manifesto foi manipulado e permite a depuração.
public static boolean isDebuggable(Context context){
return ((context.getApplicationContext().getApplicationInfo().flags & ApplicationInfo.FLAG_DEBUGGABLE) != 0);
}
isDebuggerConnected¶
Embora possa ser bastante óbvio contornar para um engenheiro reverso, você pode usar isDebuggerConnected da classe android.os.Debug para determinar se um depurador está conectado.
public static boolean detectDebugger() {
return Debug.isDebuggerConnected();
}
A mesma API pode ser chamada via código nativo acessando a estrutura global DvmGlobals.
JNIEXPORT jboolean JNICALL Java_com_test_debugging_DebuggerConnectedJNI(JNIenv * env, jobject obj) {
if (gDvm.debuggerConnected || gDvm.debuggerActive)
return JNI_TRUE;
return JNI_FALSE;
}
Verificações com Temporizador¶
Debug.threadCpuTimeNanos indica a quantidade de tempo que a thread atual esteve executando código. Como a depuração desacelera a execução do processo, você pode usar a diferença no tempo de execução para deduzir se um depurador está anexado.
static boolean detect_threadCpuTimeNanos(){
long start = Debug.threadCpuTimeNanos();
for(int i=0; i<1000000; ++i)
continue;
long stop = Debug.threadCpuTimeNanos();
if(stop - start < 10000000) {
return false;
}
else {
return true;
}
}
Manipulando Estruturas de Dados Relacionadas ao JDWP¶
No Dalvik, o estado global da máquina virtual é acessível por meio da estrutura DvmGlobals. A variável global gDvm contém um ponteiro para essa estrutura. DvmGlobals contém várias variáveis e ponteiros importantes para a depuração JDWP e que podem ser manipulados.
struct DvmGlobals {
/*
* Algumas opções que valem a pena manipular :)
*/
bool jdwpAllowed; // depuração permitida para este processo?
bool jdwpConfigured; // as informações de depuração foram fornecidas?
JdwpTransportType jdwpTransport;
bool jdwpServer;
char* jdwpHost;
int jdwpPort;
bool jdwpSuspend;
Thread* threadList;
bool nativeDebuggerActive;
bool debuggerConnected; /* depurador ou DDMS está conectado */
bool debuggerActive; /* depurador está fazendo solicitações */
JdwpState* jdwpState;
};
Por exemplo, definir o ponteiro de função gDvm.methDalvikDdmcServer_dispatch como NULL causa a falha da thread JDWP:
JNIEXPORT jboolean JNICALL Java_poc_c_crashOnInit ( JNIEnv* env , jobject ) {
gDvm.methDalvikDdmcServer_dispatch = NULL;
}
Você pode desabilitar a depuração usando técnicas semelhantes no ART, embora a variável gDvm não esteja disponível. O runtime ART exporta algumas das vtables de classes relacionadas ao JDWP como símbolos globais (em C++, vtables são tabelas que contêm ponteiros para métodos de classe). Isso inclui as vtables das classes JdwpSocketState e JdwpAdbState, que lidam com conexões JDWP via soquetes de rede e ADB, respectivamente. Você pode manipular o comportamento do runtime de depuração substituindo os ponteiros de método nas vtables associadas (arquivado).
Uma maneira de substituir os ponteiros de método é substituir o endereço da função jdwpAdbState::ProcessIncoming pelo endereço de JdwpAdbState::Shutdown. Isso fará com que o depurador se desconecte imediatamente.
#include <jni.h>
#include <string>
#include <android/log.h>
#include <dlfcn.h>
#include <sys/mman.h>
#include <jdwp/jdwp.h>
#define log(FMT, ...) __android_log_print(ANDROID_LOG_VERBOSE, "JDWPFun", FMT, ##__VA_ARGS__)
// Estrutura Vtable. Apenas para facilitar a manipulação
struct VT_JdwpAdbState {
unsigned long x;
unsigned long y;
void * JdwpSocketState_destructor;
void * _JdwpSocketState_destructor;
void * Accept;
void * showmanyc;
void * ShutDown;
void * ProcessIncoming;
};
extern "C"
JNIEXPORT void JNICALL Java_sg_vantagepoint_jdwptest_MainActivity_JDWPfun(
JNIEnv *env,
jobject /* this */) {
void* lib = dlopen("libart.so", RTLD_NOW);
if (lib == NULL) {
log("Erro ao carregar libart.so");
dlerror();
}else{
struct VT_JdwpAdbState *vtable = ( struct VT_JdwpAdbState *)dlsym(lib, "_ZTVN3art4JDWP12JdwpAdbStateE");
if (vtable == 0) {
log("Não foi possível resolver o símbolo '_ZTVN3art4JDWP12JdwpAdbStateE'.\n");
}else {
log("Vtable para JdwpAdbState em: %08x\n", vtable);
// Que comece a diversão!
unsigned long pagesize = sysconf(_SC_PAGE_SIZE);
unsigned long page = (unsigned long)vtable & ~(pagesize-1);
mprotect((void *)page, pagesize, PROT_READ | PROT_WRITE);
vtable->ProcessIncoming = vtable->ShutDown;
// Redefinir permissões e limpar cache
mprotect((void *)page, pagesize, PROT_READ);
}
}
}
Anti-Depuração Tradicional¶
No Linux, a chamada de sistema ptrace é usada para observar e controlar a execução de um processo (o tracee) e examinar e alterar a memória e os registradores desse processo. ptrace é a principal maneira de implementar rastreamento de chamadas de sistema e depuração com breakpoints em código nativo. A maioria dos truques de anti-depuração JDWP (que podem ser seguros para verificações baseadas em temporizador) não detectará depuradores clássicos baseados em ptrace e, portanto, muitos truques de anti-depuração no Android incluem ptrace, frequentemente explorando o fato de que apenas um depurador por vez pode ser anexado a um processo.
Verificando TracerPid¶
Quando você depura um aplicativo e define um breakpoint em código nativo, o Android Studio copiará os arquivos necessários para o dispositivo de destino e iniciará o lldb-server, que usará ptrace para anexar ao processo. A partir desse momento, se você inspecionar o arquivo de status do processo depurado (/proc/<pid>/status ou /proc/self/status), verá que o campo "TracerPid" tem um valor diferente de 0, o que é um sinal de depuração.
Lembre-se de que isso se aplica apenas ao código nativo. Se você estiver depurando um aplicativo apenas em Java/Kotlin, o valor do campo "TracerPid" deve ser 0.
Essa técnica geralmente é aplicada dentro das bibliotecas nativas JNI em C, conforme mostrado na implementação do método IsDebuggerAttached do Heap Checker do gperftools (Google Performance Tools) do Google. No entanto, se você preferir incluir essa verificação como parte do seu código Java/Kotlin, pode consultar esta implementação em Java do método hasTracerPid do projeto Anti-Emulator de Tim Strazzere.
Ao tentar implementar esse método por conta própria, você pode verificar manualmente o valor de TracerPid com o ADB. A listagem a seguir usa o aplicativo de exemplo do NDK do Google hello-jni (com.example.hellojni) para realizar a verificação após anexar o depurador do Android Studio:
$ adb shell ps -A | grep com.example.hellojni
u0_a271 11657 573 4302108 50600 ptrace_stop 0 t com.example.hellojni
$ adb shell cat /proc/11657/status | grep -e "^TracerPid:" | sed "s/^TracerPid:\t//"
TracerPid: 11839
$ adb shell ps -A | grep 11839
u0_a271 11839 11837 14024 4548 poll_schedule_timeout 0 S lldb-server
Você pode ver como o arquivo de status de com.example.hellojni (PID=11657) contém um TracerPID de 11839, que podemos identificar como o processo lldb-server.
Usando Fork e ptrace¶
Você pode impedir a depuração de um processo fazendo fork de um processo filho e anexando-o ao processo pai como um depurador por meio de código semelhante ao seguinte exemplo simples:
void fork_and_attach()
{
int pid = fork();
if (pid == 0)
{
int ppid = getppid();
if (ptrace(PTRACE_ATTACH, ppid, NULL, NULL) == 0)
{
waitpid(ppid, NULL, 0);
/* Continua o processo pai */
ptrace(PTRACE_CONT, NULL, NULL);
}
}
}
Com o filho anexado, tentativas adicionais de anexar ao pai falharão. Podemos verificar isso compilando o código em uma função JNI e empacotando-o em um aplicativo que executamos no dispositivo.
root@android:/ # ps | grep -i anti
u0_a151 18190 201 1535844 54908 ffffffff b6e0f124 S sg.vantagepoint.antidebug
u0_a151 18224 18190 1495180 35824 c019a3ac b6e0ee5c S sg.vantagepoint.antidebug
Tentar anexar ao processo pai com gdbserver falha com um erro:
root@android:/ # ./gdbserver --attach localhost:12345 18190
warning: process 18190 is already traced by process 18224
Cannot attach to lwp 18190: Operation not permitted (1)
Exiting
No entanto, você pode facilmente contornar essa falha matando o processo filho e "liberando" o pai do rastreamento. Portanto, você geralmente encontrará esquemas mais elaborados, envolvendo múltiplos processos e threads, além de alguma forma de monitoramento para impedir manipulação. Métodos comuns incluem:
- Fazer fork de múltiplos processos que se rastreiam mutuamente,
- Acompanhar os processos em execução para garantir que os filhos permaneçam ativos,
- Monitorar valores no sistema de arquivos
/proc, como TracerPID em/proc/pid/status.
Vejamos uma melhoria simples para o método acima. Após o fork inicial, lançamos no pai uma thread extra que monitora continuamente o status do filho. Dependendo se o aplicativo foi construído no modo de depuração ou release (o que é indicado pela flag android:debuggable no manifesto), o processo filho deve fazer uma das seguintes coisas:
- No modo release: A chamada para ptrace falha e o processo filho falha imediatamente com uma falha de segmentação (código de saída 11).
- No modo de depuração: A chamada para ptrace funciona e o processo filho deve ser executado indefinidamente. Consequentemente, uma chamada para
waitpid(child_pid)nunca deve retornar. Se retornar, algo está errado e mataríamos todo o grupo de processos.
A seguir, o código completo para implementar essa melhoria com uma função JNI:
#include <jni.h>
#include <unistd.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <pthread.h>
static int child_pid;
void *monitor_pid() {
int status;
waitpid(child_pid, &status, 0);
/* O status do filho nunca deve mudar. */
_exit(0); // Comete seppuku
}
void anti_debug() {
child_pid = fork();
if (child_pid == 0)
{
int ppid = getppid();
int status;
if (ptrace(PTRACE_ATTACH, ppid, NULL, NULL) == 0)
{
waitpid(ppid, &status, 0);
ptrace(PTRACE_CONT, ppid, NULL, NULL);
while (waitpid(ppid, &status, 0)) {
if (WIFSTOPPED(status)) {
ptrace(PTRACE_CONT, ppid, NULL, NULL);
} else {
// Processo foi encerrado
_exit(0);
}
}
}
} else {
pthread_t t;
/* Inicia a thread de monitoramento */
pthread_create(&t, NULL, monitor_pid, (void *)NULL);
}
}
JNIEXPORT void JNICALL
Java_sg_vantagepoint_antidebug_MainActivity_antidebug(JNIEnv *env, jobject instance) {
anti_debug();
}
Novamente, empacotamos isso em um aplicativo Android para ver se funciona. Assim como antes, dois processos aparecem quando executamos a compilação de depuração do aplicativo.
root@android:/ # ps | grep -I anti-debug
u0_a152 20267 201 1552508 56796 ffffffff b6e0f124 S sg.vantagepoint.anti-debug
u0_a152 20301 20267 1495192 33980 c019a3ac b6e0ee5c S sg.vantagepoint.anti-debug
No entanto, se terminarmos o processo filho neste momento, o pai também é encerrado:
root@android:/ # kill -9 20301
130|root@hammerhead:/ # cd /data/local/tmp
root@android:/ # ./gdbserver --attach localhost:12345 20267
gdbserver: unable to open /proc file '/proc/20267/status'
Cannot attach to lwp 20267: No such file or directory (2)
Exiting
Para contornar isso, devemos modificar levemente o comportamento do aplicativo (as maneiras mais fáceis são aplicar patch na chamada para _exit com NOPs e conectar a função _exit em libc.so). Neste ponto, entramos na proverbial "corrida armamentista": implementar formas mais intrincadas dessa defesa, bem como contorná-la, são sempre possíveis.