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

内核源码:

$ sudo apt install linux-source-4.15.0

$ ls /usr/src/linux-source-4.15.0.tar.bz2

带符号的内核:

$ 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):

# 内核是通过grup启动的,所以在grup配置文件中,内核启动参数里加上nokaslr 

$ cat /etc/default/grub |grep -v "#" | grep CMDLI

GRUB_CMDLINE_LINUX_DEFAULT="nokaslr"

GRUB_CMDLINE_LINUX=""

$ sudo update-grub

装一个nginx,供测试:

$ sudo apt install nginx

2. 宿主机

操作系统:MacOS

Wireshark:抓流量

虚拟机:VMware Fusion 11

调试Linux虚拟机:

$ cat ubuntu_18.04_server_test.vmx|grep debug

debugStub.listen.guest64 = "1"

编译gdb:

$ ./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进行远程调试:

$ 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

# 添加规则

$ 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

# 查看路由信息

$ 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权限

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可设置选项表:

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包:

$ sudo iptables -A OUTPUT -p tcp --tcp-flags RST RST -s 192.168.11.111 -j DROP

对于MSS的深入研究

关于该漏洞的细节,别的文章中已经分析过了,这里简单的提一下,该漏洞为uint16溢出:

tcp_gso_segs 类型为uint16

tcp_set_skb_tso_segs:

tcp_skb_pcount_set(skb, DIV_ROUND_UP(skb->len, mss_now));

skb->len的最大值为17 * 32 * 1024

mss_now的最小值为8

>>> hex(17*32*1024//8)

'0x11000'

>>> hex(17*32*1024//9)

'0xf1c7'

所以在mss_now小于等于8时,才能发生整型溢出。

深入研究的原因是因为进行了如下的测试:

攻击机器通过iptables/iproute命令将MSS值为48后,使用curl请求靶机的http服务,然后使用wireshark抓流量,发现服务器返回的http数据包的确被分割成小块,但是只小到36,离预想的8有很大的差距

这个时候我选择通过审计源码和调试来深入研究为啥MSS无法达到我的预期值,SYN包中设置的MSS值到代码中的mss_now的过程中发生了啥?

随机进行源码审计,对发生溢出的函数tcp_set_skb_tso_segs进行回溯:

tcp_set_skb_tso_segs <- tcp_fragment <- tso_fragment <- tcp_write_xmit

最后发现,传入tcp_write_xmit函数的mss_now都是通过tcp_current_mss函数进行计算的

随后对tcp_current_mss函数进行分析,关键代码如下:

# 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结构:

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达到最大值长度吗?随后我们回溯该变量:

# 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,最终导致该漏洞无法利用。

总结

我们来总结一下整个流程:

  1. 攻击者构造SYN包,自定义TCP头部可选参数MSS的值为48
  2. 靶机(受到攻击的机器)接收到SYN请求后,把SYN包中的数据保存在内存中,返回SYN,ACK包。
  3. 攻击者返回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函数的分析中漏了一段重要的代码:

# 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

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表示从对端接受的数据包中丢失的数据包数目

所以在这里修改一下总结中后三步的过程:

  1. 攻击者向靶机发送一段正常的HTTP请求

  2. 靶机接收到请求后,会发送HTTP响应包,如上面的wireshark截图所示,响应包会按照36字节的长度分割成多分

  3. 攻击者构造序列号带有缺漏的ACK包(ACK包需要带一些数据)

  4. 服务器接收到无序的ACK包后,发现产生了丢包的情况,所以在后续发送的数据包中,都会带上SACK选项,告诉客户端,那些数据包丢失,直到TCP链接断开或者接收到响应序列的数据包。

效果如下图所示:

因为算上时间戳,TCP SACK选项里最多只能包含3段序列编号,所以只要发送4次ACK包,就能把MSS设置为8。

部分scapy代码如下:

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的前提,后续将会对该漏洞进行进一步的分析。

参考

  1. https://github.com/Netflix/security-bulletins/blob/master/advisories/third-party/2019-001.md
  2. https://paper.seebug.org/959/
  3. https://paper.seebug.org/960/

以上是 Linux 内核 TCP MSS 机制详细分析 的全部内容, 来源链接: utcz.com/p/199369.html

回到顶部