1. 前言

容器逃逸技术概览一文中我们提到,由于容器与宿主机共享内核,内核漏洞成为容器逃逸的四大原因之一。由于潜在后果的严重性(提升至系统最高权限)和影响的广泛性(一个漏洞会影响相当多的计算机设备),系统开发者陆续在内核实现了一系列的漏洞缓解技术,以减小内核被攻破的可能性。

下图展示了容器与内核的关系:

面对经过安全加固的容器环境,攻击者往往会举步维艰。但是,一旦有(新曝光的)内核漏洞加持,攻击就可能会从不可行变为可行,从可行变为简单。事实上,无论攻防场景怎样变化,我们对内核漏洞的利用往往都是从用户空间非法进入内核空间开始,到内核空间赋予当前或其他进程高权限后回到用户空间结束,容器逃逸与此一脉相承。

在这种局面下,漏洞缓解技术能够有效提高漏洞的利用难度,使得即使在漏洞存在且被发现的具体场景下,攻击者也很难或根本不能利用特定漏洞发起攻击。攻击门槛和攻击成本被极大提高。

漏洞缓解技术(Vulnerability Mitigations)指的是一系列用来提高漏洞利用难度的防御技术。随着人类需求和IT技术的发展,各种各样的硬件、系统、应用程序、协议被不断地设计、开发出来。由图灵停机问题[1]能够得出,人们无法给出通用算法或工具来分析和确定程序中不存在漏洞;同时,对特定软硬件进行相当程度上彻底的安全检查又成本过高。因此,人们提出了漏洞缓解技术,在承认漏洞可能存在的情况下,对其进行缓解或阻断。

攻与防是一个持续、动态的博弈过程,而漏洞缓解技术往往是在攻击者先提出甚至发动针对某种漏洞的攻击方法之后才被提出的(值得一提的是,很多情况下攻击者与防御者都是安全研究人员,正所谓不知攻,焉知防)。例如,在最经典的「覆盖函数返回地址为攻击利用代码(shellcode)」的缓冲区溢出攻击被提出后,人们为操作系统增加了「地址空间布局随机化(ASLR)」技术。后来,攻击者一步步逐渐提出了「返回到libc库(ret2libc)」、「返回到过程链接表(ret2plt)」、「返回导向编程(ROP)」等攻击技术;防守者也一步步逐渐设计了「数据执行保护(DEP)」、「金丝雀(canary)」、「安全的结构化异常处理(SafeSEH)」等漏洞缓解技术。计算机科学技术领域有很强的分层概念,信息安全同样。

上面的例子主要体现了操作系统和应用程序安全层面的安全问题和缓解方法。事实上,其他层次上的情况也是类似的。例如,针对Web安全领域的「跨站脚本攻击(XSS)」、「SQL注入」等漏洞,人们逐渐应用了「特殊字符转义」、「参数化查询」等缓解技术。

然而,天下没有免费的午餐。诚然,漏洞缓解技术提高了攻击者的攻击门槛,但实施漏洞缓解技术也往往会对系统性能造成一定影响,这也是相当一部分漏洞缓解技术至今没有普及的原因。毕竟,对于绝大多数用户来说,安全很重要,但并非首要目的,防御不应该给生产生活带来过多不便。因此,「如何优化卓有成效的缓解技术的性能损耗」一直以来都是产业、学术界的研究热点之一。

一些安全领域的朋友对用户态各种层次(如上所述)的漏洞缓解技术比较熟悉,但对于接触较少的内核安全可能了解较少。由于容器和宿主机共享内核的特点,内核安全在云原生技术愈加流行的今天也变得愈加重要(当然,一开始就非常重要)。

本文所要介绍的正是内核中应用的漏洞缓解技术。

后文的组织结构如下:

  • 介绍mmap_min_addr
  • 介绍KASLR
  • 介绍kptr_restrict
  • 介绍dmesg_restrict
  • 介绍SMEP/SMAP
  • 总结与思考

注:通常,一种漏洞缓解技术在多平台下都有实现(Linux/Windows/Mac OSX/Android/…),考虑到云原生场景主要依托于Linux平台,我们仅介绍Linux系统上的内核漏洞缓解技术。

2. mmap_min_addr:限制虚拟地址申请下界以防零地址解引用

mmap_min_addr用来决定是否限制进程通过mmap能够申请到的内存的最小虚拟地址,或者说,限制进程申请的内存虚拟地址范围的下界。

Procfs等伪文件系统是Linux内核向用户态暴露接口的方式之一。mmap_min_addr在Linux下Procfs中对应的文件是/proc/sys/vm/mmap_min_addr。读取该文件即可查询当前系统的mmap_min_addr;写该文件(需要满足权限要求)即可改变这个限制值。后面我们介绍的漏洞缓解技术大多都是能够通过Procfs进行查看和设置的。

下面先来做一个小实验来体会mmap_min_addr

首先查看一下当前的状态:

rambo@matrix:~$ whoami
rambo
rambo@matrix:~$ cat /proc/sys/vm/mmap_min_addr
65536

可以发现,进程能够申请的最小地址值为65536。在这种设定下,我们编写以下测试代码去申请零地址处的内存并尝试修改其内容:

#include <sys/mman.h>
#include <string.h>
#include <stdio.h>

int main(){
    char hello = "hello, world";
    mmap(0, 4096,PROT_READ | PROT_WRITE | PROT_EXEC, MAP_FIXED | MAP_PRIVATE | MAP_ANONYMOUS ,-1, 0);
    printf("mmap succeeded!\n");
    memcpy(0, hello, sizeof(hello));
    return 0;
}

编译运行,出现了段错误:

rambo@matrix:~$ gcc -o mmap_test test.c -w
rambo@matrix:~$ ./mmap_test
Segmentation fault (core dumped)

接着,我们修改mmap_min_addr为0,然后再次执行上述程序:

rambo@matrix:~$ sudo sh -c "echo 0 > /proc/sys/vm/mmap_min_addr"
rambo@matrix:~$ ./mmap_test
mmap succeeded!

这次就执行成功了,说明mmap_min_addr的确发挥了作用。然而mmap_min_addr有什么用处呢?系统为什么要限制进程申请内存的地址范围下界呢?

简单来说,mmap_min_addr主要是为了限制空指针解引用(NULL Point Dereference)类型的攻击。为了理解这个问题,我们需要补充两个知识点:

  1. Linux系统将虚拟内存空间划分为用户空间和内核空间。以常见的32位系统为例,内核空间在用户空间之上,低地址的3G空间为用户空间;高地址的1G空间为内核空间。因此,空指针对应的零地址实际上是在用户空间范围内。
  2. 在大多数C语言实现中,未初始化指针的值为零(即零指针)。

综合起来,设想这样一种情形:攻击者在某程序中找到一个未初始化的函数指针,同时还能够向该程序的零地址处写入数据,那么攻击者就能够通过调用这个函数指针使该程序的控制流转向他写入的恶意指令。退一步讲,即使攻击者不能够控制该程序零地址处的内容,他也有可能通过空指针解引用触发段错误,从而导致程序崩溃,也就是一种拒绝服务攻击。

3. KASLR:随机化内核地址以提高Shellcode编写/布置难度

要了解KASLR,就不得不提到ASLR。ASLR应该是最有名的漏洞缓解机制之一了。本文开头介绍过——ASLR是指地址空间布局随机化(Address Space Layout Randomization),是一种用户态下用来对抗缓冲区溢出攻击的缓解技术。信息安全初学者在学习研究缓冲区溢出时,很可能会在ASLR上碰壁,搞了半天,觉得“我的exploit逻辑应该没问题,payload也调试过了,怎么还不行呢?”。

在介绍KASLR之前,我们先来看看用户态下的ASLR长什么样。

/proc/sys/kernel/randomize_va_space是内核暴露在用户态的ASLR接口。其值为0时,ASLR完全关闭;值为1时,仅仅对mmap基址、栈地址和VDSO页地址做随机化处理(共享库也将被加载到随机地址),对于「位置无关可执行文件」(构建时带有-fPIE选项的二进制程序)来说,程序代码段基址也会被随机化;值为2时,在值为1的基础上,加上对堆的随机化。

我们来做个小实验,编写如下一段代码:

#include <stdio.h>

int main(){
	char hello[20];
	scanf("%s", hello);
	printf("Variable address: %x\n", hello);
	printf("main func's address: %x\n", (void *)main);
	return 0;
}

在ASLR开启时,消除-fPIE效果编译并运行程序:

rambo@matrix:~$ gcc -no-pie -o aslr_test aslr_test.c -w
rambo@matrix:~$ ./aslr_test
hello
Variable address: 7549f420
main func's address: 4005c7
rambo@matrix:~$ ./aslr_test
hello
Variable address: d724c8a0
main func's address: 4005c7

可以发现,每次运行程序时栈上变量的地址都会发生变化,但程序自身的main函数地址是不变的。

此时如果关闭ASLR,则栈上变量的地址也将不再变化:

rambo@matrix:~$ sudo sh -c "echo 0 > /proc/sys/kernel/randomize_va_space"
rambo@matrix:~$ ./aslr_test
hello
Variable address: ffffe3f0
main func's address: 4005c7
rambo@matrix:~$ ./aslr_test
hello
Variable address: ffffe3f0
main func's address: 4005c7

毫无疑问,这样的地址随机化现象提高了攻击门槛,因为许多缓冲区溢出漏洞利用技术都依赖于相对固定的内存地址,当然,这些年也不断有绕过技术出现。

了解了ASLR,我们回过头来看KASLR。KASLR多出的一个K,是内核(Kernel)。顾名思义,KASLR指的就是内核态下的地址空间布局随机化技术。与ASLR在程序运行时随机化类似,KASLR在系统启动时对内核代码段地址做一次随机化[3]。

我们可以通过比较两次系统启动时的内核基址来判断KASLR是否开启,例如:

rambo@matrix:~$ sudo cat /proc/kallsyms | grep 'T startup_64'
ffffffffb5e00000 T startup_64

rambo@matrix:~$ sudo init 6 # 重启

rambo@matrix:~$ sudo cat /proc/kallsyms | grep 'T startup_64'
ffffffff9fc00000 T startup_64

从上述输出可以看到,第一次系统启动时内核基址是ffffffffb5e00000,重启后变为ffffffff9fc00000,说明KASLR开启。

那么,如何关闭KASLR呢?我们可以通过修改/etc/default/grub文件来达到目的。找到该文件中的GRUB_CMDLINE_LINUX配置项,在最后加上nokaslr,例如:

GRUB_CMDLINE_LINUX="net.ifnames=0 biosdevname=0 nokaslr"

然后执行更新即可:

rambo@matrix:~$ sudo update-grub
Sourcing file `/etc/default/grub'
Generating grub configuration file ...
Found linux image: /boot/vmlinuz-4.15.0-101-generic
Found initrd image: /boot/initrd.img-4.15.0-101-generic
Found linux image: /boot/vmlinuz-4.15.0-96-generic
Found initrd image: /boot/initrd.img-4.15.0-96-generic
done

再次重启,可以发现地址已经变成了默认值ffffffff81000000,说明KASLR已经被关闭:

rambo@matrix:~$ sudo cat /proc/kallsyms | grep 'T startup_64'
ffffffff81000000 T startup_64

不同的系统环境下配置方法可能略有差别。笔者的环境是Ubuntu 18.04,限于篇幅,不再一一列举其他环境下的配置方法。

与ASLR类似,KASLR也提高了攻击者对内核漏洞的利用门槛。例如,内核漏洞往往被用来进行提升权限或从容器中逃逸。攻击者在利用漏洞劫持控制流后,往往会去调用一个经典的内核函数组合以获取高权限:

commit_creds(prepare_creds());

系统启用KASLR后,攻击者在exploit中直接使用默认的内核符号地址就不再有效,攻击成本提高。然而,KASLR并不是完美的(后面会提到几种可能用来绕过KASLR的方法)。

4. kptr_restrict:限制内核符号地址暴露以防绕过KASLR

kptr_restrict用来决定是否限制内核中的符号地址通过/proc或其他接口暴露出来。其值为0时,非特权用户也能够查看内核符号地址(例如,通过/proc/kallsyms接口查看);值为1时,只有具有CAP_SYSLOG特权的用户才能查看内核符号地址;值为2时,即使是特权用户也无法查看内核符号地址[2]。

网络上大多数关于kptr_restrict的文章都会引用上述来自Linu内核文档[2]的介绍。但是在实践过程中,笔者发现,有时即使(例如,在笔者的测试环境4.15版本内核中)将kptr_restrict(配置文件为/proc/sys/kernel/kptr_restrict)设置为0,非特权用户也无法获得内核符号地址。

其实很简单。我们来看一下内核中与kptr_restrict相关的函数kallsyms_show_value[5]:

/*
 * We show kallsyms information even to normal users if we've enabled
 * kernel profiling and are explicitly not paranoid (so kptr_restrict
 * is clear, and sysctl_perf_event_paranoid isn't set).
 *
 * Otherwise, require CAP_SYSLOG (assuming kptr_restrict isn't set to
 * block even that).
 */
int kallsyms_show_value(void)
{
	switch (kptr_restrict) {
	case 0:
		if (kallsyms_for_perf())
			return 1;
	/* fallthrough */
	case 1:
		if (has_capability_noaudit(current, CAP_SYSLOG))
			return 1;
	/* fallthrough */
	default:
		return 0;
	}
}

可以看到,kptr_restrict为0时,内核还要去判断kallsyms_for_perf函数[6]是否返回真。这个函数就更简单了:

static inline int kallsyms_for_perf(void)
{
#ifdef CONFIG_PERF_EVENTS
	extern int sysctl_perf_event_paranoid;
	if (sysctl_perf_event_paranoid <= 1)
		return 1;
#endif
	return 0;
}

因此,对于上述版本的内核来说,只有在配置了CONFIG_PERF_EVENTS的情况下,设置kptr_restrict = 0,且设置perf_event_paranoid <= 1,非特权用户才能够获取到内核符号地址。安全性是有所提升的。

我们来进行一个小实验看一下是不是这样:

rambo@matrix:~$ whoami
rambo
rambo@matrix:~$ cat /proc/sys/kernel/kptr_restrict
0
rambo@matrix:~$ cat /proc/sys/kernel/perf_event_paranoid
3
rambo@matrix:~$ cat /proc/kallsyms | tail -n 2
0000000000000000 t cleanup_module	[pata_acpi]
0000000000000000 r __mod_pci__pacpi_pci_tbl_device_table	[pata_acpi]
rambo@matrix:~$ sudo sh -c "echo 0 > /proc/sys/kernel/perf_event_paranoid"
rambo@matrix:~$ cat /proc/kallsyms | tail -n 2
ffffffffc008141d t cleanup_module	[pata_acpi]
ffffffffc0082080 r __mod_pci__pacpi_pci_tbl_device_table	[pata_acpi]

如上,我们在将perf_event_paranoid设置为0后,非特权用户就能够获得内核符号地址了。

那么,设置kptr_restrict限制有什么意义呢?这主要是为了防止内核符号地址被非特权用户恶意利用——例如,用来绕过KASLR。由于KASLR在系统启动时对内核基址做了随机化处理,攻击者在不进行暴力破解的情况下很难命中内核符号的正确地址,继而无法在Exploit中应用关键内核函数去实现权限提升等操作。如果作为非特权用户的攻击者能够借助/proc/kallsyms等方式获得有效的内核符号地址,KASLR就被绕过了。

例如,攻击者能够借此直接获得权限提升所需的关键内核函数地址:

rambo@matrix:~$ cat /proc/kallsyms | grep 'T commit_creds'
ffffffff8b4b47c0 T commit_creds
rambo@matrix:~$ cat /proc/kallsyms | grep 'T prepare_kernel_cred'
ffffffff8b4b4b90 T prepare_kernel_cred

5. dmesg_restrict:限制内核日志暴露以防绕过KASLR

dmesg_restrict用来决定是否限制非特权用户使用dmesg查看内核日志缓冲区中的消息。其值为0时,非特权用户对内核日志的查看将不受限制;值为1时,只有具有CAP_SYSLOG特权的用户才能查看内核日志[2]。

例如,在dmesg_restrict值为0时(配置文件为/proc/sys/kernel/dmesg_restrict),非特权用户rambo能够读取到内核日志:

rambo@matrix:~$ whoami
rambo
rambo@matrix:~$ grep CapEff /proc/self/status
CapEff:	0000000000000000
rambo@matrix:~$ dmesg | tail -n 3
[4322561.736000] docker0: port 1(vethc613760) entered disabled state
[6418769.946155] audit: type=1305 audit(1593338728.486:87): audit_rate_limit=512 old=512 auid=4294967295 ses=4294967295 res=1
[6418769.946401] audit: type=1305 audit(1593338728.486:88): audit_backlog_limit=2048 old=2048 auid=4294967295 ses=4294967295 res=1

然而,当dmesg_restrict值为1时,非特权用户rambo就不被允许读取内核日志了:

rambo@matrix:~$ sudo sh -c "echo 1 > /proc/sys/kernel/dmesg_restrict"
rambo@matrix:~$ dmesg | tail -n 3
dmesg: read kernel buffer failed: Operation not permitted

此时,只要具备了CAP_SYSLOG权限,依然能够读取内核日志:

rambo@matrix:~$ sudo sh -c "grep CapEff /proc/self/status"
CapEff:	0000003fffffffff
rambo@matrix:~$ capsh --decode=0000003fffffffff | grep "cap_syslog"
0x0000003fffffffff=cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_fsetid,cap_kill,cap_setgid,cap_setuid,cap_setpcap,cap_linux_immutable,cap_net_bind_service,cap_net_broadcast,cap_net_admin,cap_net_raw,cap_ipc_lock,cap_ipc_owner,cap_sys_module,cap_sys_rawio,cap_sys_chroot,cap_sys_ptrace,cap_sys_pacct,cap_sys_admin,cap_sys_boot,cap_sys_nice,cap_sys_resource,cap_sys_time,cap_sys_tty_config,cap_mknod,cap_lease,cap_audit_write,cap_audit_control,cap_setfcap,cap_mac_override,cap_mac_admin,cap_syslog,cap_wake_alarm,cap_block_suspend,cap_audit_read
rambo@matrix:~$ sudo dmesg | tail -n 3
[4322561.736000] docker0: port 1(vethc613760) entered disabled state
[6418769.946155] audit: type=1305 audit(1593338728.486:87): audit_rate_limit=512 old=512 auid=4294967295 ses=4294967295 res=1
[6418769.946401] audit: type=1305 audit(1593338728.486:88): audit_backlog_limit=2048 old=2048 auid=4294967295 ses=4294967295 res=1

那么,设置dmesg_restrict限制有什么意义呢?与kptr_restrict类似,dmesg_restrict的设置同样是为了避免非特权用户利用内核日志泄露的敏感信息绕过KASLR机制。我们知道,KASLR只是将内核基址在启动时做了随机化处理,但是内核中各符号之间的相对偏移是不受KASLR影响的。因此,只要能够获得内核基址或某符号地址,再结合偏移量,就能够计算出其他符号的准确地址,从而绕过KASLR。

例如,在以前的Linux环境下,我们可以直接从dmesg中获得内核基址:

dmesg | grep 'Freeing SMP'

能获得类似下面一样的输出:

Freeing SMP alternatives memory: 32K (ffffffff9e309000 - ffffffff9e311000)

其中,ffffffff9e309000就是内核基址了。然而,后来的一个内核补丁[4]使得内核隐去了基址信息。补丁如下:

diff --git a/mm/page_alloc.c b/mm/page_alloc.c
index 2b3bf67..3f63973 100644
--- a/mm/page_alloc.c
+++ b/mm/page_alloc.c
@@ -6508,8 +6508,8 @@  unsigned long free_reserved_area(void *start, void *end, int poison, char *s)
 	}

 	if (pages && s)
-		pr_info("Freeing %s memory: %ldK (%p - %p)\n",
-			s, pages << (PAGE_SHIFT - 10), start, end);
+		pr_info("Freeing %s memory: %ldK\n",
+			s, pages << (PAGE_SHIFT - 10));

 	return pages;
 }

因此,在笔者的测试环境中,执行上述命令也只能获得以下输出了:

rambo@matrix:~$ dmesg | grep 'Freeing SMP'
[    0.004000] Freeing SMP alternatives memory: 36K

可见,Linux内核的安全性是在不断提升的。但是,这并不说明dmesg不再能够泄露内核符号地址。在特定的场景下,攻击者可能通过其他手段让内核将某些符号地址主动输出到日志中,从而计算出所需的内核特定符号地址。

6. SMEP/SMAP:限制特权模式操作用户空间代码及数据

SMEP全称为Supervisor Mode Execution Prevention,SMAP全称为Supervisor Mode Access Prevention,后者建立在前者的基础上,可以视为前者的补充。SMEP/SMAP基于CPU提供的新特性,用来阻止不受信应用程序以特权模式执行(SMEP)、读写(SMAP)用户空间的代码和数据[7]。

SMEP/SMAP的开闭分别受CPU中CR4寄存器第20、21标识位的控制。标识位设置1时,保护开启;标识位设置0时,保护关闭。因此,一种绕过SMEP/SMAP的手段是在高权限下通过修改CR4寄存器来关闭它们。

我们可以从/proc/cpuinfo来了解SMEP/SMAP是否开启:

rambo@matrix:~$ cat /proc/cpuinfo | grep -E "smep|smap"
flags		: fpu vme de pse tsc msr pae mce cx8 apic sep mtrr pge mca cmov pat pse36 clflush mmx fxsr sse sse2 ss syscall nx pdpe1gb rdtscp lm constant_tsc arch_perfmon rep_good nopl xtopology cpuid tsc_known_freq pni pclmulqdq ssse3 fma cx16 pcid sse4_1 sse4_2 x2apic movbe popcnt tsc_deadline_timer aes xsave avx f16c rdrand hypervisor lahf_lm abm 3dnowprefetch invpcid_single pti ibrs ibpb fsgsbase tsc_adjust bmi1 hle avx2 smep bmi2 erms invpcid rtm mpx avx512f avx512dq rdseed adx smap clflushopt clwb avx512cd avx512bw avx512vl xsaveopt xsavec xgetbv1 arat

上述测试环境的输出结果中flags包含了smepsmap,这说明SMEP/SMAP处于开启状态。

7. 总结与思考

本文对五种常见的内核漏洞缓解技术做了介绍:

  • mmap_min_addr(对应/proc/sys/vm/mmap_min_addr文件)
  • KASLR(对应/etc/default/grub文件)
  • kptr_restrict(对应/proc/sys/kernel/kptr_restrict文件)
  • dmesg_restrict(对应/proc/sys/kernel/dmesg_restrict文件)
  • SMEP/SMAP(对应proc/cpuinfo文件)

为了加强容器运行环境的安全性,我们可以检查上面括号内列出的各漏洞缓解技术的对应文件来判断相应的缓解技术是否启用。其中,SMEP/SMAP需要CPU的支持;另外,在版本较新的内核中,上述漏洞缓解技术往往都处于启用状态,但是在老版本的内核或设备中,受功能限制或处于性能需求,一些漏洞缓解技术可能会被停用。

值得注意的是,漏洞缓解技术与其他防御机制或系统类似,但是也存在显著不同。漏洞缓解技术通常是为了应对新出现的具体攻击手段而不断被设计产生的,通常具有广而杂、小而精的特点。

宏观上来看,从安全开发到安全运行,从最小权限到纵深防御,它们与其他防御机制或系统在具体场景下一起组建成抵御攻击的防御体系,共同保障业务安全。

未知攻焉知防。但另一方面,防守者也需要对自己的环境、对已有的安全机制和措施更加了解,从而更准确地评估当前系统或集群的安全状态,在此基础上制定和应用更贴合实际的安全策略。

参考文献

  1. https://en.wikipedia.org/wiki/Halting_problem
  2. https://www.kernel.org/doc/Documentation/sysctl/kernel.txt
  3. https://lwn.net/Articles/569635/
  4. https://lore.kernel.org/patchwork/patch/728905/
  5. https://elixir.bootlin.com/linux/v4.15/source/kernel/kallsyms.c#L668
  6. https://elixir.bootlin.com/linux/v4.15/source/kernel/kallsyms.c#L650
  7. https://en.wikipedia.org/wiki/Supervisor_Mode_Access_Prevention