Skip to content

Golang RASP

江湖风轻 edited this page Oct 9, 2022 · 2 revisions

简介

在以往的 RASP 解决方案中,部署方式通常需要业务参与,修改相关配置或是启动参数,这也就造成了 RASP 部署困难的窘境。更有甚者,由于 Golang 编译型语言的特性,多数 RASP 只能被迫选择在编译期集成进去,以降低技术实现成本,但这无疑又间接加大了部署推广的难度。 我始终认为限制 RASP 发展与推广的是部署,而并非是技术难度,许多厂商在各语言的技术实现上都有大同小异的成熟方案。例如使用 JVMInstrumentation 功能动态修改 bytecode,可以在虚拟机功能的基础上实现稳定的运行时防护。 所以在 Elkeid RASP 的项目初期,团队就敲定了动态注入的部署方式,一切都朝着降低部署难度的目标靠拢。

原理

目前 Elkeid RASP 开源了 JVMPythonNode 以及 Golang 四种语言的运行时保护功能,四种语言均支持对已存在的进程动态注入防护代码。其中 JVMNode 依赖于虚拟机提供的机制,运行稳定,而 PythonGolang 则需要利用 ptrace 在进程层面上做注入。

进程注入

为了实现对 Python 以及 Golang 进程的动态防护,先不考虑运行时层面的代码篡改,我们至少需要一个能够在 Linux 任意进程空间内执行任意代码的工具。但是很可惜,Linux 没有类似于 CreateRemoteThread 的接口。 大多数的代码注入,都是使用 ptrace 篡改进程执行流程,调用 dlopen 加载动态库。而且大多数项目都会指出,该方式的不稳定性可能会导致进程永久卡住。因为 dlopen 底层会调用 malloc,而在 glibc 的官方文档中指明了 malloc 是不可重入函数。 在研究过程中,我发现了 mandibule 这个项目,它另辟蹊径地编写了一个 ELF Loader,再使用 ptrace 让该 ELF Loader 在目标进程内执行,加载一个全新的程序,执行完成后恢复主线程的寄存器。 由于作者已经放弃维护,而且我自己在使用过程中,发现了项目一些设计上的缺陷以及代码 bug。于是我借鉴了该思路,开发了 pangolin 这个工具,它可以在任意进程内临时运行另一个程序,细节可以看相关的 blog

Inline Hook

借助 pangolin,我们可以在一个 Golang 的进程中执行任意代码,我们甚至可以篡改可执行段的机器码,很轻松地便可以对某个函数进行 Inline Hook。例如我们想对 Golang 的命令执行函数 exec.Command 进行 Inline Hook,那么可以在进程注入期间修改函数 os/exec.Command 的开头指令,使其执行时先跳转到我们编写的函数中。在我们自定义的函数中,便可以获取该函数调用时的入参以及调用栈,再通过某种通信方式传输出去,一个简单的 RASP 模型便完成了。 那么要完成该流程,我们需要一些先决条件:

  • 在去除掉 ELF 符号信息的情况下,如何获取 Golang 的符号信息,以确定函数的地址,完成对函数的 Inline Hook 操作。
  • Golang 如何进行函数调用,通过寄存器亦或是栈,我们又该如何读取函数的入参。
  • 如何获取 Golang 当前函数的 Stack Frame 长度,用于定位上一层函数的返回地址,完成调用栈回溯。

Golang Runtime Symbol Information

在去除了 ELF 符号信息后,一个编译好的 Golang 程序还是可以正确地执行 debug.PrintStack 函数,那么便可以证明 Golang 内部必然还存在一个符号表。根据官方文档 Go 1.2 Runtime Symbol Information 的介绍,Golang 从 1.2 之后的版本内置了符号信息,对于 ELF 格式来说,通常放置在 .gopclntab 这个 section。 这些符号信息不仅包含了函数名称、函数地址范围以及函数栈帧长度信息,甚至还有相关的代码文件名,以及行号等源码信息。根据文档记录的信息格式,我们可以很轻松地解析出一个 Golang 二进制程序的符号表。

Golang build info

对于 Golang 1.13 以上编译出的二进制,可以使用 go version 命令查看编译时的 Golang 版本,由此说明二进制中内嵌了相关的编译信息。对于 ELF 格式来说,编译信息存放在 .go.buildinfo section,其中包含 Golang 版本号以及三方依赖库列表。值得注意的是,buildinfo 的格式在 Golang 1.18 版本发生了改变,弃用了数据指针,相关解析代码可查看官方仓库

Golang internal ABI

接下来我们需要了解 Golang 编译出的机器码,是如何进行函数调用的,以及 Golang 的结构体在内存中是如何存放的,了解这些之后我们才能正确地取出函数入参。

memory layout

Golang 包含的内置类型描述,可以在官方文档 The Go Programming Language Specification 中找到。对于数值类型,Golang 规范了类型的内存占用大小,而字节对齐则随 CPU 架构不同而变化。对于复合类型,例如 stringslice 以及 map 等,内存占用大小由组成的基础类型及其字节对齐决定,而该复合类型的字节对齐由组成类型中最大的字节对齐决定。 文档中并未描述 string 等内置类型的底层内存排布,但是我们可以从一些文章,亦或是 CGO 生成的头文件中一窥究竟。 以下是我使用 cppx64 架构下 Golang 类型的描述,代码可以在 Elkeid 官方仓库中找到:

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 并不确保这些类型的字节对齐不变,官方似乎正在考虑改变 x86int64 的字节对齐。现在我们已经了解了 Golang 类型的内存排布,那么对于任意入参的函数调用,我们都能准确的从内存中取出数据。现在剩下的问题便是函数调用发生时,参数将会存放在何处?

stack-based calling conventions

Golang 在 1.17 版本之前的函数调用中,参数与结果均存放在栈上。但由于栈上频繁的内存操作影响了运行性能,所以社区草拟了基于寄存器的调用约定方案,并在 1.17 版本后切换到该调用约定。 我们先了解 Golang 最原始的 ABI0,也就是基于栈的调用约定,细节描述可以从文档 A Quick Guide to Go's Assembler 找到。在函数调用发生时,调用者需要将参数以及返回值,从低地址向高地址依次排列在栈顶。 例如在调用函数 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 结构体。当然,对于返回值而言,我们并不会真正地写入数据,而是预留内存空间以供被调用者写入。

register-based calling conventions

对于基于寄存器的调用约定,调用者需要先尝试将参数放置于寄存器中。如果结构体太大,或是结构体中包含 Non-trivial arrays 类型成员导致无法存放,则会转而放置于栈上。对于 amd64 架构,Golang 使用X0X14 寄存器存放浮点数数据,而对于整数数值,则使用以下 9 个整数寄存器存放:

RAX, RBX, RCX, RDI, RSI, R8, R9, R10, R11

对于数值类型参数,我们可以直接将参数一一对应到寄存器中。而对于结构体类型,我们需要将结构体拆解成多个基础数值类型,然后进行对应放置。如果一个结构体拆解后,需要占用的寄存器数超过了剩余的寄存器数,则该整个结构体都只能放置于栈上。该部分细节繁杂,本文不作赘述,细节请看文档 Go internal ABI specification

实现

Runtime conflict

有了上述理论支持后,我们现在可以着手编写钩子函数了。在钩子函数执行过程中,不能随意篡改堆栈上的数据,执行完成后需要恢复所有寄存器,并跳转到原函数继续执行。需要注意的是,我们必须时刻记住,执行钩子函数的是 Golang 的线程,那么就存在以下两个问题:

  • Golang 为线程分配的栈空间很小,钩子函数如果使用过度会导致 Segmentation fault
  • Golang 的线程中执行时,我们无法正常调用 glibc 函数,例如 malloc 依赖于 fs 寄存器指向的 TLS 结构,以保证线程安全,但 fs 在 1.17 版本以下的 Golang 线程中指向全局 G

为了解决第一个问题,我们需要在钩子函数的入口处,申请一块足够大的内存替换当前栈。而对于第二点,我们只能使用freestanding 代码及 syscall 来完成参数读取与栈回溯操作。为了更好地解耦与复用,于是我开发了一个不依赖于 glibc 的小型 c-runtime,包含内联汇编编写的 syscall 以及必要的标准库函数。我们可以安全地在 Golang 线程中调用 c-runtime 中的任何函数,例如使用底层是无锁环形缓冲区和 mmap syscallz_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)
);

r12r13 寄存器是 Golang 中可以随意使用的临时寄存器,我们用 r12 来标识是否要阻断当前调用。而r13 用来参与计算,以确保调用 handler 时栈指针按 16 字节对齐,这是 amd64gcc 的默认约定。 在代码的开头,我们先将 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 的参数内存布局,正确地取出数据。由于该部分代码细节繁多,限于本文篇幅所以不进行详细讲解,取参与回溯部分请直接阅读仓库代码

回溯停止

对于调用栈的回溯,上面已经解析过了,我们可以取出当前栈顶的返回地址,在符号表中查找地址相关的函数的名称、文件、行号以及栈帧大小。获取栈帧大小后,取出 sp + framesize 的上一层返回地址,循环上述步骤即可。但有一个问题是,我们在哪里结束循环?调用链的层数一定有限,那么第一个函数是哪个? 在 1.2 版本中,Golang 通过判断函数名是否为 runtime.goexitruntime.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 内置的原子操作实现了一个定长的无锁环形缓冲区,并使用 c-runtime 中实现的 condition variable 做线程同步,实现了一个高效的消息队列。在每个钩子函数触发时,都会将入参和调用栈打包放入队列,如果队列已满则丢弃该消息。 在 pangolin 注入过程中,我们启动一个消费者线程,从消息队列中消费函数调用信息,序列化为 json 后通过 unix socket 传输到 server

信号屏蔽

Golang 启动时会设置信号处理函数,而在进程收到信号时,内核会随机选择一个线程进行信号处理。我们在 Golang 进程中驻留的几个 cpp 线程有可能被选中用于执行处信号处理函数,但是处理函数默认当前处于 Golang 线程中,读取 fs 寄存器以访问 Golang 的全局 G,但此时 fs 所指向的其实是 glibcTLS,于是导致异常退出。 为了避免这种情况发生,我们需要手动设置驻留的 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-probepangolin 注入到 Golang 进程的主线程中临时运行。同时 pangolin 使用 ptrace 持续监听该线程的 syscall 调用,拦截到 main 函数发出的 exitexit_group 调用后,恢复线程状态并结束注入流程。然而可以看到,上面的代码中会优先调用环境变量 QUIT 指向的函数,这又是为何? 在实际的部署过程中,由于资源限制等诸多特殊原因,pangolin 进程可能会在注入期间被 kill。那么此时 main 函数执行的 syscall 就无人拦截,exit_group 会真正地导致业务进程退出。为了让执行 go-probe 的线程能够自我恢复,pangolin 会提前将线程状态快照写入到 Golang 内存中。同时遗留在 Golang 进程中的 shellcode 包含一个 quit 函数,能够根据该快照主动恢复线程,类似于 glibcsetcontext,而 QUIT 环境变量正是 quit 的地址。 在初始化文件日志后,先令当前线程屏蔽所有信号,之后启动的所有子线程都会继承该设置。然后从 ELFsection 中加载符号表、编译信息以及 interface 表,并且为了支持阻断功能,查找 errors.(*errorString) 的地址并保存。执行 gSmithProbe->start() 启动通信客户端后,从符号表中查找 GOLANG_API 所有子项,并进行 Inline Hook。完成以上流程后,恢复信号掩码并调用 quit 函数以通知 pangolin 结束注入。