Java语言获取TCP流

前言

使用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之前的所有数据,根据确认号,可以解决网络传输中的数据丢包问题。

那么序列号和确认号的变化有什么规则呢,规则总结如下。

  1. 本次发送报文的SeqNum 等于上一次发送报文的SeqNum 加上上一次发送报文的length
  2. 本次发送报文的AckNum 等于上一次接收报文的SeqNum 加上上一次接收报文的length
  3. SYN 报文和FIN 报文的length默认为1,而不是0。

结合下面一张图,可以更好的理解上面的变化规则。

二. TCP流获取的Java实现

结合第一节的内容,想要获取某一个TCP 报文所属TCP 连接的TCP 流,其实就可以根据这个报文的SeqNumAckNum ,向前和向后查找符合序列号和确认号变化规则的报文,只要符合规则,那么这个报文就是属于TCP流的。

Java 语言中,要实现TCP 流的获取,可以先借助io.pkts 工具把网络包先解开,然后把每个报文封装为我们自定义的Entityio.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报文序列号和确认号的变化规则,规则如下。

  1. 本次发送报文的SeqNum 等于上一次发送报文的SeqNum 加上上一次发送报文的length
  2. 本次发送报文的AckNum 等于上一次接收报文的SeqNum 加上上一次接收报文的length
  3. SYN 报文和FIN 报文的length默认为1,而不是0。

使用Java 语言解析网络包并得到TCP流,步骤总结如下。

  1. 使用io.pkts工具解开网络包;
  2. 将网络包中的TCP报文转换为自定义的可读性更强的数据结构;
  3. 选中一个TCP报文;
  4. 根据序列号和确认号变化获取TCP流。
相关推荐
考虑考虑1 天前
Jpa使用union all
java·spring boot·后端
用户3721574261351 天前
Java 实现 Excel 与 TXT 文本高效互转
java
浮游本尊1 天前
Java学习第22天 - 云原生与容器化
java
渣哥1 天前
原来 Java 里线程安全集合有这么多种
java
间彧1 天前
Spring Boot集成Spring Security完整指南
java
间彧1 天前
Spring Secutiy基本原理及工作流程
java
Java水解1 天前
JAVA经典面试题附答案(持续更新版)
java·后端·面试
洛小豆1 天前
在Java中,Integer.parseInt和Integer.valueOf有什么区别
java·后端·面试
前端小张同学1 天前
服务器上如何搭建jenkins 服务CI/CD😎😎
java·后端
ytadpole1 天前
Spring Cloud Gateway:一次不规范 URL 引发的路由转发404问题排查
java·后端