为什么手工构造的 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 的输入至少包括:
- 源 IPv4 地址
- 目的 IPv4 地址
- 一个零字节
- protocol,即
IPPROTO_TCP - TCP segment 长度
- TCP header
- TCP options
- 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 后,程序要自行完成握手:
- 发送
SEQ = client_isn的 SYN。 - 接收 DUT 的
SYN/ACK。 - 检查其
ACK = client_isn + 1。 - 读取 DUT 的 sequence number,记为
server_isn。 - 发送 ACK,其中:
SEQ = client_isn + 1ACK = server_isn + 1- 设置 ACK flag
- 后续所有 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,然后按以下顺序检查:
- 用
ip addr show dev enp3s0确认192.168.1.211确实属于该接口。 - 确认 Ethernet source MAC 与
enp3s0的实际 MAC 一致。 - 确认 DUT 与测试主机位于同一二层网络。如果不在同一二层网络,目的 MAC 应使用下一跳网关的 MAC。
- 确认 DUT 的 TCP 80 端口正在监听。关闭的端口通常返回 RST,防火墙则可能静默丢弃报文。
- 修复 IPv4 checksum。
- 使用 pseudo-header 修复 TCP checksum。
- 修正
AF_PACKET的 protocol、sll_family和sll_protocol。 - 在发送 SYN 前安装精确匹配的 RST 丢弃规则。
- 调用
ReceiveMessage(),解析返回的 Ethernet、IPv4 和 TCP header。 - 收到有效的
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。
备注:内容仅供参考。
