22| 手写一个迷你tcpdump

22| 手写一个迷你tcpdump tcpdump 和 Wireshark 是网络问题定位中的神器尽管它们已经被使用了多年许多人仍然不了解它们是如何实现抓包的。这篇文章将介绍 tcpdump 背后的底层实现并展示如何模拟 tcpdump 的部分格式来抓取 8080 端口的包。最终效果如下192.168.1.2.59043 192.168.1.4.8080 Flags [S], seq 2188010653, ack 0, win 65535, length 0 192.168.1.4.8080 192.168.1.2.59043 Flags [S.], seq 500839884, ack 2188010654, win 65160, length 0 192.168.1.2.59043 192.168.1.4.8080 Flags [.], seq 2188010654, ack 500839885, win 2058, length 0 192.168.1.2.59043 192.168.1.4.8080 Flags [P.], seq 2188010654, ack 500839885, win 2058, length 80 192.168.1.4.8080 192.168.1.2.59043 Flags [.], seq 500839885, ack 2188010734, win 509, length 0为了实现上述功能我们需要一些前置知识首先需要了解原始套接字Raw Socket的概念。原始套接字Raw Socket我们来看创建套接字的socket系统调用的函数签名#include sys/socket.h int socket(int domain, int type, int protocol);其中第二个参数type表示了 socket 的类型我们最常使用的套接字类型是流式 SocketSOCK_STREAM收发 TCP 报文数据报 SocketSOCK_DGRAM收发面向无连接的 UDP 报文但如果想开发更底层的应用比如发送自定义的 IP 报文、伪造 IP、捕获流经本机网卡的数据报文使用这两种套接字就无法实现了。为了实现这些更底层的操作就需要使用原始套接字Raw Socket)它的 type 为 SOCK_RAW。第一个参数表示套接字使用的协议族 domain比如常见的 IPv4AF_INET以及 IPv6AF_INET6原始套接字的协议族为 AF_PACKET(或 PF_PACKET)。第三个参数 protocol 用于指定要接收的协议类型在抓包的场景中可以传入 ETH_P_ALL 让原始套接字接收到网卡上所有的数据链路层数据帧从而进行网络数据包捕获和分析。原始套接字Raw Socket的读写创建完 Raw Socket接下来就是对这个 socket 进行读写。Raw Socket 可以认为是无连接的因为它的编程方式和 UDP 差不多recvfrom和sendto系统调用在一个原始 Raw Socket 上接收和发送数据报。抓包的场景是从 Raw Socket 读取数据我们来重点看一下 recvfrom 函数。ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen);其中 buf 用来指定读取的数据缓冲区len 表示缓冲区的长度src_addr 用来获取对端地址如果不关心可以设置为 NULLaddrlen 表示 src_addr 结构体的长度。实战抓包先来定义以太网帧、IP 帧、TCP 帧的数据结构#define ETHER_ADDR_LEN 6 // 以太网帧头部结构体 struct EthernetHeader { u_char ether_dhost[ETHER_ADDR_LEN]; /* 目的以太网地址 */ u_char ether_shost[ETHER_ADDR_LEN]; /* 源以太网地址 */ u_short ether_type; /* 以太网类型 */ }; // IPv4 头部 struct IPv4Header { u_char ip_vhl; /* 版本号和头部长度(in words) */ u_char ip_tos; /* 服务类型 */ u_short ip_len; /* 总长度 */ u_short ip_id; /* 标识 */ u_short ip_off; /* 片偏移 */ u_char ip_ttl; /* 生存时间 */ u_char ip_p; /* 协议 */ u_short ip_sum; /* 校验和 */ struct in_addr ip_src; /* 源地址 */ struct in_addr ip_dst; /* 目的地址 */ }; // TCP 头部 struct TCPHeader { u_short th_sport; /* 源端口号 */ u_short th_dport; /* 目的端口号 */ u_int th_seq; /* 序列号 */ u_int th_ack; /* 确认号 */ u_char th_offset; /* 数据偏移 */ u_char th_flags; /* TCP flags */ u_short th_win; /* 窗口大小 */ u_short th_sum; /* 校验和 */ u_short th_urp; /* 紧急数据偏移量 */ };这样我们就可以把读到的数据直接映射到结构体上如下所示:char buf[2048]; ssize_t n recvfrom(sock_fd, buf, sizeof(buf), 0, nullptr, nullptr); auto *eth_header (struct EthernetHeader *) buf; auto *ipv4_header (IPv4Header *) (buf sizeof(EthernetHeader)) auto *tcp_header (TCPHeader *) (buf sizeof(EthernetHeader) sizeof(IPv4Header));接下来我们写一个 helper 函数用来解析tcp_flags:std::string tcp_flags(u_char flags) { std::string result; if (flags TH_FIN) { result F; } if (flags TH_SYN) { result S; } if (flags TH_RST) { result R; } if (flags TH_PUSH) { result P; } if (flags TH_URG) { result U ; } if (flags TH_ACK) { result .; } return result; }这里过滤一下包只打印端口号为 8080 的 tcp 请求完整的代码如下int main() { int sock_fd socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)); if (sock_fd 0) { std::cerr Failed to create socket std::endl; return 1; } while (true) { char buf[2048]; struct sockaddr addr; socklen_t addr_len sizeof(addr); ssize_t n recvfrom(sock_fd, buf, sizeof(buf), 0, nullptr, nullptr); if (n 0) { std::cerr Failed to receive data std::endl; continue; } auto *eth_header (struct EthernetHeader *) buf; ushort ether_type ntohs(eth_header-ether_type); if (ether_type ETHERTYPE_IP) { auto *ipv4_header (IPv4Header *) (buf sizeof(EthernetHeader)); if (ipv4_header-ip_p IPPROTO_TCP) { auto *tcp_header (TCPHeader *) (buf sizeof(EthernetHeader) sizeof(IPv4Header)); u_char tcp_header_length (tcp_header-th_offset 4) * 4; if (ntohs(tcp_header-th_dport) 8080 || ntohs(tcp_header-th_sport) 8080) { uint16_t tcp_header_len (tcp_header-th_offset 4) * 4; std::cout inet_ntoa(ipv4_header-ip_src) . ntohs(tcp_header-th_sport) inet_ntoa(ipv4_header-ip_dst) . ntohs(tcp_header-th_dport) Flags [ tcp_flags(tcp_header-th_flags) ], seq ntohl(tcp_header-th_seq) , ack ntohl(tcp_header-th_ack) , win ntohs(tcp_header-th_win) , length n - sizeof(EthernetHeader) - sizeof(IPv4Header) - tcp_header_length std::endl; } } } } }其中有一个值得注意的是tcp_header_length的计算方式u_char tcp_header_length (tcp_header-th_offset 4) * 4;这是因为 TCP 头中的 data offset 的值在高四位以 32-bit words 的形式来指定头部的长度。我们来编译运行一下上面的代码$ g minidump.cpp -o minidump $ sudo ./minidump接下来启动一个 tcp server监听 8080 端口这里启动一个静态文件服务器使用 curl 访问一下就可以在 minidump 的输出中看到握手、发送数据和挥手的过程了。内核层面是如何处理的前面提到的都是应用层的注册行为那内核是如何处理的呢这就要提到ptype_all链表定义在net/core/dev.c文件中struct list_head ptype_all;链表元素类型为packet_typestruct packet_type { __be16 type; struct net_device *dev; int (*func) (struct sk_buff *, struct net_device *, struct packet_type *, struct net_device *); // ...omit some... struct list_head list; };其中type 表示需要匹配的 Ethernet 协议类型比如我们前面介绍的ETH_P_ALL表示所有的协议类型。dev表示需要处理的网络设备NULL 表示所有网络设备func表示当收到包时的回调函数list 指向链表头当网络设备收到一个包时会调用net/core/dev.c文件中的__netif_receive_skb_core函数内核会遍历ptype_all链表调用deliver_skb将数据包发送给注册的抓包进程。static int __netif_receive_skb_core(struct sk_buff **pskb, bool pfmemalloc, struct packet_type **ppt_prev) // .. list_for_each_entry_rcu(ptype, ptype_all, list) { if (pt_prev) ret deliver_skb(skb, pt_prev, orig_dev); pt_prev ptype; } // .. }当使用socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL))创建一个原始套接字时内核会将对应的packet_type结构体插入到ptype_all链表中。从这里我们能更加明显的感受到应用层能实现多少功能还得靠内核能开放多少能力。小结这篇文章介绍了抓包程序的实现原理我们需要了解 Raw Socket 编程、如何比较方便的解析各层的网络包最后如果你感兴趣你可以再深入研究一下内核底层的 ptype_all 链表是如何为 tcpdump 等抓包程序服务的。通过这些知识我们可以更好地理解和使用 tcpdump 和 Wireshark 进行网络问题的定位和分析。