N1CTF24 PHP Master Writeup

WriteUp 1天前 admin
105 0 0

0x01 介绍

在刚刚过去的N1CTF24上,我出了一道关于PHP的pwn题,其中涉及到的漏洞[1]是真实存在的,并且目前依然没有被修复。非常遗憾,期待的PHP master并没有出现在这次的比赛中,让我们期待下次的PHP rising star xd。在这篇文章中我会讲到以下内容:

  1. 出题思路。
  2. PHP pwn下的一般性解决路径。
  3. PHP Master的解题思路。

希望能帮助到大家。相关的利用方式: https://github.com/m4p1e/php-exploit/tree/master/n1ctf24-php-master

0x02 出题思路

对于题目中出现的PHP漏洞,我已经在文章[2]中讨论过了。首先,就它的重要性而已,它可以作为一个PHP sandbox escape方案去攻击目前所有的PHP7-8解释器。因此我想以它为背景来做一道题,但是我不希望以sandbox escape的形式呈现 (即允许选手可以执行任意PHP代码)。于是我转而在考虑一个场景:

  1. 一个没有明显漏洞的PHP应用,
  2. 但跑在一个有缺陷的PHP解释器上。

最后,我们通过和这个PHP应用来交互来攻击背后的PHP解释器。这看起来是一个可能在现实中碰到的场景,于是我写了一个满足上述要求的PHP应用。另外,为了降低难度,我在这个PHP应用里面加一个可以充当PHP反序列化链的black door。其实我有点担心,这会不会干扰选手的做题思路,去往pwn unserialize这个函数上靠,所以我这专门上了gzdeflate对数据进行了处理,告诉选手不要希冀这里。最后希望是没有出现这样的情况 (如果有,那只能say sorry了)。所以这里还引入了一点点关于web的知识点。

0x03 一般性解决路径

这里我分享一下,我个人在尝试研究PHP时的一般性解决路径。首先你要熟悉一些编程语言向的基本知识:

  1. 基本变量类型(string, double, integer, array, …)在PHP内部表示。
  2. 能够快速找到PHP内置函数(native function) 的定义。例如搜索, PHP_FUNCTION(xxx)
  3. 大概知道编程语言编译器的概念,能够快速得到目标PHP代码,被编译成了哪些中间指令。可以参考zend_compile.c, 注意PHP编译过程会现将目标代码转换成抽象语法树,再从语法树上编译得到最终中间指令。当然你也使用一些工具,来生成中间指令,比如vld或者PHP本身的opcache。
  4. 大概知道编程语言解释器的概念,能够快速找到PHP解释器关于某个指令的解释过程。可以参考zend_vm_def.h,它里面包括了所有PHP指令的解释过程,它并不是executable的,它还需要进行一次处理就可以得到实际的解释过程zend_vm_execute.h (大几万行代码,看着可能非常难受)。我推荐尽量看前者弄清楚语义,需要下断点的时候,去后者里面找。
  5. 了解PHP内存管理,它的实现并不复杂,我的建议是直接看代码,去zend_alloc.hemallocefree的实现。
  6. PHP基本的生命周期。弄清楚这一点有助于你在比较关键的点下断点。

了解上面的东西,感觉也就差不多了。后面提及一些比较有用的细节。

如何愉快地编译PHP ?

比如就本题而言,我可能会使用下面的编译指令:

1
CFLAGS="-O0 -g -fno-omit-frame-pointer" LIBS='-ldl' ./configure --prefix=/opt/php-8.3 --enable-fpm --with-fpm-user=www-data --with-fpm-group=www-data --with-zlib

这里有2个原则:

  1. 不要直接使用--enable-debug, 它会带来一些非预期的行为。比如在debug模式下,PHP会trace内存申请,它会导致你每次申请的内存会大一些 (用于记录一些额外的信息),可能会导致你exploit失败。所以这里尽量使用gcc的参数项。
  2. 不要enable一些无关的PHP扩展,增加调试的复杂性。

如何愉快地使用gdb?

  1. PHP官方在它们的php-src里面提供的一个.gdbinit, 可以帮你分析PHP里面的基本对象。
  2. php-mm-gdb-debugger 里面提供了一个gdb脚本,可以帮你打印PHP堆上的一些信息。

如何愉快地调试PHP-FPM ?

我们可以通过设置以下参数来控制PHP-FPM master只spawn一个worker方便调试

1
2
pm = static
pm.max_children = 1

然后我们可以直接使用gdb去直接attach这个唯一的worker。

如何愉快地调试PHP应用 ?

就本题而言,我们不仅要调试PHP解释器的状态,可能也需要去调试其上的PHP应用状态。现实场景中更是如此。比如我想调试执行到PHP应用中某一行时,PHP解释器的状态是怎样,该如何操作? 比较理想操作是直接对PHP做插装去实现改该功能,这可能需要coding。在不想coding的情况,你也可以用gdb去实现这一点:

  1. 对这行代码中使用的函数下断点,或者你人为插入某些无害函数。
  2. 对这行代码中使用的指令下断点。

是否可以使用类似xdebug这样工具呢? 我没有评估过它的加载是否会对PHP解释器内部状态有影响,比如堆布局,所以我这里不推荐。

0x04 PHP Master WP

最后终于进入到了writeup环节,这里存在两个可能触发我们关注的PHP漏洞的路径:

  1. Dataform->insert方法中,如果我们没有传入$_POST['content'],则会造成$content是一个undefined variable。在第106行,在对Dataform->data进行数组操作的过程中,PHP会因为$content是undefined弹出一个notice message,从而进入我们设置好的error handling。在第242行中,我们如果传入了$_POST['_display_data'],则会覆写Dataform->data。最后等到error handling返回时,继续第106行的数组操作,产生了UAF。
  2. 同样地,在Dataform->append方法中,也有上述问题。

因此这里的UAF对象是Dataform->data,我们会分别使用这里的两个UAFs,实现两个不一样的功能。这里首先给出利用的大致路线:

  1. 泄露PHP heap上的某个地址,并且我们希望这个地址和$_SESSION (对应的PHP数组)的地址比较接近。
  2. 根据泄露的地址去猜$_SESSION的地址 (bruteforce)。
  3. 猜到$_SESSION的地址之后,将$_SESSION['data']覆写成我们利用black door构造好的反序列化字符串。

内存布局

通过本地调试你可以知道$_SESSION大小,它是一个32个元素大小的PHP数组,它会在PHP heap上占据一个大小为0x500-block (这里我们用xxx-block来表示大小为xxx bytes的内存块)。如果我们想要达成利用路线的第一步,我们可能需要进行如下操作:

  1. Dataform->data也变成容量为32个元素的PHP数组。
  2. 同时考虑后面我们提到的第三步利用细节,我们也要控制Dataform->data经过反序列之后的大小刚好落在0x500-block。所以这里对写入到Dataform->data的元素大小也有要求。另外,我们观察到该PHP应用对所有数据都用gzdeflate,所以在exploit中我使用inflated_data生成了一些无法压缩的数据,来进行有效的大小扩展。

以上就是exploit中fix_data在做的事情。但是这只能保证$_SESSIONDataform->data落在了相同大小的内存块上 (0x500-block)。再结合PHP内存管理的相关内容[3]:

  1. PHP采用了memory slots的手法, 即针对小内存 (8 – 3072 bytes), 它会在连续的页上按大小划分slots (bins). 举个例子, 对于8 bytes内存, PHP会拿出1个page (4096 bytes) 出来, 将其划分为512个bins供给小于或者等于8 bytes的内存申请. 而对于320 bytes内存, PHP会拿出5个pages出来, 再上面划分64个bins供给 256< x <=320的内存申请. 小内存的回收采用是经典地free_lists.
  2. PHP使用memory chunk (跟arena是有些相似的)来作为小内存的操作对象. 一个memory chunk默认大小为2M (0x200000), PHP在其上根据需求来划分不用小内存区域. 当一个memory chunk使用完了之后, PHP会申请新的chunk. 然后用链表将这些chunks连接起来.

为了它们两个相距的比较近,我们可以采用以下操作:

  1. 首先消耗掉PHP已经划分好的0x500-blocks。
  2. 再继续申请0x500-blocks迫使PHP重新划分新的0x500-blocks,它们都位于连续的内存页上。对于这样的内存块,我们就认为它们相距的很近。

可以在exploit中的memory_planning里面看到这些操作。注意memory_planning是通过构造HTTP请求参数来操作PHP内存方式的,我在之前的文章[4]中非常详细了阐述这种方法。

还有一点也比较重要,我们的exploit是通过多次发送http requests来实现的,并不是在一次http request完成的。那么这里有一个问题,如何确保某次request泄露出来的heap上的地址,在后续requests也能用? 因为你每次构造的http request并不是一样的,这就会导致目标内存布局发生变化,你需要额外注意这一点。

地址泄露 (leak)

这里利用前面我们提到的第二个UAF,即发生DataForm->append中的那一个。当内存布局结束,后续大致路线如下:

  1. 在第243行,对DataForm->data进行赋值,使得本身指向的0x500-block (我们用A表示)被释放。
  2. 在第160行,在gzinflate中拿到A,使得gzinfalate返回结果落在A上。这里的返回结果作为error message会被我们拿到。
  3. 在第121行,这里相当于是将一个浮点数 (其二进制表示9223372036854775807) 和一个null进行字符串链接,然后将结果写到A上。这里的浮点数会先进行一个类型转换,转换成对应字符串表示,理所当然的其地址就被写到A上。

这里关键是UAF的使用方需要是一个我们可以观测的对象,比如这里的error message。

任意地址写 (modify_session_data)

在第三步中我们需要覆写掉$_SESSION['data'],这个写操作,我们通过对$_SESSION['data']进行UAF来实现。你可能会想,前面我们不是仅仅提到了两个关于DataForm->data的UAF吗? 哪里来的新UAF ? 这里我们需要好好利用一下我们提到的第一个UAF,即发生在DataForm->insert中的那一个。我们考虑第106行这里的赋值操作$this->data[$i][$j] = $content;,这里正常赋值如下:

  1. 尝试释放掉$this->data[$i][$j]原本的内容。
  2. 再将$content赋值为$this->data[$i][$j]

综上,我们可以在$this->data[$i][$j]布置一个fake string,这个fake string指向$_SESSION['data'],造成一个意外释放。随后通过这个伴随的UAF拿到这块内存,改写其内容,现实对$_SESSION['data']的覆写。这里大致路线如下:

  1. 通过http request,在指定位置$this->data[$i][$j]上写入fake string指向$_SESSION['data']
  2. 在第243行,对DataForm->data进行赋值,使得本身指向的0x500-block (我们用A表示)被释放。
  3. 在第160行,在gzinflate中拿到A,使得gzinfalate返回结果落在A上。维持我们写入的fake string不变。
  4. 在第121行,释放掉fake string指向的$_SESSION['data'] (我们用B表示)。
  5. 在第81行,函数trim拿到B,即将$_POST[callback]写到B上。实现对$_SESSION['data']的覆写。

这里比较重要是需要利用赋值过程构造一个伴随的UAF。

引用

  1. bad error handling, https://github.com/php/php-src/issues/13754
  2. PHP之殇 : 一个IR设计缺陷引发的蝴蝶效应, https://m4p1e.com/2024/03/13/bad_php_ir/
  3. CVE-2023-3824: 幸运的Off-by-one (two?), https://m4p1e.com/2024/03/01/CVE-2023-3824/
  4. 关于 “CVE-2024-2961 glibc iconv exploitation (part 2)” 注解, https://m4p1e.com/2024/07/03/CVE-2024-2961/

原文始发于maplgebra:N1CTF24 PHP Master Writeup

版权声明:admin 发表于 2024年11月13日 上午10:30。
转载请注明:N1CTF24 PHP Master Writeup | CTF导航

相关文章