翻译:https://www.ambionics.io/blog/iconv-cve-2024-2961-p1#sql-injection-to-rce
介绍
几个月前,我偶然发现了一个存在于glibc(Linux程序基础库)中已有24年之久的缓冲区溢出漏洞。尽管该漏洞可以在多个知名库或可执行文件中被触发,但实际上很难被利用利用。原因有二:一是漏洞给予的可操作空间十分有限;二是漏洞的利用需要满足一些非常苛刻的前置条件。在寻找可利用目标的过程中,无法找到可利用的目标是常有的事。然而,在PHP环境中,这个漏洞却展现出了其独特之处,并证明能通过两种不同的方式有效利用PHP引擎。
由于涉及的材料众多,关于此漏洞的影响和利用将分三部分系列进行记录。在本系列的第一部分中,我将描述我是如何遇到这个漏洞的,为何合适的攻击目标稀少,并最终深入到PHP引擎内部,展示一个新的利用途径:在PHP应用中将文件读取基本操作转换为RCE
远程代码执行。
如果您不熟悉 Web 开发、PHP 或 PHP 引擎,请不要介意:我将一路解释相关概念。
发现:一个关于过滤器的故事
在PHP中的文件读取原语
首先,我们来了解一下基础知识。假设在进行评估时,你发现了一个文件读取原语,例如下面这个:
echo file_get_contents($_GET['file']);
你能用它做什么?嗯,显然是读取文件。例如,您可以阅读 /etc/passwd
。但 PHP 还允许您使用其他协议,例如 http://
或 ftp://
。因此,您可以使用http://google.com
让 PHP 为您获取 google 的首页;或者使用 ftp://user:passwd@ftp.target.com/file.bin
从 FTP 服务器下载文件。但这还不是全部。 PHP 还实现了自定义协议,例如 phar://
。
phar:// 让您可以读取 PHAR 存档的内部内容。 PHAR 代表 PHP 存档,就像 JAR 代表 Java 存档一样。它是一组文件,例如:
- 源代码
- 资源
- 序列化的元数据
这个协议多年来一直是PHP的致命弱点,因为当你使用它访问一个PHAR文件时,其元数据会被反序列化。常见的PHAR攻击方式如下所示:
- 将PHAR归档文件上传至目标服务器(PHAR文件非常灵活,可以使其看起来像是一张图片、一个PDF文档或其他任何类型的文件)。
- 使用文件读取基本操作访问PHAR文件,路径为
phar:///path/to/file.phar/test
。 - 任意载荷在此过程中被反序列化。
将反序列化转换为代码执行有多种方法,但人们通常依赖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
过滤器将资源的内
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的出色研究,比如这里、这里或者这里。实际上,通过精确挑选过滤器(一个过滤链),攻击者可以做一些惊人的事情,比如完全改变一个文件的内容,或者使用基于错误的预言机逐个提取其字节。
例如,这里有一个过滤器链,它会在/etc/passwd
前添加Hello world!
:
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
链来为数据流添加前缀,但不能添加后缀。因此我着手开发一种算法来实现后者。
- 注:该研究产生了一个于 2023 年 12 月发布的工具:wrapwrap。
当时,我对字符集或编码一无所知(坦白说,我现在也分不清楚它们的区别)。为了起步,我编写了一个暴力破解脚本,该脚本将几个iconv过滤器叠加在一起,并展示结果。大概是这样的:
php://filter/convert.iconv.A.B/convert.iconv.C.D/convert.iconv.E.F/resource=data:,test123
在某个时刻,我的模糊测试工具崩溃了。
由于我大部分时间都在使用PHP,我很快就将矛头指向了它。但我当时并不知道,这个bug实际上存在于更底层的调用链路中:一直深入到glibc库的底层。
CVE-2024-2961:glibc 中的bug
iconv() API
当PHP从一个字符集转换到另一个字符集时,它使用iconv
,这是一个用于“使用转换描述符将输入缓冲区中的字符转换为输出缓冲区”的API。在Linux系统上,这个API由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
字节的数据,或向 outbuf
写入超过 outbytesleft
字节的数据。永远不吗?嗯,理论上...
转换为 ISO-2022-CN-EXT 时出现越界写入
恰好,在将数据转换为ISO-2022-CN-EXT字符集时,iconv可能在写入输出缓冲区之前未能检查是否有足够的空间剩余。
实际上,ISO-2022-CN-EXT是一系列字符集的集合:当需要编码一个字符时,它会选取适当的字符集,并发出一个转义序列以指示解码器需切换到该字符集。
下面的代码片段负责发出这样的转义序列。它由三个if块组成,每个块向outbuf(由outptr指向)写入不同的转义序列。如果你查看第一个if[1]
,你会发现它前面有一个额外的if()块来检查输出缓冲区是否足够大以容纳四个字符。而其他两个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\0\0)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演示了该bug:
/*
$ 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): %zd\n", 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()最多写入8字节,但实际上已写入了9字节。
检查提交历史记录时,我注意到这个 bug 很古老:它出现在 2000 年,已经有 24 年的历史了。
现在,可以用这个bug做什么?
条件和原语
通过这个漏洞,我实现了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 代码点(codepoint)。
现在,请记住:缓冲区管理是调用者的责任。当libxml2使用iconv()将我们的文档转换回其原始字符集时,它会分配一个输出缓冲区,该缓冲区的容量是输入缓冲区的四倍(代码)。这对我们来说太大了:我们无法触及缓冲区的边界以实现溢出。这是一个死胡同。
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字节的内存块(即chunk)的指针。当你完成对这块内存的使用后,通过调用efree(ptr)
来释放它。PHP中的内存块有多种大小(8, 0x10, 0x18, ... 0x200, 0x280, ...)。
PHP堆由一个2MB大小的区域组成,被分割成512个页面,每个页面大小为0x1000字节。每个页面上可能包含特定大小的chunks。例如,第10页可能包含大小为0x100的chunks,第11页包含大小为ox38的chunks,第12页包含大小为ox18o的chunks等等。各chunk之间没有元数据信息存在.
当你释放一个块时,它会被放置在一个名为空闲列表的单向链表的开头。每种大小的块都有一个对应的空闲列表。例如,如果我释放了一个大小为0x38的块,它将被放入大小为0x38的块的空闲列表中。如果我释放了一个大小为0x200的块,它将被放入大小为0x200的块的空闲列表中...
为了分配N字节的空间,PHP会查找对应大小的空闲列表,取出头部元素并返回。如果空闲列表是空的(即所有可用的块都已被分配), PHP会查看堆元数据以找到未使用的页面。然后在该页面上创建空白块并将其放入空闲列表中。
空闲列表遵循后进先出(LIFO)原则,意味着当我释放某个大小的块时,它会变为该尺寸下free list的头节点. 当进行内存分配操作时,则直接从头部取走所需部分. 这与glibc中的tcache机制非常相似但无限制使用次数.
PHP 堆的可视化表示 |
在上面的例子中,我们左边有一个堆的视觉表示。它包含512个页面,这里第5页存储大小为0x400的块。如果我们查看这一页的内容,可以看到它包含4个块(因为4 × 0x400 = 0x1000,即一页的大小)。这里,块#1和#3已被分配,而块#2和#4已被释放。因此,它们处于大小为0x400的块的空闲列表中。
空闲列表是一个单向链表,每个未分配的块在其前8个字节内包含指向下一个空闲块的指针。这就是我们在块#2中看到的:一个指向0x7ff10201400 的指针,那是大小为0x400 的下一个空闲块地址。现在, 如果我们从 块 #1 溢出到 块 #2, 我们会覆盖掉这个指针. 这对于利用来说是一个好起点: 即使只有一字节溢出, 我们也能改变空闲列表指针, 从而更改空闲列表。
应该注意PHP对每个HTTP请求创建新堆。这是远程PHP利用困难的原因之一 - 但将在第二部分介绍。
PHP filters内部结构
既然我们已经了解了PHP如何分配和释放内存,接下来可以探讨一下PHP是如何处理php://filter/
字符串的。我们很幸运,这里无需深入了解内部PHP结构,例如zval
、zend_string
、zend_array
等。
为了处理过滤器,PHP首先会获取流(即读取资源)。它将流存储在一系列桶中,这些桶是双向链接的结构,每个桶包含一定大小的缓冲区。以我们的/etc/passwd
示例来说,可能会有3个桶:第一个可能包含文件的前5个字节,第二个桶再增加30个字节,第三个桶则再增加1000个字节。它们连接在一起构成了一个bucket传送带系统。
一个包含/etc/passwd的3桶接力队列 |
这是一种将流表示为不同大小缓冲区集合的标准方法。你可以想象这就像一个网络接收到的数据包列表。第一个数据包包含前N字节的数据,第二个数据包包含接下来的M字节,以此类推。
既然PHP已经将资源的内容读入到一个由“桶队”(bucket brigade)表示的流中,它就可以在其上应用过滤器了。它取第一个过滤器并对第一个“桶”进行处理。为此,它会分配一个与“桶”缓冲区大小相同的输出缓冲区(在我们的例子中是5字节)并进行转换。例如,如果过滤器是string.upper
,它会将输入缓冲区中的每个小写字符转换为其在输出缓冲区中的大写等价物。然后它可以创建一个新的指向这个缓冲区的“桶”。
将 string.upper 应用于 Bucket Brigade |
随后,它处理第二个桶,接着是第三个桶,以此类推直至到达最后一个桶。现在,每个输出桶都形成了一个新的传送带序列。接下来,它可以在这个序列上应用第二个过滤器,并持续进行直到处理完最后一个过滤器。
情况和目标
我们已经完成了定义。让我们回到最初的漏洞:文件读取。
echo file_get_contents($_GET['file']);
既然我们可以利用convert.iconv.XXX.ISO-2022-CN-EXT
过滤器触发内存损坏,我们的目标是实现远程代码执行。而且,这看起来并不难于利用。
首先,由于我们拥有文件读取的基本能力,我们可以读取二进制文件(如PHP、Apache等)。我们甚至可以下载libc库来检查其是否已打补丁!对于ASLR和PIE我们也无需担忧:我们可以读取/proc/self/maps
。最后,感觉上我们几乎可以任意分配或释放缓冲区使用桶机制,这一点非常便利。
另一方面,你可以在多种情境下获得文件读取的能力:可能在运行在PHP 7.0上的Symfony 4.x中得到它;或在某个鲜为人知的Wordpress插件中运行在PHP 8.3上;甚至在黑盒评估期间也可能获取到这种能力。理想的漏洞利用需要具备韧性:它必须在大多数目标上都能工作,无需任何调整即可奏效。
利用
考虑到这些因素,我们开始进行利用。其思路是利用单字节缓冲区溢出来修改指向空闲块的指针的最低有效位(LSB),以便获得对某些空闲列表的控制权。
Single bucket
我们面临的首个问题是,尽管拥有bucket队列技术,PHP却仅创建一个bucket。无论是读取文件还是请求HTTP URL,亦或是使用ftp://
协议,PHP都只生成包含整个响应内容的一个bucket。这至少可以说是非常不切实际的:我们无法利用这些单一的bucket来填充堆、喷射数据或操作修改后的空闲列表。
试想一下:借助单个bucket,我们可以溢出到一个空闲块并修改空闲列表,但随后我们就用完了所有的bucket,而要利用已修改的空闲列表进行操作至少还需要再分配两个以上的新资源!
幸运的是,有一种过滤器为我们带来了转机:zlib.inflate
。该过滤器接收我们的流并对其进行解压缩处理。为此目的,它会分配一个大小为8页(0x8000字节)的缓冲区并将流膨胀至其中。如果这个缓冲区不足以容纳全部数据时, 它将再创建一个相同大小的新的缓存区域以存储剩余部分;若前两者仍不够用, 则继续增加更多此类型的内存空间.每次扩充出来的每个区块都会被加入到各自的bucket当中去。完美:通过此滤镜功能就可以随心所欲地产生所需数量的各种不同规格型号的bucket设备了——这是一个重大进步。
应用 zlib.inflate 创建多个bucket |
然而,这些存储bucket的缓冲区大小为0x8000,这个大小并不利于利用;这种大小的缓冲区分配方式与我之前所述的不同,并且在释放后不会进入空闲列表。我们需要调整我们的存储bucket大小。
正确分块
为了实现这一目标,我们将使用一个未被PHP文档记录但广受攻击者熟知的方法:dechunk
。该过滤器用于解码经过HTTP-chunked编码的字符串。
HTTP-chunked是一种非常简单的编码方式,它通过数据块(非堆内存块)发送数据。首先,你发送一个以ASCII十六进制表示的大小,紧接着是一个换行符,然后是相应大小的数据块,再接一个换行符。接着你发送另一个大小、另一个数据块、再一个大小、又一个数据块,并通过发送大小为0(零)来指示数据的结束。
使用 HTTP-chunked 编码进行编码的数据 |
在示例中,第一个块长8字节,第二个块长17字节(11h),最后一个块长13字节。解块后,结果将是:这就是分块编码的工作原理。
通过这个过滤器,调整我们的桶大小听起来就像儿戏一样简单:在每个桶中,我们首先以前缀形式添加所需的数据大小(例如第一个桶为0x148,第二个桶为0x100等),然后放入数据,最后以一个最终的0表示完成。
为 dechunk 设置bucket |
看起来不错,但实际上行不通。尽管桶被单独处理,它们并非独立:所有桶都被解析成一个大流。当dechunk过滤器处理这个流时,它会读取第一个桶中的大小,即0x148,取出0x148字节后,接着读到一个大小为零的值,这导致它停止解析。它不会转到第二个桶。而是完全停止了解析过程。我们操作的最终结果是,从拥有多个桶(好)退回到了只有一个桶(坏)的状态。
幸运的是,找到绕过这个问题的方法并不太难:在每个桶中,我们提供一个大小和一个数据块。为此,我们不是简单地写入一个大小,而是在其后面填充数千个零,以达到如下效果:
正确设置 dechunk 的存储桶 |
现在,处理完桶1之后,去块解析器跳转到桶2,准备读取新的尺寸,接着是桶3,以此类推。它成功了!我们现在可以根据需要创建任意数量的桶,设定我们想要的尺寸。这是一个巨大的进步和飞跃。
空闲列表控制:write-what-where
我们的目标是通过将某些指针的最低有效位(LSB)覆盖为值48h(ASCII中的H)来修改某个自由列表。为了无条件地达到相同效果,我们针对大小为0x100的块,因为这些块地址的最低有效位总是零。这意味着我们的溢出效果始终相同:给一个块指针增加0x48。
为了利用这一漏洞,我们遵循了一个非常标准的6步程序。我们将大小为0x100的块的自由列表命名为FL[0x100]。
控制 FL[0x100] |
考虑我们已经通过分配大量0x100大小的块成功填充了堆。因此,在内存中的某个位置,我们有三个连续的空闲块A、B和C,其中A是FL[100]的头。A指向B,B指向C。我们可以分配这三个块(步骤2),然后再次释放它们(步骤3)。此时,空闲列表被反转:我们得到的是C→B→A。接着我们再次进行分配,但这次我们在C的偏移量48h处放置了一个任意指针0x1122334455(步骤4)。再次释放它们(步骤5)后,状态与步骤1完全相同,但有一个小差异:在C+48h处存在一个任意指针。现在我们可以从块A执行溢出操作,这将改变B中包含的指针的位置。它现在指向了C+48h的位置,结果使得空闲列表变为 B→ C+48h → 0x1122334455. 再经过三次更多的分配操作,就能够使PHP在我们的任意地址上进行分配.
我们现在拥有了一个“写入任何内容到任何位置”的条件;这几乎完成了整个过程.
但是让我回到漏洞利用的实现上来.在描述的各种步骤中,有部分数据段会被分配并随后释放掉.但我们不能真正摆脱这些"桶":只能让它们的尺寸发生变化而已.然而,我们只对尺寸为0x10的数据段感兴趣——就好像其他大小的数据段并不存在一样!所以我就把每个"桶"构建成了HTTP分片式的俄罗斯套娃:
一个俄罗斯bucket套娃:它的大小在每个 dechunk 上都会变化 |
对于每一次利用步骤,都会调用dechunk过滤器:因此每个桶的大小会发生变化。有些桶的大小变为0x100,从而在利用过程中“显现”出来,而有些则变得更小,因而消失。这为我们提供了一个完美的方法,使得桶能在特定时刻实质化出现,并在不需要它们时将其丢弃。
解决了这个问题后,我们开始执行代码。
代码执行
尽管我们通过读取/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]; /* free lists for small sizes */
...
union {
struct {
void *(*_malloc)(size_t);
void (*_free)(void*);
void *(*_realloc)(void*, size_t);
} std;
} custom_heap;
};
首先,它控制了所有的自由列表。通过重写这些自由列表,我们能够获得任意数量、任意大小的“写入-何处-写什么”(write-what-where)漏洞。利用这些漏洞,我们可以覆盖最后一个字段custom_heap
,该字段包含了emalloc()
、efree()
和erealloc()
的替代函数(类似于glibc中的__malloc_hook
及其同类)。接着,我们将use_custom_heap
设置为1,并对一个桶调用free()方法,这样就能实现对一个可控参数的任意函数调用。由于我们可以使用文件读取功能访问二进制文件,本可以构建复杂的ROP链,但我们希望尽可能保持通用性;因此我将 custom_heap._free
设置为 system
函数, 这样就可以在CTF环境中执行任意的bash命令了。
注:关于这一攻击手段的具体细节我略去了很多(实际上非常多),但整个攻击过程都有详细的注释说明。
利用性能
我们的利用程序执行了三个请求:首先,它下载/proc/self/maps
文件,并从中提取PHP堆的地址和libc库的文件名。接着,它下载libc二进制文件以提取system()
函数的地址。最后,执行一次最终请求来触发溢出并执行我们预设的任意命令。
它的表现非常好:
适用于任何目标
- 从 PHP 7.0.0 (2015) 到 8.3.7 (2024)
- 任何 PHP 应用程序:Wordpress、Laravel 等。
100%可靠
- 由于它的实现,它永远不会(?)产生崩溃
- 二进制漏洞利用感觉就像网络漏洞利用!
有效负载小于 1000 字节
- 通过使用 zlib.inflate 和仅 12 个过滤器,有效负载非常小
- 它适合 GET 请求
独立的漏洞利用
- 无需以 GET 或 POST 方式发送额外参数:该漏洞利用程序自行完成所有操作,从填充堆到设置空闲列表,最后获取代码执行
这是一条单一的、不足1000字节的数据包,能够在过去十年间的所有PHP版本中导致远程代码执行。
demo
为了说明,我将针对运行在PHP 8.3.x上的WordPress实例。为了引入文件读取漏洞,我添加了BuddyForms插件(版本2.7.7),该插件存在CVE-2023-26326缺陷。此漏洞最初被报告为PHAR反序列化漏洞,但WordPress没有任何反序列化gadget链。无论如何,目标系统运行的是PHP 8+,因此它不受PHAR攻击的影响。
视频地址:https://www.ambionics.io/images/iconv-cve-2024-2961-p1/demo.mp4
注意:如果您阅读了原发现者提供的建议,您可能会注意到在文件读取基本操作之前,会执行一个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
XXES现在是RCES
<?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的优秀博文描述了多种从该原生组件读取文件的方法,这些方法均可用于触发漏洞。示例包括使用SoapClient
、Imagick
、tidy
或SimpleXMLElement
等。
作为一种改进的小工具链
如果你发现了一个文件读取反序列化(unserialize()
)的小工具链,你可以利用这个漏洞将其升级为远程代码执行(RCE)。随着近期应用程序的发展以及PHP库越来越多地使用类型,这一技巧可能会派上用场。
其他情况
只要你能控制文件读取或写入端点的前缀,你就可能实现远程代码执行(RCE)!
时间线
- 去年发现crash
- 二月开始修复bug
- 3 月 26 日 Bug 报告给 glibc 安全团队
他们做得非常出色! - 4 月 4 日 向 Linux 发行版报告bug
- 4 月 17 日 Bug 发布为 CVE-2024-2961
注意:glibc 安全团队快速、礼貌且技术精湛。他们在一周内发布了补丁(及其附带的所有内容)。非常感谢!
结论
至此,我们完成了关于CNEXT(CVE-2024-2961)系列的第一部分。该漏洞利用现已发布在我们的GitHub上。还有更多内容等待探索:直接调用iconv()会怎样?如果文件读取是不可见的,又会出现什么情况?
在第二部分中,我们将深入探讨PHP引擎,针对一个在非常流行的PHP网页邮件客户端中发现的iconv()调用进行分析。我将描述这种直接调用对PHP生态系统的影响,并向您展示一些意想不到的隐患点。
最后,在第三部分中,我们将讨论盲目文件读取的利用方法。
敬请期待!