漩涡:EDR内核回调、钩子和调用堆栈

译者序

本文来自:Maelstrom: EDR Kernel Callbacks, Hooks, and Call Stack,版权归原作者所有。

译者从事HIDS/HIPS等领域,与EDR安全产品场景类似。从本篇文章中,也学到了Windows系统上的HOOK知识,了解了攻击手法,故翻译整理出来,分享给大家。感谢翻译工具OpenAI Translator。

介绍

为了回顾一下这个系列,我们从总体上看了一个命令和控制服务器(C2)的高级目的和意图,到设计和实现我们第一个植入物和服务器。如果你一直在跟随着做,你可能会认为自己已经写出了一个 C2
这是一种常见的心态。根据我们的经验,在达到这个点上并不需要太多复杂性。我们之前所有的工作都可以很容易地通过使用 C#、Python、Go 等语言在疯狂喝咖啡打字的晚上轻松完成(而且已经完成过!)。C2 的主要特点通常可以与软件工程中相当古老且解决方案明确的概念和模式联系起来,例如线程管理、处理空闲进程以及确保正确执行程序流。

但正如我们编写各种 C2 时发现的那样,并且许多其他攻击性开发人员编写他们自己的植入物和服务器时也发现了同样问题:一旦代码运行良好并能获得 pingback 后,您就停止在开发计算机上运行您的植入物并尝试在第二台计算机上运行它。这就是问题开始浮现之处。比如“为什么我无法访问远程文件?”,“为什么我可以通过这个协议进行出站请求,但不能通过那个协议?”,“为什么这个命令只是失败了而没有解释”,以及对于有足够的冒名顶替综合症的愤世嫉俗者,“为什么 Defender 没有阻止我做这件事?”。

就我们个人而言,这篇文章是我们期待写作的。它将是一个讨论,并附带一些示例,介绍在具有主动端点保护环境中越来越常见的行为。在 2022 年,植入物面临着更多审查——植入物和 C2 运营商必须准备好面对或规避此类审查,而防御者必须了解其工作原理,以便最大限度地利用它。

在撰写本文时,我们还想澄清关于“它避开了<插入公司名称> EDR”的推文。虽然该植入物能够执行操作并不意味着端点保护程序看不到它——可能会如此,但我们想演示一些这些解决方案用于识别恶意行为并引起对植入物怀疑的技术。

简而言之,执行的证明并不等于逃避的证明。

目的

这篇文章将涵盖以下内容:

  • 设置 The Hunting ELK
  • 审查三种EDR可以检测或阻止恶意执行的方式:
    • Kernel Callbacks
    • Hooking
    • Thread Call Stacks

通过本篇文章的阅读,我们将了解到现代EDR如何保护免受恶意植入物的攻击以及这些保护措施如何被绕过。我们将从一个技术上可行但实际无法达成操作目标的植入物转变为对如何编写能够实际工作并能够达成操作者目标的植入物有所认识。

正如我们多次强调的那样,我们不会创建一个可用于操作的C2。本系列输出内容质量较差且存在缺陷 – 它只足以作为一种破碎证明特定主题中讨论内容正确性的概念验证代码,以避免该代码被恶意使用。出于同样原因,我们试图避免在本系列中讨论红队操作策略。然而随着文章深入,很明显与已经受到妥协用户典型行为相融合是有效的。这是 xpn在Twitter上曾经谈论过的话题:

Find Confluence, read Confluence.. become the employee!

— Adam Chester (@xpn) January 22, 2022

如果您的植入物已被EDR标记,查询每个AD加入计算机上的NetSessionEnum以查找活动会话可能不是典型用户行为。直到它停止响应,您可能不知道自己的植入物已被标记。从这里开始,就要竞赛了,直到您的植入物上传到VirusTotal,然后您必须重新开始规划。

在本博客中我们将经常提到以下程序:

  • The Hunting ELK (HELK):HELK是Elastic堆栈最好由他们自己总结的开源狩猎平台之一:> The Hunting ELK或简称HELK是第一个具有高级分析功能(如SQL声明性语言、图形、结构化流和甚至通过Jupyter笔记本和Apache Spark进行机器学习)ELK堆栈上的开源狩猎平台之一。

该项目主要用于研究,但由于其灵活设计和核心组件,可以在正确配置和可扩展基础设施下部署在更大环境中。

  • PreEmpt:伪EDR,具有消化EtwTi、内存扫描器、钩子等功能。虽然这不是公开的,但在必要时将共享代码。

这两个工具将允许我们在需要时生成概念验证数据。

重要概念

Maelstrom: Writing a C2 Implant类似,我们希望有一个专门的部分来澄清一些在继续之前需要一些背景知识的主题。

我们所说的端点检测和响应是什么意思

端点检测和响应(EDR)软件有许多不同的缩写,不同公司的程序及其功能可能存在区别。为简单起见,我们称所有仅限于静态扫描磁盘文件的程序为“反病毒”,而将所有进一步扫描设备内存、查看程序运行时行为并在发生威胁时进行响应的程序称为“EDR”。这些可能被称为各种名称,包括XDR、MDR或普通AV。

在本系列中,正如我们迄今所做的那样,我们将坚持使用“EDR”。

关于此问题一个很好的概述是CrowdStrike发布了“What is Endpoint Detection and Response (EDR)”

端点检测和响应(EDR),也称为端点检测和威胁响应(EDTR),是一种端点安全解决方案,可持续监视最终用户设备以侦测和应对勒索软件和恶意软件等网络威胁。

EDR由Gartner的Anton Chuvakin创造,被定义为“记录并存储端点系统级行为,使用各种数据分析技术来检测可疑的系统行为,提供上下文信息、阻止恶意活动,并提供修复建议以恢复受影响的系统。”

因为这与本帖子相关,下一节将介绍EDR架构并比较不同厂商之间的EDR行为。我们不会大量偏离主题地涉及其他相关领域,例如反病毒软件如何工作、基于磁盘保护如何防止植入执行(如果您仍在运行磁盘)、AntiVirus和EDR实际上是如何扫描文件及其行为的。结果证明,那就像一个完整的学科领域。

常见的EDR架构

在讨论端点保护时,了解其架构可能会有所帮助。Symantec EDR Architecture 的架构大致如下:

类似的方法也可以看到 Defender for Endpoint 中。基本上,安装该产品的设备将具有代理程序,其中包括几个驱动程序和进程,这些驱动程序和进程从机器的各个方面收集遥测数据。通过本文和下一篇文章,我们将介绍其中一些。

顺便说一句,在Windows环境中,Microsoft天生就占据优势。虽然这目前针对“大型企业”客户(或者至少我们认为是这样,考虑到他们在Azure上的价格!),但Microsoft Defender和新版Defender MDE都可以访问Microsoft对…自己操作系统的知识,并影响新操作系统功能的开发。长期来看,不难想象微软Defender MDE会以类似于微软Defender影响反病毒市场的方式影响EDR市场。

所有EDR通常都是从代理发送遥测数据到云端,在那里通过各种沙盒和其他测试设备运行,并且可以由机器和人员操作员进一步分析其行为。

对于过度好奇的读者,以下链接详细介绍了特定供应商的EDR架构方法:

简要回顾和比较高级别的EDR行为

不想离题太远,就像并非每个红队评估都是红队一样,并非每个EDR都是真正的EDR。以下Gartner Magic Quadrant来自Gartner 2021年5月报告,大致勾画了EDR领域的格局。值得注意的是,CrowdStrike雇佣 Alex Ionescu(ReactOS内核维护者)表明目前最佳实践的EDR在很大程度上利用了对Windows内部功能知识以提高其性能:

由于许多EDR功能依赖于我们将在此处讨论的方法(例如定制编写直接行为、如内核回调和挂钩),因此能够快速实现新Microsoft Windows特性并开发自己可靠地与恶意进程交互和中断方式似乎是现代EDR与同行区分开来的显著特点。

另一个EDR供应商倾向使用且尤其因报告公开而使用的度量标准是Mitre Enginuity攻击评估描述如下:

MITRE Engenuity ATT&CK® 评估(Evals)计划将产品和服务提供商与MITRE专家合作,共同评估安全解决方案。Evals过程采用基于威胁的紫队方法应用系统化方法,以捕获关于解决方案能力的关键上下文,该能力可以检测或保护免受ATT&CK知识库定义的已知对手行为。每次评估的结果都会得到彻底记录并公开发布。

例如,在SentinelOne中,其结果可在以下链接中查看:SentinelOne概述。概述介绍了APT场景,并标记了是否检测到该技术,并可用作其“有效性”的跟踪器。然而,一些人在线上表达了这不是确定产品效果的全面方式。

从购买角度来看EDR时,有几种确定有效性的方法值得考虑,并在此简要介绍它们。主要考虑因素是某些供应商未必比杀毒软件提供更多功能。与任何产品一样,请确保为您的业务需求购买正确的解决方案。

用户空间和内核空间

在讨论内核和用户空间模型时,将使用以下架构图像,这对任何计算机科学毕业生来说都很熟悉:

绝大多数用户活动将发生在第3环(User Mode),令人惊讶的是内核在Kernel Mode中运行。

有关更多信息,请参见Windows编程/用户模式与内核模式。值得注意的是,用户模式和内核模式之间可以并且确实会发生交叉。

前面链接中的以下定义总结了这些层之间的差异:

  • Ring0(也称为kernel mode)具有对每个资源的完全访问权限。它是Windows内核运行的方式。
  • Ring1和Ring2可以定制不同级别的访问权限,但通常不使用,除非存在虚拟机。
  • Ring3(也称为user mode)对资源具有受限制的访问权限。

再次为了避免本帖子变得比已经很长还要长,请参阅Windows组件概述文档以获取有关下图更详细信息。然而,它只是显示了从进程、服务等到内核的Windows架构。我们很快会更详细地介绍这个问题。

使用WinAPI的应用程序将穿过到在Kernel Mode中运行的Native API(NTAPI)。

例如,可以使用API Monitor查看正在执行的调用:

上面显示了CreateThread被调用,然后不久之后又被调用NtCreateThreadEx

当调用KERNEL32.DLL中的函数时,例如CreateThread,它将对NTDLL.DLL中相应的NTAPI进行后续调用。例如,CreateThread 调用 NtCreateThreadEx。此函数将RAX寄存器填充为系统服务号(SSN)。最后,NTDLL.dll将发出一个SYSENTER指令。然后会导致处理器切换到内核模式,并跳转到预定义的函数,称为系统服务分发程序。

以下图像来自Rootkits: Subverting the Windows Kernel,在Userland Hooks部分中:

驱动程序

驱动程序是Windows的软件组件,它允许操作系统和设备进行通信。以下是来自什么是驱动程序?的一个例子:

例如,假设应用程序需要从某个设备读取一些数据。应用程序调用由操作系统实现的函数,而操作系统调用由驱动程序实现的函数。该驱动程序由设计和制造该设备的同一公司编写,知道如何与设备硬件通信以获取数据。在驱动程序从设备获取数据之后,它将数据返回给操作系统,然后再将其返回给应用程序。

对于终端保护而言,有几个原因使得驱动非常有用:

  • 使用回调对象(Callback Objects),这允许在发生某些事件时调用一个函数。例如,在稍后我们将看到使用 PsSetLoadImageNotifyRoutine 的 DLL 加载回调对象。
  • 访问 Windows 威胁情报事件跟踪中只能通过 ELAM 驱动从内核访问的特权信息。

Hooking

声明:在继续之前,请务必观看 REcon 2015 – Hooking Nirvana (Alex Ionescu),请在观看完毕后再回到本帖。

EDR 的另一个常见特性是用户空间 Hooking DLL。通常,这些 DLL 会在进程创建时加载,并用于通过自身代理 WinAPI 调用以评估使用情况,然后重定向到正在使用的任何 DLL。例如,如果正在使用 VirtualAlloc,则流程将如下所示:

钩子允许通过在函数地址处放置 jmp 指令来拦截 WinAPI 调用进行函数插装。此 jmp 将重定向调用的流程。我们将在以下部分中查看其实际操作方式。通过挂钩函数调用,作者可以:

  • 评估参数
  • 允许执行
  • 阻止执行

这不是详尽无遗的列表,但应该足以演示我们运行植入物时最经常遇到的功能。

其中一些例子包括:

狩猎ELK

为了不必从头开始编写所有令人生畏的逻辑就能访问我们的内核回调,我们将使用[狩猎ELK(HELK)]:

狩猎ELK或简称HELK是最早具有高级分析功能(如SQL声明语言、图形化、结构化流和甚至通过Jupyter笔记本和Apache Spark进行机器学习)的开源狩猎平台之一。该项目主要用于研究,但由于其灵活的设计和核心组件,可以在正确配置和可扩展基础设施下部署到更大规模的环境中。

我们还使用以下脚本来自探索DLL加载、链接和执行以搜索Sysmon日志:

param (
    [string]$Loader = "",
    [string]$dll = ""
 )

$eventId = 7
$logName = "Microsoft-Windows-Sysmon/Operational"

$Yesterday = (Get-Date).AddHours(-1)
$events = Get-WinEvent -FilterHashtable @{logname=$logName; id=$eventId ;StartTime = $Yesterday;}

foreach($event in $events)
{
    $msg = $event.Message.ToString()
    $image = ($msg|Select-String -Pattern 'Image:.*').Matches.Value.Replace("Image: ", "")
    $imageLoaded = ($msg|Select-String -Pattern 'ImageLoaded:.*').Matches.Value.Replace("ImageLoaded: ", "")
    if($image.ToLower().contains($Loader.ToLower()) -And $imageLoaded.ToLower().Contains($dll.ToLower()))
    {
        Write-Host Image Loaded $imageLoaded
    }
}

内核回调

内核回调,根据微软的说法: > 内核的回调机制提供了一种通用方式,使驱动程序能够在满足某些条件时请求和提供通知。 本质上,它们允许驱动程序接收和处理特定事件的通知。从veil-ivy/block_create_process.cpp中可以看到使用PsSetLoadImageNotifyRoutine 回调来阻止进程创建的实现:

#include <ntddk.h>
#define BLOCK_PROCESS "notepad.exe"
static OB_CALLBACK_REGISTRATION obcallback_registration;
static OB_OPERATION_REGISTRATION oboperation_callback;
#define PROCESS_CREATE_THREAD  (0x0002)
#define PROCESS_CREATE_PROCESS (0x0080)
#define PROCESS_TERMINATE      (0x0001)
#define PROCESS_VM_WRITE       (0x0020)
#define PROCESS_VM_READ        (0x0010)
#define PROCESS_VM_OPERATION   (0x0008)
#define PROCESS_SUSPEND_RESUME (0x0800)
static PVOID registry = NULL;
static UNICODE_STRING altitude = RTL_CONSTANT_STRING(L"300000");
//1: kd > dt nt!_EPROCESS ImageFileName
//+ 0x5a8 ImageFileName : [15] UChar
static const unsigned int imagefilename_offset = 0x5a8;
auto drv_unload(PDRIVER_OBJECT DriverObject) {
    UNREFERENCED_PARAMETER(DriverObject);
    ObUnRegisterCallbacks(registry);
}
OB_PREOP_CALLBACK_STATUS
PreOperationCallback(
    _In_ PVOID RegistrationContext,
    _Inout_ POB_PRE_OPERATION_INFORMATION PreInfo
) {
    UNREFERENCED_PARAMETER(RegistrationContext);

    if (strcmp(BLOCK_PROCESS, (char*)PreInfo->Object + imagefilename_offset) == 0) {
        if ((PreInfo->Operation == OB_OPERATION_HANDLE_CREATE))
        {

            if ((PreInfo->Parameters->CreateHandleInformation.OriginalDesiredAccess & PROCESS_TERMINATE) == PROCESS_TERMINATE)
            {
                PreInfo->Parameters->CreateHandleInformation.DesiredAccess &= ~PROCESS_TERMINATE;
            }

            if ((PreInfo->Parameters->CreateHandleInformation.OriginalDesiredAccess & PROCESS_VM_READ) == PROCESS_VM_READ)
            {
                PreInfo->Parameters->CreateHandleInformation.DesiredAccess &= ~PROCESS_VM_READ;
            }

            if ((PreInfo->Parameters->CreateHandleInformation.OriginalDesiredAccess & PROCESS_VM_OPERATION) == PROCESS_VM_OPERATION)
            {
                PreInfo->Parameters->CreateHandleInformation.DesiredAccess &= ~PROCESS_VM_OPERATION;
            }

            if ((PreInfo->Parameters->CreateHandleInformation.OriginalDesiredAccess & PROCESS_VM_WRITE) == PROCESS_VM_WRITE)
            {
                PreInfo->Parameters->CreateHandleInformation.DesiredAccess &= ~PROCESS_VM_WRITE;
            }
        }
    }

    return OB_PREOP_SUCCESS;
}
VOID
PostOperationCallback(
    _In_ PVOID RegistrationContext,
    _In_ POB_POST_OPERATION_INFORMATION PostInfo
)
{
    UNREFERENCED_PARAMETER(RegistrationContext);
    UNREFERENCED_PARAMETER(PostInfo);

}

extern "C" auto DriverEntry(PDRIVER_OBJECT DriverObject, PUNICODE_STRING RegistryPath) -> NTSTATUS {
    UNREFERENCED_PARAMETER(RegistryPath);
    DriverObject->DriverUnload = drv_unload;
    auto status = STATUS_SUCCESS;
    static OB_CALLBACK_REGISTRATION ob_callback_register;
    static OB_OPERATION_REGISTRATION oboperation_registration;
    oboperation_registration.Operations = OB_OPERATION_HANDLE_CREATE;
    oboperation_registration.ObjectType = PsProcessType;
    oboperation_registration.PreOperation = PreOperationCallback;
    oboperation_registration.PostOperation = PostOperationCallback;
    ob_callback_register.Altitude = altitude;
    ob_callback_register.Version = OB_FLT_REGISTRATION_VERSION;
    ob_callback_register.OperationRegistrationCount = 1;
    ob_callback_register.OperationRegistration = &oboperation_registration;
    status = ObRegisterCallbacks(&ob_callback_register, &registry);
    if (!NT_SUCCESS(status)) {
        DbgPrint("failed to register callback: %x \r\n",status);
    }
    return status;
}

在这个例子中,ObRegisterCallbacks 被用来阻止 notepad 的创建。一个终端保护解决方案可能不会以这种方式使用它,但很可能会使用此类回调作为遥测来确定是否发生了恶意活动。

在本节中,我们将讨论 PsSetLoadImageNotifyRoutine。该回调负责确切地执行其名称:当图像被加载到进程中时发送通知。有关示例实现,请参见从内核驱动程序订阅进程创建、线程创建和图像加载通知

触发回调

要理解 PsSetLoadImageNotifyRoutine 如何工作,我们需要确定它的触发器是什么。

假设以下代码:

#include <windows.h>
#include <stdio.h>

int main()
{
    HMODULE hModule = LoadLibraryA("winhttp.dll");
    printf("WinHTTP: 0x%p\n", hModule);
    return 0;
}

当调用LoadLibraryA时,该函数会注册一个回调来通知驱动程序已经发生了这种情况。为了在HELK中查看此日志,我们使用之前提到的脚本。如果我们过滤掉上面的代码中的 main.exe ,我们可以看到 winhttp.dll 已加载:

在Elastic中,我们也可以使用以下KQL:

process_name : "main.exe" and event_id: 7 and ImageLoaded: winhttp.dll 

event_original_message保存整个日志:

Image loaded:
RuleName: -
UtcTime: 2022-04-29 18:50:10.780
ProcessGuid: {3ebcda8b-3362-626c-a200-000000004f00}
ProcessId: 6716
Image: C:\Users\admin\Desktop\main.exe
ImageLoaded: C:\Windows\System32\winhttp.dll
FileVersion: 10.0.19041.1620 (WinBuild.160101.0800)
Description: Windows HTTP Services
Product: Microsoft® Windows® Operating System
Company: Microsoft Corporation
OriginalFileName: winhttp.dll
Hashes: SHA1=4F2A9BB575D38DBDC8DBB25A82BDF1AC0C41E78C,MD5=FB2B6347C25118C3AE19E9903C85B451,SHA256=989B2DFD70526098366AB722865C71643181F9DCB8E7954DA643AA4A84F3EBF0,IMPHASH=0597CE736881E784CC576C58367E6FEA
Signed: true
Signature: Microsoft Windows
SignatureStatus: Valid
User: PUNCTURE\admin

为了查看这是在做什么,我们可以浏览 ReactOS source code:

这对于熟悉它的工作方式很有帮助。但是,在 Bypassing Image Load Kernel Callbacks中,由 batsec确定触发器位于 NtCreateSection 调用中,然后在LdrpCreateDllSection中调用。因此,我们不需要花太多时间进行调试以找到它。

欺骗payload

在batsec的文章中,他们展示了以下代码可以使用来刷屏上述事件:

#include <stdio.h>
#include <windows.h>
#include <winternl.h>

#define DLL_TO_FAKE_LOAD L"\\??\\C:\\windows\\system32\\calc.exe"

BOOL FakeImageLoad()
{
    HANDLE hFile;
    SIZE_T stSize = 0;
    NTSTATUS ntStatus = 0;
    UNICODE_STRING objectName;
    HANDLE SectionHandle = NULL;
    PVOID BaseAddress = NULL;
    IO_STATUS_BLOCK IoStatusBlock;
    OBJECT_ATTRIBUTES objectAttributes = { 0 };

    RtlInitUnicodeString(
        &objectName,
        DLL_TO_FAKE_LOAD
    );

    InitializeObjectAttributes(
        &objectAttributes,
        &objectName,
        OBJ_CASE_INSENSITIVE,
        NULL,
        NULL
    );

    ntStatus = NtOpenFile(
        &hFile,
        0x100021,
        &objectAttributes,
        &IoStatusBlock,
        5,
        0x60
    );

    ntStatus = NtCreateSection(
        &SectionHandle,
        0xd,
        NULL,
        NULL,
        0x10,
        SEC_IMAGE,
        hFile
    );

    ntStatus = NtMapViewOfSection(
        SectionHandle,
        (HANDLE)0xFFFFFFFFFFFFFFFF,
        &BaseAddress,
        NULL,
        NULL,
        NULL,
        &stSize,
        0x1,
        0x800000,
        0x80
    );

    NtClose(SectionHandle);
}

int main()
{
    for (INT i = 0; i < 10000; i++)
    {
        FakeImageLoad();
    }

    return 0;
}

以下截图也来自该博客文章: batsec 发现,通过调用 NtCreateSection,可以在不实际加载 DLL 的情况下发送事件。同样地,欺骗行为可以被某种程度上武器化/操纵以执行其他操作,方法是更新 LDR_DATA_TABLE_ENTRY 结构体:

typedef struct _LDR_DATA_TABLE_ENTRY {
    LIST_ENTRY InLoadOrderLinks;
    LIST_ENTRY InMemoryOrderModuleList;
    LIST_ENTRY InInitializationOrderModuleList;
    PVOID DllBase;
    PVOID EntryPoint;
    ULONG SizeOfImage;
    UNICODE_STRING FullDllName;
    UNICODE_STRING BaseDllName;
    ULONG Flags;
    USHORT LoadCount;
    USHORT TlsIndex;
    union {
        LIST_ENTRY HashLinks;
        struct
        {
            PVOID SectionPointer;
            ULONG CheckSum;
        };
    };
    union {
        ULONG TimeDateStamp;
        PVOID LoadedImports;
    };
    PVOID EntryPointActivationContext;
    PVOID PatchInformation;
} LDR_DATA_TABLE_ENTRY, *PLDR_DATA_TABLE_ENTRY;

在这个例子中,我们将毫无理由地使用 CertEnroll.dll

UNICODE_STRING uFullPath;
UNICODE_STRING uFileName;

WCHAR* dllPath = L"C:\\Windows\\System32\\CertEnroll.dll";
WCHAR* dllName = L"CertEnroll.dll";

RtlInitUnicodeString(&uFullPath, dllPath);
RtlInitUnicodeString(&uFileName, dllName);

现在我们只需要逐步遍历结构并填写所需的信息。

  • Load Time:

    status = NtQuerySystemTime(&pLdrEntry2->LoadTime);
  • Load Reason (LDR_DLL_LOAD_REASON):

    pLdrEntry2->LoadReason = LoadReasonDynamicLoad;
  • 因为加载器需要模块基地址,所以我们在这里只加载 CALC.EXE 的 shellcode(稍后我们将更详细地讨论此部分):

SIZE_T bufSz = sizeof(buf); 
LPVOID pAddress = VirtualAllocEx(hProcess, 0, bufSz, MEM_COMMIT | MEM_RESERVE, PAGE_READWRITE); 
memcpy(pAddress, buf, sizeof(buf));
  • 哈希的基本名称(RtlHashUnicodeString):

    pLdrEntry2->BaseNameHashValue = UnicodeToHash(uFileName, FALSE);
  • 填写结构体的其余部分:

    pLdrEntry2->ImageDll = TRUE;
    pLdrEntry2->LoadNotificationsSent = TRUE;
    pLdrEntry2->EntryProcessed = TRUE;
    pLdrEntry2->InLegacyLists = TRUE;
    pLdrEntry2->InIndexes = TRUE;
    pLdrEntry2->ProcessAttachCalled = TRUE;
    pLdrEntry2->InExceptionTable = FALSE;
    pLdrEntry2->OriginalBase = (ULONG_PTR)pAddress;
    pLdrEntry2->DllBase = pAddress;
    pLdrEntry2->SizeOfImage = 6969;
    pLdrEntry2->TimeDateStamp = 0;
    pLdrEntry2->BaseDllName = uFileName;
    pLdrEntry2->FullDllName = uFullPath;
    pLdrEntry2->ObsoleteLoadCount = 1;
    pLdrEntry2->Flags = LDRP_IMAGE_DLL | LDRP_ENTRY_INSERTED | LDRP_ENTRY_PROCESSED | LDRP_PROCESS_ATTACH_CALLED;
  • 完成 DdagNode 结构体:
    dll-load-spoofing

pLdrEntry2->DdagNode = (PLDR_DDAG_NODE)HeapAlloc(GetProcessHeap(), HEAP_ZERO_MEMORY, sizeof(LDR_DDAG_NODE));
if (!pLdrEntry2->DdagNode)
{
    return -1;
}

pLdrEntry2->NodeModuleLink.Flink = &pLdrEntry2->DdagNode->Modules;
pLdrEntry2->NodeModuleLink.Blink = &pLdrEntry2->DdagNode->Modules;
pLdrEntry2->DdagNode->Modules.Flink = &pLdrEntry2->NodeModuleLink;
pLdrEntry2->DdagNode->Modules.Blink = &pLdrEntry2->NodeModuleLink;
pLdrEntry2->DdagNode->State = LdrModulesReadyToRun;
pLdrEntry2->DdagNode->LoadCount = 1;

这是它的演示:

在上面,可以看到CertEnroll.dll被加载到spoof-load.exe进程中。请记住,这并没有被加载。在这里发生的唯一事情就是传递了该DLL的字符串。然后我们告诉加载器该DLL的基地址是shellcode的基地址:

查看此技术,有两个明显的用例:

  • 将植入物基址(C2IMPLANT.REFLECTIVE.DLL)绑定到合法DLL(ADVAP32.DLL),使其看起来不那么可疑
  • 通过加载ADVAPI32.DLL但将其指向WinHTTP.DLL基址来删除IOC库(WinHTTP.DLL)。

绕过回调

我们不会在这里重新发明轮子,在Bypassing Image Load Kernel Callbacks中已经很好地解释了它。本质上,为了使回调不触发,需要重写完整的装载程序。该研究得出结论DarkLoadLibrary:

实际上,“DarkLoadLibrary”是“LoadLibrary”的一个实现,它不会触发图像装载事件。它还具有许多额外的功能,可以在恶意软件开发期间使生活更轻松。

这个库的概念验证用法来自DLL Shenanigans

让我们检查一下:

然后运行上述3个命令:

  • dark-loader使用LOAD_LOCAL_FILE标志从磁盘加载磁盘,就像LoadLibraryA一样。
  • 搜索图像装载日志以查找Kernel32以确保找到了日志。
  • 搜索winhttp.dll,没有返回值

为避免调用已被识别为注册回调的“NtCreateSection”,将段映射与“NtAllocateVirtualMemory”或“VirtualAlloc”完成,如MapSections()中所示。

内核回调结论

显然,PsSetLoadImageNotifyRoutine 不是唯一的回调函数,还有很多其他可用的回调函数。内核回调函数 有一个(非全面!)列表:

  • CmRegisterCallbackEx()
  • ExAllocateTimer()
  • ExInitializeWorkItem()
  • ExRegisterCallback()
  • FsRtlRegisterFileSystemFilterCallbacks()
  • IoInitializeThreadedDpcRequest()
  • IoQueueWorkItem()
  • IoRegisterBootDriverCallback()
  • IoRegisterContainerNotification()
  • IoRegisterFsRegistrationChangeEx()
  • IoRegisterFsRegistrationChangeMountAware()
  • IoRegisterPlugPlayNotification()
  • IoSetCompletionRoutineEx()
  • IoWMISetNotificationCallback()
  • KeExpandKernelStackAndCalloutEx()
  • KeInitializeApc()
  • KeInitializeDpc()
  • KeRegisterBugCheckCallback()
  • KeRegisterBugCheckReasonCallback()
  • KeRegisterNmiCallback()
  • KeRegisterProcessorChangeCallback()
  • KeRegisterProcessorChangeCallback()
  • ObRegisterCallbacks()
  • PoRegisterDeviceNotify()
  • PoRegisterPowerSettingCallback()
  • PsCreateSystemThread()
  • PsSetCreateProcessNotifyRoutineEx()
  • PsSetCreateThreadNotifyRoutine()
  • PsSetLoadImageNotifyRoutine()
  • SeRegisterLogonSessionTerminatedRoutine()
  • TmEnableCallbacks()

其中一个强大的方法是使用PsSetCreateProcessNotifyRoutineEx(),因为进程创建的通知对于系统遥测来说是致命的。在撰写本文时,我们不知道这个领域有任何研究。虽然说实话,我们也没有去看过。

Hooking 和 进程仪表化

在本节中,我们将介绍一些流行但基础的Hooking技术。

Hooking 示例

在查看一些库之前,让我们先看两个示例 – x86手动Hooks和NtSetProcessInformation回调函数。

手动 Hooks (x86)

使用Windows API Hooking作为x86示例(更容易演示),我们可以将代码改编成以下形式:

#include <windows.h>
#include <stdio.h>

#define BYTES_REQUIRED 6

int __stdcall HookedMessageBoxA(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType) 
{
    printf("\n[ HOOKED MESSAGEBOXA ]\n");
    printf("-> Arguments:\n");
    printf("  1. lpText: %s\n", lpText);
    printf("  2. lpCaption: %s\n", lpCaption);
    printf("  3. uType: %ld\n", uType);
    return 1;
}

void PrintHexA(char* data, int sz)
{
    printf("  -> ");
    for (int i = 0; i < sz; i++)
    {
        printf("\\x%02hhX", data[i]);
    }

    printf("\n");
}

int main()
{

    SIZE_T lpNumberOfBytesRead = 0;
    HMODULE hModule = nullptr;
    FARPROC pMessageBoxAFunc = nullptr;
    char pMessageBoxABytes[BYTES_REQUIRED] = {};

    void* pHookedMessageBoxFunc = &HookedMessageBoxA;

    hModule = LoadLibraryA("user32.dll");
    if (!hModule)
    {
        return -1;
    }

    pMessageBoxAFunc = GetProcAddress(hModule, "MessageBoxA");

    printf("-> Original MessageBoxA: 0x%p\n", pMessageBoxAFunc);

    if (ReadProcessMemory(GetCurrentProcess(), pMessageBoxAFunc, pMessageBoxABytes, BYTES_REQUIRED, &lpNumberOfBytesRead) == FALSE)
    {
        printf("[!] ReadProcessMemory: %ld\n", GetLastError());
        return -1;
    }

    printf("-> MessageBoxA Hex:\n");

    PrintHexA(pMessageBoxABytes, BYTES_REQUIRED);

    printf("-> Hooked MessageBoxA: 0x%p\n", pHookedMessageBoxFunc);

    char patch[BYTES_REQUIRED] = { 0 };
    memcpy_s(patch, 1, "\x68", 1);
    memcpy_s(patch + 1, 4, &pHookedMessageBoxFunc, 4);
    memcpy_s(patch + 5, 1, "\xC3", 1);

    printf("-> Patch Hex:\n");
    PrintHexA(patch, BYTES_REQUIRED);

    if (WriteProcessMemory(GetCurrentProcess(), (LPVOID)pMessageBoxAFunc, patch, sizeof(patch), &lpNumberOfBytesRead) == FALSE)
    {
        printf("[!] WriteProcessMemory: %ld\n", GetLastError());
        return -1;
    }

    MessageBoxA(NULL, "AAAAA", "BBBBB", MB_OK);

    return 0;
}

让我们来看一下…

首先,MessageBoxAUser32.dll中,所以我们加载它:

hModule = LoadLibraryA("user32.dll");
if (!hModule)
{
    return -1;
}

接下来,我们需要USER32!MessageBoxA的地址:

pMessageBoxAFunc = GetProcAddress(hModule, "MessageBoxA");

有了地址,内存字节就可以读取了:

if (ReadProcessMemory(GetCurrentProcess(), pMessageBoxAFunc, pMessageBoxABytes, BYTES_REQUIRED, &lpNumberOfBytesRead) == FALSE)
{
    printf("[!] ReadProcessMemory: %ld\n", GetLastError());
    return -1;
}

这将读取函数调用的前6个字节,稍后将更新为push到新函数,从而导致jmp

The bytes:

\x8B\xFF\x55\x8B\xEC\x83

现在,需要构建补丁。操作如下:

char patch[BYTES_REQUIRED] = { 0 };
memcpy_s(patch, 1, "\x68", 1);
memcpy_s(patch + 1, 4, &pHookedMessageBoxFunc, 4);
memcpy_s(patch + 5, 1, "\xC3", 1);

从此生成的十六进制代码开始:

\x68\x12\x12\xBD\x00\xC3

使用defuse.ca将其反汇编,上述内容可以转换为汇编语言:

0:  68 12 12 bd 00          push   0xbd1212
5:  c3                      ret

请注意,推送的0x00BD1212是我们要跳转到的函数地址,而不是USER32!MessageBoxA调用:

void* pHookedMessageBoxFunc = &HookedMessageBoxA; 

此时,补丁已准备好。它将使用“push”替换前6个字节以指向新地址。接下来要做的就是实际写入这个新地址:

if (WriteProcessMemory(GetCurrentProcess(), (LPVOID)pMessageBoxAFunc, patch, sizeof(patch), &lpNumberOfBytesRead) == FALSE)
{
    printf("[!] WriteProcessMemory: %ld\n", GetLastError());
    return -1;
}

然后,在反汇编中:

00BB1212  jmp         HookedMessageBoxA (0BB1A80h)

添加了一个 jmp 来跳转到新函数。允许此操作运行会调用挂钩的函数并打印参数:

int __stdcall HookedMessageBoxA(HWND hWnd, LPCSTR lpText, LPCSTR lpCaption, UINT uType) 
{
    printf("\n[ HOOKED MESSAGEBOXA ]\n");
    printf("-> Arguments:\n");
    printf("  1. lpText: %s\n", lpText);
    printf("  2. lpCaption: %s\n", lpCaption);
    printf("  3. uType: %ld\n", uType);
    return 1;
}

运行它:

NtSetProcessInformation 回调函数

REcon 2015 – Hooking Nirvana (Alex Ionescu) 中,Alex Ionescu 展示了使用 NtSetProcessInformationProcessInstrumentationCallback 标志进行进程检测的过程。在这个演讲中,Alex 演示了通过回调函数进行钩子操作的过程。设置回调函数非常简单:

PROCESS_INSTRUMENTATION_CALLBACK_INFORMATION InstrumentationCallbackInfo;
InstrumentationCallbackInfo.Version = 0;
InstrumentationCallbackInfo.Reserved = 0;
InstrumentationCallbackInfo.Callback = CALLBACK_FUNCTION_GOES_HERE;
HANDLE hProcess = (HANDLE)-1;

HMODULE hNtdll = GetModuleHandleA("ntdll");
if (hNtdll == nullptr)
{
    return FALSE;
}

_NtSetInformationProcess pNtSetInformationProcess = reinterpret_cast<_NtSetInformationProcess>(GetProcAddress(hNtdll, "NtSetInformationProcess"));

if (pNtSetInformationProcess == nullptr)
{
    return FALSE;
}

NTSTATUS Status = pNtSetInformationProcess(hProcess, (PROCESS_INFORMATION_CLASS)ProcessInstrumentationCallback, &InstrumentationCallbackInfo, sizeof(InstrumentationCallbackInfo));
if (NT_SUCCESS(Status))
{
    return TRUE;
}
else
{
    return FALSE;
}

回调函数包含在以下代码中:

InstrumentationCallbackInfo.Callback = CALLBACK_FUNCTION_GOES_HERE;

CALLBACK_FUNCTION_GOES_HERE 是用作回调的函数,然后 ProcessInstrumentationCallback 是:

#define ProcessInstrumentationCallback 0x28

另外一点是,通过将回调设置为 NULL,可以删除发送的任何回调。这是由 modexpBypassing User-Mode Hooks and Direct Invocation of System Calls for Red Teams 中记录的。

随后,Secrary 建立了这个话题,并在 Secrary 的博客 Hooking via InstrumentationCallback 中进行了讨论。Alex Ionescu 的原始代码可以在 HookingNirvana 存储库中找到。

从 Secrary 借用钩子可访问函数和返回值,给我们以下汇编:

.code

PUBLIC asmCallback
EXTERN Hook:PROC

asmCallback PROC
    push rax ; return value
    push rcx
    push RBX
    push RBP
    push RDI
    push RSI
    push RSP
    push R12
    push R13
    push R14
    push R15 

    ; without this it crashes :)
    sub rsp, 1000h
    mov rdx, rax
    mov rcx, r10
    call Hook
    add rsp, 1000h

    pop R15 
    pop R14
    pop R13
    pop R12
    pop RSP
    pop RSI
    pop RDI
    pop RBP
    pop RBX
    pop rcx
    pop rax

    jmp R10
asmCallback ENDP

end

Hook: 完成汇编代码后,我们还需要编写被汇编调用的函数,以便能够接收所有提供的寄存器并返回它们的功能名称:

DWORD64 counter = 0;
bool flag = false;

EXTERN_C VOID Hook(DWORD64 R10, DWORD64 RAX/* ... */) {

    // This flag is there for prevent recursion
    if (!flag)
    {
        flag = true;

        counter++;

        CHAR buffer[sizeof(SYMBOL_INFO) + MAX_SYM_NAME] = { 0 };
        PSYMBOL_INFO pSymbol = (PSYMBOL_INFO)buffer;
        pSymbol->SizeOfStruct = sizeof(SYMBOL_INFO);
        pSymbol->MaxNameLen = MAX_SYM_NAME;
        DWORD64 Displacement;

        // MSDN: Retrieves symbol information for the specified address.
        BOOLEAN result = SymFromAddr(GetCurrentProcess(), R10, &Displacement, pSymbol);

        if (result) {
            printf("%s => 0x%llx\n", pSymbol->Name, RAX);
        }

        flag = false;
    }
}

然后,在 main 函数中,调用 SymInitialize 函数,然后设置插桩:

int main()
{
    SymSetOptions(SYMOPT_UNDNAME);
    SymInitialize(GetCurrentProcess(), NULL, TRUE);

    SetInstrumentationCallback();

    return 0;
}

运行此完成的示例,我们现在可以看到所有函数名称和返回代码:

该钩子可以更新以获取访问完整分析所需的参数,但是对于这个最初的概念验证,我们没有感觉有必要研究它。

最后提一下这种技术可用于枚举给定函数调用的系统服务号(SSN)。Paranoid NinjaEtwTi-Syscall-HookRelease v0.8 – Warfare Tactics 中记录了这一点,在那里钩子更小(代价是做得少得多):

VOID HuntSyscall(ULONG_PTR ReturnAddress, ULONG_PTR retSyscallPtr) {
    PVOID ImageBase = ((EtwPPEB)(((_EtwPTEB)(NtCurrentTeb()->ProcessEnvironmentBlock))))->ImageBaseAddress;
    PIMAGE_NT_HEADERS NtHeaders = RtlImageNtHeader(ImageBase);
    if (ReturnAddress >= (ULONG_PTR)ImageBase && ReturnAddress < (ULONG_PTR)ImageBase + NtHeaders->OptionalHeader.SizeOfImage) {
        printf("[+] Syscall detected:  Return address: 0x%X  Syscall value: 0x%X\n", ReturnAddress, retSyscallPtr);
    }
}

它的配对反汇编:

section .text

extern HuntSyscall
global hookedCallback

hookedCallback:
    push rcx
    push rdx
    mov rdx, [r10-0x10]
    call HuntSyscall
    pop rdx
    pop rcx
    ret

绕过用户空间钩子

在2019年,Cneelis 发布了 Red Team Tactics: Combining Direct System Calls and sRDI to bypass AV/EDR ,随后发布了 SysWhispers:

SysWhispers 为红队提供了生成核心内核映像 (ntoskrnl.exe) 中任何系统调用的头文件 / ASM 对的能力。这些头文件还将包括必要的类型定义。

然后,modexp 提供了一个 更新版本,修正了第一版中的缺陷,并给我们带来了 SysWhispers2:

SysWhispers2 中特定的实现是 @modexpblog 代码的变体之一。其中一个区别是每次生成时函数名称哈希都会被随机化。@ElephantSe4l曾经早期发表过这种技术,他有另外一个基于 C++17 的实现,也值得一看。

主要变化是引入了 base.c,这是 Bypassing User-Mode Hooks and Direct Invocation of System Calls for Red Teams 的结果。

而且,KlezVirus 还制作了 SysWhispers3:

使用方法与 SysWhispers2 非常相似,以下是一些例外:

  • 它还支持 x86 / WoW64
  • 它支持使用 EGG(要动态替换)替换 syscalls 指令
  • 它支持在 x86 / x64 模式下直接跳转到 syscalls(在 WOW64 中几乎是标准的)
  • 它支持随机系统调用的直接跳转(借鉴 @ElephantSeal’s idea

更好地解释这些功能可以在博客文章中找到: SysWhispers is dead, long live SysWhispers!

这只是 SysCall 技术套件之一,还有一个基于 Heavens Gate 的完整技术。请参见 Gatekeeping Syscalls 以了解这些不同的技术。

即使如此!还有更多:

回顾一下!

通过能够转换为内核模式,我们可以避开用户空间钩子。因此,让我们构建一些东西。

对于我们的示例,我们将使用MinHook

Windows 的极简 x86 / x64 API Hooking 库

DLL

这将是一个被加载到进程中并钩取功能以根据其行为做出某些决策的 DLL。这里是 DllMain

BOOL APIENTRY DllMain(HINSTANCE hInst, DWORD reason, LPVOID reserved)
{
    switch (reason)
    {
    case DLL_PROCESS_ATTACH:
    {
        HANDLE hThread = CreateThread(nullptr, 0, SetupHooks, nullptr, 0, nullptr);
        if (hThread != nullptr) {
            CloseHandle(hThread);
        }
        break;
    }
    case DLL_PROCESS_DETACH:

        break;
    }
    return TRUE;
}

DLL_PROCESS_ATTACH是加载原因时,我们会创建一个新线程并将其指向我们的“主”函数。这是我们初始化minhook和设置一些钩子的地方:

DWORD WINAPI SetupHooks(LPVOID param)
{
    MH_STATUS status;

    if (MH_Initialize() != MH_OK) {
        return -1;
    }

    status = MH_CreateHookApi(
        L"ntdll",
        "NtAllocateVirtualMemory",
        NtAllocateVirtualMemory_Hook,
        reinterpret_cast<LPVOID*>(&pNtAllocateVirtualMemory_Original)
    );

    status = MH_CreateHookApi(
        L"ntdll",
        "NtProtectVirtualMemory",
        NtProtectVirtualMemory_Hook,
        reinterpret_cast<LPVOID*>(&pNtProtectVirtualMemory_Original)
    );

    status = MH_CreateHookApi(
        L"ntdll",
        "NtWriteVirtualMemory",
        NtWriteVirtualMemory_Hook,
        reinterpret_cast<LPVOID*>(&pNtWriteVirtualMemory_Original)
    );

    status = MH_EnableHook(MH_ALL_HOOKS);

    return status;
}

MH_Initialize()是必须调用的,因此我们从这里开始。接下来,我们创建3个钩子:

  • NtAllocateVirtualMemory
  • NtProtectVirtualMemory
  • NtWriteVirtualMemory

钩子是使用MH_CreateHookApi()调用创建的:

MH_STATUS WINAPI MH_CreateHookApi(LPCWSTR pszModule, LPCSTR pszProcName, LPVOID pDetour, LPVOID *ppOriginal);

要创建一个钩子,需要4个东西:

  • 模块名称
  • 函数名称
  • 要“替换”所需函数的函数
  • 某处存储原始函数地址

以下是示例:

MH_STATUS status = MH_CreateHookApi(
    L"ntdll",
    "NtAllocateVirtualMemory",
    NtAllocateVirtualMemory_Hook,
    reinterpret_cast<LPVOID*>(&pNtAllocateVirtualMemory_Original)
);

NtAllocateVirtualMemory_Hook() 是用于替换原始函数的函数:

NTSTATUS NTAPI NtAllocateVirtualMemory_Hook(IN HANDLE ProcessHandle, IN OUT PVOID* BaseAddress, IN ULONG_PTR ZeroBits, IN OUT PSIZE_T RegionSize, IN ULONG AllocationType, IN ULONG Protect)
{
    if (Protect == PAGE_EXECUTE_READWRITE)
    {
        printf("[INTERCEPTOR]: RWX Allocation Detected in %ld (0x%p)\n", GetProcessId(ProcessHandle), ProcessHandle);
        if (BLOCKING)
        {
            return 5;
        }
        else
        {
            return pNtAllocateVirtualMemory_Original(ProcessHandle, BaseAddress, ZeroBits, RegionSize, AllocationType, Protect);
        }
    }
    else
    {
        return pNtAllocateVirtualMemory_Original(ProcessHandle, BaseAddress, ZeroBits, RegionSize, AllocationType, Protect);
    }
}

该函数的声明与typedef完全相同:

typedef NTSTATUS(NTAPI* _NtAllocateVirtualMemory)(IN HANDLE ProcessHandle, IN OUT PVOID* BaseAddress, IN ULONG_PTR ZeroBits, IN OUT PSIZE_T RegionSize, IN ULONG AllocationType, IN ULONG Protect);

这样做是为了避免钩子之间的输入问题。

NtAllocateVirtualMemory_Hook函数中,我们唯一检查的是保护类型是否为PAGE_EXECUTE_READWRITERWX,因为这通常是恶意活动的迹象(通常)。如果匹配,则只需打印我们发现了什么。

然后,我们有一个阻止概念。这意味着如果“BLOCKING”为真,则返回。如果它为假,则返回指向原始函数的指针,允许函数按用户期望执行。

NtProtectVirtualMemory中,我们只检查对于更改到 PAGE_EXECUTE_READ, 因为这是常见的保护类型以避免 RWX 分配:

NTSTATUS NTAPI NtProtectVirtualMemory_Hook(IN HANDLE ProcessHandle, IN OUT PVOID* BaseAddress, IN OUT PULONG NumberOfBytesToProtect, IN ULONG NewAccessProtection, OUT PULONG OldAccessProtection) {

    if (NewAccessProtection == PAGE_EXECUTE_READ) {
        printf("[INTERCEPTOR]: Detected move to RX in %ld (0x%p)\n", GetProcessId(ProcessHandle), ProcessHandle);
        if (BLOCKING)
        {
            return 5;
        }
        else
        {
            return pNtProtectVirtualMemory_Original(ProcessHandle, BaseAddress, NumberOfBytesToProtect, NewAccessProtection, OldAccessProtection);
        }
    }
    else
    {
        return pNtProtectVirtualMemory_Original(ProcessHandle, BaseAddress, NumberOfBytesToProtect, NewAccessProtection, OldAccessProtection);
    }

In NtWriteVirtualMemory, no additional checks are made:

NTSTATUS NTAPI NtWriteVirtualMemory_Hook(IN HANDLE ProcessHandle, IN PVOID BaseAddress, IN PVOID Buffer, IN SIZE_T NumberOfBytesToWrite, OUT PSIZE_T NumberOfBytesWritten OPTIONAL)
{
    printf("[INTERCEPTOR]: Detected write of %I64u in %ld (0x%p)\n", NumberOfBytesToWrite, GetProcessId(ProcessHandle), ProcessHandle);
    if (BLOCKING)
    {
        return 5;
    }
    else
    {
        return pNtWriteVirtualMemory_Original(ProcessHandle, BaseAddress, Buffer, NumberOfBytesToWrite, NumberOfBytesWritten);
    }
}

加载器

在这个例子中,我们有一个 PE 文件,它只是在 DLL 上调用 LoadLibraryA,然后运行一个伪装的注入:

#include <Windows.h>
#include <stdio.h>

int main()
{
    HMODULE hModule = LoadLibraryA("Interceptor.dll");

    if (hModule == nullptr)
    {
        printf("[LOADER] [LOADER] Failed to load: %ld\n", GetLastError());
        return -1;
    }
    printf("[LOADER] Interceptor.dll: 0x%p\n", hModule);

    Sleep(3000);

    CHAR buf[8] = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };

    LPVOID pAddress = VirtualAlloc(nullptr, 8, MEM_COMMIT | MEM_RESERVE, PAGE_EXECUTE_READWRITE);
    if (pAddress == nullptr)
    {
        printf("[LOADER] VirtualAlloc: %ld\n", GetLastError());
        return -1;
    }
    printf("[LOADER] Base: 0x%p\n", pAddress);

    if (WriteProcessMemory((HANDLE)-1, pAddress, buf, sizeof buf, nullptr) == FALSE)
    {
        printf("[LOADER] WriteProcessMemory: %ld\n", GetLastError());
        return -1;
    }
    printf("[LOADER] Wrote!\n");

    if (VirtualProtect(pAddress, sizeof buf, PAGE_EXECUTE_READ, nullptr) == FALSE)
    {
        printf("[LOADER] VirtualProtect: %ld\n", GetLastError());
        return -1;
    }
    printf("[LOADER] Protected!\n");

    return 0;
}

检测功能

运行此命令可以显示被检测到的调用(以非阻塞模式):

在截图中,我们可以看到:

  • 移动到 RX
  • RWX 分配
  • 写入 8 字节

这就是我们计划检测的所有内容。那么如何绕过呢?由于社区开发了很多工具,实际上相当容易。但在此之前,我们需要讨论用户空间和内核空间。

绕过用户空间钩子

对于本示例,我们将使用Tartarus Gate。我们只需在wmain中进行一次调用即可。:

LoadLibraryA("Interceptor.dll");

然后在 Payload 中更改有效载荷:

unsigned char payload[] = { 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00 };

检查已加载的模块:

DLL 已被加载。

运行它,并在线程创建时设置断点,因为有效载荷是垃圾:

上面显示了minhook的初始化,然后启用钩子。在此之间,有一个移动到RX。但是,在设置钩子之前发生了这种情况。因此,这很可能是minhook或CRT做某些事情。我们没有花时间去检查这个问题。

钩子和进程仪器结论

正如我们所述,这不是每种潜在技术的全面审查。另一种特别值得探索的技术是向量异常处理(VEH) 。其中两个例子分别为ethicalchaos关于In-Process Patchless AMSI Bypass 的文章和CounterceptCallStackSpoofer

与内核回调一样,这是一个活跃的研究领域,在本文中要探索的内容远远超出了我们拥有时间或空间的范围。

线程调用堆栈

进程的另一个组件可以通过线程调用堆栈进行查询。如在WinDbg中查看调用堆栈所述,调用堆栈定义为:

调用堆栈是导致程序计数器当前位置的函数调用链。在调用堆栈上方的函数是当前函数,下一个函数是调用当前函数的函数,以此类推。显示的呼叫堆栈基于当前程序计数器,除非更改寄存器上下文。有关如何更改寄存器上下文的详细信息,请参阅更改上下文

由于呼叫堆栈可以帮助确定线程意图,因此经常接受审查以确定其有效性。在本节中,我们想演示如何使用呼叫堆栈来确定恶意行为(在一个简单例子中),然后讨论处理这种情况时采取攻击策略。

这里有一个Vulpes植入物:

如果我们查看进程(10792) 的线程,则可以看到一些从难以捉摸的TpReleaseCleanupGroupMembers开始的线程:

这在进程中很常见,以下是chrome.exe的示例:

然后是RuntimeBroker.exe

这对攻击者来说是一个好的侧面说明。如果所讨论的植入物依赖于伪装成其他东西,则需要考虑这一点。例如,如果植入物正在使用像Chrome之类的浏览器,则HTTP应该以相同方式处理,并且应模仿线程的入口点和呼叫堆栈。

回到Vulpes植入物,调用堆栈主要为“TpReleaseCleanupGroupMembers”,这很好。但是,如果我们查看一些线程,则会发现负责WinHTTP 的线程如下图所示:

而以下则是由进程启动的通用线程:

还有几个例子,但让我们专注于第二个例子,因为它是可以在许多进程中找到的一个线程的调用堆栈。让我们看一下如何以编程方式读取线程堆栈,以及伪造的线程基地址可能会引起怀疑。

以下是入口点:

int main()
{
    DWORD dwProcessId = 10792;
    DWORD dwSussThread = 1996;
    DWORD dwNormalThread = 26084;

    HANDLE hProcess = OpenProcess(PROCESS_ALL_ACCESS, FALSE, dwProcessId);

    SymInitialize(hProcess, NULL, TRUE);

    StackWalkThread(hProcess, dwSussThread);

    SymCleanup(hProcess);
}

在上面的代码中,我们有两个线程ID。

  • 1996:欺骗线程
  • 26084:一个相对正常的堆栈。

因此,我们需要编写一个函数来枚举该线程的调用堆栈。我们可以使用以下代码实现:

void StackWalkThread(HANDLE hProcess, DWORD dwThreadId)
{
    STACKFRAME64 frame  = { 0 };
    CONTEXT context     = { 0 };
    int idx             = 0;

    HANDLE hThread = OpenThread(MAXIMUM_ALLOWED, FALSE, dwThreadId);

    if (!hThread) return;

    context.ContextFlags = CONTEXT_FULL;

    if (GetThreadContext(hThread, &context) == FALSE) return;

    frame.AddrPC.Offset = context.Rip;
    frame.AddrPC.Mode = AddrModeFlat;
    frame.AddrStack.Offset = context.Rsp;
    frame.AddrStack.Mode = AddrModeFlat;
    frame.AddrFrame.Offset = context.Rbp;
    frame.AddrFrame.Mode = AddrModeFlat;

    printf("# Thread: %ld\n\n", dwThreadId);

    while (StackWalk64(IMAGE_FILE_MACHINE_AMD64, hProcess, hThread, &frame, &context, NULL, SymFunctionTableAccess64, SymGetModuleBase64, NULL))
    {
        DWORD64 moduleBase = SymGetModuleBase64(hProcess, frame.AddrPC.Offset);
        DWORD64 offset = 0;
        char symbolBuff[sizeof(SYMBOL_INFO) + MAX_SYM_NAME * sizeof(TCHAR)] = { 0 };
        PSYMBOL_INFO symbol = (PSYMBOL_INFO)symbolBuff;
        symbol->SizeOfStruct = sizeof(SYMBOL_INFO);
        symbol->MaxNameLen = MAX_SYM_NAME;

        if (SymFromAddr(hProcess, frame.AddrPC.Offset, &offset, symbol))
        {

            printf(
                "\\_ Frame %d\n"
                "  |_ Name: %s\n"
                "  |_ Address: 0x%p\n\n",
                idx,
                symbol->Name,
                symbol->Address
            );

            idx++;
        }
    }
}

DbgHelp 库中,我们使用以下函数:

将代码指向正常线程堆栈:

这与之前看到的相匹配。现在将其更改为指向“坏”线程:

现在显示了一个迄今未见过的不同线程堆栈。查看第3个帧,它是CreateTimeQueueTimer,该技术被描述为睡眠混淆技术:

作为免责声明,此技术完全归功于Peter Winter-Smith

因此,在程序上,很容易找到线程的调用堆栈。让我们将其扩展成一些非常基本的东西,以便开始使用。

首先,我们将定义一个硬编码的预期函数列表,这些函数早些时候已经看到了,我们可以将其用作完整性检查:

std::vector < std::string > expected = {
  "ZwWaitForWorkViaWorkerFactory",
  "TpReleaseCleanupGroupMembers",
  "BaseThreadInitThunk",
  "RtlUserThreadStart"
};

然后是一个空的列表,用于跟踪我们找到的所有内容:

std::vector<std::string> found;

现在,不仅要打印输出结果,还要将所有符号名称添加到向量中:

while (StackWalk64(IMAGE_FILE_MACHINE_AMD64, hProcess, hThread, &frame, &context, NULL, SymFunctionTableAccess64, SymGetModuleBase64, NULL))
{
    DWORD64 moduleBase = SymGetModuleBase64(hProcess, frame.AddrPC.Offset);
    DWORD64 offset = 0;
    char symbolBuff[sizeof(SYMBOL_INFO) + MAX_SYM_NAME * sizeof(TCHAR)] = { 0 };
    PSYMBOL_INFO symbol = (PSYMBOL_INFO)symbolBuff;
    symbol->SizeOfStruct = sizeof(SYMBOL_INFO);
    symbol->MaxNameLen = MAX_SYM_NAME;

    if (SymFromAddr(hProcess, frame.AddrPC.Offset, &offset, symbol))
    {
        found.push_back(symbol->Name);
        printf(
            "\\_ Frame %d\n"
            "  |_ Name: %s\n"
            "  |_ Address: 0x%p\n\n",
            idx,
            symbol->Name,
            symbol->Address
        );

        idx++;
    }
}

一旦代码运行并找到所有符号,让我们看看向量是否匹配:

if (std::equal(expected.begin(), expected.end(), found.begin()))
{
    printf("[ CLEAN ]\n");
}
else
{
    printf("[ DIRTY ]\n");
}

将其指向_good_线程

我们得到了CLEAN消息。然后是“脏”线程:

显然,这段代码还没有准备好投入生产,编写此类逻辑的细微差别极具挑战性。但是,一些EDR供应商已经开始采用这种技术。鉴于研究如何混淆和使端点保护失效的增加,这是一个对于蓝队和红队都有用的技术。

谈到红队,关于修正此线程错误的研究已经在进行中了。

这项技术最初由Peter Winter-Smith推广,并由mgeekyThreadStackSpoofer中重新解释。但是,该概念验证将返回地址设置为0,从而删除了与shellcode注入内存地址相关联的引用。

这是Thread Stack Spoofing 技术的示例实现, 旨在规避检查线程调用堆栈中是否存在shellcode帧引用 的恶意软件分析员、AVs 和 EDRs 。其思想就是隐藏在线程的调用堆栈中对shellcode的引用,从而伪装包含恶意代码的分配。

如果我们从Vulpes中删除睡眠掩码,这是调用堆栈的样子:

该技术旨在通过将返回地址存储到变量中、将返回地址设置为0,然后恢复返回地址来隐藏这些地址。

从上述存储库中获取一个快速的代码示例:

void WINAPI MySleep(DWORD _dwMilliseconds)
{
    [...]
    auto overwrite = (PULONG_PTR)_AddressOfReturnAddress();
    const auto origReturnAddress = *overwrite;
    *overwrite = 0;

    [...]
    *overwrite = origReturnAddress;
}

在一个更近期的项目中,CallStackSpooferWilliam Burgess 开发,以进一步掩盖堆栈。

请参见:Spoofing Call Stacks to confused EDRs

通过使用预定义的堆栈向量,该项目能够模拟以下内容:

  • WMI
  • RPC
  • SVCHost

这是一个WMI 的预定义向量示例:

std::vector<StackFrame> wmiCallStack =
{
    StackFrame(L"C:\\Windows\\SYSTEM32\\kernelbase.dll", 0x2c13e, 0, FALSE),
    StackFrame(L"C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\CorperfmonExt.dll", 0xc669, 0, TRUE),
    StackFrame(L"C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\CorperfmonExt.dll", 0xc71b, 0, FALSE),
    StackFrame(L"C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\CorperfmonExt.dll", 0x2fde, 0, FALSE),
    StackFrame(L"C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\CorperfmonExt.dll", 0x2b9e, 0, FALSE),
    StackFrame(L"C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\CorperfmonExt.dll", 0x2659, 0, FALSE),
    StackFrame(L"C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\CorperfmonExt.dll", 0x11b6, 0, FALSE),
    StackFrame(L"C:\\Windows\\Microsoft.NET\\Framework64\\v4.0.30319\\CorperfmonExt.dll", 0xc144, 0, FALSE),
    StackFrame(L"C:\\Windows\\SYSTEM32\\kernel32.dll", 0x17034, 0, FALSE),
    StackFrame(L"C:\\Windows\\SYSTEM32\\ntdll.dll", 0x52651, 0, FALSE),
};

通过实施这种技术,将极大地增加我们之前展示的调用栈完整性检查的实现难度(尽管我们的演示是硬编码值,但观点仍然成立)。

结论

这是一篇相当长的文章,在其中我们试图提供一些清晰度,以了解EDR可以使用哪些机制来不仅识别恶意活动,而且还可以防止它。

在此过程中,我们讨论了常见陷阱和一些可进行改进以保护免受绕过攻击的方法。同时,在做这件事情时,我们试图更多地揭示“X bypasses EDR”的故事情节,在这个故事中,“信标”可能已经回来了,但很可能有活动日志记录下来。下一集将介绍ETW和AMSI!

知识共享许可协议CFC4N的博客CFC4N 创作,采用 署名—非商业性使用—相同方式共享 4.0 进行许可。基于https://www.cnxct.com上的作品创作。转载请注明转自:漩涡:EDR内核回调、钩子和调用堆栈