httptap 深度实战:用 uprobes 在用户态捕获任意 Linux 程序的 HTTP/HTTPS 流量
引言:为什么传统抓包工具越来越不够用了
2026年,HTTPS 流量已经占据了整个互联网的 99% 以上。当你面对以下场景时,传统抓包工具的局限性暴露无遗:
- 想抓一个已经运行的 Go 程序发出的 HTTPS 请求,但它用了证书固定(Certificate Pinning),Fiddler/Charles 的中间人代理根本不管用
- 想分析一个没有源码的闭源程序的网络行为,但没有 root 权限,tcpdump 只能在套接字层面抓包,看不到明文
- 想调试生产环境里的微服务,但不想改代码、不想重启服务、不想加日志
今天要介绍的这个开源项目——httptap,用一种优雅到让人拍案的方式解决了这些问题。它用 Go 写成的,通过 Linux uprobes(用户态动态追踪) 在运行时拦截任意 Linux 程序发起的 HTTP/HTTPS 请求,全程不需要 root 权限,不需要修改系统配置,不需要重启目标进程。
GitHub 两天内斩获超过 2.7k Star,开发者们直呼"这才是真正的零侵入式调试"。
一、先理解问题:传统抓包方案的死角
1.1 tcpdump——只看见电波,看不见内容
tcpdump 是 Linux 网络调试的老牌工具,但它工作在网络接口层,只能看到 IP 包和 TCP 段。HTTPS 流量被 TLS 加密后,tcpdump 只能看到一坨密文:
12:34:56.789012 IP 192.168.1.100.54321 > 142.250.185.78.443: Flags [P.], seq 1:1500, ack 1, win 502, length 1499
这段输出告诉你"程序访问了 Google",但无法告诉你请求的 URL、Header、Body。你需要的是应用层的观测能力。
1.2 Fiddler / Charles——中间人代理的局限
Fiddler 和 Charles 通过代理模式工作:浏览器/程序配置 HTTP 代理,服务端证书由代理自签,代理先解密再重新加密。这个方案有三个致命弱点:
弱点一:证书固定(Certificate Pinning)。现代移动 App(Twitter、Facebook、各大银行)会在代码里硬编码服务器的证书公钥指纹。一旦发现中间人证书与预期不符,直接拒绝连接。Fiddler 在这些 App 面前完全瞎了。
弱点二:需要主动配置。目标程序必须支持 HTTP 代理环境变量(http_proxy/https_proxy)或手动配置代理。很多命令行工具(curl、wget 的部分行为)、守护进程、系统服务根本不走代理。
弱点三:行为入侵。改了代理设置就会改变程序行为,有些 bug 只在直连模式下才会复现。
1.3 Frida / Objection——Hook 的代价
Frida 是目前最强的动态二进制 instrumentation 框架。通过 Hook SSL_read/SSL_write,可以在 OpenSSL/BoringSSL 的 TLS 加密层之前/之后截获明文数据,绕过证书固定。
但 Frida 有两个问题:
- 需要写 JavaScript/Python 脚本,调试和部署有一定门槛
- 对于已经运行的进程,需要额外的 attachment 步骤
1.4 strace——噪音太大
strace -e trace=network 可以跟踪系统调用,但输出极其繁杂——每个系统调用都会打印一行,而且看不到应用层语义,你只能看到 sendmsg、recvmsg 的原始字节流,解析成本极高。
二、uprobes:用户态动态追踪的核心原理
httptap 的技术基石是 Linux uprobes。理解 uprobes 是理解 httptap 一切设计的前提。
2.1 动态追踪技术全景
Linux 提供了多层次的动态追踪基础设施:
┌─────────────────────────────────────────────────┐
│ 应用层观测 │
├─────────────────────────────────────────────────┤
│ uprobes:用户态函数入口/返回点插桩 │
│ kprobes:内核态函数入口/返回点插桩 │
│ ftrace:函数调用图追踪 │
│ perf:硬件性能计数器 + 事件追踪 │
│ eBPF:扩展伯克利包过滤器,内核编程 │
└─────────────────────────────────────────────────┘
其中,uprobes = User-space probes,是追踪用户态程序行为的标准机制,类似于内核态的 kprobes。
2.2 uprobes 工作原理(x86-64 架构)
uprobes 的插桩机制精妙而优雅,核心依赖两件事:
第一件事:断点指令 int3(x86-64 的软中断指令,opcode 为 0xCC)
当你对一个用户态函数的入口地址注册 uprobe 时,内核会:
- 保存原字节:读取该地址的原始指令字节(通常是函数入口的前几个字节)
- 写入断点:将第一个字节替换为
0xCC(int3) - 进程命中:目标进程下次执行到该地址时,CPU 触发
#BP(Breakpoint Exception)异常 - 内核介入:内核的 uprobe handler 被调用,此刻进程被暂停,可以读取寄存器、堆栈、内存
- 恢复执行:处理完毕后,内核将原始字节写回,进程恢复正常执行(单步执行 int3 指令后再插回去)
整个过程对目标进程完全透明,不需要修改源码,不需要重新编译,不需要重启进程。
第二件事: uprobes 的两种形式
- uprobe(函数入口探针):在函数入口处插入,获取函数参数(通过寄存器或栈帧读取)
- uretprobe(函数返回探针):在函数返回地址处插入,获取函数返回值
2.3 为什么 uprobes 能绕过 HTTPS 加密
关键洞察:HTTPS 的加密发生在应用层和 TLS 库之间。
应用程序 ──明文HTTP──> OpenSSL/BoringSSL ──密文TLS──> TCP Socket ──> 网络
当我们在 SSL_write(应用层向 TLS 连接写入明文)和 SSL_read(从 TLS 连接读取明文)上插桩时,截获的数据已经是加密前的明文 / 解密后的明文,完全绕过了 TLS 层。
这就是 Frida、httptap 等工具能做到"HTTPS 明文抓包"的核心原理——不在网络层动手脚,而是在 TLS 的边界动手脚。
2.4 用 perf 工具亲手体验 uprobes
Linux 自带的 perf 工具可以让你零门槛体验 uprobes:
# 查看一个可执行文件的符号表
objdump -T /usr/lib/x86_64-linux-gnu/libssl.so.3 | grep SSL_write
# 用 perf 在 SSL_write 函数入口插桩(需要 root 或 perf_event_open 权限)
sudo perf probe -x /usr/lib/x86_64-linux-gnu/libssl.so.3 SSL_write
# 实时观察目标程序的 SSL_write 调用(-a 表示 attach 到指定进程)
sudo perf probe -a 'libssl:SSL_write%return'
三、httptap 架构深度剖析
3.1 整体设计哲学
httptap 的设计哲学可以概括为一句话:"attach 到任何进程,捕获所有 HTTP/HTTPS 流量,输出到标准输出"。
它的使用方式极简:
# 安装
go install github.com/monasticacademy/httptap@latest
# 使用:用 httptap 运行任意命令,该命令的所有 HTTP/HTTPS 请求都会被捕获
httptap -- curl https://api.github.com/users/octocat
httptap -- python3 my_script.py
httptap -- ./my_binary
3.2 架构分层
┌──────────────────────────────────────────────────────────┐
│ httptap CLI(用户态) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ 命令行参数 │ │ 进程管理 │ │ 输出格式化 │ │
│ │ 解析 │ │ (fork/exec)│ │ (NDJSON) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
├──────────────────────────────────────────────────────────┤
│ Uprobes 层(内核态 / 内核模块) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ uprobe:入口 │ │ uretprobe: │ │ 内存读写 │ │
│ │ SSL_write │ │ 返回 SSL_write│ │ (寄存器/栈) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
├──────────────────────────────────────────────────────────┤
│ 目标进程(被追踪的任意程序) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ OpenSSL │ │ Go stdlib │ │ 任意程序 │ │
│ │ BoringSSL │ │ net/http │ │ (curl/wget) │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────────┘
3.3 追踪目标:多 SSL/TLS 实现支持
httptap 的核心技术挑战在于:不同程序使用了不同的 TLS 实现,httptap 需要对每种实现都插桩才能做到"任意程序"。
典型的 TLS 实现包括:
| TLS 实现 | 使用的程序 | httptap 插桩函数 |
|---|---|---|
| OpenSSL 1.1.x | curl, wget, nginx, Python (旧版) | SSL_write, SSL_read |
| OpenSSL 3.x | 最新的 curl, Python 3.x | SSL_write_ex, SSL_read_ex |
| BoringSSL | Chrome, Android, Go (cgo) | SSL_write, SSL_read |
| GnuTLS | wget (部分编译版) | gnutls_record_send |
| Go 标准库 (crypto/tls) | 原生 Go 程序 | (*Conn).Write, (*Conn).Read |
httptap 的设计支持注册多个 uprobe 点,每个点对应一个具体的函数。当目标进程加载了某个 TLS 库,httptap 会自动检测并激活对应的探针。
3.4 数据关联:从原始字节到 HTTP 请求
SSL_write/SSL_read 的返回值是字节流,不是结构化的 HTTP 请求。httptap 需要做一层协议解析:
// httptap 的数据处理管道(简化版)
type HTTPTransaction struct {
Direction string // "request" 或 "response"
Timestamp time.Time
TLS bool // 是否为 HTTPS
Host string // 目标主机
Method string // HTTP 方法(仅请求)
Path string // URL 路径(仅请求)
StatusCode int // 状态码(仅响应)
BodyLength int
BodyPreview string // 响应体前 200 字节预览
Raw []byte // 原始字节流
}
// 数据关联的关键问题:
// SSL_write 之后紧跟的 SSL_read 是同一个请求的响应吗?
// httptap 依靠:
// 1. 连接对象指针(SSL*)
// 2. 时间戳顺序
// 3. 请求 Content-Length / 响应 Content-Length
// 来建立请求-响应对
3.5 零配置运行:uprobe 的透明 attach
httptap 最优雅的地方在于透明 attach——它不需要你预先知道目标程序用哪个 TLS 库。
实现机制:当通过 httptap -- <cmd> 启动目标进程时,httptap 利用 LD_PRELOAD 注入一个共享库。这个库在目标进程启动早期(CRT 初始化阶段)劫持 fork/exec,然后通过 /proc/[pid]/maps 动态扫描已加载的 .so 文件,找到 TLS 库路径后批量注册 uprobes。
# httptap 内部实际的工作流程(概念层面)
# 1. fork + exec 启动目标进程
# 2. 在子进程早期注入 LD_PRELOAD 共享库
# 3. 共享库初始化时扫描 /proc/self/maps
# 4. 对所有 TLS 库函数注册 uprobes
# 5. 将捕获的数据通过 pipe/ring buffer 传回父进程
四、手写一个简化版 httptap:深入代码实战
光看不练假把式。下面我们手写一个简化版的 HTTP/HTTPS 流量拦截器,理解每一行代码背后的原理。
4.1 整体项目结构
httplink/
├── main.go # CLI 入口
├── probe/
│ ├── manager.go # Uprobe 管理器
│ ├── symbol.go # 符号解析
│ └── parser.go # HTTP 协议解析
└── output/
└── formatter.go # NDJSON 输出
4.2 Uprobe 管理器
核心逻辑:扫描目标进程的内存映射,找到 TLS 库的基址,在目标函数上注册探针。
// probe/manager.go
package probe
import (
"bufio"
"fmt"
"os"
"path/filepath"
"strings"
"syscall"
"unsafe"
"golang.org/x/sys/unix"
)
// ProbePoint 描述一个 uprobe 插桩点
type ProbePoint struct {
LibraryPath string // TLS 库的绝对路径,如 /usr/lib/x86_64-linux-gnu/libssl.so.3
SymbolName string // 函数名,如 "SSL_write"
IsReturn bool // true = uretprobe(返回点),false = uprobe(入口点)
}
// UprobeManager 负责管理一个进程内所有 uprobes
type UprobeManager struct {
pid int
probes []*ProbePoint
probeFDs []int // uprobes 的文件描述符
eventsFDs []int // 事件通知的文件描述符
}
// NewUprobeManager 创建 Uprobe 管理器,attach 到指定 PID
func NewUprobeManager(pid int) (*UprobeManager, error) {
return &UprobeManager{pid: pid}, nil
}
// ScanTLS 扫描目标进程的内存映射,找到所有 TLS 库
func (m *UprobeManager) ScanTLS() ([]*ProbePoint, error) {
mapsPath := fmt.Sprintf("/proc/%d/maps", m.pid)
file, err := os.Open(mapsPath)
if err != nil {
return nil, fmt.Errorf("打开 maps 文件失败: %w", err)
}
defer file.Close()
var probes []*ProbePoint
seenLibs := make(map[string]bool)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
fields := strings.Fields(line)
if len(fields) < 6 {
continue
}
path := fields[len(fields)-1]
// 只关注 TLS 相关库
if !isTLSLibrary(path) {
continue
}
if seenLibs[path] {
continue
}
seenLibs[path] = true
// 注册常见的 TLS 函数探针
for _, sym := range getCommonSymbols(path) {
probes = append(probes, &ProbePoint{
LibraryPath: path,
SymbolName: sym,
IsReturn: false,
})
// 同时注册返回探针(用于获取写入字节数)
probes = append(probes, &ProbePoint{
LibraryPath: path,
SymbolName: sym,
IsReturn: true,
})
}
}
return probes, nil
}
// isTLSLibrary 判断一个库路径是否为 TLS 实现
func isTLSLibrary(path string) bool {
libTLS := []string{
"libssl.so", "libcrypto.so", // OpenSSL
"libgnutls.so", // GnuTLS
"libnsspem.so", // NSS
"libboringssl.so", // BoringSSL
}
for _, l := range libTLS {
if strings.Contains(filepath.Base(path), l) {
return true
}
}
return false
}
// getCommonSymbols 根据 TLS 库版本返回要插桩的函数
func getCommonSymbols(libPath string) []string {
base := filepath.Base(libPath)
if strings.Contains(base, "libssl.so") {
// OpenSSL 3.x 有新接口
return []string{
"SSL_write", "SSL_read",
"SSL_write_ex", "SSL_read_ex",
}
}
return []string{"SSL_write", "SSL_read"}
}
// Attach 注册一个 uprobe,返回事件文件描述符
func (m *UprobeManager) Attach(probe *ProbePoint) (int, int, error) {
// 步骤 1:获取目标函数的虚拟地址
addr, err := m.resolveSymbol(probe.LibraryPath, probe.SymbolName)
if err != nil {
return -1, -1, fmt.Errorf("解析符号 %s 失败: %w", probe.SymbolName, err)
}
// 步骤 2:通过 perf_event_open 创建性能监控事件
// 这是 Linux 提供给普通用户的、无需 root 即可追踪的接口
attr := unix.PerfEventAttr{
Type: uint32(unix.PerfTypeSoftware),
Config: uint64(unix.PERF_COUNT_SW_BRANCH_MISSES), // 复用为 uprobe
Size: uint32(unsafe.Sizeof(unix.PerfEventAttr{})),
}
// 对于 uprobes,需要用 perf_type=UPROBE
// 这里简化处理,实际代码中用 perf_event_open + type=perf_uprobe
fd, errno := unix.PerfEventOpen(&attr, m.pid, 0, -1, unix.PERF_FLAG_FD_NO_GROUP)
if errno != nil {
return -1, -1, fmt.Errorf("perf_event_open 失败: %v", errno)
}
m.probeFDs = append(m.probeFDs, fd)
return fd, int(addr), nil
}
// resolveSymbol 用 nm 或 /proc/[pid]/maps 解析符号地址
func (m *UprobeManager) resolveSymbol(libPath, symbol string) (uint64, error) {
// 方案一:从 maps 获取库的基址
baseAddr, err := m.getLibBase(libPath)
if err != nil {
return 0, err
}
// 方案二:用 nm 读取符号表
offset, err := m.getSymbolOffset(libPath, symbol)
if err != nil {
return 0, err
}
return baseAddr + offset, nil
}
func (m *UprobeManager) getLibBase(libPath string) (uint64, error) {
mapsPath := fmt.Sprintf("/proc/%d/maps", m.pid)
file, err := os.Open(mapsPath)
if err != nil {
return 0, err
}
defer file.Close()
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := scanner.Text()
if !strings.Contains(line, libPath) {
continue
}
// 行格式:start-end perms offset dev inode pathname
fields := strings.Fields(line)
if len(fields) < 6 {
continue
}
var startAddr uint64
_, err := fmt.Sscanf(fields[0], "%x", &startAddr)
if err != nil {
continue
}
return startAddr, nil
}
return 0, fmt.Errorf("在 maps 中未找到库: %s", libPath)
}
// getSymbolOffset 用 nm 读取符号在库内的偏移量
func (m *UprobeManager) getSymbolOffset(libPath, symbol string) (uint64, error) {
// 调用 nm 工具
// 实际生产代码中应该用 elf 库直接解析 ELF 文件
out, err := exec.Command("nm", "-D", libPath).Output()
if err != nil {
return 0, err
}
lines := strings.Split(string(out), "\n")
for _, line := range lines {
if strings.Contains(line, " "+symbol+" ") || strings.HasSuffix(line, " "+symbol) {
parts := strings.Fields(line)
if len(parts) >= 1 {
addrStr := parts[0]
addr, err := strconv.ParseUint(addrStr, 16, 64)
if err == nil {
return addr, nil
}
}
}
}
return 0, fmt.Errorf("nm 中未找到符号: %s", symbol)
}
// Close 清理所有探针,关闭文件描述符
func (m *UprobeManager) Close() error {
for _, fd := range m.probeFDs {
unix.Close(fd)
}
for _, fd := range m.eventsFDs {
unix.Close(fd)
}
return nil
}
4.3 HTTP 协议解析器
从原始字节流中解析出结构化的 HTTP 请求和响应:
// probe/parser.go
package probe
import (
"bufio"
"bytes"
"fmt"
"net/url"
"regexp"
"strconv"
"strings"
"time"
)
// HTTPTransaction 表示一个完整的 HTTP 事务(请求+响应)
type HTTPTransaction struct {
ID string `json:"id"`
Timestamp time.Time `json:"timestamp"`
TLS bool `json:"tls"`
Host string `json:"host"`
Port int `json:"port"`
Method string `json:"method,omitempty"`
Path string `json:"path,omitempty"`
StatusCode int `json:"status_code,omitempty"`
Duration int64 `json:"duration_ns,omitempty"`
Request *HTTPReq `json:"request,omitempty"`
Response *HTTPResp `json:"response,omitempty"`
Raw []byte `json:"raw,omitempty"`
}
type HTTPReq struct {
Version string `json:"version"`
Headers map[string]string `json:"headers"`
Body []byte `json:"body,omitempty"`
}
type HTTPResp struct {
Version string `json:"version"`
Status string `json:"status"`
Headers map[string]string `json:"headers"`
Body []byte `json:"body,omitempty"`
BodyLimit int `json:"-"` // 不截断前 BodyLimit 字节
}
// HTTPParser HTTP 协议解析器
type HTTPParser struct {
reqRegex *regexp.Regexp
respRegex *regexp.Regexp
hostRegex *regexp.Regexp
bodyLimit int
}
// NewHTTPParser 创建 HTTP 解析器
func NewHTTPParser() *HTTPParser {
return &HTTPParser{
reqRegex: regexp.MustCompile(`^(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(\S+)\s+HTTP/(\d\.\d)`),
respRegex: regexp.MustCompile(`^HTTP/(\d\.\d)\s+(\d+)\s+(.*)$`),
hostRegex: regexp.MustCompile(`(?im)^host:\s*(.+)$`),
bodyLimit: 200, // 预览 200 字节
}
}
// ParseRequest 解析 HTTP 请求
func (p *HTTPParser) ParseRequest(data []byte) (*HTTPReq, error) {
// 找请求行
lines := splitLines(data)
if len(lines) == 0 {
return nil, fmt.Errorf("空数据")
}
reqLine := string(lines[0])
match := p.reqRegex.FindStringSubmatch(reqLine)
if match == nil {
return nil, fmt.Errorf("无法解析请求行: %s", reqLine)
}
req := &HTTPReq{
Version: match[3],
Headers: make(map[string]string),
}
method, path := match[1], match[2]
_ = method
// 找 Host header(确定目标主机)
// 找其他 header
for i := 1; i < len(lines); i++ {
line := string(lines[i])
if line == "" {
break // 空行 = header 结束
}
idx := strings.IndexByte(line, ':')
if idx == -1 {
continue
}
key := strings.TrimSpace(line[:idx])
val := strings.TrimSpace(line[idx+1:])
req.Headers[strings.ToLower(key)] = val
}
// 找 Body(如果有 Content-Length)
if cl, ok := req.Headers["content-length"]; ok {
contentLen, _ := strconv.Atoi(cl)
if contentLen > 0 && contentLen <= 1<<20 { // 限制 1MB
bodyStart := bytes.Index(data, []byte("\r\n\r\n")) + 4
if bodyStart < len(data) {
bodyLen := min(contentLen, p.bodyLimit)
req.Body = data[bodyStart : bodyStart+bodyLen]
}
}
}
return req, nil
}
// ParseResponse 解析 HTTP 响应
func (p *HTTPParser) ParseResponse(data []byte) (*HTTPResp, error) {
lines := splitLines(data)
if len(lines) == 0 {
return nil, fmt.Errorf("空数据")
}
respLine := string(lines[0])
match := p.respRegex.FindStringSubmatch(respLine)
if match == nil {
return nil, fmt.Errorf("无法解析响应行: %s", respLine)
}
resp := &HTTPResp{
Version: match[1],
Status: match[3],
Headers: make(map[string]string),
}
if statusCode, err := strconv.Atoi(match[2]); err == nil {
resp.Status = fmt.Sprintf("%d %s", statusCode, resp.Status)
_ = statusCode
}
for i := 1; i < len(lines); i++ {
line := string(lines[i])
if line == "" {
break
}
idx := strings.IndexByte(line, ':')
if idx == -1 {
continue
}
key := strings.TrimSpace(line[:idx])
val := strings.TrimSpace(line[idx+1:])
resp.Headers[strings.ToLower(key)] = val
}
if cl, ok := resp.Headers["content-length"]; ok {
contentLen, _ := strconv.Atoi(cl)
if contentLen > 0 && contentLen <= 1<<20 {
bodyStart := bytes.Index(data, []byte("\r\n\r\n")) + 4
if bodyStart < len(data) {
bodyLen := min(contentLen, p.bodyLimit)
resp.Body = data[bodyStart : bodyStart+bodyLen]
}
}
}
return resp, nil
}
// ExtractHostPort 从 SSL 连接上下文中提取目标主机和端口
// 这是实现中的难点:SSL_write 的参数中没有 host
// 需要借助 /proc/net/tcp6 或 getsockopt SO_ORIGINAL_DST 来反向查找
func (p *HTTPParser) ExtractHostPort(pid int, sslPtr uint64) (string, int, error) {
// 实现思路:
// 1. 读取 /proc/[pid]/fd/,找到 SSL* 指针对应的 socket FD
// 2. 通过 getsockopt SO_ORIGINAL_DST 获取连接的目标地址(对代理场景有效)
// 3. 如果无法获取,从 Host header 中解析
return "", 0, fmt.Errorf("未实现")
}
// splitLines 高效分割行
func splitLines(data []byte) [][]byte {
var lines [][]byte
start := 0
for i, b := range data {
if b == '\n' {
line := data[start:i]
if len(line) > 0 && line[len(line)-1] == '\r' {
line = line[:len(line)-1]
}
lines = append(lines, line)
start = i + 1
}
}
return lines
}
4.4 用 Go 标准库解析 ELF 文件
实际项目中,一个更可靠的方法是用 Go 的 debug/elf 包直接解析 TLS 库的 ELF 文件,找到符号表:
// probe/symbol_elf.go
package probe
import (
"debug/elf"
"fmt"
)
// ELFSymbolResolver 用 ELF 格式解析库的符号表
type ELFSymbolResolver struct{}
// ResolveSymbol 解析库文件中某个符号的地址偏移量
func (r *ELFSymbolResolver) ResolveSymbol(libPath, symbol string) (uint64, error) {
f, err := elf.Open(libPath)
if err != nil {
return 0, fmt.Errorf("打开 ELF 文件失败: %w", err)
}
defer f.Close()
// 查找动态符号表(.dynsym)
syms, err := f.DynamicSymbols()
if err != nil {
return 0, fmt.Errorf("读取动态符号表失败: %w", err)
}
for _, sym := range syms {
if sym.Name == symbol {
// STT_FUNC 表示这是函数符号
if sym.Type == elf.STT_FUNC {
return sym.Value, nil
}
}
}
// 备用:搜索所有符号表
allSyms, err := f.Symbols()
if err == nil {
for _, sym := range allSyms {
if sym.Name == symbol && sym.Type == elf.STF_FUNC {
return sym.Value, nil
}
}
}
return 0, fmt.Errorf("符号 %s 在 %s 中未找到", symbol, libPath)
}
4.5 主程序:零配置 CLI
// main.go
package main
import (
"encoding/json"
"flag"
"fmt"
"log"
"os"
"os/exec"
"os/signal"
"syscall"
"time"
"github.com/yourname/httplink/probe"
)
func main() {
// 解析命令行参数
flag.Usage = func() {
fmt.Fprintf(os.Stderr, "用法: %s [OPTIONS] -- <COMMAND> [ARGS...]\n", os.Args[0])
flag.PrintDefaults()
}
jsonOutput := flag.Bool("json", false, "输出 NDJSON 格式")
verbose := flag.Bool("v", false, "显示详细日志")
filterHost := flag.String("host", "", "只显示特定主机的流量")
flag.Parse()
if flag.NArg() == 0 {
flag.Usage()
os.Exit(1)
}
// 构建要执行的命令
cmdArgs := flag.Args()
cmd := exec.Command(cmdArgs[0], cmdArgs[1:]...)
cmd.Stdin = os.Stdin
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
// 设置环境变量(某些程序会检测 HTTP_PROXY)
cmd.Env = append(os.Environ(), "HTTP_PROXY=", "HTTPS_PROXY=", "NO_PROXY=*")
// 启动目标进程
if err := cmd.Start(); err != nil {
log.Fatalf("启动进程失败: %v", err)
}
pid := cmd.Process.Pid
if *verbose {
fmt.Fprintf(os.Stderr, "[httptap] 已 attach 到 PID %d\n", pid)
}
// 创建 Uprobe 管理器
manager, err := probe.NewUprobeManager(pid)
if err != nil {
log.Fatalf("创建 Uprobe 管理器失败: %v", err)
}
defer manager.Close()
// 扫描 TLS 库并注册探针
probes, err := manager.ScanTLS()
if err != nil {
if *verbose {
fmt.Fprintf(os.Stderr, "[httptap] 扫描 TLS 库失败: %v(继续监控)\n", err)
}
}
for _, p := range probes {
if *verbose {
fmt.Fprintf(os.Stderr, "[httptap] 注册探针: %s!%s\n", p.LibraryPath, p.SymbolName)
}
_, _, err := manager.Attach(p)
if err != nil && *verbose {
fmt.Fprintf(os.Stderr, "[httptap] 探针注册失败(可能是库未加载): %v\n", err)
}
}
// 启动事件监听 goroutine
parser := probe.NewHTTPParser()
go func() {
// 实际场景中,从 perf_event_fd 读取 uprobe 事件
// 这里用简化的事件处理
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
<-ticker.C
// 读取 /proc/[pid]/fd/... 的 ring buffer
// 解析 SSL_write/SSL_read 的参数和返回值
// 组装 HTTPTransaction
}
}()
// 处理信号(优雅退出)
sigChan := make(chan os.Signal, 1)
signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM)
go func() {
<-sigChan
if cmd.Process != nil {
cmd.Process.Signal(syscall.SIGTERM)
}
}()
// 等待进程结束
err = cmd.Wait()
if err != nil && *verbose {
fmt.Fprintf(os.Stderr, "[httptap] 进程退出: %v\n", err)
}
if *verbose {
fmt.Fprintf(os.Stderr, "[httptap] 会话结束\n")
}
}
// printTransaction 以 JSON 格式输出事务
func printTransaction(tx *probe.HTTPTransaction, jsonFormat bool) {
if jsonFormat {
data, _ := json.Marshal(tx)
fmt.Println(string(data))
} else {
// 人类可读的格式
dir := "→"
if tx.Response != nil {
dir = "←"
}
fmt.Printf("%s [%s] %s %s %s\n",
tx.Timestamp.Format("15:04:05.000"),
dir,
tx.Host,
tx.Method,
tx.Path,
)
}
}
五、生产级优化:从玩具到工具
5.1 eBPF 替代 uprobes:更安全、更高效
当前版本的 httptap 基于 uprobes 实现,但生产环境中 eBPF 是更优的选择:
uprobes 的问题:
- 依赖内核版本(4.x+),某些旧内核不支持
- 每个探针占用一个性能计数器硬件(数量有限)
- 对目标进程的暂停时间不可控(极端情况下可达毫秒级)
eBPF 的优势:
- 运行在内核的沙箱 VM 中,有验证器保证安全
- 不占用性能计数器硬件
- 暂停目标进程的时间极短(微秒级)
用 eBPF 实现同样的功能:
// ebpf_http_capture.bpf.c
// 使用 BCC (BPF Compiler Collection) 编写
#include <uapi/linux/ptrace.h>
#include <net/sock.h>
#include <bcc/proto.h>
struct data_t {
u32 pid;
char comm[16];
u64 ts;
s32 type; // 1=SSL_write, 2=SSL_read
u64 ret; // 返回值(字节数)
char data[512]; // 截取的前 512 字节
};
BPF_PERF_OUTPUT(events);
// 在 SSL_write 入口处插桩
int trace_ssl_write(struct pt_regs *ctx) {
struct data_t data = {};
data.pid = bpf_get_current_pid_tgid() >> 32;
data.ts = bpf_ktime_get_ns();
data.type = 1;
// 从参数寄存器读取
// x86_64: rdi=第一个参数, rsi=第二个参数, rdx=第三个参数
void *ssl = (void *)PT_REGS_PARM1(ctx);
void *buf = (void *)PT_REGS_PARM2(ctx);
int len = (int)PT_REGS_PARM3(ctx);
// 截取前 512 字节
int copy_len = len < 512 ? len : 512;
bpf_probe_read_user(data.data, copy_len, buf);
events.perf_submit(ctx, &data, sizeof(data));
return 0;
}
// 在 SSL_write 返回处插桩
int trace_ssl_write_return(struct pt_regs *ctx) {
struct data_t data = {};
data.pid = bpf_get_current_pid_tgid() >> 32;
data.ts = bpf_ktime_get_ns();
data.type = 3; // 标记为返回
data.ret = PT_REGS_RC(ctx); // x86_64 返回值在 rax
events.perf_submit(ctx, &data, sizeof(data));
return 0;
}
5.2 连接级别的数据关联
最复杂的问题:如何把 SSL_write 的返回值和对应的 SSL_read 关联起来,从而正确地识别"请求-响应对"。
主流方案有两种:
方案一:连接对象指针(SSL)关联*
SSL_write(ssl=0x7f1234000001, buf, 200) → 返回 200
SSL_read(ssl=0x7f1234000001, buf, 4096) → 返回响应体
同一个 SSL* 指针的写和读属于同一个连接。通过追踪连接对象地址,可以把同一连接上的操作串起来。
方案二:时间窗口 + 内容特征匹配
T1: SSL_write(..., "/api/users", ...)
T2: SSL_read(..., "HTTP/1.1 200 OK", ...) [同一连接对象]
通过时间窗口(通常 5 秒内)和内容特征(请求包含的 URL 路径、响应包含的 Status Code),可以建立对应对。
5.3 性能开销分析
httptap 的性能开销主要来自三个方面:
| 开销来源 | 量级 | 说明 |
|---|---|---|
| 指令替换(int3) | 每调用 1 次 = +1 次异常 | 精确到每次 SSL 调用 |
| 内核 handler | ~1-2 微秒/次 | 必须保存寄存器、写 ring buffer |
| 用户态解析 | ~5-10 微秒/条 | 协议解析 + JSON 序列化 |
对于一个每秒发起 100 次 HTTPS 请求的程序,httptap 引入的开销约为 0.1-0.2% CPU,完全可以接受。
对于高频场景(万级别 QPS),建议使用 eBPF 方案,将数据过滤和聚合在内核态完成,只把汇总结果传回用户态。
5.4 安全注意事项
使用 httptap 的前提:
- 你拥有目标进程的用户权限(能够
ptrace或perf_event_open) - 目标进程运行在同一个 Linux 主机上
- Linux 内核开启了
CONFIG_UPROBES(几乎所有现代发行版默认开启)
httptap 不能做的事:
- 不能 attach 到设置了
PR_SET_DUMPABLE禁用的进程 - 不能 attach 到 SUID 程序(安全限制)
- 在容器环境中,需要
--cap=SYS_PTRACE或--privileged模式
六、httptap vs 竞品:选型指南
| 工具 | 原理 | root 权限 | 目标范围 | HTTPS 明文 | 实时性 |
|---|---|---|---|---|---|
| tcpdump | 网络接口抓包 | 需要 | 所有流量 | 否 | 高 |
| Fiddler/Charles | HTTP 代理 | 不需要 | 配置了代理的程序 | 有限(可被证书固定绕过) | 高 |
| Frida | PLT/GOT Hook | 需要 | 单进程 | 是 | 极高 |
| strace | 系统调用追踪 | 需要 | 单进程 | 否 | 中 |
| httptap | uprobes | 不需要 | 单进程 | 是 | 高 |
| eBPF (bpftrace) | 内核沙箱 | 部分需要 | 全系统 | 否(网络层) | 极高 |
httptap 的独特定位:普通用户权限 + 任意程序 + HTTPS 明文,这三者的交集是其他工具无法同时满足的。
七、展望:动态追踪的未来
httptap 的出现是 Linux 观测技术演进的一个缩影。我们正在经历一个范式转变:
从"日志驱动"到"运行时驱动"
过去我们靠日志:出问题了加日志 → 重新部署 → 复现问题 → 删日志。代价高、侵入性强、无法在生产环境实时操作。
现在我们靠动态追踪:不需要改代码,不需要重启进程,在运行时注入观测点。这才是"观测驱动开发"(Observability-Driven Development)的真正形态。
未来的三个方向:
- AI 辅助的智能插桩:给定一个自然语言描述("找出这个微服务所有耗时超过 100ms 的数据库查询"),AI 自动生成 uprobe/eBPF 程序
- 跨进程全链路追踪:把 uprobes 和分布式追踪系统(OpenTelemetry)打通,做真正的端到端可观测性
- 安全监控:用 uprobes 实现实时的威胁检测,在不修改程序的前提下检测异常网络行为
httptap 只是一个开始。当我们能把"观测"这件事做到零摩擦,程序员的生产力将迎来又一次跃迁。
总结
httptap 用一个简洁的 idea 解决了真实痛点:用 uprobes 在用户态零侵入地捕获任意 Linux 程序的 HTTP/HTTPS 流量。它的技术内核涉及 Linux 动态追踪(uprobes/kprobes)、ELF 符号解析、TLS 协议边界拦截、生产者-消费者并发模型等多个领域。
通过本文,你应该掌握了:
- 为什么 uprobes 能做到传统工具做不到的事(用户态函数拦截,绕过加密层)
- HTTPS 抓包的本质(在 SSL_write/SSL_read 的边界截获明文)
- 如何实现一个简化版 httptap(符号解析 → 探针注册 → 事件读取 → 协议解析)
- 生产级优化方向(eBPF、连接关联、性能开销分析)
下次遇到"这个 App 怎么走 HTTPS 的我抓不到"的问题时,记得还有 uprobes 这把利刃。