深入分析CVE-2020–15702漏洞
Apport是一款用于收集并反馈错误信息(当应用程序崩溃时操作系统认为有用的信息)的工具包。 安全研究人员在该软件中发现了一个竞态条件漏洞CVE-2020–15702,本地攻击者可利用该漏洞提升权限并执行任意代码。 在本文中,我们将从技术角度为读者详细解读该漏洞,相关链接如下所示:
http://archive.ubuntu.com/ubuntu/pool/main/a/apport/apport_2.20.11-0ubuntu27.tar.gz
同时,我们会对PoC进行概要解读,但不会发布实际的漏洞利用代码,请见谅。
概述
当apport处理PID时,存在竞态条件漏洞。利用这个漏洞,攻击者可以在特定条件下提升自身的权限。
前提条件
在目标Ubuntu计算机上具有执行任意代码的权限。
- 满足特定条件的进程,能够以某些特权(如logrotate)执行,这一点,我们将在后面详细解释。
- 我们可以通过利用这一点来获得目标进程的特权。所以,如果目标有更高的权限(例如root),我们就可以据为己有。
漏洞的影响
本地权限提升
什么是apport
https://wiki.ubuntu.com/Apport
apport是Ubuntu标配的崩溃报告程序。当用户态进程由于SEGV等原因发生崩溃时,apport就会创建一个表格形式的报告,以便进行错误分析等后续处理。
garyo@garyo:~/sandbox$ sleep 100 &
[1] 13048
garyo@garyo:~/sandbox$ kill -SIGSEGV 13048
garyo@garyo:~/sandbox$ head -n 20 /var/crash/_usr_bin_sleep.1000.crash
ProblemType: Crash
Architecture: amd64
Date: Tue Aug 25 10:33:06 2020
DistroRelease: Ubuntu 20.04
ExecutablePath: /usr/bin/sleep
ExecutableTimestamp: 1567679920
ProcCmdline: sleep 100
ProcCwd: /home/garyo/sandbox
ProcEnviron:
SHELL=/bin/bash
LANG=en_US.UTF-8
TERM=xterm-256color
XDG_RUNTIME_DIR=
PATH=(custom,no user)
ProcMaps:
564cdc98d000-564cdc98f000 r--p 00000000 08:021051009 /usr/bin/sleep
564cdc98f000-564cdc993000 r-xp 00002000 08:021051009 /usr/bin/sleep
564cdc993000-564cdc995000 r--p 00006000 08:021051009 /usr/bin/sleep
564cdc996000-564cdc997000 r--p 00008000 08:021051009 /usr/bin/sleep
564cdc997000-564cdc998000 rw-p 00009000 08:021051009 /usr/bin/sleep
在Linux平台上,当进程崩溃时,通常会转储核心文件(core file)。然而,如果/proc/sys/kernel/core_pattern的第一个字节被设置为"|"(管道),内核就会使用usermodehelper(一个从内核启动用户态进程的函数)启动一个进程。核心转储的内容将被写到与被启动进程的标准输入相连的管道中。
下面,让我们来看一下Ubuntu 20.04中相关的内核参数:
garyo@garyo:~/sandbox$ cat/proc/sys/kernel/core_pattern
|/usr/share/apport/apport %p %s %c %d %P %E
第二个参数后的格式字符串可以通过读取Linux内核的do_coredump()函数中调用的format_corename()函数找到。这里您需要知道的是,发生崩溃的进程的PID将被传递给apport。
漏洞详情
顺便说一下,有些读者可能已经听说过与apport的PID相关的竞态条件漏洞,因为过去也发现过类似的漏洞;实际上,我在编写PoC的过程中,也参考了下面的资料:
- https://securitylab.github.com/research/ubuntu-apport-CVE-2019-15790
- https://securitylab.github.com/research/ubuntu-apport-CVE-2019-7307
- https://www.exploit-db.com/exploits/37088
其中,关于CVE-2019-15790的攻击解释对我帮助很大。下面,让我们简单解释一下该漏洞,当时的apport的执行流程如下所示:
- 由内核启动,以崩溃的进程(以下简称A)的信息作为参数。
- 根据作为参数的进程A的PID,从procfs获取uid/gid/cwd。
- 通过drop_privileges()函数将真实的uid/gid改为进程A的uid/gid。
- 基于PID从procfs中获取其他信息,如maps。
- 创建的报告为进程A赋予了读取权限。
由于apport在第3步进行了降权,所以,需要通过向apport进程发送SIGSTOP信号来停止执行。实际上,在这段时间内,我们可以通过向进程A(一个具有正常用户权限的进程,故意发生崩溃)发送SIGKILL信号来终止进程A的执行(见注1)。如果我们运行多个进程,PID将被重用(当PID变为最大值时,空闲的PID将从0开始再次被重用)。所以,在运行到第4步之前,我们可以用另一个特权进程替换apport所指向的进程。然后,apport将指向特权进程的/proc/[PID]/maps,并将其包含在报告中,也就是说,这可以用于绕过特权进程的ASLR保护机制。
注1:首先,Linux内核无法保证崩溃报告函数启动后进程仍处于活动状态。在现实情况下,当崩溃报告函数启动时,进程可能仍处于活动状态。然而,这并不是因为它在等待崩溃报告函数的完成(当在do_coredump里面运行usermodehelper时,参数UMH_WAIT_EXEC被使用,该参数的作用是等待exec的完成而不是进程的终止)。进程之所以仍处于活动状态,通常是因为核心转储信息太大所致:如果这些信息无法立即写入stdin管道,那么写入操作就会被阻止。您可以看到,在读取所有标准输入后,崩溃的进程立即从procfs中消失,而无需等待崩溃报告函数完成。
实际上,这个漏洞位于注释部分。在此之前,让我们先来看看针对上述漏洞的防范措施。
- globalpidstat, real_uid, real_gid, cwd
+ globalpidstat, real_uid, real_gid, cwd, proc_pid_fd
+
+ proc_pid_fd= os.open('/proc/%s' % pid, os.O_RDONLY | os.O_PATH | os.O_DIRECTORY)
- pidstat =os.stat('/proc/%s/stat' % pid)
+ pidstat =os.stat('stat', dir_fd=proc_pid_fd)
https://git.launchpad.net/ubuntu/+source/apport/commit/data/apport?h=applied/ubuntu/bionic-devel&id=0c4ff2db0788ecffe84a5dc6938a616140f179c2
其中,最重要的是一个叫做proc_pid_fd的变量。在该漏洞被修复之前,apport通常将PID串成字符串,并且每次都会打开/proc/[PID]/{stat/cwd/maps}。因此,如果将/proc/[pid]替换为同名目录,它将引用新的(替换的)目录。不过,在漏洞修复之后,apport会首先打开/proc/[PID]目录,并且当从procfs检索数据时,openat将与打开目录的fd一起使用。下面是验证代码,表明已经无法使用openat来替换目录。
garyo@garyo:~/sandbox$ cat test.py
#!/usr/bin/python3
import os, tempfile
d=tempfile.mkdtemp()
dirfd=os.open(d, os.O_RDONLY|os.O_DIRECTORY|os.O_PATH)
os.open(d+"/bbb", os.O_RDWR|os.O_CREAT)
print("Open 通过这个补丁,如果SIGSTOP信号第2到4步之间中止了apport的执行,并且目录被替换,那么,数据检索就会失败。
然而,正如我前面提到的,实际上无法保证崩溃的进程在apport启动后还处于存活状态。这意味着PID指向的进程在apport启动的时候可能已经变成另一个进程了。这就是我发现的安全漏洞。实际的替换方法将在PoC概述部分进行解释。
顺便说一下,在以前的漏洞中,可以在报告中包含特权进程的内存映射,同时将报告的所有者保留为自己的uid,所以可以用来进行地址泄露。但是这一次呢?就这里来说,进程在apport启动的那一刻就被替换了。所以,在降权时引用的uid也属于特权进程,而普通用户没有权限阅读报告。换句话说,这个漏洞无法用于地址泄漏,然而,apport还有一个重要的功能,即按原样转储核心文件。这是一个将内核发送的核心转储信息保存到进程运行的工作目录中的功能(与正常的核心文件转储相同,无需通过apport)。通过滥用这个功能,我们可以把自己进程的核心转储信息作为核心文件写到特权进程的cwd中。这个功能对于该漏洞的利用过程来说至关重要。我将在PoC概述部分解释如何通过它来实现提权。
PoC概述
编写PoC时,有两点非常重要:
- 处理PID的“轮回”时间;
- 当核心文件被保存到特权进程的cwd时,提升权限的方法。
处理PID的“轮回”时间
第一件重要的事情,就是处理PID“轮回”的时间。目前需要做的事情是发送SIGSEGV信号来启动应用程序,并通过发送SIGKILL+在读取procfs之前启动特权进程来“轮回”PID。
实际上,我们很难在短时间内完成一次PID的轮回。但由于我们可以随时发送SIGSEGV信号,所以,我们可以不断在运行并杀死进程后发送SIGSEGV,直到PID完成一次轮回为止,这样的话,时机的问题就可以解决了。
然而,时机的问题仍然存在。例如,在先发送SIGSEGV信号并在1秒后发送SIGKILL信号的实现中,apport通常在收到SIGKILL信号之前就完成任务并终止了。另一方面,如果发送SIGKILL的时间太早,也无法很好地工作。如果我们在合适的时机发送信号,do_coredump()函数就不会在第一时间被调用。下面我将进行相应的解释。
当目标进程从内核模式过渡到用户模式时,Linux内核会处理收到的信号。这时,根据每个接收到的信号,由get_signal()函数进行相应的处理。例如,如果收到SIGSEGV这样的关键信号,do_coredump()函数将被调用,如下图所示,并保存一个核心转储文件(core dump file,如果设置了管道的崩溃报告函数的话)。
if (sig_kernel_coredump(signr)) {
if(print_fatal_signals)
print_fatal_signal(ksig->info.si_signo);
proc_coredump_connector(current);
/*
* If it was able to dump core, this kills all
* other threads in the group and synchronizeswith
* their demise. If we lost the race with another
* thread getting here, it set group_exit_code
* first and our do_group_exit call below willuse
* that value and ignore the one we pass it.
*/
do_coredump(&ksig->info);
}
https://github.com/torvalds/linux/blob/9907ab371426da8b3cffa6cc3e4ae54829559207/kernel/signal.c#L2739-L2751
如果在调用get_signal()函数时SIGSEGV和SIGKILL都被保留为接收信号,则do_coredump()函数将不会因为下面的条件分支而被调用,并假设进程应该被终止。这就是为什么信号发送得太早就不起作用的原因。
/* Has this task already been marked for death? */
if(signal_group_exit(signal)) {
ksig->info.si_signo= signr = SIGKILL;
sigdelset(¤t->pending.signal, SIGKILL);
trace_signal_deliver(SIGKILL,SEND_SIG_NOINFO,
&sighand->action[SIGKILL- 1]);
recalc_sigpending();
gotofatal;
}
https://github.com/torvalds/linux/blob/9907ab371426da8b3cffa6cc3e4ae54829559207/kernel/signal.c#L2601-L2609
我的意思是,我们至少不应该几乎在同一时刻发送它们,但如果太晚了,apport则早已完成相应的任务。这使得时机的把握变得非常困难。所以我决定使用apport实现的锁。apport在启动时,会使用名为/var/run/apport.lock的文件检查另一个apport是否已经启动。实际上,在获取proc_pid_fd之前,就会调用这个检查函数(check_lock())。我的意思是,如果我们继续运行另一个apport进程,就可以在调用do_coredump()函数和引用procfs之间停止执行。
但是,当然,当另一个apport进程终止时,执行将立即恢复。这让我想起了我发现另一个漏洞(CVE-2020-11936)时使用的dbus的功能。在函数is_closing_session()中,通过在这里指定环境变量DBUS_SESSION_BUS_ADDRESS,请求将被发送到攻击者控制下的TCP服务器。
def is_closing_session(uid):
'''Check ifpid is in a closing user session. During that, crashes are common as the session D-BUS and X.org are going
away, etc.These crash reports are mostly noise, so should be ignored.
'''
withopen('environ', 'rb', opener=proc_pid_opener) as e:
env =e.read().split(b'\0')
for e inenv:
ife.startswith(b'DBUS_SESSION_BUS_ADDRESS='):
dbus_addr = e.split(b'=', 1)[1].decode()
break
else:
error_log('is_closing_session(): no DBUS_SESSION_BUS_ADDRESS inenvironment')
returnFalse orig_uid = os.geteuid()
os.setresuid(-1, os.getuid(), -1)
try:
gdbus =subprocess.Popen(['/usr/bin/gdbus', 'call', '-e', '-d',
'org.gnome.SessionManager', '-o', '/org/gnome/SessionManager', '-m',
'org.gnome.SessionManager.IsSessionRunning'], stdout=subprocess.PIPE,
stderr=subprocess.PIPE,env={'DBUS_SESSION_BUS_ADDRESS': dbus_addr})
(out,err) = gdbus.communicate()
https://git.launchpad.net/ubuntu/+source/apport/tree/data/apport?h=applied/ubuntu/focal-devel&id=02a1fd19eafeae3f8e98f1461c9bcea850f0c419#n253
现在,如果TCP服务器没有发送响应,结果会怎样?这样的话,就会在gdbus命令还没终止,并且gdbus.communication()这一行也没有返回的情况下,执行就被暂停了。换句话说,在持有/var/run/apport.lock锁的情况下,程序执行被中止了。
综上所述,我们可以通过以下步骤停止对目标PID执行apport,并稳定地等待PID的重用:
- 将环境变量DBUS_SESSION_BUS_ADDRESS设置为类似tcp的值:host = 127.0.0.1, port = 8888
- 在指定的端口上启动TCP服务器
- 通过向进程A发送SIGSEGV信号启动apport
- 从gdbus向TCP服务器发出AUTH请求,并通过不给予响应,使其中止
- 通过向新创建的进程B发送SIGSEGV信号,启动apport
- apport将在check_lock()函数处停止运行,因为另一个apport进程没有被中止
- 向进程B发送SIGKILL信号
- 以某种方式启动一个特权进程以复制进程B的PID
- 从TCP服务器发送一些随机的响应以恢复执行
- 进程A的apport终止运行
- 锁被释放,进程B的apport执行恢复(此时,进程B的PID将被轮回使用)
当核心文件被保存到特权进程的cwd时,如何提升权限?
嗯,前面我们已经解释了如何让PID轮回使用,但是,我们还没有描述如何提升权限,实际上,这方面我们可以参考下面的链接:
https://www.exploit-db.com/exploits/37088
首先,总结一下目前的情况,我们已经能够将准备好的进程的核心转储文件保存到特权进程的cwd中(该进程甚至可以被非特权进程启动),或者说,我们已经可以预测启动时机了。这意味着目标进程必须可以通过读取cwd中的文件进行利用。不过,要想找到一个满足这个条件的进程的确有点困难。虽然有很多软件(如cron)允许我们在文件中指定命令,但很难找到一个调用chdir()来改变cwd的进程。经过一番折腾,我们最后发现,logrotate就是一款符合这个条件的软件。
logrotate对于这个漏洞来说是完美的:
- 基本上总是在特定的时间执行(时间可以预测)
- 基本上以高权限运行
- 在读取/etc/logrotate.d/中的文件前调用chdir()函数,具体参见https://github.com/logrotate/logrotate/blob/dac0b9e5db48861f7554fdf668f51ed59a07adb5/config.c#L684-L713
- 它不依赖于文件名(读取该目录中的所有文件)
- 由于没有严格检查文件格式并忽略无效字符,因此,只要在畸形的二进制文件中包含一个正常配置的字符串就足够了。详情见https://github.com/logrotate/logrotate/blob/dac0b9e5db48861f7554fdf668f51ed59a07adb5/config.c#L1048-L2050
至于第3点,我们前面已经讲过,实际上,第5点也是相当重要的。由于核心转储文件输出的是内存状态,因此操纵起来难度非常大。所以,它基本上被当作一个畸形的二进制文件。
由于第5点的存在,我们就有可能在/etc/logrotate.d/中创建一个配置文件,并通过使定义以下字符串的程序崩溃来执行touch/tmp/exploited部分:
char payload[] = "\n/tmp/pwn.log{\n su root root\n daily\n size=0\n firstaction\n touch /tmp/exploited;\n endsc ript\n}\n";
当logrotate再次被执行时,该命令就会执行。
题外话
顺便说一句,当您实际尝试利用这个漏洞时,仅凭上面介绍的内容还是不够的。这是因为,从调用chdir()完成解析配置文件到通过chdir()切换到原始目录,历时极短。同时,我们必须在这么短的时间内执行python程序(apport),所以,必须把握好时间。
对于本文来说,(因为它只是一个PoC),如果我可以在理论上证明该漏洞是可以利用的,那就可以了。所以,我在/etc/logrotate.d/中创建了一个无意义的、文件尺寸很大的配置文件:配置文件越大,解析的时间就越长,这样的话,就可以为将cwd改为特权进程提供更多的时间。同时,这里使用inotify来检测chdir(),然后将核心文件写入/etc/logrotate.d/,并再次执行logrotate,以确认实现了提权。
下面是执行的结果(logrotate是手动执行的):
漏洞的修复
相关厂商已经发布了相应的安全补丁,所以,我们建议用户立即升级apport软件包:
http://launchpadlibrarian.net/491870223/apport_2.20.11-0ubuntu27.4_2.20.11-0ubuntu27.6.diff.gz
小结
在这篇文章中,我们为读者详细解释了利用Ubuntu的崩溃报告功能的安全漏洞实现提权的方法。希望本文对读者的学习能够有所帮助,并祝阅读愉快!
参考资料
- https://securitylab.github.com/research/ubuntu-apport-CVE-2019-15790
- https://www.exploit-db.com/exploits/37088
- https://scan.netsecurity.ne.jp/article/2015/06/15/36624.html
最新评论