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.