0x00 前言
最近一段时间,我一直在审计Linux内核中的数据包套接字源码,最后成功发现了CVE-2020-14386,这是Linux内核中的一个内存破坏漏洞。利用该漏洞,低权限用户可以在Linux系统中将权限提升至root级别。在本文中,我将与大家分享该漏洞的技术细节以及如何利用该漏洞。
几年以前,研究人员在数据包套接字中发现了多个漏洞(CVE-2017-7308以及CVE-2016-8655),也公开了一些研究成果(如Project Zero及Openwall),从中我们可以对相关领域有个大致了解。
为了触发该漏洞,我们需要内核启用AF_PACKET
套接字(CONFIG_PACKET=y
),触发漏洞的进程也需要具备CAP_NET_RAW
权限。如果用户命名空间处于启用状态(CONFIG_USER_NS=y
),且能被非特权用户访问,那么非特权用户就可以在低权限用户空间满足漏洞触发条件。比较意外的是,某些Linux发行版(如Ubuntu)在默认情况下同时满足了这几个约束条件。
0x01 技术细节
备注:本文引用的所有代码片段均来自于5.7版内核源码。
由于Project Zero之前已经详细分析过AF_PACKET
的具体实现,因此这里我将忽略已经涉及过的一些细节(比如帧和块之间的关系),直接描述漏洞及漏洞根源。
该漏洞源自于一个计算错误,最终将导致内存破坏,具体位置在(net/packet/af_packet.c
的)tpacket_rcv
函数中。
这个计算错误从2008年7月19日起开始引入,来自于commit 8913336(“packet: add PACKET_RESERVE
sockopt”)。然而,只有在2016年的commit 58d19b19cd99之后(“packet: vnet_hdr
support for tpacket_rcv
”),这个漏洞才可以被触发,导致内存破坏。之后开发者做了多次尝试想修复该问题,比如2017年5月份的commit bcc536(“net/packet: fix overflow in check for tp_reserve
”)以及2017年8月份的commit edb58be(“packet: Don’t write vnet
header beyond end of buffer”),然而这些补丁并不足以阻止内存破坏。
我们先开看一下PACKET_RESERVE
选项:为了触发漏洞,我们需要使用TPACKET_V2
环形缓冲区(ring buffer)来创建raw socket(AF_PACKET
域,SOCK_RAW
类型),并且使用PACKET_RESERVE
特殊选项。
图1. 摘抄自man7.org
上图中提到的headroom
实际上只是由用户指定大小的一个缓冲区,该缓冲区分配位置位于环形缓冲区中收到的每个报文数据之前。用户可以通过setsockopt
系统调用来设置这个值。
case PACKET_RESERVE:
{
unsigned int val;
if (optlen != sizeof(val))
return -EINVAL;
if (copy_from_user(&val, optval, sizeof(val)))
return -EFAULT;
if (val > INT_MAX)
return -EINVAL;
lock_sock(sk);
if (po->rx_ring.pg_vec || po->tx_ring.pg_vec) {
ret = -EBUSY;
} else {
po->tp_reserve = val;
ret = 0;
}
release_sock(sk);
return ret;
}
图2. setsockopt
中PACKET_RESERVE
的实现
如上图所示,代码首先会检查该值是否小于INT_MAX
。这个检查逻辑添加自某次补丁,目的是避免在计算packet_set_ring
中的最小帧大小时出现溢出问题。随后,代码会验证是否尚未为接受/发送环形缓冲区分配页面,这样可以避免tp_reserve
字段与环形缓冲区自身之间存在的不一致问题。
设置tp_reserve
的值后,我们可以通过setsockopt
系统调用,使用PACKET_RX_RING
选项来触发系统分配环形缓冲区。官方文档中关于PACKET_RX_RING
的描述如下:
(这是)为接收异步数据包创建一个内存映射的环形缓冲区。
相关实现代码位于packet_set_ring
函数中,在环形缓冲区分配之前,系统会从用户空间中,对tpacket_req
结构执行多次计算检查:
min_frame_size = po->tp_hdrlen + po->tp_reserve;
…
…
if (unlikely(req->tp_frame_size < min_frame_size))
goto out;
图3. packet_set_ring
函数中的部分检查逻辑
如图3所示,代码首先会计算最小帧大小,然后将其与用户空间中收到的值进行比较。这个检查操作可以确保每个帧中都可以为tpacket
头部结构(与具体版本对应)以及tp_reserve
个字节预留空间。
执行所有检查逻辑后,代码会调用alloc_pg_vec
来分配环形缓冲区:
order = get_order(req->tp_block_size);
pg_vec = alloc_pg_vec(req, order);
图4. 在packet_set_ring
函数中调用环形缓冲区分配函数
如上图所示,block大小可以从用户空间来控制。alloc_pg_vec
函数会分配pg_vec
数组,然后通过alloc_one_pg_vec_page
函数分配每个block:
static struct pgv *alloc_pg_vec(struct tpacket_req *req, int order)
{
unsigned int block_nr = req->tp_block_nr;
struct pgv *pg_vec;
int i;
pg_vec = kcalloc(block_nr, sizeof(struct pgv), GFP_KERNEL | __GFP_NOWARN);
if (unlikely(!pg_vec))
goto out;
for (i = 0; i < block_nr; i++) {
pg_vec[i].buffer = alloc_one_pg_vec_page(order);
图5. alloc_pg_vec
代码片段
alloc_one_pg_vec_page
函数会使用__get_free_pages
来分配block页面:
static char *alloc_one_pg_vec_page(unsigned long order)
{
char *buffer;
gfp_t gfp_flags = GFP_KERNEL | __GFP_COMP |
__GFP_ZERO | __GFP_NOWARN | __GFP_NORETRY;
buffer = (char *) __get_free_pages(gfp_flags, order);
if (buffer)
return buffer;
图6. alloc_one_pg_vec_page
代码片段
分配block后,pg_vec
数组被保存到packet_ring_buffer
结构中,嵌入在packet_sock
结构中,该结构用来表示套接字。
当网卡接口上收到报文时,绑定到tpacket_rcv
函数的套接字会被调用,报文数据与TPACKET
元数据会被写入环形缓冲区。在tcpdump之类的实际应用程序中,这个缓冲区会被内存映射至用户空间,从中可以读取报文数据。
0x02 漏洞分析
现在我们来深入分析tpacket_rcv
函数的具体实现(如图7所示)。首先,代码会调用skb_network_offset
,提取接收报文的网络头偏移值,存放到maclen
。在我们的示例中,这个值为14个字节,对应以太网头的大小。随后,代码会计算netoff
(代表帧中网络头的偏移值),计算过程涉及到TPACKET
头(每个版本都为固定值)、maclen
以及tp_reserve
值(由用户控制)。
然而这个计算过程可能会溢出,因为tp_reserve
的类型为unsigned int
,而netoff
的类型为unsigned short
,唯一的约束在于tp_reserve
的值需要小于INT_MAX
(如前文分析)。
if (sk->sk_type == SOCK_DGRAM) {
…
else {
unsigned int maclen = skb_network_offset(skb);
netoff = TPACKET_ALIGN(po->tp_hdrlen + (maclen < 16 ? 16 : maclen)) + po->tp_reserve;
if (po->has_vnet_hdr) {
netoff += sizeof(struct virtio_net_hdr);
do_vnet = true;
}
macoff = netoff – maclen;
}
图7. tpacket_rcv
函数中的计算方式
如上图所示,如果套接字上设置了PACKET_VNET_HDR
选项,就会在其中添加sizeof(struct virtio_net_hdr)
,以处理virtio_net_hdr
结构,该结构应该位于以太网头之后。最后,代码会计算以太网头偏移值,保存到macoff
中。
随后如图8所示,代码会使用virtio_net_hdr_from_skb
函数,将virtio_net_hdr
结构写入环形缓冲区中,其中h.raw
指向的是环形缓冲区中当前空闲的帧(环形缓冲区在alloc_pg_vec
中分配)。
if (do_vnet && virtio_net_hdr_from_skb(skb, h.raw + macoff – sizeof(struct virtio_net_hdr), vio_le(), true, 0))
goto drop_n_account;
图8. 在tpacket_rcv
中调用virtio_net_hdr_from_skb
函数
原本我以为可以使用这个溢出问题,将netoff
改成较小的一个值,这样macoff
收到的值将大于block的大小(下溢出),导致写操作超出缓冲区的边界。
然而,如下检查代码阻止了这种攻击方式:
if (po->tp_version <= TPACKET_V2) {
if (macoff + snaplen > po->rx_ring.frame_size) {
…
…
snaplen = po->rx_ring.frame_size – macoff;
if ((int)snaplen < 0) {
snaplen = 0;
do_vnet = false;
}
}
图9. tpacket_rcv
函数中的另一处计算检查
这个检查并不足以阻止内存破坏,因此我们还是可以通过溢出netoff
来将macoff
的整数值变小。更具体一些,我们可以修改macoff
的值,使其小于sizeof(struct virtio_net_hdr)
,也就是10
字节,然后使用virtio_net_hdr_from_skb
,在缓冲区边界后执行写操作。
0x03 利用原语
通过控制macoff
的值,我们可以控制环形缓冲区后最多偏移10
个字节的空间,从而初始化virtio_net_hdr
结构。virtio_net_hdr_from_skb
函数首先会将整个结构体置零,然后根据skb
结构,初始化其中的某些字段。
static inline int virtio_net_hdr_from_skb(const struct sk_buff *skb,
struct virtio_net_hdr *hdr,
bool little_endian,
bool has_data_valid,
int vlan_hlen)
{
memset(hdr, 0, sizeof(*hdr)); /* no info leak */
if (skb_is_gso(skb)) {
…
if (skb->ip_summed == CHECKSUM_PARTIAL) {
…
图10. virtio_net_hdr_from_skb
函数部分实现片段
然而,我们可以通过设置skb
,使得只有0
会被写入结构体中,这样我们就可以在__get_free_pages
分配的空间后面清空1-10
个字节。此时如果不执行任何堆控制策略,内核将马上崩溃。
0x04 PoC
大家可以访问此处,获取能够触发漏洞的PoC。
0x05 补丁
为了修复该bug,我提交了如下补丁:
图11. 我提交的补丁
该补丁的主要工作原理是,如果我们能将netoff
的类型从unsigned short
改成unsigned int
,那么就可以判断该值是否超出了USHRT_MAX
。如果满足该条件,则可以丢弃报文,避免进一步处理该报文。
0x06 漏洞利用
我们对该漏洞的主要利用思路是将前面的利用原语改成一个释放后重用(use-after-free)原语。为了实现该目标,我们考虑过减少对某些对象的引用计数。比如,如果某个对象的引用计数值为0x10001
,那么内存破坏情况如下图所示:
图12. 在对象引用计数值中清空1个字节
如图3所示,破坏内存后,引用计数的值将变成0x1
,因此在减少一处引用后,该对象会被释放。
然而,为了触发该场景,我们需要满足如下约束条件:
1、引用计数值必须位于该对象最后的1-10个字节中;
2、我们需要在页面的尾部分配对象。这是因为get_free_pages
会返回页对齐的一个地址。
我们使用了grep
表达式,配合手动代码分析,找到了如下对象:
struct sctp_shared_key {
struct list_head key_list;
struct sctp_auth_bytes *key;
refcount_t refcnt;
__u16 key_id;
__u8 deactivated;
};
图13. sctp_shared_key
结构体
该对象看上去能够满足我们的约束条件:
1、我们可以从非特权用户上下文中创建一个sctp
服务端及客户端。更具体一些,该对象会在sctp_auth_shkey_create
函数中分配。
2、我们可以在页面尾部分配该对象。
- 该对象的大小为32字节,通过
kmalloc
分配。这意味着该对象会在kmalloc-32
缓存中分配。 - 我们可以验证是否可以在
get_free_pages
分配的空间后再分配一个kmalloc-32
slab缓存。因此我们可以在这个slab缓存页面中破坏最后一个对象(这是因为4096 % 32 = 0
,slab页面末尾没有可用的空间,并且最后一个对象分配在我们分配的空间之后。其他slab缓存大小对我们而言可能并不适用,比如大小为96
字节时,4096 % 96 != 0
)。
3、我们可以破坏refcnt
字段的2个高位字节。
- 完成编译后,
key_id
以及deactivated
的大小均为4字节。 - 如果我们使用该漏洞来破坏9-10个字节,就会破坏
refcnt
字段的1-2个最高有效位。
0x07 总结
我比较惊讶的是,这么简单的一个计算安全性问题竟然还存在于Linux内核中,并且之前一直没被发现过。此外,非特权用户命名空间也暴露出了巨大的本地提权攻击面,因此Linux的各个发行版需要考虑是否启用这些功能。