
Играемся с защитой ПО VMProtect. Автоматическая деобфускация чистых функций с помощью символьного выполнения и LLVM.
Экспериментальный динамический подход к девиртуализации чистых функций, защищённых VMProtect 3.x
Делюсь заметками о динамическом подходе к девиртуализации чистых функций, защищённых VMProtect. Этот подход показал очень хорошие результаты, если виртуализированная функция содержит только один базовый блок (независимо от его размера). Это распространённый сценарий, когда бинарные файлы защищают арифметические операции. Однако этот подход несколько более экспериментален, когда целевая функция содержит более одного базового блока. Тем не менее нам удалось девиртуализировать и восстановить двоичный код из образцов, содержащих 2 базовых блока, что позволяет предположить, что небольшие функции можно полностью девиртуализировать динамически.
VMProtect — это средство защиты ПО, которое защищает код, прогоняя его через виртуальную машину с нестандартной архитектурой. Эта защита — отличная площадка для любителей asm [0, 1, 2, 3, 4, 5, 6, 11]. Кроме того, уже существует множество инструментов, атакующих эту защиту [7, 8, 9, 12, 13]. В 2016 году мы обратили внимание на решение для защиты ПО Tigress и смогли обойти его виртуализацию с помощью символьного выполнения и LLVM. Этот подход был представлен на DIMVA 2018 [10], и я хотел проверить его на VMProtect. Обратите внимание, что не существует волшебного решения, работающего с любыми бинарными файлами; всегда есть компромиссы в зависимости от цели и ваших задач. Этот скромный вклад призван продемонстрировать пример динамической атаки на чистые функции, виртуализированные VMProtect. Главное преимущество динамической атаки в том, что она по своей природе обходит некоторые статические защиты VMProtect, такие как самоизменяющийся код, шифрование ключей и операндов и т. д.
Под чистой функцией мы понимаем функцию с конечным числом путей, не имеющую побочных эффектов. Входов может быть несколько, но выход всегда один. Ниже приведён пример чистой функции:```cpp int secret(int x, int y) { int r = x ^ y; return r; }
# Подход
Мы опираемся на ключевую интуицию, что обфусцированная трасса T' (из обфусцированного кода P') объединяет исходные инструкции из исходного кода P (трассу T, соответствующую T' в исходном коде) и инструкции виртуальной машины VM, так что T' = T + VM(T). Если нам удастся различить эти две подпоследовательности инструкций, T и VM(T), мы сможем восстановить один путь исходной программы P из трассы T'. Повторяя эту операцию для покрытия всех путей виртуализированной программы, мы сможем восстановить исходную программу P. В нашем практическом примере исходный код имеет конечное число исполняемых путей, что характерно для многих ситуаций, связанных с защитой интеллектуальной собственности. Для этого мы выполняем следующие шаги:
1. Определить виртуализированную функцию и её аргументы
2. Сгенерировать трассу VMProtect целевой программы
3. Воспроизвести трассу VMP и построить символьные выражения для получения зависимости между входными данными и выходным значением
4. Применить оптимизации к символьным выражениям, чтобы по возможности избежать инструкций VM
5. Поднять наше символьное представление до LLVM-IR, чтобы создать новую незащищённую версию целевой программы
## Пример 1: Простая побитовая операция
В качестве первого примера возьмём следующую функцию: она принимает два входных значения и возвращает `x ^ y`, которая защищена VMProtect.```cpp
int secret(int x, int y) {
VMProtectBegin("secret");
int r = x ^ y;
VMProtectEnd();
return r;
}
Мы начинаем с определения того, где функции используют VMProtect и сколько у них аргументов. Для нашего примера у нас может быть что-то вроде следующего:
Уже просто читая код, мы знаем, что функция начинается по адресу 0x4011c0, имеет два 32-битных аргумента (edi и esi)
и возвращается по адресу 0x4011ef. Это всё, что нам нужно от реверс-инжиниринга. Дальнейшие шаги будут автоматическими. Теперь мы
должны сгенерировать трассировку выполнения этой виртуализированной функции. Для этого мы используем Pintool.
Ему нужны только начальный (start) и конечный (end) адреса (для нашего примера, 0x4011c0 и 0x4011ef), которые задают диапазон
инструментирования. Обратите внимание, что для этой задачи подойдёт любой DBI или эмулятор.```
$ ./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
Вы можете увидеть результат [здесь](https://github.com/jonathansalwan/vmprotect-devirtualization/blob/HEAD/vmp_traces/sample2.vmp.trace). Формат трассировки использует три вида операций: `mr`, `r` и `i`.
`mr` — это чтение из памяти, выполненное инструкцией `i`, а `r` — регистры CPU. Например:```
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
У нас есть чтение памяти, которое загружает 8-байтовую константу 0x227db4f8 из адреса 0x7ffda459d718.
Инструкция выполняется по адресу 0x89173e, и её 8-байтовый опкод — 488BB42490000000, что является
mov rsi, qword ptr [rsp + 0x90].
Состояние регистров перед выполнением следующее:```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
После того как трассировка VMP была создана, мы воспроизводим её с помощью скрипта [attack_vmp.py](https://github.com/jonathansalwan/vmprotect-devirtualization/blob/HEAD/attack_vmp.py). Этот скрипт использует
[Triton](https://github.com/jonathansalwan/Triton) для построения предиката пути трассировки. Обратите внимание, что все выражения, которые
включают символьные переменные (входные данные функции), остаются символьными, тогда как все выражения, не связанные с входными данными, конкретизируются. Другими
словами, наши символьные выражения не содержат ни одной операции, относящейся к виртуальной машине (сам механизм
не зависит от пользователя), а только операции, относящиеся к исходной программе.
Например, ниже показан пример конкретизации. Слева у нас есть AST, содержащий подвыражения, которые не включают
символьную переменную (`1 + 2` и `6 ^ 3`). Поэтому эти ветви конкретизируются и заменяются константами `3` и `5`, что приводит к
AST справа. **Именно так мы девиртуализируем код.**
<p align="center">
<img src="https://assets.kitploit.com/production/public/readmes/8096/857ed50cfe9cb2347f816ece1d8dc4c13174c971dcb65ebc5621be14269a5ca8.png">
</p>
**Примечание об обратном слайсировании на уровне формул**: Как это принято в символьном исполнении, символьное представление сначала
вычисляется в прямом направлении вдоль пути, затем все логические операции и определения, которые не влияют ни на конечный результат,
ни на пройденный путь, удаляются из символьного выражения (слайсирование формул, также известное как обрезка формул). Это приводит к тому, что
для формулы выполняется эквивалент анализа кода с обратным слайсированием от выхода программы. Таким образом, при возврате из
функции `secret` мы получаем выражение связи между входными данными и выходом без инструкций VMProtect.
Скрипт `./attack_vmp.py` принимает в качестве параметров файл трассировки и размер символьных переменных. Напомним, это были `edi` и
`esi`, то есть они имеют длину 4 байта. Результат работы скрипта выглядит следующим образом:```
$ ./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 ==============================
Как видно, девиртуализированное выражение, возвращаемое функцией secret, довольно лаконично и не содержит инструкций из
виртуальной машины.```smt
(bvor
(bvnot (bvor
(bvnot (bvnot x))
(bvnot y)
)
)
(bvnot (bvor
(bvnot x)
(bvnot (bvand
(bvnot y)
(bvnot y)
)
)
)
)
)
Однако нам не удалось восстановить исходное выражение, которое представляло собой простую операцию `XOR`. Похоже, что `XOR` был
преобразован в побитовые операции. К счастью, недавно мы выпустили новые возможности в проекте Triton:
[синтезатор](https://github.com/JonathanSalwan/Triton/issues/1074) и лифтер для
[LLVM-IR](https://github.com/JonathanSalwan/Triton/issues/1078). Таким образом, мы можем синтезировать выражение, которое даёт нам
`(bvxor x y)`. Это хороший результат, и теперь мы можем пойти дальше, преобразовав это выражение в LLVM-IR, а затем скомпилировать новый девиртуализированный
двоичный код.
## Пример 2: Защищённая MBA-операция
Хорошо, теперь давайте рассмотрим другой пример, в котором предпринимается попытка скрыть MBA-операцию. Исходный код выглядит следующим образом:```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;
}
Как и в первом примере, нам нужно определить, где эта функция начинается и заканчивается, и сгенерировать 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
Как только будет сгенерирована [трассировка VMP](https://github.com/jonathansalwan/vmprotect-devirtualization/blob/HEAD/vmp_traces/sample3.vmp.trace), запустим скрипт `./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 ==============================
Результат довольно интересен по нескольким причинам. Во-первых, нам успешно удалось максимально избежать инструкций виртуальной машины, поскольку мы перешли от 25085 исполняемых инструкций к 25 инструкциям LLVM. Однако нам не удалось получить хорошую синтезированную версию выходных данных (да, я знаю, мы идём дальше, чем просто девиртуализация). Преимущество подъёма наших символьных выражений до LLVM-IR заключается в том, что мы можем в полной мере воспользоваться оптимизационным конвейером LLVM. Давайте сделаем это:```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 }
Используя оптимизации LLVM, мы смогли убрать шум из нашего девиртуализированного вывода и таким образом взломать MBA.
Мы видим операцию `XOR` с её константой (`%0 = xor i8 %SymVar_0, 92`) и `+ y` (`%6 = add nsw i32 %5, %1`).
Инструкции между ними просто обрабатывают знак. Если подвести итог этого примера, мы полностью девиртуализировали функцию `secret`
с помощью скрипта `attack_vmp.py`, а затем полностью взломали MBA с помощью оптимизаций LLVM.
## Пример 3: Более одного базового блока
Мы получили очень хорошие результаты, если функция `secret` содержит только один базовый блок, независимо от его размера. Итак, на данный момент
мы способны девиртуализировать один путь. Чтобы восстановить поведение всей функции, мы должны последовательно девиртуализировать
достижимые пути. Для этого необходимо выполнить покрытие путей на ветвлениях, зависящих от пользовательского ввода. В итоге мы получаем
дерево путей, которое представляет различные пути исходной функции. Дерево путей получается путём введения
конструкции if-then-else из двух трасс T1 и T2 с одинаковым префиксом, за которым следует условие C в T1 и not(C)
в T2. Как только дерево путей построено, мы можем позволить LLVM сгенерировать CFG.
<p align="center">
<img src="https://assets.kitploit.com/production/public/readmes/8096/fc5d1bf9a1544b9b44ef164247f3fc38d04bf45d7c2d7a3c0b21ea3d6636cd39.png">
</p>
В случае защиты Tigress виртуальные переходы были реализованы с помощью настоящих инструкций `jcc`, что позволило
нам быстро идентифицировать условие перехода. Однако всё становится сложнее, когда в VMProtect используются
виртуальные переходы, поскольку он не использует инструкции `jcc` для перехода к другому виртуальному блоку. Нам пришлось определять
маркеры в динамической трассе, чтобы выявить условие, участвующее в ветвлении, зависящем от пользовательского ввода. Это экспериментальная часть
данной атаки, так как маркеры не совсем точны, но сработали для наших образцов.
Хорошо, давайте рассмотрим следующий пример:```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;
}
Как и в первых примерах, нам нужно сгенерировать и проанализировать трассировку.``` $./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 ==============================
Скрипт сообщает нам, что может существовать потенциальный символический переход по флагу `AF` по адресу `0x80d905`.
Он также предоставляет новую модель (использующую символическое выполнение), которая должна пойти по другому пути. Итак, давайте сгенерируем
вторую трассировку с помощью этой модели (если вы посмотрите на модель, она корректна относительно нашего исходного кода).```
$ ./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
Once the second trace is generated, we have to provide those two traces to the attack_vmp.py script so that it
can merge them and create a path tree. We have extra options to define where the condition is located
and on what flag (AF flag at 0x80d905).
После того как второй трейс сгенерирован, мы должны передать эти два трейса скрипту attack_vmp.py, чтобы он мог объединить их и создать дерево путей. У нас есть дополнительные опции, позволяющие указать, где находится условие и по какому флагу (флаг 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 ==============================
На этом шаге мы девиртуализировали два трейса и объединили их в выражения `if-then-else`.
После подъёма выражения до LLVM-IR мы получаем CFG всего с 480 инструкциями LLVM, что
уже является хорошим выигрышем по сравнению с тысячами инструкций, выполняемых виртуальной машиной.
Но мы можем добиться большего, если используем оптимизации 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 }
Ух ты, мы восстановили исходное поведение функции secret!
Хотя подход показал очень хорошие результаты для функций, содержащих один путь, основным ограничением метода является то, что он в основном ориентирован на программы с небольшим числом путей из-за того, как VMProtect выполняет виртуальные переходы. В случае слишком большого числа путей части исходного кода могут быть потеряны, что приведёт к неполному восстановлению. Обратите внимание, что мы рассматриваем исполняемые пути, а не синтаксические пути в CFG. Хэш- и другие криптографические функции часто имеют лишь очень небольшое число путей — только один путь в случае реализаций, устойчивых к атакам по времени.
Также наша текущая реализация ограничена программами без какого-либо зависимого от пользователя доступа к памяти. Это ограничение может быть частично устранено путём использования более символической обработки обращений к памяти в DSE.
Обратите также внимание, что хотя циклы с ограничением и нерекурсивные вызовы функций обрабатываются, в настоящее время они восстанавливаются как инлайнированный или развёрнутый код, что может привести к потенциальному разрастанию размера девиртуализированного кода. Было бы интересно иметь этап постобработки, пытающийся восстановить эти высокоуровневые абстракции.
В заключение обратите внимание, что я не стремлюсь предложить какой-либо магический метод — это лишь некоторые заметки о динамической атаке на очень специфические случаи, защищённые VMProtect =).
Если вы хотите изучить тему глубже, ознакомьтесь с этими ресурсами:
И последнее, но не менее важное: особая благодарность моему товарищу @0vercl0k за вычитку и правки 🚀
[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