[kernel exploit] Dirty Cred: 一种新的无地址依赖漏洞利用方案
文章目录
简介
背景
本文主要对Zhenpeng Lin博士在2022-CCS发表的论文DirtyCred: Escalating Privilege in Linux Kernel 进行分析,Dirty Cred 是该论文中提到的一种新的内核漏洞利用思路,同时也在Black Hat中作为议题Cautious! A New Exploitation Method! No Pipe but as Nasty as Dirty Pipe 的主要内容,CVE-2022-2588 和CVE-2021-4154 也是使用该漏洞利用方法完成利用。
Dirty Cred
Dirty Cred 是一种新的linux 内核漏洞利用方法,为什么单独拿出来分析呢,因为Dirty Cred 和之前分析的"胜利方程式"一样,可以不依赖特定内核版本(特定地址)来完成漏洞利用,达到一个可以再漏洞适配的版本范围内进行一个"通杀"的效果。论文中对于dirty pipe 的回忆部分这里不过多赘述,直接来看dirty cred干货。
基础知识
内核凭证
内核中有很多结构体包含一些权限信息,比如struct cred
包含一个进程的uid、gid等信息,struct file
包含一个文件的基本信息、访问权限等,除此之外还有inode 等,不过在Dirty Cred 漏洞利用中重点实验的是struct file
。
如果可以通过漏洞替换/篡改凭证结构(中的关键信息),那么就可以达到提权效果。但这要根据具体漏洞允许的操作而具体分析。
cred
struct cred
常用语内核task 模块中,表示一个进程/任务的权限信息,包括uid、gid、euid…等身份信息,还有capability 信息。
struct cred {
atomic_t usage;
#ifdef CONFIG_DEBUG_CREDENTIALS
atomic_t subscribers; /* number of processes subscribed */
void *put_addr;
unsigned magic;
#define CRED_MAGIC 0x43736564
#define CRED_MAGIC_DEAD 0x44656144
#endif
kuid_t uid; /* real UID of the task */
kgid_t gid; /* real GID of the task */
kuid_t suid; /* saved UID of the task */
kgid_t sgid; /* saved GID of the task */
kuid_t euid; /* effective UID of the task */
kgid_t egid; /* effective GID of the task */
kuid_t fsuid; /* UID for VFS ops */
kgid_t fsgid; /* GID for VFS ops */
unsigned securebits; /* SUID-less security management */
kernel_cap_t cap_inheritable; /* caps our children can inherit */
kernel_cap_t cap_permitted; /* caps we're permitted */
kernel_cap_t cap_effective; /* caps we can actually use */
kernel_cap_t cap_bset; /* capability bounding set */
kernel_cap_t cap_ambient; /* Ambient capability set */
#ifdef CONFIG_KEYS
unsigned char jit_keyring; /* default keyring to attach requested
* keys to */
struct key *session_keyring; /* keyring inherited over fork */
struct key *process_keyring; /* keyring private to this process */
struct key *thread_keyring; /* keyring private to this thread */
struct key *request_key_auth; /* assumed request_key authority */
#endif
#ifdef CONFIG_SECURITY
void *security; /* LSM security */
#endif
struct user_struct *user; /* real user ID subscription */
struct user_namespace *user_ns; /* user_ns the caps and keyrings are relative to. */
struct group_info *group_info; /* supplementary groups for euid/fsgid */
/* RCU deletion */
union {
int non_rcu; /* Can we skip RCU deletion? */
struct rcu_head rcu; /* RCU deletion hook */
};
} __randomize_layout;
file
struct file
结构体描述一个文件的基本信息,包括文件地址、文件inode等,除此之外还保存着文件的访问信息,也就是说决定当前进程是否可读/写该文件(一定是打开成功才会有struct file
,如果打开都无法打开的话,则不会有struct file
)。
struct file {
union {
struct llist_node fu_llist;
struct rcu_head fu_rcuhead;
} f_u;
struct path f_path;
struct inode *f_inode; /* cached value */
const struct file_operations *f_op;
/*
* Protects f_ep, f_flags.
* Must not be taken from IRQ context.
*/
spinlock_t f_lock;
enum rw_hint f_write_hint;
atomic_long_t f_count;
unsigned int f_flags;
fmode_t f_mode; //读写权限
struct mutex f_pos_lock;
loff_t f_pos;
struct fown_struct f_owner;
const struct cred *f_cred;
struct file_ra_state f_ra;
u64 f_version;
#ifdef CONFIG_SECURITY
void *f_security;
#endif
/* needed for tty driver, and maybe others */
void *private_data;
#ifdef CONFIG_EPOLL
/* Used by fs/eventpoll.c to link all the hooks to this file */
struct hlist_head *f_ep;
#endif /* #ifdef CONFIG_EPOLL */
struct address_space *f_mapping;
errseq_t f_wb_err;
errseq_t f_sb_err; /* for syncfs */
} __randomize_layout
__attribute__((aligned(4))); /* lest something weird decides that 2 is OK */
struct file_handle {
__u32 handle_bytes;
int handle_type;
/* file identifier */
unsigned char f_handle[];
};
slab 种类
众所周知,在内核内存管理使用slub 进行内存分配,slub 是slab 算法的进阶,大体思路就是一个slab(struct kmem_cache
)只负责分配固定大小的内存块,slab通过向伙伴系统申请内存页,再根据自己所管理的内存块大小将该内存页分割成若干内存块分配出去的方式。也就是说,通常情况下,两个内存分配操作如果分配的大小是不同的,那么这两个内存相连的概率是非常小的(几乎不可能,除非一个在页面头部另一个在页面尾部),但即便两个内存分配操作大小相同,也不一定能保证可以让他们连着,因为这就要引申出通用slab 和特殊slab 的概念。
通用内存slab
在内核中,直接调用kmalloc 分配的内存,如果特别大(大于一页)则会直接调用伙伴系统申请页面。否则会调用通用slab 进行分配,在kmalloc代码中:
linux\include\linux\slab.h : kmalloc
static __always_inline void *kmalloc(size_t size, gfp_t flags)
{
if (__builtin_constant_p(size)) {
#ifndef CONFIG_SLOB//这里分析SLUB,不考虑SLOB相关(不共存)
unsigned int index;
#endif
if (size > KMALLOC_MAX_CACHE_SIZE)//大于slab 支持的最大分配大小直接调用页分配
return kmalloc_large(size, flags);
#ifndef CONFIG_SLOB
index = kmalloc_index(size);//根据需要分配的大小获取slab 下标
if (!index)
return ZERO_SIZE_PTR;
return kmem_cache_alloc_trace(//进入分配流程
kmalloc_caches[kmalloc_type(flags)][index],
flags, size);
#endif
}
return __kmalloc(size, flags);
}
全局变量 kmalloc_caches 是一个slab(struct kmem_cache
) 的二维列表,所有通过kmalloc 分配的内存会先根据大小计算出下标2,然后根据kmalloc 的flag 类型计算出下标1,然后通过两个下标从kmalloc_caches 中找到对应的slab(struct kmem_cache
) ,然后调用kmem_cache_alloc_trace完成内存分配:
linux\include\linux\slub.c : kmem_cache_alloc_trace
void *kmem_cache_alloc_trace(struct kmem_cache *s, gfp_t gfpflags, size_t size)
{
void *ret = slab_alloc(s, gfpflags, _RET_IP_, size);//直接调用slab_alloc
trace_kmalloc(_RET_IP_, ret, size, s->size, gfpflags);
ret = kasan_kmalloc(s, ret, size, gfpflags);
return ret;
}
kmem_cache_alloc_trace 之中时通过slab_alloc 函数完成的实际内存分配。
特殊内存slab
filp
以申请struct file
的代码为例,在函数__alloc_file
中:
fs\file_table.c : __alloc_file
static struct file *__alloc_file(int flags, const struct cred *cred)
{
struct file *f;
int error;
f = kmem_cache_zalloc(filp_cachep, GFP_KERNEL);
··· ···
··· ···
return f;
}
这里调用了kmem_cache_zalloc 并使用了一个叫做filp_cachep 的slab(struct kmem_cache
) 进行分配,kmem_cache_zalloc 的实现也是直接调用slab_alloc 完成内存分配:
mm\slab.c : kmem_cache_alloc
static inline void *kmem_cache_zalloc(struct kmem_cache *k, gfp_t flags)
{
return kmem_cache_alloc(k, flags | __GFP_ZERO);
}
void *kmem_cache_alloc(struct kmem_cache *cachep, gfp_t flags)
{
void *ret = slab_alloc(cachep, flags, cachep->object_size, _RET_IP_);//调用slab_alloc
trace_kmem_cache_alloc(_RET_IP_, ret,
cachep->object_size, cachep->size, flags);
return ret;
}
EXPORT_SYMBOL(kmem_cache_alloc);
也就是说分配动作都是一样的,但通用内存是从全局变量kmalloc_caches 中找的合适的slab(struct kmem_cache
) 进行分配,而struct file
是使用filp_cachep 来进行分配,我们看一下filp_cachep 的初始化:、
fs\file_table.c : files_init
static struct kmem_cache *filp_cachep __read_mostly;
void __init files_init(void)
{
filp_cachep = kmem_cache_create("filp", sizeof(struct file), 0,
SLAB_HWCACHE_ALIGN | SLAB_PANIC | SLAB_ACCOUNT, NULL);
percpu_counter_init(&nr_files, 0, GFP_KERNEL);
}
这里初始化了一个叫做filp 的slab(struct kmem_cache
),他负责分配的内存大小就是sizeof(struct file)
,之后所有的struct file
都会使用该slab进行分配。
cred
和file类似,也有自己专用的slab:
kernel\cred.c :
static struct kmem_cache *cred_jar;
void __init cred_init(void)
{
/* allocate a slab in which we can store credentials */
cred_jar = kmem_cache_create("cred_jar", sizeof(struct cred), 0,
SLAB_HWCACHE_ALIGN|SLAB_PANIC|SLAB_ACCOUNT, NULL);
}
struct cred *cred_alloc_blank(void)
{
struct cred *new;
new = kmem_cache_zalloc(cred_jar, GFP_KERNEL);
··· ···
··· ···
return new;
}
slab 信息可以通过/proc/slab 查看:
下面kmallocx-xx的是通用slab:
所以,在通常情况下,不同slab分配的内存想要连着是非常困难的,需要我们做一些内核堆风水(后文描述)。
漏洞利用思路与实例
思路
内核中对权限/身份的校验发生在具体操作之前,而校验身份的动作往往不会被锁限制。那么我们可以卡在身份验证和操作之间将目标结构替换掉来完成漏洞利用。这可能需要一些手段来将非内核凭证slab 的漏洞转换为内核凭证slab区域的漏洞,和一些延长身份验证和具体操作之间的时间窗的手段。
主要通过下面两个真实CVE漏洞来演示Dirty Pipe漏洞。
CVE-2021-4154
该漏洞的详细分析见[漏洞分析] CVE-2021-4154 cgroup1 fsconfig UAF内核提权,这里简要分析,已知CVE-2021-4154 漏洞是一个非法释放漏洞,可以不影响文件描述符的情况下非法释放一个struct file
结构体。
- 打开一个可写文件 /tmp/x ,那么就会在filep slab中申请出一个
struct file
结构体,我们尝试向该文件(/tmp/x) 中写入"打算写入/etc/passwd中的任意内容",也就是hacker:x:0:0:root:/:/bin/sh
- 权限校验(是否可写)会瞬间完成,不会收到任何锁的限制
- 权限校验完成后会进行写入,这时我们可以通过一些手段让它阻塞在写入之前(后文会说)
- 利用漏洞非法释放该
struct file
结构体 - 打开一堆/etc/passwd,就相当于喷射一堆/etc/passwd 的
struct file
结构体,这样就会有一个覆盖到刚刚非法释放的/tmp/x的struct file
所在的内存中,但由于之前是非法释放的,所以这里/tmp/x的fs_context (文件系统上下文)并无感知文件已经被替换了。- 而这时第一步中尝试写入操作的进程阻塞结束,开始写入,由于权限校验早已完成,这里会直接向
struct file
指向的文件/etc/passwd中写入任意内容,完成攻击。
- 而这时第一步中尝试写入操作的进程阻塞结束,开始写入,由于权限校验早已完成,这里会直接向
在该漏洞中的难点就是如何让写入操作阻塞在权限校验和实际写入之间,这会在后文中说明。
CVE-2022-2588
详见[漏洞分析] CVE-2022-2588 route4 double free内核提权,这里不过多赘述了,看上面的CVE-2021-4154已经可以简单了解了。
技术挑战
其实Dirty Cred类似的利用方法早已存在,只不过在当前内核版本很多关键细节已经无法完成。Dirty Cred的作者主要是解决了当前版本无法使用的几个难题,和将很多常见漏洞类型转化为可以"非法释放/篡改"内核凭证结构的原语。
构造漏洞原语
下面介绍常见的三种内核堆漏洞转换为dirty cred 漏洞利用原语的过程。
越界写
可以通过一些堆风水,让低权限的凭证结构体所在的slab页的其他位置(主要是页首部)为高权限的凭证结构体(如struct cred
),然后通过越界写带有凭证指针的结构体,将其低地址覆盖为0x0000(两字节0),让其指向该slab页(或某其他slab页)首部我们构造的其他高权限凭证来达到提权效果。
可以通过喷射若干高权限凭证结构,然后释放其中几个制造空洞,然后申请低权限凭证就会落在空洞之中的常见堆风水手段。
UAF
uaf 需要有写的能力,如果发生在特权凭证slab 上,那么可以直接修改特权凭证。如果发生在非特权凭证slab 上,那么只能类似越界写的办法修改"带有特权凭证指针"的其他结构,这似乎还要求uaf的写能力有比较精确操作的可能。
double free/非法free
double free比较有意思,如果double free 发生在特权凭证的slab 上,那么可以直接使用第一次非法free 释放一个低权限凭证结构,然后喷射高权限凭证结构覆盖来达到提权的效果。
如果double free发生在普通slab上,则比较麻烦,因为经过之前的分析,我们知道,不同类型的slab,即便管理的内存大小相同,那么也无法分配到一起去,也就是说从普通slab 中释放的堆块,是无法从凭证slab 中申请到的。但这里我们可以使用cross cache attack 的方法来完成攻击。
之前提到过,slab管理的是一个个内存页,将内存页切割成自己管理的内存大小的块分配出去,若一个页面中的内存块全部被释放,那么该内存页就会被系统回收,不再属于slab内存页。我们可以利用该机制来完成跨slab种类的攻击,首先我们假定发生double free 的slab 和凭证slab 的大小是一样的为kmalloc-x,该利用方法也是CVE-2022-2588中采用的:
- 喷射若干kmalloc-x 的堆块,其中构造一个洞,然后让可以double free 的结构落在洞中。或如果double free的目标允许喷射的话,则直接喷射若干目标结构即可。最后达到的一个效果就是,double free的目标指针所在slab page 的所有堆块我们都可以手动释放。
- 手动释放刚刚喷射的所有堆块,并且使用一次double free/非法free 操作,这样double free对象所在slab 页已经被释放空,那么该页就会被回收,但非法释放的指针还指向这个页中的内存块上。
- 喷射若干可控凭证结构,这时凭证slab 就会向系统申请内存页,刚被回收的页面就会被分配给凭证slab。这时之前非法释放的指针就指向了一个凭证结构体。
- 使用第二次free,将该凭证释放掉,然后喷射一堆高权限的/提权目标的凭证结构,就会申请到刚刚被非法释放的低权限凭证结构的内存块,就达到了替换低权限凭证为高权限凭证的目的。
但事实是并不能保证我们double free发生的slab 大小和凭证slab 大小相同,如果大小不同,那么可以按照如下方式来构造凭证的替换:
-
同样是喷射若干double free 大小的堆块,达到释放的时候能让double free 目标所在page 全部释放空就行
-
然后使用两次double free构造一个"三个个可以释放的指针指向同一个内存块"的状态
-
把喷射的堆块全部释放,再释放一次目标堆块,让double free目标所在page释放空以至于页面被回收
-
喷射若干可控凭证结构,这时凭证slab 就会向系统申请内存页,刚被回收的页面就会被分配给凭证slab。这时之前非法释放的指针就指向了凭证slab页面中一个不和结构体对齐的位置:
-
虽然这里ptr 1’ 和ptr 2’ 并不整齐的指向凭证结构,但仍然可以通过以下方法完成置换:
- 释放ptr 1’ ,然后喷射若干低权限凭证,就会有一个落在ptr 1’的位置
- 释放ptr 2’ ,然后喷射若干高权限凭证,完成替换
这里主要利用了free 的这个机制,查看kfree源码:
linux\mm\slub.c : kfree
void kfree(const void *x)
{
struct page *page;
void *object = (void *)x;
trace_kfree(_RET_IP_, x);
if (unlikely(ZERO_OR_NULL_PTR(x)))
return;
page = virt_to_head_page(x);//根据地址找到对应的page结构体
if (unlikely(!PageSlab(page))) {//如果该page 不是slab,那么就是大块内存了,调用free_page释放
unsigned int order = compound_order(page);
BUG_ON(!PageCompound(page));
kfree_hook(object);
mod_lruvec_page_state(page, NR_SLAB_UNRECLAIMABLE_B,
-(PAGE_SIZE << order));
__free_pages(page, order);
return;
}
slab_free(page->slab_cache, page, object, NULL, 1, _RET_IP_);//释放slab 分配的内存块
}
首先会通过释放的堆块地址找到对应的struct page
结构体(这个page肯定用于slab 分配),然后会调用slab_free 来进行实际分配,也就是说,第一步的操作是根据释放的堆内存,找到管理该内存page 的slab(struct kmem_cache
)。
linux\mm\slub.c : slab_free
static __always_inline void slab_free(struct kmem_cache *s, struct page *page,
void *head, void *tail, int cnt,
unsigned long addr)
{
if (slab_free_freelist_hook(s, &head, &tail))
do_slab_free(s, page, head, tail, cnt, addr);//调用do_slab_free
}
static __always_inline void do_slab_free(struct kmem_cache *s,
struct page *page, void *head, void *tail,
int cnt, unsigned long addr)
{
··· ···
redo:
··· ···
··· ···
if (likely(page == c->page)) {//释放的对象正好属于当前cpu_slab正在使用的slab则快速释放
void **freelist = READ_ONCE(c->freelist);//获取freelist
set_freepointer(s, tail_obj, freelist);//将新释放内存块插入freelist 开头
if (unlikely(!this_cpu_cmpxchg_double(//this_cpu_cmpxchg_double()原子指令操作存放
s->cpu_slab->freelist, s->cpu_slab->tid,
freelist, tid,
head, next_tid(tid)))) {
note_cmpxchg_failure("slab_free", s, tid);//失败记录
goto redo;
}
stat(s, FREE_FASTPATH);
} else
__slab_free(s, page, head, tail_obj, cnt, addr);//否则说明释放的内存不是当前cpu_slab立马能释放的
}
这里可以看到,将释放的堆块直接放入freelist 的开头。那么下次申请该slab 的内存的时候,就会从freelist 开头直接取一个内存。可以看出这里并没有进行内存地址对齐的判断(后面的申请操作中也没有)。
总结一下,虽然我们的内存是在该页是另一个大小的slab 管理的时候申请的,释放时该页已经换slab 管理了(大小变了),但释放后还是可以正常放入新slab 的freelist中,换句话说,内核内存在释放的时候会把它当做它所处page 所属slab 的大小看待。所以即便这里内存不对齐也可以完成释放和后续申请操作。后面就和上面普通double free 操作原理一样了。
延长时间窗
Dirty Cred 本质上还是一个条件竞争类的漏洞利用手段,那么条件竞争的关键就是延长关键操作时间窗。前文题到过,类似的利用方法在之前就已经存在,那么我们先看一下老版本中使用何种手段来延长时间窗,在分析失效之后新版Dirty Cred 如何解决这个问题。
老版本userfaultfd
userfaultfd 是一种用户态处理缺页中断的方式。当内核访问到没有实际映射到物理页的内存地址的时候,会发生缺页中断,大部分缺页中断是内核自己处理,但内核提供了一种给用户注册缺页中断处理函数的机制,就是userfaultfd。用户态缺页中断的行为是用户完全可控的,也就是我们可以主动拖时间,延长缺页中断的时间。但从5.11 版本开始内核已经进制了非特权用户的userfaultfd注册,但非特权用户还可以使用fuse 文件系统来注册userfaultfd,禁止了个寂寞。(但不得不说fuse 也不是所有场景都有的)
在linux 内核4.13 之前,writev 系统调用的关键部分如下:
可以看出相关操作的顺序是:
- 进行访问权限校验(是否可写)
- 从用户空间获取写入内容
- 实际写入操作
在这个顺序下,我们可以在第二步从用户空间获取写入内容的时候使用userfaultfd 来拖时间,延长这个身份校验和实际写入操作之间的时间窗。然而在4.13版本之后逻辑变成了下面这样:
- 从用户空间获取写入内容
- 进行访问权限校验(是否可写)
- 实际写入操作
先从用户空间获取写入内容,这样我们就无法拖时间了。
新版本利用文件系统锁
在新版本中(目前5.x的kernel 版本)Dirty Cred 提出了一种新的"拖时间办法",利用文件系统的inode锁:
- 在已经有一个进程对一个文件进行写入操作的时候,会给文件inode上锁,其他向该文件进行写入的进程需要等待上一个进程写入完成解锁
- 对文件是否可以写入的权限判断并不受锁的影响
那么可以使用的思路就是:
- 先存在一个进程向一个可写文件写入大量内容,inode锁会锁住较长时间
- 第二个进程尝试向该文件写入"打算写入/etc/passwd等特权文件的内容"
- 第三个进程利用漏洞替换file结构体
类似CVE-2022-2588 中的这个逻辑图(该漏出可以利用UAF构造出两个指向同一个struct file 的fd,可以对一个struct file释放两次):
分配特权对象的内核凭证
在上面两个漏洞中,都是使用的struct file
内核凭据,对于file类型凭据我们可以使用普通用户可读特权用户可写的/etc/passwd来进行操作,普通用户就可以喷射大量目标用于攻击。但特权的struct cred
就没那么容易了。可以通过:
- 执行大量suid 程序,如sudo(但大部分情况下并没有这个权限)
- 使用kernel thread,kernel 自己创建的任务是特权任务,我们可以利用一些内核接口控制内核启动一堆kernel thread:
- 利用workqueue
- 利用usermode helper
总结
可用结构体
在内核中的所有结构体中,含有指向内核凭证结构的指针的结构体有如下这些,按照大小分类:
星号是代表指向struct file
结构的结构体,十字架符号是代表指向struct cred
结构的结构体,根据实际情况选择使用。
可以看出几乎在任意大小的范围内都有可用结构体,结合具体漏洞和上面所述的原语就可以转化为Dirty Cred来完成"通杀"exp。
满足条件漏洞
根据一些历史漏洞,理论上满足Dirty Cred利用条件的有如下:
可见该方法通用性还是非常大的。
其实根据上面的分析就可以看出,double free原语几乎无懈可击,而对于uaf 或者溢出类的则要求高一些,要求这些漏洞必须有能精确修改下面结构体中固定位置值的能力,大范围覆盖或者溢出一两个字节或者不可控的写入都是很难利用的。
防御
懒得看了。
参考
DirtyCred: Escalating Privilege in Linux Kernel (zplin.me)
【bsauce读论文】2022-CCS-DirtyCred: Escalating Privilege in Linux Kernel
更多推荐
所有评论(0)