¿Cómo funciona el breakpoint en un depurador de C/C++?

Y llegamos al punto caliente de nuestra serie sobre el funcionamiento de los depuradores de C y C++. Ya sabemos como funciona la CPU y la pila de la memoria. También sabemos que son los eventos de depurado y que el sistema operativo ofrece una interfaz para atraparlos.

Hoy vamos a hablar en profundidad sobre los puntos de interrupción o "breakpoints" que son sin duda la parte fundamental de cualquier depurador. Hoy vamos a aprender como fijarlos y como son utilizados por el depurador de forma interna para ayudarnos a depurar nuestros procesos.

Puntos de Interrupción (Breakpoints)

La habilidad de parar un proceso que está siendo depurado se consigue mediante el fijado de puntos de interrupción o breakpoints. Al parar el proceso podemos inspeccionar el valor de las variables, los argumentos de la pila, y las direcciones de memoria sin que el proceso modifique estos valores hasta que no se lo indicas de esa forma al depurador.

También podemos inspeccionar el valor de los registros del procesador e incluso podemos cambiar cualquier dato tanto en los registros como en la memoria si queremos. Los breakpoints son sin duda la característica más comúnmente utilizada en un depurador, vamos a tratarlo en este artículo de forma extensiva.

Existen tres tipos primarios de breakpoints:

  • soft breakpoints

  • hardware breakpoints

  • memory breakpoints

Todos ellos tienen un comportamiento similar pero están implementados de diferente forma.

Soft Breakpoints

Los soft brakpoints (o puntos de interrupción por software) se usan específicamente para detener la CPU cuando se ejecuta una instrucción y son de lejos los más utilizados al depurar aplicaciones. Un soft breakpoint es una instrucción de un único byte que detiene la ejecución del proceso depurado y le pasa el control al manejador de excepciones de interrupción del depurador.

Para entender como funciona esto, es necesario comprender la diferencia entre una instrucción y un opcode en ensamblador para x86. Una instrucción de ensamblador es una representación de alto nivel de un comando a ejecutar por la CPU, por ejemplo:

mov esi, eax

La instrucción anterior indica a la CPU que debe mover el valor almacenado en el registro eax dentro del registro esi. Pero la CPU no entiende ensamblador (aunque no lo parezca el ensamblador es un lenguaje para las personas) la CPU necesita que dicha instrucción sea convertida a lo que se llama opcode (del inglés Operation Code).

Un opcode, es un comando en el lenguaje que entiende y ejecuta la CPU. La instrucción anterior se convierte a en este opcode nativo:

89f0

Es sumamente raro que necesitemos usar opcodes a la hora de depurar aplicaciones (a no ser que estemos haciendo cosas malvadas) pero es importante entender lo que son para comprender como funcionan los breakpoints. Imaginemos que la instrucción anterior se encuentra en la dirección de memoria 0x00402b29 su representación sería la siguiente:

0x00402b29:       89 f0                   mov    esi,eax

Esto es la dirección de memoria, el opcode y la instrucción de "alto nivel" de código ensamblador. Si quisieramos fijar un soft breakpoint en esta dirección para detener la CPU debemos cambiar un solo byte del opcode de dos bytes "89f0" (89 = 1b, f0 = 1b). En ese byte debemos colocar el opcode de la instrucción interrupción 3 (int 3) que le indica a la CPU que debe detenerse.

La interrupción 3 se convierte al opcode 0xcc. Siguiendo el ejemplo anterior, quedaría tal que así:

0x00402b29:       cc f0                   ...

Hemos eliminado el byte 89 y lo hemos reemplazado con el byte cc. Cuando la CPU llega a esta posición y lee el byte se detiene y dispara el evento int3 que es capturado por el depurador. Cuando le indicamos al depurador que fije un soft breakpoint en una dirección específica, lee el primer byte y lo almacena. Entonces reemplaza ese byte por el opcode cc.

Cuando la CPU se detiene en ese punto y dispara el evento int3, el depurador lee el resgistro EIP ( o lo que es lo mismo, el instruction pointer o puntero de instrucción del que hablamos en la primera parte de la serie ) y comprueba que la dirección a la que apunta ha sido almacenada por el depurador previamente. Si la dirección se encuentra en la lista de breakpoints vuelve a escribir el byte almacenado que eliminó de la dirección ( el que fue reemplazado por el byte cc ) para que el opcode pueda ser ejecutado con éxito cuando el proceso sea reanudado. Ingenioso ¿no?.

Hay dos tipos de breakpoints que pueden ser fijados por el depurador, breakpoints de un uso y persistentes. Un breakpoint de un uso, es decir, que cuando se invoca es eliminado de la lista interna de breakpoints del depurador. Por el contrario los breakpoints persistentes se mantienen durante toda la ejecución del depurador.

Hay que tener en cuenta que cuando hablamos de "escribir" el opcode este proceso de escritura se lleva a cabo en la memoria del proceso y no en el sistema de ficheros del sistema operativo. Cuando el depurador reemplaza el byte para la interrupción 3, también cambia la suma de comprobación CRC ( del inglés cyclic redundancy check ).

El CRC determina si los datos de un fichero, de un stream, de un paquete o de la memoria, han sido alterados de alguna forma. El CRC usa el rango de memoria del proceso para crear una firma hash de su contenido, entonces compara el valor hash contra un checksum válido conocido para determinar si ha habido cambios en los datos. Si las dos firmas son diferentes, entonces la comprobación CRC falla.

La mayoría del malware usa estas comprobaciones de CRC para determinar si ha habido cualquier cambio en su firma CRC y se mata a sí mismo si detecta alguno para impedir la depuración del código. Esto entorpece la labor de ingeniería inversa necesaria para la creación de herramientas que detecten y eliminen el software malicioso.

Existen formas de saltar esta trampa utilizando para ello hardware breakpoints pero eso lo dejamos para la próxima entrega de la serie.

Más en Genbeta Dev | ¿Cómo funciona un depurador de C/C++

Portada de Genbeta