The Life of Ryan

The transmundane adventures of a curious, bipedal ape



  1. Introduction
  2. Toolset
  3. Where Is Everything?
  4. DOOM's DOOM

Introduction

A few weeks ago I felt the urge to work on a programming project but I was struggling to think of ideas. I eventually had the thought to revisit moding games from scratch, something I had done at a basic level during highschool. Since it had been a long time since I'd worked on anything hacking related I was easily persuaded to fufill that part of my being. I chose DOOM (2016) for my target game because it is single player (mostly) and thus unlikely to have strong anti-cheat software. Also because I own but had not completed the game and figured moding it would make progressing more fun.

With this writeup I hope to outline the general methodology of video game hacking by using a specific mod I created during this experimentation with DOOM. I hope you find the unique challenges associated with hacking as interesting as I do.

Without further stalling for time, lets get to the hack.


Toolset

In order to perform the hack yourself you would need...

Having some knowledge of assembly and C/C++ programming are necessary for performing an original hack of this kind but are not required for understanding the central concepts I wish to outline.


Where Is Everything?

It's now time to start hacking away. Launch DOOM and start a level from arcade mode then pause the game. Launch Cheat Engine (CE), as administrator and open the process for DOOM. The process will be titled "DOOMx64vk.exe".

Our first objective is going to be to find where in the video game's memory the player's health is being stored. At the start of the level my health bar says 100.

In CE we can enter into the search bar an "Exact Value" of 100. Since it is not obvious whether the health is stored as a decimal or an integer we'll choose "All" for "Value Type", then scan.

97,584 memory addresses containing the number 100.. Hmmm... Lets take some damage and refine our search. Since CE will show only the addresses that match all previous searches, the number of matching addresses decreases each scan. After being hit my health bar now displays 85.


Performing another scan searching for 85 reveals only 11 matching addresses! Curiously, all of them are floats (4 byte decimal numbers) even though the health is only ever displayed as a whole number. Unfortunately, if we take damage a second time we will see that all 11 addresses change in unison to the new health value. What this implies is that the health variable is being copied to multiple places in memory, and it is now our duty to determine which address is the correct one. Since there are only 11 options, we can set these values to 999 one at a time and see which address changes our health in game. The address which does is the real health and we will want to keep it in the address list at the bottom.

It's tempting to think that because we've found the memory address of the health that we will always be able to open DOOM and find the health at that same location. However, this is not the case. For starters, 64 bit applications always start with a random "base address", meaning all code and static data are shifted by a different amount every time the application is run. Furthermore, many variables are allocated at runtime meaning their memory is chosen while the program is running, not statically.

To determine how to statically find the health value, we're going to reverse engineer the code that uses the health. Use the "Find out what writes to this address" feature on the health address then take some damage.

We see that the memory is being changed by the instruction

movss [rsi+rbx+1C],xmm0
at memory location DOOMx64vk.exe+64F323. Setting a breakpoint at this address then taking damage may allow us to investigate where the numbers calculating the health address came from.

At this point I noticed that the game was hitting my breakpoint even without me taking damage, but instead when enemies were taking damage. This implies that the single piece of code we discovered performs damage operations for both the player's health and the enemies'. This fact could be useful for a hack, and we'll return to this point later.

The pointer to the health (which was in rbx) came from somewhere. We can backtrace the code to see where the value came from. Using the call stack and reading through some assembly code, we can try to determine the origin of this pointer.

  1. Register rbx was set at address DOOMx64vk.exe+64F27B from instruction
    mov rbx,rcx
  2. Register rcx was set at address DOOMx64vk.exe+B66BDB from instruction
    lea rcx,[r14+00043BB8]
  3. Register r14 was set at address DOOMx64vk.exe+B65641 from instruction
    mov r14,rcx
  4. ...
And if we keep following the pointer up the callstack we eventually reach the terminal rcx being the result of a function call, determined at runtime. Darn, a dead end of sorts. However, we did learn some interesting things. The player's health was stored at 1C + rbx implying that the value in rbx is the address of the player object (which "health" would be a member of). Furthermore, it is possible that the pointer returned by the function is stored in memory somewhere. If we take the address we've found for our health and subtract the two offsets we've found (1C and 43BB8) we can calculate the initial value the callstack was given and search to see if that address is stored as a pointer in memory.

1,906 addresses are pointers to this address, wow! This almost puts us back where we started, searching for where pointers come from, except that there are two addresses at the bottom of the list which CE displays in green. These addresses are in fact located at a fixed offset from the base address and thus their position is easily known at runtime. Why does that matter? Because it means we now have everything we need to find our health variable reliably! Base address + 2DFC3A0 is the address of a pointer which when dereferenced is a constant offset from the health. In C notation this looks like

health = *(*(baseAddress + 0x2DFC3A0) + 0x43BB8 + 0x1C)


DOOM's DOOM

Earlier I had found a section of code which was invoked both when the player and the enemies took damage. Since this code was setting our health (and the enemies'), it seems that if we were to change this code correctly we could have it set enemies to have 0 health and not effect the player at all! To test this concept I created the following assembly script within CE.


alloc(newmem,2048,"DOOMx64vk.exe"+64F323)
label(returnhere)
label(exit)

newmem:
movss xmm0, [rsi+rbx+0x1C]  // Copy health to register
push #100000                // Store #100,000 onto stack
comiss xmm0, [rsp]          // Compare health to #100,000
ja exit                     // If health bigger than #100,000 then exit

xorps xmm0, xmm0            // Else, set register to zero
movss [rsi+rbx+0x1C], xmm0  // Store zero for health

exit:
pop rcx                     // Fix stack since we pushed earlier
jmp returnhere              // Jump back where we came from

"DOOMx64vk.exe"+0x64F323:
jmp newmem
nop
returnhere:
          
What this does is tells CE to allocate some new memory for this code then overwrite the address DOOMx64vk.exe+64F323 with a jump to our new code. The code itself simply checks if the health of the person being damaged is bigger than 100,000. If it is, then no damage is done. If it is not, then the person is given 0 health. I simply injected this code and set my health to 100,001 and was able to confirm it worked! We now have the conceptual basis for a one tap hack. The changes are only temporary and restarting DOOM reverts to an unhacked state.

Now, hopefully you're thinking to yourself "Surely there has to be a better way to differentiate the player!" since assuming the player has over 100,000 health is pretty contrived. To start looking for something to identify the player we can use the "Compare data/structures" tool in CE. This tool will allow us to see the data at a start address and at subsequent offsets. Furthermore we can compare the data at these offsets between groups. So we can place the address of the player object in one group and several addresses for enemy objects in a second group.

One thing that you will find from comparing these objects is that the the enemies share the same 32 bit pseudo-random number at their 0 offset, which is different than the 32 bit number at offset 0 of the player object. This suggests that the objects all start with some sort of 32 bit identifier and that this field can be used to differentiate the player. Thus we can modify the start of our assembly to


mov ecx, 0xDD53AF40         // Move player identifier into register
cmp ecx, [rbx]              // Compare register and objects identifier
je returnhere               // Leave if equal
          
where DD53AF40 is the current identifier of the player.

We now have enough information to code a hack

Launch Visual Studio and choose to "Create a new project". Select the "Dynamic-Link Library (DLL)" project template to get started. This will populate a file neamed "dllmain.cpp" with the following template code:


BOOL APIENTRY DllMain(HMODULE hModule, DWORD  ul_reason_for_call, LPVOID lpReserved)
{
    switch (ul_reason_for_call)
    {
    case DLL_PROCESS_ATTACH:
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
        break;
    }
    return TRUE;
}
          
We want to execute a continuous hack-loop upon loading the DLL into the game, and so we're going to create a thread to run our hack-loop by adding the following line to our switch-case:

case DLL_PROCESS_ATTACH:
    CloseHandle(CreateThread(nullptr, 0, (LPTHREAD_START_ROUTINE)HackThread, hModule, 0, nullptr));
          
This will create and safely close the reference to a new thread running our (to be defined) hack-loop.

Now define a new function above our DllMain with the stub

DWORD WINAPI HackThread(HMODULE hModule) {...}
Here we're going to put our code for injecting our one tap hack. First we need to determine the current base address, which as I briefly mentioned is different every time the application runs. We can do this in C++ as

// Get base address of executable
uintptr_t moduleBase = (uintptr_t)GetModuleHandle(L"DOOMx64vk.exe");
          
Next setup some constatnts.

// Use static offset to player that we found earlier for health
uintptr_t* localPlayerPtr = (uintptr_t*)(moduleBase + 0x2DFC3A0);

// Toggle for hack
bool bOneTap = false;

// System info
SYSTEM_INFO system_info;
GetSystemInfo(&system_info);
DWORD hackSize = system_info.dwPageSize;
          
Allocate memory with executable permissions for us to place our one tap code in. If allocation fails, close DLL.

// Allocate memory for one tap function
BYTE* oneTapLoc = (BYTE*)VirtualAlloc((void*)(moduleBase - 0x20000), hackSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (!oneTapLoc)
{
    FreeLibraryAndExitThread(hModule, 0);
    return 0;
}
          
We can now copy the one tap byte code into the newly allocated memory.

// Copy data to perform one-tap hack
memcpy(oneTapLoc, "\xB9\x40\xAF\xBB\xDF\x3B\x0B\x0F\x84\x09\x00\x00\x00\x0F\x57\xC0\xF3\x0F\x11\x44\x33\x1C\xC3", 23);
          
The assembly code that this byte sequence corresponds to is

mov ecx, 0xDFBBAF40         // Move player identifier into register
cmp ecx, [rbx]              // Compare register and objects identifier
je 9                        // If equal, jump to ret (9 bytes away)
xorps xmm0, xmm0            // Else, set register to zero
movss [rsi+rbx+0x1C], xmm0  // Store zero for health
ret                         // Return to calling code
          
Next, we need to craft the instruction that that will jump from the normal game code to the one tap function we just created. This is nontrivial since on 64 bit machines a call instruction works by displacing from the next instruction's address. For example, the instruction call 8 would result in execution moving 8 bytes after the call instruction. Thus, to call our one tap code we need to calculate the displacement from the origin to the hack. This can be performed as follows.

// Data to perform CALL to hack address (E8:00:00:00:00) and NOP (90) on return
// Note: The instruction data has a trailing NOP (the "no operation" instruction) because 
//       the initial data is 6 bytes and we want to overwrite it with something equally long
BYTE oneTapRedirect[] = {0xE8, 0x00, 0x00, 0x00, 0x00, 0x90};

// Set 32 bit displacement from origin to allocated hack function
*(int32_t*)&oneTapRedirect[1] = int32_t((intptr_t)oneTapLoc - (intptr_t)(moduleBase + 0x64F328));
          
This will calculate the distance needed to jump from the origin to our allocated hack code, and store it in a call instruction. Now we can write the main hack loop.

// Repeat until we choose to close DLL
while (true)
{
    // Leave DLL on END key press
    if (GetAsyncKeyState(VK_END) & 1)
    {
        break;
    }

    // Toggle one tap hack on number-pad-1 press
    if (GetAsyncKeyState(VK_NUMPAD1) & 1)
    {
        // Swap ON/OFF
        bOneTap = !bOneTap;

        // Ensure we have write permissions to the damage function we want to hack
        DWORD oldProtect;
        VirtualProtect((uint8_t*)(moduleBase + 0x64F323), 6, PAGE_EXECUTE_READWRITE, &oldProtect);

        // Check if hack enabled
        if (bOneTap)
        {
            // Check if player exists
            if (*localPlayerPtr)
            {
                // Player exists, make sure hack function has the correct identifier
                *(uint32_t*)&oneTapLoc[1] = *(uint32_t*)(*localPlayerPtr + 0x43BB8);
            }

            // Copy CALL to hack function to their damage function
            memcpy((uint8_t*)(moduleBase + 0x64F323), oneTapRedirect, 6);
        }
        else
        {
            // Copy the original bytecode back into the damage function to undo our hack
            memcpy((uint8_t*)(moduleBase + 0x64F323), (const void*)"\xF3\x0F\x11\x44\x1E\x1C", 6);
        }

        // Restore permissions
        VirtualProtect((uint8_t*)(moduleBase + 0x64F323), 6, oldProtect, &oldProtect);
    }
    Sleep(10);
}
          
Finally, outside of the while-loop we can add our cleanup code.

VirtualFree(oneTapLoc, 0, MEM_RELEASE);
FreeLibraryAndExitThread(hModule, 0);
return 0;
          
We can now compile this as a 64 bit DLL (in debug mode) and inject it into DOOM!

And that's my basic hack for DOOM! I hope you enjoyed this writeup and found some appreciation for the subtle art of video game hacking. The full source code for my DLL can be found here.





Edited: February 24, 2020