Solving CTF challenges with DLL injection
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;
}