Linux 内核 TCP MSS 机制详细分析
作者:Hcamael@知道创宇 404 实验室
时间:2019 年 6 月 26 日
英文版本:https://paper.seebug.org/967/
前言
上周Linux内核修复了4个CVE漏洞[1],其中的CVE-2019-11477感觉是一个很厉害的Dos漏洞,不过因为有其他事打断,所以进展的速度比较慢,这期间网上已经有相关的分析文章了。[2][3]
而我在尝试复现CVE-2019-11477漏洞的过程中,在第一步设置MSS的问题上就遇到问题了,无法达到预期效果,但是目前公开的分析文章却没对该部分内容进行详细分析。所以本文将通过Linux内核源码对TCP的MSS机制进行详细分析。
测试环境
1. 存在漏洞的靶机
操作系统版本:Ubuntu 18.04
内核版本:4.15.0-20-generic
地址:192.168.11.112
内核源码:
1 2 |
$ sudo apt install linux-source-4.15.0 $ ls /usr/src/linux-source-4.15.0.tar.bz2 |
带符号的内核:
1 2 3 4 5 |
$ cat /etc/apt/sources.list.d/ddebs.list deb http://ddebs.ubuntu.com/ bionic main deb http://ddebs.ubuntu.com/ bionic-updates main $ sudo apt install linux-image-4.15.0-20-generic-dbgsym $ ls /usr/lib/debug/boot/vmlinux-4.15.0-20-generic |
关闭内核地址随机化(KALSR):
1 2 3 4 5 |
# 内核是通过grup启动的,所以在grup配置文件中,内核启动参数里加上nokaslr $ cat /etc/default/grub |grep -v "#" | grep CMDLI GRUB_CMDLINE_LINUX_DEFAULT="nokaslr" GRUB_CMDLINE_LINUX="" $ sudo update-grub |
装一个nginx,供测试:
1 |
$ sudo apt install nginx |
2. 宿主机
操作系统:MacOS
Wireshark:抓流量
虚拟机:VMware Fusion 11
调试Linux虚拟机:
1 2 |
$ cat ubuntu_18.04_server_test.vmx|grep debug debugStub.listen.guest64 = "1" |
编译gdb:
1 2 3 4 5 |
$ ./configure --build=x86_64-apple-darwin --target=x86_64-linux --with-python=/usr/local/bin/python3 $ make $ sudo make install $ cat .zshrc|grep gdb alias gdb="~/Documents/gdb_8.3/gdb/gdb" |
gdb进行远程调试:
1 2 3 4 5 6 7 8 9 |
$ gdb vmlinux-4.15.0-20-generic $ cat ~/.gdbinit define gef source ~/.gdbinit-gef.py end define kernel target remote :8864 end |
3. 攻击机器
自己日常使用的Linux设备就好了
地址:192.168.11.111
日常习惯使用Python的,需要装个scapy构造自定义TCP包
自定义SYN的MSS选项
有三种方法可以设置TCP SYN包的MSS值
1. iptable
1 2 3 4 |
# 添加规则 $ sudo iptables -I OUTPUT -p tcp -m tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 48 # 删除 $ sudo iptables -D OUTPUT -p tcp -m tcp --tcp-flags SYN,RST SYN -j TCPMSS --set-mss 48 |
2. route
1 2 3 4 5 6 7 |
# 查看路由信息 $ route -ne $ ip route show 192.168.11.0/24 dev ens33 proto kernel scope link src 192.168.11.111 metric 100 # 修改路由表 $ sudo ip route change 192.168.11.0/24 dev ens33 proto kernel scope link src 192.168.11.111 metric 100 advmss 48 # 修改路由表信息就是在上面show的结果后面加上 advmss 8 |
3. 直接发包设置
PS:使用scapy发送自定义TCP包需要ROOT权限
1 2 3 4 |
from scapy.all import * ip = IP(dst="192.168.11.112") tcp = TCP(dport=80, flags="S",options=[('MSS',48),('SAckOK', '')]) |
flags选项S表示SYN
,A表示ACK
,SA表示SYN, ACK
scapy中TCP可设置选项表:
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 |
TCPOptions = ( { 0 : ("EOL",None), 1 : ("NOP",None), 2 : ("MSS","!H"), 3 : ("WScale","!B"), 4 : ("SAckOK",None), 5 : ("SAck","!"), 8 : ("Timestamp","!II"), 14 : ("AltChkSum","!BH"), 15 : ("AltChkSumOpt",None), 25 : ("Mood","!p"), 254 : ("Experiment","!HHHH") }, { "EOL":0, "NOP":1, "MSS":2, "WScale":3, "SAckOK":4, "SAck":5, "Timestamp":8, "AltChkSum":14, "AltChkSumOpt":15, "Mood":25, "Experiment":254 }) |
但是这个会有一个问题,在使用Python发送了一个SYN包以后,内核会自动带上一个RST包,查过资料后,发现在新版系统中,对于用户发送的未完成的TCP握手包,内核会发送RST包终止该连接,应该是为了防止进行SYN Floor攻击。解决办法是使用iptable过滤RST包:
1 |
$ sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST -s 192.168.11.111 -j DROP |
对于MSS的深入研究
关于该漏洞的细节,别的文章中已经分析过了,这里简单的提一下,该漏洞为uint16溢出:
1 2 3 4 5 |
tcp_gso_segs 类型为uint16 tcp_set_skb_tso_segs<span class="token punctuation">:</span> <span class="token function">tcp_skb_pcount_set<span class="token punctuation">(</span></span>skb<span class="token punctuation">,</span> <span class="token function">DIV_ROUND_UP<span class="token punctuation">(</span></span>skb<span class="token operator">-</span>>len<span class="token punctuation">,</span> mss_now<span class="token punctuation">)</span><span class="token punctuation">)</span><span class="token punctuation">;</span> skb<span class="token operator">-</span>>len的最大值为<span class="token number">17</span> <span class="token operator">*</span> <span class="token number">32</span> <span class="token operator">*</span> <span class="token number">1024</span> mss_now的最小值为<span class="token number">8</span> |
1 2 3 4 |
>>> hex<span class="token punctuation">(</span><span class="token number">17</span><span class="token operator">*</span><span class="token number">32</span><span class="token operator">*</span><span class="token number">1024</span><span class="token operator">/</span><span class="token operator">/</span><span class="token number">8</span><span class="token punctuation">)</span> <span class="token string">'0x11000'</span> >>> hex<span class="token punctuation">(</span><span class="token number">17</span><span class="token operator">*</span><span class="token number">32</span><span class="token operator">*</span><span class="token number">1024</span><span class="token operator">/</span><span class="token operator">/</span><span class="token number">9</span><span class="token punctuation">)</span> <span class="token string">'0xf1c7'</span> |
所以在mss_now小于等于8时,才能发生整型溢出。
深入研究的原因是因为进行了如下的测试:
攻击机器通过iptables/iproute
命令将MSS值为48后,使用curl请求靶机的http服务,然后使用wireshark抓流量,发现服务器返回的http数据包的确被分割成小块,但是只小到36,离预想的8有很大的差距
这个时候我选择通过审计源码和调试来深入研究为啥MSS无法达到我的预期值,SYN包中设置的MSS值到代码中的mss_now的过程中发生了啥?
随机进行源码审计,对发生溢出的函数tcp_set_skb_tso_segs
进行回溯:
1 2 |
tcp_set_skb_tso_segs <- tcp_fragment <- tso_fragment <- tcp_write_xmit 最后发现,传入tcp_write_xmit函数的mss_now都是通过tcp_current_mss函数进行计算的 |
随后对tcp_current_mss
函数进行分析,关键代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 |
# tcp_output.c tcp_current_mss -> tcp_sync_mss: mss_now = tcp_mtu_to_mss(sk, pmtu); tcp_mtu_to_mss: /* Subtract TCP options size, not including SACKs */ return __tcp_mtu_to_mss(sk, pmtu) - (tcp_sk(sk)->tcp_header_len - sizeof(struct tcphdr)); __tcp_mtu_to_mss: if (mss_now < 48) mss_now = 48; return mss_now; |
看完这部分源码后,我们对MSS的含义就有一个深刻的理解,首先说一说TCP协议:
TCP协议包括了协议头和数据,协议头包括了固定长度的20字节和40字节的可选参数,也就是说TCP头部的最大长度为60字节,最小长度为20字节。
在__tcp_mtu_to_mss
函数中的mss_now
为我们SYN包中设置的MSS,从这里我们能看出MSS最小值是48,通过对TCP协议的理解和对代码的理解,可以知道SYN包中MSS的最小值48字节表示的是:TCP头可选参数最大长度40字节 + 数据最小长度8字节。
但是在代码中的mss_now表示的是数据的长度,接下来我们再看该值的计算公式。
tcphdr结构:
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 |
struct tcphdr { __be16 source; __be16 dest; __be32 seq; __be32 ack_seq; #if defined(__LITTLE_ENDIAN_BITFIELD) __u16 res1:4, doff:4, fin:1, syn:1, rst:1, psh:1, ack:1, urg:1, ece:1, cwr:1; #elif defined(__BIG_ENDIAN_BITFIELD) __u16 doff:4, res1:4, cwr:1, ece:1, urg:1, ack:1, psh:1, rst:1, syn:1, fin:1; #else #error "Adjust your <asm/byteorder.h> defines" #endif __be16 window; __sum16 check; __be16 urg_ptr; }; |
该结构体为TCP头固定结构的结构体,大小为20bytes
变量tcp_sk(sk)->tcp_header_len
表示的是本机发出的TCP包头部的长度。
因此我们得到的计算mss_now的公式为:SYN包设置的MSS值 - (本机发出的TCP包头部长度 - TCP头部固定的20字节长度)
所以,如果tcp_header_len
的值能达到最大值60,那么mss_now就能被设置为8。那么内核代码中,有办法让tcp_header_len
达到最大值长度吗?随后我们回溯该变量:
1 2 3 4 5 6 7 8 9 10 |
# tcp_output.c tcp_connect_init: tp->tcp_header_len = sizeof(struct tcphdr); if (sock_net(sk)->ipv4.sysctl_tcp_timestamps) tp->tcp_header_len += TCPOLEN_TSTAMP_ALIGNED; #ifdef CONFIG_TCP_MD5SIG if (tp->af_specific->md5_lookup(sk, sk)) tp->tcp_header_len += TCPOLEN_MD5SIG_ALIGNED; #endif |
所以在Linux 4.15内核中,在用户不干预的情况下,内核是不会发出头部大小为60字节的TCP包。这就导致了MSS无法被设置为最小值8,最终导致该漏洞无法利用。
总结
我们来总结一下整个流程:
- 攻击者构造SYN包,自定义TCP头部可选参数MSS的值为48
- 靶机(受到攻击的机器)接收到SYN请求后,把SYN包中的数据保存在内存中,返回SYN,ACK包。
- 攻击者返回ACK包
三次握手完成
随后根据不同的服务,靶机主动向攻击者发送数据或者接收到攻击者的请求后向攻击者发送数据,这里就假设是一个nginx http服务。
1. 攻击者向靶机发送请求:GET / HTTP/1.1
。
2. 靶机接收到请求后,首先计算出tcp_header_len
,默认等于20字节,在内核配置sysctl_tcp_timestamps
开启的情况下,增加12字节,如果编译内核的时候选择了CONFIG_TCP_MD5SIG
,会再增加18字节,也就是说tcp_header_len
的最大长度为50字节。
3. 随后需要计算出mss_now = 48 - 50 + 20 = 18
这里假设一下该漏洞可能利用成功的场景:有一个TCP服务,自己设定了TCP可选参数,并且设置满了40字节,那么攻击者才有可能通过构造SYN包中的MSS值来对该服务进行Dos攻击。
随后我对Linux 2.6.29至今的内核进行审计,mss_now的计算公式都一样,tcp_header_len
长度也只会加上时间戳的12字节和md5值的18字节。
----- 2019/07/03 UPDATE -----
经过@riatre大佬的指正,我发现上述我对tcp_current_mss
函数的分析中漏了一段重要的代码:
1 2 3 4 5 6 7 8 9 |
# tcp_output.c tcp_current_mss -> tcp_sync_mss: mss_now = tcp_mtu_to_mss(sk, pmtu); header_len = tcp_established_options(sk, NULL, &opts, &md5) + sizeof(struct tcphdr); if (header_len != tp->tcp_header_len) { int delta = (int) header_len - tp->tcp_header_len; mss_now -= delta; } |
在tcp_established_options
函数的代码中,除了12字节的时间戳,20字节的md5,还有对SACK长度的计算,在长度不超过tcp可选项40字节限制的前提下,公式为:size = 4 + 8 * opts->num_sack_blocks
1 2 3 4 5 6 7 8 9 10 |
eff_sacks = tp->rx_opt.num_sacks + tp->rx_opt.dsack; if (unlikely(eff_sacks)) { const unsigned int remaining = MAX_TCP_OPTION_SPACE - size; opts->num_sack_blocks = min_t(unsigned int, eff_sacks, (remaining - TCPOLEN_SACK_BASE_ALIGNED) / TCPOLEN_SACK_PERBLOCK); size += TCPOLEN_SACK_BASE_ALIGNED + opts->num_sack_blocks * TCPOLEN_SACK_PERBLOCK; } |
所以凑齐40字节的方法是:12字节的时间戳 + 8 * 3(opts->num_sack_blocks)
变量opts->num_sack_blocks
表示从对端接受的数据包中丢失的数据包数目
所以在这里修改一下总结中后三步的过程:
- 攻击者向靶机发送一段正常的HTTP请求
- 靶机接收到请求后,会发送HTTP响应包,如上面的wireshark截图所示,响应包会按照36字节的长度分割成多分
- 攻击者构造序列号带有缺漏的ACK包(ACK包需要带一些数据)
- 服务器接收到无序的ACK包后,发现产生了丢包的情况,所以在后续发送的数据包中,都会带上SACK选项,告诉客户端,那些数据包丢失,直到TCP链接断开或者接收到响应序列的数据包。
效果如下图所示:
因为算上时间戳,TCP SACK选项里最多只能包含3段序列编号,所以只要发送4次ACK包,就能把MSS设置为8。
部分scapy代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
data = "GET / HTTP/1.1\nHost: 192.168.11.112\r\n\r\n" ACK = TCP(sport=sport, dport=dport, flags='A', seq=SYNACK.ack, ack=SYNACK.seq+1) ACK.options = [("NOP",None), ("NOP",None), ('Timestamp', (1, 2))] send(ip/ACK/data) dl = len(data) test = "a"*10 ACK.seq += dl + 20 ACK.ack = SYNACK.seq+73 send(ip/ACK/test) ACK.seq += 30 ACK.ack = SYNACK.seq+181 send(ip/ACK/test) ACK.seq += 30 ACK.ack = SYNACK.seq+253 send(ip/ACK/test) |
因为现在已经能满足mss_now=8的前提,后续将会对该漏洞进行进一步的分析。
参考
- https://github.com/Netflix/security-bulletins/blob/master/advisories/third-party/2019-001.md
- https://paper.seebug.org/959/
- https://paper.seebug.org/960/
本文由 Seebug Paper 发布,如需转载请注明来源。本文地址:https://paper.seebug.org/966/