Ingeniería inversa
¿Qué es la ingeniería inversa? Usos, límites y bases legales
Entiende qué descarta la compilación, en qué se diferencian desensambladores y descompiladores y los límites legales antes de analizar un binario.
Compila una verificación de licencia de juguete en C de dos formas y usa Ghidra para hallar la cadena, seguir referencias y comparar con tu código.
La ingeniería inversa se aprende mejor con código que tú mismo escribiste. Sabes lo que el programa debería hacer, así que puedes comprobar cada afirmación que la herramienta hace sobre él. Este artículo sigue esa idea. Vas a escribir un programa pequeño en C con una verificación de licencia de juguete, compilarlo de dos maneras y abrir las dos versiones en Ghidra. Después compararás lo que dice el descompilador con el código fuente que escribiste.
La lección principal no está en los botones de Ghidra. Cuando pones la salida del descompilador junto a tu propio código, aprendes rápido qué acierta la herramienta, qué adivina y qué pierde cuando el compilador optimiza.
Si leer código de máquina es nuevo para ti, lee antes ¿Qué es la ingeniería inversa?. No necesitas experiencia previa con ensamblador. Sí necesitas una terminal y cerca de una hora.
Ghidra es un framework de ingeniería inversa de software desarrollado por la Agencia de Seguridad Nacional de Estados Unidos (NSA) y publicado en GitHub, en el repositorio NationalSecurityAgency/ghidra. Funciona en Windows, macOS y Linux. Para un programa compilado, hace cuatro cosas que importan aquí:
Ghidra no recupera el código fuente original. Produce una aproximación. El pseudocódigo suele tener una forma parecida a la de tu código, pero los nombres, los tipos y parte del flujo de control pueden ser distintos de lo que escribiste. Esa diferencia es lo que este laboratorio está diseñado para mostrar.
Podrías bajar un binario de ejemplo de un foro y empezar a mirarlo. No lo hagas. Los binarios desconocidos pueden ser malware, y no tendrías forma de verificar la salida de la herramienta, porque no sabes cuál es la respuesta correcta.
Un programa que compilas tú mismo te da tres ventajas:
check() compara el argumento con "LAB-1234".ok o no. No hace llamadas de red ni escribe archivos.Todo en este laboratorio se queda en tu máquina. Estás analizando un archivo tuyo, que es el único alcance que este ejercicio necesita.
gcc con tu gestor de paquetes. En macOS, sirve clang de las herramientas de línea de comandos de Xcode para este laboratorio, y las banderas son las mismas. En Windows, usa WSL o un contenedor con GCC.sha256sum. Git Bash en Windows lo incluye.Instala el JDK que pide la versión de Ghidra que descargues, y no confíes en una guía antigua para ese número. Cuando revisé el README del repositorio y la guía en la fecha en que escribí esto, el README y la guía de primeros pasos pedían JDK 25 de 64 bits, y el README enlazaba a las compilaciones Temurin. Lee el README o las notas de la versión que bajes y usa la versión que indiquen. Una versión de Java incompatible es una causa común de que Ghidra no arranque.
Descarga Ghidra solo desde las versiones oficiales en github.com/NationalSecurityAgency/ghidra. El repositorio enlaza a los archivos de cada versión. Descomprime en una carpeta nueva. La guía de primeros pasos indica no extraer Ghidra sobre una instalación existente. Arráncalo con ghidraRun en Linux y macOS, o con ghidraRun.bat en Windows.
Antes de descomprimir nada, compara el archivo con el valor SHA-256 publicado. Ejecuta esto en la carpeta donde guardaste la versión:
sha256sum ghidra_*.zipCompara el hash impreso con el valor SHA-256 que GitHub muestra junto al archivo en la página de la versión, o que el proyecto publica. Tiene que coincidir exactamente. Si no encuentras un SHA-256 publicado para ese archivo, anótalo en tus notas del laboratorio. No tomes como checksum del publicador un hash copiado de un espejo cualquiera.
Comprobar un hash solo demuestra que el archivo es igual al que publicó el autor. No demuestra que el autor sea confiable. Usa la fuente oficial y guarda el nombre del archivo y su hash en tus notas.
Crea una carpeta vacía para el laboratorio. Mantén juntos el código fuente y los binarios, y guarda los proyectos de Ghidra fuera de esa carpeta para que no se llene de cosas.
mkdir -p ghidra-lab && cd ghidra-lab
cat > toy.c <<'EOF'
#include <stdio.h>
#include <string.h>
int check(const char *k){ return strcmp(k, "LAB-1234") == 0; }
int main(int c, char **v){ if(c>1 && check(v[1])) puts("ok"); else puts("no"); return 0; }
EOFEl programa lee un argumento de la línea de comandos. Si el argumento es igual a LAB-1234, imprime ok. Si no, imprime no. Esa verificación no tiene ningún valor de seguridad. Es una cadena fija dentro de un archivo que cualquiera puede leer, y ese es el punto. Vas a encontrar la cadena en el binario en pocos minutos.
Registra el hash de tu código fuente para poder demostrar después qué archivo analizaste:
sha256sum toy.cEn la máquina que usé, el comando imprimió:
d9746d8fb118fa954a46b4d501ddf9b0ed86e986e7c99adb05e4a1a60ca0071d toy.cTu hash será distinto si tu archivo tiene otros espacios o saltos de línea.
Compila el mismo código fuente en dos binarios con configuraciones distintas:
gcc -O0 -g -o toy_debug toy.c
gcc -O2 -s -o toy_stripped toy.cLas banderas hacen cosas distintas. Ambas vienen del manual de GCC, que conviene leer una vez:
-O0 desactiva la mayoría de las optimizaciones. Es el valor por defecto, y el manual lo describe como el ajuste que mantiene la depuración predecible. El código sigue de cerca al fuente.-O2 activa la mayoría de las optimizaciones que no cambian tamaño por velocidad. El manual dice que también activa la expansión en línea de funciones pequeñas, incluso las que no están declaradas como inline. El código puede verse muy distinto del fuente.-g agrega información de depuración en el formato nativo del sistema operativo. Ghidra lee parte de ella, y los depuradores la usan.-s quita la tabla de símbolos del archivo final, durante el enlazado. Los nombres check y main dejan de estar guardados en el archivo.Dos binarios con el mismo código y banderas distintas es lo que hace útil el laboratorio. Guarda los dos.
Ejecuta los binarios antes de abrir Ghidra. Un programa que no has ejecutado es un programa que todavía no entiendes.
./toy_debug LAB-1234
./toy_debug wrong
./toy_debug
./toy_stripped LAB-1234
./toy_stripped wrongLo ejecuté dentro de un contenedor Debian con GCC 14.2.0, sobre un host x86-64. La salida fue:
ok
no
no
ok
noLas dos versiones se comportan igual. Quitar símbolos y optimizar no cambió la lógica, que es lo que cabe esperar. Si tu salida es distinta, detente y averigua por qué antes de seguir.
Antes de Ghidra, mira qué hay guardado en cada archivo. Bastan dos herramientas: nm lista símbolos y strings lista textos legibles.
nm toy_debug | grep -E " (check|main)$"
nm toy_stripped
strings toy_stripped | grep LAB
ls -l toy_debug toy_strippedEn la máquina que usé, la salida fue:
0000000000001149 T check
0000000000001175 T main
nm: toy_stripped: no symbols
LAB-1234
-rwxr-xr-x 1 root root 17384 Nov 8 09:17 toy_debug
-rwxr-xr-x 1 root root 14480 Nov 8 09:17 toy_strippedLee esas líneas con atención:
check y main. Esos nombres vienen de tu código fuente.nm no encuentra ningún símbolo.LAB-1234 sobrevive en los dos archivos. Una cadena es un dato, y quitar símbolos no elimina datos que el programa necesita en ejecución. Queda en el archivo como constante, y por eso es fácil empezar con este laboratorio.Ahora desensambla check en el binario de depuración:
objdump -d -M intel toy_debug | grep -A14 "<check>:"La salida se vio así:
0000000000001149 <check>:
1149: 55 push rbp
114a: 48 89 e5 mov rbp,rsp
114d: 48 83 ec 10 sub rsp,0x10
1151: 48 89 7d f8 mov QWORD PTR [rbp-0x8],rdi
1155: 48 8b 45 f8 mov rax,QWORD PTR [rbp-0x8]
1159: 48 8d 15 a4 0e 00 00 lea rdx,[rip+0xea4] # 2004 <_IO_stdin_used+0x4>
1160: 48 89 d6 mov rsi,rdx
1163: 48 89 c7 mov rdi,rax
1166: e8 d5 fe ff ff call 1040 <strcmp@plt>
116b: 85 c0 test eax,eax
116d: 0f 94 c0 sete al
1170: 0f b6 c0 movzx eax,al
1173: c9 leave
1174: c3 retPuedes reconocer tu código en estas instrucciones. Una llamada a strcmp, una prueba sobre el valor de retorno y un sete que convierte "igual a cero" en 0 o 1. Ten eso presente cuando leas el descompilador.
Ahora mira el build optimizado. Desensamblé toy_stripped de la misma manera. El compilador puso la comparación directamente en main, que está en 0x1060 en mi build: un cmp edi,0x1 para la prueba c>1, luego mov rdi,[rsi+0x8] para cargar v[1], luego call strcmp@plt. Aun así conservó una copia separada de check en 0x1190, porque la función no es static y otro archivo podría llamarla. Las direcciones cambian según tu toolchain, así que tómalo como un mapa de qué buscar y no como números que deban coincidir.
Los nombres exactos de los menús cambian según la versión de Ghidra, y no ejecuté la interfaz gráfica en la máquina donde se revisó este artículo. Los pasos siguientes siguen la documentación oficial y lo que la interfaz suele mostrar. Verifica cada nombre con tu versión. Toma una captura de tu propia pantalla al terminar cada paso, y no reutilices capturas de otro tutorial.
ghidraRun (o ghidraRun.bat).toy-lab. Guárdalo en una carpeta que controles.toy_debug. Acepta el formato detectado si coincide con tu sistema. Un archivo generado en Linux x86-64 debería aparecer como ELF con procesador x86.toy_stripped. Ahora el proyecto tiene dos entradas.Espera a que termine el análisis. Ghidra muestra el progreso en la barra inferior. No empieces a renombrar nada antes de que termine.
Empieza con toy_stripped, que es el caso más difícil. Después compara con toy_debug.
LAB-1234 en la lista. Haz doble clic para ir a su dirección en la vista de listado.En el build de depuración, Ghidra también debería mostrar el nombre check de la tabla de símbolos. En el build sin símbolos mostrará un nombre generado, como FUN_00101190. El número depende del build, así que usa el nombre que aparezca en tu pantalla.
Haz lo mismo con las cadenas "ok" y "no". Cada una se usa en main. Seguirlas desde main te da la misma imagen vista desde el otro lado.
Abre el panel del descompilador en la función que compara la cadena. Pon toy.c abierto en otra ventana, al lado.
El resultado que conviene buscar es de este tipo, descrito como ilustración de cómo leerlo y no como salida de una versión concreta de Ghidra:
strcmp, o a un símbolo que la herramienta etiqueta como strcmp.Ahora anota las diferencias que veas. Las típicas son:
char * como char *, o usar un tipo más vago como undefined8. Puede que no sepa que la entrada es const.k aparecerá como param_1 o un marcador parecido.strcmp(...) == 0 puede aparecer como una comparación seguida de un retorno condicional. El significado es el mismo, pero la forma no.main. Tu comprobación c > 1 se convierte en una comparación sobre la cantidad de argumentos. Fíjate en el desfase de uno: argv[0] es el nombre del programa, y v[1] es el primer argumento real.El objetivo no es calificar al descompilador. El objetivo es notar en qué coinciden su historia y la tuya, y en qué se separan. Cuando se separen, vuelve al desensamblado y al código fuente y decide cuál tiene razón.
Ahora haz la parte que vuelve legible un binario sin símbolos.
L. Llámala input.check_input.main, renombra el arreglo de argumentos a argv y el contador a argc.Ghidra guarda esos nombres en tu proyecto. El binario en disco no cambia. Eso importa: estás documentando tu lectura, no editando el archivo que compilaste.
Fíjate en lo mucho que mejora el código después de renombrar. La primera vez, los nombres de las variables eran suposiciones. Ahora coinciden con tu código fuente. Así trabajan los ingenieros inversos a diario, y por eso los nombres de un programa descompilado deben tratarse como hipótesis hasta que los compruebes.
Pon la descompilación del build de depuración y la del build sin símbolos una al lado de la otra. Responde estas preguntas en tus notas:
-O2 integró check dentro de main, o mantuvo una función separada? Busca en el listado un punto de llamada separado a strcmp y un ret separado.Escribe una frase por cada respuesta. Si no puedes responder alguna, eso también es un dato útil. Vuelve al listado y busca la instrucción que lo deja confuso.
Saltarse la ejecución. Si abres el binario en Ghidra antes de ejecutarlo, no sabes cuál es el comportamiento correcto. Ejecútalo primero y registra las salidas.
Confiar en los nombres del descompilador. Un nombre como FUN_00101190 o local_18 es un marcador. Renombrarlo es una afirmación tuya, y necesita evidencia.
Mezclar los builds. Los binarios de depuración y sin símbolos tienen direcciones y nombres distintos. Anota el nombre de cada archivo y revisa en qué entrada del proyecto estás antes de renombrar nada.
Usar una muestra de una fuente no confiable. Tu propio archivo compilado es el punto de partida seguro. Cuando te sientas cómodo con él, el siguiente paso sigue siendo código tuyo o una muestra de una fuente que hayas verificado antes. El artículo sobre hashes de archivos y ética de VirusTotal explica por qué un hash por sí solo no resuelve esa pregunta.
Tratar el laboratorio como una prueba de seguridad. La comprobación de cadena de aquí se rompe de forma trivial. Encontrarla no demuestra nada sobre software real. El código real de licencias y protección es más difícil, y analizarlo debe mantenerse dentro del alcance en el que tienes permiso para trabajar.
Olvidar la versión del JDK. Si Ghidra no arranca, revisa la versión de Java con las notas de la versión antes de tocar cualquier otra cosa.
Cuando el juguete funcione, haz el ejercicio más difícil de forma controlada:
"LAB-1234" por una cadena más larga, recompila y vuelve a encontrarla en los dos builds.-O0 -s y compáralo con -O2 -s. La diferencia muestra cuánto cambia el optimizador la forma del código sin quitar la lógica.Si quieres entender mejor las banderas del compilador, las secciones del manual de GCC sobre opciones de optimización y de depuración son las fuentes primarias. Léelas antes de confiar en una bandera dentro de un informe.
Guarda tus notas. Un buen cuaderno de laboratorio tiene el hash del código fuente, los comandos de compilación, las salidas de ejecución, la versión de Ghidra, la versión del JDK y los nombres que asignaste, cada uno con la evidencia que lo respalda. Ese registro convierte este ejercicio en una habilidad que puedes reutilizar con el siguiente programa.
Ingeniería inversa
Entiende qué descarta la compilación, en qué se diferencian desensambladores y descompiladores y los límites legales antes de analizar un binario.