您现在的位置是:首页 >学无止境 >【计网 从头自己构建协议】一、libpcap 介绍 & 手撕以太网帧网站首页学无止境

【计网 从头自己构建协议】一、libpcap 介绍 & 手撕以太网帧

XcantloadX 2023-07-09 08:00:03
简介【计网 从头自己构建协议】一、libpcap 介绍 & 手撕以太网帧

上一篇:IndexError: list index out of range
下一篇:[【计网 从头自己构建协议】二、收发 ARP 请求帧与响应帧]

介绍

理论的学习总是枯燥的,想要加深对理论的理解,最好的方法就是自己实践一遍。

想要亲手实现各种协议,就必须能够接触底层 API。可惜的是,底层的 API 要么是在驱动里,要么是在系统里,都不对外开放,一般只能接触到运输层的 TCP/UDP。我们必须借助第三方库才能实现对底层操控。

libpcap 就是这样一个库,它帮我们实现了底层驱动,并将控制权向上开放,提供了发送和监听数据包的功能。著名的网络分析工具 Wireshark 就是基于这个库实现的。

libpcap 是 Linux 上的库,对应的 Windows 版本叫做 winpcap,不过这个库已经停止开发了,只支持到 Windows 8,没法运行在 Windows 10 上。npcap 在 winpcap 的基础上继续开发,添加了对新版 Windows 的支持和其他的功能。

我们下面就使用 npcap 这个库。

安装

推荐你直接安装 Wireshark,在安装 Wireshark 的同时会一并安装 npcap。所以如果你已经装了 Wireshark ,就不需要再装 npcap 本体了。

除了本体之外,我们还需要开发用的头文件和库文件
npcap 官网下载页面,找到“Downloading and Installing Npcap Free Edition”,把 installer(如果你没装 Wireshark) 和 SDK 都下载下来。

installer 是 npcap 本体,直接双击,一路 next 即可。
SDK 是个压缩包,找个地方解压即可。路径最好要短,不要有中文。推荐专门新建一个文件夹“SDK”来放开发包。
例如“D:SDK pcap-sdk-1.13”

配置

Visual Studio

先确保项目配置是 x64 的,如果不是,先切换到 x64 再继续下面的步骤。
工具栏

打开菜单栏 项目 -> XXX 属性,后面的设置都在这里面进行。
C/C++ -> 常规,右边的附加包含目录,添加一条记录 npcap-sdk-1.13Include。(自行把路径补充完整)
连接器 -> 常规,右边的附加库目录,添加一条记录 npcap-sdk-1.13Libx64。(自行把路径补充完整)
链接器 -> 输入,右边的附加依赖项,在最前面加上 Ws2_32.lib;wpcap.lib;
链接器 -> 输入,右边的延迟加载的 DLL,填入 wpcap.dll

一些前置知识

由于之后我们要经常要在比特和字节之间转换,为了方便,后面不用 charintlong,而用 int8_tint16_tint32_t。其中 int 后面的数字表示比特长度。比如 int8_t 长 8 比特,也就是 1 字节。
需要先 #include <inttypes.h> 才能使用。

此外,数据在内容中的储存分大小端
例如 0x0800 这个数字,长 2 字节,以 1 字节为单位分割,结果是 0x08 0x00。
但是计算机内存中不一定是这样储存的,可能是 0x08 0x00,也可能是 0x00 0x08。就好像以前中文的书写顺序是从右往左,而现在是从左往右,如果看的顺序不对,什么都读不出来。

解决方法是统一网络使用的字节顺序,并额外再写几个函数在转换本地机器字节顺序和网络字节顺序之间转换。

htonl() //"Host to Network Long"
ntohl() //"Network to Host Long"
htons() //"Host to Network Short"
ntohs() //"Network to Host Short"   

h 代表 “host 主机”,n 代表 “network 网络”,to 代表“到”,l 代表“long”类型,s代表“short”类型。
例如 htons() 就是把主机字节序转换为网络字节序,传入的参数是 short 类型。

发送数据

发送数据包用的是 int pcap_sendpacket(pcap_t *p, const u_char *buf, int size); 这个函数。
数据包将会直接原封不动地发送到数据链路层上去,也就是说,我们需要自行构造各种协议的头

构造以太网帧

先从以太网开始。

在发送数据之前,我们得自己起一个函数来帮助我们构造以太网头。
至于以太网帧的格式是什么,自己去翻书
可以参考 Wireshark 的 Wiki 页面 或者 维基百科,直接看 packet format 部分即可。

适配器已经帮我们搞好了帧开始之前 010101… 时钟同步信号、帧开始处的定界符、帧结束处的 FCS 帧检验序列,我们只需要搞定核心部分即可。

int make_ethernet_packet(
	uint8_t target_address[6], // 目的 MAC 地址
	uint8_t source_address[6], // 源 MAC 地址
	int16_t ethertype, // 以太网类型(DIX Ethernet II)/帧长(IEEE 802.3)
	uint8_t *payload, // 数据部分
	size_t payload_length, // 数据长度(比特数)
	uint8_t **data, // 返回值:以太网帧。由调用者释放内存。
	size_t *data_length // 返回值:以太网帧长度(字节数)
)
{
	// 计算总长度并分配内存
	*data_length = 6 + 6 + 2 + payload_length;

	// 最大帧长 1500-4 字节
	if (*data_length > 1496)
		return -2;

	// 最小帧长 64-4(FCS长度)-6-6-2(头长度)=46 字节
	// 不够要填充额外数据
	if (*data_length < 46)
	{
		int8_t *new_payload = calloc(60, sizeof(int8_t));
		memcpy(new_payload, payload, payload_length);
		// 覆盖掉原始 payload
		payload = new_payload;
		payload_length = 46;
		// 重新计算 data_length
		*data_length = 6 + 6 + 2 + payload_length;
	}

	*data = calloc(*data_length, sizeof(int8_t));
	if (data == NULL)
		return -1;

	// 处理大小端问题
	ethertype = htons(ethertype);

	// 填充头部
	memcpy(*data, target_address, 6);
	memcpy(*data + 6, source_address, 6);
	memcpy(*data + 12, &ethertype, sizeof(ethertype));

	// 填充数据
	memcpy(*data + 14, payload, payload_length);

	return 0;
}

有几点注意的事项:

  1. 以太网对帧长有要求。最小帧长是 64 字节,最大帧长是 1500 字节,两个都包括 FCS 在内。
    由于我们不需考虑 FCS,所以最小帧长是 60 字节,最大帧长是 1496 字节。
    长度不够的时候要往里面填 0,直到长度够了为止。
  2. ethertype 这个字段在 DIX Ethernet II 和 IEEE 802.3 两个标准里的含义不同。这里我们采用第一个标准。

再定义几段宏,方便使用。

// 合成 MAC 地址(只能是常量)
#define MAC(aa, bb, cc, dd, ee, ff) (int8_t[6]) { 0x##aa, 0x##bb, 0x##cc, 0x##dd, 0x##ee, 0x##ff }

#define ETHERNET_TYPE_IPV4 0x0800 // 以太网类型 IPv4
#define ETHERNET_TYPE_ARP 0x0806 // 以太网类型 ARP

发送数据

在调用 pcap_sendpacket 发送数据之前,我们还需要一些准备工作。
见下面的代码。

// common.c
#ifdef _MSC_VER
#define _CRT_SECURE_NO_WARNINGS
#endif

#include <stdlib.h>
#include <string.h>

#include "common.h"

#ifdef _WIN32
#include <tchar.h>
// 加载 Npcap DLL。需要在调用任何 pcap 函数之前调用先本函数。
int LoadNpcapDlls()
{
	_TCHAR npcap_dir[512];
	UINT len;
	len = GetSystemDirectory(npcap_dir, 480);
	if (!len) {
		fprintf(stderr, "Error in GetSystemDirectory: %x", GetLastError());
		return 0;
	}
	_tcscat_s(npcap_dir, 512, _T("\Npcap"));
	if (SetDllDirectory(npcap_dir) == 0) {
		fprintf(stderr, "Error in SetDllDirectory: %x", GetLastError());
		return 0;
	}
	return 1;
}
#endif

// 按点分十进制输出 IPv4 地址
void pretty_print_ipv4(uint8_t ip[4])
{
	printf("%d.%d.%d.%d", ip[0], ip[1], ip[2], ip[3]);
}

// 输出 MAC 地址
void pretty_print_mac(uint8_t mac[6])
{
	printf("%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]);
}

int main()
{
	char error_buffer[PCAP_ERRBUF_SIZE]; // 用于储存错误信息

	// 载入 DLL
#ifdef _WIN32
	if (!LoadNpcapDlls())
	{
		fprintf(stderr, "无法加载 Npcap。
");
		return -1;
	}
#endif

	// 初始化
	if (pcap_init(PCAP_CHAR_ENC_LOCAL, error_buffer) != 0) {
		printf("初始化 pcap 库失败: %s
", error_buffer);
		return -2;
	}

	// 获取所有适配器并让用户选择
	pcap_if_t *devices;
	char err_buffer[1024]; // 用来储存错误信息
	if (pcap_findalldevs(&devices, err_buffer) == -1)
	{
		printf("pcap_findalldevs 时出错:%s
", err_buffer);
		exit(1);
	}

	// 打印所有适配器名称(devices 是个链表)
	int i = 0;
	for (pcap_if_t* device = devices; device; device = device->next)
	{
		i++;
		printf("[%d] %s | 名称=%s
", i, device->description, device->name);
	}

	int choice = 0;
	printf("请选择一个适配器:");
	scanf("%d", &choice);

	// 找到选中的适配器
	pcap_if_t* device = devices;
	for (int i = 0; i != choice - 1; i++)
	{
		device = device->next;
	}
	// 打开它
	pcap_t *pcap = pcap_open_live(
		device->name, // 适配器名称
		0, // 
		0,
		1000, // 超时时间,毫秒
		error_buffer
	);
	if (pcap == NULL)
	{
		printf("打开适配器 %s 失败。
", device->description);
		return -3;
	}

	// 打开之后要释放之前的适配器列表
	pcap_freealldevs(devices);

	// 检查数据链路层协议
	int datalink_type = pcap_datalink(pcap);
	if (datalink_type != DLT_EN10MB) // 10Mb 以上的以太网
	{
		printf("不支持的数据链路层协议。");
		return -4;
	}

	int ret = network_main(pcap);
	pcap_close(pcap);
	system("pause");
	return ret;
}
// common.h
#pragma once

#include <inttypes.h>
#include <pcap.h>

// 加载 Npcap DLL。需要在调用任何 pcap 函数之前调用先本函数。
int LoadNpcapDlls();
// 按点分十进制输出 IPv4 地址
void pretty_print_ipv4(uint8_t ip[4]);
// 输出 MAC 地址
void pretty_print_mac(uint8_t mac[6]);

// 程序入口
int network_main(pcap_t *pcap);

具体每一段的含义在注释里已经写的比较清晰了。

上面这段代码是模板,后面我们还会多次用到。方便起见,把 main 函数再封装一下,增加一个 network_main() 函数,模板代码放到 main 里,实际代码放到 network_main,这样可以屏蔽繁琐的细节。

下面利用刚刚写的函数来构造数据帧并发送。

int network_main(pcap_t *pcap)
{
	// 构造以太网帧
	uint8_t payload[29] = "Hello world from MyEthernet!"; // 要发送的数据
	uint8_t *data = NULL;
	size_t data_length = 0;

	int ret = make_ethernet_packet(
		MAC(ff, ff, ff, ff, ff, ff), // 目的地址,广播帧
		MAC(6, 6, 6, 6, 6, 6), // 源地址
		ETHERNET_TYPE_IPV4, //IPv4
		payload,
		sizeof(payload) / sizeof(int8_t),
		&data,
		&data_length
	);

	if (ret != 0)
	{
		printf("创建以太网帧失败");
		return -5;
	}

	if (pcap_sendpacket(pcap, data, (int)data_length) != 0) // 返回值不为 0 表示出错
		printf("发送数据包时出错:%s
", pcap_geterr(pcap));
	free(data); // 最后发完了记得 free 掉
	return 0;
}

测试

打开 Wireshark,开始抓包。
运行程序,选择合适的适配器,然后回来 Wireshark,停止抓包。
在最上面的过滤栏里填上 eth.dst==ff:ff:ff:ff:ff:ff
过滤
然后就能看到我们刚刚发的以太网帧了。
抓包结果
源地址全是 06,目标地址是 FF 广播地址,内容也是我们刚刚写的 hello world。
左边有一条是红色的,意思是 IP 数据报部分受损。我们刚刚类型里填的是 IPv4,所以这里显示的是 IPv4,但是数据部分是乱写的,显示受损也不意外,不用在意。

另外,如果收到了到错误信息 “连到系统上的设备没有发挥作用(-31)” 也不用慌张,只要在 Wireshark 里能看到我们发的数据就没问题,这是库本身的问题,不是我们的问题。

继续测试

我们刚刚目的地址写的是广播帧,理论上其他设备也能收到消息。
接下来我们在同局域网的其他设备上也装上 Wireshark,看看能否收到我们发的帧。

如果没有其他机器,可以考虑用虚拟机,并且把网络改成“桥接模式”

下面是测试结果,第一个就是我们发的包。
虚拟机演示
光有以太网帧什么也干不了,下一篇文章我们来实现相对比较简单的 ARP 协议。

风语者!平时喜欢研究各种技术,目前在从事后端开发工作,热爱生活、热爱工作。