Dissecting a Windows Kernel Shield Driver: Inside Callback Internals

Kernel shield

Table of Contents

Introduction

In this analysis, we examine a 64-bit Windows driver called driver_challenge, which implements a kernel shield—that is, kernel-level protection for certain processes. The primary purpose of this driver is to make processes persistent and protect them from being terminated by the user or by third-party tools, such as Task Manager.

General information about the driver

  • Name: driver_challenge
  • Type: PE32+ executable for MS Windows 6.01 (native)
  • Arch: x86-64
  • Sections: 7 sections

The driver’s main entry point initializes structures, creates a device, and registers kernel callbacks to intercept certain operations on processes.

Decoding DriverEntry

Here is the code for the function that is called in DriverEntry

ulonglong FUN_14000114c(longlong param_1)

{
  uint *puVar1;
  uint uVar2;
  int iVar3;
  ulonglong uVar4;
  undefined8 local_res8 [4];
  undefined local_28 [16];
  undefined local_18 [16];
  
  puVar1 = (uint *)(*(longlong *)(param_1 + 0x28) + 0x68);
  *puVar1 = *puVar1 | 0x20;
  _DAT_140003018 = 0;
  RtlInitUnicodeString(local_28,L"\\Device\\NSecKrnl");
  RtlInitUnicodeString(local_18,L"\\DosDevices\\NSecKrnl");
  *(code **)(param_1 + 0x70) = FUN_140001010;
  *(code **)(param_1 + 0x80) = FUN_140001010;
  *(code **)(param_1 + 0xe0) = FUN_140001030;
  *(code **)(param_1 + 0x68) = FUN_1400010e0;
  uVar4 = IoCreateDevice(param_1,0,local_28,0x22,0,0,local_res8);
  if (-1 < (int)uVar4) {
    uVar2 = IoCreateSymbolicLink(local_18,local_28);
    uVar4 = (ulonglong)uVar2;
    if ((int)uVar2 < 0) {
      IoDeleteDevice(local_res8[0]);
    }
    else {
      iVar3 = PsSetCreateProcessNotifyRoutine(FUN_140001480,0);
      DAT_140003010 = -1 < iVar3;
      iVar3 = PsSetLoadImageNotifyRoutine(_guard_check_icall);
      DAT_140003011 = -1 < iVar3;
      FUN_140001518();
    }
  }
  return uVar4;
}

The purpose of the *puVar1 variable within param_1 is unclear. To determine this, we examine the subsequent call to IoCreateDevice(), which receives a PDRIVER_OBJECT as an argument.

  • param_1 = PDRIVER_OBJECT
  • puVar1 = DRIVER_OBJECT

PDRIVER_OBJECT is a pointer to DRIVER_OBJECT.

https://learn.microsoft.com/en-us/windows-hardware/drivers/ddi/wdm/ns-wdm-_driver_object

typedef struct _DRIVER_OBJECT {
  CSHORT             Type;          // 0x02
  CSHORT             Size;          // 0x02
  PDEVICE_OBJECT     DeviceObject;  // 0x08
  ULONG              Flags;         // 0x04
  PVOID              DriverStart;   // 0x08
  ULONG              DriverSize;
  PVOID              DriverSection;
  PDRIVER_EXTENSION  DriverExtension;
  UNICODE_STRING     DriverName;
  PUNICODE_STRING    HardwareDatabase;
  PFAST_IO_DISPATCH  FastIoDispatch;
  PDRIVER_INITIALIZE DriverInit;
  PDRIVER_STARTIO    DriverStartIo;
  PDRIVER_UNLOAD     DriverUnload;
  PDRIVER_DISPATCH   MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1];
} DRIVER_OBJECT, *PDRIVER_OBJECT;

Now that we know that puVar1 and param_1 are structures for DRIVER, let’s break down these two lines:

puVar1 = (uint *)(*(longlong *)(param_1 + 0x28) + 0x68);
*puVar1 = *puVar1 | 0x20;

When padding is added to the structure and variables along with their sizes, we end up at DriverSection for the + 0x28.

Note: Windows x64 aligns all pointers to 8 bytes and adds padding if necessary.

CSHORT             Type;          // 0x00
CSHORT             Size;          // 0x02
                                  // 0x04-0x07 padding for align DeviceObject
PDEVICE_OBJECT     DeviceObject;  // 0x08–0x0F
ULONG              Flags;         // 0x10–0x13
                                  // 0x14–0x17 padding for align DriverStart
PVOID              DriverStart;   // 0x18–0x1F
ULONG              DriverSize;    // 0x20–0x23
                                  // 0x24–0x27 padding for align DriverSection
PVOID              DriverSection; // 0x28–0x2F

Regarding DriverSection, Microsoft states:

  • Points to the driver’s section object, which represents the driver image in the memory manager. This is an opaque system structure used internally by the memory manager and loader. Drivers should not access or modify this member.

Working with LDR_DATA_TABLE_ENTRY

Often, PVOID DriverStart points to an LDR_DATA_TABLE_ENTRY structure, which Microsoft does not officially document. However, researchers have managed to document it: https://www.geoffchappell.com/studies/windows/km/ntoskrnl/inc/api/ntldr/ldr_data_table_entry.htm

According to the documentation, the offset 0x68 in the LDR_DATA_TABLE_ENTRY corresponds to ULONG Flags;

https://www.tssc.de/winint/Win11_22631_2428_ntoskrnl/_LDR_DATA_TABLE_ENTRY.htm The OR operation with 0x20 sets the ProcessStaticImport flag, marking the driver as “loaded and active” for the Windows loader. To explain what this actually does: Windows checks drivers using data in LDR_DATA_TABLE_ENTRY, so if the driver itself modifies that data, it can bypass Windows checks. It changes its status, thereby circumventing certain checks. Normally, a driver never modifies LDR_DATA_TABLE_ENTRY; it uses the official APIs.

UINT8                   FlagGroup[4];                                   // 0x0068; 0x0004 Bytes
ULONG32                 Flags;                                          // 0x0068; 0x0004 Bytes
struct                                                                  // 0x0068; 29 elements; 0x0004 Bytes
{
    ULONG32             PackagedBinary                             : 1; // 0x0068; Bit:   0
    ULONG32             MarkedForRemoval                           : 1; // 0x0068; Bit:   1
    ULONG32             ImageDll                                   : 1; // 0x0068; Bit:   2
    ULONG32             LoadNotificationsSent                      : 1; // 0x0068; Bit:   3
    ULONG32             TelemetryEntryProcessed                    : 1; // 0x0068; Bit:   4
    ULONG32             ProcessStaticImport                        : 1; // 0x0068; Bit:   5
    ULONG32             InLegacyLists                              : 1; // 0x0068; Bit:   6
    ULONG32             InIndexes                                  : 1; // 0x0068; Bit:   7
    ULONG32             ShimDll                                    : 1; // 0x0068; Bit:   8
    ULONG32             InExceptionTable                           : 1; // 0x0068; Bit:   9
    ULONG32             ReservedFlags1                             : 2; // 0x0068; Bits: 10 - 11
    ULONG32             LoadInProgress                             : 1; // 0x0068; Bit:  12
    ULONG32             LoadConfigProcessed                        : 1; // 0x0068; Bit:  13
    ULONG32             EntryProcessed                             : 1; // 0x0068; Bit:  14
    ULONG32             ProtectDelayLoad                           : 1; // 0x0068; Bit:  15
    ULONG32             ReservedFlags3                             : 2; // 0x0068; Bits: 16 - 17
    ULONG32             DontCallForThreads                         : 1; // 0x0068; Bit:  18
    ULONG32             ProcessAttachCalled                        : 1; // 0x0068; Bit:  19
    ULONG32             ProcessAttachFailed                        : 1; // 0x0068; Bit:  20
    ULONG32             CorDeferredValidate                        : 1; // 0x0068; Bit:  21
    ULONG32             CorImage                                   : 1; // 0x0068; Bit:  22
    ULONG32             DontRelocate                               : 1; // 0x0068; Bit:  23
    ULONG32             CorILOnly                                  : 1; // 0x0068; Bit:  24
    ULONG32             ChpeImage                                  : 1; // 0x0068; Bit:  25
    ULONG32             ChpeEmulatorImage                          : 1; // 0x0068; Bit:  26
    ULONG32             ReservedFlags5                             : 1; // 0x0068; Bit:  27
    ULONG32             Redirected                                 : 1; // 0x0068; Bit:  28
    ULONG32             ReservedFlags6                             : 2; // 0x0068; Bits: 29 - 30
    ULONG32             CompatDatabaseProcessed                    : 1; // 0x0068; Bit:  31
};

Next, the array PDRIVER_DISPATCH MajorFunction[IRP_MJ_MAXIMUM_FUNCTION + 1];, is initialized in the following code:

*(code **)(param_1 + 0x70) = FUN_140001010;
*(code **)(param_1 + 0x80) = FUN_140001010;
*(code **)(param_1 + 0xe0) = FUN_140001030;
*(code **)(param_1 + 0x68) = FUN_1400010e0;

Process termination IOCTL function

We now want to know which IOCTL code can be used to force a process to terminate. In the function FUN_140001030:

undefined4 FUN_140001030(undefined8 param_1,longlong param_2)
{
  int iVar1;
  longlong *plVar2;
  char cVar3;
  ulonglong uVar4;
  undefined4 uVar5;
  
  plVar2 = *(longlong **)(param_2 + 0x18);
  uVar5 = 0xc0000001;
  iVar1 = *(int *)(*(longlong *)(param_2 + 0xb8) + 0x18);
  if (iVar1 == 0x2248d4)
  {
    if (plVar2 != (longlong *)0x0)
    {
      cVar3 = FUN_1400012b8(*plVar2);
      uVar5 = 0xc0000001;
      if (cVar3 != '\0') {
        uVar5 = 0;
      }
    }
  }
  else {
    if (iVar1 == 0x2248d8) {
      if (plVar2 == (longlong *)0x0) goto LAB_1400010be;
      uVar4 = FUN_140001614(*plVar2);
      cVar3 = (char)uVar4;
    }
    else if (iVar1 == 0x2248dc) {
      if (plVar2 == (longlong *)0x0) goto LAB_1400010be;
      cVar3 = FUN_140001240(*plVar2);
    }
    else {
      if ((iVar1 != 0x2248e0) || (plVar2 == (longlong *)0x0)) goto LAB_1400010be;
      uVar4 = FUN_1400013e8(*plVar2);
      cVar3 = (char)uVar4;
    }
    if (cVar3 != '\0') {
      uVar5 = 0;
    }
  }
LAB_1400010be:
  *(undefined4 *)(param_2 + 0x30) = uVar5;
  IofCompleteRequest(param_2,0);
  return uVar5;
}

We can see that plVar2 equals param_2. The following function will help us determine which variable is param_2 actually is.

In the FUN_1400013e8 function, param_2 is passed to the PsLookupProcessByProcessId function, which takes a HANDLE, so we can conclude that param_2 is a handle and plVar2 is as well. The process termination code is 0x2248e0.


ulonglong FUN_1400013e8(undefined8 param_1)

{
  ulonglong uVar1;
  undefined8 local_res10;
  longlong local_res18 [2];
  
  local_res18[0] = 0;
  local_res10 = 0;
  uVar1 = PsLookupProcessByProcessId(param_1,local_res18);
  if (-1 < (int)uVar1)
  {
    uVar1 = ObOpenObjectByPointer
                      (local_res18[0],0x200,0,1,*(undefined8 *)PsProcessType_exref,0,&local_res10);
    if (-1 < (int)uVar1)
    {
      ZwTerminateProcess(local_res10,0);
      uVar1 = ZwClose(local_res10);
    }
  }
  if (local_res18[0] != 0) {
    uVar1 = ObfDereferenceObject();
  }
  return uVar1 & 0xffffffffffffff00;
}

Returning to the first function, we can see a call to IoCreateDevice() function. This function is used to create an object that represents a hardware device, in order to interact with the hardware. The value passed is 0x22, which is FILE_DEVICE_UNKNOWN. This means that no specific behavior is enforced; the driver is free to define its own IOCTLs. This is very commonly used in vulnerable drivers or custom drivers. https://learn.microsoft.com/en-us/windows-hardware/drivers/kernel/specifying-device-types

NTSTATUS IoCreateDevice(
  [in]           PDRIVER_OBJECT  DriverObject,
  [in]           ULONG           DeviceExtensionSize,
  [in, optional] PUNICODE_STRING DeviceName,
  [in]           DEVICE_TYPE     DeviceType,
  [in]           ULONG           DeviceCharacteristics,
  [in]           BOOLEAN         Exclusive,
  [out]          PDEVICE_OBJECT  *DeviceObject
);

Process protection using ObRegisterCallbacks

The function below shows that a callback is registered via the ObRegisterCallbacks function, which is used to take control of kernel objects—in this case, processes.

The ObRegisterCallbacks function takes the OB_CALLBACK_REGISTRATION structure as its first parameter, which itself contains a pointer to the OB_OPERATION_REGISTRATION structure that holds the callback function. This means that every time someone tries to open or manipulate a process, it triggers the callback function. In our case, this is the FUN_1400014b0 function. This is often used in EDRs, anti-cheat systems, or malware.

void FUN_140001518(void)
{
  int iVar1;
  code *local_58;
  undefined8 local_50;
  code *local_48;
  undefined8 local_40;
  undefined local_38 [8];
  undefined auStack_30 [8];
  undefined local_28 [16];
  code **local_18;
  
  local_58 = PsProcessType_exref;
  local_50 = 3;
  local_48 = FUN_1400014b0;
  local_18 = (code **)0x0;
  local_40 = 0;
  local_28 = ZEXT816(0);
  stack0xffffffffffffffcc = SUB1612(ZEXT816(0),4);
  local_38._0_4_ = 0x10100;
  RtlInitUnicodeString(auStack_30,L"328987");
  local_18 = &local_58;
  local_28._8_8_ = 0;
  iVar1 = ObRegisterCallbacks(local_38,&DAT_140003020);
  if (iVar1 < 0) {
    DAT_140003020 = 0;
  }
  return;
}

Here is an example function for initializing and registering a callback:

Source: https://medium.com/@s12deff/register-windows-object-callbacks-from-kernel-driver-e7bf4fd1e30c

Next, the prototype of a callback function generally looks like this: OB_PREOP_CALLBACK_STATUS CreateCallback(PVOID RegistrationContext, POB_PRE_OPERATION_INFORMATION OperationInformation)

To understand exactly what our callback does, we need to look at the function FUN_1400014b0 and understand what it does.

  • FUN_1400014b0:
undefined8 FUN_1400014b0(undefined8 param_1,longlong param_2)
{
  char cVar1;
  longlong lVar2;
  longlong lVar3;
  
  if (((param_2 != 0) && (lVar3 = *(longlong *)(param_2 + 8), lVar3 != 0)) &&
     (*(longlong *)(param_2 + 0x20) != 0)) {
    lVar2 = IoGetCurrentProcess();
    if (lVar2 != lVar3) {
      lVar3 = PsGetProcessId(lVar3);
      cVar1 = FUN_14000138c(lVar3);
      if (cVar1 != '\0') {
        lVar3 = PsGetCurrentProcessId();
        cVar1 = FUN_140001330(lVar3);
        if (cVar1 == '\0') {
          **(uint **)(param_2 + 0x20) = **(uint **)(param_2 + 0x20) & 0xfffffffe;
        }
      }
    }
  }
  return 0;
}

We now know that ‘param_1’ is a ‘PVOID’ and ‘param_2’ is a ‘POB_PRE_OPERATION_INFORMATION’.

*(longlong *)(param_2 + 8) =PVOID Object. *(longlong *)(param_2 + 0x20) = POB_PRE_OPERATION_PARAMETERS Parameters;

If (lVar2 != lVar3), the code checks whether the current process differs from the target process.

What’s really interesting is the line **(uint **)(param_2 + 0x20) = **(uint **)(param_2 + 0x20) & 0xfffffffe;. **(uint **)(param_2 + 0x20) is dereferenced, so this takes us further into the structure; here is a quick diagram:

POB_PRE_OPERATION_PARAMETERS –> _OB_PRE_OPERATION_PARAMETERS –> _OB_PRE_CREATE_HANDLE_INFORMATION

typedef struct _OB_PRE_CREATE_HANDLE_INFORMATION {
  ACCESS_MASK DesiredAccess;
  ACCESS_MASK OriginalDesiredAccess;
} OB_PRE_CREATE_HANDLE_INFORMATION, *POB_PRE_CREATE_HANDLE_INFORMATION;

So **(uint **)(param_2 + 0x20) = DesiredAccess.

A bitmask operation using & 0xfffffffe is performed on Parameters->CreateHandleInformation.DesiredAccess. DesiredAccess holds a value that controls the permissions the program requests when it starts a process. The values for this variable can be found in WinNT.h. https://gist.github.com/JamesMenetrey/d3f494262bcab48af1d617c3d39f34cf Here is a list of the defines for DesiredAccess:

#define PROCESS_TERMINATE                  (0x0001)  
#define PROCESS_CREATE_THREAD              (0x0002)  
#define PROCESS_SET_SESSIONID              (0x0004)  
#define PROCESS_VM_OPERATION               (0x0008)  
#define PROCESS_VM_READ                    (0x0010)  
#define PROCESS_VM_WRITE                   (0x0020)  
#define PROCESS_DUP_HANDLE                 (0x0040)  
#define PROCESS_CREATE_PROCESS             (0x0080)  
#define PROCESS_SET_QUOTA                  (0x0100)  
#define PROCESS_SET_INFORMATION            (0x0200)  
#define PROCESS_QUERY_INFORMATION          (0x0400)  
#define PROCESS_SUSPEND_RESUME             (0x0800)  
#define PROCESS_QUERY_LIMITED_INFORMATION  (0x1000)  
#define PROCESS_SET_LIMITED_INFORMATION    (0x2000)  

In our case, it sets & 0xfffffffe, which is equivalent to

  • 0xfffffffe = ~0x0001
Parameters->CreateHandleInformation.DesiredAccess &= ~0x0001

This clears the first bit of DesiredAccess, revoking the PROCESS_TERMINATE permission while leaving all other rights intact. In summary, this makes the process persistent, preventing termination by the user or any third-party tool, including Task Manager.

https://www.sentinelone.com/vulnerability-database/cve-2025-68947/

Conclusion

This driver implements a kernel shield with two main objectives:

Camouflage & bypass: modifying LDR_DATA_TABLE_ENTRY to bypass load checks. Process protection: using ObRegisterCallbacks to revoke termination rights (PROCESS_TERMINATE).

Additionally, it exposes a customizable IOCTL that allows for forced process termination, illustrating the dual nature of a driver capable of both protecting and manipulating processes at the kernel level.

In summary, this type of driver is powerful and sensitive; it can be used for legitimate protections such as EDR or anti-cheat systems, or for malicious behaviors such as rootkits.