×

CVE-2016-6187 利用堆大小差一错误爆破Linux内核

96
看雪学院
2017.04.20 18:27* 字数 5341

Background(背景)——off-by-one(大小差一)错误介绍

大小差一错误是一类常见的程序设计错误。这方面有一个经典的例子OpenSSH.去Google搜索关键词“OpenSSH off-by-one”可以了解相关状况。具体来说,

1. if(id < 0 || id >

channels_alloc)...

2. if(id < 0 || id >=

channels_alloc)...

第二句应该是正确的写法。举个更通俗的例子:

int a[5],i;

for(i = 1;i < = 5;i++)

a[i]=0;

上述代码定义了长度为5的数组a,循环的目的是给数组元素初始化,赋值为0.但是,循环下标从1开始到5,出现了a[5]=0,这样的不存在的数组元素.这就是典型的“差一错误”(off-by-one).其实,貌似说栅栏柱错误(fencepost

error)大家更熟悉。我问过身边的朋友,很多人知道这个问题。

如果你要建造一个100米长的栅栏,其栅栏柱间隔为10米,那么你需要多少根栅栏柱呢?11根或9根都是正确答案,这取决于是否要在栅栏的两端树立栅栏柱,但是10根却是错误的。

我想起来了我高中是数学老师告诉我们的一个很容易犯错的数学题目。从周一到周五一共有几天?也许你立即反应5-1=4,但是,下意识你也会说五天,实际上应该是5-1+1=5.转换到数学,数字1到数字5一共有几个数字?这里有一个公式

从M到N,一共有 M-N+1 项.

这个问题写出来后很简单,只不过在写代码的时候,往往比较容易忽略。尤其在涉及到数组操作两端界限的时候,如果不是从0开始计数,就要稍微考虑一下咯。

Introduction(前言)

我认为我决定写关于本漏洞的理由是因为当我把它发在推特上时,我收到了一些私信说这个内核路径不是爆破点(找不到bug在哪)或者不可利用。另一个的理由就是我想在实际中尝试userfaultfd() 的系统调用。我需要一个真实的UAF来试试。

首先,我不知道这个漏洞是否影响到在主要发行版本的任何upstream的内核。我只检查了ubuntu支线和Yakkety支线没有影响。但是,补丁包常常会出问题。Bug在https://git.kernel.org/cgit/linux/kernel/git/torvalds/linux.git/commit/?id=bb646cdb12e75d82258c2f2e7746d5952d3e321a中有介绍,在https://git.kernel.org/cgit/linux/kernel/git/torvalds/linux.git/commit/?id=30a46a4647fd1df9cf52e43bf467f0d9265096ca中提交和修复。

因为我不能找到一个拥有爆破点的ubuntu内核版本,我在ubuntu16.04(x86_64)上编译了4.5.1内核。虽然值得一提的是爆破点仅仅影响(像ubuntu)默认使用AppArmor的发行版本。

Vulnerability(爆破点)

写入/proc/self/attr/current 触发proc_pid_attr_write() 函数。接下来介绍的代码是爆破点前面的:

static

ssize_t proc_pid_attr_write(struct file * file, const char __user * buf,

size_t

count, loff_t *ppos)

{

struct inode * inode =

file_inode(file);

char *page;

ssize_t length;

struct task_struct *task =

get_proc_task(inode);

length = -ESRCH;

if (!task)

goto out_no_task;

if (count > PAGE_SIZE)                            [1]

count = PAGE_SIZE;

/* No partial writes. */

length = -EINVAL;

if (*ppos != 0)

goto out;

length = -ENOMEM;

page =

(char*)__get_free_page(GFP_TEMPORARY);

[2]

if (!page)

goto out;

length = -EFAULT;

if (copy_from_user(page, buf,

count))             [3]

goto out_free;

/* Guard against adverse ptrace

interaction */

length =

mutex_lock_interruptible(&task-;>signal->cred_guard_mutex);

if (length < 0)

goto out_free;

length = security_setprocattr(task,

(char*)file->f_path.dentry->d_name.name,

(void*)page, count);

...

buf参数代表用户提供的缓冲区(和长度count)被写入到/proc/self/attr/current

。在[1]处,check被执行以确保缓冲区会适应一个单页(默认4096个字节)。在[2]和[3],一个单页被分配了并且用户空间缓冲区被复制到新分配的页。这个页传递给代表LSM钩子(AppArmour,SELinux,Smack)的security_setprocattr

。假如是ubuntu,这个钩子触发了

apparmor_setprocattr()

函数,代码显示如下:

lang=EN-US style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>static int

apparmor_setprocattr(struct task_struct *task, char

*name,

style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>

void *value, size_t

size)

Consolas;mso-bidi-font-family:Consolas;color:#333333'>{

lang=EN-US style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>        struct

common_audit_data sa;

lang=EN-US style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>        struct

apparmor_audit_data aad = {0,};

lang=EN-US style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>        char *command, *args =

value;

style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>        size_t

arg_size;

style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>        int

error;

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

lang=EN-US style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>        if (size ==

0)

style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>                return

-EINVAL;

style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>        /* args points to a

PAGE_SIZE buffer, AppArmor requires

that

style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>         * the buffer must be

null terminated or have size <= PAGE_SIZE

-1

style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>         * so that AppArmor can

null terminate them

lang=EN-US style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>    

style='mso-spacerun:yes'>    */

lang=EN-US style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>        if (args[size - 1] !=

'\0') {

[4]

lang=EN-US style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>                if (size ==

PAGE_SIZE)

style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>                        return

-EINVAL;

style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>                args[size] =

'\0';

style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>

}

Consolas;mso-bidi-font-family:Consolas;color:#333333'>...

在[4]处,如果用户提供的缓冲区最后的字节不为空并且缓冲区大小不等于页的大小时,缓冲区末端为空。在另一面,如果用户提供的缓冲区超过(或者等于)单页的大小(在[2]处分配的),路径被终止并且-EINVAL被返回。

接下来显示的代码改为在爆破点之后的(在[3]处的)

proc_pid_attr_write()

lang=EN-US style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>static ssize_t

proc_pid_attr_write(struct file * file, const char __user *

buf,

.......代码省略

不像

__get_free_page()

,

memdup_user()

分配了一块被count参数指定内存并且复制用户提供的数据到其中。因此,对象被分配的大小不再是严格的4096个字节(即使它仍然是最大缓冲区大小)。让我们假设用户提供的数据是128个字节大小并且缓冲区的最后字节不为空。当apparmor_setprocattr()

被触发,args[128] 将被设置为0,因为检查的仍然为PAGE_SIZE,并不是真实缓冲区大小:

lang=EN-US style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>   if (args[size - 1] != '\0')

{

style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>                if (size ==

PAGE_SIZE)

style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>          

style='mso-spacerun:yes'>             return

-EINVAL;

style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>                args[size] =

'\0';

style='font-size:10.0pt;font-family:

Consolas;mso-bidi-font-family:Consolas;color:#333333'>

style='mso-spacerun:yes'>

}

因为对象在堆中被动态分配,下一个对象的第一位会被重写为null。标准技术在不会起作用的爆破点对象之后放置了一个目标对象(包含了一个函数指针作为第一个成员)。一个主意是在一些对象上(因为像爆破点对象同样大小的)重写一个引用计数器并且然后触发一个UAF(谢谢Nicolas

Tripar的建议)。如果你下一周将会在Ruxcon,尽管在计数器溢出的主题,在内核中利用计数器溢出检验我的演讲吧。对象引用计数器(

atomic_t

type

= signed

int所代表)通常是结构的第一个成员。因为计数器的值通常对于大多数对象都是在255以下的,重写像一个对象最低有效的字节会清除计数器并且导致一个标准的UAF。然而为了利用这个漏洞,我决定用一个不同的方法:重写SLUB

freelist 指针。

Exploitation(爆破)

关于这个漏洞的一件好事是我们能控制目标对象的大小。为了触发这个漏洞,对象大小应该被设置为缓存大小中的一个(如9,16,32,64,96,等等)。我们不会探究SLUB分配器如何工作(linux默认内核内存分配器)。我们所需要知道的是同样大小的不同对象为多用途和特殊用途分配积累成相同的缓存。slab是在缓存上包含同样大小对象主要的页。自由对象在偏移地址0(默认)处有一个“next

free”的指针指向slab的下一个自由对象。

这个主意是在同样的一个slabshang

放置我们脆弱的对象(A)邻近一个自由对象(B),然后清除对象B的“next

free”指针的least-significant字节。当两个新对象在同样的slab上被分配,最后的对象将会被分配在靠着“next

free”指针的对象A和/或者对象B。

上文情境(重叠A和B对象)是仅仅可能结局之一。目标对象“变化”的值是一个字节(0-255)并且最终目标对象的位置会依靠在原始的“next free”指针的值和对象大小。

假设目标对象会与对象A和B重叠,我们想要控制这些对象的内容。

在一个高等级,漏洞利用程序如下:

1.

在同样的slab上在邻近对象B处放置脆弱对象A

2.

重写 B中的“next free”指针的least-significant字节

3.

在同样的slab分配两个新对象:第一个对象将会被放在B处,并且第二个对象将会替代我的目标对象C

4.

如果我们控制对象A和B的内容,我们可以强制对象C被分配在用户空间

5.

假设对象C有一个能从其他地方触发函数指针,在用户空间或者可能的一个ROP链(绕过SMEP)中设置这个指针为我们的权利提升的payload。

为了执行步骤1-3,连续的对象分配能够取得使用一个标准的堆耗尽技术。

接下来,我们需要选择正确的对象的大小。对象比128字节更大(例如,申请缓存256,512,1024个字节,等等)的话,就不会在这里起作用。让我们假设起始的slab地址是0x1000(标记slab的起始地址是与页大小一致的并且连续对象分配是相连的)。接下来的C程序列出了被给定对象大小的一个单页的分配:

//

page_align.c

#include

int main(int

argc, char **argv) {

int i;

void *page_begin = 0x1000;

for (i = 0; i < 0x1000; i +=

atoi(argv[1]))

printf("%p\n",

page_begin + i);

}

因为这些对象是256个字节(或>128并<=256字节),我们接下来匹配模式:

vnik@ubuntu:~$

./align 256

0x1000

0x1100

0x1200

0x1300

0x1400

0x1500

0x1600

0x1700

0x1800

...

在slab所有配置的最低有效位为0并且重写邻近的自由对象的“next

free”指针为null会没有效果:

因为128字节缓存,这里有两种可能选项:

vnik@ubuntu:~$

./align 128

0x1000

0x1080

0x1100

0x1180

0x1200

0x1280

0x1300

0x1380

0x1400

...

第一个选项相似于之上的256字节例子(“next

free”指针的最低有效位字节已经为0)。第二个选项很有趣,因为重写“next

free”指针的最低有效位字节会指向自由对象本身。分配一些8个字节大小的对象(A)到一些(确定的)用户空间内存地址,其次是目标对象(B)的分配会在用户空间用户控制的内存地址放置对象B。这可能是在可靠性和易于利用两者间中的最好的选项。

1.

这有一个50/50成功机会。如果它是第一选项,没有崩溃,我们可以再试一次

2.

找到一个有一些用户空间地址的对象(首8个字节)将被放置在kmalloc-128 缓存并不难。

尽管这是最好的方法,我决定将所有96字节对象和用msgsnd()

堆耗尽/喷涂粘合起来。主要(仅有)的理由是因为已经发现了一个我想要使用的96字节的目标对象了。感谢Thomas

Pollet帮助找到合适的堆对象并且在运行时用gdb/python自动化处理这个乏味的过程!

然而,显然使用96字节对象有下降趋势;一个主要的原因是利用的可靠性。一个耗尽slab(如填充满slab部分)的主意就是48字节对象的标准

msgget()

技术(其他48字节被用来作为消息头)。这也将用作一个堆喷涂因为我们控制了msg对象的一半(48字节)。我们也控制脆弱对象的内容(数据从用户空间被写到/proc/self/attr/current)。如果目标对象分配以便首8个字节被我们的数据所覆盖,然后漏洞利用将会成功。在另一方面,如果这8个字节用msg头(我们没有控制的)来覆盖,这会导致一个页面错误但是内核可能会被它自身恢复。基于我的分析,这里有两个例子,在这“next

free”指针会用先前分配的随机msg头覆盖。

这里是有一些技巧来提高漏洞利用的可靠性。

Target object

因为目标对象,我使用了struct subprocess_info 结构,正是96字节大小。为了触发这个对象的分配,下面的套接字操作可以使用一个随机的协议家族:

socket(22,

AF_INET, 0);

套接字族22不存在但是模块自动加载会触发到内核中下面的函数:

int

call_usermodehelper(char *path, char **argv, char **envp, int wait)

{

struct subprocess_info *info;

gfp_t gfp_mask = (wait == UMH_NO_WAIT)

? GFP_ATOMIC : GFP_KERNEL;

info = call_usermodehelper_setup(path,

argv, envp, gfp_mask,    [6]

NULL,

NULL, NULL);

if (info == NULL)

return -ENOMEM;

return call_usermodehelper_exec(info,

wait);                    [7]

}

call_usermodehelper_setup

[6] 然后会分配对象和初始化它的字段:

struct

subprocess_info *call_usermodehelper_setup(char *path, char **argv,

char **envp, gfp_t gfp_mask,

int (*init)(struct

subprocess_info *info, struct cred *new),

void (*cleanup)(struct

subprocess_info *info),

void *data)

{

struct subprocess_info *sub_info;

sub_info = kzalloc(sizeof(struct

subprocess_info), gfp_mask);

if (!sub_info)

goto out;

INIT_WORK(⊂_info->work,

call_usermodehelper_exec_work);

sub_info->path = path;

sub_info->argv = argv;

sub_info->envp = envp;

sub_info->cleanup = cleanup;

sub_info->init = init;

sub_info->data = data;

out:

return sub_info;

}

一旦对象被初始化,这将绕过

call_usermodehelper_exec

in [7]:

int

call_usermodehelper_exec(struct subprocess_info *sub_info, int wait)

{

DECLARE_COMPLETION_ONSTACK(done);

int retval = 0;

if (!sub_info->path) {                                          [8]

call_usermodehelper_freeinfo(sub_info);

return -EINVAL;

}

...

}

如果路径变量为null[8],然后

cleanup

函数被执行并且对象被释放:

static void

call_usermodehelper_freeinfo(struct subprocess_info *info)

{

if (info->cleanup)

(*info->cleanup)(info);

kfree(info);

}

如果我们覆盖了

cleanup

函数指针(记住对象现在在用户空间被分配),然后我们随着CPL=0就有了任意代码执行。仅有的一个问题是subprocess_info 对象分配和释放在同样的路径。在

info->cleanup)(info)

被调用并且设置函数指针到我们的权限提升payload之前修改对象函数指针的一个方法是以某种方法停止执行。我本可以找到其他同样大小的因为分配和函数触发的两种“分开”路径,但是我需要一个理由去尝试

userfaultfd()

和这个页面分裂的想法。

Userfaultfd系统调用能够被用来处理用户空间中的页面错误。我们可以在用户空间分配一个页面并且设置一个处理器(当做一个分线程);当这个页面因为读或写被访问,执行会被转移到用户空间处理器去处理页面错误。这里没有新鲜的并且这是被Jann

Hornh所提到的。

SLUB分配器在被分配之前访问对象(首8个字节去更新缓存freelist指针)。因此,这个主意就是分离开

subprocess_info

对象到两个连续的页面以便所有对象字段除了说这最后一个(如

void *data

)将会被放在同样的页:

然后我们会设置用户空间页面错误处理器去处理在第二页的PF。当call_usermodehelper_setup

去设定sub_info->data ,代码被转移到用户空间PF处理器(在那里我们可以改变先前设定的sub_info->cleanup

函数指针)。如果目标被kmalloc 所分配,这个方法会起作用。不像kmalloc,

kzalloc

在分配之后使用memset(..., 0, size(...)) 归零对象。不想glibc,内核的杂类函数实现是十分简洁直接的(例如设置连续化的单个字节):

void *memset(void

*s, int c, size_t count)

{

char *xs = s;

while (count--)

*xs++ = c;

return s;

}

EXPORT_SYMBOL(memset);

这意味着设置在第二页的用户空间PF处理器将不再起作用,因为一个PF将会被杂项函数触发。然而,这仍然有可能被束缚用户空间页面错误所绕过:

1.

分配两个连续页面,分割对象到这两个页面(如之前的)并且为第二个页面设置页面处理器。

2.

当用户空间PF被杂项函数触发,为第一页设置另一个用户空间PF处理器。

3.

当对象变量在

call_usermodehelper_setup

中初始化了,那么接下来的用户空间PF将会触发。这时候设置为第二个页面设置另一个PF。

4.

最终,最后一个的用户空间PF处理器可以修改

cleanup

函数指针(通过设置它指向我们的权限提升payload或者ROP链)并且设置

path

成员为0(因为这些成员在第一页被分配并且已经初始化了)。

因为“页面错误”的页面能够通过再次去除内存页映射/映射这些页实现,设置用户空间PF处理器。并且然后传递它们到userfaultfd()。4.5.1版本的POC能够在这里被找到。尽管对于内核版本没有什么特殊的(它应该可以工作在所有含有漏洞的内核)。这里没有权限提升payload但是这POC会在用户空间地址0xdeadbeef

执行指令。

Conclusion

这有可能是更容易利用此漏洞的方法,但是我仅仅想我只想让我发现的目标对象随着userfaultfd “工作”。清理机制缺失是因为我们是分配IPC msg对象,这不是非常重要并且有一些简单的方法稳固系统。

本文由 看雪 iOS 安全小组成员 rodster 编译,来源 Vitaly Nikolenko@cyseclabs

更多优秀文章,“关注看雪学院公众号”查看!


​看雪论坛:http://bbs.pediy.com/

微信公众号 ID:ikanxue

微博:看雪安全

投稿、合作:www.kanxue.com

看雪
Web note ad 1