Saltar a contenido

Debugging with GDB

Ahora que tenemos la información general sobre nuestro programa, comenzaremos a ejecutarlo y depurarlo. La depuración consiste principalmente en cuatro pasos:

Paso Descripción
Break Establecer breakpoints en varios puntos de interés
Examine Ejecutar el programa y examinar el estado del programa en esos puntos
Step Avanzar a través del programa para examinar cómo actúa con cada instrucción e input del usuario
Modify Modificar valores en registros o direcciones específicos en breakpoints, para estudiar cómo afectaría la ejecución

Revisaremos estos puntos en esta sección para aprender los conceptos básicos de depuración de un programa con GDB.


Break

El primer paso de la depuración es establecer breakpoints para detener la ejecución en una ubicación específica o cuando se cumpla una condición en particular. Esto nos ayuda a examinar el estado del programa y el valor de los registros en ese punto. Los breakpoints también nos permiten detener la ejecución del programa en ese punto para que podamos avanzar por cada instrucción y examinar cómo cambia el programa y los valores.

Podemos establecer un breakpoint en una dirección específica o para una función en particular. Para establecer un breakpoint, podemos usar el comando break o b junto con la dirección o el nombre de la función donde queremos detenernos. Por ejemplo, para seguir todas las instrucciones ejecutadas por nuestro programa, coloquemos un breakpoint en la función _start, como sigue:

gef  b _start

Breakpoint 1 at 0x401000

Ahora, para iniciar nuestro programa, podemos usar el comando run o r:

gef  b _start
Breakpoint 1 at 0x401000
gef  r
Starting program: ./helloWorld
Breakpoint 1, 0x0000000000401000 in _start ()
[ Legend: Modified register | Code | Heap | Stack | String ]
───────────────────────────────────────────────────────────────────────────────────── registers ────
$rax   : 0x0               
$rbx   : 0x0               
$rcx   : 0x0               
$rdx   : 0x0               
$rsp   : 0x00007fffffffe310    0x0000000000000001
$rbp   : 0x0               
$rsi   : 0x0               
$rdi   : 0x0               
$rip   : 0x0000000000401000    <_start+0> mov eax, 0x1
...SNIP...
───────────────────────────────────────────────────────────────────────────────────────── stack ────
0x00007fffffffe310+0x0000: 0x0000000000000001    $rsp
0x00007fffffffe318+0x0008: 0x00007fffffffe5a0    "./helloWorld"
...SNIP...
─────────────────────────────────────────────────────────────────────────────────── code:x86:64 ────
     0x400ffa                  add    BYTE PTR [rax], al
     0x400ffc                  add    BYTE PTR [rax], al
     0x400ffe                  add    BYTE PTR [rax], al
    0x401000 <_start+0>       mov    eax, 0x1
     0x401005 <_start+5>       mov    edi, 0x1
     0x40100a <_start+10>      movabs rsi, 0x402000
     0x401014 <_start+20>      mov    edx, 0x12
     0x401019 <_start+25>      syscall
     0x40101b <_start+27>      mov    eax, 0x3c
─────────────────────────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "helloWorld", stopped 0x401000 in _start (), reason: BREAKPOINT
───────────────────────────────────────────────────────────────────────────────────────── trace ────
[#0] 0x401000 → _start()
────────────────────────────────────────────────────────────────────────────────────────────────────

Si queremos establecer un breakpoint en una dirección específica, como _start+10, podemos usar b *_start+10 o b *0x40100a:

gef  b *0x40100a
Breakpoint 1 at 0x40100a

El símbolo * le indica a GDB que debe detenerse en la instrucción almacenada en 0x40100a.

Nota: Una vez que el programa se está ejecutando, si configuramos otro breakpoint, como b *0x401005, para continuar hasta ese breakpoint debemos usar el comando continue o c. Si usamos run o r nuevamente, ejecutará el programa desde el inicio. Esto puede ser útil para omitir bucles, como veremos más adelante en el módulo.

Si queremos ver qué breakpoints tenemos en cualquier punto de la ejecución, podemos usar el comando info breakpoint. También podemos disable, enable o delete cualquier breakpoint. Además, GDB también admite la configuración de breakpoints condicionales que detienen la ejecución cuando se cumple una condición específica.


Examine

El siguiente paso de la depuración es examinar los valores en los registros y direcciones. Como podemos ver en la salida del terminal anterior, GEF nos proporcionó automáticamente mucha información útil cuando alcanzamos nuestro breakpoint. Este es uno de los beneficios de tener el plugin GEF, ya que automatiza muchos pasos que normalmente tomaríamos en cada breakpoint, como examinar los registros, la pila y las instrucciones en ensamblador actuales.

Para examinar manualmente cualquiera de las direcciones o registros, o cualquier otro valor, podemos usar el comando x en el formato x/FMT ADDRESS, como lo indica help x. El ADDRESS es la dirección o registro que queremos examinar, mientras que FMT es el formato de examen. El formato de examen FMT puede tener tres partes:

Argumento Descripción Ejemplo
Count El número de veces que queremos repetir el examen 2, 3, 10
Format El formato en el que queremos representar el resultado x(hex), s(string), i(instruction)
Size El tamaño de memoria que queremos examinar b(byte), h(halfword), w(word), g(giant, 8 bytes)
### Instructions

Por ejemplo, si quisiéramos examinar las siguientes cuatro instrucciones en línea, tendríamos que examinar el registro $rip (que contiene la dirección de la siguiente instrucción), y usar 4 para el count, i para el format y g para el size (para 8 bytes o 64 bits). Entonces, el comando final para examinar sería x/4ig $rip, como se muestra a continuación:

gef  x/4ig $rip

=> 0x401000 <_start>:   mov    eax,0x1
   0x401005 <_start+5>:   mov    edi,0x1
   0x40100a <_start+10>:  movabs rsi,0x402000
   0x401014 <_start+20>:  mov    edx,0x12

Vemos que obtenemos las siguientes cuatro instrucciones como se esperaba. Esto puede ayudarnos mientras recorremos un programa para examinar ciertas áreas y qué instrucciones pueden contener.

Strings

También podemos examinar una variable almacenada en una dirección de memoria específica. Sabemos que nuestra variable message está almacenada en la sección .data en la dirección 0x402000 de nuestro desmontaje anterior. También vemos el próximo comando movabs rsi, 0x402000, por lo que podríamos querer examinar qué se está moviendo desde 0x402000.

En este caso, no pondremos nada para el Count, ya que solo queremos una dirección (1 es el valor predeterminado), y usaremos s como formato para obtenerlo en formato string en lugar de hexadecimal:

gef  x/s 0x402000

0x402000:   "Hello HTB Academy!"

Como podemos ver, podemos ver el string en esta dirección representado como texto en lugar de caracteres hexadecimales.

Nota: si no especificamos el Size o Format, se usará el último que utilizamos.

Addresses

El formato más común para examinar es hexadecimal x. A menudo necesitamos examinar direcciones y registros que contienen datos en formato hexadecimal, como direcciones de memoria, instrucciones o datos binarios. Examinemos la misma instrucción anterior, pero en formato hex, para ver cómo se ve:

gef  x/wx 0x401000

0x401000 <_start>:  0x000001b8

En lugar de mov eax,0x1, obtenemos 0x000001b8, que es la representación hexadecimal del código máquina mov eax,0x1 en formato little-endian.

  • Esto se lee como: b8 01 00 00.

Prueba a repetir los comandos que usamos para examinar strings utilizando x para examinarlos en formato hexadecimal. Deberíamos ver el mismo texto pero en formato hexadecimal. También podemos usar las características de GEF para examinar ciertas direcciones. Por ejemplo, en cualquier momento podemos usar el comando registers para imprimir el valor actual de todos los registros:

gef  registers
$rax   : 0x0               
$rbx   : 0x0               
$rcx   : 0x0               
$rdx   : 0x0               
$rsp   : 0x00007fffffffe310    0x0000000000000001
$rbp   : 0x0               
$rsi   : 0x0               
$rdi   : 0x0               
$rip   : 0x0000000000401000    <_start+0> mov eax, 0x1
...SNIP...

Step

El tercer paso en la depuración es stepping a través del programa una instrucción o línea de código a la vez. Como podemos ver, actualmente estamos en la primera instrucción de nuestro programa helloWorld:

───────────────────────────────── code:x86:64 ────
     0x400ffe                  add    BYTE PTR [rax], al
    0x401000 <_start+0>       mov    eax, 0x1
     0x401005 <_start+5>       mov    edi, 0x1

Nota: la instrucción mostrada con el símbolo -> es donde estamos, y aún no ha sido procesada.

Para avanzar a través del programa, hay tres comandos diferentes que podemos usar: stepi y step.

Step Instruction

El comando stepi o si avanzará a través de las instrucciones de ensamblador una por una, que es el nivel más pequeño de pasos posible durante la depuración. Usemos el comando si para ver cómo llegamos a la siguiente instrucción:

─────────────────────────────── code:x86:64 ────
gef  si
0x0000000000401005 in _start ()
   0x400fff                  add    BYTE PTR [rax+0x1], bh
    0x401005 <_start+5>       mov    edi, 0x1
     0x40100a <_start+10>      movabs rsi, 0x402000
     0x401014 <_start+20>      mov    edx, 0x12
     0x401019 <_start+25>      syscall
───────────────────────── threads ────
     [#0] Id 1, Name: "helloWorld", stopped 0x401005 in _start (), reason: SINGLE STEP

Como podemos ver, dimos exactamente un paso y nos detuvimos nuevamente en la instrucción mov edi, 0x1.

Step Count

De manera similar a "examine", podemos repetir el comando si agregando un número después. Por ejemplo, si quisiéramos movernos 3 pasos para alcanzar la instrucción syscall, podemos hacerlo de la siguiente manera:

gef  si 3
0x0000000000401019 in _start ()
──────────────────────────────────── code:x86:64 ───
     0x401004 <_start+4>       add    BYTE PTR [rdi+0x1], bh
     0x40100a <_start+10>      movabs rsi, 0x402000
     0x401014 <_start+20>      mov    edx, 0x12
    0x401019 <_start+25>      syscall
     0x40101b <_start+27>      mov    eax, 0x3c
     0x401020 <_start+32>      mov    edi, 0x0
     0x401025 <_start+37>      syscall
────────────────────────────────── threads ───
[#0] Id 1, Name: "helloWorld", stopped 0x401019 in _start (), reason: SINGLE STEP

Como podemos ver, nos detuvimos en la instrucción syscall como se esperaba.

Consejo: Puedes presionar la tecla return/enter vacía para repetir el último comando. Intenta presionarla en esta etapa, y deberías avanzar otros 3 pasos, deteniéndote en la otra instrucción syscall.

Step

El comando step o s, por otro lado, continuará hasta que se alcance la siguiente línea de código o hasta que salga de la función actual. Si ejecutamos un código en ensamblador, se detendrá cuando salgamos de la función actual _start.

Si hay una llamada a otra función dentro de esta función, se detendrá al inicio de esa función. De lo contrario, se detendrá después de salir de esta función al final del programa. Probemos usar s y veamos qué sucede:

gef  step

Single stepping until exit from function _start,
which has no line number information.
Hello HTB Academy!
[Inferior 1 (process 14732) exited normally]

Vemos que la ejecución continuó hasta que llegamos a la salida de la función _start, por lo que llegamos al final del programa y exited normally sin ningún error. También vemos que GDB imprimió la salida del programa Hello HTB Academy!.

Nota: También existe el comando next o n, que continuará hasta la siguiente línea, pero omitirá cualquier función llamada en la misma línea de código, en lugar de detenerse en ellas como step. También existe el comando nexti o ni, que es similar a si, pero omite llamadas a funciones, como veremos más adelante en el módulo.


Modify

El paso final de la depuración es modificar valores en registros y direcciones en un punto específico de ejecución. Esto nos ayuda a ver cómo afectaría la ejecución del programa.

Addresses

Para modificar valores en GDB, podemos usar el comando set. Sin embargo, utilizaremos el comando patch en GEF para simplificar este paso. Ingresemos help patch en GDB para ver su menú de ayuda:

gef  help patch

Write specified values to the specified address.
Syntax: patch (qword|dword|word|byte) LOCATION VALUES
patch string LOCATION "double-escaped string"
...SNIP...

Como podemos ver, debemos proporcionar el type/size del nuevo valor, la location donde se almacenará y el value que queremos usar. Probemos cambiar la cadena almacenada en la sección .data (en la dirección 0x402000 como vimos antes) por la cadena Patched!\n.

Nos detendremos en el primer syscall en 0x401019 y luego aplicaremos el parche, como sigue:

gef  break *0x401019

Breakpoint 1 at 0x401019
gef  r
gef  patch string 0x402000 "Patched!\\x0a"
gef  c

Continuing.
Patched!
 Academy!

Vemos que modificamos con éxito la cadena y obtuvimos Patched!\n Academy! en lugar de la cadena anterior. Nota cómo usamos \x0a para agregar una nueva línea después de nuestra cadena.

Registers

También notamos que no reemplazamos toda la cadena. Esto se debe a que solo modificamos los caracteres hasta la longitud de nuestra cadena y dejamos el resto de la cadena anterior. Finalmente, la función printf especificó una longitud de 0x12 bytes para imprimir.

Para solucionar esto, vamos a modificar el valor almacenado en $rdx a la longitud de nuestra cadena, que es 0x9. Solo parchearemos un tamaño de un byte. Vamos a demostrar cómo usar set para modificar $rdx, como sigue:

gef  break *0x401019

Breakpoint 1 at 0x401019
gef  r
gef  patch string 0x402000 "Patched!\\x0a"
gef  set $rdx=0x9
gef  c

Continuing.
Patched!

Vemos que modificamos con éxito la cadena final impresa y logramos que el programa muestre algo de nuestra elección. La capacidad de modificar valores de registros y direcciones nos ayudará mucho durante la depuración y explotación binaria, ya que nos permite probar varios valores y condiciones sin tener que cambiar el código y recompilar el binario cada vez.


Conclusion

La capacidad de establecer breakpoints para detener la ejecución, avanzar paso a paso por un programa y cada una de sus instrucciones, examinar diversos datos y direcciones en cada punto, y modificar valores cuando sea necesario, nos permite realizar una depuración y un análisis inverso adecuados.

Ya sea que queramos ver exactamente por qué nuestro programa está fallando o entender cómo está funcionando un programa y qué está haciendo en cada punto, GDB resulta muy útil.

Para el pentesting, este proceso nos permite comprender cómo un programa maneja la entrada en un punto específico y exactamente por qué está fallando. Esto nos permite desarrollar exploits que aprovechen dichas fallas, como aprenderemos en los módulos de Binary Exploitation.