PAYATHON 2026

为什么手工构造的 TCP SYN 收不到 DUT 的 ACK?

排障赵师傅

结论

DUT 不返回 SYN/ACK,直接原因是构造出的 IPv4 和 TCP checksum 都有误。代码中的 checksum() 按字节累加,而 Internet checksum 要按 16 bit word 计算一补和。计算 TCP checksum 时,还要包含 IPv4 pseudo-header。多数 TCP/IP 实现会直接丢弃 checksum 错误的报文,不作响应。

代码还存在以下问题:

  • AF_PACKET 的 protocol 参数用错了。这里应传入 htons(ETH_P_IP) 或 htons(ETH_P_ALL),不能使用 IPPROTO_RAW。
  • sll_protocol = htons(0x0008) 的字节序写反了,应使用 htons(ETH_P_IP)。
  • m_socket_address 在 bind() 后被 memset() 清空,发送前却没有重新设置 sll_family 和 sll_protocol。
  • 启用 TCP options 后,代码只修改了 th_off,没有将 options 复制进报文,也没有更新 ip_len 和 checksum。
  • main() 调用 SendMessage() 后立即退出,没有调用 ReceiveMessage()。因此程序既不会接收 SYN/ACK,也不会发送握手所需的第三个 ACK。
  • 通过 raw socket 发送 SYN 不会在 Linux TCP stack 中创建连接状态。Linux 收到对应的 SYN/ACK 后找不到连接,便会自动回复 RST。应用是否及时调用 recvfrom() 不会改变这一行为。

checksum 的问题

当前实现实际执行的是:

while (count > 0) {
    sum += *(addr++);
    count -= 1;
}

这段代码将每个字节分别相加。RFC 1071 定义的 Internet checksum 需要按网络字节序将数据组成 16 bit word,再进行一补加法。

可以改成:

#include <stdint.h>
#include <stddef.h>
#include <arpa/inet.h>

static uint16_t internet_checksum(const void *data, size_t len)
{
    const uint8_t *p = static_cast<const uint8_t *>(data);
    uint32_t sum = 0;

    while (len >= 2) {
        sum += (static_cast<uint16_t>(p[0]) << 8) |
               static_cast<uint16_t>(p[1]);
        p += 2;
        len -= 2;
    }

    if (len == 1) {
        sum += static_cast<uint16_t>(p[0]) << 8;
    }

    while (sum >> 16) {
        sum = (sum & 0xffffU) + (sum >> 16);
    }

    return htons(static_cast<uint16_t>(~sum & 0xffffU));
}

计算 IPv4 header checksum 前,先将 checksum 字段清零:

iphdr->ip_sum = 0;
iphdr->ip_sum = internet_checksum(iphdr, IP4_HDRLEN);

TCP checksum 必须包含 pseudo-header

下面的写法不正确:

tcphdr->th_sum = checksum(buffer, sizeof(struct tcphdr));

TCP checksum 的输入至少包括:

  1. 源 IPv4 地址
  2. 目的 IPv4 地址
  3. 一个零字节
  4. protocol,即 IPPROTO_TCP
  5. TCP segment 长度
  6. TCP header
  7. TCP options
  8. TCP payload

可以这样计算:

#include <vector>
#include <cstring>
#include <netinet/ip.h>
#include <netinet/tcp.h>

struct TcpPseudoHeader {
    struct in_addr src;
    struct in_addr dst;
    uint8_t zero;
    uint8_t protocol;
    uint16_t tcp_length;
};

static_assert(sizeof(TcpPseudoHeader) == 12,
              "Unexpected TCP pseudo-header size");

static uint16_t tcp_checksum(const struct ip *iphdr,
                             const uint8_t *tcp_segment,
                             size_t tcp_length)
{
    TcpPseudoHeader pseudo{};

    pseudo.src = iphdr->ip_src;
    pseudo.dst = iphdr->ip_dst;
    pseudo.zero = 0;
    pseudo.protocol = IPPROTO_TCP;
    pseudo.tcp_length = htons(static_cast<uint16_t>(tcp_length));

    std::vector<uint8_t> input(sizeof(pseudo) + tcp_length);

    memcpy(input.data(), &pseudo, sizeof(pseudo));
    memcpy(input.data() + sizeof(pseudo), tcp_segment, tcp_length);

    return internet_checksum(input.data(), input.size());
}

调用方式如下:

size_t tcp_length = TCP_HDRLEN;

tcphdr->th_sum = 0;
tcphdr->th_sum = tcp_checksum(iphdr,
                              reinterpret_cast<uint8_t *>(tcphdr),
                              tcp_length);

iphdr 必须指向同一报文中的 IPv4 header。如果 TCP header 后还有 options 或 payload,tcp_length 也必须计入这些内容。

修正 AF_PACKET socket 配置

AF_PACKET 的第三个参数是 Ethernet protocol,需要使用网络字节序。IPPROTO_RAW 是 IP 层的 protocol 编号,不能在这里代替 EtherType。

接收地址和发送地址最好分开保存,以免绑定后又将同一个结构清零:

#include <linux/if_packet.h>
#include <linux/if_ether.h>
#include <net/if.h>
#include <sys/socket.h>
#include <cstring>

int fd = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL));
if (fd == -1) {
    perror("socket");
    return false;
}

struct sockaddr_ll bind_addr{};
bind_addr.sll_family = AF_PACKET;
bind_addr.sll_protocol = htons(ETH_P_IP);
bind_addr.sll_ifindex = if_index;

if (bind(fd,
         reinterpret_cast<struct sockaddr *>(&bind_addr),
         sizeof(bind_addr)) == -1) {
    perror("bind");
    return false;
}

struct sockaddr_ll tx_addr{};
tx_addr.sll_family = AF_PACKET;
tx_addr.sll_protocol = htons(ETH_P_IP);
tx_addr.sll_ifindex = if_index;
tx_addr.sll_halen = ETH_ALEN;
memcpy(tx_addr.sll_addr, dst_mac, ETH_ALEN);

ssize_t sent = sendto(
    fd,
    frame,
    frame_length,
    0,
    reinterpret_cast<struct sockaddr *>(&tx_addr),
    sizeof(tx_addr)
);

Ethernet header 中的 EtherType 仍写为:

ethhdr->etherType = htons(ETH_P_IP);

也就是:

ethhdr->etherType = htons(0x0800);

原代码中的:

m_socket_address.sll_protocol = htons(0x0008);

应改成:

m_socket_address.sll_protocol = htons(ETH_P_IP);

发送前还要重新设置:

m_socket_address.sll_family = AF_PACKET;

在 SOCK_RAW 模式下,目的 MAC 已经包含在 Ethernet header 中,但仍应正确填写 sll_addr,不要依赖驱动对不完整 sockaddr_ll 的容忍行为。

TCP options 代码目前不能使用

现有代码启用 options 后只执行了两步:

optsize = 20;
tcphdr->th_off = (TCP_HDRLEN + optsize) / 4;

但 optbuffer 没有复制到 TCP header 后面,函数最终仍然只返回:

return sizeof(struct tcphdr);

这样生成的报文声称 TCP header 长度是 40 字节,实际却只发送了 20 字节,通常会被直接丢弃。

正确处理至少需要:

memcpy(buffer + TCP_HDRLEN, optbuffer, optsize);

size_t tcp_length = TCP_HDRLEN + optsize;

tcphdr->th_off = tcp_length / 4;
iphdr->ip_len = htons(IP4_HDRLEN + tcp_length);

tcphdr->th_sum = 0;
tcphdr->th_sum = tcp_checksum(
    iphdr,
    reinterpret_cast<const uint8_t *>(tcphdr),
    tcp_length
);

时间戳等多字节 option 值也要使用网络字节序。例如:

uint32_t time1 = htonl(0x12345678);
uint32_t time2 = htonl(0x87654321);

不带 TCP options 的 SYN 是合法的。DUT 不会只因 SYN 没有携带 MSS、SACK 或 timestamp options 就保持沉默。因此,排查时可以先使用最简单的 20 字节 TCP header。

为什么 Linux 会自动发送 RST

raw socket 只负责收发原始报文,不会在 Linux TCP stack 中建立 TCP control block。手工发送 SYN 后,Linux 内核并不知道本机存在一个处于 SYN-SENT 状态的连接。

当 DUT 返回:

192.168.1.94:80 -> 192.168.1.211:8080  SYN, ACK

Linux TCP stack 找不到对应连接,就会按正常规则返回 RST。这个处理在内核中完成,通常远快于用户态程序调用 recvfrom()。因此,无论提前还是更快地调用 recvfrom(),都无法阻止 RST。raw socket 仍可能收到该 SYN/ACK,但内核也已经生成了 RST。

在 8080 端口调用 listen() 同样不能解决这个问题,因为监听 socket 等待的是发往 8080 的 SYN,而 DUT 返回的是 SYN/ACK。

如何阻止内核发送 RST

测试期间,可以用防火墙丢弃这个特定四元组上的出站 RST:

sudo iptables -I OUTPUT 1 \
  -p tcp \
  -s 192.168.1.211 --sport 8080 \
  -d 192.168.1.94 --dport 80 \
  --tcp-flags RST RST \
  -j DROP

测试结束后删除同一规则:

sudo iptables -D OUTPUT \
  -p tcp \
  -s 192.168.1.211 --sport 8080 \
  -d 192.168.1.94 --dport 80 \
  --tcp-flags RST RST \
  -j DROP

不要丢弃主机上的所有 TCP RST,否则可能影响其他连接和系统服务。规则还要在发送 SYN 前安装,以免内核已经回复 RST。

丢弃内核 RST 后,程序要自行完成握手:

  1. 发送 SEQ = client_isn 的 SYN。
  2. 接收 DUT 的 SYN/ACK。
  3. 检查其 ACK = client_isn + 1。
  4. 读取 DUT 的 sequence number,记为 server_isn。
  5. 发送 ACK,其中:
    • SEQ = client_isn + 1
    • ACK = server_isn + 1
    • 设置 ACK flag
  6. 后续所有 data、ACK 和 RST 的 sequence number 与 acknowledgment number 都由测试程序维护。

更稳妥的 RFC 5961 测试方式

如果测试目标只是向已经建立的连接注入带有特定 sequence number 的 RST,通常不必手工完成三次握手。可以先用普通 TCP socket 调用 connect() 建立真实连接,再通过 AF_PACKET 或 raw IP socket 注入额外报文。

这种方式有以下好处:

  • Linux 会正常维护连接状态和重传。
  • 无需屏蔽握手阶段的内核 RST。
  • DUT 看到的是一个真实建立的连接。
  • 测试程序只需取得连接四元组和当前 sequence number,再构造待测 RST。

如果普通 socket 和注入程序使用同一个四元组,就要准确掌握当前 sequence space。可以在测试点抓包获取序列号,也可以将握手和测试流量放入独立的 network namespace,减少其他流量的干扰。

按照 RFC 5961 的测试思路,如果 RST 的 sequence number 落在接收窗口内,却不等于当前期望值,符合要求的实现通常不会立即关闭连接,而是发送 challenge ACK。测试时需要验证:

  • DUT 是否发送 challenge ACK;
  • 原连接是否仍然可用;
  • sequence number 恰好等于 RCV.NXT 的 RST 是否产生不同结果;
  • 注入报文的 checksum、四元组和 TCP 状态是否准确。

建议的排查顺序

先关闭 TCP options,也不要发送 payload,然后按以下顺序检查:

  1. 用 ip addr show dev enp3s0 确认 192.168.1.211 确实属于该接口。
  2. 确认 Ethernet source MAC 与 enp3s0 的实际 MAC 一致。
  3. 确认 DUT 与测试主机位于同一二层网络。如果不在同一二层网络,目的 MAC 应使用下一跳网关的 MAC。
  4. 确认 DUT 的 TCP 80 端口正在监听。关闭的端口通常返回 RST,防火墙则可能静默丢弃报文。
  5. 修复 IPv4 checksum。
  6. 使用 pseudo-header 修复 TCP checksum。
  7. 修正 AF_PACKET 的 protocol、sll_family 和 sll_protocol。
  8. 在发送 SYN 前安装精确匹配的 RST 丢弃规则。
  9. 调用 ReceiveMessage(),解析返回的 Ethernet、IPv4 和 TCP header。
  10. 收到有效的 SYN/ACK 后,自行发送第三个 ACK。

另外还有几个细节。它们不会直接造成当前现象,但会影响后续测试。

uint8_t ReceiveMessage(...)

该返回值最多只能表示 255。报文长度应使用 ssize_t,或至少使用 int:

ssize_t ReceiveMessage(uint32_t delay, unsigned char *msg);

recvfrom() 的地址长度类型应为:

socklen_t size = sizeof(m_socket_address);

Cleanup() 也要真正关闭 socket:

void CPacket::Cleanup()
{
    if (m_socket >= 0) {
        close(m_socket);
        m_socket = -1;
    }
}

本机 Wireshark 能看到报文离开 packet socket,只能证明程序已经将帧交给网络接口,不能证明 DUT 收到的 checksum 正确。排查 checksum offload 的影响时,最好在 DUT、交换机镜像端口或另一台主机上抓包。必要时,可以临时关闭发送端接口的 checksum 和 segmentation offload。

备注:内容仅供参考。