前情提要
在 上一篇PCPP使用记录 中,我记录了PcapPlusPlus这个库的环境安装和简单使用,紧接着,就准备基于这个库来实现一些具体的、有用的功能了。
数据包解析
PCPP这个库将一个原始数据包解析为若干层,每一层的协议信息由一个变量来保存,我们可以自由读写这些解析后的数据。
一个 RawPacket
表示原始的字节流,也就是我们最开始从 pcap
文件中读进来的一个数据包,经过解析,可以将这个数据包拆分成我们熟悉的若干层数据!PCPP的一个特性是它不保存多个副本,而只是在同一个数据包上标记各层协议的起点,这些起点可以由上一层解析结果访问到。
例如这个图中的解析结果,首先是数据链路层的 Ethernet Layer
,它可以看到所有原始数据;由 Ethernet Layer
层扣除它的头部数据,就是整个 IPv4
层的数据;而由 IPv4
层再继续解析,就是 UDP
层啦!这样层层递推,实际上跟学习计算机网络的时候对数据包的解析顺序差不多。
PCPP提供的数据包解析方法有两种,我们分别来看。
首先还是需要先创建一个 reader
,如果你还记得第一章的内容,那就很简单了:
#include <iostream>
#include <IPv4Layer.h>
#include <Packet.h>
#include <PcapFileDevice.h>
int main(int argc, char* argv[])
{
// Part 1
// open a pcap file for reading
pcpp::IFileReaderDevice* reader = pcpp::IFileReaderDevice::getReader("test_file_1.pcap");
if (!reader)
{
std::cerr << "Cannot determine reader for file type" << std::endl;
return 1;
}
if (!reader->open())
{
std::cerr << "Cannot open input.pcap for reading" << std::endl;
return 1;
}
// read the first packet from the file
pcpp::RawPacket rawPacket;
if (!reader->getNextPacket(rawPacket))
{
std::cerr << "Couldn't read the first packet in the file" << std::endl;
return 1;
}
// ===== Code to write =====
reader->close();
delete reader;
return 0;
}
上面是程序的基本框架,已经写好了创建Reader、读取第一个数据包 rawPacket
的部分。接下来的所有代码都追加在 Code to write 那块地方~
首先需要创建一个解析后的数据包:
// parse the raw packet into a parsed packet
pcpp::Packet parsedPacket(&rawPacket);
在上面的介绍中,我们知道这个解析后的数据包是一层一层的结构,每一层都有一个指向下一层的指针,于是我们可以使用一个循环来遍历这些层:
// first let's go over the layers one by one and find out its type, its total length, its header length and its payload length
for (pcpp::Layer* curLayer = parsedPacket.getFirstLayer(); curLayer != NULL; curLayer = curLayer->getNextLayer())
{
// Code to write
}
在这个循环中呢, curLayer
就是当前获取到的层了!假如说我们需要使用到TCP层的信息,那就需要用API获取一下当前层的协议,然后判断 curLayer->getProtocol() == pcpp::TCP
,真是有点麻烦!
好在PCPP为我们提供了第二种获取协议层的方法:
pcpp::IPv4Layer* ipLayer = parsedPakcet.getLayerOfType<IPv4Layer>();
直接使用 getLayerOfType
这个接口来获取我们想要的层,很酷!
在我接下来的场景中,使用到的是网络层的IP地址和传输层的端口,那么就只需要:
pcpp::TcpLayer* tcpLayer = parsedPakcet.getLayerOfType<TcpLayer>();
pcpp::IPv4Layer* ipLayer = parsedPakcet.getLayerOfType<IPv4Layer>();
方便得很!
当我们获取到某个特定的层之后,就可以来使用这一层的信息了。在VS环境中有自动补全,使用 tcpLayer->getxxxxxx()
这样的格式,一般就能看到这一层包含的信息。例如本次使用较多的有:
IPv4Address ipLayer->getSrcIPv4Address();
uint16_t tcpLayer->getSrcPort();
tcphdr* tcpLayer->getTcpHeader()->synFlag
这几个API从名字上就很容易看出它们是什么作用!如果想进行更多的解析,可以看参考资料 [1] 和参考资料 [3] 😁
并不成功的会话切分
所谓 会话分割 ,就是说给出一个很大的 pcap
文件,里面有超级多的数据包,我们要把它按照一个个的TCP会话(或者叫TCP连接,whatever)整理好!
这一工作很有意义!TCP会话一般是数据传输的一个基本单元,它以三次握手开始、以四次挥手结束。譬如以前的HTTP协议,传输一个文件就使用一个TCP会话,即使引入了长连接,也是在一个相对完整的语境中(如一个网页的加载)使用一次TCP会话,所以一个TCP会话的背后可能就是用户的一次网络行为!很多基于网络流量的行为分析技术都是基于TCP会话的,学者们提取TCP会话的各种特征信息,然后使用各种模型,希望把网络流量跟它背后的用户行为对应起来 😹 我们今天还做不到这么多啦,但是从 pcap
文件中整理出一个个TCP会话来,还是可以试试的~
实验目标:整理出会话的开始时间和结束时间,可以顺便计算一下时长 。
All right?先讲思路,再说不足,最后慢慢改进。
我基本的思路是使用一个 四元组 来保存连接信息。熟悉TCP的朋友应该知道,一个TCP连接可以由 (SrcIP, DstIP, SrcPort, DstPort)
这个元组唯一确定,其实就是两端的 Socket 啊!
这么一个元组怎么储存呢?我的做法是进行一个简单的字符串拼接:
string keyTuple =
ipLayer->getSrcIPv4Address().toString() + " " +
ipLayer->getDstIPv4Address().toString() + " " +
to_string(tcpLayer->getSrcPort()) + " " +
to_string(tcpLayer->getDstPort());
我们要做的是 整理出会话的开始时间和结束时间 ,还记得吗?
我最初的想法是:对每个数据包进行分析,如果数据包中包含 SYN
,说明它是会话的开始,放入一个 map
中;如果数据包中包含 FIN
,说明它是会话的结束,依据这个数据包的四元组从 map
中获取开始时间,然后使用当前数据包的时间作为结束时间,打印!
总的代码为:
#include <iostream>
#include <IPv4Layer.h>
#include <TcpLayer.h>
#include <Packet.h>
#include <PcapFileDevice.h>
#include <set>
#include <map>
#include <ctime>
#include <cstdlib>
#include <iomanip>
using namespace pcpp;
using namespace std;
#define MY_FORMAT setw(18) << setiosflags(ios::left)
IFileReaderDevice* openFileReader(string fileName)
{
IFileReaderDevice* openedReader = IFileReaderDevice::getReader(fileName);
if (!openedReader)
{
cerr << "Cannot determine reader for file type" << endl;
exit(EXIT_FAILURE);
}
if (!openedReader->open())
{
cerr << "Cannot open input.pcap for reading" << endl;
exit(EXIT_FAILURE);
}
return openedReader;
}
void printSplitedTuple(string s)
{
int prevFind = -1;
int nowFind;
while ((nowFind = s.find(" ", prevFind + 1)) != string::npos)
{
cout << MY_FORMAT << s.substr(prevFind + 1, nowFind - prevFind);
prevFind = nowFind;
}
cout << MY_FORMAT << s.substr(prevFind + 1);
}
int main(int argc, char* argv[])
{
// open a pcap file for reading
string pcapFile = "test_file_1.pcap";
IFileReaderDevice* reader = openFileReader(pcapFile);
if (!reader->setFilter("tcp"))
{
cerr << "Cannot set filter for file reader" << endl;
exit(EXIT_FAILURE);
}
RawPacket rawPacket;
multimap<string, timespec> sessionMap;
auto BEGIN = clock();
while (reader->getNextPacket(rawPacket))
{
Packet parsedPakcet(&rawPacket);
TcpLayer* tcpLayer = parsedPakcet.getLayerOfType<TcpLayer>();
IPv4Layer* ipLayer = parsedPakcet.getLayerOfType<IPv4Layer>();
string keyTuple =
ipLayer->getSrcIPv4Address().toString() + " " +
ipLayer->getDstIPv4Address().toString() + " " +
to_string(tcpLayer->getSrcPort()) + " " +
to_string(tcpLayer->getDstPort());
if (tcpLayer->getTcpHeader()->synFlag == 1)
{
sessionMap.insert(pair<string, timespec>(keyTuple, rawPacket.getPacketTimeStamp()));
}
if (tcpLayer->getTcpHeader()->finFlag == 1)
{
auto sessionStart = sessionMap.find(keyTuple);
if (sessionStart != sessionMap.end())
{
cout << MY_FORMAT << "Src" << MY_FORMAT << "Dst"
<< MY_FORMAT << "SrcPort" << MY_FORMAT << "DstPort"
<< MY_FORMAT << "Start" << MY_FORMAT << "End"<< MY_FORMAT << "Duration"
<< endl;
printSplitedTuple(keyTuple);
double startTime = sessionStart->second.tv_sec + (double)sessionStart->second.tv_nsec / 1e9;
double endTime = rawPacket.getPacketTimeStamp().tv_sec + (double)rawPacket.getPacketTimeStamp().tv_nsec / 1e9;
cout << MY_FORMAT << sessionStart->second.tv_sec
<< MY_FORMAT << rawPacket.getPacketTimeStamp().tv_sec
<< MY_FORMAT << setprecision(5) << (endTime - startTime)
<< endl;
sessionMap.erase(sessionStart);
}
}
}
auto END = clock();
cout << "Total time: " << double(END - BEGIN) / CLK_TCK * 1000 << "ms." << endl;
reader->close();
delete reader;
return 0;
}
上面的代码使用了 rawPacket.getPacketTimestamp()
来获取数据包的时间戳,返回结果是一个 timespec 类型!这个类型表示从日历起点到现在所经过的秒数,它的第一部分是 秒 ,第二部分是 纳秒 ,我使用了 秒 来打印会话开始时间和结束时间,使用了 纳秒 来计算会话持续时间。
此外,为了输出结果的美观,我定义了一个 MY_FORMAT
宏,将输出的字符串指定为 18
个宽度,方便对齐。
这次的运行花了5秒多!结果看着蛮厉害,其实问题有很多 😢 这些问题是在我基本上完成了这个程序之后,逐渐意识到的。
元组的表示问题
首先是那个四元组的问题。我们说一个TCP会话是由一个元组确定的,这自然是没有错,但是在编程实现中,我把这四个元素简单地做了字符串拼接,这就带来问题了!本来 (SrcIP, DstIP, SrcPort, DstPort)
这四个元素的顺序是可以倒换的,也就是它跟 (DstIP, SrcIP, DstPort, SrcPort)
是同一个东西啊!更加具体地说,本来由 192.168.0.102
发往 1.1.1.1
的会话是双向的,也就是说 (192.168.0.102, 1.1.1.1, 4321, 443)
这么一个元组表示主机发往服务器的数据,而 (1.1.1.1, 192.168.0.102, 443, 4321)
表示的是服务器发往主机的数据,这两个元组表示的是同一个会话!而我 愚蠢地 做了字符串拼接,而且用这个拼接后的字符串来作为 map
的 key
。这种 key
表示出来的东西根本就不全啊?!会使得我们在取得一个 FIN
包时,只能找到 同向的 SYN
包,也就是说,我们整理出来的会话,只能是 由主机发起、由主机断开 或者是 由服务器发起、由服务器断开 的会话,至于 由主机发起、由服务器断开 的会话,或者 由服务器发起、由主机断开 的会话,就整理不到了。
会话起止的问题
即便解决了元组的表示问题,还有会话起止的问题没有考虑到。
TCP的三次握手和四次挥手中,会产生 两个SYN包、两个FIN包 !这是我在编程过程中遗漏的知识点,反应过来之后,头痛不已……
会话的起止,应该表示为第一个 SYN
和最后一个 FIN
,第一个 SYN
是不含 ack
的,而最后一个 FIN
是在同一个元组下出现的第二个 FIN
。
这样的算法 不考虑超时重传的情况、不考虑最后一个 ack
的传输、不考虑连接断开之前的 2MSL
等待 。
感觉误差挺大的样子,不过这样的误差会作用到每一个会话上,相对来说是可以接受的。
比较成功的会话切分
在上面部分的代码宣告失败后,我翻看PCPP的 示例应用 ,意外地发现了 PcapSplitter 这个好东西,作者已经初步实现了按照会话切分 pcap
文件的功能。
不过,作者的代码只能作为参考,因为他是将一个大的 pcap
文件按照一定规则切分为若干小的 pcap
文件,跟我的主要需求不太一致。
当我阅读 ConnectionSplitter.h 这个文件时,发现了一个重要的函数: hash5Tuple()
。
有没有搞错?!搞了大半天的用四元组来表示一个会话,结果这个库自己就能够处理元组哈希?而且参数简单得要死,传入一个 parsedPacket
即可。
行叭,就用这个函数来替代之前的愚蠢的元组表示方式,同时完善一下会话起止的判定方法,改写一下会话切分代码:
#include <iostream>
#include <IPv4Layer.h>
#include <TcpLayer.h>
#include <Packet.h>
#include <PcapFileDevice.h>
#include <PacketUtils.h>
#include <set>
#include <map>
#include <vector>
#include <ctime>
#include <cstdlib>
#include <iomanip>
using namespace pcpp;
using namespace std;
#define MY_FORMAT setw(18) << setiosflags(ios::left)
IFileReaderDevice* openFileReader(string fileName)
{
IFileReaderDevice* openedReader = IFileReaderDevice::getReader(fileName);
if (!openedReader)
{
cerr << "Cannot determine reader for file type" << endl;
exit(EXIT_FAILURE);
}
if (!openedReader->open())
{
cerr << "Cannot open input.pcap for reading" << endl;
exit(EXIT_FAILURE);
}
return openedReader;
}
int main(int argc, char* argv[])
{
// open a pcap file for reading
string pcapFile = "test_file_1.pcap";
IFileReaderDevice* reader = openFileReader(pcapFile);
if (!reader->setFilter("tcp"))
{
cerr << "Cannot set filter for file reader" << endl;
exit(EXIT_FAILURE);
}
RawPacket rawPacket;
map<uint32_t, timespec> sessionMap;
map<uint32_t, int> finCount;
int outputCount = 0;
auto BEGIN = clock();
while (reader->getNextPacket(rawPacket))
{
Packet parsedPakcet(&rawPacket);
TcpLayer* tcpLayer = parsedPakcet.getLayerOfType<TcpLayer>();
IPv4Layer* ipLayer = parsedPakcet.getLayerOfType<IPv4Layer>();
uint32_t keyTuple = hash5Tuple(&parsedPakcet);
if (tcpLayer->getTcpHeader()->synFlag == 1 && tcpLayer->getTcpHeader()->ackFlag == 0)
{
sessionMap[keyTuple] = rawPacket.getPacketTimeStamp();
}
if (tcpLayer->getTcpHeader()->finFlag == 1 || tcpLayer->getTcpHeader()->rstFlag == 1)
{
if (finCount.find(keyTuple) != finCount.end())
{
finCount[keyTuple]++;
}
else
{
finCount[keyTuple] = 1;
}
auto sessionStart = sessionMap.find(keyTuple);
if (finCount[keyTuple] == 2 && sessionStart != sessionMap.end())
{
cout << MY_FORMAT << "Src" << MY_FORMAT << "Dst"
<< MY_FORMAT << "SrcPort" << MY_FORMAT << "DstPort"
<< MY_FORMAT << "Start" << MY_FORMAT << "End"<< MY_FORMAT << "Duration"
<< endl
<< MY_FORMAT << ipLayer->getSrcIPv4Address().toString()
<< MY_FORMAT << ipLayer->getDstIPv4Address().toString()
<< MY_FORMAT << tcpLayer->getSrcPort()
<< MY_FORMAT << tcpLayer->getDstPort();
double startTime = sessionStart->second.tv_sec + (double)sessionStart->second.tv_nsec / 1e9;
double endTime = rawPacket.getPacketTimeStamp().tv_sec + (double)rawPacket.getPacketTimeStamp().tv_nsec / 1e9;
cout << MY_FORMAT << sessionStart->second.tv_sec
<< MY_FORMAT << rawPacket.getPacketTimeStamp().tv_sec
<< MY_FORMAT << setprecision(5) << (endTime - startTime)
<< endl;
sessionMap.erase(sessionStart);
outputCount++;
}
}
}
auto END = clock();
cout << "Total time: " << double(END - BEGIN) / CLK_TCK * 1000 << "ms." << endl;
cout << "Total sessions: " << outputCount << endl;
reader->close();
delete reader;
return 0;
}
这次代码作出的改变主要有:
- 使用
uint32_t keyTuple = hash5Tuple(&parsedPakcet);
来表示会话元组,既节省空间(原先的String
超级大)又节省时间(把String
当做键值,比较起来很慢); - 重新考虑会话起止的算法。会话起点为带有
SYN
但不带ack
的包,会话终点为第二个FIN
包;在后期观察时,发现 会话终点还可以是RST
包 。
结果不坏。从时间上看,基本上比之前的切分方式快了一倍。
与WireShark统计结果的对比
程序计算出来的结果是 163
个完整会话,而WireShark的统计结果是 221
个会话:
但是将这些会话按照字节大小排序,跟踪前面的比较小的会话,发现这些会话并不完整:
我推测WireShark仅仅是根据五元组来进行统计,根本没有去考虑会话的完整性。如果我们使用 hash5Tuple()
,计算出来的每一个 uint_32
值都表示一个会话,那么结果跟WireShark的统计应该是一样的。
简单在代码中添加一个记录这些五元组的集合 set<uint32_t> tupleSet;
,然后每计算一个哈希值就往里放,最后打印一下:
果不其然!
WireShark这种统计方式没什么用啦!一个没头没尾的数据段能代表什么呢?还是按照之前提到的方法来表示一个会话,这样精确一点!
参考资料
[1] 4. Packet Parsing - PcapPlusPlus
[2] 1. Introduction - PcapPlusPlus
[3] PcapPlusPlus: API Documentation
[4] 一站式学习Wireshark(七):Statistics统计工具功能详解与应用 - zhuimeng~ - 博客园 (cnblogs.com)
[5] PcapPlusPlus/Examples/PcapSplitter at master · seladb/PcapPlusPlus (github.com)