Cracking BattlEye packet encryption

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 BEClient_x64.dll called 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.

Luckily for us, fellow Secret Club member can1357 made a tool that completely breaks this protection, which you can find at VTIL.

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;
	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 [rsp+0x58].

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);

Note that rsi is rcx, and 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.