Let_go
人生如棋,我愿为卒。行动虽慢,可谁曾见我后退一步。

CVE-2017-7533

2018/03/25 Android_Kernel

CVE-2017-7533


前言

漏洞信息

漏洞验证

https://github.com/hardenedlinux/offensive_poc/blob/master/CVE-2017-7533/exploit.c

背景知识

首先了解下漏洞涉及到的Inotify机制(文件系统变化通知机制)。
为了更好的管理设备,给用户提供更好的服务,如hotplug,udev和inotify就是这类需求催生的,Hotplug是一种内核想用户态应用通报关于热插拔设备事件发生的机制,udev动态维护/dev下的设备文件,inotify是一种文件系统变化通知机制,如文件增加、删除、重命名等事件可以立刻让用户态获得。
Inotify API用于检测文件系统变化的机制,可用于检测单个文件,也可以用于检测整个目录,该机制出现的目的是当内核空间发生某种事件之后,可以立即通知用户空间,方便用户做出具体的操作,该漏洞就出现在inotify机制相关的函数中。
Inotify即可以监视文件,也可以监视目录
Inotify使用系统调用而非SIGIO来通知文件系统时间
Inotify使用文件描述符作为接口,因而可以使用通常的I/O操作select和poll来监视文件系统的变化。
用户层接口:
inotify_init(void):用于创建一个inotify的实例,返回inotify事件队列的文件描述符
inotify_add_watch(int fd,const char* pathname,uint32_t mask):用于添加”watch list(检测列表)”,成功返回一个unique的watch描述符
inotify_rm_watch(int fd,int wd):用于从watch list中移除检测的对象
可以通过read函数获取监听到的事件:
size_t len = read (fd, buf, BUF_LEN); :fd指向inotify实例的文件描述符
数据结构

1
2
3
4
5
6
7
8
9
10
11
12
struct inotify_event {
int wd; /* Watch descriptor */
uint32_t mask; /* Mask of events */
uint32_t cookie; /* Unique cookie associating related events (for rename(2)) */
uint32_t len; /* Size of name field */
char name[]; /* Optional null-terminated name */
};
.wd : 检测对象的watch descriptor
.mask : 检测事件的mask
.cookie : 和rename事件相关
.len : name字段的长度
.name : 检测对象的name

1
2
3
4
5
6
7
struct inotify_event_info {
struct fsnotify_event fse;
int wd;
u32 sync_cookie;
int name_len;
char name[];
};
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
Inotify 可以监视的文件系统事件包括:
- IN_ACCESS,即文件被访问
- IN_MODIFY,文件被 write
- IN_ATTRIB,文件属性被修改,如 chmod、chown、touch 等
- IN_CLOSE_WRITE,可写文件被 close
- IN_CLOSE_NOWRITE,不可写文件被 close
- IN_OPEN,文件被 open
- IN_MOVED_FROM,文件被移走,如 mv
- IN_MOVED_TO,文件被移来,如 mv、cp
- IN_CREATE,创建新文件
- IN_DELETE,文件被删除,如 rm
- IN_DELETE_SELF,自删除,即一个可执行文件在执行时删除自己
- IN_MOVE_SELF,自移动,即一个可执行文件在执行时移动自己
- IN_UNMOUNT,宿主文件系统被 umount
- IN_CLOSE,文件被关闭,等同于(IN_CLOSE_WRITE | IN_CLOSE_NOWRITE)
- IN_MOVE,文件被移动,等同于(IN_MOVED_FROM | IN_MOVED_TO)

漏洞成因:

相关函数

缓冲区溢出链:open -> SyS_open -> do_sys_open -> fsnotify_open -> fsnotify_parent -> __fsnotify_parent -> fsnotify -> send_to_group -> inotify_handle_event
来看看inotify机制中的一个堆溢出漏洞,当监控文件被打开时会调用到下面这个函数
Heap-Overflow:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
int inotify_handle_event(struct fsnotify_group *group,
struct inode *inode,
struct fsnotify_mark *inode_mark,
struct fsnotify_mark *vfsmount_mark,
u32 mask, void *data, int data_type,
const unsigned char *file_name, u32 cookie)
{
struct inotify_inode_mark *i_mark;
struct inotify_event_info *event;
struct fsnotify_event *fsn_event;
int ret;
int len = 0;
int alloc_len = sizeof(struct inotify_event_info); /* 计算inotify_event_info结构体大小 */
BUG_ON(vfsmount_mark);
if ((inode_mark->mask & FS_EXCL_UNLINK) &&
(data_type == FSNOTIFY_EVENT_PATH)) {
struct path *path = data;
if (d_unlinked(path->dentry))
return 0;
}
if (file_name) {
len = strlen(file_name); /* 如果文件名为真,则计算文件名长度 */
alloc_len += len + 1;
}
pr_debug("%s: group=%p inode=%p mask=%x\n", __func__, group, inode,
mask);
i_mark = container_of(inode_mark, struct inotify_inode_mark,
fsn_mark);
event = kmalloc(alloc_len, GFP_KERNEL); /* 分配内存大小等于 sizeof(inotify_event_info) + sizeof(file_name) */
if (unlikely(!event))
return -ENOMEM;
fsn_event = &event->fse;
fsnotify_init_event(fsn_event, inode, mask);
event->wd = i_mark->wd;
event->sync_cookie = cookie;
event->name_len = len;
if (len)
strcpy(event->name, file_name); /* 溢出点 把file_name拷贝到之前分配的内存中 */
ret = fsnotify_add_event(group, fsn_event, inotify_merge);
if (ret) {
/* Our event wasn't used in the end. Free it. */
fsnotify_destroy_event(group, fsn_event);
}
if (inode_mark->mask & IN_ONESHOT)
fsnotify_destroy_mark(inode_mark, group);
return 0;
}
溢出结构体
struct inotify_event_info {
struct fsnotify_event fse;
int wd;
u32 sync_cookie;
int name_len;
char name[]; /* 拷贝时存在溢出 */
};

我们来看看这个函数,首先计算inotify_event_info结构体的大小,然后计算目标文件名的长度,通过前面计算的值使用kmalloc分配出来一块内存,如果file_name的长度不为0,那么就使用strcpy函数把file_name拷贝到event->name处,也就是之前通过计算file_name长度申请的内存中。
问题就出在这个函数中,仔细看会发现这里存在一个堆溢出漏洞(HeapOverflow),实际上这个HeapOverflow是因为竞争条件(Race-condition)产生的。
在访问file_name资源时未做加锁操作,存在一种情况是第一次计算长度时的file_name与第二次strcpt拷贝时的file_name内容不一致,因为strcpy的目标内存是通过计算第一次file_name的长度申请的内存,并且使用的还是strcpy不安全的拷贝函数,所以如果第二次的file_name内容的长度大于第一次计算的长度,那么这里就存在一个堆溢出漏洞。
这种情况很好触发的,只需要再开启一个线程,当线程A执行完strlen(file_name)和kmalloc(alloc_len, GFP_KERNEL)操作后,线程B去修改file_name的内容,当线程A再返回去执行strcpy时就会出现前面说到的堆溢出情况。

Race-condition:
常见的竞争条件漏洞分为两种:
第1种属于time-of-check-to-time-of-use漏洞,程序先检查对象的某个特征,后续的动作是假设这些特征一直保持的情况下作出的,但是这时的特征可能已经不具备了,导致信息不同步问题。
第2种是因为程序的编写者考虑不周全,操作某些关键数据时未对数据做多线程/进程保护,导致多线程/进程访问同一资源时产生资源未及时更新的问题,可以通过这个冲突来对系统进行攻击。
避免出现竞争条件可以通过对关键数据做加锁操作。
竞争条件漏洞模式及其检测

条件竞争模式:
具备的条件:

  1. 存在两个(或两个以上)时间发生,两个事件存在一定的间隔时间,两个事件存在一定的关系,即第二个事件(及其后的事件)依赖于第一个事件。
  2. 攻击者能够改变第一个事件所产生的,为第二个事件所依赖的假设。
    两事件之间是否存在间隔称为”编程条件”,间隔本身称为”编程间隔”,攻击者不但要发现这个间隔,还需要能够影响由第一个事件所产生的假设,这一条件称为”环境条件”。

该漏洞分为长文件名与短文件名两种溢出方式

  • 长文件名使用kmalloc_256溢出
  • 短文件名使用kmalloc_64溢出,不过现在的Android手机貌似已经去除了kmalloc_64分配
    要理解这两种溢出方式我们需要先来看看内核是如何存储溢出字符串的
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
struct dentry {
/* RCU lookup touched fields */
unsigned int d_flags; /* protected by d_lock */
seqcount_t d_seq; /* per dentry seqlock */
struct hlist_bl_node d_hash; /* lookup hash list */
struct dentry *d_parent; /* parent directory */ /* 父目录的目录项对象 */
struct qstr d_name; /* qstr结构体中的name元素指向真正的文件名字符串 */
struct inode *d_inode; /* Where the name belongs to - NULL is
* negative */
unsigned char d_iname[DNAME_INLINE_LEN]; /* small names */ /* 文件名长度<32时会使用该数组存放文件名 */
/* Ref lookup also touches following */
struct lockref d_lockref; /* per-dentry lock and refcount */
const struct dentry_operations *d_op;
struct super_block *d_sb; /* The root of the dentry tree */
unsigned long d_time; /* used by d_revalidate */
void *d_fsdata; /* fs-specific data */
struct list_head d_lru; /* LRU list */
struct list_head d_child; /* child of parent list */
struct list_head d_subdirs; /* our children */
/*
* d_alias and d_rcu can share memory
*/
union {
struct hlist_node d_alias; /* inode alias list */
struct rcu_head d_rcu;
} d_u;
};

dentry(directory entry):目录项
进程每次open一个文件,就会有一个file对象与之对应,同一个文件可以打开多次从而得到不同的file对象,file对象描述了被打开文件的属性,读写的偏移指针等信息。
不同的file对象可以对应同一个dentry结构。dentry结构体保存则目录项和对应的文件inode信息。
inode中不存储文件名字,只存储节点号,而dentry则保存文件名和预期对应的节点号,所以可以通过不同的dentry访问同一个inode。
再来看一下dentry结构体是怎么分配的
内存申请链:rename -> sys_renameat2 -> lookup_hash -> __lookup_hash -> lookup_dcache -> d_alloc -> __d_alloc

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
struct dentry *__d_alloc(struct super_block *sb, const struct qstr *name){
struct dentry *dentry;
char *dname;
dentry = kmem_cache_alloc(dentry_cache, GFP_KERNEL);/* 创建一个dentry结构体 */
if (!dentry)
return NULL;
dentry->d_iname[DNAME_INLINE_LEN-1] = 0; /* d_iname数组末尾填上0结尾 */
if (name->len > DNAME_INLINE_LEN-1) { /* 判断文件名的长度是否大于d_iname数组的大小 DNAME_INLINE_LEN == 32 */
size_t size = offsetof(struct external_name, name[1]); /* 进入文件名长度 > 32的处理流程 */
struct external_name *p = kmalloc(size + name->len, GFP_KERNEL);/* 分配内存 大小等于external_name结构体大小 + 字符串长度 */
if (!p) {
kmem_cache_free(dentry_cache, dentry);
return NULL;
}
atomic_set(&p->u.count, 1); /* external_name结构体的u.count元素为引用计数 并设置为1 */
dname = p->name; /* 让dname指针指向external_name结构体的name元素 */
if (IS_ENABLED(CONFIG_DCACHE_WORD_ACCESS))
kasan_unpoison_shadow(dname,
round_up(name->len + 1, sizeof(unsigned long)));
} else { /* 如果文件名长度小于DNAME_INLINE_LEN(32个字符)那么直接让dname指向dentry结构体的d_iname数组元素 */
dname = dentry->d_iname;
}
dentry->d_name.len = name->len; /* 初始化dentry结构体中代表文件名长度的元素 */
dentry->d_name.hash = name->hash;
memcpy(dname, name->name, name->len); /* 把实际的文件名拷贝到dname指针指向的内存 如果文件名长度<32就拷贝到dentry->d_iname,如果长度>32就拷贝到external_name->name */
dname[name->len] = 0; /* 添加0字符结尾 */
/* Make sure we always see the terminating NUL character */
smp_wmb();
dentry->d_name.name = dname; /* 让dentry结构体的d_name.name元素指向dname的位置(实际文件名)*/
dentry->d_lockref.count = 1;
dentry->d_flags = 0;
spin_lock_init(&dentry->d_lock);
seqcount_init(&dentry->d_seq);
dentry->d_inode = NULL;
dentry->d_parent = dentry;
dentry->d_sb = sb;
dentry->d_op = NULL;
dentry->d_fsdata = NULL;
INIT_HLIST_BL_NODE(&dentry->d_hash);
INIT_LIST_HEAD(&dentry->d_lru);
INIT_LIST_HEAD(&dentry->d_subdirs);
INIT_HLIST_NODE(&dentry->d_u.d_alias);
INIT_LIST_HEAD(&dentry->d_child);
d_set_d_op(dentry, dentry->d_sb->s_d_op);
this_cpu_inc(nr_dentry);
return dentry;
}
struct external_name { /* 如果文件名大于32,那么内核会创建一个该结构体用来存放新文件名 */
union {
atomic_t count; /* 引用计数 */
struct rcu_head head;
} u;
unsigned char name[]; /* 文件名 */
};

首先根据文件名的长度判断属于长文件还是短文件

  • 如果大于32个字符就属于长文件,申请一片空间,申请的空间大小等于external_name结构体大小加文件名长度,分配成功后就把文件名拷贝到申请的内存中,其实这就相当于创建了一个external_name结构体,然后把文件名拷贝到external_name结构体后面。
  • 如果小于等于32个字符那么就属于短文件,直接把文件名拷贝到dentry结构体变量的d_iname数组中。

不管是长文件还是短文件,最后都会把dentry结构体中的d_name.name指向最终存放文件名的位置,这就是文件名两种不同的存放方式。

再来看下rename时是如何把oldname改为newname的
重命名链: rename -> sys_renameat2 -> vfs_rename -> d_move -> __d_move -> copy_name

1
2
3
4
5
6
SYSCALL_DEFINE2(rename, const char __user *, oldname, const char __user *, newname){
return sys_renameat2(AT_FDCWD, oldname, AT_FDCWD, newname, 0);
}
SYSCALL_DEFINE5(renameat2, int, olddfd, const char __user *, oldname,
int, newdfd, const char __user *, newname, unsigned int, flags)

Use-After-Free:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
static void copy_name(struct dentry *dentry, struct dentry *target)
{
struct external_name *old_name = NULL;
if (unlikely(dname_external(dentry))) /* 判断old文件名是长文件名还是短文件名 */
old_name = external_name(dentry); /* 如果为长文件名,就备份一下old_name 后续做释放 */
if (unlikely(dname_external(target))) { /* 判断新文件名长度是否大于32,大于32进入*/
atomic_inc(&external_name(target)->u.count); /* 引用计数-1*/
dentry->d_name = target->d_name; /* 让旧文件名dentry的d_name元素直接指向新文件名dentry的d_name */
} else {
memcpy(dentry->d_iname, target->d_name.name, /* 否则表示新文件名长度小于32,直接把newname拷贝到栈中的dentry->d_iname处 */
target->d_name.len + 1);
dentry->d_name.name = dentry->d_iname;
dentry->d_name.hash_len = target->d_name.hash_len;
}
if (old_name && likely(atomic_dec_and_test(&old_name->u.count)))/* 如果老文件名长度>64 并且结构体引用计数为0,那么就把老文件名做释放操作 */
kfree_rcu(old_name, u.head); /* old_name -> UAF结构体 */
}
static inline int dname_external(const struct dentry *dentry)
{
return dentry->d_name.name != dentry->d_iname; /* 检查dentry的d_name.name 与 dentry->d_iname是否指向同一块内存 */
}
static inline struct external_name *external_name(struct dentry *dentry) /* 获取文件名的位置 */
{
return container_of(dentry->d_name.name, struct external_name, name[0]);
}

一样的,首先还是判断old文件属于长文件还是短文件。如果是短文件,直接把新文件名直接拷贝到旧dentry变量中的d_iname数组中,然后让旧dentry->d_name指向dentry->d_iname。如果是长文件名那么就把odl_name指针指向旧dentry->d_name指向的结构体方便后面释放时索引,然后把旧dentry->d_name指向新dentry->d_name,接着判断old_name结构体中的引用计数是否为0,如果为0那么就使用kfree_rcu释放掉old_name这个结构体。
这里还存在一个UAF漏洞,因为这里被释放的old_name->name在inotify_handle_event函数中存在释放后重引用的情况,后面的漏洞利用会用到这个UAF漏洞。

触发模型

漏洞利用

利用思路

随机地址写任意值 -> 可控地址写任意值

1.因为最终触发的是一个堆溢出,会对溢出对象后面的内存进行写数据操作,不过这里被写的地址是我们不可控的,因为我们不知道被溢出的那个event对象会被分配在内存的什么位置,如果想要在指定的地址上写任意值的话还需要做一些别的操作。

在对内存进行布局之前我们先来看看pipe subsystem中存在的一个TOCTTOU(time of check to time of user)检查时间到使用时间的问题,就是检查值的操作与使用值的操作之间存在间隔,可以在这间隔时间去修改检查后的值,这就会导致使用时的值实际上已经不是检查时的值了。这也属于竞争条件漏洞的一种,这和7533的竞争条件原理差不多。不过在pipe中的time of use是我们可控的。

通过readv和writev来控制检查/使用时间

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
static ssize_t do_readv_writev(int type, struct file *file,
const struct iovec __user * uvector,
unsigned long nr_segs, loff_t *pos){
size_t tot_len;
struct iovec iovstack[UIO_FASTIOV];
struct iovec *iov = iovstack;
ssize_t ret;
io_fn_t fn;
iov_fn_t fnv;
iter_fn_t iter_fn;
ret = rw_copy_check_uvector(type, uvector, nr_segs, /* rw_copy_check_uvector对传入的用户层的iovec做校验,并拷贝到内核态 */
ARRAY_SIZE(iovstack), iovstack, &iov);
if (ret <= 0)
goto out;
tot_len = ret;
ret = rw_verify_area(type, file, pos, tot_len);
if (ret < 0)
goto out;
fnv = NULL;
if (type == READ) { /* read */
fn = file->f_op->read;
fnv = file->f_op->aio_read;
iter_fn = file->f_op->read_iter;
} else { /* write */
fn = (io_fn_t)file->f_op->write;
fnv = file->f_op->aio_write;
iter_fn = file->f_op->write_iter;
file_start_write(file);
}
if (iter_fn)
ret = do_iter_readv_writev(file, type, iov, nr_segs, tot_len,
pos, iter_fn);
else if (fnv)
ret = do_sync_readv_writev(file, iov, nr_segs, tot_len,
pos, fnv);
else
ret = do_loop_readv_writev(file, iov, nr_segs, pos, fn);
if (type != READ)
file_end_write(file);
out:
if (iov != iovstack)
kfree(iov);
if ((ret + (type == READ)) > 0) {
if (type == READ)
fsnotify_access(file);
else
fsnotify_modify(file);
}
return ret;
}

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
ssize_t rw_copy_check_uvector(int type, const struct iovec __user * uvector,
unsigned long nr_segs, unsigned long fast_segs,
struct iovec *fast_pointer,
struct iovec **ret_pointer){
unsigned long seg;
ssize_t ret;
struct iovec *iov = fast_pointer;
if (nr_segs == 0) { /* nr_segs表示iovec的个数 等于0就退出*/
ret = 0;
goto out;
}
if (nr_segs > UIO_MAXIOV) { /* nr_segs不能大于UIO_MAXIOV(1024) */
ret = -EINVAL;
goto out;
}
if (nr_segs > fast_segs) { /* 如果nr_segs 大于8(ARRAY_SIZE(iovstack)) 那么就重新申请内存进行存储,小于8就直接放在开始申请的栈中的数组里面 */
iov = kmalloc(nr_segs*sizeof(struct iovec), GFP_KERNEL);
if (iov == NULL) {
ret = -ENOMEM;
goto out;
}
}
if (copy_from_user(iov, uvector, nr_segs*sizeof(*uvector))) { /* 把应用层的数据拷贝新内核层中 */
ret = -EFAULT;
goto out;
}
ret = 0;
for (seg = 0; seg < nr_segs; seg++) { /* 对iovec中的iov_base与iov_len做合法性检查,iov_base必须指向用户态且iov_len不存在溢出*/
void __user *buf = iov[seg].iov_base;
ssize_t len = (ssize_t)iov[seg].iov_len;
/* see if we we're about to use an invalid len or if
* it's about to overflow ssize_t */
if (len < 0) {
ret = -EINVAL;
goto out;
}
if (type >= 0
&& unlikely(!access_ok(vrfy_dir(type), buf, len))) {
ret = -EFAULT;
goto out;
}
if (len > MAX_RW_COUNT - ret) {
len = MAX_RW_COUNT - ret;
iov[seg].iov_len = len;
}
ret += len;
}
out:
*ret_pointer = iov;
return ret;
}

rw_copy_check_uvector函数主要是判断iovec的个数是否为零和是否大于1024,如果都不成立的话就使用copy_from_user函数把用户态的iovec数据拷贝到内核态的中,如果传入的iovec个数大于8个就使用kmalloc在内核态申请一片内存进行存放用户态的iovecarray数据,如果小于等于8个那么就直接使用内核栈存放iovec array数据,接着遍历判断每个iovec结构体中的iov_base是否属于用户态地址并且iov_len是否不会出现溢出。这里的检测就是前面说的TOCTTOU中的time of check操作。
iovec array在内核中存放的样子

检查完毕后通过判断type来区分是readv还是writev,如果是readv函数调用进来的,会走到pipe_read函数中,pipe_read函数会检查iovec中的iov_base指向的地址是否可写,pipe写端是否有数据,如果此时写端没有数据该函数就进入到等待模式,等待witev对pipe进行写入,当我们在用户态调用writev对pipe进行写入的时候,pipe_read就会把我们写入到pipe中的数据写入对应的iov_base指向的地址中。这里就属于TOCTTOU中的time of use操作。因为写入时间通过用户态调用writev来触发的,所以写入的值和写入的时机都是我们可控的。

如果我们能够在第一次检查iov_base指向地址是否为用户态地址后把iov_base指向的地址改为内核地址,当在第二次检查时因为只检查了是否可写而没检查是否为用户态地址,所以后续pipe_read会直接把我们writev传入的值写入到iov_base指向的内核地址中,这就达到了一个内核地址写操作。

引用一下少仲大佬的流程图

结合前面的堆溢出漏洞,可以把需要写入的内核地址溢出到iov_base上,这样写入地址,写入值都由我们控制了,不过写入地址有一个限制,因为是strcpy函数导致的溢出,所以写入地址中不能带有0字符不然就会存在截断问题。

内存布局

现在的问题是如何把iovec结构体数组布局到受害对象event结构体后面?
我们需要想办法对堆内存进行布局,促使溢出结构体后面是我们可控的数据(iovec结构体数组),这样在触发堆溢出的时候才能完成对iovec->iov_base值的非正常修改。
为了更容易让堆布局到我们理想的状态,我们可以先使用event对象把内核中slab空洞填满,然后内核会分配新的slab内存块,接着申请大量的iovec数组,然后间隔释放iovec数组同时再次创建event对象去填充间隔释放的iovec数组与触发堆溢出漏洞。

1
2
3
4
5
Event object / Payload / Victim object
Event object (监听事件对象)
Payload == 喷射数据(也就是strcpy操作的数据,Event对象中的name指向)
Victim object (pipe受害对象)

理想的内存布局

竞争条件 -> 释放后重引用 -> 堆溢出

构造溢出数据

这里需要注意网上公布的poc是基于短文件名的,但是android默认好像已经没有使用kmalloc-64了,在kmalloc时最少都会使用kmalloc-128,这样就导致文件名太短无法溢出到下一个slab,所以在android上运行poc每次都只是检测到了溢出但是并没有导致设备崩溃,下面是通过构造长文件名来触发溢出kmalloc-256。

1
2
3
inotify_event_info结构体大小为0x2c,也就是说最起码都有0x2c大小
(gdb) p &((struct inotify_event_info*)0)->name
$1 = (char (*)[]) 0x2c <-- 44

1
2
3
4
5
6
7
264 - 256 = 8 需要溢出8个字节
事件结构体: 44 + 200 = 244
264 - 244 = 20(喷射字符串必须比实际字符串多20)
喷射结构体:44 + 200 + 20 = 264
实际拷贝字符串长度:200 + 20 = 220 (拷贝的长度)
喷射构造:16 + 220 = 236(实际喷射字节 16的UAF结构体头部,220的实际字符串拷贝)
16 + 220 = 16 + 212 + 8 = 头部(16) + 填充值(212) + 写入地址(8)

构造好了一个任意地址读写,那么提权就只是套路了。

思考短文件名

如果内核支持kmalloc-64的话短文件名其实也可以利用,比如HUAWEI Mate9,可以通过spray ipv6_mc_socklist,触发堆溢出对rcu进行覆盖,通过rcu回调来控制内核执行流程,只是猜想并没有验证,以后有时间再看看。

1
2
3
4
5
6
7
8
9
struct ipv6_mc_socklist {
struct in6_addr addr;
int ifindex;
struct ipv6_mc_socklist __rcu *next; /* <<<<<<< Overflow <<<<<<<< */
rwlock_t sflock;
unsigned int sfmode; /* MCAST_{INCLUDE,EXCLUDE} */
struct ip6_sf_socklist *sflist;
struct rcu_head rcu;
};

权限提升

现在我们实现了任意地址写任意值,那么如何进行权限提升呢?

  1. 通过漏洞把ptmx->check_flags改为work_for_cpu_fn
  2. 通过fcntl函数触发work_for_cpu_fn,间接调用register_sysctl_table函数注册新sysctl
  3. 通过read触发新sysctl的处理函数,间接调用kernel_sock_ioctl函数修改limit
  4. 通过pipe对内核进行任意读写,patch关键结构体

减少硬编码的方式

在kernel\sysctl.c这个文件中存在对kptr_restrict的sysctl结构体进行初始化操作,我们可以通过内存遍历获取到kptr_restrict符号的地址,然后修改为0,这样就关闭了kptr_restrict保护机制
用来过滤一些地址,以此避免将内核地址泄漏给攻击者,通过配置kptr_restrict的值来控制是否开启:

  • 0:完全禁止
  • 1:使用”%pk”打印的内核指针被隐藏(以0替换),除非用户存在CAP_SYSLOG权限。
  • 2:所有内核使用”%pk”打印的都被隐藏
1
2
3
4
5
6
7
8
9
{
.procname = "kptr_restrict",
.data = &kptr_restrict,
.maxlen = sizeof(int),
.mode = 0644,
.proc_handler = proc_dointvec_minmax_sysadmin,
.extra1 = &zero,
.extra2 = &two,
},
1
2
3
4
5
6
7
8
9
.text:FFFFFFC001237700 aKptrRestrict DCB "kptr_restrict",0
.text:FFFFFFC00156D0C8 DCQ 0xFFFFFFC001237700
.text:FFFFFFC00156D0D0 DCQ 0xFFFFFFC001760B78
.text:FFFFFFC00156D0D8 DCQ 0x1A400000004
.text:FFFFFFC00156D0E0 DCQ 0
marlin:/data/local/tmp $ cat /proc/kallsyms |grep kptr_restrict
ffffffc001760b78 B kptr_restrict

exploit编写流程

6.0~7.0 -> root方案
利用步骤

  • 步骤0:准备资源并填充缓冲区
  • 步骤1:产生读取线程并使用iovec对象对堆进行布局
  • 步骤2:产生竞争线程
  • 第3步:赢得比赛,触发溢出实现任意地址写任意值
  • fcntl(ptmx_fd,F_SETFL,0x40002000)== 0x40002000
  • 第4步:覆盖uid,禁用SELinux并产生ROOT外壳

修补方案

总结

最开始分析的时候被网上公布的poc坑了,一直以为只是简单的堆溢出漏洞,后来才发现还存在一个UAF漏洞。
一般的条件竞争漏洞修补是都会进行加锁,所以分析下加锁的用意可能会对理解漏洞成因有所帮助。
又学习了一种新的利用方法通过布局iovec实现任意读写的方式。

参考

https://www.ibm.com/developerworks/cn/linux/l-inotifynew/
https://www.anquanke.com/post/id/129468
The-Art-of-Exploiting-Unconventional-Use-after-free-Bugs-in-Android-Kernel
asia-18-WANG-KSMA-Breaking-Android-kernel-isolation-and-Rooting-with-ARM-MMU-features

Author: Let_go

Link: http://github.com/2018/03/25/CVE-2017-7533/

Copyright: All articles in this blog are licensed under CC BY-NC-SA 3.0 unless stating additionally.

< PreviousPost
linux内核调试环境搭建
NextPost >
Linux Kernel x86-64 bypass SMEP-KASLR-kptr_restric
CATALOG
  1. 1. CVE-2017-7533
    1. 1.1. 前言
      1. 1.1.1. 漏洞信息
      2. 1.1.2. 漏洞验证
      3. 1.1.3. 背景知识
    2. 1.2. 漏洞成因:
      1. 1.2.1. 相关函数
      2. 1.2.2. 触发模型
    3. 1.3. 漏洞利用
      1. 1.3.1. 利用思路
        1. 1.3.1.1. 随机地址写任意值 -> 可控地址写任意值
        2. 1.3.1.2. 内存布局
        3. 1.3.1.3. 构造溢出数据
      2. 1.3.2. 思考短文件名
      3. 1.3.3. 权限提升
        1. 1.3.3.1. 减少硬编码的方式
        2. 1.3.3.2. exploit编写流程
      4. 1.3.4. 修补方案
    4. 1.4. 总结
    5. 1.5. 参考