旁观者eCapture:支持龙芯,text 模式看完整 HTTP 关于新版本,笔者想聊聊两件事:跑上龙芯,以及更省心的 HTTPS 明文分析。 龙芯用户,这次安排上了 v2.8.0 开始,eCapture 增加 Linux/LoongArch64 支持,发行包认准 linux-loong64,提供 tar.gz 和 deb 两种格式。 龙芯用户也能沿用熟悉的 TLS 抓包方式,无需安装 CA 证书。 龙芯要求 Linux 内核 6.6 及以上,下载前先用 uname -r 看看。目前已完成交叉构建和模拟启动检查,真机完整抓包仍待验证,欢迎有设备的同学反馈。适配说明 text 模式,少一点“人工拼包” 以前看 text 输出,一个 HTTP 请求可能拆成好几段,内容抓到了,排查还得自己拼,多少有点费眼睛。 v2.9.0 给 tls、gotls、gnutls 增加了 pcapng 数据源,依赖 ecapture_decryptor 实时解密,重组完整 HTTP 消息。 时间、方向、两端 IP 和端口一起输出,HTTP/2 还有流标识,多连接交织时更容易定位。以 OpenSSL 为例: sudo ecapture tls -m text --text-source=pcapng -i eth0 'tcp port 443' 把 eth0 换成实际网卡,明文直接输出到终端。加上 --pcapfile capture.pcapng,还能留给 Wireshark 复查。 hook 仍是默认数据源;新链路目前支持 TCP 上的 HTTP/1.1、HTTP/2,尚不支持 HTTP/3。使用说明 其他修复 pcapng:修复突发流量下因用户态队列造成的丢包,退出时排空待写入的网络包和密钥。 配置与加载:重载时保留事件输出地址、轮转设置,统一 CO-RE/非 CO-RE 字节码选择。v2.8.0 记录 Android:v2.8.2 补回发行包中缺少的可执行文件,避免解压之后找不到程序。v2.8.2 记录 eCapture Decryptor,解密能力也能单独用 前面提到的 ecapture_decryptor,就是这条实时解密链路的关键:eCapture 负责抓包、提取密钥,它负责解密、重组 HTTP 消息。 下图能直观看到两种数据源的区别: 它也能独立解密抓包文件,作为 Go SDK 接入自己的工具,示例提供 JSON/HAR 导出。做流量分析平台的同学,欢迎试试,也顺手点个 Star。 新版下载:eCapture v2.9.0。 2026-10-31 0 0
如何给eBPF程序写单元测试 前言 eBPF程序还很年轻,周边质量建设体系还刚起步,常用于Linux内核上的监控跟踪,本身比较底层,测试成本很高。对于常写eBPF的同学,特别头疼的是快速迭代的项目,如何保证功能正常。如何给eBPF程序写单元测试呢?译者看到一篇文章,分享给大家。本文使用openAI翻译,如有错误,请看原文:Unit Testing eBPF Programs 当然,原文在 Hacker News上也被热烈讨论,大佬Daniel给出自己的看法,文章质量也很高,推荐看看。 BPF_PROG_RUN很棒,但不幸的是它依赖于正在运行的内核版本。为此,我编写了vmtest(https://dxuuu.xyz/vmtest.html),它专门用于BPF_PROG_RUN的使用场景。 eBPF的单元测试 无论你喜欢与否,编写单元测试几乎已经成为你的代码的必需品。在进行更改时,它们为你提供了一个安全网,并在更改后全部通过时给你一种愉悦、温暖的感觉。 在处理内核补丁时,我不得不研究为 eBPF 程序编写单元测试。事实证明,内核开发人员已经考虑到这一点,并已存在基础设施来实现它。 我将提供一个实际的例子,演示如何对一个 TC eBPF 程序进行单元测试。在这个测试中,我们希望确认查找一个发送到外部 IP 地址的数据包的路由是否会选择默认网关。我们将完全控制测试所运行的网络命名空间。如果你对这其中的任何概念一无所知,别担心,我将介绍的概念同样适用于测试其他类型的 eBPF 程序。 测试环境 在本文中,我假设你知道如何使用clang、bpftool编译你的eBPF程序,并且知道如何生成一个vmlinux.h文件。 话虽如此,我们确实需要在你的编程环境中进行一些基础设置,并安装我们需要的工具。 你必须拥有: bpftool - 除了生成vmlinux.h文件之外,还将用于为你编译的eBPF程序生成一个"骨架"加载器。 clang - 我们需要它来编译eBPF程序。 make - 用于运行我的修改过的Makefile。 此外,你的机器上必须具有CAP_SYS_ADMIN特权,如果你不知道是什么意思,99%的情况下以root身份运行将满足此要求。 我还假设你使用的是Linux系统,你可能会认为这是一个多此一举的说法,但Windows eBPF在快速迭代。 好了,最后一个假设,你已经正确安装了libbpf,并且clang/gcc能够找到它并编译你的eBPF程序。 介绍BPF_PROG_RUN命令 我们想要重点关注用于单元测试eBPF程序的核心功能,这个功能就是一个名为BPF_PROG_RUN的eBPF命令。 这个命令从BPF_PROG_TEST_RUN改名而来,这个标识符可能是可以互换使用的。命令是一个枚举值,可以传递给Linux暴露的bpf系统调用。然而,libbpf通常会为了方便和健壮性而封装bpf系统调用的使用。 因此,我们将重点关注使用libbpf封装的BPF_PROG_RUN命令,即bpf_test_run_opts。 让我们来看一下它的前向声明: struct bpf_test_run_opts { size_t sz; /* size of this struct for forward/backward compatibility */ const void *data_in; /* optional */ void *data_out; /* optional */ __u32 data_size_in; __u32 data_size_out; /* in: max length of data_out * out: length of data_out */ const void *ctx_in; /* optional */ void *ctx_out; /* optional */ __u32 ctx_size_in; __u32 ctx_size_out; /* in: max length of ctx_out * out: length of cxt_out */ __u32 retval; /* out: return code of the BPF program */ int repeat; __u32 duration; /* out: average per repetition in ns */ __u32 flags; __u32 cpu; __u32 batch_size; }; #define bpf_test_run_opts__last_field batch_size LIBBPF_API int bpf_prog_test_run_opts(int prog_fd, struct bpf_test_run_opts *opts); 如果我们来看实现,我们会发现bpf_prog_test_run_opts简单地将提供的opts复制到一个内核将拥有的结构体中,对opts结构体进行一些合理性检查,然后直接调用bpf系统调用。 libbpf函数的参数接受一个eBPF程序文件描述符和一个opts结构体。 eBPF程序文件描述符表示加载到内核中的eBPF程序,我们将在本文后面演示一种方便获取此文件描述符的方法。 opts结构体既提供模拟数据,也提供函数的选项。虽然某些字段标注为可选,但我们将了解到这实际上取决于你正在测试的eBPF程序类型,这些字段到底是可选还是必需的。 在本文中,我们将使用以下重要字段: sz始终是必需的,它只需设置为sizeof(bpf_test_run_opts)。 data_in、data_size_in允许您向传递给eBPF程序的ctx提供模拟数据,对于TC程序而言,就是模拟的IPv4数据包。 ctx_in、ctx_size_in允许您传入一个模拟的ctx,对于TC程序而言,就是模拟的__sk_buff结构,它是eBPF对内核套接字缓冲区的表示。 测试用例和Skeleton加载器 现在介绍了bpf_test_run_opts,让我们开始编写我们的eBPF测试用例。 我们还将使用bpftool生成一个Skeleton加载器,它是一个带有函数的头文件,用于将我们编译的eBPF程序加载到内核,并为我们提供一个对已加载程序的句柄。 该句柄可用于获取加载的eBPF程序的文件描述符,并在内核运行时与其交互。我们的测试用例的目标是确保源自主机、目标为外部节点的数据包选择默认路由,并转发到正确的接口。 为了测试这一点,我们将利用eBPF辅助函数bpf_fib_lookup。我们不需要详细了解这个辅助函数的工作原理,简单来说,我们提供一个传入数据包的源地址和目的地址,它将返回一个接口(如果有的话),该数据包将被转发到该接口。 在我们的测试用例中,我们希望上述接口是网络命名空间的默认网关。我们的测试数据包的源地址将为127.0.0.1,目的地址将为8.8.8.8。 由于我们运行的是单元测试,实际上不会发送任何数据,并且主机之外不会产生任何副作用。请记住,这个测试有点人为,主要是为了展示测试基础设施的一些特点,我们更倾向于演示而不是实用。 好的,让我们来看看我们的测试eBPF程序: // fib_lookup.bpf.c #include "../vmlinux.h" #include <bpf/bpf_helpers.h> #define TC_ACT_OK 0 #define TC_ACT_SHOT 2 #define TC_ACT_REDIRECT 7 #define AF_INET 2 /* Internet IP Protocol */ struct bpf_fib_lookup fib_params = {0}; int fib_lookup_ret = 0; SEC("tc") int fib_lookup(struct __sk_buff *skb) { struct iphdr *ip = 0; bpf_printk("performing FIB lookup\n"); bpf_printk("fib lookup original ret: %d\n", fib_lookup_ret); fib_lookup_ret = bpf_fib_lookup(skb, &fib_params, sizeof(fib_params), 0); bpf_printk("fib lookup ret: %d\n", fib_lookup_ret); return TC_ACT_OK; } char _license[] SEC("license") = "GPL"; 如您所见,测试非常简单。我们导入必要的头文件,然后定义两个全局变量,并将它们都设置为零。 通过将这些变量定义为全局变量并将其设置为零,实际上使其通过我们的骨架在用户空间中可用。让我们使用以下Makefile来编译并生成此eBPF程序的骨架。 # makefile CFLAGS += -g3 \ -Wall LIBS = bpf all: fib_lookup.bpf.o fib_lookup.skel.h fib_lookup.bpf.o: fib_lookup.bpf.c clang -target bpf -Wall -O2 -g -c $< fib_lookup.skel.h: fib_lookup.bpf.o bpftool gen skeleton $< > $@ test: test.c gcc $(CFLAGS) -l$(LIBS) -o $@ $< .PHONY: clean: rm -rf fib_lookup.bpf.o rm -rf fib_lookup.skel.h rm -rf test 现在先忽略测试二进制文件,我们将在下一部分编写测试运行器。 如果我们检查文件fib_lookup.skel.h,我们会遇到一个有趣的结构。 // fib_lookup.skel.h struct fib_lookup_bpf { struct bpf_object_skeleton *skeleton; struct bpf_object *obj; struct { struct bpf_map *bss; struct bpf_map *rodata; } maps; struct { struct bpf_program *fib_lookup; } progs; struct { struct bpf_link *fib_lookup; } links; struct fib_lookup_bpf__bss { struct bpf_fib_lookup fib_params; int fib_lookup_ret; } *bss; struct fib_lookup_bpf__rodata { } *rodata; #ifdef __cplusplus static inline struct fib_lookup_bpf *open(const struct bpf_object_open_opts *opts = nullptr); static inline struct fib_lookup_bpf *open_and_load(); static inline int load(struct fib_lookup_bpf *skel); static inline int attach(struct fib_lookup_bpf *skel); static inline void detach(struct fib_lookup_bpf *skel); static inline void destroy(struct fib_lookup_bpf *skel); static inline const void *elf_bytes(size_t *sz); #endif /* __cplusplus */ }; 这是我们加载的eBPF程序的句柄,当我们调用Skeleton加载器时,它会返回给我们。 static inline struct fib_lookup_bpf * fib_lookup_bpf__open_and_load(void) 在这个文件里,我感兴趣的在这 struct fib_lookup_bpf__bss { struct bpf_fib_lookup fib_params; int fib_lookup_ret; } *bss; 注意,我们可以在bss字段中访问到全局的零初始化变量。 这使得用户空间程序能够加载eBPF程序,并检索其句柄,然后在调用bpf_test_run_opts之前和之后注入和读取全局变量的值。 这正是我们的测试运行器要做的事情。 编写测试运行器 如上所述,我们希望我们的测试运行器执行以下操作: 将eBPF测试程序加载到内核中,并获得在bpf_lookup.skel.h中定义的fib_lookup_bpf结构的句柄。 在运行测试之前,向测试中注入一个模拟的bpf_fib_lookup参数结构。 利用libpf的bpf_test_run_opts函数在用户空间中运行我们的测试。 读取生成的fib_lookup_bpf和fib_lookup_ret以确定是否使用了默认网关。 由于我们控制测试运行的网络命名空间,因此我们可以硬编码表示默认网关的接口ID(ifindex),使得我们的测试运行器更简单。 让我们来看一下测试运行器: // test.c #include <bpf/bpf.h> #include <bpf/libbpf.h> #include <stdio.h> #include <bpf/bpf_endian.h> #include "fib_lookup.skel.h" #include "net/ethernet.h" #include "linux/ip.h" #include "netinet/tcp.h" #define TARGET_IFINDEX 2 // in our test, we only care that the packet is the correct size, // since our test does not touch any packet data. char v4_pkt[(sizeof(struct ethhdr) + sizeof(struct iphdr) + sizeof(struct tcphdr))]; // create an empty skb as mock data, our tests do not touch any skb fields. struct __sk_buff skb = {0}; int main (int argc, char *argv[]) { struct fib_lookup_bpf *skel; int prog_fd, err = 0; // define our BPF_PROG_RUN options with our mock data. struct bpf_test_run_opts opts = { // required, or else bpf_prog_test_run_opts will fail .sz = sizeof(struct bpf_test_run_opts), // data_in will wind up being ctx.data .data_in = &v4_pkt, .data_size_in = sizeof(v4_pkt), // ctx is an skb in this case .ctx_in = &skb, .ctx_size_in = sizeof(skb) }; // load our fib lookup test program into the Kernel and return our // skeleton handle to it. skel = fib_lookup_bpf__open_and_load(); if (!skel) { printf("[error]: failed to open and load skeleton: %d\n", err); return -1; } // inject our test parameters into the fib lookup parameter, this primes // our test. skel->bss->fib_lookup_ret = -1; skel->bss->fib_params.family = AF_INET; skel->bss->fib_params.ipv4_src = 0x100007f; skel->bss->fib_params.ipv4_dst = 0x8080808; skel->bss->fib_params.ifindex = 1; // get the prog_fd from the skeleton, and run our test. prog_fd = bpf_program__fd(skel->progs.fib_lookup); err = bpf_prog_test_run_opts(prog_fd, &opts); if (err != 0) { printf("[error]: bpf test run failed: %d\n", err); return -2; } // check global variables for response if (skel->bss->fib_lookup_ret != 0) { printf("[FAIL]: fib lookup returned: %d", skel->bss->fib_lookup_ret); return -1; } if (skel->bss->fib_params.ifindex != TARGET_IFINDEX) { printf("[FAIL]: fib lookup did not choose default gw interface: %d", skel->bss->fib_params.ifindex); return -1; } printf(" %d\n", skel->bss->fib_params.ifindex); return 0; } 让我们更新Makefile来构建我们的测试运行器。 CFLAGS += -g3 \ -Wall LIBS = bpf all: fib_lookup.bpf.o fib_lookup.skel.h test fib_lookup.bpf.o: fib_lookup.bpf.c clang -target bpf -Wall -O2 -g -c $< fib_lookup.skel.h: fib_lookup.bpf.o bpftool gen skeleton $< > $@ test: test.c gcc $(CFLAGS) -l$(LIBS) -o $@ $< .PHONY: clean: rm -rf fib_lookup.bpf.o rm -rf fib_lookup.skel.h rm -rf test 最后,我们提供一个脚本,为这个测试运行程序创建一个网络命名空间,并运行测试。 #!/bin/bash NETNS_NAME="netns-1" n='sudo ip netns' nexec="sudo ip netns exec $NETNS_NAME" function setup_netns() { # add 'netns-1' network namespace where we'll # run our test. $n add $NETNS_NAME # setup loopback $nexec ip addr add 127.0.0.1 dev lo # setup a dummy interface which can route to the default gw, and # setup a route to the default gw. $nexec ip link add name eth0 type dummy $nexec ip link set up eth0 $nexec ip addr add 192.168.1.10/24 dev eth0 $nexec ip route add default via 192.168.1.11 # since 192.168.1.11 doesn't actually exist, create a perm arp-table entry # for it, allowing fib lookup to succeed. $nexec ip neigh add 192.168.1.11 dev eth0 lladdr "0F:0F:0F:0F:0F:0F" nud permanent } function teardown_netns() { $n del $NETNS_NAME } setup_netns $nexec ./test teardown_netns 当我们运行这个脚本时,我们会得到以下输出: [PASS]: ifindex 2 总结 让我们总结一下这篇文章的要点。 一个 eBPF 程序可以定义全局变量,在用户空间测试运行程序之前和之后都可以对其进行修改。 可以使用BPF_PROG_RUN命令在用户空间中运行你的 eBPF 程序,该命令由 libbpf 中的 bpf_prog_test_run_opts()函数包装。 一旦将 eBPF 程序编译为对象文件,你可以使用 bpftool 生成一个skeleton加载器,该加载器将你的 eBPF 程序加载到内核,并为用户空间程序提供访问上述全局变量的权限。 最后,你可以编写一个用户空间测试运行器,在测试之前设置加载的 eBPF 程序的全局变量,并在测试之后读取它们,从而确定 eBPF 程序是否执行了你预期的操作。 2023-05-20 0 0
漩涡: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 Architecture (Bitdefender) EDR Security | Move Beyond Traditional Endpoint Detection and Response (paloalto Networks) Symantec EDR architecture (Symantec) Onboarding using Microsoft Endpoint Manager (Microsoft) 简要回顾和比较高级别的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 将重定向调用的流程。我们将在以下部分中查看其实际操作方式。通过挂钩函数调用,作者可以: 评估参数 允许执行 阻止执行 这不是详尽无遗的列表,但应该足以演示我们运行植入物时最经常遇到的功能。 其中一些例子包括: 了解并绕过 AMSI:通过挂钩 AmsiScanBuffer 调用来绕过 AMSI。 RDPThief:拦截并读取 RDP 凭据。 Windows API Hooking:重定向 MessageBoxA。 导入地址表(IAT)挂钩:重定向 MessageBoxA。 通过 Hooking msv1_0!SpAcceptCredentials 拦截登录凭据:拦截并读取 msv1_0!SpAcceptCredentials 的凭据。 保护堆:加密和 Hooks:Hook RtlAllocateHeap,RtlReAllocateHeap LdrLoadDll Hook: 防止 DLL 被加载 狩猎ELK 为了不必从头开始编写所有令人生畏的逻辑就能访问我们的内核回调,我们将使用[狩猎ELK(HELK)]: 狩猎ELK或简称HELK是最早具有高级分析功能(如SQL声明语言、图形化、结构化流和甚至通过Jupyter笔记本和Apache Spark进行机器学习)的开源狩猎平台之一。该项目主要用于研究,但由于其灵活的设计和核心组件,可以在正确配置和可扩展基础设施下部署到更大规模的环境中。 HELK:安装 高级Windows日志记录-查找AV遗漏内容 我们还使用以下脚本来自探索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, ®istry); 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: LoadLibraryA LoadLibraryExA LoadLibraryExW LdrLoadDll LdrpLoadDll LdrpCreateDllSection 这对于熟悉它的工作方式很有帮助。但是,在 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 结构体: 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; } 让我们来看一下... 首先,MessageBoxA在User32.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 展示了使用 NtSetProcessInformation 和 ProcessInstrumentationCallback 标志进行进程检测的过程。在这个演讲中,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,可以删除发送的任何回调。这是由 modexp 在 Bypassing 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 Ninja 在EtwTi-Syscall-Hook 和 Release 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 以了解这些不同的技术。 即使如此!还有更多: FreshyCalls EtwTi-Syscall-Hook FireWalker 回顾一下! 通过能够转换为内核模式,我们可以避开用户空间钩子。因此,让我们构建一些东西。 对于我们的示例,我们将使用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_READWRITE、RWX,因为这通常是恶意活动的迹象(通常)。如果匹配,则只需打印我们发现了什么。 然后,我们有一个阻止概念。这意味着如果“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 的文章和Countercept的 CallStackSpoofer。 与内核回调一样,这是一个活跃的研究领域,在本文中要探索的内容远远超出了我们拥有时间或空间的范围。 线程调用堆栈 进程的另一个组件可以通过线程调用堆栈进行查询。如在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 库中,我们使用以下函数: StackWalk64: 获取堆栈跟踪。 SyGetModuleBase64: 检索包含指定地址的模块的基址。 SymFromAddr: 检索指定地址的符号信息。 将代码指向正常线程堆栈: 这与之前看到的相匹配。现在将其更改为指向“坏”线程: 现在显示了一个迄今未见过的不同线程堆栈。查看第3个帧,它是CreateTimeQueueTimer,该技术被描述为睡眠混淆技术: Vulpes: Obfuscating Memory Regions with Timers Maelstrom: Writing a C2 Implant 作为免责声明,此技术完全归功于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推广,并由mgeeky在ThreadStackSpoofer中重新解释。但是,该概念验证将返回地址设置为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; } 在一个更近期的项目中,CallStackSpoofer 由 William 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! 2023-03-26 0 0