前言

requestresponseuretprobe

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("NewTestFuncn")
    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 :%dn", a)
    return a
}

func main() {
    var maxLevel = flag.Int("l", 100, "max recursion level")
    flag.Parse()
    for {
        go CountCC(*maxLevel)
        time.Sleep(time.Second)
    }
}
CountCC101CountCCmain.CountCCgo:nolinemain.CountCC
执行挂载动作的代码

内核空间代码:

SEC("uretprobe/countcc")
int uretprobe_countcc(struct pt_regs *ctx)
{
    bpf_printk("new countCC[RET] detectedn");
    return 0;
};
SECuretprobe/countccuretprobe_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/countCCuretprobeuretprobe_countccmain.CountCC

执行重现

maindemo
bin/mainbin/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挂载解决方案

冲突点

uprobeuretprobe

给定一个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偏移地址

main.CountCCRET
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
    }
内核空间程序
uprobeuretprobe
SEC("uprobe/countcc")
int uprobe_countcc(struct pt_regs *ctx)
{
    bpf_printk("new countCC detectedn");
    int num;
    num = (int)GO_PARAM1(ctx);
    bpf_printk("countCC :: num:%d, ret_num:%dn", num);
    return 0;
};
uprobe_countcc
用户空间程序调整
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),
      })
  }
Sectionuprobe/countccuprobe_countccUprobeOffsetoffsetuprobe

模拟验证

按照之前的步骤,先启动观测程序,打开内核调试的日志,再启动被观测程序:

bin/main -e-emain.CountCCcat /sys/kernel/debug/tracing/trace_pipebin/demo

观测程序

RET指令,并分别进行
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.CountCC0x7A0xE3uprobe_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

总结

uretprobe中断stack
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日,周末,雷阵雨,北京望京。