Switch-Router

内核一个 IPv6 socket 的插入顺序修改引入的 bug

Published at 2020-10-23 | Last Update 2020-10-23

最近同事发现,当 IPv6 服务端收到大量 SYN 报文时,如果内核参数 tcp_synkooie = 1,可能出现内核重复创建 ESTABLISHED socket,然后导致应用程序多次 accept 的问题。翻了一下内核的 git 修改记录,最终确认这是这是一个在 2016 年中引入patch1patch2,又在 2017 年末修复的问题patch-fix

本文仅作记录。

前置知识—TCP 连接建立的细节

TCP 用四元组来区分不同的连接,对内核(4.9.29 版本)来说,它还会将 sock 套接字按照 TCP 状态( LISTEN 或者 ESTABLISHED )放在两个独立的 hash 表中。 而当内核 TCP 收到一个报文,它总会首先在搜索是否有匹配的 ESTABLISHED 状态的 sock,如果没有,再去搜索 LISTEN 状态的。

struct inet_hashinfo tcp_hashinfo    
    +-------------------+               struct inet_ehash_bucket
    |                   |         +-------->+----------+
    +-------------------+         |         |   [0]    |----->+---------+--->+---------+---->
    |     ehash         |---------+         +----------+      | sock #1 |    | sock #2 |
    +-------------------+                   |   [1]    |      +---------+    +---------+
    |     ......        |                   +----------+
    +-------------------+                   |   [2]    |----->+---------+
    |                   |                   +----------+      | sock #3 | 
    +-------------------+                                     +---------+
    |                   +
    +-------------------+               struct inet_listen_hashbucket
    |  listening_hash   |------------------>+----------+        
    +-------------------|                   |   [0]    |----->+---------+--->+---------+---->
    |    ......         |                   +----------+      | sock #4 |    | sock #5 |
    +-------------------+                   |   [1]    |      +---------+    +---------+
                                            +----------+
                                            |   [2]    |
                                            +----------+

下面是内核一个普通的 TCP 连接的建立步骤 (不使用 SYN-Cookie):

  1. 收到一个 SYN 报文,它搜索 ESTABLISHED 表, 没有找到匹配的 sock, 然后搜索 LISTEN 表,找到了一个匹配的 listen sock (状态为 LISTEN)
  2. 创建一个 request sock ,插入 ESTABLISHED 表, 回复 SYNACK 报文,此时 request sock 的状态为 (NEW_SYN_RECV)
  3. 收到第三次握手的 ACK 报文,从 ESTABLISHED 表找到了 request sock,创建新的 child sock, 加入 ESTABLISHED 表 (状态为 ESTABLISHED),删除 request sock

而在 SYN-Cookie 生效时,上述步骤变为

  1. 收到一个 SYN 报文,它搜索 ESTABLISHED 表, 没有找到匹配的 sock, 然后搜索 LISTEN 表,找到了一个匹配的 listen sock (状态为 LISTEN)
  2. 回复一个特别的 SYNACK (序列号经过精心计算),本地不创建任何资源(request sock)
  3. 收到第三次握手的 ACK 报文,搜索 ESTABLISHED 表,找不到,然后搜索 LISTEN 表,找到 listen sock 进行 SYN-Coookie 检查,检查通过后,创建 child sock,加入 ESTABLISHED 表 (状态为 ESTABLISHED)

而 SYN-Cookie 功能生效由内核 tcp_synkooie 确定:

  • 值为 0 :始终不生效
  • 值为 1 :一般情况下不生效,但当 listen sock 的 accept 队列满时(应用程序没有及时使用 accpet()) 生效。
  • 值为 2 :始终生效

reuseport 进入内核之后

关于 reuseport, 在https://switch-router.gitee.io/blog/tcp-listener/已经大概说过其历史了。本文开头提到的 2016 年的修改也与这个有关。

怎么回事呢?先来看看patch2的修改

diff --git a/net/ipv4/inet_hashtables.c b/net/ipv4/inet_hashtables.c
index fcadb67..b76b0d7 100644
--- a/net/ipv4/inet_hashtables.c
+++ b/net/ipv4/inet_hashtables.c
@@ -479,7 +479,11 @@ int __inet_hash(struct sock *sk, struct sock *osk,
 		if (err)
 			goto unlock;
 	}
-	hlist_add_head_rcu(&sk->sk_node, &ilb->head);
+	if (IS_ENABLED(CONFIG_IPV6) && sk->sk_reuseport &&
+		sk->sk_family == AF_INET6)
+		hlist_add_tail_rcu(&sk->sk_node, &ilb->head);
+	else
+		hlist_add_head_rcu(&sk->sk_node, &ilb->head);
 	sock_set_flag(sk, SOCK_RCU_FREE);
 	sock_prot_inuse_add(sock_net(sk), sk->sk_prot, 1);
 unlock:

这是将 listen sock 插入 LISTEN 表时的操作,它修改了 IPv6 使能了 reuseport 时的插入顺序,这种情况下插入到链表尾部。这么做的原因在 log 中已经写了,这不是本文的重点,而且这本身没有问题。

重点在于patch1的修改

diff --git a/include/net/sock.h b/include/net/sock.h
index 255d3e0..121ffc1 100644
--- a/include/net/sock.h
+++ b/include/net/sock.h
@@ -630,7 +630,11 @@ static inline void sk_add_node_rcu(struct sock *sk, struct hlist_head *list)
 
 static inline void __sk_nulls_add_node_rcu(struct sock *sk, struct hlist_nulls_head *list)
 {
-	hlist_nulls_add_head_rcu(&sk->sk_nulls_node, list);
+	if (IS_ENABLED(CONFIG_IPV6) && sk->sk_reuseport &&
+	    sk->sk_family == AF_INET6)
+		hlist_nulls_add_tail_rcu(&sk->sk_nulls_node, list);
+	else
+		hlist_nulls_add_head_rcu(&sk->sk_nulls_node, list);
 }

它也改变了 IPv6 + reuseport 时的插入方式, 而它在 request sock 或者 child sock 插入到 ESTABLISHED 表中被调用(它们会继承 listen sock 的 reuseport 属性):

bool inet_ehash_insert(struct sock *sk, struct sock *osk)
{
	struct inet_hashinfo *hashinfo = sk->sk_prot->h.hashinfo;
	struct hlist_nulls_head *list;
	struct inet_ehash_bucket *head;
	
	sk->sk_hash = sk_ehashfn(sk);
	head = inet_ehash_bucket(hashinfo, sk->sk_hash);
	list = &head->chain;
	
	__sk_nulls_add_node_rcu(sk, list);

	return ret;
} 

这样做看上去没什么问题,但和 SYN-Cookie 结合起来就有了:

首先,内核 tcp_synkooie = 1,系统中某应用创建了一个使能了 reuseport 的 IPv6 listen 套接字。而同时,有多个客户端大量发送 SYN 报文发起连接。

  • Step1. 收到一个 SYN 报文,此时 listen 套接字的 accept 队列满了,因此 SYN-Cookie 生效,本端不再分配 request sock ,但还是会回复 SYNAC, 序列号为 seq1
  • Step2. 由于网络原因 ACK 一直未到达,本端又收到了重传的 SYN 报文,此时 accept 恰好没有满了,SYN-Cookie 不生效,本端创建 request sock,按照patch1的修改,内核将其插入到 ESTABLISHED 表的某条链表的尾部,然后回复 SYNACK,序列号为 seq2。此时 hash 表状态如下图(只画了某一条冲突链表)
ehash -> ... -> request sock

listening_hash -> listen sock
  • Step3. Step1 中的 ACK 姗姗来迟,查找 ESTABLISHED 表,找到 request sock,但序列号不对,然后内核将其交给其 listen sock 处理

  • Step4. listen sock 发现这个 ACK 能通过 SYN-Cookie 检查 (它的确能通过, 这本来就是 SYN-Cookie 的正常过程),于是完成三次握手,创建 child sock,将其插入链表尾部。此时状态如下:

ehash -> ... -> request sock -> child sock

listening_hash -> listen sock
  • Step5. 收到该连接发送的数据报文,本端查找 ESTABLISHED 表,此时虽然我是希望查找到 4 中创建的 child sock,但由于它在尾部,因此还是会找到 request sock,然后又发现序列号不对,将其交给 listen sock 处理
  • Step6. 重复 Step4,创建新的 child sock#2
ehash -> ... -> request sock -> child sock -> child sock#2

listening_hash -> listen sock

这就是问题所在!由于是插入到尾部,因此每次收到数据报文,都会创建新的 child sock !,表现在应用上,就是一直可以 accept 新的连接(显然是假的)

问题的修改

修改方式patch-fix很简单,撤销掉patch1就行,request sock 和 child sock 加入 hash 表时就始终加到链表头就完事儿了。

diff --git a/net/ipv4/inet_hashtables.c b/net/ipv4/inet_hashtables.c
index fcadb67..b76b0d7 100644
--- a/net/ipv4/inet_hashtables.c
+++ b/net/ipv4/inet_hashtables.c
@@ -479,7 +479,11 @@ int __inet_hash(struct sock *sk, struct sock *osk,
 		if (err)
 			goto unlock;
 	}
-	hlist_add_head_rcu(&sk->sk_node, &ilb->head);
+	if (IS_ENABLED(CONFIG_IPV6) && sk->sk_reuseport &&
+		sk->sk_family == AF_INET6)
+		hlist_add_tail_rcu(&sk->sk_node, &ilb->head);
+	else
+		hlist_add_head_rcu(&sk->sk_node, &ilb->head);
 	sock_set_flag(sk, SOCK_RCU_FREE);
 	sock_prot_inuse_add(sock_net(sk), sk->sk_prot, 1);

不过,针对该问题,我觉得还有一种思路,request sock 和 child sock 压根没有必要继承 reuesport 嘛…