Pavel Yosifovich, co-author of “Windows Internals 7th Edition, Part 1” and author of “Windows Native API Programming”, previously hid a DLL from the current process’s own module list with a couple of pointer writes. This post takes the same idea and moves it into a different process entirely, which sounds like a small change and turns out not to be one. This post is for malware analysts, EDR engineers, and Windows systems programmers who want to understand exactly what changes when a technique has to cross a process boundary, and why that change is where the real work is.
Same Idea, Different Address Space
The underlying technique has not changed. A process’s loaded modules live in three LIST_ENTRY-based lists anchored off the PEB’s Ldr field, and removing a module from one of those lists is ordinary doubly linked list surgery: two pointer writes, and a LDR_DATA_TABLE_ENTRY is no longer reachable by a forward or backward walk. In the current process, that surgery is direct. You dereference Ldr, you dereference the list head, you follow Flink, and every pointer along the way means exactly what it says, because it points somewhere in your own address space.
None of that holds once the target is a different process. Every address you obtain, the PEB address, the loader data address, each list entry’s address, is a valid pointer only in the target process’s address space. Dereferencing it in your own process either crashes or silently reads whatever unrelated data happens to live at that address locally. The conceptual technique is unchanged; the mechanics of touching it are not.
Opening the Target with the Minimum It Needs
Hiding a DLL in an arbitrary process starts with OpenProcess, and the access mask matters more than it does for the current process. Asking for PROCESS_ALL_ACCESS is tempting and usually wrong: request more than the target allows and the call fails outright, where a more targeted request might have succeeded. This technique needs exactly three things: PROCESS_VM_READ and PROCESS_VM_WRITE to read and write the target’s memory, and PROCESS_QUERY_LIMITED_INFORMATION to query basic process information. For the current process, OpenProcess effectively always succeeds with any mask; for a random target process, it depends entirely on what the caller’s token allows against that process, so the explicit, minimal mask is what gives the call the best chance of working at all.
Finding the PEB Without NtCurrentTeb
Inside your own process, getting to the PEB is NtCurrentTeb()->ProcessEnvironmentBlock, a single dereference backed by a CPU register. That shortcut only exists for the thread that is asking about itself. For another process, the PEB address comes from NtQueryInformationProcess, the native API for querying process information, called with the ProcessBasicInformation class and a PROCESS_BASIC_INFORMATION structure. That structure’s PebBaseAddress field is the answer, but it is worth being deliberate about its type: returning it as void pointer rather than a typed pointer to PEB is not a style choice, it is a safety rail. A typed pointer invites the arrow operator, and every member access through that arrow operator would compile cleanly and fail at runtime, because the address is only valid in the other process. A void makes that mistake impossible to make by accident.
Read and Write Helpers Around the Remote Process
Working with a remote address space comes down to two primitives: ReadProcessMemory and WriteProcessMemory, both requiring the process handle opened earlier, a remote address, a local buffer, and a byte count. Both can also report a partial transfer through their “bytes actually read or written” output parameter, so a thin wrapper that checks that count against the requested size, not just the boolean return value, is worth writing once: a partial write that goes unnoticed is a worse failure mode than an outright error.
A small templated read on top of the raw read function makes the rest of the code considerably less noisy. Given a process handle and a remote address, it reads sizeof(T) bytes into a local T and returns it by value, which works cleanly for trivially copyable types: a PEB, a PEB_LDR_DATA, a LIST_ENTRY, a LDR_DATA_TABLE_ENTRY. Every one of these structures that used to be “dereference a pointer” in the single-process version becomes “read a local copy of the whole structure,” because there is no other way to see its fields.
A UNICODE_STRING needs its own helper, since its Buffer field is itself a remote pointer to character data, not inline storage. Reading one remotely means resizing a local std::wstring to the string’s Length in characters (the field is in bytes, so divide by two), then calling the raw read function to copy that many bytes directly into the string’s own buffer. Nothing about the single-process version of this problem looked anything like this, because in that version the characters were simply there to read.
Walking the Module List as Local Copies
With those helpers in place, walking the target’s module list looks structurally like the single-process version, with every dereference swapped for a read. The PEB is read whole into a local PEB variable. Its Ldr field is a remote address, so the PEB_LDR_DATA it points to is read whole as well. The head of the in-memory order module list is computed from that local copy using offsetof, cast through a byte pointer so the arithmetic lands in bytes rather than in units of PEB_LDR_DATA, and the result is, once again, a remote address rather than something to dereference.
Iterating that list means reading a LIST_ENTRY at the current address to get its Flink, checking it against the head’s own remote address to know when the loop is done, and reading the full LDR_DATA_TABLE_ENTRY at each Flink to reach its fields. CONTAINING_RECORD still does the same offset arithmetic it always did, just against a local copy of a structure instead of a live one. Matching by name uses BaseDllName rather than FullDllName, read through the UNICODE_STRING helper into a local std::wstring, then compared with an ordinary case-insensitive wcsicmp against the target name, since by this point both sides are plain null-terminated strings and there is no UNICODE_STRING left to compare.
Validating this walk against EnumProcessModules, which already accepts an arbitrary process handle and does all of this remote-process work internally, confirms the two agree before anything gets unlinked: run both against mspaint.exe, and both report the same module list, mspaint.exe, ntdll.dll, kernelbase.dll, and everything else it loads.
Unlinking Across the Boundary
Once the matching LDR_DATA_TABLE_ENTRY is found, the unlink itself is conceptually identical to the single-process version: the previous node’s forward link is set to point past the current node, and the next node’s backward link is set to point back to the previous node. What changes is that these are no longer pointer assignments, they are WriteProcessMemory calls. The previous node’s Flink field, at an address in the target process, is overwritten with the current node’s Flink value. The next node’s Blink field, similarly a remote address, is overwritten with the previous node’s address. Two writes, same as before, just aimed at someone else’s memory instead of your own.
Testing this against mspaint.exe‘s kernelbase.dll and re-running both enumeration methods shows the same result as the single-process case: kernelbase.dll disappears from EnumProcessModules‘s output and from the manual PEB walk, in a process that was never touched by anything other than two remote writes.
Why Debugging This Is Harder Than the Code Looks
The single biggest practical difference from the earlier video is not in the code, it is in how little a debugger can tell you while you write it. Every address this code touches, the PEB address, the Ldr pointer, every Flink and Blink, is a number that has no meaning in the process doing the debugging. Casting one of those addresses to a PEB* in your own debugger and dereferencing it just reads garbage or faults, because the debugger is reading your process’s memory, not the target’s. The only way to sanity-check a value mid-run is to open a separate tool, a memory viewer in Process Hacker or System Informer, WinDbg attached to the target process, or similar, pointed at the target process specifically, and compare what it shows at that address against what the code expects to be there. This is also exactly why the read and write helper functions are worth writing deliberately and testing in isolation first: once the loop is running, there is very little direct visibility into whether a given remote address is correct until the final result either matches or does not.
What This Means Practically
- Everything in the single-process technique that was a pointer dereference becomes a
ReadProcessMemorycall; everything that was a pointer write becomes aWriteProcessMemorycall. The doubly linked list surgery itself does not change. OpenProcessfor a target process should request the minimum access it actually needs:PROCESS_VM_READ,PROCESS_VM_WRITE, andPROCESS_QUERY_LIMITED_INFORMATIONare enough for this technique.- A remote process’s PEB address comes from
NtQueryInformationProcesswithProcessBasicInformation. - A remote PEB address should be typed
void, notPEB, so the arrow operator cannot be used by mistake on a pointer that is only valid in another address space. WriteProcessMemoryandReadProcessMemorycan both report a partial transfer; check the actual bytes transferred, not only the boolean return value.- A
UNICODE_STRING‘s character buffer is itself remote; reading it means resizing a local string buffer to itsLengthin characters and reading the bytes directly into it. - A debugger attached to your own process cannot make sense of these addresses; verifying them needs a tool or debugger pointed at the target process itself.
- A technique that injects its own code into the target (so the unlink runs from inside that process) avoids all of this cross-process read/write machinery entirely, since the pointers are local from the DLL’s own perspective; this video deliberately takes the harder, external route to show what that machinery actually involves.
Keep Learning
If you want to understand these techniques well enough to build and detect them, these TrainSec courses go deeper:
- Malware Analysis and Development: stealth and anti-detection techniques like this one, and how they look from the defender’s side
- Windows System Programming: the Win32 and native API foundations this relies on, from process handles to native data structures
- EDR Internals: Research and Development: how an endpoint product builds visibility that does not depend solely on the PEB
Related reading in the knowledge library: Hiding a DLL from the PEB Loader List, the single-process version of this exact technique, Process Memory Map in Code: Walking VirtualQueryEx and Finding the PEB (Part 1), another way to reach a process’s PEB address, and DLL Injection with SetWinEventHook, one way a DLL ends up in a target process in the first place.