击败 eBPF Uprobe 监控 这篇文章介绍了一种可以用于监控用户空间程序的 eBPF 程序。它首先向您介绍了 eBPF 和 uprobes,然后探讨了我们在 uprobes 中发现的缺陷,所有演示示例都适用于 Linux 和 x86_64 架构。原文地址:Defeating eBPF Uprobe Monitoring 简介 监控系统发生的事情非常重要。eBPF 可以通过将特定程序钩入各种系统范围的事件来帮助您在 Linux 服务器上执行此操作。您可以通过将内核或用户空间函数钩入来收集大量信息。例如,您可以读取两个进程之间加密通信的内容,或者查找使用特定库函数的进程。理解 eBPF 的一个好方法是记住这个图表: 要创建自己的 eBPF 程序,请选择一个 eBPF 库,它将生成 eBPF 字节码,然后调用 bpf 系统调用将其加载到内核中。在内核端,如果您的程序是安全的,它将经过验证并加载。您还必须记住,有不同类型的 eBPF 程序(适应触发事件),每种程序都可以访问不同的 eBPF 辅助程序和上下文。目前使用 eBPF 进行监控的工具通常涉及 kprobes(内核探针)。例如,这种类型的程序允许您记录每次进程使用系统调用的情况。然而,并非所有有趣的信息都可以通过这种方式捕获。这就是为什么正在对 uprobes(用户空间探针)进行新研究以进行用户空间监控的原因。 Uprobes:基础知识 定义 Uprobes 是允许钩入任何用户空间程序任意指令的内核功能。当触发这些钩子时,将创建一个事件,并向处理程序(例如,一个 eBPF 程序)提供被探测程序的上下文。然后,您可以记录 CPU 寄存器的值或执行一个 eBPF 程序。例如,由 Quarkslab 开发的 peetch 工具集使用 eBPF 和 uprobes 钩子在 OpenSSL 的 SSL_read() 和 SSL_write() 函数上,以记录系统范围的 TLS 消息并以纯文本形式访问数据。 如何创建 您可以通过向 /sys 伪文件系统添加一行到 /sys/kernel/debug/tracing/uprobe_events 文件来创建一个 uprobes。语法如下: p[:[GRP/]EVENT] PATH:OFFSET [FETCHARGS] : Set a uprobe r[:[GRP/]EVENT] PATH:OFFSET [FETCHARGS] : Set a return uprobe (uretprobe) -:[GRP/]EVENT : Clear uprobe or uretprobe event 更多细节,请参阅 内核文档。 示例 学习最好的方式是实践。让我们创建一个 uprobes,以打印系统中发出的每个命令。 首先,我们需要找一个地方来钩入。我们选择了 bash 二进制中的 readline()。这是一个不错的选择,因为我们的命令在函数结束时返回。现在,让我们找到 /bin/bash 中 readline 的偏移量。可以使用 gdb 快速实现: gdb /bin/bash (gdb) p readline $1 = {<text variable, no debug info>} 0xd5690 <readline> 如上 gdb 所示,我们的偏移量是 0xd5690。内核文档解释了我们可以在 uretprobe 中打印返回值。Uretprobes 实际上只是放置在函数末尾的 uprobes。我们的命令必须以 r: 开头,表示 "uretprobe",接着是我们的探针 bashReadline 的名称,二进制文件的路径 /bin/bash,偏移量 0xd5690,以及返回值打印为字符串的形式:cmd=+0($retval):string。 # First log-in as root. # This line creates a uretprobe named bashReadline at offset 0xd5690 of /bin/bash program that prints the return value as a string. echo 'r:bashReadline /bin/bash:0xd5690 cmd=+0($retval):string' >> /sys/kernel/tracing/uprobe_events # When the uprobe is added, activate it with this command: echo 1 > /sys/kernel/tracing/events/uprobes/bashReadline/enable cat /sys/kernel/tracing/trace_pipe bash-24834 [010] ..... 26372.295012: bashReadline: (0x5630d6af8015 <- 0x5630d6b98690) cmd="cat trace_pipe " <...>-14869 [014] ..... 26393.048772: bashReadline: (0x55f2c8640015 <- 0x55f2c86e0690) cmd="ls" bash-14869 [014] ..... 26399.267734: bashReadline: (0x55f2c8640015 <- 0x55f2c86e0690) cmd="whoami" <...>-24909 [010] ..... 26428.810573: bashReadline: (0x5638c7785015 <- 0x5638c7825690) cmd="cat /etc/passwd" 警告 如果在向 uprobe_events 文件写入时收到 设备或资源忙 的错误,请将 /sys/kernel/tracing/events/uprobes/enabled 设置为 0,然后重试。 如果收到 无效参数 错误,请阅读 /sys/kernel/tracing/error_log 文件以获取详细信息。 您的内核必须启用 CONFIG_UPROBES(如果您的 Linux 内核版本为 3.5 或更高版本,则默认启用)。 使用 eBPF 升级 如果您想要做的不仅仅是打印,并且要给您的 uprobes 添加一些逻辑,您可以使用 eBPF 程序。为简化起见,我们将使用做了繁重工作的 bcc Python 包。您只需使用构造函数创建一个 bpf 对象,并使用 attach_uretprobe() 方法将其挂接到 uretprobe 上。最后,我们编写一个简短的 eBPF 程序,读取命令和当前用户 ID,并在用户为 root 时打印它。 #!/usr/bin/python3 from bcc import BPF from time import sleep # load BPF program bpf_text=""" #include <linux/sched.h> int printForRoot(struct pt_regs *ctx){ char command[16] = {}; //use a bpf helper to get the user id. uid_t uid = bpf_get_current_uid_gid() & 0xffffffff; //another bpf helper to read a string in userland bpf_probe_read_user_str(&command, sizeof(command), (void *)PT_REGS_RC(ctx)); if(uid == 0){ bpf_trace_printk("Command from root: %s",command); } return 0; } """ b = BPF(text=bpf_text) b.attach_uretprobe(name="/bin/bash", sym="readline", fn_name="printForRoot") while(1): sleep(1) cat /sys/kernel/tracing/trace_pipe bash-9442 [000] d...1 2634.932058: bpf_trace_printk: Command from root: whoami bash-9442 [000] d...1 3575.645538: bpf_trace_printk: Command from root: cd /root/ bash-9442 [000] d...1 3584.413448: bpf_trace_printk: Command from root: sl 现在您已经了解了如何使用 uprobes,下一节将向您展示内核的工作原理。 uprobes 在幕后是如何工作的 Uprobe 创建 内核将 uprobes 实现为仅由内核使用的特殊断点。Uprobes 由程序文件 inode、指令偏移量、相关操作列表和替换指令代码组成。创建探测点时,它会被添加到特定的二叉树中。 在设置 uprobes 时,内核会调用 probes_write() 和 trace_uprobe_create(),它们又调用 __trace_uprobe_create()。最后一个函数以 uprobe_events 中的行作为参数,并调用 kern_path() 获取与我们路径相对应的文件的 inode。 随后,register_trace_uprobe()、_add_event_to_tracers() 和其他函数创建了伪目录 /sys/kernel/tracing/events/uprobes/<EVENT>/,以及一些文件(enable、id 等)。 probes_write(){ trace_uprobe_create(){ /* * Argument syntax: * - Add uprobe: p|r[:[GRP/]EVENT] PATH:OFFSET[%return][(REF)] [FETCHARGS] */ __trace_uprobe_create(int argc, const char **argv); } } Uprobe 激活 当我们启用 uprobes 时,会发生以下嵌套调用:trace_uprobe_register() => probe_event_enable() => trace_uprobe_enable() => uprobe_register()。 这最后一个函数调用了另外两个有趣的函数: alloc_uprobe(),它创建了一个带有 inode、偏移量和替换指令的 struct uprobe 并调用 insert_uprobe() 将这个 uprobe 添加到 uprobe rb_tree 中。 register_for_each_vma(),它循环遍历所有现有的虚拟内存区域,找到与某些 uprobe inode 相对应的内存区域(并验证 valid_vma())。对于这些 vma,它调用 install_breakpoint() 将完整的被探测指令复制到 arch.insn 中(一个取决于当前架构的结构),然后将其替换为断点。 trace_uprobe_register(){ probe_event_enable(){ trace_uprobe_enable(){ uprobe_register(){ alloc_uprobe(){ struct uprobe{ inode; offset; insn; } insert_uprobe(); } register_for_each_vma(){ if(valid_vma){ install_breakpoint(); } } } } } } 新程序实例的检测 当执行一个 ELF 程序时,它的内存使用 mmap 系统调用进行映射。在内核中,函数 mmap_region/vma_merge => __vma_adjust() 被调用以管理这种映射。__vma_adjust() 是一个在虚拟内存区域被添加/修改时使用的辅助函数。当文件支持的虚拟内存区域被修改时,它调用 uprobe_mmap()。我们程序的代码部分与其程序文件相连,所以 uprobe_mmap() 被用于包含我们 uprobe 的虚拟内存区域。 如果 valid_vma() 正常,它会使用 build_probe_list() 找到与 uprobe rb_tree 中相同 inode 的所有 uprobes,并为每个 uprobe 调用 install_breakpoint()。 SYS_mmap(){ mmap_region/vma_merge(){ __vma_adjust(){ uprobe_mmap(){ if(valid_vma){ build_probe_list(){ for each uprobe: install_breakpoint(); } } } } } } 记住,在 mmap 调用期间会将 uprobes 添加到新的程序实例中! Uprobe 事件 当达到断点时,会触发 int3 异常。do_int3() 调用 notify_die(DIE_INT3, …),然后调用 atomic_notifier_call_chain(&die_chain, …)。链 die_chain 包含了之前通过 register_die_notifier() 注册的所有通知者。atomic_notifier_call_chain 调用 notifier_call_chain(),通过其 notifier_call 属性通知链中注册的通知者有关事件的信息。对于我们的 uprobes,它是在 uprobe_init() 中设置的 arch_uprobe_exception_notify()。它调用 uprobe_pre_sstep_notifier(),该函数设置了 TIF_UPROBE 标志。在返回到用户空间时,线程注意到了 TIF_UPROBE 标志,并调用 uprobe_notify_resume(struct pt_regs * regs),该函数调用 handle_swbp(regs)。 此函数执行两个主要操作: handler_chain(find_active_uprobe()),执行此 uprobes 的处理程序。例如,由 eBPF 程序使用的 perf_event。 pre_ssout(),准备对被探测指令进行单步执行。这个指令不能在程序内存中执行,因为原始指令已被 uprobes 断点指令替换。内核开发人员首先尝试暂时删除断点,但存在一些问题,因此选择在一个新的内存区域中执行这个指令(也称为 xol)。因此,它首先调用 xol_get_insn_slot 获取 xol 虚拟地址,此函数使用 get_xol_area(),如果尚未创建 uprobes 特殊虚拟内存区域,则会创建该区域,并使用 xol_add_vma() => install_special_mapping()。这个 vma 是原始指令将要在 xol 中执行的地方。继续执行 pre_ssout(),它使用 arch_uprobe_pre_xol() 调用 regs_set_return_ip(regs, current->utask->xol_vaddr) 和 user_enable_single_step()。此时 current->utask->xol_vaddr 指向之前创建的分配的 XOL slot。因此,此函数将程序计数器设置为原始指令的副本所在的位置,并激活单步模式。然后,执行这个指令,并再次停止程序。 当单步执行结束时,arch_uprobe_post_xol 从 uprobe_notify_resume 中调用。此函数准备在单步执行后恢复执行,并调用 post_xol 处理程序。默认情况下,它是 default_post_xol_op(也可以看看 branch_post_xol_op)。新的 RIP 寄存器是相对于复制的指令的,因此它使其相对于原始指令(有一些例外,比如返回、调用、绝对或间接跳转等)。如果指令使用了 RIP,则将其替换为另一个寄存器。恢复这个寄存器的值,最后恢复程序的执行。 [...] uprobe_init(){ register_die_notifier(arch_uprobe_exception_notify); } [...]//breakpoint is reached do_int3(){ notify_die(DIE_INT3, ...){ atomic_notifier_call_chain(&die_chain, ...){ notifier_call_chain(){ for each: notifier_call = arch_uprobe_exception_notify(){ uprobe_pre_sstep_notifier(){ //set TIF_UPROBE flag } } } } } } [...] exit_to_user_mode_prepare() { exit_to_user_mode_loop() { uprobe_notify_resume(struct pt_regs * regs){ handle_swbp(regs){ handler_chain(find_active_uprobe()); pre_ssout(){ xol_get_insn_slot(){ get_xol_area(){ __create_xol_area(){ xol_add_vma(){ install_special_mapping(); } } } } arch_uprobe_pre_xol(){ regs_set_return_ip(regs, current->utask->xol_vaddr); } user_enable_single_step(); } } } } } [...]//single_step uprobe_notify_resume(struct pt_regs * regs){ arch_uprobe_post_xol(){ post_xol = default_post_xol_op(); } } 总结一下,当触发断点时,会执行处理程序,然后执行替换为断点的原始指令,它在一个特殊的虚拟内存区域中执行 与 uprobes 一起玩耍 我们已经知道 uprobes/eBPF 组合是一种获取系统所有进程数据的非常高效的方式。例如,在 Quarkslab,我们创建了 peetch,它记录了所有明文的 TLS 连接(在加密过程之前)。但是,从安全的角度来看,这些数据能够被信任吗?还是只是提供了信息而已? 在这一节中,我们假设编写了一个将被 uprobes 监控/检测的程序。让我们看看我们可以用这些 uprobes 做些什么。 检测 uprobes Uprobes 基于断点,因此我们可以使用常见的反调试技巧来检测它们。受监视程序快速且不太正规的方法是读取其 .text 内存,然后搜索断点操作码。 下面的 C 代码片段通过读取 tracedFunction 的第一个字节,并检查是否对应于断点操作码(0xcc)来实现此功能。 unsigned char * functionBytes = (unsigned char *) &tracedFunction; if (functionBytes[0] == 0xcc){ printf("Detected uprobe breakpoint in beginning of tracedFunction.\n"); } 问题在于,你可能必须检查每条指令,并将其与二进制文件中的实际指令进行比较,以避免误报。 另一种方法是在触发 uprobe 后检测它。利用我们对内核内部工作原理的了解,我们知道创建了一个特殊的内存映射,称为 [uprobes],用于执行原始指令。因此,我们的被监视程序可以读取 /proc/self/maps,并搜索此类映射。 bool detect_uprobes(){ FILE * memfile = fopen("/proc/self/maps", "r"); char line[200]; while(fgets(line, 200, memfile) != NULL){ char * uprobes_str = strstr(line,"[uprobes]");//search for "[uprobes]" in line if(uprobes_str != NULL){ return true; } } return false; } uprobes 监控逃逸 根据 Uprobe 激活 和 检测新程序实例 段落的内容,我们知道在添加断点之前始终会调用 valid_vma() 函数。让我们来看看这个函数的代码: static bool valid_vma(struct vm_area_struct *vma, bool is_register){ vm_flags_t flags = VM_HUGETLB | VM_MAYEXEC | VM_MAYSHARE; if (is_register) flags |= VM_WRITE; return vma->vm_file && (vma->vm_flags & flags) == VM_MAYEXEC; } 在 uprobe 注册期间,is_register 被启用。我们的代码是由程序文件支持的,所以 vma->vm_file 为 true,而且我们的代码具有执行标志,因此 VM_MAY_EXEC 也为 true。这个函数的有趣之处在于,如果我们的代码具有 VM_WRITE 标志,虚拟内存区域就不被视为有效的 vma,因此断点永远不会添加到我们的代码部分(.text)。 一个简单的方法是编辑包含 .text 部分的 ELF 段的权限,而 Quarkslab 提供了一个很好用的工具:lief。 import lief prog = "./bin/prog" binary = lief.parse(prog) binary.segment_from_offset(binary.get_section(".text").offset).flags = lief.ELF.SEGMENT_FLAGS(7) binary.write(prog) 这些技术结合一下: char isRoot(int uid){ if(detect_uprobes()){ printf("Previous uprobe usage detected.\n"); }else{ printf("No uprobe has been activated.\n"); } return uid == 0; } int main(int argc, char * argv[]){ if(argc == 2 && argv[1][0] == '1'){ unsigned char * funcBytes = (unsigned char *) &isRoot; if (funcBytes[0] == 0xcc) { int pagesize = sysconf(_SC_PAGE_SIZE); char * debut_page = ((char *) &isRoot) - ((long)&isRoot % pagesize);//find page aligned address mprotect(debut_page, pagesize, PROT_WRITE | PROT_READ | PROT_EXEC); printf("Detected uprobe breakpoint at the beginning of tracedFunction.\n"); funcBytes[0] = 0xf3; } }else if(argc != 2 || argv[1][0] != '0'){ printf("Usage:\n\t%s 0 : to disable anti-uprobe\n\t%s 1 : to enable anti-uprobe\n", argv[0], argv[0]); exit(1); } //PoC function isRoot(getuid()); return 0; } 这个程序有两种模式: 没有任何检测(即常规执行) 在 isRoot 上检测断点(以及修补)。 无论哪种情况,它都使用第二种检测技术来查找是否有任何 uprobes 被激活。 让我们试一试: # We begin without uprobe user@pc:~/ebpf-for-security/uprobe$ ./bin/prog Usage: ./bin/prog 0 : to disable anti-uprobe ./bin/prog 1 : to enable anti-uprobe user@pc:~/ebpf-for-security/uprobe$ ./bin/prog 0 No uprobe has been activated. Print from testFunction user@pc:~/ebpf-for-security/uprobe$ gdb ./bin/prog -q # We find isRoot function offset Reading symbols from ./bin/prog... (gdb) p isRoot $1 = {void ()} 0x1320 <isRoot> ----------------- # Now we activate the uprobe root@pc:~# echo 'p:isRootFunction /home/cglenaz/Documents/eBPF/gitlab/ebpf-for-security/uprobe/bin/prog:0x1320 uid=%di:u32' > /sys/kernel/tracing/uprobe_events ------------------------------------------------------- user@pc:~/ebpf-for-security/uprobe$ ./bin/prog 0 Previous uprobe usage detected. # our uprobe is detected! ------------------------------------------------------- # Let's read the uprobe output: cat /sys/kernel/tracing/trace_pipe prog-19936 [013] ..... 19399.726502: isRootFunction: (0x55ff8a5b8320) uid=1000 # The uprobe has intercepted the uid ------------------------------------------------------- user@pc:~/ebpf-for-security/uprobe$ ./bin/prog 1 # we test the first detection and mitigation strategy Detected uprobe breakpoint in beginning of testFunction. No uprobe has been activated. # it works ------------------------------------------------------- # Let's see if something is printed: cat /sys/kernel/tracing/trace_pipe # nothing is printed in trace_pipe because the uprobe is not activated ------------------------------------------------------- user@pc:~/ebpf-for-security/uprobe$ python3 permission.py # now we patch the binary with lief user@pc:~/ebpf-for-security/uprobe$ ./bin/prog 0 No uprobe has been activated. # no more uprobe on this program ------------------------------------------------------- cat /sys/kernel/tracing/trace_pipe # nothing again in trace_pipe ------------------------------------------------------- 这个技巧在我们程序的 isRoot 函数上效果很好,但对于共享库不起作用。而且,你必须是 root 用户才能编辑特权 ELF 库,比如 libc,所以你必须在它们加载到程序内存之前修改权限(或者你也可以编写一个自定义的 ELF 加载器,为每个库添加写权限)。你的程序可以再次读取 /proc/self/maps 来找到所有来自库的可执行 vma。 示例: 55cc466af000-55cc466b1000 r--p 00000000 fd:01 22282389 /usr/bin/cat 55cc466b1000-55cc466b5000 r-xp 00002000 fd:01 22282389 /usr/bin/cat 55cc466b5000-55cc466b7000 r--p 00006000 fd:01 22282389 /usr/bin/cat 55cc466b7000-55cc466b8000 r--p 00007000 fd:01 22282389 /usr/bin/cat 55cc466b8000-55cc466b9000 rw-p 00008000 fd:01 22282389 /usr/bin/cat 55cc4807f000-55cc480a0000 rw-p 00000000 00:00 0 [heap] 7f32c7ce9000-7f32c7d0b000 rw-p 00000000 00:00 0 7f32c7d0b000-7f32c8af2000 r--p 00000000 fd:01 22287657 /usr/lib/locale/locale-archive 7f32c8af2000-7f32c8af5000 rw-p 00000000 00:00 0 7f32c8af5000-7f32c8b1d000 r--p 00000000 fd:01 22288450 /usr/lib/x86_64-linux-gnu/libc.so.6 7f32c8b1d000-7f32c8cb2000 r-xp 00028000 fd:01 22288450 /usr/lib/x86_64-linux-gnu/libc.so.6 7f32c8cb2000-7f32c8d0a000 r--p 001bd000 fd:01 22288450 /usr/lib/x86_64-linux-gnu/libc.so.6 7f32c8d0a000-7f32c8d0e000 r--p 00214000 fd:01 22288450 /usr/lib/x86_64-linux-gnu/libc.so.6 7f32c8d0e000-7f32c8d10000 rw-p 00218000 fd:01 22288450 /usr/lib/x86_64-linux-gnu/libc.so.6 7f32c8d10000-7f32c8d1d000 rw-p 00000000 00:00 0 7f32c8d30000-7f32c8d32000 rw-p 00000000 00:00 0 7f32c8d32000-7f32c8d34000 r--p 00000000 fd:01 22288113 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 7f32c8d34000-7f32c8d5e000 r-xp 00002000 fd:01 22288113 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 7f32c8d5e000-7f32c8d69000 r--p 0002c000 fd:01 22288113 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 7f32c8d6a000-7f32c8d6c000 r--p 00037000 fd:01 22288113 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 7f32c8d6c000-7f32c8d6e000 rw-p 00039000 fd:01 22288113 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 7ffd18ebb000-7ffd18edc000 rw-p 00000000 00:00 0 [stack] 7ffd18ee6000-7ffd18eea000 r--p 00000000 00:00 0 [vvar] 7ffd18eea000-7ffd18eec000 r-xp 00000000 00:00 0 [vdso] ffffffffff600000-ffffffffff601000 --xp 00000000 00:00 0 [vsyscall] 感兴趣的虚拟内存区域: 7f32c8b1d000-7f32c8cb2000 r-xp 00028000 fd:01 22288450 /usr/lib/x86_64-linux-gnu/libc.so.6 7f32c8d34000-7f32c8d5e000 r-xp 00002000 fd:01 22288113 /usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2 每个 vma,你必须使用 munmap 系统调用取消映射该 vma 以删除 uprobes,并使用 mmap 系统调用重新映射具有写权限的完全相同的 vma。只有一个注意事项:当 libc 被取消映射时,你无法使用 libc 中的 mmap 函数。这就是为什么你必须直接在你的程序中硬编码系统调用的汇编指令。 extern long int syscall (long int __sysno, ...){ asm("mov %rdi,%rax"); asm("mov %rsi,%rdi"); asm("mov %rdx,%rsi"); asm("mov %rcx,%rdx"); asm("mov %r8,%r10"); asm("mov %r9,%r8"); asm("mov 0x10(%rbp),%r9"); asm("syscall"); } void remove_lib_uprobes(){ FILE * memfile = fopen("/proc/self/maps", "r"); char line[200]; while(fgets(line, 200, memfile) != NULL){ char * lib_str = strstr(line,".so");//find all libraries char * r_xp = strstr(line,"r-xp");// only their code sections if(lib_str != NULL && r_xp != NULL){ //read the start and end address. And the file offset. char * dash = strchr(line, '-'); dash[0] = '\0'; char * space = strchr(dash + 1, ' '); space[0] = '\0'; char * space2 = strchr(space + 1, ' '); space2[0] = '\0'; char * space3 = strchr(space2 + 1, ' '); space3[0] = '\0'; unsigned long addr1 = strtol(line, NULL, 16); unsigned long addr2 = strtol(dash + 1, NULL, 16); unsigned long offset = strtol(space2 + 1, NULL, 16); unsigned long delta = addr2-addr1; //now read the library file name // Locate the last occurrence of space in line (the one before the lib name) char * name_lib = strrchr(space3 + 1, ' ') + 1; name_lib[strlen(name_lib)-1] = 0; //replace the \n by '\0' long int fd = open(name_lib, O_RDONLY); syscall(SYS_munmap,(void *) addr1, delta); syscall(SYS_mmap,(void *) addr1, delta, (unsigned long) PROT_EXEC | PROT_READ | PROT_WRITE, (unsigned long) MAP_PRIVATE, (unsigned long) fd, (void *) offset); } } } 提供虚假信息 现在我们能够检测和禁用 uprobes,我们可以尝试向一个挂接在 uprobes 上的 eBPF 程序发送虚假的上下文信息。让我们试图伪造 sudo peetch tls --content 命令的输出。我们的程序将发送一个 GET 请求到 “evil” 文件,但 peetch 将监视对 “test” 文件的请求。这怎么可能?我们将利用在 uprobes 断点触发和消息实际加密之间的竞争条件。第一种策略是创建另一个线程,希望它在正确的时刻更改文件名;但这种方法只有 50% 的成功率,我们可以做得更好。事实上,我们可以控制我们程序的执行,所以我们可以将我们的程序分叉为两个进程: 一个子进程,在此调用 SSL_write 时使用假文件名并在此调用之前放置一个断点。 一个父进程,使用 PTRACE_SEIZE 附加到子进程,并使用 PTRACE_SINGLESTEP 逐条执行 CHILD 的指令。从断点开始,我们必须逐步执行设置寄存器中的 ptrace 参数和准备跳转到库的指令。在我们的情况下,在断点和 ptrace 的第二条指令之间恰好有 9 条指令。当子进程到达 SSL_write 的第二条指令时,uprobes 已经执行,所以我们现在可以将 SSL_write 的消息缓冲区更改为真实的文件名。我们使用 PTRACE_GETREGS 复制寄存器,修改 rsi 值(rsi 用于第二个参数),然后调用 PTRACE_SETREGS。最后,我们可以使用 PTRACE_CONT 恢复子进程的执行。 请注意,这第二种方法比第一种方法需要更高的特权级别,因为它使用了 ptrace 系统调用。 void SSL_write_race_condition(SSL* ssl, char * realName, char * fakeName){ char format[] = "GET /%s HTTP/1.0\r\n\r\n"; int fakeMsgLen = strlen(format) + strlen(fakeName); char realMsg[fakeMsgLen]; char fakeMsg[fakeMsgLen]; sprintf(fakeMsg, format, fakeName); sprintf(realMsg, format, realName); printf("\nMessage before the uprobe: %s\n", fakeMsg); pid_t pid_fils = fork(); if(pid_fils != 0){ ptrace(PTRACE_SEIZE, pid_fils, NULL, NULL); printf("Attached\n"); wait(NULL); struct user_regs_struct luser; for(int i=0; i<9; i++){//9 instructions between int3 and the first instruction of SSL_write ptrace(PTRACE_SINGLESTEP, pid_fils, NULL, NULL);//step one instruction wait(NULL);//wait for the step to be done } ptrace(PTRACE_GETREGS, pid_fils, NULL, &luser); luser.rsi = (long long unsigned int) realMsg;//change the SSL_write second argument to our real message printf("Set rsi to realMsg...\n"); ptrace(PTRACE_SETREGS, pid_fils, NULL, &luser) == -1); ptrace(PTRACE_CONT, pid_fils, NULL, NULL);//continue the SSL_write printf("Continue execution of SSL_write\n"); exit(1); }else{ ptrace(PTRACE_TRACEME, 0, 0, 0);//wait for the parent to trace this child __asm__("int3");//the breakpoint to stop the child just before SSL_write SSL_write(ssl, fakeMsg, fakeMsgLen); // encrypt and send message } } 我们需要创建一个测试的HTTPS server: from http.server import HTTPServer, BaseHTTPRequestHandler import ssl from io import BytesIO class SimpleHTTPRequestHandler(BaseHTTPRequestHandler): def do_GET(self): if self.path == "/test": self.send_response(200) self.send_header('Content-Type', 'text/html') self.send_header('Content-Length', str(len(b'<html>Hello, world!</html>\r\n\r\n'))) self.end_headers() self.wfile.write(b"<html>Hello, world!</html>\r\n\r\n") elif self.path == "/evil": self.send_response(200) self.send_header('Content-Type', 'text/html') self.send_header('Content-Length', str(len(b'<html>Hello, evil man!</html>\r\n\r\n'))) self.end_headers() self.wfile.write(b'<html>Hello, evil man!</html>\r\n\r\n') return True httpd = HTTPServer(('localhost', 4443), SimpleHTTPRequestHandler) #first create key : openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 #(example key pass = pass) httpd.socket = ssl.wrap_socket (httpd.socket, keyfile="./key.pem", certfile='./cert.pem', server_side=True) httpd.serve_forever() 这个 Python 服务器在我们请求 /evil 端点时返回 “Hello, evil man!”,而在 /test 端点返回 “Hello, world!”。 然后我们启动 peetch 并发起我们的攻击。 让我们看看结果: Message before the uprobe: GET /test HTTP/1.0 Attached rip=0x5613718b8deb Single-step rip=0x5613718b8df1 Single-step rip=0x5613718b8df8 Single-step rip=0x5613718b8dff Single-step rip=0x5613718b8e02 Single-step rip=0x5613718b8e05 Single-step rip=0x5613718b8510 Single-step rip=0x5613718b8514 Single-step rip=0x7f3fe29ba240 Single-step Set rsi to realMsg... Continue execution of SSL_write [+] Server data received : HTTP/1.0 200 OK Server: BaseHTTP/0.6 Python/3.10.4 Date: Wed, 06 Jul 2022 09:25:21 GMT Content-Type: text/html Content-Length: 29 <html>Hello, evil man!</html> 这很有效,文件名在消息发送之前被替换,所以我们收到了“Hello, evil man!”的消息。让我们检查一下 peetch 是否意识到我们的恶意行为: <- client (12918) 127.0.0.1/4443 TLS1.3 None 0000 47 45 54 20 2F 74 65 73 74 20 48 54 54 50 2F 31 GET /test HTTP/1 0010 2E 30 0D 0A 0D 0A 00 00 .0...... -> client (12918) 127.0.0.1/4443 TLS1.3 None 0000 48 54 54 50 2F 31 2E 30 20 32 30 30 20 4F 4B 0D HTTP/1.0 200 OK. 0010 0A 53 65 72 76 65 72 3A 20 42 61 73 65 48 54 54 .Server: BaseHTT 0020 50 2F 30 2E 36 20 50 79 74 68 6F 6E 2F 33 2E 31 P/0.6 Python/3.1 0030 30 2E 34 0D 0A 44 61 74 65 3A 20 57 65 64 2C 20 0.4..Date: Wed, 攻击成功,peetch已经监视了伪造的消息! 我们的攻击现在每次发送消息都会生效,而且可以轻松地适应不同的情况。因此,我们可以使用这种攻击方法来向使用先前方法检测到的任何 uprobes 提供虚假信息。 从另一个角度来看,这种攻击很容易被检测出来。您可以使用 kprobes 跟踪任何 PTRACE_SETREGS 并观察 rip 寄存器是否在包含您的 uprobes 的函数中。然而,攻击也可以使用线程(精度较低)进行,并且更难以检测。但是,如果攻击者可以使用我们先前的技巧轻松禁用任何 uprobes,那么研究此类攻击的意义又在哪里呢? 结论 我们发现一个程序可以通过自身代码和库执行任何操作,以欺骗 uprobes,因此基于 uprobes 的 eBPF 程序不是一种可靠的监视_不受信任_程序的方法,但它们是收集信息的强大工具。如果您想要监视程序以检测恶意行为,那么 kprobes 更适合此目的。它们基本上具有 uprobes 的相同功能,但在内核方面实现。竞争条件仍然可能存在问题,因此最好在 LSM(Linux 安全模块)中定义的安全点上挂接 kprobes。 2023-11-27 0 0
golang uretprobe的崩溃与模拟实现 前言 在eCapture最初支持golang的https明文捕获时,是不支持request\response完整的匹配的。这点不同于C语言编写的程序,是因为golang的uretprobe类型钩子有个较为致命的bug,会导致被挂载进程崩溃,这问题在BCC社区也有讨论过:Go crash with uretprobe #1320, 火焰图作者brendangregg也提到,在他的一篇博客里,用户评论如下: Another problem I ran into: the uretprobe seems to place the return probes by modifying the stack, which is in conflict with how Go manages stack (stacks in Go can grow/shrink at anytime, it does so by copying entire stack to a new larger area, adjusting the pointers in the stack to point to new area etc). So if we are doing a uretprobe, and stack happens to grow (or shrink) at that time, it can lead Go runtime panics. Please see here for an example panic message:go.stp#L32-L58 也就是说 uretprobe似乎通过修改堆栈来放置返回探针,这与Go管理堆栈的方式冲突(Go中的堆栈可以在任何时候增长/缩小,它通过将整个堆栈复制到一个新的较大区域,调整堆栈中的指针以指向新区域等方式实现)。因此,如果我们正在进行uretprobe操作,并且堆栈在此期间发生增长(或缩小),它可能导致Go运行时发生错误。请参阅此处的示例错误消息:go.stp#L32-L58 亲自验证 是的,笔者在为eCapture增加go tls的明文捕获时,也是attach到Go 函数的uretprobe上,结果自然是,被挂载的进程崩溃了。经过漫长的debug、查资料,终于有点眉目。这其实跟Golang的runtime、寄存器等实现机制有关,我写了一个DEMO,验证一番。 这个DEMO是我在5月初写的,期间一直想写篇简单的文章给大家介绍一下,奈何太忙了,接着这次出差的机会,周末整理一下,分享给大家。时间相隔太久,可能很多细节都忘记了,笔者水平有限,如有错误,欢迎指出。 golang uretprobe冲突 话不多说,Go程序崩溃的核心原因为Go的栈在runtime管理时,被插入了异常的内存地址。Go中常见的堆栈变化为协程goroutine的创建与销毁。栈内 被插入异常内存地址是因为eBPF的实现机制是向函数的返回地址前,插入了断点指令(i386和x86_64是INT3)。 两个条件的叠加,就出现了这个错误。 那么重现起来也比较简单,写一个协程goroutine数量不停变化的程序,并使用eBPF uretprobe挂载上去即可。 案例演示 被HOOK的测试代码 package main import ( "flag" "fmt" "time" ) //go:noinline func recursion(level, maxLevel int) int { if level > maxLevel { return level } return recursion(level+1, maxLevel) } //go:noinline func NewTestFunc() int { //nothing print("NewTestFunc\n") return 100 } // uretprobe挂载的目标函数 // //go:noinline func CountCC(maxLevel int) (a int) { a = NewTestFunc() fmt.Println(a) if a > 100 { return a } a = recursion(0, maxLevel) fmt.Printf("CountCC return :%d\n", a) return a } func main() { var maxLevel = flag.Int("l", 100, "max recursion level") flag.Parse() for { go CountCC(*maxLevel) time.Sleep(time.Second) } } 被挂载的函数是CountCC,他的返回值应该是101,这段代码被Go编译后,CountCC在符号表里名字是main.CountCC,这个就是eBPF挂载的函数名。 要注意,在代码里务必使用go:noline语法来让Go编译器不要对这段代码进行内联inline,否则编译后的可执行文件中,符号表内就找不到main.CountCC函数了。 执行挂载动作的代码 内核空间代码: SEC("uretprobe/countcc") int uretprobe_countcc(struct pt_regs *ctx) { bpf_printk("new countCC[RET] detected\n"); return 0; }; 其中SEC的参数uretprobe/countcc在编译为ebpf字节码后,会被用户空间程序读取,关联到uretprobe_countcc这个符号上。 用户空间代码: 执行挂载动作的代码,也很好实现,使用笔者的golang eBPF管理SDK ebpfmanager,只需要几行代码,以下为用户空间程序: const COUNT_CC_SYMBOL = "main.CountCC" var sec = "uretprobe/countcc" var ebpfFunc = "uretprobe_countcc" var m = &manager.Manager{ Probes: []*manager.Probe{ { Section: sec, EbpfFuncName: ebpfFunc, AttachToFuncName: COUNT_CC_SYMBOL, BinaryPath: goAppPath, }, }, } // Initialize the manager buf, err := Asset("/probe.o") if err != nil { log.Fatal(errors.New(fmt.Sprintf("error:%v , couldn't find asset", err))) } if err = m.Init(bytes.NewReader(buf)); err != nil { log.Fatal(err) } // Start the manager if err = m.Start(); err != nil { log.Fatal(err) } 挂载类型uretprobe/countCC,被Go的eBPF类库解析为uretprobe类型程序。挂载的eBPF执行函数为uretprobe_countcc,挂载目标符号为main.CountCC。 执行重现 编译后,观测程序是main,被观测程序是demo。 启动观测程序bin/main 启动被观测程序bin/demo 崩溃栈信息 可以看到被观测程序立刻崩溃,崩溃的信息如下: bin/demo NewTestFunc 100 runtime: unexpected return pc for main.CountCC called from 0x7fffffffe000 stack: frame={sp:0xc000069f50, fp:0xc000069fc8} stack=[0xc000069000,0xc00006a000) 0x000000c000069e50: 0x000000c0000560d8 0x000000c0000140b8 0x000000c000069e60: 0x000000c000069e80 0x000000000048802b <main.recursion+0x000000000000002b> 0x000000c000069e70: 0x000000000047a0a0 <internal/poll.(*FD).Write.func1+0x0000000000000000> 0x000000c0000560c0 0x000000c000069e80: 0x000000c000069ea0 0x000000000048802b <main.recursion+0x000000000000002b> 0x000000c000069e90: 0x0000000000468efe <sync.(*Pool).pin+0x000000000000001e> 0x000000c0000560c0 0x000000c000069ea0: 0x000000c000069ec0 0x000000000048802b <main.recursion+0x000000000000002b> 0x000000c000069eb0: 0x000000000052e3c0 0x0000000000000000 0x000000c000069ec0: 0x000000c000069ee0 0x000000000048802b <main.recursion+0x000000000000002b> 0x000000c000069ed0: 0x000000000047d76a <fmt.(*pp).free+0x00000000000000ca> 0x000000000052e3c0 0x000000c000069ee0: 0x000000c000069f00 0x000000000048802b <main.recursion+0x000000000000002b> 0x000000c000069ef0: 0x000000c000036740 0x000000000047dc4e <fmt.Fprintln+0x000000000000008e> 0x000000c000069f00: 0x000000c000069f20 0x000000000048802b <main.recursion+0x000000000000002b> 0x000000c000069f10: 0x0000000000000004 0x000000000000000c 0x000000c000069f20: 0x000000c000069f40 0x000000000048802b <main.recursion+0x000000000000002b> 0x000000c000069f30: 0x0000000000000000 0x0000000000000000 0x000000c000069f40: 0x000000c000069fb8 0x0000000000488147 <main.CountCC+0x0000000000000087> 0x000000c000069f50: <0x00000000004c0798 0x000000c00000e018 0x000000c000069f60: 0x000000c0000367a8 0x0000000000000001 0x000000c000069f70: 0x0000000000000001 0x0000000000000000 0x000000c000069f80: 0x0000000000000000 0x0000000000000064 0x000000c000069f90: 0x0000000000000064 0x0000000000000000 0x000000c000069fa0: 0x0000000000000000 0x0000000000490180 0x000000c000069fb0: 0x0000000000528bc0 0x000000c0000367d0 0x000000c000069fc0: !0x00007fffffffe000 >0x0000000000000000 0x000000c000069fd0: 0x0000000000000000 0x000000000045bd81 <runtime.goexit+0x0000000000000001> 0x000000c000069fe0: 0x0000000000000000 0x0000000000000000 0x000000c000069ff0: 0x0000000000000000 0x0000000000000000 fatal error: unknown caller pc runtime stack: runtime.throw({0x4a2fba?, 0x522940?}) /usr/local/go1.18.8/go/src/runtime/panic.go:992 +0x71 runtime.gentraceback(0x423d45?, 0x7f46d2dc2fff?, 0x1?, 0x400?, 0x0, 0x0, 0x7fffffff, 0x4a92e0, 0x7f46d2dc2fff?, 0x0) /usr/local/go1.18.8/go/src/runtime/traceback.go:258 +0x1c2a runtime.copystack(0xc000003860, 0x800000002?) /usr/local/go1.18.8/go/src/runtime/stack.go:930 +0x2f5 runtime.newstack() /usr/local/go1.18.8/go/src/runtime/stack.go:1110 +0x497 runtime.morestack() /usr/local/go1.18.8/go/src/runtime/asm_amd64.s:547 +0x8b 其中致命的错误信息是fatal error: unknown caller pc,是的,重现了。 Go程序uretprobe挂载解决方案 冲突点 正如前文所说,这是golang 协程收缩容,导致stack变动, int3指令执行后,添加到stack中,破坏原来的栈,执行报错。如何解决这个问题呢,在之前的issue里,有人提了一个用uprobe模拟uretprobe的思路。 给定一个Golang二进制文件,解析ELF符号表并获取我们想要跟踪的符号的地址。如果需要,在该地址附加一个uprobes。 不要将uretprobe附加到符号地址,而是从该地址开始读取ELF文本部分,并解码汇编指令,直到达到符号的结束。在扫描过程中,在每个返回过程的指令(例如对于x86-64,RETN指令,操作码为0xC2和0xC3)处放置一个uprobes。对于我感兴趣的符号,通常只有很少的RET指令,大约在1到5个范围内,这是合理的。 当在上述点安装的任一uprobes触发时,实际上就像我们执行了一个uretprobe一样,除了我们没有干扰堆栈,因此当Go运行时移动堆栈时,解决方案足够稳健以避免崩溃(至少看起来是这样)。而且,由于uprobes恰好放置在RET指令之前,栈指针已经方便地放置在帧的开头,因此我们可以轻松访问输入参数和返回值,因为它们在Go中都存储在栈上。 评论者还提到,这种方法具有一些轻微的性能优势,因为我们避免了uretprobe的开销。但缺点是我们现在必须在用户空间中解码ELF文件的汇编指令,所以相比标准的替代方案要麻烦得多,而且,无法使用BCC之类工具,只能自己实现eBPF程序。 Go函数的RET偏移地址 这可难不到我,笔者一直不太用BCC,更喜欢自己写eBPF程序。实现起来也很简单,只需要按照DWARF Debugging Standard规范,读取Golang的ELF文件,查找符号表内对应main.CountCC函数对应符号的汇编指令,并按照X86格式解析,循环判断是否为RET,并记录当前指令在整个函数符号的偏移地址即可。 goElf, err = elf.Open(elfPath) // ... goSymbs, err = goElf.Symbols() // ... var found bool var symbol elf.Symbol for _, s := range goSymbs { if s.Name == symbolName { symbol = s found = true break } } section := goElf.Sections[symbol.Section] var elfText []byte elfText, err = section.Data() // ... start := symbol.Value - section.Addr end := start + symbol.Size var instHex []byte instHex = elfText[start:end] for i := 0; i < len(instHex); { inst, err := x86asm.Decode(instHex[i:], 64) // ... if inst.Op == x86asm.RET { offsets = append(offsets, i) } i += inst.Len } 内核空间程序 因为是用uprobe来模拟uretprobe,eBPF内核代码肯定要调整的了,为了要验证能否拿到返回值,这里也增加了返回值的获取。 SEC("uprobe/countcc") int uprobe_countcc(struct pt_regs *ctx) { bpf_printk("new countCC detected\n"); int num; num = (int)GO_PARAM1(ctx); bpf_printk("countCC :: num:%d, ret_num:%d\n", num); return 0; }; 可以看到,这里新增一个函数uprobe_countcc,将用于用户空间的eBPF执行函数。 用户空间程序调整 经过ELF文件分析,将RET指令的偏移地址保存到offsets中,在用户空间挂载到函数的偏移位置上: sec = "uprobe/countcc" ebpfFunc = "uprobe_countcc" m.Probes = m.Probes[:0] // 清空slice for _, offset := range offsets { m.Probes = append(m.Probes, &manager.Probe{ Section: sec, UprobeOffset: uint64(offset), EbpfFuncName: ebpfFunc, AttachToFuncName: COUNT_CC_SYMBOL, BinaryPath: goAppPath, UID: fmt.Sprintf("%s_%d", ebpfFunc, offset), }) } 可以看到Section改成了uprobe/countcc, 并挂载到内核函数uprobe_countcc上。以及新增 UprobeOffset字段,并设定offset,这样就实现自动的uprobe偏移量挂载。(PS:你就说,笔者的 ebpfmanager方便不方便吧) 模拟验证 按照之前的步骤,先启动观测程序,打开内核调试的日志,再启动被观测程序: 启动观测程序,bin/main -e,这里多了-e参数,来使用模拟模式。 打开内核调试日志,方便观察是否能拿到main.CountCC函数的返回值,命令为cat /sys/kernel/debug/tracing/trace_pipe。 启动被观测程序,bin/demo 观测程序 观测程序启动后,可以看到终端日志中,搜索到两处RET指令,并分别进行uprobe`挂载。 root@vm-server-2004:/home/cfc4n/project/go_uretprobe_demo# bin/main -e 2023/06/11 23:49:18 Github repo : https://github.com/cfc4n/go_uretprobe_demo 2023/06/11 23:49:18 Use uprobe+offset address instead of uretprobe:true 2023/06/11 23:49:18 traced ELF file:/home/cfc4n/project/go_uretprobe_demo/bin/demo 2023/06/11 23:49:18 attach function: main.CountCC 2023/06/11 23:49:18 Golang uretprobe hook main.CountCC [RET] at 0x7A 2023/06/11 23:49:18 Golang uretprobe hook main.CountCC [RET] at 0xE3 2023/06/11 23:49:18 successfully started, head over to /sys/kernel/debug/tracing/trace_pipe main.CountCC函数内,RET汇编指令的偏移地址分别为0x7A、0xE3 ,且都挂载成功,执行的内核函数为uprobe_countcc。 被观察程序 如你所见,被观测程序没有崩溃,可以正常运行,并输出结果。 bin/demo NewTestFunc 100 CountCC return :101 NewTestFunc 100 CountCC return :101 NewTestFunc 100 CountCC return :101 观察结果 笔者的DEMO里没有将内核调试结果传输到用户空间,直接打印了。 root@vm-server-2004:/home/cfc4n# cat /sys/kernel/debug/tracing/trace_pipe demo-18960 [000] .... 5125.277053: 0: new countCC detected demo-18960 [000] .... 5125.277089: 0: countCC :: num:101, ret_num:0 demo-18962 [001] .... 5126.276907: 0: new countCC detected demo-18962 [001] .N.. 5126.276940: 0: countCC :: num:101, ret_num:0 可以看到,demo-18960 (程序名+PID)运行结果后,出现了我们打印的日志。并且,捕获的结果是101,符合预期。 总结 eBPF挂载uretprobe崩溃的问题,只在Golang程序上发生,这跟Golang的协程缩容、扩容机制有关,受到CPU中断指令插入影响,破坏原有调用栈,导致问题发生。其他编译型语言上,不会有这个问题。假如有的语言也跟Golang一样,使用stack来做运动时管理,哪也会遇到这个问题。 关于 Golang的这个问题,在其社区里也有关于runtime: fatal error: unknown caller pc when uprobes are attached #27077的讨论,Go语言开发者aclements认为,这不是Go的问题,近期也不会考虑修复,希望uretprobe的管理层面,自动做返回地址栈的修复。 感谢提供的参考资料,@sillyousu。这些资料确认了我的猜测,很不幸地,我们实际上无法有效地解决uretprobes损坏堆栈的问题。 既然我们无能为力,而且这并不是一个Go的错误,我决定关闭这个问题。如果将来uretprobes能够提供足够的信息来恢复用户空间中被破坏的返回地址,我们可以重新考虑这个问题,并可能找到解决方法。 所以,这个问题,大家还是自己使用模拟的方法来解决Golang程序的函数返回值观测需求吧。eCapture也是自己写了PR支持了Go TLS的明文捕获:support gotls request and response #357。 本次DEMO的测试代码在GitHub仓库:cfc4n/go_uretprobe_demo ,祝大家玩得开心。 写于2023年6月11日,周末,雷阵雨,北京望京。 2023-06-12 0 0
如何使用eBPF观测用户空间应用程序 译者注 近来这一年,很多刚接触eBPF的朋友会问我,eCapture的原理是什么,为什么区分OpenSSL、Gnutls、Nspr等类库实现?为什么要设定OpenSSL类库地址?为什么C、JAVA、Go实现的https通讯程序,在eCapture上实现却不一样。对于这些问题,我觉得核心问题是大家对「eBPF实现用户空间的行为跟踪」原理不了解,一直想写一篇文章介绍这个知识点,但总是太忙,没时间。这几天看到外网一篇简单的介绍,文章名是How to Instrument UserLand Apps with eBPF,我在这里翻译、调整一下,分享给大家。 前言 eBPF彻底改变了Linux内核中的可观察性。在之前的博客文章中,我介绍了eBPF生态系统的基本构建,揭开了XDP的面纱,并展示了它与eBPF基础设施如何紧密合作,以便在网络堆栈中引入快速处理的数据路径。 然而,eBPF并不是kernel-space内核空间跟踪所独有的。如果我们能够检测在生产环境中运行的应用程序,同时享受eBPF驱动的跟踪的好处,那不是很赞吗? 这就是eBPF uprobe的价值所在。可以直白地把它们看成附加到用户空间的跟踪点,跟内核符号的kprobe类似。 许多语言的运行时、数据库系统以及其他软件堆栈都包含可供BCC工具使用的钩子。具体地说,ustat工具会收集有价值的事件,例如垃圾回收事件、对象创建统计信息、方法调用等等。 但是“,很多官方语言运行时版本,都不附带对DTrace支持,比如Node.js和Python等,这意味着您必须从源代码构建时,就设定好参数。也就是说,编译python这个解释语言时,就需要在参数中指定。将--with -dtrace标志传递给编译器。当然,这不是必要条件。对于ELF文件,只要符号表可用,就可以对它Section段中的任何符号进行应用动态跟踪。对Go或Rust stdlib的函数调用是通过这种方式完成的。 也就是说,对于eCapture来说,哪怕是TLS类库是静态编译的或者没有符号表的,也是可以通过自行确定Offset的方式,来实现对指定偏移地址进行动态跟踪。在eHIDS-Agent也有过一个例子,user/probe_ujava_rasp.go的92行: /* openjdk version "1.8.0_292" OpenJDK Runtime Environment (build 1.8.0_292-8u292-b10-0ubuntu1-b10) OpenJDK 64-Bit Server VM (build 25.292-b10, mixed mode) */ //ex, err := link.OpenExecutable("/usr/lib/jvm/java-8-openjdk-amd64/jre/lib/amd64/libjava.so") // sub_19C30 == JDK_execvpe(p->mode, p->argv[0], p->argv, p->envv); // md5sum : 38590d0382d776234201996e99487110 /usr/lib/jvm/java-8-openjdk-amd64/jre/lib/amd64/libjava.so Probes: []*manager.Probe{ { Section: "uprobe/JDK_execvpe", EbpfFuncName: "java_JDK_execvpe", AttachToFuncName: "JDK_execvpe", UprobeOffset: 0x19C30, BinaryPath: "/usr/lib/jvm/java-8-openjdk-amd64/jre/lib/amd64/libjava.so", }, } 对于相应JVM的libjava.so中,符号表是少了JDK_execvpe这个函数。但是,依旧可以通过IDA pro等工具,对so文件进行静态分析,定位到JDK_execvpe的偏移地址是0x19C30,从而使用eBPF uprobe的HOOK方式完成HOOK。 其实,在eBPF的加载器类库中,不管是C的libbbpf还是Go的cilium/ebpf,都会自行读取uprobe的二进制ELF文件,自行读取符号表,进行被HOOK函数的偏移地址定位,最终依旧使用偏移地址作为HOOK参数。 用于检测应用程序的eBPF技术 有多种方法可以跟踪用户空间进程: 静态声明的USDT 动态声明USDT 使用uprobe进行动态跟踪 静态声明USDT USDT(Userland Statically Defined Tracing)体现了直接在用户代码中嵌入探针的想法。该技术的起源可以追溯到Solaris/BSD DTrace时代,包括使用DTRACE_PROBE()宏来声明策略代码位置的跟踪点。与普通符号不同,USDT钩子可以保证在代码被重构的情况下保持稳定。下图描述了在用户代码中声明USDT跟踪点,以及其在内核中执行的整个过程。 开发人员将首先通过DTRACE_PROBE和DTRACE_PROBE1宏来植入跟踪点,用来圈定感兴趣的代码块。这两个宏都接受几个强制性参数,例如provider/probe的名称,然后是你想从追踪点了解的任何值。编译器会在目标二进制文件的ELF部分中压制USDT追踪点。同时,编译器和追踪工具之间有个契约规定,也就是USDT的元数据所在的.note.stapstd段必须存在。 USDT跟踪工具会对ELF部分进行自检,并在跟踪点得位置放置一个断点,该断点将转换为int 3中断。每当在跟踪点的标记处执行控制流时,都会触发中断处理程序,并在内核中调用与uprobe关联的程序来处理事件并将它们发送到用户空间,执行相应的数据聚合等处理。 动态声明USDT 由于USDT被推入静态生成的ELF部分,对于在解释型语言或基于JIT的语言上运行的软件来说,它违背了声明USDT的目的。幸运的是,可以通过libstapsdt在运行时定义跟踪点。它生成一个带有 USDT 信息的小型共享对象,该对象映射到进程的地址空间,因此跟踪工具可以附加到期望的目标跟踪点。libstapsdt的绑定在大部分语言中都有。可以阅读这个示例,来了解如何在Node.js中安装 USDT 探针。 使用 uprobes 进行动态跟踪 这种类型的跟踪机制除了目标程序的符号表是可访问以外,不需要何额外功能。这是最通用、最强大的插桩方法,因为它允许在任意指令上注入断点,甚至无需重新启动目标进程。 跟踪示例 在简单的理论介绍之后,让我们看看一些具体的例子,看看如何针对不同的语言的应用程序进行插桩。 C语言程序 Redis 是用 C 语言实现的热门KV对数据结构服务器。查看一下 Redis 符号表会发现大量函数可以通过 uprobes 捕获。 $ objdump -tT /usr/bin/redis-server … 000000000004c160 g DF .text 00000000000000cc Base addReplyDouble 0000000000090940 g DF .text 00000000000000b0 Base sha1hex 00000000000586e0 g DF .text 000000000000007c Base replicationSetMaster 00000000001b39e0 g DO .data 0000000000000030 Base dbDictType 00000000000ace20 g DF .text 0000000000000030 Base RM_DictGetC 0000000000041bc0 g DF .text 0000000000000073 Base sdsull2str 00000000000bba00 g DF .text 0000000000000871 Base raxSeek 00000000000ac8c0 g DF .text 000000000000000c Base RM_ThreadSafeContextUnlock 00000000000e3900 g DF .text 0000000000000059 Base mp_encode_lua_string 00000000001cef60 g DO .bss 0000000000000438 Base rdbstate 0000000000047110 g DF .text 00000000000000b5 Base zipSaveInteger 000000000009f5a0 g DF .text 0000000000000055 Base addReplyDictOfRedisInstances 0000000000069200 g DF .text 000000000000004a Base zzlDelete 0000000000041e90 g DF .text 00000000000008ba Base sdscatfmt 000000000009ac40 g DF .text 000000000000003a Base sentinelLinkEstablishedCallback 00000000000619d0 g DF .text 0000000000000045 Base psetexCommand 00000000000d92f0 g DF .text 00000000000000fc Base luaL_argerror 00000000000bc360 g DF .text 0000000000000328 Base raxRandomWalk 0000000000096a00 g DF .text 00000000000000c3 Base rioInitWithFdset 000000000003d160 g DF .text 0000000000000882 Base serverCron 0000000000032907 g DF .text 0000000000000000 Base je_prof_thread_name_set 0000000000043960 g DF .text 0000000000000031 Base zfree 00000000000a2a40 g DF .text 00000000000001ab Base sentinelFailoverDetectEnd 00000000001b8500 g DO .data 0000000000000028 Base je_percpu_arena_mode_names 00000000000b5f90 g DF .text 0000000000000018 Base geohashEstimateStepsByRadius 00000000000d95e0 g DF .text 0000000000000039 Base luaL_checkany 0000000000048850 g DF .text 00000000000002d4 Base createClient ... Redis 内部使用了一个有趣的createStringObject函数来分配robj结构的字符串。Redis 命令是以createStringObject调用名义生成的。我们可以通过挂钩这个函数来监视发送到 Redis 服务器的任何命令。为此,我将使用BCC工具箱中的跟踪工具来演示。 $ /usr/share/bcc/tools/trace '/usr/bin/redis-server:createStringObject "%s" arg1' PID TID COMM FUNC - 8984 8984 redis-server createStringObject b'COMMANDrn' 8984 8984 redis-server createStringObject b'setrn$4rnoctirn$4rnfestrn' 8984 8984 redis-server createStringObject b'octirn$4rnfestrn' 8984 8984 redis-server createStringObject b'festrn' 8984 8984 redis-server createStringObject b'getrn$4rnoctirn' 8984 8984 redis-server createStringObject b'octirn' 以上是在Redis CLI客户端执行set octi fest和get octi所产生的输出。 JAVA语言程序 现代的JVM版本带有对USDT的内置支持。所有的探针都是以libjvm共享对象的名义带来的。我们可以在ELF部分挖掘出可用的追踪点。 $ readelf -n /usr/lib/jvm/jdk-11-oracle/lib/server/libjvm.so ... stapsdt 0x00000037 NT_STAPSDT (SystemTap probe descriptors) Provider: hs_private Name: cms__initmark__end Location: 0x0000000000e2420c, Base: 0x0000000000f725b4, Semaphore: 0x0000000000000000 Arguments: stapsdt 0x00000037 NT_STAPSDT (SystemTap probe descriptors) Provider: hs_private Name: cms__remark__begin Location: 0x0000000000e24334, Base: 0x0000000000f725b4, Semaphore: 0x0000000000000000 Arguments: stapsdt 0x00000035 NT_STAPSDT (SystemTap probe descriptors) Provider: hs_private Name: cms__remark__end Location: 0x0000000000e24418, Base: 0x0000000000f725b4, Semaphore: 0x0000000000000000 Arguments: stapsdt 0x0000002f NT_STAPSDT (SystemTap probe descriptors) Provider: hotspot Name: gc__begin Location: 0x0000000000e2b262, Base: 0x0000000000f725b4, Semaphore: 0x0000000000000000 Arguments: 1@$1 stapsdt 0x00000029 NT_STAPSDT (SystemTap probe descriptors) Provider: hotspot Name: gc__end Location: 0x0000000000e2b31a, Base: 0x0000000000f725b4, Semaphore: 0x0000000000000000 Arguments: ... 要捕获所有class load类加载事件,我们可以使用以下命令: $ /usr/share/bcc/tools/trace 'u:/usr/lib/jvm/jdk-11-oracle/lib/server/libjvm.so:class__loaded "%s", arg1' 同样,我们可以观察线程创建事件: $ /usr/share/bcc/tools/trace 'u:/usr/lib/jvm/jdk-11-oracle/lib/server/libjvm.so:thread__start "%s", arg1' 结果输出 PID TID COMM FUNC 27390 27398 java thread__start b'Reference Handler' 27390 27399 java thread__start b'Finalizer' 27390 27400 java thread__start b'Signal Dispatcher' 27390 27401 java thread__start b'C2 CompilerThread0' 27390 27402 java thread__start b'C1 CompilerThread0' 27390 27403 java thread__start b'Sweeper thread' 27390 27404 java thread__start b'Service Thread' 当扩展探针启用时(即-XX:+ExtendedDTraceProbes属性),uflow 工具能够实时跟踪并绘制所有方法的执行过程。 $ /usr/share/bcc/tools/lib/uflow -l java 27965 Tracing method calls in java process 27965... Ctrl-C to quit. CPU PID TID TIME(us) METHOD 5 27965 27991 0.736 <- jdk/internal/misc/Unsafe.park 5 27965 27991 0.736 -> java/util/concurrent/locks/LockSupport.setBlocker' 5 27965 27991 0.736 -> jdk/internal/misc/Unsafe.putObject 5 27965 27991 0.736 <- jdk/internal/misc/Unsafe.putObject 5 27965 27991 0.736 <- java/util/concurrent/locks/LockSupport.setBlocker' 5 27965 27991 0.736 <- java/util/concurrent/locks/LockSupport.parkNanos 5 27965 27991 0.736 -> java/util/concurrent/locks/AbstractQueuedSynchronizer$ConditionObject.checkInterruptWhileWaiting 5 27965 27991 0.737 -> java/lang/Thread.interrupted 5 27965 27991 0.737 -> java/lang/Thread.isInterrupted 5 27965 27991 0.737 <- java/lang/Thread.isInterrupted 5 27965 27991 0.737 <- java/lang/Thread.interrupted 5 27965 27991 0.737 <- java/util/concurrent/locks/AbstractQueuedSynchronizer$ConditionObject.checkInterruptWhileWaiting 5 27965 27991 0.737 -> java/lang/System.nanoTime 5 27965 27991 0.737 <- java/lang/System.nanoTime 但是,扩展探针所产生的系统开销,是特别特别大的,所以,它们不适合生产环境,仅用于调试。 Go语言程序 我将用Go语言中的一个例子来完成对追踪技术的演示。 由于Go语言是一种原生编译语言,因此尝试使用trace工具在目标符号上附加uprobe程序。 您可以使用以下简单的代码片段亲自尝试: package main import "fmt" func main() { fmt.Println("Hi") } $ go build -o hi build.go $/usr/share/bcc/tools/trace '/tmp/hi:fmt.Println "%s" arg1' PID TID COMM FUNC - 31545 31545 hi fmt.Println b'xd6x8dK' 我们在参数列中得到不是我们期望的“Hi”字符串,而是一些随机的垃圾数据。这是由于trace不能处理Println变量参数造成的,但也是有关ABI调用约定的错误假设。 与C/C++不同的是,Go语言在堆栈上传递参数,而C/C++更喜欢在普通的寄存器中传递参数。 由于我们不能依靠trace来演示如何插桩Go代码,我将构建一个简单的工具来跟踪所有由http.Get函数发出的HTTP GET请求。 你可以很容易地修改它,来捕获其他HTTP请求。但我只是用这个例子演示, 完整的源代码可以在https://github.com/sematext/uprobe-http-tracer这个repo中找到。 由于我们使用libbcc的Go绑定来完成繁重的工作,所以我不会去讨论关于uprobe attach/load过程的细节。OK,一起来看看真实的uprobe程序。 在所需的包含include之后,我们定义了负责通过偏移处理从堆栈中读取参数的宏。 #define SP_OFFSET(offset) (void *)PT_REGS_SP(ctx) + offset * 8 接下来,我们声明用于封装通过reqs map流传输的事件结构。 这个map是用BPF_PERF_OUTPUT宏定义的。 我们的程序的核心是__uprobe_http_get函数。 每当调用http.Get时,在内核空间中触发这个函数。 我们知道http.Get有一个参数,它表示HTTP请求被发送到的URL。 C和Go语言的另一个区别是它们在内存中如何布局字符串。 C语言的字符串是以 null 结尾的序列,但 Go 将字符串视为包含指向内存缓冲区的指针和字符串长度的两个字值。这说明我们需要对bpf_probe_read进行两次调用,一次用于读取字符串,第二次用于读取其长度。 bpf_probe_read(&url, sizeof(url), SP_OFFSET(1)); bpf_probe_read(&req.len, sizeof(req.len), SP_OFFSET(2)); 触发之后,在用户空间中,URL从slice切片被修剪到其相应的长度。顺便说一下,这工具demo能够通过注入uretprobe来发现每个HTTP GET请求的延迟。然而,事实证明,每次 Go 运行时决定收缩/增长堆栈时,都会产生灾难性的影响,因为uretprobe 会将堆栈上的返回地址修补到在 eBPF VM 的上下文中执行的trampoline函数。在退出uretprobe函数时,指令指针恢复到原始返回地址,这个地址可能指向一个无效地址,从而扰乱堆栈并导致进程崩溃。有一些提议来解决这个问题:Go crash with uretprobe #1320。 结论 在这篇文章中,我们介绍了用于User Space用户空间进程插桩的eBPF特性。 通过几个实际案例,我们已经展示了BCC框架在捕获可观测性信号方面的通用性。 至此,相信你对eBPF uprobe的动态插桩有了一定的了解。也可以阅读eCapture源码,更好的实战。 在golang语言的二进制程序插桩实现中,一定要考虑ABI的规范差异,不过,golang官方也在考虑调整参数传递方式,从堆栈改到寄存器,你可以查看提案:基于寄存器的 Go 调用约定 了解更多详情。 2022-10-27 0 0
eCapture:无需CA证书抓https网络明文通讯 eCapture介绍 eCapture是一款基于eBPF技术实现的用户态数据捕获工具。不需要CA证书,即可捕获https/tls的通讯明文。 项目在2022年3月中旬创建,一经发布,广受大家喜爱,至今不到两周已经1200多个Star。 作用 不需要CA证书,即可捕获HTTPS/TLS通信数据的明文。 在bash审计场景,可以捕获bash命令。 数据库审计场景,可以捕获mysqld/mariadDB的SQL查询。 官网 代码仓库见:https://github.com/ehids/ecapture ,也可以关注微信公众号「榫卯江湖」获取最新动态。 产品架构 eCapture系统用户态程序使用Golang语言开发,具有良好的系统兼容性,无依赖快速部署,更适合云原生场景。 内核态代码使用C编写,使用clang/llvm编译,生产bpf字节码后,采用go-bindata转化为golang语法文件,之后采用ehids/ebpfmanager类库,调用bpf syscall进行加载、HOOK、map读取。 golang编译后,无其他任何依赖即可运行,兼容linux kernel 4.18以上所有版本。 eBPF加载机制 关于eBPF详细加载机制,可到https://ebpf.io/ 查阅相关原理。 实现原理 如工作原理的图所示,在用户态的加密解密函数中下钩子。 tcpdump(libpcap)是在数据包接收到,XDP处理后,进行clone packet,进行包的复制,发送给用户态进程。二者工作的所在层不一样。 功能介绍 eCapture有三个模块 tls/ssl明文数据捕获 bash命令审计 mysqld数据库审计 第一个功能适用于基于tls/ssl解密需求的运维监控、故障排查、抽样分析场景。 第二个功能适用于安全领域的bash入侵发现场景,这里只是简单的功能,可以在此基础上增加其他功能。 第三个功能适用于数据库审计场景,尤其是做数据安全、数据防泄漏,甚至入侵检测等。同样,可以在此基础上扩充其他功能。 查看其使用说明,如下 cfc4n@vm-desktop:~/ehids/ecapture$ ./bin/ecapture NAME: ecapture - capture text SSL content without CA cert by ebpf hook. USAGE: ecapture [flags] VERSION: 0.1.5-20220325-47edbed COMMANDS: bash capture bash command help Help about any command mysqld56 capture sql queries from mysqld >5.6 . tls alias name:openssl , use to capture tls/ssl text content without CA cert. DESCRIPTION: ecapture是一款无需安装CA证书,即可抓去HTTPS、TLS等明文数据包的工具。 也可以捕获bash的命令,适用于安全审计场景。包括mysqld的数据库审计等。 仓库地址: https://github.com/ehids/ecapture OPTIONS: --debug[=false] enable debug logging -h, --help[=false] help for ecapture --hex[=false] print byte strings as hex encoded strings -p, --pid=0 if target_pid is 0 then we target all pids 其中,有四个全局参数,分别是 --debug , 用于启动调试日志 --help , 查看帮助 --hex ,按照hex模式打印字符,用与查看不可见字符 --pid ,用于针对特定进程进行数据捕获 HOOK机制 eCapture采用eBPF uprobe相关函数进行HOOK,故需要目标用户态函数信息,包含函数符号表(symbol table),函数偏移地址(offset)。 在大部分linux发行版中,使用的二进制可执行文件(ELF)都是包含符号表的;少部分发行版,会去掉ELF中的符号表。那么针对这种场景,就需要用户自行定位目标函数所在ELF/SO中的偏移地址,通过工具的参数来指定。 对于ELF文件,可以将目标类库静态编译到自身,也可以通过动态链接库的方式引用。那么对于这两种形式,eCapture根据不同场景进行自动查找。若查找不到,用户可以通过命令行参数指定。 故eCapture支持HOOK ELF,以及HOOK SO两种模式。会自动分析ELF文件,读取.dynamic和.dynsym等段信息,查找相关链接库名以及函数名、偏移地址。 查找原理如下图: tls/ssl ecapture tls命令用于启动tls/ssl模块,支持了三类tls/ssl加密类库,分别是 openssl ,动态链接库名字为libssl.so gnutls ,动态链接库名字为libgnutls.so nss/nspr ,动态链接库名字为libnspr4.so 在不同的linux发行版中,因为各种原因,会选择不同的类库。比如wget程序,在ubuntu跟centos中就会使用不同的类库。有的是openssl,有的是gnutls,甚至两个库都引入了。 具体情况,你可以使用ldd $ELF_PATH | grep -E "tls|ssl|nspr|nss"来查看一个ELF文件使用类库情况。 cfc4n@vm-desktop:~$ ldd `which wget` |grep -E "tls|ssl|nspr|nss" libssl.so.1.1 => /lib/x86_64-linux-gnu/libssl.so.1.1 (0x00007f50699f6000) 对于firefox、chrome这种进程,需要在程序启动后才能看到tls类库依赖情况,那么,你可以使用sudo pldd $PID | grep -E "tls|ssl|nspr|nss" 来查看 cfc4n@vm-desktop:~$ ps -ef|grep firefox cfc4n 6846 1432 45 17:50 ? 00:00:04 /usr/lib/firefox/firefox -new-window cfc4n@vm-desktop:~$ sudo pldd 6846 |grep -E "tls|ssl|nspr|nss" /usr/lib/firefox/libnspr4.so /usr/lib/firefox/libnssutil3.so /usr/lib/firefox/libnss3.so /usr/lib/firefox/libssl3.so /lib/x86_64-linux-gnu/libnss_files.so.2 /lib/x86_64-linux-gnu/libnss_mdns4_minimal.so.2 /lib/x86_64-linux-gnu/libnss_dns.so.2 /usr/lib/firefox/libnssckbi.so eCapture的tls模块命令行参数如下,用户可以使用默认配置外,也可以根据自己环境自行指定。 OPTIONS: --curl="" curl or wget file path, use to dectet openssl.so path, default:/usr/bin/curl --firefox="" firefox file path, default: /usr/lib/firefox/firefox. --gnutls="" libgnutls.so file path, will automatically find it from curl default. -h, --help[=false] help for tls --libssl="" libssl.so file path, will automatically find it from curl default. --nspr="" libnspr44.so file path, will automatically find it from curl default. --wget="" wget file path, default: /usr/bin/wget. 同时,使用方法也比较简单,./ecapture tls --hex命令即可。 在linux上,firefox程序中,有很多通讯都使用了/usr/lib/firefox/libnspr4.so,但实际上业务请求是可以通过Socket Thread进程来发送的。可以通过这个特点来过滤,对于chrome程序,相信细心的你,也能搞定。 bash 笔者在安全部门工作,接到过bash审计需求,其实现方法无非是修改系统类库、使用内核模块等技术实现,对系统稳定性有一定风险。基于eBPF技术实现,可以避开这些问题。这里的bash命令的监控,是作为eBPF技术在安全审计场景中的一个探索。 eCapture在实现时首先查找ENV的$SHELL值,作为bash的二进制文件路径进行HOOK。对于bash加载了libreadline.so的场景,也会自动分析,进行符号表查找、offset定位,再进行HOOK。 bash模块的参数有三个,用户可以自定义bash、readlineso的路径。 OPTIONS: --bash="" $SHELL file path, eg: /bin/bash , will automatically find it from $ENV default. -h, --help[=false] help for bash --readlineso="" readline.so file path, will automatically find it from $BASH_PATH default. mysql/mariadb 与bash模块一样,也是作为数据库审计的一个探索。笔者环境为ubuntu 21.04,mysqld也因为协议关系,使用了衍生的MariadDB,用户也可以根据自己实际场景,使用命令行参数进行指定。 mysqld模块,核心原理是HOOK了dispatch_command函数, 第一个参数为CMD类型,值为COM_QUERY时,为查询场景,即审计需求的查询类型。 第二个参数是THD的结构体,在这里我们用不到。 第三个是查询的SQL语句 第四个参数是SQL语句的长度, // https://github.com/MariaDB/server/blob/b5852ffbeebc3000982988383daeefb0549e058a/sql/sql_parse.h#L112 dispatch_command_return dispatch_command(enum enum_server_command command, THD *thd, char* packet, uint packet_length, bool blocking = true); mysqld审计模块参数如下: OPTIONS: -f, --funcname="" function name to hook -h, --help[=false] help for mysqld56 -m, --mysqld="/usr/sbin/mariadbd" mysqld binary file path, use to hook --offset=0 0x710410 其中,--mysqld是用来指定mysqld的路径。 mysqld二进制程序符号表里虽然有dispatch_command信息,但dispatch_command这个函数名每次编译都是变化的,故不能写死。 eCapture的查找方式是读取mysqld二进制的.dynamic段信息,正则语法\w+dispatch_command\w+去匹配所有符号信息,找到其函数名、偏移地址,再使用。 你也可以通过objdump命令来查找,再通过命令行参数自行指定funcname。 mariadbd version : 10.5.13-MariaDB-0ubuntu0.21.04.1 objdump -T /usr/sbin/mariadbd |grep dispatch_command 0000000000710410 g DF .text 0000000000002f35 Base _Z16dispatch_command19enum_server_commandP3THDPcjbb 即offset为0x710410,函数名为_Z16dispatch_command19enum_server_commandP3THDPcjbb。 使用 下载二进制包 eCapture发布在https://github.com/ehids/ecapture/releases ,目前最新版为eCapture v0.1.5。 可在linux kernel 4.18以上版本运行。 二进制包地址: https://github.com/ehids/ecapture/releases/download/v0.1.5/ecapture_v0.1.5.zip 国内加速地址: https://github.do/https://github.com/ehids/ecapture/releases/download/v0.1.5/ecapture_v0.1.5.zip 自行编译 代码仓库在https://github.com/ehids/ecapture ,你可以自行修改源码编译。 演示视频 腾讯视频: B站: https://www.bilibili.com/video/BV1si4y1Q74a Github仓库 2022-03-27 +3 7 0