内核一个 IPv6 socket 的插入顺序修改引入的 bug
最近同事发现,当 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):
- 收到一个 SYN 报文,它搜索 ESTABLISHED 表, 没有找到匹配的 sock, 然后搜索 LISTEN 表,找到了一个匹配的 listen sock (状态为 LISTEN)
- 创建一个 request sock ,插入 ESTABLISHED 表, 回复 SYNACK 报文,此时 request sock 的状态为 (NEW_SYN_RECV)
- 收到第三次握手的 ACK 报文,从 ESTABLISHED 表找到了 request sock,创建新的 child sock, 加入 ESTABLISHED 表 (状态为 ESTABLISHED),删除 request sock
而在 SYN-Cookie 生效时,上述步骤变为
- 收到一个 SYN 报文,它搜索 ESTABLISHED 表, 没有找到匹配的 sock, 然后搜索 LISTEN 表,找到了一个匹配的 listen sock (状态为 LISTEN)
- 回复一个特别的 SYNACK (序列号经过精心计算),本地不创建任何资源(request sock)
- 收到第三次握手的 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 嘛…