Image & Registry Notifications

Chapter 5

  • An image-load notification occurs whenever an executable, DLL, or driver is loaded into memory on the system.

  • A registry notification is triggered when specific operations in the registry occur, such as key creation or deletion.

How Image-Load Notifications Work

  • By collecting image-load telemetry, we can gain extremely valuable information about a process’s dependencies. For example, offensive tools that use in-memory .NET assemblies, such as the execute-assembly command in Cobalt Strike’s Beacon, routinely load the common language runtime clr.dll into their processes. By correlating an image load of clr.dll with certain attributes in the process’s PE header, we can identify non-.NET processes that load clr.dll, potentially indicating malicious behavior.

Registering A Callback Routine

  • The kernel facilitates these notifications through nt!PsSetLoadImageNotifyRoutine() API. If a driver wants to receive these events, the developers simply pass in their callback function as the only parameter to that API and system will invoke the internal callback function each time a new image is loaded into a process.

NTSTATUS DriverEntry(PDRIVER_OBJECT pDriverObj, PUNICODE_STRING pRegPath)
{
    NTSTATUS status = STATUS_SUCCESS;

    --snip--

    status = PsSetLoadImageNotifyRoutine(ImageLoadNotificationCallback);

    --snip--
}

void ImageLoadNotificationCallback(
    PUNICODE_STRING FullImageName,
    HANDLE ProcessId,
    PIMAGE_INFO ImageInfo)
{
    --snip--
}

Viewing Registered Callback Routines

  • The system also adds a pointer to the function nt!PspLoadImageNotifyRoutine(). We can traverse this array in the same way as for the process-notification callbacks.

1: kd> dx ((void**[0x40])&nt!PspLoadImageNotifyRoutine)
.Where(a => a != 0)
.Select(a => @$getsym(@$getCallbackRoutine(a).Function))
[0]         : WdFilter+0x467b0 (fffff803`4ade67b0)
[1]         : ahcache!CitmpLoadImageCallback (fffff803`4c95eb20)
  • Image loads are a critical datapoint for EDRs, so we can expect to see any EDRs loaded on the system here alongside Defender [0] and the Customer Interaction Tracker [1].

Collecting Image-Load Information

  • Upon image-load, the callback routine receives a pointer to an IMAGE_INFO structure.

typedef struct _IMAGE_INFO {
    union {
            ULONG Properties;
            struct {
                    ULONG ImageAddressingMode : 8;
                    ULONG SystemModeImage : 1;
                    ULONG ImageMappedToAllPids : 1;
                    ULONG ExtendedInfoPresent : 1;
                    ULONG MachineTypeMismatch : 1;
                    ULONG ImageSignatureLevel : 4;
                    ULONG ImageSignatureType : 3;
                    ULONG ImagePartialMap : 1;
                    ULONG Reserved : 12;
           };
      };
      PVOID ImageBase;
      ULONG ImageSelector;
      SIZE_T ImageSize;
      ULONG ImageSectionNumber;
} IMAGE_INFO, *PIMAGE_INFO;
  • SystemModeImage is set to 0 if the image is mapped to user address space, such as in DLLs and EXEs. If this field is set to 1, the image is a driver being loaded into kernel.

  • The ImageSignatureLevel field represents the signature level assigned to the image by Code Integrity, a Windows feature that validates digital signatures, among other things. This information is useful for systems that implement some software restriction policy. For example, an organization might require that certain systems in the enterprise run signed code only. These signature levels are constants defined in the ntddk.h header.

#define SE_SIGNING_LEVEL_UNCHECKED     0x00000000
#define SE_SIGNING_LEVEL_UNSIGNED      0x00000001
#define SE_SIGNING_LEVEL_ENTERPRISE    0x00000002
#define SE_SIGNING_LEVEL_CUSTOM_1      0x00000003
#define SE_SIGNING_LEVEL_DEVELOPER     SE_SIGNING_LEVEL_CUSTOM_1
#define SE_SIGNING_LEVEL_AUTHENTICODE  0x00000004
#define SE_SIGNING_LEVEL_CUSTOM_2      0x00000005
#define SE_SIGNING_LEVEL_STORE         0x00000006
#define SE_SIGNING_LEVEL_CUSTOM_3      0x00000007
#define SE_SIGNING_LEVEL_ANTIMALWARE   SE_SIGNING_LEVEL_CUSTOM_3
#define SE_SIGNING_LEVEL_MICROSOFT     0x00000008
#define SE_SIGNING_LEVEL_CUSTOM_4      0x00000009
#define SE_SIGNING_LEVEL_CUSTOM_5      0x0000000A
#define SE_SIGNING_LEVEL_DYNAMIC_CODEGEN 0x0000000B
#define SE_SIGNING_LEVEL_WINDOWS       0x0000000C
#define SE_SIGNING_LEVEL_CUSTOM_7      0x0000000D
#define SE_SIGNING_LEVEL_WINDOWS_TCB   0x0000000E
#define SE_SIGNING_LEVEL_CUSTOM_6      0x0000000F
  • The ImageSignatureType field, a companion to ImageSignatureLevel, defines the signature type with which Code Integrity has labeled the image to indicate how the signature was applied. The SE_IMAGE_SIGNATURE_TYPE enumeration that defines these values is shown below:

typedef enum _SE_IMAGE_SIGNATURE_TYPE
{
        SeImageSignatureNone = 0,
        SeImageSignatureEmbedded,
        SeImageSignatureCache,
        SeImageSignatureCatalogCached,
        SeImageSignatureCatalogNotCached,
        SeImageSignatureCatalogHint,
        SeImageSignaturePackageCatalog,
} SE_IMAGE_SIGNATURE_TYPE, *PSE_IMAGE_SIGNATURE_TYPE;
  • SeImageSignatureNone (meaning the file is unsigned), SeImageSignatureEmbedded (meaning the signature is embedded in the file), and SeImageSignatureCache (meaning the signature is cached on the system).

  • If the ImagePartialMap value is nonzero, the image being mapped into the process’s virtual address space isn’t complete. This value, added in Windows 10, is set in cases such as when kernel32!MapViewOfFile() is invoked to map a small portion of a file whose size is larger than that of the process’s address space.

  • The ImageBase field contains the base address into which the image will be mapped, in either user or kernel address space, depending on the image type.

  • It is worth noting that when the image-load notification reaches the driver, the image is already mapped. This means that the code inside the DLL is in the host process’s virtual address space and ready to be executed; as shown below.

0: kd> bp nt!PsCallImageNotifyRoutines
0: kd> g
Breakpoint 0 hit
nt!PsCallImageNotifyRoutines:
fffff803`49402bc0 488bc4      mov      rax,rsp
0: kd> dt _UNICODE_STRING @rcx
ntdll!_UNICODE_STRING
    "\SystemRoot\System32\ntdll.dll"
     +0x000 Length            : 0x3c
     +0x002 MaximumLength     : 0x3e
     +0x008 Buffer   : 0xfffff803`49789b98 1 "\SystemRoot\System32\ntdll.dll"
  • Once we have this image, we can view the current process’s VADs to see which images have been loaded into the current process.

0: kd> !vad
VAD          Level Commit
--snip--
ffff9b8f9952fd80 0 0  Mapped READONLY Pagefile section, shared commit 0x1
ffff9b8f9952eca0 2 0  Mapped READONLY Pagefile section, shared commit 0x23
ffff9b8f9952d260 1 1  Mapped NO_ACCESS Pagefile section, shared commit 0xe0e
ffff9b8f9952c5e0 2 4  Mapped Exe EXECUTE_WRITECOPY \Windows\System32\notepad.exe
ffff9b8f9952db20 3 16 Mapped Exe EXECUTE_WRITECOPY \Windows\System32\ntdll.dll
  • This output shows that ntdll.dll is located on disk and copied into memory. The loader needs to do a few things, such as resolving the DLL’s dependencies, before the DllMain() function inside the DLL is called and its code executes.

Evading Image-Load Notifications

  • An evasion tactic that has gained popularity over the past few years is to proxy one’s tooling rather than run it on the target. When an attacker avoids running post-exploitation tooling on the host, they remove many host-based indicators from the collection data, making detection extremely difficult for the EDR.

  • One way of staying off the host is by proxying the tools from an outside computer and then routing the tool’s traffic through the compromised host. Many C2 agents, such as Beacon and its socks command, support some form of proxying.

A generic proxying architecture
  • As EDR vendors enhance their ability to identify beaconing traffic, offensive teams and developers will continue to advance their tradecraft to evade detection. One of the next logical steps in accomplishing this is to use multiple channels for command-and-control tasking rather than only one, either by employing a secondary tool, such as gTunnel, or by building this support into the agent itself.

The gTunnel proxying architecture

Triggering Image-Load Notifications

  • Most EDR vendors use KAPC injection, a procedure that instructs the process being spawned to load the EDR’s DLL despite it not being explicitly linked to the image being executed.

  • To inject a DLL, it must be mapped in a manner that follows the PE format. To achieve this from kernel mode, the driver relies on an image-load callback notification to watch for a newly created process loading ntdll.dll. Loading ntdll.dll is one of the first things a new process does, so if the driver can notice this happening, it can act on the process before the main thread begins its execution.

Understanding KAPC Injection

  • The general gist is that we want to tell a newly created process to load the DLL we specify. In the case of EDRs, this will almost always be a function-hooking DLL.

  • One of several methods is to wait until a thread is in an alertable state, such as when the thread executes kernel32!SleepEx() or kernel32!WaitForSingleObjectEx(), to perform the task we requested.

  • KAPC injection queues this task from kernel mode, and unlike plain user-mode APC injection, the operating system doesn’t formally support it, making its implementation a bit hacky.

  • The process consists of a few steps. First, the driver is notified of an image load, whether it be the process image (such as notepad.exe) or a DLL that the EDR is interested in. Because the notification occurs in the context of the target process, the driver then searches the currently loaded modules for the address of a function that can load a DLL, specifically ntdll!LdrLoadDll().

  • Next, the driver initializes a few key structures, providing the name of the DLL to be injected into the process; initializes the KAPC; and queues it for execution into the process. Whenever a thread in the process enters an alertable state, the APC will be executed and the EDR driver’s DLL will be loaded.

Implementation of KAPC

  • Before the driver can inject its DLL, it must get a pointer to the undocumented ntdll!LdrLoadDll() function, which is responsible for loading a DLL into a process, similarly to kernel32!LoadLibrary().

NTSTATUS
LdrLoadDll(IN PWSTR SearchPath OPTIONAL,
        IN PULONG DllCharacteristics OPTIONAL,
        IN PUNICODE_STRING DllName,
        OUT PVOID *BaseAddress)
  • A post-operation callback is more favorable because the image is fully mapped, meaning that the driver can get a pointer to ntdll!LdrLoadDll() in the mapped copy of ntdll.dll. Because the image is mapped into the current process, the driver also doesn’t need to worry about address space layout randomization (ASLR).

  • Once the driver gets a pointer to ntdll!LdrLoadDll(), it can start injecting its DLL. The driver allocates memory inside the target process for a context structure containing the name of the DLL to be injected.

typedef struct _INJECTION_CTX
{
    UNICODE_STRING Dll;
    WCHAR Buffer[MAX_PATH];
} INJECTION_CTX, *PINJECTION_CTX

void Injector()
{
    NTSTATUS status = STATUS_SUCCESS;
    PINJECTION_CTX ctx = NULL;
    const UNICODE_STRING DllName = RTL_CONSTANT_STRING(L"hooks.dll");

--snip--

    status = ZwAllocateVirtualMemory(
        ZwCurrentProcess(),
        (PVOID *)&ctx,
        0,
        sizeof(INJECTION_CTX),
        MEM_COMMIT | MEM_RESERVE,
        PAGE_READWRITE);

    --snip--

    RtlInitEmptyUnicodeString(
        &ctx->Dll,
        ctx->Buffer,
        sizeof(ctx->Buffer));

    RtlUnicodeStringCopyString(
        &ctx->Dll,
        DllName);

    --snip--
}
  • After this allocation and initialization completes, the driver needs to allocate space for a KAPC structure

PKAPC pKapc = (PKAPC)ExAllocatePoolWithTag(
              NonPagedPool,
              sizeof(KAPC),
              'CPAK'
);
  • The driver allocates this memory in NonPagedPool, a memory pool that guarantees the data will stay in physical memory rather than being paged out to disk as long as the object is allocated. This is important because the thread into which the DLL is being injected may be running at a high interrupt request level, such as DISPATCH_LEVEL, in which case it shouldn’t access memory in the PagedPool, as this causes a fatal error that usually results in an IRQL_NOT_LESS_OR_EQUAL bug check (BSOD).

  • Next, the driver initializes the previously allocated KAPC structure using the undocumented nt!KeInitializeApc() API.

VOID KeInitializeApc(
    PKAPC Apc,
    PETHREAD Thread,
    KAPC_ENVIRONMENT Environment,
    PKKERNEL_ROUTINE KernelRoutine,
    PKRUNDOWN_ROUTINE RundownRoutine,
    PKNORMAL_ROUTINE NormalRoutine,
    KPROCESSOR_MODE ApcMode,
    PVOID NormalContext
);

// Example implementation
KeInitializeApc(
    pKapc,
    KeGetCurrentThread(),
    OriginalApcEnvironment,
    (PKKERNEL_ROUTINE)OurKernelRoutine,
    NULL,
    (PKNORMAL_ROUTINE)pfnLdrLoadDll,
    UserMode,
    NULL
);
  • This function first takes the pointer to the KAPC structure created previously, along with a pointer to the thread into which the APC should be queued, which can be the current thread in our case.

  • Following these parameters is a member of the KAPC_ENVIRONMENT enumeration, which should be OriginalApcEnvironment (0), to indicate that the APC will run in the thread’s process context.

  • The next three parameters, the routines, are where a bulk of the work happens. The KernelRoutine, named OurKernelRoutine() in the example code, is the function to be executed in kernel mode at APC_LEVEL before the APC is delivered to user mode. Most often, it simply frees the KAPC object and returns.

  • The RundownRoutine function is executed if the target thread is terminated before the APC was delivered. Usually this should free the KAPC object.

  • The NormalRoutine function should execute in user mode at PASSIVE_LEVEL when the APC is delivered. In our case, this should be the function pointer to ntdll!LdrLoadDll(). The last two parameters, ApcMode and NormalContext, are set to UserMode (1) and the parameter passed as NormalRoutine, respectively.

  • Lastly, the driver needs to queue this APC. The driver calls the undocumented function nt!KeInsertQueueApc()

BOOL KeInsertQueueApc(
     PRKAPC Apc,
     PVOID SystemArgument1,
     PVOID SystemArgument2,
     KPRIORITY Increment
);
  • The first input parameter is the APC, which will be the pointer to the KAPC we created. Next are the arguments to be passed. These should be the path to the DLL to be loaded and the length of the string containing the path.

  • At this point, the DLL is queued for injection into the new process whenever the current thread enters an alertable state, such as if it calls kernel32!WaitForSingleObject() or Sleep(). After the APC completes, the EDR will start to receive events from the DLL containing its hooks, allowing it to monitor the execution of key APIs inside the injected function.

Preventing KAPC Injection

  • Beginning in Windows build 10586, processes may prevent DLLs not signed by Microsoft from being loaded into them via process and thread mitigation policies. But this is not much useful against commercial EDR vendors as they have begun getting their DLLs attestation-signed by Microsoft.

  • The mitigation strategies work as follows. When a process is created via the user-mode process-creation API, a pointer to a STARTUPINFOEX structure is expected to be passed as a parameter. Inside this structure is a pointer to an attribute list, PROC_THREAD_ATTRIBUTE_LIST. This attribute list, once initialized, supports the attribute PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY.

  • When this is set, the lpValue member of the attribute points to a DWORD containing PROCESS_CREATION_MITIGATION_POLICY_BLOCK_NON_MICROSOFT_BINARIES_ALWAYS_ON flag.

  • If this flag is set, only DLLs signed by Microsoft will be permitted to load in the process. If a program tries to load a DLL not signed by Microsoft, STATUS_INVALID_IMAGE_HASH error will be returned. By leveraging this attribute, processes can prevent EDRs from injecting their function-hooking DLL.

How Registry Notifications Work

  • Like most software, malicious tools commonly interact with the registry, such as by querying values and creating new keys. In order to capture these interactions, drivers can register notification callback routines that get alerted any time a process interacts with the registry, allowing the driver to prevent, tamper with, or simply log the event.

Attacker Tradecraft in the Registry and the Related REG_NOTIFY_CLASS Members
  • One of the ways that adversaries abuse services is by modifying the registry values that describe the configuration of a service. Inside a service’s configuration, there exists a value, ImagePath, that contains the path to the service’s executable. If an attacker can change this value to the path for a piece of malware they’ve placed on the system, their executable will be run in this privileged context when the service is restarted.

  • Because this attack procedure relies on registry value modification, an EDR driver that is monitoring RegNtSetValueKey-type events could detect the adversary’s activity.

Registering A Notification

  • To register a registry callback routine, drivers must use the nt!CmRegister CallbackEx() function. The Cm prefix references the configuration manager, which is the component of the kernel that oversees the registry.

NTSTATUS CmRegisterCallbackEx(
    PEX_CALLBACK_FUNCTION Function,
    PCUNICODE_STRING      Altitude,
    PVOID                 Driver,
    PVOID                 Context,
    PLARGE_INTEGER        Cookie,
    PVOID                 Reserved
);
  • First, the Function parameter is the pointer to the driver’s callback. It must be defined as an EX_CALLBACK_FUNCTION, according to Microsoft’s Code Analysis for Drivers and the Static Driver Verifier, and it returns an NTSTATUS.

NTSTATUS ExCallbackFunction(
    PVOID CallbackContext,
    PVOID Argument1,
    PVOID Argument2
)
  • Next, as in object notification callbacks, the Altitude parameter defines the callback’s position in the callback stack. The Driver is a pointer to the driver object, and Context is an optional value that can be passed to the callback function but is very rarely used.

  • Lastly, Cookie parameter is a LARGE_INTEGER passed to nt!CmUnRegisterCallback() when unloading the driver.

  • The CallbackContext parameter is the value defined in the registration function’s Context parameter, and Argument1 is a value from the REG_NOTIFY_CLASS enumeration that specifies the type of action that occurred. Refer to the below list for possible reg actions.

REG_NOTIFY_CLASS Members
  • The Argument2 parameter is a pointer to a structure that contains information relevant to the operation specified in Argument1. Each operation has its own associated structure. For example, RegNtPreCreateKeyEx operations use the REG_CREATE_KEY_INFORMATION structure. This information provides the relevant context for the registry operation that occurred on the system, allowing the EDR to extract the data it needs to make a decision on how to proceed.

  • Every pre-operation member of the REG_NOTIFY_CLASS enumeration (those that begin with RegNtPre or simply RegNt) uses structures specific to the type of operation. For example, the RegNtPreQueryKey operation uses the REG_QUERY_KEY_INFORMATION structure. These pre-operation callbacks allow the driver to modify or prevent the request from completing before execution is handed off to the configuration manager.

  • Post-operation callbacks always use the REG_POST_OPERATION_INFORMATION structure, with the exception of RegNtPostCreateKey and RegNtPostOpenKey, which use the REG_POST_CREATE_KEY_INFORMATION and REG_POST_OPEN_KEY _INFORMATION structures, respectively.

  • This post-operation structure consists of a few interesting members. The Object member is a pointer to the registrykey object for which the operation was completed. The Status member is the NTSTATUS value that the system will return to the caller. The ReturnStatus member is an NTSTATUS value that, if the callback routine returns STATUS _CALLBACK_BYPASS, will be returned to the caller. Lastly, the PreInformation member contains a pointer to the structure used for the corresponding pre-operation callback.

Mitigating Performance Challenges

  • One of the biggest challenges that EDRs face when receiving registry notifications is performance. Because the driver can’t filter the events, it receives every registry event that occurs on the system.

  • To reduce the risk of adverse performance impacts, EDR drivers must carefully select what they monitor. The most common way that they do this is by monitoring only certain registry keys and selectively capturing event types. For Example:

NTSTATUS RegistryNotificationCallback(
    PVOID pCallbackContext,
    PVOID pRegNotifyClass,
    PVOID pInfo)
{
    NTSTATUS status = STATUS_SUCCESS;
    switch (((REG_NOTIFY_CLASS)(ULONG_PTR)pRegNotifyClass))
    {
         case RegNtPostCreateKey:
         {
             PREG_POST_OPERATION_INFORMATION pPostInfo =
                 (PREG_POST_OPERATION_INFORMATION)pInfo;
 
             --snip--
 
             break;
         }
 
         case RegNtPostSetValueKey:
         {
 
             --snip--
 
             break;
         }
 
         default:
             break;
    }

    return status;
}
  • An EDR developer may want to limit its scope even further to lessen the performance hit the system will take. For instance, if a driver wants to monitor service creation via the registry, it would need to check for registry-key creation events in the HKLM:\SYSTEM\CurrentControlSet\Services\ path only.

Last updated