利用Ubuntu的错误报告功能实现本地提权(LPE)——Part2

 

在第二篇文章中,我将重点介绍 CVE-2019-730,apport的TOCTOU漏洞,它允许本地攻击者在错误报告中包含系统上任何文件的内容。

漏洞

Apport允许在主目录下放一个名为 ~/.apport-ignore.xml 的文件。它允许你指定一个可执行程序的自定义列表,但是,如果将~/.apport-ignore.xml符号链接替换为一个不属于你的文件(如/etc/shadow),会发生什么情况呢?处理的代码在report.py第962行:

if not os.access(ifpath, os.R_OK) or os.path.getsize(ifpath) == 0:
    # create a document from scratch
    dom = xml.dom.getDOMImplementation().createDocument(None, 'apport', None)
else:
    try:
        dom = xml.dom.minidom.parse(ifpath)
    except ExpatError as e:
        raise ValueError('%s has invalid format: %s' % (_ignore_file, str(e)))

如上所见,它用os.access检查用户是否有权访问文件。如果权限检查通过,则它将调用xml.dom.minidom.parse来解析XML。这是“time of check to time of use”(TOCTOU)漏洞的经典示例。如果这个文件在os.access检查时是有效的,但是在调用xml.dom.minidom之前,我迅速替换其文件的符号链接指向其他文件。则可以欺骗其使用提升的特权来读取我没有权限访问的文件

 

apport中取消特权的技巧

你可能想知道为什么os.access检查会失败,因为apport是一个root进程。原因是apport在执行过程中分两个阶段取消了特权。
第一阶段发生在 Apport的455行:

# Partially drop privs to gain proper os.access() checks
drop_privileges(True)

第二阶段发生在 601行

# Totally drop privs before writing out the reportfile.
drop_privileges()

“取消部分特权”和“完全取消特权”是什么意思?这与 进程的 真实、有效和保存的用户ID有关:

RUID(真实用户ID) EUID(有效用户ID) EUID(保存的用户ID)
root 进程 0 0 0
“partially drop privs”(取消部分特权) 1001 0 0
read files safely(安全读取文件) 1001 1001 0
“totally drop privs”(完全取消特权) 1001 1001 1001

RUID(实际用户id)确定进程的所有者,EUID(有效用户id)确定进程可以读写哪些文件。这表示apport处于“partial drop privs(取消部分特权)”状态时,它仍然可以读取系统上的任何文件。

要确保apport不会意外地使用root权限来读取或写入文件,正确的方法是首先进入我在表格中命名为“read files safely(安全读取文件)”的状态。由于保存的用户ID(SUID)仍然是root用户,因此该过程可以暂时进入”read files safely”(安全读取文件)状态,然后在读取文件完成后恢复为”partially drop privs”(取消部分特权)。注意,向”totally drop privs”(完全取消特权)的转换是不可逆的。

`os.access检查是不一样的,因为它使用RUID而不是EUID来检查真实用户是否有权访问文件。这就是为什么存在TOCTOU漏洞的原因,调用os.access时,Apport处于”partially drop privs”(取消部分特权)状态。也就是说,它将拒绝不属于我的文件,但是如果我可以绕过os.access检查,那么后续调用的xml.dom.minidom.parse将能够读取任何文件,因为EUID仍然是root。我可以在调用os.access之后替换~/.apport-ignore.xml的符号链接。

 

与CVE-2019-11481对比

我在fileutils.py的第335行发现了一个类似的Bug:

def get_config(section, setting, default=None, path=None, bool=False):
    '''Return a setting from user configuration.

    This is read from ~/.config/apport/settings or path. If bool is True, the
    value is interpreted as a boolean.
    '''
    if not get_config.config:
        get_config.config = ConfigParser()
        if path:
            get_config.config.read(path)
        else:
            get_config.config.read(os.path.expanduser(_config_file))

这段代码使用root(EUID)打开文件~/.config/apport/settings。大概看,由于os.access处不存在检查,以为比其他漏洞更容易利用。经过进一步审查,我发现不是,原因在于错误处理的方式不同。例如,如果我想使用这个漏洞来读取/var/shadow的内容,它不是一个有效的XML文件,而且它的格式也不正确,不能作为apport文件解析。因此,无论哪种情况,都会在apport中触发一个解析错误。在~/.config/apport/settings的情况下,将导致apport立即中止。但是在~/.apport-ignore.xml的情况下,格式不正确的文件将被忽略,apport将继续运行。因此,我发现~/.apport-ignore.xml更容易利用。

我向Ubuntu报告了~/.config/apport/settings漏洞: bug 1830862。此问题已修复并分配到CVE-2019-11481

 

漏洞利用方案

这个漏洞让我可以通过替换~/.apport-ignore.xml的符号链接来欺骗apport加载系统上的任何文件。但是,我感兴趣的文件几乎都不会是有效的XML文件,因此它将导致解析错误,而apport将忽略它。这如何让我访问禁止的信息?

这是我巧妙的方案:

主要思路是,即使被禁止的文件将触发解析错误并被忽略,但它仍然会加载到apport的堆中。这意味着如果apport崩溃,那文件的内容也将包含在错误报告中。这是方案中事件的顺序:

  1. 1.通过启动/bin/sleep来发送使其崩溃SIGSEGV
  2. 2.启动Apport,为/bin/sleep生成错误报告。
  3. 3.在合适的时机替换~/.apport-ignore.xml符号链接,让apport将禁止的文件加载到内存中。
  4. 4.通过发送一个SIGSEGV来造成apport崩溃。
  5. 5.启动第二个apport来生成第一个apport崩溃的错误报告。
  6. 6.第二个apport为第一个apport创建一个错误报告,其中包含核心转储中禁止的文件副本。

阻碍

事情不是那么容易。我遇到了几个问题。比较明显的是,符号链接切换的时机至关重要,所以我预计很难正确做到,但也有一些意想不到的问题,我将在下面的章节中介绍。

Apport有两个缓解机制来防止其自身运行。在apport第30行的评论中注释,这是为了避免“如果发生一系列崩溃将会导致系统瘫痪”。

第一个缓解机制是一个名为/var/crash/.lock的文件锁。当apport启动时,它使用lockf在该文件上设置一个锁,以防止另一个apport同时运行。

有趣的是,lockf文件锁只是建议!实际上,正如Victor Gaydov在这篇优秀的文章中解释,锁实际上与一个[i-node, pid]相关联。这意味着如果在第一个apport设置了锁之后,用一个新文件替换/var/crash/.lock,那么第二个apport将看到一个不同的I节点,因此两个apport就可以同时持有锁`/var/crash/.lock!

用新文件替换/var/crash/.lock的技巧依赖于是否有删除或移动该文件的权限。由于/var/crash目录设置了sticky (粘滞位),这表示着必须拥有该文件。幸运的是,/var/crash可以写入,因此只要/var/crash/.lock不存在就可以创建。当我在5月29日第一次向Ubuntu提交漏洞报告时,我认为这会使漏洞无法利用。那是因为在我工作的笔记本电脑上,/var/crash/.lock几乎总是存在并且属于root。后来我发现/var/crash/.lock每天被一个计划任务:/etc/cron.daily/apport删除。锁文件经常存在于我的工作笔记本电脑上,是因为我经常故意让应用程序崩溃。但是在典型的Ubuntu系统上,由于日常计划任务,它不可能在任何给定时间存在。

在我的漏洞报告中,我建议/var/crash/.lock应始终被root拥有,以缓解这种类型的漏洞利用。虽然我自己并不认为它是一个漏洞,但是Sander Bos提交了一个关于这个问题的漏洞报告。为它分配到CVE-2019-11485,并通过更改锁文件存储的目录来修复。

第二个缓解机制是基于内核中的RLIMIT_CORERLIMIT_CORE是限制core文件(核心转储文件)大小,RLIMIT_CORE == 1用作一个特殊的值,表示该进程是一个错误报告程序,在它崩溃时不应该生成核心转储文件(以防止递归)。在此评论中,我找到了对此缓解机制的解释。

我为RLIMIT_CORE缓解机制感到幸运。原来可以使用prlimit修改另一个进程的RLIMIT_CORE!当然,需要有合适的权限才能这样做,我发现只要apport进入”totally drop privs”(完全取消特权)状态,它就会起作用。不幸的是,不可能使用prlimit来增加RLIMIT_CORE的值,但是可以将它降低到0,这就可以利用此漏洞了。

信号处理

我方案的一部分是通过发送一个SIGSEGV来破坏apport。但这不起作用,因为apport为SIGSEGV设置了一个信号处理程序:

def setup_signals():
    '''Install a signal handler for all crash-like signals, so that apport is
    not called on itself when apport crashed.'''

    signal.signal(signal.SIGILL, _log_signal_handler)
    signal.signal(signal.SIGABRT, _log_signal_handler)
    signal.signal(signal.SIGFPE, _log_signal_handler)
    signal.signal(signal.SIGSEGV, _log_signal_handler)
    signal.signal(signal.SIGPIPE, _log_signal_handler)
    signal.signal(signal.SIGBUS, _log_signal_handler)

这样做的动机是防止对自己进行递归操作。对我来说幸运的是,setup_signals设置处理程序的信号列表不够完整。signal手册的第7页部分有一个名为“Standard signals”的表。以下是一段摘录:

信号 Action 含义
SIGINT 2 Term 键盘中断
SIGQUIT 3 Core 从键盘退出
SIGILL 4 Core 非法指令

在“Action”列中包含“Core”的任何信号都将触发一个Core Dump(核心转储)。Apport的信号处理程序列表包括最常见的core-generating信号,但它还不够完整,还有好几种可供选择,我的exploit使用了SIGTRAP

 

实施漏洞攻击

我已经在GitHub上公布了我的POC(proof-of-concept)的源代码。它主要根据我上面描述的方案执行,但有一些调整来解释上面讨论的阻碍。这是修改后的方案中的事件顺序:

  1. 1.启动/bin/sleep
  2. 2.创建/var/crash/.lock,方便后面可以删除它。
  3. 3.使用SIGINT(程序终止信号)Kill掉/bin/sleep
  4. 4.启动Apport,为/bin/sleep生成错误报告。
  5. 5.在合适的时机替换~/.apport-ignore.xml符号链接,让apport将禁止的文件加载到内存中。
  6. 6.用一个新文件替换/var/crash/.lock,来绕过文件锁并使第二个apport与第一个apport同时运行。
  7. 7.使用prlimit将apport的RLIMIT_CORE设置为零。
  8. 8.通过发送一个SIGTRAP来造成apport崩溃。
  9. 9.启动第二个apport来生成第一个apport崩溃的错误报告。
  10. 10.第二个apport为第一个apport写出一个错误报告,其中包含核心转储中禁止文件的副本。

接下要讨论的是我如何计算切换符号链接的时间。最初,我认为很难这个程序的漏洞,因为 os.access 对文件的调用与打开之间存在非常短的时间间隔。但是事实证明,如果使用c语言编程,赢得与Python的比赛是非常容易的。在PoC中,发生切换时的关键时机在第155行
。我用inotify来计时。通过运行sudo strace -e file -tt -p <apport PID>,我发现一个名为expatbuilder.cpython-36.pyc的文件总是在~/.apport-ignore.xml解析之前打开。通过观察该文件上的IN_OPEN事件,我可以非常精确地设置切换时间。

当我终于能够利用这个漏洞时,我很兴奋地查看了错误报告,并在/var/crash中看到了以下内容:

kev@constellation:~$ ls -al /var/crash/
total 4492
drwxrwsrwt  2 root whoopsie   12288 Nov  5 12:26 .
drwxr-xr-x 17 root root        4096 Jul 17 19:31 ..
-rw-r-----  1 root whoopsie 4583201 Nov  5 12:26 _usr_share_apport_apport.0.crash

该文件属于root,发生了什么?我确信它将属于我,因为我的PoC直到第一个apport进入”totally drop privs”(完全取消特权)状态后才发送SIGTRAP。当apport进程在崩溃时完全归我所有,所以我应该能够查看错误报告吗?这个问题是由apport如何确定崩溃进程的所有者引起的。这发生在get_pid_info中,通过os.stat运行/proc/[pid]/stat。在源代码中分散了一些注释,例如此处此处,对此进行了解释。/proc/[pid]/stat即使是在过渡到”totally drop privs”(完全取消特权)状态之后,apport还是作为root进程启动的,因此归root所有。我还没有找到任何方法来破坏这种保护。

当我查看文件的内容时,这就是我看到的内容:

另一个好消息是该exploit非常短暂和稳定。我以为符号链接切换的时间安排可能会使它不稳定,但是我发现它每次都能完美运行。

因此,一切都不会丢失。尽管错误报告是由root拥有的,但是whoopsie依然可以读取它,这说明,如果我可以在whoopsie守护程序中找到漏洞,我也就可以读取错误报告的内容。

本文翻译自GitHub Security Lab

(完)