利用 eBPF 记录系统调用

更新: 来自@l2dy的补充信息:
Aya 是支持 BPF CO-RE 的,README 里有写 “Support for the BPF Type Format (BTF), which is transparently enabled when supported by the target kernel. This allows eBPF programs compiled against one kernel version to run on different kernel versions without the need to recompile.”

当你使用 aya-tool generate 生成 bindings 时就在用 BTF 了,它有一个 --btf 的参数默认指向 /sys/kernel/btf/vmlinux.在Aya加载eBPF的时候,Bpf::load() 只会读取 /sys/kernel/btf/vmlinux,所以要自定义路径的话可以用 Loader 的 .btf() 指定. 如这个例子
对于较老的不支持BTF的线上内核,可以使用 BTFhub 提供的 btf 文件,或者自行从 .ddeb 文件生成。 https://github.com/aquasecurity/btfhub/blob/main/docs/supported-distros.md#ubuntu.

但是注意到,aya 库(用户空间侧)可以用来加载有 relocations 的 BPF 程序,但用 Rust 语言编写 BPF 程序的 aya-ebpf 库(内核侧)还不支持 relocations 相关的 bpf_core_read 或编译器的 __builtin_preserve_access_index 函数,所以如果无法避免 relocations 的话只能像这个测试用例一样用 C 写内核部分,一半 Rust 一半 C.
--Issue原文

谁杀掉了进程?

今天同学发现自己跑在GPU服务器上的进程被 Kill 掉了,日志中并没有报错,仅有一行 Killed 记录.雪上加霜的是,出于方便维护的考量,这台服务器没有开启 Audit审计,因此事后能找到的线索也非常有限, 查找了/var/log/auth.log 中的sudo日志,也没有什么收获.那么,亡羊补牢,为了避免这种情况再次出现,有什么办法可以预防呢?

  • 最容易想到的方案是在Python进程中注册Signal Handler回调. 如signal.signal(signal.SIGTERM, handler) .这种方式虽然简单方便,缺点是拿不到底层呈递的siginfo结构体,也就没办法记录更多信息.
  • 一点改进是额外挂起一个线程并调用C函数循环等待处理siginfo_t): signal.sigwaitinfo(sigset) ,从 siginfo_t的si_pidsi_uid 中可获取发出Kill信号的进程ID与用户ID.

    上面在Python层面的解决方案听上去还可以接受,而且处理signal时拥有完整的Python环境,使得通过网络发送消息提醒或者写文件log都很简单. 但是很遗憾受Linux平台的限制,Python中的Signal机制并不能处理所有的情况:

    1. 当Signal为 SIGKILL(使用kill -9)时,并不会唤醒回调函数,而是直接被杀死.这是由POSIX标准规定的,应用程序自身对SIGKILL信号无法感知,只会被动被父进程杀死.
    2. 当用户使用sudo临时借用其他用户的uid(如root[uid=0])时,执行进程的所有身份(cred)都与目标用户没有差别,sudo仅在应用程序内提供了环境变量来标识原来的用户uid.因此不论是Python还是Linux系统调用,都无法确认用户的真实uid. (我个人认为这是一种设计缺陷,Linux的cred机制既然提供了ruid/euid等多种uid表示,就不应该直接简单粗暴使用直接设置进程为目标用户uid这种方式.)

    但非常遗憾, 以我的经验来看,当一个用户想Kill掉其他用户的进程时,最常用的方式是 sudo kill -9 xxx .目标进程只能一声不吭被干掉,直到你吃完夜宵归来目睹残骸.

    当然,这种情况的根源在于实验室服务器混乱的管理(很多人有了不该有的权限)和粗糙的运维,但是我们是否有办法记录下杀掉进程的真实用户信息? 用了一个周末的时间,我用eBPF技术完成了这个小小的Demo,初步达成了我的目标.

内核中的检查点

简单来讲,eBPF允许用户在不重新编译或加载内核的情况下,在用户定义的时机中向内核程序的执行流程中插入执行自定义的程序(如某个内核中的函数被调用或某个Syscall的进入和退出,甚至TCP数据包的读取和路由等),这些程序运行在内核的上下文,因此可以访问内核函数或者Syscall的参数或返回值等数据,同时执行这些逻辑不需要也频繁陷入内核开销,是Profiling或Audit应用的不二之选. eBPF的核心包括一套高效的支持JIT的虚拟机(用于在内核中执行eBPF字节码),内核中的预留API和插桩点(只有在这些插桩点中才能运行),一套严格的Verifier(保证字节码的内存安全和执行时间要求,避免恶意程序干扰内核的性能和安全性)和一套编译器与辅助函数等(将高级语言编写的eBPF程序变成字节码).由于eBPF程序运行在内核中,因此无法像普通用户程序一样实现过于复杂的逻辑(如不支持无限循环,无法使用全局变量,对程序的指令总数也有要求),也无法访问dev和文件系统.但是eBPF可以通过特殊的Ringbuffer或者Map与用户空间的程序通信.

对于记录(并审计)Kill调用这件事情,eBPF官方就提供了一个Examples: Killsnoop.其他语言的库也做了相关实现,如Python BCC库中也有类似的示例.但是这些示例通常仅仅是将系统调用的参数输出(printk),而没有实现复杂的筛选(当然对于sudo的伪uid问题也无能为力). 在我的实践中,选择了Rust的Aya库作为eBPF的实现库,它允许你用Rust来编写eBPF和配套的用户空间程序,来提供了方便的输出和日志宏,而且比起BCC那种把eBPF的C文本嵌入到Python文件中的编程体验好很多(虽然后来的实践证明,这个库无论是文档还是代码质量都差得令人发指.但高星低质也是Rust社区的通病了,鉴定为不得不品尝).

在eBPF编程中,实现业务逻辑的单位是函数,模块或者包的概念似乎很少被提及. 每个入口函数和它所有调用的子函数组成了一个eBPF程序,一起attach到某个插桩点(可以是Syscall Tracepoint或内核中的某个函数KProbe).当指定的事件触发时,eBPF就会被执行.具体到我们这个例子中,我们要追踪的系统调用是KILL这个系统调用.追踪一个系统调用一般有两种方法,定位到它在内核中的具体实现函数并使用KProbe探针,或对Kernel内置的TracePoint事件进行追踪.考虑到内核函数的名称和形参可能在内核版本更迭过程中发生变动,我这里将函数Attach到 syscall::sys_enter_killsyscall::sys_exit_kill两个调用点,分别记录系统调用的传入参数(Signal和目标PID)与调用返回值(是否执行成功)[你可以在本机中查看 /sys/kernel/debug/tracing/available_events列出的所有TracePoint/].

Aya框架中提供了一个脚手架来搭建项目,使用 cargo generate即可搭建一个Hello World级别的项目.项目大致分为三部分:用户空间程序(负责eBPF的加载和生命周期管理),eBPF侧代码(会被编译为字节码),Common (公共数据结构等).在内核态中要运行的逻辑放在eBPF侧,一个典型的示例如下:

#[tracepoint(category = "syscalls", name = "sys_enter_kill")]
pub fn kill_probe(ctx: TracePointContext) -> u32 {
  info!(&ctx,"into sys_enter_kill");
  0
}

同时在用户侧,需要完成eBPF的解析,加载和Attach.

    let bpf = BpfLoader::new()
        .load(include_bytes_aligned!(
            "../../target/bpfel-unknown-none/release/kill-probe"
        ))?;
    let program: &mut TracePoint = bpf.program_mut("kill_probe").unwrap().try_into()?;
    program.load()?;
    program.attach("syscalls", "sys_enter_kill")?;

BpfLoader负责load eBPF项目经过Cargo工具链的编译产物(实际上是eBPF的汇编表示)到eBPF虚拟机进行尝试加载与解析.成功加载后,eBPF中的程序(被#[tracepoint]标记的入口函数)和变量(被#[map]标记)会被列出,使用program_mut(Func_Name)可以获得程序实例并Attach到具体的TracePoint上.至此,一段eBPF就成功加载入内核了.当相应的TracePoint被触发(此处为kill系统调用开始),eBPF就会被内核执行.

访问内核数据结构

上面的示例是一段非常简单的代码,实际上也无法实现任何有意义的功能.为了完成功能,eBPF程序不可或缺的就是对内核数据结构的读写.具体到eBPF程序类型,TracePoint类型的程序会附加到系统调用的进入或结束时刻,从而可以分别访问系统调用的参数和返回值.因此,要想同时获得一个Syscall的传参和返回值,就必须编写两个eBPF函数.好在Syscall是阻塞调用,因此进程的PID和线程TID总是在一段时间内可以唯一标识一次系统调用.因此我们可以在两个函数间共享一个“全局变量”Map来实现.(注意,从eBPF虚拟机层面来看,两个不互相调用的函数并非一个程序,因此所谓的全局变量也实际上不存在.LLVM编译器会将代码中定义的全局变量尝试映射为在eBPF FS中挂载的文件[eBPF Pin]来曲线救国.)

  1. 读取内核中TracePoint的传参.

    内核通过Tracing提供了每个TracePoint的传参格式信息.可以直接在/sys/kernel/debug/tracing/events/{category}/{tracepoint_name}/format中查看格式.以sys_enter_kill为例.

    name: sys_enter_kill
    ID: 177
    format:
            field:unsigned short common_type;       offset:0;       size:2; signed:0;
            field:unsigned char common_flags;       offset:2;       size:1; signed:0;
            field:unsigned char common_preempt_count;       offset:3;       size:1; signed:0;
            field:int common_pid;   offset:4;       size:4; signed:1;
    
            field:int __syscall_nr; offset:8;       size:4; signed:1;
            field:pid_t pid;        offset:16;      size:8; signed:0;
            field:int sig;  offset:24;      size:8; signed:0;
    
    print fmt: "pid: 0x%08lx, sig: 0x%08lx", ((unsigned long)(REC->pid)), ((unsigned long)(REC->sig))

    format部分详细介绍了TracePoint携带的参数,print fmt还给出了Tracing内部打印参数时使用的fmt字符串.前4个common_开头的field是所有TracePoint都携带的属性,一般没有什么帮助.__syscall_nr是当前Syscall的系统调用号,在RawTracePoint的时候可能有用,但是此处我们已经很清楚我们追踪的是Kill调用.下面pid和sig则是真正的入参.

    在Aya中,框架自动帮我们把TracePoint携带的参数放到了入口函数传入的Context中.因此我们按照Format给出的Offset和变量Size以及是否有符号即可对应取出.

        let killed_pid = unsafe { ctx.read_at::<u64>(16)? }; //Size 8 Unsigned => U64
        let sig = unsafe { ctx.read_at::<u64>(24)? };

    eBPF的Helpers还提供了很多数据的采集,如 bpf_get_current_uid_gid获取当前发起系统调用的uid[在sudo下为root],bpf_get_current_pid_tgid 获取当前进程的pid和tid(注意这里有一个易混淆的点:线程是内核调度的基本单位,因此内核认为的PID其实是用户空间的TID,而真正的PID被内核成为 Thread Group Id,即tgid).

    简单的内核数据结构利用这种方式可以获取,TracePoint暴露给我们的大多数也都是简单数据结构. 但是当我们想访问内核中的结构体等复杂类型时,eBPF框架提供的简单helpers就无能为力了,因为我们的程序必须引入内核结构体的定义,才能访问结构体的内部成员.但是遗憾的是,内核结构体的定义并非一成不变的,成员变量在结构体中的Offset可能会改变(因此使用了过时的头文件可能导致读出错误数据甚至非法访问),甚至被转移到子结构体中.

    这种情形随着内核版本的更迭经常出现,BCC采用的方式是打包一整套LLVM工具链(100M+)到目标机器上现场编译,更高级的方案是利用高版本内核自己携带的Debug符号特性(BTF)来完成CO-RE(一次编译处处运行).这种方案是由LLVM在编译字节码时将对内核结构体成员变量的访问语句动态改写成从内核BTF中查询成员变量字段并访问的魔法函数,听上去是很振奋人心的特性,但是在我们的实践中,一方面线上内核没有那么新(18.04 with 5.4.0),另一方面Aya库并不支持这个特性(猜测可能是Rust语法层面的问题).

具体到我们这个例子中,如何获取用户藏在sudo之后的真实ID呢?一个比较好的办法是查找进程树.众所周知,sudo仅仅是创建一个uid为root的进程来提权,它的父进程仍然归属于用户本身,因此我们只需要递归遍历进程的父进程,直到ppid为1(代表INIT根进程)或uid不再为0即可. 在实现层面,有 bpf_get_current_task 这个helper来获取当前进程的 task_t 结构体指针.但是要访问其中的值,还要费一番功夫.首先我们需要从内核头文件中获取结构体的定义.这点Aya提供了一个工具来直接生成Rust Bindings–Aya Tools.使用也非常简单.接下来我们需要从获取的结构体指针中取出成员变量.在不支持CO-RE的环境中,我们需要使用 bpf_probe_read_kernel这个系统调用来读取结构体:

let task = unsafe { bpf_get_current_task() } as *const task_struct;
let mut parent = unsafe { bpf_probe_read_kernel(&(*task).real_parent)? };
let parent_pid = unsafe { bpf_probe_read_kernel(&(*parent).tgid)? };

至此我们就成功获取了sudo背后的真实uid,但是还有一个问题: 如何将系统调用的返回值与参数关联起来呢?

  1. 利用Map进行通信和共享数据

    在eBPF中,Mape 是用户空间和内核空间进行数据交换、信息传递的桥梁,它以 key/value 方式将数据存储在内核中,可以被任何知道它们的BPF程序访问。在内核空间的程序创建 BPF Map 并返回对应的 文件描述符,用户空间程序或其他eBPF程序就可以通过这个文件描述符来访问并操作BPF Map,达到通信和状态共享.

    在Aya中,我们如下创建两个Map:

    use aya_bpf::{maps::{HashMap, PerfEventArray},macros::{map, tracepoint}}
    #[map] // #[map]宏标记导出为map.
    static mut EVENTS: HashMap<u64, Data> = HashMap::pinned(1024, 0);
    #[map]
    static mut EVENTS_MAP: PerfEventArray<Data> = PerfEventArray::with_max_entries(0, 0);

    map作为eBPF中的基本数据结构,有HashMap(即基于哈希表)和ArrayMap(基于链表)两种,分别用作KV存储和数组,可在这里看到所有的细分类型.其中 PerfEventArray是一个较为特殊的类型,他其实是一个多生产者、单消费者的 (MPSC) 队列,并允许用户程序来进行监听更新.这样子就可以近乎实时将消息传送到用户程序,很适合流式传输.但是尴尬的问题在于PerfArray的缓冲区在于每个CPU独立,因此用户态不得不为每个CPU创建一个线程进行缓冲区轮询.Ringbuffer类型解决了这个问题,但是每次加入新数据之前必须进行空间申请,而且Aya似乎也不支持这种Map.

    在Aya中使用HashMap基本上和Rust中没有任何区别,为了便于Debug,我在这里将Hashmap设置为pinned,从而可以在用户空间中通过FS读取.设置为Pin之后,需要再BpfLoader中设置PinPath,此后在装载eBPF代码时,Aya会自动进行Map的初始化并挂在到相应位置下以Map名称命名的文件中.我在此创建了一个EVENT的HashMap,用于在enter_kill和exit_kill 间交换信息.由于Syscall是阻塞调用,因此进程的PID和线程TID总是在一段时间内可以唯一标识一次系统调用,以pid_tgid[pid>>32|tid]这个u64值为key,即可标志系统调用的信息.当enter和exit被触发后,先检测EVENT_MAP中是否有值,有则取出并删除kv,并汇报给用户侧,没有则将值写入.

    而PerfEventArray的使用更为麻烦一点,在eBPF侧使用 EVENTS_MAP.output函数往队列中加入新数据.而在用户侧,需要为每个CPU创建一个循环,读取当前CPU上的缓冲区内容:

    async fn handle_enter_envent(bpf: &'static mut Bpf) -> Result<(), anyhow::Error> {
        let cpus = online_cpus()?;
        let mut events = AsyncPerfEventArray::try_from(bpf.map_mut("EVENTS_MAP").unwrap())?;
        for cpu in cpus {
            let mut buf = events.open(cpu, None)?;
            tokio::task::spawn(async move {
                let mut buffers = (0..cpu)
                    .map(|_| BytesMut::with_capacity(10240))
                    .collect::<Vec<_>>();
                loop {
                    let events = buf.read_events(&mut buffers).await.unwrap();
                    for i in 0..events.read {
                        let event_ptr = &mut buffers[i];
                        let val_data = unsafe { (event_ptr.as_ptr() as *const Data).read_unaligned() };
                        let _ = handle_kill(&val_data).await;}}});}
        Ok(())
    }

本Demo可在kill_probe_ebpf查看具体代码.

Edit with markdown

仅有一条评论

  1. SaltedFish

    test.

    SaltedFish July 21st, 2024 at 06:38 pm回复