简介
新发现的“老洞”
当您随意浏览Linux内核的源代码时,是否有过突然冒出“等一下,这里好像不太对劲”的想法呢?当我们在Linux内核的一个被遗忘的角落里发现了三个安全漏洞时,就出现过这种情况,并且,新发现的这些漏洞已经潜伏了15年左右了。与我们发现的大多数积满灰尘的东西不同,这些漏洞仍然具有很高的实用价值,其中一个漏洞可以用来在多个Linux环境中实现本地提权(LPE)。
关于SCSI
本文中涉及的子系统为SCSI(Small Computer System Interface,小型计算机系统接口)数据传输系统,它是为连接计算机与外围设备而制定的数据传输标准。SCSI是一个古老的标准,最初发布于1986年,并且是服务器设置的首选标准,而iSCSI基本上就是基于TCP的SCSI。实际上,SCSI至今仍在使用,特别是当我们要与某些存储设备打交道的时候,但是,它是如何成为默认Linux系统上的攻击面的呢?
通过广泛的包依赖关系,rdma-core软件包最终被安装到了所有包含GUI的RHEL或CentOS基础环境(工作站、带GUI的服务器和虚拟化主机),以及Fedora工作站中。此外,rdma-core软件包也可以安装到其他Linux发行版上,包括Ubuntu和Debian,因为它是许多其他软件包的依赖项之一(见图1)。在Ubuntu Server 18.04 LTS和更早的版本上,rdma-core是默认安装的软件包。
在默认安装的RHEL 8.3系统中,依赖rdma-core包的软件清单
RDMA(Remote Direct Memory Access,远程直接内存访问)是一种高吞吐量、低延迟的网络技术,其在数据传输和存储方面的应用非常广泛。当然,RDMA的实现方案有多个,但本文中讨论的是Infiniband,它位于ib_iser内核模块中。
读到这里,您可能会想:“等等,如果不使用SCSI或iSCSI,这一切是否还能自动启动和运行的吗?”,那很好,因为这个问题会把我们引向按需内核模块加载的概念,这也是一个存在已久的攻击载体。
自动模块加载
如果特定代码注意到需要某些功能并可以加载相关的模块(例如对不常见的协议系列的支持),则Linux内核可以按需加载内核模块,这样做的好处是,不仅可以提供更多的功能,还能提高兼容性。凡事有利即有弊,它同时也为本地攻击者打开了一扇窗户——因为它允许无特权用户加载某些内核模块,然后他们就可以利用这些模块了。实际上,十多年前人们就认识到了这一点,例如早在2009年,grsecurity就引入了GRKERNSEC_MODHARDEN,以防御这种攻击。2010年,Jon Oberheide以诙谐的方式命名的public exploit,让人们更加清楚地认识到个问题的严重性,不过,Dan Rosenberg在2010年提供的实现sysctl选项的补丁并没有被采纳。在过去的几年里,还陆续出现了其他的缓解措施,但不同的Linux发行版对这些措施的支持也是一言难尽。
所以,您是想说这个漏洞仍在作祟么?
拥有源代码的一个好处是,只要扫一眼,某些东西可能会映入您的眼帘,并激发您的兴趣,让您想要抓住它们。我们发现的第一个漏洞就是这种情况。当我们看到一个sprintf函数时,首先想到的就是没有规定长度的缓冲区副本,这意味着如果它是攻击者控制的数据并且没有进行长度验证(参见图2),很可能就会出现漏洞。因此,虽然有相当多的间接寻址和宏,而且您必须跳过许多文件来跟随执行线程,但主要的问题往往只是一个简单的缓冲区溢出,因为这里使用了sprintf。正如其他漏洞所显示的那样,开发人员往往缺乏安全意识的编程实践;从这里的问题也可以看出,这种现象是普遍存在的。
溢出,无处不在的溢出
第二个漏洞的情况也是类似的:使用内核地址作为句柄。很明显,之所以出现这种漏洞,是没有努力防止内核泄露指针所致;利用该漏洞,攻击者可以轻松绕过内核地址空间布局随机化(KASLR),因为它指向一个含有许多指针的结构体。而最后一个漏洞,则是未验证来自用户态的数据所致,这是一个典型的内核编程问题。
识别漏洞
Linux内核堆缓冲区溢出
·漏洞类型:堆缓冲区溢出。
·漏洞所在位置:位于drivers/scsi/libiscsi.c文件中的iscsi_host_get_param()函数中。
·受影响的版本:已知RHEL 8.1、8.2与8.3版本受此漏洞影响。
·漏洞的影响:LPE,信息泄漏,拒绝服务(DoS)。
·CVE编号:CVE-2021-27365
第一个漏洞是位于iSCSI子系统中的一个堆缓冲区溢出漏洞。对于攻击者来说,他们可以通过将iSCSI字符串属性设置为一个大于一页的值,然后试图读取它来触发该漏洞。实际上,这是因为有一个sprintf调用(详见kernel-4.18.0-240.el8源码中drivers/scsi/libiscsi.c文件中的第3397行)被用于处理用户提供的值,其缓冲区为单页,用于支持iscsi属性的seq文件。更具体地说,无特权的用户可以向iSCSI子系统发送netlink消息(详见drivers/scsi/scsi_transport_iscsi.c),而iSCSI子系统则会通过drivers/scsi/libiscsi.c文件中的helper函数设置与iSCSI连接相关的属性,如hostname、username等,这些属性的大小只受netlink消息的最大长度的限制(根据处理消息的具体代码,可以为2**32或2**16)。然后,sysfs和seqfs子系统将负责读取这些属性,但是,它只会分配一个长度为PAGE_SIZE的缓冲区(详见fs/seq_file.c中的single_open,在打开sysfs文件时调用)。这个漏洞最初是在2006年开发iSCSI子系统时引入的(参见drivers/scsi/libiscsi.c,提交号为a54a52caad和fd7255f51a)。然而,自首次提交以来,问题代码中使用的kstrdup/sprintf模式已经扩展为涵盖更多的字段。
Linux内核指针被泄露到用户空间
·漏洞类型:内核指针泄漏。
·漏洞所在位置:位于drivers/scsi/scsi_transport_iscsi.c文件中的show_transport_handle()函数中。
·受影响的版本:已知RHEL 8.1、8.2与8.3版本受此漏洞影响。
·漏洞的影响:信息泄漏。
·CVE编号:CVE-2021-27363。
除了堆溢出漏洞外,GRIMM还发现了一个内核指针泄漏漏洞,攻击者可以利用该漏洞来确定iscsi_transport结构体的地址。当iSCSI传输在iSCSI子系统中注册时,传输的“句柄”可以通过sysfs文件系统被非特权用户所获取,其地址为/sys/class/iscsi_transport/$TRANSPORT_NAME/handle。当读取该句柄时,show_transport_handle函数(该函数位于drivers/scsi/scsi_transport_iscsi.c中)会被调用,从而泄露了这个句柄。实际上,这个句柄就是内核模块全局变量中指向iscsi_transport结构体的指针。
Linux内核越界读取
·漏洞类型:越界读取。
·漏洞所在位置:位于drivers/scsi/scsi_transport_iscsi.c文件中的iscsi_if_recv_msg()函数中。
·受影响的版本:已知RHEL 8.1、8.2与8.3版本受此漏洞影响。
·漏洞的影响:信息泄漏, DoS。
·CVE编号:CVE-2021-27364。
最后一个漏洞是libiscsi模块(drivers/scsi/libiscsi.c)的越界内核读取所致。当调用send_pdu(详见kernel-4.18.0-240.el8源代码中drivers/scsi/scsi_transport_iscsi.c中的第3747-3750行)函数时,该漏洞就会被触发。与第一个漏洞类似,非特权用户可以创建netlink消息,并指定未经驱动程序验证的缓冲区大小,从而导致可控的越界读取。实际上,有多个用户控制的值都未被验证,其中包括前一个头部长度的计算结果,因此,攻击者可以利用可控的32位偏移从原始堆缓冲区最多读取8192字节。
技术分析
Exploit
实际上,GRIMM开发了一个PoC,以展示前两个漏洞的利用方法。
在当前状态下,该PoC支持Linux内核的4.18.0-147.8.1.el8_1.x86_64、4.18.0-193.14.3.el8_2.x86_64和4.18.0-240.el8.x86_64版本。当然,其他版本的Linux内核也存在这些漏洞,但要想利用它们,需要先收集对应的符号地址和结构体偏移量。为此,大家可以参考symbols.c、symbols.h和utilities/get_symbols.sh 脚本,以了解半自动化的符号收集方法。需要说明的是,虽然我们的测试是针对RHEL系统的8.1到8.3版本进行的,但其他使用相同内核映像的Linux发行版也存在同样的漏洞。
KASLR泄漏
要想修改内核结构体和改变函数指针,我们必须首先绕过KASLR机制。这是因为,Linux会随机化内核的基址,从而为漏洞利用制造障碍。然而,由于本地信息泄露的来源很多,因此,KASLR经常可以被本地用户绕过。当然,这个漏洞也不例外,因为它包括两个独立的信息泄露漏洞,使其能够绕过KASLR机制。
第一个信息泄漏来自非NULL终止的堆缓冲区。当通过iscsi_switch_str_param函数(如下图所示)设置iSCSI字符串属性时,会对用户提供的输入(位于new_val_buf中)调用kstrdup函数。然而,存放用户输入的缓冲区在分配时并没有被初始化,内核也没有强制要求用户的输入是以NULL为终止符的。因此,kstrdup函数将复制客户端输入后的所有非NULL字节,以便将来可以通过读取该属性来检索这些字节。攻击者可以通过指定一个656字节的字符串来利用这种信息泄露,从而导致netlink_sock_destruct的地址包含在kstrdup的字符串中。之后,攻击者可以读取这里的属性集,得到这个函数的地址。通过减去netlink_sock_destruct的基地址,攻击者就可以计算出内核地址随机化时所用的偏移量。实际上,netlink_sock_destruct函数指针的内存分配是在__netlink_create函数(该函数位于net/netlink/af_netlink.c内)中进行的,所以,这其实就是发送netlink消息的“副作用”。
int iscsi_switch_str_param(char **param, char *new_val_buf)
{
char *new_val;
if (*param) {
if (!strcmp(*param, new_val_buf))
return 0;
}
new_val = kstrdup(new_val_buf, GFP_NOIO);
if (!new_val)
return -ENOMEM;
kfree(*param);
*param = new_val;
return 0;
}
第二个信息泄露,是通过第二个漏洞获取目标模块的iscsi_transport结构体的地址来实现的。这个结构体定义了传输的相关操作,即如何处理各种iSCSI请求。由于这个结构体位于目标内核模块的全局内存中,因此,攻击者可以利用这个信息泄露来获取其内核模块的地址(从而获取其中的任何其他变量)。
获取内核写入原语
众所周知,Linux内核堆的SLUB分配器维护着一个相同大小的对象高速缓存(以2的幂为单位)。这个缓存中的每个空闲对象都包含一个指向缓存中下一个空闲项的指针(位于该空闲项的偏移量0处)。因此,攻击者可以利用堆溢出来修改位于相邻内存slab开头位置的空闲项指针。通过重定向这个指针,攻击者就可以控制内核在哪里进行分配内存空间。通过仔细选择内存分配位置并控制该缓存中的分配空间,攻击者就获得了一个受限的内核写原语。正如下一节所解释的那样,攻击者可以利用这个受控写原语来修改iscsi_transport结构体。
为了获得所需的堆布局,我们的exploit代码将利用POSIX消息队列消息进行堆梳理。这里的exploit代码将在4096 kmalloc缓存上执行相关的操作,这是一个流量相对较低的缓存。因此,在利用该空闲列表时,无论将其重定向到哪些位置,都不太可能引起问题。另外,之所以选择消息队列消息进行堆梳理,是因为它们不仅便于在用户态中进行分配/释放,同时,它们的内容和长度也可以在用户态中进行控制。
该exploit将通过以下步骤利用堆溢出漏洞获取内核写原语:
·向自己发送大量的消息队列消息,但不接收它们。这将导致内核在内核空间中创建相关的消息队列结构体,进而“淹没”4096 kmalloc缓存。
·接收一些消息队列消息。这将导致内核释放一些内核消息队列结构体。如果成功的话,所导致的4096 kmalloc缓存将包含一系列的空闲项,如下图(1)所示。
·该exploit代码触发溢出漏洞。溢出的空间将占用缓存中的一个空闲项,并溢出相邻空闲项的下一个空闲列表指针,如下图(2)和(3)所示。该exploit利用溢出漏洞来重定向下一个空闲列表指针,使其指向ib_iser模块的iscsi_transport结构体。
·发生溢出后,该exploit会发送更多的消息队列消息,以便重新分配修改后的空闲项。内核会遍历空闲项,并在我们修改后的空闲项指针处返回一个分配空间,如下图(4)所示。
堆修整与溢出缓存布局
虽然这种方法确实能够在受控位置对内核内存进行写操作,但仍有一些事项需要注意:所选位置必须以一个NULL指针开始;否则,分配内存时会将这个值的指针链接到空闲列表中。随后的内存分配将会使用这个指针,并导致内存损坏,从而引起内核崩溃。此外,由于内核消息队列结构在用户控制的消息体之前包括一个0x30字节的头部,因此,该exploit无法控制其写入的前0x30字节的内容。
目标选择与利用方法
现在,我们不仅绕过了KASLR防御机制,并能够将任意内容写入内核内存,那么,下一个任务就是使用这些原语来获得更强的内核读/写原语并提升权限。为此,该exploit需要针对ib_iser模块的iscsi_transport结构体实现任意写入。如下所示,此结构体中含有许多iSCSI netlink消息处理函数的指针,而iSCSI子系统则使用处于用户的部分控制之下的参数来调用这些指针对应的函数。通过修改这些函数指针,我们的exploit就可以调用任意函数,并且这些函数都带有多个由用户控制的参数。
struct iscsi_transport {
...
int (*alloc_pdu) (struct iscsi_task *task, uint8_t opcode);
int (*xmit_pdu) (struct iscsi_task *task);
...
int (*set_path) (struct Scsi_Host *shost, struct iscsi_path *params);
int (*set_iface_param) (struct Scsi_Host *shost, void *data,
uint32_t len);
int (*send_ping) (struct Scsi_Host *shost, uint32_t iface_num,
uint32_t iface_type, uint32_t payload_size,
uint32_t pid, struct sockaddr *dst_addr);
int (*set_chap) (struct Scsi_Host *shost, void *data, int len);
int (*new_flashnode) (struct Scsi_Host *shost, const char *buf,
int len);
int (*del_flashnode) (struct iscsi_bus_flash_session *fnode_sess);
...
};
获取稳定的内核读/写原语
该exploit会修改iscsi_transport结构体中的函数指针,具体如下图所示。修改之后,函数指针send_ping和set_chap将指向seq_buf_to_user和seq_buf_putmem函数。之后,该exploit将利用这些函数来获取稳定的内核读写原语。这些函数将以seq_buf结构体、缓冲区和长度作为其参数,然后对内核内存进行相应的读写操作。实际上,传入的seq_buf结构体,定义了这些函数将从内存中哪些位置进行读取或写入操作。但是,从上面的结构体的定义中可以看出,被覆盖的函数指针将传递一个Scsi_Host结构体来作为第一个参数,而不是将由exploit提供的seq_buf结构体作为其第一个参数。解决这个问题的一种简单方法,就是修改与我们的连接相关联的Scsi_Host结构体,使其类似于seq_buf结构体。由于Scsi_Host结构体的开头部分并没有提供被利用的函数所需的任何值,因此,我们的exploit可以使用修改后的set_iface_param函数指针来调用memcpy并覆盖Scsi_Host结构体。之后,就可以调用seq_buf_to_user和seq_buf_putmem函数来读写内核内存了。
然后,exploit代码将利用内核读写原语从4096 kmalloc缓存中删除重叠的空闲列表区域,并修复由于重叠分配而导致的一些内存损坏问题。
修改exploit代码前后,ib_iser模块的iscsi_transport的变化情况
实现提权
有了调用任意函数和读写内核内存的能力,攻击者就可以通过各种途径来获得root权限。就这里的exploit来说,它是通过对param_array_free的链式调用来调用内核函数run_cmd,从而获得特权代码执行的权限。这个内核函数将接收一个要运行的命令,并在kernel_t SELinux上下文中以root身份执行该命令。该exploit将利用 iscsi_transport结构体中指向/tmp/a.sh字符串的指针来调用这个函数,并运行提权后所需的payload.。由于特权升级或内核读/写原语都不是直接从内核中解引用或执行用户态下的内存中的内容,因此,该exploit能够绕过Supervisor模式执行保护(Supervisor Mode Execution Prevention,SMEP)、Supervisor模式访问保护(Supervisor Mode Access Prevention,SMAP)和内核页表隔离(Kernel Page Table Isolation,KPTI)机制。
虽然这个exploit在某些Linux发行版上运行,但其使用的技术无法适用于所有发行版。例如,有些Linux发行版会为堆中的空闲列表指针提供相应的保护机制,以防止被它们被攻击者所利用(比如CONFIG_SLAB_FREELIST_HARDENED机制);对于这些系统来说,这个exploit就无法正常发挥作用了。不过,只要采用相应的技术来重新设计exploit,攻击者仍然能够绕过这些防御机制。
进行测试
要想对本文提供的PoC代码进行测试,请确保安装了合适的RHEL系统和内核版本,为此,大家可以用cat /etc/redhat-release命令来检查系统的发布版本,用 uname -r命令来检查内核版本。同时,如果您使用的是不受支持的版本,这里的exploit也会检测到并发出警告。之后,请安装所需的软件包并编译exploit,然后复制相应的脚本并运行该exploit即可,具体命令如下所示:
$ sudo yum install -y make gcc
$ make
$ cp a.sh /tmp/
$ chmod +x /tmp/a.sh
$ ./exploit
最后,这个exploit将会以root身份来运行位于硬编码位置(/tmp/a.sh)处的一个脚本。实际上,我们的存储库中提供了一个演示脚本,它可以在/tmp目录中创建一个隶属于root的文件。当然,该exploit并非100%可靠,因此,有时候需要运行许多次才可成功。其中,可能遇到的错误情况包括:错误警告,如“Failed to detect kernel slide”和“Failed to overwrite iscsi_transport struct (read 0x0)”;以及内核发生崩溃。最后,下面给出成功运行后的输出内容,具体如下所示:
$ ./exploit
Got iscsi iser transport handle 0xffffffffc0e1a040
KERNEL_BASE=0xffffffff98600000
Setting up target heap buffer
Triggering overflow
Allocating controlled objects
Cleaning up
Running escalation payload
Success
由于该PoC的写入方式的缘故,运行同一PoC或send_pdu_oob PoC的其他尝试将会失败(除非系统重新启动)。
$ ls -l /tmp/proof
-rwsrwxrwx. 1 root root 14 Jan 14 10:09 /tmp/proof
此外,针对第三个漏洞的PoC也被开发出来了。实际上,send_pdu_oob PoC提供了一个静态的大偏移量,它会导致对(很可能是)未映射的内核内存进行狂读,从而导致内核崩溃。需要注意的是,编译后的PoC应该以root权限运行。由于数据读取的大小和偏移量都是受控的,所以这个PoC可以通过只泄漏少量的超出原始堆缓冲区的信息来造成信息泄漏。这个漏洞的可利用性的主要限制是,泄露的数据不会直接返回给用户,而是存储在另一个缓冲区中,作为以后操作的一部分发送。攻击者很可能需要设计一个伪造的iSCSI目标来接收泄露的信息,但这超出了本PoC的范围。
漏洞的影响
由于堆溢出的稳定性较差,第一个漏洞可以用于不可靠的本地DoS攻击。然而,当与信息泄露相结合时,这个漏洞可以进一步被用于本地权限提升:这样的话,攻击者就能从非特权用户提升为root用户。不过,这里无需借助于单独的信息泄露,因为这个漏洞也可以用来泄露内核内存。第二个漏洞(内核指针泄露)影响较小,只能作为潜在的信息泄露。同样,第三个漏洞(越界读取)的功能也仅限于作为潜在的信息泄露,甚至是不可靠的本地DoS攻击。
受影响的系统
为了将这些漏洞暴露到用户态,必须加载scsi_transport_iscsi内核模块。实际上,当执行创建NETLINK_ISCSI套接字的套接字调用时,该模块会自动加载。此外,必须向iSCSI子系统注册至少一个iSCSI传输。在某些配置中,当非特权用户创建NETLINK_RDMA套接字时,ib_iser传输模块将被自动加载。此外,必须向iSCSI子系统注册至少一个iSCSI传输。
用于识别自己的系统是否受影响的流程图
在CentOS 8、RHEL 8和Fedora系统上,如果安装了rdma-core包,非特权用户就可以自动加载所需模块。因为这个包是许多流行包的依赖项,因此,它存在于许多系统中。以下CentOS 8和RHEL 8基础环境的初始安装中就包含了这个软件包:
·带有图形用户界面(GUI)的服务器;
·工作站;
·虚拟主机。
以下CentOS 8和RHEL 8基础环境在初始安装时并没有包含这个软件包,但可以在之后通过yum软件进行安装:
·服务器;
·最小化安装;
·自定义操作系统。
该软件包包含在Fedora 31 Workstation的基础安装中,但是Fedora 31 Server并没有包含该软件包。
在Debian和Ubuntu系统中,只有在RDMA硬件可用的情况下,rdma-core软件包才会自动加载两个所需的内核模块。因此,该漏洞的范围因此受到了一定的限制。
小结
上面讨论的漏洞来自于Linux内核中一个非常老的驱动程序。由于一种相当新的技术(RDMA)和基于兼容性而非风险的默认行为,这个驱动程序的影响变得更加明显。Linux内核之所以加载模块,要么是因为检测到了新的硬件,要么是因为内核函数检测到模块丢失。并且,后一种隐式自动加载的情况更容易被滥用,并且很容易被攻击者触发,从而增加了内核的攻击面。
在这些特定漏洞的上下文中,在没有连接iSCSI设备的机器上存在与iSCSI子系统相关的加载内核模块是一个潜在的入侵指标。一个更大的指标是主机的系统日志中存在以下日志消息:
localhost kernel: fill_read_buffer: dev_attr_show+0x0/0x40 returned bad count
虽然这条消息并不能保证本报告中描述的漏洞已经被利用,但它确实表明已经发生了某种缓冲区溢出。
这种风险向量已经被人们所熟知多年,并且已经出现了多种针对模块自动加载的防御措施,包括grsecurity的MODHARDEN和许多系统实现的modules_autoload_mode、modules_disabled sysctl变量、将特定协议系列列入黑名单的发行版,以及使用特定机器的模块黑名单。这些都是独立的选项,可以用于深入防御的策略,并且服务器管理员和开发人员可以采取被动和主动的措施。
然而,由于兼容性和安全性之间的紧张关系,这仍然是Linux内核的一个真正的问题领域。对于管理员和操作人员来说,需要了解风险、防御选择,以及如何应用这些选择,才能有效保护自己的系统。
时间线
·02/17/202:通知的Linux安全团队
·02/17/2021:申请并收到的CVE编号
·03/07/2021:主流Linux内核发布补丁程序
·03/12/2021:公开披露(NotQuite0DayFriday)
参考及来源:https://blog.grimm-co.com/2021/03/new-old-bugs-in-linux-kernel.html