Linux上文件监控的踩坑分享 Linux上文件监控的踩坑分享 前言 在Linux主机安全产品HIDS中,文件监控是特别常见的需求,在实现方案上,Linux内核层提供了文件变动的通知机制fsnotify,然而,在高磁盘IO的主机上、不同版本的内核上以及海量监控目标中,将会面临哪些问题呢?业务性能与安全性如何做更好地取舍均衡?今天,我的小伙伴阿松给大家分享以下文件监控系统的建设历程。 文件监控的实现 Linux系统上,常见的文件监控一般有如下几种实现方案。 粗暴的周期性轮询,在内核不支持(比如伪文件系统),并且也没有特别好的方式的时候,轮询不失为一个办法,但是缺点如轮询字面表述的那样: 无论轮询周期多短,总可能会有事件的丢失;当然了,对于丢失事件不敏感的应用来说,这个也能接受; 为了减少事件的丢失,需要尽可能短的轮询周期,但是如果轮询周期太短,性能消耗就巨大,这是一个两难的选择; 不过,如果仅仅是感知个别文件的变动,还是可以采用短周期轮询的方式,但是如果是需要感知大量文件变动呢? 采用内核提供的fsnotify机制。在fsnotify的基础上,内核提供了给用户态使用的接口,这套接口从dnotify到inotify再到fanotify,也是经历了多次迭代;基于fsnotify机制,只需要watch 某个文件,然后等待内核上报文件变动的event即可。 在我们的业务场景中,需要对大量的文件进行监控,显然是不可能采用轮询了,经过多次测试验证,内核提供的inotify接口最能满足我们的需求; inotify使用的方式大概如下,具体可参考:Linux man page inotify.7 inotify_init,初始化inotify fd; inotify_add_watch,监控某个文件/目录; receive event,处理文件变动事件; inotify的事件通知机制,以open事件为例子,inotify event的产生,反映在内核中是如下的函数调用链路: open syscall -> fsnotify_open -> fsnotify -> send_to_group -> handle_event (inotify_handle_event) 用户态的代码等待接收event,即可获取文件变动的数据。 inotify的踩坑优化 在生产环境中,一旦用户态功能需要和内核子系统进行大量的交互,那么由于生产环境的复杂性,难免会暴露出一些问题,而且生产环境业务的多样性使得开发者不得不尝试做更深入的思考;然而,即便是如此小心谨慎,还是踩到了一个坑; 第一次踩坑 背景大概是这样的,有一台机器上运行了文件监控服务A,在文件监控服务的一次变更中,突然发现某个业务服务器上发生了tcp timeout,时间线和文件监控服务的变更时间点完全一致,但这很难将文件监控的 功能跟TCP timeout异常这两个事情联系在一起。为了排查这些问题,我们请了内核组的同学协助排查,发现确实是能够复现tcp timeout这个问题; 通过review变更发现,本次变更仅仅是对监控的范围做了扩大,属于常规操作;因为监控的目标文件数量太多了,初步怀疑是高频次系统调用导致CPU内核态被打满,影响了正常网络业务的处理,导致tcp timeout。 但是随后的一系列实验,都否定了这个猜想,即系统调用的频率和tcp timeout无关。 tcp timeout问题分析 在一筹莫展之际,增加了inotify_add_watch系统调用的耗时统计,发现文件监控服务A在变更后,监控文件的整体流程(inotify_add_watch监控配置目录)的耗时异常高。这其实和认知不符合,正常的系统调用耗时应该极短; 那么只好在复现代码的流程上对每个inotify_add_watch的耗时做统计了,这一统计不要紧,发现有个目录就是很特别,一旦监控这个目录,那么某个CPU核内核态会被打满,耗时能到秒级;通过perf对测试代码进行火焰图分析,发现__fsnotify_update_child_dentry_flags耗时占了大头。 在这个函数中,可以看到似乎都是常规操作,Linux 3.10 fsnotify.c#L52,看起来让人不放心的是有一把inode的锁:spin_lock(&inode->i_lock); spin_lock就是不停的询问资源有没有准备好的一把锁,使用spin_lock的的前提是:假设很快就能获得锁,且不会占用锁太久; spin_lock不关闭中断,如果一个内核线程拿不到锁,它就会一直自旋等待锁可用,处于忙等状态,表现出来就是该线程所在的CPU的内核态几乎被打满。且由于该CPU不可被调度(服务器内核配置的PREEMPT_VOLUNTARY,实际就是不可抢占模式),那么该CPU会一直做无用功。 问题解决思路 所以基于perf分析和spin_lock的原理和使用场景,得出来如下结论:这个被监控的目录inode的spin_lock被某个进程长时间持有不释放,在文件监控服务A监控该目录,尝试获取spin_lock时一直自旋,导致CPU被打满,进而影响了正常的网络处理流程,导致tcp timeout。 查到这里,那么看起来如何解决tcp timeout的办法就比较清晰了: 找到长时间持有该目录inode的spin_lock的进程,kill掉; 文件监控服务A其实不太关注这个目录,直接移除对这个目录的监控就可以解决问题,实际上最终也是采取了这个方案; 文件监控的再次踩坑 上次的踩坑和欧,以为inode的锁占用问题得到了完全解决,但没想到后面又一次掉到坑里了。 后来复盘的时候做了回顾,之前inotify导致了个别服务器tcp timeout,且并没有其它的内核日志等证据来辅助证明,所以所有的证据都指向一个地方:这个目录被其它进程占用inode锁。这就为后面的再一次踩坑埋下伏笔。 case原委 某天,内核团队反馈,似乎有文件监控服务进程导致的soft lockup,可以从内核日志清晰看到。 当时收到反馈的时候整个人是懵的,受限于术业专攻,无法理解理解用户态的代码是如何导致内核态的soft lockup的;但现实是如此残酷,通过一系列的实验验证,确实能够复现soft lockup; 这里多亏了内核团队非常给力,内核团队通过分析soft lockup时的日志,发现问题点确实还是在__fsnotify_update_child_dentry_flags这个函数内部。 并且通过对内核日志的分析,可以精确定位到soft lockup时,实际卡住的地方在if (!child->d_inode)这个位置。这个判断条件是在一个循环的内部,循环内部遍历该inode所有的children,通过crash工具,也可以观察到当前这个目录对应inode下的children(然而是negative)很多; void __fsnotify_update_child_dentry_flags(struct inode *inode) { struct dentry *alias; int watched; if (!S_ISDIR(inode->i_mode)) return; /* determine if the children should tell inode about their events */ watched = fsnotify_inode_watches_children(inode); spin_lock(&inode->i_lock); /* run all of the dentries associated with this inode. Since this is a * directory, there damn well better only be one item on this list */ hlist_for_each_entry(alias, &inode->i_dentry, d_u.d_alias) { struct dentry *child; /* run all of the children of the original inode and fix their * d_flags to indicate parental interest (their parent is the * original inode) */ spin_lock(&alias->d_lock); list_for_each_entry(child, &alias->d_subdirs, d_child) { if (!child->d_inode) continue; spin_lock_nested(&child->d_lock, DENTRY_D_LOCK_NESTED); if (watched) child->d_flags |= DCACHE_FSNOTIFY_PARENT_WATCHED; else child->d_flags &= ~DCACHE_FSNOTIFY_PARENT_WATCHED; spin_unlock(&child->d_lock); } spin_unlock(&alias->d_lock); } spin_unlock(&inode->i_lock); } 通过分析crash文件,发现遍历的目录为/path/to/problem,但是通过ls查看,这个下面其实没几个子目录/文件。 如上所说,结合卡住的位置是在if (!child->d_inode),那么是不是可以推断,/path/to/problem这个目录下有大量的无效dentry呢?(参考:https://lwn.net/Articles/814535/); dentry相关 当读写文件时,内核会为该文件对象建立一个dentry,并将其缓存起来,方便下一次读写时直接从内存中取出dentry提高效率。这些dentry cache可以让文件路径lookup工作大大加速,尤其是那些频繁访问的目录(如/tmp, /dev/null, /usr/bin/tetris)都在dentry cache里,可以节省大量filesystem I/O操作。 dentry属于slab cache,且dentry有三种状态: inuse,字段d_inode指向实际对象,且dentry的d_count大于0,不能被释放; unused,和inuse的区别在于这个dentry的d_count为0,也即是没有被引用,内存回收时可能会被释放; negative,d_inode不指向实际对象,这种dentry的产生主要有两种原因: 关联的inode已经被释放; 进程访问一些不存在的文件会产生这种dentry,这种dentry有利于加速不存在文件的检索(可以快速返回失败); 解开谜题 在上述的case中,结合已有的crash分析和业务情况可以发现,业务场景比较特殊,业务会在目录/path/to/problem中地创建随机文件名的文件然后删除,且频繁地重复这一动作; 这也就解释了为啥/path/to/problem这个目录下面有大量的negative dentry;显然在这种业务场景中,negative dentry加速访问的作用不明显,但遗憾的是内核并未提供相关的接口进行细粒度的删除。 在此进一步说明,如下的红框中的代码是真正导致耗时居多的地方。 复现soft lockup 有了以上的结论,就可以尝试复现soft lockup这一现象,看看是不是和我们的推断(inode下negative dentry太多,watch时导致soft lockup)完全契合。 复现soft lockup准备工作: 首先清空slab cache,echo 2 > /proc/sys/vm/drop_caches; 为了缩短复现时间,调低soft lockup的阈值,比如echo 1 > /proc/sys/kernel/watchdog_thresh; 准备测试代码inotify_watch,就做一件事情,inotify_add_watch目录/tmp/test; 代码可以参考Linux man pages inotify; 使用time ./inotify_watch测试内核态的耗费时间,实际测试可以看到,耗时基本为0; 在/tmp/test这个目录中,随机生成文件名然后删除,高频次做这一动作; #! /bin/bash num=0 while true; do ((num++)) rm -f /tmp/test/$num.tmp > /dev/null done dentry cache的大小可以通过slabtop --once观测,上述脚本运行时定时观测dentry cache的增大现象; 等到dentry cache增大到一定量,比如到5GB; 使用time ./inotify_watch测试内核态的耗费时间,可以发现内核态耗时可能在3S左右; dmesg -T也能查看到对应的soft lockup日志,和之前的火焰图是一致的,call Trace如下: call Trace:inotify_add_watch -> entry_SYSCALL_64_after_hwframe -> do_syscall_64 -> x64_sys_inotify_add_watch -> inotify_update_watch -> fsnotify_add_mark_locked -> fsnotify_update_child_dentry_flags 再次梳理问题链路 基于以上的分析流程,再次梳理下soft lockup问题产生的链路,分析下是否还会有其它影响: 目录/path/to/problem下的dentry cache特别多,比如占据内存10GB,可能其中大部分都是negative dentry; inotify_add_watch对/path/to/problem这个目录进行了watch; 用户态代码调用inotify_add_watch 监控/path/to/problem这个目录时,调用链路中会调用__fsnotify_update_child_dentry_flags这个函数; __fsnotify_update_child_dentry_flags函数会对这个目录对应inode下的所有dentry(包括negative dentry)进行遍历,耗时太多导致在该CPU上产生了soft lockup; 另外,因为目录/path/to/problem的inode的spin_lock被该内核线程占用太久,如果此时有其他进程需要获取该目录对应inode的spin_lock时,也会在其它CPU上产生soft lockup; 所以,综上,如果/path/to/problem目录对应inode下的dentry巨多,soft lockup会有两个触发路径: Inotify_add_watch导致,由于内核线程A持有目录/path/to/problem inode的i_lock,占据CPU 1太久导致soft lockup; 这时候内核线程B也尝试获取目录/path/to/problem的i_lock,因为长时间获取不到,spin_lock自旋导致CPU 2产生soft lockup; 问题解决方式 到此,问题已经描述清晰了,那么要如何解决呢? 社区也提出了一些解决方式,但是并没有合并到主线代码:https://lore.kernel.org/lkml/20220209231406.187668-5-stephen.s.brennan@oracle.com/T/#m039bc61f4ea656bc268b7f565973d9924af15652 如上的复现步骤中,可以看到,通过清空dentry cache(echo 2 > /proc/sys/vm/drop_caches)的方式来解决,但是由于dentry和inode cache的特殊作用,该操作可能会带来性能下降,作为应急方案比较合适; 文件监控服务中,对于要监控的目标目录有清晰的认知,要结合业务情况做监控目标的取舍。比如某些业务在某个目录高频次创建删除随机文件名文件,或者高频次尝试访问不存在的文件,那么这时候这个目录需要被加入黑名单; 总结 这两个问题,是我们遇到的比较复杂的问题,甚至是Linux Kernel的文件系统缺陷,在这里分享给大家。如果大家有更好的解决方案,也欢迎留言交流。 文件监控是特别常见、特别普通的功能,然而,在生产环境中运行时,会因为环境、业务、需求等不同,遇到各种各样的问题,而这些问题的发生,将严重拉长HIDS类产品的覆盖周期。面对疑难杂症,务必要有一颗打破砂锅问到底的心,才能定位出问题根因,给出更加高效的解决方案,保证安全产品的快速覆盖。 武林秘籍 追根究底,追求卓越,永远都是工程师最珍贵的品质。小弟,既然读到这里了,想必你也是全身散发着这种品质,我看你计算机内功极厚,简直是百年难得一遇的编程奇才,我这里有个招聘,你要不要来试试。 职位:反爬蓝军对抗专家 职位隶属美团-基础研发平台-信息安全部,办公地点在北京望京、上海杨浦,两地任选。要求候选人需要在相关领域具备3年以上工作经验。 职位详情:https://mp.weixin.qq.com/s/IkWNRYUOWuh0woL0_Uzg2w 2023-06-05 2 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
eBPF verifier验证器与编译器inline内联 笔者在二月份编写eCapture的GoTls密钥捕获功能时,遇到了一个小bug,就是在UBUNTU 21.04的Linux上运行时,遇到eBPF verifier的报错,无法启动,笔者后来通过启用always inline解决了这个问题。 我觉得,这是一个比较好的编写eBPF的技巧案例,决定把详细排查过程、原因、修复方法一起分享出来,奈何事情比较忙,一直没时间,拖延了一个多月,这个周末,咬牙立Flag,整理出来,分享给大家。 报错详情 报错的eCapture代码是aa0b86ea,在ubuntu 20.04上,使用make nocore编译运行,则一切正常。若在ubuntu 21.04以及以后版本编译运行,则出现如下错误: tls_2023/03/24 19:44:20 EBPFProbeGoTLS BPF bytecode filename:user/bytecode/gotls_kern.o tls_2023/03/24 19:44:21 EBPFProbeGoTLS module run failed, [skip it]. error:error:program gotls_text_register: load program: invalid argument: Arg#0 type PTR in gotls_text() is not supported yet. (2 line(s) omitted) , couldn't load eBPF programs, cs:&{map[events:PerfEventArray(keySize=0, valueSize=0, maxEntries=0, flags=0) gte_context:LRUHash(keySize=8, valueSize=4136, maxEntries=2048, flags=0) gte_context_gen:Array(keySize=4, valueSize=4136, maxEntries=1, flags=0)] map[gotls_text_register:0xc0003f6630 gotls_text_stack:0xc0003f66c0] 0xc000072f40 LittleEndian} 当然,把ubuntu 20.04上编译的版本拿到21.04上运行,也是如上错误。 关键信息 很显然,重点的错误是下面这句error:error:program gotls_text_register: load program: invalid argument: Arg#0 type PTR in gotls_text() is not supported yet. (2 line(s) omitted) 。 eCapture是使用了gojue/ebpfmanager这个纯Go的ebpf类库来管理,里面是使用了 cilium/ebpf类库实现syscall eBPF调用。通过分析定位,在相应的代码处打日志,则会看到详细的报错信息,如下: invalid argument Validating gotls_text() func#1... Arg#0 type PTR in gotls_text() is not supported yet. processed 0 insns (limit 1000000) max_states_per_insn 0 total_states 0 peak_states 0 mark_read 0 继续跟进debug,确定报错信息来自cilium/ebpf类库中errors包内的323行,变量 logBuf ,当调用sys.ProgLoad加载eBPF程序时报错。 // internal/errors.go #line 323 var err2 error if !opts.LogDisabled && opts.LogLevel == 0 { logBuf = make([]byte, opts.LogSize) attr.LogLevel = LogLevelBranch attr.LogSize = uint32(len(logBuf)) attr.LogBuf = sys.NewSlicePointer(logBuf) _, err2 = sys.ProgLoad(attr) } 重点的报错内容为Arg#0 type PTR in gotls_text() is not supported yet.,而这段报错是来自内核的BPF验证器。 在内核代码中,这段报错是来自 btf_prepare_func_args函数,功能为将函数的BTF转换为bpf_reg_state,无法转换BTF时,则返回EINVAL。即本次bug的现象。 该函数触发本次报错的代码如下: /* Convert BTF function arguments into verifier types. * Only PTR_TO_CTX and SCALAR are supported atm. */ for (i = 0; i < nargs; i++) { t = btf_type_by_id(btf, args[i].type); while (btf_type_is_modifier(t)) t = btf_type_by_id(btf, t->type); if (btf_type_is_int(t) || btf_type_is_enum(t)) { reg[i + 1].type = SCALAR_VALUE; continue; } if (btf_type_is_ptr(t) && btf_get_prog_ctx_type(log, btf, t, prog_type, i)) { reg[i + 1].type = PTR_TO_CTX; continue; } bpf_log(log, "Arg#%d type %s in %s() is not supported yet.\n", i, btf_kind_str[BTF_INFO_KIND(t->info)], tname); return -EINVAL; } 如注释内容,将BTF函数参数转换为验证器类型。目前仅支持PTR_TO_CTX和SCALAR。 问题在哪里? 很好理解,看gotls_text这个函数代码,Arg#0即struct pt_regs *ctx,而这个参数实际上是PTR_TO_CTX这个类型,但依然没通过验证器的验证。 int gotls_text(struct pt_regs *ctx, bool is_register_abi) { // ... } SEC("uprobe/gotls_text_register") int gotls_text_register(struct pt_regs *ctx) { return gotls_text(ctx, true); } SEC("uprobe/gotls_text_stack") int gotls_text_stack(struct pt_regs *ctx) { return gotls_text(ctx, false); } 显然,内核编译的 btf_vmlinux是不正确的,eBPF验证器无法确定上下文指针的类型,并将struct pt_regs *ctx作为PTR,而非真正的PTR_TO_CTX 。 btf_vmlinux为什么损坏,我不知道。继续排查下去会花费我很多时间、精力。所以,暂时跳过查找根因这个方向,转向跳过验证器方法。 不过,在笔者今天写这篇文章时,发现cilium社区这两天也遇到这个问题了,在bpf: inline test functions with ctx as input #24662 这个issue里,开发者也认为这是内核编译时遇到错误了,大概是pahole 或 gcc 的问题。 探索解决思路 正确的解决办法应该是定位出btf_vmlinux损坏的原因,但这事会特别复杂、特别漫长,涉及UBUNTU的内核编译问题排查,重现成本会很高。为此,我想尝试找到规避这个问题的方法,比如不进入BTF的BPF verifier验证器。 为什么会被eBPF verifier验证 继续通过内核函数 btf_prepare_func_args 继续跟进的话,会看到是do_check_subprogs()函数在验证所有ebpf progs。 /* Verify all global functions in a BPF program one by one based on their BTF. * All global functions must pass verification. Otherwise the whole program is rejected. * Consider: * int bar(int); * int foo(int f) * { * return bar(f); * } * int bar(int b) * { * ... * } * foo() will be verified first for R1=any_scalar_value. During verification it * will be assumed that bar() already verified successfully and call to bar() * from foo() will be checked for type match only. Later bar() will be verified * independently to check that it's safe for R1=any_scalar_value. */ static int do_check_subprogs(struct bpf_verifier_env *env) { // ... } 正如函数的注释所写,根据BTF对BPF程序中的所有全局函数逐个进行验证。所有全局函数都必须通过验证,否则整个程序将被拒绝。 在验证过程中,会先验证foo(),验证时会假定bar()已经通过了验证,并且只会检查从foo()调用bar()的类型匹配性。之后,会单独验证bar(),以检查当R1=any_scalar_value时是否安全。 回到我的代码写法,即gotls_text_register调用了gotls_text函数,所以,gotls_text函数也会被eBPF 验证器再验证一次。 灵机一动 既然是独立的函数会被验证,那么我不拆分函数呗,但考虑到gotls_text函数是公用部分,编写上还是要剥离出来,降低维护成本。 显然,用编译器inline内联呗。 为此,我在ubuntu 20.04、21.04上分别验证了no_inline、inline两种模式的情况,并导出BPF字节码文件的汇编码逐一验证。 no_inline ubuntu 20.04编译 没有自动inline,但不报错,可正常运行。 把程序放到21.04上,则报错 tls_2023/03/27 16:18:57 EBPFProbeGoTLS module run failed, [skip it]. error:error:program gotls_text_register: load program: invalid argument: Arg#0 type PTR in gotls_text() is not supported yet. (2 line(s) omitted) , couldn't load eBPF programs, ubuntu 21.04编译 没有自动inline,但报错,运行失败 tls_2023/03/27 16:18:57 EBPFProbeGoTLS module run failed, [skip it]. error:error:program gotls_text_register: load program: invalid argument: Arg#0 type PTR in gotls_text() is not supported yet. (2 line(s) omitted) , couldn't load eBPF programs, 将21.04上编译的版本,运行报错,但放到20.04上运行,则正常。 将20.04上编译、运行正常的程序,放到21.04上,也依旧会报错。 可以确定是内核BPF验证器增加了相关验证。 见[bpf-next,3/6] bpf: Introduce function-by-function verification inline 关闭inline内联 gotls_text 关闭用inline内联后,可以看到反汇编后的机器码: 0000000000000248 <gotls_text>: 73: bf 16 00 00 00 00 00 00 r6 = r1 74: 15 02 1f 00 00 00 00 00 if r2 == 0 goto +31 <LBB3_2> 75: b7 01 00 00 28 00 00 00 r1 = 40 76: bf 63 00 00 00 00 00 00 r3 = r6 // ... 104: 85 00 00 00 71 00 00 00 call 113 105: 05 00 1f 00 00 00 00 00 goto +31 <LBB3_3> gotls_text_register调用者的反汇编: Disassembly of section uprobe/gotls_text_register: 0000000000000000 <gotls_text_register>: 0: b7 02 00 00 01 00 00 00 r2 = 1 1: 85 10 00 00 ff ff ff ff call -1 2: b7 00 00 00 00 00 00 00 r0 = 0 3: 95 00 00 00 00 00 00 00 exit 编译器自动inline内联 当gotls_text 内函数较小时,则被clang inline,比如注释掉函数后面几行。 int gotls_text(struct pt_regs *ctx, bool is_register_abi) { s32 record_type, len; const char *str; void * record_type_ptr; void * len_ptr; record_type_ptr = (void *)go_get_argument(ctx, is_register_abi, 2); bpf_probe_read_kernel(&record_type, sizeof(record_type), (void *)&record_type_ptr); str = (void *)go_get_argument(ctx, is_register_abi, 3); len_ptr = (void *)go_get_argument(ctx, is_register_abi, 4); bpf_probe_read_kernel(&len, sizeof(len), (void *)&len_ptr); debug_bpf_printk("gotls_text record_type:%d\n", record_type); if (record_type != recordTypeApplicationData) { return 0; } // ... 以下代码都注视掉 return 0; } 从BPF 汇编可以看到,虽然gotls_text函数依然存在,但是gotls_text_register已经内联了他的内容。 llvm-objdump -d gotls_kern.o gotls_kern.o: file format elf64-bpf Disassembly of section .text: 0000000000000248 <gotls_text>: 73: bf 16 00 00 00 00 00 00 r6 = r1 74: 15 02 1e 00 00 00 00 00 if r2 == 0 goto +30 <LBB3_2> 75: b7 01 00 00 28 00 00 00 r1 = 40 76: bf 63 00 00 00 00 00 00 r3 = r6 77: 0f 13 00 00 00 00 00 00 r3 += r1 78: bf a1 00 00 00 00 00 00 r1 = r10 79: 07 01 00 00 f8 ff ff ff r1 += -8 80: b7 02 00 00 08 00 00 00 r2 = 8 81: 85 00 00 00 71 00 00 00 call 113 82: 79 a1 f8 ff 00 00 00 00 r1 = *(u64 *)(r10 - 8) ... Disassembly of section uprobe/gotls_text_register: 0000000000000000 <gotls_text_register>: 0: bf 16 00 00 00 00 00 00 r6 = r1 1: b7 01 00 00 28 00 00 00 r1 = 40 2: bf 63 00 00 00 00 00 00 r3 = r6 3: 0f 13 00 00 00 00 00 00 r3 += r1 4: bf a1 00 00 00 00 00 00 r1 = r10 5: 07 01 00 00 f8 ff ff ff r1 += -8 6: b7 02 00 00 08 00 00 00 r2 = 8 7: 85 00 00 00 71 00 00 00 call 113 8: 79 a1 f8 ff 00 00 00 00 r1 = *(u64 *)(r10 - 8) 9: 7b 1a e8 ff 00 00 00 00 *(u64 *)(r10 - 24) = r1 10: bf a1 00 00 00 00 00 00 r1 = r10 11: 07 01 00 00 f4 ff ff ff r1 += -12 12: bf a3 00 00 00 00 00 00 r3 = r10 ... 手动inline 可以通过__always_inline 关键字,手动让编译器对这块代码进行内联。 static __always_inline int gotls_text(struct pt_regs *ctx, bool is_register_abi) { s32 record_type, len; const char *str; // ... } 最终的反汇编情况,与 自动inline一样,并且可以支持更多行数的代码。 解决方法 经过前面的验证,解决办法就是__always_inline关键字启用编译器内联,跳过eBPF verifier的验证。 总结 经过笔者的验证,可以看出几个问题 ubuntu 20.04的5.4内核中,eBPF verifier未启用 对被调用子函数的参数类型判断。 ubuntu 21.04的5.11内核中,eBPF verifier启用了对被调用子函数的参数类型判断。 ubuntu 21.04的5.11内核中,btf_vmlinux文件有问题,大概编译内核时遇到错误了。 clang会对较短的子函数进行自动inline内联,具体的行数阈值我也不清楚。 编译器内联inline能很好的规避eBPF verifier的检查,建议默认开启。 其他 另外,笔者在解决这个问题时,也搜到几个类似的案例,虽然报错信息、排查思路不一样,但都是通过启用__always_inline来解决的,供参考。 Always Use always_inline In BPF Programs CI: ebpf unit tests fail with Ubuntu 20.04.3 kernel 5.11 #24051 [译] LLVM eBPF 汇编编程(2020) 2023-04-03 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
eCapture旁观者支持Golang tls/https加密明文捕获 前言 云原生生态中,golang语言开发的项目越来越多,例如Docker和K8s、etcd等。作为SRE、RD,偶尔需要在生产环境抓网络通讯包,用来分析排查故障。很多时候,都是tls/https加密协议,如何在不重启业务保留现场,不改为自定义CA证书的情况下,分析明文通讯内容呢? 适用场景 eCapture 0.5.0版本在2023年3月12日发布,支持了go语言编写的软件的tls/https明文抓包。只需要root权限,即可捕获并保存为pcapng格式,使用wireshark即可打开查看。 使用方法 gotls模块的e参数用来设定golang编译的可执行文件路径,可以通过ecapture gotls -h来查看使用说明。 bin/ecapture gotls -h NAME: gotls - capture golang tls/https text content without CA cert for ELF compile by Golang toolchain USAGE: ecapture gotls [flags] DESCRIPTION: use eBPF uprobe/TC to capture process event data and network data. also support pcap-NG format. ecapture gotls ecapture gotls --elfpath=/home/cfc4n/go_https_client --hex --pid=3423 ecapture gotls --elfpath=/home/cfc4n/go_https_client -l save.log --pid=3423 ecapture gotls -w save_android.pcapng -i wlan0 --port 443 --elfpath=/home/cfc4n/go_https_client OPTIONS: -e, --elfpath="" ELF path to binary built with Go toolchain. -h, --help[=false] help for gotls -i, --ifname="" (TC Classifier) Interface name on which the probe will be attached. --port=443 port number to capture, default:443. -w, --write="" write the raw packets to file as pcapng format. GLOBAL OPTIONS: -d, --debug[=false] enable debug logging --hex[=false] print byte strings as hex encoded strings -l, --log-file="" -l save the packets to file --nosearch[=false] no lib search -p, --pid=0 if pid is 0 then we target all pids -u, --uid=0 if uid is 0 then we target all users 举个例子 比如/path/elf_filepath_compiled_by_go是一个go写的web服务,并且开启了https加密,代码如下: package main import ( "crypto/tls" "fmt" "io" "net/http" "os" ) func main() { b, e := GetHttp("https://github.com") if e == nil { fmt.Printf("response body: %s\n\n", b) } else { fmt.Printf("error :%v", e) } } func GetHttp(url string) (body []byte, err error) { // 开启TLS密钥记录,用于跟eCpature捕获的密钥对比。 f, err := os.OpenFile("/tmp/go_master_secret.log", os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0600) if err != nil { panic(err) } defer f.Close() c := &http.Client{ Transport: &http.Transport{ TLSClientConfig: &tls.Config{InsecureSkipVerify: true, KeyLogWriter: f}, }} resp, e := c.Get(url) if e != nil { return nil, e } defer resp.Body.Close() body, err = io.ReadAll(resp.Body) return body, err } 可以使用如下命令,捕获明文通讯。 不用重启这个服务进程,也不需要做其他任何配置,就跟使用tcpdump一样。 ./ecapture gotls -e=/path/elf_filepath_compiled_by_go -w a.pcapng -i eth0 Wireshark打开网络包 下载地址 eCapture Github仓库:https://github.com/gojue/ecapture/releases/tag/v0.5.0 韩国GitHub镜像:https://ghproxy.com/https://github.com/gojue/ecapture/releases/tag/v0.5.0 以下内容,为功能实现原理,若你只是使用,可跳过。 技术原理 Probe参数获取 Golang的ABI不同于C,自定义了ABI机制。并且在go 1.17之前,使用的是栈方式传递调用参数;1.17以以后使用了寄存器方式传递调用参数。你可以阅读Register-based Go calling convention 了解更多知识。 这里有一张Golang的函数参数、返回值的寄存器传递布局,供参考。更多内容可以在线阅读《Go语言高级编程》中文版 eCapture的参数获取实现,可以阅读kern/go_argument.h Probe参数选择 笔者这里hook的是Golang源码目录下crypto/tls/common.go文件中的writeKeyLog函数。用来捕获tls的master secret的label类别、clientRandom、密钥值等。 Golang函数参数传递 有个需要注意的地方,比如writeKeyLog函数的第一个参数是string类型,第二、三个参数是slice类型。在Golang里,也都是一个结构体,如下代码: // runtime/string.go type stringStruct struct { str unsafe.Pointer len int } // runtime/slice.go type slice struct { array unsafe.Pointer len int cap int } 以string类型为例,在Go 的参数传递时,不光传递字符串的str unsafe.Pointer指针地址,也还会传递len int到寄存器上。所以,在获取参数时,需要注意参数所在位置。 eCapture的实现: lab_ptr = (void *)go_get_argument(ctx, is_register_abi, 2); lab_len_ptr = (void *)go_get_argument(ctx, is_register_abi, 3); cr_ptr = (void *)go_get_argument(ctx, is_register_abi, 4); cr_len_ptr = (void *)go_get_argument(ctx, is_register_abi, 5); secret_ptr = (void *)go_get_argument(ctx, is_register_abi, 7); secret_len_ptr = (void *)go_get_argument(ctx, is_register_abi, 8); bpf_probe_read_kernel(&lab_len, sizeof(lab_len), (void *)&lab_len_ptr); bpf_probe_read_kernel(&cr_len, sizeof(lab_len), (void *)&cr_len_ptr); bpf_probe_read_kernel(&secret_len, sizeof(lab_len), (void *)&secret_len_ptr); Golang uretprobe 在eCapture的文本模式中,需要在加密之前、解密之后拿到明文,对应的两个函数分别是crypto/tls.(*Conn).writeRecordLocked和 crypto/tls.(*Conn).Read。加密之前的获取只需要使用eBPF uprobe HOOK即可实现。而解密之后,则需要uretprobe,但Golang里,uretprobe的实现机制,会破坏他的堆栈,导致Golang程序进程崩溃。 这个问题,在iovisor/bcc社区也有讨论:BCC issue: Go crash with uretprobe #1320,包括火焰图、eBPF的领导者Brendan Gregg,对这个问题也没有太好的办法。Gianluca Borello给了间接的解决方案,相对来说还是比较繁琐的,也有一定的crash风险,有兴趣的同学可以去看看。 eCapture里,加密之前的uprobe 已经完成hook,实现https/tls的请求内容明文捕获。但解密后的内容,暂时无法实现。笔者也在尝试其他思路,比如找到Read返回值的调用函数,在哪里使用uprobe实现,但这点逻辑比较偏业务层,调用者比较多,不太方便过滤。如果你有更好的办法,也欢迎提出来。 扩展阅读 Hooking Go from Rust - Hitchhiker’s Guide to the Go-laxy BCC issue: Go crash with uretprobe #1320 Interface method calls with the Go register ABI eCapture旁观者官网 eCapture旁观者 Github仓库 2023-03-12 4 0
如何使用Delve和eBPF更快地调试Go程序 前言 此文章将解释如何使用 Delve 跟踪 Go 程序,以及 Delve 如何利用 eBPF 在后台优化效率和速度。Delve 的目标是为开发人员提供愉快且高效的 Go 调试体验。因此,本文重点介绍了我们如何优化函数跟踪子系统,以便您可以更快地检查程序并找到根本原因分析。Delve 的跟踪实现有两个不同的后端,一个是基于 ptrace 的,另一个使用 eBPF。如果您不熟悉任何这些术语,请不要担心,我会随着解释一起说明。 什么是程序跟踪? 跟踪是一种允许开发人员在执行时看到程序正在做什么的技术。与典型的调试技术相反,这种方法不需要直接用户交互。最知名的跟踪工具之一是 strace,它允许开发人员在执行期间查看程序的系统调用。 尽管上述的 strace 工具对于了解系统调用很有用,但 Delve trace 命令允许您洞察 "用户空间" 中 Go 程序的情况。这种 Delve 跟踪技术允许您跟踪程序中的任意函数,以便查看这些函数的输入和输出。 此外,您还可以使用此工具了解程序的控制流,而无需交互式调试会话的开销,因为它还会显示正在执行该函数的 Goroutine。对于高度并发的程序,这可能是获得程序执行洞察力的更快方法,而无需启动完整的交互式调试会话。 如何使用 Delve 跟踪 Go 程序 Delve 允许您通过调用 dlv trace 子命令来跟踪 Go 程序。该子命令接受一个正则表达式,并将执行您的程序,在与正则表达式匹配的每个函数上设置跟踪点,并实时显示结果。 以下是示例程序: package main import "fmt" func foo(x, y int) (z int) { fmt.Printf("x=%d, y=%d, z=%d\n", x, y, z) z = x + y return } func main() { x := 99 y := x * x z := foo(x, y) fmt.Printf("z=%d\n", z) } 程序跟踪将给出以下输出: $ dlv trace foo > goroutine(1): main.foo(99, 9801) x=99, y=9801, z=0 >> goroutine(1): => (9900) z=9900 Process 583475 has exited with status 0 如您所见,我们在正则表达式中提供了 foo,它在这种情况下与主包中同名的函数匹配。以>为前缀的输出表示被调用的函数,并显示调用函数的参数,而以>>为前缀的输出表示从函数返回并与其相关联的返回值。所有输入和输出行均以在该时刻执行的 Goroutine 作为前缀。 默认情况下,dlv trace 命令使用基于 ptrace 的后端,但添加 --ebpf 标志将启用基于 eBPF 的实验性后端。使用上面的示例,如果我们要像以下方式调用 trace 子命令: $ dlv trace –ebpf foo 我们将收到类似的输出。但是,背后发生的情况要大大不同并且更加高效。 ptrace低效率 默认情况下,Delve 会使用 ptrace 系统调用来实现跟踪功能。ptrace 是一个系统调用,允许程序观察和操纵同一台机器上的其他程序。实际上,在 Unix 系统上,Delve 使用这个 ptrace 功能来实现调试器提供的许多低级功能,例如读写内存、控制执行等。 虽然 ptrace 是一个有用和强大的机制,但它存在固有的效率低下。首先,ptrace 是一个系统调用,意味着我们必须跨越用户空间/内核空间边界,这增加了每次使用函数时的开销。这是由于我们必须调用 ptrace 的次数越多,开销就越大。考虑前面的示例,以下是使用 ptrace 实现跟踪的大致步骤概述: 使用 ptrace(PT_ATTACH) 启动程序并附加调试器。 使用 ptrace 在匹配所提供的正则表达式的每个函数处设置断点,并在被跟踪的进程的可执行内存中插入断点指令。 另外,在该函数的每个返回指令处设置断点。 再次使用 ptrace(PT_CONT) 继续程序。 此步骤可能涉及多次ptrace调用,因为我们需要读取函数入口的CPU寄存器、堆栈上的内存以及如果必须取消指针引用的堆上的内存。 再次使用ptrace(PT_CONT)继续程序。 在函数返回时遇到断点,通过读取变量,可能涉及到更多的ptrace调用,以读取寄存器和内存。 再次使用ptrace(PT_CONT)继续程序。 直到程序结束。 显然,函数的参数和返回值越多,每次停止就越昂贵。所有调试器花费在进行 ptrace 系统调用的时间,我们跟踪的程序都处于暂停状态,没有执行任何指令。从用户的角度来看,这使得程序的运行速度比原本要慢得多。现在,对于开发和调试来说,这也许不是什么大问题,但是时间是宝贵的,我们应该尽量快速地完成事情。程序在跟踪过程中的运行速度越快,你就能越快找到问题的根本原因。 现在的问题是,我们如何使其更好呢?在下一节中,我们将讨论新的基于 eBPF 的后端,以及它如何改进这种方法。 eBPF 为何比 ptrace 更快 一个最大的速度和效率改进是避免大量的系统调用开销。这是 eBPF 发挥作用的地方,因为我们可以在函数入口和出口设置 uprobes,并将小 eBPF 程序附加到它们上。Delve 使用 Cilium eBPF Go 库加载和与 eBPF 程序交互。 每次触发 probe 时,内核将调用我们的 eBPF 程序,然后在它完成后继续主程序。我们编写的小 eBPF 程序将处理函数入口和出口中列出的所有步骤,但不会有所有的系统调用上下文切换,因为程序直接在内核空间中执行。我们的 eBPF 程序可以通过 eBPF 环形缓冲区和映射数据结构与用户空间中的调试器通信,使 Delve 能够收集所需的所有信息。 这种方法的优点是,我们正在跟踪的程序需要暂停的时间大大减少。在触发 probe 时运行我们的 eBPF 程序比在函数入口和出口处调用多个系统调用要快得多。 使用eBPF调试与跟踪步骤 这里再概括一遍使用 eBPF 跟踪调试的流程: 启动程序并使用 ptrace(PT_ATTACH) 附加到进程上。 在内核中加载所有需要跟踪的函数的 uprobes。 使用 ptrace(PT_CONT) 继续执行程序。 在函数入口和出口触发 uprobes。每当 probe 被触发,内核部分将运行我们的 eBPF 程序,该程序获取函数的参数或返回值,并将其发送回用户空间。在用户空间中,从 eBPF 环形缓冲区读取函数参数和返回值。 重复此过程直到程序结束。 通过使用这种方法,Delve 可以比使用默认的 ptrace 实现更快地跟踪程序。现在,你可能会问,为什么不将这种方法默认使用?事实上,未来很有可能会成为默认方法。但目前,仍在进行开发,以改进这种基于 eBPF 的后端并确保它与基于 ptrace 的后端具有平衡性。然而,您仍然可以在执行 dlv trace 时使用 --ebpf 标志来使用它。 为了给出一个使用不同跟踪方法的程序的效率差异的大致数字,我测量了另一个程序的运行情况,如下所示: Program execution: 23.7µs With eBPF trace: 683.1µs With ptrace tracing: 2.3s 数字本身就是最好的证明! 为什么不使用uretprobe 如果您熟悉 eBPF、uprobes / uretprobes,您可能会问为什么我们对一切都使用 uprobes,而不是仅使用 uretprobes 捕获返回参数。关于此的解释相当复杂,但简短版本是,Go 运行时在执行 Go 程序过程中需要多次检查调用堆栈。当 uretprobes 附加到函数时,它们将该函数的返回地址覆盖在堆栈上。当 Go 运行时检查堆栈时,它会找到该函数的意外返回地址,最终会导致程序致命退出。为了解决这个问题,我们只需使用 uprobes,并利用 Delve 的能力检查程序的机器指令来在每个函数的返回指令处设置探测器。 Delve使用eBPF更快地调试Go代码 Delve的总体目标是帮助开发人员尽快地找到Go代码中的错误。为此,我们利用最新的方法和技术,并试图推动调试器可以完成的范围。 Delve在内部利用eBPF来最大化效率和速度。用户空间跟踪是任何工程师工具箱中的重要工具,我们的目标是使其高效易用。 原文: How debugging Go programs with Delve and eBPF is faster 2023-02-14 0 0
2022虎年总结 我已经有几年没写年度总结,去年春节回老家,完全没时间。前年春节工作特别忙,忙到春节七天假,都加班了六天班。 今年还好,不回老家,而且春节期间不加班。挤出点时间,整理复盘一下。 年度总结这件事,我跟别人的不同,我更喜欢以春节作为分割,而不是阳历的元旦。 毕竟春节的假期太长,仪式感更强,更让我认为一年的起始是春节后的正月和临近春节的腊月。 上海疫情爆发 今年年初,刚上班没多久,上海就爆发了疫情,全市封闭了将近3个月。哪里都不能去。 2002年非典 人生中上次遇到这种呼吸道传播病毒还是2002年的非典,那时我刚上高中,也几乎是禁止出门,全市的网吧都关了。我那时刚成为网瘾少年,天天想着我的《热血传奇》游戏的升级、打装备,忍受不了封禁,跟着小伙伴到处找私底下偷开的黑网吧。 20年过去了,照片中的网瘾少年团已经变成油腻大叔了。我对于非典时的印象,也仅限于此,只记得偷偷全城找黑网吧。 2022年3月的新冠 20年后,新冠分支奥密克戎再次让我体验到长期封禁。 确切的说2020年春节期间也经历也,不过时间蛮短,没什么太大感觉。而且重点是武汉,上海这边受影响较小,周围也没人确诊,相应的应急响应也没什么。 今年3月份时,那可不一样了,周围病例不断爆出,距离也越来越近,紧接着就是小区里有人确诊。社区组织的应急也大不一样,给大家发蔬菜、食品、生活用品等。 困难时刻,人们都很团结,每次政府发的物资到达时,都需要志愿者派送到户。作为社会良好青壮年,必须站出来,帮助大家,一起度过难关。 在这期间也看到很多志愿者感染中招,我自己内心也害怕,怕中招,怕家里唯一的顶梁柱倒下。为此,还跟社区志愿者们设计规划了配送方案,最大化的减少人的接触,减少感染。 孩子牙疼就医 1、2个月下来,顺利过关,我没感染。但家庭成员出了点小插曲。儿子平时刷牙不认真,牙齿被蛀,在封禁期间,疼了起来。应该是4月中左右,有大医院的急诊对外开放。儿子的牙疼在家扛了两天,依旧不减缓解。第三天早晨,想办法申请到出门证,带他去医院看急诊。从家到医院很近1公里之内,我们俩是走着过去的。一路上所有商户都是关门,有的还没蓝色铁皮围起来。路过地铁站,看到地铁站也是拉着铁门上了锁。 这现象,估计是这地铁站从建好到现在第一次出现这状况。 虽然是中午,阳光也很好,但依旧觉得冷清,压抑。 我让儿子在地铁站门口留影,让他长大后记得这次疫情,记得疫情对城市的伤害。 小区抗疫、志愿者 疫情结束后一个多月吧,接到社区的电话,通知我来领取志愿者证书。对我来说有点意外,都忘记这事了。 有时候,精神上鼓励要比物质上更让人印象深刻。 2023年1月的新冠 2022年12月,全国放开后,疫情很快传播开来。我听父母说,他们在老家很早就感染了,农村里生活的大部分都是老头、老太太,发烧、流鼻涕的人一堆一堆的,身体素质好的就扛过去,不好的就挂了。大家都没有抗原测试,不知道自己是不是感染新冠了。 在上海,我也没躲过,老婆先中招,三天后我跟儿子中招。前两天浑身酸痛,第三天发烧38.5度,第四天就不发烧了。接着就是嗓子疼、咳嗽、流鼻涕。一周后,抗原转阴,除了干咳没有其他症状。 但是,康复至今40多天了,我依旧有一些干咳,尤其是呼吸到冷空气、热空气,干咳的特别厉害。 相比其他同龄人,我觉得我恢复的很慢,也不知道是不是当初发烧时间太短的原因,没能很好的让白细胞杀死病毒。 也或许是今年熬夜太多了,身体素质下降太厉害。 但给我的警示是身体是革命的本钱。 这道理一直都懂,但从来没有重视过。 明年,应该要有点行动了。 除此之外,觉得今年我老了很多,身体机能变化比较大,比如胡须长的特别快,往年都是两三天一刮,现在一天不刮,就很长。四肢反应也变得更迟钝,比如打游戏时,眼睛意识到危险,大脑判断要【闪现】了,可手的控制总是跟不上。 开源项目 也就是在疫情封禁这段时间,零散的空闲时间特别多,节省了上班通勤的时间,也不需要周末送娃上培训班,在家待着也无聊,想着找点事情做做,不能荒废了。 正好最近在公司的主机安全项目中,在推广eBPF技术,对于新技术的难点、风险还不是特别熟悉,有必要也练习练习。 天时地利,就搞了eCapture旁观者这个项目,本来是无心插柳,没曾想到前期就收到很多人的关注,而且Cilium.io的liz rice 也关注过。 同行的支持,就是最大的动力来源,激励着我不停的优化项目。 项目现状 从3月底到现在的 23年1月,一共10个月的时间,累计了5500星,涨星速度确实是GitHub社区中特别特别快的了。根据eCapture在GitHub的数据,我做了一个eCapture代码仓库的统计 关注的用户主要来自国内知名互联网公司与高校 腾讯Tencent 2.9% 字节跳动Bytedance 2.2% 阿里巴巴alibaba 2.1% 美团meituan 0.7% 清华大学Tsinghua University 0.7% 微软Microsoft 0.6% 百度Baidu 0.6% 虾皮Shopee 0.6% 长亭chaitin 0.6% kong 0.6% 看着关注着的排序,好像跟国内科技公司的规模有一定关系。 疫情解封之后,空闲时间也不多了,为了快速解决用户的反馈,不得不牺牲睡眠时间来更新。我统计了项目的代码提交时间,可以看到大多数在周五、周六、周日的深夜21点至凌晨2点。熬夜有点多,头发掉了一把又一把。 回顾我的GitHub开源贡献,今年是最多的一年,900多个贡献。明年的话,估计很难有这个拼劲了: 行业 今年在eBPF技术上投入了很多时间,包括技术分享、咨询转发等。有幸结识很多行业技术大牛,百度的狄卫华、携程的赵亚楠、深信服的许庆伟、蚂蚁的张绪峰、Google的赵亚雄、以及腾讯、华为、字节等等大佬。为此,开阔了眼界,了解了行业在eBPF应用上的状况、困难、趋势,也跟他们学到了很多经验。 除了行业大牛,也有幸认识了学术界陈莉君老师、张国强老师。陈莉君老师是西安邮电大学的教授,在20多年前就在做Linux内核相关技术,我那时估计还在读初中。现在,也算是近距离接触了偶像。 张国强老师是阅码场的创始人,最近一直在推广Linux内核知识,他几乎认识国内IT行业所有Linux内核大佬,有很大的影响力。 在陈莉君老师的主导下,首届中国eBPF大会在11月举行,我有幸做了一篇分享eCapture旁观者:无CA证书HTTPS抓包软件实现 ,希望能将eBPF技术推广大家,顺便推广一下eCapture,帮助大家解决技术问题,提高生产力。 工作 年初时,部门里有很多小变化,而我对这种变化逐渐有种无力感,掌控不了、改变不了,唯一能做的,就是尽人事听天命,做好自己该做的本职工作。 在IDC的攻防领域,我认为比较突出的就是eBPF技术的引入,并成功大面积应用,取得了比较好的效果。其实,我在2020年年末的述职中,就开始规划eBPF应用,在2021年安排给别人去调研建设,但整个方向进度比较慢,没拿到让我满意的结果。 2021年年末我直接自己来跟进。 在HIDS版本规划中,有协同部门对这个技术不是很认可,不理解我的技术规划。为此,我也做了一些eBPF攻防科普类的文章,让大家对这个技术的优势、危害有着更深入的理解,以便更好得统一建设思想。在今年年初的时候,与团队小伙伴一起,写了Linux中基于eBPF的恶意利用与检测机制 ,以及Cilium、Tetragon等产品的技术原理分析,逐步对齐认知,统一目标与思路。 直到今年年末,我司服务器只要内核版本符合要求,那么都已经具备了eBPF的观测能力。 在以往的内核数据捕获场景,以kpatch、kernel module为主,但缺点也很明显,开发周期长、稳定性差、业务影响大,项目延期风险高。而eBPF技术在安全可观测方向成为了较好的代替品,最初eBPF技术实现的功能是作为原kpatch技术的兜底策略,但最后因为其优势,恰恰成为核心、主流的安全观察能力。并且快速迭代支撑了云原生场景的安全风险。 也成为协同团队称赞的安全切面能力。 五周年 不知不觉,来美团已经五年了,也是从也十几年中,待得最久收获最多的公司。公司里有句话我特别认可,就是「每天前进三十公里」。有得人会认为这是PUA,但如果你视野放开,从人生角度来理解这句话,就是在逆境中,不管环境如何,都不放弃前进,哪怕每天走一点点。 感恩 个人的努力固然重要,也要考虑历史的进程。职场也是,有些时候,并不是我很强,而是我遇到了好的机遇,站在好的平台上,遇到了优秀的合作伙伴。在这五年,感谢相处的每一位同事。 这两年逐渐觉得,人到中年,少了年轻时的意气、气魄,对于事与物,开始认命。正确认识自己很平庸,认识到家庭环境造就了后天视野、性格、能力。很清晰的看到自己的天花板就在那里。很多事情我无能为力,只能躺平,却不甘心躺平。唯一能做的,就是尽人事听天命。当下已经很知足了。 三十而立,四十而不惑,说得真准。 2023-01-19 3 0
eCapture支持boringssl TLS 1.3明文数据包捕获 前言 一个多月前,有网友反馈Android上,TLS 1.2明文网络包正常捕获,而1.3的密钥无法捕获。笔者得知这个问题后,花了几个周末时间修复、添加了这个功能。在这期间,笔者工作繁忙以及感染新冠,整个功能拖了1个月 才完成。精力与体力,支撑起来越来越吃力。搞开源,全是靠爱发电,实属不易。 值得庆幸的是,收获了5400星的关注,这也是对笔者最大的认可。 这个周末,发布了eCapture 0.4.11版本,支持boringssl的TLS 1.3的密钥捕获,支持明文解密pcapng数据包存储。 有需要的朋友可以下载使用。 其次,这次支持boringssl的TLS 1.3明文数据包捕获,还遇到了一些技术性问题,在这里也一并分享给大家。 boringssl与openssl的差异 Google开源了boringssl这个SSL加密类库,是基于openssl的增强版。使用C++封装C的类库,在结构体内存布局上,与openssl有一些差异。而且TLS通信密钥捕获的Hook函数上,与openssl有差异。在openssl中,只需要HOOK SSL_write、SSL_read 两个函数,读取参数中SSL结构体的内存布局,定位相关SSL密钥存储地址,即可读取密钥。 而在boringssl中,在调用SSL_write函数时,这个TLS链接可能还没建立好。需要寻找更合适的HOOK函数,笔者在实现时,做了大量的测试、调试工作,最终在SSL_do_handshake 跟SSL_in_init两个函数中,选择了SSL_in_init,进行密钥的读取。 实际使用时,仍会有s3_st为空、s3->hs地址为空的情况,以及TLS未建立、正在握手中,密钥不完整等情况,笔者最后选择将所有eBPF捕获内存数据,通过bpf map发送给用户空间程序,再在用户空间判断密钥,进行过滤,曲折实现。 android boringssl与普通boringssl的差异 说到boringssl,刚接触的人可能搞不清楚版本,认为google在github上开源的https://github.com/google/boringssl.git 项目就是,直接用这个测试。实际上,Android系统使用的boringssl并不是这个仓库,而是 https://android.googlesource.com/platform/external/boringssl ,二者的差异,对ecapture来说,其结构体的内存布局不一样,属性的偏移地址不一样。 笔者就被这个问题困扰很久,为什么在PC上,使用boringssl测试成功,而在android手机上却无法工作。手机、模拟器、重刷ROM,都是不行。。一直到笔者把Android上类库源码重新检出,仔细测试boringssl的代码,才发现这个问题。 Google在这块文档上,并没有说明,实在是让人无奈。 Android boringssl偏移地址 你可以按照自己Android使用的boringssl版本,自己重新生成boringssl的偏移量,使用命令如下: rm -f kern/boringssl_1_1_1_kern.c bash utils/boringssl_offset_1.1.1.sh 普通boringssl类库偏移地址 在Linux上,很多类库都是支持改用openssl为boringssl的,eCapture也提供了自助生成新偏移量的脚本,它比Android boringssl命令后面多个参数1,生成命令如下: rm -f kern/boringssl_1_1_1_kern.c bash utils/boringssl_offset_1.1.1.sh 1 偏移地址差异diff Linux上普通boringssl类库的eCapture使用 以curl类库为例,启用boringssl的编译命令如下: git clone https://github.com/curl/curl.git autoreconf -fi LIBS=-lpthread ./configure --with-ssl=/home/cfc4n/project/boringssl --enable-versioned-symbols make sudo make install sudo ldconfig 如果你在编译启用boringssl的curl遇到错误时,可以参考笔者的经历:alt-svc build error on ubuntu 18.04: missing struct tm #9989 eCapture捕获命令: bin/ecapture tls --libssl=/usr/local/lib/libcurl.so.4 --ssl_version="boringssl 1.1.1" -w a.pcapng -i ens33 功能演示 演示环境 系统:macOS 模拟器:Android Studio Dolphin | 2021.3.1 Patch 1 镜像ROM:Pixel 3a API 33 x86_64 内核:Linux localhost 5.10.66-android12-9-00041-gfa9c9074531e-ab7914766 #1 SMP PREEMPT Fri Nov 12 11:36:25 UTC 2021 x86_64 测试APP:百度手机助手9.5.8.1 演示命令 启动eCapture emulator64_x86_64_arm64:/data/local/tmp # ./ecapture tls -w aa.pcapng -i wlan0 tls_2023/01/08 14:02:54 ECAPTURE :: ecapture Version : androidgki_x86_64:0.4.11-20230107-59a76e0:5.4.0-131-generic tls_2023/01/08 14:02:54 ECAPTURE :: Pid Info : 26496 tls_2023/01/08 14:02:54 ECAPTURE :: Kernel Info : 5.10.66 tls_2023/01/08 14:02:54 EBPFProbeOPENSSL module initialization tls_2023/01/08 14:02:54 EBPFProbeOPENSSL Module.Run() tls_2023/01/08 14:02:54 EBPFProbeOPENSSL TC MODEL tls_2023/01/08 14:02:54 EBPFProbeOPENSSL OpenSSL/BoringSSL version not found, used default version :android_default tls_2023/01/08 14:02:54 EBPFProbeOPENSSL HOOK type:2, binrayPath:/apex/com.android.conscrypt/lib64/libssl.so tls_2023/01/08 14:02:54 EBPFProbeOPENSSL Ifname:wlan0, Ifindex:16, Port:443, Pcapng filepath:/data/local/tmp/aa.pcapng tls_2023/01/08 14:02:54 EBPFProbeOPENSSL Hook masterKey function:SSL_in_init tls_2023/01/08 14:02:54 EBPFProbeOPENSSL target all process. tls_2023/01/08 14:02:54 EBPFProbeOPENSSL target all users. tls_2023/01/08 14:02:54 EBPFProbeOPENSSL BPF bytecode filename:user/bytecode/boringssl_1_1_1_kern.o tls_2023/01/08 14:02:54 EBPFProbeOPENSSL module started successfully. tls_2023/01/08 14:02:54 ECAPTURE :: start 1 modules 794b86e16f4f17d1c90fee21e72d56aa92c1f4deb5f7494c0eab9598895d48ff to file success, 176 bytes tls_2023/01/08 14:05:06 TLS1_3_VERSION: save CLIENT_RANDOM 7fbea9bdcc818fb80cc04c1d0ad0c0c870cf97267678d42fa6026dd92cfa6b20 to file success, 778 bytes tls_2023/01/08 14:05:43 TLS1_2_VERSION: save CLIENT_RANDOM 50a103ecbdd2a84c707eeda3262381ab8a427993c2407ecd537ae2414978f821 to file success, 176 bytes 捕获的TLS 1.3密钥 130|emulator64_x86_64_arm64:/data/local/tmp # cat ecapture_masterkey.log CLIENT_HANDSHAKE_TRAFFIC_SECRET 7fbea9bdcc818fb80cc04c1d0ad0c0c870cf97267678d42fa6026dd92cfa6b20 e34deeab543a3cd63e8009737a4cf4188e9f5c2b6588e5b72874055818004c6c CLIENT_TRAFFIC_SECRET_0 7fbea9bdcc818fb80cc04c1d0ad0c0c870cf97267678d42fa6026dd92cfa6b20 82857c8eb07c88b6c56c9d38759c23cc6d14d9dd23a37bb17fa41e5913cf211e SERVER_HANDSHAKE_TRAFFIC_SECRET 7fbea9bdcc818fb80cc04c1d0ad0c0c870cf97267678d42fa6026dd92cfa6b20 bef4cde1643c47d5971f226e9d637155d55087184f0f75963db792d71afc56c0 SERVER_TRAFFIC_SECRET_0 7fbea9bdcc818fb80cc04c1d0ad0c0c870cf97267678d42fa6026dd92cfa6b20 c949827f3150a3c4c4589478caa1b2987493dd17dfe008aa26ce9b881d9af21b EXPORTER_SECRET 7fbea9bdcc818fb80cc04c1d0ad0c0c870cf97267678d42fa6026dd92cfa6b20 0b0017b44c8ac50126a7aeb03494d1454f7f4ec560fbaba144198eb74ddab280 CLIENT_RANDOM 50a103ecbdd2a84c707eeda3262381ab8a427993c2407ecd537ae2414978f821 e1a6d3edd5f4e817aea86b2d15d3d21a23c8d2d60770c36819303e25873f30089d555d447ab29b8208b8c0ecd230405a 演示截图: eCapture链接地址 eCapture v0.4.11 下载地址 eCapture 官网 eCapture 代码仓库 未来展望: eCapture功能趋于稳定,以及笔者经历有限,未来更新频率可能会降低,欢迎广大网友参与其中,一起为开源做贡献,一起为你点赞。 2023-01-08 0 0
使用 eBPF-TC 在内核中安全地改变数据包 介绍 eBPF 允许你在 OS 内核中安全地运行沙箱程序,用于安全性和网络等功能,而无需修改内核源代码或加载内核模块。eBPF-TC 具有牢固的数据包处理能力,并支持入口和出口操作,而且性能很高。 这是我使用 TC-eBPF 构建插件 TPROXY 截流防火墙 (IFW) 来截取并将数据包发送到我的目标应用程序 OpenZiti Edge Routers 的方法。你可以使用类似的 eBPF-TC 实现来截取要发送到你特定可观察性、安全性或网络应用程序的数据包。代码和 README.md 在这里:GitHub - r-caamano/ebpf-tproxy-splicer。流程图和数据流图在本文末尾。 项目背景 - 将数据包发送到 OpenZiti 端点 OpenZiti(开源零信任网络平台)能够在完整网络覆盖网络中建立私有连接。Ziti 端点作为代码嵌入在应用程序中(通过 Ziti SDK),并作为 OS 代理、守护程序、容器或虚拟机部署。你可以在你的私有网络中启动任意数量的 Ziti 路由器 - 如果你部署了两个路由器,它看起来像这样: Ziti Edge 路由器通常在 Linux 上使用,默认使用 iptables 将传入的感兴趣流量映射到服务侦听端口,使用 IP 表 Tproxy 目标。如果你正在运行 Ubuntu 和 FWD,这就很好用了。然而,还有许多带有关键差异的 Linux 发行版。因此,我使用 TC-eBPF 为支持 eBPF 的 Linux 发行版构建了更通用的选项,使这些发行版能够截取感兴趣的流量。eBPF 特性集还提供了 iptables/nftables 原生不支持的附加数据包过滤和操作。 TC-eBPF IFW - tproxy 目标条目 因此,在开始构建 eBPF IFW 之前,我需要反向工程 OpenZiti 边缘路由器如何将服务本地转换为 iptables 规则 - 基于路由器从 OpenZiti Controller 学习的服务,需要添加/删除哪些 TPROXY 目标语句。以下是通过 iptables 创建 tproxy 目标条目所使用的信息。 IP Destination Prefix: Dotted Decimal IP/mask bit-length TCP/UDP port range in the format Decimal Low_Port:High_Port Protocol: TCP/UDP TPROXY Listening port: Decimal port TC-eBPF IFW - 插入和映射 现在,有了这些信息,我可以开始考虑我将使用的 eBPF map 类型和结构,以便在用户空间映射工具和 IFW 之间进行通信,以动态更新规则。 由于我希望我的程序的功能与 ufw/iptables 类似,所以我选择了 TC-eBPF 作为我的插入点,因为它在接口级别的组合(在转发到 Linux IP 栈之前丢弃数据包的能力)和当前可用的 sk 帮助程序(用于 socket 查找/拼接)。 为了检查传入的数据包是否与 Ziti 网络管理员创建的截获策略(Ziti 特别定义了流量,而不是默认截获所有流量)匹配,我需要一个支持结构体键类型的固定映射。eBPF 哈希映射允许使用结构体作为键,这使我能够自定义查找,并随着用例的发展添加或删除标准。使用固定映射允许多个 ebpf 程序的副本运行(每个入站接口上运行一个),并共享由映射工具/Ziti 更新的映射。 下图描述了我选择的初始映射定义: struct { __uint(type, BPF_MAP_TYPE_ARRAY); __uint(id, BPF_MAP_ID_IFINDEX_IP); __uint(key_size, sizeof(uint32_t)); __uint(value_size, sizeof(struct ifindex_ip4)); __uint(max_entries, 50); __uint(pinning, LIBBPF_PIN_BY_NAME); } ifindex_ip_map SEC(".maps"); The initial key I chose a struct of the following form: struct tproxy_key { __u32 dst_ip; __u16 prefix_len; __u16 protocol; } 这些数据结构允许基于目的 IP 前缀、CIDR 长度和 IP 协议(tcp/udp)进行查找,这些都可以从传入的数据包中推断出来。 对于值,我使用了以下格式的结构体: struct tproxy_tuple { __u16 index_len; /*tracks the number of entries in the index_table*/ __u16 index_table[MAX_INDEX_ENTRIES]; /* Array used as index table which points to Struct *tproxy_port_mapping in the port_maping array with each poulated index representing a udp or tcp tproxy mapping in the port_mapping */ struct tproxy_port_mapping port_mapping[MAX_TABLE_SIZE]; /* Array to store unique tproxy mappings with each index match the low_port of the struct tproxy_port_mapping{ __u16 low_port; __u16 high_port; __u16 tproxy_port; __u32 tproxy_ip; } */ } 由于 OpenZiti 基于 IP 的服务策略可以在任何粒度级别定义,包括网络 CIDR 块,所以我不希望为包含在块中的每个主机地址生成哈希条目。因此,我实现了一种最长匹配查找算法,该算法逐次扩大掩码检查,以查看传入的 IP 元组是否直接匹配主机地址,或者是否在与 tproxy_key 中的 ip_dest/prefix_len 字段匹配的 CIDR 块范围内,以及匹配 IP 运输协议(TCP 或 UDP)。 struct tproxy_tuple *tproxy __u32 exponent=24; /* unsugend integer used to calulate prefix matches */ __u32 mask = 0xffffffff; /* starting mask value used in prefix match calculation */ __u16 maxlen = 32; /* max number ip ipv4 prefixes */ for (__u16 count = 0;count <= maxlen; count++){ struct tproxy_key key = {(tuple->ipv4.daddr & mask), maxlen-count,protocol} if ((tproxy = get_tproxy(key))){ { Redacted for brevity} /*algorithm used to calucate mask while traversing each octet.*/ if(mask == 0x00ffffff){ exponent=16; } if(mask == 0x0000ffff){ exponent=8; } if(mask == 0x000000ff){ exponent=0; } if(mask == 0x00000080){ return TC_ACT_SHOT; } if((mask >= 0x80ffffff) && (exponent >= 24)){ mask = mask - (1 << exponent); }else if((mask >= 0x0080ffff) && (exponent >= 16)){ mask = mask - (1 << exponent); }else if((mask >= 0x000080ff) && (exponent >= 8)){ mask = mask - (1 << exponent); }else if((mask >= 0x00000080) && (exponent >= 0)){ mask = mask - (1 << exponent); } exponent++; } 此外,由于 Ziti 最终用户可以将任意数量的端口范围与协议基础上的目标关联,在这第一次尝试中,我不希望为每个端口创建哈希映射条目,因为可能存在大的端口范围,例如 1-65535。相反,我在索引表中创建了一个条目,其中每个条目都指向 port_mapping 表中已填充的数组索引,其中索引是映射规则的 low_port 值。这将查找端口匹配的搜索限制为仅已填充的端口范围条目,而不是直接在端口映射表中的顺序索引搜索。我计划测试为每个成员端口创建哈希映射条目与范围起始的索引查找的性能和资源限制。下面是一个用于基于传入的 tuple->ipv4.dport 查找匹配的代码片段。 for (int index = 0; index < max_entries; index++) { /* set port_key equal to the port value stored at current Index */ int port_key = tproxy->index_table[index]; /* check if tuple destination port is greater than low port and lower than high port at mapping[port_key] if matched get associated tproxy port and attempt to find listening socket if successfull jump to assign: */ if ((bpf_ntohs(tuple->ipv4.dport) >= bpf_ntohs(tproxy->port_mapping[port_key].low_port)) && (bpf_ntohs(tuple->ipv4.dport) <=bpf_ntohs(tproxy>port_mapping[port_key].high_port))){ If(local){ /* if tuple->daddr == router’s ip then forward to stack */ return TC_ACT_OK; } /* construct tuple to used to lookup TPROXY sk */ sockcheck.ipv4.daddr = tproxy->port_mapping[port_key].tproxy_ip; sockcheck.ipv4.dport = tproxy->port_mapping[port_key].tproxy_port; /* look up sk based on protocol in map key */ if(protocol == 6){ sk = bpf_skc_lookup_tcp(skb, &sockcheck, sizeof(sockcheck.ipv4), BPF_F_CURRENT_NETNS, 0); }else{ sk = bpf_sk_lookup_udp(skb, &sockcheck, sizeof(sockcheck.ipv4), BPF_F_CURRENT_NETNS, 0); } if(!sk){ return TC_ACT_SHOT; } if((protocol == IPPROTO_TCP) && (sk->state != BPF_TCP_LISTEN)){ bpf_sk_release(sk); return TC_ACT_SHOT; } goto assign; } } assign: /*attempt to splice the skb to the tproxy or local socket*/ ret = bpf_sk_assign(skb, sk, 0); /*release sk*/ if(ret == 0){ //if succedded forward to the stack return TC_ACT_OK; } /*else drop packet if not running on loopback*/ if(skb->ingress_ifindex == 1){ return TC_ACT_OK; }else{ return TC_ACT_SHOT; } } TC-eBPF – 有状态防火墙 由于我希望 eBPF 执行有状态防火墙的功能(至少在对于从该主机接收的已确认数据包必须有一个活动的出站会话到主机的程度上),我需要考虑如何使程序管理 UDP 和 TCP 的会话状态。最初我认为这可能很复杂。然而,在使用用于拼接套接字的 ebpf 帮助程序时,我意识到可以使用相同的帮助程序来检查是否已发起了出站套接字。在传入数据包元组与现有出站会话匹配的情况下,拼接传入的 skb 到现有的 sk 中,同时执行与程序已经为 openziti 服务 tproxy sk(s) 执行的相同查找。下面的代码摘录显示了我用于 TCP 的基本状态检查代码: /* if tcp based tuple implement statefull inspection to see if they were initiated by the local OS if not pass on to tproxy logic to determin if theopenziti router has tproxy intercepts defined for the flow */ sk = bpf_skc_lookup_tcp(skb, tuple, tuple_len,BPF_F_CURRENT_NETNS, 0); if(sk){ if (sk->state != BPF_TCP_LISTEN){ goto assign; } bpf_sk_release(sk); } assign: /*attempt to splice the skb to the tproxy or local socket*/ ret = bpf_sk_assign(skb, sk, 0); /*release sk*/ if(ret == 0){ //if succeeded forward to the stack return TC_ACT_OK; } /*else drop packet if not running on loopback*/ if(skb->ingress_ifindex == 1){ return TC_ACT_OK; }else{ return TC_ACT_SHOT; } } TC-eBPF IFW – 入站 SSH 将事情进一步,我希望默认情况下允许 ssh 入站,但是希望将 ssh 限制为 eBPF 程序附加的外部接口的 IP 地址。但是,在 TC 级别运行 eBPF 时,您无法访问接口的 IP 地址。当使用标准端口在 Ziti 上使用 ssh 服务时,具有此功能是必不可少的,因为服务还将具有目标端口 TCP/22。因此,如果无法分辨路由器的 IP,则程序将无法知道是否应转发到 Linux 栈或 Ziti Tproxy 服务端口。为了解决此问题,我最初允许 ssh 传递到任何地址,然后让我的用户空间 map_update 工具通过数组映射告诉 eBPF 其附加的 IP /* Array Map */ struct { __uint(type, BPF_MAP_TYPE_ARRAY); __uint(id, BPF_MAP_ID_IFINDEX_IP); __uint(key_size, sizeof(uint32_t)); __uint(value_size, sizeof(struct ifindex_ip4)); __uint(max_entries, 50); __uint(pinning, LIBBPF_PIN_BY_NAME); } ifindex_ip_map SEC(".maps"); /*value to ifindex_ip_map*/ struct ifindex_ip4 { __u32 ipaddr; char ifname[IF_NAMESIZE]; }; static inline struct ifindex_ip4 *get_local_ip4(__u32 key){ struct ifindex_ip4 *ifip4; ifip4 = bpf_map_lookup_elem(&ifindex_ip_map, &key); return ifip4; } /*look up attached interface IP address*/ struct ifindex_ip4 *local_ip4 = get_local_ip4(skb->ingress_ifindex); /* if ip of attached interface found in map only allow ssh to that IP */ if(((!local_ip4) || (!local_ip4->ipaddr)) || (tuple->ipv4.daddr == local_ip4->ipaddr)){ if((bpf_ntohs(tuple->ipv4.dport) == 22)){ return TC_ACT_OK; } } UDP 遵循相同的基本原则,但我需要特别为 DHCP 做一个例外,因为它的操作方式使状态检查困难(像 UFW 这样的其他有状态 FW 也做出了相同的入站例外) /* forward DHCP messages to local system */ if(udp && (bpf_ntohs(tuple->ipv4.sport) == 67) && (bpf_ntohs(tuple->ipv4.dport) == 68)){ return TC_ACT_OK; } eBPF 用户空间集成 我创建了两个用户空间应用 map_update.c 和 map_delete_elem.c,它们更新了前面描述的固定映射。Map update 创建填充带有附加的 eBPF 程序的接口的 IP 地址/名称,并使用以下模式插入 nf_tproxy_map 的规则: Usage:./map_update <ip dest address or prefix> <prefix length> <low_port> <high_port> <tproxy_port> <protocol id> sudo ./map_update 172.16.240.0 24 5060 5060 58997 17 Usage: ./map_delete_elem <ip dest address or prefix> <prefix len> <low_port> <protocol id> sudo ./map_delete_elem 172.16.240.0 24 5060 17 将这些用户空间应用编译并放置在 ziti-edge 路由器上的可执行路径中,然后在 https://github.com/openziti/edge 项目中修改 tunnel/intercept/tproxy/tproxy_linux.go。修改允许 OpenZiti edge-router 动态地调用系统,更新由 IFW 使用的固定 BPF nt_trproxy_map,以进行转发决策。当对任何服务进行添加或删除操作时,都会动态地调用系统调用。可以在以下存储库中访问修改后的边缘:https://github.com/r-caamano/edge/tree/v0.26.10 以下是从修改后的 edge-router 代码生成的日志输出,当初始学习 Ziti 控制器上的服务时。您可以看到,它调用 map-update 用户空间程序的系统调用,将学到的服务作为 bpf 映射的哈希映射条目添加。 [ 3.913] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: dst_ip=172.16.1.0/28 [ 3.913] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: protocol=tcp [ 3.914] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: low port=30000, highport=40000 [ 3.914] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: tproxy_ip=127.0.0.1 [ 3.914] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: tproxy_port=33401 [ 3.915] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: dst_prefix=172.16.1.0 [ 3.917] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: Updated ebpf zt_tproxy_map: map_update 172.16.1.0 28 30000 40000 33401 6 Adding TCP mapping lookup success [ 3.918] INFO edge/tunnel/intercept.(*ServiceListener).HandleServicesChange: {service=[TESTSVC]} adding service [ 3.918] INFO edge/tunnel/intercept.(*ServiceListener).addService: starting tunnel for newly available service TESTSVC [ 3.919] INFO edge/tunnel/intercept/tproxy.(*interceptor).newTproxy: tproxy listening on udp:127.0.0.1:39250, remoteAddr: <nil> [ 3.919] INFO edge/tunnel/dns.(*resolver).AddHostname: adding test.dns.ziti = 100.64.0.2 to resolver [ 3.920] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: dst_ip=100.64.0.2/32 [ 3.920] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: protocol=udp [ 3.921] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: low port=5000, highport=10000 [ 3.921] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: tproxy_ip=127.0.0.1 [ 3.921] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: tproxy_port=39250 [ 3.922] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: dst_prefix=100.64.0.2 [ 3.924] INFO edge/tunnel/intercept/tproxy.(*tProxy).addInterceptAddr: Updated ebpf zt_tproxy_map: map_update 100.64.0.2 32 5000 10000 39250 17 Adding UDP mapping TC-eBPF IFW – 数据流图 总结: 本文描述了我如何利用 eBPF-TC 构建插件 TPROXY IFW,将流量引导到我的目标应用程序。希望您发现我分享的经验有用。很想听听其他 eBPF 开发人员如何使用这种强大的功能。 原文地址: [Using eBPF-TC to securely mangle packets in the kernel, and pass them to my secure networking application]( 2023-01-07 0 0
使用eBPF实现基于DWARF的堆栈遍历 译者注: 原文为https://www.parca.dev/的Javier Honduvilla Coto,文章地址DWARF-based Stack Walking Using eBPF,副标题Deep dive into the new DWARF-based stack unwinder using eBPF,即深度解析使用eBPF基于DWARF的栈展开。 文章为机翻,以及人工矫正。译者水平有限,有疑问请看原文。 parca项目介绍 parca是一款基于eBPF技术实现的CPU、内存观测产品。可以持续分析 CPU 和内存使用情况产品,细化到行号和整个时间。 节省基础架构成本、提高性能并提高可靠性。 GitHub地址:https://github.com/parca-dev/parca 原文: 采样CPU分析器周期性地获取目标进程的堆栈,比如用C、C++、Rust等语言编写的进程,可能比大家想象的要复杂一些。最普遍的问题是因为缺少frame pointer帧指针。 我们已经开发了一个改进的堆栈遍历器 :Parca continuous profiling Agent,即使在省略了帧指针的情况下也能工作。 x86_64中的堆栈 x86_64架构除了描述它的指令集和其他一些重要特征外,还定义了数据在其应用二进制接口或简称ABI中应如何布局的规则。该规范显示了该架构下的堆栈应该如何设置。 当这段代码被执行时,不同的call指令将把返回地址压入堆栈中。一旦函数返回,CPU将读取返回地址并跳转到该地址,继续执行该函数的调用点。 由于没有额外的信息,不可能可靠地生成堆栈跟踪。可能有其他的值,比如函数的本地数据,被存储在堆栈中,可能看起来像函数地址。这就是帧指针要解决的问题。 Can I have a (frame) pointer? 如下伪代码为例,假设没有编译器优化 int top(void) { for(;;) { } } int c1(void) { top(); } int b1(void) { c1(); } int a1(void) { b1(); } int main(void) { a1(); } 使用这方法来遍历堆栈,我们需要保留指向上一帧的指针。 在x86体系结构中,这通常在帧指针$rbp中。 由于函数可能调用其他函数,因此必须在函数进入时存储该寄存器,并在函数退出时恢复该寄存器。 这是通过所谓的function prologue函数序言来实现的,在函数入口处,它可能看起来像这样 push $rbp # saves the stack frame pointer mov $rbp, $rsp # sets the current stack pointer to the frame pointer function epilogue函数序言在函数返回处 pop $rbp # restores the function's frame pointer ret # pops the saved return address and jumps to it 如果我们用帧指针编译并运行上面的C代码,堆栈将具有遍历堆栈所需的所有信息。 有效地调用不同的函数会创建一个需要遍历的链表。 反汇编使用帧指针编译的代码 # compiled with `gcc sample.c -o sample_with_frame_pointers -fno-omit-frame-pointer` $ objdump -d ./sample_with_frame_pointers 0000000000401106 <top>: 401106: 55 push %rbp 401107: 48 89 e5 mov %rsp,%rbp 40110a: eb fe jmp 40110a <top+0x4> 000000000040110c <c1>: 40110c: 55 push %rbp 40110d: 48 89 e5 mov %rsp,%rbp 401110: e8 f1 ff ff ff call 401106 <top> 401115: 90 nop 401116: 5d pop %rbp 401117: c3 ret 0000000000401118 <b1>: 401118: 55 push %rbp 401119: 48 89 e5 mov %rsp,%rbp 40111c: e8 eb ff ff ff call 40110c <c1> 401121: 90 nop 401122: 5d pop %rbp 401123: c3 ret 0000000000401124 <a1>: 401124: 55 push %rbp 401125: 48 89 e5 mov %rsp,%rbp 401128: e8 eb ff ff ff call 401118 <b1> 40112d: 90 nop 40112e: 5d pop %rbp 40112f: c3 ret 0000000000401130 <main>: 401130: 55 push %rbp 401131: 48 89 e5 mov %rsp,%rbp 401134: e8 eb ff ff ff call 401124 <a1> 401139: b8 00 00 00 00 mov $0x0,%eax 40113e: 5d pop %rbp 40113f: c3 ret 上面的例子代码中的native stack的内容是在顶部函数运行时用帧指针编译的 要遍历堆栈,我们必须遵循上面生成的链表,读取每个保存的$rbp之前压入的值,这将使我们的堆栈帧,直到$rbp为零,意味着已经到达了堆栈的末尾。 这很好,因为它允许我们以很低的成本计算出堆栈跟踪。 对于编译器实现者来说,添加它也相对容易,而且一般来说,只需要相当少量的周边基础设施就可以使它工作。 尽管有这些优点,但我们所依赖的许多代码并不是用帧指针编译的。 我们中的许多人依赖于我们的Linux发行版应用程序和库,其中绝大多数选择省略帧指针。 即使你使用框架指标编译程序,动态或静态链接你的发行版本所提供的任何库,也可能会让你无法仅使用帧指针来正确展开堆栈。 我们不会深入探讨在某些环境中禁用帧指针的原因以及与之相关的细微差别,但我们认为必须逐个应用地对它们的开销进行基准测试。 禁用帧指针带来的成本也应该考虑在内。 反汇编不使用帧指针编译的代码 # compiled with `gcc sample.c -o sample_without_frame_pointers -fomit-frame-pointer` $ objdump -d ./sample_without_frame_pointers [...] 0000000000401106 <top>: 401106: eb fe jmp 401106 <top> 0000000000401108 <c1>: 401108: e8 f9 ff ff ff call 401106 <top> 40110d: 90 nop 40110e: c3 ret 000000000040110f <b1>: 40110f: e8 f4 ff ff ff call 401108 <c1> 401114: 90 nop 401115: c3 ret 0000000000401116 <a1>: 401116: e8 f4 ff ff ff call 40110f <b1> 40111b: 90 nop 40111c: c3 ret 000000000040111d <main>: 40111d: e8 f4 ff ff ff call 401116 <a1> 401122: b8 00 00 00 00 mov $0x0,%eax 401127: c3 ret [...] 二者差异 top: - push %rbp - mov %rsp,%rbp jmp 40110a <top+0x4> c1: - push %rbp - mov %rsp,%rbp call 401106 <top> nop - pop %rbp ret b1: - push %rbp - mov %rsp,%rbp call 40110c <c1> nop - pop %rbp ret a1: - push %rbp - mov %rsp,%rbp call 401118 <b1> nop - pop %rbp ret main: - push %rbp - mov %rsp,%rbp call 401124 <a1> mov $0x0,%eax - pop %rbp ret 假设我们在分析c1的执行过程,堆栈可能如下所示: top运行时上述代码的native stack的内容 我们需要一些其他信息或硬件支持,以便能够可靠地展开堆栈。 硬件方法 我们可以使用一些硬件设施来展开堆栈,例如Intel的Last Branch Record (LBR).LBR产生起始地址和目的地址对FROM_IP和TO_IP。我们可以使用它们来建立堆栈跟踪。 他的缺点就是他能记录的深度有限。 虽然 LBR 用途广泛且功能强大,但我们决定不将其用于 CPU 分析,因为并非每个虚拟化环境都提供此功能,而且它是英特尔特有的。这些缺点延伸到其他感兴趣的供应商特定的处理器特征,例如 Intel Processor Trace (PT) 一场特殊的邂逅 你可能会想,我怎么可能编译没有帧指针的C++应用程序,并且异常仍然工作得很好? 那么Rust呢?在Rust中,默认情况下帧指针是禁用的,但是调用panic() 会显示完整且正确的堆栈跟踪。 为了使C++异常无论二进制代码是如何编译的都能工作,以及添加一些其他必要的工具来使它们发挥作用,编译器可以发出一些元数据来指示如何展开堆栈。 该信息提供程序计数器到关于如何恢复所有寄存器的指令的映射。 更多详情参见 DWARF debugging information format 和 x86_64 ABI两篇文章。 DWARF’s Call Frame Information (CFI) CFI的主要目标是提供如何在我们代码执行的任何部分恢复前一帧的每个寄存器的答案。直接存储一个包含每个程序计数器和所有寄存器及其位置(例如它们是否已被压入堆栈)的表,将生成巨大的展开表。 因此,此格式尽量紧凑,仅包含所需的信息。 它使用各种技术来达到此效果,例如 使用 LEB128实现数字的可变长度压缩 使用状态机进行数据压缩。 这一点很重要,因为它允许以增加复杂性为代价来实现数据的非常简洁的表示 回溯表以CFI格式编码,采用我们需要计算的操作码的形式。 它有两个主要的层。第一层是在VM中编码的状态机。 这有助于压缩性能良好的重复模式,并允许更紧凑地表示某些数据,因为在某些情况下,有一个专门的操作码占用1、2或4个字节,而不是一直使用4个字节。 未压入堆栈的寄存器可能不会出现在表中。 我称之为第二层的是一个特殊的操作码,它包含另一组操作码,包含我们需要计算的任意表达式。 这两个层次之间的主要区别是,对于第一层次,我们只需要一个堆栈来记忆和恢复寄存器(分别为DW_CFA_remember_state和DW_CFA_restore_state),对于第二层,我们需要计算任意图灵完整表达式。 因此,我们需要一个成熟的VM来计算任何表达式。 在BPF中实现VM并不太实际,因此我们决定采用一种实用的方法,首先对2个表达式进行硬编码,这2个表达式在我们评估的大多数二进制文件中出现的次数超过50%。 我们有一些关于如何进一步改进表达式支持的想法,但是这篇博客文章已经太长了。 使用DWARF CFI遍历堆栈 要使用此方法遍历给定程序计数器(PC)的堆栈,我们需要找到其相应的展开信息。 但是,我们所说的展开信息到底是什么意思呢? 我们需要恢复: 已保存的返回地址 前一帧的栈指针($rsp)和帧指针($rbp)寄存器的值,用于恢复前一帧的栈指针 前一帧的堆栈指针的值,就在我们当前函数被调用之前,在 DWARF 的 CFI 术语中,称为规范帧地址或 CFA。 正如我们之前看到的,在 x86_64 中,保存的返回地址总是在 CFA 之前 8 个字节(一个字)。 展开算法看起来像这样 读取初始寄存器 指令指针 $rip。 需要在展开表中查找行。 堆栈指针$rsp和帧指针$rbp,用于计算前一帧的堆栈指针值 (CFA)。 我们可以在 CFA 的偏移量处找到压入堆栈的返回地址和其他寄存器。 While循环 unwind_frame_count <= MAX_STACK_DEPTH: 找到 i 满足的 PC 的展开表行$unwind_table[i].PC <= $target_PC <= $unwind_table[i+1].PC。 如果没有记录并且 $rbp 为零,我们就到达了堆栈的底部。 将指令指针添加到堆栈。 计算前一帧的栈指针。 这可以基于当前帧的 $rsp 或 $rbp,如果它不是表达式或直接注册。 用前一帧的计算值更新寄存器。 继续下一帧。 转到2。 Note: 为简单起见,我们省略了我们的展开器实现的一些重要细节. 为此,我们需要读取回溯操作码,对它们求值,然后生成表。 这个过程可能会非常昂贵,但它是由C++中的异常处理基础结构完成的。 因为异常应该是,呃,异常,所以这些代码路径不应该经常使用,开销也不会太高。 对于调试器(如GDB)也是如此,在调试器中,用户可能希望了解各处的堆栈跟踪,以了解他们在执行中的位置。 这些用例有时被归类为离线展开。 分析器有点不同,因为它们通常每秒对堆栈进行数十次或数百次采样。必须读取、解析和评估展开信息的开销可能非常高。虽然堆栈展开器可能会做一些缓存,但整个过程仍然非常昂贵。 在我们的案例中,一个关键的观察结果是,我们不需要恢复每个寄存器,我们只需要这2个寄存器和保存的返回地址。 这种洞察力使我们能够生成一种更适合在线展开用例的表示。 可能的实现 我们开发的分析器并不是第一个使用这种技术的分析器。 Perf是一个古老的Linux分析器,它支持基于DWARF的堆栈展开已经有一段时间了。 通过利用Linux 3.4中的perf_event_open系统调用中引入的PERF_SAMPLE_REGS_USER和PERF_SAMPLE_STACK_USER,它可以接收所分析进程的寄存器以及每个样本的堆栈副本。 虽然这种方法已经被证明是可行的,并且我们对实现我们的分析器进行了类似的评估,但它有一些我们希望避免的缺点: 性能:内核将为每个样本复制用户堆栈。 它会复制当前正在使用的用户堆栈,但这可能是相当多的数据。 假设非常保守的情况是:每栈1 K * 每秒100个样本 * CPU运行时间的30%* 10个CPU =每秒300 KB。 安全性:使另一个进程具有另一个进程的堆栈的值的含义可能是复杂的。 如果那里存在一些私钥或任何种类的个人身份信息(PII)怎么办? 虽然300 KB/s的数据量看起来并不多,但我们认为,对于运行CPU密集型应用程序的繁忙机器来说,这个数字可能会高得多。 我们希望通过在分析器运行时减少测量的影响,减少专用于分析器的资源,否则应用程序可能会使用这些资源。 另一个突然出现在我们脑海中的想法是复制BPF程序中的堆栈,但这仍然有我们希望避免的缺点,我们必须重新实现内核已经拥有的功能,而且它被证明工作得很好! 这就引出了我们最终采用的方法,仍然利用BPF! Why BPF? 我们是 BPF 的忠实信徒,有很多原因。 从广义上讲,它允许 Linux 内核以更高的安全保证和更低的学习门槛进行编程。 在 BPF 中开发分析器非常有意义,因为一旦实现了堆栈遍历机制,我们就可以利用 perf 子系统来获取有关 CPU 周期、指令、L3 缓存未命中或我们机器中可用的任何其他性能计数器的样本。 它还有助于开发其他工具,如分配跟踪器、off-CPU分析器等。 你可能会想,为什么要在BPF中讨论堆栈展开? 使用bpf_get_stackid(ctx,& map,BPF_F_USER_STACK) helper,我们可以获取用户堆栈!事实上,这个助手使用帧指针遍历堆栈,而一个功能齐全的 DWARF 展开器不太可能登陆内核 展开表的BPF友好表示形式 大多数离线堆栈展开器不会处理大部分 DWARF CFI 信息,因为它们只针对很少的程序计数器。 另一方面,分析器可能会产生更高的程序计数器基数。 出于这个原因,事实上我们只需要数据的一个子集来遍历堆栈,因为我们不需要知道如何恢复每个寄存器,也不需要生成一些表示来最小化 BPF 的工作 unwinder 必须做,我们决定预先承担 unwind 表生成成本。 在用户空间中,我们首先解析、计算和生成回溯表。 到目前为止,我们只支持存储在.eh_frame ELF段中的信息。 生成的表是由以下行类型构建的几个数组: typedef struct { u64 pc; u16 _reserved_do_not_use; u8 cfa_type; u8 rbp_type; s16 cfa_offset; s16 rbp_offset; } stack_unwind_row_t; 我们使用一个完整的字作为程序计数器,然后有几个字段帮助我们计算CFA。 例如,它可以与当前的$rsp或$rbp有一定的偏移量。 我们还将了解如何恢复$rbp。 使用上述算法,我们通过恢复前一帧的寄存器来遍历帧。 该表按程序计数器排序,以便能够在BPF程序中对该表进行二进制搜索。 当我们找不到给定PC的回溯信息,且当前的$rbp为零时,我们就完成了对堆栈的遍历。 然后,我们在内核空间的BPF map中聚合堆栈,以最大限度地提高效率,我们每分钟从用户空间收集两次数据,在用户空间生成配置文件并将其发送到Parca Server兼容的服务器。 开发 在我们开始这个项目的早期,我们就意识到有很多变量会影响它的成功。据我们所知,没有其他功能完整的、开源的、基于 DWARF 的 BPF 展开器,所以我们不确定它的可行性。 因此,为了最大限度地提高我们的成功机会,我们试图大幅减少问题空间,同时仍给我们尽可能多的信号。 在Polar Signals,我们为每一个我们想要讨论或获得反馈的大功能或主题创建了一个征求意见(RFC)。对于这项工作,我们从一个文档开始,列出了我们希望在第一次迭代中实现的目标,包括目标,更重要的是,非目标。 经过几个星期的工作,我们得到了第一个版本,它专注于正确性。 我们继续进行后续工作,以放宽最低内核要求(kernel ~4.10),并使展开表行更加紧凑。 在BPF中构建这是一个有趣的挑战。 内核必须确保任何加载的程序都不会使其崩溃,它使用BPF 验证器静态分析代码,验证器将拒绝或接受程序。 在写这篇文章的时候,一些当前的规则是,没有动态分配,终止必须是可证明的,以及许多其他的,所以我们必须有创造性地让验证器接受我们的程序。 这篇文章太长了,所以这将是另一个故事。 Testing测试 为了让展开器工作,展开表和 BPF 中实现的展开算法都必须运行良好。 确保table正确是该项目开发的重中之重。 在这种情况下,我们很早就决定以非常简单的形式使用快照测试。 我们在一个单独的git仓库中有一些测试二进制文件和预期的回溯表。 作为Agent中测试套件的一部分,我们重新生成表并确保没有任何更改。 这种技术允许我们快速迭代DWARF展开信息解析器,帮助我们找到大量的bug,并节省了大量的时间,否则我们将花费大量的时间来试图理解为什么我们在遍历堆栈时失败。 未来工作 我们正在修复、研发很多功能,将很快与您分享! 我们几天前才发布了第一个版本,其中包括基于DWARF的堆栈展开。 但是我们已经做了一些更多的改进,以确保分析器在内存受限的机器上运行良好,改进了体系结构,更好地支持JIT代码,等等。 近期,我们将重点转向可靠性、性能和更广泛的支持。 DWARF的回溯信息的解析、求值和处理尚未优化。 我们还希望确保我们的分析器具有详细的性能指标。 最后,我们希望对Clang和Rust编译器工具链生成的表进行更详尽的测试。 这个项目的最终目标是在默认情况下为我们的所有用户启用这个分析器,而不会导致明显更高的资源使用率。 试用 如上所述,这个新特性是在一个特性标志后面,但是我们将在下一个版本中默认启用它,一旦我们获得了一些改进,你可下载parca-agent当前的版本来体验。 $ ./parca-agent [...] --experimental-enable-dwarf-unwinding \ --debug-process-names="(postgres|mysql|redpanda)" 与社区合作 我们认为,帧指针的普遍缺乏对于应用程序开发人员以及分析器、调试器和编译器的开发人员来说是一个大问题。 幸运的是,这个问题空间正在被更广泛的工程社区的许多成员积极地研究,比如在Fedora中默认启用帧指针的提议,或者the .ctf_frame work,这是一种DWARF展开的代替格式,专门为分析器和其他工具需要的在线、异步(意味着可以解旋任何程序计数器,而不仅仅是从特定部分)用例定制。 开源和与其他社区合作是我们公司精神的重要组成部分。 这就是为什么我们很早就开始谈论这个项目,从去年9月的 Linux Plumbers talk开始,在那里我们宣布了这项工作。 我们的unwinder是根据 GPL 许可证获得许可的。它开放供检查和贡献,我们很乐意与面临类似问题的其他项目合作。不要犹豫,伸出手!如果您希望在我们的Discord 或 GitHub discussion中看到任何反馈或功能,请告诉我们。 致谢 如果没有许多人的努力,这项工作是不可能完成的。 感谢如下项目。 Go 的 Delve 调试器,我们的 DWARF 展开信息解析器基于它。 Ian Lance Taylor 的 关于 .eh_frame 的博客系列。 MaskRay 的博客 包含有趣的编译器和链接器内容。 参与 DWARF 标准和不同 ABI 的工程师。想出如此灵活的东西并不容易。开放和详细的规范是一个很好的资源。 编译器工程师是这项工作的无名英雄。创建展开表并不容易。在编译过程中保持它们同步是一项艰巨的任务。 如果没有 BPF 及其周围的生态系统,我们将无法安全地创建可编程的系统级分析器。虽然有时验证者可能很强硬,但它是我们最好的盟友。 可靠且快速的基于 DWARF 的堆栈展开论文 对我们在这篇文章中描述的所有系统提供了极好的描述,并尝试了一些不同的非 BPF 方法来加速基于 dwarf 的展开器,他们进行的一些正确性测试发现了几个错误。我们欠他们的不仅是提高许多系统(包括我们的系统)所依赖的展开表的质量,而且还帮助提高了对所有这些系统及其重要性的认识。 杂谈 虽然术语stack walking堆栈遍历在分析器的上下文中更为正确,而stack unwinding堆栈展开通常在运行时处理异常时使用,但我们可以互换使用这两个术语。 我们的表格式使用了我们可以使用的最小数据类型,它对偏移量的最小值和最大值等设置了一些限制。您可以在本设计文档中查看它们,其中还包括一些unwinder的要求。我们已经在努力消除上面提到的限制 Reliable and fast DWARF-based stack unwonding一文中的一个非常有趣的想法是,当展开表由于某种原因不存在或不完整时,从目标代码合成展开表。这是我们将来可能会考虑的事情。 为了增加对复杂性影响的一些见解,仅就代码大小而言,之前基于帧指针的展开器可以在不到 50 行的 BPF 中重新实现,而这个基于 dwarf 的展开器超过 500 行。这排除了用户空间和测试中所有必要的支持代码。 最后但并非最不重要的一点是,不要把这项工作当作删除帧指针的道歉!如果我们可以改变计算行业中的一些技术和低级别的东西,它可能会默认启用帧指针。这是 Facebook 和谷歌等超大规模企业已经在做的事情,尽管可能会产生额外的计算成本,因为在故障排除的每一分钟都需要花费大量金钱的情况下,它们可以节省他们的头痛和时间。话虽如此,我们知道即使每个人都同意启用帧指针,也需要数年时间才能使我们的所有用户都获得好处。 旁注:C++ 异常机制非常复杂,必须完成本文中描述的大量工作。 一些有趣的事情需要考虑:展开表在内存中与不在内存中时的成本是多少? 这可能是您的应用程序的问题吗? 这些路径是如何行使的? 2022-12-01 0 0