
Jouer avec la protection logicielle VMProtect. Désobfuscation automatique des fonctions pures à l'aide de l'exécution symbolique et de LLVM.
Une approche dynamique expérimentale pour dévirtualiser des fonctions pures protégées par VMProtect 3.x
Je partage quelques notes sur une approche dynamique pour dévirtualiser des fonctions pures protégées par VMProtect. Cette approche a donné de très bons résultats si la fonction virtualisée ne contient qu’un seul bloc de base (quelle que soit sa taille). C’est un scénario courant lorsque les binaires protègent des opérations arithmétiques. Cependant, cette approche est un peu plus expérimentale lorsque la fonction cible contient plus d’un bloc de base. Néanmoins, nous avons réussi à dévirtualiser et reconstruire le code binaire à partir d’échantillons contenant 2 blocs de base, ce qui suggère qu’il est possible de dévirtualiser complètement de petites fonctions de manière dynamique.
VMProtect est une protection logicielle qui protège le code en le faisant passer par une machine virtuelle à l’architecture non standard. Cette protection est un formidable terrain de jeu pour les amateurs d’assembleur [0, 1, 2, 3, 4, 5, 6, 11]. De plus, il existe déjà de nombreux outils qui attaquent cette protection [7, 8, 9, 12, 13]. En 2016, nous avons examiné la solution de protection logicielle Tigress et réussi à vaincre sa virtualisation en utilisant l’exécution symbolique et LLVM. Cette approche a été présentée à DIMVA 2018 [10] et j’ai voulu la tester sur VMProtect. Notez qu’il n’existe pas de solution magique qui fonctionne sur tous les binaires ; il y a toujours des compromis selon la cible et vos objectifs. Cette modeste contribution vise à fournir un exemple d’attaque dynamique contre les fonctions pures virtualisées par VMProtect. Le principal avantage d’une attaque dynamique est qu’elle neutralise de par sa conception certaines protections statiques de VMProtect comme le code auto-modifiant, le chiffrement des clés et des opérandes, etc.
Nous considérons comme fonction pure une fonction avec un nombre fini de chemins et sans effets de bord. Il peut y avoir plusieurs entrées mais une seule sortie. Voici un exemple de fonction pure :```cpp int secret(int x, int y) { int r = x ^ y; return r; }
# L'approche
Nous nous appuyons sur l'intuition clé qu'une trace obfusquée T' (provenant du code obfusqué P') combine des instructions originales
du code original P (la trace T correspondant à T' dans le code original) et
des instructions de la machine virtuelle VM de sorte que T' = T + VM(T). Si nous sommes capables de distinguer entre
ces deux sous-suites d'instructions T et VM(T), nous sommes alors capables de reconstruire un chemin du
programme original P à partir d'une trace T'. En répétant cette opération pour couvrir tous les chemins du programme virtualisé,
nous pourrons reconstruire le programme original P. Dans notre exemple pratique, le code original
a un nombre fini de chemins exécutables, ce qui est le cas dans de nombreuses situations impliquant la protection de la propriété
intellectuelle. Pour ce faire, nous procédons aux étapes suivantes :
1. Identifier la fonction virtualisée et ses arguments
2. Générer une trace VMProtect de la cible
3. Rejouer la trace VMP et construire des expressions symboliques pour obtenir la relation entre les entrées et la sortie
4. Appliquer des optimisations sur les expressions symboliques pour éviter autant que possible les instructions de la VM
5. Élever notre représentation symbolique vers LLVM-IR pour construire une nouvelle version non protégée de la cible
## Exemple 1 : une simple opération bit à bit
Prenons comme premier exemple la fonction suivante : elle prend deux entrées et retourne `x ^ y`, qui est protégée par VMProtect.```cpp
int secret(int x, int y) {
VMProtectBegin("secret");
int r = x ^ y;
VMProtectEnd();
return r;
}
Nous commençons par identifier où les fonctions utilisent VMProtect et combien d'arguments elles ont. Pour notre exemple, nous pouvons avoir quelque chose comme ci-dessous :
Rien qu'en lisant le code, nous savons que la fonction commence à l'adresse 0x4011c0, a deux arguments 32 bits (edi et esi)
et retourne à 0x4011ef. C'est tout ce dont nous avons besoin en rétro-ingénierie. Les prochaines étapes seront automatiques. Maintenant, nous
devons générer une trace d'exécution de cette fonction virtualisée. Pour ce faire, nous utilisons un Pintool.
Il a seulement besoin d'une adresse start et end (pour notre exemple, 0x4011c0 et 0x4011ef) qui représente la plage
de l'instrumentation. Notez que tout type de DBI ou d'émulateur pourrait faire ce travail.```
$ ./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
Vous pouvez voir le résultat [ici](https://github.com/jonathansalwan/vmprotect-devirtualization/blob/main/vmp_traces/sample2.vmp.trace). Le format de trace utilise trois types d'opérations : `mr`, `r` et `i`.
`mr` est un accès en lecture mémoire effectué par l'instruction `i`, et `r` sont les registres du CPU. Par exemple :```
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
Nous avons une lecture mémoire qui charge une constante de 8 octets 0x227db4f8 depuis l’adresse 0x7ffda459d718.
L’instruction est exécutée à l’adresse 0x89173e et son opcode de 8 octets est 488BB42490000000, ce qui est un
mov rsi, qword ptr [rsp + 0x90].
L’état des registres avant l’exécution est le suivant :```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
Une fois la trace VMP générée, nous la rejouons à l'aide du script [attack_vmp.py](https://github.com/jonathansalwan/vmprotect-devirtualization/blob/main/attack_vmp.py). Ce script utilise
[Triton](https://github.com/jonathansalwan/Triton) pour construire le prédicat de chemin de la trace. Notez que toutes les expressions
qui impliquent des variables symboliques (entrées de la fonction) restent symboliques tandis que toutes les expressions non liées
aux entrées sont concrétisées. En d'autres termes, nos expressions symboliques ne contiennent aucune opération liée à la machine
virtuelle (le mécanisme lui-même ne dépend pas de l'utilisateur) mais uniquement des opérations liées au programme d'origine.