CVE-2024-2961 Iconv 实现PHP引擎 RCE(译)

引言

几个月前,我偶然发现了一个存在于glibc(Linux程序的基础库)中长达24年的缓冲区溢出问题。尽管这个问题在多个知名的库或可执行文件中都可以被利用,但事实证明它很少能被成功利用——虽然它提供了一些利用空间,但需要难以实现的先决条件。寻找目标主要导致了失望。然而,在PHP中,这个漏洞却大放异彩,并证明了它在两种不同方式上利用PHP引擎的实用性。

由于材料的数量,漏洞的影响和利用将在三部分系列中记录。在这个系列的第一部分,我将描述我是如何遇到这个漏洞的,为什么合适的目标很少,最后深入PHP引擎,展示一个新的利用向量:将PHP应用程序中的文件读取原语转换为远程代码执行

如果你对网络利用、PHP或PHP引擎不熟悉,请注意:我将在路上解释相关的概念。

  • [引言]
  • [发现:关于过滤器的故事]
    • [PHP中的文件读取原语]
    • [PHP过滤器简介]
    • [PHP过滤器:前缀、后缀和崩溃]
  • [CVE-2024-2961:glibc中的一个漏洞]
    • [libxml2:字节的海洋]
    • [pkexec:4个字节太多了]
    • [条件和原语(更新)]
    • [iconv() API]
    • [转换为ISO-2022-CN-EXT时的越界写入]
    • [条件和原语]
  • [利用PHP过滤器]
    • [单个桶]
    • [正确去块]
    • [空闲列表控制:写入什么-在哪里]
    • [代码执行]
    • [漏洞利用性能]
    • [演示]
    • [PHP堆的入门]
    • [PHP过滤器内部]
    • [情况和目标]
    • [利用]
  • [影响]
    • [SQL注入到RCE]
    • [XXE]
    • [标准接收器]
    • [利用漏洞]
    • [作为PHAR的替代]
    • [解析库]
    • [类实例化]
    • [作为改进装置链]
    • [其他,可能]
  • [时间线]
  • [结论]
  • [我们正在招聘!]

发现:关于过滤器的故事

PHP中的文件读取原语

让我们首先了解基础知识。假设在进行评估时,你发现了一个文件读取原语,如下所示:

echo file_get_contents($_GET['file']);

你可以用它做什么?显然,读取文件。例如,你可以读取/etc/passwd。但PHP还允许你使用其他协议,如http://ftp://。因此,你可以要求PHP为你获取Google的首页,使用http://google.com;或者从FTP服务器下载文件,使用ftp://user:[email protected]/file.bin。但这还不是全部;PHP还实现了自定义协议,如phar://

phar://让你可以读取PHAR归档内部的内容。PHAR代表PHP归档,就像JAR代表Java归档一样。它是一个文件的组合,例如:

  • 源代码
  • 资源
  • 序列化元数据

多年来,这个协议一直是PHP的致命弱点,因为当你使用它访问PHAR文件时,它的元数据会被反序列化。通常的PHAR攻击看起来像这样:

  1. 将PHAR归档上传到目标服务器(PHAR文件非常多样化,所以你可以让它们看起来像图像、PDF或任何东西)
  2. 使用phar:///path/to/file.phar/test访问PHAR文件
  3. 任意有效负载被反序列化

将反序列化转换为代码执行可以通过许多方式完成,但人们通常依赖于PHP上的反序列化工具PHPGGC。

你不能过分强调PHAR攻击的影响。从2018年创建以来,它们一直是在PHP目标上获取shell的关键。但派对即将结束:

  • 从PHP 8.0(2020年发布)开始,phar://不再反序列化元数据了。(他们本来就不使用元数据,所以为什么要反序列化它)。这完全杀死了PHAR攻击。
  • 大型应用程序(如Drupal或Magento)已经禁用了phar://协议
  • 随着时间的推移,反序列化将变得越来越难以利用:库正在修补它们的反序列化链,并且类型化正在回归,大幅减少了反序列化路径。

phar://并不是唯一对攻击者有用的协议;另一个协议也取得了很好的成果:php://filter

PHP过滤器简介

多年来,人们对于php://filter这一PHP特有的协议(如果名称没有明确指出)产生了兴趣。它提供了一种方式,在返回流之前对其应用转换。语法如下:

php://filter/[filters...]/resource=[resource]

资源可以是我们在上一节中讨论过的任何内容:一个简单的文件、一个HTTP响应、来自FTP服务器的文件…

过滤器是您希望PHP对流应用的一系列转换。这里,我们要求PHP使用convert.base64-encode过滤器将资源的内容转换为base64:

php://filter/convert.base64-encode/resource=/etc/passwd

它返回:

cm9vdDp4OjA6MDpyb290Oi9yb290Oi9iaW4vYXNoCmJpbjp4OjE6MTpiaW46L2Jpbjovc2Jpbi9u
b2xvZ2luCmRhZW1vbjp4OjI6MjpkYWVtb246L3NiaW46L3NiaW4vbm9sb2dpbgphZG06eDozOjQ6
...
Yi92bnN0YXQ6L2Jpbi9mYWxzZQpyZWRpczp4OjEwMjoxMDM6cmVkaXM6L3Zhci9saWIvcmVkaXM6
L2Jpbi9mYWxzZQo=

你可以添加任意多的过滤器。在这里,我要求PHP两次base64编码流:

php://filter/convert.base64-encode|convert.base64-encode/resource=/etc/passwd

我得到的是:

Y205dmREcDRPakE2TURweWIyOTBPaTl5YjI5ME9pOWlhVzR2WVhOb0NtSnBianA0T2pFNk1UcGlh
...
RXdNam94TURNNmNtVmthWE02TDNaaGNpOXNhV0l2Y21Wa2FYTTZMMkpwYmk5bVlXeHpaUW89

显然,base64编码不是唯一可以做的事情。许多过滤器都是可用的。它们包括:

  • string.upper,它将字符串转换为大写
  • string.lower,它将字符串转换为小写
  • string.rot13,它执行一些BC加密
  • convert.iconv.X.Y,它将字符集从X转换为Y

让我们看看最后一个过滤器:convert.iconv.X.Y。假设我需要将我的文件从UTF8转换为UTF16。我可以使用:

php://filter/convert.iconv.UTF-8.UTF-16/resource=/etc/passwd

它给出的结果是(以十六进制形式):

00000000: fffe 7200 6f00 6f00 7400 3a00 7800 3a00  ..r.o.o.t.:.x.:.
00000010: 3000 3a00 3000 3a00 7200 6f00 6f00 7400  0.:.0.:.r.o.o.t.
...
00000a40: 2f00 6200 6900 6e00 2f00 6600 6100 6c00  /.b.i.n./.f.a.l.
00000a50: 7300 6500 0a00                           s.e...

众多的过滤器和将它们串联起来的可能性引导了一些关于PHP的优秀研究,例如这里,这里,或这里。实际上,使用精心挑选的过滤器(一个过滤器链),攻击者可以做出一些了不起的事情,例如完全改变文件的内容,或者使用基于错误的神谕逐字节提取文件内容。

例如,这里有一个将Hello world!添加到/etc/passwd开头的过滤器链:

php://filter/convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.CSGB2312.UTF-32|
convert.iconv.IBM-1161.IBM932|convert.iconv.GB13000.UTF16BE|convert.iconv.864.UTF-32LE|
convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.IBM860.UTF16|
convert.iconv.ISO-IR-143.ISO2022CNEXT|convert.base64-decode|convert.base64-encode|
convert.iconv.855.UTF7|convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|
convert.iconv.GBK.SJIS|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|
convert.iconv.L5.UTF-32|convert.iconv.ISO88594.GB13000|convert.iconv.BIG5.SHIFT_JISX0213|
convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.JS.UNICODE|
convert.iconv.L4.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|
convert.iconv.CP-AR.UTF16|convert.iconv.8859_4.BIG5HKSCS|convert.base64-decode|convert.base64-encode|
convert.iconv.855.UTF7|convert.iconv.SE2.UTF-16|convert.iconv.CSIBM921.NAPLPS|
convert.iconv.CP1163.CSA_T500|convert.iconv.UCS-2.MSCP949|convert.base64-decode|
convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.L4.UTF32|convert.iconv.CP1250.UCS-2|
convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.UTF8.UTF16LE|
convert.iconv.UTF8.CSISO2022KR|convert.iconv.UTF16.EUCTW|convert.iconv.ISO-8859-14.UCS2|
convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.INIS.UTF16|
convert.iconv.CSIBM1133.IBM943|convert.iconv.GBK.BIG5|convert.base64-decode|convert.base64-encode|
convert.iconv.855.UTF7|convert.iconv.CP1046.UTF16|convert.iconv.ISO6937.SHIFT_JISX0213|
convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.L5.UTF-32|
convert.iconv.ISO88594.GB13000|convert.iconv.BIG5.SHIFT_JISX0213|convert.base64-decode|
convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|
convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.ISO2022KR.UTF16|
convert.iconv.L6.UCS2|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|
convert.iconv.L6.UNICODE|convert.iconv.CP1282.ISO-IR-90|convert.base64-decode|convert.base64-encode|
convert.iconv.855.UTF7|convert.iconv.JS.UNICODE|convert.iconv.L4.UCS2|convert.iconv.UCS-2.OSF00030010|
convert.iconv.CSIBM1008.UTF32BE|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|
convert.iconv.IBM869.UTF16|convert.iconv.L3.CSISO90|convert.base64-decode|convert.base64-encode|
convert.iconv.855.UTF7|convert.iconv.CP861.UTF-16|convert.iconv.L4.GB13000|convert.iconv.BIG5.JOHAB|
convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|convert.iconv.L6.UNICODE|
convert.iconv.CP1282.ISO-IR-90|convert.base64-decode|convert.base64-encode|convert.iconv.855.UTF7|
convert.iconv.INIS.UTF16|convert.iconv.CSIBM1133.IBM943|convert.iconv.GBK.SJIS|convert.base64-decode|
convert.base64-encode|convert.iconv.855.UTF7|convert.base64-decode/resource=/etc/passwd

结果如下:

Hello, world!!!root:x:0:0:root:/root:/bin/bash...

PHP过滤器:前缀、后缀和崩溃

遗憾的是,文件读取并不总是像这样简单:

echo file_get_contents($_POST['file']);

通常情况下,文件不会原样返回,而是会以某种方式被解析或检查。例如,我经常遇到这种代码的变化,它期望你的文件是有效的JSON:

$data = file_get_contents($_POST['url']); 
$data = json_decode($data); 
echo $data->message;

这里我们有一个文件读取,但内容随后被JSON反序列化,只返回文档的一部分。为了读取标准文件,如/etc/passwd,我们需要给流添加任意的前缀和后缀。类似这样:{"message": "<contents-of-/etc/passwd>"}。在2023年末,情况是你可以使用php://filter链给流添加前缀,但不能添加后缀。所以我开始研究一个算法来做后者***。

当时,我对字符集或编码一无所知(坦白说,我仍然不知道它们的区别)。为了开始,我构建了一个暴力破解脚本,它将几个iconv过滤器堆叠在一起,并显示结果。类似这样:

php://filter/convert.iconv.A.B/convert.iconv.C.D/convert.iconv.E.F/resource=data:,test123

在某个点,我的“fuzzer”崩溃了

由于我一生中大部分时间都在使用PHP,我很快就开始指责。但我并不知道,错误发生在调用链的更深层次:一直到glibc

*备注:这项研究产生了一个工具,于2023年12月发布:wrapwrap.

CVE-2024-2961: glibc中的一个漏洞

iconv() API

当PHP从一种字符集转换为另一种时,它使用iconv,这是一个API,用于“使用转换描述符将输入缓冲区中的字符转换到输出缓冲区”。这个API在Linux上由glibc实现。

这个API非常简单。你首先打开一个转换描述符,它指示输入和输出字符集。

iconv_t iconv_open(const char *tocode, const char *fromcode);

然后,你可以使用iconv()将输入缓冲区inbuf转换为outbuf中的新字符集,即输出缓冲区。

size_t iconv(iconv_t cd, char **restrict inbuf, size_t *restrict inbytesleft, char **restrict outbuf, size_t *restrict outbytesleft);

缓冲区管理是调用者的责任。如果输出缓冲区不够大,iconv()将返回一个错误指示,你将能够重新分配outbuf并通过再次调用iconv()继续转换。该函数保证它永远不会从inbuf读取超过inbytesleft字节,或者写入超过outbytesleft字节到outbuf。永远不会?嗯,在理论上

转换为ISO-2022-CN-EXT时的越界写入

恰好在将数据转换为ISO-2022-CN-EXT字符集时,iconv可能未能检查在写入之前输出缓冲区是否有足够的空间。

实际上,ISO-2022-CN-EXT实际上是一组字符集:当它需要编码一个字符时,它将选择适当的字符集,并发出一个转义序列,以指示解码器需要切换到这样的字符集。

下面的代码是负责发出这种转义序列的部分。它由3个if块组成,每个块都向outbuf(由outptr指向)写入不同的转义序列。如果你看第一个[1],你可以看到它前面有一个if()块,检查输出缓冲区是否足够大以容纳4个字符。另外两个if()[2][3]则没有。因此,转义序列可能会被写出界限。

// iconvdata/iso-2022-cn-ext.c

/* See whether we have to emit an escape sequence.  */
if (set != used)
{
    /* First see whether we announced that we use this
        character set.  */
    if ((used & SO_mask) != 0 && (ann & SO_ann) != (used << 8)) // [1]
    {
        const char *escseq;

        if (outptr + 4 > outend) // <-------------------- BOUND CHECK
        {
            result = __GCONV_FULL_OUTPUT;
            break;
        }

        assert(used >= 1 && used <= 4);
        escseq = ")A)G)E" + (used - 1) * 2;
        *outptr++ = ESC;
        *outptr++ = '$';
        *outptr++ = *escseq++;
        *outptr++ = *escseq++;

        ann = (ann & ~SO_ann) | (used << 8);
    }
    else if ((used & SS2_mask) != 0 && (ann & SS2_ann) != (used << 8)) // [2]
    {
        const char *escseq;

        // <-------------------- NO BOUND CHECK

        assert(used == CNS11643_2_set); /* XXX */
        escseq = "*H";
        *outptr++ = ESC;
        *outptr++ = '$';
        *outptr++ = *escseq++;
        *outptr++ = *escseq++;

        ann = (ann & ~SS2_ann) | (used << 8);
    }
    else if ((used & SS3_mask) != 0 && (ann & SS3_ann) != (used << 8)) // [3]
    {
        const char *escseq;

        // <-------------------- NO BOUND CHECK

        assert((used >> 5) >= 3 && (used >> 5) <= 7);
        escseq = "+I+J+K+L+M" + ((used >> 5) - 3) * 2;
        *outptr++ = ESC;
        *outptr++ = '$';
        *outptr++ = *escseq++;
        *outptr++ = *escseq++;

        ann = (ann & ~SS3_ann) | (used << 8);
    }
}

要触发这个漏洞,我们需要强迫iconv()在输出缓冲区结束前发出一个转义序列。为此,我们可以使用如下一些生僻字符:

  • 湿

结果会导致1到3字节的溢出,具体溢出的值如下:

  • $*H [24 2A 48]
  • $+I [24 2B 49]
  • $+J [24 2B 4A]
  • $+K [24 2B 4B]
  • $+L [24 2B 4C]
  • $+M [24 2B 4D]

一个快速的POC(Proof of Concept,概念验证)展示了这个漏洞:

/*
$ gcc -o poc ./poc.c && ./poc
*/
...

void hexdump(void *ptr, int buflen)
{
    ...
}

void main()
{
    iconv_t cd = iconv_open("ISO-2022-CN-EXT""UTF-8");

    char input[0x10] = "AAAAA劄";
    char output[0x10] = {0};

    char *pinput = input;
    char *poutput = output;

    // Same size for input and output buffer: 8 bytes
    size_t sinput = strlen(input);
    size_t soutput = sinput;

    iconv(cd, &pinput, &sinput, &poutput, &soutput);

    printf("Remaining bytes (should be > 0): %zdn", soutput);

    hexdump(output, 0x10);
}

在易受攻击的系统上,执行以下命令会产生以下结果:

$ gcc -o poc ./poc.c && ./poc
Remaining bytes (should be > 0): -1
000000: 41 41 41 41  41 1b 24 2a  48 00 00 00  00 00 00 00    AAAA A.$* H... ....

尽管指示iconv()最多写入八个字节,但实际上写了九个字节。

查看glibc的提交历史,我注意到这个漏洞非常古老:它出现在2000年,距今已有24年。

现在,这个漏洞能用来做什么呢?

条件和原语

有了这个漏洞,我有一个1到3字节的溢出,而且是非控制字符。这并不多。除此之外,还有一些先决条件。我需要找到一个iconv()调用,其中我:

  • 控制输出字符集(ISO-2022-CN-EXT
  • 控制输入缓冲区的一部分(以放入美妙的中文字符)

考虑到这些,我开始寻找目标。从在我的/lib/bin目录中搜索iconv到浏览数百个开源软件项目,我找到了一些有趣的目标。但实际上都没有可利用的。

举个例子,让我们看看一个非常有希望的目标:libxml2

libxml2: 字节的海洋

libxml2只处理UTF-8格式的XML。如果XML文档不是UTF-8,它将被转换为UTF-8,然后进行处理,然后在所有处理完成后再转换回其原始字符集。转换使用的是iconv()

因此,我们可以通过这样的文档满足我们的先决条件:

<?xml version="1.0" encoding="ISO-2022-CN-EXT"?> <root>&21124;</root>

注意:21124是劄的Unicode代码点。

现在,请记住:缓冲区管理是调用者的责任。当libxml2使用iconv()将我们的文档转换回其原始字符集时,它分配了一个输出缓冲区,其大小是输入缓冲区的4倍(代码链接)。对我们来说太大了:我们无法到达缓冲区的界限来溢出。死路一条。

pkexec: 4个字节太多了

另一个有趣的目标是pkexec,一个在许多Linux发行版中出现的setuid二进制文件。该二进制文件允许你通过设置CHARSET环境变量来选择它输出的每条消息的字符集。示例:

$ CHARSET=ISO-2022-CN-EXT pkexec 'trigger劄' 2>&1 | hexyl
┌────────┬─────────────────────────┬─────────────────────────┬────────┬────────┐
│00000000│ 43 61 6e 6e 6f 74 20 72 ┊ 75 6e 20 70 72 6f 67 72 │Cannot r┊un progr│
│00000010│ 61 6d 20 74 72 69 67 67 ┊ 65 72 1b 24 2a 48 1b 4e │am trigg┊er•$*H•N│
│00000020│ 4c 61 0f 3a 20 4e 6f 20 ┊ 73 75 63 68 20 66 69 6c │La•: No ┊such fil│
│00000030│ 65 20 6f 72 20 64 69 72 ┊ 65 63 74 6f 72 79 0a    │e or dir┊ectory_ │
└────────┴─────────────────────────┴─────────────────────────┴────────┴────────┘

pkexec 在内部使用 GLib 来输出其消息。它执行以下操作:

#define NUL_TERMINATOR_LENGTH 4

outbuf_size = len + NUL_TERMINATOR_LENGTH;

outbytes_remaining = outbuf_size - NUL_TERMINATOR_LENGTH;
outp = dest = g_malloc (outbuf_size);

...

err = g_iconv (converter, NULL, &inbytes_remaining, &outp, &outbytes_remaining);

它分配了一个 N + 4 字节的缓冲区,但只告诉 iconv 关于 N 字节的信息。我们的溢出最多是 3 字节长。因此,不管我们怎么努力,我们都无法到达缓冲区的外面。

这是另一个死胡同。

条件和原语(更新)

充满失望,我只能更新我的要求列表。为了利用这个漏洞,我们需要:

  • 控制输出字符集 (ISO-2022-CN-EXT)
  • 控制输入缓冲区的一部分
  • 拥有合适的输出缓冲区

利用 PHP 过滤器

即使寻找了几天,我也没有找到有效的目标。盲目地在库和二进制文件中搜索 iconv() 调用,遍历开源生态系统,寻找可触发的漏洞实例,我拼命地寻找崩溃。一次崩溃。徒劳无功。

为了重新燃起我的希望,我回到了 PHP:毕竟,它确实崩溃了,甚至没有我要求它这么做。

目标很简单:将无聊的文件读取漏洞转换为远程代码执行。

PHP 堆的入门知识

注意:在这一部分,以及每个描述 PHP 内部的部分,我将做一些近似和省略某些事情。

为了理解接下来的事情,我们需要了解 PHP 堆是如何工作的(至少是它的一部分)。别担心,它是一个非常简单的堆。

在 PHP 中分配内存,你使用 emalloc(N),其中 N 是你想要的字节数。你得到一个至少可以存储 N 字节的块(一个内存块)的指针。当你完成你的块时,你使用 efree(ptr) 来释放它。PHP 有各种大小的块(8, 0x10, 0x18, … 0x200, 0x280, …)。

PHP 堆由一个 2MB 的区域组成,分成 512 页,每页 0x1000 字节。每一页可能包含特定大小的块。例如,第 10 页可能包含 0x100 大小的块,第 11 页包含 0x38 大小的块,第 12 页包含 0x180 大小的块,等等。块之间没有元数据。

当你释放一个块时,它被放在一个叫做空闲列表的单链表的开头。每种块大小都有一个空闲列表。例如,如果我释放一个 0x38 大小的块,它会进入 0x38 大小块的空闲列表。如果我释放一个 0x200 大小的块,它会进入 0x200 大小块的空闲列表…

要分配 N 字节,PHP 查看对应块大小的空闲列表,取出头部,并返回它。如果空闲列表为空(即所有可用的块已经被分配),PHP 查看堆元数据以找到一个未被使用的页面。然后,它在这样一个页面中创建空块,并将它们放入空闲列表。

空闲列表是后进先出(LIFO),这意味着当我释放一个某个大小的块时,它成为空闲列表的头部。当我分配时,取出头部。这与 glibc 的 tcache 非常相似,但是没有限制。

CVE-2024-2961 Iconv 实现PHP引擎 RCE(译)

PHP 堆的可视化表示

在上面的例子中,我们有堆的可视化表示在左侧。它包含 512 页,这里第 5 页存储 0x400 大小的块。如果我们看看这一页的内容,我们可以看到它包含 4 个块(因为 4 × 0x400 = 0x1000,一页的大小)。这里,块 #1 和 #3 被分配了,块 #2 和 #4 被释放了。因此,它们在 0x400 大小块的空闲列表中。

空闲列表作为一个单链表,每个未分配的块包含在其前 8 个字节中,指向下一个空闲块的指针。这就是我们在块 #2 中看到的:指向 0x7ff10201400 的指针,这是下一个大小为 0x400 的空闲块的地址。现在,如果我们要从块 #1 溢出到块 #2,我们会覆盖这个指针。这是利用的一个很好的起点:即使只有一个字节的溢出,我们也可以改变空闲列表指针,从而 改变空闲列表

我们应该注意,PHP 为每个 HTTP 请求创建一个新的堆。这是使远程 PHP 利用变得困难的原因之一 – 但这将在第二部分中介绍。

PHP过滤器内部机制

现在我们知道了PHP如何分配和释放内存,我们可以看看PHP如何处理一个php://filter/字符串。我们在这方面很幸运:我们不需要深入了解PHP内部结构的细节,比如zvalzend_stringzend_array等。

要处理一个过滤器,PHP首先会获取流(即读取资源)。它将流存储在一个集合的buckets中,这些是双向链表结构,每个结构都包含一个特定大小的缓冲区。以我们的/etc/passwd为例,我们可能有三个buckets:第一个包含文件的前5个字节,第二个bucket包含接下来的30个字节,第三个bucket包含另外的1000个字节。它们链接在一起,构成了一个bucket brigade

CVE-2024-2961 Iconv 实现PHP引擎 RCE(译)

一个包含/etc/passwd的3个buckets的bucket brigade

这是将流表示为不同大小缓冲区集合的标准方式。你可以将其想象为通过网络接收的数据包列表。数据包1包含数据的前N个字节,数据包2包含接下来的M个字节,等等。

现在PHP已经将资源的内容读取到一个流中,由一个bucket brigade表示,它可以在其上应用过滤器。它取出第一个过滤器,并处理第一个bucket。为此,它分配一个与bucket缓冲区大小相同的输出缓冲区(在我们的例子中,将是5个字节),并执行转换。如果过滤器是string.upper,例如,它将输入缓冲区中的每个小写字符转换为输出缓冲区中的大写等价物。然后它可以创建一个新的bucket,指向这个缓冲区。

CVE-2024-2961 Iconv 实现PHP引擎 RCE(译)

在bucket brigade上应用string.upper

它接着处理第二个bucket,然后是第三个,依此类推,直到它到达最后一个bucket。现在它有了一个全新的bucket brigade,每个输出bucket都在里面。现在它可以在这个brigade上应用第二个过滤器,并继续进行,直到最后一个过滤器被处理完毕。

情况和目标

我们已经完成了定义。让我们回到原始的漏洞:文件读取。

echo file_get_contents($_GET['file']);

现在我们可以利用convert.iconv.XXX.ISO-2022-CN-EXT过滤器触发内存损坏,我们想要远程代码执行。并且它看起来不难利用。

首先,由于我们有一个文件读取原语,我们可以读取二进制文件(PHP、Apache等)。我们甚至可以下载libc并检查它是否打了补丁!我们也不在乎ASLR和PIE:我们可以读取/proc/self/maps。最后,感觉我们几乎可以随意使用buckets来分配或释放缓冲区,这很方便。

另一方面,有许多上下文可以获得文件读取原语:你可能会在PHP 7.0上运行的Symfony 4.x上获得它,或者在PHP 8.3上运行的不知名的Wordpress插件上获得,甚至在黑盒评估中获得。理想的漏洞利用需要具有弹性:它必须能够在大多数目标上工作,而不需要任何调整。

利用

考虑到所有这些,让我们开始利用。我们的想法是使用单字节缓冲区溢出来修改指向空闲块的指针的LSB(最低有效位),以便控制一些空闲列表。

单个bucket

我们面临的第一个问题是,尽管有bucket brigade技术,PHP只创建一个bucket。如果你读取一个文件,你会得到一个包含整个文件的bucket。如果你请求一个HTTP URL,PHP会创建一个包含整个HTTP响应的bucket。使用ftp://也是如此。这至少可以说是非常不实用的:我们不能用buckets来填充堆,喷洒东西,甚至使用改变的空闲列表。

想想看:用单个bucket,我们可以溢出到一个空闲块并修改一个空闲列表,但之后我们就用完了buckets,我们需要至少另外2个分配才能使用我们改变的空闲列表!

幸运的是,有一个过滤器拯救了我们:zlib.inflate。这个过滤器获取我们的流并将其解压缩。为此,它分配了一个8页(0x8000字节)的缓冲区,并将我们的流充气到其中。如果还不够大,它会创建一个新的同样大小的缓冲区来存储其余的数据。如果这两个还不够,它会再创建另一个缓冲区。然后每个缓冲区都被添加到一个bucket中。完美的是:我们可以使用这个过滤器来创建我们想要的尽可能多的buckets,这是向前迈出的良好一步。

CVE-2024-2961 Iconv 实现PHP引擎 RCE(译)

应用zlib.inflate创建多个buckets

然而,这些buckets有0x8000大小的缓冲区,这对于利用来说不是一个好的大小;这些大小的缓冲区以我告诉你的不同方式分配,并且在释放时不会进入空闲列表。我们需要调整我们的buckets的大小。

正确去块

为此,我们将使用一个PHP没有文档记录,但攻击者众所周知的过滤器:dechunk。这个过滤器解码使用HTTP块编码的字符串。

HTTP块编码是一个非常简单的编码方式,你通过块(不是块,是数据块)发送数据。首先,你发送一个ASCII十六进制的大小,然后是换行符,接着是相应大小的数据块,再是换行符。然后你发送另一个大小,另一个块,如此反复,并在发送结束时通过发送一个大小为0)来表示数据结束。

CVE-2024-2961 Iconv 实现PHP引擎 RCE(译)

使用HTTP块编码编码的数据

在示例中,第一个块是8字节长,第二个是17字节长(11h),最后一个是13字节长。去块后的结果将是:This is how the chunked encoding works

有了这个过滤器,调整我们的buckets大小听起来像小菜一碟:在每个bucket中,我们加上我们想要的大小(比如第一个是0x148,第二个是0x100等),然后我们放入数据,最后是一个0,表示我们完成了。

CVE-2024-2961 Iconv 实现PHP引擎 RCE(译)

dechunk设置buckets

看起来不错,但这将不会起作用。尽管buckets被单独处理,但它们并不是独立的:它们都被解析为一个大数据流。当dechunk过滤器处理流时,它读取第一个bucket中的大小,0x148,取出0x148字节,然后读取一个大小为,这导致它停止解析。它不会去第二个bucket。它完全停止解析。我们的操作最终结果是,我们从拥有几个buckets(好)回到了一个bucket(坏)。

幸运的是,找到一个绕过这个问题的方法并不太难:在每个bucket中,我们提供一个大小和一个数据块。为此,我们不是简单地写一个大小,而是用成千上万的零填充它,以便得到这样的东西:

CVE-2024-2961 Iconv 实现PHP引擎 RCE(译)

正确为dechunk设置buckets

现在,在处理完第一个bucket后,dechunk解析器跳到第二个bucket,准备读取一个新的大小,然后跳到第三个bucket,依此类推。它起作用了!我们现在可以创建我们想要的尽可能多的buckets,并且是我们想要的大小。我们向前迈出了一大步。

空闲列表控制:写入什么-在哪里

我们的目标现在是通过用值48h(ASCII中的H)覆盖某些指针的LSB来改变一些空闲列表。为了无条件获得相同的效果,我们针对大小为0x100的chunks,因为chunks地址的LSB总是零。这意味着我们溢出的效果总是相同的:给chunk指针添加0x48

为了利用,我们遵循一个非常标准的6个步骤的程序。我们将大小为0x100的chunks的空闲列表命名为FL[0x100]

CVE-2024-2961 Iconv 实现PHP引擎 RCE(译)

控制FL[0x100]

假设我们已经通过分配许多0x100 chunks来填充堆。因此,在内存中某处,我们有三个连续的空闲chunks ABC,其中AFL[100]的头。A指向BB指向C。我们可以分配这3个(步骤2),然后再次释放它们(步骤3)。此时,空闲列表被反转:我们有CBA。然后我们再次分配,但这次我们在C48h偏移处放入一个任意指针0x1122334455(步骤4)。我们再次释放它们(步骤5),得到与步骤1完全相同的状态,但这次有一个小区别:在C+48h处,我们有一个任意指针。现在我们可以执行来自chunk A的溢出,这将改变B中包含的指针。现在它指向C+48h,结果空闲列表现在是BC+48h0x1122334455。通过另外3个分配,我们可以让我们的PHP在我们的任意地址上分配。

我们现在有了一个写入什么-在哪里;这差不多要结束了。

但是让我回到漏洞利用的实现。在这里描述的各个步骤中,我们有被分配然后释放的chunks。但我们不能完全摆脱buckets:我们只能改变它们的大小。然而,我们只对大小为0x100的chunks感兴趣。就好像其他chunks不存在一样。因此,我构建了每个bucket作为一个HTTP-chunked套娃

CVE-2024-2961 Iconv 实现PHP引擎 RCE(译)

一个bucket的套娃:在每次dechunk时它的大小改变

在漏洞利用的每一步,都会调用dechunk过滤器:每个bucket的大小因此改变。一些的大小变为0x100,因此在利用中”出现”,一些变得更小,因此”消失”。这为我们提供了一个完美的方法,让buckets在特定时刻具体化,并在我们不再需要它们时将它们丢弃。

解决了这个问题,让我们进行代码执行。

代码执行

尽管我们通过读取/proc/self/maps看到内存区域,我们并不精确地知道我们在堆中的位置。幸运的是,我们可以通过定位PHP的堆完全忽略这个问题。由于其对齐方式(~0x1fffff)和大小(2MB),它很容易识别。在其顶部是一个zend_mm_heap结构,包含非常有用的字段:

struct _zend_mm_heap {
    ...
    int                use_custom_heap;
    ...
    zend_mm_free_slot *free_slot[ZEND_MM_BINS]; /* 用于小尺寸的空闲列表 */
    ...
    union {
        struct {
            void      *(*_malloc)(size_t);
            void       (*_free)(void*);
            void      *(*_realloc)(void*, size_t);
        } std;
    } custom_heap;
};

首先,它保存了每个空闲列表。通过覆盖空闲列表,我们得到了任意数量的写入什么-在哪里,具有任意大小。我们可以使用这些来覆盖最后一个字段,custom_heap,它包含emalloc()efree()erealloc()的替代函数(类似于glibc中的__malloc_hook及其同类)。然后,我们把use_custom_heap设置为1,并在一个bucket上调用free(),给我们带来了一个带有控制参数的任意函数调用。由于我们可以使用文件读取访问二进制文件,我们可以构建花哨的ROP链,但我们想要尽可能通用的东西;因此,我将custom_heap._free设置为system,允许我们以CTF的方式运行任意的bash命令。

注意:我省略了利用的许多(许多)细节,但漏洞利用有详细的注释。

漏洞利用性能

我们的漏洞利用运行3个请求:它下载/proc/self/maps,并提取PHP堆的地址和libc的文件名。然后,它下载libc二进制文件以提取system()的地址。最后,它执行一个最终请求来执行溢出并执行我们的任意命令。

它的表现非常好:

  • 任何目标上都能工作
    • 从PHP 7.0.0 (2015) 到 8.3.7 (2024)
    • 任何PHP应用程序:Wordpress、Laravel等
  • 它是100%可靠
    • 由于其实现方式,它永远不会(?)产生崩溃
    • 一个感觉像Web漏洞利用的二进制漏洞利用!
  • 有效载荷小于1000字节
    • 通过使用zlib.inflate和仅12个过滤器,有效载荷非常小
    • 它适合在GET请求中
  • 自包含漏洞利用
    • 不需要发送其他参数作为GET或POST:漏洞利用自己做所有事情,从填充堆到设置空闲列表,最后获得代码执行

这是一个单一的、小于1000字节的有效载荷,可以在10年的PHP版本中导致远程代码执行

演示

为了说明,我将针对在PHP 8.3.x上运行的WordPress实例。为了引入文件读取漏洞,我添加了存在CVE-2023-26326漏洞的BuddyForms插件(v2.7.7)。这个漏洞最初被报告为PHAR反序列化漏洞,但Wordpress没有任何反序列化装置链。无论如何,目标运行在PHP 8+上,因此不受PHAR攻击的影响。


注意:如果你阅读了原始发现者的公告,你可能会看到在文件读取原语之前,执行了一个getimagesize()调用来检查文件是否为图像。因此,为了允许漏洞利用读取/proc/self/maps和libc,我使用了wrapwrap让它们看起来像GIF图像。

影响

这对PHP生态系统有什么影响?这不是一个新的漏洞,而是一个新的漏洞利用向量。然而,有多种方法可以让PHP读取文件;文件读取原语在Web应用程序中非常普遍。

标准接收器

显然,PHP的每个标准文件读取接收器都受到影响:file_get_contents()file()readfile()fgets()getimagesize()SplFileObject->read()等。文件写入也受到影响(file_put_contents()及其同类)。

利用漏洞

SQL注入到RCE

如果您在PDO/MySQL中获得SQL注入,您可能能够使用LOAD DATA LOCAL INFILE

LOAD DATA LOCAL INFILE 'php://filter/cnext...';

XXE

XXE现在变成了RCE。

<?xml version="1.0" ?> 
<!DOCTYPE root [     
<!ENTITY exploit SYSTEM "php://filter/cnext...">
]>
 
<root>&exploit;</root>

作为PHAR的替代

与PHAR攻击相反,仅对文件执行检查的函数,如file_exists()is_file()受影响。然而,在其他情况下,可以像演示中所示,将此漏洞用作PHAR攻击的替代品。禁用phar://或更新到PHP 8并不能拯救你。

解析库

任何以某种方式操作URL的库都可能是易受攻击的。以下是我在研究此漏洞时发现的一些新目标:

  • meyfa/php-svg:最受欢迎的SVG操作库
  • symfony/translation:XLIFF解析器是易受攻击的

例如,PHP-SVG库可以用这样的负载进行攻击:

<svg width="100" height="100">     
<image href="php://filter/cnext/..." width="1" height="1" /> 
</svg>

HTML到PDF解析器,如dompdf、tcpdf等也可能是目标。

类实例化

有时,当攻击PHP时,您会遇到以下原语:

new $_GET['cls']($_GET['argument']);

PTswarm的优秀博文描述了许多从这个原语中获得文件读取的方法,所有这些方法都可以触发此漏洞。示例包括SoapClientImagicktidySimpleXMLElement

作为装置链的改进

如果您找到一个文件读取unserialize()装置链,您可以使用此漏洞将其升级为RCE。由于最近的应用程序以及PHP库越来越多地使用类型,这可能很有用。

其他,可能

只要您控制文件读取或文件写入接收器的前缀,您就拥有RCE!

时间线

  • 去年 发现崩溃
  • 二月 开始研究漏洞
  • 3月26日 向glibc安全团队报告漏洞
    • 他们做得很棒!
  • 4月4日 向Linux发行版报告漏洞
  • 4月17日 作为CVE-2024-2961发布漏洞

注意:glibc安全团队反应迅速、礼貌且技术能力强。他们在一周内发布了补丁(以及随之而来的所有内容)。非常感谢!

结论

这结束了关于CNEXT (CVE-2024-2961)的第一部分系列。该漏洞利用现在可在我们的GitHub上获得。仍然有更多内容需要探索:直接调用 iconv()怎么样?如果文件读取是的,会发生什么?

在第二部分中,我们将深入PHP引擎,针对一个非常流行的PHP webmail中的iconv()调用。我将描述这种直接调用对PHP生态系统的影响,并展示一些意想不到的接收器。最后,在第三部分中,我们将介绍盲文件读取的漏洞利用。

敬请关注!

– END –


原文始发于微信公众号(3072):CVE-2024-2961 Iconv 实现PHP引擎 RCE(译)

版权声明:admin 发表于 2024年5月28日 下午1:45。
转载请注明:CVE-2024-2961 Iconv 实现PHP引擎 RCE(译) | CTF导航

相关文章