Deobfuscating Mutation of VMProtect.NET
This article introduces the mutation protection of VMProtect in .NET assembly protection and how to restore it using control flow analysis techniques.
Introduction
This is about VMProtect deobfuscation in .NET, not C++.
VMProtect v3.4 added support for .NET programs with features such as anti-debugging, anti-dumping, mutation, and virtualization. Virtualization is indeed strong and I couldn’t handle it, but I understood how it works after studying it carefully. Maybe by writing a tool to automatically rename it, it can be understood more clearly, but its strength may still not be as good as KoiVM. However, mutation is relatively easy to deal with, which is a kind of constant encryption related to control flow that can be described as control flow obfuscation.
Analysis
As the program protected by unregistered VMProtect can only run on local computer, I created a sample and then protected it with VMProtect using the “Mutation” compilation mode.
Using dnSpy to open the program, I found many loops that were generated by the obfuscator.
Upon careful observation, it can be seen that the obfuscation is relatively simple, and the variable “num” can be called Context, which updates itself once per instruction execution. Why did we choose a sample with a switch statement to protect? Because we needed to know whether when entering different basic blocks and then transferring to the same basic block, the context is the same (generally speaking, it is the same, as I cannot think of any exceptions…).
Perhaps this statement is not easy to understand. Let me debug it directly to explain it more clearly. Each case block in the sample corresponds to the different basic blocks mentioned just now, and they will all eventually transfer to the same basic block, which is the basic block starting with “Console.WriteLine(“输入完毕”)”. So we set a breakpoint on this basic block.
We input different numbers respectively to make the switch statement jump to different case blocks and see if the value of “num” is the same when it finally executes to “Console.WriteLine(“输入完毕”)”.
In this way, our speculation is correct. We have a clear understanding of the macrostructure of mutation and can proceed to the next analysis.
We need to think a simple solution to restore mutation, so we switch dnSpy’s decompilation mode back to IL to observe what the statements that update “num” look like.
The entry point of this method is a br jump, so we directly look at what the basic block that br jumps to looks like.
Since this basic block is the entry point of the entire method, it must also be the place where mutation is initialized and the entry point we emulate with the emulator. The details can be seen in my previous article .NET Control Flow Analysis (II) - Deobfuscation, which is similar to clearing the switch obfuscation of ConfuserEx and clearing VMProtect.NET’s mutation.
The first arrow points to the initialization of the context variable “num”.
1 | ldc.i4 1149763845 |
Later, code similar to the following will be used to update “num”:
1 | ldc.i4 1099382934 |
If there are constants in the code, their decryption is similar to this as well, as it was analyzed beforehand.
Deobfuscation
Although this type of obfuscation is simple, it can be difficult to clean up in practice. Why? Because you have to emulate the entire control flow and every possible branch condition to ensure that your decryption result is foolproof. Simulation can be very complicated and may cause an infinite loop. Another problem is how to handle the decrypted results during emulation.
Attempt
The following content is all about my attempts (only part of them), and I found the solution after various attempts.
My initial idea was to use feature matching:
1 | ldc.i4 |
When encountering this type of code, replace it directly with:
1 | nop |
In the end, it failed because the effect was not good. Some places did not have such features. For example, the obfuscated branch jump instruction before (it’s called obfuscated branch jump because the jump result is certain and the result is the same every time).
So I came up with a very opportunistic method, just replacing:
1 | ldloc |
为
1 | ldc.i4 |
This works for all situations.
After each emulation, I checked whether the emulated instruction read the variable “num”. If so, I replaced it directly with:
1 | ldc.i4 |
But I found a big problem with this. Because I cannot accurately identify the methods obfuscated by VMProtect.NET mutation. It is indeed easy to recognize by visual inspection of C# decompiled results, but identifying it by code is a big challenge. Sometimes misjudgment occurs, and non-obfuscated methods are identified as obfuscated. Then the result is emulated, replaced directly, and finally found that it should not be replaced, which is very troublesome. There are also other problems with replacing in place after decryption, which is not ideal overall.
Finally, I decided to use a collection to store the decryption results and can verify that each emulation result is the same. If it is different, it means that there is a problem with the code or VMProtect has a bug, which can enhance stability.
Implementing the Logic
In fact, I initially wrote the logic and specific implementation together because it was convenient to modify. It wasn’t until later when the tool was mostly stable without many bugs that I abstracted the logic and separated the underlying decryption operations.
Because I have already developed the tool, let me explain based on my source code first.
The abstract class has the following member list:
1 | namespace ControlFlow.Deobfuscation { |
If you have read my article on cleaning up switch obfuscation in ConfuserEx, you will notice that this abstract class and that switch cleanup are very similar. Both require all available entry points to be provided for emulation until completion, covering the entire method to achieve the decryption effect.
The most crucial logic lies in VisitAllBasicBlocks, which I have modified multiple times. All of the bugs I encountered were due to logical issues in this method.
Therefore, instead of providing code with bugs, I will directly provide the working code below, which includes comments that I wrote due to previous bugs.
1 | protected virtual void VisitAllBasicBlocks(BasicBlock basicBlock) { |
The variable blockInfo.IsEntered was originally named IsVisited and was changed by me to IsEntered. In order to prevent entering an infinite loop during emulation, it is necessary to prevent a basic block from being executed repeatedly, but this must be done carefully. Initially, I planned to skip emulating a basic block if it had already been emulated. It is obvious that this idea is flawed. For example, in the sample provided, the same basic block is reached under different conditions in different branches. If IsVisited is used to indicate that a basic block can only be emulated once, then it may not be possible to emulate every branch condition and may lead to incorrect judgment.
To illustrate, consider the following scenario:
1 | uint num = 0; |
Assume that the logic is such that each basic block can only be executed once. The case we emulate first is that the “if” statement is not executed, then the execution reaches
1 | Console.WriteLine(num); |
The value of num is determined and is 0.
Let’s emulate the execution of the if branch. Since the basic block for Console.WriteLine has already been executed, we will not execute it again.
Therefore, we obtain a decryption result where the value of num used in Console.WriteLine is 0, but this is not actually the case.
So, we should use IsEntered to indicate that if we are already in a basic block, we should not emulate that basic block again to prevent infinite loops like while(true). Before emulating a basic block, set its IsEntered to true, and after emulating the branching instruction of this basic block, set it to false. This will solve the problem perfectly.
The code for CallNextVisitAllBasicBlocksConditional is the same as the code for cleaning up the switch obfuscation in ConfuserEx (the code for CallNextVisitAllBasicBlocksConditional in the previous post had a bug, which you can see by comparing it carefully with the one I will provide below; note that my EmulationContext is a reference type).
1 | protected virtual void CallNextVisitAllBasicBlocksConditional(BasicBlock basicBlock) { |
Next, we implement all the abstract methods and some virtual methods. The code is directly pasted below.
1 | namespace ControlFlow.Deobfuscation.Specials.VMProtect { |
First of all, we need to find out which local variable is the context of Mutation, but in reality, I can only roughly judge whether it is or not. It is really difficult to determine without emulating it once. We write this in OnBegin because it is the initialization step. The specific implementation is given above.
The last line of OnBegin has an
1 | _emulator.Interceptor = Interceptor; |
This Interceptor is I newly added. This thing is like a hook.
1 | /// <summary> |
If you want to use it, just modify the source code of the emulator that I previously released.
Returning to the Interceptor implemented in the deobfuscator. In order to improve stability and speed, I filtered out many instructions because VMProtect.NET’s Mutation only requires four types of instructions: arithmetic, comparison, value retrieval, and branching.
For ldloc, the instruction that reads variables, special processing is required to prevent reading the values of irrelevant Mutation variables, to prevent the emulator from calculating incorrectly and causing problems with branch emulation.
VMProtect.NET’s Mutation encrypts constants and adds obfuscated branching code, so we need to restore everything after emulation ends. This part is implemented in OnEnd.
Overall, VMProtect.NET’s Mutation is okay, and you shouldn’t be scared by the results of C# decompilation because that is intentionally scaring you. How do you know if you can’t try it?
As for VMProtect.NET’s virtualization mode, I haven’t figured it out yet. I don’t know if someone researching devirtualization are willing to share their experience. I’m not very clear about this part and plan to study it.
Tool Download
Currently, the tool is very stable and has been tested many times without any bugs. Occasionally, it may prompt an invalid branch instruction, but there is no need to worry as it has no impact on the program’s execution.
Before and after cleanup comparison:
The results are still very impressive.
Deobfuscating Mutation of VMProtect.NET
https://wwh1004.com/en/deobfuscating-mutation-of-vmprotect_net/