网络抓包实战 04——深入浅出连接建立
TCP 是一种面向连接的、可靠的、基于字节流的通信协议,具有流量和网络拥塞控制等功能。这些功能需要通信双方协商好相关信息,包括传递窗口大小、各自的初始序列号等,而这些工作是通过三次握手完成的。
从互联网角度来考虑,设计三次握手有如下目的:
1、通信双方之间建立初步信任
2、初始化传输参数和状态
3、降低互联网错误连接的概率
从最终效果来看,三次握手的设计是相当成功的,是整个互联网成功运行的基础。
1. 三次握手
1.1. 序列号设计
TCP 对每一个 TCP stream 传输方向都采用了序列号(sequence number),用来确保数据的可靠传输。通过 ack 确认序列号,可以确认哪些数据已经成功传递给对方,哪些数据还需要重传。相关数据字段见下图。
除了可靠传输,sequence number 的初始选择对互联网安全非常关键。如果选择的初始值是可以预测的,那就很容易通过序列值去伪造连接,这对于整个互联网会是个灾难。
Sequence number 初始化值的实现算法很多,但都具备这样的特性:随机,不可预测。
在 wireshark 中,采用绝对值查看 sequence 值,方法如下:
下图中每一个 sequence 值都是不一样的。
wireshark 为了方便用户查看,默认采用 sequence 序列相对值。因此,下图中 wireshark 里抓包文件中的不同连接,sequence 序列值显示都为 0。
连接是由<客户端 IP,客户端端口,服务器 IP,服务器端口>组成。如果连接一样,sequence 初始值也一样,那么能不能通过 syn 数据包建立连接呢?答案是不确定的,需要具体问题具体分析。当服务器端已经有相应的 TCP 状态,那么 TCP 很容易判断这个 syn 数据包是不是旧的连接请求;如果没有相应的 TCP 状态,那么 TCP 就会回复第二次握手数据包,以验证客户端是不是真的在建立连接,一旦服务器端接收到第三次握手数据包,一般情况下,服务器端成功与客户端建立连接。三次握手的重要作用之一是可以减少错误连接请求。
1.2. 三次握手详解
下面讲述三次握手是如何完成的。
当客户端发起 connect()请求时,委托给客户端 TCP 去完成连接建立过程,客户端 TCP 会发送第一次握手数据包(带有 SYN seq=x,其中 seq 是序列号)过去,同时标记这个连接处于 SYN_SENT 状态。当第一次握手数据包到达服务器 TCP 的时候,如果被判断合法,服务器 TCP 把这个连接状态改为 SYN_RECV 状态,发送第二次握手数据包给客户端以确认自己身份(通过 ack=x+1),同时要求客户端确认信息(SYN seq=y),并把连接信息放入 SYN queue。客户端收到了第二次握手数据包,检查服务器端 ack 确认的序列号是否是预期的序列号(第一次握手数据包的序列号 x+1),这里 ack 序列号为 x+1,客户端 TCP 确认合法,客户端端连接建立成功,进入 ESTABLISHED 状态,并发送对服务器端的确认(ack=y+1)。服务器端 TCP 收到第三次握手数据包,检查客户端发过来的 ack 序列号是否是 y+1,如果是,从 SYN queue 取出连接信息,置为 ESTABLISHED 状态,并放入 accept queue,通知应用去取连接。
这里最关键的信息是序列号的确认,一旦序列号不合法,连接就建立不起来,TCP 通过这种机制构建起互联网第一道安全屏障。
正因为三次握手机制,攻击者也不容易随意构建假连接。我们从上图可以看出,客户端建立连接需要第一次握手数据包和第三次握手数据包。伪造第一次握手数据包没有难度,因为没有 ack 确认信息;伪造第三次握手数据包,需要的最关键信息是 ack=y+1,y 是第二次握手数据包的服务器端序列号。当攻击者一旦能够捕获第二次握手数据包(如在第二次握手数据包必经的路由器),那么攻击者就可以伪造连接请求,服务器端无法判断这是真正的客户端连接请求,还是伪造的连接请求。因此,抓包权限要谨慎开通,避免被人当成连接攻击的肉鸡。
1.3. 初始化参数
三次握手信息,还包括下面的初始化信息:
1、窗口大小
2、最大分段大小
3、 selective 选择性确认算法
4、 Window scale 参数
5、 Timestamp 信息
具体见下图
为了更加有效传输数据,在三次握手的前两个阶段,客户端和服务器端互相发送了窗口大小,告诉对方本方的接收能力,以防对端发送数据过多。客户端和服务器端互相发送最大分段大小,是为了告诉对方数据包的最大大小(MSS 推断出 MTU),防止数据包无法传递和减少拆分。双方协商是否采用选择性确认算法,提升 ack 确认数据的效率,防止大量低效率重传。window scale 用来扩大窗口大小,解决 TCP 字段窗口 65535 大小的限制。这些协商过程在三次握手阶段完成。
Timestamp 信息在连接建立阶段会试探性协商,一旦协商成功,后续过程会一直传递 timestamp 信息。
下图展示了客户端尝试发送 timestamp 信息给服务器端。
第二次握手数据包不仅发送了服务器的 timestamp 信息,也回送了客户端的 timestamp 信息,具体见下图。
因此,三次握手过程不仅是建立信任的过程,也是参数协商的过程。
1.4. 为什么不选择二次握手?
从协议设计角度来看,两次握手简单高效;但从安全角度来看,二次握手设计是有缺陷的。上图展示了两次握手过程,对于客户端来说,服务器是可信任的,因为服务器进行了 ack 确认,而对于服务器来说,客户端没有进行任何确认。这样的设计在安全方面有着非常致命的缺陷,因为攻击者可以随意模拟客户端来攻击服务器。
互联网存在着大量带有邪恶目的第一次握手数据包(此类数据包远超游荡的第一次握手重传数据包),如果采用二次握手过程,很容易导致服务器连接爆满,而利用三次握手,可以很好地屏蔽这类问题,这是三次握手最大的价值所在。
2、连接建立与应用的关系
2.1 对应关系
连接建立与应用之间的关系,大概可以通过下图来表示:
我们可以通过 syn backlog 和 somaxconn 系统参数来影响建立连接的过程。syn backlog 用来设置 syn 队列的长度(假设没有设置 syncookies),而 somaxconn 用来设置 accept queue(已完成连接的队列)的长度。用户态程序也可以在调用 listen 函数的时候设置 backlog 来影响 accept queue。
2.2 实验 syncookies 关闭状态下的 syn backlog
首先要确认 syncookies 关闭,如下图:
然后我们通过下图命令修改 syn backlog 为 2,也即 syn queue 只能容纳两个连接的 syn 连接请求(第一次握手数据包对应 syn backlog)。
我们利用 tcpburn(具体用法参考 tcpburn 章节)来发起 1024 个连接 syn 请求(第一次握手数据包)。
下图中只有 3 个 syn 请求被服务器 TCP 接收,其它都因为 queue overflow 被服务器 TCP 丢弃。
从这里可以看出,在数量比较小的情况下,能够接纳的 syn 数量=syn backlog +1
把 syn backlog 增大为 32 个,会怎么样呢?
下图发送 100 个并发 syn 过去
结果只有 25 个成功放入 syn 队列,为什么呢?
查看 Linux 源代码。
上述箭头所指公式:syn backlog – queue len < syn backlog / 4。
我们设置了 syn_backlog=32, syn_backlog >> 2 为 8,算式如下:
32 - queue len < 8
即 queue len > 24 会去执行 tcp_peer_is_proven,意味着 queue len 为 25 的时候,执行 tcp_peer_is_proven,由于后续 syn 数据包没有通过检测都被 TCP 丢弃了,所以我们看到下图只有 25 个 TCP 状态。
下图展示了客户端 tcpburn 发送了 syn 数据包,一旦被服务器 TCP 接收,就会有第二次握手数据包(SYN,ACK) 。
下图 syn 数据包没有被接收,tcpburn 客户端只能重传。
我们继续扩大 syn backlog 的大小,如下图,配置了 128 大小。
我们发送 1024 个 syn 连接请求。
下图成功了 97 个。
根据算法:syn backlog – queue len < syn backlog / 4 满足这个条件就会去执行 tcp_peer_is_proven(一旦执行,tcpburn syn 连接数据包都会被 drop 掉)。
128 – 97 = 31 < 128/4=32,这里 97 正好是没有执行 tcp_peer_is_proven 的最大 queue len 的长度。
2.3 实验 syncookies 开启状态下的 syn backlog
首先,开启 syncookies(防止 syn flood 攻击的一种手段,默认开启)。
设置 syn backlog 为 2。
利用 tcpburn 发起 syn 请求。
结果显示 syn queue 长度为 255,如下图。
我们继续增大 syn backlog 为 128
继续使用 tcpburn 来发起 syn 请求。
显示还是 255。
继续增大到 1024 大小
显示还是 255,说明跟 syn backlog 设置没有关系。
我们查看 netstat -st 结果,下图是测试之前的值(4105 syn cookies sent)。
上图是测试以后的值(5131 syn cookies sent)。
这说明如果 syncookies 启用了,那么 syn backlog 相关设置都会被忽略,这与如下的官方说明是一致的:
“When syncookies are enabled,there is no logical maximum length and this setting is ignored.
2.4 实验 somaxconn
官方对 listen backlog 参数说明如下:
If the backlog argument is greater than the value in
/proc/sys/net/core/somaxconn, then it is silently truncated to that value; the default value in this file is 128.
我们实验一下是否真是这样呢?
查看系统默认 somaxconn 的值,如下图:
同时查看 syn backlog 的值,默认也是 128。
我们把 somaxconn 修改为 2,如下图:
在 Linux 系统,Nginx 默认的 listen backlog 值为 511,如下图:
修改 Nginx 代码,让 Nginx 不去接收新连接,如果 accept queue 足够大,这些连接会被阻塞在 accept queue。
启动 Nginx,监听 8080 端口。
启动抓包,如下图:
利用 telnet 来进行对 Nginx 的访问,下面三个 telnet 实例都成功了。
第四个 telnet 实例没有成功。
最终报连接超时,如下图:
分析抓包情况,最后一个会话 6 次重传第一次握手数据包,最终仍没有被 TCP 接收。
我们设置了 somaxconn 为 2,按道理只能接收 2 个 telnet 连接请求,为什么是 3 呢?
我们从 Linux 源代码找线索:
上图中,如果 backlog 设置大于系统设置 somaxconn,则会被截断为系统参数 somaxconn 的值。
对 Nginx 的监听 socket,如上图,设置 sk_max_ack_backlog 为 backlog(被截断过的值)。
继续分析代码,如上图,如果判断 accept queue 为满,则会走 overflow 过程。
判断 queue 是否 full,是由 sk_acceptq_is_full 函数来做的,这个函数体如下:
只有超过了 sk_max_ack_backlog 的值才会报 overflow,也即 sk_max_ack_backlog+1 才能走 drop 过程。
综合上述内容,我们可以看出 accept queue 能够存放的连接数量为:
min(应用层 backlog,系统层 somaxconn) +1
我们根据上述公式继续做实验。
修改 Nginx listen backlog 为 2
修改系统参数 somaxconn=4
根据公式计算出 min(4,2)+ 1 = 3
我们进行测试,发现确实只有 3 个连接建立成功,下图 ESTABLISED 状态的个数为 3。
我们恢复 Nginx listen backlog 为 511。为什么 Nginx 选择 511 而不是 512?原因可以用上述公式解释,Nginx 选择 511,即 accept queue 能够最多存放 512 个连接。
设置系统参数 somaxconn 为 128
利用 tcpburn 发送 129 个用户连接
建立连接后,利用 telnet 来访问 Nginx 服务,如下图,最终显示连接超时。
利用 netstat 查看成功建立连接的数量,如下图,是 129,其它状态的连接没有。
如果我们模拟 132 个连接呢?
我们分析抓包文件,累计有 129 个连接成功,如下图。
tcp.stream eq 129(从 0 开始计算),即第 130 个 TCP 会话比较特殊,看似完成三次握手过程,其实连接根本就没有建立,因为后续过程在重传第二次握手数据包和第三次握手数据包。
从 131 个 TCP 连接请求开始,均是如下图。
我们查看 TCP 状态,显示有 130 个 TCP 状态,其中 129 个 ESTABLISHED 状态和 1 个
SYN_RECV 状态。tcpburn 发起的连接请求与 telnet 的主要区别是 IP 地址不一样,tcpburn 用了 192.168.100.130 地址,而 telnet 用了 172.17.0.3 地址。
我们继续扩大试验范围:
增大系统参数 somaxconn=1024
按照公式,最大支持 min(511, 1024) + 1 =512
我们发起 512 个连接请求
下图显示 512 个连接成功建立。
利用 telnet 去建立连接,最终显示连接超时。
查看 TCP 状态,telnet 的 TCP 状态为空,如下图。
从中可以看出 telnet 发起的连接,没有 TCP 状态。
我们利用 tcpburn 进一步试验:
尝试 1024 个连接
除了成功建立 512 个连接,还有两个处于 SYN_RECV 状态。
从上面可以看出,不同 IP 地址访问,Linux 会有细节上的不同。这也说明了 TCP 的复杂性。
我们查看 SYN cookies 值有没有发生变化。
上图是实验之前的值,下图实验之后的值,我们发现 SYN cookies 值没有变化。
这说明,在我们实验环境下,内核对正常连接并没有去发送 SYN cookies。
3、案例分析
3.1 Nginx 乌龙事件
实验 Nginx 0.9.4 版本,Nginx listen backlog 在 Linux 环境下默认为 511,系统 somaxconn 默认为 128,利用 tcpburn 测试(具体见下图)。
得到的结果如下:
能够成功建立 129 个连接。
接着我们实验 Nginx 0.9.5 版本,此版本修改了 listen backlog,从 511 改为了-1(系统默认的 backlog)。
我们利用 tcpubrn 发起连接请求,如下:
测试结果也是 129 连接。
看似 Nginx 对 listen backlog 的修改没有问题。
我们查看 Nginx 发布的版本日志:
从上面可以看出 Nginx 作者感谢了用户的建议。
然而到了 Nginx 1.0.1 版本,Nginx -1 改回到了 511,到底发生了什么呢?
利用 tcpcopy 对我们的某个系统进行了测试,测试结果如下:
141 机器是线上机器,而 148 机器是测试机器。
在该测试中,Nginx 0.9.5~1.0.0 版本吞吐量下降厉害,跟 Nginx 变动正好吻合。
下图是 Nginx 作者对用户的答疑。
采用 511 是比较安全的限制,在 Linux 系统改回-1,会导致某些系统吞吐量下降。
这个案例说明了系统的复杂性。不同环境下,情况是不一样的。需要实际环境中实际测试,才能少犯错误。
3.2 localhost 连接超时
我们进行如下实验,启动 Nginx 8080 端口服务,但不去 accept 用户连接。
利用 tcpburn 来发起连接请求
这里显示成功建立了 129 个连接
我们利用 telnet 进行本地 localhost 访问,结果如下图:
访问 127.0.0.1 居然连接超时了,为什么呢?
抓包文件如下:
由于 Nginx 没有及时接收连接,导致 accept queue 满了。
Localhost 访问 8080 服务,由于 syn queue 大小受制于 accept queue 大小,且受到 tcpburn syn 冲击,导致 localhost 的 syn 请求不能放入 syn queue 队列,syn 数据包也重传了 6 次,最终报连接超时。
从这个案例可以看出,只要服务器不接收连接或者接收连接速度慢,就可能导致服务不可用(localhost 都无法建立连接),由此可见连接攻击的可怕性。
4、总结
连接建立的过程看似简单,但其实作用巨大。三次握手是互联网 TCP/IP 协议中至关重要的设计。
连接建立过程与应用之间的关系很紧密,需要具体问题具体分析,不能犯经验主义或者片面主义错误。
评论