Rust 与 Go 可以互操作?

1531次阅读  |  发布于2年以前

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 的三个不同函数:

Don’t Panic

大跳跃

让我们从一个非常基本的 trampoline 开始,钩子syscall.RawSyscall.abi0(一个使用 3 个参数调用系统调用的例程,也在socketsyscall 包中使用)。下面是这个函数的反汇编:

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(如前所述),准确地说 ABI0ABIInternal。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。调度器主要作用于四个重要对象:

如 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 提供的值并不重要)。不过,我们计划稍后解决它。

参考资料

[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

Copyright© 2013-2020

All Rights Reserved 京ICP备2023019179号-8