Reversing EFT's player profile API

If you have been playing Escape From Tarkov around the time this post was published, you most likely know that there has been a wipe, and with it, a huge update. This update (0.14.0.0) brought a new, highly requested feature: the ability to view player profile statistics of enemies who have killed you.

screenshot

As someone who had been following the game for a while, I was surprised because BSG’s (studio behind EFT) stance was to not show any player statistics publicly. This spiked my interest since I wanted to know if it was finally possible to write a website that would be able to query profiles by name and display statistics about them.

Let’s start reversing

From the past, I knew that the game used quite a weird system of standard REST APIs to do everything from user authentication to matchmaking, and only after the player finds a server to play on would it switch to Unity networking. This meant that if I were to search for functions responsible for querying player information, they would be somewhere in the classes related to those APIs.

To find them, all that I had to do was search for PhpSessionId.

screenshot

After spending a bit more time searching through the derived and parent classes, I have found an interface that implements the function GetOtherPlayerProfile.

screenshot

This was the function I was searching for, but I expected it to take as input some after-raid data; however, apparently, all it needed was an account ID. This was great since it meant I could get any player’s information.

screenshot

In the past, those APIs were completely unprotected. All you needed to do was get the token the launcher gave you after authentication, and then send it in with your requests. The only caveat was that you needed to use zlib compression.

I extracted the URL from the assembly resources. The URL was:

https://prod.escapefromtarkov.com/client/profile/view

I started making requests but quickly noticed something was off. At first, I thought I was just messing up the zlib decompression, but then I realized the entire response was different every single time with the same input. Just compression would not do that.

screenshot

Then, I saw the headers returned by the server. They contained field x-encryption with the value aes.

screenshot

I knew I had to find the decryption function in the game. Since it was AES, I tried searching for common variable and function names related to it. After searching for quite a long time and getting help from a friend, searching for cipherText got me the function I was looking for.

screenshot

The function itself was just a basic wrapper around build-in .NET libraries, so I rewrote it.

screenshot

private static byte[] DecryptBytes(byte[] cipherText, int cipherBytesLength, byte[] key, byte[] iv)
{
    byte[] decryptedData = null;

    using (var aesAlg = Aes.Create())
    {
        aesAlg.Padding = PaddingMode.Zeros;
        aesAlg.Key = key;
        aesAlg.IV = iv;

        var decryptor = aesAlg.CreateDecryptor(aesAlg.Key, aesAlg.IV);

        using (var msDecrypt = new MemoryStream())
        {
            using (var csDecrypt = new CryptoStream(msDecrypt, decryptor, CryptoStreamMode.Write))
            {
                csDecrypt.Write(cipherText, 0, cipherBytesLength);
                csDecrypt.Close();
            }

            decryptedData = msDecrypt.ToArray();
        }
    }

    return decryptedData;
}

Well, okay, but where do I get the actual key and IV? After going through the xrefs, I found this function.

screenshot

At first, it may seem a bit horrific, but actually, it is pretty simple. It reads the input data, uses the first few bytes of the data as the actual IV, and then performs the decryption. In order for me to get everything working, I just needed to obtain the encryption key and get the IV length.

The encryption key is stored in the variable \uE2B8.\uE001 and is populated at runtime. There are many different ways to get it, but I decided to use the first one that came to mind, which was to create a new .NET Framework project, add Assembly-CSharp as a reference, and then, since the variable is private, get it using reflection.

private static void GetKey()
{
    Console.WriteLine("Extracting key...");
    var assembly = typeof(EFT.Player).Assembly;
    var type = assembly.GetType("\uE2B8", throwOnError: true);
    var field = type.GetField("\uE001", BindingFlags.NonPublic | BindingFlags.Static);
    var date = (byte[])field.GetValue(null);
    Console.WriteLine($"Key: {BitConverter.ToString(date)}");
}
Extracting key...
Key: 51-6F-2A-6E-70-37-2A-79-50-48-71-57-58-38-5A-42-33-5A-4F-40-6D-31-6B-34

And since I already had the code in place, I could also get the IV from \uE2B8.\uE002. I only needed the length since the key itself will be populated once the decryption happens.

private static void GetIV()
{
    Console.WriteLine("Extracting IV...");
    var assembly = typeof(EFT.Player).Assembly;
    var type = assembly.GetType("\uE2B8", throwOnError: true);
    var field = type.GetField("\uE002", BindingFlags.NonPublic | BindingFlags.Static);
    var instance = field.GetValue(null);
    var method = instance.GetType().GetMethod("Get", BindingFlags.Public | BindingFlags.Instance);
    var data = (byte[])method.Invoke(instance, null);
    Console.WriteLine($"IV length: {data.Length}");
}
Extracting IV...
IV length: 16

Now with those two resolved, all I had to do was reimplement the routine that would get the IV from the start of the response and decrypt it.

private static byte[] ProcessData(byte[] key, byte[] iv, byte[] data)
{
    var actualDataLength = data.Length - iv.Length;
    var actualData = ArrayPool<byte>.Shared.Rent(actualDataLength);

    Array.Copy(data, iv.Length, actualData, 0, actualDataLength);
    Array.Copy(data, iv, iv.Length);

    Console.WriteLine($"IV: {BitConverter.ToString(iv)}");

    var decryptedData = DecryptBytes(actualData, actualDataLength, key, iv);

    return decryptedData;

}

Once that was done, all I had to do was use the zlib decompression on the decrypted output.

var responseBytes = response.RawBytes;
var decrypted = ProcessData(key, iv, responseBytes);
var deflatedContent = SimpleZlib.Decompress(decrypted, null);
Console.WriteLine(deflatedContent);

And to my surprice, even without any non-default headers, which means without any token or session ID, I got a response.

screenshot

It seems like the developers forgot to protect this particular endpoint, which was great since, as I said in the beginning, I wanted to make a website out of it. There was one big problem, though: the request input was account ID, not nickname.

Since I already had the decryption working, I began searching for an endpoint that would accept a nickname and return an account ID. Soon enough, I found one.

screenshot

Unfortunately, this one required authentication, so my original idea to create a simple website where you would be able to look up players was doomed. If I supplied a valid session ID though, I was able to query account ID and profile information just fine.

screenshot

I won’t be publishing the full source code, as I feel it would only be used for malicious stuff. However, if you have your own account ID (which you can get on the website), you can check your own account using the proof of concept here.