
Jugando con la protección de software VMProtect. Desofuscación automática de funciones puras mediante ejecución simbólica y LLVM.
Un enfoque dinámico experimental para desvirtualizar funciones puras protegidas por VMProtect 3.x
Comparto algunas notas sobre un enfoque dinámico para desvirtualizar funciones puras protegidas por VMProtect. Este enfoque ha mostrado muy buenos resultados si la función virtualizada contiene solo un bloque básico (independientemente de su tamaño). Este es un escenario común cuando los binarios protegen operaciones aritméticas. Sin embargo, este enfoque es un poco más experimental cuando la función objetivo contiene más de un bloque básico. No obstante, logramos desvirtualizar y reconstruir el código binario de muestras que contienen 2 bloques básicos, lo que sugiere que es posible desvirtualizar completamente funciones pequeñas de forma dinámica.
VMProtect es una protección de software que protege el código ejecutándolo a través de una máquina virtual con una arquitectura no estándar. Esta protección es un gran campo de juego para los amantes del ensamblador [0, 1, 2, 3, 4, 5, 6, 11]. Además, ya existen numerosas herramientas que atacan esta protección [7, 8, 9, 12, 13]. En 2016 analizamos la solución de protección de software Tigress y logramos derrotar su virtualización utilizando ejecución simbólica y LLVM. Este enfoque fue presentado en DIMVA 2018 [10] y quería probarlo en VMProtect. Nótese que no existe una solución mágica que funcione en todos los binarios, siempre hay compromisos según el objetivo y tus metas. Esta modesta contribución pretende proporcionar un ejemplo de un ataque dinámico contra funciones puras que están virtualizadas por VMProtect. La principal ventaja de un ataque dinámico es que derrota por diseño algunas protecciones estáticas de VMProtect como el código automodificable, el cifrado de claves y operandos, etc.
Consideramos una función pura como una función con un número finito de caminos y que no tiene efectos secundarios. Puede tener varias entradas pero solo una salida. A continuación se muestra un ejemplo de una función pura:```cpp int secret(int x, int y) { int r = x ^ y; return r; }
# El enfoque
Nos basamos en la intuición clave de que un trace ofuscado T' (del código ofuscado P') combina instrucciones
originales del código original P (el trace T correspondiente a T' en el código original) e
instrucciones de la máquina virtual VM, de modo que T' = T + VM(T). Si somos capaces de distinguir
entre estas dos subsecuencias de instrucciones T y VM(T), entonces podemos reconstruir una ruta del
programa original P a partir de un trace T'. Repitiendo esta operación para cubrir todas las rutas del
programa virtualizado, podremos reconstruir el programa original P. En nuestro ejemplo práctico, el código
original tiene un número finito de rutas ejecutables, lo cual es común en muchas situaciones que involucran
protección de propiedad intelectual. Para ello, procedemos con los siguientes pasos:
1. Identificar la función virtualizada y sus argumentos
2. Generar un trace de VMProtect del objetivo
3. Reproducir el trace VMP y construir expresiones simbólicas para obtener la relación entre entradas y salida
4. Aplicar optimizaciones en las expresiones simbólicas para evitar en lo posible las instrucciones de la VM
5. Elevar nuestra representación simbólica a LLVM-IR para construir una nueva versión no protegida del objetivo
## Ejemplo 1: Una operación bitwise simple
Tomemos como primer ejemplo la siguiente función: toma dos entradas y devuelve `x ^ y` que está protegida por VMProtect.```cpp
int secret(int x, int y) {
VMProtectBegin("secret");
int r = x ^ y;
VMProtectEnd();
return r;
}
Comenzamos identificando dónde las funciones están usando VMProtect y cuántos argumentos tienen. Para nuestro ejemplo podríamos tener algo como lo siguiente:
Con solo leer el código sabemos que la función comienza en la dirección 0x4011c0, tiene dos argumentos de 32 bits (edi y esi)
y retorna en 0x4011ef. Eso es todo lo que necesitamos de ingeniería inversa. Las siguientes partes serán automáticas. Ahora tenemos
que generar una traza de ejecución de esta función virtualizada. Para ello usamos un Pintool.
Solo necesita una dirección de inicio y fin (para nuestro ejemplo, 0x4011c0 y 0x4011ef) que representan el rango
de la instrumentación. Tenga en cuenta que cualquier tipo de DBI o emulador podría realizar este trabajo.```
$ ./pin/pin -t ./pin/source/tools/VMP_Trace/obj-intel64/VMP_Trace.so -start 4198848 -end 4198895 -- ./vmp_binaries/binaries/sample2.vmp.bin 1 2 &> ./vmp_traces/sample2.vmp.trace
Puedes ver el resultado [aquí](https://github.com/jonathansalwan/vmprotect-devirtualization/blob/main/vmp_traces/sample2.vmp.trace). El formato de traza utiliza tres tipos de operaciones: `mr`, `r` e `i`.
`mr` es un acceso de lectura de memoria realizado por la instrucción `i`, y `r` son los registros de la CPU. Por ejemplo:```
mr:0x7ffda459d718:8:0x227db4f8
r:0x40200a:0x0:0x7ffda459f571:0x2:0x40200a:0x0:0x0:0x7ffda459d688:0x0:0x0:0x7feee9b80ac0:0x7feee9b8000f:0xad1c3e:0x0:0x0:0x0
i:0x89173e:8:488BB42490000000
Tenemos una lectura de memoria que carga una constante de 8 bytes 0x227db4f8 desde la dirección 0x7ffda459d718.
La instrucción se ejecuta en la dirección 0x89173e y su código de operación de 8 bytes es 488BB42490000000, que es un
mov rsi, qword ptr [rsp + 0x90].
El estado de los registros antes de la ejecución es el siguiente:```python
(1) RAX = 0x40200a (9) R8 = 0
(2) RBX = 0 (10) R9 = 0
(3) RCX = 0x7ffda459f571 (11) R10 = 0x7feee9b80ac0
(4) RDX = 0x2 (12) R11 = 0x7feee9b8000f
(5) RDI = 0x40200a (13) R12 = 0xad1c3e
(6) RSI = 0 (14) R13 = 0
(7) RBP = 0 (15) R14 = 0
(8) RSP = 0x7ffda459d688 (16) R15 = 0
Una vez que se ha generado la traza VMP, la reproducimos usando el script [attack_vmp.py](https://github.com/jonathansalwan/vmprotect-devirtualization/blob/main/attack_vmp.py). Este script utiliza [Triton](https://github.com/jonathansalwan/Triton) para construir el predicado de ruta de la traza. Nótese que todas las expresiones que involucran variables simbólicas (entradas de la función) se mantienen simbólicas, mientras que todas las expresiones de entrada no relacionadas se concretizan. En otras palabras, nuestras expresiones simbólicas no contienen ninguna operación relacionada con la máquina virtual (el mecanismo en sí mismo no depende del usuario), sino solo operaciones relacionadas con el programa original.