Recently, Battlestate Games, the developers of Escape From Tarkov, hired BattlEye to implement encryption on networked packets so that cheaters can’t capture these packets, parse them and use them for their advantage in the form of radar cheats, or otherwise. Today we’ll go into detail about how we broke their encryption in a few hours.
Analysis of EFT
We started first by analyzing Escape From Tarkov itself. The game uses Unity Engine, which uses C#, an intermediate langauge, which means you can very easily view the source code behind the game by opening it in tools like ILDasm or dnSpy. Our tool of choice for this analysis was dnSpy.
Unity Engine, if not under the IL2CPP option, generates game files and places them under
GAME_NAME_Data\Managed, in this case it’s
EscapeFromTarkov_Data\Managed. This folder contains all the dependencies that the engine uses, including the file that contains the game’s code which is
Assembly-CSharp.dll, we loaded this file in dnSpy then searched for the string
encryption, which landed us here:
This segment is in a class called
EFT.ChannelCombined, which is the class that handles networking as you can tell by the arguments passed to it:
Right clicking on
channelCombined.bool_2, which is the variable they log as an indicator for whether encryption was enabled or not, then clicking Analyze, shows us that it’s referenced by 2 methods:
The second of which is the one we’re currently in, so by double clicking on the first one, it lands on this:
Voila! There’s our call into BEClient.EncryptPacket, when you click on that method it’ll take you to the BEClient class, which we can then dissect and find a method called
DecryptServerPacket, this method calls into a function in
pfnDecryptServerPacket that will decrypt the data into a user-allocated buffer and write the size of the decrypted buffer into a pointer supplied by the caller.
pfnDecryptServerPacket is not exported by BattlEye, nor is it calculated by EFT, it’s actually supplied by BattlEye’s initializer once called by the game. We managed to calculate the RVA (Relative Virtual Address) by loading BattlEye into a process of our own, and replicating how the game initializes it.
The code for this program is available here.
Analysis of BattlEye
As we’ve deduced from the last section, EFT calls into BattlEye to do all its cryptography needs. So now it’s a matter of reversing native code rather than IL, which is significantly harder.
BattlEye uses a protector called VMProtect, which virtualizes and mutates segments specified by the developer. To properly reverse a binary protected by this obfuscator, you’ll need to unpack it.
Unpacking is as simple as dumping the image at runtime; we did this by loading it into a local process then using Scylla to dump it’s memory to disk.
Opening this file in IDA, then going to the
DecryptServerPacket routine will lead us to a function that looks like this:
This is what’s called a
vmentry, which pushes a
vmkey on the stack then calls into a
vminit which is the handler for the virtual machine.
Here is the tricky part: the instructions in this function are only understandable by the program itself due to them being “virtualized” by VMProtect.
Figuring the algorithm
The file produced by VTIL reduced the function from 12195 instructions down to 265, which simplified the project massively. Some VMProtect routines were present in the disassembly, but these are easily recognized and can be ignored, the encryption begins from here:
Equivalent in pseudo-C:
uint32_t flag_check = *(uint32_t*)(image_base + 0x4f8ac); if (flag_check != 0x1b) goto 0x20e445; else goto 0x20e52b;
VTIL uses its own instruction set, I translated this to psuedo-C to simplify it further.
We analyze this routine by going into
0x20e445, which is a jump to
0x1a0a4a, at the very start of this function they move
sr12 which is a copy of
rcx (the first argument on the default x64 calling convention), and store it on the stack at
[rsp+0x68], and the xor key at
This routine then jumps to
0x1196fd, which is:
Equivalent in pseudo-C:
uint32_t xor_key_1 = *(uint32_t*)(packet_data + 3) ^ xor_key; (void(*)(uint8_t*, size_t, uint32_t))(0x3dccb7)(packet_data, packet_len, xor_key_1);
sr47 is a copy of
rdx. Since this is x64, they are calling
0x3dccb7 with arguments in this order: (rcx, rdx, r8). Lucky for us
vxcallq in VTIL means
call into function, pause virtual exectuion then return into virtual machine, so
0x3dccb7 is not a virtualized function!
Going into that function in IDA and pressing
F5 will bring up pseudo-code generated by the decompiler:
This code looks incomprehensible with some random inlined assembly that has no meaning at all. Once we nop these instructions out, change some var types, then hit F5 again the code starts to look much better:
This function decrypts the packet in 4-byte blocks non-contiguously starting from the 8th byte using a rolling XOR key.
Once we continue looking at the assembly we figure that it calls into another routine here:
Equivalent in x64 assembly:
mov t225, dword ptr [rsi+0x3] mov t231, byte ptr [rbx] add t231, 0xff ; uhoh, overflow ; the following is psuedo mov [$flags], t231 u< rbx:8 not t231 movsx t230, t231 mov [$flags+6], t230 == 0 mov [$flags+7], t230 < 0 movsx t234, rbx mov [$flags+11], t234 < 0 mov t236, t234 < 1 mov t235, [$flags+11] != t236 and [$flags+11], t235 mov rdx, sr46 ; sr46=rdx mov r9, r8 sbb eax, eax ; this will result in the CF (carry flag) being written to EAX mov r8, t225 mov t244, rax and t244, 0x11 ; the value of t244 will be determined by the sbb from above, it'll be either -1 or 0 shr r8, t244 ; if the value of this shift is a 0, that means nothing will happen to the data, otherwise it'll shift it to the right by 0x11 mov rcx, rsi mov [rsp+0x20], r9 mov [rsp+0x28], [rsp+0x68] call 0x3dce60
Before we continue dissecting the function it calls, we have to come to the conclusion that the shift is meaningless due to the carry flag not being set, resulting in a 0 return value from the
sbb instruction, which means we’re on the wrong path.
If we look for references to the first routine
0x1196fd, we’ll see that it’s actually referenced again, this time with a different key!
That means the first key was actually a red herring, and the second key is most likely the correct one. Nice one Bastian!
Now that we’ve figured out the real xor key and the arguments to
0x3dce60, which are in the order: (rcx, rdx, r8, r9, rsp+0x20, rsp+0x28).
We go to that function in IDA, hit F5 and it’s very readable:
We know the order of the arguments, their type and their meaning, the only thing left is to translate this to actual code, which we’ve done nicely and wrapped into a gist available here.
This encryption wasn’t the hardest to reverse engineer, and our efforts were certainly noticed by BattlEye; after 3 days, the encryption was changed to a TLS-like model, where RSA is used to securely exchange AES keys. This makes MITM without reading process memory by all intents and purposes infeasible.