5月27日国外安全研究员披露了GLIBC库(Linux 程序的基础库)中iconv()函数一个存在了 24 年的缓冲区溢出漏洞(CVE-2024-2961),并在博客(https://www.ambionics.io/blog/iconv-cve-2024-2961-p1)中公开了该漏洞应用在php程序中将php://filter任意文件读取提升为RCE的详细细节,稳定pwn了近十年的php版本。对CVE-2024-2961的研究共分为三部分,目前原作者公开了前两个部分。本文全文翻译其第二部分,第一部分在我自己的文章中对其中的重点内容进行了翻译,感兴趣的师傅们可以看看:https://mp.weixin.qq.com/s/hZ9yaa2exQC5hr4OKWNUsw。
英文原文地址:https://www.ambionics.io/blog/iconv-cve-2024-2961-p2
发表时间:2024年6月17日
引言
几个月前,我偶然发现了 glibc(Linux 程序的基础库)中一个已有24 年历史的缓冲区溢出漏洞。尽管在多个知名库或可执行文件中都存在该漏洞,但事实证明它很少能被利用 — 因为它没有提供太多的回旋余地,它需要苛刻的前提条件。寻找目标往往带来失望。然而在 PHP 上,这个漏洞大放异彩,并被证明可以通过两种不同的方式利用其引擎。
在第一部分中,我通过叙述漏洞的发现及其限制条件介绍了该漏洞,并通过将文件读取漏洞提升为 RCE 演示了它在 PHP 上的应用。在这篇博客中,我将探索该漏洞在 PHP 上一种新的利用方法,即直接调用iconv()
,并通过针对Roundcube(一个流行的 PHP webmail)来阐明该漏洞。同样,我将通过使用mbstring(https://www.php.net/manual/en/intro.mbstring.php) 时意外访问 iconv() 的例子来说明该漏洞对生态的影响。
如果您不熟悉 Web 开发、PHP 或 PHP 引擎,请不要担心:我将会解释相关概念。
-
引言
-
另一个触发器
-
PHP远程二进制漏洞利用(理论)
-
自然而然的缓解
-
鼓舞人心的架构
-
理想的利用目标
-
攻击 Roundcube
-
找到漏洞
-
获取泄漏
-
堆调整 101
-
代码小工具
-
修改泄漏策略
-
寻找目标
-
两种方法
-
纯数据攻击
-
PHP 数组
-
覆盖session数组
-
演示
-
对生态的影响
-
结论
另一个触发器
虽然使用 php://filter
触发漏洞非常方便,但调用 iconv() 最显而易见的方式是使用它的同名 API。在 PHP 中,它具有以下原型:
此函数与其 C 等效函数之间的区别在于缓冲区管理(在 C 中必须由调用者完成)。但在这里并不可见,因为它由 PHP 在后台处理。在第 1 部分中,我们了解到我们非常依赖输出缓冲区的大小:在许多情况下,该漏洞很可能无法利用。
那么,PHP 的iconv()
实现是否存在漏洞?当我们使用 iconv()
将大小为N的字符串转换为另一个字符集时,PHP 会分配一个大小为N+32的输出缓冲区,以期“在大多数情况下避免 realloc()” [1]。如果缓冲区不够大[2],则将其变得更大[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 可在此处(https://github.com/ambionics/cnext-exploits/blob/main/pocs/poc.php)找到。它归结为:
$input =
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
"AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"
"AAA劄劄n劄劄n劄劄n劄n劄n劄n劄";
$output = iconv("UTF-8", "ISO-2022-CN-EXT", $input);
现在我们知道可以使用iconv()
触发该漏洞,我们可以寻找目标了。同样,前提条件如下:我们需要能控制输出字符集,以及至少部分输入缓冲区。那么,什么类型的软件可以满足这些条件?我最初的想法是电子邮件客户端,因为电子邮件意味着编码。但在深入了解攻击细节之前,让我们先了解一些理论。
PHP远程二进制漏洞利用(理论)
自然而然的缓解
虽然许多人认为 PHP 不安全,但针对 PHP 的远程二进制漏洞利用至少没有得到充分记录。攻击者如何利用我们已有的缓冲区溢出漏洞来破坏 PHP 引擎并实现远程代码执行?在我们开始之前,我将向您展示为什么这并不像看起来那么容易。
从第 1 部分开始,您应该对 PHP 堆的工作原理有了大致的了解。它的设计很简单,而且没有受到任何保护[至今还没有?(https://github.com/php/php-src/issues/14083)]。然而,对我来说,它主要的攻击缓解来自一个简单的(可能与安全无关的)设计选择:一个堆只处理一个请求。当您向 PHP 发送一个HTTP 请求时,它将创建一个新的堆来解析和分配您的参数(GET、POST等),编译并运行请求的脚本,返回 HTTP 响应。在所有操作都完成后,删除堆。
想想您的远程利用标准流程。我们可以粗略地将其分为三个步骤:设置,触发和使用。以释放后使用为例:首先,您可能与服务器进行交互以构造目标堆,可能喷射一些结构,或编排一些空闲列表。这是设置。然后,您会发送第二个请求,触发漏洞,并使应用程序释放一些块,同时留下一个悬空的指针。紧接着,您会多发出一根“稻草”,用您更喜欢的东西替换释放的块。第四个请求会是“棺材上的钉子”:利用您的类型混淆结构来发挥您的优势,并启动一些 ROP 链,从而使用您的漏洞。
在PHP上,我们需要在一次请求-响应交换过程中完成所有这些步骤。然而在发送 HTTP 参数后,我们就束手无策了:没有更多方法可以与引擎交互,我们希望在获得 HTTP 响应之前自行完成设置、触发和使用,并销毁堆。
为了规避这个问题,人们经常针对请求运行时允许您与 PHP 交互的函数进行设计。这就是几年前我针对 PHP 中与数据库相关的代码的原因:当 PHP 发送 SQL 查询并接收结果时,它为我们提供了一种交换数据、强制 PHP 进行分配、释放等的方法。更常见地,诸如unserialize()
等函数中的漏洞构成了一个理想的目标,因为它可以让你触发漏洞,然后创建任意对象、字符串、数组……
在第 1 部分中,当攻击php://filter
时,我们遇到了类似的情况,我们可以使用精心挑选的过滤器和buckets在堆上执行操作,并通过在任意时刻(将字符集)转换为ISO-2022-CN-EXT引发内存损坏。但在直接调用iconv()
这种新情况下,我们处于一个非常令人不安的境地:该函数只会让我们触发漏洞。要执行设置并使用它,我们需要另一种方法。
鼓舞人心的架构
然而,我们已做好了准备。环境易于攻击:为了处理 HTTP 请求,PHP-FPM 和 mod PHP 具有主/工作器架构,即根进程控制一些权限较低的工作器。每当一个工作器消亡时,主进程都会通过分叉重新启动它。这有双重优势。首先,如果我们以某种方式使一个工作器崩溃,它会被重新生成。不存在对服务器进行 DOS 攻击的风险。其次,内存布局(ASLR、PIE)在所有工作器上是相同的:如果从一个工作器那里泄漏了地址,我们可以保证它们的 MSB 与其他工作器的地址相同。
理想的利用目标
因此,进行远程 PHP 利用的标准方法是使用该漏洞两次:一次是获取内存泄漏,然后执行代码。为了实现这一点,我们需要依次破坏两个结构:zend_string
s 和zend_array
s。
zend_string
结构表示一个 PHP 字符串。它由几个字段和一个缓冲区组成。
在 PHP 中,字符串不是以 NULL 终止的字节集合;len
字段定义其大小。因此,要显示字符串s
,PHP 会显示从 s+18h
开始的len
个字节。如果我们设法人为地增加此字段的值,则可能会得到内存泄漏。
有了泄漏的内存,事情就变得简单了。我们可以轻松地定位自己在堆中的位置,并获取指向主二进制文件的指针。下一步,执行代码,可以通过覆盖zend_array
结构(代表一个 PHP 数组)的最后一个字段来完成:
pDestructor
是一个指向负责从数组中删除元素的函数的指针。它通常指向zval_ptr_dtor
,PHP 的变量销毁函数(https://www.phpinternalsbook.com/php5/zvals/memory_management.html#managing-the-refcount-and-zval-destruction)。更改其值允许我们获得 RIP:当数组被删除时,其元素也会被删除,因此pDestructor
会被调用。
现在理论已经足够了。
攻击 Roundcube
Roundcube(https://roundcube.net/)可能是最流行的 PHP webmail。它经常被邮件提供商、网络托管商或私人公司用作一种无需桌面客户端即可快速轻松地访问邮件的方式。您可能早已在网上见过了:
可悲的是,它符合我们的每一个前置条件,并让我们以标准用户的身份实现远程代码执行。
找到漏洞
使用 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]);
}
}
}
rcube_utils::get_input_string()
是一个用于获取 HTTP 参数并将其转换为$charset
的简单包装器,email_input_format()
是一个用于验证电子邮件地址列表是否有效的复杂函数。如果提供的邮件地址有一个是无效的,它将被复制到$this->invalid_email
,并显示在错误消息中,像这样:Invalid email address: <email>
[2]。
我们可以使用_to
、_cc
或 _bcc
来触发漏洞。
获取泄漏
为了获取内存泄漏,我们需要在显示之前覆盖zend_string
的len
字段。我们将此字符串称为目标字符串。在我们的例子中,我们有一个非常简单的候选:如果我们发送的电子邮件地址中有一个无效,Roundcube 将显示一条包含该邮件地址的错误消息。我们可以在 _to
中发送这样的邮件地址,并将其用作目标字符串!
现在,我们的原语远非“写入任意位置”,甚至不是任意溢出。它最多写入 3 个越界字节。如果我们直接溢出到zend_string
,我们唯一可以覆盖的就是它的refcount
。显然,我们不能直接利用这个漏洞来做我们想做的事情。相反,我们可以利用 1个字节溢出到一个空闲块指针,类似于第 1 部分中使用的技术,以便将其替换,并使一个块与目标字符串重叠,从而允许我们覆盖其头部。
虽然从理论上讲,这一切都是可行的,但我们面临的是一个请求一个堆的缓解措施。我们如何在漏洞触发之前调整堆?一旦我们改变了空闲列表指针的 LSB(最低有效位),我们如何让 PHP 分配更多块,以覆盖目标字符串的头部?
堆调整 101
使用 GET、POST 和 cookies,可以强制 PHP 分配任意长度的字符串。每次发送一个键值对(如key=value
)时,PHP 都会分配一个zend_string
来存储键,分配两个zend_string
来存储值。此外,您可以通过发送键的新值来让 PHP 释放块:key=value&key=other-value
将导致 PHP 分配key
一次,分配value
两次,然后分配other-value
两次,最后释放这两个value
字符串。举个例子,要用大小为0x400的块填充一个页,并释放第三个块,您可以使用以下组合(大小为N 的zend_string
存储在N+0x19字节上):
# Imagining that we have a page of four unallocated 0x400 chunks: C1 C2 C3 C4
# With a "standard" free list of C1→C2→C3→C4
a=AA...AAAAA (0x3e7 times) # Allocates two 0x400 chunks in C1 and C2
&b=BB...BBBBB (0x3e7 times) # Allocates two more in C3 and C4
&b= # Frees C3, then C4
&CC...CCCCC (0x3e7 times)= # Allocates C4
因此,使用 HTTP 参数,我们可以在创建堆后立即将其调整为我们所想要的样子。虽然这很好,但并不完美:现代 PHP 应用程序在编译和运行时将执行数千个堆操作,从而完全扰乱我们的工作。想象一下整个过程:解析代码、注释、字符串、对象、将代码编译为 PHP VM 指令,然后运行它们、操作数据、进入和退出函数等。最好的计划(如果可以的话)是尝试攻击应用程序较少使用的块大小,以便程序不会过多地弄乱您的设置。
代码小工具
现在我们可以影响堆的外部构造,我们可以构建一个五步流程来获取内存泄漏:
我们首先对堆进行调整,使得 4 个大小为0x100的块A
、B
、C
、D
连续且空闲,空闲列表为:D
→ A
→ B
→ C
(图 1)。
在让 PHP 写入无效电子邮件地址存储到zend_string
[虚拟地址为0x7fff11a33300
( D
) (图 2)]之后,我们从地址0x7fff11a33000
( A
) 处的块溢出,覆盖指向0x7fff11a33200
( C
) 的指针的 LSB,该指针变为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 分配字符串,覆盖invalid_email
的长度(步骤 5)
当显示错误消息时,内存就会泄漏。
构造利用之后,我设法让 Roundcube 使用我修改后的电子邮件(Adresse courriel invalide
为法语,意思是Invalid email address
,无效的电子邮件地址)显示错误,但是它……索然无味。
错误消息仅包含空格、unicode 转义的空字节和 ASCII 字符。发生了什么?实际上,Roundcube 将错误消息显示为 JSON。为了对它们进行编码,它使用带有JSON_INVALID_UTF8_IGNORE
标志(https://www.php.net/manual/en/json.constants.php#constant.json-invalid-utf8-ignore)的json_encode()
API。因此 ,无效 的UTF-8 字符会被丢弃。由于内存中的大多数数据都不是有效的,因此我们的泄漏不包含任何有趣的内容。
修改泄漏策略
我们的目标字符串不是最有成效的,但这个想法仍然是正确的。相反,我们需要找到一个“按原样”(或修改较少)显示的变量。
与大多数 Web 应用程序一样,Roundcube 在执行的最后阶段(在我们触发漏洞之后)才格式化其输出。显然,这是大多数候选目标字符串被分配的地方。因此,我们需要修改我们的漏洞利用算法。我们仍将使用 4 个块,并且仍将替换C
以使其与包含目标字符串的D
重叠,但这次,我们在目标字符串被分配之前触发溢出。
之前的空闲列表是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
执行溢出,从而进入步骤 2。为了强制在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;
}
}
此方法生成在 HTTP 响应中显示的原始JavaScript 代码。
它相当复杂,但从某种意义上来说,这是一件好事:由于返回值$out
必然会在某个时刻显示出来,因此与其连接的每个变量都是潜在的目标字符串。此外,这里的每一行代码都会执行一个或多个分配、释放或重新分配...一种处理步骤 5 的方法。因此,每行代码都是一个gadget,可能会也可能不会帮助我们覆盖字符串头部。
遗憾的是,这里没有像explode_quoted_string()
这样简单的gadget:我们需要变得更聪明。
寻找目标
让我们通过删除未输入的条件来简化代码:
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
是一个包含单个元素的数组,即一个包含 2 项的数组:["display_message", "Addresse courriel invalide: <email-we-sent>"]
。因此,该$commands
数组由 2 个元素组成:
[
["hide_message", "<unlock-value>"],
["display_message", "Addresse courriel invalide: <invalid-email>"],
]
然后,在返回变量前,每行都用于迭代构建$out
中某些javascript 代码的一部分 。在第12 行到第21行的每次循环迭代中,我们控制会被 JSON 序列化的$args[0]
,然后使用 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
的头部:通过与sprintf()
[2]的结果连接起来$out
的大小在它再次被修改之前大大增加。
最后,我们得到了我们想要的内存泄漏:
注意:sprintf()
分配一个大小为 0x800 的块来存储结果字符串,这迫使我们攻击这个大小的块。
两种方法
通过精心设置堆,我们可以同时泄漏指向 PHP 二进制文件的指针和靠近目标字符串位置的指针。因此,ASLR 和 PIE 变得无关紧要。此外,我们早已知道如何破坏空闲列表,因此我们可以在堆中的任意位置**分配一个块。但 游戏还没有结束。此时,有两种方法可以采用。
第一种是我们工作的逻辑延续:使用我们的二进制破坏来执行代码。它通常涉及转储二进制文件的部分以找到有趣的偏移量,然后启动 ROP 链。第二种涉及执行纯数据攻击。每种方法都有其优点和缺点。虽然我在 OffensiveCon 上演示了二进制漏洞利用,但我发现纯数据攻击更优雅,因此我将展示它。它是更深入地研究 PHP 引擎的好方法。
纯数据攻击
与二进制攻击相比,纯数据攻击的优势在于我们不依赖机器代码。相反,我们使用低级漏洞来破坏 PHP 变量并改变脚本的执行(一个非常简单的例子是设置一个假想的$is_admin
标志为true
来提升我们的权限)。
虽然我们能够构造相当复杂的结构,但我们只能引用堆地址。因此,并非每个变量都可以被覆盖:我们可以针对简单类型(bool
、int
、string
)和数组,但不能针对对象,因为代表它们的结构zend_object
包括指向主二进制文件的指针(我们对此了解不多!)。
一个理想的目标是存储在$_SESSION
数组中的session变量,原因如下:首先,它们在漏洞利用后仍然存在(前提是它没有崩溃)。其次,它们在脚本执行的最后被保存,让我们有“时间”来修改它们。第三,从攻击者的角度来看,它们通常很有趣:谁没有梦想过能够将自己的角色更改为superadmin
呢?
然而,在 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()
!(https://www.php.net/manual/en/function.unserialize.php)在大型框架上能够执行反序列化通常意味着可以实现 RCE,Roundcube 也不例外。事实上,它使用Guzzle(一个流行的库)来执行 HTTP 请求。使用PHPGGC(https://github.com/ambionics/phpggc),我们可以生成一个guzzle/fw1
payload,并将反序列化转换为任意文件写入。
显然,在正常使用下,用户无法修改$_SESSION['preferences']
。然而,我们不是普通用户:我们可以在堆中写入东西!因此,我们可以让两个块重叠并覆盖此session变量的zend_string
!
但问题又来了:在默认配置下,$_SESSION['preferences']
永远不会被设置,没有什么可覆盖的!不过,一切还不算完:我们可以更深入地研究并使用我们的任意分配添加一个元素到$_SESSION
数组中。我们该怎么做呢?
我们需要深入研究 PHP 数组的实现。
PHP 数组
PHP 数组由键/值对组成,其中值可以是任意类型,但键要么是整数,要么是字符串。我们在此仅介绍字符串键。
每一对都保存在一个称为Bucket
的结构中:
第一个元素val
可以是简单值(long、float)或指向值(zend_string
、zend_object
等)的指针。type
定义变量类型。next
表示列表中(稍后会详细介绍)下一个Bucket
的索引。key
存储在 Bucket.key
中,它的DJBX33A hash(https://github.com/php/php-src/blob/master/Zend/zend_string.h#L429)存储在 Bucket.h
中。
当一个非空数组被创建时,PHP 会分配一个zend_array
结构和另一个由uint32_t
值列表(hashmap)组成的块,后跟8个 Bucket
的列表。
zend_array.arData
指向此butterfly 结构:这个结构的底部是bucket列表,顶部是 hashmap。hashmap可以将zend_string
哈希转换为bucket列表中的索引:当尝试访问某个键的值时,PHP 会将表掩码 ( zend_array.nTableMask
) 与键的哈希值 (zend_string.h
)进行或运算,得到一个int32_t
类型的负数,并将其用作(uint32_t[]) arData指针的索引。
在上面的例子中,我们在包含8 个元素的数组中查找preferences
。索引等于(int32_t) (0xfffffff0h | 0xc0c1e3149808db17) = 0xfffffff7 = -9
。通过从hashmap的末尾开始选择第 9
元素,我们得到4
(蓝色)。因此,PHP 检查第五个存储桶。
一个 bucket 及其键值对。键是preferences
,值是序列化的字符串。
为了确保这确实是正确的bucket(两个不同的字符串可能具有相同的哈希值,或者映射到相同索引的哈希值),PHP 会根据所提供的键的哈希值检查存储桶的哈希值。 如果它们相等(在我们的示例中,它们是相等的),它会比较键的大小和值。 如果它们相等(在我们的示例中,它们再次相等),则它就是正确的bucket,PHP 将返回其值。 如果预期的键与bucket的键不同,PHP 将转到以next
值为索引的bucket(在我们的示例中为蓝色的5
),并继续查找,直到找到它。FF FF FF FF
是一个特殊值,表示没有bucket。
覆盖session数组
因此,覆盖hashmap和一个bucket就足以添加一对键值对到数组中。
现在,请记住,我们的原语允许我们修改空闲列表指针,从而在堆中的任何位置分配一个zend_string
(或任何东西,但是zend_string
s最有用的),即我们可以让 PHP 分配任意块。
session数组由 32 个元素组成,其hashmap+bucket块的大小为0x500( hashmap 为0x100, bucket 为0x400)。我们希望让假堆指针指向正上方。
这相当简单,但我们还需要采取一些预防措施。首先,我们不能随便指向任何地方:当 PHP分配我们的任意块时,它会认为这真的是一个空闲块(愚蠢的 PHP)。因此,它会认为它的前 8 个字节是指向下一个块的指针。假设它不是一个有效的指针,如果 PHP 再分配一个相同大小的块,我们就会崩溃。此外,当我们的假块被释放时,它可能会再次被分配,并包含我们无法控制的数据。我们需要保护它不被重新分配;否则,它可能会完全破坏我们的工作。
为了解决第一个问题,我们可以使用 HTTP 参数创建一个由0x500 个块组成的网络,这些块中填充了空字节,并以空洞分隔,希望hashmap+bucket块能够分配在其中两个块之间。如果我们指向这样一个块,PHP 会读到空指针来作为下一个空闲列表元素,并认为空闲列表已经用尽,从而避免崩溃。
为了解决第二个问题,一旦我们分配了我们的任意块,我们也会分配大量大小为0x500的块。当它们全部被释放时,按顺序,最后的块将位于空闲列表中我们的块之前,从而保护它免受分配。
现在我们开始:使用我们的二进制漏洞,修改session的内容,并将preferences
设置为任意字符串。然后,我们向索引发出 HTTP 请求,其中$_SESSION['preferences']
被反序列化。使用guzzle/fw1
payload,我们将文件写入public_html/shell.php
。
演示
这是一个针对 PHP 8.3 下的 Roundcube 1.6.6 的演示
该利用脚本可在此处(https://github.com/ambionics/cnext-exploits)获取。与往常一样,它带有注释,并揭示了我在博客文章中未谈及到的那部分漏洞利用。
对生态的影响
因此,直接调用iconv()
是可利用的,并且会产生影响。但它是唯一受 CVE-2024-2961 影响的 PHP 函数吗?根本不是。
首先,iconv()
有很多兄弟函数,例如iconv_strrpos()
,iconv_substr()
...这些函数可能存在漏洞(我还没有检查过)。但还有一个更可怕、非常出乎意料的漏洞。
PHP 有一个非常流行的称为mbstring
的扩展(https://www.php.net/manual/en/intro.mbstring.php)。该扩展用 C 语言编写,允许您在各种字符集下操作字符串,并执行字符集转换。它是许多框架和 CMS 的依赖项(https://packagist.org/packages/ext-mbstring/dependents?order_by=downloads)。
但mbstring
默认情况下不会安装。如果它没有安装(您需要超级用户权限才能安装),但您仍然想使用依赖它的库或框架,会发生什么?好吧,在这种情况下,您可以使用该库的 PHP 实现。该项目名为symfony/polyfill-mbstring
(https://packagist.org/packages/symfony/polyfill-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-2691 和 PHP。
结论
在利用PHP 过滤器之后,我们现在通过直接调用iconv()
来利用CVE-2024-2961攻击著名的网络邮件 Roundcube。这让我们更深入地了解了 PHP 引擎的底层实现,并为潜在的新利用开辟了道路,利用明显的和不太明显的sink(入口点)。
现在我们已经通过PHP中的两种利用方式展示了影响,我们需要讨论最后一个问题:如果您拥有的文件读取原语是无回显的,会发生什么?
敬请关注第 3 部分!
如果喜欢小编的文章,记得多多转发,点赞+关注支持一下哦~,您的点赞和支持是我最大的动力~
原文始发于微信公众号(沃克学安全):[翻译]iconv,设置字符集实现RCE:利用GLIBC攻击PHP引擎(part 2)
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论