【翻译】从设置字符集到RCE:利用 GLIBC 攻击 PHP 引擎(篇二)
朝闻道 发表于 湖北 二进制安全 832浏览 · 2024-06-17 16:02

翻译:https://www.ambionics.io/blog/iconv-cve-2024-2961-p2

介绍

几个月前,我意外地发现了一个存在于 glibc(Linux 程序的基础库)中长达 24 年的缓冲区溢出漏洞。尽管这个漏洞存在于多个广为人知的库或可执行文件中,但很少能够被实际利用——它没有提供足够的操作空间,而且需要满足一些难以达成的先决条件。尝试寻找可利用的目标大多以失败告终。然而,在 PHP 中,这个漏洞却显得格外有用,并且证明了它能够在两种不同的方式中成功利用 PHP 引擎。

在第一部分中,我通过回顾这个漏洞的发现过程、它的局限性,并展示了如何将文件读取漏洞转化为远程代码执行(RCE)。在这篇博客文章中,我将探讨一种新的在 PHP 中利用这个漏洞的方法,即通过直接调用 iconv() 函数,并以 Roundcube(一个流行的 PHP webmail 应用)为目标来展示这个漏洞。同样,我将通过揭示在使用 mbstring 扩展时意外触发 iconv() 的情况,来展示这个漏洞对整个生态系统的潜在影响。

如果你对 web 攻击、PHP 或 PHP 引擎不太熟悉,不用担心:我会在文章中逐步解释相关的概念。

另一个触发方法

虽然利用 php://filter 来触发这个漏洞非常方便,但最直接的方式是调用 iconv() 函数,通过使用它的官方 API。在 PHP 中,iconv() 函数的定义如下:

图 1:iconv()函数定义

这个函数与 C 语言中的等价函数的区别在于,原本在 C 语言中需要调用者手动进行的缓冲区管理,在 PHP 中变得自动化且对用户透明。在第一部分中,我们了解到输出缓冲区的状态对我们至关重要:在许多情况下,如果缓冲区的状态不理想,这个漏洞可能无法被成功利用。

那么,PHP 的 iconv() 函数实现是否存在漏洞?当我们使用 iconv() 将一个长度为 N 的字符串转换成另一种字符集时,PHP 会自动分配一个大小为 N+32 的输出缓冲区,目的是为了“在大多数情况下避免进行内存的重新分配”[1]。如果分配的缓冲区大小不足以容纳转换后的字符串[2],PHP 会自动增加缓冲区的大小[3]。

// ext/iconv/iconv.c

PHP_ICONV_API php_iconv_err_t php_iconv_string(const char *in_p, size_t in_len, zend_string **out, const char *out_charset, const char *in_charset)
{
    ...

    in_left= in_len;
    out_left = in_len + 32; /* Avoid realloc() most cases */ // [1]
    out_size = 0;
    bsz = out_left;
    out_buf = zend_string_alloc(bsz, 0);
    out_p = ZSTR_VAL(out_buf);

    while (in_left > 0) {
        result = iconv(cd, (ICONV_CONST char **) &in_p, &in_left, (char **) &out_p, &out_left);
        out_size = bsz - out_left;
        if (result == (size_t)(-1)) {
            if (ignore_ilseq && errno == EILSEQ) {
                if (in_left <= 1) {
                    result = 0;
                } else {
                    errno = 0;
                    in_p++;
                    in_left--;
                    continue;
                }
            }

            if (errno == E2BIG && in_left > 0) { // [2]
                /* converted string is longer than out buffer */
                bsz += in_len;

                out_buf = zend_string_extend(out_buf, bsz, 0); // [3]
                out_p = ZSTR_VAL(out_buf);
                out_p += out_size;
                out_left = bsz - out_size;
                continue;
            }
        }
        break;
    }

    ...
}

因此,输出缓冲区比输入缓冲区大32字节,使得溢出很容易触发。这里有一个 poc。总结为:

$input =
    "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" .
    "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA" .
    "AAA劄劄\n劄劄\n劄劄\n劄\n劄\n劄\n劄";
$output = iconv("UTF-8", "ISO-2022-CN-EXT", $input);

既然我们已经了解到可以通过 iconv() 函数触发这个漏洞,接下来我们可以开始寻找易受攻击的目标。先决条件是:我们能够控制输出的字符编码,并且至少能够控制输入缓冲区的一部分。那么,哪些类型的软件可能满足这些条件呢?我最初的考虑是检查电子邮件客户端,因为处理电子邮件通常涉及到编码转换。但在深入探讨攻击方法之前,我们先进一步了解一些相关的理论知识。

理论:在php 中的远程二进制漏洞利用

意外的缓解措施

尽管许多人认为 PHP 不够安全,但针对 PHP 的远程二进制漏洞攻击——至少可以说——并没有被充分记录。攻击者如何利用像我们手头这样的缓冲区溢出漏洞来破坏 PHP 引擎并实现远程代码执行?在我们深入技术细节之前,我会先解释为什么这并不像看上去那么简单。

你们应该已经从第一部分了解到 PHP 堆的工作原理。它的设计简单明了,目前还没有特别的保护措施。然而,对我来说,它主要的攻击缓解来自于一个简单的设计选择:每个堆只处理一个请求。当你向 PHP 发送 HTTP 请求时,PHP 会创建一个新的堆,解析并分配你的参数(如 GET、POST 等),编译并执行请求的脚本,返回 HTTP 响应,然后在一切结束后删除这个堆。

考虑一个典型的远程漏洞利用过程,我们可以将其大致分为三个步骤:准备、触发和利用。以使用后释放漏洞为例:你首先可能与服务器交互,以形成目标的堆,可能散布一些结构,或安排一些空闲列表。这是准备阶段。然后,你发送第二个请求,触发漏洞,使应用程序释放一些内存块,同时留下一个悬空的指针。紧接着,你发出第三个请求,用你更喜欢的内容替换缺失的内存块。第四个请求将是致命一击:利用你的类型混淆结构启动一些 ROP 链,从而利用你的漏洞。

对于 PHP,我们需要所有这些步骤在一次请求-响应交换中完成。一旦我们发送了 HTTP 参数,我们就不能与引擎进一步交互了,我们希望在收到 HTTP 响应和堆被销毁之前,准备、触发和利用步骤能够自动完成。

为了解决这个问题,人们经常寻找那些在请求过程中允许与 PHP 交互的函数。这就是为什么我几年前会针对 PHP 中与数据库相关的代码:当 PHP 发送 SQL 查询并接收结果时,它为我们提供了一种数据交换的方式,迫使 PHP 进行内存分配和释放等操作。更一般地说,像 unserialize() 这样的函数中的漏洞是理想的攻击目标,因为它允许你触发一个错误,然后创建任意的对象、字符串、数组等。

在第一部分中,当我们攻击 php://filter 时,我们面临的情况有些类似,我们可以通过使用精心挑选的过滤器和桶,在堆上执行操作,并在任意时刻通过转换为 ISO-2022-CN-EXT 来引发内存损坏。但在这种新情况下,直接调用iconv() 让我们处于一个非常棘手的位置:这个函数只允许我们触发漏洞。为了进行准备和利用,我们需要找到另一种方法。

有利的架构设计

然而,我们面对的是一个对攻击者有利的环境。PHP-FPM 和 mod PHP 为了处理 HTTP 请求,采用了主从进程架构,其中主进程控制着一些权限较低的工作进程。如果工作进程崩溃,主进程会通过分叉机制重新启动它,这带来了两个好处。首先,即使我们导致一个工作进程崩溃,它也会被自动重启,这样就不存在使服务器遭受拒绝服务攻击的风险。其次,所有工作进程的内存布局(ASLR(地址空间布局随机化), PIE(位置无关可执行文件))都是相同的:如果我们从一个工作进程中泄露了地址信息,我们可以确定这些地址的最高有效位在其他工作进程中也是相同的。

理论上的利用目标

进行远程 PHP 漏洞利用的标准做法是分两步利用同一个漏洞:首先获取信息泄露,然后执行代码。为了实现这一点,我们可以依次破坏两种数据结构:zend_strings(代表 PHP 字符串的结构)和 zend_arrays(代表 php 数组的结构)。

zend_string 结构用于表示 PHP 中的字符串。它由多个字段组成,紧随其后的是实际的字符串数据缓冲区。

图 2:zend_string 结构

在 PHP 中,字符串不是由 NULL 字节标记结束的字节序列;它们的长度由 len 属性来定义。因此,要输出一个字符串 s,PHP 会从字符串对象的内存地址加上 0x18(特定偏移量)的位置开始,输出 len 指定的长度。如果我们能够人为地增加 len 属性的值,就可以造成内存泄露。

一旦我们有了泄露的内存信息,接下来的操作就变得相对简单。我们可以轻松确定自己在内存堆中的位置,并获取指向程序主执行文件的指针。执行代码的下一步,可以通过覆盖 PHP 数组结构 zend_array 的最后一个属性来实现:

图 3:zend_array 结构

pDestructor 是一个指向负责删除数组元素的函数的指针。它通常指向 PHP 的变量销毁函数 zval_ptr_dtor。当我们改变 pDestructor 的值时,就可以在数组被销毁时通过它来控制程序的执行流程(即获取 RIP 控制权):数组及其元素被销毁时,pDestructor 指向的函数将被调用。

不过,现在我们暂时不深入理论了。

攻击Roundcube

Roundcube 很可能是最受欢迎的 PHP 编写的网页邮件服务。它经常被邮件服务提供商、网站托管公司或私营企业采用,作为一种快捷简便的电子邮件访问方式,无需使用传统的桌面邮件客户端。您可能已经在网上遇到过这样的服务:

图 4:Roundcube接口

遗憾的是,它符合我们所有的预设条件,并且让我们能够作为一般用户执行远程代码。

触发漏洞的方法

使用 Roundcube 发送电子邮件时,可以通过 _to_cc_bcc 字段来指定邮件的收件人、抄送和密送地址。鉴于大家对这些字段应该已经很熟悉,我就不多解释了,它们是用来输入一系列电子邮件地址的。

除此之外,用户还可以指定一个 _charset HTTP 参数 [1]。这时,Roundcube 会在处理这些参数之前,利用 iconv() 函数将它们转换成指定的字符集编码。简化版的相关代码如下:

# /program/include/rcmail_sendmail.php
class rcmail_sendmail
{
    public function headers_input()
    {
        ...

        // set default charset
        if (empty($this->options['charset'])) { // [1]
            $charset = rcube_utils::get_input_string('_charset', rcube_utils::INPUT_POST) ?: $this->rcmail->output->get_charset();
            $this->options['charset'] = $charset;
        }

        $charset = $this->options['charset'];

        ...

        $mailto  = $this->email_input_format(rcube_utils::get_input_string('_to', rcube_utils::INPUT_POST, true, $charset), true);
        $mailcc  = $this->email_input_format(rcube_utils::get_input_string('_cc', rcube_utils::INPUT_POST, true, $charset), true);
        $mailbcc = $this->email_input_format(rcube_utils::get_input_string('_bcc', rcube_utils::INPUT_POST, true, $charset), true);

        ...

        if (!empty($this->invalid_email)) { // [2]
            return display_error('emailformaterror', 'error', ['email' => $this->invalid_email]);
        }
    }
}

Roundcube 的rcube_utils::get_input_string()函数是一个用于获取 HTTP 参数并将其转换为$charset的简单封装器;而 email_input_format()函数则更为复杂,它负责验证电子邮件列表是否有效。实际上,如果列表中有一个电子邮件地址无效,该地址就会被复制到 $this->invalid_email,并在错误信息中显示,如:“无效的电子邮件地址:<email>” [2]。</email>

我们可以通过 _to_cc_bcc 字段来触发这个漏洞。

获取信息泄露

为了实现信息泄露,我们需要在 zen_string(目标字符串)被展示之前,覆盖其 len 字段。在我们的情况下,一个非常简单的候选目标是:如果我们发送的电子邮件地址之一无效,Roundcube 会在错误消息中展示这个地址。我们可以在 _to 字段中发送这样一个无效邮件,并将其作为目标字符串。

现在,我们所利用的基础漏洞并不是一个任意写入(write-what-where)漏洞,甚至不是任意溢出。它最多只能写入 3 个字节到边界外。如果我们直接向 zen_string 溢出,我们唯一能覆盖的就是它的引用计数(refcount)。显然,我们不能直接利用这个漏洞来实现我们的目的。相反,我们可以利用一个 1 字节的溢出到一个已释放内存块指针(free chunk pointer),类似于第一部分中使用的技术,以此来移动它,并使一个内存块与目标字符串重叠,从而允许我们覆盖它的头部信息。

虽然这一切在理论上是可行的,但我们面临着每个请求对应一个堆(1-heap-per-request)的缓解措施。我们如何在漏洞触发前塑造堆?一旦我们更改了释放列表指针(free list pointer)的最低有效位(LSB),我们如何让 PHP 分配更多的内存块,以覆盖目标字符串的头部?

堆塑形基础

通过使用 GET、POST 和 cookies,我们可以强制 PHP 分配任意长度的字符串。每次你发送一个键值对,如 key=value,PHP 就会为键分配一个 zen_string,并为值分配两个。此外,你可以通过发送新的键值对来让 PHP 释放内存块:例如,发送 key=value&key=other-value 会让 PHP 分配 key,然后 value 两次,接着 other-value 两次,最后释放两个 value 字符串。例如,要填充一个页面,使其包含大小为 0x400 的内存块,并使第三个内存块被释放,你可以使用以下组合(一个大小为 N 的 zen_string 占用 N+0x19 字节的存储空间):

# 设想我们有一个页面,其中包含四个未分配的0x400块: C1 C2 C3 C4
# 使用一个“标准”的空闲列表 C1→C2→C3→C4
 a=AA...AAAAA (0x3e7 times)  # 在C1和C2中分配两个0x400块
&b=BB...BBBBB (0x3e7 times)  # 在C3和C4分配两个额外的
&b=                          #释放C3,然后是C4
&CC...CCCCC (0x3e7 times)=   # 分配C4

通过 HTTP 参数,我们可以在堆创建之后,根据我们的需要来调整堆的布局。尽管这听起来很不错,但这种方法并非无懈可击:现代 PHP 应用程序在编译和执行过程中,会进行成千上万次的堆操作,这可能会彻底打乱我们的布局计划。想象一下整个过程:解析代码、注释、字符串、对象,将代码编译成 PHP 虚拟机指令,然后执行这些指令,进行数据操作,进出函数等。如果可能的话,最佳策略是尽量在应用程序较少使用的内存块大小上进行攻击,以减少程序对我们布局的干扰。

代码片段

既然我们能够影响堆的结构,我们就可以构建一个五步流程来实现信息泄露:

图 5:对大小为 0x100 的块的利用步骤

我们首先调整堆的布局,使得 4 个大小为 0x100 的内存块 ABCD 彼此相邻且处于空闲状态,空闲链表按照 D→A→B→C 的顺序排列([1])。

在让 PHP 将存储无效电子邮件地址的 zend_string 写入地址 0x7fff11a33300(D)([2])之后,我们从地址 0x7fff11a33000(A)的内存块溢出,改写指向 0x7fff11a33200(C)的指针的最低有效位,使其变为 0x7fff11a33248[3])。漏洞被触发后,空闲链表变为 A→B→C+48h[4])。然后,通过再进行 3 次内存分配,我们分配了一个与目标字符串重叠的内存块([5]),这让我们得以覆盖目标字符串的 zend_string 头部,尤其是其 len 字段。

我们已经知道如何完成前期的设置(步骤[1]、[2])和如何触发这个漏洞(步骤[3]、[4])。不过,我们还有一个步骤需要解决:如何在破坏了空闲链表之后分配内存块?在程序执行到这一点时,脚本处于独立运行状态。唯一能够促使 PHP 进行内存分配的就是脚本本身。因此,为了完成我们所需的内存分配,我们需要考虑如何让 PHP 应用程序帮我们完成这一任务。

让我们再次审视目标函数:

# /program/include/rcmail_sendmail.php
class rcmail_sendmail
{
    public function headers_input()
    {
        ...

        $mailto  = $this->email_input_format(rcube_utils::get_input_string('_to', rcube_utils::INPUT_POST, true, $charset), true);
        $mailcc  = $this->email_input_format(rcube_utils::get_input_string('_cc', rcube_utils::INPUT_POST, true, $charset), true); // [1]
        $mailbcc = $this->email_input_format(rcube_utils::get_input_string('_bcc', rcube_utils::INPUT_POST, true, $charset), true);

        ...

        if (!empty($this->invalid_email)) {
            return display_error('emailformaterror', 'error', ['email' => $this->invalid_email]); // [2]
        }
    }
}

假设我们使用 _to设置一个无效的电子邮件,然后用 _cc 触发这个故障,我们可以使用 [1][2]之间发生的任何事情来分配我们的块。让我们再看一下email_input_format()(再次大大简化):

# /program/include/rcmail_sendmail.php
class rcmail_sendmail
{
    /**
     * Parse and cleanup email address input (and count addresses)
     *
     * @param string $mailto Address input
     * @param bool   $count  Do count recipients (count saved in $this->parse_data['RECIPIENT_COUNT'])
     * @param bool   $check  Validate addresses (errors saved in $this->parse_data['INVALID_EMAIL'])
     *
     * @return string Canonical recipients string (comma separated)
     */
    public function email_input_format($mailto, $count = false, $check = true)
    {
        ...

        $emails  = rcube_utils::explode_quoted_string("[,;]", $mailto); // [1]

        foreach($emails as $email) {
            if(!is_valid_email($email)) {
                $this->invalid_email = $email;
                return "";
            }
        }

        return implode(", ", $emails);
    }

这个方法将 $mailto,即电子邮件的列表,分解为数组 [1]。这是迫使 PHP 进行内存块分配的绝佳途径!

我们现在已经制定出了一个完整的策略:

  • 利用 HTTP 参数调整堆结构(步骤 1)
  • 通过 _to 字段发送一个无效的电子邮件,从而设置 $this->invalid_email(步骤 2)
  • 利用 _cc 字段触发漏洞,进而修改空闲链表(步骤 3 和 4)
  • 使用 _bcc 字段迫使 PHP 分配字符串,覆盖无效电子邮件的地址长度(步骤 5)

一旦错误信息显示出来,就会发生内存泄露。

在开发了一个漏洞利用程序后,我成功让 Roundcube 显示出一个包含我修改过的电子邮件地址的错误信息("Adresse courriel invalide" 是法语,意思是 "Invalid email address"),但效果却有些令人失望。

图 6:JSON编码的错误信息

错误信息中仅包含空格、Unicode 转义的零字节和 ASCII 字符。这是怎么回事?实际上,Roundcube 以 JSON 格式展示错误信息。它采用 json_encode()函数并启用了 JSON_INVALID_UTF8_IGNORE 标志来执行编码。这意味着所有无效的 UTF-8 字符都会被忽略。由于内存中的大多数数据都不是有效的 UTF-8,因此我们的信息泄露并没有揭露任何有价值的内容。

修订后的信息泄露策略

虽然我们选择的目标字符串并没有带来预期的丰富信息,但我们的基本策略是正确的。现在,我们需要寻找一个在展示时尽可能保持原样或改动较少的变量。

与大多数网络应用一样,Roundcube 在程序执行的后期阶段才进行输出格式化,远在我们触发漏洞之后。显然,这是大多数潜在的目标字符串被分配的时机。因此,我们需要调整我们的漏洞利用策略。我们依然计划使用 4 个内存块,并依旧通过移动 C 块使其与包含目标字符串的 D 块重叠。但这一次,我们会在目标字符串被分配之前触发溢出。

图 6:更好的利用步骤(针对大小为0x800的块)

之前的空闲链表顺序是 D→A→B→C。现在,我们需要调整为 A→D→B→C(见 [1])。接着,我们可以从 A 溢出到 B(见图 2),形成 A→D→B→C'的新链表(见[3]),此时C'会与 D 重叠。

我们无法像上一节那样轻松地分配内存块,因为现在的 explode_quoted_string()函数会在目标字符串分配前执行。此外,我们不能简单地分配 3 个内存块:由于当前的空闲链表是A→D→B→C',我们需要让 PHP 先分配一个内存块,然后是目标字符串(见图[4]),最后再分配另外两个内存块(见 [5])。

让我们一步步执行我们的策略。首先,我们使用 _to字段来执行溢出操作,完成第二步。为了在A 处强制分配内存,我们将使用 _bcc字段,这样做可以使得 email_input_format() 函数返回一个恰好适合 0x800 大小内存块的字符串。随后,空闲链表变为D→B→C'

现在,我们需要解决一个难题:找到合适的目标字符串。我仔细检查了负责显示错误消息的所有调用栈,并最终定位到了 rcmail_output_html::get_js_commands() 函数中:

# program/include/rcmail_output_html.php    
class rcmail_output_html extends rcmail_output
{
    protected function get_js_commands(&$framed = null)
    {
        $out             = '';
        $parent_commands = 0;
        $parent_prefix   = '';
        $top_commands    = [];

        // these should be always on top,
        // e.g. hide_message() below depends on env.framed
        if (!$this->framed && !empty($this->js_env)) {
            $top_commands[] = ['set_env', $this->js_env];
        }
        if (!empty($this->js_labels)) {
            $top_commands[] = ['add_label', $this->js_labels];
        }

        // unlock interface after iframe load
        $unlock = isset($_REQUEST['_unlock']) ? preg_replace('/[^a-z0-9]/i', '', $_REQUEST['_unlock']) : 0;
        if ($this->framed) {
            $top_commands[] = ['iframe_loaded', $unlock];
        }
        else if ($unlock) {
            $top_commands[] = ['hide_message', $unlock];
        }

        $commands = array_merge($top_commands, $this->js_commands);

        foreach ($commands as $i => $args) {
            $method = array_shift($args);
            $parent = $this->framed || preg_match('/^parent\./', $method);

            foreach ($args as $i => $arg) {
                $args[$i] = self::json_serialize($arg, $this->devel_mode);
            }

            if ($parent) {
                $parent_commands++;
                $method        = preg_replace('/^parent\./', '', $method);
                $parent_prefix = 'if (window.parent && parent.' . self::JS_OBJECT_NAME . ') parent.';
                $method        = $parent_prefix . self::JS_OBJECT_NAME . '.' . $method;
            }
            else {
                $method = self::JS_OBJECT_NAME . '.' . $method;
            }

            $out .= sprintf("%s(%s);\n", $method, implode(',', $args));
        }

        $framed = $parent_prefix && $parent_commands == count($commands);

        // make the output more compact if all commands go to parent window
        if ($framed) {
            $out = "if (window.parent && parent." . self::JS_OBJECT_NAME . ") {\n"
                . str_replace($parent_prefix, "\tparent.", $out)
                . "}\n";
        }

        return $out;
    }
}

这种方法生成的 JavaScript 代码将直接体现在 HTTP 响应中。

它的复杂性相当高,这在某种程度上是有利的:因为返回值$out 迟早会被展示出来,所有连接到它的变量都可能成为我们的目标字符串。此外,这里的每行代码都可能执行内存分配、释放或重新分配的操作……这为我们在第五步中覆盖字符串头部提供了可能。因此,每一行代码都相当于一个工具,它可能对我们的目标有所帮助,也可能没有。

遗憾的是,这里没有像 explode_quoted_string() 那样简单的工具可供我们使用:我们需要更加机智。

寻找目标

让我们通过剔除那些我们不会进入的条件来简化代码:

01: protected function get_js_commands()
02: {
03:     $out             = '';
04:     $top_commands    = [];
05: 
06:     // unlock interface after iframe load
07:     $unlock = isset($_REQUEST['_unlock']) ? preg_replace('/[^a-z0-9]/i', '', $_REQUEST['_unlock']) : 0;
08:     $top_commands[] = ['iframe_loaded', $unlock];
09: 
10:     $commands = array_merge($top_commands, $this->js_commands);
11: 
12:     foreach ($commands as $i => $args) {
13:         $method = array_shift($args);
14: 
15:         foreach ($args as $i => $arg) {
16:             $args[$i] = self::json_serialize($arg, $this->devel_mode); // [1]
17:         }
18: 
19:         $method = 'if (window.parent && parent.rcmail) parent.rcmail.' . $method;
20:         $out .= sprintf("%s(%s);\n", $method, implode(',', $args)); // [2]
21:     }
22: 
23:     $out = "if (window.parent && parent.rcmail) {\n"
24:          . str_replace('if (window.parent && parent.rcmail) parent.rcmail.', "\tparent.", $out)
25:          . "}\n";
26: 
27:     return $out;
28: }

在我们的案例中,当代码执行到 get_js_commands()时, $this->js_commands是一个包含单个元素的数组,这个元素是一个有两项的数组:["display_message", "Addresse courriel invalide: <email-we-sent>"]。因此,$commands数组由两个元素组成:

[
    ["hide_message", "<unlock-value>"],
    ["display_message", "Addresse courriel invalide: <invalid-email>"],
]

每一行代码都被用来逐步构建 $out变量中的 JavaScript 代码片段,然后再返回这个变量。在第 12 至 21 行的循环迭代中,我们可以控制 $args[0],它会被 JSON 序列化,然后通过 sprintf()函数进行格式化。我们逐一分析两次迭代。在进入foreach()之前,空闲链表的顺序是D→B→C',下一个分配的内存块应该是我们的目标字符串。

如果我们为 HTTP 参数 _unlock设置一个大小为 0x6a1,那么循环的第一次迭代将使$out的大小超过 0x700 字节,因此它会被分配到 D 块中。接下来,我们需要再进行两次分配来覆盖 $out 的长度信息,这需要在循环的最后一次迭代中完成。

为了实现这个目标,我们将 invalid_email设置为 0x63c 个 ASCII 字节,后面紧跟着 0x37 个空字节。当错误消息进行 JSON 序列化时 [2],由于 $args[0]中包含的空字节,其大小会大幅增加:每个空字节都会被转换成它的 Unicode 转义形式 \u0000。这样,经过 JSON 编码后的 $args[0] 大小约为 0x786 字节,因此被分配到 B 块中。随后,sprintf() 函数调用又增加了一些字节,导致C' 块分配了一个新的大小为 0x800 的内存块。此时,我们已经成功覆盖了D 块的头部信息:$out 的大小在再次被修改之前已经大幅增加,即将与 sprintf()的结果进行拼接 [2]!

最终,我们得到了非常期望的信息泄露:

图 7:成功的内存泄露

请注意:sprintf() 函数会分配一个固定大小为 0x800 字节的内存块来存放格式化后的字符串,这限制了我们的攻击目标必须针对这一特定大小的内存块。

两条途径

通过精心配置内存堆,我们能够同时泄露指向 PHP 程序本身的指针以及接近我们目标字符串位置的指针。这样,地址空间布局随机化(ASLR)和位置无关可执行文件(PIE)的安全机制就变得无效了。此外,我们已经掌握了破坏空闲链表的方法,因此我们可以在内存堆的任何位置分配一个内存块。但游戏尚未结束。在这一点上,我们有两个方向可以选择。

第一个方向是对我们工作的自然延续:利用二进制文件的漏洞来执行代码。这通常包括转储程序的一部分以寻找有用的代码位置,然后构建一个返回导向编程(ROP)链。第二个方向是执行一种只涉及数据的攻击。两种方法各有利弊。尽管我在 OffensiveCon 上展示了一个二进制漏洞的利用,但我发现仅涉及数据的攻击更为优雅,因此我将展示这种方法。它是深入探索 PHP 引擎内部工作的一个好途径。

数据攻击的优势

与基于二进制的攻击相比,数据攻击的优势在于我们不依赖于机器代码。相反,我们利用底层漏洞来破坏 PHP 变量并改变脚本的执行流程(一个简单的例子可能是将一个假想的 $is_admin 标志设置为 true,以提升我们的权限)。

虽然我们可以构建相当复杂的数据结构,但我们只能引用内存堆中的地址。因此,并非所有变量都可以被覆盖:我们可以针对基本数据类型(布尔值、整数、字符串)和数组,但不能针对对象,因为表示对象的结构体 zend_object 包含了指向主程序的指针(我们对此知之甚少)。

理想目标是存储在 $_SESSION 数组中的会话变量,原因有几个。首先,它们在漏洞利用后仍然存在(只要不导致程序崩溃)。其次,它们在脚本执行结束时被保存,为我们提供了修改它们的时间窗口。第三,从攻击者的角度来看,它们通常具有吸引力:谁没有梦想过能够改变自己的角色为超级管理员呢?

在 Roundcube 中,虽然没有角色概念。但在浏览代码时,我们实际上可以找到更好的目标:

# program/lib/Roundcube/rcube_user.php
class rcube_user
{
    function get_prefs()
    {
        if ($_SESSION['preferences_time'] < time() - 5 * 60) {
            $saved_prefs = unserialize($_SESSION['preferences']); // <-----------
            $this->rc->session->remove('preferences');
            $this->rc->session->remove('preferences_time');
            $this->save_prefs($saved_prefs);
        }
        ...
    }
}

PHP 反序列化函数 unserialize() 的调用!通常,能够执行反序列化操作在大型框架中意味着可以实现远程代码执行,Roundcube 也不例外。Roundcube 使用了一个名为 Guzzle 的流行库来执行 HTTP 请求。通过 PHPGGC,我们可以生成一个适用于 Guzzle/fw1 的攻击载荷,将反序列化过程转化为任意文件写入操作。

在正常使用中,用户无法修改 $_SESSION['preferences']。然而,我们并非普通用户:我们能够在内存堆上进行写操作!因此,我们可以让两个内存块重叠,覆盖会话变量的 zend_string

但这里又出现了一个问题:在默认配置下,$_SESSION['preferences'] 从未被设置过。没有内容可以覆盖!不过,我们并未陷入绝境:我们可以更深入地利用任意内存分配的能力,向 $_SESSION 数组中添加一个元素。我们该如何操作呢?

我们需要深入了解 PHP 数组的实现机制。

PHP 数组

PHP 数组由键值对组成,其中值可以是任何类型,但键只能是整数或字符串。在这里,我们只讨论字符串类型的键。

每个键值对都存储在一个名为 Bucket 的结构中:

图 8:Bucket结构

在 PHP 数组中,第一个元素val 可以是一个基本值(如长整型或浮点型)或者是一个指向值的指针(如指向 zend_stringzend_object 的指针)。type 字段定义了变量的类型。next 字段指示链表中下一个 Bucket 的索引(稍后将详细介绍)。键存储在 Bucket.key 中,而其 DJBX33A 哈希值存储在 Bucket.h 中。

创建一个非空数组时,PHP 会分配一个 zend_array 结构,并在其后分配一个包含 uint32_t 类型值列表的内存块,即哈希表,紧接着是 8 个 Bucket。

zend_array.arData 指向一个复合结构,称为蝴蝶表:这个结构的底部是 Bucket 列表,顶部是哈希表。哈希表可以将 zend_string的哈希值转换为 Bucket 列表中的索引:当尝试访问某个键对应的值时,PHP 会将哈希表掩码(zend_array.nTableMask)与键的哈希值(zend_string.h)进行按位或(OR)操作,得到一个负的 int32_t 值,该值将作为从 arData 指针起始的索引。

图 9:访问哈希图

在上面的例子中,我们在一个包含8个元素的数组中寻找preferences。索引等于(int32_t) (0xfffffff0h | 0xc0c1e3149808db17) = 0xfffffff7 = -9。通过从哈希图的末尾开始挑选第九个元素,我们得到了4(以蓝色表示)。因此,PHP检查第五个bucket。

图 10:一个bucket及其键值对。键是preferences,值是序列化字符串。

为了确认我们找到的确实是正确的 Bucket(因为不同的字符串可能产生相同的哈希值,或者哈希值可能指向同一个索引),PHP 会对比 Bucket 的哈希值与给定键的哈希值。如果它们相同(在我们的示例中,它们是相同的),PHP 接着会比较键的长度和值。如果这些也都相同(在我们的示例中,它们也是相同的),那么我们找到的就是正确的 Bucket,PHP 就会返回相应的值。如果预期的键与当前 Bucket 的键不匹配,PHP 会根据 next 指针(在我们的例子中用蓝色标出的 5)所指示的索引,继续查找下一个 Bucket,直到找到匹配的键。如果遇到特殊值FF FF FF FF,则表示没有更多的 Bucket 可查找。

覆盖会话数组

因此,我们只需要覆盖哈希表和任意一个 Bucket,就能在数组中添加一个新的键值对。

记住,我们的基本漏洞允许我们修改空闲列表的指针,从而在堆的任意位置分配一个 zend_string(或者任何其他类型的对象,但 zend_strings 在这里最有用),也就是说我们可以强制 PHP 分配任意大小的内存块。

会话数组包含 32 个元素,因此它的哈希表和 Bucket 块的大小是 0x500(哈希表占 0x100,Buckets 占 0x400)。我们希望我们的伪造堆指针正好指向这个块的上方。

实现这一点相对简单,但我们还需要采取一些预防措施。首先,我们不能随意指定位置:当 PHP 分配我们指定的任意内存块时,它会认为这真的是一个空闲的内存块(PHP 真是天真)。因此,它会期望这 8 个字节是指向下一个空闲块的指针。假如这不是一个有效的指针,如果 PHP 再次进行相同大小的内存分配,我们就会遭遇崩溃。此外,当我们的伪造内存块被释放后,它可能会被再次分配,并包含我们无法控制的数据。我们需要确保它不会被重新分配;否则,它可能会彻底破坏我们的计划。

为解决第一个问题,我们可以利用 HTTP 参数创建一个由 0x500 大小的块组成的网络,这些块用空字节填充,并在它们之间留有一个空隙,希望 PHP 能在这些块中的某两个之间分配我们的哈希表和 Bucket 块。如果我们指向这样一个块,PHP 会读取到一个空指针,认为空闲列表已经用尽,从而避免了崩溃。

为解决第二个问题,一旦我们分配了任意的内存块,我们也会同时分配许多大小为 0x500 的内存块。当这些内存块被释放后,它们将按照顺序排在我们的伪造内存块之前,保护它不被再次分配。

就这样,我们利用二进制漏洞,修改了会话内容,并将 preference 设置为任意字符串。然后我们发出一个 HTTP 请求到首页,在那里 $_SESSION['preferences'] 会被反序列化。利用 guzzle/fw1 负载,我们就能在public_html 目录下写入一个名为 shell.php 的文件。

演示

这是一个针对 PHP 8.3 下的 Roundcube 1.6.6 的演示。

https://www.ambionics.io/images/iconv-cve-2024-2961-p2/demo.mp4

此漏洞利用代码可在此处获取。像往常一样,它包含了注释,并揭示了我在博客文章中未包括的漏洞利用部分。

对生态系统的影响

直接调用iconv()不仅可以被利用,还会对系统产生影响。但这是否是 CVE-2024-2961 影响的唯一 PHP 函数呢?远非如此。

首先,iconv() 有很多类似的函数,比如 iconv_strrpos()iconv_substr()等,它们可能同样存在漏洞(尽管我尚未验证)。但还有一个更令人担忧且出人意料的安全漏洞点。

PHP 有一个广受欢迎的扩展名为 mbstring,它用 C 语言编写,允许用户在不同的字符集下操作字符串,并执行字符集转换。它是许多框架和内容管理系统的依赖项。

然而,mbstring 扩展并不是默认安装的。如果你想使用依赖 mbstring 的库或框架,但又没有安装它(通常需要超级用户权限),那该怎么办呢?在这种情况下,你可以使用库的 PHP 实现版本。一个名为 symfony/polyfill-mbstring 的项目,提供了与 mbstring 完全一致的 API,两者可以互相替代使用。它非常流行,安装量超过 8.23 亿次。

但 polyfill-mbstring 如何在不使用 mbstring 的情况下完成字符集之间的转换工作呢?它使用的是 iconv() 函数。

因此,你或许以为自己在使用 mbstring 扩展,认为自己没有漏洞风险,但实际上你可能使用的是 PHP 自身实现的 polyfill 版本,而这个版本正是使用了 iconv() 函数。

随着 PHP 包管理器 composer 的普及,人们可能在不知不觉中就跨越了使用界限。如果你安装了两个项目,一个依赖于 ext-mbstring(原始的 C 扩展),另一个依赖于 polyfill-mbstring(PHP 的等效实现),不论是否安装了 mbstring 扩展,安装过程都会成功。

当你运行我提供的 POC时,如果这次使用 mb_convert_encoding()函数替代iconv()

- $output = iconv("UTF-8", "ISO-2022-CN-EXT", $input);
+ $output = mb_convert_encoding($input, "ISO-2022-CN-EXT", "UTF-8");

您同样可能会遇到系统崩溃的情况。

尽管我在这里暂停了我的分析工作——寻找目标实在是一件耗时的事情——我依然满怀希望地认为,这不会是我们最后一次目睹 CVE-2024-2961 与 PHP 相关的影响。

结论

继我们利用 PHP 过滤器之后,现在我们通过直接调用 iconv() 函数,利用 CVE-2024-2961 进一步破坏了著名的 webmail 应用 Roundcube。这带我们深入了解了 PHP 引擎的内部工作机制,并为未来可能的新漏洞利用指明了方向,无论是利用显而易见的漏洞点,还是那些不那么显眼的。

现在我们已经通过两种不同的方式展示了在 PHP 中的影响,我们还需要探讨最后一个问题:如果您掌握的文件读取操作是不可观测的,那将会发生什么?

预知后事如何,请听下回分解

0 条评论
某人
表情
可输入 255