PHP反序列化防线:白名单与phar暗线

PHP 反序列化在很多人心里是「CTF 才考的东西」,现实里它更常见的形态是:站点的缓存文件被人动过、队列里的任务数据来自外部接口、或者一次 phar:// 请求触发了看不见的反序列化。这类漏洞的特点是代码里一个 unserialize 都找不到,但链子照样能打通。这篇文章按「漏洞长在哪 → 链子怎么构成 → 怎么用白名单关死 → 存量数据怎么迁移」的顺序讲,最后给一个替代 JSON 的迁移路径。

一、反序列化的危险点究竟在哪

unserialize() 本身只是把字符串还原成对象,真正的风险来自还原过程中的两个自动行为:

  • 魔法方法被自动调用。__wakeup()、__destruct()、__toString() 会在对象销毁、转字符串、序列化恢复时自动执行,攻击者不能直接「调用函数」,但可以控制「哪些对象的哪些属性」,等着这些方法自己跑起来。
  • 属性完全可控。你以为还原出来的对象属性都是代码里写死的,实际上序列化串里就明确写着每个属性的值,攻击者可以把「文件名」改成任意路径,把「回调函数名」改成 system。

所以漏洞的判据不是「有没有 un/deserialize」,而是「有没有一段可控的序列化串,能流到某个带魔法方法的类上」。

二、入口比你想的多得多

写代码审计时按下面这张表逐个排查,最容易漏的是后两行:

  1. 直接调用:unserialize($_COOKIE['data'])、unserialize(base64_decode($_POST['s']))。这类最显眼,通常已经被人修过了。
  2. 会话反序列化:session.serialize_handler 为 php 或 php_serialize 时,会话文件内容会被反序列化。如果会话写入路径可控、且会话文件在可访问目录,就能构造 payload。
  3. 缓存后端:Memcached、Redis 里存的「对象」在某些框架里是序列化串,缓存键被别人写进去就等于控制输入。
  4. 队列消费:任务体的 payload 字段被 unserialize,且队列对多个生产者开放时,任何一个生产者都能构造恶意任务。
  5. 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;   // 用常量目录拼接,绝不接收完整路径

七、存量数据怎么迁移

线上已经有一批序列化数据(比如队列里积压的任务、缓存里的对象),不能一刀切。稳妥的迁移分三步走:

  1. 双读:读取端先判断首字符,{ 或 [ 走 JSON,a: 或 O: 走旧路径,但旧路径加 allowed_classes 收紧。这一步不会破坏任何现有功能。
  2. 回写:每次读取后立刻用 JSON 重新写回,同时打一个 migrated=1 的日志计数。等新格式数据占比到 95% 以上,说明活跃数据基本都换完了。
  3. 收口:把旧路径的 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 层临时封禁。

九、一个可执行的上线顺序

如果你接手的是一个已经上线的站点,建议按下面的顺序推进,每一步都能独立验收:

  1. grep 盘点全部 unserialize 与 serialize,列成表,标注输入来源。
  2. 给所有 unserialize 加 allowed_classes,先写具体类名,跑一周观察有无 __PHP_Incomplete_Class 报错。
  3. 把对外接口类的载荷逐个改成 JSON,优先改「外部能写进来」的那些。
  4. 确认 phar.readonly=On,并把上传目录的 .phar 直链关闭。
  5. 提交日志巡检规则,确认能筛出 O: 形态的请求。
  6. 最后再删旧分支,删之前备份数据。

不要跳过第 2 步直接做第 3 步。改格式是一次性的、不可逆的,而 allowed_classes 是一行代码的改动,能立刻把「任意对象还原」降级成「只能还原白名单里的类」,风险下降最快。

十、小结

PHP 反序列化的核心是「可控属性 + 自动执行的魔法方法」,防御的三条线分别对应三件事:能换格式就换成 JSON(彻底消灭)、不能换就加 allowed_classes(收窄到白名单)、别忘了 PHAR 这条不需要 unserialize 的暗线(关掉生成 + 文件名白名单)。三条里最容易遗漏的是第三条,而它恰恰出现在「上传头像」「生成缩略图」这种看起来毫无风险的日常功能里。做完这三条,再配一条日志巡检,这个方向基本就收口了。

Last modification:November 1st, 2026 at 09:26 pm

Leave a Comment