CVE-2020-8835:Linux内核eBPF程序验证不正确导致的特权提升漏洞
2020-04-25 11:10:00 Author: www.4hou.com(查看原文) 阅读量:274 收藏

一、概述

在最近的Pwn2Own 2020竞赛中,RedRocket CTF的Manfred Paul(@_manfp)利用Linux内核的一个不正确输入验证漏洞实现了从普通用户提权到root用户。Manfred在比赛中依靠这个漏洞赢得了特权提升类型的30000美元奖金。在比赛后,他整理了他的研究报告,并在Write-Up中详细分析了利用的漏洞。

本文主要说明使用Linux eBPF功能实现本地特权提升的漏洞利用技术细节。这一漏洞源于名为verifier(验证程序)的关键安全功能中存在的细微问题,要分析该漏洞,我们首先需要对eBPF内部的工作原理进行分析。在我们的分析过程中,还附带了内核源代码(特别是kernel/bpf/verifier.c)作为参考。该漏洞已经分配编号CVE-2020-8835,并在2020年3月30日进行了修复。以下是该漏洞的演示视频:

https://youtu.be/8rNsxbCgKzY

二、eBPF工作原理分析

2.1 eBPF

从Linux内核3.15版本开始,Linux内核开始支持通用跟踪功能,被称为“扩展的Berkeley数据包过滤器”,简称为eBPF。这一功能使得用户可以直接在内核空间中运行以类似程序集的指令集编写的eBPF程序,并可以用于跟踪某些内核功能。这个功能还可以用于过滤网络数据包。

所有BPF功能都可以通过BPF syscall访问,该调用支持各种命令。在BPF的手册中,指出:

在当前实现中,所有bpf()命令都要求调用者具有CAP_SYS_ADMIN功能。

这是不正确的。从Linux 4.4开始,任何用户都可以通过将eBPF程序附加到其拥有的套接字,来实现对其的加载和运行。

2.2 eBPF程序

eBPF使用的指令集与标准x86程序集的(有限)子集非常相似。有10个寄存器(加上一个栈指针),我们可以在它们之上执行所有的基本复制和算术运算,包括按位运算和移位。例如:

BPF_MOV64_IMM(BPF_REG_3, 1)

将寄存器3的值设置为1,随后:

BPF_ALU64_REG(BPF_ARSH, BPF_REG_7, BPF_REG_5)

将寄存器7算数右移寄存器5的内容。请注意,后缀_REG表示作为第二操作数的寄存器,而_IMM表示取立即数。

还有分支跳转指令:

BPF_JMP_IMM(BPF_JNE, BPF_REG_3, 0, 3)

如果寄存器3不等于0,则跳过接下来的3个指令。

对于每个mov、alu和jmp指令,还存在一个对应的32位版本,仅在寄存器的较低32位上运行。针对其结果,如有需要,将会进行零扩展。

最后,还支持内存加载和存储:

BPF_LDX_MEM(BPF_DW, BPF_REG_3, BPF_REG_9, 24)

将[reg9 + 24]中的64位值加载到reg3中,然后:

BPF_STX_MEM(BPF_DW, BPF_REG_8, BPF_REG_6, 0)

将寄存器6的内容存储在[reg8 + 0]中。

要执行eBPF程序,首先必须通过BPF_PROG_LOAD指令将其加载。这样将会返回与程序相对应的文件描述符。然后,可以将这个文件描述符附加到套接字。然后,针对经过此套接字的每个数据包执行该程序。

2.3 eBPF映射

为了存储数据,或与用户空间程序进行通信,eBPF程序可以使用称为映射的功能。所有映射都代表某种键值对映射。其中,包含不同的映射类型,例如队列和堆栈。但是在我们的漏洞利用中,仅仅会使用其中的arraymap。顾名思义,这种类型的映射只是一个连续的条目数组。它由三个参数定义:

1、key_size是用于访问元素的每个索引的大小,以字节为单位。在漏洞利用中,总会使用key_size=sizeof(int)=4。

2、value_size是每个数组元素的大小。在合理的范围内,这个大小可以是任意的。

3、max_entries是数组的长度。我们始终只会将value_size设置为所需的最大值,并将max_entries设置为1。

在这里,我们是将max_entries设置为1,而没有在将value_size设置为较小值的同时设置更多条目,其原因在于,这样一来我们就可以在eBPF程序中获取指向单个值的指针,因此arraymap实际上仅是单个内存块。随后将会非常方便。

与程序类似,映射是由bpf syscall的BPF_MAP_CREATE命令创建的,并使用文件描述符标识。

该漏洞利用将会使用三个映射:

1、inmap是一个小型的映射,其中包含漏洞利用程序需要运行的所有参数(例如,要执行的越界偏移量)。需要关注的是,尽管会有多个参数,但它们都会存储在一个较大的数组条目中。

2、outmap是一个非常小的映射,其中包含漏洞利用程序的任何输出,这是针对越界读取而言,也就是读取值。

3、explmap是一个更大一些的映射,它将用于漏洞利用自身。

2.4 JIT编译

出于性能原因,所有eBPF程序在加载时都会被JIT编译为本地代码(除非CONFIG_BPF_JIT_ALWAYS_ON被禁用)。

JIT的编译过程非常简单,因为找到与大多数eBPF指令相对应的x86指令非常容易。编译后的程序会在内核空间中运行,没有其他沙箱。

2.5 验证程序

显然,运行任意JIT编译的eBPF指令可以轻而易举地允许任意内存访问,原因在于加载和存储指令被转换为间接movs。因此,内核在每个程序上会运行一个验证程序,以确保无法执行越界内存访问,并确保不会泄露内核指针。验证程序大致会执行以下操作(其中一些仅适用于非特权进程加载的程序):

1、除了指针与标量值的加法或减法之外,都无法执行指针算术或比较。其中,标量值是任何不从指针值派生的值。

2、无法执行超出已知安全存储区域(即映射)范围的指针算术。

3、无法从映射返回指针值,也不能将其存储在从用户空间可存储的映射中。

4、自身无法到达指令,这意味着程序可能不包含任何循环。

为此,验证程序必须针对每个程序指令进行跟踪,确认哪个寄存器中包含指针,哪个寄存器中包含标量值。另外,验证程序必须进行范围的计算,以确保指针永远不会离开其适当的存储区域。它还必须对标量值执行范围跟踪,因为如果不知道上下限,就无法判断将包含标量值的寄存器添加到包含指针的寄存器是否会导致指针的越界。

为了跟踪每个寄存器的可能值范围,验证程序会跟踪三个单独的边界:

1、umin和umax跟踪当解释为无符号整数时可以包含的最小值和最大值。

2、smin和smax跟踪当解释为有符号整数时可以包含的最小值和最大值。

3、var_off包含有关某些已知为0或1的位的信息。var_off的类型是称为tnum的结构,而tnum则是“Tracked Number”或“Tristate Number”的缩写。一个tnum包含两个字段,其中一个名为value的字段将所有在寄存器中已知的位设置为1。另一个字段称为mask,将所有寄存器中未知的位设置为1。例如,如果value是二进制010,而mask是二进制100,那么寄存器中可能包含二进制010或二进制110。

要了解为什么1和2都是必须的,我们可以想想一个有符号寄存器,边界为-1和0。如果解释为无整数符号,这些值的范围可以从0到2^64-1,这个表示形式与有符号的-1相同。另一方面,从2^63-1到2^63的无符号范围包括可能的最小和最大的有符号值。

所有这些界限都经常用于相互更新。例如,如果umax小于2^63,则smin设置为0(如果之前为负),因此所有这样的数字都为正。同样,如果var_off只是除最后三位以外其他位都为0,则可以安全地将umax设置为7。

验证程序检查每个可能的执行路径,这意味着会在每个分支分别检查两个结果。对于条件跳转的两个分支,可以了解一些其他信息。例如,考虑:

BPF_JMP_IMM(BPF_JGE, BPF_REG_5, 8, 3)

如果在无符号比较中,寄存器5大于或等于8,则进入分支。在分析错误分支时,验证程序将寄存器5的umax设置为7,因为任何更高的无符号值都会走向其他的分支。

2.6 ALU Sanitation

为了应对由于验证程序中的错误导致的大量安全漏洞,引入了一种称为“ALU Sanitation”的功能。其思路是,通过对程序正在处理的实际值进行运行时检查,来弥补验证程序的静态范围检查。回想一下,唯一允许使用指针的计算是对标量的加或减。对于涉及指针和标量寄存器(相对于立即数)的每个算术运算,将alu_limit确定为可以安全地添加到指针或从指针减去而不会超出允许范围的最大绝对值。

不允许对标量操作数进行符号未知的指针算数。在本文的其他章节,我们假设每个标量都是正数,负数的情况也与之类似。

在每个具有alu_limit的算术指令前,添加以下指令序列。请注意,off_reg是包含标量值的寄存器,而BPF_REG_AX是辅助寄存器。

BPF_MOV32_IMM(BPF_REG_AX, aux->alu_limit - 1)
BPF_ALU64_REG(BPF_SUB, BPF_REG_AX, off_reg)
BPF_ALU64_REG(BPF_OR, BPF_REG_AX, off_reg)
BPF_ALU64_IMM(BPF_NEG, BPF_REG_AX, 0)
BPF_ALU64_IMM(BPF_ARSH, BPF_REG_AX, 63)
BPF_ALU64_REG(BPF_AND, off_reg, BPF_REG_AX)

如果标量超过alu_limit,则第一个减法将为负数,因此将设置BPF_REG_AX的最左位。同样,如果假定为正的标量实际为负,那么BPF_OR指令将设置BPF_REG_AX的最左位。运算后的结果将会导致BPF_REG_AX全部为0,以便BPF_AND将off_reg全部强制变为0,从而替换有问题的标量。另一方面,如果标量是在0 <= off_reg <= alu_limit的适当范围内,则算术移位会将BPF_REG_AX填充为全1,因此BPF_AND会使标量寄存器保持不变。

为了确保将寄存器设置为0不会再引入新的越界条件,验证程序将跟踪一个附加的推测(Speculative)分支,其中该操作数会被视为0。

三、验证程序漏洞利用

3.1 突破范围跟踪

如前所述,跳转条件在32位版本中也存在。但是,由于仅针对完整64位的寄存器跟踪所有范围,因此没有直接的方法可以用于对32位分支更新寄存器范围。由于这会导致不正确地拒绝程序,因此,对于每个32位分支跳转,都会添加一个附加的函数调用,以尝试最大化可以收集的边界信息。其思路是,使用umin和umax来缩小var_off的后32位。代码如下,其位于kernel/bpf/verifier.c中:

图片 1.png

要理解上述代码,我们必须首先解释这里使用的几个函数的作用。

1、tnum_range函数将生成与指定范围的无符号整数中可能值相对应的tnum。

2、tnum_cast将根据现有tnum的最低位创建一个新的tnum。在这里,它用于返回reg->var_off的较低32位。

3、tnum_lshift/tnum_rshift用于对tnum执行移位。在这里,它们共同用于清除tnum的较低32位。

4、tnum_intersect接受两个tnum参数,它们都属于一个值,并返回一个tnum,该tnum中结合了这些参数传递的所有内容。

5、tnum_or返回一个tnum,表示我们对两个操作数的按位或,其中的两个参数就是两个操作数。

现在,我们考虑有一个umin_value = 1且umax_value = 2ˆ32+1的寄存器,以及不受限制的var_off。然后,reg->umin_value & mask和reg->umax_value & mask均为1。因此,生成的tnum_range将指示已知最低位为1,并且已知的所有其他位都为0。tnum_intersect将保留此信息,并将所有较低32位标记为输出中已知的。最终,tnum_or将重新引入较高32位的不确定性,但是由于hi32指示较低的32位是已知的零,因此将保留tnum_intersect中的较低32位。这意味着,该函数完成后,var_off的后半部分将被标记为已知的二进制00...01。

但是,这并不能形成我们的结论。umin和umax都以二进制00...01结尾,并不意味着两者之间的每个值也都相同。例如,寄存器的真实值就有可能是2。正如我们所看到的,验证程序最初的错误假设可以用于破坏进一步的假设。

3.2 触发漏洞

要实际触发我们所描述的漏洞,需要有一个满足以下条件的寄存器:

1、在执行期间,寄存器的实际值为2。

2、寄存器的umin_val设置为1,而umax_val设置为2^32+1。

3、在这个寄存器上,执行带有32位比较的条件跳转。

我们无法直接通过mov将值2加载到寄存器中,因为验证程序随后将知道umin_val=umax_val=2。但是,有一个简单的解决方法,如果我们从映射加载寄存器(我们可以使用我们的输入映射inmap),验证程序将不会得到关于其值的信息,因为我们可以在运行时更改映射值。

要设置umin_val和umax_val,我们可以使用验证程序的跳转分支逻辑:

BPF_JMP_IMM(BPF_JGE, BPF_REG_2, 1, 1)
BPF_RAW_INSN(BPF_JMP | BPF_EXIT, 0, 0, 0, 0)

条件跳转将会产生两个分支。在采用的分支中,验证程序知道BPF_REG_2 >= 1,而另一分支将会以退出指令结束而被丢弃。因此,对于所有其他指令,寄存器2的umin_val将为1。

类似地,可以使用另一个条件跳转,将umax_val设置为2^32 + 1。但是,在这里我们需要与寄存器进行比较,因为仅支持32位立即数。之后,我们根据需要设置了umin_val和umax_val。

现在,可以使用任何有条件的32位跳转来触发该漏洞:

BPF_JMP32_IMM(BPF_JNE, BPF_REG_2, 5, 1),
BPF_RAW_INSN(BPF_JMP | BPF_EXIT, 0, 0, 0, 0),

验证程序现在认为寄存器2的最后32位是二进制00...01,而实际上它们是二进制00...10。在另外两条指令之后:

BPF_ALU64_IMM(BPF_AND, BPF_REG_2, 2),
BPF_ALU64_IMM(BPF_RSH, BPF_REG_2, 1),

验证程序现在假设寄存器2必须为0,因为如果寄存器2的倒数第二位为0,则AND指令必然会导致结果为0,但实际上它是(2&2)>>1 = 1。这是一个非常有用的原语,因为我们现在可以将寄存器2与任何数字相乘,以创建验证程序将任意值视为0。

3.3 绕过ALU Sanitation

尽管在运行时重新检查所有指针算术是一个好思路,但仍然严重缺少具体的实现。主要问题在于,这些值实际上并不仅限于验证程序的静态部分假设其所处的位置,而是一个更大的限制,该限制看似安全,实则不然。要了解其区别,我们需要考虑以下示例:

1、寄存器1包含一个指向安全内存4000字节开头的指针p。假设*p的类型只有一个字节,这意味着p再加上3999也是完全可以的,但如果加4000会超出界限。

2、寄存器2包含标量a,静态验证程序会认为该标量是1000。

3、现在,将寄存器2添加到寄存器1。静态验证程序认为没有问题,因为p + 100仍然在存储区域的范围内。因为这是涉及指针的算术运算,所以ALU Sanitizer将设置alu_limit。但是,即使验证程序认为a不大于1000,该限制也不会设置为1000,而是会设置为3999,因为这是被确认为安全的最大合法值。

现在我们考虑,如果在静态分析中利用漏洞,诱使验证程序相信a = 1000,但实际上我们设置a = 2500,这样该值仍然能通过两次运行时检查,而不会被强制设置为0,因为2500小于3999和2999。但是,我们总共为指针p增加了5000,导致地址远超出了安全存储范围。尽管每个alu_limit都足够低,但将其组合在一起却不够。第二个极限计算仅捕获静态分析对第二次相加所产生的错误,同时会假设第一次相加是完全正确的。

3.4 实现越界读写

现在,要实现越界内存访问,只是一个如何组合每个步骤的问题。首先,我们使用JMP32漏洞将寄存器设置为1,而验证程序现在认为该寄存器必须为0。假设我们有一个指向大小为8000字节的映射的指针p(漏洞利用的值稍有不同)。出于漏洞利用的目的,我们最方便的是在映射开始之前获取OOB访问权限,而不是结束之后,因此第一步是在p中添加一个较大的值(例如6000),以便我们可以从“镜像”ALU Sanitation绕过上述过程。

接下来,我们会生成一个实际值为6000的寄存器,而验证程序认为其值为0。我们通过将原始的伪寄存器乘以6000的方式来实现。现在,我们可以从p中减去它。之所以这样可行,是因为将alu_limit设置为6000,这是我们可以减去的最大合法数量。但是,验证程序的静态部分仍然会假设p只想我们映射中的位置6000。因此,我们现在就可以从p减去不超过6000的任何值,而实际上p指向映射的开始。

现在,我们在映射内容开始之前有了一个长达6000字节的指针。由于验证程序仍然认为该指针位于映射边界内,因此我们可以读取和写入此地址。为了方便起见,我们甚至可以让操作和偏移量(对于写操作来说,是写入的值)取决于输入映射inmap的参数。对于越界读取,读取的值将会写入到outmap中。由于可以通过bpf syscall从用户空间读取并修改映射,因此我们现在只需要加载该程序一次,然后针对任意OOB读写小工具,使用不同的参数来重复触发。

四、特权提升

4.1 泄露KASLR

对我们而言,比较方便的一点是,映射的内容并不存储在堆块中,而是放置在称为结构bpf_map(在include/linux/bpf.h定义)的末尾。该结构包含一些有用的成员:

2.png

指向映射表类型的函数vtable的指针ops非常有用。在这种情况下,由于映射是一个arraymap,因此它指向array_map_ops。由于这是rodata(甚至是导出符号)中固定位置的固定结构,因此读取该数据可以用于直接绕过KASLR。

4.2 任意读取

在该结构中,另一个有用的地方是指针结构btf *btf,它指向包含调试信息的其他结构。该指针通常是未使用的(并设置为NULL),这使其成为了良好的覆盖对象,并且不会造成过多的混乱。

事实证明,可以通过bpf_map_get_info_by_fd函数来方便地访问这个指针,如果提供了映射的文件描述符,则该函数又由bpf syscall的BPF_OBJ_GET_INFO_BY_FD命令调用。该函数可以有效地执行以下操作(完成函数可以在kernel/bpf/syscall.c中找到):

3.png

这意味着,如果我们使用OOB编写的原语将map->btf设置为someaddr - offsetof(struct btf, id),则BPF_OBJ_GET_INFO_BY_FD在info.btf_id中将返回*someaddr。由于结构btf的id字段是u32,因此可以使用该原语,一次性从任意地址读取4个字节。

4.3 查找进程结构

为了找到进程的cred和文件结构,我们首先搜索init_pid_ns,这是默认的进程命名空间。如果在容器中运行,则需要首先找到相应的命名空间。这可能不是最快的方法,但是可以成功。

有两种方法可以找到init_pid_ns:

1、如果我们知道array_map_ops和init_pid_ns之间的偏移量,我们可以简单地将其添加到已经知道的array_map_ops地址中。这个偏移量不取决于KASLR,但在内核更新中不稳定。

2、相反,我们可以在ksymtab和kstrtab段中找到init_pid_ns。为此,我们首先通过从array_map_ops开始的迭代搜索,找到kstrtab的开始位置。然后,再次通过简单的迭代搜索,在kstrtab中找到空终止的字符串init_pid_ns。kstrtab上的最终一次迭代,将找到引用kstrtab条目的符号条目,并且还包含init_pid_ns的相对地址。

一旦找到init_pid_ns,就可以对其基数树(Radix Tree)进行迭代,以找到指向任务结构的指针,该指针与漏洞利用过程的pid相对应。实际上,这就是内核本身通过其pid查找任务时所使用的机制。

使用任务结构,既可以找到包含用户权限的cred结构,也可以找到打开文件描述符的数组。后者可以由已加载的explmap文件描述符进行索引,以获得映射的相应结构文件。反过来,该结构的private_data指向结构bpf_map。这意味着,我们现在也可以获得explmap内容的地址。

4.4 任意写入

请注意,通过使用越界写入覆盖操作,我们可以将RIP控制为我们拥有指针的任何函数。但是,RDI中的第一个参数会始终设置为bpf_map-struct,从而帮助我们排除了很多现有的函数。因此,似乎可以将array_map_ops的某些元素覆盖为不同的映射操作,这至少可以正确处理第一个参数。

为此,我们首先使用任意读取原语,以及带有BPF_MAP_UPDATE_ELEM的bpf syscall将array_map_ops表的完整副本加载到explmap的数据中。对这个副本的唯一修改是,通常在arraymaps中未使用的map_push_elem成员被map_get_next_key操作覆盖。

map_get_next_key的arraymap实现如下(来自kernel/bpf/arraymap.c):

4.png

如果我们控制了next_key和key,那么*next = index + 1; 可以在index < array->map.max_entries的情况下用作任意写入原语。如果可以将map->max_entries设置为0xffffffff,那么该检查将始终会通过。除了index=0xffffffff,这实际上是我们要使用的,但这并没有关系,因为*next仍然设置为0=index+1。

因为我们已经获得了指向explmap数据的指针,所以我们现在可以覆盖explmap->array_map_ops指向修改后的操作表。

请注意,我们已经使用map_push_elem覆盖的map_get_next_key签名为:

5.png

但是,仅当映射的类型为BPF_MAP_TYPE_STACK或BPF_MAP_TYPE_QUEUE时,BPF_MAP_UPDATE_ELEM命令才会调用map_push_elem。但是,如果是这种情况,我们可以直接控制flag参数和value参数,该参数将被解释为next_key。

要触发任意写入小工具,我们必须按照以下顺序执行OOB写入:

1、将ops设置为explmap缓冲区中的虚假vtable。

2、将explmap->spin_lock_off设置为0,以通过其他一些检查。

3、将explmap->max_entries设置为0xffffffff,以通过array_map_get_next_key中的检查。

4、将explmap->map_type设置为BPF_MAP_TYPE_STACK,以能够达到map_push_elem。

现在,可以通过将适当的参数传递给BPF_MAP_UPDATE_ELEM,来触发任意32位写入。完成所有写入后,应该将字段重置为其原始值,以防止清理时发生崩溃。

4.5 获取root特权

接下来,就可以轻松地获取root特权。我们只需要在进程的cred结构中将uid设置为0。现在,我们就可以执行任意特权命令,或者使用交互式Shell。

五、结语

再次感谢Manfred对本地特权提升做出的详细介绍。这是Manfred第一次参加Pwn2Own,我们希望将来能看到他的更多成果。在此之前,欢迎关注他们的团队,以获取最新的漏洞利用技术和安全补丁。

本文翻译自:https://www.zerodayinitiative.com/blog/2020/4/8/cve-2020-8835-linux-kernel-privilege-escalation-via-improper-ebpf-program-verification如若转载,请注明原文地址:


文章来源: https://www.4hou.com/posts/Xnxm
如有侵权请联系:admin#unsafe.sh