TCP随机初始化序列号(ISN)的原因

TCP随机初始化序列号(ISN)的原因

TCP的三次握手过程中,要随机初始化seq字段两次,客户端和服务端各一次,那么为什么需要随机初始化序列号(ISN)?而不是默认从0开始?

有两个原因:

1. 避免不同tcp连接的冲突

rfc793的说明

这个可以追溯到tcp最开始的rfc文档(rfc793),其中有一小节(Initial Sequence Number Selection)专门讲述了为什么要这样设计。

下面是我翻译的原文:

该协议对一个特定的连接没有限制,可以重复使用。连接由一对套接字定义。连接的新实例将被称为连接的不同化。由此引发的问题是,“TCP如何识别先前连接的重复段?”如果连接在快速打开和关闭之间进行,或者连接中断并且丢失了记忆,然后重新建立,这个问题就变得明显起来。

为了避免混淆,我们必须防止连接的一个不同化中的段在网络中仍然存在相同的序列号时被使用。即使TCP崩溃并且失去了所有关于它使用的序列号的知识,我们也希望确保这一点。当创建新连接时,会使用一个初始序列号(ISN)生成器,该生成器选择一个新的32位ISN。生成器绑定到一个(可能是虚构的)32位时钟,其低位每隔大约4微秒递增一次。因此,ISN大约每4.55小时循环一次。由于我们假设段在网络中的最大存活时间(MSL)不超过4.55小时,并且MSL小于4.55小时,我们可以合理地假设ISN是唯一的。

举个栗子

我们拿一个实际的例子来举例子。

我们假设这样一个场景:客户端刚连上服务端,握手完成后,才刚发两次包,每次发包的len都为10,此时要发第三次包(可推算出,此时包的序号为:客户端初始序列号1+20),这时网络阻塞了,这个包堵在了漫漫网络中。

巧好很不辛运,此时服务端可能由于空指针,服务自动重启了!这意味着我们的服务端将会关闭所有的连接,并再次开启一个新的监听。在服务端关闭连接的时候,根据TCP规范,服务端会向客户端发送一个RST告诉客户端我们应该断开连接了。

客户端被断连后,决定立马再和服务端连接,于是乎SYN请求又发了出去,此时服务端恰好也自动重启完成,接受了握手。客户端和服务端又开始通信了。但是此时,在网络中被阻塞的那个包,它终于到了服务端。并且由于我们新的连接建立没多久,新连接的客户端的包序号,现在大致为客户端初始序列号2+20左右,由于TCP有接收窗口范围这种缓冲的结构在。如果有在窗口范围内的包都会被认为是合法包,而被服务端收入自己的接收缓冲区。

流程的图像化描述为下:

img

现在问题即是客户端初始序列号1+20客户端初始序列号2+20这两个属于不同连接的序号,是否容易令人混淆。

假设我们初始序列号不是随机的,而是固定死的,如取0。那么很明显客户端初始序列号1+20 == 客户端初始序列号2+20出现了混淆。

解决方法,当然就是随机啦

因此为了尽量避免这种情况,而采用了随机的方式,正如rfc文档中定义的那样

该生成器选择一个新的32位ISN。生成器绑定到一个(可能是虚构的)32位时钟,其低位每隔大约4微秒递增一次。因此,ISN大约每4.55小时循环一次。

但现在的实际系统,更进一步将随机的算法增强为:ISN = F(localhost, localport, remotehost, remoteport) + M

  • M是一个计时器,每4微秒+1
  • F是一个Hash算法

结合实际,看看Linux内核是怎么搞的

接下来我们拿Linux最新的内核代码的实现来分析一下,现行系统是如何生成ISN的。

首先是入口函数secure_tcp_seq源码:

/* secure_tcp_seq_and_tsoff(a, b, 0, d) == secure_ipv4_port_ephemeral(a, b, d),
* but fortunately, `sport' cannot be 0 in any circumstances. If this changes,
* it would be easy enough to have the former function use siphash_4u32, passing
* the arguments as separate u32.
*/
u32 secure_tcp_seq(__be32 saddr, __be32 daddr,
__be16 sport, __be16 dport)
{
u32 hash;

net_secret_init();
hash = siphash_3u32((__force u32)saddr, (__force u32)daddr,
(__force u32)sport << 16 | (__force u32)dport,
&net_secret);
return seq_scale(hash);
}

其接收四个参数,刚好是四元组(localhost,localport,remotehost,remoteport),这可以唯一确认一个连接。

接下来会运行net_secret_init函数,

net_secret_init源码

static __always_inline void net_secret_init(void)
{
net_get_random_once(&net_secret, sizeof(net_secret));
}

这个函数非常简单,只调用了net_get_random_once函数。net_secret_init是一个汇编函数,linux内核邮件列表中2013年有一篇对这个函数的介绍https://lore.kernel.org/lkml/1381987923-1524-6-git-send-email-hannes@stressinduktion.org/。

这里省个流。该函数的作用为获取一串随机数,赋值进net_secret中。该函数的特点为只有第一次调用的时候才会随机生成,接下来的所有调用都会返回第一次生成的随机数,因次只有第一次调用非常耗时间,其它时间的调用都为伪随机。但注意,该函数会在每次开机的时候重置随机,来增强安全性。

我们再回到secure_tcp_seq函数中,接下来其调用了siphash_3u32函数。该函数主要就是调用siphash算法用刚随机出的net_secre来对remotehost,localhost和localhost | remoteport这些内容来加密。

chatgpt4 介绍siphash算法:

SipHash 是一个加密型散列函数,设计用于在哈希表等场景中快速处理小的输入数据。SipHash 的设计者是 Jean-Philippe Aumasson 和 Daniel J. Bernstein。

SipHash 通过固定的轮数(通常为 2 或 4)的 SipRound 运算,对两个 64 位的状态变量进行处理。每个 SipRound 运算包括一系列的位移、位与和异或运算。

SipHash 的输入是一个任意长度的消息和一个 128 位的密钥。输出是一个 64 位的哈希值。SipHash 的一般形式是 SipHash-c-d,其中 c 是压缩阶段的轮数,d 是最终阶段的轮数。SipHash-2-4 是最常用的变体。

SipHash 的一个重要特性是它对密钥的使用。这使得攻击者即使知道了哈希函数的结果,也无法推断出输入数据的内容。这使得 SipHash 特别适合用于防止哈希冲突攻击,这种攻击方法会尝试通过生成大量哈希值相同的输入数据,来消耗哈希表的资源。

接着将上一步获得到的hash值,调用seq_scale函数来处理。

seq_scale源码

static u32 seq_scale(u32 seq)
{
/*
* As close as possible to RFC 793, which
* suggests using a 250 kHz clock.
* Further reading shows this assumes 2 Mb/s networks.
* For 10 Mb/s Ethernet, a 1 MHz clock is appropriate.
* For 10 Gb/s Ethernet, a 1 GHz clock should be ok, but
* we also need to limit the resolution so that the u32 seq
* overlaps less than one time per MSL (2 minutes).
* Choosing a clock of 64 ns period is OK. (period of 274 s)
*/
return seq + (ktime_get_real_ns() >> 6);
}

其实我们可以发现,实际的ISN的形式就是ISN = F(localhost, localport, remotehost, remoteport) + M,只不过是ISN = SipHash(localhost, localport, remotehost, remoteport) + M, 其中M如注释所说一样,没有采用rfc建议的4微秒+1,而是在权衡了溢出和现代网络速度后,采用了64ns+1的策略,大概274s循环一次。

还不够,还要增加时间戳,来防止冲突

客户端和服务端初始化序列号都是随机生成的话,是否能避免连接接收历史报文了。

并不完全是,显然还是有概率撞车的。

因为我们知道SEQ这东西是会溢出重绕的,ISN也是会重绕的,在rfc793中4.4h重绕一次,在现代linux系统中,甚至4分半就能回绕一次。而且当代网络速度动不动上百兆,SEQ重绕也是很快的。撞车的概率大大增加(虽然其实还是挺小的)。

于是TCP加入了TCP时间戳来解决这个问题,它有两个好处,一个是便于精确计算 RTT ,另一个是能防止序列号回绕(PAWS)。

又一个示例,展示回绕冲突的可能

试看下面的示例,假设 TCP 的发送窗口是 1 GB,并且使用了时间戳选项,发送方会为每个 TCP 报文分配时间戳数值,我们假设每个报文时间加 1,然后使用这个连接传输一个 6GB 大小的数据流。

图片

32 位的序列号在时刻 D 和 E 之间回绕。假设在时刻B有一个报文丢失并被重传,又假设这个报文段在网络上绕了远路并在时刻 F 重新出现。如果 TCP 无法识别这个绕回的报文,那么数据完整性就会遭到破坏。

使用时间戳选项能够有效的防止上述问题,如果丢失的报文会在时刻 F 重新出现,由于它的时间戳为 2,小于最近的有效时间戳(5 或 6),因此防回绕序列号算法(PAWS Protection Against Wrapped Sequence numbers)会将其丢弃。

防回绕序列号算法要求连接双方维护最近一次收到的数据包的时间戳(Recent TSval),每收到一个新数据包都会读取数据包中的时间戳值跟 Recent TSval 值做比较,如果发现收到的数据包中时间戳不是递增的,则表示该数据包是过期的,就会直接丢弃这个数据包。

至于Linux内核是如何处理PAWS问题的

可以查看这个link https://github.com/torvalds/linux/blob/d2f51b3516dade79269ff45eae2a7668ae711b25/net/ipv4/tcp_input.c#L5842。在该函数(/net/ipv4/tcp_input/tcp_validate_incoming())内搜索paws即可看到相关处理逻辑。

/* RFC1323: H1. Apply PAWS check first. */
if (tcp_fast_parse_options(sock_net(sk), skb, th, tp) &&
tp->rx_opt.saw_tstamp &&
tcp_paws_discard(sk, skb)) {
if (!th->rst) {
if (unlikely(th->syn))
goto syn_challenge;
NET_INC_STATS(sock_net(sk), LINUX_MIB_PAWSESTABREJECTED);
if (!tcp_oow_rate_limited(sock_net(sk), skb,
LINUX_MIB_TCPACKSKIPPEDPAWS,
&tp->last_oow_ack_time))
tcp_send_dupack(sk, skb);
SKB_DR_SET(reason, TCP_RFC7323_PAWS);
goto discard;
}
/* Reset is accepted even if it did not pass PAWS. */
}

.....
/* If PAWS failed, check it more carefully in slow path */
if ((s32)(tp->rx_opt.rcv_tsval - tp->rx_opt.ts_recent) < 0)
goto slow_path;

/* DO NOT update ts_recent here, if checksum fails
* and timestamp was corrupted part, it will result
* in a hung connection since we will drop all
* future packets due to the PAWS test.
*/

在此不再解析

所以客户端和服务端的初始化序列号都是随机生成,能很大程度上避免历史报文被下一个相同四元组的连接接收,然后又引入时间戳的机制,从而完全避免了历史报文被接收的问题。

如果时间戳也回绕了怎么办?

时间戳的大小是 32 bit,所以理论上也是有回绕的可能性的。

时间戳回绕的速度只与对端主机时钟频率有关。

Linux 以本地时钟计数(jiffies)作为时间戳的值,不同的增长时间会有不同的问题:

  • 如果时钟计数加 1 需要1ms,则需要约 24.8 天才能回绕一半,只要报文的生存时间小于这个值的话判断新旧数据就不会出错。
  • 如果时钟计数提高到 1us 加1,则回绕需要约71.58分钟才能回绕,这时问题也不大,因为网络中旧报文几乎不可能生存超过70分钟,只是如果70分钟没有报文收发则会有一个包越过PAWS(这种情况会比较多见,相比之下 24 天没有数据传输的TCP连接少之又少),但除非这个包碰巧是序列号回绕的旧数据包而被放入接收队列(太巧了吧),否则也不会有问题;
  • 如果时钟计数提高到 0.1 us 加 1 回绕需要 7 分钟多一点,这时就可能会有问题了,连接如果 7 分钟没有数据收发就会有一个报文越过 PAWS,对于TCP连接而言这么短的时间内没有数据交互太常见了吧!这样的话会频繁有包越过 PAWS 检查,从而使得旧包混入数据中的概率大大增加;

Linux 在 PAWS 检查做了一个特殊处理,如果一个 TCP 连接连续 24 天不收发数据则在接收第一个包时基于时间戳的 PAWS 会失效,也就是可以 PAWS 函数会放过这个特殊的情况,认为是合法的,可以接收该数据包。

时间戳带来的问题

上一节我们说到Linux用jiffies来作为TCP的timestamp,但是实际上这样会带来安全问题。

这意味着攻击者只需要与你建立连接,就能知道你开机了多长时间,这太可怕了。

在Linux4.1之后(当初的commit),实际的timestamp不再是单纯的开机时间了。而是采用了开机时间+random的offset的形式来解决这个问题。

时间戳Linux的实现

这个部分比较复杂,新建了一篇文章来专门讲述timestamp的内核实现。

2. 安全因素

如果详细读了上面防冲突的解释,这个原因也就非常好理解了。

如果黑客想要干扰你的连接,他只需要首先模拟你的ip和端口,再知道你的seq,就可以成功模拟数据。

如果seq是从0开始,黑客就太好猜了,也许甚至他只要不断发syn-ack包,并且把seq置1,他就可以骗过服务端,大概率代替真正的客户端握手,真正的客户端发而会因为是他的syn-ack包到达速度没有黑客的快,被服务端当非法重复包而丢弃了。如果是随机,黑客还需要去想办法抓包获取,这就难太多了。