Decompilación
Con decompilación nos referiremos a la acción de tomar un programa compilado, ya sea a código binario o bytecode, y devolverlo a una forma legible para humanos. Esto puede ser código Assembler, o algo un poco más sofisticado, como C, Java, Python, .Net, PHP, entre otros. Hacer esto de manera manual se puede volver prohibitivamente costoso y difícil, pero afortunadamente existen algunas herramientas que ayudan en el proceso, y muchas veces logran obtener un código casi idéntico al original. Esto claramente irá variando caso a caso, pero de todas formas analizar un código en C poco legible, o incluso en Assembler, es mucho más fácil que hacerlo para código binario.
Decompilación de Binarios
La decompilación de binarios puede tomar 2 formas:
Decompilación Estática: Toma como entrada el código binario y retorna uno o más archivos con código decompilado. Esto sirve para analizar el código de manera estática, como también para realizar cambios al código y ver cómo se comporta.
Decompilación en Tiempo de Ejecución: Se ejecuta el código de una forma especial, que permite ver las instrucciones y datos utilizados durante la ejecución en formato Assembler. Funciona de manera similar a un debugger, teniendo la capacidad de iterar por las instrucciones una a una y definir break points. Adicionalmente, permite leer y modificar directamente los registros de la CPU y la memoria del proceso.
Las herramientas más utilizadas son Ghidra para la decompilación estática, y radare2 para la decompilación en tiempo real.
A modo de ejemplo ocuparemos una implementación de Fibonacci en C, la cual la decompilaremos luego usando Ghidra y radare2.
#include <stdio.h>
#include <stdlib.h>
int fibonacci(int n) {
if (n == 1 || n == 2) {
return 1;
}
return fibonacci(n - 1) + fibonacci(n - 2);
}
void main(int argc, char *argv[]) {
int n = atoi(argv[1]);
int f = fibonacci(n);
printf("%d\n", f);
}
La compilamos:
gcc -o fibonacci fibonacci.c
Y vemos que funciona como esperamos:
Radare2
Radare2 nos permite ejecutar el código, y al mismo tiempo decompilarlo a Assembler. Este tiene muchas opciones y funcionalidades, y se puede volver bastante complejo de usar, por lo que es recomendable revisar la documentación y tutoriales como este.
Empezaremos por lanzar radare2 en modo debug para analizar el ejecutable. Notar que es necesario entregarle un argumento a fibonacci, sino fallará. Una vez dentro de radare2, realizamos un análisis básico del binario.
Luego, listamos las funciones definidas para entender mejor la estructura interna. Vemos 2 que nos podrían interesar: main y sym.fibonacci.
Definimos un break point en la función main y dejamos correr el proceso. Para ver el código decompilado
(o desensamblado), ejecutamos pdf
(por Print Disassembled Function). Esto nos muestra 3 columnas:
la dirección de memoria de la instrucción, la instrucción en hexadecimal, y la instrucción desensamblada
en formato Assembler.
Observamos que aparecen algunas cosas interesantes, como las llamadas a funciones y el string utilizado para formatear e imprimir el resultado. Ahora definimos un break point en la dirección de memoria en donde se llama a la función sym.fibonacci, y continuamos con la ejecución.
Para entrar a la función, ejecutamos solo una instrucción (la llamada a la función) usando el comando ds
.
Finalmente, pueden ver un diagrama de flujos usando el comando VV
. Esto les puede ayudar a entender
los distintos caminos de ejecución que puede tomar el proceso y qué sucede en cada uno.
Ghidra
Para decompilar el ejecutable, primero abrimos Ghidra y creamos un proyecto cualquiera.
Luego hacemos click en la herramienta CodeBrowser (En Tool Chest es el dragón verde) e importamos el archivo. Debería aparecer un popup preguntando si desean analizarlo, y dicen que sí. Aparecerá mucha información, más de la que se espera para un código tan simple. Esta corresponde a las librerías utilizadas (stdio y stdlib), al igual que algunas otras definiciones.
A la izquierda está en panel de Symbol Tree, con un dropdown llamado Functions. Si seleccionan la función fibonacci podrán ver el código decompilado de la función que definimos. Es bastante parecido al código original, pero se ven claras diferencias y es menos legible.
Cutter
Cutter es una herramienta open source similar a las anteriores pero con una interfaz más moderna. Al ejecutar Cutter, vemos una ventana donde podemos seleccionar un tema y el idioma.
Al continuar, veremos una ventana donde podemos escoger un archivo.
Una vez se escoge un archivo, se abre el espacio de trabajo de Cutter. Lo primero que se ve es un resumen (“overview”) con información del archivo.
A la izquierda podemos ver una lista de funciones. Abajo, tenemos varias pestañas, con
el disassembly, hexdump, decompilación, gráficos y otras visualizaciones más. Por ejemplo,
podemos seleccionar la función loc.hex1
y ver su diagrama en la pestaña Graph
.
Decompilación de Bytecode
El bytecode es una especie de código compilado, pero no a lenguaje de máquina, sino que a un lenguaje intermedio pensado para ser ejecutado en un ambiente virtualizado. Luego, el bytecode es análogo a un lenguaje Assembler para la máquina virtual que lo corre. Se utiliza mucho para transformar lenguajes interpretados en algo un poco más eficiente de ejecutar, pero sin tener que implementar un compilador completo. Este bytecode usualmente no difiere mucho del código original, y puede ser decompilado directamente, casi sin alteraciones. Esto suele ser mucho más fácil de analizar que los binarios decompilados, ya que se obtiene inmediatamente código en un lenguaje de alto nivel.
Java
En el caso de Java, el código se compila para ser ejecutado en la Java Virtual Machine (JVM).
El bytecode se encuentra en los archivos .class
generados al hacer la compilación, los cuales también
pueden ser obtenidos desde un archivo empaquetado .jar
. Estos pueden ser decompilados directamente
con JD-GUI.
Como ejemplo crearemos un ejecutable .jar
y realizaremos el proceso para decompilarlo. El código será un
simple Hello World en el archivo HelloWorld.java
.
public class HelloWorld {
public static void main(String[] args) {
System.out.println("Hello World!");
}
}
Necesitaremos un manifesto para crear el .jar
correctamente. Para eso debemos tener el archivo
MANIFEST.MF
con el contenido:
Main-Class: HelloWorld
Luego compilamos y empaquetamos el código:
Al ejecutar el archivo HelloWorld.jar
verificamos que efectivamente hace lo que queremos:
Para decompilar el código simplemente abrimos el archivo HelloWorld.jar
con el decompilador JD-GUI, lo que
nos muestra un código Java casi idéntico al original.
Python
Si bien Python es un lenguaje interpretado, también tiene una forma compilada. Estos son los archivos .pyc
o .pyo
que aparecen a veces dentro de __pycache__
.
Para decompilar el bytecode de Python, se puede utilizar librerías de Python:
decompyle3 para Python3.7 en adelante
(aún no hay soporte para Python3.9), y uncompyle6
para versiones anteriores. Se usan en la terminal de manera muy simple:
decompyle3 <archivo pyc o pyo>
uncompyle6 <archivo pyc o pyo>
Como ejemplo ocuparemos un script para calcular PI usando la fórmula de Leibniz y Python2.7.
import sys
def Leibniz(n):
s = 0.0
k = 1.0
for i in range(n):
if i % 2 == 0:
s += 4 / k
else:
s -= 4 / k
k += 2
return s
n = int(sys.argv[1])
pi = Leibniz(n)
print 'PI = ', pi
Al ejecutarlo vemos que nos retorna una aproximación de PI que depende del número de iteraciones que definamos.
Compilamos el código a bytecode, el cual aparecerá en un nuevo archivo pi.pyc
.
python -m compileall pi.py
Finalmente decompilamos el archivo usando uncompyle6 y obtenemos un código casi exactamente igual al original:
Editar en GitHub Modificado por última vez el 16/06/2023 a las 12:44:00 hrs.