Rust 與 Go 互操作指南
大家好,我是程序員幽鬼。
Go 和 Rust 是近幾年比較受關注的新編程語言,兩者沒有直接的競爭關係,更多是互補。如果你想使用兩者的優勢並喜歡互操作,本文也許對你有幫助!
大多數主流編程語言都努力適應一些通用標準,以提高互操作性並減少採用摩擦。不過 Golang 不是其中之一。在這篇博文中,我們將展示如何克服 Go 的孤立主義設計並與另一種語言(在我們的例子中爲 Rust)集成。
爲什麼我們需要與 Go 互操作?mirrord 通過將系統調用掛接到操作系統 [1] 並應用決定是在本地還是遠程執行的邏輯來工作。爲此,鏡像側加載(使用LD_PRELOAD
)到進程中,然後掛鉤相關函數。爲了涵蓋最常見的場景,鏡像掛 libc 函數,這適用於大多數常見語言(Python、macOS 上的 Go、Rust、Node 等等),因爲它們都依賴於 libc。
基本無害
Go 在 Linux 上不使用 libc[2],而是直接調用系統調用。這對普通開發人員來說幾乎是無害的——他們不關心程序集、系統調用、鏈接等——他們只希望他們的二進制文件能夠工作。因此,自包含提供了非常好的用戶體驗,因爲 Go 應用程序不依賴於本地機器的 libc。
不過,這對我們來說是非常有害的。由於我們顯式地覆蓋了 libc 函數,因此我們的軟件在與 Go 應用程序(或任何其他不調用 libc 的進程)一起運行時根本無法運行。因此,我們必須 Hook Golang 函數!
幾乎,但不完全等同
幸運的是,Go 應用程序與其他軟件並不完全不同。Golang 必須與操作系統一起工作,所以它必須使用系統調用。由於 libc 並沒有在它包裝的系統調用之上添加太多邏輯,我們仍然可以使用我們現有的所有代碼——我們只需要用它覆蓋一個不同的函數。
我們如何 Hook Golang 函數?我們使用 libc 函數的方式與 Frida[3] 相同。問題是編寫可以在 Go 例程調用狀態下工作的 Rust 代碼並非易事。Go 有自己的 ABI,它不符合任何常見的 ABI。不過,這種不符合項相對常見。例如,Rust 也有一個不穩定的內部 ABI。如果我們可以在加載到 Go 二進制文件之前重新編譯它,我們可以使用 cgo 來訪問標準 C ABI,但在我們的用例中不能。這意味着我們必須實現 trampoline[4]。
rust, go, asm trampoline
trampoline 將用 Assembly 編寫,其目的是將 Go 函數調用轉換爲 Rust 函數調用,然後返回原始 Go 函數的調用者期望它返回的結果。
查看我們的 Go 二進制文件 [5] 和包的依賴項的回溯net/http
,很明顯它涉及到syscall
包的使用。通過使用 Ghidra[6] 對 Go 二進制文件進行逆向工程,我們將相關流程(socket、listen、accept 等)映射到我們需要 Hook 的三個不同函數:
-
syscall.Syscall6.abi0
- 帶有 6 個參數的系統調用讓運行時知道我們切換到阻塞操作,以便它可以在另一個線程 / goroutine 上調度。 -
syscall.Syscall.abi0
- 相同syscall.Syscall6.abi0
但具有三個參數。 -
syscall.RawSyscall.abi0
- 與上述相同,但不通知運行時。
Don’t Panic
大跳躍
讓我們從一個非常基本的 trampoline 開始,鉤子syscall.RawSyscall.abi0
(一個使用 3 個參數調用系統調用的例程,也在socket
syscall 包中使用)。下面是這個函數的反彙編:
disassembly of syscall.RawSyscall.abi0 using Ghidra
我們將按照 Rust 在 C ABI 中的期望將參數從堆棧移動到寄存器來實現這個蹦牀,然後按照 Go 的期望在堆棧上返回結果。
從堆棧到寄存器
mov rsi, QWORD PTR [rsp+0x10]
mov rdx, QWORD PTR [rsp+0x18]
mov rcx, QWORD PTR [rsp+0x20]
mov rdi, QWORD PTR [rsp+0x8]
Golang 有自己的 ABI(如前所述),準確地說 ABI0
和 ABIInternal
。Go 與基於堆棧的調用約定以及最近引入的基於寄存器的調用約定保持向後兼容性 [7]。事實證明,ABI0
函數遵循基於堆棧的約定,這就是我們從堆棧而不是寄存器中移動值的原因。
調用處理程序
call c_abi_syscall_handler
遵循 Go 中基於堆棧的約定,我們將參數移動到寄存器。但究竟是什麼登記,爲什麼?由於我們要掛鉤一個直接進行系統調用的函數,因此我們需要一個處理程序來爲我們管理系統調用。我們的處理程序將使用 C ABI 調用約定進行調用,它將匹配系統調用並根據它們的類型將它們重定向到它們的特定彎路,並將結果返回到符合 C ABI 的特定寄存器中。
#[no_mangle]
unsafe extern "C" fn c_abi_syscall_handler(
syscall: i64,
param1: i64,
param2: i64,
param3: i64,
) -> i32 {
let res = match syscall {
libc::SYS_socket => {
let sock = libc::socket(param1 as i32, param2 as i32, param3 as i32);
sock
}
_ => libc::syscall(syscall, param1, param2, param3) as i32,
};
return res;
}
將其放回堆棧以進行 Go
asm_linux_amd64.s[8]:
// func Syscall(trap int64, a1, a2, a3 uintptr) (r1, r2, err uintptr);
// Trap # in AX, args in DI SI DX R10 R8 R9, return in AX DX
// Note that this differs from "standard" ABI convention, which
// would pass 4th arg in CX, not R10.
如上所述,正如我們在反彙編中看到的,我們將把處理程序返回的結果移回堆棧,如下所示:
mov QWORD PTR [rsp+0x28],rax
mov QWORD PTR [rsp+0x30],rdx
mov QWORD PTR [rsp+0x38],0x0
ret
總結一下
#[cfg(target_os = "linux")]
#[cfg(target_arch = "x86_64")]
#[naked]
unsafe extern "C" fn go_raw_syscall_detour() {
asm!(
"mov rsi, QWORD PTR [rsp+0x10]",
"mov rdx, QWORD PTR [rsp+0x18]",
"mov rcx, QWORD PTR [rsp+0x20]",
"mov rdi, QWORD PTR [rsp+0x8]",
"call c_abi_syscall_handler",
"mov QWORD PTR [rsp+0x28],rax",
"mov QWORD PTR [rsp+0x30],rdx",
"mov QWORD PTR [rsp+0x38],0x0",
"ret",
options(noreturn),
);
}
注意 Naked 功能特性 [9] 的使用。裸函數使我們可以完全控制生成的程序集(根據我們的用例的需要),因爲 Rust 不會爲它們生成 epilogue/prologue。
讓我們做一個示例運行,看看是否一切正常:
running the gin server with the rawsyscall hook
偉大的!它就像我們預期的那樣工作。但是,mirrord 中的實際彎路包含日誌並進行大量記賬。讓我們從添加一個簡單的調試語句開始,看看情況如何。
#[no_mangle]
unsafe extern "C" fn c_abi_syscall_handler(
syscall: i64,
param1: i64,
param2: i64,
param3: i64,
) -> i32 {
debug!("c_abi_sycall_handler received syscall: {syscall:?}");
let res = match syscall {
libc::SYS_socket => {
let sock = libc::socket(param1 as i32, param2 as i32, param3 as i32);
sock
}
_ => libc::syscall(syscall, param1, param2, param3) as i32,
};
return res;
}
行動:
mehula@mehul-machine:~/golang-e2e/server$ LD_PRELOAD=../target/debug/libmirrord.so ./server
2022-08-15T17:15:36.497241Z DEBUG mirrord: LD_PRELOAD SET
2022-08-15T17:15:36.498403Z DEBUG mirrord: "syscall.RawSyscall.abi0" hooked
Server listening on port 8080
2022-08-15T17:15:36.505606Z DEBUG mirrord: c_abi_sycall_handler received syscall: 41
2022-08-15T17:15:36.505689Z DEBUG mirrord: c_abi_sycall_handler received syscall: 41
2022-08-15T17:15:36.505738Z DEBUG mirrord: c_abi_sycall_handler received syscall: 41
unexpected fault address 0x0
fatal error: fault
[signal SIGSEGV: segmentation violation code=0x80 addr=0x0 pc=0x7fa45a87f6b2]
goroutine 1 [running]:
runtime.throw({0x7e0b21?, 0x46?})
/usr/local/go/src/runtime/panic.go:992 +0x71 fp=0xc0002372a8 sp=0xc000237278 pc=0x4354b1
runtime: unexpected return pc for runtime.sigpanic called from 0x7fa45a87f6b2
stack: frame={sp:0xc0002372a8, fp:0xc0002372f8} stack=[0xc000218000,0xc000238000)
0x000000c0002371a8: 0x000000000045551b <runtime.write+0x000000000000003b> 0x0000000000000002
0x000000c0002371b8: 0x000000c0002371f0 0x0000000000436bae <runtime.recordForPanic+0x000000000000004e>
0x000000c0002371c8: 0x000000000045551b <runtime.write+0x000000000000003b> 0x0000000000000002
0x000000c0002371d8: 0x00000000008833ec 0x0000000000000001
0x000000c0002371e8: 0x0000000000000001 0x000000c000237228
0x000000c0002371f8: 0x0000000000436eb2 <runtime.gwrite+0x00000000000000f2> 0x00000000008833ec
0x000000c000237208: 0x0000000000000001 0x0000000000000001
0x000000c000237218: 0x000000c000237295 0x0000000000000003
0x000000c000237228: 0x000000c000237278 0x000000000046274e <runtime.systemstack+0x000000000000002e>
0x000000c000237238: 0x00000000004356f0 <runtime.fatalthrow+0x0000000000000050> 0x000000c000237248
0x000000c000237248: 0x0000000000435720 <runtime.fatalthrow.func1+0x0000000000000000> 0x000000c0000021a0
0x000000c000237258: 0x00000000004354b1 <runtime.throw+0x0000000000000071> 0x000000c000237278
0x000000c000237268: 0x000000c000237298 0x00000000004354b1 <runtime.throw+0x0000000000000071>
0x000000c000237278: 0x000000c000237280 0x00000000004354e0 <runtime.throw.func1+0x0000000000000000>
0x000000c000237288: 0x00000000007e0b21 0x0000000000000005
0x000000c000237298: 0x000000c0002372e8 0x000000000044a8c5 <runtime.sigpanic+0x0000000000000305>
0x000000c0002372a8: <0x00000000007e0b21 0x0000000000000046
0x000000c0002372b8: 0x00007fa45a0f27c0 0x00007fa45a89e8e9
0x000000c0002372c8: 0x0000000000000000 0x0000000000000000
0x000000c0002372d8: 0x00007fa45a0f27c0 0x0000000000000000
0x000000c0002372e8: 0x000000c000237ab0 !0x00007fa45a87f6b2
0x000000c0002372f8: >0x000000c000237320 0x000000c0002373e8
0x000000c000237308: 0x00007fa45a0f27c0 0x0000000000000000
0x000000c000237318: 0x00007fa45a0f27c0 0x00007fa45a0f27c0
0x000000c000237328: 0x00007fa45a0f27c0 0x0000000000000000
0x000000c000237338: 0x0000000000000000 0x0000000000000000
0x000000c000237348: 0x00007fa45a895d84 0x0000000000000000
0x000000c000237358: 0x000000000237c328 0x00007fa45a0f2840
0x000000c000237368: 0x0000000000000000 0x00007fa45b698f50
0x000000c000237378: 0x0000000000000000 0xffffffffffffffff
0x000000c000237388: 0x00007fa45a0f2840 0x0000000000000000
0x000000c000237398: 0x00007fa45a0f2840 0xffffffffffffffff
0x000000c0002373a8: 0x0000000000000000 0x00007fa45a0f27c0
0x000000c0002373b8: 0x00007fa45a0f27c0 0x00007fa45a0f27c0
0x000000c0002373c8: 0x00007fa45a0f27c0 0x00007fa45a87e8e7
0x000000c0002373d8: 0x0000000000000000 0x00007fa45a895d44
0x000000c0002373e8: 0x000000c000237420 0x000000000237c340
runtime.sigpanic()
/usr/local/go/src/runtime/signal_unix.go:825 +0x305 fp=0xc0002372f8 sp=0xc0002372a8 pc=0x44a8c5
爲什麼這個 goroutine 會因爲我們的鉤子而 panic?
Go 的運行時調度程序遵循一種非常奇特但智能的方式來調度 goroutine。調度器主要作用於四個重要對象:
-
G - 協程
-
N - goroutine 的數量
-
M - 操作系統線程(N 映射到 M)
-
P - 代表處理器的概念,即 M 在運行 goroutine 時的資源提供者。
如 Go 運行時調度程序的設計文檔中所述 [10],
“當一個新的 G 被創建或一個現有的 G 變爲可運行時,它被推送到當前 P 的可運行 goroutine 列表中。當 P 執行完 G 時,它首先嚐試從自己的可運行 goroutine 列表中彈出一個 G;如果列表爲空,P 會選擇一個隨機受害者(另一個 P)並嘗試從中竊取一半可運行的 goroutine。”
總之,每個 G 在分配給 P 的 M 上運行。
現在我們對 Go 如何調度 goroutine 有了一些瞭解,通過查看這個 [11] 源文件,我們可以看到 Golang 不能使用 “系統堆棧”(在 Linux 上大多數情況下是 pthread 堆棧),而是使用自己的 goroutine 堆棧實現最小大小爲 2048 字節。
Goroutine 堆棧是動態的,即它根據當前需求不斷擴展 / 收縮。這意味着在系統堆棧中運行的任何通用代碼都假定它可以隨心所欲地增長(直到超過最大堆棧大小),而實際上,除非使用 Go API 進行擴展,否則它不能。我們的 Rust 代碼沒有意識到這一點,因此它使用了實際上不可用的部分堆棧並導致堆棧溢出。
我們缺少一些步驟。有人可能會考慮使用runtime.morestack
,但這對我們來說可能並不理想,因爲這涉及到根據我們的需要手動管理堆棧。幸運的是,我們不是第一個在 Go 中做 FFI 的人,所以我們研究了 cgo 在調用外部函數時做了什麼:
引用自 runtime/cgocall.go[12]:
// Cgo call and callback support.1
//
// To call into the C function f from Go, the cgo-generated code calls
// runtime.cgocall(_cgo_Cfunc_f, frame), where _cgo_Cfunc_f is a
// gcc-compiled function written by cgo.
//
// runtime.cgocall (below) calls entersyscall so as not to block
// other goroutines or the garbage collector, and then calls
// runtime.asmcgocall(_cgo_Cfunc_f, frame).
//
// runtime.asmcgocall (in asm_$GOARCH.s) switches to the m->g0 stack
// (assumed to be an operating system-allocated stack, so safe to run
// gcc-compiled code on) and calls _cgo_Cfunc_f(frame).
//
// _cgo_Cfunc_f invokes the actual C function f with arguments
// taken from the frame structure, records the results in the frame,
// and returns to runtime.asmcgocall.
//
// After it regains control, runtime.asmcgocall switches back to the
// original g (m->curg)'s stack and returns to runtime.cgocall.
//
// After it regains control, runtime.cgocall calls exitsyscall, which blocks
// until this m can run Go code without violating the $GOMAXPROCS limit,
// and then unlocks g from m.
//
我們將跳過非阻塞部分,即調用 runtime.entersyscall/runtime.exitsyscall 以讓調度程序提防 “阻塞” 調用,以便調度程序可以將其時間讓給另一個 goroutine,如 Syscall.Syscall6.abi0
的情況所示Syscall.Syscall.abi0
。因此,我們只需使用 runtime.asmcgocall.abi0
。
mov rbx, QWORD PTR [rsp+0x10]
mov r10, QWORD PTR [rsp+0x18]
mov rcx, QWORD PTR [rsp+0x20]
mov rax, QWORD PTR [rsp+0x8]
mov rdx, rsp
mov rdi, QWORD PTR fs:[0xfffffff8]
cmp rdi, 0x0
je 2f
mov r8, QWORD PTR [rdi+0x30]
mov rsi, QWORD PTR [r8+0x50]
cmp rdi, rsi
je 2f
mov rsi, QWORD PTR [r8]
cmp rdi, rsi
je 2f
call go_systemstack_switch
mov QWORD PTR fs:[0xfffffff8], rsi
mov rsp, QWORD PTR [rsi+0x38]
sub rsp, 0x40
and rsp, 0xfffffffffffffff0
mov QWORD PTR [rsp+0x30], rdi
mov rdi, QWORD PTR [rdi+0x8]
sub rdi, rdx
mov QWORD PTR [rsp+0x28],rdi
mov rsi, rbx
mov rdx, r10
mov rdi, rax
call c_abi_syscall_handler
在將參數保存在一些未觸及的寄存器中之後,我們調用系統堆棧上的處理程序,並打亂寄存器 / 堆棧數據以匹配 Go 的期望,主要是返回參數到堆棧中的特定位置。
mov QWORD PTR [rsp+0x28], -0x1
mov QWORD PTR [rsp+0x30], 0x0
neg rax
mov QWORD PTR [rsp+0x38], rax
xorps xmm15, xmm15
mov r14, QWORD PTR FS:[0xfffffff8]
ret
3:
mov QWORD PTR [rsp+0x28], rax
mov QWORD PTR [rsp+0x30], 0x0
mov QWORD PTR [rsp+0x38], 0x0
xorps xmm15, xmm15
mov r14, QWORD PTR FS:[0xfffffff8]
ret
在將所有系統調用繞道與 mirrord 拼接在一起之後 ABI0
,讓我們看看事情是否按預期工作。
running the gin server with the rawsyscall hook
成功!🥂
此處 [13] 提供了所有掛鉤的完整實現。
我們決定不處理 Go 所做的非阻塞更改,主要是因爲它對我們的用例並不重要(“一點延遲” 對於我們嘗試通過 mirrord 提供的值並不重要)。不過,我們計劃稍後解決它。
原文鏈接:https://metalbear.co/blog/hooking-go-from-rust-hitchhikers-guide-to-the-go-laxy/,作者:Aviram Hassan 和 Mehul Arora。
參考資料
[1]
mirrord 通過將系統調用掛接到操作系統: https://metalbear.co/blog/mirrord-internals-hooking-libc-functions-in-rust-and-fixing-bugs/
[2]
Go 在 Linux 上不使用 libc: https://lwn.net/Articles/771441/
[3]
Frida: https://www.frida.re/
[4]
trampoline: https://metalbear.co/blog/hooking-go-from-rust-hitchhikers-guide-to-the-go-laxy/
[5]
Go 二進制文件: https://github.com/metalbear-co/mirrord/blob/main/tests/go-e2e/main.go
[6]
通過使用 Ghidra: https://github.com/NationalSecurityAgency/ghidra
[7]
向後兼容性: https://go.googlesource.com/proposal/+/master/design/27539-internal-abi.md
[8]
asm_linux_amd64.s: https://go.googlesource.com/go/+/c0d6d33/src/syscall/asm_linux_amd64.s
[9]
Naked 功能特性: https://rust-lang.github.io/rfcs/2972-constrained-naked.html
[10]
設計文檔中所述: https://docs.google.com/document/d/1TTj4T2JO42uD5ID9e89oa0sLKhJYD0Y_kqxDv3I3XMw/edit
[11]
這個: https://cs.opensource.google/go/go/+/master:src/runtime/stack.go
[12]
runtime/cgocall.go: https://go.dev/src/runtime/cgocall.go
[13]
此處: https://github.com/metalbear-co/mirrord/blob/main/mirrord-layer/src/go_hooks.rs
歡迎關注「幽鬼」,像她一樣做團隊的核心。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/1AUhAG_9q0THoU6jqtWmkg