介绍
几个月前,我发现了一个存在于glibc中的24年的缓冲区溢出漏洞,glibc是Linux程序的基础库。尽管在多个知名库或可执行文件中都可以触发这个漏洞,但它很少能被利用——虽然它提供的攻击面不大,但却需要难以实现的前提条件。寻找目标的过程大多令人失望。然而,在PHP中,这个漏洞却大放异彩,证明了在两种不同的方式下可以利用其引擎。
在第一部分中,我演示了如何使用CVE-2024-2961将文件读取转换为远程代码执行。然而,该漏洞利用依赖于文件读取原语的输出。在本系列的第三部分也是最后一部分中,我们将探讨如何在没有任何输出的情况下利用同一个漏洞!
我们将在与第一部分相同的条件下进行:我们希望构建一个无崩溃、通用的漏洞利用,并且有效载荷相对较小,以便在GET请求中使用(小于7000字节)。
真是幸运
之前,我解释了如何使用CVE-2024-2961来构建针对如下代码的漏洞利用:
echo file_get_contents($_REQUEST['file']);
该漏洞利用依赖于三个步骤:
首先,读取/proc/self/maps
以找到glibc和PHP堆的地址,使PIE和ASLR失效; 然后,读取libc.so
以提取system()
、malloc()
和realloc()
的地址; 最后,发送精心构造的php://filter/...
负载,该负载会破坏堆、覆盖函数指针,并最终执行system("...")
。 获取file_get_contents()
函数的输出是前两个步骤所必需的,这些步骤提供了第三步所需的关键信息。
然而,很多时候,我们有一个文件读取漏洞,但没有输出。例如,我们可能会遇到如下代码:
if(md5_file($_REQUEST['file']) === '8f199aebac0036c0c1fa2304eecc3d54') {
echo "Valid file";
} else {
echo "Invalid file";
}
这还能被利用吗?好吧,人们已经证明,可以通过基于错误的Oracle远程转储文件。理论上,我们可以使用这种方法转储/proc/self/maps
和LIBC。但是,正如您所知,对于大型文件,这种技术表现不佳:构建的php://filter/
链会变得非常大,很快就会超过HTTP服务器的限制(例如,URL的约8000字符限制)。而LIBC很大(在我的系统上为2.2MB)。即使我们事先使用zlib.deflate
过滤器进行压缩,仍然会有大量字节,远远超出可能的范围。无论如何,另一个我们需要绕过的障碍是使用open_basedir
,它限制了可以从哪些目录读取文件。
因此,要盲目地利用这个漏洞,我们需要一种更接近标准二进制漏洞利用的不同算法:我们必须先获取泄露,然后进行任意读取,最后使用获得的信息进行写操作并获得代码执行。这意味着事情将会变得更加困难。
漏洞利用思路
不过,我们并不是从零开始!如果我们能够确定主PHP堆的地址以及system()
、malloc()
和realloc()
(在libc中)的地址,那么我们可以使用第一部分描述的“有视觉”的漏洞利用中相同的技术:将zend_mm_heap.custom_heap._free
更改为system
,并通过释放任意块来获得代码执行。
由于我们不能依赖文件来获取这些信息,我们可以依赖进程内存中的数据。如果我们能够构建一个原语来转储内存的一部分,就可以依次泄露指向不同内存区域的指针并解析它们以获得所需信息。换句话说,目标是获得任意读取原语,并使用它来解析一切,直到找到我们需要的信息。
为此,我们需要仔细使用php://filter
字符串来操纵PHP的堆。在第一部分中,我们了解了PHP如何在内存中表示流数据,使用桶队列,这是一个包含缓冲区和大小的桶的链表。通过精心选择过滤器和数据,我们学会了如何:
-
使用 zlib.inflate
过滤器分配任意数量的桶(字节缓冲区); -
使用 dechunk
过滤器任意选择它们的大小; -
通过创建 dechunk
俄罗斯套娃,使它们“出现”和“消失”,从而多次改变它们的大小。
覆盖桶
幸运的是,表示桶的C结构体php_stream_bucket
将成为我们利用的关键。
typedef struct _php_stream_bucket {
php_stream_bucket *next;
php_stream_bucket *prev;
php_stream_bucket_brigade *brigade;
char *buf;
size_t buflen;
uint8_t own_buf;
uint8_t is_persistent;
int refcount;
} php_stream_bucket;
桶通过其prev
和next
字段链接在一起,并持有对其队列的引用。它们持有buf
字段指向的数据,其大小存储在buflen
中。桶及其缓冲区是在堆上分配的。
如果我们能够通过内存损坏控制桶结构体,就可以泄露任意部分的内存:可以让buf
指向任何地方,然后使用php_filter_chains_oracle_exploit
(简称PFCOE)逐字节泄露内存。这是一种很好的Web和二进制结合的方法。
具体目标
我们希望修改堆中的php_stream_bucket
结构体,以更改其缓冲区的地址,然后使用PFCOE转储流。由于PFCOE使用base64,我们希望将buflen
设置为3,以便有一个对齐的base64四元组来转储。设计需要非常稳定,因为PFCOE会发出多个请求来转储每个B64数字,每次都会改变PHP堆的外观。如果我们的内存损坏在某些情况下失败,Oracle会出错,漏洞利用也会失败。
此外,我们希望漏洞利用发送的有效载荷适合放在URL中。换句话说,我们不希望它们的大小超过约7000字节。在“有视觉”的漏洞利用中,我们使用了12个过滤器的组合,因此最终的有效载荷大约为1000字节,非常小。然而,这里我们使用PFCOE转储字节,该工具非常字符密集,尤其是在尝试转储大流时:base64数字离流的开头越远,php://filter
负载就会变得越大。
覆盖0x30块
为了覆盖桶,我们设置堆以拥有一个0x400块的页面和一个紧邻其下的0x30块页面(php_stream_bucket
结构体的大小)。我们在顶部页面中有4个块A、B、C和D。然后,我们使用第一部分中的相同技术将块D移位,使其与0x30页面重叠。为了更详细地理解这个想法,让我们看看这个三步过程。首先,假设A已分配(步骤1),并且底部页面已填满桶:
我们可以使用 convert.iconv.UTF-8.ISO-2022-CN-EXT
过滤器从 B 触发 CVE-2024-2961,并使其溢出一个字节,直接进入 C 的第一个字节,从而覆盖其下一个指针的最低有效位(LSB),使其指向 D+48h(步骤 2)。此时,空闲列表中的第二个块比原来低了 48h 字节,因此与下一页的前几个字节重叠。由于 0x400 块可以容纳 0x381 到 0x400 大小的数据(更小的数据使用 0x380 bin 分配),我们可以利用这个错位的块来覆盖位于页面顶部的 php_stream_bucket 结构的部分或全部。
堆利用细节
堆设置不谈,利用似乎相当直接:我们只需部分覆盖桶的 buf
,使其指向稍高或稍低的位置,泄露部分堆内存。然后从中提取一些指针,再用这些指针泄露其他内存区域,如此往复。
遗憾的是,事情并没有那么简单:要部分覆盖 buf
,我们需要完全覆盖前面的字段。
typedef struct _php_stream_bucket {
php_stream_bucket *next;
php_stream_bucket *prev;
php_stream_bucket_brigade *brigade;
char *buf; // <----- 目标
size_t buflen;
uint8_t own_buf;
uint8_t is_persistent;
int refcount;
} php_stream_bucket;
next
和 prev
可以相对容易地设置为 NULL
,但接下来是 brigade
。虽然我们将其置为 NULL
也不会导致崩溃,但这会使我们的桶无法从链表中移除,从而产生无限循环。最终,进程会超时并终止,但我们无法利用修改后的桶。
因此,在开始之前,我们需要泄露桶队列的地址。
泄露队列
如果不在乎 PHP 崩溃,这不会是个大问题(记住,如果工作进程崩溃,PHP 会重新生成一个新的,且内存布局相同)。我们可以覆盖队列地址的最低有效字节,然后碰碰运气:如果 PHP 崩溃或进入无限循环,说明我们搞错了字节。如果没有,说明我们猜对了,可以继续猜测第二字节,第三字节,依此类推。
这种方法速度相当快,非常可靠,会使利用相对容易实现。但它会产生数百次崩溃,这是明显的利用迹象。此外,这是一个 Web 利用,按照我的标准,不允许使服务器崩溃。因此,我选择了无崩溃的利用方法。
无崩溃利用
注意:这里我们只是浅尝辄止,但写下每个细节会使这篇博客更长。如果你想了解更多细节,我已经在利用代码中添加了大量的注释。
泄露队列
为了泄露桶的 brigade
字段,我们将简单地颠倒顶部页面中错位块和底部页面中桶之间的分配顺序。这样,php_stream_bucket
结构就会被“印入”缓冲区!
假设我们已成功修改了 0x400
的空闲列表,使得头块位于 0x7fffAABB1C48
而不是 0x7fffAABB1C00
,因此与下一个页面重叠,而此时该页面包含大小为 0x30
的空闲块(步骤 1)。然后我们在该块中分配一个缓冲区(步骤 2),接着分配大量桶。其中一个将与缓冲区重叠,并写入其中(步骤 3)。
如果我们能够读取整个流,我们将能够看到桶结构及其指向队列的指针。但我们不能。因此,我们需要提取它。为此,我们使用 PFCOE,但首先必须将泄漏的数据移到流的开头——否则,如前所述,负载变得太大。
为了展示我们如何以最小的努力做到这一点,让我们可视化当印记发生时的流。
为了执行溢出,我们创建了块 B
、C
和 D+0x48h
。因此,我们至少有 3 个大小为 0x400
的桶。此外,通过创建大量缓冲区大小为 0x30
的桶,我们迫使 PHP 在底层页面中分配桶。首先想象一下,每个缓冲区都填充了空字节。我们有:
在最后一个0x400缓冲区中,在偏移量 ( 0x400-0x48 = ) 0x3b8处,印刻了一个存储桶结构。我们如何摆脱前两个存储桶的内容以及第三个存储桶的大部分内容?好吧,我们可以再次利用在dechunk泄露的数据前加上大小前缀,并用数千个零填充:
该流现在包含:
0000000000000000..0030↲
<fake bucket structure>
↲0↲...
就这样!dechunk现在,只需一个额外的过滤器 ,就可以清除除泄漏之外的所有物质,然后我们可以使用PFCOE转储泄漏。
任意读取
有了泄露的brigade
地址,我们现在能够完全控制一个bucket
,并因此控制其buf
和buflen
字段。终于!让我们再次可视化流。这次,我们使用移位的chunk来覆盖页面下方的第一个php_stream_bucket
结构:将其next
和prev
设置为NULL
,保持brigade
完整,并将buf
设置为0x112233445566
,buflen
设置为3。
我们最终覆盖了其中一个0x30
桶。由于我们将它的下一个指针置为null
,它变成了链表的最后一个桶,从而丢弃了其后的所有桶。
我们得到了泄漏的信息,但前面有很多多余的数据。为了移除这些数据,我们需要再次使用dechunk
:被覆盖的桶(在示例中为桶#190
)需要由一个特殊块(包含↲
,表示dechunk
大小头的结束)来前置。
流变成了(用 ???
表示3个泄露的字节):
0000000000000000..0003-
<假的桶结构>
@@@@@@@@@@...@@@@@@@@@↲
???
由于PHP实现的 dechunk
算法的慷慨性,应用过滤器会导致除了最后3个字节外其他所有内容都被移除。
但我们有一个问题:我们不知道覆盖了哪个桶!在示例中,它是第190个,但根据堆的状态,它可能是任何一个。
幸运的是,这可以通过额外的步骤轻松确定。我们在某个偏移量 i
处放置一个包含 0↲
的特殊桶:如果 dechunk
后流为空,则我们覆盖的桶在 i
之后;否则,它是在 i
之前。通过二分法进行,这只会给整个漏洞利用增加几次请求,这是完全可以接受的。
当我们获得这一最终信息后,我们可以构建可解块的结构,并仅保留我们想要的3个泄露字节。
终于!我们可以读取任意内存。但这种实现足够好吗?
悬而未决
虽然该实现实验室条件下表现很好,但我无法避免感到不满意。
你看,泄露字节需要时间。在很长一段时间内,PHP脚本的环境可能会改变,可能会分配更多的或更少的内存块。这让漏洞利用悬而未决:如果在运行的几分钟内,PHP脚本多分配或少分配了一个大小为_0x30_的内存块,被覆盖的块的偏移量会发生变化,从而破坏我们的dechunk
设置。Oracle测试会失败,结果,漏洞利用会错误地泄露一个字节,最终失败。
幸运的是,我找到了一个更加优雅的解决方案,大大强化了漏洞利用。想法是在覆盖其中一个桶之前,使桶的大小(buflen
)为零。这看起来像是一个微不足道的想法,但实际上很难实现:大多数过滤器实际上会在它们的 buflen
为零时删除一个桶(这合乎逻辑:为什么要保留一个空桶?)。最终的流将会是这样的:
攻击因此变得对堆结构的变化具有抵抗力,仅依赖于 0x400 分配的数量不变。幸运的是,这个块大小在PHP引擎中很少使用。
寻找 system()
有了我们干净可靠的任意读取能力,我们可以转储任何与获取代码执行相关的信息。遗憾的是,泄露字节的过程极其缓慢。在我的测试中,每秒只能得到大约2个字节(尽管我攻击的是附加了gdb的单工作进程Apache)。这使得浏览代码寻找ROP小工具显得完全不切实际。无论如何,我们不能对PHP版本或CPU架构做出任何假设:我们希望这是一个通用的漏洞利用。
我没有找到一种简单的「单一小工具」类型的漏洞利用,完全包含在PHP二进制文件中,所以我像第一次漏洞利用那样做:寻找 system()
、malloc()
和 realloc()
的地址,以便覆盖 zend_mm_heap.custom_heap
结构。以下是漏洞利用找到所需地址所经历的步骤:
-
泄露桶队列的头部,以获取某些PHP堆块 zend_mm_chunk
的地址; -
从堆块中泄露主堆 zend_mm_heap
的地址; -
解析堆元数据,找到包含 zend_array
结构的页面; -
在页面中找到这样的结构,并提取其 pDestructor
字段,其中包含zval_ptr_dtor
; -
从 zval_ptr_dtor
的地址回溯到PHP ELF的顶部; -
解析ELF的程序头,查找 _dynamic_
部分; -
找到 _STRTAB_、_SYMTAB_ 和 _JMPREL_
段; -
使用这些段找到LIBC中任何函数的地址; -
从该函数的地址回溯到LIBC ELF的顶部; -
解析ELF的程序头,查找 _dynamic_
部分; -
找到 _STRTAB_、_SYMTAB_ 和 _GNU_HASH_
段; -
在哈希表中找到 system()
、malloc()
和realloc()
的地址。
在所有内容都被转储后,我们获得了PHP主堆和 system()
的地址。漏洞利用发出最终请求以获得 代码执行,方式与原始漏洞利用相同。
快速追踪
正如你可能猜到的,这并不非常 快,但它带来了一些有趣的优化问题,我不得不解决。其中一个问题是直接使用 _PFCOE_
的API效率不高,因为我们经常不想转储内存,而是将其与值进行比较。例如,在寻找PHP二进制文件的ELF头时,我们遍历页面并将它们的前3个字节与 b'x7fEL'
进行比较。
一个幼稚的实现是转储4个base64位数(3个字节),并将其与 f0VM
进行比较。更好的实现是逐个比较base64位数。转储第一个位数,将其与 f
进行比较,然后继续。然而,最高效的实现是构建测试,直接询问服务器字符是否为 f
。结果是,用于比较内存与值所需的几十个请求会减少到一两个。我为一些冗余的base64位数实现了这种快速追踪技术,漏洞利用的速度大大提高。
然而,漏洞利用仍然 不够快:在我的实验室条件下,它运行大约15分钟。可以使用多线程加速某些部分,不过。
漏洞利用
漏洞利用可在我们的Github上(https://github.com/ambionics/cnext-exploits)
获得。请注意,这是一个PoC;它不一定总是能直接运行。然而,我有信心它通常情况下可以运行,而且在其他情况下,对于有动机的人来说,它仍然是一个非常好的起点。
由于漏洞利用运行时间很长,每个重要的值都可以通过CLI参数设置,允许你分多次迭代运行。
以下是一个完整的漏洞利用演示。它运行17分钟(加速8倍):
结论
这篇博文是关于iconv、PHP和CVE-2024-2961系列的第三部分。这个存在25年的漏洞,起初似乎无足轻重,却产生了两个漏洞利用案例:一个是提供了文件读取原语的新攻击向量,促成了系列的第一部分和第三部分,利用 php://filter
,第二个则是通过直接调用 iconv()
,展示了Roundcube RCE。
更重要的是,它提供了一种深入PHP复杂性的方法,理解其引擎,并展示了在这种美妙的语言上进行远程二进制漏洞利用的可能性。
原文始发于微信公众号(独眼情报):从设置字符集到RCE:利用 GLIBC 攻击 PHP 引擎(篇三)
- 左青龙
- 微信扫一扫
- 右白虎
- 微信扫一扫
评论