Solving CTF challenges with DLL injection

July 08, 2018

The game

A reversing CTF challenge which has long been on my TODO list to solve is RockPaperScissorsLizardSpock_v0.3.exe, which was designed and created by ex-colleague of mine, Thijs back in 2014. The challenge requires you to play a customised version of “Rock-Paper-Scissors” with two additional movements “Lizard-Spock”.

To win, you need to beat the game 15 times in a row while also branching across every possible win condition.

The problem

When opening the binary in a static analysis tool we can see a number of calls to named timers and other anti-debugging tricks which result in different code paths and values set for XOR keys.

Knowing the creator, I decided it was wiser to solve this challenge without using a debugger or attempting to otherwise patch out these functions. Rather, the approach I used was to actually beat the game.

In order to beat the game, we need to be able to somehow predict which moves will be taken. The most likely method the application is using to choose a move will be with a call to random. We can see how this choice is made in the assembly listing below.

The approach

The snippet above follows the following process:

  • Request a random value via a call to rand
  • Map the value to be within the range 0-5 (representing possible moves)
  • Store this value in the eax register

If we can access the value stored in eax at the control flow of 0x00401925 we can predict the move which will be made. By knowing this move in advance, we can cheat the game and always select a game winning move.

Reading memory

We can achieve the goal of reading game memory by using DLL injection to inject our own thread inside the active game process. Once injected, we can add a hook to hijack and manipulate the control flow.

At a high-level, we want our hook to achieve the following:

  • Overwrite existing instructions starting from mov eax, ecx to modify the control flow
  • Clean-up any mangled instructions resulting from our overwrite
  • Save the current state of the registers and flags
  • Process the current random value chosen by the application
  • Replace any overwritten bytes
  • Update the previous state of the registers and flags
  • Return the control flow

The call

As this is a 32bit application, creating a call operation will use a total of 5 bytes. 1 byte for the 0xe8 call op-code and 4 bytes for the actual location address. The data we are directly overwriting is only 2 bytes, which means performing the write will ‘overflow’ and mangle subsequent instructions. A solution for this is to overwrite the subsequent mangled bytes with nops and thereafter recreate them in our own function.

This process is more clearly demonstrated with a debugger. In the screenshot below, you can see the normal application process at the break point set at 0x00401925.

We overwrite mov eax, ecx (2 bytes) with a call to another location (5 bytes), which also partially overwrites mov dword ptr ss:[ebp-1c0], eax (6 bytes). To complete this partial overwrite, we add additional nop instructions (3 bytes):

If we follow the call we see that our own function performs the clean-up and replaces the overwritten instructions before redirecting execution back to the original location:

We can create this call flow using the following snippet, where the parameters will be the location of our hook function, the location to hijack and the lengths of the patch and subsequent mangled bytes:

void write_bytes(void *destination_address, void *patch, int length) {
    unsigned long oldProtect = 0;
    VirtualProtect(destination_address, length, PAGE_EXECUTE_READWRITE, &oldProtect);
    CopyMemory(destination_address, patch, length);
    VirtualProtect(destination_address, length, oldProtect, &oldProtect);
}

void code_cave(void *destination_address, void *patched_function, int patch_length, int mangled_bytes) {
    long offset = (long)patched_function - (long)destination_address - (patch_length - mangled_bytes);
    byte *patch = (byte *)malloc(sizeof(byte) * patch_length);
    patch[0] = 0xe8;								// call opcode
    CopyMemory(patch + 1, &offset, (patch_length - mangled_bytes - 1));		// patch address
    for (int i = 0; i < mangled_bytes; i++){
        patch[5 + i] = 0x90;							// nop alignment
    }
    write_bytes(destination_address, patch, patch_length);
}

The hook

When modifying the application flow, we don’t want any function prologues to modify registers or flags, so we can use a naked call and handle the data ourselves with some inline asm:

void __declspec(naked) rng_hijack() {
    __asm {
        pop return_address		// return address from top of stack
	mov rng_value, ecx		// copy rng from ecx
	pushad				// preserve registers and stack
    }
    make_choice();			// game winning logic
    __asm {
        popad				// restore registers and stack
	mov eax, ecx			// restore mangled opcodes
	mov [ebp - 0x1C0], eax		// restore mangled opcodes
	push return_address		// restore 'next' address to stack
	ret				// return back to 'next' address
    }
}

The loader

Now we have created our hook, we still need a way to inject into the game process. There are a number of online utilities which can be used for performing DLL injection, however we need to hook the application at the very beginning, otherwise we miss the initial random value (and therefore the first move).

We can create and directly inject into the process via the following:

  • Create a suspended version of the application
  • Allocate memory inside the suspended application
  • Create a new thread and pass our DLL location as the start address using LoadLibrary
  • Resume the suspended process

Assuming no errors, we can use the following snippet:

#include <windows.h>

int main(int argc, char *argv[]) {
    char *application = "RockPaperScissorsLizardSpock_v0.3.exe";
    char *dll = "solver.dll";
    int length = strlen(dll) + 1;

    STARTUPINFO si = { 0 };
    PROCESS_INFORMATION pi = { 0 };
    si.cb = sizeof(STARTUPINFO);

    CreateProcess(NULL, application, NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi);
    void *page = VirtualAllocEx(pi.hProcess, NULL, length, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    WriteProcessMemory(pi.hProcess, page, dll, length, NULL);
    HANDLE hThread = CreateRemoteThread(pi.hProcess, NULL, 0, (LPTHREAD_START_ROUTINE)LoadLibraryA, page, 0, NULL);

    WaitForSingleObject(hThread, INFINITE);
    CloseHandle(hThread);
    ResumeThread(pi.hThread);

    CloseHandle(pi.hProcess);
    VirtualFreeEx(pi.hProcess, page, MAX_PATH, MEM_RELEASE);
    return 0;
}

When starting the application via our loader, our DLL will be directly injected, thereby hooking the application flow. Each time the application makes a new random choice, our function will be called and we can cheat the game.

The flag

By following our own advice, we can we solve the game (while reaching all possible win conditions) and we are ultimately presented with the flag.

Full DLL code listing

The full DLL code listing including the game winning logic can be found below:

#include <iostream>
#include <Windows.h>

long *rng_value;
long return_address;
long rng_hook = 0x00401925;

int unique_wins = 0;
const int required_wins = 10;
int seen_map[required_wins];

char *choice_string[]{ "ROCK", "PAPER", "SCISSORS", "LIZARD", "SPOCK" };
enum choice {
    ROCK = 0,
    PAPER = 1,
    SCISSORS = 2,
    LIZARD = 3,
    SPOCK = 4
};

choice get_move(choice computer_choice, int bit1, choice win_choice1, int bit2, choice win_choice2) {
    choice best_move;
    if (!seen_map[bit1] || unique_wins >= required_wins) {	// win condition 1 vs computer choice
        seen_map[bit1] = 1;
        best_move = win_choice1;
        unique_wins++;
    }
    else if (!seen_map[bit2]) {					// win condition 2 vs computer choice
        seen_map[bit2] = 1;
        best_move = win_choice2;
	unique_wins++;
    }
    else {
	best_move = computer_choice;				// force draw
    }
    return best_move;
}

void make_choice() {
    choice our_move;
    choice computer_move = (choice)((int)rng_value);
    printf(">>> Computer move => %s\n", choice_string[computer_move]);
    if (computer_move == ROCK) {
        our_move = get_move(computer_move, 0, PAPER, 1, SPOCK);
    } 
    else if(computer_move == PAPER) {
	our_move = get_move(computer_move, 2, SCISSORS, 3, LIZARD);
    }
    else if (computer_move == SCISSORS) {
 	our_move = get_move(computer_move, 4, ROCK, 5, SPOCK);
    }
    else if (computer_move == LIZARD) {
	our_move = get_move(computer_move, 6, ROCK, 7, SCISSORS);
    }
    else if (computer_move == SPOCK) {
	our_move = get_move(computer_move, 8, PAPER, 9, LIZARD);
    }
    printf(">>> Our move => %s\n", choice_string[our_move]);
}

void __declspec(naked) rng_hijack() {
    __asm {
        pop return_address	// return address from top of stack
	mov rng_value, ecx	// copy rng from ecx
	pushad			// preserve registers and stack
    }
    make_choice();
    __asm {
	popad			// restore registers and stack
	mov eax, ecx		// restore mangled opcodes
	mov [ebp - 0x1C0], eax	// restore mangled opcodes
	push return_address	// restore 'next' address to stack
	ret			// return back to 'next' address
    }
}

void write_bytes(void *destination_address, void *patch, int length) {
    unsigned long oldProtect = 0;
    VirtualProtect(destination_address, length, PAGE_EXECUTE_READWRITE, &oldProtect);
    CopyMemory(destination_address, patch, length);
    VirtualProtect(destination_address, length, oldProtect, &oldProtect);
}

void code_cave(void *destination_address, void *patched_function, int patch_length, int mangled_bytes) {
    long offset = (long)patched_function - (long)destination_address - (patch_length - mangled_bytes);
    byte *patch = (byte *)malloc(sizeof(byte) * patch_length);
    patch[0] = 0xe8;								// call opcode
    CopyMemory(patch + 1, &offset, (patch_length - mangled_bytes - 1));		// patch address
    for (int i = 0; i < mangled_bytes; i++){
        patch[5 + i] = 0x90;							// nop alignment
    }
    write_bytes(destination_address, patch, patch_length);
}

DWORD WINAPI MainThread(LPVOID param) {
    code_cave((void*)rng_hook, rng_hijack, 8, 3);
    return 0;
}

bool WINAPI DllMain(HINSTANCE hModule, DWORD dwReason, LPVOID lpReserved) {
    if (dwReason == DLL_PROCESS_ATTACH) {
        AllocConsole();
	AttachConsole(ATTACH_PARENT_PROCESS);
	CreateThread(0, 0, MainThread, hModule, 0, 0);
    }
    else if (dwReason == DLL_PROCESS_DETACH) {
        getchar();								// don't auto-close console
    }
    return true;
}