
Why Hooking WriteProcessMemory Is Not Injection Detection: A Primitive-by-Primitive Teardown
Why Hooking WriteProcessMemory Is the Rule Most EDR Tools Anchor On
CyberSecurityNews published a report on 2026-09-27 describing a Windows process injection technique that reaches code execution in a remote process without touching WriteProcessMemory — the API most EDR behavioral rules are anchored on. This post is a primitive-by-primitive teardown of why hooking that one function is not injection detection: which alternative memory-write, mapping, and execution primitives route around it, and which kernel-side telemetry keeps working after the hooked function is swapped out. The public description of the reported technique is thin — no PoC in what I could read, no target build list, no primitive sequence. So I'm not reconstructing their chain. I'm tearing down the assumption underneath the hook, regardless of which chain they used.
My position: a hook on one API name is a signature, not a detection. It answers exactly one question — did this function get entered — and anyone writing an implant can simply decline to answer it. Everything below rests on documented Windows behavior, not on the unreleased PoC.
The Assumptions Baked Into a User-Mode Hook
A user-mode agent patches the prologue of WriteProcessMemory, or more often the ntdll stub for NtWriteVirtualMemory, and treats entry as intent. Three assumptions ride along:
- remote memory writes funnel through a single function name;
- the caller cannot route around the patched prologue;
- hook entry equals detection coverage.
All three are testable, and all three fail. The Win32 function is a wrapper, the wrapper is optional, and patching ntdll bytes has nothing to do with the kernel's own access checks. What the hook measures is whether the attacker took the convenient path.
Alternative Memory-Write Primitives That Bypass the WriteProcessMemory Hook
NtWriteVirtualMemory and the Syscall Beneath the Wrapper
WriteProcessMemory is a thin kernel32 wrapper that lands in ntdll's NtWriteVirtualMemory, which loads a system service number into EAX and executes syscall. On x64 the first argument moves into R10. That's the whole mechanism:
; shape of the transition, not a runnable stub
mov r10, rcx
mov eax, <SSN for NtWriteVirtualMemory>
syscall
ret
Two gaps open here. Hook only kernel32 and any loader calling the native API directly walks past you. Hook the ntdll stub and a caller that copies the SSN and issues its own syscall never enters the patched bytes. I'm deliberately not publishing an SSN — the value is build-specific and shifts between Windows releases, and the detection lesson doesn't depend on it.
Section Objects and NtMapViewOfSection: Writing Without a Write API
The cleanest way to put bytes in a remote process without naming a write API is to not write at all. Create a pagefile-backed section, map a PAGE_READWRITE view into your own process, map a view of the same section into the target as PAGE_EXECUTE_READWRITE, then memcpy into your local view. The shared page is the target's page. No WriteProcessMemory, no NtWriteVirtualMemory, no copy call to hook.
If I had to guess what the reported technique uses, this family is where I'd look first. That's inference, not something the public source establishes. Note what doesn't go away: mapping a view into another process still needs a target handle carrying PROCESS_VM_OPERATION. The write primitive changes; the access-right requirement doesn't.
Thread-Context and APC Primitives: Splitting Placement From Execution
The other half of the problem is splitting placement from execution. NtQueueApcThread (or Win32's QueueUserAPC) queues a routine a thread eventually runs when it hits an alertable wait — which is why the "early bird" variant queues before the thread even starts. GetThreadContext and SetThreadContext rewrite RIP/RSP on a suspended thread so it resumes inside the payload. The bytes arrived by some earlier mechanism, and the execution primitive never touches memory contents.
This is what breaks correlation rules built on a single hook: the write and the jump are separate events from separate APIs, with no shared call chain. A hook on a write function sees at most one of them.
Direct and Indirect Syscalls: Skipping the Hooked ntdll Prologue
Direct syscalls mean a stub in your own module containing the syscall instruction. Indirect syscalls mean reading the address of the syscall instruction inside ntdll and jumping to it, so the return address on the stack lands in ntdll and the call stack looks ordinary. Neither enters the hooked prologue. Direct syscalls leave a return address in a private, unbacked page — its own artifact. Indirect syscalls fix the stack cosmetics but change nothing about how the kernel sees the operation.
Primitive-by-Primitive Comparison: What Each Bypass Still Leaves Behind
This is the load-bearing part of the argument. Each row is a different naming convention; the telemetry column stays roughly the same across all of them.
| Primitive | What it avoids | What still fires |
|---|---|---|
WriteProcessMemory | nothing | user-mode hook, kernel write event, PROCESS_VM_WRITE handle |
NtWriteVirtualMemory via direct/indirect syscall | hooked ntdll prologue, wrapper hook | kernel write event, PROCESS_VM_WRITE handle, possible unbacked return frame |
Section map + NtMapViewOfSection | every write API name | PROCESS_VM_OPERATION handle, section creation, MEM_MAPPED region with no file backing |
QueueUserAPC / SetThreadContext | the write API entirely when placement used a mapping | THREAD_SET_CONTEXT handle rights, suspend/resume, APC queue event |
Protection change (NtProtectVirtualMemory) | the write hook if used only to flip page rights | protection-change event, new executable page not owned by a module |
Read the right column as one statement: the kernel sees the operation and the handle no matter which user-mode name produced it.
Detection That Survives When the Hooked Function Is Swapped Out
ETW-Ti Kernel Telemetry
Microsoft-Windows-Threat-Intelligence is the provider that matters, and the reason is architectural rather than feature-based: it emits from kernel mode, so user-mode byte patching in ntdll is irrelevant to it. Microsoft restricts subscription to protected consumers running at an antimalware-light protection level, which is why an unprivileged process can't just open it.
Conceptually, the event families cover remote allocation, protection changes, remote writes, thread creation and context manipulation, and APC queueing — the primitives from the table above, seen from the side that can't be unhooked. I'm keeping this at the conceptual level on purpose. Enumerating exact keywords and event IDs with a filter that turns them into a silent alert is closer to writing the bypass than documenting detection.
Cross-Process Handle and Access-Right Telemetry
This is the most underrated signal, and the one I'd build on first. PROCESS_VM_WRITE (0x0020) plus PROCESS_VM_OPERATION (0x0008) on another process is suspicious before anything is written. Add PROCESS_CREATE_THREAD (0x0002) and THREAD_SET_CONTEXT (0x0010) and you cover every row in that table. Alerting on the grant rather than the use is what makes the signal primitive-agnostic.
On Windows, Sysmon's ProcessAccess event carries the requested and granted access mask for cross-process handle opens, and CreateRemoteThread covers the classic thread path. Full kernel-side visibility into every handle grant needs a signed kernel driver using object callbacks, and newer builds restrict how freely that can be registered — so treat user-mode handle telemetry as strong but incomplete.
Call Stacks and Memory State as Injection Artifacts
Look at what the frames point into. A return address in memory no loaded module owns is a direct-syscall artifact. A MEM_PRIVATE page that's RX or RWX, appearing after an allocation and a protection flip with no module backing it, is a placement artifact. A MEM_MAPPED region with no file name and no image identity is what an anonymous section map looks like — a different signal, but still a signal. None of these share a code path with a write function, which is the point.
Correlation Over Signatures: Sequencing the Whole Chain
The detection is the ordering and the target: allocation, then protection change, then write or map, then thread start or context set, all against the same process and thread. Any one event alone is noisy. The sequence against a single target isn't. A user-mode hook firing is enrichment on top of that sequence, never the sequence itself.
Lab Reproduction: Which Hooks Fire, Primitive by Primitive (Authorized Lab Only)
In a throwaway VM, instrument the ntdll stubs for the watched names, exercise each variant against a dummy target, and record which hook fires. Frida's tracer is the fastest way to do the instrumenting step:
frida-trace -f target.exe -i "NtWriteVirtualMemory" -i "NtProtectVirtualMemory" -i "NtMapViewOfSection" -i "NtQueueApcThread" -i "SetThreadContext"Expected output shape, with counts left variable since they depend on how much the loader does at startup:
Started tracing 5 functions. Press Ctrl+C to stop.
NtWriteVirtualMemory(0x3c, 0x0, 0x0) <- variant A: WriteProcessMemory
NtProtectVirtualMemory(0x3c, ...) <- variant A
NtMapViewOfSection(0x44, 0x3c, ...) <- variant B: section map
NtQueueApcThread(...) <- variant C: placement + execution split
(no output from variant D: direct syscall issue)
Variant D printing nothing is the entire lesson: the payload landed and the instrumented prologue was never entered. Keep this in a lab, against a process you own, with a benign buffer as payload. Nothing here needs a weaponized stage.
What I Confirmed and What I Did Not Test
Confirmed from documentation and from instrumenting ntdll in a lab VM: WriteProcessMemory is a wrapper resolving to a native syscall, the native path can be issued without entering the patched prologue, section mapping places bytes in a remote process without naming a write API while still requiring PROCESS_VM_OPERATION, and user-mode instrumentation only reports prologue entry. Sysmon's ProcessAccess event exposing granted access masks is documented behavior, not a guess.
Not tested: the 2026-09-27 technique itself. I haven't reproduced it, and the public reporting doesn't establish its primitive sequence, target builds, or success rate. My guess that section mapping is involved is a guess. I also haven't consumed ETW-TI (that needs a protected, signed consumer) or registered object callbacks in a driver. My claim that kernel-side telemetry survives an unhook is a statement about where that telemetry is generated, not a test result I ran.
What to Fix First: Ranked Detection Engineering Priorities
Ranked, in the order I'd actually spend engineering time:
- Stop treating any single API hook as injection detection. Demote user-mode hooks to enrichment and pull them out of the alert path where they're currently the trigger.
- Move the primary signal to cross-process handle rights.
PROCESS_VM_WRITEplusPROCESS_VM_OPERATION, orTHREAD_SET_CONTEXTon a thread you don't own, before any memory operation occurs. - Correlate allocation, protection change, write or map, and execution into one sequence keyed on target process and thread.
- If you can operate a protected consumer, consume kernel threat-intelligence telemetry. It's the one signal that doesn't encode a naming convention an implant author can swap.
Hooks are expensive, too: they break across Windows builds, they carry maintenance cost, and a ten-line stub defeats them. Funding that instead of handle and kernel telemetry is the real detection gap.
Further Reading
- CyberSecurityNews report, 2026-09-27 — Windows process injection evading EDR without WriteProcessMemory (discovery link to the report)
- Microsoft Learn — WriteProcessMemory
- Microsoft Learn — MapViewOfFile
- Microsoft Learn — QueueUserAPC
- Microsoft Learn — SetThreadContext
- Microsoft Learn — VirtualProtect
- Microsoft Learn — Event Tracing for Windows reference
- Microsoft Learn — Sysmon
Conclusion
The reported technique is interesting mainly because it makes an old design flaw visible: a detection rule that names a function documents a preference, not a capability. If your alert path is "WriteProcessMemory was called," you're detecting the convenient path and calling it coverage. Move the trigger to handle rights and kernel-side operation events, keep the hooks as supporting detail, and the swap stops mattering.


