I believe Process Explorer uses the GetMappedFileNameA/W API exported from psapi.dll or it uses NtQueryVirtualMemory directly. Anyhow, ntdll.dll!NtQueryVirtualMemory can check for the memory section name of the loaded module even when the PEB entries are erased. VAD node / leaf erasing or linked-list reassignment in kernel mode can bypass this easily when it comes to enumerating for said module(s).
My own version of GetMappedFileName looks like this in a nutshell:
Code: Select allfunction GetMappedFileName(const hProcess: ULONG; lpV: Pointer; lpFileName: PAnsiChar; nSize: ULONG): ULONG; stdcall;
var
Ret: ULONG;
us: PUNICODE_STRING;
const
MEM_SECTION_NAME = 2;
AllocSz = (MAX_PATH * sizeof(WCHAR)) + (sizeof(WORD) * 2) + sizeof(DWORD);
begin
result := 0;
if (hProcess > 0) and (nSize > 0) and (lpFileName <> nil) then
begin
us := VirtualAlloc(nil, AllocSz, MEM_COMMIT, PAGE_READWRITE);
if us <> nil then
begin
if (NtQueryVirtualMemory(hProcess, lpV, MEM_SECTION_NAME, us, AllocSz, @Ret) = 0) and (Ret <> DWORD(-1)) then
begin
if (nSize >= (us^.Len div sizeof(WCHAR))) then
result := WideCharToMultiByte(CP_ACP, 0, us^.Buf, -1, lpFileName, us^.Len, nil, nil) - 1
else
SetLastError(ERROR_INSUFFICIENT_BUFFER);
end;
VirtualFree(us, 0, MEM_RELEASE);
end;
end;
end;