简介
本案例研究 CVE-2014-2851 漏洞,其影响 Linux 内核直到3.14.1版本。首先,我非常感谢Thomas的帮助,他给出了最初的分析和PoC。
这个漏洞不是很实用(它需要一段时来溢出一个32位的整数),但是从开发的角度来看,这是一个有趣的漏洞。在我们测试的系统上,为了得到 # 花去了超过50分钟时间。由于 RCU 回调的一些不测预测使得其利用非常困难。
我们的测试系统为32位 Ubuntu 14.04 LTS (3.13.0-24-generic kernel) SMP。下面我们首先描述这个漏洞及其利用,之后我们会讨论其利用中存在的困难点。
漏洞
下面是存在漏洞的地方,用于创建 ICMP 套接字。注意虽然标准用户(在大多数的发行版中)不允许创建 ICMP 套接字,但无需 root 权限也可以访问到存在漏洞的部分:
1 2 3 4 5 6 7 8 9 10 11 12 | int ping_init_sock(struct sock *sk) { struct net *net = sock_net(sk); kgid_t group = current_egid(); struct group_info *group_info = get_current_groups(); [1] int i, j, count = group_info->ngroups; kgid_t low, high; inet_get_ping_group_range_net(net, &low, &high); |
当在用户空间创建一个 ICMP socket后,会到达下列路径(特别是[1]):
1 | socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP); |
[1]中的函数
1 | get_current_groups() |
为
1 | include/linux/cred.h |
中定义的宏:
1 2 3 4 5 6 7 8 | #define get_current_groups() / ({ / struct group_info *__groups; / const struct cred *__cred; / __cred = current_cred(); / __groups = get_group_info(__cred->group_info); / [3] __groups; / }) |
[3]中的
1 | get_group_info() |
函数有一个用于统计使用量的原子类型增量
1 | group_info |
,其定义为一个整数类型:
1 2 3 4 5 6 7 8 9 10 11 | type = struct group_info { atomic_t usage; int ngroups; int nblocks; kgid_t small_block[32]; kgid_t *blocks[]; } typedef struct { |
每当一个新的 ICMP 套接字被创建,这个计数器就会增加 1 在[1]中。然而,对于普通用户,[2] 中的检测会失败(返回0)。因此,这个使用量在退出时永远不会减少。我们可以通过反复创建新的 ICMP 套接字来溢出这个有符号整数(0xffffffff + 1 = 0)。
结构体
1 | group_info |
是与其 fork 出的子进程所共享的。当其中计数器值变为0后,内核有很多种方法可以释放它。其中一种方法就是由 Thomas 发现的使用
1 | faccessat() |
系统函数:
1 2 3 4 5 6 7 8 9 10 11 12 13 | SYSCALL_DEFINE3(faccessat, int, dfd, const char __user *, filename, int, mode) { const struct cred *old_cred; struct cred *override_cred; int res; … override_cred = prepare_creds(); [4] |
在 [4]中,一个新的结构体被分配,其内的计数器(不要跟
1 | group_info->usage |
混淆)被置为1并且
1 | group_info->usage |
也递增了1。这时[5]中的
1 | put_cred |
会将
1 | cred->usage |
值递减并调用
1 | __put_cred() |
:
1 2 3 4 5 6 7 8 | static inline void put_cred(const struct cred *_cred) { struct cred *cred = (struct cred *) _cred; validate_creds(cred); |
最重要的部分就是利用 RCU [6]释放
1 | cred |
结构体:
1 2 3 4 5 6 7 8 9 | void __put_cred(struct cred *cred) { … BUG_ON(cred == current->cred); BUG_ON(cred == current->real_cred); call_rcu(&cred->rcu, put_cred_rcu); [6] |
下面显示的是
1 | put_cred_rcu |
回调函数,当计数器变为0时其调用[7]中的
1 | put_group_info() |
来释放
1 | group_info |
结构体:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | static void put_cred_rcu(struct rcu_head *rcu) { struct cred *cred = container_of(rcu, struct cred, rcu); … |
1 | put_group_info() |
函数为宏定义,用于递减
1 | group_info |
的计数器并且在计数值为0时释放这个结构体:
1 2 3 4 5 | #define put_group_info(group_info) / do { / if (atomic_dec_and_test(&(group_info)->usage)) / groups_free(group_info); / } while (0) |
利用
很显然我们可以通过溢出计数器为0让
1 | group_info |
结构体被释放,然后在用户空间调用
1 | faccessat() |
:
1 2 3 4 5 6 7 8 9 10 | // increment the counter close to 0xffffffff (-10 = 0xfffffff6) for (i = 0; i < -10; i++) { socket(AF_INET, SOCK_DGRAM, IPPROTO_ICMP); } // increment the counter by 1 and try to free it |
上面的代码可以溢出计数器并释放
1 | group_info |
结构体。释放这个结构体是通过调用 RCU 系统回调完成的,这里会存在一些不可预测问题,随后我们会再“挑战”章节中进行讨论。
一旦
1 | group_info |
结构被释放,SLUB 分配器会将其地址保存到 freelist 中。网上有很多资料讲述 SLUB 分配器,这里我们就不在详述。但我们知道当一个对象被释放后,会将其放入 freelist 并且前四个字节(32-bit)会被一个指向下一块空闲对象的指针所覆盖。因此
1 | group_info |
前四个字节会被一个有效的内核存储地址所覆盖。而原先的前四个字节为计数器,所以我们可以通过继续建立 ICMP 套接字来增加这个值。
在
1 | group_info |
会被释放后可能会出现两种情况:
1 2 | 1.它是 freelist 的最后一个对象 2.它是 freelist 中的一个空闲对象 |
前一种情况下,
1 | group_info |
中指向下一个空闲对象的指针会被置为 NULL。我们主要关注后一种情况,指针会指向 slab 中下一个空闲对象(这也是最常见的情况)。
在我们测试的系统中,
1 | group_info |
结构体总计 140 比特长,分配在
1 | kmalloc-192 |
缓存中。当收到一个请求分配 128-192 比特的对象(通过kmalloc,kmem_cache_alloc 等等)时,SLUB 分配器会查看 freelist 并分配我们计数器被覆盖后指针指向的地址。
我们可以通过反复增加计数值使其溢出并使用 mmap来使得指针指向用户区域。举个例子,给定一个内核地址 0xf3XXXXXX 增加 0xfffffff 后就到了用户区域 0x3XXXXXX 。
总的来说,利用的过程如下:
1.通过创建 ICMP 套接字增加
1 | group_info |
计数器接近 0xffffffff
2.反复尝试每次让计数器增加1,然后通过调用
1 | faccessat() |
释放
1 | group_info |
3.一旦成功释放,
1 | group_info |
中的计数器会被覆盖为指向 slab 中下一块空闲区域的指针
4.通过创建更多的 ICMP 套接字来继续增加
1 | group_info |
的计数器,直到其指向用户区域的内存空间
5.记录用户空间的这块区域(例如 0×3000000-0×4000000)并使用 memset 将其置为0
6.请求在内核空间分配一个大小为 128-192 比特的结构 X (理想情况下包含函数指针)
7.SLUB 分配器会分配结构 X 到我们的用户空间地址 0×3000000-0×4000000
8.如果结构 X 包含任何函数指针,我们就可以指向我们的 payload 了(我们案例中是ROP链)
我们使用的结构 X 利用为文件结构,具有跟
1 | group_info |
相同的大小并且包含一些函数指针(例如:文件操作
1 | *f_op |
)。分配这个文件结构可以通过以下示例代码:
1 2 | for (i = 0; i < N; i++) fd = open("/etc/passwd", O_RDONLY); |
如果要求文件必须至少分配 1024,那么可以 fork 其他进程继续分配更多的文件结构。
一旦这个文件结构分配到我们的用户空间 0×3000000-0×4000000,我们可以简单的搜索这块区域中非0比特。下面是我们文件结构的开始部分:
1 2 3 4 5 6 7 8 9 10 | unsigned *p; struct file *f = NULL; // find the file struct |
从这一点上面看,这一系列的利用操作很普通。
挑战
正如上一章里面所说的,RCU 回调可能会造成一些无法预知的情况。例如,
1 | faccessat() |
跟随的
1 | ping_init_sock() |
再循环中可能不会执行。很显然我们希望按如下执行:
1.增加
1 | group_info |
中的计数器
2.如果计数器值为0则通过
1 | faccessat() |
释放
然而,RCU 回调往往是积累在一起然后批量处理的。回调函数只有在系统中的至少一个 CPU 标记为“空闲”状态才会执行(例如:上下文切换,idle循环等等)。因此,经常会出现大量的
1 | ping_init_sock |
同时被执行(溢出了计数器并使其值>0),跟随着一系列的
1 | put_cred_rcu() |
RCU 回调。出现这种情况时释放
1 | group_info |
的步骤就会被跳过。不过,我们已经找到了一种方法可以控制计数增加并检查。
另一个问题出现在与漏洞利用相关的恢复阶段。如果另一个对象同时从相同的 slab 发来请求怎么办?对于这种情况,我们可以将我们对象中指向下一个 freelist 的指针置为 NULL。这样分配器就会将 freelist指针置为NULL,反过来说,这会强迫分配器创建一个新的 slab 并且“忘记”我们当前的 slab。
现在,如果一些属于我们当前使用 slab 的对象被释放了怎么办?这就提出了一个真正的挑战,我们可以在利用后通过 LKM 修复系统来解决。
总结
在实用性方面,这个漏洞可能不太理想,因为它需要一段时间来溢出一个32位的计数器。在我们的测试系统上,整个利用时间花费了超过50分钟。然而,一旦当
1 | group_info |
被释放其利用还是比较可靠的(即使在多处理平台下)。