-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA512 |=-------------------------------------------------------------------------=| |=------------------=[ Overview of code virtualization ]=------------------=| |=-------------------------------------------------------------------------=| |=--------------------=[ patate ]=--------------------=| |=-------------------------------------------------------------------------=| |=---------------------=[ 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 | |int main(int argc, char** argv) {| | int a = atoi(argv[1]); | | if(a == 0) | | return 1; | | else | | return 10; | | return 0; | |} | +----------------+----------------+ | | | | | v +---------------------------------+ |#include | |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 between 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 way 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. But 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 usefull 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} ") 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 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 -----BEGIN PGP SIGNATURE----- iHUEARYKAB0WIQSk825IekeiclrsTNChRti/82dCUAUCac1z7gAKCRChRti/82dC UG4kAP9z1tkkdbdslcsCdw02XzIuLvjHctRbTXYT1iLG04knZAD6AzQJQJ09nUFW gj0Y48afcuEJ1OIQl1qi7gKtgcE27Ag= =iEbL -----END PGP SIGNATURE-----