08 agosto 2012

Y ahora que estamos siendo depurados...


Hemos ocultado el código, pero el hábil hacker destinado a sacarle las tripas a nuestra criatura es persistente y se ha percatado de que hay "algo" haciendo cosas extrañas dentro de un binario. Ni corto ni perezoso, saca el gdb y nos mete breakpoints hasta en la sopa. Finalmente, acaba llegando a algún punto de entrada del código y tirando del hilo, deshace el ovillo. Pongámoselo difícil y hagamos un repaso de las técnicas de anti-debug más conocidas.


Detección del depurador: no dejándonos ver

La práctica totalidad de la depuración de programas en Linux (y en Unix en general) se hace mediante una llamada al sistema, ptrace(2), la cual nos permite leer y escribir la memoria y registros de otro proceso, además de otras operaciones como ejecución instrucción a instrucción en función de las prestaciones de la arquitectura subyacente.

Esta llamada al sistema es utilizada extensivamente por gdb en cualquier sesión de depuración, y por aplicaciones como strace para poder capturar llamadas al sistema. La llamada acepta un código de operación como primer argumento (como leer memoria, escribir memoria, etc), y otros tres argumentos más (pid, dirección y datos) cuya importancia depende del código de operación del que se trate. 

En general, existen dos formas diferentes de depurar un proceso:
  1. Creando un proceso hijo con fork(2), llamado a ptrace con la operación PTRACE_TRACEME desde este proceso hijo, bloqueándolo y permitiendo que el padre pueda seguir su ejecución con otras operaciones ptrace (incluso si el proceso hijo se transforma en otro mediante exec) o
  2. Haciendo que el padre se enganche a otro proceso cualquiera mediante PTRACE_ATTACH, haciendo que el proceso que queramos depurar se pare y se comporte como si hubiese llamado a PTRACE_TRACEME.
Hay ciertas restricciones sobre a qué procesos debemos engancharnos (evidentemente, si no somos root no podemos depurar un proceso que no es nuestro) pero a efectos prácticos el funcionamiento de ambas formas es equivalente: el proceso depurado parará ante cualquier señal recibida, el padre será notificado mediante wait y podrá examinar dicho proceso, continuar su ejecución hasta la siguiente señal, hasta la siguiente llamada al sistema o incluso hasta la siguiente instrucción.

Si un proceso cualquiera puede hacernos esto, estamos desnudos ante él. La forma más inocente de librarnos de esta amenazante presencia es comprobar si estamos siendo depurados con PTRACE_TRACEME. Si ptrace falla ante esta operación quiere decir que alguien intenta depurarnos (ya que PTRACE_TRACEME ya ha sido utilizada antes), por lo que deberíamos salir.

Como llevo repitiendo desde la primera entrega, no podemos llamar a funciones externas, por lo que vamos a tener que llamar a ptrace directamente desde la int 0x80. Podemos desesperar a cualquiera rellenando nuestro código con fragmentos de este tipo:
 
  xor    %eax,%eax        /* Limpieza de registros */
  xor    %ebx,%ebx
  xor    %ecx,%ecx
  xor    %edx,%edx
  xor    %edi,%edi
  mov    $25,%al          /* 25... */
1: 
  inc    %al              /* + 1 = 26 (ptrace), ya veréis por qué hago esto. */
  int    $0x80            /* Syscall! */
  or     $0xff,%al        /* Encendemos los últimos 8 bits. */
  xor    $0xffffffff,%eax /* Si la syscall falló: eax = 0 */
  jz     1b               /* Aprovecho el valor de eax saltando a 1, que lo incrementa en 1 y lo convierte en el código de exit(). Y me ahorro volver a escribir int $0x80 */

Este código se puede incluir perfectamente dentro de un inline assembly y en una macro, de forma que desplegar estas comprobaciones por todo el código sea tan simple como escribir CHECK().

Detección del depurador (II): descriptores de fichero

La presencia de un depurador puede detectarse formas menos explícitas que una simple llamada a ptrace. Normalmente, el simple acto de ejecutar un programa implica una serie cambios en todo el contexto del proceso (y no sólo en la memoria) que podemos detectar de varias formas.

Un indicio de la presencia de un depurador es la existencia de más descriptores de fichero abiertos que los correspondientes a la entrada estándar, salida estándar y salida estándar de error. Al arrancar cualquier programa, el descriptor más alto abierto debe ser el 2. Por eso mismo, la existencia de un descriptor 3 abierto antes de abrir cualquier archivo puede querer decir que hay algún otro programa supervisando nuestra ejecución. La comprobación para evitar esto es tan simple como:

   if (close (3) != -1)
   {
     exit (EXIT_FAILURE);
   }

Si bien esta comprobación puede parecer demasiado explícita (y por tanto, fácil de detectar y parchear), nada nos impide introducirla implícitamente en el resto del código, de forma que el código no pueda funcionar correctamente si el descriptor 3 está abierto. Por ejemplo: en vez de guardar la semilla explícitamente de nuestro generador de números aleatorios podemos guardarla con signo invertido, y al descifrar pasar en vez de la semilla guardada, la semilla guardada multiplicada por el resultado de close (3). Así, si el descriptor 3 está abierto, close devolverá 0 y la clave no se recuperá correctamente. Y lo mismo puede hacerse con ptrace.

Detección del depurador (III): tiempos límite

Cuando un programa es depurado, el usuario suele hacer numerosas pausas entre instrucciones simplemente para examinar cómo cambian los registros y la memoria durante toda la ejecución. Ciertos pedazos de código no deberían ejecutarse por más de unos cuantos milisegundos, y añadir cosas del tipo:

#include <sys/times.h>

...

clock_t clock;

clock = times (NULL);

... cosas diversas que no queremos depurar ...

if ((unsigned int) (times (NULL) - clock) > LIMIT)
{
  fprintf (stderr, "I see what you did there...\n");
  exit (EXIT_FAILURE);
}

El único problema es que times devuelve el número de "clocks" desde el inicio del sistema, habiendo CLOCKS_PER_SEC "clocks" en cada segundo. Esta macro se define en time.h, y POSIX exige que valga 1000000, lo que nos lleva que en la práctica times mida el número de microsegundos que han pasado desde que arrancamos nuestra máquina. De la misma forma que close (3) en el truco anterior, podemos incluir esta comprobación de forma implícita en nuestro código, haciéndola más difícil de parchear.

Detectando al depurador (IV): EFLAGS

Este truco sólo funciona si estamos siendo depurados instrucción a instrucción. Cuando esto sucede, el bit 8 del registro EFLAGS (es decir, el bit menos significativo del segundo byte de EFLAGS) conocido como Trap Flag (TF) se pone a uno, pudiendo hacer las comprobaciones pertinentes para evitar este tipo de estudio en nuestro código.

Como EFLAGS es un registro propio de la máquina, la única forma de acceder a él es desde el ensamblador. El código para recuperar EFLAGS suele ser de la forma:

uint32_t eflags;
...
__asm__ __volatile__ ("pushf\npopl %0" : "=g" (eflags));

La comprobación se haría de una forma similar a:

if (eflags & 0x100)
{
  printf ("Take it easy, man...\n");
  exit (EXIT_FAILURE);
}

De las misma manera que con los trucos anteriores, esta comprobación es mejor introducirla de forma implícita en el código, haciendo que ese bit del registro EFLAGS forme parte de un cálculo más complejo. Más adelante daré un ejemplo concreto con este mismo registro para explicar su funcionamiento.

Confundiendo al depurador: falso desensamblado

Una de las llamativas consecuencias del hecho de ser x86 una arquitectura medio CISC es que las instrucciones tienen varios tamaños (desde uno hasta más de una decena de bytes), por lo que no podemos tomar una dirección al azar y desensamblarla ya que podemos caer en mitad de una instrucción. Para saber donde empieza una instrucción, debemos saber qué instrucciones vienen antes y cuánto miden.

Esta técnica se aprovecha de esta característica de x86: si introducimos artificialmente instrucciones incompletas (debidamente omitidas en el flujo de ejecución con saltos de algún tipo) la mayoría de desensambladores se equivocarán a la hora de interpretar las instrucciones subsiguientes, dejando todo un trozo de código ilegible hasta que vuelve a encontrar un inicio de instrucción correcto.

Es muy fácil construir estas instrucciones erróneas. Basta con desensamblar cualquier binario y copiar  los primeros bytes de cualquier instrucción de más de un byte. Por ejemplo:

#include <stdio.h>

int
main (void)
{
  __asm__ __volatile__ ("jmp 1f\n.byte 0xe8,0x00,0x00,0x00\n1:");
  printf ("Hello world\n");
  return 0;
}

Los bytes introducidos se corresponden a los de un call menos el último byte (saltando con un jmp corto para evitar esa instrucción). El resultado al desensamblar es algo como esto:

080483d4 <main>:
 80483d4:       55                      push   %ebp
 80483d5:       89 e5                   mov    %esp,%ebp
 80483d7:       83 e4 f0                and    $0xfffffff0,%esp
 80483da:       83 ec 10                sub    $0x10,%esp
 80483dd:       eb 04                   jmp    80483e3 <main+0xf>
 80483df:       e8 00 00 00 c7          call   cf0483e4 <_end+0xc6ffe3c8>
 80483e4:       04 24                   add    $0x24,%al
 80483e6:       d0 84 04 08 e8 01 ff    rolb   -0xfe17f8(%esp,%eax,1)
 80483ed:       ff                      (bad)
 80483ee:       ff                      (bad)
 80483ef:       b8 00 00 00 00          mov    $0x0,%eax
 80483f4:       c9                      leave
 80483f5:       c3                      ret

Se puede experimentar con otras instrucciones, con los que se obtendrían resultados similares. Incluso se puede añadir un plus de aleatoriedad si a la hora de infectar otros binarios mantenemos información de zonas del código no ejecutables donde podamos colocar cualquier tipo de contenido. Los efectos serán diferentes en cada infección.

Otra forma de complicar las cosas es creando if's y bucles que nunca se ejecuten en condiciones normales (ejemplo de condición normal: fuera de un depurador ;), mezclando instrucciones erróneas y código en C que haga cualquier cosa. Los resultados pueden ser verdaderamente desesperantes para aquel que tenga que leer el desensamblado.

Confundiendo al depurador (II): espaguetis

Una cosa que enseñan mucho en las clases de programación cuando se usa un lenguaje con instrucciones goto o equivalentes es que NUNCA debemos usar tales instrucciones. El (ab)uso de estas instrucciones lleva a lo que se conoce como "código espagueti", el flujo de ejecución del programa deja de ser normal y complica la legibilidad y la depuración. La única excusa que se suele dar a su uso es escapar de bucles demasiado complejos, y aún así hay gente que no los tocaría nunca.

A bajo nivel, todo lo relacionado con el flujo de ejecución del programa se traduce a instrucciones equivalentes a gotos (los saltos incondicionales, y también los condicionales). Si además añadimos nuestros propios saltos, el código resultante puede llegar a ser de una ilegibilidad total. Los decompiladores suelen ser los principales afectados por estas técnicas: si modificamos por completo el flujo normal del programa las heurísticas de las aplicaciones de la talla del IDA (con HexRays) empezarán a liarse hasta el punto de dar una salida que no tiene nada que ver con el algoritmo realmente implementado.

Para ilustrar su uso intentaremos ofuscar el código del siguiente programa que calcula los primeros términos de la sucesión de Fibonacci:

#include <stdio.h>

int
main (void)
{
  int fibo_1, fibo_2;
  int calc, i;

  fibo_1 = fibo_2 = 1;
  
  printf ("1, 1");

  for (i = 0; i < 10; i++)
  {
    calc = fibo_1 + fibo_2;
    fibo_2 = fibo_1;
    fibo_1 = calc;
    printf (", %d", calc);
  }
  
  puts ("");

  return 0;
}

Y comparémoslo con lo que IDA 6.1 + HexRays consigue decompilar:

int __cdecl main()
{
  int v0; // ST1C_4@2
  signed int v2; // [sp+10h] [bp-10h]@1
  signed int v3; // [sp+14h] [bp-Ch]@1
  signed int i; // [sp+18h] [bp-8h]@1

  v3 = 1;
  v2 = 1;
  printf("1, 1");
  for ( i = 0; i <= 9; ++i )
  {
    v0 = v2 + v3;
    v3 = v2;
    v2 = v0;
    printf(", %d", v0);
  }
  puts(&s);
  return 0;
}

Y vemos que podemos recuperar el algoritmo implementado sin problemas. Ahora, vamos a intentar introducir saltos de todo tipo. Algunos de ellos tendrán que ver con el ya mencionado registro EFLAGS: saltaremos si y sólo si TF está a cero (lo cual nos añade una complejidad adicional, no podremos depurar fácilmente este pedazo de código):

#include <stdio.h>
#define A __asm__ __volatile__
#define AG __asm__

AG ("tag7:");
AG ("ret");

int
main (void)
{
  A ("jmp tag9");
  A ("tag2:");
    
  int fibo_1, fibo_2;
  int calc, i;

  A ("jmp 2f");
  
 /* Esto es una función anidada */
  A ("tag1:");
  A ("pusha");
  A ("mov $26, %eax");
  A ("xor %ebx, %ebx");
  A ("xor %ecx, %ecx");
  A ("xor %edx, %edx");
  A ("xor %esi, %esi");
  A ("int $0x80");
  A ("orb $0xff, %al");
  A ("cmp $0xffffffff, %eax"); /* ¿ptrace() == -1? */
  A ("popa");
  A ("jz 2f"); /* Pues sí. Ya estamos siendo depurados. */
  A ("ret");

  A ("2:");

  fibo_1 = fibo_2 = 1;
    
  printf ("1, 1");

  for (i = 0; i < 10; i++)
  {
    calc = fibo_1 + fibo_2;

    A ("jmp 3f");
    A ("tag9:");
    A ("push %eax");
    A ("pushf"); /* Meto EFLAGS en la pila */
    A ("pop %eax"); /* Y lo saco a EAX */
    A ("test $0x100, %eax"); /* Comprobemos si TF está encendido */
    A ("pop %eax");
    A ("jz tag2");
    A ("3:");

    fibo_2 = fibo_1;
    fibo_1 = calc;
    
    if (i == 0) /* Sólo podemos ejecutar ptrace una vez */
      A ("call tag1");

    A ("jmp 4f");
    A ("tag10:");
    A ("ret");
    A ("4:");

    printf (", %d", calc);

    A ("jmp tag8");
    A ("jmp 5f");
    A ("tag6:");
    A ("5:");

    A ("call tag5");
  }
  
  puts ("");

  A ("jmp 6f");
  A ("tag4:");
  A ("ret");
  A ("6:");


  A ("jmp 7f");
  A ("tag5:");
  A ("call tag7");
  A ("ret");
  A ("7:");
  
  return 0;
}

/* Cambio el registro del que hago POP, sólo por liar */
AG ("tag8:");
AG ("call tag10");
AG ("push %ebx");
AG ("pushf");
AG ("pop %ebx");
AG ("test $0x100, %ebx");
AG ("pop %ebx");
AG ("jz tag6");

Y si ahora intentamos decompilar con el HexRays, el resultado será un completo caos que no dice nada en absoluto del funcionamiento del código:

void __usercall main(char _CF<cf>, char _ZF<zf>, char _SF<sf>, char _OF<of>, __int16 a5<ax>, __int16 a6<bx>)
{
  int v10; // [sp+18h] [bp-8h]@0
  int v11; // [sp+1Ch] [bp-4h]@0

  __asm { pushf }
  if ( a5 & 0x100 )
  {
    if ( !v10 )
      tag1();
    printf(", %d", v11);
    tag10();
    __asm { pushf }
    if ( a6 & 0x100 )
      JUMPOUT(*(int *)_libc_csu_init);
    tag5();
    JUMPOUT(*(int *)loc_80484BF);
  }
  JUMPOUT(loc_804843B);
}

Confundiendo al depurador (III): chapuceando con la pila

Una técnica muy popular que ha hecho la depuración imposible a hackers noveles es la de omitir el marco de pila. Normalmente, cuando compilamos con gcc cualquier programa, las variables locales se referencian desde el EBP, previa salvaguarda de su contenido en la pila y no desde el ESP. Esto es bueno por dos cosas: la primera, es que las mismas variables locales en toda una función son referenciadas desde el mismo offset respecto del EBP (lo cual hace su depuración mucho más sencilla), la segunda que cada EBP apunta al siguiente, y como justo antes del EBP está la dirección de retorno podemos reconstruir la pila de llamadas examinando estos punteros. 

Esta es la única información de la que dispone gdb para saber qué funciones han llamado a otras, y podemos hacer la depuración verdaderamente complicado diciéndole al gcc que no utilice este truco de los marcos de pila. Esto es tan simple como compilar con:

% gcc hola.c -o hola -fomit-frame-pointer

Automáticamente, los prólogos de las funciones pasan de ser:

 8048414:       55                      push   %ebp
 8048415:       89 e5                   mov    %esp,%ebp
 8048417:       83 ec 18                sub    $0x18,%esp

A ser, simplemente:

 8048414:       83 ec 1c                sub    $0x1c,%esp

Ya que el EBP dejó de ser necesario para referenciar las variables locales, todo se hará con desplazamientos relativos al ESP.

Bien es cierto que existen depuradores más sofisticados para los cuales este tipo de cosas no son un problema, sin embargo siempre hay un punto flaco a explotar: los que tengan cierta experiencia con IDA y HexRays, el mensaje "sp-analysis failed" les resultará muy familiar. Cuando IDA se encuentra con una pila que se prepara de una manera poco usual, se rinde y suelta ese infame mensaje en fondo rojo. IDA se niega a desensamblar y HexRays por consiguiente dice adiós por culpa de un "desplazamiento positivo del puntero de pila".

Esto es una trivialidad con fácil solución, cierto, pero también es cierto que hace la depuración mucho más engorrosa. Para hacer que IDA exhiba este odioso comportamiento lo único que tenemos que hacer es restar al ESP cualquier valor (0x8000, por ejemplo), saltar a un pedazo de código que lo arregle de nuevo (preferiblemente fuera de la función) y volver. Por ejemplo:
 
#include <stdio.h>

__asm__ (".restore_esp:");
__asm__ ("subl $0x8000, %esp");
__asm__ ("jmp .go_on");

int
main (void)
{
  __asm__ __volatile__ ("addl $0x8000, %esp");
  __asm__ __volatile__ ("jmp .restore_esp");
  __asm__ __volatile__ (".go_on:");
  printf ("¡Depúrame!\n");
  
  return 0;
}

Y listo. Todo lo que IDA ha conseguido desensamblar de la función es:


Si a pesar de todo pulso F5 para intentar descompilar, el resultado es si cabe más frustrante:


Y nuestro código quedará momentáneamente protegido. Digo momentáneamente, pues pulsando Alt+K en el IDA y restando 0x8000 al ESP podemos desensamblar y si arreglamos el final de función, decompilar también sin problemas. 

Esto asusta

Y lo que queda. Hasta ahora no he hecho más que un resumen de la punta del iceberg de lo que podemos encontrarnos al lidiar contra las infecciones de ELF en Linux. Pero no caigamos en la desesperación: la motivación último de este artículo era hablar de estas técnicas para defendernos contra ellas. En la próxima entrega analizaremos técnicas que nosotros, como potenciales objetivos de estas infecciones, podremos poner en práctica para defendernos. Algunas incluirán software de terceros, otras directamente cosas de nuestra propia cosecha. Y veremos que, parafraseando de nuevo a Jesús de Marcelo, entre el atacante (el escritor del virus en este caso) y el experto se desarrolla una especie de partida de ajedrez virtual en la que el experto siempre gana, aunque a veces la partida se alarga más de lo deseado.

1 comments :

Antonio Rodríguez dijo...

Ahora que he tenido tiempo de leerme la serie completa con calma, solo he de decir. GRANDISIMO trabajo.
Espero más articulos en esta línea. Enhorabuena