Featured image of post eBPF: Attach Go(Part2): Use uprobe with Go.

eBPF: Attach Go(Part2): Use uprobe with Go.

本文是使用eBPF挂钩Go程序的第三篇,介绍如何使用eBPF于Go程序。

    在前两篇eBPF: Attach Go(Part0): Go 语言基础和特性eBPF: Attach Go(Part1): Go ABI SPEC (silence.pink)中,我们已经对Go语言的基础类型和接口的实现、Go的ABI和在eBPF程序中访问获取Go函数的参数和返回值有所了解。而本文,将介绍如何编写eBPF程序去挂钩Go程序的函数,以及提到了eBPF uretprobe引起的程序崩溃问题。

前言

Go: go1.20.7 linux/amd64

OS: Ubuntu 22.04.2 LTS

Kernel: Linux 5.19.0-50-generic

我们在以下面的代码为例,看看eBPF程序是如何获取Go函数的参数和返回值的:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// main.go
package main
import (
    "fmt"
    "time"
)

func main() {
    var v1, v2, v3 int64 = 2, 3, 4
    var str string = "cccccc"
    var b []byte = []byte(str)

    for {
        go foo(v1, v2, v3, b)
        time.Sleep(time.Second)
    }
}

//go:noinline
func foo(a1, a2, a3 int64, b []byte) (int64, []byte) {
    var b1, b2 int64 = 10, 20
    var c []byte = b
    if a1 > 6 {
        return a1, c
    }
    fmt.Printf("foo return :%d\n", a1*a3+a2*a3+b1*b2)
    return a1*a3 + a2*a3 + b1*b2, c
}

结合前面篇章中的知识,对上述代码我们有:

  • 该版本的Go采用基于寄存器方式的ABI;

  • 对于a1~a3和b,在函数调用时存放在寄存器中,具体的:

     1
     2
     3
     4
     5
     6
     7
     8
     9
    10
    11
    12
    13
    14
    
    +-----------------------------------+
    |AX |BX |CX |DI |SI |R8 |R9 |R10|R11|
    +-----------------------------------+
     ↓   ↓   ↓   ↓   ↓    ↓
     ↓   ↓   ↓   ↓   ---→ --→
     a1  a2  a3  b.data ↓   ↓  
                      b.len ↓  
                          b.cap
    # a1 = AX
    # a2 = BX
    # a3 = CX
    # b.data = DI
    # b.len  = SI
    # b.cap  = R8
    

所以,在eBPF的kern代码中,我们可以很容易的如下代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
// gouprobe.bpf.c
#include <vmlinux.h>
#include <bpf/bpf_helpers.h>
#include <bpf/bpf_tracing.h>
#include <bpf/bpf_core_read.h>

char LICENSE[] SEC("license") = "Dual BSD/GPL";

// func foo(a1, a2, a3 int64, bbbb []byte) (int64, []byte)
SEC("uprobe/foo")
int probe_entry_foo(struct pt_regs *ctx) {
    uint64_t a1 = (uint64_t)(ctx->ax);
    uint64_t a2 = (uint64_t)(ctx->bx);
    uint64_t a3 = (uint64_t)(ctx->cx);
    uint64_t b_data = (char*)(ctx->di);

    bpf_printk("foo entry, a1:%ld, a2:%ld, a3:%ld, b:%s",
        a1, a2, a3, b_data);
}

SEC("uretprobe/foo")
int probe_exit_foo(struct pt_regs *ctx) {
    uint64_t ret0 = (uint64_t)(ctx->ax);
    uint64_t ret1 = (uint64_t)(ctx->bx);

    bpf_printk("foo eixt, ~r0:%ld, ~r1:%s", ret0, ret1);    
}

问题: eBPF uretprobe 引起的Go程序崩溃

然后,我们执行go build -gcflags=all="-N -l" main.go编译得到Go的二进制文件main,而eBPF文件编译得到gouprobe

  • 执行sudo ./gouprobe启动eBPF程序;

  • 然后再执行./main便会得到:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
./main
runtime: g 18: unexpected return pc for main.foo called from 0x7fffffffe000
stack: frame={sp:0xc00009aeb8, fp:0xc00009af70} stack=[0xc00009a000,0xc00009b000)
0x000000c00009adb8:  0x0000000000000000  0x0000000000000000 
0x000000c00009adc8:  0x0000000000000000  0x0000000000000000 
0x000000c00009add8:  0x0000000000000000  0x0000000000000000 
0x000000c00009ade8:  0x0000000000000000  0x0000000000000000 
0x000000c00009adf8:  0x0000000000000000  0x0000000000000000 
0x000000c00009ae08:  0x0000000000000000  0x0000000000000000 
0x000000c00009ae18:  0x000000c00009aea8  0x000000000049827e <fmt.Printf+0x000000000000009e> 
0x000000c00009ae28:  0x00000000004c5ec8  0x000000c0000ae008 
0x000000c00009ae38:  0x00000000004ba136  0x000000000000000f 
0x000000c00009ae48:  0x000000c00009af08  0x0000000000000001 
0x000000c00009ae58:  0x0000000000000001  0x0000000000000000 
0x000000c00009ae68:  0x0000000000000000  0x0000000000000000 
0x000000c00009ae78:  0x0000000000000000  0x0000000000000000 
0x000000c00009ae88:  0x0000000000000000  0x0000000000000000 
0x000000c00009ae98:  0x0000000000000000  0x0000000000000000 
0x000000c00009aea8:  0x000000c00009af60  0x000000000049f516 <main.foo+0x00000000000001b6> 
0x000000c00009aeb8: <0x00000000004ba136  0x000000000000000f 
0x000000c00009aec8:  0x000000c00004a708  0x0000000000000001 
0x000000c00009aed8:  0x0000000000000001  0x0000000000000000 
0x000000c00009aee8:  0x0000000000000014  0x000000000000000a 
0x000000c00009aef8:  0x0000000000532ee0  0x000000c00004a708 
0x000000c00009af08:  0x00000000004a7220  0x0000000000532ee0 
0x000000c00009af18:  0x0000000000000000  0x0000000000000000 
0x000000c00009af28:  0x0000000000000000  0x000000c0000b4000 
0x000000c00009af38:  0x0000000000000006  0x0000000000000008 
0x000000c00009af48:  0x000000c00004a708  0x0000000000000001 
0x000000c00009af58:  0x0000000000000001  0x000000c00004a7d0 
0x000000c00009af68: !0x00007fffffffe000 >0x0000000000000002 
0x000000c00009af78:  0x0000000000000003  0x0000000000000004 
0x000000c00009af88:  0x000000c0000b4000  0x0000000000000006 
0x000000c00009af98:  0x0000000000000008  0x0000000000000004 
0x000000c00009afa8:  0x0000000000000003  0x0000000000000002 
0x000000c00009afb8:  0x000000c0000b4000  0x0000000000000006 
0x000000c00009afc8:  0x0000000000000008  0x0000000000000000 
0x000000c00009afd8:  0x0000000000467141 <runtime.goexit+0x0000000000000001>  0x0000000000000000 
0x000000c00009afe8:  0x0000000000000000  0x0000000000000000 
0x000000c00009aff8:  0x0000000000000000 
fatal error: unknown caller pc

runtime stack:
runtime.throw({0x4ba8ad?, 0x52cd20?})
        /home/xxx/xxx/go1.20.7/src/runtime/panic.go:1047 +0x5d fp=0x7ffdb981bbc0 sp=0x7ffdb981bb90 pc=0x437abd
runtime.gentraceback(0xffffffffffffffff, 0xffffffffffffffff, 0x7ffdb981bf10?, 0xc0000824e0, 0x0, 0x0, 0x7fffffff, 0x4c1050, 0xc?, 0x0)
... ...

    是的,由于使用了eBPF uretprobe而引起了被attach的Go程序的崩溃。其实,在bcc和ecapture的相关讨论和博客中,都提到了在对Go程序使用uprobe时,会导致目标进程崩溃,uretprobe似乎通过修改堆栈来放置返回探针,这与Go管理堆栈的方式冲突(Go中的堆栈可以在任何时候增长/缩小,它通过将整个堆栈复制到一个新的较大区域,调整堆栈中的指针以指向新区域等方式实现)。因此,如果我们正在进行uretprobe操作,并且堆栈在此期间发生增长(或缩小),它可能导致Go运行时发生错误。

值得注意的是,我尝试浮现的过程发现也并不是每次都会导致崩溃?在未使用goroutine的情况下,倒是正常运行的。

替代方案

    得益于前人的栽树,崩溃的问题已有成熟的解决方案:使用uprobe来替代uretprobe,原理就是在函数的RET指令处插入uprobe。

    如果用过libbpf的框架就很清楚在eBPF attach时有一个func_offset的参数,如果再往libbpf更深处查看其实会发现,在内部libbpf其实会去读取ELF格式的二进制文件,然后根据symbol得到函数符号在文件中的偏移offset,也就是说假设你知道函数符号相对于文件的偏移,那么你就可以不用告诉libbbpf函数符号symbol名称,而是直接使用offset挂载(只是libbpf帮我们做了从函数符号到偏移值转换的这一步)。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/**
 * @brief **bpf_program__attach_uprobe_opts()** is just like
 * bpf_program__attach_uprobe() except with a options struct
 * for various configurations.
 *
 * @param prog BPF program to attach
 * @param pid Process ID to attach the uprobe to, 0 for self (own process),
 * -1 for all processes
 * @param binary_path Path to binary that contains the function symbol
 * @param func_offset Offset within the binary of the function symbol
 * @param opts Options for altering program attachment
 * @return Reference to the newly created BPF link; or NULL is returned on error,
 * error code is stored in errno
 */
LIBBPF_API struct bpf_link *
bpf_program__attach_uprobe_opts(const struct bpf_program *prog, pid_t pid,
                const char *binary_path, size_t func_offset,
                const struct bpf_uprobe_opts *opts);

我们可以使用readelfobjdump对Go二进制文件进行反汇编有:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
readelf -s main|grep main.foo
2253: 000000000049f360   630 FUNC    GLOBAL DEFAULT    1 main.foo
objdump -D --start-address=0x049f360 --stop-address=0x049f5e6 main
objdump -D --start-address=0x049f360 --stop-address=0x049f5e6 main

main:     文件格式 elf64-x86-64


Disassembly of section .text:

000000000049f360 <main.foo>:
  49f360:       4c 8d 64 24 d0          lea    -0x30(%rsp),%r12
  49f365:       4d 3b 66 10             cmp    0x10(%r14),%r12
  49f369:       0f 86 21 02 00 00       jbe    49f590 <main.foo+0x230>
  49f36f:       48 81 ec b0 00 00 00    sub    $0xb0,%rsp
  49f376:       48 89 ac 24 a8 00 00    mov    %rbp,0xa8(%rsp)
  49f37d:       00 
  49f37e:       48 8d ac 24 a8 00 00    lea    0xa8(%rsp),%rbp
  49f385:       00 
  49f386:       48 89 84 24 b8 00 00    mov    %rax,0xb8(%rsp)
  49f38d:       00 
  49f38e:       48 89 9c 24 c0 00 00    mov    %rbx,0xc0(%rsp)
... ...

查找ret指令的位置:

1
2
3
objdump -D --start-address=0x049f360 --stop-address=0x049f5e6 main|grep ret
  49f44a:       c3                      ret    
  49f58f:       c3                      ret

所以得到函数main.fooret的偏移位置为:

1
2
 0x49f44  - 0x49f360 = 0xea
 0x49f58f - 0x49f360 = 0x22f

示例

kern修改代码如下:

1
2
3
4
5
6
7
SEC("uprobe/foo_exit")
int probe_exit_foo(struct pt_regs *ctx) {
    uint64_t ret0 = (uint64_t)(ctx->ax);
    uint64_t ret1 = (uint64_t)(ctx->bx);

    bpf_printk("foo eixt, ~r0:%ld, ~r1:%s", ret0, ret1);    
}

user修改代码为如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
obj->links.probe_exit_foo =
        bpf_program__attach_uprobe_opts(obj->progs.probe_exit_foo, 
                                -1,
                                binary,
                                0xea,
                                &goabi_opts
        );

    obj->links.probe_exit_foo =
        bpf_program__attach_uprobe_opts(obj->progs.probe_exit_foo, 
                                -1,
                                binary,
                                0x22f,
                                &goabi_opts
        );

查看日志打印:

参考链接

golang uretprobe的崩溃与模拟实现 – CFC4N的博客 (cnxct.com)

Go crash with uretprobe · Issue #1320 · iovisor/bcc

哦吼是一首歌。
Built with Hugo
Theme Stack designed by Jimmy