Featured image of post eBPF:捕获Go程序的HTTPS(GoTLS)明文数据

eBPF:捕获Go程序的HTTPS(GoTLS)明文数据

本文主要介绍了如何使用eBPF获取Go程序的HTTPS明文数据的细节。

    在本文之前,我已经对OpenSSL、LibreSSL和GnuTLS等网络程序常用的SSL/TLS层依赖库进行过HTTPS的明文数据捕获分析和实践。绝大多数的HTTPS的网络程序都依赖上述的这些库(毕竟C/C++在底层的高度依赖在那),当然不排除还有其他库被应用,比如Java、Go和Rust都有实现了自己的SSL/TLS库,对于Java我们暂且不谈,因为目前eBPF应用于JVM能力有限。因此,本文接下来将讨论有关于eBPF应用于GoTLS的细节实现。

前言

    对于“使用eBPF捕获GoTLS明文数据”这个问题,和OpenSSL等一样,大致步骤如下:

  1. 找到要挂钩(观测)的函数点: 一般来讲都是与SSL/TLS有关的read/write函数,比如SSL_read/SSL_write

  2. 读取函数的参数和返回值: 一般明文明文数据就是挂钩函数的入参,返回值一般可以确定函数的返回状态和读写数据的长度;

  3. 根据唯一标识组织无序的明文数据:由于观测的函数可能被多个进程的多个线程调用, 因而需要找一个唯一标识用于组织,一般这个唯一标识是{pid,fd}

  4. 为了获取唯一标识而需要做出的额外努力: 在eBPF内核态的代码中,有限的前置条件为:libbpf辅助函数能获取到的信息和挂钩函数入参、返回值携带的信息等(当然也还有寄存器和堆栈数据)。额外的努力指的是获取pid和fd: pid可通过libbpf辅助函数获取,fd需要从函数入参中获得;

  5. 于是额外努力转变为对fd的求值:从函数入参结构体中提取fd,前提需要知道"fd"字段的偏移值,而结构体在不同版本也会有所变化,因而又涉及求值版本号

  6. 因此求值fd的补充是:从库二进制文件获取版本号以确定fd在结构中的偏移值。

而eBPF捕获GoTLS明文数据在遵循上述步骤的同时,还具有一些特殊性:

  1. Go的ABI有所不同,获取参数和返回值需特别结合ABI规范,且存在两种ABI,需通过版本号确定是ABI0还是ABIInternal;

  2. Go有goroutine协程的概念,一个线程可能对应多个goroutine。这使得{pid, fd}的标识不再奏效,而转变为{pid,goid,fd}

  3. 由于已知的uretprobe引起Go崩溃问题,需要通过RET+uprobe的替代方案进行解决,涉及反汇编函数查找中RET位置。

挂钩函数

    从Go的crypto模块源码可以找到:

1
2
3
// ~/src/crypto/tls/conn.go
func (c *Conn) Write(b []byte) (int, error)
func (c *Conn) Read(b []byte) (int, error)

在二进制文件中的符号为:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
# crypto/tls.(*Conn)Write
# crypto/tls.(*Conn)Read
readelf -sW https_server |grep tls|grep "Read$"
3915: 00000000005ac5e0   311 FUNC    GLOBAL DEFAULT    1 crypto/tls.(*atLeastReader).Read
3939: 00000000005afa80  1022 FUNC    GLOBAL DEFAULT    1 crypto/tls.(*Conn).Read
readelf -sW https_server |grep tls|grep "Write$"
3865: 00000000005a4740    95 FUNC    GLOBAL DEFAULT    1 crypto/tls.(*cthWrapper).Write
3920: 00000000005acbe0   550 FUNC    GLOBAL DEFAULT    1 crypto/tls.(*Conn).maxPayloadSizeForWrite
3931: 00000000005ae5a0  1967 FUNC    GLOBAL DEFAULT    1 crypto/tls.(*Conn).Write
3942: 00000000005b0020    88 FUNC    GLOBAL DEFAULT    1 crypto/tls.(*Conn).CloseWrite
4177: 00000000005ddf60   405 FUNC    GLOBAL DEFAULT    1 crypto/tls.(*finishedHash).Write
5558: 0000000000893610    16 OBJECT  GLOBAL DEFAULT   22 crypto/tls.errEarlyCloseWrite

获取参数与返回值

    针对挂钩函数,我们分析一下对应的参数类型Conn[]byteerror

1
2
3
4
5
6
7
8
// ~/src/crypto/tls/conn.go
// A Conn represents a secured connection.
// It implements the net.Conn interface.
type Conn struct {
    // constant
    conn        net.Conn
    ... 省略一些内容 ...
}
1
2
3
4
5
6
// ~/src/runtime/slice.go#line15 or ~/src/reflect/value.go#2793    
type SliceHeader struct {
    Data uintptr
    Len  int
    Cap  int
}
1
2
3
4
5
6
// ~/src/builtin/builtin.go
// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
    Error() string
}

结合Go的ABI规范我们得如下结论:

  • ABI0(stack-based):基于栈传递参数和返回值

    • 入参c,指针类型,即SP+8;

    • 入参b,切片类型{data,len,cap},即:

      • b.data = SP+16

      • b.len = SP+24

      • b.cap = SP+32

    • 返回值~r0,整型类型,即SP+40

    • 返回值~r1,接口类型(具体为eface):

      • ~r1._type = SP+48

      • ~r1.data = SP+56

  • ABIInternal(register-based):基于寄存器传递参数和返回值

    • 入参c,指针类型,通过AX传递;

    • 入参b,切片类型{data,len,cap},通过BX,CX,DI传递:

      • b.data = BX

      • b.len = CX

      • b.cap = DI

    • 返回值~r0,整型类型,通过AX传递;

    • 返回值~r1,接口类型(具体为eface),通过BX,CX传递:

      • ~r1._type = BX

      • ~r1.data = CX

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
# ABI0: 参数与返回值
  +-----------+
  | ~r1.data  |
  | ~r1._type |
  | ~r0       |
  | b.cap     |
  | b.len     |
  | b.data    |
  |   a       |
8 +-----------+ <-+ SP+8  
  |return.PC  |
0 +-----------+ <-+ SP

# ABIInternal: 参数
+-----------------------------------+
|AX |BX |CX |DI |SI |R8 |R9 |R10|R11|
+-----------------------------------+
 ↓   ↓    ↓   ↓-→
 c b.data ↓     ↓  
        b.len   b.cap
# ABIInternal: 返回值
+-----------------------------------+
|AX |BX |CX |DI |SI |R8 |R9 |R10|R11|
+-----------------------------------+
 ↓   ↓    ↓-----→
~r0  ~r1._type  ↓  
               ~r1.data               

额外的努力

    基于前言的步骤,对于GoTLS额外的工作为:

  • 求值fd,即求值fd在入参c的结构体Conn中的偏移值;

  • 求值goid;

  • 从Go二进制文件求值version版本号.

fd

    在Conn结构体中有conn net.Conn字段,net.Conn接口如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
// ~/src/net/net.go
type net.Conn interface {
    // Read reads data from the connection.
    // Read can be made to time out and return an error after a fixed
    // time limit; see SetDeadline and SetReadDeadline.
    Read(b []byte) (n int, err error)
    ...
}

// ~/src/net/tcpsock.go
// TCPConn is an implementation of the Conn interface for TCP network
// connections.
type TCPConn struct {
    conn
}

//~/src/net/net.go
type conn struct {
    fd *netFD
}

//~/src/net/fd_posix.go
// Network file descriptor.
type netFD struct {
    pfd poll.FD
    ...
}

// ~/src/internal/poll/fd_unix.go
// FD is a file descriptor. The net and os packages use this type as a
// field of a larger type representing a network connection or OS file.
type FD struct {
    // Lock sysfd and serialize access to Read and Write methods.
    fdmu fdMutex

   // System file descriptor. Immutable until Close.
    Sysfd int
    ...
}

// ~/src/internal/poll/fd_mutex.go
// fdMutex is a specialized synchronization primitive that manages
// lifetime of an fd and serializes access to Read, Write and Close
// methods on FD.
type fdMutex struct {
    state uint64
    rsema uint32
    wsema uint32
}

因而,我们有:

  • fd = c.conn.conn.fd.pfd.Sysfd

  • fd = c.conn(type net.Conn -> type TCPConn).conn(type conn).fd.pfd.Sysfd

  • 由于在type Conn structtype TCPConn struct中conn的offset都是0,则

    • fd = conn.conn.fd.pfd.Sysfd

    • fd = conn.fd.pfd.Sysfd

    • fd = c.data.fd.pfd.Sysfd

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/////////////////////////////////////////////////////////////////////
// stirling/source_connectors/socket_tracer/bcc_bpf/go_trace_common.h
// pixie-stirling的注释说明:

// Another representation:
//   conn net.Conn
//   type net.Conn interface {
//     ...
//     data  // A pointer to *net.TCPConn, which implements the net.Conn interface.
//     type TCPConn struct {
//       conn  // conn is embedded inside TCPConn, which is defined as follows.
//       type conn struct {
//         fd *netFD
//         type netFD struct {
//           pfd poll.FD
//           type FD struct {
//             ...
//             Sysfd int
//           }
//         }
//       }
//     }
//   }

值得注意的是net.Conn是eface类型的接口,内存布局为{_type,data}。而fdpfd在对应的结构体中的offset,因此我们只需要知道Sysfdtype FD struct中的偏移(在64位下为sizeof(fdMutex)=16)即可得到fd。

1
2
int* fd_ptr = (int*)((char*)(c.data) + 16);
int fd = *fd_ptr;

因而,在eBPF中可以这样获取fd:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
struct go_interface {
  int64_t type; // _type
  void* ptr;    // data
};

static __inline int32_t get_fd_from_conn_intf_core(struct go_interface conn_intf) {

  int32_t tlsConn_conn_offset = 0;
  bpf_probe_read(&conn_intf, sizeof(conn_intf), conn_intf.ptr + tlsConn_conn_offset);

  // fd_ptr = c.data(c.ptr)
  void* fd_ptr;
  bpf_probe_read(&fd_ptr, sizeof(fd_ptr), conn_intf.ptr);

  // pfd.sysfd = *((char*)fd_ptr + 16)
  int64_t sysfd;
  bpf_probe_read(&sysfd, sizeof(int64_t), fd_ptr + 16);

  return sysfd;
}

version

    获取Go的版本号之前在文章:从Golang的二进制程序中获取version等信息中已经有过介绍,版本号即runtime.buildVersion对象:

  • Go二进制文件(ELF格式)有.go.buildinfosection描述了编译信息,相关解析见src/debug/buildinfo/buildinfo.go;

  • 存在符号为runtime.buildVersion的字符串对象,存放版本号,内容如go1.18.7;

  • 符号runtime.buildVersion位于.datasection,而其指向的字符串常量即go.18.7位于.rodatasection.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
///////////////////////////////////////////////////////////////////////
// https://github.com/golang/go/blob/2f6a25f4478905db5e169019bf9dc39ab2a50f89/src/debug/buildinfo/buildinfo.go#L45-L49

// The build info blob left by the linker is identified by
// a 16-byte header, consisting of buildInfoMagic (14 bytes),
// the binary's pointer size (1 byte),
// and whether the binary is big endian (1 byte).
buildInfoMagic = []byte("\xff Go buildinf:")

// ~/src/runtime/extern.go
// buildVersion is the Go tree's version string at build time.
//
// If any GOEXPERIMENTs are set to non-default values, it will include
// "X:<GOEXPERIMENT>".
//
// This is set by the linker.
//
// This is accessed by "go version <binary>".
var buildVersion string

////////////////////////////////////////////////////////
// Notes:
// 有关runtime.buildVersion,
// 数据解析见: src/debug/buildinfo/buildinfo.go#L180-L189
  • 获取Go版本号的步骤如下:

    • 先获取.go.buildinfo描述的信息:

      • 读取.go.buildinfo ,得到指针大小、大小端信息;

      • 如果大小端位的值为0x02读取往后的32字节数据即为版本号信息,其中前16字节忽略,然后读取版本号信息的大小,最后读出对应大小的字符串;

      • 如果大小端位的值非0x02则,先读取runtime.buildVersion

    • 直接在.data中查找符号为.runtime.buildVersion的字符串对象{data,len,cap}

      • 然后,字符串对象data指向的内容,即为版本号信息,字符串常量go1.18.7位于.rodata.

goid

    Go为并发而生,而Goroutine特性则为其并发的灵魂。与线程相比,Goroutine更轻量级,而且多个 Goroutine 可以在一个线程内共享同一个堆栈,调度由Go运行时自己实现而不像线程由操作系统内核负责调用。另外一点值得我们注意的是,Go语言并没有提供获取goid的函数接口,特地将goid隐藏而避免被开发者使用。因而,针对如何获取goid这个问题,网上有许多hack的解决方案。对于本文而言,我们选择从type g struct中获取:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// ~/src/runtime/runtime2.go
type g struct {
    ... 省略 ...

    param        unsafe.Pointer
    atomicstatus atomic.Uint32
    stackLock    uint32 // sigprof/scang lock; TODO: fold in to atomicstatus
    goid         uint64
    ... 省略 ...
}

同样的,对于goidg struct的偏移值为:

1
2
3
4
5
6
7
# amd64
readelf --debug-dump=info https_server |grep -100 "runtime.g"|grep -10 "goid$"
<2><805ac>:缩写编号:24 (DW_TAG_member)
    <805ad>   DW_AT_name        : goid
    <805b2>   DW_AT_data_member_location: 152
    <805b4>   DW_AT_type        : <0x7f86c>
    <805b8>   未知的 AT 值:2903: 0

另外直接算也能得出是偏移是152,那么如何获取g呢?看如下源码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
// ~/src/runtime/stubs.go
// getg returns the pointer to the current g.
// The compiler rewrites calls to this function into instructions
// that fetch the g directly (from TLS or from the dedicated register).
func getg() *g

// ~/src/runtime/go_tls.h
#ifdef GOARCH_amd64
#define    get_tls(r)    MOVQ TLS, r
#define    g(r)    0(r)(TLS*1)
#endif


// ~/src/runtime/sys_linux_amd64.s#L637
// set tls base to DI
TEXT runtime·settls(SB),NOSPLIT,$32
#ifdef GOOS_android
    // Android stores the TLS offset in runtime·tls_g.
    SUBQ    runtime·tls_g(SB), DI
#else
    ADDQ    $8, DI    // ELF wants to use -8(FS)
#endif
    MOVQ    DI, SI
    MOVQ    $0x1002, DI    // ARCH_SET_FS
    MOVQ    $SYS_arch_prctl, AX
    SYSCALL
    CMPQ    AX, $0xfffffffffffff001
    JLS    2(PC)
    MOVL    $0xf1, 0xf1  // crash
    RET

// ~/src/cmd/link/internal/arm64/asm.go
// The TCB is two pointers. This is not documented anywhere, but is
// de facto part of the ABI.
v := ldr.SymValue(rs) + int64(2*target.Arch.PtrSize)
if v < 0 || v >= 32678 {
            ldr.Errorf(s, "TLS offset out of range %d", v)
}

可知g结构的指针存储在TLS(Thread Local Storage)中,而在x86_64架构中的FS(Fbase)寄存器用于存储TLS的基址,对于linux_amd64可通过FS-8获取得到g的指针。

    在eBPF中则如此获取:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
static inline uint64_t get_goid() {

    // Get fsbase from `struct task_struct`.
    const struct task_struct* task_ptr = (struct task_struct*)bpf_get_current_task();
    if (!task_ptr) {
        return 0;
    }

    __u64 g_addr;
#if defined(__TARGET_ARCH_x86)
    // TLS
    const void* fs_base;
    bpf_probe_read(&fs_base, sizeof(fs_base), &(task_ptr->thread.fsbase));

    // struct g location
    int32_t g_addr_offset = -8;
    bpf_probe_read(&g_addr, sizeof(void*), (void*)(fs_base + g_addr_offset));
#elif defined(__TARGET_ARCH_aarch64)
    const void* tp;
    bpf_probe_read(&tp, sizeof(tp), &(task_ptr->thread.uw.tp_value));

    int32_t g_addr_offset = 16;
    bpf_probe_read(&g_addr, sizeof(void*), (void*)(tp + g_addr_offset));
#else
#warning Target architecture not supported
#endif

    // goid in `struct g`, offset is 152.
    __u64 goid;
    bpf_probe_read(&goid, sizeof(void*), (void*)(g_addr + 152));
    return goid;
}

特殊情况: RET + uprobe

    我们已知,对Go程序使用eBPF uretprobe时会引起Go程序崩溃。替代方案是:使用RET+uprobe的方式替代uretprobe,原理是在函数的RET指令处attach uprobe。于是,我们需要进行反汇编从而找到函数的RET指令。

crypto/tls.(*Conn).Read为例,首先使用readelf获取函数符号信息:

1
2
3
# Search function symbol: "crypto/tls.(*Conn).Read"
readelf -sW https_server |grep tls|grep "Conn"|grep "Read$"
3939: 00000000005afa80  1022 FUNC    GLOBAL DEFAULT    1 crypto/tls.(*Conn).Read

可知crypto/tls.(*Conn).Read的地址是0x00000000005afa80,函数大小是10220x3fe,使用objdump反汇编函数crypto/tls.(*Conn).Read

1
2
3
4
5
6
7
8
9
objdump -D --start-address=0x05afa80 --stop-address=0x05afe7e https_server|grep ret
5afb84:       c3                      ret    
5afbb1:       c3                      ret    
5afc32:       c3                      ret    
5afd7d:       c3                      ret    
5afdb0:       c3                      ret    
5afe2d:       c3                      ret    
5afe2e:       e8 0d 4b e8 ff          call   434940 <runtime.deferreturn>
5afe4b:       c3                      ret

所以得到7条RET指令的地址,减去函数地址可得,RET指令相对函数入口的偏移值:

1
2
3
4
5
6
7
// 0x5afb84 - 0x5afa80 = 0x104
// 0x5afbb1 - 0x5afa80 = 0x131
// 0x5afc32 - 0x5afa80 = 0x1b2
// 0x5afd7d - 0x5afa80 = 0x2fd
// 0x5afdb0 - 0x5afa80 = 0x330
// 0x5afe2d - 0x5afa80 = 0x3ad
// 0x5afe4b - 0x5afa80 = 0x3cb

在libbpf中使用bpf_program__attach_uprobe_opts函数进行attach uprobe:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
DECLARE_LIBBPF_OPTS(bpf_uprobe_opts, read_func_opts, 
        .retprobe = false, 
        .func_name="crypto/tls.(*Conn).Read");

int offsets[7] = {0x104, 0x131, 0x1b2, 0x2fd, 0x330 ,0x3ad, 0x3cb};
for(int i = 0; i < 7; i++) {
    obj->links.probe_ret_gotls_read =
    bpf_program__attach_uprobe_opts(obj->progs.probe_ret_gotls_read, 
                            -1, /* -1表示 trace 所有进程 */
                            "/path/to/go_binary",
                            offsets[i], /* RET 指令的偏移位置*/
                            &read_func_opts
    );

    int err = libbpf_get_error(obj->links.probe_ret_gotls_read);
    if (0 != err) {
        printf("failed to attach attach_go_tls_ret_read:%d\n", err);
        return;
    }
}

总结

整个过程可以浓缩为如下:

我想要: Trace GoTLS with eBPF,捕获HTTPS明文数据

于是:eBPF uprobe/uretprobe, **crypto/tls.(Conn)Write and crypto/tls.(Conn)Write

但是:已知的问题“uretprobe应用于Go程序会引起其崩溃”

原因是:Go的ABI不同,函数调用约定不同以及goroutine特性,使用uretprobe会破坏堆栈

但是:有一种解决方案是:使用RET + uprobe 替代 uretprobe

其实:剖析ebpf的原理可以发现,其实是用了INT/INT3这样的指令在特定的地方插bpf程序

因此:上述方法是可行的

于是乎:就需要找到要attach的函数的所有RET指令,然后在这些位置处attach

其实:查看libbpf的bpf_attach_uprobe_program也可知,其最终也是找对应的函数的偏移位置

所以:想要找到函数的RET指令则要对其二进制文件反汇编

因此:需要解析ELF文件(C++可选elfio库进行解析),在.symtab符号表中找到函数符号信息,在.text中读取函数的汇编指令

于是:C/C++项目可以引入反汇编依赖库capstone-engine

继续:当找到RET的位置后(这个位置是相对于文件开始0的位置)

下一步:则需要将probe挂到RET的位置

另一方面:由于Go的ABI有两种,函数调用传参有所不同,因此寄存器和堆栈的有所差异

比如:PROGM_PRAM1()这样的宏访问可能是不对的,是获取不到第一个参数的

所以:需要根据具体的ABI规范读取参数和返回值,ABI有基于堆栈和基于寄存器两种方式

此外:还有一些额外的辅助工作需要做,比如获取fd,goid,version

最后:经过一番折腾后才能得到要Trace的明文数据.

参考链接

ELF文件解析和加载(附代码)-阿里云开发者社区 (aliyun.com)

GO汇编-GoroutineID - Binb - 博客园 (cnblogs.com)

https://github.com/pixie-io/pixie/blob/main/src/stirling/source_connectors/socket_tracer/bcc_bpf/go_trace_common.h

https://github.com/apache/skywalking-rover/blob/main/bpf/include/goid.h

哦吼是一首歌。
Built with Hugo
Theme Stack designed by Jimmy