Introduction

For an EDR to hook a user-mode process, it needs to load its DLL during the early stages of process creation. To do that from kernel mode, it uses two techniques.

The first technique is registering a process creation callback from the kernel driver by calling PsSetCreateProcessNotifyRoutineEx. Once the callback is called, it injects shellcode into that process and patches Ntdll!LdrLoadDll to jump to the injected shellcode. The shellcode will then load the desired DLL before the process finishes initializing .

The second method, which I learned from @dennisbabkin’s DLL Injection playlist playlist is registering an image load callback by calling PsSetLoadImageNotifyRoutine and waiting for kernel32.dll to be loaded into the process. Once the callback is triggered, it injects an APC routine into the process. in the last stages of process initialization, it calls Ntdll!NtTestAlert to empty the APC queue. Thus, our APC routine gets called and loads the DLL.

source code of KMDLLInjector

PsSetCreateProcessNotifyRoutineEx

PsSetCreateProcessNotifyRoutineEx is used to register a callback that gets called whenever a process is created.

Locating Ntdll Base Address

Since this technique relies on patching a jump instruction into the Ntdll!LdrLoadDll function prologue, we need to get the base address of Ntdll that is loaded into the newly created process. However, the issue with this is the process hasn’t fully initialized yet, and because of that, PEB_LDR_DATA is not initialized as shown in windbg in the following figure:

LdrIsNull

The solution I came up with is that since Ntdll is already mapped into the process’s memory space, I can use Ntoskrnl!ZwQueryVirtualMemory to enumerate memory regions mapped as images, check if the memory region start with PE header signiture.

while ( TRUE ) {
  Status = ZwQueryVirtualMemory( 
              ZwCurrentProcess(),
              BaseAddress,
              MemoryBasicInformation,
              &MemoryInfo,
              sizeof( memInfo ),
              &returnLength );
  if ( !NT_SUCCESS( Status ) )
			break;

  if ( MemoryInfo.Type == MEM_IMAGE ) {
    if ( IsPeHeader( MemoryInfo.BaseAddress ) ) {
      DBG_PRINT( "[+] NTDLL Base Address: %p", MemoryInfo.BaseAddress );
      break;
    }
  }

  BaseAddress = ( PVOID ) ( ( ULONG_PTR ) MemoryInfo.BaseAddress + MemoryInfo.RegionSize );
}

Injecting our shellcode

Once we found the base address of Ntdll we use it to resolve Ntdll!LdrLoadDll function address then patch its prologue with a jump instruction to our injected shellcode. the shellcode will do the following:

  1. Restores the original prologue of Ntdll!LdrLoadDll (removing our jump instruction),
  2. Call Ntdll!LdrLoadDll with the passed arguments.
  3. Load our desired DLL into the current process.

Position Indepedent Code (PIC)

Instead of writing the shellcode in assembly, I used trick from Rhydon1337: windows-kernel-dll-injector to use a function as a shellcode. Since the function will be position-independent code, I disabled stack cookies, optimization, and Control Flow Guard (CFG). I also used #pragma code_seg(".text$") to ensure that the functions is in the same order as in cpp file to calculate the start and the end of HookLdrLoadDll function.

#pragma optimize("", off)
#pragma code_seg(".text$A")
__declspec( safebuffers ) // disable stack cookies
// CFG can be disabled from Properties > C/C++ > Code Generation > Control Flow Guad > No
NTSTATUS HookLdrLoadDll( PWCHAR pwPathToFile, ULONG ulFlags, PUNICODE_STRING puModuleFileName, PHANDLE phModuleHandle )
{
	PHOOK_CONTEXT pContext = ( PHOOK_CONTEXT ) 0xBAADF00DBAADBEEF;
}
#pragma code_seg(".text$B")
DWORD HookLdrLoadDllEnd( ) {
	return 2;
}
#pragma optimize("", on)

The shellcode will need a saved copy of the LdrLoadDll prologue (used to restore NTDLL!LdrLoadDll) and ntdll exports (NtProtectVirtualMemory, LdrLoadDll, RtlInitUnicodeString). this data will get injected into shellcode memory from the kernel driver by scanning for the pattern 0xBAADF00DBAADBEEF and replacing it with address to this data.

Demo

PsImageLoadNotify

PsImageLoadNotify is used to register kernel callback that get called whenever an image is loaded into a process. we can use this callback to inject our desired DLL before process is fully initialized.

Catching KERNEL32.dll Image Load

The second dll that get loaded into process in it creation stages is kernel32.dll we can use this filter in our callback to catch these
Since we only want to inject the DLL into newly created user-mode processes, we’ll apply this filter in our callback:

if (
      // Exclude system images.
      !ImageInfo->SystemModeImage &&

      // Exclude images loaded remotely.
      ProcessId == PsGetCurrentProcessId( ) &&

      // Exclude image name that not end with kernel32.dll.
      (the first dll that is get loaded from user-mode on process creation is kernel32.dll)
      Utils::EndsWithUnicodeString( FullImageName, &uKernel32, TRUE ) &&

      // Exclude images that not get loaded via `LdrLoadDll`.
      // (This is checked by verifying if Teb->ArbitraryUserPointer == L"...\kernel32.dll".)
      Utils::IsLoadedByLdrLoadDll( &uKernel32 )
)
{
    // At this point, we're can inject our DLL
    // right after kernel32.dll has been loaded. 
}

APC Injection

LdrInitializeThunk is the first user-mode function executed while a process is still in its creation phase. One of the last things this function does is call NTDLL!NtTestAlert to empty the APC queue. (For more details on this mechanism, you can read the Outflank blog post: @outflank: Introducing Early Cascade Injection).

This presents an excellent opportunity for DLL injection. If we queue an APC before NTDLL!NtTestAlert is called, our code will execute seamlessly as part of the process’s normal initialization flow. From kernel mode, we can initialize and queue this APC using KeInitializeApc and KeInsertQueueApc.

The injected APC routine must be position-independent, so we will use the method discussed earlier.

Demo

Credits