heavener:当你买不起EDR授权时会发生什么

admin 2026-06-30 08:36:03 网络安全文章 来源:ZONE.CI 全球网 0 阅读模式

文章总结: heavener是一款面向Windows的模块化EDR仿真引擎,能够加载从商业安全产品逆向提取的真实检测逻辑(包括ML模型、YARA规则等)对实时遥测数据进行评估。项目采用六层架构,通过内核驱动和ETW采集数据,支持热切换SentinelOne、CortexXDR等厂商模块,提供与真实EDR相同的检测能力。系统包含进程模型、行为修饰符等组件,可用于恶意软件测试而无需暴露真实环境。 综合评分: 87 文章分类: 安全工具,恶意软件,红队,渗透测试,安全开发


cover_image

heavener:当你买不起 EDR 授权时会发生什么

otterpwn otterpwn

securitainment

2026年6月29日 11:50 中国香港

在小说阅读器读本章

去阅读

| 原文链接 | 作者 | | — | — | | https://blog.otterpwn.com/projects/heavener | otterpwn |

heavener 是我过去 6 个月一直在开发的项目,大概也是我做过的最具雄心的事情。它是一款面向 Windows 的模块化 EDR 仿真引擎,能够加载从商业端点安全产品中逆向提取的 真实检测逻辑,并使用厂商的实际工件——用于文件分类的 ML 模型、编译后的 YARA 规则集以及行为脚本——对实时遥测数据进行评估。用户选择一个厂商模块,即可获得生产环境 EDR 将会给出的确切判定结果。

在针对 EDR 产品测试载荷时,你有两种选择:运行真实的 agent ( 并冒着在真正使用之前就暴露载荷的风险 ),或者充分理解其检测逻辑以预测其行为。heavener 就是将第二种选择推向了逻辑极致。

截至撰写本文时,已实现四个厂商模块:SentinelOneCortex XDRCrowdStrike和 Sophos。每个模块都加载真实的厂商工件并通过同一接口运行。引擎负责采集遥测数据、对其进行富化,然后交给当前加载的模块,用户可通过 IPC 客户端在运行时热切换模块而无需重启引擎。

架构

系统由六个主要层级组成,通过清晰的数据流连接。遥测数据从内核驱动和 ETW provider 流入,被归一化为统一的事件模式,经过富化和关联后送达当前激活的厂商模块,告警则流向 SOC 风格的 Web 控制台。

计划是持续添加更多遥测源,扩展当前架构以实现对已提取规则集的 100% 覆盖。

引擎中的每个事件都是一个 BehavioralEvent,携带一个类型化头部和一个基于 19 个具体数据结构的 std::variant载荷。该分类体系覆盖 25 种事件类型:

enum class EventType : std::uint16_t
{
    ProcessCreate = 1, ProcessExit, ThreadCreate, ImageLoad,
    FileCreate, FileWrite, FileDelete, FileRename,
    RegistryCreateKey, RegistrySetValue, RegistryDeleteKey, RegistryDeleteValue,
    NetworkConnect, DnsQuery, DriverLoad, LdapQuery, WmiOperation,
    UserAccountCreated, ScheduledTaskCreated, ScriptExecution,
    BehavioralIndicator, ProcessHandleAccess, EtwTiEvent,
    NamedPipeCreate, FileSetBasicInfo,
};

EventPipeline在单个工作线程上运行,这是一个刻意的设计选择,因为检测规则关心事件顺序:一个进程创建后跟一个文件写入再跟一个镜像加载,与这些事件被打乱后到达是完全不同的故事。单线程保证了厂商模块看到的事件顺序与在真实 EDR agent 中一致。事件通过无锁的 submit()到达,并按批次排空以避免逐事件加锁竞争:

void worker_loop(std::stop_token token) noexcept
{
    while (m_running.load(std::memory_order_acquire))
    {
&nbsp; &nbsp; &nbsp; &nbsp; std::deque<BehavioralEvent> batch;
&nbsp; &nbsp; &nbsp; &nbsp; {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; std::unique_lock lock(m_queue_mutex);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; m_queue_cv.wait(lock, [&] {
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return !m_queue.empty()
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; || !m_running.load(std::memory_order_acquire);
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; });
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; batch.swap(m_queue);
&nbsp; &nbsp; &nbsp; &nbsp; }
&nbsp; &nbsp; &nbsp; &nbsp; for (auto& evt : batch)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; process_event(evt);
&nbsp; &nbsp; }
}

每次 process_event调用遵循严格的三阶段流水线:首先更新 ProcessModel使所有下游消费者看到当前进程状态,然后运行 ModifierEngine附加聚合信号,最后将事件送入当前激活的厂商模块。TelemetryCorrelator还可能从原始遥测中合成额外事件 ( 计划任务创建、用户账户创建 ),这些合成事件会回送至同一流水线再次处理。

内核驱动

我构建的第一样东西是 heavendrv——一个 Windows 内核微过滤驱动 ( minifilter driver ),用于捕获多种不同的事件类型。

该驱动通过四种内核机制注册回调:

  • PsSetCreateProcessNotifyRoutineEx

    和 PsSetCreateThreadNotifyRoutineEx用于进程和线程生命周期事件,两者均在内核态捕获调用栈

  • 一个 minifilter( altitude 370000 ) 用于文件操作:创建、写入、删除、重命名、命名管道创建、用于时间戳篡改检测的 SetBasicInfo,以及用于符号链接滥用的 FSCTL_SET_REPARSE_POINT

  • CmRegisterCallbackEx

    用于注册表操作:键创建、值设置 ( 原始值数据最大 4KB )、键和值删除

  • ObRegisterCallbacks

    用于跨进程句柄访问,捕获访问掩码和调用栈帧,用于诸如以 PROCESS_VM_READ打开 lsass.exe的句柄操作

每个事件进入一个无锁队列,由用户态的 DriverConsumer通过 FilterSendMessage拉取。通信协议经过版本握手,使用打包的 C 结构体,字段含义按事件类型区分复用:

typedef struct _HV_EVENT_HEADER {
&nbsp; &nbsp; USHORT &nbsp; &nbsp; Version;
&nbsp; &nbsp; USHORT &nbsp; &nbsp; Type;
&nbsp; &nbsp; ULONG &nbsp; &nbsp; &nbsp;Size;
&nbsp; &nbsp; ULONGLONG &nbsp;Timestamp;
&nbsp; &nbsp; ULONG &nbsp; &nbsp; &nbsp;Pid;
&nbsp; &nbsp; ULONG &nbsp; &nbsp; &nbsp;Tid;
&nbsp; &nbsp; ULONG &nbsp; &nbsp; &nbsp;ExtraPid;
&nbsp; &nbsp; ULONG &nbsp; &nbsp; &nbsp;U32;
&nbsp; &nbsp; ULONGLONG &nbsp;U64a;
&nbsp; &nbsp; ULONGLONG &nbsp;U64b;
&nbsp; &nbsp; USHORT &nbsp; &nbsp; VarCount;
} HV_EVENT_HEADER;

U32U64a和 U64b根据 Type携带不同数据:进程退出的退出码、进程创建的登录 LUID、线程创建的起始地址、镜像加载的基址/大小、句柄访问的期望访问掩码,等等。变长字段 ( UTF-16LE 字符串、调用栈帧的原始字节数组 ) 以长度前缀的 blob 形式跟随其后。记录上限为 64KB。当消费者跟不上时,驱动会统计丢弃数量而非阻塞内核线程。

为了「解锁」大量行为规则,驱动会为每个新进程捕获其 创建进程,而不仅仅是父 PID。这一点很重要,因为父 PID 欺骗 ( 通过 PROC_THREAD_ATTRIBUTE_PARENT_PROCESS) 会改变报告的 PPID 但不会改变实际创建者。heavendrv 同时报告两者,使引擎能够标记这一差异。

ETW

内核驱动承担了主要工作,但有些遥测只能通过 ETW 获取。引擎订阅了七个 provider:

  • AMSI

    用于脚本内容扫描。每个 PowerShell 脚本、VBScript、JScript 调用都会经过 AMSI,我们能获取完整的脚本缓冲区。这正是 Cortex CLIPS 规则 ( 对 PowerShell 命令字符串做模式匹配 ) 和 S1 Lua 规则 ( 做同样事情 ) 的数据来源

  • WMI-Activity

    用于 WMI 方法调用和事件订阅,驱动诸如通过 WMI 提取 BitLocker 密钥以及基于 WMI 的横向移动等检测

  • LDAP

    用于搜索过滤器字符串,捕获诸如针对敏感 AD 属性的可疑 LDAP 查询之类的情况

  • DNS

    用于解析事件

  • 内核 provider

    ( 进程、文件、注册表、网络 ) 在驱动未加载时作为后备

当驱动处于活动状态时,ETW 以 dns_only模式运行,仅启用补充性 provider。没有必要以更低的保真度消费重复的进程 / 文件事件。

引擎还订阅了 ETW Threat Intelligenceprovider;Microsoft 将其限制为仅 Protected Process Light ( PPL ) 消费者可用,因此普通进程无法消费。heavener 有一个独立的 PPL-AM 采集服务 ( heaventi-svc),它订阅 TI provider 并通过命名管道转发原始事件。这可以捕获跨进程内存操作,如 ALLOCVM_REMOTE和 PROTECTVM_REMOTE,用于发现远程进程交互。heaventi-svc服务当然由一个 ELAM ( Early Launch Anti-Malware ) 驱动支撑,以为 PPL-AM 建立信任锚。该 ELAM 将所有启动驱动归类为「Unknown」( 全部放行 ) 然后卸载;其唯一目的是让引擎的进程以受保护方式运行;在我的逆向过程中,我成功提取了启动驱动的白名单,但觉得这有点超出项目范围。

进程模型

由于脱离上下文的原始遥测对检测逻辑而言用处不大,ProcessModel维护着一棵实时内存中的进程树。每个节点是一个 ProcessInfo,携带了检测规则可能需要查询的一切信息:

struct ProcessInfo
{
&nbsp; &nbsp; std::uint64_t &nbsp;uid{};
&nbsp; &nbsp; std::uint32_t &nbsp;pid{};
&nbsp; &nbsp; std::uint32_t &nbsp;ppid{};
&nbsp; &nbsp; std::wstring &nbsp; name;
&nbsp; &nbsp; std::wstring &nbsp; command_line;
&nbsp; &nbsp; std::wstring &nbsp; image_path;
&nbsp; &nbsp; std::uint64_t &nbsp;create_time{};
&nbsp; &nbsp; std::string &nbsp; &nbsp;integrity_level;
&nbsp; &nbsp; std::uint32_t &nbsp;session_id{};
&nbsp; &nbsp; std::wstring &nbsp; user_sid;
&nbsp; &nbsp; std::wstring &nbsp; file_description;
&nbsp; &nbsp; std::wstring &nbsp; original_filename;
&nbsp; &nbsp; std::wstring &nbsp; company_name;
&nbsp; &nbsp; std::string &nbsp; &nbsp;cert_publisher;
&nbsp; &nbsp; std::wstring &nbsp; active_content_file;
&nbsp; &nbsp; std::uint64_t &nbsp;logon_session_id{};
&nbsp; &nbsp; bool &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; is_remote_group{false};

&nbsp; &nbsp; bool &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; has_injected_thread{false};
&nbsp; &nbsp; std::uint32_t &nbsp;injected_by_pid{};
&nbsp; &nbsp; bool &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; was_opened_cross_process{false};
&nbsp; &nbsp; std::uint32_t &nbsp;opened_by_pid{};
&nbsp; &nbsp; bool &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; opened_for_vm_read{false};
&nbsp; &nbsp; bool &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; opened_for_vm_write{false};

&nbsp; &nbsp; ProcessInfo* &nbsp; model_parent{};
&nbsp; &nbsp; std::unordered_map<std::uint32_t, std::string> extension_values;
};

该树采用双重索引 ( 按 PID 用于实时查找,按 UID 用于历史查找 ),并由 std::shared_mutex保护以支持读写并发。model_parent指针允许你在遍历祖先链时无需在每一跳进行 map 查找。每次跨进程句柄访问会将访问掩码进行 OR 折叠并标记 PROCESS_VM_READPROCESS_VM_WRITE,因此凭据转储检测族可以直接查询 lsass 的 opened_for_vm_read而无需重新扫描句柄事件。extension_valuesmap 是一个逐进程字典,厂商规则可以跨事件读写它来积累行为状态。

在此之上,ModifierEngine在事件流上计算聚合行为修饰符。它监视每个 FileCreate事件,并为每个进程维护最近 32 个文件的滑动窗口,以解锁与文件操作相关的检测逻辑,例如勒索软件勒索信信号——即大量同名文件在不同目录中被快速连续投放。

TelemetryCorrelator从原始遥测中合成更高层级的事件。在 \Windows\System32\Tasks\下的文件创建变为 ScheduledTaskCreated事件。在 \SAM\SAM\Domains\Account\Users\下匹配 per-RID 模式的注册表键创建变为 UserAccountCreated。这些合成事件让厂商规则能够在与真实 agent 相同的抽象层级上运作。

LogonSessionMap通过 LsaGetLogonSessionData将登录会话 LUID 解析为登录类型,将一个会话标识符转化为可操作的上下文信息,如「此进程正在 RDP 会话中运行」,从而驱动横向移动检测。

模块系统

每个厂商模块都实现同一个 C++ 接口:

class IEdrModule
{
public:
&nbsp; &nbsp; virtual std::string_view name() const noexcept = 0;
&nbsp; &nbsp; virtual std::string_view version() const noexcept = 0;
&nbsp; &nbsp; virtual ModuleCaps capabilities() const noexcept = 0;

&nbsp; &nbsp; virtual std::expected<void, ModuleError>
&nbsp; &nbsp; &nbsp; &nbsp; initialize(const ModuleConfig& cfg) noexcept = 0;
&nbsp; &nbsp; virtual void shutdown() noexcept = 0;

&nbsp; &nbsp; virtual std::expected<ScanResult, ModuleError>
&nbsp; &nbsp; &nbsp; &nbsp; scan_file(std::span<const std::byte> data,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; std::string_view file_path) noexcept = 0;

&nbsp; &nbsp; virtual void on_event(const BehavioralEvent& evt,
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; const ProcessModel& model) noexcept;
&nbsp; &nbsp; virtual std::vector<Alert> drain_alerts() noexcept;
};

scan_file处理静态分析 ( YARA、ML 模型、签名引擎 )。on_event实时接收行为遥测。drain_alerts拉取模块行为引擎已积累的任何检测结果。引擎不关心内部发生了什么;它只是调用接口。

一个 ModuleConfig结构体携带模块所需的一切:厂商数据目录路径、规则文件、模型文件,以及模块特定的旋钮,如 Cortex YARA+ML 融合策略或要模拟的 SentinelOne agent 版本。

模块可以在运行时通过 IPC 客户端 ( client.exe switch cortex) 进行切换,该操作会卸载当前激活的模块并初始化新模块,同时不中断遥测采集。流水线在切换期间缓存事件,在新模块就绪后排空到新模块中。

厂商覆盖

每个模块根据真实 agent 所暴露的内容带来不同的检测能力组合:

  • SentinelOne

    :静态 ML、YARA,以及一个完整的行为引擎 ( 203 个 Lua 脚本,通过两种分发机制消费 19 种事件类型 )

  • Cortex XDR

    :静态 ML、YARA ( 36 个规则集,覆盖 PE、脚本、宏、文档等 ),以及一个完整的行为引擎 ( 7967 条 CLIPS 规则,消费 18 种事实类型 )

  • CrowdStrike

    :仅静态 ML。我正在积极研究 CS 的 channel 文件如何被解析和导入,以扩展项目覆盖范围

  • Sophos

    :静态 ML 和传统签名扫描

我正在积极扩展以上每一个:研究 CrowdStrike channel 文件的解析和导入逻辑,从其他产品中提取配置,并编写灵活的「cradle」以支持各项功能。

SentinelOne

S1 模块分为两层。静态扫描器对提交的文件运行 S1 的 ML 分类流水线 ( 八个 XGBoost 模型 ) 和 YARA 规则,返回从 benign到 suspicious再到 malware的判定结果。

行为引擎嵌入了一个 LuaJIT 运行时,加载来自真实 S1 agent 的 203 个 Lua 行为脚本。这些脚本通过两种并行的分发机制工作。

第一种是 event-class 系统:每个脚本为 S1 的内部事件类层次结构注册处理器。当一个事件到达时,引擎将我们的 BehavioralEvent转换为 S1 的 Lua 事件格式并分发给所有已注册的处理器。例如,单个 ImageLoad事件会分发给三个独立的类处理器。

第二种是 registerOSEventFilter管道。厂商 Lua 语料库定义了 137 个 OS-filter 回调,每个都是一个自包含的检测例程,注册对特定事件类型的兴趣。引擎有一个静态路由表,将每个检测逻辑名称映射到其事件类型和一个可选的 guard 函数:

static constexpr OsFilterRoute kOsFilterRoutes[] = {
&nbsp; &nbsp; { "logic_scheduledTaskWithoutInteraction",
&nbsp; &nbsp; &nbsp; EventType::ScheduledTaskCreated, nullptr },
&nbsp; &nbsp; { "logic_samQuery",
&nbsp; &nbsp; &nbsp; EventType::ProcessCreate, nullptr },
&nbsp; &nbsp; { "research_dropAndExecute",
&nbsp; &nbsp; &nbsp; EventType::ProcessCreate, guard_drop_and_execute },
&nbsp; &nbsp; { "logic_forbiddenSpawnActiveContentWithZoneIdentifier",
&nbsp; &nbsp; &nbsp; EventType::ProcessCreate, guard_active_content_has_zone },
&nbsp; &nbsp; { "logic_multipleRansomMiscFilesCreated",
&nbsp; &nbsp; &nbsp; EventType::FileCreate, guard_ransom_misc_burst },
&nbsp; &nbsp; // ...
};

Guard 函数是引擎侧的前置条件,用于避免对规则不关心的事件调用 Lua VM。它们复现了真实 S1 agent 在进入 Lua VM 之前用 C++ 检查的原生前置条件:检查特定的修饰符标志、匹配注册表路径、验证文件名模式等。

Lua 脚本通过在 Lua state 中注册的原生函数回调 C++,用于进程模型查询、文件富化、agent 配置数据,以及跨事件积累逐进程状态的模型扩展字典。每个事件在分发前被转换为匹配 S1 内部事件模式的 Lua table,因此脚本看到的数据布局与在真实 agent 内部一致。

PS heavener> .\engine.exe --replay replay_s1_motw_socgholish.json
[~] Module loaded: SentinelOne vX.Y.Z.
[~] Replayed 3 events.
[~] 1 alert(s) generated.
&nbsp; [MALICIOUS] SuspiciousSocGholish - Filename: Update.js (pid=3000, module=SentinelOne)

PS heavener> .\engine.exe --replay replay_s1_lt_wmiexec.json
[~] Module loaded: SentinelOne vX.Y.Z.
[~] Replayed 1 events.
[~] 1 alert(s) generated.
&nbsp; [SUSPICIOUS] logic_cmdFromWmiRemotely (pid=4400, module=SentinelOne)

PS heavener> .\engine.exe --replay replay_s1_netsupport.json
[~] Module loaded: SentinelOne vX.Y.Z.
[~] Replayed 2 events.
[~] 1 alert(s) generated.
&nbsp; [MALICIOUS] NetSupportRatUnusualPath (pid=1000, module=SentinelOne)

PS heavener> .\engine.exe --replay replay_s1_ransom_samenames.json
[~] Module loaded: SentinelOne vX.Y.Z.
[~] Replayed 4 events.
[~] 1 alert(s) generated.
&nbsp; [MALICIOUS] logic_multipleRansomMiscFilesCreated (pid=4000, module=SentinelOne)

PS heavener> .\engine.exe --replay replay_s1_lt_bitlocker.json
[~] Module loaded: SentinelOne vX.Y.Z.
[~] Replayed 2 events.
[~] 1 alert(s) generated.
&nbsp; [MALICIOUS] ModifyEnableBDEWithNoTPMFromReg (pid=5000, module=SentinelOne)

PS heavener> .\engine.exe --replay replay_s1_lt_wdigest.json
[~] Module loaded: SentinelOne vX.Y.Z.
[~] Replayed 2 events.
[~] 1 alert(s) generated.
&nbsp; [MALICIOUS] logic_registryWDigestLogonCredential (pid=1000, module=SentinelOne)

Cortex XDR

Cortex 是架构上最复杂的模块,拥有三个独立的检测层。

YARA覆盖静态扫描面,包含 36 个按文件类型组织的规则集 ( PE、脚本、宏、文档等 )。在静态扫描时,引擎识别文件类型并对其运行匹配的规则集。

ML运行 Cortex 的本地分析 XGBoost 模型。结果通过可配置的融合策略与 YARA 判定结果组合:max-severity( 取最严重的判定 )、yara-primary( 冲突时 YARA 优先 )、ml-primary( ML 优先 ),或 weighted( 概率加权组合 )。

CLIPS 行为引擎是 Cortex 行为检测的核心。引擎运行一个完整的 CLIPS 专家系统,加载了从近期 Cortex XDR agent 中提取的 约 7967 条产生式规则语料库CLIPS 是一个前向链推理规则引擎,最初于 80 年代在 NASA 约翰逊航天中心开发

引擎将每个 BehavioralEvent转换为 CLIPS 事实字符串并断言到工作内存中。转换过程是一个基于事件类型的分发 switch,每个分支从进程模型和文件富化缓存中拉取富化信息:

switch (evt.header.type)
{
&nbsp; &nbsp; case EventType::ProcessCreate:
&nbsp; &nbsp; &nbsp; &nbsp; return build_process_start(evt.header, *d, proc, injected);
&nbsp; &nbsp; case EventType::ProcessExit:
&nbsp; &nbsp; &nbsp; &nbsp; return build_process_exit(evt.header, proc_iid);
&nbsp; &nbsp; case EventType::ImageLoad:
&nbsp; &nbsp; &nbsp; &nbsp; return build_image_load(evt.header, *d, proc_iid, proc, injected);
&nbsp; &nbsp; case EventType::FileCreate: case EventType::FileWrite:
&nbsp; &nbsp; case EventType::FileDelete: case EventType::FileRename:
&nbsp; &nbsp; &nbsp; &nbsp; return build_file_operation(evt.header, *d, proc_iid, injected);
&nbsp; &nbsp; case EventType::RegistryCreateKey: case EventType::RegistrySetValue:
&nbsp; &nbsp; case EventType::RegistryDeleteKey: case EventType::RegistryDeleteValue:
&nbsp; &nbsp; &nbsp; &nbsp; return build_registry_operation(evt.header, *d, proc_iid, injected);
&nbsp; &nbsp; case EventType::ScriptExecution:
&nbsp; &nbsp; &nbsp; &nbsp; return build_script_execution(evt.header, *d, proc_iid, injected);
&nbsp; &nbsp; case EventType::EtwTiEvent:
&nbsp; &nbsp; &nbsp; &nbsp; return build_etw_ti_yara(evt.header, *d, proc_iid, injected);
&nbsp; &nbsp; // ...
}

每个 builder 生成一个字段完整 ( slot-complete ) 的 CLIPS 事实字符串。仅一个进程启动事实就携带 27 个字段,覆盖镜像路径、命令行、父进程信息、SID、完整性级别、代码签名、哈希、PE 版本信息和熵。每个字段的存在都因为至少有一条规则在条件中引用了它。

ETW-Ti 路径值得单独说明,因为它比其他 builder 做了显著更多的工作。当一个远程内存操作到达时 ( ALLOCVM_REMOTE、PROTECTVM_REMOTE、MAP_VIEW_OF_SECTION、QUEUE_APC_THREAD、SET_CONTEXT_THREAD ),引擎通过 StackEnricher富化调用栈帧,然后对四个独立的内存区域进行 YARA 扫描:目标分配区、调用点区域、页面基址和分配基址。它通过 AddressEnricher将基址解析为映射的镜像路径。所有这些被打包进一个 internal.x64_yara_match事实,与操作特定的事实并列,使 CLIPS 规则既能看到发生了什么,也能看到发生时内存中有什么。

StackEnricher本身对每个调用栈产出丰富的分析:

struct StackEnrichment
{
&nbsp; &nbsp; std::string &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;stack_trace;
&nbsp; &nbsp; std::uint64_t &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;shellcode_address{};
&nbsp; &nbsp; std::vector<std::uint8_t> &nbsp;call_region_buffer;
&nbsp; &nbsp; std::vector<std::uint8_t> &nbsp;page_base_buffer;
&nbsp; &nbsp; std::vector<std::uint8_t> &nbsp;allocation_base_buffer;
&nbsp; &nbsp; bool &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; has_spoofed_frame{};
&nbsp; &nbsp; bool &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; has_unbacked_frame{};
&nbsp; &nbsp; bool &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; has_private_pages{};
&nbsp; &nbsp; std::string &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;syscall_origin_module;
&nbsp; &nbsp; std::uint64_t &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp;syscall_origin_rip{};
&nbsp; &nbsp; bool &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; is_direct_syscall{};
&nbsp; &nbsp; bool &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; is_indirect_syscall{};
};

每个栈帧都会被分类:如果 VirtualQueryEx报告 MEM_IMAGE,则它由合法模块支撑;否则它是无支撑的 ( shellcode、反射式加载器等 ),富化器会读取周围内存以供 YARA 扫描。系统调用来源分类器遍历已解析的栈,确定系统调用是否源自 ntdll ( 正常 )、源自 ntdll 但链中无 KERNELBASE ( indirect syscall ),还是完全来自 ntdll 之外 ( direct syscall )。

栈帧伪造检测的工作方式是读取每个返回地址之前的字节并验证它们是否构成一条 CALL 指令。这是一项 x86 反汇编检查:E8用于相对调用,FF /2或 FF /3用于间接调用,并包含完整的 ModRM/SIB 解码:

static bool is_preceded_by_call(
&nbsp; &nbsp; const std::uint8_t* bytes, std::size_t n) noexcept
{
&nbsp; &nbsp; if (n >= 5 && bytes[n - 5] == 0xE8)
&nbsp; &nbsp; &nbsp; &nbsp; return true;

&nbsp; &nbsp; for (std::size_t len = 2; len <= 7 && len <= n; ++len)
&nbsp; &nbsp; {
&nbsp; &nbsp; &nbsp; &nbsp; std::size_t pos = n - len;
&nbsp; &nbsp; &nbsp; &nbsp; if (bytes[pos] != 0xFF)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; continue;
&nbsp; &nbsp; &nbsp; &nbsp; std::uint8_t modrm = bytes[pos + 1];
&nbsp; &nbsp; &nbsp; &nbsp; std::uint8_t reg = (modrm >> 3) & 7;
&nbsp; &nbsp; &nbsp; &nbsp; if (reg != 2 && reg != 3)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; continue;
&nbsp; &nbsp; &nbsp; &nbsp; // ... ModRM/SIB displacement decoding
&nbsp; &nbsp; &nbsp; &nbsp; if (expected == len)
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; return true;
&nbsp; &nbsp; }
&nbsp; &nbsp; return false;
}

如果返回地址之前没有任何有效的 CALL 编码,则该帧被标记为伪造。

对于 PROTECTVM_LOCAL 事件 ( 一个进程更改自身内存的保护属性 ),有一条单独的字节补丁检测路径。当引擎看到敏感 DLL ( ntdll、kernel32 等 ) 的 .text段发生向可写+可执行的页面转换时,它会运行 BytePatchScanner:读取内存中的 .text段和磁盘上的原始版本,然后逐字节做差异对比。每个被补丁的区域连同其偏移量和修改前后的字节序列一起报告。这就是引擎在字节级别检测 EDR unhooking 的方式。

让这一切正常工作还需要实现 66 个外部函数,供 CLIPS 规则回调。在真实的 Cortex agent 中,这些由周围的 C++ 基础设施提供;在 heavener 中,必须根据逆向所得的逻辑从零重建,包括一个完整的 ECMAScript 兼容正则表达式引擎,因为规则使用的语法是 POSIX std::regex无法处理的。

CLIPS 引擎还可以在可选的 进程外隔离模式下运行:引擎在一个 kill-on-close job object 中生成一个 worker 子进程,通过命名管道通信,并在崩溃时自动重启 worker。反复导致崩溃的事件会被跳过以保证前向推进。

PS heavener> .\engine.exe --config config\engine_cortex.json --replay replay_amsi_cortex.json
[~] CLIPS engine loaded (bload): 7967 rules, 6448 templates, 581 functions, 1502 globals.
[~] Module loaded: CortexXDR vX.Y.Z.
[~] Replayed 3 events.
[~] 1 alert(s) generated.
&nbsp; [MALICIOUS] bioc.malicious_amsi_activity.2.1 - cid=heavener-01 (pid=0, module=CortexXDR)

PS heavener> .\engine.exe --config config\engine_cortex.json --replay replay_cp1_ldap_kerberoast.json
[~] CLIPS engine loaded (bload): 7967 rules, 6448 templates, 581 functions, 1502 globals.
[~] Module loaded: CortexXDR vX.Y.Z.
[~] Replayed 2 events.
[~] 1 alert(s) generated.
&nbsp; [MALICIOUS] bioc.sync.ldap_query_adrecon_kerberoasting - cid=heavener-01 (pid=0, module=CortexXDR)

我最初引入这个功能是因为在 CLIPS 库深处遇到了一个棘手的堆损坏 bug。由于该 bug 导致的崩溃是非确定性的,我最初的应对方法是将 CLIPS 行为引擎放在一个单独的进程中运行,引擎可以在检测到崩溃时重新生成它。

不过,修复 bug 后我就不再需要这个功能了,但还是决定将其保留为可选功能,用于测试和可扩展性。

CrowdStrike

CrowdStrike 的模块仅支持静态检测。其行为检测似乎嵌入在 channel 文件中,我正在积极尝试逆向其解析和导入逻辑。静态扫描器运行 CrowdStrike 的 XGBoost ML 模型,返回一个概率值,引擎将其映射为直接取自组件逻辑的判定阈值。

Sophos

Sophos 有两个 ML 模型:一个用于 PE 文件,另一个用于文档 ( PDF、Office )。在两者之上,一个传统签名扫描器提供常规检测。

根据我能够逆向工程的结果,三项判定结果会被组合,「最严重的一项」胜出。


回放系统

heavener 中的每个检测场景都可以被捕获、序列化和回放。引擎有捕获模式 ( --etw-capture--driver-capture) 将实时事件记录到 JSON,以及一个 --replay模式加载 JSON 文件并将每个事件推过完整流水线。

这对开发来说至关重要,因为它将测试与内核驱动和 ETW 解耦。我可以在 VM 上捕获一次攻击场景,保存 JSON 文件,然后在开发主机上回放以迭代模块行为,无需在提权上下文中运行、加载驱动或订阅 ETW。

典型的开发循环是:在 VM 上运行攻击、捕获事件、对当前激活的模块回放、检查哪些规则触发、调整富化或路由、再次回放。

告警与控制台

当模块检测到某些东西时,引擎构建一个 EnrichedAlert,将检测结果与其所有周围上下文打包在一起:完整的进程祖先链、子进程、文件富化信息 ( 哈希、签名、PE 信息、熵 )、按 PID 划分的近期事件遥测窗口,以及任何静态扫描发现。告警 ID 是确定性的 FNV-1a 哈希,因此回放相同的事件流会在控制台产生幂等结果:

static std::string event_id(const std::string& key) noexcept
{
&nbsp; &nbsp; std::uint64_t h = 1469598103934665603ULL;
&nbsp; &nbsp; for (unsigned char ch : key) { h ^= ch; h *= 1099511628211ULL; }
&nbsp; &nbsp; char out[17];
&nbsp; &nbsp; std::snprintf(out, sizeof(out), "%016llx",
&nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; &nbsp; static_cast<unsigned long long>(h));
&nbsp; &nbsp; return out;
}

告警以 JSONL 格式写入一个 outbox 目录。

一个 ForwarderSupervisor作为一个独立 Go 二进制 ( heavener-forwarder) 的子进程管理者。forwarder 尾随 JSONL outbox,用 gzip 压缩批次,并以 NDJSON 格式 POST 到 Web 控制台的接入端点。它通过持久化的字节偏移游标跟踪进度,以实现至少一次交付 ( at-least-once delivery )。

Web 控制台是一个完整的 Docker 技术栈,包括:ClickHouse 用于告警存储 ( 仅追加,带物化视图实现即时直方图 )、Postgres 用于可变应用状态 ( 资产清单、分诊工作流、保存的视图和 Rule Atlas )、Redis 用于接入幂等性和 SSE 发布/订阅、一个 Go REST API,以及一个通过 Caddy 提供服务的 React SPA。(感谢 Claude Design <3)

控制台有四个主要视图。告警时间线通过 Server-Sent Events 实时更新展示流入的检测结果。告警调查视图允许你深入某个具体检测:完整的进程树、富化数据、原始遥测窗口、哪条规则触发以及为什么触发。资产视图跟踪已连接的引擎。

而 Rule Atlas是一个可搜索的数据库,包含所有厂商行为规则、其条件、可观测对象和事件类型,从 S1 Lua 脚本和 Cortex CLIPS 语料库中解析而来。我添加 Atlas 是为了让操作员能够浏览那些可能难以复现的行为规则,或者让用户只需查看当前正在主动监控的检测面。

局限性

heavener 运行的一切都是我能够从磁盘上的 agent 二进制文件中本地提取的内容。每个 EDR 厂商都将相当一部分检测逻辑放在云端:信誉查询、云端 ML 模型、威胁情报关联、动态分析判定。那整个层级是一个黑盒。没有办法提取或复制它,因为它存在于我无法访问的基础设施上,而且 agent 到云端的协议是经过认证且不透明的。

因此,heavener 永远不会是这些产品中任何一个的完美复制品。它是在不运行真实 agent 的前提下你能达到的最接近的程度,而就测试载荷和理解检测逻辑的目的而言,这已经绰绰有余了。


免责声明:本博客文章仅用于教育和研究目的。提供的所有技术和代码示例旨在帮助防御者理解攻击手法并提高安全态势。请勿使用此信息访问或干扰您不拥有或没有明确测试权限的系统。未经授权的使用可能违反法律和道德准则。作者对因应用所讨论概念而导致的任何误用或损害不承担任何责任。


免责声明:

本文所载程序、技术方法仅面向合法合规的安全研究与教学场景,旨在提升网络安全防护能力,具有明确的技术研究属性。

任何单位或个人未经授权,将本文内容用于攻击、破坏等非法用途的,由此引发的全部法律责任、民事赔偿及连带责任,均由行为人独立承担,本站不承担任何连带责任。

本站内容均为技术交流与知识分享目的发布,若存在版权侵权或其他异议,请通过邮件联系处理,具体联系方式可点击页面上方的联系我

本文转载自:securitainment otterpwn otterpwn《heavener:当你买不起 EDR 授权时会发生什么》

评论:0   参与:  0