代码仓库:https://github.com/bytedance/Elkeid
本文介绍 Golang 部分的设计思路、原理与实现。
在以往的 RASP
解决方案中,部署方式通常需要业务参与,修改相关配置或是启动参数,这也就造成了 RASP
部署困难的窘境。更有甚者,由于 Golang
编译型语言的特性,多数 RASP
只能被迫选择在编译期集成进去,以降低技术实现成本,但这无疑又间接加大了部署推广的难度。
我们始终认为限制 RASP
发展与推广的是部署,而并非是技术难度,许多厂商在各语言的技术实现上都有大同小异的成熟方案。例如使用 JVM
的 Instrumentation
功能动态修改 bytecode
,可以在虚拟机功能的基础上实现稳定的运行时防护。
所以在 Elkeid RASP
的项目初期,团队就敲定了动态注入的部署方式,一切都朝着降低部署难度的目标靠拢。
原理
其中JVM
、Node
依赖于虚拟机提供的机制,运行稳定,而 Python
与 Golang
则需要利用 ptrace
在进程层面上做注入。
为了实现对 Python
以及 Golang
进程的动态防护,先不考虑运行时层面的代码篡改,我们至少需要一个能够在 Linux
任意进程空间内执行任意代码的工具。但是很可惜,Linux
没有类似于 CreateRemoteThread
的接口。
大多数的代码注入,都是使用 ptrace
篡改进程执行流程,调用 dlopen
加载动态库。而且大多数项目都会指出,该方式的不稳定性可能会导致进程永久卡住。因为 dlopen
底层会调用 malloc
,而在 glibc
的官方文档中指明了 malloc
是不可重入函数。
在研究过程中,我发现了 mandibule 这个项目(https://github.com/ixty/mandibule),它另辟蹊径地编写了一个 ELF Loader,再使用 ptrace
让该 ELF Loader 在目标进程内执行,加载一个全新的程序,执行完成后恢复主线程的寄存器。
由于作者已经放弃维护,而且我自己在使用过程中,发现了项目一些设计上的缺陷以及代码 bug。于是我借鉴了该思路,开发了 pangolin 这个工具(https://github.com/Hackerl/pangolin),它可以在任意进程内临时运行另一个程序,细节可以看相关的 blog(https://hackerl.github.io/2021/02/11/Linux%E8%BF%9B%E7%A8%8B%E6%B3%A8%E5%85%A5/)。
借助 pangolin
,我们可以在一个 Golang
的进程中执行任意代码,我们甚至可以篡改可执行段的机器码,很轻松地便可以对某个函数进行 Inline
Hook
。例如我们想对 Golang
的命令执行函数 exec.Command
进行 Inline Hook
,那么可以在进程注入期间修改函数 os/exec.Command
的开头指令,使其执行时先跳转到我们编写的函数中。在我们自定义的函数中,便可以获取该函数调用时的入参以及调用栈,再通过某种通信方式传输出去,一个简单的 RASP
模型便完成了。
那么要完成该流程,我们需要一些先决条件:
在去除掉 ELF
符号信息的情况下,如何获取 Golang
的符号信息,以确定函数的地址,完成对函数的 Inline Hook
操作。
Golang
如何进行函数调用,通过寄存器亦或是栈,我们又该如何读取函数的入参。
如何获取 Golang
当前函数的 Stack Frame
长度,用于定位上一层函数的返回地址,完成调用栈回溯。
在去除了 ELF
符号信息后,一个编译好的 Golang
程序还是可以正确地执行 debug.PrintStack
函数,那么便可以证明 Golang
内部必然还存在一个符号表。根据官方文档 Go 1.2 Runtime Symbol Information 的介绍(https://docs.google.com/document/d/1lyPIbmsYbXnpNj57a261hgOYVpNRcgydurVQIyZOz_o/pub),Golang
从 1.2 之后的版本内置了符号信息,对于 ELF
格式来说,通常放置在 .gopclntab
这个 section
。
这些符号信息不仅包含了函数名称、函数地址范围以及函数栈帧长度信息,甚至还有相关的代码文件名,以及行号等源码信息。根据文档记录的信息格式,我们可以很轻松地解析出一个 Golang
二进制程序的符号表。
对于 Golang
1.13 以上编译出的二进制,可以使用 go version
命令查看编译时的 Golang
版本,由此说明二进制中内嵌了相关的编译信息。对于 ELF
格式来说,编译信息存放在 .go.buildinfo
section
,其中包含 Golang
版本号以及三方依赖库列表。值得注意的是,buildinfo
的格式在 Golang
1.18 版本发生了改变,弃用了数据指针,相关解析代码可查看官方仓库。
接下来我们需要了解 Golang
编译出的机器码,是如何进行函数调用的,以及 Golang
的结构体在内存中是如何存放的,了解这些之后我们才能正确地取出函数入参。
Golang
包含的内置类型描述,可以在官方文档 The Go Programming Language Specification 中找到(https://go.dev/ref/spec#Types)。对于数值类型,Golang
规范了类型的内存占用大小(https://go.dev/ref/spec#Size_and_alignment_guarantees),而字节对齐则随 CPU
架构不同而变化。对于复合类型,例如 string
、slice
以及 map
等,内存占用大小由组成的基础类型及其字节对齐决定,而该复合类型的字节对齐由组成类型中最大的字节对齐决定。
文档中并未描述 string
等内置类型的底层内存排布,但是我们可以从一些文章(https://go101.org/article/string.html),亦或是 CGO
生成的头文件中一窥究竟。
以下是我使用 cpp
对 x64
架构下 Golang
类型的描述,代码可以在 Elkeid
官方仓库中找到:
https://github.com/bytedance/Elkeid/blob/main/rasp/golang/go/type/basic.h
namespace go {
typedef signed char Int8;
typedef unsigned char Uint8;
typedef short Int16;
typedef unsigned short Uint16;
typedef int Int32;
typedef unsigned int Uint32;
typedef long long Int64;
typedef unsigned long long Uint64;
typedef Int64 Int;
typedef Uint64 Uint;
typedef __SIZE_TYPE__ Uintptr;
typedef float Float32;
typedef double Float64;
typedef float _Complex Complex64;
typedef double _Complex Complex128;
struct interface {
void *t;
void *v;
};
struct string {
const char *data;
ptrdiff_t length;
};
template<typename T>
struct slice {
T *values;
Int count;
Int capacity;
};
}
可以看到 string
类型由两个字段组成,数据指针加上字符串长度,在内存中总共占用 16 字节。string
的内存对齐则由这两个字段决定,即 align(string) = max(align(const char *), align(ptrdiff_t))
。
对于 int32
这些 Golang
的基础数值类型来说,其字节对齐与 cpp
默认的对齐一致。
Type | 64-bit | 32-bit | ||
---|---|---|---|---|
Size | Align | Size | Align | |
bool, uint8, int8 | 1 | 1 | 1 | 1 |
uint16, int16 | 2 | 2 | 2 | 2 |
uint32, int32 | 4 | 4 | 4 | 4 |
uint64, int64 | 8 | 8 | 8 | 4 |
int, uint | 8 | 8 | 4 | 4 |
float32 | 4 | 4 | 4 | 4 |
float64 | 8 | 8 | 8 | 4 |
complex64 | 8 | 4 | 8 | 4 |
complex128 | 16 | 8 | 16 | 4 |
uintptr, *T, unsafe.Pointer | 8 | 8 | 4 | 4 |
但是 Golang
并不确保这些类型的字节对齐不变,官方似乎正在考虑改变 x86
上 int64
的字节对齐。现在我们已经了解了 Golang
类型的内存排布,那么对于任意入参的函数调用,我们都能准确的从内存中取出数据。现在剩下的问题便是函数调用发生时,参数将会存放在何处?
Golang
在 1.17 版本之前的函数调用中,参数与结果均存放在栈上。但由于栈上频繁的内存操作影响了运行性能,所以社区草拟了基于寄存器的调用约定方案(https://go.googlesource.com/proposal/+/master/design/40724-register-calling.md),并在 1.17 版本后切换到该调用约定。
我们先了解 Golang
最原始的 ABI0
,也就是基于栈的调用约定,细节描述可以从文档 A Quick Guide to Go’s Assembler 找到(https://go.dev/doc/asm)。在函数调用发生时,调用者需要将参数以及返回值,从低地址向高地址依次排列在栈顶。
例如在调用函数 func A(a int32, b string) (int32, error)
时,我们需要按下列排布存放参数与返回值:
+------------------------------+
| 2nd result error.v |
| 2nd result error.t |
| 1st result int32 |
| <pointer-sized alignment> |
| b string.length |
| b string.data |
| a int32 |
+------------------------------+ ↓ stack pointer
先放入 4 字节的参数 a,接着放入 string
类型的参数 b。由于 string
类型的字节对齐是 8,而此时的地址为 sp + 4
,所以需要填充 4 字节的空白区域,从 sp + 8
开始放置 string
的数据。参数存放完成后,如果此时的地址没有按指针大小对齐,则需要填充空白字节。例如在 amd64
架构上,最后一个 int32
的参数放置于地址 0x40000,占用 4 字节大小,那么我们需要再填充 4 字节空白数据,使得返回值存放地址为 0x40008,按当前架构的指针大小 8 对齐。
我们接着放入第一个 int32
的返回值,而第二个返回值类型为 error
,实际上就是 interface
类型。由上一小结可知,interface
类型占用 16 字节,按 8 字节对齐,所以我们填充 4 字节后,放入 error
结构体。当然,对于返回值而言,我们并不会真正地写入数据,而是预留内存空间以供被调用者写入。
对于基于寄存器的调用约定,调用者需要先尝试将参数放置于寄存器中。如果结构体太大,或是结构体中包含 Non-trivial arrays
类型成员导致无法存放,则会转而放置于栈上。对于 amd64
架构,Golang
使用X0
– X14
寄存器存放浮点数数据,而对于整数数值,则使用以下 9 个整数寄存器存放:
RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11
对于数值类型参数,我们可以直接将参数一一对应到寄存器中。而对于结构体类型,我们需要将结构体拆解成多个基础数值类型,然后进行对应放置。如果一个结构体拆解后,需要占用的寄存器数超过了剩余的寄存器数,则该整个结构体都只能放置于栈上。
该部分细节繁杂,本文不作赘述,细节请看文档 Go internal ABI specification。
(https://go.googlesource.com/go/+/refs/heads/dev.regabi/src/cmd/compile/internal-abi.md)
有了上述理论支持后,我们现在可以着手编写钩子函数了。在钩子函数执行过程中,不能随意篡改堆栈上的数据,执行完成后需要恢复所有寄存器,并跳转到原函数继续执行。需要注意的是,我们必须时刻记住,执行钩子函数的是 Golang
的线程,那么就存在以下两个问题:
Golang
为线程分配的栈空间很小,钩子函数如果使用过度会导致 Segmentation fault
。
在 Golang
的线程中执行时,我们无法正常调用 glibc
函数,例如 malloc
依赖于 fs
寄存器指向的 TLS
结构,以保证线程安全,但 fs
在 1.17 版本以下的 Golang
线程中指向全局 G
。
为了解决第一个问题,我们需要在钩子函数的入口处,申请一块足够大的内存替换当前栈。而对于第二点,我们只能使用freestanding
代码及 syscall
来完成参数读取与栈回溯操作。
为了更好地解耦与复用,于是我开发了一个不依赖于 glibc
的小型 c-runtime(https://github.com/Hackerl/c-runtime),包含内联汇编编写的 syscall
以及必要的标准库函数。我们可以安全地在 Golang
线程中调用 c-runtime
中的任何函数,例如使用底层是无锁环形缓冲区和 mmap syscall
的 z_malloc
来分配堆空间。
下面是使用内联汇编编写的钩子函数 wrapper
,可以通用地进行栈替换、寄存器备份以及函数跳转:
asm volatile(
"mov $1, %%r12;"
"mov %%rsp, %%r13;"
"add $8, %%r13;"
"and $15, %%r13;"
"sub $16, %%rsp;"
"movdqu %%xmm14, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm13, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm12, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm11, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm10, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm9, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm8, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm7, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm6, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm5, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm4, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm3, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm2, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm1, (%%rsp);"
"sub $16, %%rsp;"
"movdqu %%xmm0, (%%rsp);"
"push %%r11;"
"push %%r10;"
"push %%r9;"
"push %%r8;"
"push %%rsi;"
"push %%rdi;"
"push %%rcx;"
"push %%rbx;"
"push %%rax;"
"sub %%r13, %%rsp;"
"mov %0, %%rdi;"
"call z_malloc;"
"cmp $0, %%rax;"
"je end_%=;"
"mov %%rsp, %%rdi;"
"mov %%rax, %%rsp;"
"add %0, %%rsp;"
"push %%rax;"
"push %%rdi;"
"add $312, %%rdi;"
"add %%r13, %%rdi;"
"call %P1;"
"mov %%rax, %%r12;"
"pop %%rsi;"
"pop %%rdi;"
"mov %%rsi, %%rsp;"
"call z_free;"
"end_%=:"
"add %%r13, %%rsp;"
"pop %%rax;"
"pop %%rbx;"
"pop %%rcx;"
"pop %%rdi;"
"pop %%rsi;"
"pop %%r8;"
"pop %%r9;"
"pop %%r10;"
"pop %%r11;"
"movdqu (%%rsp), %%xmm0;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm1;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm2;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm3;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm4;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm5;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm6;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm7;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm8;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm9;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm10;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm11;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm12;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm13;"
"add $16, %%rsp;"
"movdqu (%%rsp), %%xmm14;"
"add $16, %%rsp;"
"cmp $0, %%r12;"
"je block_%=;"
"jmp *%2;"
"block_%=:"
"ret;"
::
"i"(STACK_SIZE),
"i"(handler),
"m"(origin)
);
r12
和r13
寄存器是 Golang
中可以随意使用的临时寄存器,我们用 r12
来标识是否要阻断当前调用。而r13
用来参与计算,以确保调用 handler
时栈指针按 16
字节对齐,这是 amd64
下 gcc
的默认约定。
在代码的开头,我们先将 X0
- X14
浮点数寄存器推入栈中,接着推入 Golang
1.17 以上需要使用的整数寄存器。然后调用 z_malloc
申请 40K 的内存替换当前栈,再以原始栈指针为参数调用 handler
。
在 handler
函数中,根据 Golang
的版本不同,我们可以从栈上存储的寄存器中,或上一函数的 Stack frame
中读出入参。当然也可以根据原始栈指针读取返回地址,从 Golang
符号表中查找函数信息,然后根据 Stack frame
读出上一层的返回地址,循环往复完成栈回溯。
我们甚至可以在 handler
中判断参数是否合法,当参数匹配到我们设置的正则时,可以手动写入 error
返回值到栈上,并返回 false
以将 r12
寄存器置零完成阻断。在 handler
函数执行完成后,从栈上恢复寄存器,并根据 r12
决定返回还是跳转至原函数。
为了更好地进行参数读取和阻断,我是使用Templates
编写了一套 Golang
类型反射库,可以在运行时获取 Golang
类型元数据。元数据包含类型的基础类型成员数,每个成员的相对偏移以及占用大小,还有该类型需要占用的浮点/整数寄存器数。
在 handler
函数中,我们可以轻松地利用这些元数据分析 Golang
的参数内存布局,正确地取出数据。由于该部分代码细节繁多,限于本文篇幅所以不进行详细讲解,取参与回溯部分请直接阅读仓库代码。
https://github.com/bytedance/Elkeid/tree/main/rasp/golang/go/api
对于调用栈的回溯,上面已经解析过了,我们可以取出当前栈顶的返回地址,在符号表中查找地址相关的函数的名称、文件、行号以及栈帧大小。获取栈帧大小后,取出 sp + framesize
的上一层返回地址,循环上述步骤即可。但有一个问题是,我们在哪里结束循环?调用链的层数一定有限,那么第一个函数是哪个?
在 1.2 版本中,Golang
通过判断函数名是否为 runtime.goexit
、runtime.rt0_go
等入口函数,由此决定是否终止回溯。而对于较新版本的 Golang
,符号信息中增加了一个 funcID
字段,通过 funcID
判断函数类型是否为入口函数。但 funcID
的本质与函数名比较无二,而且 funcID
在版本之间会发生变动,所以最后决定简单地使用函数名判断:
constexpr auto STACK_TOP_FUNCTION = {
"runtime.mstart",
"runtime.rt0_go",
"runtime.mcall",
"runtime.morestack",
"runtime.lessstack",
"runtime.asmcgocall",
"runtime.externalthreadhandler",
"runtime.goexit"
};
成功获取入参和调用栈后,要如何把消息传输出去?如果需要进行 socket
通信,并且不阻塞 Golang
线程,那就需要驻留一个线程在 Golang
进程内,实现一个简单的生产者消费者模型。那么在无法使用 std::queue
等标准库的情况下,要怎么实现消费丢列,又该如何保证线程安全?
为了尽可能地减少性能影响,我利用 gcc
内置的原子操作实现了一个定长的无锁环形缓冲区(https://github.com/Hackerl/zero/blob/master/include/zero/atomic/circular_buffer.h),并使用 c-runtime
中实现的 condition variable
做线程同步,实现了一个高效的消息队列。在每个钩子函数触发时,都会将入参和调用栈打包放入队列,如果队列已满则丢弃该消息。
在 pangolin
注入过程中,我们启动一个消费者线程(https://github.com/bytedance/Elkeid/blob/main/rasp/golang/client/smith_probe.cpp#L40),从消息队列中消费函数调用信息,序列化为 json
后通过 unix socket
传输到 server
。
Golang
启动时会设置信号处理函数,而在进程收到信号时,内核会随机选择一个线程进行信号处理。我们在 Golang
进程中驻留的几个 cpp
线程有可能被选中用于执行处信号处理函数,但是处理函数默认当前处于 Golang
线程中,读取 fs
寄存器以访问 Golang
的全局 G
,但此时 fs
所指向的其实是 glibc
的 TLS
,于是导致异常退出。
为了避免这种情况发生,我们需要手动设置驻留的 cpp
线程,令其屏蔽所有信号:
sigset_t mask = {};
sigset_t origin_mask = {};
sigfillset(&mask);
if (pthread_sigmask(SIG_SETMASK, &mask, &origin_mask) != 0) {
LOG_ERROR("set signal mask failed");
quit(-1);
}
在学习了原理和实现细节后,我们来解析一下 go-probe
的执行流程,入口函数如下:
#include "go/symbol/build_info.h"
#include "go/symbol/line_table.h"
#include "go/symbol/interface_table.h"
#include "go/api/api.h"
#include <zero/log.h>
#include <csignal>
#include <asm/api_hook.h>
#include <z_syscall.h>
void quit(int status) {
uintptr_t address = 0;
char *env = getenv("QUIT");
if (!env) {
LOG_WARNING("can't found quit env variable");
z_exit_group(-1);
}
if (!zero::strings::toNumber(env, address, 16) || !address) {
LOG_ERROR("invalid quit function address");
z_exit_group(-1);
}
((decltype(quit) *)address)(status);
}
int main() {
INIT_FILE_LOG(zero::INFO, "go-probe");
sigset_t mask = {};
sigset_t origin_mask = {};
sigfillset(&mask);
if (pthread_sigmask(SIG_SETMASK, &mask, &origin_mask) != 0) {
LOG_ERROR("set signal mask failed");
quit(-1);
}
if (!gLineTable->load()) {
LOG_ERROR("line table load failed");
quit(-1);
}
if (gBuildInfo->load()) {
LOG_INFO("go version: %s", gBuildInfo->mVersion.c_str());
CInterfaceTable table = {};
if (!table.load()) {
LOG_ERROR("interface table load failed");
quit(-1);
}
table.findByFuncName("errors.(*errorString).Error", (go::interface_item **)CAPIBase::errorInterface());
}
gSmithProbe->start();
for (const auto &api : GOLANG_API) {
for (unsigned int i = 0; i < gLineTable->mFuncNum; i++) {
CFunc func = {};
if (!gLineTable->getFunc(i, func))
break;
const char *name = func.getName();
void *entry = (void *)func.getEntry();
if ((api.ignoreCase ? strcasecmp(api.name, name) : strcmp(api.name, name)) == 0) {
LOG_INFO("hook %s: %p", name, entry);
if (hookAPI(entry, (void *)api.metadata.entry, api.metadata.origin) < 0) {
LOG_WARNING("hook %s failed", name);
break;
}
break;
}
}
}
pthread_sigmask(SIG_SETMASK, &origin_mask, nullptr);
quit(0);
return 0;
}
需要明确的是,go-probe
由 pangolin
注入到 Golang
进程的主线程中临时运行。同时 pangolin
使用 ptrace
持续监听该线程的 syscall
调用,拦截到 main
函数发出的 exit
或 exit_group
调用后,恢复线程状态并结束注入流程。然而可以看到,上面的代码中会优先调用环境变量 QUIT
指向的函数,这又是为何?
在实际的部署过程中,由于资源限制等诸多特殊原因,pangolin
进程可能会在注入期间被 kill
。那么此时 main
函数执行的 syscall
就无人拦截,exit_group
会真正地导致业务进程退出。为了让执行 go-probe
的线程能够自我恢复,pangolin
会提前将线程状态快照写入到 Golang
内存中。同时遗留在 Golang
进程中的 shellcode
包含一个 quit 函数(https://github.com/Hackerl/pangolin/blob/master/shellcode/loader/quit.c#L18),能够根据该快照主动恢复线程,类似于 glibc
的 setcontext
,而 QUIT
环境变量正是 quit
的地址。
在初始化文件日志后,先令当前线程屏蔽所有信号,之后启动的所有子线程都会继承该设置。然后从 ELF
的 section
中加载符号表、编译信息以及 interface
表,并且为了支持阻断功能,查找 errors.(*errorString)
的地址并保存。执行 gSmithProbe->start()
启动通信客户端后,从符号表中查找 GOLANG_API
所有子项,并进行 Inline Hook
。完成以上流程后,恢复信号掩码并调用 quit
函数以通知 pangolin
结束注入。