作者:leveryd
原文鏈接:https://mp.weixin.qq.com/s/hvb_Kr6DqAPPfnN-lbx1aA

背景

假設機器A和機器B在同一個局域網,機器A使用nc -l 127.0.0.1 8888,在機器B上可以訪問機器A上"僅綁定在127.0.0.1的服務"嗎?

[root@instance-h9w7mlyv ~]# nc -l 127.0.0.1 8888 &
[1] 44283
[root@instance-h9w7mlyv ~]# netstat -antp|grep 8888
tcp        0      0 127.0.0.1:8888          0.0.0.0:*               LISTEN      44283/nc

nc用法可能不同,有的使用 nc -l 127.0.0.1 -p 8888 監聽8888端口

kubernetes的kube-proxy組件之前披露過CVE-2020-8558漏洞,這個漏洞就可以讓"容器內的惡意用戶、同一局域網其他機器"訪問到node節點上"僅綁定在127.0.0.1的服務"。這樣有可能訪問到監聽在本地的"kubernetes無需認證的apiserver",進而控制集群。

本文會帶你做兩種網絡環境(vpc和docker網橋模式)下的漏洞原理分析,并復現漏洞。

漏洞分析

怎么復現?

先說最終結果,我已經做好基于terraform漏洞靶場

terraform可以基于聲明式api編排云上的基礎設施(虛擬機、網絡等)

你也可以按照文章后面的步驟來復現漏洞。

為什么可以訪問其他節點的"僅綁定在127.0.0.1的服務"?

假設實驗環境是,一個局域網內有兩個節點A和B、交換機,ip地址分別是ip_a和ip_b,mac地址分別是mac_a和mac_b。

來看看A機器訪問B機器時的一個攻擊場景。

如果在tcp握手時,A機器構造一個"惡意的syn包",數據包信息是:

源ip 源mac 目的ip 目的mac 目的端口 源端口
ip_a mac_a 127.0.0.1 mac_b 8888 44444(某個隨機端口)

此時如果交換機只是根據mac地址做數據轉發,它就將syn包發送給B。

syn包的數據流向是:A -> 交換機 -> B

B機器網卡在接收到syn包后: 鏈路層:發現目的mac是自己,于是扔給網絡層處理 網絡層:發現ip是本機網卡ip,看來要給傳輸層處理,而不是轉發 * 傳輸層:發現當前"網絡命名空間"確實有服務監聽 127.0.0.1:8888, 和 "目的ip:目的端口" 可以匹配上,于是準備回復syn-ack包

從"內核協議棧"角度看,發送包會經過"傳輸層、網絡層、鏈路層、設備驅動",接受包剛好相反,會經過"設備驅動、鏈路層、網絡層、傳輸層"

syn-ack數據包信息是:

源ip 源mac 目的ip 目的mac 目的端口 源端口
127.0.0.1 mac_b ip_a mac_a 44444(某個隨機端口) 8888

syn-ack包的數據流向是:B -> 交換機 -> A

A機器網卡在收到syn-ack包后,也會走一遍"內核協議棧"的流程,然后發送ack包,完成tcp握手。

這樣A就能訪問到B機器上"僅綁定在127.0.0.1的服務"。所以,在局域網內,惡意節點"似乎"很容易就能訪問到其他節點的"僅綁定在127.0.0.1的服務"。

但實際上,A訪問到B機器上"僅綁定在127.0.0.1的服務"會因為兩大類原因失敗: 交換機有做檢查,比如它不允許數據包的目的ip地址是127.0.0.1,這樣第一個syn包就不會轉發給B,tcp握手會失敗。公有云廠商的交換機(比如ovs)應該就有類似檢查,所以我在某個公有云廠商vpc網絡環境下測試,無法成功復現漏洞。 數據包到了主機,但是因為ip是127.0.0.1,很特殊,所以"內核協議棧"為了安全把包丟掉了。

所以不能在云vpc環境下實驗,于是我選擇了復現"容器訪問宿主機上的僅綁定在127.0.0.1的服務"。

先來看一下,"內核協議棧"為了防止惡意訪問"僅綁定在127.0.0.1的服務"都做了哪些限制。

"內核協議棧"做了哪些限制?

先說結論,下面三個內核參數都會影響 route_localnet rp_filter * accept_local

以docker網橋模式為例,想要在docker容器中訪問到宿主機的"僅綁定在127.0.0.1的服務",就需要: 宿主機上 route_localnet=1 docker容器中 rp_filter=0、accept_local=1、route_localnet=1

宿主機網絡命名空間中

[root@instance-h9w7mlyv ~]# sysctl -a|grep route_localnet
net.ipv4.conf.all.route_localnet = 1
net.ipv4.conf.default.route_localnet = 1
...

容器網絡命名空間中

[root@instance-h9w7mlyv ~]# sysctl -a|grep accept_local
net.ipv4.conf.all.accept_local = 1
net.ipv4.conf.default.accept_local = 1
net.ipv4.conf.eth0.accept_local = 1
[root@instance-h9w7mlyv ~]# sysctl -a|grep '\.rp_filter'
net.ipv4.conf.all.rp_filter = 0
net.ipv4.conf.default.rp_filter = 0
net.ipv4.conf.eth0.rp_filter = 0
...

容器中和宿主機中因為是不同的網絡命名空間,所以關于網絡的內核參數是隔離的,并一定相同。

route_localnet配置

是什么?

宿主機上curl 127.0.0.1時,源ip和目的都是127.0.0.1,此時網絡能正常通信,說明數據包并沒有被丟棄。說明這種情景下,沒有調用到 ip_route_input_noref 函數查找路由表。

CVE-2020-8558漏洞中,kube-proxy設置route_localnet=1,導致關閉了上面所說的檢查。

內核協議棧中哪里用route_localnet配置來檢查?

https://elixir.bootlin.com/linux/v4.18/source/net/ipv4/route.c#L1912

ip_route_input_slow 函數中用到 route_localnet配置,如下:

/*
 *  NOTE. We drop all the packets that has local source
 *  addresses, because every properly looped back packet
 *  must have correct destination already attached by output routine.
 *
 *  Such approach solves two big problems:
 *  1. Not simplex devices are handled properly.
 *  2. IP spoofing attempts are filtered with 100% of guarantee.
 *  called with rcu_read_lock()
 */

static int ip_route_input_slow(struct sk_buff *skb, __be32 daddr, __be32 saddr,
                   u8 tos, struct net_device *dev,
                   struct fib_result *res)
{
    ...
    /* Following code try to avoid calling IN_DEV_NET_ROUTE_LOCALNET(),
     * and call it once if daddr or/and saddr are loopback addresses
     */
    if (ipv4_is_loopback(daddr)) {      // 目的地址是否"loopback地址"
        if (!IN_DEV_NET_ROUTE_LOCALNET(in_dev, net))    // localnet配置是否開啟。net是網絡命名空間,in_dev是接收數據包設備配置信息
            goto martian_destination;       // 認為是非法數據包
    } else if (ipv4_is_loopback(saddr)) {       // 源地址是否"loopback地址"
        if (!IN_DEV_NET_ROUTE_LOCALNET(in_dev, net))
            goto martian_source;    // 認為是非法數據包
    }
    ...
    err = fib_lookup(net, &fl4, res, 0);        // 查找"路由表",res存放查找結果
    ...
    if (res->type == RTN_BROADCAST)
    ...
    if (res->type == RTN_LOCAL) {   // 數據包應該本機處理
        err = fib_validate_source(skb, saddr, daddr, tos,
                  0, dev, in_dev, &itag);  // "反向查找", 驗證源地址是否有問題
        if (err < 0)
            goto martian_source;
        goto local_input; // 本機處理
    }
    if (!IN_DEV_FORWARD(in_dev)) {   // 沒有開啟ip_forward配置時,認為不支持 轉發數據包
        err = -EHOSTUNREACH;
        goto no_route;
    }
    ...
    err = ip_mkroute_input(skb, res, in_dev, daddr, saddr, tos, flkeys);    // 認為此包需要"轉發"
}
static int ip_rcv_finish(struct net *net, struct sock *sk, struct sk_buff *skb)
{
    ...

    /*
     *  Initialise the virtual path cache for the packet. It describes
     *  how the packet travels inside Linux networking.
     */
    if (!skb_valid_dst(skb)) {  // 是否有路由緩存. 宿主機curl 127.0.0.1時,就有緩存,不用查找路由表。
        err = ip_route_input_noref(skb, iph->daddr, iph->saddr,
                       iph->tos, dev);  // 查找路由表
        if (unlikely(err))
            goto drop_error;
    }
    ...
    return dst_input(skb);   // 將數據包交給tcp層(ip_local_deliver) 或 轉發數據包(ip_forward)

在收到數據包時,從ip層來看,數據包會經過 ip_rcv(ip層入口函數) -> ip_rcv_finish -> ip_route_input_slow。

在ip_route_input_slow函數中可以看到,如果源ip或者目的ip是"loopback地址",并且接收數據包的設備沒有配置route_localnet選項時,就會認為是非法數據包。

rp_filter和accept_local

是什么?

內核網絡參數詳解 提到,rp_filter=1時,會嚴格驗證源ip。

怎么檢查源ip呢?就是收到數據包后,將源ip和目的ip對調,然后再查找路由表,找到會用哪個設備回包。如果"回包的設備"和"收到數據包的設備"不一致,就有可能校驗失敗。這個也就是后面說的"反向檢查"。

內核協議棧中哪里用rp_filter和accept_local配置來檢查?

上面提到 收到數據包時,從ip層來看,會執行 ip_route_input_slow 函數查找路由表。

ip_route_input_slow 函數會執行 fib_validate_source 函數執行 "驗證源ip",會使用到rp_filter和accept_local配置

https://elixir.bootlin.com/linux/v4.18/source/net/ipv4/fib_frontend.c#L412

/* Ignore rp_filter for packets protected by IPsec. */
int fib_validate_source(struct sk_buff *skb, __be32 src, __be32 dst,
            u8 tos, int oif, struct net_device *dev,
            struct in_device *idev, u32 *itag)
{
    int r = secpath_exists(skb) ? 0 : IN_DEV_RPFILTER(idev);    // r=rp_filter配置
    struct net *net = dev_net(dev);

    if (!r && !fib_num_tclassid_users(net) &&
        (dev->ifindex != oif || !IN_DEV_TX_REDIRECTS(idev))) {      // dev->ifindex != oif 表示 不是lo虛擬網卡接收到包
        if (IN_DEV_ACCEPT_LOCAL(idev))          // accept_local配置是否打開。idev是接受數據包的網卡配置
            goto ok;
        /* with custom local routes in place, checking local addresses
         * only will be too optimistic, with custom rules, checking
         * local addresses only can be too strict, e.g. due to vrf
         */
        if (net->ipv4.fib_has_custom_local_routes ||
            fib4_has_custom_rules(net))     //  檢查"網絡命名空間"中是否有自定義的"策略路由"
            goto full_check;
        if (inet_lookup_ifaddr_rcu(net, src))       // 檢查"網絡命名空間"中是否有設備的ip和源ip(src值)相同
            return -EINVAL;

ok:
        *itag = 0;
        return 0;
    }

full_check:
    return __fib_validate_source(skb, src, dst, tos, oif, dev, r, idev, itag);      // __fib_validate_source中會執行"反向檢查源ip"
}

當在容器中curl 127.0.0.1 --interface eth0時,有一些結論: 宿主機收到請求包時,無論 accept_local和rp_filter是啥值,都通過fib_validate_source檢查 容器中收到請求包時,必須要設置 accept_local=1、rp_filter=0,才能不被"反向檢查源ip"

如果容器中 accept_local=1、rp_filter=0 有一個條件不成立,就會發生丟包。這個時候如果你在容器網絡命名空間用tcpdump -i eth0 'port 8888' -n -e觀察,就會發現詭異的現象:容器接收到了syn-ack包,但是沒有回第三個ack握手包。如下圖 img

小技巧:nsenter -n -t 容器進程pid 可以進入到容器網絡空間,接著就可以tcpdump抓"容器網絡中的包"

docker網橋模式下復現漏洞

docker網橋模式下漏洞原理是什么?

借用網絡上的一張圖來說明docker網橋模式 img

在容器內curl 127.0.0.1:8888 --interface eth0時,發送第一個syn包時,在網絡層查找路由表

[root@instance-h9w7mlyv ~]# ip route show
default via 172.17.0.1 dev eth0
172.17.0.0/16 dev eth0 proto kernel scope link src 172.17.0.3

因此會走默認網關(172.17.0.1),在鏈路層就會找網關的mac地址

[root@instance-h9w7mlyv ~]# arp -a|grep 172.17.0.1
_gateway (172.17.0.1) at 02:42:af:2e:cd:ae [ether] on eth0

實際上02:42:af:2e:cd:ae就是docker0網橋的mac地址,所以網關就是docker0網橋

[root@instance-h9w7mlyv ~]# ifconfig docker0
docker0: flags=4163<UP,BROADCAST,RUNNING,MULTICAST>  mtu 1500
        inet 172.17.0.1  netmask 255.255.0.0  broadcast 172.17.255.255
        ...
        ether 02:42:af:2e:cd:ae  txqueuelen 0  (Ethernet)
        ...

因此第一個syn包信息是

源ip 目的ip 源mac 目的mac 源端口 目的端口
容器eth0 ip 127.0.0.1 容器eth0 mac docker0 mac 4444(隨機端口) 8888

syn包數據包數據流向是 容器內eth0 -> veth -> docker0。

veth設備作為docker0網橋的"從設備",接收到syn包后直接轉發,不會調用到"內核協議棧"的網絡層。

docker0網橋設備收到syn包后,在"內核協議棧"的鏈路層,看到目的mac是自己,就把包扔給網絡層處理。在網絡層查路由表,看到目的ip是本機ip,就將包扔給傳輸層處理。在傳輸層看到訪問"127.0.0.1:8888",就會查看是不是有服務監聽在"127.0.0.1:8888"。

怎么復現?

從上面分析可以看出來,需要將宿主機docker0網橋設備route_localnet設置成1。

宿主機docker0網橋設備需要設置rp_filter和accept_local選項嗎?答案是不需要,因為docker0網橋設備在收到數據包在網絡層做"反向檢查源地址"時,會知道"響應數據包"也從docker0網橋發送。"發送和接收數據包的設備"是匹配的,所以能通過"反向檢查源地址"的校驗。

容器中eth0網卡需要設置rp_filter=0、accept_local=1、localnet=1。為什么容器中eth0網卡需要設置rp_filter和accept_local選項呢?因為eth0網橋設備如果做"反向檢查源地址",就會知道響應包應該從lo網卡發送。"接收到數據包的設備是eth0網卡",而"發送數據包的設備應該是lo網卡",兩個設備不匹配,"反向檢查"就會失敗。rp_filter=0、accept_local=1可以避免做"反向檢查源地址"。

即使ifconfig lo down,ip route show table local仍能看到local表中有回環地址的路由。

下面你可以跟著我來用docker復現漏洞。

首先在宿主機上打開route_localnet配置

[root@instance-h9w7mlyv ~]# sysctl -w net.ipv4.conf.all.route_localnet=1

然后創建容器,并進入到容器網絡命名空間,設置rp_filter=0、accept_local=1

[root@instance-h9w7mlyv ~]# docker run -d busybox tail -f /dev/null     // 創建容器
62ba93fbbe7a939b7fff9a9598b546399ab26ea97858e73759addadabc3ad1f3
[root@instance-h9w7mlyv ~]# docker top 62ba93fbbe7a939b7fff9a9598b546399ab26ea97858e73759addadabc3ad1f3
UID                 PID                 PPID                C                   STIME               TTY                 TIME                CMD
root                43244               43224               0                   12:33               ?                   00:00:00            tail -f /dev/null
[root@instance-h9w7mlyv ~]# nsenter -n -t 43244     // 進入到容器網絡命名空間
[root@instance-h9w7mlyv ~]#
[root@instance-h9w7mlyv ~]# sysctl -w net.ipv4.conf.all.accept_local=1  // 設置容器中的accept_local配置
[root@instance-h9w7mlyv ~]# sysctl -w net.ipv4.conf.all.rp_filter=0     // 設置容器中的rp_filter配置
[root@instance-h9w7mlyv ~]# sysctl -w net.ipv4.conf.default.rp_filter=0
[root@instance-h9w7mlyv ~]# sysctl -w net.ipv4.conf.eth0.rp_filter=0

如果你是docker exec -ti busybox sh進入到容器中,然后執行sysctl -w配置內核參數,就會發現報錯,因為/proc/sys目錄默認是作為只讀掛載到容器中的,而內核網絡參數就在/proc/sys/net目錄下。

然后就可以在容器中使用curl 127.0.0.1:端口號 --interface eth0來訪問宿主機上的服務。

image

kubernetes對漏洞的修復

這個pr 中kubelet添加了一條iptables規則

root@ip-172-31-14-33:~# iptables-save |grep localnet
-A KUBE-FIREWALL ! -s 127.0.0.0/8 -d 127.0.0.0/8 -m comment --comment "block incoming localnet connections" -m conntrack ! --ctstate RELATED,ESTABLISHED,DNAT -j DROP

這條規則使得,在tcp握手時,第一個syn包如果目的ip是"環回地址",同時源ip不是"環回地址"時,包會被丟棄。

所以如果你復現時是在kubernetes環境下,就需要刪掉這條iptables規則。

或許你會有疑問,源ip不也是可以偽造的嘛。確實是這樣,所以在 https://github.com/kubernetes/kubernetes/pull/91569 中有人評論到,上面的規則,不能防止訪問本地udp服務。

總結

公有云vpc網絡環境下,可能因為交換機有做限制而導致無法訪問其他虛擬機的"僅綁定在127.0.0.1的服務"。

docker容器網橋網絡環境下,存在漏洞的kube-proxy已經設置了宿主機網絡的route_localnet選項,但是因為在容器中/proc/sys默認只讀,所以無法修改容器網絡命名空間下的內核網絡參數,也很難做漏洞利用。

kubernetes的修復方案并不能防止訪問本地udp服務。

如果kubernetes使用了cni插件(比如calico ipip網絡模型),你覺得在node節點能訪問到master節點的"僅綁定在127.0.0.1的服務"嗎?

參考

內核網絡參數詳解


Paper 本文由 Seebug Paper 發布,如需轉載請注明來源。本文地址:http://www.bjnorthway.com/1938/