该漏洞影响启用AF_PACKET
套接字(CONFIG_PACKET=y
)配置的系统,利用漏洞需要CAP_NET_RAW
权限,通过user namespace
来获取。
AF_PACKET
套接字允许用户在设备驱动程序级别上发送或接收数据包。例如,这让他们可以在物理层上实现自己的协议,或者嗅探包括以太网和更高级别协议头在内的数据包。
要创建AF_PACKET
套接字,进程必须在管理network namespace
的user namespace
中具有CAP_NET_RAW
权限。
要在包套接字上发送和接收数据包,进程可以使用send
和recv
系统调用。然而,通过使用内核和用户空间之间共享的循环缓冲区,包套接字提供了一种更快地完成这一任务的方法。环形缓冲区可以通过PACKET_TX_RING
和PACKET_RX_RING
套接字选项来创建。然后用户可以mmap
环形缓冲区,然后可以直接对其读取或写入数据包数据。
使用过程:
参考sudo strace tcpdump -i enp0s31f6
,enp0s31f6
是网卡名称,需要根据自己的平台确定。
This sequence of syscalls corresponds to the following actions:
- A socket(AF_PACKET, SOCK_RAW, htons(ETH_P_ALL)) is created.
- The socket is bound to the eth0 interface.
- Ring buffer version is set to TPACKET_V2 via the PACKET_VERSION socket option.
- A ring buffer is created via the PACKET_RX_RING socket option.
- The ring buffer is mmapped in the userspace.
漏洞主要影响版本:TPACKET_V3
,主要集中于PACKET_RX_RING
。
Ring buffer储存packet的内存区域,每个packet保存在单独的帧中,帧被分组到块(block)中,TPACKET_V3的ring buffer帧的大小不固定,帧的大小只要能适应block其值是任意的。
创建TPACKET_V3,通过PACKET_RX_RING套接字选项,用户需要提供确切的参数。通过setsockopt系统调用,通过一个指向tpacket_req3结构体的指针。
其定义如下:
struct tpacket_req3{
unsigned int tp_block_size; // 每个区块的大小
unsigned int tp_block_nr; // 区块的数量
unsigned int tp_frame_size;
unsigned int tp_frame_nr; // 因为大小是任意的,所以这一部分被忽略了
unsigned int tp_retire_blk_tov; // timeout after which a block is retired, even if it’s not fully filled with data (see below).
unsigned int tp_sizeof_priv; // 每个区块private区域的大小,user可以使用这一部分储存任意信息。
unsigned int tp_feature_req_word; // flags,启用一些额外的功能
}
每个区块都有一个头,叫做tpacket_block_desc:
struct tpacket_block_desc{
__u32 version;
__u32 offset_to_priv;
union tpacket_bd_header_u hdr;
}
union tpacket_bd_header_u{
struct tpacket_hdr_v1 bh1;
}
struct tpacket_hdr_v1{
__u32 block_status;
__u32 num_pkts;
__u32 offset_to_first_pkt;
}
其中包含了block_status
域,这个域指示了区块被kernel使用或者被user使用。常见的工作流程如下:
内核把packet保存到block中直到其满,然后将block_status
设置成TP_STATUS_USER
,用户读要求的数据,然后再将其设置为TP_STATUS_KERNEL
。
块中的每个帧都一个头,被结构体tpacket3_hdr
描述:
struct tpacket3_hdr{
__u32 tp_next_offset;
}
指向了同一区块中的下一帧:
当区块填满数据后(新的packet无法放入到剩余空间中时),区块会关闭,并且提交给userspace,用户总是想asap看到packet的,所以有的时候内核也会在block还没完全填满的时候就释放给user,这是通过设置一个timer,正是tp_retire_blk_tov
参数控制的。
当pakcet socket被创建时,一个相关联的packet_sock
结构体会被创建
struct packet_sock{
struct sock sk;
...
struct packet_ring_buffer rx_ring;
struct packet_ring_buffer tx_ring;
...
enum tpacket_versions tp_version;//TPACKET_V3
...
int (*xmit)(struct sk_buff *skb);
}
其中rx_ring和tx_ring的结构体类型如下定义:
struct packet_ring_buffer{
struct pgv *pg_vec;
struct tpacket_kbdq_core prb_bdqc;
}
struct pgv{
char *buffer;
}
pg_vec
指向了一个pgv结构体数组,数组中的每个元素是一个block的引用,所以实际上block是被分块分配的而不是连续的一大段内存。
prb_bdqc结构体是tpacket_kbdq_core
类型,其描述了当前ring buffer的状态:
struct tpacket_kbdq_core{
...
unsigned short blk_sizeof_priv;//每个区块private区域的大小
...
char *nxt_offset;//指向当前获活跃的区块,也就是下一个packet将被保存到的位置
...
struct timer_list retire_blk_timer;// the timer which retires current block on timeout.
}
timer_list
结构体:
struct timer_list{
...
struct hlist_node entry;
unsigned long expires;
void (*function)(unsigned long);
unsigned long data;
...
}
- 内核使用
packet_setsockopt()
函数去处理建立套接字选项 - 使用了
PACKET_VERSION
套接字后,内核会将tp_version
设置为提供的值 - PACKET_RX_RING选项会创建receive ring buffer。通过
packet_set_ring
函数实现
static int packet_set_ring(struct sock *sk, union tpacket_req_u *req_u,
int closing, int tx_ring)
{
...
err = -EINVAL;
if (unlikely((int)req->tp_block_size <= 0))
goto out;
if (unlikely(!PAGE_ALIGNED(req->tp_block_size)))
goto out;
if (po->tp_version >= TPACKET_V3 &&
(int)(req->tp_block_size -
BLK_PLUS_PRIV(req_u->req3.tp_sizeof_priv)) <= 0)
goto out;
if (unlikely(req->tp_frame_size < po->tp_hdrlen +
po->tp_reserve))
goto out;
if (unlikely(req->tp_frame_size & (TPACKET_ALIGNMENT - 1)))
goto out;
rb->frames_per_block = req->tp_block_size / req->tp_frame_size;
if (unlikely(rb->frames_per_block == 0))
goto out;
if (unlikely((rb->frames_per_block * req->tp_block_nr) !=
req->tp_frame_nr))
goto out;
err = -ENOMEM;
order = get_order(req->tp_block_size);
pg_vec = alloc_pg_vec(req, order);
if (unlikely(!pg_vec))
goto out;
其调用了alloc_gp_vec
:利用了内核page allocator以分配blocks:
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);
if (unlikely(!pg_vec))
goto out;
for (i = 0; i < block_nr; i++) {
pg_vec[i].buffer = alloc_one_pg_vec_page(order);
if (unlikely(!pg_vec[i].buffer))
goto out_free_pgvec;
}
out:
return pg_vec;
out_free_pgvec:
free_pg_vec(pg_vec, order, block_nr);
pg_vec = NULL;
goto out;
}
alloc_gp_vec调用了alloc_one_pg_vec_page:
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;
/* __get_free_pages failed, fall back to vmalloc */
buffer = vzalloc((1 << order) * PAGE_SIZE);
if (buffer)
return buffer;
/* vmalloc failed, lets dig into swap here */
gfp_flags &= ~__GFP_NORETRY;
buffer = (char *) __get_free_pages(gfp_flags, order);
if (buffer)
return buffer;
/* complete and utter failure */
return NULL;
}
最终使用了kernel page allocator以分配区块。
switch (po->tp_version) {
case TPACKET_V3:
/* Transmit path is not supported. We checked
* it above but just being paranoid
*/
if (!tx_ring)
init_prb_bdqc(po, rb, pg_vec, req_u);
break;
default:
break;
}
}
调用了init_prb_bdqc()
:这个函数把提供的ring buffer的参数拷贝到prb_bdqc域中,基于参数计算一些其他的参数,建立block retire timer,调用prb_open_block
。
static void init_prb_bdqc(struct packet_sock *po,
struct packet_ring_buffer *rb,
struct pgv *pg_vec,
union tpacket_req_u *req_u)
{
struct tpacket_kbdq_core *p1 = GET_PBDQC_FROM_RB(rb);
struct tpacket_block_desc *pbd;
memset(p1, 0x0, sizeof(*p1));
p1->knxt_seq_num = 1;
p1->pkbdq = pg_vec;
pbd = (struct tpacket_block_desc *)pg_vec[0].buffer;
p1->pkblk_start = pg_vec[0].buffer;
p1->kblk_size = req_u->req3.tp_block_size;
p1->knum_blocks = req_u->req3.tp_block_nr;
p1->hdrlen = po->tp_hdrlen;
p1->version = po->tp_version;
p1->last_kactive_blk_num = 0;
po->stats.stats3.tp_freeze_q_cnt = 0;
if (req_u->req3.tp_retire_blk_tov)
p1->retire_blk_tov = req_u->req3.tp_retire_blk_tov;
else
p1->retire_blk_tov = prb_calc_retire_blk_tmo(po,
req_u->req3.tp_block_size);
p1->tov_in_jiffies = msecs_to_jiffies(p1->retire_blk_tov);
p1->blk_sizeof_priv = req_u->req3.tp_sizeof_priv;
p1->max_frame_len = p1->kblk_size - BLK_PLUS_PRIV(p1->blk_sizeof_priv);
prb_init_ft_ops(p1, req_u);
prb_setup_retire_blk_timer(po);
prb_open_block(p1, pbd);
}
调用了prb_open_block
,这个函数设置了tpacket_kbdq_core
的nxt_offset
域,使这个值刚好指向每个区块private area后面的位置。
static void prb_open_block(struct tpacket_kbdq_core *pkc1,
struct tpacket_block_desc *pbd1)
{
...
pkc1->pkblk_start = (char *)pbd1;
pkc1->nxt_offset = pkc1->pkblk_start + BLK_PLUS_PRIV(pkc1->blk_sizeof_priv);
...
}
每当收到新的packet,内核会将其保存到ring buffer中。关键函数是__packet_lookup_frame_in_block()
。
其功能是:
- 检查当前活跃block是否有足够的空间容纳packet。
- 如果有则保存packet到当前块中并返回。
- 没有则分配下一个区块,并且将packet保存到那里。
static void *__packet_lookup_frame_in_block(struct packet_sock *po,
struct sk_buff *skb,
int status,
unsigned int len
)
{
struct tpacket_kbdq_core *pkc;
struct tpacket_block_desc *pbd;
char *curr, *end;
pkc = GET_PBDQC_FROM_RB(&po->rx_ring);
pbd = GET_CURR_PBLOCK_DESC_FROM_CORE(pkc);
...
curr = (char *)prb_dispatch_next_block(pkc, po);
if (curr) {
pbd = GET_CURR_PBLOCK_DESC_FROM_CORE(pkc);
prb_fill_curr_block(curr, pkc, pbd, len);
return (void *)curr;
}
...
}
在packet_set_ring()
中:
if (po->tp_version >= TPACKET_V3 &&
(int)(rep->tp_block_size - BLK_PLUS_PRIV(req_u->req3.tp_sizeof_priv))<= 0 )
这个检查的目的是保证block头和private data的长度之和小于块的大小。
通过整数溢出:
A = req->tp_block_size = 4096 = 0x1000
B = req_u->req3.tp_sizeof_priv = (1 << 31) + 4096 = 0x80001000
BLK_PLUS_PRIV(B) = (1 << 31) + 4096 + 48 = 0x80001030
A - BLK_PLUS_PRIV(B) = 0x1000 - 0x80001030 = 0x7fffffd0
(int)0x7fffffd0 = 0x7fffffd0 > 0
在后续,req_u->req3.tp_sizeof_priv
会在init_prb_bdqc()
中被拷贝到p1->blk_sizeof_priv
,由于其类型为unsigned short
,所以会被截断。利用这个bug可以任意设置blk_sizeof_priv
。
内核中对其有两种使用方式:
在init_prb_bdqc()
中设置max_frame_len
,这个值表示可以保存到块中的帧的最大大小。因为已经控制了p1->blk_sizeof_priv
,所以可以让BLK_PLUS_PRIV(p1->blk_sizeof_priv)
的值比p1->kblk_size
大。导致p1->max_frame_len
有一个巨大的值。最终可以使得当一个帧被拷贝到区块中时绕过size检查,绕制堆的越界写。
prb_open_block()
,初始化一个区块,pkc1->nxt_offset
表示地址(新的packet接收到时将要写入的地址)。内核对于block header和per-block private data不想覆盖,pkc1->nxt_offset
指向了区块头和private数据的后面。由于已经控制了blk_sizeof_priv
,所以控制了nxt_offset
低两位,允许越界写。
Ubuntu 16.0.4.2 内核版本4.8.0-41-generic内核版本,操作系统防御措施:KASLR、SMEP、SMAP。
- 利用的想法:使用堆越界重写内存中临近溢出块的函数指针。
- 需要:带有可触发函数指针的对象放置在一个循环缓冲区块之后。
- 目标:packet_sock。
- 需要的工作:让内核分配一个环形缓冲区区块和packet_sock相邻。
分析:
ring buffer通过内核的页分配器(buddy allocator,伙伴系统)分配。
伙伴系统:分配器会为每个2^n大小的内存维护一个空闲链表,每次需要分配的时候找对应的n并且返回头结点。如果对于某个n空闲链表为空,那么会找第一个m>n的,并且对其进行分割。
做法:不停申请2^n的区块,所以某一个时刻会出现一个大内存裂变,进而保证邻近的。
packet_sock是通过kmalloc进行分配的,其不是通过伙伴系统分配而是slab分配器。(slab分配器主要分配小于一页的,通过page allocator分配一大块内存,并且将其分成小块,这一大块内存被叫做slab。slabs的集合,以及它们的当前状态、以及一系列的操作,例如分配对象、释放对象,叫做cache。slab分配器会创建一系列通用目的的cache,大小是2^n。当kmalloc(size)
被调用时,slab 分配器会找最接近的2的指数使用cache)。
结论:当想要分配对象时,对象所在内存大概率来自其中一个slabs并且以前已经使用过。然而,如果分配相同的大小的object,某时slab分配器会用完这个size的slab,会通过page allocater分配slab。
新分配的slab的大小取决于这个slab对象的大小,packet_sock大小通常为1920, 所以会使用kmalloc-2048 cache。对于特定的cache SLUB(Ubuntu中使用的那种分配器)使用大小为0x8000的slab。因此,每当分配器用完kmalloc-2048缓存的块时,会向页面分配器分配0x8000字节。
最终:如何分配kmaalloc-2048 slab临近ring buffer block:
- 分配大量(例如512个)2048的object,填充当前已经存在的kmalloc-2048cache。创建一系列的packet socket,使得分配一系列packet_sock结构体。
- 分配大小的0x8000将对应的page allocator空闲列表,并且引发一些high-order 页被分割。创意一个pakcet socket,并且attach一个ring buffer有1024个0x8000的区块。
- 创建一个packet socket并且attach一个0x8000的ring buffer,最后一个区块就是需要overflow的。
- 创建一系列pakcet sockets以分配pakcet_sock结构体,导致新slab的分配。
该错误导致在超出环形缓冲区块边界的受控偏移量上写入受控的最大大小。事实证明,我们不仅可以控制最大大小和偏移量,我们实际上可以控制写入的确切数据(和它的大小)。由于存储在环形缓冲区块中的数据是经过特定网络接口的数据包,所以我们可以通过loopback接口手动发送原始套接字上带有任意内容的数据包。如果我们在一个隔离的网络名称空间中进行此操作,就不会有外部流量的干扰。【解释了为什么需要创建net namespace】
一些注意事项:
- 需要至少14字节(12字节)。
nxt_offset
的低三位,总是0?【我理解错了吗】,按照8字节对齐。- 如果将nxt_offset指向特定的偏移量,一些数据,例如区块头和帧头会被破坏。
- 如果将
nxt_offset
指向block的尾,在收到第一个packet后,第一个区块会立即关闭。
提权套路:进程环境下执行commit_creds(prepare_kernel_cred(0))
。
pakcet_sock中有两个函数指针:
packet_sock->xmit
packet_sock->rx_ring->prb_bdqc->retire_blk_timer->func
packet_sock->xmit
:当用户通过pakcet socket发送packet时,会调用xmit函数指针调用。
需要将payload放到可执行的内存区域中,如果是在用户空间则需要绕过SMEP以及SMAP。
retire_blk_timer
:每当retire timeer到时时,这个函数会被调用。正常的执行流retire_blk_timer->func
指向了prb_retire_rx_blk_timer_expired
,并且以retire_blk_timer->data
作为参数,这参数包含了packet_sock结构体的地址。
关闭SMEP和SMAP的套路:修改CR4寄存器。调用native_write_cr4(X)。
利用sched_setaffinity系统调用是的exp执行在特定的CPU核上,保证该CPU上SMAP和SMEP被禁用。
总体利用步骤:
- 计算内核基地址,绕过KASLR。
- 构建堆的布局。
- 禁用SMEP和SMAP:
- 在一个ring buffer后面分配一个packet_sock。
- 在packet_sock上attach一个接收ring buffer,以分配其一个block retire timer。
- 溢出,覆盖retire_blk_timer域,使得
retire_blk_timer->func
指向native_write_cr4
,使得retire_blk_timer->data
的值等同于CR4的值。 - 等待这个函数被执行,当前CPU上的SMEP和SMAP被禁用。
- 获取root权限:
- 分配另外一对packet_sock和ring buffer区块。
- 覆盖区块,并且覆盖xmit域,使得xmit指向commit_creds(prepare_kernel_cred(0)),这一部分在userspace。
- 发送packet,触发xmit,获取root权限。
以上完成了本地提权,后面继续分析如何进行容器逃逸。
在拿到一个可以执行任意内核代码的漏洞后,可以考虑去将自己进程的namespace切换到host上。
- 具体可以首先获取容器内init进程的
task_struct
。 - 让容器中init进程的nsproxy字段指向
init_nsproxy
。 - 利用setns系统调用将exp进程切换到init进程的namespace中。
注意setns系统调用通常会被docker的默认seccomp所过滤,但是我们可以考虑直接在内核中执行setns系统调用的执行函数达到同样的效果。
- 调用sched_setaffinity保证exp进程只在CPU 0上运行。
- 开启环形接口,问题:在没有安装ipconfig的机器上无法运行吧。
kmalloc_pad:调用512次 packet_sock_kmalloc
:
socket(AF_PACKET,SOCK_DGRAM,htons(ETH_P_ARP))
pagealloc_pad:调用了packet_socket_setup
。
packet_socket_setup
:调用了packet_socket_rx_ring_init
。
packet_socket_rx_ring_init
:分两次调用setsockopt:
- 第一次调用:指定
PACKET_VERSION
为TPACKET_V3
。 - 第二次调用:指定
PACKET_RX_RING
,传递参数req(类型为tpacket_req3结构体,结构体中tp_block_nr指定为1024),目的是分配1024个0x8000大小的内存,为了把伙伴系统中的内存分配完。然后就会有大页被分配。
oob_timer_executed
oob_setup
:调用pakcet_socket_setup
packet_socket_setup
:创建一个socket,然后调用packet_socket_rx_ring_init
,初始化版本和rx_ring_buffer
,其中区块的数量为2。
调用bind进行绑定。
回到oob_setup
,创建32个packet socket,调用pakcet_sock_timer_schedule
。
packet_sock_timer_sehedule
调用packet_socket_rx_ring_init
进行初始化,区块数量为1。
oob_write
:调用loopback_send
。
loopback_send
:创建一个套接字,然后调用packet_socket_send
。
调用sendto发送数据,发送的内容实质上就是shellcode。
sleep
:等待被触发。
由于不同发行版的差异,作者原exp中的地址偏移和实际中不太相同。需要手动定位。
在root权限下获取内核关键函数的地址:
获取内核地址基址:
sudo grep "T _text" /proc/kallsyms
ffffffff8e400000 T _text
获取commit_creds地址:
sudo grep commit_creds /proc/kallsyms
ffffffff8e4a5d50
获取prepare_kernel_cred地址:
sudo grep prepare_kernel_cred /proc/kallsyms
ffffffff8e4a6140
获取native_write_cr4地址:
sudo grep native_write_cr4 /proc/kallsyms
ffffffff8e464210
宿主机环境:
- VMware 16.1.0
- Ubuntu 16.04
首先切换内核,直接通过Metarget安装环境
./metarget cnv install cve-2017-7308 --verbose
基于本目录构建漏洞利用镜像并运行容器:
gcc -o poc poc.c
docker build -t exp .
docker run -it --rm exp
在容器中执行以下命令:
/poc
在弹出的shell中已经成功逃逸,且拥有全部的capabilities,相当于宿主机上的root拥有的权限。