反反rootkit技术的一些探索检测方法

admin 2024年10月6日23:46:36评论24 views字数 9070阅读30分14秒阅读模式

前言

发现一个不错的仓库:https://github.com/eversinc33/unKover,里面不断开发了一些反rootkit技术。将针对其中部分技术展开本博客。

对于熟悉用户级恶意软件的人来说,手动映射驱动程序的概念类似于反射 PE 加载的概念 ,不是从磁盘加载程序,而是手动将其映像布局到内存中。然而,驱动程序必须映射到内核内存,这意味着我们必须在环 0 中已经有某种 write-primitive。这就是 BYOVD 和 LOLDrivers 发挥作用的地方,如果我们可以利用已签名但易受攻击的驱动程序将任意数据写入内核空间,我们就可以将 rootkit 驱动程序的映像写入内存,就像它是合法加载的一样。

使用 kdmapper 手动映射驱动程序

将驱动程序映射到内存的最知名工具可以说是 kdmapper,它利用 intel 的易受攻击的 iqvw64e.sys 驱动程序将任意驱动程序写入内核。当然,现在这个 loldriver在某些环境中被列入黑名单。但是可以将内存原语替换为任何其他易受攻击的驱动程序,最好是只有你的红队知道的驱动程序,并且仍然使用 KDMapper 来部署你的 Rootkit。除了这个映射过程之外,kdmapper 还负责擦除正在加载的 intel 驱动程序的痕迹。描述映射器不是本文的重点,但它涉及清除各种未记录的数据结构中的条目,例如 Defender WdFilter.sys 驱动程序使用的 PiDDBCacheTable、MmUnloadedDrivers 数组、g_KernelHashBucketList 和 RuntimeDriver* 结构。那么,我们如何检测手动映射的驱动程序呢?在第一个检测思路之前,我们需要再谈谈一个话题:驱动通信。

驱动间通信

通常,驱动程序和 rootkit 通过 IOCTL 代码进行通信,IOCTL 代码是通过设备句柄通过 DeviceIoControl API 发送到设备的控制消息。若要使用户模式程序获取此类句柄,驱动程序必须注册用户模式程序可用于调用 CreateFile 的设备对象。在驱动程序中如下所示:

  1. NTSTATUS

  2. DriverEntry(PDRIVER_OBJECT pDriverObject, PUNICODE_STRING pRegistryPath)

  3. {

  4. IoCreateDevice(

  5. pDriverObject,

  6. 0,

  7. &usDriverName,

  8. FILE_DEVICE_UNKNOWN,// not associated with any real device

  9. FILE_DEVICE_SECURE_OPEN,

  10. FALSE,

  11. &pDeviceObject

  12. );

  13. }

这会在 Windows 对象管理器中为驱动程序创建一个对象,通常采用 Device<Name>名称。客户端可以使用

  1. CreateFileA("\Device\Rootkit",

  2. GENERIC_READ | GENERIC_WRITE,0, NULL, OPEN_EXISTING,0, NULL)

打开rootkit句柄,随后将 IOCTL 代码发送到此句柄以控制 rootkit。

如果我们选择这种标准的 Usermode/Kernelmode 通信方式,我们的 rootkit 将显示在 Windows 对象管理器中。但是,反 Rootkit 如何将其与合法驱动程序区分开来呢?好吧,如果它是手动映射的,我们可以简单地查看所有设备对象,并检查它们在内核内存中的映像是否真的由有效的模块支持。参考下文检测规则1.

检测规则1:查询设备对象

要查询设备对象,首先需要一系列 API 调用来获取 Driver 目录对象:

  1. /* Code skeleton stolen from https://github.com/not-wlan/driver-

  2. hijack/blob/master/memedriver/hijack.cpp#L136 */

  3. // Get Handle to Driver directory

  4. InitializeObjectAttributes(&attributes,&directoryName, OBJ_CASE_INSENSITIVE, NULL,

  5. NULL);

  6. ZwOpenDirectoryObject(&handle, DIRECTORY_ALL_ACCESS,&attributes);

  7. // Get the object from the handle

  8. ObReferenceObjectByHandle(handle, DIRECTORY_ALL_ACCESS,nullptr,KernelMode,

  9. &directory,nullptr);

  10. POBJECT_DIRECTORY directoryObject =(POBJECT_DIRECTORY)directory;

有了这个对象,我们现在可以开始迭代每个 device 对象。对象管理器实际上将对象组织在具有 37 个条目的 hashbucket 中(有关技术详细信息,请参阅:https://www.informit.com/articles/article.aspx?p=22443&seqNum=7)。

  1. // acquire the lock for accessing the directory object

  2. KeEnterCriticalRegion();

  3. ExAcquirePushLockExclusiveEx(&directoryObject->Lock,0);

  4. // iterate over the hashbucket

  5. for(POBJECT_DIRECTORY_ENTRY entry : directoryObject->HashBuckets)

  6. {

  7. if(!entry)

  8. continue;

  9. // iterate over each hashbuckets entries items

  10. while(entry !=nullptr&& entry->Object)

  11. {

  12. PDRIVER_OBJECT driver =(PDRIVER_OBJECT)entry->Object;

  13. /*

  14. * We are simply checking if the driver entry (DriverInit) memory

  15. * resides inside of one of the loaded modules address spaces.

  16. */

  17. if(GetDriverForAddress((ULONG_PTR)driver->DriverInit)== NULL)

  18. {

  19. LOG_MSG("[DeviceObjectScanner] -> Detected DriverEntry

  20. pointing to unbacked region %ws @ 0x%llxn",

  21. driver->DriverName.Buffer,

  22. (ULONG_PTR)driver->DriverInit

  23. );

  24. }

  25. entry = entry->ChainLink;

  26. }

  27. }

  28. // Release lock when done

  29. ExReleasePushLockExclusiveEx(&g_hashBucketLock,0);

  30. KeLeaveCriticalRegion();

检查内存地址是否在已加载模块的地址空间内的实现相当简单:我们迭代 DriverSection->InLoadOrderLinks 链表,其中包含每个已加载驱动程序的KLDRDATATABLE_ENTRY(有点类似于的 PEB 的 Usermode InLoadOrderModuleList)。在这里,我们检查地址是否驻留在其中一个模块中 - 如果它不属于任何模块,则手动将其映射到内存。

  1. PKLDR_DATA_TABLE_ENTRY

  2. GetDriverForAddress(ULONG_PTR address)

  3. {

  4. if(!address)

  5. {

  6. return NULL;

  7. }

  8. PKLDR_DATA_TABLE_ENTRY entry =(PKLDR_DATA_TABLE_ENTRY)(g_drvObj)-

  9. >DriverSection;

  10. for(auto i =0; i <512;++i)

  11. {

  12. UINT64 startAddr = UINT64(entry->DllBase);

  13. UINT64 endAddr = startAddr + UINT64(entry->SizeOfImage);

  14. if(address >= startAddr && address < endAddr)

  15. {

  16. return(PKLDR_DATA_TABLE_ENTRY)entry;

  17. }

  18. entry =(PKLDR_DATA_TABLE_ENTRY)entry->InLoadOrderLinks.Flink;

  19. }

  20. return NULL;

  21. }

如果我们现在加载基于 IOCTL 的 rootkit 驱动程序(例如通过 kdmapper 加载 Nidhogg)并运行 unKover,我们可以快速看到 DeviceObject 扫描程序将 Nidhogg 驱动程序发现为未支持的内核内存区域:

反反rootkit技术的一些探索检测方法

虽然有几种方法可以逃避这种简单的检测,但其中一种更简单的方法是改变 Usermode/Kernelmode 通信的方法,不再使用基于 IOCTL 的通信,这样我们甚至不需要再注册设备,因此在对象管理器中根本不可见。我想到的是通过共享内存或命名管道进行通信。在github中,目前正在使用共享内存,这是在 rootkit 驱动程序和 Usermode 客户端之间共享的。

现在,有了等待新命令的 rootkit 持续读取的命名管道或共享内存(while true:ReadCommandFromSharedMemory()),下面出现了新的检测 - 反 rootkit 只需通过分析线程调用堆栈来识别线程,以查找指向无支持的内存的帧。

检测规则2:使用 APC 检测无支持的系统线程

有无数方法可以完成上述操作。一种检测映射作弊驱动程序的方法(例如 BattleEye 反作弊器使用的方法)是将 APC 排队到所有系统线程,从而展开每个线程的堆栈帧。然后,反作弊可以检查每一帧的地址是否指向无支持的内存 - 如果指向,则我们捕获了潜在的 rootkit/作弊线程。

现在内核中的 APC 是一个超级复杂的话题。我发现一个对实现这一点有用的博客是 https://repnz.github.io/posts/apc/kernel-user-apc-api/#kernel-apc-api-reference。

首先,必须定义分析线程调用堆栈的 APC(为简洁起见,省略了大量代码):

  1. VOID

  2. CaptureStackAPC(

  3. IN PKAPC Apc,

  4. IN OUT PKNORMAL_ROUTINE*NormalRoutine,

  5. IN OUT PVOID*NormalContext,

  6. IN OUT PVOID*SystemArgument1,

  7. IN OUT PVOID*SystemArgument2

  8. )

  9. {

  10. // allocate memory for the stack frames

  11. PVOID* stackFrames =(PVOID*)ExAllocatePoolWithTag(NonPagedPoolNx,

  12. MAX_STACK_DEPTH *sizeof(PVOID), POOL_TAG);

  13. // zero the memory

  14. RtlSecureZeroMemory(stackFrames, MAX_STACK_DEPTH *sizeof(PVOID));

  15. /*

  16. * Capture the stack trace.

  17. * All the heavy lifting is done by RtlCaptureStackBackTrace, which

  18. * unwinds the stack for us and gives back a pointer of

  19. */

  20. USHORT framesCaptured =RtlCaptureStackBackTrace(0, MAX_STACK_DEPTH,

  21. stackFrames, NULL);

  22. // Stack trace analysis...

  23. for(auto i =0; i < framesCaptured;++i)

  24. {

  25. // Check if address of frame is from unbacked memory

  26. ULONG_PTR addr =(ULONG_PTR)stackFrames[i];

  27. if(GetDriverForAddress(addr)== NULL)

  28. {

  29. DbgPrint("[APCStackWalk] -> Detected stack frame pointing to

  30. unbacked region: TID: %lu @ 0x%llxn",HandleToUlong(PsGetCurrentThreadId()), addr);

  31. }

  32. }

  33. if(stackFrames){ExFreePoolWithTag(stackFrames, POOL_TAG);}

  34. // Free the APC and signal that the APC is done

  35. ExFreePoolWithTag(Apc, POOL_TAG);

  36. KeSetEvent(&g_kernelApcSyncEvent,0, FALSE);

  37. }

RtlCaptureStackBackTrace 完成了艰苦的工作,它为我们展开了堆栈帧,使堆栈分析变得轻而易举。现在我们只需将此 APC 排队到所有系统线程。

  1. VOID

  2. APCStackWalk()

  3. {

  4. KeInitializeEvent(&g_kernelApcSyncEvent,NotificationEvent, FALSE);

  5. // Queue APCs to system threads. System thread IDs are a multiple of 4.

  6. // (Usually at least. See:

  7. https://devblogs.microsoft.com/oldnewthing/20080228-00/?p=23283)

  8. for(auto tid =4; tid <0xFFFF; tid +=4)

  9. {

  10. PETHREAD ThreadObj;

  11. // Get ETHREAD object for TID

  12. if(!NT_SUCCESS(PsLookupThreadByThreadId(UlongToHandle(tid),

  13. &ThreadObj)))

  14. {

  15. continue;

  16. }

  17. // Ignore current thread and non system threads

  18. if(!PsIsSystemThread(ThreadObj)||ThreadObj==

  19. KeGetCurrentThread())

  20. {

  21. ObDereferenceObject(ThreadObj);

  22. continue;

  23. }

  24. // Initialize APC

  25. PKAPC apc =(PKAPC)ExAllocatePoolWithTag(

  26. NonPagedPool,

  27. sizeof(KAPC),

  28. POOL_TAG

  29. );

  30. KeInitializeApc(apc,

  31. ThreadObj,

  32. OriginalApcEnvironment,

  33. CaptureStackAPC,

  34. RundownAPC,// Empty APC routine

  35. NormalAPC,// Empty APC routine

  36. KernelMode,

  37. NULL

  38. );

  39. // Queue APC

  40. NTSTATUS NtStatus=KeInsertQueueApc(apc, NULL, NULL,

  41. IO_NO_INCREMENT);

  42. // Wait for event to signal that the apc is done before queueing the

  43. next one

  44. LARGE_INTEGER timeout;

  45. timeout.QuadPart=2000;// 2 second wait timeout

  46. NtStatus=KeWaitForSingleObject(&g_kernelApcSyncEvent,Executive,

  47. KernelMode, FALSE,&timeout);

  48. KeResetEvent(&g_kernelApcSyncEvent);

  49. // Clean up

  50. if(ThreadObj){ObDereferenceObject(ThreadObj);}

  51. }

  52. }

运行Git项目,它使用共享内存 KM/UM 通信和读取命令的系统线程,我们马上能看到线程被找到:

反反rootkit技术的一些探索检测方法

因此,即使我们没有注册设备对象,由于我们的系统线程源自未支持的内存,我们也会被 unKover 检测到。

同样,有很多方法可以规避这种检测:其中之一是堆栈欺骗,这样我们就可以假装不在无支持的内存中。另一种技术基于系统线程的 KTHREAD 对象的直接内核对象修改 (DKOM) , 如果我们将其 ApcQueueable 位设置为 0,则实际上不允许任何 APC 在我们的线程上排队 (https://www.unknowncheats.me/forum/anti-cheat-bypass/587069-disable-apc.html) - 这是 KeEnterCriticalRegion 使用的功能(即使反 rootkit 可以将此位翻转回来 - 这是非常具有侵入性的,并且极大地使操作系统的稳定性处于危险之中, 如果 APC 开始在关键代码区域中排队)。请记住,KTHREAD 是一个未记录的结构,它因 Windows 版本而异。

检测规则3:Non-Maskable-Interrupts

NMI 是不可屏蔽的中断,这意味着它们是发送到 CPU 的硬件驱动的中断,无法被屏蔽(即防止发生)。在 Windows 中,可以使用 HalSendNmi API 将 NMI 发送到 CPU 内核,将在中断时直接中断该内核上运行的线程,并调用 NMI 回调。NMI 回调函数可以由任何内核驱动程序定义,因此可用于检查在特定内核上运行的线程的调用堆栈,就像上面对 APC 一样。这意味着,如果我们幸运的话,我们会捕获到 CPU 上运行的 rootkit 线程,并且如果我们不时发送足够的 NMI,则可以遍历堆栈以查找未支持的内存指针。

首先,必须定义 NMI 回调:

  1. BOOLEAN

  2. NmiCallback(PVOID context, BOOLEAN handled)

  3. {

  4. PNMI_CONTEXT nmiContext =(PNMI_CONTEXT)context;

  5. ULONG procNum =KeGetCurrentProcessorNumber();

  6. nmiContext[procNum].threadId =HandleToULong(PsGetCurrentThreadId());

  7. // capture the stack trace

  8. nmiContext[procNum].framesCaptured =RtlCaptureStackBackTrace(

  9. 0,

  10. STACK_CAPTURE_SIZE,

  11. (PVOID*)nmiContext[procNum].stackFrames,

  12. NULL

  13. );

  14. return TRUE;

  15. }

由于 NMI 不应运行太长时间,因此出于稳定性考虑,我们将信息保存到堆分配的内存中,并在另一个线程中解析其数据:

  1. VOID

  2. AnalyzeNmiData()

  3. {

  4. for(auto core=0u; core<g_numCores;++core)

  5. {

  6. PETHREAD ThreadObj= NULL;

  7. NMI_CONTEXT nmiContext = g_NmiContext[core];

  8. // get the thread object

  9. PsLookupThreadByThreadId(ULongToHandle(nmiContext.threadId),

  10. &ThreadObj);

  11. // Check each stack frame for origin

  12. for(auto i =0; i < nmiContext.framesCaptured;++i)

  13. {

  14. ULONG_PTR addr =(ULONG_PTR)(nmiContext.stackFrames[i]);

  15. PKLDR_DATA_TABLE_ENTRY driver =GetDriverForAddress(addr);

  16. if(driver == NULL)

  17. {

  18. LOG_MSG("[NmiCallback] -> Detected stack frame

  19. pointing to unbacked region. TID: %u @ 0x%llx", nmiContext.threadId, addr);

  20. }

  21. }

  22. if(ThreadObj){ObDereferenceObject(ThreadObj);}

  23. }

  24. }

此 logic 与 APC 解析中使用的 logic 几乎相同。现在在主循环中,我们会定期发送 NMI,以期捕获线程:

  1. VOID

  2. SendNMI(IN PVOID StartContext)

  3. {

  4. NTSTATUS NtStatus;

  5. do

  6. {

  7. // Register callback

  8. g_NmiCallbackHandle =KeRegisterNmiCallback(NmiCallback,

  9. g_NmiContext);

  10. // Fire NMI for each core

  11. for(auto core=0u; core<g_numCores;++core)

  12. {

  13. KeInitializeAffinityEx(g_NmiAffinity);

  14. KeAddProcessorAffinityEx(g_NmiAffinity, core);

  15. HalSendNMI(g_NmiAffinity);

  16. // Sleep for 1 seconds between each NMI to allow completion

  17. SleepMs(1000);

  18. }

  19. // Unregister the callback

  20. KeDeregisterNmiCallback(g_NmiCallbackHandle);

  21. // Analyze data

  22. AnalyzeNmiData();

  23. SleepMs(5000);

  24. }while(true);

  25. }

由于回调可以被 rootkit 删除,因此我们确保在触发 NMI 之前注册回调,并在之后取消注册。如果我们让它运行足够长的时间,我们迟早会捕获一个指向 unbacked memory 的线程(尽管如果线程大部分时间都在休眠。这有点像在进行内存扫描时捕获beacon ,在这种情况下,通常只捕获混淆的内存)。

反反rootkit技术的一些探索检测方法

结论

虽然我不确定 rootkit 在常见 EDR 产品的威胁模型中的集成程度如何,并且到目前为止还没有真正面临任何检测,但这个实验了解潜在的检测向量。我相信 EDR 在干扰内核组件时会非常小心,因为这会对整个系统的稳定性构成重大风险。但是,我计划花一些时间反转常见的反 Rootkit 驱动程序,以找出它们实现的检测类型。

原文始发于微信公众号(TIPFactory情报工厂):反反rootkit技术的一些探索检测方法

免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉。
  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2024年10月6日23:46:36
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   反反rootkit技术的一些探索检测方法https://cn-sec.com/archives/3220230.html
                  免责声明:文章中涉及的程序(方法)可能带有攻击性,仅供安全研究与教学之用,读者将其信息做其他用途,由读者承担全部法律及连带责任,本站不承担任何法律及连带责任;如有问题可邮件联系(建议使用企业邮箱或有效邮箱,避免邮件被拦截,联系方式见首页),望知悉.

发表评论

匿名网友 填写信息