Ya bien sea por fines maliciosos, como el caso de los autores de malware, por fines corporativos, o por otras razones, las técnicas de ofuscación son utilizadas para proteger un programa haciendo que una vez compilado, el análisis estático del binario sea más costoso. La ofuscación por tanto, consiste en transformar un programa de forma que sea más difícil de entender, pero que al mismo tiempo mantenga la semántica original.
Estas técnicas de ofuscación pueden aplicarse a nivel de datos, de instrucciones o de flujo de control del programa, pasamos a explicar algunas de ellas. Estas son algunas de las técnicas de ofuscación de datos:
- Cegar las constantes (constant blinding): en vez de tener un valor en claro, esta técnica busca sustituir los valores de las constantes, generalmente mediante una XOR con un valor aleatorio, por datos aparentemente sin sentido.
- Cambio del encoding de las variables: de forma similar al constant blinding, cambiando el encoding de las variables se busca ocultar el valor original de la variable, por ejemplo un string, por su valor equivalente en otro encoding. Ejemplos típicos son la codificación en Base64 o los cifrados sencillos mediante XOR, ROT13 etc.
- Agregación de datos: consiste en agrupar variables en estructuras más complejas, por ejemplo juntando enteros en un mismo struct.
- Separación de datos: en contraposición a la agregación de datos, la separación de datos consiste en dividir los datos en unidades más pequeñas, por ejemplo transformando un int de 4 bytes en dos shorts de 2 bytes.
Para ofuscar el flujo de control del programa existen diversas técnicas:
- Inserción de código muerto (dead code insertion): una de las fases de optimización que hacen los compiladores modernos por defecto es la eliminación de código que no afecta al programa (opción -fdce en GCC); la inserción de código muerto es todo lo contrario, es decir, inserta código en el programa que es redundante. Los ejemplos más sencillos son la inserción de nops, guardar el valor de una variable, hacer operaciones matemáticas con ella para luego restaurar el valor original etc.
- Uso de predicados opacos: un predicado es una expresión lógica que se evalúa a true o false, y que generalmente se usa para dirigir el flujo de un programa. Los predicados opacos tienen como objetivo hacer que esa decisión de si algo se evalúa a true o false no sea posible saberse en tiempo de compilación o de forma estática, aunque siempre vaya a tener el mismo resultado. De esta forma se generan más ramas en el control flow graph (CFG) y se molesta en la fase de reversing. Tratar de detectar predicados opacos y desofuscarlos es una línea de investigación abierta. Se pueden ver ejemplos en estas respuestas de Reverse Engineering Stack Exchange.
- Aplanado del flujo de control (control flow flattening): esta técnica consiste en modificar y reordenar los bloques básicos de un programa (basic block, BB) de forma que, en vez de tener en el CFG una estructura de decisión if-else típica en la que se sigue la ejecución a un BB o se salta a otro BB dependiendo del valor de los flags, se pasa a una estructura aplanada, en la que un BB llamado dispatcher decide a qué BB saltar en base al valor de una variable artificial. Cada BB de las ramas de decisión tiene el dispatcher como predecesor y sucesor, dificultando así averiguar la lógica del programa detrás del CFG aplanado.
- Desenroscado de bucles (loop unrolling): en las fases de optimización de la compilación de un programa se utiliza el loop unrolling para evitar añadir instrucciones de salto condicional y variables adicionales para emular bucles. El loop unrolling, además de ser computacionalmente menos costoso (se activa con la opción -O1 en adelante en GCC) genera bloques de instrucciones muy parecidos entre ellos, lo que aumenta la confusión en el análisis estático del código.
Finalmente a nivel de instrucción se busca sustituir la instrucción original por otra serie de instrucciones equivalentes más complicadas, generalmente mediante el uso de operaciones matemáticas. Algunas de estas técnicas pueden aplicarse con Obfuscator-LLVM, una suit de compilación sobre LLVM que en vez de aplicar las optimizaciones típicas del middle-end de LLVM, permite ofuscar el programa.
Por otro lado, las técnicas de ofuscación también pueden servir para dar seguridad. Por ejemplo, PointGuard es una antigua extensión de GCC para proteger punteros. PointGuard cifra los valores de los punteros cuando estos están en memoria aplicando una XOR con una clave generada aleatoriamente cuando el proceso del programa arranca. Cuando un puntero se va a dereferenciar se descifra el valor del puntero, de esta forma, si un atacante consigue sobrescribir el valor del puntero, cuando este se dereferencie y por PointGuard se descifre, el atacante estará accediendo a una dirección de memoria aleatoria, y muy posiblemente si el acceso no es válido hará que el programa falle, frustrando así la explotación del programa.