1. 从一个限制登录框开始讲起
搞渗透测试这些年,见过太多开发者在写登录功能时踩同一个坑:用户输入看起来正常,参数名也简单,后端逻辑也就那么几行代码,结果就是被一条精心构造的字符串直接打穿内网。今天要聊的unserialize反序列化漏洞,在PHP应用里出现频率极高,很多看似人畜无害的功能点,比如Cookie存登录态、购物车数据序列化、缓存文件持久化,都可能变成攻击者的突破口。
简单说,序列化是把对象变成字符串存起来,反序列化是把字符串还原成对象。本身设计是为了方便传值和存储,但问题在于,如果反序列化操作直接使用了用户可控的输入,而程序又恰好存在某些特定方法(PHP里叫魔术方法)会在对象还原时被自动触发,那攻击者就可以控制还原什么样的对象,进而触发危险函数,最终实现任意代码执行、文件读写、SQL注入,甚至拿下整台服务器。
适合看这篇文章的人有三类:刚入门想搞懂漏洞原理的渗透测试新手;在写PHP代码但对安全边界模糊的开发同学;以及准备面试时被问到“反序列化漏洞怎么利用”需要系统性答案的求职者。我会从原理讲到实操,再讲绕过手法和修复方案,最后附上我踩过的一些坑。
2. 序列化与反序列化的底层逻辑
2.1 PHP里对象是怎么被“打包”的
在聊漏洞之前,得先搞清楚PHP序列化的数据格式长什么样。下面是一段最普通的PHP类定义和它的序列化结果。
class User { public $name = 'admin'; public $is_admin = false; public $age = 28; } echo serialize(new User()); // 输出: O:4:"User":3:{s:4:"name";s:5:"admin";s:8:"is_admin";b:0;s:3:"age";i:28;}我来逐段拆解这个字符串的结构,你会看得很清楚:
O:4:"User"表示这是一个对象(Object),类名长度是4个字节,类名是User3表示有3个属性s:4:"name"表示属性名是字符串类型,长度4,值是names:5:"admin"表示属性值字符串,长度5,值是adminb:0是布尔值falsei:28是整数28
除了上面用到的类型标志,PHP序列化还支持这些:a代表数组,d代表浮点数,N代表NULL,O代表对象,C代表自定义序列化对象,R代表引用。攻击者最爱的就是O类型,因为只有对象才会触发类的魔术方法。
这种格式本身没有任何加密或者完整性校验,意味着谁拿到这段字符串,谁就能按照同样格式拼一个任意对象的序列化数据出来。如果程序在某个地方直接对用户输入做unserialize,那用户就控了“还原成什么对象”这个选项。
2.2 魔术方法为什么是漏洞的催化剂
PHP对象在生命周期里有很多特殊方法,不需要手动调用,满足条件就会自动触发,它们叫魔术方法。和反序列化漏洞直接相关的有这么几个:
| 魔术方法 | 触发时机 | 危害场景 | 利用难度 |
|---|---|---|---|
__wakeup() | unserialize()时被调用 | 执行任意代码、连接恶意服务器 | 低 |
__destruct() | 对象销毁时被调用 | 写文件、删文件 | 低 |
__toString() | 对象被当作字符串使用时触发 | 文件读取、SQL拼接 | 中 |
__call() | 调用不存在的方法时触发 | 链式调用跳板 | 中 |
__get() | 访问不存在属性时触发 | 链式调用跳板 | 中 |
__invoke() | 对象被当作函数调用时触发 | 链式调用跳板 | 中 |
这就引出了两个利用层面。第一层是“单一魔术方法直接getshell”,比如某个类的__destruct()里恰好有一条file_put_contents(),攻击者只要构造这个类的对象序列化数据,反序列化时类被还原,脚本结束对象被销毁,恶意代码就被写进文件了。这种利用方式叫做“一条链打到底”。
第二层是“多个类串联成POP链”。真实业务代码里,单独的魔术方法很少直接执行危险操作,通常是调用其他类的方法。这时攻击者就需要在当前代码环境中寻找一个类的方法A会调用另一个类的方法B,B又调用C,最终落到一个敏感函数上。这种由多个类方法调用拼接起来的利用链,就叫POP链(Property-Oriented Programming,属性导向编程)。
我打个比方,魔术方法就像是一堆搭好的积木,单块积木用处不大,但攻击者可以按图纸把它们拼成一座能发射的弩。图纸就是POP链,积木就是代码里已有的类和方法。
3. 漏洞出现的高频场景与根本成因
3.1 哪个环节最容易出问题
根据我实际做渗透测试的经验,反序列化漏洞最常出现在下面几类功能点里:
第一类是Cookie直接存序列化数据。很多早期PHP框架为了在无会话环境下维持登录状态,会把用户信息序列化后塞进Cookie。看起来省事,但Cookie完全由客户端控制,把Cookie里的序列化字符串拿出来改一改,再放回去,服务端解开时就被注入恶意对象了。
第二类是参数名直接进入unserialize。有些接口为了兼容旧逻辑,直接接收data、param这类参数值做反序列化。如果参数是GET或POST直接传的,那漏洞判定就是“无认证可利用”,风险会立刻拉满。
第三类是缓存系统和Session。把业务对象序列化后存到文件或Redis里,读取时再反序列化。缓存内容如果用用户输入拼了文件名,或者Session文件能被控制,同样会造成反序列化入口。
判断一个反序列化点是否可利用,有两个关键条件:输入点是否可控、输出点是否有危害。输入可控是前提,比如unserialize($_GET['data'])这种写出了一百遍的典型写法,不爆都难。危害可证明是第二步,需要找到代码里能构成利用链的类。
3.2 为什么会普遍存在认知偏差
说了这么多技术细节,我想专门花点篇幅聊聊开发者的认知问题。很多同学觉得“我的类又没有危险函数,怕什么反序列化”,这是最常见的误区。漏洞利用的价值并不取决于你写了什么危险函数,而取决于你的代码里有没有足够的类、方法、函数能够被攻击者串联起来。
换句话说,一个本身没有执行命令功能的类,也可能成为另一条POP链的关键跳板。攻击者不需要当前类直接执行代码,只要某个类的方法可以被当作跳板,链接到下一个类就行。这意味着框架里即使只有几个“看起来无害”的基础类,也可能被拼成完整利用链。
所以我在做代码审计时有个习惯:先把整个项目里的类全部拿出来,标注每个方法能干的“危险动作”,比如读写文件、执行系统命令、发起网络请求、操作数据库等,然后再去找这些方法之间的调用关系。这种方式比单纯看入口直白得多。
3.3 一个最简示例的完整还原
为了让你对“利用”有具象认知,我给你模拟一个极简场景。假设某个测试环境里有这样一段代码:
class Log { public $filename; public $data; public function __destruct() { file_put_contents($this->filename, $this->data, FILE_APPEND); } } // 入口 if (isset($_COOKIE['user_prefs'])) { $prefs = unserialize(base64_decode($_COOKIE['user_prefs'])); }这段代码的问题清晰可见:Cookie可控,传入内容被base64解码后直接反序列化,最终对象销毁时触发__destruct()写文件。攻击者构造一个Log对象,把filename设成webshell.php,data设成<?php eval($_POST['x']); ?>,序列化后base64编码放进Cookie,访问一次,网站根目录就会多一个一句话木马。
我用笔算一下这个过程:第一步,打开终端生成序列化payload,PHP脚本写$exp = new Log; $exp->filename = './shell.php'; $exp->data = 'demo'; echo serialize($exp);,执行拿到O:3:"Log":2:{s:8:"filename";s:11:"./shell.php";s:4:"data";s:4:"demo";}。第二步,base64编码后塞进Cookie模拟器。第三步,请求目标页面,服务端反序列化后创建Log对象,脚本结束对象销毁,file_put_contents被调用。第四步,访问shell.php确认写入成功。
攻击流程就这么简单,真正打起来比SQL注入还直观,因为它完全合乎PHP的“设计逻辑”。
4. 实操过程:构造利用链的完整思路与方法
4.1 第一步:确定反序列化数据入口
实操第一步永远是找入口,而不是急着构造payload。我拿到目标代码后,通常会先全局搜索这些关键函数,一个都别放过:
unserialize(这是直接入口,优先级最高serialize(先找出哪些数据被序列化了,再逆推到哪一步解出来的__wakeup()和一个__destruct()找出可触发的魔术方法所在类__toString()、__call()找类的自动调用关系file_put_contents、assert、call_user_func找危险函数作为链条终点
搜完后,把每个入口对应输入参数追踪一下:参数是从$_GET、$_POST、$_COOKIE、$_REQUEST来的,还是从文件、数据库、缓存里读取的。如果是从数据库读的,利用难度会高一些,因为可能还需要先拿到数据库权限;如果是直接前端传的,那不用犹豫,漏洞确认。
我实际做测试时,还会在Burp Suite里加一个“全请求抓包检查”,把每个POST参数、每个Cookie字段都尝试在值前后分别出现O:串和a:串的地方标红,重点观察是否有参数名带data、param、cache、session之类的接口。
4.2 第二步:穷举可用的类与方法
确定入口后,要开始收集“装备”,也就是代码里所有可用的类。这一步我用两种方式加快速度。
一种是静态利用代码审计工具,比如RIPS、PHPStan这类能识别类和函数调用关系的工具。另一种更接地气的办法是写一小段PHP脚本扫描整个项目目录,提取所有类的类名、属性、方法名,再人工叠加关键词过滤,找哪个方法触发了敏感函数。
下面是我经常在目标后端环境直接执行的一小段资产收集脚本:
<?php // 扫描代码库中的危险函数调用 $dangerFuncs = ['file_put_contents','eval','assert','system','exec','shell_exec','call_user_func','unserialize','include','require','fwrite','curl_exec']; $targetDir = '/var/www/html/app'; $rii = new RecursiveIteratorIterator(new RecursiveDirectoryIterator($targetDir)); foreach ($rii as $file) { if ($file->isFile() && in_array($file->getExtension(), ['php'])) { $content = file_get_contents($file->getPathname()); foreach ($dangerFuncs as $func) { if (stripos($content, $func) !== false) { echo $file->getPathname() . " => " . $func . "\n"; } } } } ?>这个脚本帮我节省了大量翻代码的时间。它会把所有涉及危险函数的文件打出来,之后我再逐个去读上下文,看危险函数附近是否有魔术方法、是否可能被反序列化触发。这里有个技巧:不只看直接调用了危险函数的类,更要看“谁调用了这个类的方法”。因为反序列化利用往往是间接的,__destruct里一行$this->logger->log($this->msg),看起来安全,但logger属性可控,攻击者把它指向另外一个有危险方法的类,调用链就被续上了。
4.3 第三步:拼接POP链的核心方法论
POP链拼接是整场利用中最考验耐心的环节。我把它拆成三步走,按照这个方法一步步推,基本不会迷路。
第一步,选终点。先确定一个想要执行的最终动作,比如读取/etc/passwd,或者向当前目录写一个PHP文件。然后全项目搜哪个类的方法能直接完成这个动作。比如我要写文件,会重点找file_put_contents、fwrite、rename这些调用所在的类。
第二步,找跳板。选好终点后往前推,假设终点类方法是B,那我要找谁触发B。通常寻找逻辑是这样:找某个类的某个方法里出现了$this->xxx->yyy()这种调用,而这个xxx属性可控、它的类型又指向终点类。这时候把那个类也纳入链条,作为新的“起点”。
第三步,定起点。一直往前推,直到找到由魔术方法触发的那一刻。常见起点是__destruct()或者__wakeup()。因为这两个方法不需要我们额外操作,反序列化本身就会触发。
我总结了一个特别实用的表格式“链条检查清单”:
| 环节 | 检查内容 | 确认结果 |
|---|---|---|
| 入口 | 用户输入直接进入unserialize | 确认 |
| 起点 | 存在自动触发的魔术方法 | 确认 |
| 跳板 | 魔术方法里调用了可控属性的方法 | 确认 |
| 衔接 | 每个跳板方法调用的下一个对象也可控 | 确认 |
| 终点 | 末位方法执行了敏感操作 | 确认 |
| 依赖 | 类已加载,且构造函数无阻断 | 确认 |
只要上面六个环节都确认,这条链基本就可以打。实际利用时,攻击者要生成本身序列化的payload,并不需要真的一步步在内存里还原对象。只需要在代码里new出这些类、设好属性,然后serialize()出来,整个利用重放就完成了。这也是PHP反序列化利用工具的原理。
4.4 第四步:绕过与限制的现实情况
搞渗透测试的人都知道,真实项目里不可能老是遇到“一把梭”的完美代码,总会碰到各种限制。最常见的几个限制就是老生常谈的__wakeup()绕过、属性数量绕过、引用绕过等等。
__wakeup()绕过这个经典技术在CVE-2016-7124里描述得很清楚,处理的版本是PHP 5.6.25之前和7.0.10之前的版本:在反序列化时如果对象属性总数大于真实属性数量,__wakeup()就不会被调用。我自己实测时,把这个技巧用于一个类里__wakeup()存在反制逻辑(比如重置恶意属性)的情况,修改序列化串里表示属性数量的那个数字,比如真实有2个属性,把它改成3或更大,直接跳过wakeup方法,然后后续魔术方法以及恶意属性就得以保留。
PHP 7.4之后官方修复了这个行为,现在__wakeup()无论如何都会被调用。这个老技巧只能在老版本环境里玩,不过老旧系统存量巨大,不该丢掉这个知识。
另一个常见的绕过点是小写类名和大小写敏感问题。PHP 8之前的类名不区分大小写,序列化时类名不一致会导致反序列化失败,但在PHP 5里O:4:"User"和O:4:"user"都能被识别。早期利用时,有人用大小写变异绕过某些对类名做了硬匹配的WAF规则。
还有一个利用方式值得注意,就是引用绕过。某些情况下不能直接给属性赋我们想要的值,因为反序列化之后某些属性会被覆盖掉,但利用R类型引用可以制造“两个属性指向同一块内存”的效果,一个属性负责触发,另一个属性负责存储值。比如a:2:{i:0;R:1;i:1;s:5:"admin";}这种构造,可以让一个属性引用另一个属性的值,达到绕过安全赋值的目地。
4.5 实战里的“垃圾代码”排查思路
上面说的都挺清晰,但实战里还有一个特别容易卡壳的点:你以为找到了一条完美链条,结果反序列化的时候直接报错,或触发了别的代码。原因多半出在构造函数上。
PHP反序列化时会不会调用构造函数?答案是__construct正常情况下不会调,因为对象不是通过new创建的。但很多类的属性在构造函数里被初始化或者被赋予默认值,如果链条上某个环节的方法依赖构造函数里设置的属性,而反序列化生成的属性覆盖后数值变成空,调用时就会直接抛异常。
所以我的经验是:在构造payload前,先把链条上涉及的类都实例化一遍,观察构造函数是否有副作用、是否设置了一些必须存在的辅助属性。如果构造函数里有die()/exit()这种直接中断的代码,那么这个类就不能用作起点,得换跳板绕过它。
另外,反序列化时PHP是“懒加载”的,只有当反序列化遇到引用到类时,才会尝试加载类文件。如果类不在当前已加载的类列表里,而又没有自动加载机制处理的话,就会抛“Class not found”错误。这种情况我一般会在payload头部附近随手include目标类文件,或者在代码库里找有没有现成的autoload机制可以利用。
5. 进阶利用手法与真实战场案例
5.1 从反序列化到RCE的完整路径
利用链一旦打通,最终执行什么命令、往哪里写文件,就完全看攻击者的目的了。我按危害从低到高排列一下常见利用方式:
- 文件读取:可利用
__toString配合文件读取类,读取配置、源码、数据库账号密码 - 文件写入:利用
__destruct配合文件写入类,写一句话木马或者WebShell - SSRF:利用类的网络请求方法,探测内网、访问云元数据接口
- 命令执行:利用
exec、system等调用的类方法,直接执行系统命令 - 数据库操作:利用PDO、MySQL连接类,读库、拖取数据
拿命令执行来说,最常见的终点函数是call_user_func和assert。这两个函数的特点是都可以接受字符串当代码执行。如果类里有一个方法把某个属性作为参数传给call_user_func,那我只需要让这个属性等于我们想要执行的函数名,就能完成一次调用。
class Command { public $callback; public $param; public function run() { call_user_func($this->callback, $this->param); } }序列化成:
O:7:"Command":2:{s:8:"callback";s:6:"system";s:5:"param";s:11:"id;whoami";}反序列化后run()被触发,等价于执行了system('id;whoami')。这个还是最基础的版本,真实利用链往往更长,终点也不一定是Command这个类,但思路完全一致。
5.2 真实场景复盘:某业务系统登录接口
有一次我在做一个内部模拟测试,目标是一个自研后台管理系统的登录接口。接口本身没有直接的sql注入,但登录成功后会把用户信息序列化放进Cookie。这属于我前面说的高危场景,所以重点盯了这个点。
我把Cookie里那段base64字符串解出来,看到O:8:"stdClass":3:{...}的结构,说明服务端确实在用PHP序列化。顺着这个入口,我把系统代码里的类和危险函数全扫了一遍,发现有个Cache类,__destruct()里会调用$this->store->save($this->key, $this->value),而这个store属性完全可控。继续追踪save方法,找到FileCacheStore类,它的save()最后调用file_put_contents。
链条清晰了:
- 起点:
Cache::__destruct() - 跳板:
Cache::$store指向FileCacheStore对象 - 终点:
FileCacheStore::save()写入$this->path指定的文件
最后构造的payload,把Cache::$key设为shell.php,$value设为<?php phpinfo();?>,$store属性填一个FileCacheStore对象,对象里的path属性设为网站根目录。反序列化执行后,shell.php被成功写入。整个过程没遇到登录拦截,入口就在Cookie里,危害从“无认证写文件”直接升级到了“RCE”。
这类案例我碰得不少,都是同一套思维:找入口,找链条,构造对象。
5.3 利用工具的选择与限制
搞渗透测试不可能每次都是手工造payload,效率太低。真实测试中,我常用两类工具来辅助PHP反序列化利用。
第一类是现成框架的漏洞利用工具,比如针对某知名PHP框架的历史漏洞打出的exp脚本。这类工具开箱即用,但局限于已知漏洞,对自研系统毫无作用。
第二类就是PHPGGC,这款工具收集了大量框架和CMS的现成POP链,用命令直接生成payload。比如生成一个针对Laravel的RCE链,命令大概长这样:
phpggc Laravel/RCE4 system "id"它会自动输出一个序列化好的payload字符串,直接复制到请求里就能用。注意PHPGGC不生产利用链,它只是把你的目标框架内已知可利用的POP链以工具化形式固定下来。真遇到一个架构独特的自研系统,PHPGGC就帮不上忙了,只能回到手工拼链的思路。
我的习惯是:框架类目标先用PHPGGC快速试一遍,不行再手工分析。自研系统从头到尾手工分析,这条流程帮我节省了太多时间。
6. 常见问题速查与漏洞修复方案
6.1 我在测试中踩过哪些坑
第一次自己拼POP链的时候,我花了一整个下午,最后发现payload一提交就报错,原因让我哭笑不得:序列化串里的属性顺序和代码里属性声明顺序不一致,导致某些属性能被赋值,某些则因为被反序列化时的赋值顺序问题被意外覆盖。后来我才明白,PHP反序列化后的对象属性值完全以字符串中给定的为准,顺序并不影响赋值,但会影响某些逻辑的判断。如果代码某个方法检查属性A不为空才执行关键操作,而序列化串里A排在B后面,恰好被后面的逻辑覆盖成空,链条就断了。
真实的经验是:构造序列化串时要逐个属性确认最终值,千万别图省事直接serialize()一个构造好的对象就完事,有时候还得手动改改字符串里的值,让两条路径都能走通。
第二个坑是属性可见性。PHP序列化对private和protected属性的格式有严格要求,private属性名会带上类名前缀,还包含空字节。直接手写序列化串时,稍有差错就导致反序列化失败。我在实战中一般用脚本生成,避免手写污染。
第三个坑是编码问题。序列化串里有中文或者特殊字符时,长度计算会出错。比如echo strlen("你好")在PHP 7以上是6,但在某些老版本是4,如果序列化串里的长度是按老版本算的,到新版本就会解不开。这个坑在跨版本环境中尤其致命,解决方式是全部用ASCII字符构造最优解,目标敏感值也用十六进制或者base64包一层再放进去。
6.2 常见问题速查表
很多刚接触反序列化的同学,遇到问题总在线上问东问西,我干脆把高频问题整理成一个速查表,省得大家踩重复的坑。
| 问题 | 现象 | 排查思路 | 解决方案 |
|---|---|---|---|
| 反序列化报错Class not found | 类未被加载 | 检查目标环境是否存在该类,类文件路径是否正确 | 在payload中提前include关键类文件 |
| __wakeup()被阻断 | 恶意属性被重置 | 确认PHP版本是否低于7.4 | 使用CVE-2016-7124绕过或更换起点 |
| 属性值为空 | 赋值后出现null | 检查属性名大小写和可见性格式 | 用脚本生成序列化串 |
| 长度错误 | base64解码后乱码 | 检查序列化串长度标记 | 全ASCII构造,特殊值做二次编码 |
| 链条走不通 | 某方法返回false | 检查前置条件判断语句 | 回看方法源码,补齐必要条件 |
| WAF拦截 | 请求被403 | 序列化串特征太明显 | 拆分payload、变形类名、混淆编码 |
| 命令执行无回显 | shell_exec不回显 | 目标环境可能过滤了stdout | 用到写文件的方式反弹输出或使用时间盲打 |
| 版本兼容问题 | 本地成功远端失败 | 本地PHP版本与目标不一致 | 在多个PHP版本下测试payload |
6.3 防御方该怎么修,别再看漏了
讲完攻击思路,得说说怎么修,不然这篇文章只教了破坏不教防御,干我们这行的都知道,防御思路才是渗透测试真正要让客户明白的核心价值。
第一条也是最彻底的一条:永远不要对用户输入直接调用unserialize()。如果必须反序列化不可信数据,那就不应该用PHP原生unserialize(),而是改用json_decode()处理数组和标量数据,或者对字符串做一次严格的签名校验。比如存储时hash_hmac('sha256', $data, $key),取值时先校验签名一致再unserialize,确保数据没有被篡改。
第二条是用safe_mode或allowed_classes参数限制反序列化白名单。PHP 7.0之后,unserialize()支持第二个参数,可以指定允许反序列化的类列表。配置方式是:
unserialize($data, ['allowed_classes' => ['User', 'CartItem']]);这样即使攻击者把恶意类序列化串塞进来,反序列化时遇到白名单之外的类会直接转为__PHP_Incomplete_Class,魔术方法根本不会被触发。这个方法对修复老代码特别实用,改动量极小。
第三条是对类做“消毒”。以__destruct()为例,如果业务上根本不需要它写文件,那就把写文件逻辑挪出去,或者加严格类型校验。尽量让每个魔术方法内部只做无副作用的基础操作,别有危险函调。
第四条是属性访问控制。攻击者拼接POP链时,依赖的就是属性可控。如果关键属性设置成private或者readonly,序列化时属性名会被带上类名前缀,而且外部代码无法通过$this->xxx方式来恶意赋值,利用链条会断得很难看。
最后一条是上线前的自动化审计。把PHP代码扔进支持自定义规则的静态扫描工具里,规则写上“unserialize的入参直接来自超全局变量”就算一条高危告警。如果研发流程能卡住这种代码上线,反序列化漏洞的数量会骤降一大截,这比我写十篇文章都管用。
7. 最后分享一点个人经验
做渗透测试这么多年,反序列化漏洞是我觉得“性价比”最高的入门漏洞类型。SQL注入需要懂数据库语法,XSS需要懂前端上下文,而反序列化漏洞最核心的就是面向对象的思维,只要你能理解对象、方法、调用关系这三件事,它就不难。很多新手一听说POP链就头大,其实拆开了就是“有个方法会调用别的方法”这个常识。
我个人在实操中体会最深的一条是:不要把反序列化利用想成黑客炫技,它本质上是编程逻辑缺陷。攻击者只是比开发者更认真翻了一遍代码,把“属性可控”这个事实用到了极致。所以反过来,防御的本质也不是堆WAF,而是收紧可控数据进入反序列化函数的路径。每次拿到代码我都先问自己三个问题:这段数据从哪来?进到哪里去?中间有哪一层校验?这三个问题想清楚了,漏洞基本不会扎堆出现。
练手的话,我建议大家先在自己本地搭一个PHP环境,把文章里那个极简Log类例子亲手过一遍,然后尝试把类数量扩展成三个,自己拼一条小型POP链。这个过程会帮你彻底打通“构造payload”的任督二脉。真实系统里所有复杂利用链,本质上都是在这个练习上加了更多跳板而已。
如果你手头有旧项目代码,可以顺手全局搜一遍unserialize,看看有没有类型白名单,有没有签名校验,没有的话那大概率存在隐患。趁早修,别等被打穿了才回头看这篇总结。