声明
出品|leveryd(ID:leveryd)
以下内容,来自先知社区的Ahoge作者原创,由于传播,利用此文所提供的信息而造成的任何直接或间接的后果和损失,均由使用者本人负责,长白山攻防实验室以及文章作者不承担任何责任。
背景
假设机器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配置
是什么?
内核文档[3]提到route_localnet参数,如果route_localnet等于0,当收到源ip或者目的ip是"loopback地址"(127.0.0.0/8)时,就会认为是非法数据包,将数据包丢弃。
宿主机上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握手包。如下图
![图片](https://mmbiz.qpic.cn/mmbiz_png/FwyeCXsWiaYwT9ePZcgMkrbKk0ic8t7muJM7PZayXtlLEUcuOiaWOtZiaNCrzcic4DcBjXEUC9mcDgMQNs6FvqNUbJw/640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1)
> 小技巧:nsenter -n -t 容器进程pid 可以进入到容器网络空间,接着就可以tcpdump抓"容器网络中的包"
docker网桥模式下复现漏洞
===============
docker网桥模式下漏洞原理是什么?
-------------------
借用网络上的一张图来说明docker网桥模式![图片](https://mmbiz.qpic.cn/mmbiz_png/FwyeCXsWiaYwT9ePZcgMkrbKk0ic8t7muJrficQooOl61MFEic8ZdV1OYyOgbleN7rPeIBeph9KTUP8LQU6JU7clCA/640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1)
在容器内`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💿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💿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`来访问宿主机上的服务。
![图片](https://mmbiz.qpic.cn/mmbiz_png/FwyeCXsWiaYwT9ePZcgMkrbKk0ic8t7muJEXGeMibHdGjpFs05n0DCr15QnzcjlGDmbHWUaWKsqibV0LtTKubSYMDQ/640?wx_fmt=png&wxfrom=5&wx_lazy=1&wx_co=1)
kubernetes对漏洞的修复
================
在 这个pr\[5\] 中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的服务"吗?
### 参考资料
* terraform: https://www.terraform.io/
* 漏洞靶场:
https://github.com/HuoCorp/TerraformGoat/blob/main/kubernetes/kube-proxy/CVE-2020-8558/README\_CN.md
* 内核文档:
https://www.kernel.org/doc/Documentation/networking/ip-sysctl.txt
* 内核网络参数详解:
https://www.kernel.org/doc/Documentation/networking/ip-sysctl.txt
* 这个pr: https://github.com/kubernetes/kubernetes/pull/91569/commits/8bed088224fb38b41255b37e59a1701caefa171b
* 内核网络参数详解:
https://www.kernel.org/doc/Documentation/networking/ip-sysctl.txt