Maelstrom #5: EDR Kernel Callbacks, Hooks, and Call Stacks
Endpoint Protection and Response is complicated for both offence and defence. In this blog we take a look at Kernel Callbacks, Hooks, and Thread Call Stacks from both perspectives.
To recap the series so far, we've gone from looking at the high level purposes and intentions of a Command and Control Server (C2) in general to designing and implementing our first iteration of our implant and server. If you've been following along, you might think you've written a C2...
This is a common mindset. In our experience, getting to this point does not require much sophistication. All of our previous work could easily be achieved (and has been achieved!) using C#, Python, Go, in an evening's worth of frenetic caffeine-fuelled typing. Leading features of C2s can often be linked to pretty old solved concepts and patterns from software engineering, such as thread management, handling idle processes, and ensuring correction execution and program flow.
But as we found when writing our various C2s, and as numerous other offensive developers have found when writing their own implants and servers, once you have the code working and you can get a pingback, you stop running your implant on your development computer and try it on a second computer. This is where the questions start creeping in. Questions like "Why can't I access remote files?", "Why can I make outbound requests over this protocol, but not this?", "Why does this command just fail with no explanation", and for the cynical self-doubter with enough imposter syndrome "Why isn't Defender stopping me from doing this?".
This, personally, is the post we were looking forward to writing. It's going to be a discussion, with a few examples, of increasing common behaviours within environments with active endpoint protection. In 2022, implants face far more scrutiny - the implant and C2 operator must to be prepared to face or evade this scrutiny, and the defender must be aware of how it works so that it can be used to the best of its ability.
Whilst writing this, we also want to clear up the 'it avoids <insert company here> EDR' tweets. Just because the implant is able to execute, doesn't mean that Endpoint Protection is blind to it - it can mean that, but we want to demonstrate some techniques these solutions use to identify malicious behaviour and raise the suspicion of an implant.
In a nutshell, proof of execution is not proof of evasion.
This post will cover:
- Reviewing three ways EDRs can detect or block malicious execution:
- Kernel Callbacks
- Hooking
- Thread Call Stacks
By the end of this post, we will have covered how modern EDRs can protect against malicious implants, and how these protections can be bypassed. We will move from having an implant which technically works to an awareness of how to write an implant which actually starts to work, and can achieve the goals of an operator.
As we've said many times, we are not creating an operational C2. The output from this series is poorly written and riddled with flaws - it only does enough to act as a broken proof-of-concept of the specific items we discuss in this series to avoid this code from being used by bad actors. For this same reason, we are trying to avoid discussing Red Team operational tactics in this series. However, as we go on, it will become obvious why blending in with the compromised users typical behaviour will work. This is something that xpn has discussed on Twitter:
Find Confluence, read Confluence.. become the employee!
If your implant has been flagged by EDR, querying
NetSessionEnum
on every AD-joined computer to find active sessions is probably not typical user behaviour. You likely will not know your implant has been flagged until it stops responding. From here, it's a race until your implant is uploaded to VirusTotal and you have to go back to the drawing board.We will be referring to the following programs a lot during this blog:
The Hunting ELK or simply the HELK is one of the first open source hunt platforms with advanced analytics capabilities such as SQL declarative language, graphing, structured streaming, and even machine learning via Jupyter notebooks and Apache Spark over an ELK stack.This project was developed primarily for research, but due to its flexible design and core components, it can be deployed in larger environments with the right configurations and scalable infrastructure.
- PreEmpt: A pseudo-EDR which has the capability to digest EtwTi, memory scanners, hooks, and so on. Although, this is not public but code will be shared when necessary.
These two tools will allow us to generate proof-of-concept data when required.
Similar to Maelstrom: Writing a C2 Implant, we want to have a section dedicated to clearing up some topics we feel need some background before moving on.
Endpoint Detection and Response (EDR) software goes by a number of different acronyms, and there may well be distinctions between different companies programs and their functionality. For the sake of simplicity, we are call all programs that are limited to scanning files on disk statically "anti-virus", and all programs that go further and scan device memory, look at the behaviour of programs while they are running, and responding to threats as they happen "EDR"s. These may be called various names, including XDR, MDR, or just plain AV.
Throughout this series, as we have done so far, we will be sticking with "EDR".
Endpoint Detection and Response (EDR), also referred to as endpoint detection and threat response (EDTR), is an endpoint security solution that continuously monitors end-user devices to detect and respond to cyber threats like ransomware and malware.Coined by Gartner’s Anton Chuvakin, EDR is defined as a solution that “records and stores endpoint-system-level behaviors, uses various data analytics techniques to detect suspicious system behavior, provides contextual information, blocks malicious activity, and provides remediation suggestions to restore affected systems.”
Because it's relevant to this post, the next section will look at EDR architecture and comparing EDR behaviours across the various vendors. Without going hugely off-topic, we won't look at a number of also relevant areas, such as how Anti-Virus works, how disk-based protection may work to also stymie your implant execution (if you're still running on disk), and how AV and EDR actually goes about scanning files and their behaviours while they are doing so. Turns out, that's like, a whole field of study.
When discussing endpoint protection, it may help to be somewhat familiar with their architecture. The Symantec EDR Architecture looks something like this:
Symantec Architecture
A similar approach can be seen for Defender for Endpoint. Essentially, a device with the product installed will have an agent which can consist of several drivers and processes, which gather telemetry from various aspects of the machine. Through this post and the next, we will go over a few of those.
As an aside, in a Windows environment, Microsoft inherently have an edge here. While this is currently aimed at "Large Enterprise" customers (or at least, we assume, given their price point for Azure!), Microsoft's Defender and new Defender MDE can both access Microsoft's knowledge of ... their own operating system, but also influence the development of new operating system functionality. Long-term, it wouldn't be a surprise to see Microsoft Defender MDE impact the EDR market in a similar way that Microsoft Defender impacted the anti-virus market.
The general gist of all EDR is that telemetry from the agent is sent to the cloud where it's run through various sandboxes and other test devices, and its behaviour can be further analysed by machine and human operators.
For the excessively curious reader, the following links go in to more detail about specific vendor approaches to EDR architecture:
Without going hugely off-topic, just as how not every red team assessment is a red team, not every EDR is an EDR.
The following "Gartner Magic Quadrant", from Gartner's May 2021 Report roughly maps out the EDR landscape. It's worth noting that CrowdStrike's hire of Alex Ionescu (a maintainer for the Kernel in ReactOS) demonstrates that the current best-in-class EDR's heavily leverage knowledge of internal Windows functionality to maximise their performance:

Gartner Quadrant
With so much of EDR functionality relying on implementing the methods we will discuss here such as custom-written direct behaviours like kernel callbacks and hooking, being able to quickly implement new Microsoft Windows features and develop your own custom ways of reliably interacting with and interrupting malicious processes seems to be the distinguishing feature of a modern EDR from its peers.
Another metric that EDR Vendors tend to use, especially because the reports are made so public, is the Mitre Enginuity. The Attack Evaluations is described as thus:
The MITRE Engenuity ATT&CK® Evaluations (Evals) program brings together product and service providers with MITRE experts to collaborate in evaluating security solutions. The Evals process applies a systematic methodology using a threat-informed purple teaming approach to capture critical context around a solution’s ability to detect or protect against known adversary behavior as defined by the ATT&CK knowledge base. Results from each evaluation are thoroughly documented and openly published.
For example, with SentinelOne, their results can be seen in: SentinelOne Overview. The overview goes through APT scenarios and marks whether or not the technique was detected and can be used as a tracker for its "effectiveness". However, some have expressed feelings online that this is not a thorough way to determine the effectiveness of the product.
When looking at EDRs from a purchasing perspective, there are a few methods of determining effectiveness and we wanted to briefly highlight them here. The main thing to consider is that some vendors do not necessarily provide more functionality than an anti-virus. As with any product, ensure that you purchase the right solution for your businesses needs.
When discussing the kernel and user-land model, the following architectural image familiar to any Computer Science graduate will be used:

Kernel Rings
A big majority of user activity will occur at ring 3, User Mode, surprisingly the Kernel operates within Kernel Mode.
For more information on this, see Windows Programming/User Mode vs Kernel Mode. A worthwhile note is that cross-over between user mode and kernel mode can and does happen. The following definitions from the previous link summarise the differences between these layers:
Ring 0 (also known as kernel mode) has full access to every resource. It is the mode in which the Windows kernel runs. Rings 1 and 2 can be customized with levels of access but are generally unused unless there are virtual machines running. Ring 3 (also known as user mode) has restricted access to resources.
Again, to save this post from being longer than it already is, see the Overview of Windows Components documentation for more detail on the following diagram. However, its simply showing the Windows architecture from processes, services, etc, crossing over to the Kernel. We will cover more on this shortly.

Windows Architecture
Applications that use the WinAPI will traverse through to the Native API (NTAPI) which operates within Kernel Mode.
As an example, API Monitor can be used to look at the calls being executed:

API Monitor of CreateThread
The above shows
CreateThread
being called and then, subsequently, NtCreateThreadEx
being called shortly after.When a function within KERNEL32.DLL is called, for example
CreateThread
, it will make a subsequent call to the NTAPI equivalent in NTDLL.DLL. For example, CreateThread calls NtCreateThreadEx. This function will then fill RAX register with the System Service Number (SSN). Finally, NTDLL.dll will then issue a SYSENTER instruction. This will then cause the processor to switch to kernel mode, and jumps to a predefined function, called the System Service Dispatcher. The following image is from Rootkits: Subverting the Windows Kernel, in the section on Userland Hooks:
SysCall example
A driver is a software component of Windows which allows the operating system and device to communicate with each other. Here is an example from What is a driver?:
For example, suppose an application needs to read some data from a device. The application calls a function implemented by the operating system, and the operating system calls a function implemented by the driver. The driver, which was written by the same company that designed and manufactured the device, knows how to communicate with the device hardware to get the data. After the driver gets the data from the device, it returns the data to the operating system, which returns it to the application.
In the case of Endpoint Protection, there are a few reasons why drivers are useful:
- The use of Callback Objects which allows for a function to be called if an action occurs. For example, later on we will see the usage of PsSetLoadImageNotifyRoutine which is the call-back object for DLLs being loaded.
- Access to privileged information from Event Tracing for Windows Threat Intelligence which is only accessible from the Kernel with an ELAM Driver.
DISCLAIMER: Before moving on, we highly recommend watching REcon 2015 - Hooking Nirvana (Alex Ionescu) Please come back to this post after.
Another common feature of EDR's are the Userland Hooking DLLs. Typically, these are loaded into a process on creation, and are used to proxy WinAPI Calls through themselves to assess the usage, then redirect onto whichever DLL is being used. As an example, if
VirtualAlloc
was being used, the flow would look something like this:Example of a hook
A hook allows for function instrumentation by intercepting WinAPI calls, by placing a
jmp
instruction in place of the function address. This jmp
will redirect the flow of a call. We will take a look at this in action in the following section. By hooking a function call, it gives the author the ability to:- Assess arguments
- Allowing Execution
- Blocking Execution
This isn't an exhaustive list, but should serve to demonstrate the functionality which we will be coming across most when running our implants.
Examples of this in use are:
- Intercepting Logon Credentials by Hooking msv1_0!SpAcceptCredentials: Intercept and read credentials from
msv1_0!SpAcceptCredentials
- Protecting the Heap: Encryption & Hooks: Hook RtlAllocateHeap, RtlReAllocateHeap and RtlFreeHeap to monitor heap allocations
To access our kernel callbacks without having to write all of that intimidating logic from scratch, we will be using [the] Hunting ELK (HELK):
The Hunting ELK or simply the HELK is one of the first open source hunt platforms with advanced analytics capabilities such as SQL declarative language, graphing, structured streaming, and even machine learning via Jupyter notebooks and Apache Spark over an ELK stack. This project was developed primarily for research, but due to its flexible design and core components, it can be deployed in larger environments with the right configurations and scalable infrastructure.
We also use the following script is used from Exploring DLL Loads, Links, and Execution to search through the
Sysmon
logs:param (
[string]$Loader = "",
[string]$dll = ""
)
$eventId = 7
$logName = "Microsoft-Windows-Sysmon/Operational"
$Yesterday = (Get-Date).AddHours(-1)
$events = Get-WinEvent -FilterHashtable @{logname=$logName; id=$eventId ;StartTime = $Yesterday;}
foreach($event in $events)
{
$msg = $event.Message.ToString()
$image = ($msg|Select-String -Pattern 'Image:.*').Matches.Value.Replace("Image: ", "")
$imageLoaded = ($msg|Select-String -Pattern 'ImageLoaded:.*').Matches.Value.Replace("ImageLoaded: ", "")
if($image.ToLower().contains($Loader.ToLower()) -And $imageLoaded.ToLower().Contains($dll.ToLower()))
{
Write-Host Image Loaded $imageLoaded
}
}
The kernel's callback mechanism provides a general way for drivers to request and provide notification when certain conditions are satisfied.
Essentially, they allow drivers to receive and handle notifications for specific events. From veil-ivy/block_create_process.cpp, here is an implementation of using the
PsSetLoadImageNotifyRoutine
Callback to BLOCK process creation:#include <ntddk.h>
#define BLOCK_PROCESS "notepad.exe"
static OB_CALLBACK_REGISTRATION obcallback_registration;
static OB_OPERATION_REGISTRATION oboperation_callback;
#define PROCESS_CREATE_THREAD (0x0002)
#define PROCESS_CREATE_PROCESS (0x0080)
#define PROCESS_TERMINATE (0x0001)
#define PROCESS_VM_WRITE (0x0020)
#define PROCESS_VM_READ (0x0010)
#define PROCESS_VM_OPERATION (0x0008)
#define PROCESS_SUSPEND_RESUME (0x0800)
static PVOID registry = NULL;
static UNICODE_STRING altitude = RTL_CONSTANT_STRING(L"300000");
//1: kd > dt nt!_EPROCESS ImageFileName
//+ 0x5a8 ImageFileName : [15] UChar
static const unsigned int imagefilename_offset = 0x5a8;
auto drv_unload(PDRIVER_OBJECT DriverObject) {
UNREFERENCED_PARAMETER(DriverObject);
ObUnRegisterCallbacks(registry);
}
OB_PREOP_CALLBACK_STATUS
PreOperationCallback(
_In_ PVOID RegistrationContext,
_Inout_ POB_PRE_OPERATION_INFORMATION PreInfo
) {
UNREFERENCED_PARAMETER(RegistrationContext);
if (strcmp(BLOCK_PROCESS, (char*)PreInfo->Object + imagefilename_offset) == 0) {
if ((PreInfo->Operation == OB_OPERATION_HANDLE_CREATE))
{
if ((PreInfo->Parameters->CreateHandleInformation.OriginalDesiredAccess & PROCESS_TERMINATE) == PROCESS_TERMINATE)
{
PreInfo->Parameters->CreateHandleInformation.DesiredAccess &= ~PROCESS_TERMINATE;
}
if ((PreInfo->Parameters->CreateHandleInformation.OriginalDesiredAccess & PROCESS_VM_READ) == PROCESS_VM_READ)
{
PreInfo->Parameters->CreateHandleInformation.DesiredAccess &= ~PROCESS_VM_READ;
}
if ((PreInfo->Parameters->CreateHandleInformation.OriginalDesiredAccess & PROCESS_VM_OPERATION) == PROCESS_VM_OPERATION)
{
PreInfo->Parameters->CreateHandleInformation.DesiredAccess &= ~PROCESS_VM_OPERATION;
}
if ((PreInfo->Parameters->CreateHandleInformation.OriginalDesiredAccess & PROCESS_VM_WRITE) == PROCESS_VM_WRITE)
{
PreInfo->Parameters->CreateHandleInformation.DesiredAccess &= ~PROCESS_VM_WRITE;
}
}
}
return OB_PREOP_SUCCESS;
}
VOID
PostOperationCallback(
_In_ PVOID RegistrationContext,
_In_ POB_POST_OPERATION_INFORMATION PostInfo
)
{
UNREFERENCED_PARAMETER(RegistrationContext);
UNREFERENCED_PARAMETER(PostInfo);
}
extern "C" auto DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) -> NTSTATUS {
UNREFERENCED_PARAMETER(RegistryPath);
DriverObject->DriverUnload = drv_unload;
auto status = STATUS_SUCCESS;
static OB_CALLBACK_REGISTRATION ob_callback_register;
static OB_OPERATION_REGISTRATION oboperation_registration;
oboperation_registration.Operations = OB_OPERATION_HANDLE_CREATE;
oboperation_registration.ObjectType = PsProcessType;
oboperation_registration.PreOperation = PreOperationCallback;
oboperation_registration.PostOperation = PostOperationCallback;
ob_callback_register.Altitude = altitude;
ob_callback_register.Version = OB_FLT_REGISTRATION_VERSION;
ob_callback_register.OperationRegistrationCount = 1;
ob_callback_register.OperationRegistration = &oboperation_registration;
status = ObRegisterCallbacks(&ob_callback_register, ®istry);
if (!NT_SUCCESS(status)) {
DbgPrint("failed to register callback: %x \r\n",status);
}
return status;
}
In this instance,
ObRegisterCallbacks
is being used to block the creation of notepad
. An Endpoint Protection solution may not use it in this way, but its very likely this type of callback will be used as telemetry to determine if malicious activity is occurring.In this section, we are going to discuss PsSetLoadImageNotifyRoutine. This callback is responsible for exactly what it says: Sending a notification when an image is loaded into a process. For an example implementation, see Subscribing to Process Creation, Thread Creation and Image Load Notifications from a Kernel Driver.
To understand how
PsSetLoadImageNotifyRoutine
works, we need to determine what its trigger is.Assuming the following code:
#include <windows.h>
#include <stdio.h>
int main()
{
HMODULE hModule = LoadLibraryA("winhttp.dll");
printf("WinHTTP: 0x%p\n", hModule);
return 0;
}
When
LoadLibraryA
is called, the function registers a callback to notify the driver than this has happened. In order to see this log in HELK, we use the script we mentioned earlier on.If we filter for
main.exe
, which is the above code, we can see the winhttp.dll
loaded:
Identifying image loads
In Elastic, we can also use the following KQL:
process_name : "main.exe" and event_id: 7 and ImageLoaded: winhttp.dll
event_original_message
holds the whole log:Image loaded:
RuleName: -
UtcTime: 2022-04-29 18:50:10.780
ProcessGuid: {3ebcda8b-3362-626c-a200-000000004f00}
ProcessId: 6716
Image: C:\Users\admin\Desktop\main.exe
ImageLoaded: C:\Windows\System32\winhttp.dll
FileVersion: 10.0.19041.1620 (WinBuild.160101.0800)
Description: Windows HTTP Services
Product: Microsoft® Windows® Operating System
Company: Microsoft Corporation
OriginalFileName: winhttp.dll
Hashes: SHA1=4F2A9BB575D38DBDC8DBB25A82BDF1AC0C41E78C,MD5=FB2B6347C25118C3AE19E9903C85B451,SHA256=989B2DFD70526098366AB722865C71643181F9DCB8E7954DA643AA4A84F3EBF0,IMPHASH=0597CE736881E784CC576C58367E6FEA
Signed: true
Signature: Microsoft Windows
SignatureStatus: Valid
User: PUNCTURE\admin
This is good to get some familiarity with how this would work. However, in Bypassing Image Load Kernel Callbacks, by batsec, identifies that the trigger is in NtCreateSection call which is then called in the
LdrpCreateDllSection
. So, we don't need to spend too much time debugging to find this.In the article from batsec, they show that the aforementioned events can be spammed with the following code:
#include <stdio.h>
#include <windows.h>
#include <winternl.h>
#define DLL_TO_FAKE_LOAD L"\\??\\C:\\windows\\system32\\calc.exe"
BOOL FakeImageLoad()
{
HANDLE hFile;
SIZE_T stSize = 0;
NTSTATUS ntStatus = 0;
UNICODE_STRING objectName;
HANDLE SectionHandle = NULL;
PVOID BaseAddress = NULL;
IO_STATUS_BLOCK IoStatusBlock;
OBJECT_ATTRIBUTES objectAttributes = { 0 };
RtlInitUnicodeString(
&objectName,
DLL_TO_FAKE_LOAD
);
InitializeObjectAttributes(
&objectAttributes,
&objectName,
OBJ_CASE_INSENSITIVE,
NULL,
NULL
);
ntStatus = NtOpenFile(
&hFile,
0x100021,
&objectAttributes,
&IoStatusBlock,
5,
0x60
);
ntStatus = NtCreateSection(
&SectionHandle,
0xd,
NULL,
NULL,
0x10,
SEC_IMAGE,
hFile
);
ntStatus = NtMapViewOfSection(
SectionHandle,
(HANDLE)0xFFFFFFFFFFFFFFFF,
&BaseAddress,
NULL,
NULL,
NULL,
&stSize,
0x1,
0x800000,
0x80
);
NtClose(SectionHandle);
}
int main()
{
for (INT i = 0; i < 10000; i++)
{
FakeImageLoad();
}
return 0;
}
The following screenshot is also from that blog post:

EventViewer for ImageLoaded
batsec identified that by making the call to
NtCreateSection
, the event can be spammed whilst not actually loading a DLL. Similarly, the spoof can be somewhat weaponised/manipulated to do other things by updating the LDR_DATA_TABLE_ENTRY struct:typedef struct _LDR_DATA_TABLE_ENTRY {
LIST_ENTRY InLoadOrderLinks;
LIST_ENTRY InMemoryOrderModuleList;
LIST_ENTRY InInitializationOrderModuleList;
PVOID DllBase;
PVOID EntryPoint;
ULONG SizeOfImage;
UNICODE_STRING FullDllName;
UNICODE_STRING BaseDllName;
ULONG Flags;
USHORT LoadCount;
USHORT TlsIndex;
union {
LIST_ENTRY HashLinks;
struct
{
PVOID SectionPointer;
ULONG CheckSum;
};
};
union {
ULONG TimeDateStamp;
PVOID LoadedImports;
};
PVOID EntryPointActivationContext;
PVOID PatchInformation;
} LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;
In this example, we will use
CertEnroll.dll
for no reason at all:UNICODE_STRING uFullPath;
UNICODE_STRING uFileName;
WCHAR* dllPath = L"C:\\Windows\\System32\\CertEnroll.dll";
WCHAR* dllName = L"CertEnroll.dll";
RtlInitUnicodeString(&uFullPath, dllPath);
RtlInitUnicodeString(&uFileName, dllName);
Now we just need to step through the struct and fill out the required information.
- Load Time:
status = NtQuerySystemTime(&pLdrEntry2->LoadTime);
pLdrEntry2->LoadReason = LoadReasonDynamicLoad;
- Because the Loader needs a module base address, we'll just load shellcode for
CALC.EXE
here (we'll discuss this part more afterwards):
SIZE_T bufSz = sizeof(buf);
LPVOID pAddress = VirtualAllocEx(hProcess, 0, bufSz, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE);
memcpy(pAddress, buf, sizeof(buf));
pLdrEntry2->BaseNameHashValue = UnicodeToHash(uFileName, FALSE);
- Fill out the rest of the struct:
pLdrEntry2->ImageDll = TRUE;
pLdrEntry2->LoadNotificationsSent = TRUE;
pLdrEntry2->EntryProcessed = TRUE;
pLdrEntry2->InLegacyLists = TRUE;
pLdrEntry2->InIndexes = TRUE;
pLdrEntry2->ProcessAttachCalled = TRUE;
pLdrEntry2->InExceptionTable = FALSE;
pLdrEntry2->OriginalBase = (ULONG_PTR)pAddress;
pLdrEntry2->DllBase = pAddress;
pLdrEntry2->SizeOfImage = 6969;
pLdrEntry2->TimeDateStamp = 0;
pLdrEntry2->BaseDllName = uFileName;
pLdrEntry2->FullDllName = uFullPath;
pLdrEntry2->ObsoleteLoadCount = 1;
pLdrEntry2->Flags = LDRP_IMAGE_DLL | LDRP_ENTRY_INSERTED | LDRP_ENTRY_PROCESSED | LDRP_PROCESS_ATTACH_CALLED;
- Complete the
DdagNode
struct:

dll-load-spoofing
pLdrEntry2->DdagNode = (PLDR_DDAG_NODE)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(LDR_DDAG_NODE));
if (!pLdrEntry2->DdagNode)
{
return -1;
}
pLdrEntry2->NodeModuleLink.Flink = &pLdrEntry2->DdagNode->Modules;
pLdrEntry2->NodeModuleLink.Blink = &pLdrEntry2->DdagNode->Modules;
pLdrEntry2->DdagNode->Modules.Flink = &pLdrEntry2->NodeModuleLink;
pLdrEntry2->DdagNode->Modules.Blink = &pLdrEntry2->NodeModuleLink;
pLdrEntry2->DdagNode->State = LdrModulesReadyToRun;
pLdrEntry2->DdagNode->LoadCount = 1;
Here is it in action:

Example of image load
In the above,
CertEnroll.dll
can be seen loaded in the spoof-load.exe
process. Remember, this is not loaded. The only thing that happened here is that a string for that DLL was passed in. We then told the loader than the base address of the DLL is that of the shellcode:Looking at this technique, there are two obvious use cases:
- Tie the implant base address (C2IMPLANT.REFLECTIVE.DLL) to a legitimate DLL (ADVAP32.DLL) causing it to appear less suspicious
- Remove an IOC Library (WinHTTP.DLL) by loading ADVAPI32.DLL but pointing it to a WinHTTP.DLL base address.
We aren't going to reinvent the wheel here, its explained wonderfully in Bypassing Image Load Kernel Callbacks. Essentially, to cause the callback to not trigger, a full loader needs to be rewritten. The conclusion to that research was DarkLoadLibrary:
In essence,DarkLoadLibrary
is an implementation ofLoadLibrary
that will not trigger image load events. It also has a ton of extra features that will make life easier during malware development.
Let's inspect it:
Searching for image loads with DarkLoadLibrary
Then the above 3 commands are ran:
dark-loader
uses theLOAD_LOCAL_FILE
flag to load a disk from disk, asLoadLibraryA
does.- The Image Load logs are searched for Kernel32 to make sure logs were found.
- winhttp.dll was searched, and nothing returned
To avoid the call to
NtCreateSection
which was identified to be registering the callback, the section mapping is done with NtAllocateVirtualMemory
or VirtualAlloc
, as seen in MapSections().Obviously,
PsSetLoadImageNotifyRoutine
is not the only callback, and there are quite a few other callbacks readily available. Kernel Callback Functions has a (non-comprehensive!) list:CmRegisterCallbackEx()
ExAllocateTimer()
ExInitializeWorkItem()
ExRegisterCallback()
FsRtlRegisterFileSystemFilterCallbacks()
IoInitializeThreadedDpcRequest()
IoQueueWorkItem()
IoRegisterBootDriverCallback()
IoRegisterContainerNotification()
IoRegisterFsRegistrationChangeEx()
IoRegisterFsRegistrationChangeMountAware()
IoRegisterPlugPlayNotification()
IoSetCompletionRoutineEx()
IoWMISetNotificationCallback()
KeExpandKernelStackAndCalloutEx()
KeInitializeApc()
KeInitializeDpc()
KeRegisterBugCheckCallback()
KeRegisterBugCheckReasonCallback()
KeRegisterNmiCallback()
KeRegisterProcessorChangeCallback()
KeRegisterProcessorChangeCallback()
ObRegisterCallbacks()
PoRegisterDeviceNotify()
PoRegisterPowerSettingCallback()
PsCreateSystemThread()
PsSetCreateProcessNotifyRoutineEx()
PsSetCreateThreadNotifyRoutine()
PsSetLoadImageNotifyRoutine()
SeRegisterLogonSessionTerminatedRoutine()
TmEnableCallbacks()
One that would be powerful would be
PsSetCreateProcessNotifyRoutineEx()
as the notification for process creation would be crippling for system telemetry. At the time of writing, we are not aware of any research in this space. Although to be totally honest, we haven't looked.In this section, we are going to look at some popular, but elementary, hooking techniques.
Lets look at two examples before looking into some libraries - Manual Hooks in x86 and NtSetProcessInformation Callbacks.
Manual Hooks (x86)
Using Windows API Hooking as a x86 example (easier to demonstrate), we can adapt the code to look something like this:
#include <windows.h>
#include <stdio.h>c
#define BYTES_REQUIRED 6
int __stdcall HookedMessageBoxA(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType)
{
printf("\n[ HOOKED MESSAGEBOXA ]\n");
printf("-> Arguments:\n");
printf(" 1. lpText: %s\n", lpText);
printf(" 2. lpCaption: %s\n", lpCaption);
printf(" 3. uType: %ld\n", uType);
return 1;
}
void PrintHexA(char* data, int sz)
{
printf(" -> ");
for (int i = 0; i < sz; i++)
{
printf("\\x%02hhX", data[i]);
}
printf("\n");
}
int main()
{
SIZE_T lpNumberOfBytesRead = 0;
HMODULE hModule = nullptr;
FARPROC pMessageBoxAFunc = nullptr;
char pMessageBoxABytes[BYTES_REQUIRED] = {};
void* pHookedMessageBoxFunc = &HookedMessageBoxA;
hModule = LoadLibraryA("user32.dll");
if (!hModule)
{
return -1;
}
pMessageBoxAFunc = GetProcAddress(hModule, "MessageBoxA");
printf("-> Original MessageBoxA: 0x%p\n", pMessageBoxAFunc);
if (ReadProcessMemory(GetCurrentProcess(), pMessageBoxAFunc, pMessageBoxABytes, BYTES_REQUIRED, &lpNumberOfBytesRead) == FALSE)
{
printf("[!] ReadProcessMemory: %ld\n", GetLastError());
return -1;
}
printf("-> MessageBoxA Hex:\n");
PrintHexA(pMessageBoxABytes, BYTES_REQUIRED);
printf("-> Hooked MessageBoxA: 0x%p\n", pHookedMessageBoxFunc);
char patch[BYTES_REQUIRED] = { 0 };
memcpy_s(patch, 1, "\x68", 1);
memcpy_s(patch + 1, 4, &pHookedMessageBoxFunc, 4);
memcpy_s(patch + 5, 1, "\xC3", 1);
printf("-> Patch Hex:\n");
PrintHexA(patch, BYTES_REQUIRED);
if (WriteProcessMemory(GetCurrentProcess(), (LPVOID)pMessageBoxAFunc, patch, sizeof(patch), &lpNumberOfBytesRead) == FALSE)
{
printf("[!] WriteProcessMemory: %ld\n", GetLastError());
return -1;
}
MessageBoxA(NULL, "AAAAA", "BBBBB", MB_OK);
return 0;
}
Lets walk through this...
First off,
MessageBoxA
is in User32.dll
so we load that:hModule = LoadLibraryA("user32.dll");
if (!hModule)
{
return -1;
}
Next, we need the address of
USER32!MessageBoxA
:pMessageBoxAFunc = GetProcAddress(hModule, "MessageBoxA");
With that address, the bytes can now be read:
if (ReadProcessMemory(GetCurrentProcess(), pMessageBoxAFunc, pMessageBoxABytes, BYTES_REQUIRED, &lpNumberOfBytesRead) == FALSE)
{
printf("[!] ReadProcessMemory: %ld\n", GetLastError());
return -1;
}
This will read the first 6 bytes of the function call which will later be updated to hold a
push
to the new function, resulting in a jmp
.The bytes:
\x8B\xFF\x55\x8B\xEC\x83
Now, the patch needs to be built. This is done like so:
char patch[BYTES_REQUIRED] = { 0 };
memcpy_s(patch, 1, "\x68", 1);
memcpy_s(patch + 1, 4, &pHookedMessageBoxFunc, 4);
memcpy_s(patch + 5, 1, "\xC3", 1);
The hex produced from this:
\x68\x12\x12\xBD\x00\xC3
0: 68 12 12 bd 00 push 0xbd1212
5: c3 ret
Note that
0x00BD1212
being pushed is the address of the function we want to jump to INSTEAD of theUSER32!MessageBoxA
call:void* pHookedMessageBoxFunc = &HookedMessageBoxA;
At this point, the patch is prepared. It's going to replace the first 6 bytes with a
push
to the new address.The next thing is to actually write this new address in:
if (WriteProcessMemory(GetCurrentProcess(), (LPVOID)pMessageBoxAFunc, patch, sizeof(patch), &lpNumberOfBytesRead) == FALSE)
{
printf("[!] WriteProcessMemory: %ld\n", GetLastError());
return -1;
}
Then, in the disassembly:
00BB1212 jmp HookedMessageBoxA (0BB1A80h)
A
jmp
is added to jump to the new function. Allowing this to run calls the hooked function and the arguments are printed:int __stdcall HookedMessageBoxA(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType)
{
printf("\n[ HOOKED MESSAGEBOXA ]\n");
printf("-> Arguments:\n");
printf(" 1. lpText: %s\n", lpText);
printf(" 2. lpCaption: %s\n", lpCaption);
printf(" 3. uType: %ld\n", uType);
return 1;
}
Running it:
NtSetProcessInformation Callbacks
In REcon 2015 - Hooking Nirvana (Alex Ionescu), Alex Ionescu demonstrates Process Instrumentation with NtSetProcessInformation with the
ProcessInstrumentationCallback
flag. In this talk, Alex demonstrates hooking via callbacks with Process Instrumentation.Setting up the callback is straight forward:
PROCESS_INSTRUMENTATION_CALLBACK_INFORMATION InstrumentationCallbackInfo;
InstrumentationCallbackInfo.Version = 0;
InstrumentationCallbackInfo.Reserved = 0;
InstrumentationCallbackInfo.Callback = CALLBACK_FUNCTION_GOES_HERE;
HANDLE hProcess = (HANDLE)-1;
HMODULE hNtdll = GetModuleHandleA("ntdll");
if (hNtdll == nullptr)
{
return FALSE;
}
_NtSetInformationProcess pNtSetInformationProcess = reinterpret_cast<_NtSetInformationProcess>(GetProcAddress(hNtdll, "NtSetInformationProcess"));
if (pNtSetInformationProcess == nullptr)
{
return FALSE;
}
NTSTATUS Status = pNtSetInformationProcess(hProcess, (PROCESS_INFORMATION_CLASS)ProcessInstrumentationCallback, &InstrumentationCallbackInfo, sizeof(InstrumentationCallbackInfo));
if (NT_SUCCESS(Status))
{
return TRUE;
}
else
{
return FALSE;
}
Where the callback function is included as follows:
InstrumentationCallbackInfo.Callback = CALLBACK_FUNCTION_GOES_HERE;
CALLBACK_FUNCTION_GOES_HERE
is a function to use as the callback and then ProcessInstrumentationCallback
is:#define ProcessInstrumentationCallback 0x28
An additional point is that by setting the callback to
NULL
, any callbacks sent will be removed. This was documented by modexp in Bypassing User-Mode Hooks and Direct Invocation of System Calls for Red Teams.This talk was then built on by Secrary and again in Secrary's blog Hooking via InstrumentationCallback. The original code from Alex Ionescu can be found in the HookingNirvana repo.
Borrowing the hooks from Secrary gives access to the function and return value, giving us the following Assembly:
.code
PUBLIC asmCallback
EXTERN Hook:PROC
asmCallback PROC
push rax ; return value
push rcx
push RBX
push RBP
push RDI
push RSI
push RSP
push R12
push R13
push R14
push R15
; without this it crashes :)
sub rsp, 1000h
mov rdx, rax
mov rcx, r10
call Hook
add rsp, 1000h
pop R15
pop R14
pop R13
pop R12
pop RSP
pop RSI
pop RDI
pop RBP
pop RBX