0%

TCP同时打开-深度剖析

前言

在socket的编程中偶然发现了两端同时connect可以不经过listen-accept而建立tcp连接,然后去找了许多资料,发现这个现象叫同时打开,下面就来具体研究这个连接的建立情况。

同时打开需要去连接对方的端口,因此客户在connect之前需要bind一个端口。

参考资料:

在参考资料的基础上,整理了整个逻辑,并补充了序列号确认的流程和能同步的原因,以及syn丢包时能成功建立的原因。

connect与accept

我从tcp正常的建立说起。

在简单的服务器-客户端模型中,服务器在执行listen()方法之后还会执行一个accept()方法,并阻塞等待客户端连接。客户端在创建socket后可以调用connect()方法来连接服务器。这个过程中,TCP的三次握手究竟什么时候完成呢?

参考资料4中,作者在执行accept前进行sleep等待,让客户端直接去connect并抓包。作者的抓包结果如下:

img

从抓包结果看来,就算不执行accept()方法,三次握手照常进行,并顺利建立连接。


从listen开始说起,在服务器执行listen方法后,就会进入监听(LISTEN)状态,内核会为每个处于监听状态的socket分配两个队列:半连接队列和全连接队列。相信这两个队列大家也不陌生了。

  • 半连接队列(SYN队列),服务端收到第一次握手后,会将socket加入到这个队列中,队列内的socket都处于SYN_RECV 状态。然后发回syn+ack执行第二次握手。
  • 全连接队列(ACCEPT队列),在服务端收到第三次握手后,会将半连接队列的socket取出,放到全连接队列中。队列里的socket都处于 ESTABLISHED状态。这里面的连接,就等着服务端执行accept()后被取出了。

这就是说,accept实际上只是从全连接队列取出一条连接,不参与TCP三次握手的过程。

这两个队列将详细点,全连接队列(icsk_accept_queue)是个链表,而半连接队列(syn_table)是个哈希表

img

这是因为全连接队列只需要把头部的连接给accept即可,维护一个线性结构就可以了。

而半连接队列中的连接都不是完整的连接,在等待第三次握手的到来。当第三次握手到来时,要根据IP端口来看是哪个socket,这就需要哈希表查找。这样,两个队列是时间复杂度都是O(1)。

参考资料4还谈到了这两个队列满了会怎么样,不是本文重点,有兴趣的朋友可以去看看。

  • 全连接队列满了,再来第三次握手也会丢弃,此时如果tcp_abort_on_overflow=1,还会直接发RST给客户端。
  • 半连接队列满了,可能是因为受到了SYN Flood攻击,可以设置tcp_syncookies,绕开半连接队列。

connect是如何保存socket信息的

当服务端回复syn+ack时,客户端实际上也没有处于listen状态的套接字,没有半连接队列和全连接队列,但却可以完成三次握手。这意味着,客户端进行connect调用后,该套接字一定被加入到某个表中,并可以被匹配到。

跟踪内核源码可以看到当调用connect时,对应的套接字就被加入了全局的tcp已连接(established)的表中:

1
2
3
4
5
6
7
8
9
10
11
12
int tcp_v4_connect(struct sock *sk, struct sockaddr *uaddr, int addr_len)
{
...
/* Socket identity is still unknown (sport may be zero).
* However we set state to SYN-SENT and not releasing socket
* lock select source port, enter ourselves into the hash tables and
* complete initialization after this.
*/
tcp_set_state(sk, TCP_SYN_SENT);
err = inet_hash_connect(&tcp_death_row, sk);
...
}

注意这句注释 enter ourselves into the hash tables,在inet_hash_connect函数中,socket在调用connect的时候就会把自己加入到establish hash表,虽然它此时连syn都还没有发送。确切的讲,应该是在它调用的__inet_check_established函数中。

当内核收到syn+ack报文时,内核是先在established表中查找,再进行listen表的查找。对于客户端来说,syn+ack报文必然可以在已连接表中匹配上对应的套接字。

同时打开

现在进入本文的重点

同时打开连接是指通信的双方在接收到对方的SYN包之前,都进行了主动打开的操作并发出了自己的SYN包。如之前所说一个四元组标识一个TCP连接,因此如果一个TCP连接要同时打开需要通信的双方知晓对方的IP和端口信息才行,这种场景在实际情况中很少发生(NAT穿透中可能会多一些)。同时打开的流程如下图

image.png

状态转移

套接字有以下三种状态:

  • 在发送syn之后状态处于SYN_SENT状态;
  • SYN_RECV状态;
  • ESTABLISHED状态。

在正常的连接过程中,客户端向服务器在监听的端口发起connect,第一次握手发送syn后进入SYN_SENT状态,服务器发回syn+ack之后进入ESTABLISHED状态,服务器处的端口处于SYN_RECV状态,并在第三次握手中发回ack后进入ESTABLISHED状态。

SYN_RECV状态还可以是:处于SYN_SENT状态的套接字接收到syn后会进入的状态。这个过程中,客户发送syn并收到syn,就是一个同时打开的过程(双方同时connect)。

假设两台设备双方均发送syn给对端,理想情况下,在发送syn之后状态处于SYN_SENT状态,此时双方均收到对端的发来的syn,则立即进入SYN_RECV状态,并且都向对端回复syn+ack,在收到syn+ack之后,连接从SYN_RECV状态切换到ESTABLISHED状态。

在发送syn进入SYN_SENT状态之后,收到对端发来的syn包处理流程如下。如果收到syn,进入SYN_RECV状态。如果收到的是ack(syn+ack和ack两种),会进入另外的处理流程,稍后再说。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
static int tcp_rcv_synsent_state_process(struct sock *sk, struct sk_buff *skb,
const struct tcphdr *th)
{

struct inet_connection_sock *icsk = inet_csk(sk);
struct tcp_sock *tp = tcp_sk(sk);
struct tcp_fastopen_cookie foc = { .len = -1 };
int saved_clamp = tp->rx_opt.mss_clamp;
...
if (th->ack) { //包中带ACK标记,走此路径
...//另外的处理流程
return -1;
}
if (th->syn) {
/* We see SYN without ACK. It is attempt of
* simultaneous connect with crossed SYNs.
* Particularly, it can be connect to self.
*/
tcp_set_state(sk, TCP_SYN_RECV);

if (tp->rx_opt.saw_tstamp) {
tp->rx_opt.tstamp_ok = 1;
tcp_store_ts_recent(tp);
tp->tcp_header_len =
sizeof(struct tcphdr) + TCPOLEN_TSTAMP_ALIGNED;
} else {
tp->tcp_header_len = sizeof(struct tcphdr);
}

tp->rcv_nxt = TCP_SKB_CB(skb)->seq + 1;
tp->copied_seq = tp->rcv_nxt;
tp->rcv_wup = TCP_SKB_CB(skb)->seq + 1;

/* RFC1323: The window in SYN & SYN/ACK segments is
* never scaled.
*/
tp->snd_wnd = ntohs(th->window);
tp->snd_wl1 = TCP_SKB_CB(skb)->seq;
tp->max_window = tp->snd_wnd;

tcp_ecn_rcv_syn(tp, th);

tcp_mtup_init(sk);
tcp_sync_mss(sk, icsk->icsk_pmtu_cookie);
tcp_initialize_rcv_mss(sk);

tcp_send_synack(sk);
}

这个负责发送SYN+ACK的函数tcp_send_synack有些特殊:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
int tcp_send_synack(struct sock *sk)
{
struct sk_buff *skb;

skb = tcp_write_queue_head(sk);
if (skb == NULL || !(TCP_SKB_CB(skb)->tcp_flags & TCPHDR_SYN)) { //包一定携带SYN标记
pr_debug("%s: wrong queue state\n", __func__);
return -EFAULT;
}
if (!(TCP_SKB_CB(skb)->tcp_flags & TCPHDR_ACK)) { //未携带ACK标记
if (skb_cloned(skb)) {
struct sk_buff *nskb = skb_copy(skb, GFP_ATOMIC);
if (nskb == NULL)
return -ENOMEM;
tcp_unlink_write_queue(skb, sk);
skb_header_release(nskb);
__tcp_add_write_queue_head(sk, nskb);
sk_wmem_free_skb(sk, skb);
sk->sk_wmem_queued += nskb->truesize;
sk_mem_charge(sk, nskb->truesize);
skb = nskb;
}

TCP_SKB_CB(skb)->tcp_flags |= TCPHDR_ACK; //加上ACK标记
TCP_ECN_send_synack(tcp_sk(sk), skb);
}
TCP_SKB_CB(skb)->when = tcp_time_stamp;
return tcp_transmit_skb(sk, skb, 1, GFP_ATOMIC); //发送出去
}

在同时打开的情况下,发送队列中已经有一个SYN包等待确认。tcp_send_synack的基本功能是:将发送队列中的SYN包加上ACK标记位再发送。这样TCP收到SYN后发送的SYN+ACK的序列号与最开始发送的SYN包一致,ack的确认号是对方syn的序列号+1,连接就可以在收到对端的SYN+ACK后得以正常建立。


在SYN_RECV状态收到对端发来的syn+ack包,则直接进入ESTABLISHED已连接状态:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
int tcp_rcv_state_process(struct sock *sk, struct sk_buff *skb)
{
/* step 5: check the ACK field */
acceptable = tcp_ack(sk, skb, FLAG_SLOWPATH |
FLAG_UPDATE_TS_RECENT) > 0;

switch (sk->sk_state) {
case TCP_SYN_RECV:

/* ack处理失败 */
if (!acceptable)
return 1;

/* RTT */
if (!tp->srtt_us)
tcp_synack_rtt_meas(sk, req);

/* Once we leave TCP_SYN_RECV, we no longer need req
* so release it.
*/
if (req) {
inet_csk(sk)->icsk_retransmits = 0;
reqsk_fastopen_remove(sk, req, false);
} else {
/* Make sure socket is routed, for correct metrics. */
/* 检查重建路由 */
icsk->icsk_af_ops->rebuild_header(sk);
/* 初始化拥塞邋控制 */
tcp_init_congestion_control(sk);
/* 路径mtu发现初始化 */
tcp_mtup_init(sk);
/* 用户待读取数据初始化 */
tp->copied_seq = tp->rcv_nxt;
/* 调整接收发送缓存以及窗口等 */
tcp_init_buffer_space(sk);
}
smp_mb();

/* 连接更新为已连接状态 */
tcp_set_state(sk, TCP_ESTABLISHED);
sk->sk_state_change(sk);
/* 省略了一些代码 */
}

序列号确认

如流程图所示,假设客户端A的syn序列号为x,B的syn序列号为y。双方收到对方的syn时,发送的syn+ack序列号为,A:syn-x+ack-y+1;B:syn-y+ack-x+1

这样,A收到B的syn+ack时,A知道这个是B过来的(syn还是y);并且知道B收到的就是自己的syn(ack确认号是x+1),至此双方序列号就完成了同步。

在这个过程中,有三种情况被其他客户端干扰:

  • 如果客户端C伪造syn-z发给A,A发给B的syn-ack确认号就是z+1,B就知道不对了;
  • 如果客户端C伪造syn-z+ack-n发给了A,A看到syn是z而不是B的y就直接丢弃了。
  • 如果客户端C两个报文都发给A,这样A查看syn+ack时发现syn是z,还要进一步查看ack,发现ack不是自己的x+1,就知道是错误了。

syn丢包

在双方交换四个数据包的过程中,可能会发生丢包。有四种情况:

  • 客户AB向对方发送syn,进入SYN_SEND状态,两个包都丢了,超时重传。

  • 客户AB向对方发送syn,但B的syn包丢了。这时AB都在SYN_SENT状态,B收到A的syn后进入SYN_RECV状态,然后发送syn+ack。
    • A收到syn+ack时还在SYN_SENT状态,注意到源码中有另一条“ack”的处理路径。
    • 实际上客户A在发送syn后收到syn+ack是与listen中的服务器正常建立连接的过程(第二次握手),A并不知道对方是服务器还是客户端,B发的syn-y+ack-x+1中的y在A看来就是服务器的初始序列号。此时A会返回ack并进入ESTABLISHED状态。其中ack的序列号是y+1。
    • B收到ack后发现没有syn,检查ack序列号正确,就知道是自己的syn包丢了,进入ESTABLISHED状态,连接建立完成。
  • 这个过程由于B的syn包丢了,所以实际上变成了client-server模型,退化为正常的三次握手。

  • 客户AB向对方发送syn都收到了,AB都进入SYN_RECV状态,发送syn+ack,如果两个包都丢了,超时重传。

  • 客户AB向对方发送syn都收到了,AB都进入SYN_RECV状态,发送syn+ack。
    • 假设B发送的包丢了,这时B收到syn+ack直接进入ESTABLISHED状态。
    • A一直在等待syn+ack,当超时时,A会猜测是自己的syn丢了(且syn+ack也丢了,因为syn+ack没丢的话就是第二种情况client-server)或是对方的syn+ack丢了。
    • 此时如上所述,A在SYN_RECV状态超时会重传syn+ack,对于A来说,B没发回syn+ack可能有两种情况:
      • 如果B在ESTABLISHED状态,知道自己syn+ack丢了,发回syn+ack让A进入ESTABLISHED状态。
      • 如果B根本就没收到syn(B知道自己发了syn),此时退化为client-server模型,发回ack。

至此,连接建立完成。

同时关闭

同时关闭的流程和四次挥手差不多

image.png

总结

  • 客户端在connect后在内核中会把套接字加入已连接哈希表,收到syn+ack报文先查已连接哈希表再查listen半连接哈希表
  • 客户端同时打开时,双方会进行syn、syn+ack四种报文的发送接收,在三种状态中转移:发送syn的SYN_SENT状态、接收syn发送syn+ack的SYN_RECV状态、接收syn+ack的ESTABLISHED状态。
  • 序列号的同步中,syn发的序列号是随机初始序列号,syn+ack回复的序列号是syn的序列号(标识了该包是自己发的),确认号是对方syn序列号+1(标识了对方的包自己已经收到)。收到syn+ack后会检测序列号(该包是否是发syn的对方发来的)以及确认号(该包是否是自己想连接的发了syn过去的对方发来的)。
  • syn丢包中,如果有一个对方丢了syn包,就退化成client-server模型;如果有一个对方丢了syn+ack包,自己会重传syn+ack,对方要么重传syn+ack(对方的syn+ack丢了),要么重传ack(自己的syn丢了,退化为client-server模型)。