┌───────────────────────┐
▄▄▄▄▄ ▄▄▄▄▄ ▄▄▄▄▄ │
│ █ █ █ █ █ █ │
│ █ █ █ █ █▀▀▀▀ │
│ █ █ █ █ ▄ │
│ ▄▄▄▄▄ │
│ █ █ │
│ █ █ │
│ █▄▄▄█ │
│ ▄ ▄ │
│ █ █ │
│ █ █ │
│ █▄▄▄█ │
│ ▄▄▄▄▄ │
│ █ │
Overview of code virtualization │ █ │
~ patate < camille@patate.dev > └───────────────────█ ──┘
[ s/o uwu, zorm, jit, blank ]
- --[ Table of contents
0. A word from the author
1. Introduction
2. State of the art
2.1 Software Protection Techniques
2.1.1 Code obfuscation
2.1.2 Limitations of traditional obfuscation
2.2 Virtualization-based obfuscation
2.2.1 Concepts and architecture
2.2.2 Virtualization is the key
2.3 Existing solutions
3. Reverse engineering virtualization based protection, the usual way
3.1 Understanding the structure of the VM
3.2 Reversing the handlers
3.3 Writing a bytecode disassembler
3.4 Making sense of the bytecode
4. Other techniques
4.1 Defeating obfuscation using taint analysis
4.2 LLVM IR
5. Conclusion
6. Acknowledgements
7. References
8. Appendix
- --[ 0. A word from the author
Hello dear reader, this is my first submission to tmpout and any zines of the
sort! I tried my best to write a comprehensive overview of virtualization-
based code obfuscation, hope you'll like it! Most of what's in here isn't new
per se, it is the result of my hours of reading and I wanted to share what I
learned. If you're curious, I linked everything I reference at the end of the
paper.English isn't my native language so you might see some mistakes,
apologies. This paper is aimed at people with an already deep understanding
of C and assembly programming.
Don't hesitate to email me if you find a technical error or oversight in
here, I am nowhere near an absolute expert in the domain, but I'm always
learning.
- --[ 1. Introduction
Ever since the first line of commercial code was written, piracy has been the
elephant in the server room.
For us, the warez scene wasn't just about freebies; it was the only way to
get our hands on the latest AAA titles and overpriced dev tools.
Obviously, big-budget companies started sweating, desperately trying to find
new ways to keep their binaries from getting nuked by a crack within hours of
release.
At the same time, virus authors needed to find a way to make reverse
engineering harder to stay undetected for as long as possible.
I'm not going to "edge" you more, the answer was and is binary obfuscation!
- --[ 2. State of the art
- --[ 2.1 Software Protection Techniques
In this section we will see the traditional way we use code obfuscation to
protect software and the limitations of these techniques, it is crucial for
understanding more complex subjects like virtualization.
- --[ 2.1.1 Code obfuscation
The goal of obfuscation is to make code harder to read for people that try to
reverse engineer or crack it.
In this section I will demonstrate how code mutation, constant expansion and
control flow flattening work.
Code mutation is the action of taking a simple instruction and turning it
into multiple instructions that are semantically the same, but artificially
increase the complexity of the program.
Here is an example using the "add" assembly instruction :
+------------+
|add rcx, rax|
+------+-----+
|
|
| Code mutation happens...
/
|
v
+------------+
|push rax |
|not rax |
|sub rcx, rax|
|pop rax |
|sub rcx, 1 |
+------------+
We successfully made the code harder to understand without sacrificing its
original behaviour!
We can also make use of constant expansion which is a way to obfuscate
constant values in our code :
+-------------------+
|mov eax, 0xdeadbeef|
+---------+---------+
|
|
|
v
+-------------------+
|pushfg |
|mov eax, 0x1cbd0f9 |
|add eax, 0x12345678|
|shl eax, 0x00000001|
|xor eax, 0xf6adf00d|
|popfd |
+-------------------+
Perfect, now pattern matching for a specific value/address is way harder.
Great, we've made the code look like garbage but retain its original
behaviour, that's cool but we can go further, we can modify its control flow
to create additional complexity!
This example will be in C for ease of understanding.
+---------------------------------+
|#include <stdlib.h> |
|int main(int argc, char** argv) {|
| int a = atoi(argv[1]); |
| if(a == 0) |
| return 1; |
| else |
| return 10; |
| return 0; |
|} |
+----------------+----------------+
|
|
|
|
|
v
+---------------------------------+
|#include <stdlib.h> |
|int main(int argc, char** argv) {|
| int a = atoi(argv[1]); |
| int b = 0; |
| while(1) { |
| switch(b) { |
| case 0: |
| if(a == 0) |
| b = 1; |
| else |
| b = 2; |
| break; |
| case 1: |
| return 1; |
| case 2: |
| return 10; |
| default: |
| break; |
| } |
| } |
| return 0; |
|} |
+---------------------------------+
As we can see, all basic blocks are split and put into an infinite loop, and
the program flow is controlled by a switch and the variable b. (quote from
obfuscator-llvm control flow flattening wiki page, see the references
section). This is the usual way CFF is implemented and this has proven to be
quite effective.
- --[ 2.1.2 Limitations of traditional obfuscation
The obfuscation methods I showed in the previous section are by far the most
commonly used. They work reliably and are widely understood. They will slow
down reverse engineering attempts and probably discourage novice analysists
but they can be easily defeated (to a certain extent).
In my example, as in many engines, code mutation follows very strict rules.
These rules can be exploited by heuristic algorithms to recover the original
instructions. It can be as simple as pattern matching, then byte patching.
Any experienced analysist will be able to spot the patterns and write a
custom script to undo the protection.
We can also use this method to defeat constant expansion quite reliably.
However, many engines introduce randomness at this stage, so a more
comprehensive approach would be to use symbolic execution (outside of the
scope of this paper) to determine the result of the instruction block.
Control flow flattening on the other hand is harder to defeat and under the
right conditions "can render the determining of the precise control flow
NP-hard" (quote from "OBFUSCATING C++ PROGRAMS VIA CONTROL FLOW FLATTENING"
by T. Laszlo and A. Kiss). It can still be undone using heuristics, I'll
leave a few papers in the references section if you're curious but this is
besides the scope of this paper.
More generally, these techniques can be defeated using optimization
algorithms and some decompilers (IDA Pro mainly) already have that feature
baked in, so you might not even see that these protections are present.*
* it's a bit of an overstatement but it greatly diminishes the complexity of
the protected program.
- --[ 2.2 Virtualization-based obfuscation
To me, virtualization-based obfuscation is a very elegant and effective way
to solve these issues.
- --[ 2.2.1 Concepts and architecture
The idea behind this protection is to translate the opcodes of a function
from its original instruction set (here Intel x64) to a new and custom set.
If you followed what I just said you might be wondering how we're supposed
to execute this new code that isn't in any way compatible with our CPU. The
solution is to create a virtual machine. In its most simplistic form, it is
an interpreter for our custom opcodes.
Here is a high level example :
Function before
+------------+
|add rcx, rax|
+------------+
Function after
+--------+ +--------+
|VM Magic|<+ |load r1 |
+--------+ | |load r2 |
+---------> |add |
Loading & |store r1|
interpreting +--------+
custom opcodes
We translated the original instructions to a new bytecode and replaced the
function with an interpreter. This way the original code is no longer present
in the function.
The "VM Magic" part is actually more complex than it seems, here is a basic
implementation of it :
push offset VMBytecode
jmp VMEntry
|
v
+------------------+
|Pre-Initialization|
+--------+---------+
|
v
+--------------+
|Initialization|
+------+-------+
|
v
+-----+
|Fetch|<----+
+--+--+ |xx
| xxxx
v xxxx
+------+ xxxx
|Decode| xxx
+--+---+ xxx
| xx
v x
+----------+ xx
|Dispatcher| xx
+-------------+------+-----+----+------+--------------+ x
| | | | | xx
| | | | | x
v v v v v x
+----------+ +----------+ +----------+ +----------+ +-------+ xx
|Handler #1| |Handler #2| |Handler #3| |Handler #4| |VM Exit| x
+-----+----+ +-----+----+ +---+------+ +-----+----+ +---+---+ x
| | | | | x
| | | | | x
| | v | | x
| | +----+ | | x
+-------------+-------> |Next|<-----------+-----------+ xx
+----+ xxxx
xxx xxxxxxxx
xxxxxxxxxxxxxxxxxxxxxx
In the plan above, the pre-initialization and initialization phases are
responsible for allocating what we need to setup the VM context. Typically it
is a struct composed of :
- The VM stack
- The different VM registers
- The instruction pointer
- The internal flags of the VM
Next we save the original registers and flags of the program to re-apply them
after the VM ran, this way we avoid any unwanted new behaviour due to the VM
logic. Here is an example of this from the VM initialization of the
"guardian-rs" project (link in resources) :
; saving the registers in the VM ctx structure
mov [rax+10h], rax
mov [rax+18h], rcx
mov [rax+20h], rdx
mov [rax+28h], rbx
mov [rax+30h], rsp
mov [rax+38h], rbp
mov [rax+40h], rsi
mov [rax+48h], rdi
mov [rax+50h], r8
mov [rax+58h], r9
mov [rax+60h], r10
mov [rax+68h], r11
mov [rax+70h], r12
mov [rax+78h], r13
mov [rax+80h], r14
mov [rax+88h], r15
sub rsp, 10h
pop rcx ; pop flags into rcx
mov [rax+210h], rcx ; save flags into VM ctx struct
mov rcx, rax
call fxsave ; save xmm registers
Then we fetch the opcodes to execute (generally the address the offset pushed
before going into the VMEntry), we decode them (below is a table describing
how Intel x64 instructions are structured) :
+-----------------+------------------+
| Component | Size (Bytes) |
+-----------------+------------------+
| Legacy Prefixes | 0-4 |
| REX Prefix | 0-1 |
| Opcode | 1-3 |
| ModR/M | 0-1 |
| SIB | 0-1 |
| Displacement | 0, 1, 2, or 4 |
| Immediate | 0, 1, 2, 4, or 8 |
+-----------------+------------------+
The VM bytecode might be simpler, but this table is good for understanding why
we need this decoding phase.
After decoding we enter -in the simpler implementations- a big "switch case"
(the Dispatcher) that figures which function is responsible for handling the
current instruction. These functions are called "handlers".
Below are the Dispatcher and one Handler I used for my "pasm" interpreter
(see references):
const command_t *dispatcher(const command_t *commands, char *func)
{
if (func == NULL)
return NULL;
for (int index = 0; commands[index].fptr != NULL; index += 1) {
if (strcmp__(func, commands[index].command) == 0) {
return &commands[index];
}
}
return NULL;
}
void cmp_handler() {
if (!check_args(state->args, 1, 2)) {
state->last_cmp_code = CMP_ERROR;
return;
}
long long a1_ = get_value(state->args->arg1, state->args->arg1);
long long a2_ = get_value(state->args->arg2, state->args->arg2);
if (a1_ == a2_) state->last_cmp_code = CMP_EQUAL;
else if (a1_ > a2_) state->last_cmp_code = CMP_ABOVE;
else if (a2_ > a1_) state->last_cmp_code = CMP_BELOW;
return;
}
As you can see, nothing very special is happening here, this is all very
standard. Please note that this is a simplified example, a real world VM
will have more things going on.
Once we executed the instruction in our VM context, we fetch the next one and
do everything again. When the function is done executing, we copy the
original values for registers and flags we saved before and continue with the
execution of the program.
We successfully managed to replace entirely the original code of the function
with our own custom bytecode and added our VM on top. This greatly improved
the protection of the program.
- --[ 2.2.2 Virtualization is the key
Looking at what we did in the previous section we can easily affirm that we
managed to obfuscate the program to a whole new extent, if we were to do this
for every single function in our binary file using custom opcodes every time,
we would create a very strong protection.
VM-based code obfuscation is often use alongside standard code mutation and
obfuscation we saw in chapter 2.1.1, for the VM internal functions AND the
bytecode. This makes the analysis of the program tedious and time consuming.
- --[ 2.3 Existing solutions
Virtualization has been the norm for DRM software for quite a long time now,
and a few companies started selling this type of protection, mainly :
- - -The infamous- Denuvo
- - VMProtect
- - Themida
These are nowhere cheap but they use all the methods I described earlier
alongside anti-debug, anti-VM, JIT interpreters and self modifying code.
A proper implementation of this can be very interesting for a company wanting
to secure their software, Denuvo for example has not seen a proper crack
since 2023 (date of writing this is 2026!).
- --[ 3. Reverse engineering virtualization based protection, the usual way
Now that we have an understanding of this whole mess we can start by looking
at some actual programs. I will be using IDA Pro 9.3 on Linux for this.
You can find the source code of the program used for this example in
the appendix (see chapter 9).
- --[ 3.1 Understanding the structure of the VM
We start by loading our executable into IDA :
.text:0000000000401000 public start
.text:0000000000401000 start proc near
.text:0000000000401000
.text:0000000000401000 mov edi, 0Ah
.text:0000000000401005 call sub_401014
.text:000000000040100A mov rdi, rax ; error_code
.text:000000000040100D mov eax, 3Ch
.text:0000000000401012 syscall ; sys_exit
.text:0000000000401012 start endp
Great, our start function does nothing interesting, calls a function,
then exits.
sub_401014 takes one argument in edi, here 0xA (or 10 in base 10).
We can note that the exit code of the program is the exit code of
this function.
Following this trail we then find :
.text:0000000000401014 pushfq
.text:0000000000401015 push rax
.text:0000000000401016 push rcx
.text:0000000000401017 push rdx
.text:0000000000401018 push rbx
.text:0000000000401019 push rbp
.text:000000000040101A push rsi
.text:000000000040101B push rdi
.text:000000000040101C push r8
.text:000000000040101E push r9
.text:0000000000401020 push r10
.text:0000000000401022 push r11
.text:0000000000401024 push r12
.text:0000000000401026 push r13
.text:0000000000401028 push r14
.text:000000000040102A push r15
This is the program saving the registers and EFLAGS in the stack to restore
them later.
We saw something like this in 2.2.1, we are in "VM initialization" !
Continuing we find these 3 lines :
.text:000000000040102C mov ds:qword_402018, rdi
.text:0000000000401034 mov rax, offset unk_402000
.text:000000000040103E mov ds:qword_402008, rax
Saving the function's argument into qword_402018 (which i'll rename func_arg)
Copying the address of unk_402000 into rax. What is at unk_402000 anyway ?
.data:0000000000402000 unk_402000 db 1
.data:0000000000402001 db 2
.data:0000000000402002 db 5
.data:0000000000402003 db 3
.data:0000000000402004 db 3
.data:0000000000402005 db 0FFh
It looks like a blob of data, it's probably the pointer to the start of
our bytecode. I'll rename unk_402000 to "vm_bytecode".
Going back to 0x40103E, we move the address of the bytecode into
qword_402008. This could be our instruction pointer, I'll rename this symbol
to "vm_ip". Continuing.
.text:0000000000401046 mov rsi, ds:vm_ip
.text:000000000040104E xor rax, rax
.text:0000000000401051 mov al, [rsi]
.text:0000000000401053 inc rsi
.text:0000000000401056 mov ds:vm_ip, rsi
In order we :
- - copy the IP into rsi
- - set rax to 0
- - fetch 1 byte from rsi
- - increment rsi
- - copy rsi back into "vm_ip"
This looks like we're in the "Fetch" function, we fetch one byte of the
bytecode, then we update the IP to step over it.
The next part of the code confirms our hypothesis :
.text:000000000040105E cmp al, 1
.text:0000000000401060 jz short loc_401070
.text:0000000000401062 cmp al, 2
.text:0000000000401064 jz short loc_401082
.text:0000000000401066 cmp al, 3
.text:0000000000401068 jz short loc_4010AF
.text:000000000040106A cmp al, 0FFh
.text:000000000040106C jz short loc_4010E0
.text:000000000040106E ud2
The code compares the byte we fetched with the values 1-2-3-0xFF, jumps
somewhere if it matches and crashes if this byte doesn't correspond
to anything.
We are looking at our "Dispatch" function !
We can safely assume that the "loc_*" functions correspond to VM Handlers.
We have successfully indentified all the key elements of this VM, we can then
try to make sense of the handlers.
- --[ 3.2 Reversing the handlers
As we don't know in which order the handlers will be accessed, we will put a
breakpoint at the start of each of them then debug the program.
First, we're going to create a table with the handlers, their function and
the bytecode value needed to trigger them. This will become very useful in
chapter 3.3 :
+--------+------------+-----------+
| Opcode | Handler | Behaviour |
+--------+------------+-----------+
| 0x1 | loc_401070 | ? |
| 0x2 | loc_401082 | ? |
| 0x3 | loc_4010AF | ? |
| 0xff | loc_4010E0 | ? |
+--------+------------+-----------+
Good, we can now start our debugger.
We first break on loc_401070, let's examine its code :
.text:0000000000401070 mov rax, ds:func_arg
.text:0000000000401078 mov ds:qword_402010, rax
.text:0000000000401080 jmp short loc_401046
This handler takes the original function argument and copies it to
qword_402010 (renaming it to vm_arg). Let's update our table :
+--------+------------+--------------------+
| Opcode | Handler | Behaviour |
+--------+------------+--------------------+
| 0x1 | loc_401070 | loads an argument |
| 0x2 | loc_401082 | ? |
| 0x3 | loc_4010AF | ? |
| 0xff | loc_4010E0 | ? |
+--------+------------+--------------------+
Continuing the execution we then break on loc_401082 :
.text:0000000000401082 mov rsi, ds:vm_ip
.text:000000000040108A xor rbx, rbx
.text:000000000040108D mov bl, [rsi]
.text:000000000040108F inc rsi
.text:0000000000401092 mov ds:vm_ip, rsi
.text:000000000040109A mov rax, ds:vm_arg
.text:00000000004010A2 add rax, rbx
.text:00000000004010A5 mov ds:vm_arg, rax
.text:00000000004010AD jmp short loc_401046
There is a bit more code here. The function starts by loading the vm_ip
into rsi, then loading the next byte of the vm_bytecode (pointed at
by "rsi") into "bl". It then increments "rsi" and saves it back into vm_ip.
This part basically saves the next byte in the bytecode, then increments the
instruction pointer.
Then it loads the vm argument saved earlier in vm_arg by loc_401070 and adds
it to "rbx" (basically "bl" as it's the lower 8 bits of rbx).
It then saves back rax into vm_arg.
We can conclude that this handler adds add immediate to the vm argument.
Let's update our table :
+--------+------------+------------------------------+
| Opcode | Handler | Behaviour |
+--------+------------+------------------------------+
| 0x1 | loc_401070 | loads an argument |
| 0x2 | loc_401082 | adds an immediate to vm_arg |
| 0x3 | loc_4010AF | ? |
| 0xff | loc_4010E0 | ? |
+--------+------------+------------------------------+
Continuing the execution we break on loc_4010AF :
.text:00000000004010AF mov rsi, ds:vm_ip
.text:00000000004010B7 xor rbx, rbx
.text:00000000004010BA mov bl, [rsi]
.text:00000000004010BC inc rsi
.text:00000000004010BF mov ds:vm_ip, rsi
.text:00000000004010C7 mov rax, ds:vm_arg
.text:00000000004010CF imul rax, rbx
.text:00000000004010D3 mov ds:vm_arg, rax
.text:00000000004010DB jmp loc_401046
If you've been paying attention you've already detected that this is -almost-
the same code as our previour handler except that we now multiply vm_arg with
an immediate. Table update required :
+--------+------------+--------------------------------------+
| Opcode | Handler | Behaviour |
+--------+------------+--------------------------------------+
| 0x1 | loc_401070 | loads an argument |
| 0x2 | loc_401082 | adds an immediate to vm_arg |
| 0x3 | loc_4010AF | multiplies an immediate with vm_arg |
| 0xff | loc_4010E0 | ? |
+--------+------------+--------------------------------------+
Continuing the execution, we break on loc_4010E0 :
.text:00000000004010E0 mov rax, ds:vm_arg
.text:00000000004010E8 mov [rsp+70h], rax
.text:00000000004010ED pop r15
.text:00000000004010EF pop r14
.text:00000000004010F1 pop r13
.text:00000000004010F3 pop r12
.text:00000000004010F5 pop r11
.text:00000000004010F7 pop r10
.text:00000000004010F9 pop r9
.text:00000000004010FB pop r8
.text:00000000004010FD pop rdi
.text:00000000004010FE pop rsi
.text:00000000004010FF pop rbp
.text:0000000000401100 pop rbx
.text:0000000000401101 pop rdx
.text:0000000000401102 pop rcx
.text:0000000000401103 pop rax
.text:0000000000401104 popfq
.text:0000000000401105 retn
This is interesting, at the very end we see a "retn" instead of our usual
"jmp loc_401046" that jumps back to "Fetch". Maybe VM_EXIT ?
This function saves the vm_arg into rax then puts rax at rsp+0x70.
Then it proceeds to pop all the registers and ELFAGS.
It looks like a cleanup routine to restore the saved registers and flags
saved in "VM Initialization".
We can confirm this because rsp+0x70 is at (14 registers * 8 bytes) =
112 bytes (or 0x70), which corresponds to the saved rax on the stack.
This means that we exit the function and use vm_arg as a return value.
This is most probably VM_EXIT.
We have successfully reversed all the handlers for this function,
let's update our table :
+--------+----------+-------------------------------------------------+
| Opcode | Handler | Behaviour |
+--------+----------+-------------------------------------------------+
| 0x1 | LOAD_ARG | loads an argument |
| 0x2 | ADD_IMM | adds an immediate to vm_arg |
| 0x3 | MUL_IMM | multiplies an immediate with vm_arg |
| 0xff | VM_EXIT | exits the function with vm_arg as return value |
+--------+----------+-------------------------------------------------+
Great! We have a table with our opcodes, which handler is used to execute
them, the behaviour of said handler and the address of the vm_bytecode !
This will come very handy foooor....
- --[ 3.3 Writing a bytecode disassembler
..writing a bytecode disassembler ! Yeah ok you read the title..
I will be doing this in Python because it's easy but your "C Chad" co-worker
can probably do it in a few less CPU cycles.
First let's define our opcodes values, names and size :
OPCODES = {
0x01: ("LOAD_ARG", 0),
0x02: ("ADD_IMM", 1),
0x03: ("MUL_IMM", 1),
0xFF: ("VM_EXIT", 0),
}
The default size here is 1, the last number in the tuple is the "extra size"
(here 1 for ADD_IMM and MUL_IMM as we read a 1 byte number after the
instruction).
We then paste our extracted bytecode :
bytecode = [0x01, 0x02, 0x05, 0x03, 0x03, 0xFF]
We want to iterate over our bytecode and extract its name and extra size :
i = 0
while i < len(bytecode):
ins = bytecode[i]
if ins not in OPCODES:
print(f"0x{ins:02x} is not a valid opcode.")
break
name, extra_size = OPCODES[ins]
Then we check if the exression needs an extra byte, if so, we extract it,
print it and increment "i" accordingly :
if extra_size != 0:
if len(bytecode) <= (i + extra_size):
print(f"{i:04x}: {name} <missing imm>")
break
print(f"{i:04x}: {name} {bytecode[i + extra_size]:01x}")
i += 1 + extra_size
else:
print(f"{i:04x}: {name}")
i += 1
Aaaand, we're done, we have our disassembler, it was easy, wasn't it ?
The output is :
0000: LOAD_ARG
0001: ADD_IMM 5
0003: MUL_IMM 3
0005: VM_EXIT
- --[ 3.4 Making sense of the bytecode
Now that we have a readable bytecode, we can start to re-write this function
as it was before virtualization.
This example is very minimal so we can just do it by hand.
Looking at the disassembly we can safely assume that this function adds 5 to
the argument, then multiplies this total by 3, then returns the result.
In x64 assembly this would look like this :
mov rax, rdi ; load argument x
add rax, 5 ; x + 5
imul rax, 3 ; (x + 5) * 3
ret
And in C :
long vm_func(long x) {
return (x + 5) * 3;
}
Hell yeah.
We got back the original code of a virtualized function, using an unknown
VM in an unknown program.
I call this a success.
I'm proud of you, good girl ~
- --[ 4. Other techniques
In the previous section we saw how devirtualization was possible using
"standard" analysis and tools, but this is very time consuming and not
necessarily adequate. We need to up our game.
This section will focus more on theory than the one before, mainly because
this paper is starting to get quite long and also because i'm not skilled
enough to pull these types of tricks in real life lol.
This will be mostly based on the research paper "Symbolic deobfuscation: from
virtualized code back to the original" by Jonathan Salwan, Sebastien Bardin,
and Marie-Laure Potet (see references).
- --[ 4.1 Defeating obfuscation using taint analysis
Taint analysis is a technique where we mark user input (the "taint") and
track which instructions are influenced by it as it propagates through the
program. By doing this, we can isolate the instructions that actually matter
for the program's core logic.
In practice, we usually generate an execution trace of the program to analyze
this data flow. A great tool for this is the project "TheCodexRebirth"
by AntoineBlaud (see references).
Once we've identified the tainted path, we can deal with the untainted
instructions. Since these untainted instructions don't depend on user input,
their outputs are inherently static. We can evaluate them and replace them
with their concrete, static results using a compiler optimization technique
known as "Constant Folding".
After we've concretized those values, we can simply discard the leftover,
useless instructions using Dead Code Elimination (DCE). It works because we
are stripping away the artificial complexity the VM added, leaving only the
bare-bones logic (not true if the program interacts with the OS or anything
external that we don't control and from which we can get a non-static output,
this is a limitation).
Here is an example of taint analysis :
Before After
[ * ] [ * ]
/ \ / \
[+] [+] (3) [x]
/ \ / \ / \
(1) (2)[x] [^] [x] (5)
/ \
(6) (3)
First we made a list of all the instructions that had a direct impact on
the user input, so only the top multiplication, then the addition on the
right. Everything else is constant, so we can simplify!
After this we can have our result, which is way easier to read.
This is how, using taint analysis we can simplify obfuscated code. When
you apply this to a VM, reversing the handlers is no longer necessary, we
only need to know what's touching the user input directly, the rest we can
replace with the result as it's always the same!
- --[ 4.2 LLVM IR
Using the previous step (and other complicated things I omitted because
the goal here is to get an overview, not a PhD), we can reconstruct the
code we got into LLVM IR.
For those who don't know, LLVM IR (or Intermediate Representation,
or "bitcode") is a language used by LLVM (no shit) between the parsing
of the language and the compiling into assembly phase.
+-----------+ +-------+ +--------+
|Source code+----->|LLVM IR+------>|Assembly|
+-----------+ +-------+ +--------+
This means that if we get LLVM IR, we can recompile the code to any
architecture while still getting the optimizations of the LLVM compiler!
Usually we use "binary lifters" such as McSema or Remill for this job.
At this stage you've most certainly de-virtualized the whole program.
- --[ 5. Conclusion
If you made it this far you now have a basic understanding of
virtualization-based code obfuscation and how to analyze it.
This type of protection is currently being used more and more, this is
something any analyst should be familiar with.
My goal with this paper was to give you all the keys you need to go
experiment by yourself, of course things are missing, of course I could've
done a few things better, but at least you -hopefully- know a bit more
than when you started reading <3
- --[ 6. Acknowledgements
I want to thank "uwu" who has been my mentor for quite some time and that
sparked my interest in writing obfuscation software. You were very patient
even when I had stupid questions !
Special thanks to xss.is where I learned a lot about niche W*ndows
exploitation and malware engineering.
vx-underground & phrack for giving me access to tons of papers !
And of course to tmpout (thanks netspooky <3)
- --[ 7. References
[1] Valdemar Caroe.
Attacking virtualization-based obfuscation
https://github.com/67-6f-64/AntiOreans-CodeDevirtualizer/blob/main/
Masters%20Thesis.pdf
[2] myself.
Reverse engineering Guardian-rs's virtualization
https://patate.dev/pages/reversing_guardianrs1.html
[3] Jonathan Salwan, Sebastien Bardin, and Marie-Laure Potet.
Symbolic deobfuscation:
from virtualized code back to the original
http://sebastien.bardin.free.fr/2018-final-dimva.pdf
[4] Sebastien Bardin, Robin David, and Jean-Yves Marion.
Backward-Bounded DSE:
Targeting Infeasibility Questions on Obfuscated Codes
https://www.ieee-security.org/TC/SP2017/papers/220.pdf
[5] weak1337.
Alcatraz.
https://github.com/weak1337/Alcatraz
[6] obfuscator-llvm wiki
https://github.com/obfuscator-llvm/obfuscator/wiki/Control-Flow-
Flattening
[7] T. Laszlo and A. Kiss
OBFUSCATING C++ PROGRAMS VIA CONTROL FLOW FLATTENING
https://www.inf.u-szeged.hu/~akiss/pub/fulltext/
laszlo2009obfuscating.pdf
[8] Zerotistic
Breaking Control Flow Flattening: A Deep Technical Analysis
https://zerotistic.blog/posts/cff-remover/
[9] Andreas Klopsch
Attacking Emotet's Control Flow Flattening
https://www.sophos.com/en-us/blog/attacking-emotets-control-flow-
flattening
[10] Geri Revay
Don't flatten yourself: restoring malware with Control-Flow
Flattening obfuscation
https://www.virusbulletin.com/conference/vb2023/abstracts/dont-
flatten-yourself-deobfuscating-malware-control-flow-flattening/
[11] Branko Spasojevic
Using optimization algorithms for malware deobfuscation
http://sigurnost.zemris.fer.hr/ns/malware/2010_spasojevic/
Diplomski_Spasojevic.pdf
[12] meowette
guardian-rs
https://github.com/meowette/guardian-rs/
[13] myself.
pasm
https://git.patate.dev/patate/pasm
[14] AntoineBlaud
TheCodexRebirth
https://github.com/AntoineBlaud/TheCodexRebirth
- --[ 8. Appendix
Source code for the program used for the example in section 3.
Compile with:
nasm -f elf64 vm_demo.asm -o vm_demo.o
ld vm_demo.o -o vm_demo
code:
section .data
; the bytecode for out virtualized function
vm_bytecode:
db 0x01
db 0x02, 0x05
db 0x03, 0x03
db 0xFF
section .bss
; VM context structure
vip resq 1 ; Virtual Instruction Pointer
vr0 resq 1 ; Virtual Register 0
saved_rdi resq 1 ; function argument
section .text
global _start
_start:
mov rdi, 10 ; x = 10
call vm_entry ; vm_entry(x)
mov rdi, rax ; save return value of virtualized function
mov rax, 60 ; exit
syscall
vm_entry:
; save original registers and EFLAGS
pushfq
push rax
push rcx
push rdx
push rbx
push rbp
push rsi
push rdi
push r8
push r9
push r10
push r11
push r12
push r13
push r14
push r15
mov [saved_rdi], rdi ; save the function argument
mov rax, vm_bytecode
mov [vip], rax ; copy the address of the bytecode to our VIP
vm_loop:
mov rsi, [vip] ; load VIP into rsi
xor rax, rax
mov al, byte [rsi] ; fetch 1 byte opcode
inc rsi ; vip++ (we just read an opcode)
mov [vip], rsi ; save vip
; compare our opcode to the vm instructions to see what handler to call
cmp al, 0x01 ; LOAD_ARG
je handler_load_arg
cmp al, 0x02 ; ADD_IMM8
je handler_add_imm
cmp al, 0x03 ; MUL_IMM8
je handler_mul_imm
cmp al, 0xFF ; VM_EXIT
je handler_vm_exit
ud2 ; crash on unknown opcode
handler_load_arg:
mov rax, [saved_rdi] ; load function argument
mov [vr0], rax ; copy it to vr0
jmp vm_loop ; go back to "fetch"
handler_add_imm:
mov rsi, [vip] ; load vip
xor rbx, rbx
mov bl, byte [rsi] ; get 1 byte immediate
inc rsi ; increment vip (we just loaded 1 byte)
mov [vip], rsi ; update vip
mov rax, [vr0] ; load function argument
add rax, rbx ; add argument with immediate
mov [vr0], rax ; update function argument
jmp vm_loop ; go back to "fetch"
handler_mul_imm:
mov rsi, [vip] ; load vip
xor rbx, rbx
mov bl, byte [rsi] ; get 1 byte immediate
inc rsi ; increment vip (we just loaded 1 byte)
mov [vip], rsi ; update vip
mov rax, [vr0] ; load function argument
imul rax, rbx ; multiply argument with immediate
mov [vr0], rax ; update function argument
jmp vm_loop ; go back to "fetch"
handler_vm_exit:
mov rax, [vr0] ; save vm function register
; (function's return value)
mov [rsp + 112], rax ; we pushed all registers on the stack so
; rsp + 112 is rax on the stack, we then can
; pop it back into rax
pop r15
pop r14
pop r13
pop r12
pop r11
pop r10
pop r9
pop r8
pop rdi
pop rsi
pop rbp
pop rbx
pop rdx
pop rcx
pop rax ; pop vr0 into rax
popfq
ret
--[
PREV |
HOME |
NEXT ]--