Técnicas anti-depurador (anti-debugging)

Una de las herramientas más utilizadas en la ingeniería inversa de software son los depuradores. Hay múltiples razones por las que un programa querría poder evadir el uso de un depurador, ya sean plenamente legítimas o más oscuras. Muchos virus y malware, por razones obvias, suelen usar técnicas para dificultar la depuración de su código, pero también hay casos legítimos donde es interesante tratar de evitar que cualquiera pueda descifrar nuestro código. Un ejemplo claro son los sistemas de protección anti-copia.
Por otro lado, conseguir una protección total contra la depuración de un código no es posible. Sólo depende de la pericia y de lo motivado que esté el analista. En cualquier caso, es bueno conocer algunas de estas técnicas ya sea para proteger tu código o para poder saltártelas mientras haces ingeniería inversa de un malware.


¿Cómo funciona un depurador?


Básicamente existes tres formas en las que un depurador se apoya para trazar los programas en ejecución, y todas están basadas en el uso de puntos de ruptura. El primer método se basa en el uso de la interrupción 3. La instrucción en ensamblador int 3 que ocupa un byte (concretamente es el byte 0xCC), ha de colocarse en la dirección de memoria donde queremos poner el punto de ruptura o breakpoint. Sí, has entendido bien. La instrucción que haya en esa dirección de memoria se perderá, pero no es grave, los depuradores suelen hacer una copia del byte que va a ser sobrescrito antes de poner la instrucción int 3. Cuando la ejecución del programa llega a esta instrucción, se genera una interrupción software (también conocida como trap) que invoca al manejador de la interrupción que gestiona el depurador. Seguidamente, el depurador toma el control, sustituye la instrucción int 3 por la original y queda a la espera de órdenes por parte del usuario.

El segundo método usa también puntos de ruptura, pero en este caso son gestionados por el propio procesador. Son los llamados puntos de ruptura por hardware o hardware breakpoints. A grandes rasgos, se informa al procesador de que debe detenerse cuando se acceda a una dirección de memoria concreta y que se pase el control al depurador cuando esto suceda través de una invocación de la interrupción 1. Tiene la desventaja de que sólo podemos poner cuatro puntos de ruptura a la vez (sin embargo, con el método de la int 3 podemos colocar tantos puntos de ruptura como necesitemos). A cambio, no es necesario modificar el programa, y tiene la ventaja de que podemos poner un punto de ruptura que se active cuando se lea el valor de una variable, por ejemplo, ya que el flujo del programa se detendrá cuando se acceda a la dirección especificada, ya sea para lectura, escritura o ejecución de una instrucción.

Existe un tercer método, también basado en la int 1, pero menos habitual (aunque es usado por softICE, por ejemplo). Es posible poner el procesador en un modo llamado de depuración que se detiene tras la ejecución de cada instrucción. Al finalizar la ejecución de cada una de ellas, se invoca al manejador de la interrupción 1.

Cómo detectar al depurador


El primer método es el más sencillo, pero también es posiblemente el más fácil de evitar. Sabemos que un depurador sustituye la instrucción dónde se ha colocado el breackpoint por 0xCC, así que podemos comprobar que, por ejemplo, no se ha colocado este valor a la entrada de una función (o en cualquier otra dirección de memoria que nos interese). El siguiente código ilustra este método. Si la primera instrucción de la función saluda() contiene el valor 0xCC (int 3) la ejecución se detiene.


#include <stdio.h>
#include <stdlib.h>

void saluda() { 
 printf("Hola\n"); 
} 
 
int main() { 
 if ( (*((unsigned char *) saluda)) == 0xcc) { 
   printf("Detectado punto de ruptura en saluda()\n"); 
   exit(1); 
  }  
 saluda();
 return 0; 
}



¿Será capaz de detectar a GDB? veámoslo.


alberto@BlackStorm:~/temp$ gdb -q anti1
Reading symbols for shared libraries .. done
(gdb) print saluda
$1 = {void ()} 0x100000e84 
(gdb) b *0x100000e84
Breakpoint 1 at 0x100000e84: file anti1.c, line 4.
(gdb) run
Starting program: /Users/albertogarcia/temp/anti1 
Reading symbols for shared libraries +. done
Detectado punto de ruptura en saluda()

Program exited with code 01.



Una forma de saltarse esta medida de detección es simplemente poner el el punto de ruptura en un lugar diferente, por lo que esta técnica nos ofrece una protección bastante básica.

Otro método para detectar si el programa está siendo depurado es comprobar si se está usando ptrace.


#include <stdio.h>
#include <sys/ptrace.h>

int main(int argc, char *argv[]) {
  if (ptrace(PTRACE_TRACEME, 0, 1, 0) < 0) {
    printf("Detectado debugger. Adios.\n");
    return 1;
    }

    printf("Hola\n");
    return 0;
} 

El depurador gdb usa la llamada del sistema ptrace() para tomar el control y poder observar y controlar al programa que se está depurando. Si nuestro programa trata de hacer una llamada a ptrace() para depurarse a sí mismo, se obtendrá un error en el caso de que el programa ya esté siendo depurado ya que un programa no puede ser depurado por más de un proceso. Si ejecutamos el programa desde gdb, vemos como es detectado.

alberto@BlackStorm:~/temp$ gdb -q ./ptrace
Leyendo símbolos desde /home/alberto/temp/ptrace...(no se encontraron símbolos de depuración)hecho.
(gdb) run
Starting program: /home/alberto/temp/./ptrace 
Detectado debugger. Adios.
[Inferior 1 (process 5552) exited with code 01]
(gdb) 


La forma de saltarse esta protección no es tan sencilla como la anterior, pero tampoco es es muy compleja. Se basa en crear una nueva versión de la función ptrace() que no haga nada salvo devolver el valor 0.

int ptrace(int p1, int p2, int p3, int p4) {
  return 0;                 
} 


Lo compilamos con:
gcc -shared -fPIC miptrace.c -o ptrace.so

Y hacemos que el sistema use nuestra flamante función ptrace() con:
export LD_PRELOAD=./ptrace.so

O desde dentro de gdb:
set environment LD_PRELOAD ./ptrace.so

Obtendremos un resultado como el siguiente:

alberto@BlackStorm:~/temp$ gdb -q ./ptrace
Leyendo símbolos desde /home/alberto/temp/ptrace...(no se encontraron símbolos de depuración)hecho.
(gdb) set environment LD_PRELOAD ./ptrace.so
(gdb) run
Starting program: /home/alberto/temp/./ptrace 
Hola
Durante el arranque, el programa salió normalmente.
(gdb) 

¿Y en Windows?

En el sistema operativo Windows también hay varias opciones a la hora de detectar si un programa está funcionando dentro de un depurador. La forma más sencilla y directa es usar la función IsDebuggerPresent de la API de Windows (en kernel32.dll). Esta función devuelve el valor cero si el programa no está corriendo dentro del depurador.

if (IsDebuggerPresent()) {
    printf("En una sesión de depuración\n");
}


Obviamente, es fácil sortear esta medida de protección con sólo examinar el código desensamblado del programa. Otro inconveniente añadido es que es relativamente sencillo capturar la llamada (hooking) como suelen hacer los rootkits. Una alternativa es realizar manualmente la misma operación que hace internamente esta función, lo que dificultará la tarea de ingeniería inversa de nuestro código. La función IsDebuggerPresent examina el campo BeingDebugged de la estructura de datos PEB (Process Environment Block).

typedef struct _PEB {
  BYTE                          Reserved1[2];
  BYTE                          BeingDebugged;
  BYTE                          Reserved2[1];
  PVOID                         Reserved3[2];
  PPEB_LDR_DATA                 Ldr;
  PRTL_USER_PROCESS_PARAMETERS  ProcessParameters;
  BYTE                          Reserved4[104];
  PVOID                         Reserved5[52];
  PPS_POST_PROCESS_INIT_ROUTINE PostProcessInitRoutine;
  BYTE                          Reserved6[128];
  PVOID                         Reserved7[1];
  ULONG                         SessionId;
} PEB, *PPEB;


Cada proceso tiene un apuntador a su estructura PEB referenciada mediante la dirección fs:[30h]. Así pues, podemos leer el flag BeingDebugged accediendo al segundo campo de esta estructura.

mov eax, dword ptr fs:[30h]
mov ebx, byte ptr [eax+2]
test ebx, ebx
jnz InDebug 



Este método, aunque es el más usado, tiene el problema de que nadie nos asegura de que en el futuro, la estructura de datos PEB pueda cambiar.

No hay comentarios:

Publicar un comentario