BCC 实战:用 eBPF SOCKET_FILTER 解析 HTTP 流量并提取 URL
2026/9/20 23:57:05 网站建设 项目流程
  • eBPF
  • 可观测性
  • 性能剖析
  • 网络

【免费下载链接】bcc

BCC - Tools for BPF-based Linux IO analysis, networking, monitoring, and more

项目地址:https://gitcode.com/gh_mirrors/bc/bcc
点击查看免费下载

导读

本篇文章以 BCC 仓库中的 examples/networking/http_filter 示例为线索,完整讲解如何用 eBPF 程序在内核态过滤 HTTP 报文、把命中流量送回用户态,再由 Python 脚本重组并打印 GET/POST 请求行与 URL。读完本文,你将掌握 BPF_PROG_TYPE_SOCKET_FILTER 的加载与挂载流程、eBPF 哈希表跨内核/用户态共享的用法,以及"简单版"与"完整版"两种针对跨包 URL 的流量重组策略,并可以直接把示例跑起来做实验。

示例概览:一个"读 URL 的 eBPF 应用"

http_filter示例是一个典型的"内核过滤 + 用户态解析"双层应用:eBPF 程序http_filter作为PROG_TYPE_SOCKET_FILTER类型加载进内核,并绑定到网络接口(默认eth0)的原始套接字上;它只把载荷以 "HTTP"、"GET"、"POST"(以及 PUT/DELETE/HEAD)开头的 IP/TCP 报文,以及同属一个 HTTP 会话(相同的源/目的 IP 与端口四元组)的后续报文返回给用户态,其余报文直接丢弃。Python 脚本从套接字文件描述符读回这些原始报文,自行解析以太网 / IP / TCP 头部,打印请求行的第一行(即包含 URL 的那一行)。

仓库里提供了两个版本,文件都位于 examples/networking/http_filter:

版本eBPF 程序Python 脚本能力
simple(简单版)http-parse-simple.chttp-parse-simple.py只处理完整落在单个报文内的 URL
complete(完整版)http-parse-complete.chttp-parse-complete.py能重组跨多个报文的 URL,打印完整 URL

对应地,CMakeLists.txt 会把这两个.c文件、两个.py脚本连同README.md一起安装到share/bcc/examples/networking/http_filter目录,说明它们属于 BCC 官方分发的示例程序集。

如何运行这个示例

环境前提

  • 系统需要安装 BCC(BPF Compiler Collection),Python 侧通过from bcc import BPF调用 BCC 的 Python 绑定;
  • 需要root权限(脚本内部会创建原始套接字并挂载 BPF 程序,二者都需要特权);
  • 内核需支持 eBPF 与 socket filter 类型的程序(主流发行版默认开启)。

运行命令

examples/networking/http_filter目录下,任选其一执行:

$ sudo python http-parse-simple.py $ sudo python http-parse-complete.py

脚本启动后会打印binding socket to 'eth0',随后持续把抓到的 HTTP 请求行输出到标准输出,典型输出如下:

GET /pipermail/iovisor-dev/ HTTP/1.1 HTTP/1.1 200 OK GET /favicon.ico HTTP/1.1 HTTP/1.1 404 Not Found GET /pipermail/iovisor-dev/2016-January/thread.html HTTP/1.1 HTTP/1.1 200 OK GET /pipermail/iovisor-dev/2016-January/000046.html HTTP/1.1 HTTP/1.1 200 OK

注意:程序绑定的是eth0 接口的原始套接字,因此抓到的流量是流经该接口的明文 HTTP 报文;对 HTTPS(TLS 加密流量)不适用,这属于该示例的明确适用范围。

命令行参数

两个 Python 脚本都支持-i指定网卡(默认eth0):

$ sudo python http-parse-complete.py -i wlan0 # 绑定到 wlan0 $ sudo python http-parse-complete.py -h # 打印帮助

从 http-parse-complete.py 的参数解析逻辑可以看到:无参数时默认接口为eth0-h显示帮助;-i if_name切换接口;其余用法会打印 USAGE 并退出。

实现总览:内核态过滤 + 用户态解析的两段式架构

整个应用按 README 的描述分为两大部分,这一分工也直接体现在文件组织上:

  1. 第一部分(eBPF 过滤):用 C 编写的 eBPF 程序(.c文件),过滤出载荷以 "HTTP" / "GET" / "POST" 等字符串开头的 IP/TCP 报文,以及属于同一会话(相同ip.src, ip.dst, port.src, port.dst四元组)的后续报文;程序以PROG_TYPE_SOCKET_FILTER加载并挂到绑定eth0的套接字上;命中报文返回用户态(return -1),未命中直接丢弃(return 0)。
  2. 第二部分(用户态解析):Python 脚本从套接字文件描述符读取过滤后的原始报文,必要时按会话重组,把 HTTP GET/POST 请求的第一行打印到标准输出。

eBPF 程序运行在数据包路径上,只做最轻量的字节匹配,避免把每个包都拷到用户态;用户态只处理已被过滤的少量报文,二者配合实现了"低开销的在线 HTTP 行提取"。

理解 socket filter 的返回语义

两个 C 程序头部注释都明确写出了 socket filter 的返回约定(见 http-parse-complete.c):

  • return 0→ 丢弃该报文;
  • return -1→ 保留该报文,并把它送回到用户态(用户态可从socket_fd读到)。

这是理解整段 eBPF 代码的关键:程序体内到处出现的goto KEEP;goto DROP;最终都汇合到这两个返回值上。

eBPF 过滤程序逐段拆解(simple 版)

以 http-parse-simple.c 为例,它的处理流程可以概括为"以太网头 → IP 头 → TCP 头 → 载荷前 7 字节"的逐层解析与匹配。

1. 用 cursor 逐层推进报文指针

程序使用 BCC 提供的cursor_advance宏沿报文向前移动解析指针。该宏定义于 src/cc/export/helpers.h:

#define cursor_advance(_cursor, _len) \ ({ void *_tmp = _cursor; _cursor += _len; _tmp; })

它返回移动前的指针、同时把游标前移_len字节,配合 BCC 在 src/cc/export/proto.h 中定义的紧凑网络头结构体(ethernet_tip_ttcp_t),即可安全地按偏移解析报文。

第一步取出以太网头并过滤 IP 报文(EtherType 为0x0800):

struct ethernet_t *ethernet = cursor_advance(cursor, sizeof(*ethernet)); if (!(ethernet->type == 0x0800)) { goto DROP; }

第二步取出 IP 头并过滤 TCP 报文(IP 协议号0x06,代码里#define IP_TCP 6):

struct ip_t *ip = cursor_advance(cursor, sizeof(*ip)); if (ip->nextp != IP_TCP) { goto DROP; }

2. 计算可变长度的 IP / TCP 头部

IPv4 头部长度字段(ip->hlen,单位是 4 字节)和 TCP 数据偏移字段(tcp->offset,单位也是 4 字节)都是可变的,代码用左移 2 位(等价于乘 4)换算成字节数,并做最小长度校验:

ip_header_length = ip->hlen << 2; // 例如 hlen=5 → 20 字节 if (ip_header_length < sizeof(*ip)) { goto DROP; } void *_ = cursor_advance(cursor, (ip_header_length - sizeof(*ip))); // 跳过 IP 选项 struct tcp_t *tcp = cursor_advance(cursor, sizeof(*tcp)); tcp_header_length = tcp->offset << 2; // 例如 offset=5 → 20 字节

对照 src/cc/export/proto.h 中的ip_t定义(ver:4 / hlen:4共享第一字节、nextp是协议号)和tcp_t定义(offset:4位于第 12 字节的高 4 位),可以确认上述位域语义与 RFC 791 / RFC 793 的头部布局一致。

3. 计算载荷偏移与长度,并做最小长度防护

payload_offset = ETH_HLEN + ip_header_length + tcp_header_length; payload_length = ip->tlen - ip_header_length - tcp_header_length; if (payload_length < 7) { goto DROP; }

ETH_HLEN为 14 字节(#define ETH_HLEN 14)。payload_length < 7的检查基于一个经验事实:HTTP 请求的最小长度也大于 7 字节(示例注释引用了相关讨论),这个下界既能排除空载荷报文,也防止后续循环越界访问。

4. 读取载荷前 7 字节并匹配 HTTP 方法

socket filter 程序不能直接解引用skb指向的内存,必须通过load_byte(skb, offset)逐字节读取:

unsigned long p[7]; for (i = 0; i < 7; i++) { p[i] = load_byte(skb, payload_offset + i); }

随后依次与HTTPGETPOSTPUTDELETEHEAD的 ASCII 码逐字节比较,命中任意一个就goto KEEPreturn -1),全部不中则goto DROPreturn 0)。注意 simple 版只匹配"首包",一旦 URL 横跨多个 TCP 报文,超出第一个报文的 URL 部分就无法打印——这正是 README 中描述的 simple 版局限。

完整版:用 eBPF 哈希表追踪 HTTP 会话

完整版在 http-parse-complete.c 中引入了一张 eBPF 哈希表sessions,用来记住"哪些四元组属于 HTTP 会话",从而让同会话的后续报文(即使载荷不以 HTTP 方法开头)也能被放行到用户态。

1. 定义 Key / Leaf 与哈希表

struct Key { u32 src_ip; u32 dst_ip; unsigned short src_port; unsigned short dst_port; }; struct Leaf { int timestamp; // 时间戳(ns) }; BPF_HASH(sessions, struct Key, struct Leaf, 1024);

BPF_HASH是 BCC 提供的宏,展开后即一个最多 1024 项、以会话四元组为键、以时间戳为值的哈希表。这张表同时被内核态(写入/查找)和用户态(通过bpf.get_table("sessions")获取句柄后读写/清理)共享。

2. 命中 HTTP 方法时登记会话

在完成与 simple 版相同的以太网/IP/TCP 过滤后,代码先取出四元组:

key.dst_ip = ip->dst; key.src_ip = ip->src; key.dst_port = tcp->dst_port; key.src_port = tcp->src_port;

若载荷前几字节命中 HTTP 方法,则把该四元组插入表(lookup_or_try_init),然后goto KEEP放行:

HTTP_MATCH: sessions.lookup_or_try_init(&key, &zero); KEEP: return -1;

3. 非 HTTP 首包:查表决定去留

若首字节不匹配任何 HTTP 方法,程序并不直接丢弃,而是查表判断当前包是否属于已登记的 HTTP 会话:

struct Leaf *lookup_leaf = sessions.lookup(&key); if (lookup_leaf) { goto KEEP; // 属于已知 HTTP 会话,送回用户态 } goto DROP;

这样一来,即使一个很长的 URL 被 TCP 拆成多个报文,后续报文也会因为四元组命中而全部被送回用户态,由 Python 侧负责拼接。

Python 用户态脚本:从原始报文到 URL 打印

加载与挂载流程(两个版本一致)

以 http-parse-complete.py 为例,核心步骤是:

bpf = BPF(src_file="http-parse-complete.c", debug=0) function_http_filter = bpf.load_func("http_filter", BPF.SOCKET_FILTER) BPF.attach_raw_socket(function_http_filter, interface) socket_fd = function_http_filter.sock sock = socket.fromfd(socket_fd, socket.PF_PACKET, socket.SOCK_RAW, socket.IPPROTO_IP) sock.setblocking(True)
  1. BPF(src_file=...):让 BCC 在运行时把 C 源码交给 LLVM/Clang 编译成 eBPF 字节码并加载进内核;
  2. load_func("http_filter", BPF.SOCKET_FILTER):指定程序类型为 socket filter;
  3. attach_raw_socket:在指定网卡上创建原始套接字并挂上该程序;程序句柄上的.sock属性即该原始套接字的文件描述符;
  4. socket.fromfd:把 fd 包装成 Python socket 对象,并设置为阻塞模式。

随后主循环用os.read(socket_fd, ...)读回被 eBPF 放行的原始报文(simple 版每次读 2048 字节,complete 版读 4096 字节)。

用户态报文解析

Python 侧需要重新解析以太网/IP/TCP 头部(注释中贴出了 RFC 791 IP 头与 RFC 793 TCP 头的字段布局),关键计算与 C 端一一对应:

# 总长度(IP 头第 2、3 字节,大端) total_length = packet_bytearray[ETH_HLEN + 2] << 8 + packet_bytearray[ETH_HLEN + 3] # IP 头长度(首字节低 4 位 × 4) ip_header_length = (packet_bytearray[ETH_HLEN] & 0x0F) << 2 # TCP 头长度(第 12 字节高 4 位 × 4:SHR 4 后再 SHL 2,等价于 SHR 2) tcp_header_length = (packet_bytearray[ETH_HLEN + ip_header_length + 12] & 0xF0) >> 2 payload_offset = ETH_HLEN + ip_header_length + tcp_header_length

simple 版 http-parse-simple.py 随后从payload_offset起逐字节打印,直到遇到\r\n0x0D 0x0A)为止,即打印请求行(如果想把整个请求头都打出来,注释提示可以把终止条件改成\r\n\r\n)。

完整版的跨包 URL 重组逻辑

complete 版在用户态维护了一个local_dictionary(键为会话四元组的十六进制形式,值为已累积的载荷片段),配合内核sessions表完成三段式处理,见 http-parse-complete.py:

  1. 当前包是 HTTP 方法首包:若载荷内已含\r\n,说明 URL 完整落在本包,直接printUntilCRLF(payload_string)并删除sessions中对应条目;若不含\r\n,说明 URL 被拆包,把当前载荷存入local_dictionary等待后续报文。
  2. 当前包不是方法首包、但四元组命中sessions:若该四元组已在local_dictionary中,就把当前载荷追加进去;追加后若出现\r\n,则打印完整 URL 并清理内核表与本地字典;若尚未出现\r\n且累计长度超过MAX_URL_STRING_LEN(8192 字节),打印url too long并清理。
  3. 命中sessions但不在本地字典中:说明内核表里存的是无效条目,直接删除。

其中printUntilCRLF的定义是print(s.split(b'\r\n')[0].decode()),与 simple 版的逐字节打印殊途同归。

会话表清理机制

为避免sessions哈希表无限膨胀,complete 版引入了三个常量(http-parse-complete.py):

CLEANUP_N_PACKETS = 50 # 每收到 50 个包执行一次清理 MAX_URL_STRING_LEN = 8192 # URL 最大长度上限(通常 8K) MAX_AGE_SECONDS = 30 # 会话条目最大存活时间(秒)

cleanup()遍历sessions表:时间戳为 0 的条目补上当前时间(首次见到),年龄超过MAX_AGE_SECONDS(30 秒)的条目删除。清理动作在主循环里每累计CLEANUP_N_PACKETS(50 个)包触发一次。这套"时间戳 + 定期清扫"机制保证了长期运行时内核哈希表不会残留过期会话。

两个版本的对比与适用场景

维度simple 版complete 版
eBPF 程序只做单包字节匹配(http-parse-simple.c)增加sessions哈希表追踪会话(http-parse-complete.c)
用户态逻辑打印首包中的请求行维护local_dictionary,跨包重组 URL
URL 跨包场景只显示首包中的 URL 片段打印完整 URL
资源占用无额外状态内核哈希表 + 本地字典 + 定时清理
适用场景演示与教学、URL 较短生产级演示、长 URL / 大请求行场景

小结与延伸阅读

这个示例的价值在于它浓缩了 BCC 应用的四个核心模式:PROG_TYPE_SOCKET_FILTER程序类型的加载与挂载、用cursor_advanceproto.h结构体做报文逐层解析、用BPF_HASH实现内核/用户态共享状态,以及"内核粗过滤 + 用户态精处理"的分层设计。你可以在 BCC 仓库中找到更多同类型示例继续对照学习:

  • 同目录下的其他网络示例:examples/networking(含tcp_mon_blocktunnel_monitorvlan_filter等);
  • BCC 提供的BPF类与哈希表 API 的完整定义位于 src/cc/export/helpers.h 与 src/python;
  • 想了解 socket filter 之外的更多程序类型(如 kprobe、tracepoint、XDP),可参考 docs/reference_guide.md;
  • 若需对明文 HTTP 以外的流量做解析,仓库中的 tools/trace.py 等工具提供了更通用的追踪能力。

需要注意的是,本示例针对的是明文 HTTP 流量且默认绑定eth0,在 HTTPS 普及的今天更适合作为 eBPF socket filter 与流量重组的教学样例,实际生产环境建议结合流量镜像、TLS 解密的旁路方案或升级到基于 TC/XDP 的过滤路径。

  • eBPF
  • 可观测性
  • 性能剖析
  • 网络

【免费下载链接】bcc

BCC - Tools for BPF-based Linux IO analysis, networking, monitoring, and more

项目地址:https://gitcode.com/gh_mirrors/bc/bcc
点击查看免费下载

相关推荐

上一篇:LongCat-Flash-Thinking-ZigZag开发者手册:FlashMLA与流式稀疏注意力接口使用教程
下一篇:SeaQwen2-0.5B硬件优化终极指南:如何在NPU和CPU上实现最佳推理速度

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询