thread-execution-hijacking.md (9370B)
1 --- 2 title: "Thread Execution Hijacking" 3 description: "Suspending an existing thread and repointing its instruction pointer at your code, so nothing new is ever created to notice." 4 category: exploitation 5 subcategory: "Process Injection" 6 tags: [process-injection, evasion, windows, t1055, entry-point] 7 tools: [windbg, process-hacker, sysmon, volatility] 8 difficulty: advanced 9 updated: 2026-10-03 10 upstreamName: "ired.team" 11 upstreamUrl: "https://ired.team/offensive-security/code-injection-process-injection/injecting-to-remote-process-via-thread-hijacking" 12 upstreamAuthor: "Mantvydas Baranauskas" 13 upstreamLicense: none 14 upstreamRelation: derived 15 references: 16 - name: "AddressOfEntryPoint code injection" 17 url: "https://ired.team/offensive-security/code-injection-process-injection/addressofentrypoint-code-injection" 18 author: "Mantvydas Baranauskas" 19 license: none 20 relation: derived 21 note: "The suspended-process variant that overwrites the image entry point instead of repointing RIP." 22 - name: "AddressOfEntryPoint injection without VirtualAllocEx RWX" 23 url: "https://ired.team/offensive-security/code-injection-process-injection/addressofentrypoint-code-injection-without-virtualallocex-rwx" 24 author: "Mantvydas Baranauskas" 25 license: none 26 relation: derived 27 note: "The variant that avoids allocating a fresh RWX region; folded in below with the caveat about transient page protection." 28 - name: "MITRE ATT&CK T1055.003 — Thread Execution Hijacking" 29 url: "https://attack.mitre.org/techniques/T1055/003/" 30 license: none 31 relation: inspired 32 note: "Technique ID and detection data sources." 33 --- 34 35 ## What this covers 36 37 Instead of creating a thread to run your code, you take one that already exists. Suspend it, 38 read its register context, point its instruction pointer at shellcode you have written into 39 the process, and resume. The thread belongs to the target, was started by the target, and 40 appears in no remote-thread telemetry. 41 42 Two variants are folded in here because they are the same idea at different moments: 43 44 - **Running-thread hijack** — against a live process, grab its main thread and repoint `RIP`. 45 - **AddressOfEntryPoint** — against a process you created suspended, overwrite the image's 46 entry point so the thread runs your code when first resumed. No context manipulation 47 needed, because the thread has not started yet. 48 49 ## Prerequisites 50 51 - `PROCESS_VM_OPERATION`, `PROCESS_VM_WRITE` on the process, and `THREAD_SUSPEND_RESUME`, 52 `THREAD_GET_CONTEXT`, `THREAD_SET_CONTEXT` on the thread. 53 - Shellcode must be position-independent. You are jumping into it with whatever register 54 state the hijacked thread happened to have. 55 - **Restore or accept the loss.** Save the original context before you change it. Code that 56 does not return to the saved `RIP` kills the host thread, which is often the difference 57 between a quiet injection and an application that visibly hangs. 58 59 ## Walkthrough — running-thread hijack 60 61 | # | Call | Purpose | Artefact | 62 |---|---|---|---| 63 | 1 | `OpenProcess` | handle to the target | Sysmon 10 | 64 | 2 | `VirtualAllocEx(..., PAGE_EXECUTE_READWRITE)` | space for shellcode | private RWX region | 65 | 3 | `WriteProcessMemory` | the shellcode | cross-process write | 66 | 4 | `CreateToolhelp32Snapshot` / `Thread32First` | find a thread of the target | thread enumeration | 67 | 5 | `OpenThread(THREAD_ALL_ACCESS, ...)` | handle to that thread | — | 68 | 6 | `SuspendThread` | freeze it | a suspended thread in a running process | 69 | 7 | `GetThreadContext` | capture registers | — | 70 | 8 | set `ctx.Rip = shellcodeAddress` | redirect | **thread with RIP outside any module** | 71 | 9 | `SetThreadContext` | apply | EDR commonly hooks `NtSetContextThread` | 72 | 10 | `ResumeThread` | run | execution from private memory | 73 74 Finding the target's thread is step one in practice — you need a thread ID, usually the 75 process's main thread. 76 77 <figure class="shot"> 78 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28609%29.png" 79 alt="Thread listing for the target notepad process with its main thread ID identified" 80 loading="lazy" referrerpolicy="no-referrer"> 81 <figcaption>Locating the target's main thread ID. 82 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 83 </figcaption> 84 </figure> 85 86 The captured context is what you must preserve — it holds the CPU registers as the thread 87 was executing, and it is the only route back to normal operation. 88 89 <figure class="shot"> 90 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28612%29.png" 91 alt="Retrieved thread context structure showing the hijacked thread's CPU register values" 92 loading="lazy" referrerpolicy="no-referrer"> 93 <figcaption>The hijacked thread's captured register context. 94 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 95 </figcaption> 96 </figure> 97 98 After the context is set, `RIP` points into the allocated region rather than into any loaded 99 module — which is simultaneously the point of the technique and the way it is caught. 100 101 <figure class="shot"> 102 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28613%29.png" 103 alt="Thread context showing RIP now set to the shellcode address inside the target process memory" 104 loading="lazy" referrerpolicy="no-referrer"> 105 <figcaption><code>RIP</code> repointed to the shellcode address in the target's memory. 106 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 107 </figcaption> 108 </figure> 109 110 <figure class="shot"> 111 <img src="https://raw.githubusercontent.com/mantvydasb/RedTeaming-Tactics-and-Techniques/8cdbdd60eb4a8997e689649f3911f7c893e59ed9/.gitbook/assets/image%20%28617%29.png" 112 alt="Shellcode executing after the hijacked thread is resumed, with a shell returned" 113 loading="lazy" referrerpolicy="no-referrer"> 114 <figcaption>Execution after <code>ResumeThread</code>. 115 <span class="shot-credit">ired.team · Mantvydas Baranauskas</span> 116 </figcaption> 117 </figure> 118 119 ## Walkthrough — AddressOfEntryPoint 120 121 Against a process started `CREATE_SUSPENDED`, the thread exists but has not executed, so 122 there is no context to juggle: 123 124 1. `CreateProcessA(..., CREATE_SUSPENDED, ...)`. 125 2. Read the PEB to find `ImageBaseAddress`, parse the headers, compute 126 `ImageBase + OptionalHeader.AddressOfEntryPoint`. 127 3. `WriteProcessMemory` your shellcode directly over the entry point. 128 4. `ResumeThread`. 129 130 **On the "without RWX" framing.** This variant is often presented as avoiding RWX memory, 131 and it does avoid allocating a *fresh* RWX region — you are writing into the image's own 132 executable section. But `WriteProcessMemory` has to make that page writable to write it, so 133 an executable page does transiently become writable regardless. The honest claim is that 134 there is no new private RWX allocation to find afterwards, not that no write-to-executable 135 ever happens. A hook on `NtProtectVirtualMemory` still sees it. 136 137 The upside for the operator is real though: the shellcode lives inside an image-backed, 138 on-disk-matching region, so `malfind`-style private-RWX hunting misses it entirely. The 139 catch moves to comparing the in-memory entry point against the file on disk. 140 141 ## Detection 142 143 **Thread start address vs module list.** `NtQueryInformationThread` with 144 `ThreadQuerySetWin32StartAddress`, compared against the loaded modules, finds any thread 145 beginning in unbacked memory. Works on the running-thread variant. 146 147 **`NtSetContextThread` on a foreign thread.** Setting the context of a thread in another 148 process is rare outside debuggers. Most EDR products hook this specifically, because it is 149 the one call the running-thread variant cannot avoid. 150 151 **No Sysmon event 8.** Worth stating plainly: hijacking creates no remote thread, so 152 `CreateRemoteThread` telemetry never fires. A detection strategy resting on event 8 does not 153 see this technique at all. Event 10 (`ProcessAccess`) is the one that fires, with 154 `GrantedAccess` including `PROCESS_VM_WRITE`. 155 156 **Suspend/resume pattern.** `SuspendThread` on a thread you do not own, followed within 157 milliseconds by `SetThreadContext` and `ResumeThread`, is the signature. Thread suspension 158 counts are visible in `Process Hacker`. 159 160 **Entry-point integrity.** For the AddressOfEntryPoint variant, read the first bytes at 161 `ImageBase + AddressOfEntryPoint` in memory and compare with the same offset in the file on 162 disk. A mismatch in a process that has just started is conclusive, and it is the only check 163 that catches this variant reliably. 164 165 **Private RWX.** Applies to the running-thread variant only — step 2 leaves a 166 `MEM_PRIVATE` executable region. `malfind`, or `VirtualQueryEx` enumeration looking for 167 committed executable private pages. 168 169 ## References 170 171 - [MITRE ATT&CK T1055.003](https://attack.mitre.org/techniques/T1055/003/) 172 - [Process hollowing](/sheets/exploitation/process-hollowing) — the variant that unmaps the 173 host image first 174 - [MITRE ATT&CK T1055.004](https://attack.mitre.org/techniques/T1055/004/) — APC queue 175 injection, the other route to running code on a thread you did not create