Target

Our target in this lesson will be Wesnoth 1.14.9.

Overview

When reversing complex applications like video games, one of the most difficult steps is establishing a context inside the application. While there are many techniques to establish a context, one approach is to create a modified debugger that logs all call instructions executed by the application. Actions can then be executed in the game, such as clicking a button, and all the related calls can be observed. The logged calls can then be used to establish a context and begin reversing the target.

Our goal in this lesson is to modify the debugger we created in the previous lesson to log all call instructions made by the target. The full code for this lesson is available on github.

Locating the Main Module

In the previous lesson, we wrote a break instruction to a single location inside Assault Cube. Our target for this lesson will be Wesnoth. Therefore, we will modify the code responsible for locating the process’s pid to find the Wesnoth process and remove the code responsible for writing the single breakpoint:

do {
  if (wcscmp(pe32.szExeFile, L"wesnoth.exe") == 0) {
    pid = pe32.th32ProcessID;

    process_handle = OpenProcess(PROCESS_ALL_ACCESS, true, pe32.th32ProcessID);
  }
} while (Process32Next(process_snapshot, &pe32));

For this tool, we will only log calls in the main game module and not in external DLL’s, such as user32.dll. To determine the beginning and end address of the main module, we will first use the EnumProcessModules API to retrieve a list of all loaded modules. Then, we will use the GetModuleInformation API to retrieve the address space of the first module, which always represents the main game module. We will execute this code in the first debug event that occurs in the target (when we attach our debugger to the process):

HMODULE modules[128] = { 0 };
MODULEINFO module_info = { 0 };

DWORD bytes_read = 0;

if (!first_break_has_occurred) {
  EnumProcessModules(process_handle, modules, sizeof(modules), &bytes_read);
  GetModuleInformation(process_handle, modules[0], &module_info, sizeof(module_info));

The GetModuleInformation API will fill module_info.SizeOfImage with the size of the main module, and module_info.lpBaseOfDll with the base address of the main module. With this range, we can begin searching for call instructions.

Like we have done previously, we will use ReadProcessMemory to read the instructions into a buffer. While we would like to read the entire memory of the whole process, this approach will not work. This is because different memory sections of the process have different memory protections. If the section does not allow reading, the call to ReadProcessMemory will fail. If we try to read the entire memory of the process in one call, we will encounter a section that fails, and then the entire read will fail.

To deal with this, we will instead read the memory in sections. These sections are called memory pages, and the default memory page size in Windows is 4096 bytes. As such, we will create a loop to read 4096 bytes of instructions at a time. We will use the bytes_read parameter to determine how many bytes of the page were actually read:

#define READ_PAGE_SIZE 4096

unsigned char instructions[READ_PAGE_SIZE] = { 0 };

for (DWORD i = 0; i < module_info.SizeOfImage; i += READ_PAGE_SIZE) {
  ReadProcessMemory(process_handle, (LPVOID)((DWORD)module_info.lpBaseOfDll + i), &instructions, READ_PAGE_SIZE, &bytes_read);
  for (DWORD c = 0; c < bytes_read; c++) {

  }
}

Locating Calls

Next, we will locate the call instructions in each page of memory. We know that the opcode for the call instruction is 0xe8. While iterating over each instruction, we will check to see if it is 0xe8:

BYTE instruction_call = 0xe8;
...
for (DWORD c = 0; c < bytes_read; c++) {
  if (instructions[c] == instruction_call) {

  }
}

However, not all 0xe8’s represent call instructions. For example, the opcode for the add eax, ebp instruction is 0x01 e8. We need to ensure that we do not identify these random 0xe8’s as calls. The easiest way to do that is to read the 4 bytes after the call.

As we know from the Using Code Caves lesson, these 4 bytes encode the location of the call. By retrieving this location, we can check if the calculated location of these bytes is valid. If not, we can assume that the 0xe8 is not a call and use the continue instruction to escape this check:

DWORD offset = 0;
DWORD call_location = 0;
DWORD call_location_bytes_read = 0;
... 
if (instructions[c] == instruction_call) {
  offset = (DWORD)module_info.lpBaseOfDll + i + c;
  ReadProcessMemory(process_handle, (LPVOID)(offset + 1), &call_location, 4, &call_location_bytes_read);

  call_location += offset + 5;
  if (call_location < (DWORD)module_info.lpBaseOfDll || call_location >(DWORD)module_info.lpBaseOfDll + module_info.SizeOfImage)
    continue;

Finally, we will write a break instruction (0xcc) to the location. In addition to WriteProcessMemory, we will use the FlushInstructionCache API to make sure our changes are done immediately to the target:

BYTE instruction_break = 0xcc;
...
WriteProcessMemory(process_handle, (void*)offset, &instruction_break, 1, &bytes_written);
FlushInstructionCache(process_handle, (LPVOID)offset, 1);

Writing thousands of break instructions to a process can cause the program to crash. To avoid this, we will only write 2000 breakpoints:

int breakpoints_set = 0;
...
if (breakpoints_set < 2000) {
  WriteProcessMemory... 
  breakpoints_set++;
}

Handling Breakpoints

Now that we have written breakpoints to all the calls, we need to handle the breakpoint events. We will start with the same approach that we used in the previous lesson:

else {
  thread_handle = OpenThread(THREAD_ALL_ACCESS, true, debugEvent.dwThreadId);
  if (thread_handle != NULL) {
    context.ContextFlags = CONTEXT_ALL;
    GetThreadContext(thread_handle, &context);

    context.Eip--;

    SetThreadContext(thread_handle, &context);
    CloseHandle(thread_handle);

    WriteProcessMemory(process_handle, (void*)context.Eip, &instruction_call, 1, &bytes_written);
    FlushInstructionCache(process_handle, (LPVOID)context.Eip, 1);
  }
}

Like we saw before, this code will decrease EIP and restore the original call instruction. Then, execution will resume at the call and the program will continue execution normally. The downside with this approach is that each breakpoint is only hit once. For our call logger, we want to log each time a call is executed. To achieve this behavior, we will use single-step mode.

Single-stepping is a special type of debug event that executes a single instruction before triggering an exception again. To enable single-step mode, we modify the EFlags of the current thread like so:

context.Eip--;
context.EFlags |= 0x100;

Next, we need to handle the single-step event. We will introduce another case for this:

case EXCEPTION_SINGLE_STEP:

When we receive our exception here, it means that the call has finished executing. Ultimately, our goal in this event is to restore the break instruction. We can do this via WriteProcessMemory in an identical way to restoring the call instruction:

thread_handle = OpenThread(THREAD_ALL_ACCESS, true, debugEvent.dwThreadId);
if (thread_handle != NULL) {
  context.ContextFlags = CONTEXT_ALL;
  GetThreadContext(thread_handle, &context);
  CloseHandle(thread_handle);

  WriteProcessMemory(process_handle, (void*)last_call_location, &instruction_break, 1, &bytes_written);
  FlushInstructionCache(process_handle, (LPVOID)last_call_location, 1);
}

With this code in place, our breakpoints will be restored after being triggered.

Adding Logging

Finally, we will add logging to this code so that we can see the triggered breakpoints. In our debug event, we will store the current location of EIP:

DWORD last_call_location = 0;
... 
last_call_location = context.Eip;

Next, in the single-step event, we will add the logging code. We know at this point that we have executed the call and we are at the call’s location. Now we can use the following print statement to print the call’s address and the location called:

printf("0x%08x: call 0x%08x\n", last_call_location, context.Eip);
last_call_location = 0;

In this lesson, we are only logging the calls as they happen. However, it is possible to modify this code to also hook ret instructions. This would allow you to build out a graph showing all calls made by the process and which calls call other calls.

 

results matching ""

    No results matching ""