结构体偏移量计算
目的与范围
本文档说明 eCapture 如何为 SSL/TLS 库(OpenSSL、BoringSSL)生成结构体偏移量定义,以使 eBPF 程序能够访问内部数据结构。结构体偏移量是编译时的值,用于指定 C 结构体中字段的字节位置,这些位置会因库版本的内部 API 变化而有所不同。
关于 eBPF 程序结构和辅助函数的信息,请参见 eBPF 程序结构。关于创建新捕获模块的指南,请参见 添加新模块。
来源: user/module/probe_openssl_lib.go:1-449
为什么需要结构体偏移量
eBPF 程序在内核空间中运行,无法在运行时直接调用库函数或访问 C 结构体定义。在捕获 TLS 流量时,eCapture 必须从用户空间内存读取内部 SSL 结构体(例如 SSL、SSL_CTX、SSL_SESSION)。这些结构体是不透明的指针,其内部布局会在库版本之间发生变化。
偏移量生成过程通过以下方式解决这个问题:
- 在构建时提取实际字段位置,从特定库版本中获取
- 生成 C 头文件,为每个字段偏移量提供
#define宏 - 将这些定义嵌入到 eBPF 字节码编译过程中
- 使
bpf_probe_read_user()能够访问正确的内存位置
如果没有准确的偏移量,eBPF 程序将读取错误的内存地址,导致数据捕获损坏或内核验证器拒绝。
来源: user/module/probe_openssl_lib.go:189-282, utils/openssl_offset_3.0.sh:1-95
偏移量生成流程
脚本工作流程
每个偏移量生成脚本都遵循标准化的工作流程,从特定的 OpenSSL 版本系列中提取结构体偏移量。这个过程是自动化的,可重复的,以确保构建的一致性。
来源: utils/openssl_offset_3.0.sh:24-88, utils/openssl_offset_3.2.sh:24-75
偏移量提取实现
offset.c 文件(例如 openssl_3_0_offset.c、openssl_3_2_0_offset.c)使用 C 语言的 offsetof() 宏来计算结构体字段的字节偏移量。当针对特定的 OpenSSL 版本编译时,生成的二进制文件会打印带有实际数值偏移量的 #define 语句。
提取的关键结构体字段:
| 结构体 | 字段 | 用途 |
|---|---|---|
SSL | version, wbio, rbio, session | TLS 版本、I/O 通道、会话数据 |
SSL_CONNECTION | version, wbio, rbio | OpenSSL 3.x 连接对象 |
SSL_SESSION | master_key, master_key_length, cipher | 用于解密的会话密钥 |
SSL_CTX | keylog_callback | 密钥日志回调指针 |
BIO | num | 文件描述符编号 |
在 openssl_3_0_0_kern.c 中生成的输出示例:
#define SSL_ST_VERSION 0
#define SSL_ST_WBIO 8
#define SSL_ST_RBIO 16
#define SSL_SESSION_ST_MASTER_KEY 32来源: utils/openssl_offset_3.0.sh:63-80, utils/openssl_offset_3.2.sh:51-67
特定版本的脚本
eCapture 为不同的 OpenSSL 主要.次要版本系列维护独立的偏移量生成脚本:
| 脚本 | 覆盖的版本 | 偏移量模板 | 备注 |
|---|---|---|---|
openssl_offset_3.0.sh | 3.0.0 - 3.0.17 | openssl_3_0_offset.c | 版本 3.0.12 有独特的偏移量 |
openssl_offset_3.1.sh | 3.1.0 - 3.1.8 | openssl_3_0_offset.c | 与 3.0.x 偏移量相同 |
openssl_offset_3.2.sh | 3.2.0 - 3.2.5 | openssl_3_2_0_offset.c | 引入 SSL_CONNECTION |
openssl_offset_3.3.sh | 3.3.0 - 3.3.4 | openssl_3_2_0_offset.c | 与 3.2.x 偏移量相同 |
openssl_offset_3.4.sh | 3.4.0 - 3.4.2 | openssl_3_2_0_offset.c | 与 3.2.x 偏移量相同 |
openssl_offset_3.5.sh | 3.5.0 - 3.5.4 | openssl_3_5_0_offset.c | 最新支持的版本 |
每个脚本定义一个版本映射(sslVerMap),将共享相同偏移量的补丁版本分组。例如,在 utils/openssl_offset_3.0.sh:27-45 中:
declare -A sslVerMap=()
sslVerMap["0"]="0" # 3.0.0 使用 openssl_3_0_0_kern.c
sslVerMap["1"]="0" # 3.0.1 使用 openssl_3_0_0_kern.c
# ...
sslVerMap["12"]="12" # 3.0.12 有独特的偏移量
sslVerMap["13"]="0" # 3.0.13+ 回到 3.0.0 偏移量来源: utils/openssl_offset_3.0.sh:27-45, utils/openssl_offset_3.2.sh:27-33, utils/openssl_offset_3.5.sh:27-33
版本分组与映射
运行时版本到字节码映射
MOpenSSLProbe 模块维护一个综合映射(sslVersionBpfMap),将检测到的库版本与预编译的 eBPF 字节码文件关联起来。该映射在 initOpensslOffset() 中初始化。
来源: user/module/probe_openssl_lib.go:73-187, user/module/probe_openssl_lib.go:189-282
版本分组策略
当多个 OpenSSL 版本的内部结构布局相同时,它们会共享字节码文件。这减少了字节码的重复,简化了维护。
OpenSSL 1.1.1 分组 (user/module/probe_openssl_lib.go:105-121):
| 组 | 版本 | 字节码文件 | 原因 |
|---|---|---|---|
| A | 1.1.1a | openssl_1_1_1a_kern.o | 首个 1.1.1 版本 |
| B | 1.1.1b-c | openssl_1_1_1b_kern.o | 轻微结构变化 |
| C | 1.1.1d-i | openssl_1_1_1d_kern.o | 稳定的结构布局 |
| D | 1.1.1j-w | openssl_1_1_1j_kern.o | 最新 1.1.1 系列 |
OpenSSL 3.x 分组 (user/module/probe_openssl_lib.go:124-176):
3.0.0 - 3.0.11 → openssl_3_0_0_kern.o
3.0.12 → openssl_3_0_12_kern.o (异常:独特的偏移量)
3.0.13 - 3.0.17 → openssl_3_0_0_kern.o
3.1.0 - 3.1.8 → openssl_3_1_0_kern.o (与 3.0.x 共享结构)
3.2.0 - 3.2.2 → openssl_3_2_0_kern.o
3.2.3 → openssl_3_2_3_kern.o
3.2.4 - 3.2.5 → openssl_3_2_4_kern.o
3.3.0 - 3.3.1 → openssl_3_3_0_kern.o
3.3.2 → openssl_3_3_2_kern.o
3.3.3 - 3.3.4 → openssl_3_3_3_kern.o
3.4.0 → openssl_3_4_0_kern.o
3.4.1 - 3.4.2 → openssl_3_4_1_kern.o
3.5.0 - 3.5.4 → openssl_3_5_0_kern.oBoringSSL Android 版本 (user/module/probe_openssl_lib.go:92-97):
boringssl_a_13 → boringssl_a_13_kern.o (Android 13)
boringssl_a_14 → boringssl_a_14_kern.o (Android 14)
boringssl_a_15 → boringssl_a_15_kern.o (Android 15)
boringssl_a_16 → boringssl_a_16_kern.o (Android 16)来源: user/module/probe_openssl_lib.go:73-187
版本降级机制
当在 sslVersionBpfMap 中找不到精确的版本匹配时,downgradeOpensslVersion() 函数实现一个回退策略,以找到最接近的兼容旧版本。
算法 (user/module/probe_openssl_lib.go:341-369):
- 前缀匹配:从右到左迭代截断版本字符串
- 候选过滤:查找所有匹配前缀且
version <= detected_version的版本 - 选择:选择最高兼容版本(按字典序排序)
- 默认回退:如果没有匹配,根据库文件名使用
linux_default_3_0或linux_default_1_1_1
示例:如果检测到 openssl 3.2.6 但不在映射中:
- 第 1 次迭代:搜索
openssl 3.2.→ 找到3.2.0、3.2.3、3.2.4、3.2.5 - 选择最高:
3.2.5→ 使用openssl_3_2_4_kern.o
来源: user/module/probe_openssl_lib.go:341-369, user/module/probe_openssl_lib.go:371-422
支持新的库版本
添加新的 OpenSSL 版本
要支持新的 OpenSSL 版本(例如 3.6.0),开发人员必须确定现有偏移量是否兼容,或是否需要新的偏移量生成。
步骤 1:创建偏移量生成脚本
如果版本系列是新的,创建一个脚本,模仿现有脚本:
# 创建 utils/openssl_offset_3.6.sh
cp utils/openssl_offset_3.5.sh utils/openssl_offset_3.6.sh步骤 2:更新版本映射
修改 sslVerMap 数组以包含新的补丁版本:
declare -A sslVerMap=()
sslVerMap["0"]="0" # 3.6.0
sslVerMap["1"]="1" # 3.6.1(如果偏移量不同)
# ... 随着发布添加更多版本步骤 3:运行偏移量生成
从项目根目录执行脚本:
./utils/openssl_offset_3.6.sh这会生成类似 kern/openssl_3_6_0_kern.c 的文件,其中包含偏移量定义。
步骤 4:更新代码中的版本映射
在 user/module/probe_openssl_lib.go:73-187 中修改 initOpensslOffset():
// 添加新的版本范围
for ch := 0; ch <= MaxSupportedOpenSSL36Version; ch++ {
m.sslVersionBpfMap[fmt.Sprintf("openssl 3.6.%d", ch)] = "openssl_3_6_0_kern.o"
}更新文件顶部的版本常量:
const (
MaxSupportedOpenSSL36Version = 5 // openssl 3.6.5
)步骤 5:验证 eBPF 编译
确保生成的头文件能够正确编译:
make clean
make ebpf
# 验证 user/bytecode/openssl_3_6_0_kern_core.o 已创建步骤 6:测试版本检测
使用新 OpenSSL 版本的系统测试模块,以验证:
- 正确的版本字符串检测
- 正确的字节码选择
- 成功的数据捕获
来源: utils/openssl_offset_3.5.sh:1-81, user/module/probe_openssl_lib.go:73-187
处理特殊情况
OpenSSL 3.0.12 异常
OpenSSL 3.0.12 虽然在 3.0.x 系列中,但有独特的结构体偏移量。这需要在版本映射中特殊处理 (user/module/probe_openssl_lib.go:128-130):
// 3.0.0-3.0.11 和 3.0.13-3.0.17 使用 openssl_3_0_0_kern.o
m.sslVersionBpfMap[fmt.Sprintf("openssl 3.0.%d", SupportedOpenSSL30Version12)] = "openssl_3_0_12_kern.o"在生成脚本中 (utils/openssl_offset_3.0.sh:40):
sslVerMap["12"]="12" # 3.0.12 是不同的BoringSSL 非 Android 版本
BoringSSL 对 Android 和非 Android 系统有独立的构建。非 Android 版本使用特殊标识符:
// git repo: https://github.com/google/boringssl
"boringssl na": "boringssl_na_kern.o",开发人员必须创建相应的偏移量脚本(例如 boringssl_offset_na.sh),遵循相同的模式,但使用 BoringSSL 仓库。
来源: user/module/probe_openssl_lib.go:99-103, utils/openssl_offset_3.0.sh:40
偏移量验证
生成新偏移量后,通过以下方式验证正确性:
- 编译时检查:eBPF 验证器会拒绝无效的内存访问
- 运行时测试:捕获实际的 TLS 流量并验证数据完整性
- 跨版本测试:使用同一系列中的多个补丁版本进行测试
- 比较:将生成的偏移量与之前的版本进行比较,以识别变化
如果偏移量不正确,症状包括:
- eBPF 程序加载期间的内核验证器错误
- 捕获的数据损坏(错误的字节、空值)
- 主密钥提取不正确
- 目标进程中的分段错误
来源: user/module/probe_openssl_lib.go:189-282
与构建系统的集成
Makefile 集成
偏移量生成脚本在开发期间手动调用,但不会在正常构建期间自动运行。生成的头文件提交到仓库的 kern/ 目录中。
构建过程 (Makefile:117-127):
$(KERN_OBJECTS): %.o: %.c \
| .checkver_$(CMD_CLANG) \
.checkver_$(CMD_GO) \
autogen
$(CMD_CLANG) -D__TARGET_ARCH_$(LINUX_ARCH) \
$(EXTRA_CFLAGS) \
$(BPFHEADER) \
-target bpfel -c $< -o $(subst kern/,user/bytecode/,$(subst .o,_core.o,$@)) \
...生成的头文件被 eBPF 源文件(例如 openssl.bpf.c)通过 #include 引入,然后编译成字节码。
CI/CD 工作流 (.github/workflows/go-c-cpp.yml:38-65):
- name: Build CO-RE
run: |
make clean
make env
DEBUG=1 make -j8工作流不会重新生成偏移量,而是使用已提交的头文件。这确保了构建的一致性,避免了在 CI 期间依赖外部 OpenSSL 仓库。
来源: Makefile:117-127, .github/workflows/go-c-cpp.yml:38-65
资源嵌入
编译后,eBPF 字节码文件使用 go-bindata 嵌入到 Go 二进制文件中:
assets: .checkver_$(CMD_GO) ebpf ebpf_noncore
$(CMD_GO) run github.com/shuLhan/go-bindata/cmd/go-bindata $(IGNORE_LESS52) \
-pkg assets -o "assets/ebpf_probe.go" $(wildcard ./user/bytecode/*.o)这会创建 assets/ebpf_probe.go,包含所有字节码文件作为 Go 字节数组。在运行时,MOpenSSLProbe 根据检测到的版本选择并加载适当的字节码。
来源: Makefile:162-164, builder/Makefile.release:63-76
跨编译考虑
在为不同架构交叉编译时(例如,在 amd64 上构建 arm64 二进制文件),偏移量值保持一致,因为它们依赖于 OpenSSL 库结构,而不是 CPU 架构。但是,由于 eBPF 指令集的差异,会为每个架构生成独立的字节码文件。
跨编译工作流 (.github/workflows/go-c-cpp.yml:56-65):
- name: Build CO-RE (Cross-Compilation)
run: |
make clean
CROSS_ARCH=arm64 make env
CROSS_ARCH=arm64 make -j8使用相同的偏移量头文件,但 eBPF 编译器生成特定于架构的字节码(*_core.o 文件)。