MmCopyVirtualMemory ships in every version of ntoskrnl.exe you’re likely to run, it’s exported by name, and Microsoft has never once put it in the WDK headers. The function copies memory between two arbitrary virtual address spaces without attaching to either process. Every kernel cheat uses it. A lot of rootkits use it. The implementation details that actually matter – what PreviousMode does to validation, what happens when the source page is guarded or paged out, how to avoid faulting when the target process tears down – are nowhere in the documentation because there is no documentation.

Here’s what it actually does.

Declaration

The WDK doesn’t expose this. You declare it yourself:

c
NTSTATUS MmCopyVirtualMemory(
    PEPROCESS SourceProcess,
    PVOID     SourceAddress,
    PEPROCESS TargetProcess,
    PVOID     TargetAddress,
    SIZE_T    BufferSize,
    KPROCESSOR_MODE PreviousMode,
    PSIZE_T   NumberOfBytesCopied
);

PreviousMode takes either KernelMode (0) or UserMode (1). Everything else is what you’d expect from the name.

The relationship to the documented API: NtReadVirtualMemory and NtWriteVirtualMemory (the syscalls behind ReadProcessMemory and WriteProcessMemory) call MmCopyVirtualMemory internally. When you call MmCopyVirtualMemory directly from a driver, you bypass the syscall layer entirely and go straight to the memory manager.

PreviousMode semantics

This is the one that catches people.

When PreviousMode is UserMode, the function calls ProbeForRead (or ProbeForWrite) on the source and target address ranges before touching them. That probe validates:

  • The entire range fits within user-mode address space (below MmHighestUserAddress)
  • The range doesn’t wrap around zero

If either probe fails, you get STATUS_ACCESS_VIOLATION back before any copy happens. Guard page detection also fires during the probe: a PAGE_GUARD page raises a STATUS_GUARD_PAGE_VIOLATION exception when probed, which the probe translates into STATUS_ACCESS_VIOLATION at the call site.

When PreviousMode is KernelMode, both probes are skipped. You can supply any valid virtual address – including kernel-mode addresses – in either SourceAddress or TargetAddress. Address range validation does not happen. A kernel-to-kernel copy with KernelMode works fine; the same copy with UserMode fails the probe because kernel addresses are above MmHighestUserAddress.

For cross-process reads where you control the source address and it’s in user space: use KernelMode if you need to avoid the overhead of probe exceptions on guard pages, use UserMode if you want to let the function handle out-of-range addresses gracefully rather than raising a structured exception in your driver.

In practice, kernel cheats use KernelMode universally:

c
/* kernel cheat driver -- excerpt */
MmCopyVirtualMemory(
    process,
    (void*)packet.address,
    PsGetCurrentProcess(),
    &packet.response,
    packet.size,
    KernelMode,
    &bytes
);

process is the game process obtained with PsLookupProcessByProcessId. PsGetCurrentProcess() is the driver’s own process context – the response field lives there. KernelMode skips the probe, which is safe here because the driver already validated the packet size.

What happens on guard pages and paged-out memory

Guard pages: with UserMode, the probe raises STATUS_ACCESS_VIOLATION before the copy. With KernelMode, the actual page walk happens and the guard page exception fires during the copy. The memory manager typically catches it, strips the guard bit (PAGE_GUARD is cleared on first access), and completes the access. The function returns STATUS_SUCCESS. The guard bit is gone. Behavior may vary in edge cases – verify empirically if guard-page semantics matter for your target build.

This is the observable side effect: MmCopyVirtualMemory with KernelMode silently clears guard pages in the source process. If the target is using stack guard pages as a stack overflow detector, your read removes the guard. A debugger watching for STATUS_GUARD_PAGE_VIOLATION on that process won’t see it – the access went through the kernel, not through a user-mode exception dispatcher.

Paged-out memory: the memory manager faults the page in as part of the copy. This is the main functional difference from a physical-memory-based approach. MmCopyVirtualMemory operates on virtual addresses and respects the full demand-paging machinery. If the page is on disk, it comes back. If the page is in a process that’s been swapped out, the memory manager brings the working set back. This is slower than a physical read but correct for pageable memory.

Uncommitted memory (reserved but not committed, or already freed): you get STATUS_ACCESS_VIOLATION. The function doesn’t attempt to commit pages.

Comparison to the MDL approach

The alternative for cross-process reads is an MDL pipeline:

c
PMDL mdl = IoAllocateMdl(SourceAddress, BufferSize, FALSE, FALSE, NULL);
MmProbeAndLockPages(mdl, UserMode, IoReadAccess);
PVOID mapped = MmGetSystemAddressForMdlSafe(mdl, NormalPagePriority);
RtlCopyMemory(TargetBuffer, mapped, BufferSize);
MmUnlockPages(mdl);
IoFreeMdl(mdl);

You build an MDL for the source pages, lock them into physical memory, map them into system address space, copy out of the system mapping, then unlock and free. The pages are pinned for the duration – they can’t be paged out while locked.

The tradeoff:

  • MDL pins pages. MmCopyVirtualMemory doesn’t. If you’re copying a hot region that gets modified mid-copy, MDL gives you a stable snapshot (assuming the physical pages don’t change under you). MmCopyVirtualMemory reads the virtual address, which can be remapped or modified between accesses.
  • MDL requires you to be in a process context where you can probe the source. You call MmProbeAndLockPages at IRQL <= APC_LEVEL; the probe needs the source process’s page table visible, which means you usually call it while attached to the source process with KeStackAttachProcess. MmCopyVirtualMemory handles the context switch internally.
  • MDL allocates a descriptor proportional to the page count. For large reads this is non-trivial pool overhead. MmCopyVirtualMemory uses an internal transfer mechanism that doesn’t require explicit descriptor allocation.
  • MDL is documented. MmCopyVirtualMemory isn’t.

For a simple read-from-game pattern, MmCopyVirtualMemory is three lines instead of six, doesn’t require managing attach/detach, and is what every example uses. The MDL approach is worth knowing when you need pinned pages or when you’re above APC_LEVEL (where MmCopyVirtualMemory will bugcheck you – it requires IRQL <= APC_LEVEL).

The process teardown race

PsLookupProcessByProcessId returns an EPROCESS pointer and, if successful, increments the object reference count. You must ObDereferenceObject the pointer when you’re done. This is not optional: the object manager will not free the process object while the reference count is above zero.

The race is between the reference count increment and the target process exiting:

  1. You call PsLookupProcessByProcessId – succeeds, it increments the EPROCESS object reference count by one for your caller.
  2. The process exits. The kernel drops its own internal reference. You still hold the reference from PsLookupProcessByProcessId.
  3. You call MmCopyVirtualMemory. The EPROCESS is valid (the object exists), but the virtual address space has been torn down – VadRoot is empty, page tables have been freed, virtual addresses in that process map to nothing meaningful.
  4. MmCopyVirtualMemory faults walking the page tables of the exited process, or returns STATUS_PROCESS_IS_TERMINATING.

The function does check for process termination before copying. It inspects flags in EPROCESS (the ProcessExiting flag) to detect a terminated process. You’ll typically get STATUS_PROCESS_IS_TERMINATING if the process has exited cleanly. The fault scenario is rarer and depends on timing.

The correct pattern:

c
PEPROCESS target = NULL;
NTSTATUS status = PsLookupProcessByProcessId((HANDLE)pid, &target);
if (!NT_SUCCESS(status)) {
    return status;
}

SIZE_T copied = 0;
status = MmCopyVirtualMemory(
    target,
    source_address,
    PsGetCurrentProcess(),
    dest_buffer,
    size,
    KernelMode,
    &copied
);

ObDereferenceObject(target);
return status;

Call ObDereferenceObject after the copy, not before. The reference keeps the EPROCESS object alive for the duration of the call. You can still lose the race – the process can exit between PsLookupProcessByProcessId and MmCopyVirtualMemory – but the EPROCESS object itself stays valid, and the function handles the terminated-process case. What you avoid by holding the reference is having ntoskrnl free the EPROCESS memory underneath you.

Practical usage pattern

Here’s the full kernel-side dispatch from a real BYOVD implementation. The vulnerable driver receives an IOCTL with this struct:

c
struct copy_buffer_t {
    HANDLE  h_target_process;
    void*   from_address;
    void*   to_address;
    SIZE_T  buffer_size;
    SIZE_T* number_of_bytes_copied;
    int     status;
    SIZE_T  maybe_index;
};

The usermode side sends h_target_process (obtained by asking the driver to open the target via PsLookupProcessByProcessId + ObOpenObjectByPointer) and the addresses to copy between. The driver resolves the handle to an EPROCESS pointer with ObReferenceObjectByHandle, runs MmCopyVirtualMemory, then ObDereferenceObject.

c
NTSTATUS DispatchCopy(PDEVICE_OBJECT DeviceObject, PIRP Irp)
{
    UNREFERENCED_PARAMETER(DeviceObject);
    PIO_STACK_LOCATION stack = IoGetCurrentIrpStackLocation(Irp);
    copy_buffer_t* req = (copy_buffer_t*)Irp->AssociatedIrp.SystemBuffer;

    if (stack->Parameters.DeviceIoControl.InputBufferLength < sizeof(copy_buffer_t)) {
        return CompleteIrp(Irp, STATUS_BUFFER_TOO_SMALL, 0);
    }

    PEPROCESS target = NULL;
    NTSTATUS status = ObReferenceObjectByHandle(
        req->h_target_process,
        PROCESS_VM_READ,
        *PsProcessType,
        KernelMode,
        (PVOID*)&target,
        NULL
    );
    if (!NT_SUCCESS(status)) {
        return CompleteIrp(Irp, status, 0);
    }

    SIZE_T copied = 0;
    status = MmCopyVirtualMemory(
        target,
        req->from_address,
        PsGetCurrentProcess(),
        req->to_address,
        req->buffer_size,
        KernelMode,
        &copied
    );

    ObDereferenceObject(target);
    return CompleteIrp(Irp, status, sizeof(copy_buffer_t));
}

ObReferenceObjectByHandle validates that the handle is for a process object and increments the reference count. Using ObReferenceObjectByHandle instead of PsLookupProcessByProcessId means the caller supplies a handle to the target process (opened with PsLookupProcessByProcessId + ObOpenObjectByPointer in a prior IOCTL), which is the pattern in kur’s vul_driver implementation.

The diagram

SourceProcessEPROCESSSourceAddressTargetProcessEPROCESSTargetAddressMmCopyVirtualMemorykernelProbeForRead(UserMode only)page fault / bring-inRtlCopyMemoryNumberOfBytesCopied

Why not WriteProcessMemory

WriteProcessMemory (and its kernel-side equivalent NtWriteVirtualMemory) goes through the syscall layer and fires every relevant ETW provider and minifilter callback on the way. From a kernel driver, you call MmCopyVirtualMemory directly and bypass all of that. The copy is invisible to user-mode tooling: it doesn’t show up in process monitor, doesn’t fire ObRegisterCallbacks on the process handle, doesn’t generate an ETW event. The only way to catch it is a kernel-mode callback or a hypervisor watching for the page table walks.

The function name is stable across Windows versions going back to at least Windows 7 and the export is present in every ntoskrnl variant (checked: x64 free and checked builds, core/desktop). That’s why it’s still the default choice a decade later.