Windows 内核驱动程序完整性校验的原理分析

2022-12-12 16:14:18 浏览数 (2)

在上一篇文章中提到了 Windows Vista 及之后版本的 Windows 操作系统在驱动程序加载完成后,驱动中调用的一些系统回调函数(如 ObRegisterCallbacks,可用来监控系统中对进线程句柄的操作,如打开进程、复制线程句柄等)等 API 中会通过 MmVerifyCallbackFunction 函数对该驱动程序进行完整性检查,检测未通过则会返回 0xC0000022 拒绝访问的返回值。在这篇文章中将会对这个函数进行简单的分析,以明确其原理。

0x0 获取函数地址

通过 Windbg 连接 64 位的 Windows 7 SP1 虚拟机,并通过 u nt!MmVerifyCallbackFunction 命令得到该函数的基地址。接下来使用 lm 命令获得 nt 内核模块的基地址,通过与前面的函数首地址相减得到函数的地址偏移 4700B0。

在 IDA 中加载 64 位 Windows 7 SP1 的 ntoskrnl.exe 文件并指定 pdb 文件,在 IDA View-A 页面中定位到前面获得的函数地址偏移位置 PAGE:00000001404700B0,即定位到该函数所在。

0x1 简单分析

如果 pdb 文件正确加载的话会在 IDA View-A 页面中看到 IDA 已正确识别该函数的名称符号。通过 IDA 获得初步的 C 代码,对其进行一些修正后得到下述代码。

代码语言:javascript复制
ULONG64 __fastcall MmVerifyCallbackFunction(PVOID pAddr)
{
  PVOID    address;  // rsi@1
  ULONG64  result;   // rax@2
  PVOID    thread;   // rbx@3
  BOOLEAN  status;   // edi@3
  PVOID    ldrentry; // rax@3
  bool     disable;  // zf@6

  address = pAddr;
  if ( (ULONG64)(pAddr   0x70000000000) > 0x7FFFFFFFFF )
  {
    thread = *MK_FP(__GS__, 0x188);  // get _ETHREAD pointer from KPCR
    --*(WORD *)(thread   0x1C4);     // Disable Kernel APCs // Thread->KernelApcDisable--;
    status = FALSE;
    ExAcquireResourceSharedLite(&PsLoadedModuleResource, TRUE);
    ldrentry = MiLookupDataTableEntry(address, TRUE);
    if ( ldrentry && *(BYTE *)(ldrentry   0x68) & 0x20 )
      status = TRUE;
    ExReleaseResourceLite(&PsLoadedModuleResource);
    disable = (*(WORD *)(thread   0x1C4))   == -1;  // Enable Kernel APCs // Thread->KernelApcDisable  ;
    // 1. Thread->KernelApcDisable == -1 before   ;
    // 2. EThread->ApcState->ApcListHead does not point to itself;
    // 3. EThread->SpecialApcDisable == 0;
    // means KernelApc/SpecialApc enabled && ApcList not empty now.
    if ( disable && *(QWORD *)(thread   0x50) != thread   0x50 && !*(WORD *)(thread   0x1C6) )
      KiCheckForKernelApcDelivery();
    result = (ULONG)status;
  }
  else
  {
    result = FALSE;
  }
  return result;
}

0x2 代码解释

首先是判断传入地址参数的有效性。为了更精确地理解 if ( (ULONG64)(pAddr 0x70000000000) > 0x7FFFFFFFFF ) 这行语句执行的操作和作用,下面贴出其汇编指令代码。

代码语言:javascript复制
mov     rax, 70000000000h
mov     rsi, rcx
add     rax, rcx
mov     rcx, 7FFFFFFFFFh
cmp     rax, rcx
ja      nt!MmVerifyCallbackFunction 0x3c

其中 rcx 初始值是传入的地址参数的值。开始时并未明确这几行指令代码这样判断的目的是什么,通过 Windbg 跟踪 PsSetCreateProcessNotifyRoutineEx 等 API 函数对 MmVerifyCallbackFunction 的调用,发现 ecx 都是 0xfffff8800373c620 之类正常的地址数值,通过和 0x70000000000 相加得到的值 0xffffff800373c620 也远大于 0x7FFFFFFFFF 这个比较小的数。

后经过计算得知,相加后的值在此处只有在一种情况才会小于 0x7FFFFFFFFF 值。地址参数是个 ULONG64 长度的数字,其值位于 0xFFFFF90000000000, 0xFFFFF97FFFFFFFFF 区间时,相加后的数值对 ja 条件成立,函数跳转到检测失败并返回的位置。后通过查阅资料得知,在 64 位 Windows 操作系统中,该地址空间区间范围正是内核地址空间中的会话空间(Session Space)。

Session Space Session Data Structures, Session Pool and Session Images are loaded in this area. The session image space contains driver images like Win32K.sys (Window Manager), CDD.DLL (Canonical Display Driver), TSDDD.dll (Frame Buffer Display Driver), DXG.sys (DirectX Graphics Driver) etc. For any process that belongs to a session the field EPROCESS->Session points to a MM_SESSION_SPACE structure for that session. Session paged pool limits are pointed to by MM_SESSION_SPACE->PagesPoolStart and MM_SESSION_SPACE->PagesPoolEnd.

在判断地址参数的有效性之后,首先通过 EThread->KernelApcDisable 禁用 Normal Kernel APC。GS 寄存器存储当前 CPU 核心的 _KPCR 结构地址。当前线程 EThread 地址位于 KPCR->Prcb->CurrentThread 位置,根据偏移 0x188 获取到 EThread 的地址。对偏移为 0x1C4 的 EThread->KernelApcDisable 进行累减运算操作。

在 EThread 结构中有个 ApcState 域,以及 KernelApcDisable 和 SpecialApcDisable 域。 KernelApcDisable 和 SpecialApcDisable 域可以合并成一个 CombinedApcDisable 域,两者都是 16 位的整数值,0 表示不禁止 APC,负数表示禁止 APC。一个线程在执行过程中可以有多种因素要禁止 APC,这些因素以负值来表示,并累加起来,当因素消除的时候再减去相应的负值。 只有当 KernelApcDisable 或 SpecialApcDisable 为 0 的时候,该线程才允许插入或提交 APC。这两个值分别控制普通的内核 APC 和特殊的内核 APC。 ApcState 是一个结构成员,指定了一个线程的 APC 信息,包括 APC 链表、是否正在处理 APC 或者是否有内核 APC 或用户 APC 正在等待等信息。APC 链表头指针 ApcListHead 位于其结构第一个成员位置。

为调用线程的共享读访问请求指定的资源。PsLoadedModuleResource 是一个 ERESOURCE 结构体数据类型的全局变量,在 MiInitializeLoadedModuleList 函数中初始化。通过 ExAcquireResourceSharedLite 函数请求 PsLoadedModuleResource 系列资源的读访问权限。根据 MSDN 上的描述,调用该函数之前必须禁用 Normal Kernel APC。直到资源释放之前,APC 投递必须保持禁用状态。以下是该函数的声明。

代码语言:javascript复制
BOOLEAN 
ExAcquireResourceSharedLite(
  _Inout_ PERESOURCE Resource,
  _In_    BOOLEAN    Wait
);

ERESOURCE 结构类型是 Windows 操作系统内核中的读写锁对象类型。通过它和一系列的 Resource 例程可以实现同时只有一个 Writer 写入,多个 Reader 读访问的机制。

详细信息:http://msdn.microsoft.com/en-us/library/windows/hardware/ff544363(v=vs.85).aspx(http://msdn.microsoft.com/en-us/library/windows/hardware/ff544363(v=vs.85).aspx)

在对指定资源的读访问请求完成后,通过 MiLookupDataTableEntry 函数对 PsLoadedModuleList 指针指向的已加载的内核模块链表进行遍历。MSDN 和其他文档中没有查到有关这个函数的定义信息,以下函数定义是通过 IDA 逆向 ntoskrnl.exe 推测得到的。其中的第二个参数 BOOLEAN bAcquiredResource 表示当前调用环境是否已获取到前述的系列资源的读访问权限,如果传入 FALSE 则在该函数内部会调用 ExAcquireResourceSharedLite 函数进行这些资源的读访问权限的获取。

代码语言:javascript复制
PLDR_DATA_TABLE_ENTRY
NTAPI
MiLookupDataTableEntry(
  IN PVOID Address, 
  IN BOOLEAN bAcquiredResource
);

PsLoadedModuleList 是一个 PLDR_DATA_TABLE_ENTRY 类型的全局指针,指向当前内核中已加载的内核模块的 LDR_DATA_TABLE_ENTRY 环形链表的第一个节点。每个节点是一个 LDR_DATA_TABLE_ENTRY 类型的结构体对象。以下是在 Windows 7 x64 SP1 操作系统环境下该结构体的数据类型定义。

代码语言:javascript复制
typedef struct _LDR_DATA_TABLE_ENTRY
{
    LIST_ENTRY InLoadOrderLinks;
    LIST_ENTRY InMemoryOrderLinks;
    LIST_ENTRY InInitializationOrderLinks;
    PVOID DllBase;
    PVOID EntryPoint;
    ULONG SizeOfImage;
    UNICODE_STRING FullDllName;
    UNICODE_STRING BaseDllName;
    ULONG Flags;
    UINT16 LoadCount;
    UINT16 TlsIndex;
    union
    {
        LIST_ENTRY HashLinks;
        struct
        {
            PVOID SectionPointer;
            ULONG CheckSum;
        };
    };
    union
    {
        ULONG TimeDateStamp;
        PVOID LoadedImports;
    };
    PVOID EntryPointActivationContext;
    PVOID PatchInformation;
    LIST_ENTRY ForwarderLinks;
    LIST_ENTRY ServiceTagLinks;
    LIST_ENTRY StaticLinks;
    PVOID ContextInformation;
    ULONG_PTR OriginalBase;
    LARGE_INTEGER LoadTime;
} LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;

在 MiLookupDataTableEntry 函数中遍历链表时,通过结构体成员 DllBase 和 SizeOfImage 判断 Address 参数是否在该内核模块地址区间范围内。如果命中,则返回该 LDR_DATA_TABLE_ENTRY 链表节点的地址指针。

在 MiLookupDataTableEntry 函数返回后,ldrentry 变量取得对应内核模块的 LDR_DATA_TABLE_ENTRY 指针。判断 lprentry 0x68 位置的 BYTE 类型的数据是否对 0x20 标志位置位。通过上面的 LDR_DATA_TABLE_ENTRY 结构体定义发现,0x68 位置是 ULONG Flags 成员。该成员存储一些对应内核模块的属性标志。

判断为真则赋值 status = TRUE,其在后面会用来赋值 result 并作为 MmVerifyCallbackFunction 函数的返回值。那么到现在可知该函数的关键判定就是在这一步了。如果在调用该函数之前就将对应的内核模块 LDR_DATA_TABLE_ENTRY 节点的 Flags 的 0x20 标志位置位,则会得到 MmVerifyCallbackFunction 函数校验通过的结果,达到绕过强制签名校验的目的。

在判断标志位之后,函数会执行一些诸如资源权限释放、恢复 Normal Kernel APC、Kernel APC 投递检查并继续投递等操作。具体可以参考前面部分的内容,代码的注释已经写清楚。

具体的验证可以通过 Windbg 跟一下。

现在回想在编译链接驱动程序的时候,在 sources 文件中可选添加的 LINKER_FLAGS=/INTEGRITYCHECK 链接标记,其实就是给生成的 sys 文件的 PE 文件头中对应的 Flags 数据置位 0x20 标志位。

  • THE END -

文章链接: https://cloud.tencent.com/developer/article/2191134

0 人点赞