欢迎光临散文网 会员登陆 & 注册

golang uretprobe的崩溃原因与模拟实现

2023-07-25 23:49 作者:清澄秋爽  | 我要投稿

https://mp.weixin.qq.com/s/-LPlETem33rbL6zKomS-mQ

前言

在eCapture[1]最初支持golang的https明文捕获时,是不支持request\response完整的匹配的。这点不同于C语言编写的程序,是因为golang的uretprobe类型钩子有个较为致命的bug,会导致被挂载进程崩溃,这问题在BCC社区也有讨论过:Go crash with uretprobe #1320[2], 火焰图作者brendangregg也提到,在他的一篇博客[3]里,用户评论如下:

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[4]

也就是说

uretprobe似乎通过修改堆栈来放置返回探针,这与Go管理堆栈的方式冲突(Go中的堆栈可以在任何时候增长/缩小,它通过将整个堆栈复制到一个新的较大区域,调整堆栈中的指针以指向新区域等方式实现)。因此,如果我们正在进行uretprobe操作,并且堆栈在此期间发生增长(或缩小),它可能导致Go运行时发生错误。请参阅此处的示例错误消息:go.stp#L32-L58[5]

亲自验证

是的,笔者在为eCapture增加go tls的明文捕获时,也是attach到Go 函数的uretprobe上,结果自然是,被挂载的进程崩溃了。经过漫长的debug、查资料,终于有点眉目。这其实跟Golang的runtime、寄存器等实现机制有关,我写了一个DEMO,验证一番。

这个DEMO是我在5月初写的,期间一直想写篇简单的文章给大家介绍一下,奈何太忙了,接着这次出差的机会,周末整理一下,分享给大家。时间相隔太久,可能很多细节都忘记了,笔者水平有限,如有错误,欢迎指出。

golang uretprobe冲突

话不多说,Go程序崩溃的核心原因为Go的栈在runtime管理时,被插入了异常的内存地址。Go中常见的堆栈变化为协程goroutine的创建与销毁。栈内 被插入异常内存地址是因为eBPF的实现机制是向函数的返回地址前,插入了断点指令(i386和x86_64[6]是INT3)。两个条件的叠加,就出现了这个错误。

那么重现起来也比较简单,写一个协程goroutine数量不停变化的程序,并使用eBPF uretprobe挂载上去即可。

案例演示

被HOOK的测试代码

被挂载的函数是CountCC,他的返回值应该是101,这段代码被Go编译后,CountCC在符号表里名字是main.CountCC,这个就是eBPF挂载的函数名。要注意,在代码里务必使用go:noline语法来让Go编译器不要对这段代码进行内联inline,否则编译后的可执行文件中,符号表内就找不到main.CountCC函数了。

执行挂载动作的代码

内核空间代码:

其中SEC的参数uretprobe/countcc在编译为ebpf字节码后,会被用户空间程序读取,关联到uretprobe_countcc这个符号上。

用户空间代码:

执行挂载动作的代码,也很好实现,使用笔者的golang eBPF管理SDK ebpfmanager[7],只需要几行代码,以下为用户空间程序:

挂载类型uretprobe/countCC,被Go的eBPF类库解析为uretprobe类型程序。挂载的eBPF执行函数为uretprobe_countcc,挂载目标符号为main.CountCC

执行重现

编译后,观测程序是main,被观测程序是demo

  1. 启动观测程序bin/main

  2. 启动被观测程序bin/demo

崩溃栈信息

可以看到被观测程序立刻崩溃,崩溃的信息如下:

其中致命的错误信息是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[8]规范,读取Golang的ELF文件,查找符号表内对应main.CountCC函数对应符号的汇编指令,并按照X86格式解析,循环判断是否为RET,并记录当前指令在整个函数符号的偏移地址即可。

内核空间程序

因为是用uprobe来模拟uretprobe,eBPF内核代码肯定要调整的了,为了要验证能否拿到返回值,这里也增加了返回值的获取。

可以看到,这里新增一个函数uprobe_countcc,将用于用户空间的eBPF执行函数。

用户空间程序调整

经过ELF文件分析,将RET指令的偏移地址保存到offsets中,在用户空间挂载到函数的偏移位置上:

可以看到Section改成了uprobe/countcc, 并挂载到内核函数uprobe_countcc上。以及新增  UprobeOffset字段,并设定offset,这样就实现自动的uprobe偏移量挂载。(PS:你就说,笔者的 ebpfmanager[9]方便不方便吧)

模拟验证

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

  1. 启动观测程序,bin/main -e,这里多了-e参数,来使用模拟模式。

  2. 打开内核调试日志,方便观察是否能拿到main.CountCC函数的返回值,命令为cat /sys/kernel/debug/tracing/trace_pipe

  3. 启动被观测程序,bin/demo

观测程序

观测程序启动后,可以看到终端日志中,搜索到两处RET指令,并分别进行uprobe`挂载。


图片

main.CountCC函数内,RET汇编指令的偏移地址分别为0x7A0xE3 ,且都挂载成功,执行的内核函数为uprobe_countcc

被观察程序

如你所见,被观测程序没有崩溃,可以正常运行,并输出结果。


图片

观察结果

笔者的DEMO里没有将内核调试结果传输到用户空间,直接打印了。

可以看到,demo-18960 (程序名+PID)运行结果后,出现了我们打印的日志。并且,捕获的结果是101,符合预期。

图片

总结

eBPF挂载uretprobe崩溃的问题,只在Golang程序上发生,这跟Golang的协程缩容、扩容机制有关,受到CPU中断指令插入影响,破坏原有调用栈,导致问题发生。其他编译型语言上,不会有这个问题。假如有的语言也跟Golang一样,使用stack来做运动时管理,哪也会遇到这个问题。

关于 Golang的这个问题,在其社区里也有关于runtime: fatal error: unknown caller pc when uprobes are attached #27077[10]的讨论,Go语言开发者**aclements[11]**认为,这不是Go的问题,近期也不会考虑修复,希望uretprobe的管理层面,自动做返回地址栈的修复。

感谢提供的参考资料,@sillyousu。这些资料确认了我的猜测,很不幸地,我们实际上无法有效地解决uretprobes损坏堆栈的问题。

既然我们无能为力,而且这并不是一个Go的错误,我决定关闭这个问题。如果将来uretprobes能够提供足够的信息来恢复用户空间中被破坏的返回地址,我们可以重新考虑这个问题,并可能找到解决方法。

所以,这个问题,大家还是自己使用模拟的方法来解决Golang程序的函数返回值观测需求吧。eCapture也是自己写了PR支持了Go TLS的明文捕获:support gotls request and response #357[12]。本次DEMO的测试代码在GitHub仓库:cfc4n/go_uretprobe_demo[13] ,祝大家玩得开心。

图片

写于2023年6月11日,周末,雷阵雨,北京望京。

参考资料

[1]

eCapture: https://ecapture.cc

[2]

Go crash with uretprobe #1320: https://github.com/iovisor/bcc/issues/1320

[3]

博客: https://www.brendangregg.com/blog/2017-01-31/golang-bcc-bpf-function-tracing.html

[4]

go.stp#L32-L58: https://github.com/surki/misc/blob/master/go.stp#L32-L58

[5]

go.stp#L32-L58: https://github.com/surki/misc/blob/master/go.stp#L32-L58

[6]

x86_64: https://c9x.me/x86/html/file_module_x86_id_280.html

[7]

ebpfmanager: https://github.com/gojue/ebpfmanager

[8]

DWARF Debugging Standard: https://dwarfstd.org/dwarf5std.html

[9]

ebpfmanager: https://github.com/gojue/ebpfmanager

[10]

runtime: fatal error: unknown caller pc when uprobes are attached #27077: https://github.com/golang/go/issues/27077

[11]

aclements: https://github.com/aclements

[12]

support gotls request and response #357: https://github.com/gojue/ecapture/pull/357

[13]

cfc4n/go_uretprobe_demo: https://github.com/cfc4n/go_uretprobe_demo




golang uretprobe的崩溃原因与模拟实现的评论 (共 条)

分享到微博请遵守国家法律