在本文之前,我已经对OpenSSL、LibreSSL和GnuTLS等网络程序常用的SSL/TLS层依赖库进行过HTTPS的明文数据捕获分析和实践。绝大多数的HTTPS的网络程序都依赖上述的这些库(毕竟C/C++在底层的高度依赖在那),当然不排除还有其他库被应用,比如Java、Go和Rust都有实现了自己的SSL/TLS库,对于Java我们暂且不谈,因为目前eBPF应用于JVM能力有限。因此,本文接下来将讨论有关于eBPF应用于GoTLS的细节实现。
前言
对于“使用eBPF捕获GoTLS明文数据”这个问题,和OpenSSL等一样,大致步骤如下:
-
找到要挂钩(观测)的函数点: 一般来讲都是与SSL/TLS有关的read/write
函数,比如SSL_read/SSL_write
;
-
读取函数的参数和返回值: 一般明文明文数据就是挂钩函数的入参,返回值一般可以确定函数的返回状态和读写数据的长度;
-
根据唯一标识组织无序的明文数据:由于观测的函数可能被多个进程的多个线程调用, 因而需要找一个唯一标识用于组织,一般这个唯一标识是{pid,fd}
;
-
为了获取唯一标识而需要做出的额外努力: 在eBPF内核态的代码中,有限的前置条件为:libbpf辅助函数能获取到的信息和挂钩函数入参、返回值携带的信息等(当然也还有寄存器和堆栈数据)。额外的努力指的是获取pid和fd: pid可通过libbpf辅助函数获取,fd需要从函数入参中获得;
-
于是额外努力转变为对fd的求值:从函数入参结构体中提取fd,前提需要知道"fd"字段的偏移值,而结构体在不同版本也会有所变化,因而又涉及求值版本号;
-
因此求值fd的补充是:从库二进制文件获取版本号以确定fd在结构中的偏移值。
而eBPF捕获GoTLS明文数据在遵循上述步骤的同时,还具有一些特殊性:
-
Go的ABI有所不同,获取参数和返回值需特别结合ABI规范,且存在两种ABI,需通过版本号确定是ABI0还是ABIInternal;
-
Go有goroutine协程的概念,一个线程可能对应多个goroutine。这使得{pid, fd}
的标识不再奏效,而转变为{pid,goid,fd}
;
-
由于已知的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
、[]byte
和error
:
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规范我们得如下结论:
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
在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 struct
和type TCPConn struct
中conn的offset都是0,则
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}
。而fd
和pfd
在对应的结构体中的offset,因此我们只需要知道Sysfd
在type 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.buildinfo
section描述了编译信息,相关解析见src/debug/buildinfo/buildinfo.go
;
-
存在符号为runtime.buildVersion
的字符串对象,存放版本号,内容如go1.18.7
;
-
符号runtime.buildVersion
位于.data
section,而其指向的字符串常量即go.18.7
位于.rodata
section.
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
|
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
... 省略 ...
}
|
同样的,对于goid
在g 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
,函数大小是1022
即0x3fe
,使用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