type
status
date
slug
summary
tags
category
icon
password
0x00 probe
1. 概述
Uprobes
(用户空间探针)是Linux内核的一个特性,它允许在任何用户空间程序的任何指令上设置钩子(hook)。当这些钩子被触发时,会创建一个事件,并将被探测程序的上下文(例如,CPU寄存器的值)提供给处理程序,这些处理程序可以是eBPF(扩展的Berkeley Packet Filter)程序。通过这种方式,你可以记录程序执行时的详细信息,或者执行特定的eBPF程序来分析或修改程序行为。例如,Quarkslab开发的peetch工具集就利用了uprobes和eBPF技术。它通过在OpenSSL库的
SSL_read()
和SSL_write()
函数上设置uprobes钩子,来全系统地记录TLS(传输层安全协议)消息。当这些函数被调用时,uprobes机制会触发,并允许eBPF程序访问这些函数的上下文,包括可以读取或修改CPU寄存器的值。这样,peetch能够在TLS加密之前或解密之后捕获数据,实现对TLS消息的明文访问。这种技术的应用场景包括但不限于安全研究、性能分析、调试以及监控和审计系统行为。uprobes提供了一种强大的机制来深入理解和控制运行中的程序,而eBPF则扩展了这种能力,允许开发者编写高效且灵活的程序来处理uprobe生成的事件。
可以通过向
/sys/kernel/debug/tracing/uprobe_events
中添加内容进行挂钩例如假设要监听
/bin/bash
的readline调用,执行下面的命令即可通过读取
/sys/kernel/tracing/error_log
查看错误日志 同样也可以通过
bcc
等脚手架使用uprobe
挂钩各种调用2. 实现
在设置 uprobes 时,内核会调用 probes_write() 和 trace_uprobe_create(),它们又调用 __trace_uprobe_create()。(在5.10版本的源码中没有
__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
等)。
当启用
uprobes
时,会发生以下嵌套调用:trace_uprobe_register() => probe_event_enable() => trace_uprobe_enable() => uprobe_register()。
当探针被注册时,除了访问引用计数(access refcount)外,
__uprobe_register
还会通过 alloc_uprobe
增加一个创建引用计数(creation refcount),但这只在探针被插入红黑树(即该 @inode:@offset
对的第一个消费者)时发生。创建引用计数防止在注册操作完成之前 uprobe_unregister
函数释放 @uprobe
alloc_uprobe()
:这个函数创建一个 struct uprobe
实例。struct uprobe
是一个结构体,用于存储探针的相关信息,包括要放置探针的文件(inode)、在文件中的偏移量(offset),以及被替换的指令。创建的 struct uprobe
实例包含了探针需要的所有信息,以便将来使用。insert_uprobe()
:这个函数将创建的 struct uprobe
实例添加到一个红黑树(rb_tree)中。这个红黑树用于存储所有的探针实例,以便高效地查找和管理。register_for_each_vma()
:这个函数遍历所有现有的虚拟内存区域(VMA),寻找与某个探针的 inode
对应的 VMA
。对于每个找到的对应 VMA,valid_vma
函数会检查它是否是一个有效的 VMA。install_breakpoint()
:在确认 VMA 有效后,这个函数会在 arch.insn
结构体中复制完整的被探测指令。arch.insn
是一个依赖于当前架构的结构体,用于存储指令信息。然后,它会将原指令替换为一个断点ret = set_swbp(&uprobe->arch, mm, vaddr);
(set_swbp - store breakpoint at a given address.
)。这样,当程序执行到这个位置时,执行会暂停。然后需要了解当一个新的ELF(Executable and Linkable Format)程序被执行时,其内存是如何通过
mmap
系统调用被映射的过程,以及uprobes(用户空间探针)是如何被添加到新的程序实例中的。mmap
系统调用的入口点,用于请求内核将文件或设备的某个部分映射到内存中。__vma_adjust()
是一个辅助函数,用于在添加或修改VMA时调整VMA的属性。如果VMA是基于文件的(即代码段或数据段映射自一个文件),这个函数会调用uprobe_mmap()
,这个函数专门处理与uprobes相关的VMA。如果VMA有效,build_probe_list()
函数会查找与VMA关联的文件(inode)匹配的所有uprobes,并将它们组织成一个列表。对于列表中的每个uprobe,install_breakpoint
会在程序的代码中安装一个断点。当程序执行到这个断点时,会触发一个事件,允许调试器或其他工具介入程序的执行。在ARM 64中,断点会被设置为A0 00 20 D4 BRK #5
综上,其实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
属性通知链中注册的通知器发生了一个事件。对于我们的uprobe,这个事件是arch_uprobe_exception_notify()
,这个函数在uprobe_init()
时设置。它调用uprobe_pre_sstep_notifier()
设置TIF_UPROBE
标志。当线程返回用户空间时,会注意到TIF_UPROBE
标志,并调用uprobe_notify_resume(struct pt_regs * regs)
,这个函数接着调用handle_swbp(regs)
。handle_swbp
这个函数主要做两件事:handler_chain(find_active_uprobe())
执行这个uprobe的处理程序。例如,被eBPF程序使用的perf_event
。
pre_ssout()
准备单步执行被探测的指令。因为原始指令已经被uprobe的断点操作码替换,所以这个指令不能在程序内存中执行。内核开发者最初尝试临时移除断点,但遇到了一些问题,因此他们选择在一个新的内存区域(也称为xol
,即out of line
)执行这个指令。首先调用xol_get_insn_slot
获取xol的虚拟地址,这个函数使用get_xol_area()
来设置[uprobes]特殊的虚拟内存区域,如果它还没有被创建的话,通过xol_add_vma() => install_special_mapping()
。这个vma是原始指令将要被out of line执行的地方。接着,pre_ssout()
使用arch_uprobe_pre_xol()
调用regs_set_return_ip(regs, current->utask->xol_vaddr)
和user_enable_single_step()
。此时current->utask->xol_vaddr
指向之前创建的XOL槽。因此,这个函数将程序计数器设置到原始指令的副本所在的位置,并激活单步模式。然后,这个指令将被执行,并且程序将再次被停止。
当单步执行结束后,从
uprobe_notify_resume
调用arch_uprobe_post_xol
。这个函数准备在单步执行后恢复执行,并调用post_xol处理程序。在单步执行之后的RIP寄存器是相对于复制的指令地址的,指向的是在XOL区域中地址,而不是原本要指向的地址。接下来,内核需要调整RIP寄存器的值,使其指回原始指令之后的位置。如果原始指令使用了RIP寄存器(例如,用于计算相对地址的指令),内核可能需要使用另一个寄存器来替换RIP,以保持地址计算的正确性。以便程序可以正常继续执行。0x01 对抗
1.断点检测
程序可以读取待检测函数的前几个字节,以检测某个函数是否被挂钩,但是这样显然是不现实的,因为
uprobe
可以实现任意位置的挂钩,不仅局限于某个函数的开头。除此之外,检测函数需要不间断的检测,其开销十分巨大。同样的也可以去强制覆写函数的开头取消断点以实现uprobe监控逃逸。2. 内存映射检测
另一种方法是在触发
uprobe
后检测它。我们知道在创建指令副本用于执行原本的指令时,创建了一个特殊的内存映射,称为 [uprobes]
。因此可以读取 /proc/self/maps
,并搜索这些内容。3. 监控逃逸
在进行uprobe注册时会寻找所有与
inode
关联的vma,并且通过valid_vma
检查是否是有效的vma,其实现如下,可以发现,如果is_register
的标志为true ,则flags |= VM_WRITE
。在 uprobe 注册期间,is_register
值为true,就会执行flags |= VM_WRITE
,而如果代码段是可写的VM_WRITE
, 在之后的返回语句中(vma->vm_flags & flags) == VM_MAYEXEC
就会不成立,从而无法继续添加断点。所以可以通过将代码段的属性添加VM_WRITE
就可以使uprobe无法正常添加断点,从而实现监控逃逸。对于一般的进程可以设置自己的代码段为可写的,但是无法设置共享的动态链接库的属性,也就是说对共享的动态链接库(除非拥有root权限),比如
libc
等无法实现监控逃逸。但是可以自实现ELF加载器,在这些库加载到程序内存前设置权限,为每个库添加写属性。当然在这么操作存在很多问题,包括破坏程序原本的安全性等等。4. 返回虚假信息
这个部分就不再赘述,一个方法是条件竞争,创建一个线程发送虚假数据。另一个方法就是使用ptrace实现对自身的调试,相当于实现了一个调试器,在关键位置修改上下文以实现返回虚假数据。
参考
- 作者:LLeaves
- 链接:https://lleavesg.top//article/Uprobe-defeat
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
相关文章