前言
使用Wireshark 分析网络包时,一个很常用的功能就是选中一个TCP 报文,然后查看这个TCP 报文的TCP 流,从而可以进一步分析建连是否慢了,断连是否正常等情况,那么本文就TCP 流的概念以及在Java中如何获取,做一个简单的学习。
正文
一. TCP流概念
如果去搜索引擎搜索:什么是TCP 流,那么大概率是很难得到一个有效的答案的,而在Wireshark 中,选中一个TCP 报文并单击右键时,在菜单的追踪流中可以选择到TCP流这个功能,如下所示。

当点击TCP 流后,Wireshark 会把选中的TCP 报文对应的TCP 连接的所有TCP 报文过滤出来并顺序展示,那么这里就知道了,TCP 流就是一次TCP 连接中,从连接建立,到数据传输,再到连接断开整个过程中的TCP报文集合。
那么Wireshark 凭什么可以从那么多TCP 报文中,精确的把某一条TCP 连接的TCP 报文过滤出来并顺序展示呢,其实就是基于TCP 报文的序列号和确认号。下面是TCP报文头的格式。

可以看到每个TCP 报文都有一个序列号SeqNum 和确认号AckNum,并且他们的含义如下。
- 序列号 :表示本次传输的数据的起始字节在整个TCP 连接传输的字节流中的编号。举个例子,某个TCP 报文的SeqNum 为500,然后报文长度length 为100,则表示本次传输数据的起始字节在整个TCP流中的序列号为100,并且本次传输的数据的序列号范围是500到599,根据序列号,能够将传输的数据有序的排列组合起来,以解决网络传输中的数据乱序问题;
- 确认号 :用来告诉对端本端期望下一次收到的数据的序列号,换言之,告诉对端本端已经正常接收了序列号等于AckNum之前的所有数据,根据确认号,可以解决网络传输中的数据丢包问题。
那么序列号和确认号的变化有什么规则呢,规则总结如下。
- 本次发送报文的SeqNum 等于上一次发送报文的SeqNum 加上上一次发送报文的length;
- 本次发送报文的AckNum 等于上一次接收报文的SeqNum 加上上一次接收报文的length;
- SYN 报文和FIN 报文的length默认为1,而不是0。
结合下面一张图,可以更好的理解上面的变化规则。

二. TCP流获取的Java实现
结合第一节的内容,想要获取某一个TCP 报文所属TCP 连接的TCP 流,其实就可以根据这个报文的SeqNum 和AckNum ,向前和向后查找符合序列号和确认号变化规则的报文,只要符合规则,那么这个报文就是属于TCP流的。
在Java 语言中,要实现TCP 流的获取,可以先借助io.pkts 工具把网络包先解开,然后把每个报文封装为我们自定义的Entity (io.pkts 工具包解开后的报文对象不太易用),最后就是根据序列号和确认号的变化规则,来得到某一个报文所属的TCP流。
现在进行实操,先引入io.pkts工具的依赖,如下所示。
xml
<dependency>
<groupId>io.pkts</groupId>
<artifactId>pkts-streams</artifactId>
<version>3.0.10</version>
</dependency>
<dependency>
<groupId>io.pkts</groupId>
<artifactId>pkts-core</artifactId>
<version>3.0.10</version>
</dependency>
同时自定义一个TCP 报文的Entity,如下所示。
java
/**
* TCP报文Entity。
*/
@Getter
@Setter
@AllArgsConstructor
public class TcpPackage {
/**
* 源地址IP。
*/
private String sourceIp;
/**
* 源地址端口。
*/
private int sourcePort;
/**
* 目的地址IP。
*/
private String destinationIp;
/**
* 目的地址端口。
*/
private int destinationPort;
/**
* 报文载荷长度。
*/
private int length;
/**
* ACK报文标识。
*/
private boolean ack;
/**
* FIN报文标识,
*/
private boolean fin;
/**
* SYN报文标识。
*/
private boolean syn;
/**
* RST报文标识。
*/
private boolean rst;
/**
* 序列号。
*/
private long seqNum;
/**
* 确认号。
*/
private long ackNum;
/**
* 报文到达时间戳。
*/
private long arriveTimestamp;
/**
* 报文体。
*/
private String body;
}
现在假设已经拿到了网络包对应的MultipartFile ,下面给出基于io.pkts工具解析网络包的实现,如下所示。
java
public static List<TcpPackage> parseTcpPackagesFromFile(MultipartFile multipartFile) {
List<TcpPackage> tcpPackages = new ArrayList<>();
try (InputStream inputStream = multipartFile.getInputStream()) {
GZIPInputStream gzipInputStream = new GZIPInputStream(inputStream);
Pcap pcap = Pcap.openStream(gzipInputStream);
pcap.loop(packet -> {
if (packet.hasProtocol(Protocol.TCP)) {
TCPPacket tcpPacket = (TCPPacket) packet.getPacket(Protocol.TCP);
tcpPackages.add(convertTcpPacket2TcpPackage(tcpPacket));
}
return true;
});
return tcpPackages;
} catch (Exception e) {
String message = "从网络包解析TCP报文失败";
log.error(message);
throw new RuntimeException(message, e);
}
}
上述实现中,假定网络包是gzip 的压缩格式,所以使用了GZIPInputStream 来包装网络包文件的输入流,同时因为我们要获取的是TCP 流,所以我们只处理有TCP 协议的报文,并会在convertTcpPacket2TcpPackage() 方法中完成到TcpPackage 结构的转换,convertTcpPacket2TcpPackage() 方法实现如下所示。
java
public static TcpPackage convertTcpPacket2TcpPackage(TCPPacket tcpPacket) {
// 报文长度=IP报文长度-IP报文头长度-TCP报文头长度
IPPacket ipPacket = tcpPacket.getParentPacket();
int length = ipPacket.getTotalIPLength() - ipPacket.getHeaderLength() - tcpPacket.getHeaderLength();
Buffer bodyBuffer = tcpPacket.getPayload();
String body = ObjectUtils.isNotEmpty(bodyBuffer)
? bodyBuffer.toString() : StringUtils.EMPTY;
long arriveTimestamp = tcpPacket.getArrivalTime() / 1000;
return new TcpPackage(ipPacket.getSourceIP(), tcpPacket.getSourcePort(), ipPacket.getDestinationIP(), tcpPacket.getDestinationPort(),
length, tcpPacket.isACK(), tcpPacket.isFIN(), tcpPacket.isSYN(), tcpPacket.isRST(), tcpPacket.getSequenceNumber(),
tcpPacket.getAcknowledgementNumber(), arriveTimestamp, body);
}
上述方法需要注意的一点就是TCP 报文载荷长度的获取,我们能够拿到的数据是IP 报文长度,IP 报文头长度和TCP 报文头长度,所以IP 报文长度减去IP 报文头长度可以得到TCP 报文长度,再拿TCP 报文长度减去TCP 报文头长度就能得到TCP报文载荷长度。
现在我们已经拿到网络包里面所有TCP 报文的集合了,并且这些报文是按照时间先后顺序进行正序排序的,我们随机选中一个报文,拿到这个TCP 报文以及其在集合中的索引,然后我们就可以基于下面的实现拿到对应的TCP流。
java
public static List<TcpPackage> getTcpStream(List<TcpPackage> tcpPackages, int index) {
LinkedList<TcpPackage> tcpStream = new LinkedList<>();
TcpPackage beginTcpPackage = tcpPackages.get(index);
long currentSeqNum = beginTcpPackage.getSeqNum();
long currentAckNum = beginTcpPackage.getAckNum();
// 从index位置向前查找
for (int i = index - 1; i >=0; i--) {
TcpPackage previousTcpPackage = tcpPackages.get(i);
long previousSeqNum = previousTcpPackage.getSeqNum();
long previousAckNum = previousTcpPackage.getAckNum();
if (isPreviousTcpPackageSatisfied(currentSeqNum, currentAckNum, previousSeqNum, previousAckNum)) {
tcpStream.addFirst(previousTcpPackage);
currentSeqNum = previousSeqNum;
currentAckNum = previousAckNum;
}
}
// index位置的报文也要放到tcp流中
tcpStream.add(beginTcpPackage);
currentSeqNum = beginTcpPackage.getSeqNum();
currentAckNum = beginTcpPackage.getAckNum();
// 从index位置向后查找
for (int i = index + 1; i < tcpPackages.size(); i++) {
TcpPackage nextTcpPackage = tcpPackages.get(i);
long nextSeqNum = nextTcpPackage.getSeqNum();
long nextAckNum = nextTcpPackage.getAckNum();
if (isNextTcpPackageSatisfied(currentSeqNum, currentAckNum, nextSeqNum, nextAckNum)) {
tcpStream.add(nextTcpPackage);
currentSeqNum = nextSeqNum;
currentAckNum = nextAckNum;
}
}
return tcpStream;
}
上述方法中,向前查找时判断TCP 报文是否属于TCP 流是基于isPreviousTcpPackageSatisfied() 方法,向后查找时判断TCP 报文是否属于TCP 流是基于isNextTcpPackageSatisfied() 方法,而这两个方法其实就是把序列号和确认号的变化规则翻译成了代码,如下所示。
java
public static boolean isPreviousTcpPackageSatisfied(long currentSeqNum, long currentAckNum,
long previousSeqNum, long previousAckNum) {
boolean condition1 = currentSeqNum == previousSeqNum && currentSeqNum != 0;
boolean condition2 = currentAckNum == previousAckNum && currentAckNum != 0;
boolean condition3 = currentSeqNum == previousAckNum;
boolean condition4 = currentAckNum - 1 == previousSeqNum;
return condition1 || condition2 || condition3 || condition4;
}
public static boolean isNextTcpPackageSatisfied(long currentSeqNum, long currentAckNum,
long nextSeqNum, long nextAckNum) {
boolean condition1 = currentSeqNum == nextSeqNum && currentSeqNum != 0;
boolean condition2 = currentAckNum == nextAckNum && currentAckNum != 0;
boolean condition3 = currentAckNum == nextSeqNum;
boolean condition4 = currentSeqNum + 1 == nextAckNum;
return condition1 || condition2 || condition3 || condition4;
}
至此,使用Java 语言如何从网络包中获得TCP流就介绍完毕。
总结
TCP 流就是一次TCP 连接中,从连接建立,到数据传输,再到连接断开整个过程中的TCP 报文集合,而获取TCP 流是基于TCP报文序列号和确认号的变化规则,规则如下。
- 本次发送报文的SeqNum 等于上一次发送报文的SeqNum 加上上一次发送报文的length;
- 本次发送报文的AckNum 等于上一次接收报文的SeqNum 加上上一次接收报文的length;
- SYN 报文和FIN 报文的length默认为1,而不是0。
使用Java 语言解析网络包并得到TCP流,步骤总结如下。
- 使用io.pkts工具解开网络包;
- 将网络包中的TCP报文转换为自定义的可读性更强的数据结构;
- 选中一个TCP报文;
- 根据序列号和确认号变化获取TCP流。