翻译: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 的内存块 A
、B
、C
、D
彼此相邻且处于空闲状态,空闲链表按照 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_string
或 zend_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 中的影响,我们还需要探讨最后一个问题:如果您掌握的文件读取操作是不可观测的,那将会发生什么?
预知后事如何,请听下回分解