PHP 反序列化在很多人心里是「CTF 才考的东西」,现实里它更常见的形态是:站点的缓存文件被人动过、队列里的任务数据来自外部接口、或者一次 phar:// 请求触发了看不见的反序列化。这类漏洞的特点是代码里一个 unserialize 都找不到,但链子照样能打通。这篇文章按「漏洞长在哪 → 链子怎么构成 → 怎么用白名单关死 → 存量数据怎么迁移」的顺序讲,最后给一个替代 JSON 的迁移路径。
一、反序列化的危险点究竟在哪
unserialize() 本身只是把字符串还原成对象,真正的风险来自还原过程中的两个自动行为:
- 魔法方法被自动调用。
__wakeup()、__destruct()、__toString()会在对象销毁、转字符串、序列化恢复时自动执行,攻击者不能直接「调用函数」,但可以控制「哪些对象的哪些属性」,等着这些方法自己跑起来。 - 属性完全可控。你以为还原出来的对象属性都是代码里写死的,实际上序列化串里就明确写着每个属性的值,攻击者可以把「文件名」改成任意路径,把「回调函数名」改成
system。
所以漏洞的判据不是「有没有 un/deserialize」,而是「有没有一段可控的序列化串,能流到某个带魔法方法的类上」。
二、入口比你想的多得多
写代码审计时按下面这张表逐个排查,最容易漏的是后两行:
- 直接调用:
unserialize($_COOKIE['data'])、unserialize(base64_decode($_POST['s']))。这类最显眼,通常已经被人修过了。 - 会话反序列化:
session.serialize_handler为 php 或 php_serialize 时,会话文件内容会被反序列化。如果会话写入路径可控、且会话文件在可访问目录,就能构造 payload。 - 缓存后端:Memcached、Redis 里存的「对象」在某些框架里是序列化串,缓存键被别人写进去就等于控制输入。
- 队列消费:任务体的
payload字段被unserialize,且队列对多个生产者开放时,任何一个生产者都能构造恶意任务。 - PHAR 伪协议:这条最隐蔽。PHP 在遇到
phar://时会读取 PHAR 的元数据并反序列化,而触发条件只是任何接受文件路径的参数,例如file_exists()、filesize()、getimagesize()、md5_file()。也就是说,一个「上传头像」的功能,如果文件名参数能被控制成phar://uploads/x.jpg,就能触发反序列化。PHP 8.0 之后需要显式调用Phar::loadPhar()才生效,但 7.x 上仍然默认触发。
盘点命令:
grep -rn --include=*.php -E "unserialize\s*\(" . | grep -v vendor/
grep -rn --include=*.php -E "serialize\s*\(" . | grep -v vendor/
php -i | grep -E "session.serialize_handler|phar.readonly"第二行容易被忽略:找出 serialize() 是为了知道「我们自己往哪里写了序列化串」,这些位置通常就是对应的消费端。
三、POP 链是怎么串起来的
攻击者手里只有「属性可控的类实例」,要打通成代码执行,需要靠类之间的调用关系一层层传递。典型链条形态是:A 的 __destruct 调用 B 的某个方法 → B 的方法里执行 call_user_func 或 file_put_contents → 参数由 B 的属性决定 → 属性由攻击者控制。
一个极简的示意(不是真实代码,用来说明链路):
class Logger {
public $writer;
public function __destruct() {
$this->writer->write('flush'); // ① 触发 B 的方法
}
}
class FileWriter {
public $path;
public $content;
public function write($msg) {
file_put_contents($this->path, $this->content); // ② 落点
}
}构造串的思路是:让 Logger::$writer 指向 FileWriter,并设置 path 为 webshell 路径、content 为代码。脚本退出时 __destruct 自动跑,文件就写下去了。传统链子的终止点多半是这四类:call_user_func / eval 类函数调用、文件写入、include、以及 SQL 执行。
值得注意的是,现代框架的自研反序列化链一般早就被修了,实际入口更多来自「项目自己写的类」。所以审计时优先看业务代码里的 __destruct、__wakeup、__toString,而不是去翻框架的 candy。
四、防线一:用白名单彻底替代
最有效的处置是不再对用户输入做反序列化,改用 JSON。改动要点有三处,缺一处就白改:
// 写入端
$payload = json_encode($data, JSON_UNESCAPED_UNICODE);
// 读取端
$data = json_decode($payload, true, 512, JSON_THROW_ON_ERROR);
// 类型校验:JSON 只还原出数组/标量,不会有对象
if (!is_array($data) || !isset($data['uid'], $data['act'])) {
throw new RuntimeException('bad payload');
}JSON 的关键优势是不可能还原出对象,魔法方法自然无从触发。需要注意的是历史数据兼容:已有的序列化串要做一个迁移,见第七节。
五、防线二:真的要反序列化时,用 allowed_classes
有些场景确实不适合换格式,例如第三方 SDK 的返回值、或者从 PHP 5 时代沿用下来的缓存。此时给 unserialize 显式加第二个参数:
// 失败
$obj = unserialize($raw);
// 正确:只允许具体的几个类
$obj = unserialize($raw, ['allowed_classes' => [Order::class, OrderItem::class]]);
// 更保守:只要数据、不要对象
$arr = unserialize($raw, ['allowed_classes' => false]);三个坑必须记住:
allowed_classes => false之后,原来类对象会变成__PHP_Incomplete_Class,访问属性会报错,必须先跑一遍全量数据确认没有真的依赖对象方法。- 类名要写全限定名(含命名空间),写错不会报错,只会静默变成 incomplete 对象——排查起来很费时间。
allowed_classes不防御 PHAR。PHAR 的元数据触发路径不走你写的unserialize调用,所以这一条对它无效,必须靠phar.readonly与「不接受用户控制的 phar:// 路径」来防。
六、防线三:捏住 phar:// 这条暗线
PHAR 的处置有两条:
; 生产环境:不允许生成 PHAR(默认即为 On,但要确认没被关掉)
phar.readonly = On
; 上传目录拿不到可执行性,也别让名字里出现 phar
location ^~ /uploads/ {
location ~ \.(php|phtml|phar)$ { return 403; }
}在代码层,如果某个参数真的只能接受文件名,就把它当成「文件名」而不是「路径 + 协议」:用 basename() 去掉目录部分,再显式拒绝含 : 的输入。: 是协议分隔符,文件名里出现它就说明输入不干净。加一道白名单正则更稳:
if (!preg_match('/^[a-zA-Z0-9_\-]+\.(jpg|png|webp)$/', $name)) {
http_response_code(400);
exit;
}
$path = '/data/uploads/' . $name; // 用常量目录拼接,绝不接收完整路径七、存量数据怎么迁移
线上已经有一批序列化数据(比如队列里积压的任务、缓存里的对象),不能一刀切。稳妥的迁移分三步走:
- 双读:读取端先判断首字符,
{或[走 JSON,a:或O:走旧路径,但旧路径加allowed_classes收紧。这一步不会破坏任何现有功能。 - 回写:每次读取后立刻用 JSON 重新写回,同时打一个
migrated=1的日志计数。等新格式数据占比到 95% 以上,说明活跃数据基本都换完了。 - 收口:把旧路径的
allowed_classes改成false,观察一周无异常后,把旧分支彻底删掉。删掉之前先备份一次数据库里的相关字段,别只靠 git 回滚。
判断「首字符」这种做法看起来粗糙,但比「试一次 JSON 解析、失败再降级」要安全:后者可能在异常处理里又把输入交给不安全的路径。首字符判断是纯字符串操作,不会触发任何反序列化。
八、日志里怎么发现有人正在打
反序列化攻击在日志里有几个特征形态,配一条 grep 就能筛出来:
- 请求体或 Cookie 里出现
O:后面跟数字(对象声明)、a:跟数字(数组声明),尤其是O:8:"stdClass"这类。 - 参数里出现
phar://、php://filter、data://。 - 同一个 IP 短时间内对同一参数提交多个不同长度的大 body,这通常是自动化工具在试链子。
grep -E 'O:[0-9]+:"|phar://|a:[0-9]+:\{' /var/log/nginx/access.log | tail -50把这条挂进日志巡检脚本里,每天跑一次。真正的价值不是「拦住」(日志层拦不住),而是比扫描器更快知道有人在试,从而判断是否需要在 Nginx 层临时封禁。
九、一个可执行的上线顺序
如果你接手的是一个已经上线的站点,建议按下面的顺序推进,每一步都能独立验收:
- grep 盘点全部
unserialize与serialize,列成表,标注输入来源。 - 给所有
unserialize加allowed_classes,先写具体类名,跑一周观察有无__PHP_Incomplete_Class报错。 - 把对外接口类的载荷逐个改成 JSON,优先改「外部能写进来」的那些。
- 确认
phar.readonly=On,并把上传目录的.phar直链关闭。 - 提交日志巡检规则,确认能筛出
O:形态的请求。 - 最后再删旧分支,删之前备份数据。
不要跳过第 2 步直接做第 3 步。改格式是一次性的、不可逆的,而 allowed_classes 是一行代码的改动,能立刻把「任意对象还原」降级成「只能还原白名单里的类」,风险下降最快。
十、小结
PHP 反序列化的核心是「可控属性 + 自动执行的魔法方法」,防御的三条线分别对应三件事:能换格式就换成 JSON(彻底消灭)、不能换就加 allowed_classes(收窄到白名单)、别忘了 PHAR 这条不需要 unserialize 的暗线(关掉生成 + 文件名白名单)。三条里最容易遗漏的是第三条,而它恰恰出现在「上传头像」「生成缩略图」这种看起来毫无风险的日常功能里。做完这三条,再配一条日志巡检,这个方向基本就收口了。