假设机器A和机器B在同一个局域网,机器A使用nc -l 127.0.0.1 8888
,在机器B上可以访问机器A上"仅绑定在127.0.0.1的服务"吗?
[[email protected] ~]# nc -l 127.0.0.1 8888 &
[1] 44283
[[email protected] ~]# 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[1]的漏洞靶场[2]。
terraform可以基于声明式api编排云上的基础设施(虚拟机、网络等)
你也可以按照文章后面的步骤来复现漏洞。
假设实验环境是,一个局域网内有两个节点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包后:
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的服务"会因为两大类原因失败:
所以不能在云vpc环境下实验,于是我选择了复现"容器访问宿主机上的仅绑定在127.0.0.1的服务"。
先来看一下,"内核协议栈"为了防止恶意访问"仅绑定在127.0.0.1的服务"都做了哪些限制。
先说结论,下面三个内核参数都会影响
以docker网桥模式为例,想要在docker容器中访问到宿主机的"仅绑定在127.0.0.1的服务",就需要:
宿主机网络命名空间中
[[email protected] ~]# sysctl -a|grep route_localnet
net.ipv4.conf.all.route_localnet = 1
net.ipv4.conf.default.route_localnet = 1
...
容器网络命名空间中
[[email protected] ~]# 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
[[email protected] ~]# 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
...
容器中和宿主机中因为是不同的网络命名空间,所以关于网络的内核参数是隔离的,并一定相同。
内核文档[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,导致关闭了上面所说的检查。
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选项时,就会认为是非法数据包。
内核网络参数详解[4] 提到,rp_filter=1时,会严格验证源ip。
怎么检查源ip呢?就是收到数据包后,将源ip和目的ip对调,然后再查找路由表,找到会用哪个设备回包。如果"回包的设备"和"收到数据包的设备"不一致,就有可能校验失败。这个也就是后面说的"反向检查"。
上面提到 收到数据包时,从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=1、rp_filter=0 有一个条件不成立,就会发生丢包。这个时候如果你在容器网络命名空间用tcpdump -i eth0 'port 8888' -n -e
观察,就会发现诡异的现象:容器接收到了syn-ack包,但是没有回第三个ack握手包。如下图
小技巧:nsenter -n -t 容器进程pid 可以进入到容器网络空间,接着就可以tcpdump抓"容器网络中的包"
借用网络上的一张图来说明docker网桥模式
在容器内curl 127.0.0.1:8888 --interface eth0
时,发送第一个syn包时,在网络层查找路由表
[[email protected] ~]# 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地址
[[email protected] ~]# 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网桥
[[email protected] ~]# 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配置
[[email protected] ~]# sysctl -w net.ipv4.conf.all.route_localnet=1
然后创建容器,并进入到容器网络命名空间,设置rp_filter=0、accept_local=1
[[email protected] ~]# docker run -d busybox tail -f /dev/null // 创建容器
62ba93fbbe7a939b7fff9a9598b546399ab26ea97858e73759addadabc3ad1f3
[[email protected] ~]# docker top 62ba93fbbe7a939b7fff9a9598b546399ab26ea97858e73759addadabc3ad1f3
UID PID PPID C STIME TTY TIME CMD
root 43244 43224 0 12:33 ? 00:00:00 tail -f /dev/null
[[email protected] ~]# nsenter -n -t 43244 // 进入到容器网络命名空间
[[email protected] ~]#
[[email protected] ~]# sysctl -w net.ipv4.conf.all.accept_local=1 // 设置容器中的accept_local配置
[[email protected] ~]# sysctl -w net.ipv4.conf.all.rp_filter=0 // 设置容器中的rp_filter配置
[[email protected] ~]# sysctl -w net.ipv4.conf.default.rp_filter=0
[[email protected] ~]# 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
来访问宿主机上的服务。
在 这个pr[5] 中kubelet添加了一条iptables规则
[email protected]:~# 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的服务"吗?
内核网络参数详解[6]
terraform: https://www.terraform.io/
[2]漏洞靶场: https://github.com/HuoCorp/TerraformGoat/blob/main/kubernetes/kube-proxy/CVE-2020-8558/README_CN.md
[3]内核文档: https://www.kernel.org/doc/Documentation/networking/ip-sysctl.txt
[4]内核网络参数详解: https://www.kernel.org/doc/Documentation/networking/ip-sysctl.txt
[5]这个pr: https://github.com/kubernetes/kubernetes/pull/91569/commits/8bed088224fb38b41255b37e59a1701caefa171b
[6]内核网络参数详解: https://www.kernel.org/doc/Documentation/networking/ip-sysctl.txt