Home Blog The Finals – Defeating Theia Anti-Tamper

The Finals – Defeating Theia Anti-Tamper

by reversingthread.info
Published: Last Updated on

The Finals

The Finals is a multiplayer first-person shooter game developed by Embark Studios. They were able to attract a lot of users from the beta to the launch, and it’s currently one of the most played games on steam.

They were successful in attracting a large number of users from the beta phase to the official launch, and currently, they are one of the most popular games being played on Steam.

Theia

Theia, created by ZeroItLab, is a tool designed to prevent tampering, debugging, and obfuscation in various games such as The Finals, EA FC 24, and the discontinued The Cycle Frontier game. It fills a void left by the acquisition of Byfron Company when Roblox purchased them. Theia offers similar features to Byfron, including anti-debugging, anti-analysis, obfuscation of game pages, and hardware ID tracking. In this post, our main focus will be on decrypting all the game pages.

Defeating their Encryption

Initially, it should be noted that the process at hand is highly time consuming. The individuals responsible for this task have effectively concealed, encrypted, and implemented measures to obstruct analysis of their system. In order to thoroughly examine it, we must overcome certain protective measures, granting us greater freedom to conduct our analysis.

1. Get the game running without EAC

To achieve greater freedom, we should eliminate EAC (Easy Anti Cheat), thereby allowing us to solely focus on handling user-mode protections and giving us control over the Kernel.

However, the developers of Theia are well aware of this and have not made it easy for us to prevent their Anti-Cheat from loading. They persistently communicate with the driver to confirm its loading status. If it is not loaded, they simply prevent the process from running. So, what we can do about it? Well, we create a fake EAC driver and reply to their requests as they want.

Initially, we need to intercept DeviceIoControl to monitor the exchanged data during communication with the EAC driver. Once we have gathered this information, we can construct our own EAC driver and respond accordingly.

The following is a compilation of IOCTL requests made to the EAC Driver. However, the specific names of these IOCTLs are currently unknown.

0x226003 - We return the following bytes [0x19, 0x04, 0x00, 0x00]

0x226013 - If that is the first request we return [0x04, 0x00, 0x00, 0x00] else we return [01 00 00 00]

0x22e017 - We can just return STATUS_SUCCESS

Knowing those IOCTL, our task becomes crafting the fake EAC driver. We should ensure that the driver employs the same device, event names, and effectively handles the IOCTL requests. Then compile and load the driver.

#define IOCTL_UNKNOWN_BASE                                              FILE_DEVICE_UNKNOWN
#define IOCTL_EAC_DEBUG_ECHO                                            CTL_CODE(IOCTL_UNKNOWN_BASE, 0x0800, METHOD_BUFFERED, FILE_ANY_ACCESS)
#define IOCTL_EAC_UNK0                                                    0x226003
#define IOCTL_EAC_UNK1                                                    0x226013
#define IOCTL_EAC_UNK2                                                    0x22e017

PDRIVER_OBJECT g_pDriverObject = nullptr;
PDEVICE_OBJECT g_pDeviceObject = nullptr;

WCHAR g_szDeviceName[260];
WCHAR g_szDeviceLnkName[260];

BOOLEAN    g_IsFirstCall = TRUE;
KSPIN_LOCK g_SpinLock;

HANDLE eventHandle1, eventHandle2, eventHandle3;

PVOID  sectionBaseAddress = NULL;
HANDLE sectionHandle = NULL;

NTSTATUS CreateNamedEvent(PCWSTR eventName, PHANDLE pEventHandle)
{
    UNICODE_STRING    uniName;
    OBJECT_ATTRIBUTES objAttr;
    NTSTATUS          status;

    RtlInitUnicodeString(&uniName, eventName);
    InitializeObjectAttributes(&objAttr, &uniName, OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE, NULL, NULL);

    status = ZwCreateEvent(pEventHandle, EVENT_ALL_ACCESS, &objAttr, SynchronizationEvent, FALSE);

    return status;
}

NTSTATUS CreateFakeEACSection(PHANDLE sectionHandlePtr, PLARGE_INTEGER maximumSize)
{
    UNICODE_STRING    sectionName;
    OBJECT_ATTRIBUTES objAttr;
    NTSTATUS          status;

    RtlInitUnicodeString(&sectionName, L"\\BaseNamedObjects\\EasyAntiCheat_EOSBin");
    InitializeObjectAttributes(&objAttr, &sectionName, OBJ_CASE_INSENSITIVE | OBJ_KERNEL_HANDLE, NULL, NULL);

    maximumSize->QuadPart = 4096; // Set the size of the section (e.g., 4 KB)

    status = ZwCreateSection(sectionHandlePtr, SECTION_ALL_ACCESS, &objAttr, maximumSize, PAGE_READWRITE, SEC_COMMIT,
        NULL);
    if (!NT_SUCCESS(status))
    {
        return status;
    }
    return status;
}


VOID DriverUnload(PDRIVER_OBJECT pDriverObj)
{
    NTSTATUS status = STATUS_SUCCESS;

    //close events
    if (eventHandle1 != NULL)
    {
        ZwClose(eventHandle1);
    }

    if (eventHandle2 != NULL)
    {
        ZwClose(eventHandle2);
    }

    if (eventHandle3 != NULL)
    {
        ZwClose(eventHandle3);
    }

    //remove section
    if (sectionBaseAddress != NULL)
        ZwUnmapViewOfSection(NtCurrentProcess(), sectionBaseAddress);
    if (sectionHandle != NULL)
        ZwClose(sectionHandle);


    //Delete Sym link n Device
    UNICODE_STRING uncLinkName;
    RtlInitUnicodeString(&uncLinkName, g_szDeviceLnkName);

    status = IoDeleteSymbolicLink(&uncLinkName);
    if (!NT_SUCCESS(status))
    {
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL,
            "[EasyAntiCheat_EOS] DriverUnload::Unable to delete SymbolicLink NTSTATUS -> % u \n", status);
    }

    IoDeleteDevice(pDriverObj->DeviceObject);
    DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "[EasyAntiCheat_EOS]Driver Unload\n");
}

NTSTATUS DispatchUnused(PDEVICE_OBJECT pDevObj, PIRP pIrp)
{
    UNREFERENCED_PARAMETER(pDevObj);
    UNREFERENCED_PARAMETER(pIrp);


    NTSTATUS NtStatus = STATUS_SUCCESS;
    return NtStatus;
}

NTSTATUS DispatchCreate(PDEVICE_OBJECT pDevObj, PIRP pIrp)
{
    UNREFERENCED_PARAMETER(pDevObj);

    DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "[EasyAntiCheat_EOS]Dispatch Create\n");

    pIrp->IoStatus.Status = STATUS_SUCCESS;
    pIrp->IoStatus.Information = 0;
    IoCompleteRequest(pIrp, IO_NO_INCREMENT);
    return STATUS_SUCCESS;
}

NTSTATUS DispatchClose(PDEVICE_OBJECT pDevObj, PIRP pIrp)
{
    UNREFERENCED_PARAMETER(pDevObj);

    DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "[EasyAntiCheat_EOS]Dispatch Close\n");

    pIrp->IoStatus.Status = STATUS_SUCCESS;
    pIrp->IoStatus.Information = 0;
    IoCompleteRequest(pIrp, IO_NO_INCREMENT);
    return STATUS_SUCCESS;
}

NTSTATUS DispatchIoctl(PDEVICE_OBJECT pDevObj, PIRP pIrp)
{
    //UNREFERENCED_PARAMETER(pDevObj);

    NTSTATUS           status = STATUS_INVALID_DEVICE_REQUEST;
    PIO_STACK_LOCATION pIrpStack;
    ULONG              uIoControlCode;
    PVOID              pIoBuffer;
    ULONG              uInSize;
    ULONG              uOutSize;

    pIrpStack = IoGetCurrentIrpStackLocation(pIrp);
    uIoControlCode = pIrpStack->Parameters.DeviceIoControl.IoControlCode;
    pIoBuffer = pIrp->AssociatedIrp.SystemBuffer;
    uInSize = pIrpStack->Parameters.DeviceIoControl.InputBufferLength;
    uOutSize = pIrpStack->Parameters.DeviceIoControl.OutputBufferLength;

    switch (uIoControlCode)
    {
    case IOCTL_EAC_DEBUG_ECHO:
    {
        __try
        {
            DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "[EasyAntiCheat_EOS]Hello from Ring0!\n");
            status = STATUS_SUCCESS;
        }
        __except (EXCEPTION_EXECUTE_HANDLER)
        {
            status = STATUS_UNSUCCESSFUL;
        }
        break;
    }
    case IOCTL_EAC_UNK0: //0x226003
    {
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL,
            "[EasyAntiCheat_EOS] TRIGGERED CHECK 0x226003 (InputBufferLength: %lu) (OutputBufferLength: %lu)\n",
            uInSize, uOutSize);

        unsigned char DummyReturnData[4] = { 0x19, 0x04, 0x00, 0x00 };

        PVOID pBuffer = pIrpStack->Parameters.DeviceIoControl.Type3InputBuffer;

        // Ensure pBuffer is valid and large enough
        if (pBuffer != nullptr && uOutSize >= sizeof(DummyReturnData))
        {
            RtlCopyMemory(pBuffer, &DummyReturnData[0], sizeof(DummyReturnData));
            uOutSize = sizeof(DummyReturnData);
            status = STATUS_SUCCESS;
            DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "[EasyAntiCheat_EOS] RETURNING [19 04 00 00] \n");

            KIRQL oldIrql;
            KeAcquireSpinLock(&g_SpinLock, &oldIrql);

            g_IsFirstCall = FALSE; // Toggle

            KeReleaseSpinLock(&g_SpinLock, oldIrql);
        }
        else
        {
            uOutSize = 0;
            status = STATUS_BUFFER_TOO_SMALL;
        }


        break;
    }
    case IOCTL_EAC_UNK1: //0x226013
    {
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "[EasyAntiCheat_EOS] TRIGGERED CHECK 0x226013 \n");

        unsigned char DummyReturnDataA[4] = {
            0x04, 0x00, 0x00, 0x00
        };

        unsigned char DummyReturnDataB[4] = {
            0x01, 0x00, 0x00, 0x00
        };


        PVOID pBuffer = pIrpStack->Parameters.DeviceIoControl.Type3InputBuffer;

        // Ensure pBuffer is valid and large enough
        if (pBuffer != nullptr && uOutSize >= sizeof(DummyReturnDataA))
        {
            KIRQL oldIrql;
            KeAcquireSpinLock(&g_SpinLock, &oldIrql);

            // Check the flag and toggle it
            if (g_IsFirstCall)
            {
                RtlCopyMemory(pBuffer, &DummyReturnDataA, sizeof(DummyReturnDataA));
                DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL,
                    "[EasyAntiCheat_EOS] RETURNING [04 00 00 00] FIRST CALL \n");
            }
            else
            {
                RtlCopyMemory(pBuffer, &DummyReturnDataB, sizeof(DummyReturnDataB));
                //g_IsFirstCall = TRUE; // Reset for the next call
                DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL,
                    "[EasyAntiCheat_EOS] RETURNING [01 00 00 00] SEC CALL \n");
            }

            KeReleaseSpinLock(&g_SpinLock, oldIrql);

            uOutSize = sizeof(DummyReturnDataA);
            status = STATUS_SUCCESS;
        }
        else
        {
            uOutSize = 0;
            status = STATUS_BUFFER_TOO_SMALL;
        }

        break;
    }
    case IOCTL_EAC_UNK2: //0x22e017
    {
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "[EasyAntiCheat_EOS] TRIGGERED CHECK 0x22e017\n");
        status = STATUS_SUCCESS;
        break;
    }
    default:
    {
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "[EasyAntiCheat_EOS] Triggered Unknown IOCTL: [%x] \n",
            uIoControlCode);
        break;
    }
    } //Switch End

    if (status == STATUS_SUCCESS)
        pIrp->IoStatus.Information = uOutSize;
    else
        pIrp->IoStatus.Information = 0;

    pIrp->IoStatus.Status = status;

    IoCompleteRequest(pIrp, IO_NO_INCREMENT);
    return status;
}

NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObj, PUNICODE_STRING RegistryPath)
{
    //UNREFERENCED_PARAMETER(pRegistryString);

    NTSTATUS       status = STATUS_SUCCESS;
    UNICODE_STRING uncDeviceName;
    UNICODE_STRING uncLinkName;

    //set global var
    g_pDriverObject = pDriverObj;


    //Check Unicode String
    status = RtlUnicodeStringValidate(RegistryPath);
    if (!NT_SUCCESS(status))
    {
        return STATUS_INVALID_PARAMETER;
    }



    //setup the MajorFunctions to nothing first
    for (int i = 0; i < IRP_MJ_MAXIMUM_FUNCTION; i++)
        pDriverObj->MajorFunction[i] = DispatchUnused;

    pDriverObj->MajorFunction[IRP_MJ_CREATE] = DispatchCreate;
    pDriverObj->MajorFunction[IRP_MJ_CLOSE] = DispatchClose;
    pDriverObj->MajorFunction[IRP_MJ_DEVICE_CONTROL] = DispatchIoctl;
    pDriverObj->DriverUnload = DriverUnload;


    // extract the driver name from the registry path
    LPCWSTR szDriverName = wcsrchr(RegistryPath->Buffer, L'\\');
    if (szDriverName == nullptr)
        return STATUS_INVALID_PARAMETER;
    szDriverName++;


    szDriverName = L"EasyAntiCheat_EOS";

    //Make Device Name : \\Device\\BEDaisy
    status = RtlStringCchPrintfW(g_szDeviceName, ARRAYSIZE(g_szDeviceName), L"\\Device\\%ws", szDriverName);
    if (!NT_SUCCESS(status))
        return STATUS_INVALID_PARAMETER;

    //convert the device name to a unicode string struct
    RtlInitUnicodeString(&uncDeviceName, g_szDeviceName);


    //Make Symbolic Link : \\DosDevices\\Global\\BEMapr  \\DosDevices\\BEMapr
    if (IoIsWdmVersionAvailable(1, 0x10))
        status = RtlStringCchPrintfW(g_szDeviceLnkName, ARRAYSIZE(g_szDeviceLnkName), L"\\DosDevices\\Global\\%ws",
            szDriverName);
    else
        status = RtlStringCchPrintfW(g_szDeviceLnkName, ARRAYSIZE(g_szDeviceLnkName), L"\\DosDevices\\%ws",
            szDriverName);

    if (!NT_SUCCESS(status))
        return STATUS_INVALID_PARAMETER;


    // convert the symlink to a unicode string struct
    RtlInitUnicodeString(&uncLinkName, g_szDeviceLnkName);


    // create device
    status = IoCreateDevice(pDriverObj, 0, &uncDeviceName, FILE_DEVICE_UNKNOWN, FILE_DEVICE_SECURE_OPEN, FALSE,
        &g_pDeviceObject);
    if (!NT_SUCCESS(status))
    {
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "[EasyAntiCheat_EOS] IoCreateDevice Failed %u \n", status);
        return status;
    }

    //setup flags for buffered io
    g_pDeviceObject->Flags |= DO_BUFFERED_IO;

    // create link
    status = IoCreateSymbolicLink(&uncLinkName, &uncDeviceName);
    if (!NT_SUCCESS(status))
    {
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "[EasyAntiCheat_EOS] IoCreateSymbolicLink Failed %u \n",
            status);
        IoDeleteDevice(g_pDeviceObject);
        return status;
    }

    //init spinlock
    KeInitializeSpinLock(&g_SpinLock);

    //make events
    status = CreateNamedEvent(L"\\BaseNamedObjects\\EasyAntiCheat_EOSEventDriver", &eventHandle1);
    if (!NT_SUCCESS(status))
    {
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL,
            "[EasyAntiCheat_EOS] CreateNamedEvent Failed %u for [EasyAntiCheat_EOSEventDriver] \n", status);
        eventHandle1 = NULL;
    }

    status = CreateNamedEvent(L"\\BaseNamedObjects\\EasyAntiCheat_EOSEventGame", &eventHandle2);
    if (!NT_SUCCESS(status))
    {
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL,
            "[EasyAntiCheat_EOS] CreateNamedEvent Failed %u for [EasyAntiCheat_EOSEventGame] \n", status);
        eventHandle2 = NULL;
    }

    status = CreateNamedEvent(L"\\BaseNamedObjects\\EasyAntiCheat_EOSEventModule", &eventHandle3);
    if (!NT_SUCCESS(status))
    {
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL,
            "[EasyAntiCheat_EOS] CreateNamedEvent Failed %u for [EasyAntiCheat_EOSEventModule] \n", status);
        eventHandle3 = NULL;
    }

    //Create section
    LARGE_INTEGER maximumSize;
    NTSTATUS      sectionCreateStatus = CreateFakeEACSection(&sectionHandle, &maximumSize);
    if (NT_SUCCESS(sectionCreateStatus))
    {
        sectionBaseAddress = NULL;
        SIZE_T viewSize = 0;
        status = ZwMapViewOfSection(sectionHandle, NtCurrentProcess(), &sectionBaseAddress, 0, 0, NULL, &viewSize,
            ViewShare, 0, PAGE_READWRITE);
        if (!NT_SUCCESS(status))
        {
            ZwClose(sectionHandle);
            DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL,
                "[EasyAntiCheat_EOS]CreateFakeEACSection ZwMapViewOfSection Failed: %u \n", status);
        }
        else
        {
            RtlZeroMemory(sectionBaseAddress, maximumSize.QuadPart);
            DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL,
                "[EasyAntiCheat_EOS] CreateFakeEACSection [EasyAntiCheat_EOSBin] \n");
        }
    }
    else
    {
        DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "[EasyAntiCheat_EOS]CreateFakeEACSection Failed: %u \n",
            sectionCreateStatus);
    }


    DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "[EasyAntiCheat_EOS]Driver Loaded \n");
    DbgPrintEx(DPFLTR_IHVDRIVER_ID, DPFLTR_ERROR_LEVEL, "[EasyAntiCheat_EOS]Name: %wZ LinkName : %wZ \n", uncDeviceName,
        uncLinkName);

    return STATUS_SUCCESS;
}
Expand

2. Creating a custom Launcher

To directly launch the game without going through the EAC startup, we develop a custom launcher to replace the existing one in the game folder. Another option is to hook Steam when it initiates the process, but we are going with replacing the launcher for this.

Here’s the code for our launcher:

void LauncherTest(int argc, char** argv)
{
    STARTUPINFOA processStartupInfo{0};
    processStartupInfo.cb = sizeof(processStartupInfo); // setup size of strcture in bytes
    PROCESS_INFORMATION processInfo{nullptr};


    // Main .exe (Like Discovery.exe, FC24.exe, or any other)
    auto commandLineToExecute = FormarString(xorstr_(R"("C:\Program Files (x86)\Steam\steamapps\common\The Finals\Discovery\Binaries\Win64\Discovery.exe")"), ExePath().c_str());


    // Load the arguments passed by the game itself. We can skip any if we want
    for (int i = 1; i < argc; i++)
    {
        // Add the argument to the command line
        commandLineToExecute = commandLineToExecute.append(FormarString(" %s", argv[i]));
    }

    // Append Extra commands
    commandLineToExecute.append(xorstr_(R"( Discovery)"));

    auto result = CreateProcessA(nullptr, (LPSTR)commandLineToExecute.c_str(), nullptr, nullptr, false, 0,
                                               nullptr,
                                               nullptr,
                                               &processStartupInfo, &processInfo);

   auto  getlasterror = GetLastError();
    std::cout << "error: " << getlasterror << std::endl;
    std::cout << "CreateProcessA: " << result << std::endl;

    if (getlasterror)
    {
        CloseHandle(processInfo.hProcess);
        CloseHandle(processInfo.hThread);
    }
    
    Sleep(5000);
}


int main(int argc, char** argv)
{
    LauncherTest(argc, argv);
    return 0;
}

Once compiled, rename it to Discovery.exe and replace the one at: C:\Program Files (x86)\Steam\steamapps\common\The Finals

3. EOS SDK

The EOS SDK, provided by Epic Games, is a comprehensive set of services and tools for streamlined online game development. It offers various functionalities such as cross-platform play, matchmaking, anti-cheat measures, lobbies, achievements, leaderboards, and more.

To be able to get the game running, we need to address EOS as well. Since we are using our fake EAC driver, we have two options: either simulate the EOS functions by loading a proxy DLL and handling the requests, or completely remove it.

Surprisingly, we can simply delete or rename the EOSSDK-Win64-Shipping.dll file located at C:\Program Files (x86)\Steam\steamapps\common\The Finals\Engine\Binaries\Win64, and the game will still launch successfully.

After that we have the game running.

4. Understading the page obsfucation

The Discovery.exe game is made up of three allocations. The first allocation has the encrypted pages marked as PAGE_NOACCESS, the second allocation has the pages marked as RW and the encrypted pages contain the byte 0xCC, and the third and last page contain the encrypted bytes.


How the decryption process works?

When the game attempts to access an encrypted page, it causes an exception known as EXCEPTION_ACCESS_VIOLATION. This exception is then caught by a handler, where some checks are performed. If everything is deemed fine, the page is decrypted and marked as RX, then the execution resumes. It is important to note that the exception is not triggered by attempting to READ/WRITE the page, either from the game module or their module runtime.dll; rather, the page must be executed to initiate the decryption process.

After a while the pages are encrypted back and marked as PAGE_NOACCESS again.

5. Decrypting it

There are several methods available for decrypting a page. One approach involves executing the page, which triggers the decryption routine. As a result, the page will contain the decrypted code and be marked as RX. Achieving this is relatively straightforward. We can either create an assembly shellcode that jumps or calls a specific function in the encrypted page, or alternatively, we can create a thread and pass the encrypted page address as the routine.

Keep in mind that if we provide an encrypted page address to the CreateThread function and it encounters a faulty instruction, the entire program will crash. However, there is a simple solution available. We can register our own exception handler, catch the error, and exit the thread without any concern. The same applies when creating a shellcode.

Decrypting a page.

Decrypting using their decryption routine

To locate their decryption routine, we can employ a VEH handler to capture the EXCEPTION_ACCESS_VIOLATION exception. Within our handler, we can instruct it to return the value EXCEPTION_CONTINUE_SEARCH, allowing the search for the decryption routine to continue. In our Debugger, we set a breakpoint in our handler and create a thread that calls the encrypted page. Once our exception captures it, we have the option to single step or generate a trace.

But what else we could use to find their decryption routine?

Well, thats pretty easy, remember the three allocations of the game memory I mentioned earlier? Well, they aren’t just copies without a purpose. Each allocation serves a specific function.

Let’s assign names to the allocations as per their utility:

  • Allocation 1: It’s where the pages are marked as PAGE_NOACCESS.
  • Allocation 2: It’s a RW allocation, the encrypted pages has 0xCC instead the original bytes.
  • Allocation 3: It’s the allocation which has the encrypted code.

Once the game executes an encrypted page, the decryption routine is run. The decrypted code is then copied to Allocation 2. Following this, the page permission at Allocation 1 is set to RWX and then the decrypted code from Allocation 2 is copied to Allocation 1. Finally, Allocation 1 is set to RX. That sparked me a idea: I cannot set a breakpoint in a page with PAGE_NOACCESS, but I can certainly set a breakpoint in a RW page, which in our case is Allocation 2.

Thus, I placed a breakpoint on an Allocation 2 page to catch accesses. After that, I created a thread that will attempt execution inside an encrypted page and voila: the decrypted code was going to be written to the page, then I checked where the write was coming from. It looks like it’s the code responsible for copying the decrypted code. After some analysis I figured out that this function is actually the page decryption routine.

Here is the pseudo code of the Decrypt function

// Functin offset: 0x694f80

unsigned __int64 __fastcall DECRYPT_PAGE(__int64 page_ptr_in_allocation_two, __int64 page_ptr_in_allocation_three, __int64 encryptionKeyData)
{
  unsigned __int64 v6; // r12
  __int64 current_ptr_in_page_in_allocation_two; // r13
  __int64 current_ptr_in_page_in_allocation_three; // rbp
  unsigned __int64 offset; // r15
  unsigned __int64 current_index_in_page; // rax
  bool is_not_end_of_page; // cf
  __int64 i; // rax
  __m128i v13[8]; // [rsp+20h] [rbp-88h] BYREF

  v6 = page_ptr_in_allocation_two - page_ptr_in_allocation_three;
  current_ptr_in_page_in_allocation_two = page_ptr_in_allocation_two + 1;
  current_ptr_in_page_in_allocation_three = page_ptr_in_allocation_three + 1;
  offset = 0;
  do
  {
    GetKeyDataForOffset((Key *)encryptionKeyData, offset, v13[0].m128i_i8, 0x40);
    if ( v6 > 0xF )
    {
      *(__m128i *)(page_ptr_in_allocation_two + offset) = _mm_sub_epi8(_mm_loadu_si128((const __m128i *)(page_ptr_in_allocation_three + offset)), v13[0]);
      *(__m128i *)(page_ptr_in_allocation_two + (offset | 0x10)) = _mm_sub_epi8(_mm_loadu_si128((const __m128i *)(page_ptr_in_allocation_three + (offset | 0x10))), v13[1]);
      *(__m128i *)(page_ptr_in_allocation_two + (offset | 0x20)) = _mm_sub_epi8(_mm_loadu_si128((const __m128i *)(page_ptr_in_allocation_three + (offset | 0x20))), v13[2]);
      *(__m128i *)(page_ptr_in_allocation_two + (offset | 0x30)) = _mm_sub_epi8(_mm_loadu_si128((const __m128i *)(page_ptr_in_allocation_three + (offset | 0x30))), v13[3]);
    }
    else
    {
      for ( i = 0; i != 64; i += 2 )
      {
        *(_BYTE *)(current_ptr_in_page_in_allocation_two + i - 1) = *(_BYTE *)(current_ptr_in_page_in_allocation_three + i - 1) - v13[0].m128i_i8[i];
        *(_BYTE *)(current_ptr_in_page_in_allocation_two + i) = *(_BYTE *)(current_ptr_in_page_in_allocation_three + i) - v13[0].m128i_i8[i + 1];
      }
    }

    current_index_in_page = offset + 0x40;
    current_ptr_in_page_in_allocation_two += 64;
    current_ptr_in_page_in_allocation_three += 64;
    is_not_end_of_page = offset < 4032;
    offset += 0x40;
  }
  while ( is_not_end_of_page );

  return current_index_in_page;
}

Once I figured out what the function was actually doing, I placed a breakpoint on it to determine its arguments. After running it for a few times, I was able to uncover them.

Arguments:

First argument: Pointer to an encrypted page in Allocation 2
Second argument: Pointer to a page in Allocation 3, which contains the encrypted code
Third argument: Decryption data key

To determine the data key structure and its corresponding layout we repeated the previous process a few times. Eventually, we obtained the following struct.

struct Key
{
   m128     key1;
   m128     key2;
}

class decrypt_key_class
{
public:
    Key       general_one;                      //0x0000
    Key       general_two;                      //0x0020
    uint64_t  zero;                             //0x0040
    Key       unique_key;                       //0x0048
    int32_t  page_index;                        //0x0068
    char     pad_006C[28];                      //0x006C
    uint32_t not_int_a_bunch_of_flags_uint8_t;  //0x0088
    char     pad_008C[1764];                    //0x008C
    
    // The not_int_a_bunch_of_flags_uint8_t is not actually a int value, but since
    // it always the same value, i've just converted to int, they are actually bytes flag.
   
}; 

Explanation of the fields:

  • The keys “general_one” and “general_two” are identical, and are both static constants located in runtime.dll. These keys remain unchanged even after process restarts.
  • The “unique_key” refers to a key that is distinct for every page. These keys are unchanging and can be located in the runtime.dll. These keys remain unchanged even after process restarts.
  • zero” is actually always zero.
  • page_index” is actually the index of the page that we are decrypting it.
  • The variable “not_int_a_bunch_of_flags_uint8_t” is a collection of uint8_t flags whose purpose I didn’t reverse, considering they had the same value across the pages.

Crafting the encrypted key data

To obtain the “general key”, we can hook the decryption routine and check the third argument, which is a pointer to the key structure, and then extract the key from it. Following this, we can search for those values in runtime.dll and make a signature for them.

The unique key can be found by using the same strategy. In this case we’re interested in current page_index and unique_key. Then we can search for the unique key in runtime.dll and use the page index to find where is the beginning of the encryption key list.

// This give us the page index, we can compare with the page_index in the decryption routine. You will see that we are correct.

current_page_index = ( ptr_page_encrypted - ptr_base ) / 0x1000;
decryption_key_size = 0x20;

// So if we can find the page_index, and we know the size of the encryption key, it then calculate where it starts.

// Step 
// 1. Find the current encryption key that we got from the hooked function in the runtime.dll
// 2. Since we also the index where we are, and we know the size of the encryption key. We do page_index * decryption_key_size.
// 3. We the result_value, we subtract the ptr found in runtime.dll - result_value and magic!
// 4. We are now in the begin of encryption key list.

Finally, we can use the same method again to obtain “not_int_a_bunch_of_flags_uint8_t“. This field receives the same value for all pages.

Crafting a encryption data key:

#define OFFSET_BEGIN_KEY_DECRYPT_LIST 0x41D2A0
#define FLAG_VALUE = 0x100024

    static decrypt_key_class* GetKeyData(uint32_t pageIndex)
    {
        auto result = new decrypt_key_class;
        RtlZeroMemory(result, sizeof decrypt_key_class);

        auto uniqueKey = (uintptr_t*)(runtime_imageBase + OFFSET_BEGIN_KEY_DECRYPT_LIST +   (pageIndex * 0x20));


        result->page_index = pageIndex;


        result->general_one.key1.m128_u64[0] = 0x98ADD1365BF4D30A;
        result->general_one.key1.m128_u64[1] = 0x6D22E7E35A9D06B5;
        result->general_one.key2.m128_u64[0] = 0x9EC9C0A169CEB5D3;
        result->general_one.key2.m128_u64[1] = 0x7DEC1707A2910127;
        
        result->general_two.key1.m128_u64[0] = 0x98ADD1365BF4D30A;
        result->general_two.key1.m128_u64[1] = 0x6D22E7E35A9D06B5;
        result->general_two.key2.m128_u64[0] = 0x9EC9C0A169CEB5D3;
        result->general_two.key2.m128_u64[1] = 0x7DEC1707A2910127;


        result->unique_key.key1.m128_u64[0] = uniqueKey[0];
        result->unique_key.key1.m128_u64[1] = uniqueKey[1];
        result->unique_key.key2.m128_u64[0] = uniqueKey[2];
        result->unique_key.key2.m128_u64[0] = uniqueKey[3];

        result->not_int_a_bunch_of_flags_uint8_t = FLAG_VALUE;


        return result;
    }

Calling the Decryption routine

With all the necessary components in place—the decryption routine, keys, and puzzle pieces—it is now time to execute the function.

using DecryptPage_t = uintptr_t(__fastcall*)(uintptr_t allocation_2, uintptr_t allocation_3, uintptr_t keyData);

uintptr_t DecryptPage(uint32_t pageIndex)
{
   
        if (pageIndex > 0)
            pageIndex--;


        static DecryptPage_t decryptPage = nullptr;
        if (!decryptPage)
        {
            // Set Decrypt routine address
            decryptPage = (DecryptPage_t)(runtime_imageBase + 0x694f80);
        }


        // Set the page we are looking to decrypt
        uintptr_t decryptAddrCC        = allocation_2 + pageIndex * 0x1000;
        uintptr_t decryptAddrEncrypted = allocation_3 + pageIndex * 0x1000;

        // Get the page Key where we want to look.
        auto decryptionKeyData = decrypt_key_class::GetKeyData(pageIndex + 1);
        
        
        auto result = decryptPage(decryptAddrCC, decryptAddrEncrypted,
                                                   (uintptr_t)decryptionKeyData);
        return result;

}

Wrapping all together and creating a dumper

void InitiateDumping()
{
    printf("======== [Initiated dumping] ======== \n");


    auto baseModuleAddr = base_address_allocation_2;

    // Get Image Size
    PIMAGE_NT_HEADERS pNtHeader        = RtlImageNtHeader((PVOID)baseModuleAddr);
    auto              imageSize        = pNtHeader->OptionalHeader.SizeOfImage;
    auto              moduleEndAddress = baseModuleAddr + imageSize;


    // Our current page dumping.
    uint32_t currentIndexPage = 1;

    // BlackListed Pages from Byfron
    constexpr uint32_t blacklistedPagesIndex[1000] = {};

    // Allocate Memory for saving the dump.
    auto bufferDataModule = (uintptr_t)VirtualAlloc(nullptr, imageSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);

    RtlZeroMemory((void*)bufferDataModule, moduleEndAddress - baseModuleAddr);


    memcpy((void*)bufferDataModule, (void*)baseModuleAddr, 0x1000);

    uintptr_t decryptPage = 0;
    uintptr_t mybuffer    = 0;
    do
    {
        decryptPage = baseModuleAddr + currentIndexPage * 0x1000;
        mybuffer    = bufferDataModule + currentIndexPage * 0x1000;


        printf("[%d] Decrypting page: %p\n", currentIndexPage, decryptPage);

        // Are we going to skip the page?
        bool skipPage = false;

        // Loop all blacklisted page and check if matches the current one
        for (const unsigned int i : blacklistedPagesIndex)
        {
            if (i == currentIndexPage)
            {
                skipPage = true;
                break;
            }
        }

        // Check if we are going to skip the page.
        if (skipPage)
        {
            currentIndexPage++;                    // Increment
            memset((void*)mybuffer, 0x90, 0x1000); // Nope the page
            continue;
        }


        auto check = Misc::IsPageNoAcccess(base_address_allocation_1 + currentIndexPage * 0x1000);
        if (check)
        {
            auto result = DecryptPage(currentIndexPage);
        }

        memcpy((void*)mybuffer, (void*)decryptPage, 0x1000);


        currentIndexPage++;
    }
    while (baseModuleAddr + (currentIndexPage * 0x1000) < moduleEndAddress);

    Misc::SaveDllToDisk((unsigned char*)bufferDataModule, pNtHeader->OptionalHeader.SizeOfImage, "C:\\dumps\\Discovery_dumped.exe");

    printf("======== [Dumped] ======== \n");
}

Injecting a DLL

Theia places inline hooks in some modules to strengthen their protection mechanisms. Those include hooks inside ntdll, DirectX modules and user32 callbacks. Here’s a list of all functions that need to be restored so we can inject a DLL.

ntdll.dll hooks to be removed:

Conclusion

In conclusion, Theia presents a formidable challenge for achieving any desired purpose due to the numerous barriers it imposes. The presence of anti-debuggers, live communication with the kernel driver, hypervisor detection, stack obfuscation, analysis tool detection, and runtime.dll protection further complicate the task. As my experience goes, it is recommended to implement blacklisted pages that can only be decrypted using a special function or key. Additionally, the use of dynamic keys instead of static ones and preventing all pages from being unlocked simultaneously can enhance security. It is also crucial to regularly check if any hooks have been removed and take appropriate action by closing the game if any reversing tool is detected. These improvements may make the Theia more resilient and challenging to overcome.

I had a great fun and headache analyzing it, The journey itself was enjoyable, and I successfully achieved my goal.

Thanks

Cra0 for the help provided throughout my journey.

You may also like

Leave a Comment