golang uretprobe的崩潰原因與模擬實現(xiàn)
https://mp.weixin.qq.com/s/-LPlETem33rbL6zKomS-mQ
前言
在eCapture[1]最初支持golang的https明文捕獲時,是不支持request\response
完整的匹配的。這點不同于C語言編寫的程序,是因為golang的uretprobe
類型鉤子有個較為致命的bug,會導(dǎo)致被掛載進程崩潰,這問題在BCC社區(qū)也有討論過: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管理堆棧的方式?jīng)_突(Go中的堆??梢栽谌魏螘r候增長/縮小,它通過將整個堆棧復(fù)制到一個新的較大區(qū)域,調(diào)整堆棧中的指針以指向新區(qū)域等方式實現(xiàn))。因此,如果我們正在進行uretprobe操作,并且堆棧在此期間發(fā)生增長(或縮?。?,它可能導(dǎo)致Go運行時發(fā)生錯誤。請參閱此處的示例錯誤消息:go.stp#L32-L58[5]
親自驗證
是的,筆者在為eCapture增加go tls的明文捕獲時,也是attach到Go 函數(shù)的uretprobe上,結(jié)果自然是,被掛載的進程崩潰了。經(jīng)過漫長的debug、查資料,終于有點眉目。這其實跟Golang的runtime、寄存器等實現(xiàn)機制有關(guān),我寫了一個DEMO,驗證一番。
這個DEMO是我在5月初寫的,期間一直想寫篇簡單的文章給大家介紹一下,奈何太忙了,接著這次出差的機會,周末整理一下,分享給大家。時間相隔太久,可能很多細節(jié)都忘記了,筆者水平有限,如有錯誤,歡迎指出。
golang uretprobe沖突
話不多說,Go程序崩潰的核心原因為Go的棧在runtime管理時,被插入了異常的內(nèi)存地址。Go中常見的堆棧變化為協(xié)程goroutine的創(chuàng)建與銷毀。棧內(nèi) 被插入異常內(nèi)存地址是因為eBPF的實現(xiàn)機制是向函數(shù)的返回地址前,插入了斷點指令(i386和x86_64[6]是INT3)。兩個條件的疊加,就出現(xiàn)了這個錯誤。
那么重現(xiàn)起來也比較簡單,寫一個協(xié)程goroutine數(shù)量不停變化的程序,并使用eBPF uretprobe掛載上去即可。
案例演示
被HOOK的測試代碼
被掛載的函數(shù)是CountCC
,他的返回值應(yīng)該是101
,這段代碼被Go編譯后,CountCC
在符號表里名字是main.CountCC
,這個就是eBPF掛載的函數(shù)名。要注意,在代碼里務(wù)必使用go:noline
語法來讓Go編譯器不要對這段代碼進行內(nèi)聯(lián)inline,否則編譯后的可執(zhí)行文件中,符號表內(nèi)就找不到main.CountCC
函數(shù)了。
執(zhí)行掛載動作的代碼
內(nèi)核空間代碼:
其中SEC
的參數(shù)uretprobe/countcc
在編譯為ebpf字節(jié)碼后,會被用戶空間程序讀取,關(guān)聯(lián)到uretprobe_countcc
這個符號上。
用戶空間代碼:
執(zhí)行掛載動作的代碼,也很好實現(xiàn),使用筆者的golang eBPF管理SDK ebpfmanager[7],只需要幾行代碼,以下為用戶空間程序:
掛載類型uretprobe/countCC
,被Go的eBPF類庫解析為uretprobe
類型程序。掛載的eBPF執(zhí)行函數(shù)為uretprobe_countcc
,掛載目標符號為main.CountCC
。
執(zhí)行重現(xiàn)
編譯后,觀測程序是main
,被觀測程序是demo
。
啟動觀測程序
bin/main
啟動被觀測程序
bin/demo
崩潰棧信息
可以看到被觀測程序立刻崩潰,崩潰的信息如下:
其中致命的錯誤信息是fatal error: unknown caller pc,是的,重現(xiàn)了。
Go程序uretprobe掛載解決方案
沖突點
正如前文所說,這是golang 協(xié)程收縮容,導(dǎo)致stack變動, int3指令執(zhí)行后,添加到stack中,破壞原來的棧,執(zhí)行報錯。如何解決這個問題呢,在之前的issue里,有人提了一個用uprobe
模擬uretprobe
的思路。

給定一個Golang二進制文件,解析ELF符號表并獲取我們想要跟蹤的符號的地址。如果需要,在該地址附加一個uprobes。
不要將uretprobe附加到符號地址,而是從該地址開始讀取ELF文本部分,并解碼匯編指令,直到達到符號的結(jié)束。在掃描過程中,在每個返回過程的指令(例如對于x86-64,RETN指令,操作碼為0xC2和0xC3)處放置一個uprobes。對于我感興趣的符號,通常只有很少的RET指令,大約在1到5個范圍內(nèi),這是合理的。
當在上述點安裝的任一uprobes觸發(fā)時,實際上就像我們執(zhí)行了一個uretprobe一樣,除了我們沒有干擾堆棧,因此當Go運行時移動堆棧時,解決方案足夠穩(wěn)健以避免崩潰(至少看起來是這樣)。而且,由于uprobes恰好放置在RET指令之前,棧指針已經(jīng)方便地放置在幀的開頭,因此我們可以輕松訪問輸入?yún)?shù)和返回值,因為它們在Go中都存儲在棧上。
評論者還提到,這種方法具有一些輕微的性能優(yōu)勢,因為我們避免了uretprobe的開銷。但缺點是我們現(xiàn)在必須在用戶空間中解碼ELF文件的匯編指令,所以相比標準的替代方案要麻煩得多,而且,無法使用BCC之類工具,只能自己實現(xiàn)eBPF程序。
Go函數(shù)的RET偏移地址
這可難不到我,筆者一直不太用BCC,更喜歡自己寫eBPF程序。實現(xiàn)起來也很簡單,只需要按照DWARF Debugging Standard[8]規(guī)范,讀取Golang的ELF文件,查找符號表內(nèi)對應(yīng)main.CountCC
函數(shù)對應(yīng)符號的匯編指令,并按照X86格式解析,循環(huán)判斷是否為RET
,并記錄當前指令在整個函數(shù)符號的偏移地址即可。
內(nèi)核空間程序
因為是用uprobe
來模擬uretprobe
,eBPF內(nèi)核代碼肯定要調(diào)整的了,為了要驗證能否拿到返回值,這里也增加了返回值的獲取。
可以看到,這里新增一個函數(shù)uprobe_countcc
,將用于用戶空間的eBPF執(zhí)行函數(shù)。
用戶空間程序調(diào)整
經(jīng)過ELF文件分析,將RET指令的偏移地址保存到offsets
中,在用戶空間掛載到函數(shù)的偏移位置上:
可以看到Section
改成了uprobe/countcc
, 并掛載到內(nèi)核函數(shù)uprobe_countcc
上。以及新增 ?UprobeOffset
字段,并設(shè)定offset
,這樣就實現(xiàn)自動的uprobe
偏移量掛載。(PS:你就說,筆者的 ebpfmanager[9]方便不方便吧)
模擬驗證
按照之前的步驟,先啟動觀測程序,打開內(nèi)核調(diào)試的日志,再啟動被觀測程序:
啟動觀測程序,
bin/main -e
,這里多了-e
參數(shù),來使用模擬模式。打開內(nèi)核調(diào)試日志,方便觀察是否能拿到
main.CountCC
函數(shù)的返回值,命令為cat /sys/kernel/debug/tracing/trace_pipe
。啟動被觀測程序,
bin/demo
觀測程序
觀測程序啟動后,可以看到終端日志中,搜索到兩處RET指令,并分別進行
uprobe`掛載。

main.CountCC
函數(shù)內(nèi),RET匯編指令的偏移地址分別為0x7A
、0xE3
,且都掛載成功,執(zhí)行的內(nèi)核函數(shù)為uprobe_countcc
。
被觀察程序
如你所見,被觀測程序沒有崩潰,可以正常運行,并輸出結(jié)果。

觀察結(jié)果
筆者的DEMO里沒有將內(nèi)核調(diào)試結(jié)果傳輸?shù)接脩艨臻g,直接打印了。
可以看到,demo-18960
(程序名+PID)運行結(jié)果后,出現(xiàn)了我們打印的日志。并且,捕獲的結(jié)果是101,符合預(yù)期。

總結(jié)
eBPF掛載uretprobe
崩潰的問題,只在Golang程序上發(fā)生,這跟Golang的協(xié)程縮容、擴容機制有關(guān),受到CPU中斷
指令插入影響,破壞原有調(diào)用棧,導(dǎo)致問題發(fā)生。其他編譯型語言上,不會有這個問題。假如有的語言也跟Golang一樣,使用stack
來做運動時管理,哪也會遇到這個問題。
關(guān)于 Golang的這個問題,在其社區(qū)里也有關(guān)于runtime: fatal error: unknown caller pc when uprobes are attached #27077[10]的討論,Go語言開發(fā)者**aclements[11]**認為,這不是Go的問題,近期也不會考慮修復(fù),希望uretprobe
的管理層面,自動做返回地址棧
的修復(fù)。
感謝提供的參考資料,@sillyousu。這些資料確認了我的猜測,很不幸地,我們實際上無法有效地解決uretprobes損壞堆棧的問題。
既然我們無能為力,而且這并不是一個Go的錯誤,我決定關(guān)閉這個問題。如果將來uretprobes能夠提供足夠的信息來恢復(fù)用戶空間中被破壞的返回地址,我們可以重新考慮這個問題,并可能找到解決方法。
所以,這個問題,大家還是自己使用模擬的方法來解決Golang程序的函數(shù)返回值觀測需求吧。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