测了一次tcp syncookie的抗D性能,发现了一件有趣的事情,周末写一篇随笔出来。
请看下面的时序:
简单讲就是在syncookie被触发的时候,客户端可能会被静默丢掉最多3个字节,所谓静默就是客户端认为这些字节被收到了(因为它们被确认了),然而服务端真真切切没有收到。
关于这个POC也非常简单:
//$ cat poc.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <pthread.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
void *serverfunc(void *arg)
{
int sd = -1;
int csd = -1;
struct sockaddr_in servaddr, cliaddr;
int len = sizeof(cliaddr);
sd = socket(AF_INET, SOCK_STREAM, 0);
servaddr.sin_family = AF_INET;
servaddr.sin_addr.s_addr = htonl(INADDR_ANY);
servaddr.sin_port = htons(1234);
bind(sd, (struct sockaddr *)&servaddr, sizeof(servaddr));
listen(sd, 1);
while (1) {
char buf[2];
int ret;
csd = accept(sd, (struct sockaddr *)&cliaddr, &len);
memset(buf, 0, 2);
ret = recv(csd, buf, 1, 0);
// but unexpected char is 'b'
if (ret && strncmp(buf, "a", 1)) {
printf("unexpected:%s\n", buf);
close(csd);
exit(0);
}
close(csd);
}
}
void *connectfunc(void *arg)
{
struct sockaddr_in addr;
int sd;
int i;
for (i = 0; i < 500; i++) {
sd = socket(AF_INET, SOCK_STREAM, 0);
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr("127.0.0.1");
addr.sin_port = htons(1234);
connect(sd, (struct sockaddr *)&addr, sizeof(addr));
send(sd, "a", 1, 0); // expected char is 'a'
send(sd, "b", 1, 0);
close(sd);
}
return NULL;
}
int main(int argc, char *argv[])
{
int i;
pthread_t id;
pthread_create(&id, NULL, serverfunc, NULL);
sleep(1);
for (i = 0; i < 500; i++) {
pthread_create(&id, NULL, connectfunc, NULL);
}
sleep(5);
}
//$ sudo gcc poc.c -lpthread
//$ sudo sysctl -w net.ipv4.tcp_syncookies=1
//$ sudo sysctl -w net.ipv4.tcp_max_syn_backlog=2 # just for triggering problems easily.
//$ sudo ./a.out # please try as many times.
我是怎么发现这个问题的呢?也比较有趣。
一开始我是想替换syncookie的hash算法的,我知道以前这个是SHA-1,性能比较低,所以我们自己在3.10内核上换成了jhash,现在我们用5.4内核,我又手痒了,也想换成jhash,在换之前review代码的时候发现已经变成siphash了,所以我就想测下siphash和jhash的性能对比,于是我把syncookie这块逻辑整个拷贝到了用户态程序:
#include <stdio.h>
#include <stdlib.h>
#include <linux/types.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#define COOKIEBITS 24 /* Upper bits store count */
#define COOKIEMASK (((__u32)1 << COOKIEBITS) - 1)
#define MAX_SYNCOOKIE_AGE 2
static __u32 cookie_hash(__be32 saddr, __be32 daddr, __be16 sport, __be16 dport,
__u32 count, int c)
{
// jhash or siphash
return saddr + daddr + sport + dport + count + c;
}
static __u32 secure_tcp_syn_cookie(__be32 saddr, __be32 daddr, __be16 sport,
__be16 dport, __u32 sseq, __u32 data, __u32 count)
{
/*
* Compute the secure sequence number.
* The output should be:
* HASH(sec1,saddr,sport,daddr,dport,sec1) + sseq + (count * 2^24)
* + (HASH(sec2,saddr,sport,daddr,dport,count,sec2) % 2^24).
* Where sseq is their sequence number and count increases every
* minute by 1.
* As an extra hack, we add a small "data" value that encodes the
* MSS into the second hash value.
*/
//__u32 count = tcp_cookie_time();
return (cookie_hash(saddr, daddr, sport, dport, 0, 0) +
sseq + (count << COOKIEBITS) +
((cookie_hash(saddr, daddr, sport, dport, count, 1) + data)
& COOKIEMASK));
}
static __u32 check_tcp_syn_cookie(__u32 cookie, __be32 saddr, __be32 daddr,
__be16 sport, __be16 dport, __u32 sseq, __u32 count)
{
__u32 diff;
/* Strip away the layers from the cookie */
cookie -= cookie_hash(saddr, daddr, sport, dport, 0, 0) + sseq;
/* Cookie is now reduced to (count * 2^24) ^ (hash % 2^24) */
diff = (count - (cookie >> COOKIEBITS)) & ((__u32) -1 >> COOKIEBITS);
if (diff >= MAX_SYNCOOKIE_AGE)
return (__u32)-1;
return (cookie -
cookie_hash(saddr, daddr, sport, dport, count - diff, 1))
& COOKIEMASK; /* Leaving the data behind */
}
int main(int argc, char **argv)
{
__u32 saddr, daddr;
__be16 sport, dport;
__u32 seq;
__u32 count;
__u32 mssid;
struct in_addr in_saddr, in_daddr;
int drop_count;
int cookie;
int result;
if (argc != 9) {
printf("./a.out saddr daddr sport dport seq count mssid drop_count(<=3)\n");
exit(1);
}
saddr = inet_addr(argv[1]);
in_saddr.s_addr = saddr;
daddr = inet_addr(argv[2]);
in_daddr.s_addr = daddr;
sport = atoi(argv[3]);
dport = atoi(argv[4]);
seq = atoi(argv[5]);
count = atoi(argv[6]);
mssid = atoi(argv[7]);
drop_count = atoi(argv[8]);
printf("syn:%s:%d-->%s:%d with mssid %d\n",
inet_ntoa(in_saddr),
sport,
inet_ntoa(in_daddr),
dport,
mssid);
cookie = secure_tcp_syn_cookie(saddr, daddr, sport, dport, seq, mssid, count);
printf("cookie:%d\n", cookie);
result = check_tcp_syn_cookie(cookie, saddr, daddr, sport, dport, seq + drop_count, count);
printf("result:%d\n", result);
}
当mssid是3的时候,seq可以越过最多3个字节。按照syncookie算法,mssid和seq都是直接加法拼接到cookie上去的,如果seq增加了1,2或者3字节,那么mssid相应减去1,2或者3就是了,而如果mss是1460(大概率是这个),它的index是3,那么当seq越过3个字节后,mssid就成了0,依然是符合的,这就是问题所在。
见招拆招的解法很简单,把seq也加入到hash运算里就是了:
- return (cookie_hash(saddr, daddr, sport, dport, 0, 0) +
+ return (cookie_hash(saddr, daddr, sport, dport, sseq, 0) +
...
- cookie -= cookie_hash(saddr, daddr, sport, dport, 0, 0) + sseq;
+ cookie -= cookie_hash(saddr, daddr, sport, dport, sseq, 0) + sseq;
如此一来,只有保序到达的才能成功建立连接,即便是客户端发出的前3个字节没有丢失但是乱序了,也无法建立连接,服务端收到任何seq错误的报文,均会RST掉连接。
这个解法有问题吗?跟社区的maintainer埃里克聊,埃里克站在practice的视角,认为这是用一个小代价换取了一个小收益,虽然静默丢字节不存在了,但也会误伤仅仅由于乱序而试图创建连接的session。所以字节丢失的问题应该由高层协议校验。
可我仔细一想,这不对呀,RST是一个明确的信息,客户端收到一个很明确的信息并没有什么问题,它知道自己建连失败了,然后它可能会重试,或者走人,但如果客户端发出了3个字节,并且服务端还都确认了,按照TCP的语义,这3个字节就是确实被服务端接收了的,然而事实上服务端并没有接收了,this could cause confusion。
字节丢失当然能由高层协议校验,事实上TCP连保序重传都不用做,这些都可以通过高层协议完成。事实上,这里无关HTTPS,SSL,TLS,这里和安全攻击无关,这里仅仅是在说, 在syncookie触发的时候,该不该兑现TCP的承诺。
我认为任何时候都应该兑现承诺,可以明确RST掉session,但不能有歧义。
在想到将seq参与hash运算解决这个问题之前,还有另一个解法,事实上是一个缓解方法。仅仅针对mss为1460字节的连接防静默丢弃:
1460 is the single most frequently announced mss value (30 to 46% depending on monitor location).
修改很简单, 只需要把msstab倒序就好了 。因为我们只需要让1460在msstab中的index是0就可以了,当然如果syn报文中的mss是536,那还是可能丢失最多3个字节的。但还是会有reorder后被RST的问题。
So the question is, when syncookie is triggered, which is more important, the practice or the principle?
埃里克说用sysctl来控制会比较好,但我还是觉得,这是一个feature吗?这并不是非此即彼的,在我看来运维并没有能力去控制这个开关。
反转到另一个话题,如果syncookie被触发了,抗D的责任,在内核协议栈吗?
我倾向于syncookie只是一个告警机制,而不是常态,一旦syncookie被触发,运维应该第一时间获取信息,然后采取动作,而不是空留内核自己在那里抗D,基于此,我认为hash算法的安全性并不重要,jhash完全可以胜任,SHA-1,MD5这种完全就没有必要,至于siphash,和jhash还是没法比。
有篇文章希望在内核推广siphash:
https://lwn.net/Articles/711167/
很明显,事情过头了,jhash目前并没有看出有什么大的问题,仅仅是因为siphash 被证明更安全 就要被替换,那效率呢?好吧,谈到效率,halfsiphash出来了,总之都是买卖,直接jhash不好吗?想想也是够了。大卫米勒的态度多少显得有点被迫。
就像maintainer埃里克说的那样,用一点小代价换一点小收益这种买卖在内核社区还少吗? 能不能做成这笔买卖的核心在于看摆摊的是谁。 有点意思。换个人摆摊,买卖就做成了。
浙江温州皮鞋湿,下雨进水不会胖。