摘要
Golang 是开发云技术时最常用的编程语言。Kubernetes、Docker、Containerd 和 gVisor 等工具都是用 Go 编写的。尽管这些程序的代码是开源的,但如果不重新编译其代码,就无法动态分析和扩展其行为。本篇文章主要研究在Golang 程序中开发和插入运行时Hook。
运行时Hook Golang程序困难点
如今,大多数现代云技术都是用 Golang 编写的(例如 Kubernetes、Docker、Containerd、runc、gVisor 等)。这些技术大多数都具有庞大而复杂的架构,静态分析起来很麻烦。如果能够在静态分析的同时动态分析这些工具就完美了。遗憾的是,目前还没有任何无需重新编译程序源代码即可进行动态分析的解决方案。这可能是一个问题,因为有时无法修改这些工具的源代码,而应该直接与已经执行代码的进程进行交互。但是为什么没有任何工具允许在正在运行的 Go 程序中插入一些任意逻辑呢?假设问题之一可能是 Gopl(Golang 编程语言)具有与 C 和 C++ 中使用的 ABI(应用程序二进制接口)不同的 ABI(应用程序二进制接口)此外Golang 包含了一个特定于语言的运行时,它负责复杂的过程,例如垃圾收集和 goroutine 的调度。该运行时在程序中的放置方式及其功能完全改变了构造和插入钩子的方式。最初,Gopl 的目的是独立的 - 它没有被设计为在运行时可扩展(例如加载共享库),令人高兴的是,这种情况发生了变化,但如果 Go 程序不使用 net
包,或者它们不使用 cgo
。不过通过一些假设和调整,可以规避这些问题。
什么是程序挂钩及作用
挂钩程序是更改其默认执行流程的过程,大多数时候是为了收集有关程序环境的信息(例如检查函数的参数)或为了更改其行为(例如更改函数的参数或一个函数)。
Detours 用于调试、热补丁、指标收集,也用于恶意软件开发、游戏破解等。一般来说,挂钩有两种类型:
-
常规钩子——劫持原始执行流程并用辅助逻辑替换它。
-
trampoline钩子——劫持原始执行流程,执行辅助逻辑,然后执行原始流程。
这里, 将执行流程中被挂钩的程序称为:Host, 将被重定向的外部代码段称为:Guest
以下是trampoline钩子如何在常规编译的程序中工作的流程图:
上面的架构说明了将Host中的函数的执行流程重定向到Guest中的另一个函数。挂钩发生在Host执行期间,因此上述所有内容都发生在 RAM 中,其中加载了其指令。在上面的模式中,假设在Host执行期间,由称为“加载器”的辅助程序从外部加载Guest。可以将 Guest 视为外部对象(共享库)。这种方法可以应用于挂钩函数的任何部分,假设该函数不是内联的,或者它至少可以在其中托管一个 JUMP 代码块。上述架构中具有数字标识符的每个阶段过程如下:
-
1.创建备份 - 此阶段涉及保存原始函数的一些指令。重定向代码块的插入(在阶段 2 中)将覆盖 5 或 14 个字节的指令,具体取决于代码块的大小。为了能够在钩子之后执行原始指令,需要保存这些字节并稍后执行它们。注意:选择保存哪些指令很重要。由于这些指令将存储在另一个段中,如果它们包含相对偏移量,则可能涉及指令修补。另一种解决方案是覆盖其执行与其位置无关的指令。
-
2.初始化 Trampoline — 在此阶段,Guest 初始化 Trampoline 段(分配、根据 Guest 加载位置初始化调用代码块地址、插入备份指令等)。
-
3.插入重定向代码块——在此阶段,重定向代码块被插入到函数体中,覆盖原始指令。当执行流到达它时,它将被重定向到包含trampoline逻辑的外部段。该段不是Host的一部分,因此它是由Guest在加载时创建和初始化的。
-
4.保存上下文——执行流在重定向后结束。其目的是在调用 Guest 中的钩子函数之前保留执行上下文。该钩子可以在执行时修改 CPU 状态,这可能会破坏程序在未来状态的执行。在大多数编程语言中,都有调用者保存的和被调用者保存的CPU寄存器。为了在执行流程返回正常路径时不破坏程序,需要保存调用者保存的寄存器,以便Guest可以自由修改它们。此外,在此阶段,还要准备函数调用,这可能需要添加、重新组织或删除函数参数。
-
5.调用钩子 — 调用指令将流程重定向到 Guest 的
.text
段中定义的钩子。 -
6.恢复上下文——当钩子返回时,在图中的trampoline部分恢复并在必要时修改(如果钩子返回结果的话) 存储的上下文(CPU寄存器)。
-
7.执行备份——执行保存的指令。
-
8.继续执行——流程被重定向到重定向代码块之后的第一条指令。
使用C和纯汇编Hook Golang
下面介绍一种如何创建运行时挂钩将执行流从 Go 函数重定向到 C 函数的方法,并讨论这种方法的局限性。先看一段Golang 程序:
package main
import (
"fmt"
"os"
"strings"
)
import "C"
// "import C" 语句是为了让编译器生成一个在启动时加载libc.so的二进制文件
// 这是为了侧加载钩子逻辑所必需的
var SECRET string = "VALIDATEME"
func theGuessingGame(s string) bool {
if s == SECRET {
fmt.Println("Authorized")
return true
} else {
fmt.Println("Unauthorised")
return false
}
}
func main() {
var s string
for {
if _, err := fmt.Scanf("%s", &s); err != nil {
panic(err)
}
s = strings.ToLower(s)
if theGuessingGame(s) {
os.Exit(0)
}
}
}
上面的代码接收用户输入的字符串并将其与硬编码值进行比较。问题是用户永远无法提供正确的字符串,因为其输入是小写的,而硬编码的字符串是大写的。为了获得“授权”输出,可以执行以下操作:
-
跳过
main
中对strings.ToLower
的调用,直接跳转到对theGuessingGame
的调用。 -
当开始执行
theGuessingGame
时,直接跳转到fmt.Println
代码。 -
通过调用执行相反操作(大写)的挂钩来更改小写字符串的值。这可以在调用
strings.ToLower
之后直接完成,也可以在执行实际检查之前在theGuessingGame
函数开始时完成。
即使前两个选项更简单,通常也会采用第三个选项,因为它是本文的主题。这里将使用trampoline钩子,以便可以保留原始执行流程并且仅更改函数的参数。上面的代码片段中有一个 import C
语句。当二进制文件加载到内存中时,这将指示编译器添加加载器加载 libc
的指令。正如之前所说,默认情况下,Go 二进制文件是静态链接的,并包含标准库的实现。这是侧载钩子逻辑所需要的。
用C表示Golang字符串
在上面的流程图中, 如果Host程序使用类似 C 的字符串,那么Guest中的例程将具有以下原型 void toUpper(char *s);
(以 Null 结尾的 ASCII 字符序列)。然而,Go 中的字符串表示方式有所不同。在 Golang 中,字符串被视为 UTF-8 序列,其中每个位置都可能包含一个 Null 字节。因此,在 Go 中,实际的字符序列与其长度一起嵌入到结构中。该结构的编译器定义(对于 Go 版本 1.20.3)是:
// src/internal/unsafeheader/unsafeheader.go:28
// String 是字符串的运行时表示。
// 它不能被安全或可移植地使用,并且其表示方式可能在以后的版本中更改。
// 与 reflect.StringHeader 不同,它的Data字段足以保证其引用的数据不会被垃圾回收。
type String struct {
Data unsafe.Pointer
Len int
}
要在C 中定义等价的数据结构,必须找到每个字段的有效表示方法:
-
在这种情况下,Go 中的
unsafe.Pointer
类型可以看作是 C 中的const char *
(一般情况下可以将其视为void *
)。 -
Go 中的
int
类型相当于 C 中的ptrdiff_t
(来自<stddef.h>
)(一般可以将其视为uint64_t
) 。
结合以上内容,现在可以使用以下定义在 C 中表示 Go 字符串:
// hook.h
typedef struct GoString {
char *p;
ptrdiff_t n;
} GoString;
现在,可以用 C 语言定义 toUpper
例程。为了简单起见,假设实际的字节数据是 UTF-8 字符集的大写 ASCII 子集。
/*
将ASCII字符串(a-z)转换为大写(A-Z)。
这里假设 str->p 中的字符串字节序列仅包含有效的大写ASCII字母。
*/
void
toUpper(GoString str) {
char * data = str.p;
for (int i=0; i<str.n; i++){
data[i] -= 32;
}
}
准确定位插入钩子的正确位置
现在是时候选择在Host中劫持执行流并将其重定向到Guest的位置了。这里选择了 theGuessingGame
函数,首先使用以下命令编译代码:
$ go build -o secret secret.go
应该确保生成的二进制文件是动态链接的,并且 libc
将被加载到其中。
$ file secret && echo && ldd secret
secret: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=c7ba267d636a05fe5c7438b3cfba76116a26f878, for GNU/Linux 3.2.0, with debug_info, not stripped
linux-vdso.so.1 (0x00007fff8eabe000)
libc.so.6 => /lib64/libc.so.6 (0x00007f11dab6c000)
/lib64/ld-linux-x86-64.so.2 (0x00007f11dad54000)
这里使用 GDB 分析 main
的汇编代码,看看如何调用 theGuessingGame
函数, 结果如下:
...
0x0000000000493c15 <+341>: call 0x48d3a0 <fmt.Fscanf> ; here we read from STDIN and store the user string
0x0000000000493c1a <+346>: test rbx,rbx ; test for errors
0x0000000000493c1d <+349>: jne 0x493c6a <main.main+426>
0x0000000000493c1f <+351>: mov rcx,QWORD PTR [rsp+0x38] ; load the string structure in RCX
0x0000000000493c24 <+356>: mov rax,QWORD PTR [rcx] ; load the pointer to the byte data in RAX (1st member of the structure)
0x0000000000493c27 <+359>: mov rbx,QWORD PTR [rcx+0x8] ; load the size of the string in RBX (2nd member of the structure)
0x0000000000493c2b <+363>: call 0x4934a0 <strings.ToLower> ; strings in Go are immutable so lowercasing
one will create a new structure. RAX contains the pointer to the bytes of the new string and RBX its size
0x0000000000493c30 <+368>: mov rdi,QWORD PTR [rsp+0x38] ; load the pointer to the original string structure
0x0000000000493c35 <+373>: mov QWORD PTR [rdi+0x8],rbx ; store the new size
# The instructions ensure that if the concurrent garbage collector is running, it's up to him to update the pointer and update its view of the used heap dat
0x0000000000493c39 <+377>: cmp DWORD PTR [rip+0xea280],0x0 ; 0x57dec0 <runtime.writeBarrier>
0x0000000000493c40 <+384>: jne 0x493c47 <main.main+391>
0x0000000000493c42 <+386>: mov QWORD PTR [rdi],rax
0x0000000000493c45 <+389>: jmp 0x493c4c <main.main+396>
0x0000000000493c47 <+391>: call 0x45f9c0 <runtime.gcWriteBarrier>
0x0000000000493c4c <+396>: call 0x4939c0 <main.theGuessingGame> ; call into the theGuessingGame where RAX holds a pointer to the sequence of UTF-8 data and RBX the size of this data
...
在这里可以看到 Golang ABI。第一个参数位于 RAX
中,第二个参数位于 RBX
中,第三个参数位于 RCX
中,依此类推。
接着在比较参数字符串和硬编码字符串之前分析一下 theGuessingGame
函数的入口部分:
Dump of assembler code for function main.theGuessingGame:
# The above 2 instructions ensures that the current goroutine stack has enough place to accomodate the function execution
0x00000000004939c0 <+0>: cmp rsp,QWORD PTR [r14+0x10]
0x00000000004939c4 <+4>: jbe 0x493a94 <main.theGuessingGame+212>
0x00000000004939ca <+10>: sub rsp,0x50
0x00000000004939ce <+14>: mov QWORD PTR [rsp+0x48],rbp
0x00000000004939d3 <+19>: lea rbp,[rsp+0x48]
0x00000000004939d8 <+24>: mov QWORD PTR [rsp+0x58], rax
0x00000000004939dd <+29>: mov rdx,QWORD PTR [rip+0xa2f9c] ; 0x536980 <main.SECRET> - load into RDX the pointer to the data of the hardcoded string indentified with main.SECRET+8
0x00000000004939e4 <+36>: cmp QWORD PTR [rip+0xa2f9d],rbx ; 0x536988 <main.SECRET+8> - compare the size of the parameter string with the size of the hardcoded string's
0x00000000004939eb <+43>: jne 0x4939fc <main.theGuessingGame+60> ; if these are not equal; no need to compare the actual data bytes
0x00000000004939ed <+45>: mov rcx,rbx ; move the equal size of the two strings in RCX
0x00000000004939f0 <+48>: mov rbx,rdx ; move the pointer to the hardcoded string in RBX
0x00000000004939f3 <+51>: call 0x4038e0 <runtime.memequal> ; the memory regions are compared (RAX-> argument pointer to the string's data, RVX -> idem but for the hardcoded one, rcx the number of bytes to be compared)
0x00000000004939f8 <+56>: test al,al ; if al=0 then the strings are equal
这里设想的是在 runtime.memequal
之前的某个地方劫持执行流。并插入一个 14 字节的 JUMP代码块,因此在插入之前应该备份至少 14 条指令:
push <last-four-bytes-of-destination-address>
move [rsp+4] <first-four-bytes-of-destination-address>
ret
这里可以替换堆栈管理例程(从 0x04939c0
到 0x4939ce
),但该区域包含相对于 RIP
的指令,这需要指令修补。另一个合适的位置是从 0x4939ca
到 0x4939d8
,这是一个常规函数序言加上一条附加指令。备份不需要任何修补,即使指令被放置在另一个位置,也可以按原样执行。现在是时候加载自己的钩子了。
加载Guest
为了加载包含Host内部钩子逻辑的Guest,需要使用一种非常常见的技术,即使用 ptrace
API 将共享库侧加载到 Linux 上正在运行的进程中。这里不详细介绍其工作原理,网上有大量资源。这里使用了自己编写的Go 程序来实现。然而,为了使侧加载发挥作用,需要指出一些重要的方面:
-
C 钩子使用 gcc 和选项
-shared
编译为 PIC(位置无关代码),生成共享对象。 -
库加载发生在目标程序运行时。这是通过使用 ptrace API 附加到进程,然后调用
dlopen
来完成的,它是加载到进程中的标准库 (libc
) 的一部分。dlopen
函数的参数是编译后的共享库的路径,该库之前使用 ptrace 再次写入正在运行的程序的内存中。 -
加载进程(将库加载到目标程序的进程)应该是特权进程,或者由与目标进程相同的用户拥有,并且具备 CAP_SYS_PTRACE 能力。
-
跳转代码块插入逻辑被编译为共享库的一部分。当加载共享库并且加载器调用其
__constructor__
函数时,插入便会顺利完成。
插入Jump代码块并保存覆盖指令
加载 Guest 时会插入重定向代码块。加载Guest后 theGuessingGame
函数的入口如下:
Dump of assembler code for function main.theGuessingGame:
0x00000000004939c0 <+0>: cmp rsp,QWORD PTR [r14+0x10]
0x00000000004939c4 <+4>: jbe 0x493a94 <main.theGuessingGame+212>
0x00000000004939ca <+10>: push 0x4b9e4000 ; hohoho this is new
0x00000000004939cf <+15>: mov DWORD PTR [rsp+0x4],0x7fc3 ; and this too
0x00000000004939d7 <+23>: ret
0x00000000004939d8 <+24>: mov QWORD PTR [rsp+0x58],rax
这里可以看到插入的代码块加载地址 0x7fc34b9e4000
。检查一下那里有什么:
0x7fc34b9e4000: push r9 ; r9 will be clobbered, so push it onto the stack
0x7fc34b9e4002: movabs r9,0x7fc34b9e782d ; cloberring r9 with a function address
0x7fc34b9e400c: call r9 ; calling the function;
0x7fc34b9e400f: pop r9 ; restore r9 from the stack
0x7fc34b9e4011: sub rsp,0x50 ; backup
0x7fc34b9e4015: mov QWORD PTR [rsp+0x48],rbp ; backup
0x7fc34b9e401a: lea rbp,[rsp+0x48] ; backup
0x7fc34b9e401f: push 0x4939d8 ; the lower 4 bytes of the address of the next instruction
0x7fc34b9e4024: mov DWORD PTR [rsp+0x4],0x0 ; the upper 4 bytes of the address of the next instruction
0x7fc34b9e402c: ret
可以从这个方案中看到trampoline部分。最后一部分(执行被覆盖的指令并跳转到下一条指令)是相同的,但第一部分并不是。跳板调用了位于 0x7fc34b9e782d 的某个东西。那么,这个地址是什么呢?要回答这个问题,先来看看Go和C的ABI(应用二进制接口)之间的区别。
钩子插入-ABI切换
Go 和 C 有两个不同的 ABI。因此,如果想从 Go 调用 C 函数,需要切换 ABI。Go 使用基于寄存器的 ABI。需要将其转换为 C ABI(也称为 System V)。这里只有两个参数——指向字符串字节的指针(在 RAX
中)及其大小(在 RBX
中)。
但是 toUpper
函数的ABI在Guest中是如何安排的呢?
Dump of assembler code for function toUpper:
0x00007fada9d711d9 <+0>: push rbp
0x00007fada9d711da <+1>: mov rbp,rsp
0x00007fada9d711dd <+4>: mov rax,rdi ; rdi contains the pointer to the bytes of the Go string
0x00007fada9d711e0 <+7>: mov rcx,rsi ; rsi contains the length of the the Go string
0x00007fada9d711e3 <+10>: mov rdx,rcx
0x00007fada9d711e6 <+13>: mov QWORD PTR [rbp-0x20],rax ; save the pointer to the Go string data
0x00007fada9d711ea <+17>: mov QWORD PTR [rbp-0x18],rdx ; save the length of the Go string data
0x00007fada9d711ee <+21>: mov rax,QWORD PTR [rbp-0x20]
0x00007fada9d711f2 <+25>: mov QWORD PTR [rbp-0x10],rax
0x00007fada9d711f6 <+29>: mov DWORD PTR [rbp-0x4],0x0 ; the i varaible
0x00007fada9d711fd <+36>: jmp 0x7fada9d71227 <toUpper+78>
0x00007fada9d711ff <+38>: mov eax,DWORD PTR [rbp-0x4] ; the beginning of the loop modifying the string
0x00007fada9d71202 <+41>: movsxd rdx,eax
0x00007fada9d71205 <+44>: mov rax,QWORD PTR [rbp-0x10]
0x00007fada9d71209 <+48>: add rax,rdx
0x00007fada9d7120c <+51>: movzx eax,BYTE PTR [rax]
0x00007fada9d7120f <+54>: lea ecx,[rax-0x20]
0x00007fada9d71212 <+57>: mov eax,DWORD PTR [rbp-0x4]
0x00007fada9d71215 <+60>: movsxd rdx,eax
0x00007fada9d71218 <+63>: mov rax,QWORD PTR [rbp-0x10]
0x00007fada9d7121c <+67>: add rax,rdx
0x00007fada9d7121f <+70>: mov edx,ecx
0x00007fada9d71221 <+72>: mov BYTE PTR [rax],dl
0x00007fada9d71223 <+74>: add DWORD PTR [rbp-0x4],0x1
0x00007fada9d71227 <+78>: mov eax,DWORD PTR [rbp-0x4]
0x00007fada9d7122a <+81>: movsxd rdx,eax
0x00007fada9d7122d <+84>: mov rax,QWORD PTR [rbp-0x18] ; get the length of the Go string
0x00007fada9d71231 <+88>: cmp rdx,rax ; compare it with the i variable
0x00007fada9d71234 <+91>: jl 0x7fada9d711ff <toUpper+38> ; jump into the loop
0x00007fada9d71236 <+93>: nop
0x00007fada9d71237 <+94>: nop
0x00007fada9d71238 <+95>: pop rbp
0x00007fada9d71239 <+96>: ret
可以看到,指向 Go 字符串数据的指针位于 RDI
中,而其大小位于 RSI
中。所以需要做的转换很简单 - RAX->RDI
和 RBX—>RSI
。这应该在调用 C 函数之前和插入 JUMP 代码块之后完成。该逻辑可以位于堆上,也可以作为共享库代码段的一部分。下面是执行 ABI 切换的简单程序集代码块:
ABI_SWITCH:
mov rdi, rax
mov rsi, rbx
CALL_C_FUNC:
mov r9, <address-of-toUpper>
call r9
ABI_RESTORE:
; nothing to be done
在 C 中存在被调用者和调用者保存寄存器的概念。换句话说,应该保存 C 代码最终会破坏的所有寄存器,并在执行trampoline部分中被覆盖的指令之前恢复它们。在 System V ABI 中,这些是 RAX, RCX, RDX, RSI, RDI, R8, R9, R10, R11
,因此用以下内容扩展上述逻辑:
SAVE_CTX:
push rax
push rcx
push rdx
push rdi
push rsi
push r8
push r9
push r10
push r11
ABI_SWITCH:
...
CALL_C_FUNC:
...
ABI_RESTORE:
RESTORE_CTX:
pop r11
pop r10
pop r9
pop r8
pop rsi
pop rdi
pop rdx
pop rcx
pop rax
注意:如果挂钩返回结果,则应相应地调整 ABI 恢复逻辑。现在如果跳转到 SAVE_CTX
段,应该没问题吧?不完全是——可能会耗尽堆栈空间!
钩子插入-栈旋转
在 theGuessingGame
函数的入口,有一个四字节跳转:
0x00000000004939c0 <+0>: cmp rsp,QWORD PTR [r14+0x10] ; retrieves the goroutine structure of the current thread
0x00000000004939c4 <+4>: jbe 0x493a94 <main.theGuessingGame+212>
...
如果跟进跳转,最终会到达这里:
0x0000000000493a94 <+212>: mov QWORD PTR [rsp+0x8],rax ; 保存栈的第一个参数
0x0000000000493a99 <+217>: mov QWORD PTR [rsp+0x10],rbx ; 保存栈的第二个参数
0x0000000000493a9e <+222>: xchg ax,ax
0x0000000000493aa0 <+224>: call 0x45d8c0 <runtime.morestack_noctxt> ; 增加栈的大小并更新在goroutine结构中的限制
0x0000000000493aa5 <+229>: mov rax,QWORD PTR [rsp+0x8] ; 恢复第一个参数
0x0000000000493aaa <+234>: mov rbx,QWORD PTR [rsp+0x10] ; 恢复第二个参数
0x0000000000493aaf <+239>: jmp 0x4939c0 <main.theGuessingGame> ; 跳转继续执行
在 Go 中,goroutine 堆栈的大小是可调整的并指向堆。系统堆栈仅由运行时的某些组件使用。这些指令实际上是在验证当前 goroutine 堆栈的大小(R14 包含一个指向 goroutine 结构的指针,并且在偏移量 0x10
处位于称为 stackguard
的堆栈限制)。如果堆栈空间不够,则调用 runtime.morestack_noctxt
来增加堆栈。在此函数中,运行时将根据编译器插入的堆栈映射(用于分配和释放内存的当前函数的堆栈空间的描述)分配正确数量的堆栈空间。 Goroutine 堆栈很小(2Kb)。理论上,如果在堆栈大小调整之前劫持控制流,最终可能会没有足够的堆栈来存储寄存器, 并无法执行该堆栈的挂钩代码。为了解决这个问题,可以在调用 C 函数之前(以及在保存寄存器之前)将堆栈旋转到新的 RW 区域,然后恢复旧堆栈。新堆栈的内存分配是在加载 Guest 时完成的。这是堆栈旋转逻辑:
STACK_PIVOT:
; save the current G stack in memory
mov r9, <addr-to-store-g-stack>
mov [r9], rsp
; load the new stack and pivot it (atomic swap)
mov r9, <addr-new-stack>
xchg r9, rsp
SAVE_CTX:
...
ABI_SWITCH:
...
CALL_C_FUNC:
...
ABI_RESTORE:
RESTORE_CTX:
...
STACK_PIVOT_REV:
mov r9, <addr-stack-backup>
xchg rsp, [r9]
ret
上面的代码中还存在一个潜在问题。如果Host中的目标函数使用的栈空间少于8个字节,会怎么样呢?别忘了编译器没有预料到我们会干预执行流!因此,如果推送 R9 导致当前栈溢出怎么办?别担心,Go 已经考虑到了这点, 正如之前提到的,栈限制检查是针对 goroutine 结构的一个成员 stackguard 进行的,它可以看作是栈的底部。然而,这个 stackguard 并不是 goroutine 的真正栈限制。Go 运行时会允许一定数量的字节(常量定义为 StackSmall=128[bytes])超出这个限制(也称为溢出区)。这个小空间可以被具有小型或零大小栈帧的函数使用,这些函数不需要调整其栈大小或执行额外的检查(也用于优化)。这种类型的函数的示例(大多数是用汇编编写的)主要可以在 runtime 包中找到(标记为 NOSPLIT 的函数)。因此,理论上应该有足够的空间来推送 R9 寄存器。
现在, 钩子逻辑将会正常工作。现在我们知道在跳板部分地址 0x7fc34b9e782d 处是 STACK_PIVOT 桩的地址。然而,还有另一个理论上可能出现的小问题,应该做好准备。
钩子插入-解决并发问题
上面的示例程序相当简单,但总的来说,Go 程序往往具有高度并发性。因此,上述代码块序列引入了重入问题 — 如果两个 goroutine 执行相同的函数并且都被重定向,它们可能会使用相反的栈!这种情况也会破坏对执行 C 代码安全性的假设,因为第二个 goroutine 可能会使用第一个 goroutine 的小栈。可以通过向现有程序添加以下代码来说明这个问题:
...
s = strings.ToLower(s)
go theGuessingGame(s)
if theGuessingGame(s) {
os.Exit(0)
}
为了解决这个问题,可以使用一个简单的信号量引入繁忙等待, 相关Poc可以参考:https://github.com/quarkslab/hooking-golang-playground/tree/main/part-1
局限性
上面讨论的方法适用于简单的程序,遗憾的是它非常依赖于体系结构和平台。以下是此方法的一些限制:
-
在 Windows 上,ABI 不同,因此上面的代码将不起作用。
-
使用的汇编代码片段适用于 x86-64。对于其他架构,例如 ARM 或 MIPS,上述方法不起作用。
-
所有 Go 类型和各自的偏移量都必须在 C 标头中手动定义。
-
上述方法引入了严重的并发问题。
因此, 探索仍将继续...
参考地址:https://blog.quarkslab.com/lets-go-into-the-rabbit-hole-part-1-the-challenges-of-dynamically-hooking-golang-program.html
原文始发于微信公众号(二进制空间安全):研究开发针对Golang程序的运行时钩子
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论