CVE-2024-2961是最近公布出现在iconv中的漏洞。原发现者cfreal也陆续展示了关于它的利用方式[1] [2]。我之前是看过part 1 [1],其中通过使用PHP filters来操作内存的方式相当有趣。在part 1的结尾,作者提到接下来还会公布两个有趣的利用方式。最近,恰巧一个朋友让我帮他调试一下刚出来的part 2 [2]。相比于part 1,part 2中的利用场景则是一个真实PHP应用: Roundcube。然而,调试过程非常曲折,即使我本人非常熟悉PHP里面的东西。所以我觉得有必要写下来。从这篇文章中你可以了解和学习到以下几点:
- cfreal提供的利用过程不是开箱即用的 (其中某些内存地址是写死的)。
- cfreal文章中的利用方式和给出的exp.py存在差异,并且部分文章中提到的利用手法存在问题。
- 我是如何解决上述问题,并且让利用过程能够去尽可能地适应不同的目标对象。
- 如何简单地检测是否可以在roundcube上利用CVE-2024-2961。
如果读者没有看过[1] [2],我建议先看它们再往后看这篇文章。因为这仅仅是一篇comment性质的文章。同时也意味着我更加关注于原文中的一些细节和未曾提到的东西。两者结合起来看,可能收获更大。
0x01 环境搭建
glibc降级
1 | apt install libc6-dev=2.31-0ubuntu9 libc-dev-bin=2.31-0ubuntu9 libc6=2.31-0ubuntu9 |
我这里用的ubuntu20.04,其他ubuntu版本可以参见以下链接,来回滚安全更新。
https://launchpad.net/ubuntu/+source/glibc
php-fpm 编译
PHP版本: 387b1c62bfbe5e14647620f132a00880ccfcf0c6
(PHP8.3.10-dev)
编译选项:
1 | CFLAGS='-g -O0 -fPIC' CXXFLAGS='-g -O0 -fPIC' ./configure --prefix=/opt/php-8.3 --enable-fpm --with-fpm-user=www-data --with-fpm-group=www-data --enable-exif --enable-gd --with-freetype --with-jpeg --with-ldap --enable-intl --with-pdo-mysql --with-pdo-pgsql --with-pdo-sqlite --with-zip --with-pspell --with-mhash --with-pic --enable-ftp --enable-mbstring --enable-mysqlnd --with-password-argon2 --with-pdo-sqlite --with-sqlite3 --with-curl --with-iconv --with-openssl --with-readline --with-zlib |
选择自己编译PHP是为了更好的后续调试工作。另外可以通过设置以下参数来控制php-fpm master只spawn一个worker方便调试.
1 | pm = static |
roundcube配置
本文使用的roundcube 1.6.1 (原文是1.6.3),具体配置可以参见roundcubemail-docker。同时使用的是docker-mailserver作为邮件服务。
- roundcubemail-docker: https://github.com/roundcube/roundcubemail-docker
- docker-mailserver: https://github.com/docker-mailserver/docker-mailserver
(抱歉,我太懒了,没有整理出来一个compose yaml : ()
0x01 关于CVE-2024-2961的基本理解
iconv只能溢出1-3字节
1 | // iconvdata/iso-2022-cn-ext.c |
可以看到它在无bound check的情况下,往后写了4个字节。那么这里为什么不能溢出4个字节呢? 因为在进入上述过程时,肯定就保证了output buffer有空余,那么其至少是空余1个字节的,所以最多只可以溢出3个字节。于是,我们知道这里发生越界的条件有两个:
- output buffer空余不大于4;
- 下一个需要编码的字符要满足进入上述过程的分支条件。
可以溢出的3字节种类:
1 | $*H [24 2A 48] |
php_iconv上的利用点
我们用php_iconv
表示PHP内置函数 iconv
,将其和glibc中的iconv
函数区分开来。php_iconv
一开始会申请 l + 32
的内存作为output buffer,其中l
为传入的字符长度。那么想要利用它,我们需要进行以下步骤:
- 需要让长度
l
为input string扩张到l + 32
大小左右,比如l + 32 - 1
,l + 32 - 2
,l + 32 - 3
。 - 编码特定字符,实现4字节越界写。
作者展示了两个gadgets:
劄\n
: 4字节 => 10字节.劄劄\n
: 7字节 => 12字节.
可以看到这个编码似乎带有一些压缩功能。任意组合上述两种gadgets时,都是固定增长的。例如
劄\n劄\n
: 8字节 => 20字节劄\n劄劄\n
: 11字节 => 22字节
思考一下,如果我们想要通过组合上述gadgets通过php_iconv
新增30
个字符,那么应当如何组合呢? 我们设需要x
个劄\n
和 y
个劄劄\n
, 那么其相关约束为:
1 | (10 - 4) * x + (12 - 7) * y = 30 ===> 6 * x + 5 * y = 30 ====> x = 5, y = 0 |
另外,如果我们考虑在input最后放一个gadget来做4字节溢出,这个gadget你可以只用单个字符 i.e., 劄
。那么input的一般形式应当如下:
1 | {normal_chars : z} | {"劄\n劄\n劄\n劄\n劄\n" : 5 * 4} | {"劄" : 3} |
其中{normal_chars}
可以看做长度为z
的ascii字符的padding i.e., aaaaaa....
,可以用来控制目标output buffer的大小 (l + 32
)。经过php_iconv
之后会变成:
1 | {normal_chars : z} | {"..." : 5 * 10} | {"..." : 9} |
如果我们希望在处理最后一个劄
的时候,output buffer只空余2个字符,对应的约束就变成了
1 | output_buffer_len - input_len = 2 ===> |
可以看到它是与z
无关的。这正是cfreal给出的cnext-exploits/pocs/poc.php
中等式的来源。
0x02 利用HTTP请求参数布局内存
PHP处理请求参数的过程
PHP在处理HTTP请求时,会解析对应的请求参数,将每个参数用合适的内存存放。因此我们可以通过控制参数的大小,来控制PHP申请对应大小的内存,从而操作PHP内存。比如参数a=1
,PHP会将其解析为对应key (a
) 和value (1
),然后用合适内存块来存储它们。具体来说,对每一个参数k=v
,PHP会将其放到对应PHP数组上,例如GET参数会放在变量$_GET
上。这样一次操作会涉及如下内存操作
alloc(sizeof(str("k")))
alloc(sizeof(str("v")))
值得注意的是,当filter扩展[3]开启的时候 (默认编译选项下是开启的),会额外多一次关于v
的内存申请,即
alloc(sizeof(str("k")))
alloc(sizeof(str("v")))
alloc(sizeof(str("v")))
这是因为filter扩展对每种类型的参数,它会单独维护一个PHP数组来存储具体的值。那么为什么k
只申请了一次内存呢?这涉及到PHP内部针对字符串的部分优化。对于k
,PHP会使用一种叫internal string的字符串表示来维护,是一种带缓存机制的字符串表示。即当你创建某个PHP字符串时,它会首先去寻找之前是否创建过相同的字符串,如果存在,则直接使用它。因此这里只有一次关于k
的内存申请。
比较有趣是,你不但能够控制PHP内存申请,同样可以在某种程度去控制PHP内存释放。比如存在两个key相同的参数k=v1&k=v2
,PHP在处理后者的时候,会把前者的value给覆盖掉,即前者占用的内存会被释放掉。具体来说,PHP在处理k=v1&k=v2
会发生以下内存操作:
alloc(sizeof(str("k")))
alloc(sizeof(str("v1")))
alloc(sizeof(str("v1")))
free(sizeof(str("v1")))
alloc(sizeof(str("v2")))
free(sizeof(str("v1")))
alloc(sizeof(str("v2")))
可以看到是内存操作是有顺序的。接下来,理解这其中的顺序对我们后面利用构造非常重要。首先我们用$_POST
和$_POST_COPY
来表示前面提到的两个用来存储POST参数的PHP数组。那么处理k=v1&k=v2
的过程可以简单地表示为:
update_array($_POST, "k", "v1")
: A1, A2update_array($_POST_COPY, "k", "v1")
: A3update_array($_POST, "k", "v2")
: F4, A5update_array($_POST_COPY, "k", "v2")
: F6, A7
我将每个处理过程和相应的内存操作对应起来了。
另外,我们还可以注册数组类型的参数,例如a[k]=v
。它处理过程于上述类似:
arr = get_or_create_array($_POST, "a")
alloc(sizeof(str("a")))
alloc(empty_array())
(我们假设a
是第一次出现)
update_array(arr, "k", "v")
alloc(sizeof(str("k")))
alloc(sizeof(str("v"))
arr = get_or_create_array($_POST_COPY, "a")
alloc(sizeof(str("a")))
alloc(empty_array())
(我们假设a
是第一次出现)
update_array(arr, "k", "v")
alloc(sizeof(str("v"))
值得注意是在get_or_create_array
中的key是不走internal string机制的,即会独立申请内存。
最后,我们讨论一个特殊参数注册过程, a[k1]=v1&a[k2]=v2&a=k3
,这里处理过程如下:
arr = get_or_create_array($_POST, "a")
update_array(arr, "k1", "v1")
arr = get_or_create_array($_POST_COPY, "a")
update_array(arr, "k1", "v1")
arr = get_or_create_array($_POST, "a")
update_array(arr, "k2", "v2")
arr = get_or_create_array($_POST_COPY, "a")
update_array(arr, "k2", "v2")
update_array($_POST, "a", "k3")
update_array($_POST_COPY, "a", "k3")
值得一说是的,最后9和10步对应处理a=k3
,这两步会首先释放(析构)掉前面申请的PHP数组。在释放一个PHP数组时,它里面的元素会逐个释放,按照它们被插入该数组的顺序。
PHP内存管理基本概念
之前我在关于CVE-2023-3824的文章[4]中简单提到过PHP的内存管理。内存申请通常分为小内存和大内存申请。 对于小内存 (8 - 3072字节) 申请,PHP会首先向系统申请一块大内存 (memory chunk) (2M),然后根据内存申请需求,在上面分割出来各种大小的slots其使用。特别地是,通常会再整数倍的内存页(page)上划分slots。比如对于8字节slot,PHP会拿出来1个page (4096),那么就会得到512个8字节slots。这些slots会用一张单链表(freelist)连接起来,对于未来小于或者等于8字节内存申请,可以直接从freelist上取slot。对于小内存回收,PHP会将其重新放到对应的freelist上,并且最近释放的小内存总是freelist的最前面。对于不同大小的小内存,PHP会预先申请不同数量的slots,具体的策略可以在[5]中看到。当一个memory chunk被使用完成之后,PHP会再次申请一个新的memory chunk,将它们用一个循环链表连接起来。文章中并不涉及大内存的申请,所以这里我们略过。
布置期望内存环境
结合前面我们提到的关于PHP处理HTTP请求的过程和PHP内存管理机制,我们实际是可以布置近乎于任意内存环境的。我们通过几个小例子来感受一下。这里我们假设只针对某个特定大小的小内存来布局,它的大小我用x
来表示。再给出几个可控内存操作:
alloc_key(x)
: 通过参数中key的大小,去控制PHP申请1个大小为x
的slot.alloc_value(k, x)
: 通过参数中value的大小,去控制PHP申请2个大小为x
的slots,其key为k
.free_value(k)
: 通过覆盖某个key为k
的参数,去释放它之前value占据的两个slots.
我们来研究下面几种freelist情况,我们假定A, B , C, D是4个在地址上连续的大小为x
的slots,A所在的地址最低。其中->表示next slot。
case 1: A -> B -> C -> D
freelist上为顺序的slots,这容易在PHP初始化freelist时候达到。当一个freelist没有了可用的slots之后,PHP会按照前面提到的方式为补充。在初始完成之后,freelist上就是顺序的slots。
因此,这里我们需要申请大量x
-slots,让freelist重新初始化即可。其过程为
alloc_key(x)
alloc_key(x)
- ...
这个量最好接近PHP初始化时产生的x
-slots数量,使得尽可能把之前申请的都用完。
case 2: B -> A -> C -> D
相较于case 1,这里交换了A和B的位置。因此,这里我们需要首先构造case 1,再交换最前面两个slots。其过程为
- 构造case 1: A -> B -> C -> D
alloc_value(k, x)
: C -> Dfree_value(k)
: B -> A -> C -> D
只需要一次alloc/free即可交换对应的slots。
case 3: A -> D -> B -> C
想较于前面两个cases,这里变化比较大,我们还是需要先构造case 1,然后再做后续操作。其过程为
- 构造 case 1: A -> B -> C -> D
alloc_value(k1, x)
: C -> Dalloc_value(a[k2], x)
: emptyfree_value(k1, x)
: B -> Aalloc_value(a[k3], x)
: emptyfree_value(a)
: A -> D -> B -> C
在第5步之后,假设这里我们使用是POST参数,那么$_POST["a"]
和$_POST_COPY["a"]
里面的元素和其占据slot分别为:
$_POST["a"]
:{k2 : C, k3 : B}
$_POST_COPY["a"]
:{k2 : D, k3 : A}
那么经过第6步之后就会变成我们期望的样子。
case 4: A -> O -> D -> B -> C
在case 3的基础上,case 4讨论是我们想要在A和D之前再插入一个任意的slot。 显然我们需要先构造出case 3,然后再做后续操作。但是在构造case 3之前我们需要一些额外的工作,其过程为
alloc_value(k1, x)
: 任意freelist- 构造case 3: A -> D -> B -> C
free_value(k1, x)
: O1 -> O2 -> A -> D -> B -> Calloc_key(x)
: O2 -> A -> D -> B -> Calloc_value(k2, x)
: D -> B -> Cfree_value(k2)
: A -> O2 -> D -> B -> C
上述操作可以一般化,比如将A往前移动n次。
case 5: A -> C -> E -> G 并且B, D, F, H 已经被allocated
在case 5里面我们研究如何构造一张类似网(net)一样的freelist,即两个相邻slots,其中一个已经被allocated了,而另外一个还在freelist里面。这个net实际非常容易构造,但是容易让人复杂化 (比如我)。我们只需要合理的利用PHP数组的析构即可。其过程如下:
- 先构造case 1: A -> B -> C -> D -> E -> F -> G -> H
alloc_value(a[k1], x)
: C -> D -> E -> F -> G -> Halloc_value(a[k2], x)
: E -> F -> G -> Halloc_value(a[k3], x)
: G -> Halloc_value(a[k4], x)
:free_value(a)
: H-> F -> D -> B -> G -> E -> C -> Aalloc_value(a[k5], x)
: D -> B -> G -> E -> C -> Aalloc_value(a[k6], x)
: G -> E -> C -> A
如果我们不care它们在freelist的顺序,其实做到这一步就够了,更多只是希望是net的形状。另外,去reverse它们也比较简单,合理的alloc/free即可,这里我们就不探讨了。
case 6: F1 -> F2 -> F3 -> ... -> Fn -> expected_freelist
case 6讨论是一个非常实际的问题,我们期望的内存布局往往需要保持某个特定的PHP脚本执行的地方。然而,利用PHP处理HTTP请求构造的内存布置,极有可能被后续的PHP引擎本身或者PHP脚本运行所破坏。在part2 中,作者也提到了这一点。因此,我们需要去规避在这些过程里面的内存操作对我们的影响。有一个很简单的优化规避方法是尽可能使用目标应用的比较少的小内存类型,比如使用一些较大的小内存,但是我们还是从根本上解决这个问题。另外一个非常自然的想法就是在我们构造的freelist之前去填充一些可用的slots,让这些去满足额外的内存操作。但是,要填充多少slots,我们并不知道,这跟目标环境有关系。因此这里可能存在一个多次尝试的过程。一般地,我们填充过程如下:
如果n
是偶数:
(n/2) * alloc_value(k_i, x)
- 布置我们期望的内存环境
(n/2) * free_value(k_i)
如果n
是奇数:
((n+1)/2) * alloc_value
- 布置我们期望的内存环境
(n/2) * free_value(k_i)
alloc_key(x)
整体来看,需要把我们构造的过程夹在中间。
总的来说,通过HTTP参数来进行内存布局,可能未来会成为一个远程利用PHP漏洞的范式。
鉴赏一个非常难搞的内存布局
通过以上的6个cases,读者对利用HTTP请求来布置内存应该有一定的感觉。但是现实里面我们需要处理的问题,往往是让人猝不及防的。它还是和case 6 讨论的问题有关系。如果我们不能通过padding slots来规避额外的内存操作影响,该怎么吗? 现实中可能存在这样一个问题,
- 最初的freelist: F1 -> F2 -> F3 -> F4 -> A -> D -> B -> C,其中A -> D -> B -> C是我们期望的内存。
- 经过一段时间的PHP脚本执行,到达了特定的程序位置,freelist变成了: F1 -> D -> B -> C.
具体来说,在某个时间段,我们同时使用了F1, F2, F3, F4, A,但最后我们释放了F1,而其他slots没有被释放。这种情况我们并不能通过简单地通过增加padding slots来规避,比如我们再加一个F5
- F1 -> F2 -> F3 -> F4 -> F5 -> A -> D -> B -> C
- F1 -> A -> D -> B -> C
这个时候虽然A没有被使用,但是前面多了一个F1。
这里我们有两种解决思路:
思路1: 交换A和F1的位置
如果我们将初始freelist变成 A -> F2 -> F3 -> F4 -> F1 -> D -> B -> C
,最终也可以达到我们期望的效果。这个操作等价与把A往前移到4次,因此操作可以参考case 4.
思路2: 想办法让F1被拿走
当我们填充一个F5之后,在到达特定程序位置之前,改变程序路径,让F1被拿走。亦或者你可以找到一个稳定消耗对应slots的地方。相比于思路1,这个操作比较依赖目标PHP应用。
这里比较好的策略是如果思路2可用,首先选择思路2,其次思路1。 这是为什么呢? 因为在实际中我们并不知道需要将A移动多少次,其次你可能还需要移动D, B, C... 这就麻烦了。但是两个方法都需要你去找到一个合适数量的padding slots,我觉得可以先本地测试,然后远程目标在这个范围内上下浮动。
0x03 Playing with Roundcube
前面我们实际已经提到了部分利用roundcube的细节问题,这里我们开始去真正的审视roundcube的利用过程。原文中的核心思路分为两个阶段:
- 泄露PHP堆上某个地址。
- 通过第一步确定地址,确定
$_SESSION
相关结构的地址; 然后去修改$_SESSION
的内容,去触发某个稳定的反序列链 i.e., (unserialize($_SESSION["preferences"])
)。
原文中利用方式的一些问题:
- 阶段1的泄露方式不稳定,整体上比较粗糙。
- 阶段2确定
$_SESSION
相关结构的地址时,用的写死offset。这导致在实际过程中基本是无法利用的,除非是和原作者一样的环境。
另外给出的exp.py和文中利用方式存在差异,文中部分利用细节是有问题的。
这篇文章针对性对其两个阶段分布做出了优化:
- 让阶段1更加稳定,告诉你如何根据目标环境进行调整参数。
- 只要阶段1能成功,阶段2可以确定大概率成功。
下面我们每一个阶段逐步分析。
阶段1: 堆地址泄露
首先介绍一下这个过程的大致思路:
- 首先在某个特定程序位置构造出freelist: A -> D -> B -> C;
- 在后续执行中,位置P0处的
php_iconv
拿到A,实现1字节越写B的next_free_slot指针的低地址位。然后,php_iconv
执行结束后会释放A,此时freelist就变成了A -> D -> B -> C', 其中C'为C中间的某个位置; - 位置P1拿到A,freelist变成了 D -> B -> C';
- 位置P2拿到D,这个D实际被一个PHP字符串S1使用,freelist变成了B -> C';
- 位置P3拿到B,freelist变成了C';
- 位置P4拿到C',因为C'和D存在重叠区域,在写C'过程中会修改S1的长度。
- 位置P5,拼接S1到其他字符串,然后输出拼接字符串。此时会输出多余的字符串,其中就包含了我们需要的堆上地址。
各P0-P5准确位置:
- P0:
roundcubemail-1.6.1/program/lib/Roundcube/rcube_charset.php:334 (iconv)
- P1:
roundcubemail-1.6.1/program/include/rcmail_sendmail.php:817 (implode)
- P2:
roundcubemail-1.6.1/program/include/rcmail_output_html.php:900 (sprintf)
- P3:
roundcubemail-1.6.1/program/include/rcmail_output_html.php:887 (json_serialize)
- P4:
roundcubemail-1.6.1/program/include/rcmail_output_html.php:900 (implode)
可以看到都是PHP内部函数消耗了相应slots。
我不想详细介绍上述整体过程,原文讲的很好。原文详细地讲述了是如何让每个位置恰好拿到对应的slots的。下面我们讲一些稍微细节的地方,这些细节大概率可以帮你回答你在读原文时产生的一些疑惑 (我希望)。
为什么选择了0x800作为目标slots大小?
前面提到的freelist上A,B,C,D都是0x800-slots。原文提到的原因如下:
sprintf()
allocates a chunk of size 0x800 to store the result string, which forces us into attacking on this chunk size.
这个sprintf
位于前面提到P2。你如果仔细查看了sprintf的代码[6],你会发现sprintf总是会申请240 * (2 ^ n)
大小的内存。当n = 3
等于3时,其申请的内存会对齐到0x800上。那么实际不使用0x800也是完全可以,比如还可以使用0x400 (n = 2
)。
一个异常0x800 slot
文章利用方式要求P0处的freelist为A -> D -> B -> C,以便于php_iconv
可以拿到A。传入这里的php_iconv
的值来源于_to
参数 (目标邮件地址),其参数大小l
接近0x800,以便于在使得l + 32
可以直接拿到0x800。但是非常致命一个东西来了,在P0位置的前面不远处,会调用一次mb_convert_encoding($input, 'UTF-8', 'UTF-8')
来处理_to
参数。Roundcube中的注释提到是为了清理掉一些invalid characters. 那么这里我们感兴趣的运行逻辑可以简化为:
1 | $out1 = mb_convert_encoding($input, 'UTF-8', 'UTF-8'); |
前面我们提到了_to
参数的大小是接近0x800,并且是可以对齐到0x800,意味着在mb_convert_encoding
这里会使用一个0x800-slot。这会导致什么问题呢? 在P0处理完之后,直到P1处理开始,freelist会变成 F -> A -> D -> B -> C。没错,多了一个0x800,导致后续利用出问题。这是作者文章中没有提到的东西,因此这是一个异常0x800-slot申请。
这个地方必须要解决掉,我的基本思路是让_to
变小,使得mb_convert_encoding
不会拿到0x800-slot,但是php_iconv
依然可以拿到0x800-slot并造溢出。这里借助了php_iconv
的多步转换。设_to
参数长度为l
,php_iconv
转换过程如下:
- output buffer初始化为
l + 32
,因为我们调小了l
,这一步并不会拿到0x800。 - 当初始output buffer被用完,但是input buffer还没有处理完时,
php_iconv
会扩容output_buffer。扩容之后的大小为2l + 32
,这时我们再拿到0x800,实现越界写。
那么这里如何计算l
呢? 其约束条件如下:
1 | align8(2 * l + 32 + 24 + 1) = 2048 ===> l = 995 |
其中24表示PHP string header大小,1字符串表示结尾的\x00
,align8
表示最终申请内存需要对其8的倍数。align8
这里比较关键,可以看到2 * 995 + 32 + 24 + 1 == 0x7FF
并不是 0x800。那么我们如果设计这995个字符呢? 它的形状应该如下
1 | P | n * "劄\n" | 劄 | |
我们使用劄\n
来扩容原始的字符串,最后依然是是用劄
来实现越界写,再补上一些合适ascii字符来作为padding。其对应的约束应当为
1 | (3 + 32 + 995) - (6x + 5y) = 1 ===> 6x + 5y = 1029 ===> x = 169, y = 3 |
首先此时需要多增长995个字符,1表示我们需要这时output buffer只空余1个字符,因为此时output buffer实际大小只有0x7FF。
拿到C'是implode()
而不是sprintf()
拿到C' slot的过程是发现在P4,文章中说是在最后sprintf()
中拿到了它。
The
sprintf()
call adds a few bytes to this, and results in a new chunk of size 0x800 being allocated, inC'
.
实际这是不可能做到的,而真正拿到C'实际是在P4中的implode()
。首先我们在P0这里改写了B的next_free_slot指针最低位,再结合我们在文章最初提到的溢出字符串组合。这个最低位只可能被改写成: 48, 49, 4A, 4B, 4C, 4D。相当于C' = C + b
,其中b
等于前面这些值。另外,我们在前面提到了sprintf()
在这里创建了一个长度为1920的字符串。因此,sprintf()
最多可以写到C + 1920 + 0x4D + 24
的位置上。其中1920 + 0x4D + 24 = 2021
是小于0x800,因此这里根本不能实现越界写。我不知道作者如何这样的认为是sprintf()
拿到了这个C'。
阶段2: 修改$_SESSION
首先介绍一下这个过程的大致思路:
- 借助阶段1得到的堆上地址,确定
$_SESSION
数组的bufferfly地址 A1,其最终会落在0x500-slot上。关于bufferfly相关内容可以看原文,也可以去看我之前写一篇文章[7]。 - 将0x400-slot作为
php_iconv
中目标溢出slot,在内存布置阶段,申请大量的0x400-slots,并且在这些slots中特定位置写入A1附近位置的地址A2,然后释放它们填充对应的freelist。其freelist为: ... -> A -> B -> C -> D -> ... - 在P0位置中的
php_iconv
拿到某个其中的0x400-slot,比如A,触发溢出,将其连续位置上的下一个0x400-slot B的next_free_slot指针改写。这个时候freelist就变成了 ... -> B -> C' -> A2 ..., 注意这个B需要存在与freelist之中,C'里面包含就是在step2里面写入的A2。 - 在P6位置处,大量申请0x400,最终拿到A2,写掉
$_SESSION
数组的bufferfly,新增一个$_SESSION["preferences"]
,它的值为可利用的反序列字符串。 - 为了存放字符串
"preferences"
和相关反序列字符串,并获取它们的地址。布置0x500-slots对应freelist成为一个net (参考前case 5),使得A1刚好落在这个net的某个洞上,而在其连续位置上的下一个0x500-slot中存放前面这些我们需要的字符串。自然地,这些字符串的地址为A1加上相应的offset。
其中P6位于roundcubemail-1.6.1/program/lib/Roundcube/rcube_mime.php:parse_address_list(...)
。
同样我不会详细介绍每一步的细节,原文写的比较好。这里我提两个细节问题。
如何确定$_SESSION
数组的bufferly地址
原文给出的exp.py里面用的是一个死offset。
SESSION_BUCKETS_ADDR = HEAP_BASE_ADDR + 0xA2000 - 0x100
这显然是没有办法在其他环境使用的,我们如何优化它呢? 这里我们可以来爆破这个地址,提前是让这个bufferfly靠近我们泄露在阶段1泄露的地址。为了实现这个前提,我们可以在阶段2开始之前,做一些额外的内存操作,使得阶段2的PHP内存初始环境,比较接近在阶段1在布置泄露的地址相关内存之后的内存环境。在阶段2里面做的这些额外内存操作,我将其称之为memory fix。
优化0x400-slots的freelist布置
原文给出的exp.py给出0x400-slots布置如下:
1 | 10 * alloc_value(a[i], 0x400) |
我们可以看一下这样布置之后freelist应该长是什么样子。在最初0x400-slots基本是没有怎么使用的,那么初始化状态下freelist应当为
1 | A -> B -> C -> D -> E -> F -> ... |
经过上述alloc/free之后,会变成
1 | ... -> F -> D -> B -> E -> C -> A |
这样的话,高地址位的slots总是会被优先使用,使得阶段2中第3步极有可能失败。这时候最好将其再逆序一下。
总的来说,这种data-only利用方式是极为有用的,阶段2整个利用过程还是比较流畅的。虽然阶段2里面还要一些比较重要的细节,但是这些细节作者都兼顾了,具体可以看作者的文章和给出的exp.py。
0x04 检测CVE-2024-2961
这里我们可以通过一串magic string来检测目标roundcube是否存在CVE-2024-2961。
aa劄劄劄a硇h瞘b碶c磘g硄d硇e礮
其大概原理就是,roundcube会首先根据_charset
参数,将接受到相关参数从利用iconv
utf8转到_charset
。这一步会发生overflow,导致最终output buffer少了一个字节。最后roundcube又会将前面转换得到的字符串再转回utf8来进行内部处理,最后输出。至于我是怎么构造这一串magic string的,可以参考ISO-2022-CN-EXT的标准和相关字符集。
0x05 改进的DEMO
最终改进的利用方式位于 https://github.com/m4p1e/php-exploit/blob/master/CVE-2024-2961/exp.py
相关的执行结果如下,我本地大概爆破了153 * 2次,这个次数可以改进。
0x06 总结
在这篇文章中我主要阐述了一些part2 [2]中的利用细节,以及我是如何处理它们的。非常适合想要深入理解PHP内核安全的同学去研究一下。
相关引用
- Iconv, set the charset to rce: exploiting the glibc to hack the php engine (part 1), https://www.ambionics.io/blog/iconv-cve-2024-2961-p1
- Iconv, set the charset to rce: exploiting the glibc to hack the php engine (part 2), https://www.ambionics.io/blog/iconv-cve-2024-2961-p2
- PHP filter extension, https://www.php.net/manual/en/book.filter.php
- CVE-2023-3824: 幸运的Off-by-one (two?), https://m4p1e.com/2024/03/01/CVE-2023-3824/
- Zend/zend_alloc_sizes.h, https://github.com/php/php-src/blob/master/Zend/zend_alloc_sizes.h
- PHP sprintf(), https://github.com/php/php-src/blob/master/ext/standard/formatted_print.c#L755
- PHP之殇 : 一个IR设计缺陷引发的蝴蝶效应, https://m4p1e.com/2024/03/13/bad_php_ir/