
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/HEAD/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/HEAD/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.
Par exemple, voici un exemple de concrétisation. À gauche, nous avons un AST qui contient des sous-expressions n'impliquant pas de
variable symbolique (`1 + 2` et `6 ^ 3`). Ces branches sont donc concrétisées et remplacées par les constantes `3` et `5`, ce qui
conduit à l'AST de droite. **C'est ainsi que nous dévirtualisons le code.**
<p align="center">
<img src="https://assets.kitploit.com/production/public/readmes/8096/857ed50cfe9cb2347f816ece1d8dc4c13174c971dcb65ebc5621be14269a5ca8.png">
</p>
**Une note sur le backward slicing au niveau de la formule** : Comme il est courant en exécution symbolique, la représentation symbolique est
d'abord calculée de manière progressive le long du chemin, puis toutes les opérations logiques et définitions qui n'affectent ni le résultat final
ni le chemin suivi sont supprimées de l'expression symbolique (formula slicing, également appelé formula pruning). Cela revient à
effectuer sur la formule l'équivalent d'une analyse de code par backward slicing à partir de la sortie du programme. Ainsi, au retour de la
fonction `secret`, nous obtenons une expression de la relation entre les entrées et la sortie sans les instructions de VMProtect.
Le script `./attack_vmp.py` prend comme paramètres le fichier de trace et la taille des variables symboliques. Rappelez-vous, il s'agissait de `edi` et
`esi`, ils font donc 4 octets. Le résultat du script est le suivant :```
$ ./attack_vmp.py --trace1 ./vmp_traces/sample2.vmp.trace --symsize 4
[+] Replaying the VMP trace
[+] Symbolize inputs
[+] Instruction executed: 12462
[+] Emulation done
[+] Return value: 0x3
[+] Devirt expr: (bvor (bvnot (bvor (bvnot (bvnot x)) (bvnot y))) (bvnot (bvor (bvnot x) (bvnot (bvand (bvnot y) (bvnot y))))))
[+] Synth expr: (bvxor x y)
[+] LLVM IR ==============================
; ModuleID = 'tritonModule'
source_filename = "tritonModule"
define i32 @__triton(i32 %SymVar_0, i32 %SymVar_1) {
entry:
%0 = xor i32 %SymVar_0, %SymVar_1
ret i32 %0
}
[+] EOF LLVM IR ==============================
Comme nous pouvons le voir, l'expression dévirtualisée renvoyée par la fonction secret est assez concise et ne contient pas d'instructions provenant de
la machine virtuelle.```smt
(bvor
(bvnot (bvor
(bvnot (bvnot x))
(bvnot y)
)
)
(bvnot (bvor
(bvnot x)
(bvnot (bvand
(bvnot y)
(bvnot y)
)
)
)
)
)
Cependant, nous n'avons pas réussi à retrouver l'expression originale qui était une simple opération `XOR`. Il semble que le `XOR` ait été
traduit en opérations bit à bit. Heureusement, nous avons récemment publié de nouvelles fonctionnalités dans le projet Triton, à savoir
un [synthétiseur](https://github.com/JonathanSalwan/Triton/issues/1074) et un élévateur vers
[LLVM-IR](https://github.com/JonathanSalwan/Triton/issues/1078). Ainsi, nous pouvons synthétiser l'expression, ce qui nous donne l'expression
`(bvxor x y)`. C'est une belle victoire et nous pouvons maintenant aller plus loin en élevant cette expression vers LLVM-IR puis en compilant un nouveau code binaire dévirtualisé.
## Exemple 2 : Une opération MBA protégée
Bon, examinons maintenant un autre exemple qui tente de masquer une opération MBA. Le code source original est le suivant :```cpp
// This function is an MBA that computes: (x ^ 92) + y
// We will protect this MBA with VMProtect and see if we can recover "(x ^ 92) + y"
char secret(char x, char y) {
VMProtectBegin("secret");
int a = 229 * x + 247;
int b = 237 * a + 214 + ((38 * a + 85) & 254);
int c = (b + ((-(2 * b) + 255) & 254)) * 3 + 77;
int d = ((86 * c + 36) & 70) * 75 + 231 * c + 118;
int e = ((58 * d + 175) & 244) + 99 * d + 46;
int f = (e & 148);
int g = (f - (e & 255) + f) * 103 + 13;
int r = (237 * (45 * g + (174 * g | 34) * 229 + 194 - 247) & 255) + y;
VMProtectEnd();
return r;
}
Comme pour le premier exemple, nous devons identifier où cette fonction commence et se termine et générer une trace VMP.``` $ ./pin/pin -t ./pin/source/tools/VMP_Trace/obj-intel64/VMP_Trace.so -start 4198857 -end 4199140 -- ./vmp_binaries/binaries/sample3.vmp.bin 1 2 &> ./vmp_traces/sample3.vmp.trace
Une fois la [trace VMP](https://github.com/jonathansalwan/vmprotect-devirtualization/blob/HEAD/vmp_traces/sample3.vmp.trace) générée, lançons le script `./attack_vmp.py`.```
$ ./attack_vmp.py --trace1 ./vmp_traces/sample3.vmp.trace --symsize 1
[+] Replaying the VMP trace
[+] Symbolize inputs
[+] A potential symbolic jump found on CF flag: 0x821dac: popfq - Model: {0: x:32 = 0xa3, 1: y:32 = 0xff}
[+] A potential symbolic jump found on CF flag: 0x87f437: popfq - Model: {0: x:32 = 0xa3, 1: y:32 = 0xff}
[+] Instruction executed: 25085
[+] Emulation done
[+] Return value: 0x5f
[+] Devirt expr: In: (bvadd (bvadd (bvshl (bvadd (_ bv1 32) (bvnot (bvlshr (concat (_ bv0 8) (_ bv0 8) ((_ extract 15 8) ...
[+] Synth expr: In: (bvadd (bvadd (bvshl (bvadd (_ bv1 32) (bvnot (bvlshr (concat (_ bv0 8) (_ bv0 8) ((_ extract 15 8) ...
[+] LLVM IR ==============================
; ModuleID = 'tritonModule'
source_filename = "tritonModule"
define i32 @__triton(i8 %SymVar_0, i8 %SymVar_1) {
entry:
%0 = xor i8 %SymVar_0, 92
%1 = and i8 %SymVar_0, 0
%2 = zext i8 %1 to i32
%3 = or i32 0, %2
%4 = shl i32 %3, 8
%5 = zext i8 %0 to i32
%6 = or i32 %4, %5
%7 = and i8 %SymVar_1, 0
%8 = zext i8 %7 to i32
%9 = or i32 0, %8
%10 = shl i32 %9, 8
%11 = zext i8 %SymVar_1 to i32
%12 = or i32 %10, %11
%13 = zext i8 %7 to i32
%14 = or i32 0, %13
%15 = shl i32 %14, 8
%16 = zext i8 %SymVar_1 to i32
%17 = or i32 %15, %16
%18 = lshr i32 %17, 7
%19 = xor i32 %18, -1
%20 = add i32 1, %19
%21 = shl i32 %20, 8
%22 = add i32 %21, %12
%23 = add i32 %22, %6
ret i32 %23
}
[+] EOF LLVM IR ==============================
Le résultat est assez intéressant pour plusieurs raisons. D'abord, nous avons réussi à éviter autant que possible les instructions de la machine virtuelle, passant de 25 085 instructions exécutées à 25 instructions LLVM. Cependant, nous ne sommes pas parvenus à obtenir une version synthétisée correcte de la sortie (oui, je sais, nous allons plus loin que la simple dévirtualisation). L'avantage de hisser nos expressions symboliques vers du LLVM-IR est que nous pouvons pleinement bénéficier du pipeline d'optimisation de LLVM. Faisons ceci :```llvm $ opt -S -O3 ./devirt/sample3.ll ; ModuleID = 'devirt/sample3.ll' source_filename = "tritonModule"
; Function Attrs: mustprogress nofree norecurse nosync nounwind readnone willreturn define i32 @__triton(i8 %SymVar_0, i8 %SymVar_1) local_unnamed_addr #0 { entry: %0 = xor i8 %SymVar_0, 92 %1 = zext i8 %0 to i32 %2 = zext i8 %SymVar_1 to i32 %3 = shl nuw nsw i32 %2, 1 %4 = and i32 %3, 256 %5 = add nuw nsw i32 %1, %2 %6 = sub nsw i32 %5, %4 ret i32 %6 }
Using LLVM optimizations we managed to remove noise from our devirtualized output and thus break the MBA.
We can see the `XOR` operation with its constant (`%0 = xor i8 %SymVar_0, 92`) and the `+ y` (`%6 = add nsw i32 %5, %1`).
Instructions between are just dealing with the sign. To summarize this example, we fully devirtualized the `secret`
function using the `attack_vmp.py` script and then we fully broke the MBA using LLVM optimizations.
## Exemple 3 : Plus d'un bloc de base
Nous avons obtenu de très bons résultats si la fonction `secret` ne contient qu'un seul bloc de base, quelle que soit sa taille. Donc à ce stade, nous sommes capables de désvirtualiser un chemin. Pour reconstruire le comportement de toute la fonction, nous devons désvirtualiser successivement les chemins accessibles. Pour ce faire, nous devons effectuer une couverture de chemins sur les branches dépendantes de l'utilisateur. À la fin, nous obtenons un arbre de chemins qui représente les différents chemins de la fonction d'origine. L'arbre de chemins est obtenu en introduisant une construction if-then-else à partir de deux traces T1 et T2 avec un même préfixe suivi d'une condition C dans T1 et d'une non(C) dans T2. Une fois l'arbre de chemins construit, nous pouvons laisser LLVM générer un CFG.
<p align="center">
<img src="https://assets.kitploit.com/production/public/readmes/8096/fc5d1bf9a1544b9b44ef164247f3fc38d04bf45d7c2d7a3c0b21ea3d6636cd39.png">
</p>
Avec la protection logicielle Tigress, les sauts virtuels étaient implémentés avec de vraies instructions `jcc`, ce qui nous a permis d'identifier rapidement la condition de saut. Cependant, les choses se compliquent lorsque des sauts virtuels sont impliqués avec VMProtect, car celui-ci n'utilise pas d'instructions `jcc` pour sauter vers un autre bloc virtuel. Nous avons dû définir des marqueurs sur une trace dynamique pour repérer la condition impliquée dans une branche dépendante de l'utilisateur. C'est la partie expérimentale de cette attaque, car les marqueurs ne sont pas vraiment précis, mais ils ont fonctionné pour nos échantillons.
Bon, considérons l'exemple suivant :```cpp
int secret(int x, int y) {
VMProtectBegin("secret");
int r = 0;
if (x + y == 1001)
r = x + 1;
else
r = y - 1;
VMProtectEnd();
return r;
}
Comme avec les premiers exemples, nous devons générer et analyser la trace.``` $./pin/pin -t ./pin/source/tools/VMP_Trace/obj-intel64/VMP_Trace.so -start 4198848 -end 4198928 -- ./vmp_binaries/binaries/sample5.vmp.bin 1 2 &> ./vmp_traces/sample5.vmp.trace.1
$ ./attack_vmp.py --trace1 ./vmp_traces/sample5.vmp.trace.1 --symsize 4 [+] Replaying the VMP trace [+] Symbolize inputs [+] A potential symbolic jump found of AF flag: 0x80d905: cmp r11b, dl - Model: {0: x:32 = 0x0, 1: y:32 = 0x3e9} [+] Instruction executed: 16164 [+] Emulation done [+] Return value: 0x4 [+] Devirt expr: (bvnot (bvadd (bvand (bvnot y) (bvnot y)) (_ bv1 32))) [+] Synth expr: (bvadd y (_ bv4294967295 32))
[+] LLVM IR ==============================
; ModuleID = 'tritonModule' source_filename = "tritonModule"
define i32 @__triton(i32 %SymVar_1) { entry: %0 = add i32 %SymVar_1, -1 ret i32 %0 }
[+] EOF LLVM IR ==============================
Le script nous indique qu'il pourrait y avoir un saut symbolique potentiel trouvé sur le drapeau `AF` à l'adresse `0x80d905`.
Il fournit également un nouveau modèle (utilisant l'exécution symbolique) qui devrait prendre l'autre chemin. Générons donc une
seconde trace à l'aide de ce modèle (si vous examinez le modèle, il est correct par rapport à notre code source).```
$ ./pin/pin -t ./pin/source/tools/VMP_Trace/obj-intel64/VMP_Trace.so -start 4198848 -end 4198928 -- ./vmp_binaries/binaries/sample5.vmp.bin 0 1001 &> ./vmp_traces/sample5.vmp.trace.2
Une fois la deuxième trace générée, nous devons fournir ces deux traces au script attack_vmp.py afin qu'il
puisse les fusionner et créer un arbre de chemins. Nous avons des options supplémentaires pour définir où se trouve la condition
et sur quel drapeau (drapeau AF à 0x80d905).```
$ ./attack_vmp.py --trace1 ./vmp_traces/sample5.vmp.trace.1 --symsize 4 --trace2 ././vmp_traces/sample5.vmp.trace.2 --vbraddr 0x80d905 --vbrflag af
[+] Replaying the VMP trace
[+] Symbolize inputs
[+] A potential symbolic jump found of AF flag: 0x80d905: cmp r11b, dl - Model: {0: x:32 = 0x0, 1: y:32 = 0x3e9}
[+] Instruction executed: 16164
[+] Emulation done
[+] A second trace has been provided
[+] Replaying the VMP trace
[+] Symbolize inputs
[+] Instruction executed: 15758
[+] Emulation done
[+] Merging expressions from trace1 and trace2
[+] Return value: 0x3e9
[+] Devirt expr: In: (ite (= (ite (= (_ bv16 8) (bvand (_ bv16 8) (bvxor (bvsub (_ bv80 8) ((_ extract 7 0) (bvadd (bvlsh ...
[+] Synth expr: In: (ite (= (ite (= (_ bv16 8) (bvand (_ bv16 8) (bvxor (bvsub (_ bv80 8) ((_ extract 7 0) (bvadd (bvlsh ...
[+] LLVM IR ==============================
; ModuleID = 'tritonModule' source_filename = "tritonModule"
define i32 @__triton(i32 %SymVar_0, i32 %SymVar_1) { entry: %0 = add i32 %SymVar_1, -1 %1 = add i32 %SymVar_0, 1 %2 = add i32 %SymVar_1, %SymVar_0 %3 = xor i32 %2, -1 %4 = xor i32 %2, -1 %5 = and i32 %4, %3 %6 = xor i32 %5, 1001 %7 = add i32 %5, 1001 %8 = xor i32 %5, 1001 %9 = xor i32 %8, %7 %10 = and i32 %9, %6 [... skip ...] %469 = add i64 %468, 140737488347280 %470 = trunc i64 %469 to i8 %471 = xor i8 80, %470 %472 = sub i8 80, %470 %473 = xor i8 %472, %471 %474 = and i8 16, %473 %475 = icmp eq i8 16, %474 %476 = select i1 %475, i1 true, i1 false %477 = icmp eq i1 %476, false %478 = select i1 %477, i32 %1, i32 %0 ret i32 %478 }
[+] EOF LLVM IR ==============================
À cette étape, nous avons dévirtualisé les deux traces et les avons fusionnées en expressions `if-then-else`.
Après avoir élevé l'expression en LLVM-IR, nous obtenons un CFG avec seulement 480 instructions LLVM, ce qui
constitue déjà un bon gain par rapport aux milliers d'instructions exécutées par la machine virtuelle.
Mais nous pouvons faire mieux si nous utilisons les optimisations LLVM :```llvm
$ opt -S -O3 ./devirt/sample5.ll
; ModuleID = './devirt/sample5.ll'
source_filename = "tritonModule"
; Function Attrs: mustprogress nofree norecurse nosync nounwind readnone willreturn
define i32 @__triton(i32 %SymVar_0, i32 %SymVar_1) local_unnamed_addr #0 {
entry:
%0 = add i32 %SymVar_0, 1
%1 = add i32 %SymVar_1, -1
%2 = add i32 %SymVar_1, %SymVar_0
%.not = icmp eq i32 %2, 1001
%3 = select i1 %.not, i32 %0, i32 %1
ret i32 %3
}
attributes #0 = { mustprogress nofree norecurse nosync nounwind readnone willreturn }
Woot, nous avons récupéré le comportement original de la fonction secret !
Bien que l'approche ait montré de très bons résultats pour les fonctions contenant un seul chemin, la principale limitation de la méthode est qu'elle est surtout adaptée aux programmes avec un petit nombre de chemins en raison de la façon dont VMProtect effectue les sauts virtuels. En cas de nombre de chemins trop élevé, des parties du code d'origine peuvent être perdues, ce qui donne une récupération incomplète. Notez que nous considérons les chemins exécutables plutôt que les chemins syntaxiques dans le CFG. Les fonctions de hachage et autres fonctions cryptographiques n'ont souvent que très peu de chemins — un seul chemin dans le cas des implémentations résistantes aux attaques temporelles.
Notre implémentation actuelle est également limitée aux programmes sans accès mémoire dépendant de l'utilisateur. Cette limitation peut être en partie levée en utilisant une gestion plus symbolique des accès mémoire dans DSE.
Notez également que bien que les boucles bornées et les appels de fonction non récursifs soient pris en charge, ils sont actuellement récupérés sous forme de code inliné ou déroulé, ce qui peut provoquer une augmentation potentiellement importante de la taille du code dévirtualisé. Il serait intéressant d'avoir une étape de post-traitement essayant de reconstruire ces abstractions de haut niveau.
Pour conclure, veuillez noter que je ne cherche pas à fournir une quelconque méthode magique, ce ne sont que quelques notes sur une attaque dynamique contre des cas très spécifiques protégés par VMProtect =).
Si vous souhaitez approfondir, consultez ces ressources :
Enfin, et non des moindres, un merci spécial à mon pote @0vercl0k pour la relecture et les modifications 🚀
[00] https://www.usenix.org/legacy/event/woot09/tech/full_papers/rolles.pdf [01] https://secret.club/2021/09/08/vmprotect-llvm-lifting-1.html [02] https://secret.club/2021/09/08/vmprotect-llvm-lifting-2.html [03] https://secret.club/2021/09/08/vmprotect-llvm-lifting-3.html [04] https://back.engineering/17/05/2021/ [05] https://back.engineering/21/06/2021/ [06] https://www.mitchellzakocs.com/blog/vmprotect3 [07] https://github.com/can1357/NoVmp [08] https://github.com/archercreat/vmpfix [09] https://github.com/void-stack/VMUnprotect [10] https://github.com/JonathanSalwan/Triton/blob/master/publications/DIMVA2018-slide-deobfuscation-salwan-bardin-potet.pdf [11] https://whereisr0da.github.io/blog/posts/2021-02-16-vmp-3/ [12] https://github.com/pgarba/UniTaint [13] https://github.com/mrexodia/VMProtectTest