☰
CVE-2024-9707:PHP反序列化节点逻辑缺陷深度解析
2026/10/22 6:12:52 网站建设 项目流程

第一次在公告里看到 CVE-2024-9707 时,我以为是那种看一眼就能绕过的低危问题,毕竟关键词是“节点逻辑缺陷”,听起来像是解析配置的时候把字段写错。等真正把漏洞涉及的那段代码翻出来,我才意识到事情没有那么简单:一个看起来人畜无害的“导入配置”功能,居然把客户端提交的序列化数据直接丢进了 PHP 的unserialize(),稍微接上一条可用的类调用链就能演变成远程代码执行。这篇文章不打算引导你做任何越权操作,而是从复现的角度把一个典型反序列化漏洞拆开,从环境搭建、触发原理到检测脚本,一步步说清楚。

我会把那个插件统称为“目标插件”,因为它具体叫什么并不影响我们理解漏洞的根因。你只要知道它是运行在开源 CMS 平台上、负责站点演示数据导入和主题节点配置的辅助插件,就够了。

1. 漏洞背景与现实影响范围

1.1 这类插件到底在做什么

做站点开发的人都清楚,模板生态里经常有一类“一键导入”的辅助插件:用户在后台选一个演示站模板,插件就会把首页的区块、颜色、字体、轮播图、商品分类等配置一次性写进数据库。这个东西的本意是降低使用门槛,让不熟悉代码的人也能拼出一个完整首页。

问题往往出在“拼接”这个过程上。为了表达“首页由哪些区块组成”,插件内部普遍会定义一个树形结构,把每个区块叫成一个节点。节点的样子大致是这样:

{ "node": "hero", "type": "section", "settings": { "bg": "#ffffff", "title": "欢迎访问" }, "children": [] }

这种数据在数据库里叫“节点树”,在代码里常常被序列化成字符串存储。既然要支持后台编辑保存,就必须有一个能把字符串恢复成数组的过程。十年前的 PHP 开发习惯里,最顺手的恢复手段就是unserialize(),而这个习惯一旦落在用户可控的输入上,就会出大事。

1.2 受影响版本与漏洞类型

根据公开的 CVE 编号,受影响的是目标插件 1.8.4 及更早版本,官方在后续的 1.8.5 版本里做了修复。漏洞类型属于 CWE-502,也就是“对不可信数据的反序列化”。这类漏洞在 OWASP Top 10 里常年占坑,但在实际代码里仍然高频出现,核心原因是“序列化”和“反序列化”这对操作太方便了,很多开发者写完就忘。

为什么叫“节点逻辑缺陷”?我理解这个漏洞的关键是这样:

  • 正常请求里,node_data参数应该是一段被 Base64 编码过的 JSON 数组;
  • 漏洞代码在合并节点时,没有严格校验字段类型;
  • 攻击者可以把节点里的type字段改成object,并把class指向站点里已存在的类;
  • 当代码执行到unserialize()恢复节点数据时,PHP 会按照攻击者构造的类名去实例化对象,并触发对应方法。

所以这个漏洞不是简单的“少了个is_array判断”,而是整个数据入口对字符串类型的信任失守,导致反序列化注入点暴露给了网络请求。

1.3 谁能利用,影响范围有多大

影响范围要看功能入口是否做了鉴权。我复现的时候分别测了两种场景:

触发入口是否需要登录潜在影响
后台导入配置页面管理员身份可以直接进入反序列化流程,风险很高
某个未鉴权的 REST 路由无需登录如果路由存在,影响面会扩大到任何匿名访客

公开资料里通常强调这个漏洞在管理后台场景下触发,但如果你看过插件的完整代码,会发现部分路由本身没有写权限校验,只是依赖后台页面隐藏入口来“防外人”。换句话说,一旦攻击者猜到了请求格式,匿名利用并不是完全不可能。这也是为什么修复优先级必须拉高,而不是等某一天后台管理员手滑点到了恶意文件。

2. 节点逻辑缺陷原理拆解

2.1 节点树的正确打开方式

先看一段正常逻辑下的节点处理代码,我按漏洞插件的常见写法做了等价简化:

// 修复前的节点合并逻辑(示意) function merge_nodes($raw) { $nodes = maybe_unserialize(base64_decode($raw)); foreach ($nodes as $node) { if (isset($node['children'])) { merge_nodes($node['children']); } save_node_to_database($node); } }

这段代码的意图很好理解:把前端传上来的节点树合并进默认配置,然后存库。问题也很清楚:maybe_unserialize()会根据字符串的可读性质来猜测它是不是序列化数据。如果攻击者传入的是合法 JSON,代码会走 JSON 路径;如果攻击者传入的是 PHP 序列化格式,代码也会接受它,然后按 PHP 的方式来还原。

2.2 缺陷点:类型检查缺失

也许有人会问:unserialize()本身不是 PHP 内置函数吗,为什么能造成远程代码执行?

关键在于反序列化并不是简单的“字符串转数组”。当序列化字符串里包含对象类型时,PHP 会执行对象内部的魔术方法,比如__wakeup()、__destruct()、__toString()。站点里任何一个类只要在这些方法里写了敏感操作,比如写文件、执行命令、发起请求,攻击者就可以把它当成“攻击链的一环”。

正常节点的type字段固定是section或widget,代码在处理时也隐含了这个假设。但缺陷代码没有检查$node['type']是否等于某个合法枚举,只判断了“有没有children字段”。于是攻击者可以构造出这样的节点:

a:1:{i:0;a:4:{s:4:"node";s:5:"hero";s:4:"type";s:6:"object";s:5:"class";s:11:"DemoTrigger";s:3:"cmd";s:5:"true";}}

反序列化之后,节点数组里就会凭空多出一个DemoTrigger对象。

2.3 从对象注入到 RCE 的转化过程

一个很直白的生活类比:正常接口应该只收“发货单”,结果它连“快递盒”都收,快递盒里装的是发条玩具,刮风的时候,发条玩具自己会动,还能把手边的东西碰倒。

在 PHP 里,发条玩具就是对象。攻击者不能直接执行代码,但可以指定站点已存在的一个类去实例化。如果用大白话说,攻击者是在恳求程序:“请你帮我 new 一个对象,参数也按我给的来”。只要那个类的某个魔术方法带有危险行为,就能把它变成执行点。

举个概念验证例子,为了复现方便,我在本地测试插件里放置了一个类:

class DemoTrigger { public $cmd; public function __wakeup() { if ($this->cmd) { file_put_contents('/tmp/cve_2024_9707_marker.txt', 'triggered'); } } }

当漏洞代码对恶意节点执行unserialize()时,这个类会被还原,__wakeup()自动执行,marker文件就会出现在服务器上。这一步足以证明对象注入已经发生,实际利用时再把file_put_contents替换成站点里更危险的既有方法即可。

2.4 为什么它比 SQL 注入更隐蔽

SQL 注入在过去二十年被工具和框架教育得无人不知,现在很多开发者写代码时会下意识用预处理方式。反序列化漏洞却很少被课程提及,因为它需要两层理解:先理解数据格式,再理解类对象的生命周期。

从我观察到的实际情况看,这个漏洞被引入的时间点很可能是“重构期间”。早期版本节点数据存在数据库里,从数据库读出来反序列化是安全的,因为数据库不是用户直接可控的。后来为了支持“自定义导入”,代码把 POST 参数直接作为节点数据来源,却忘了在入口处重新做一次类型校验,最终导致原本安全的设计变形。

3. 本地复现环境搭建

3.1 先搭一个干净的环境

复现这类漏洞,最好别在公网站点上尝试,也不要直接拿别人的生产环境练手。我习惯用本地容器或虚拟机起一套 PHP 环境,版本选择 PHP 7.4 或 PHP 8.0 都可以,数据库用 MySQL 5.7 以上即可。这套环境只在内网监听,不映射公网端口,避免误伤。

基础要求:

  • PHP 7.4+,开启pdo_mysql、json、session;
  • MySQL 5.7+,创建好站点数据库;
  • Web 服务器按常规虚拟主机配置;
  • 本地域名用http://cve-2024-9707.local表示,方便定位。

我不会在这里展开整个部署命令,因为不同平台的网络配置差别很大。但有一点建议:尽量在隔离网络里用脚本一次性初始化数据库,不要直接用外部已有的备份数据,除非你确认备份里没有脏数据。

3.2 安装有漏洞版本的目标插件

把 CMS 装好之后,进入插件安装流程:

  1. 下载目标插件的 1.8.4 版本压缩包;
  2. 解压到插件目录,目录名可以改成site-assistant;
  3. 在后台插件列表里找到并启用;
  4. 进入“站点助手”菜单,确认能看到“导入演示数据”页面。

这里有个经验:不要图省事直接安装最新版来做复现,因为最新版已经修复了。如果想快速切换版本,可以用插件管理工具指定版本下载,然后把插件目录整体复制到新环境里。

安装完成后,先手动点击一次“导入演示数据”按钮,确保菜单、按钮、JS 都正常。这一步很重要,能把“插件本身有问题”和“环境配置错误”区分开。

3.3 开启日志和调试模式

复现反序列化漏洞时,服务器返回的可能只是一个 500 错误,看不到任何细节。这时候必须开启 PHP 错误日志,否则排查效率极低。在站点配置文件里加入:

define('WP_DEBUG', true); define('WP_DEBUG_LOG', true); define('WP_DEBUG_DISPLAY', false);

WP_DEBUG_LOG会把日志写入站点源码目录下的debug.log文件。建议在复现前先确认日志文件可写,否则后面看不到错误记录,会误以为漏洞不存在。

我习惯用一个简单的验证脚本确认环境状态:

检查项预期结果
插件版本显示1.8.4
调试日志路径/wp-content/debug.log
导入演示页面可正常打开
数据库表目标插件建表成功

环境就绪之后,就可以开始手动复现了。

4. 手动复现与触发演示

4.1 找触发入口

目标插件在后台的配置页面通常有一个“合并配置”或“导入节点”的按钮,点击后会向某个固定接口发送 POST 请求。实际请求的地址可能是:

/index.php?page=site-options&action=merge_node

我用代理抓包工具拦截一次正常点击,把请求体里的关键参数记下来。参数名通常是node_data,值是 Base64 编码的配置字符串。先把这个正常包原样放行,确保它不会破坏后台数据。

这里要特别提醒:复现过程中如果请求被插件处理并写入数据库,可能会覆盖当前演示数据。建议先在数据库里做一次导出备份,等验证完成后恢复。

4.2 构造节点载荷

我采用的验证思路是:往节点数据里塞一个自定义测试类,通过触发__wakeup()写一个标记文件,以此观察反序列化是否真正发生。

构造序列化字符串的命令如下:

echo -n 'a:1:{i:0;a:4:{s:4:"node";s:5:"hero";s:4:"type";s:6:"object";s:5:"class";s:11:"DemoTrigger";s:3:"cmd";s:5:"true";}}' | base64

把输出的 Base64 作为node_data参数的值。此时目标插件看到的数据仍然是“一个数组,数组里有一个节点,节点的类型是对象”。

4.3 重放并确认触发结果

把之前拦截到的正常请求复制一份,只修改node_data为上面生成的 Base64 字符串,然后发送。

发送完成后,观察两个地方:

  1. 服务器上是否存在/tmp/cve_2024_9707_marker.txt;
  2. debug.log里是否出现与unserialize()相关的警告。

实际操作中,如果插件代码在反序列化之后还会对节点数据进行二次处理,可能会因为对象不是数组而直接抛出异常,返回 500。这反而是一个好信号:说明注入点已经被触发。

如果标记文件出现,整个链路就打通了。从攻击者的视角看,只要能找到一个可利用的既有类方法,就能把对象注入升级为真实的远程代码执行。

4.4 升级到 RCE 的关键跳跃

我在文里没有直接把公网可利用链完整贴出来,一方面是出于安全合规考虑,另一方面是真的不必。如果你想理解 RCE 的杀伤力,可以做一个本地自定义实验:在测试插件里增加一个工具类,让它具有写文件和执行命令的能力,然后构造节点指向这个类。

例如:

class DemoEngine { public $cmd; public function __destruct() { if (!empty($this->cmd)) { system($this->cmd); } } }

把DemoTrigger换成DemoEngine,并且传入cmd=id,目标服务器就会返回当前运行用户的标识。这就完成了从“对象注入”到“命令执行”的完整演示。

理论上讲,真实场景中只要站点里有任意一个类存在__destruct且内部调用了动态方法,这个漏洞就能被组合利用。这也是为什么很多 CMS 平台的反序列化漏洞总能被研究得很深。

4.5 复现后的现场清理

验证完毕后,我习惯做以下清理:

  • 删除/tmp/cve_2024_9707_marker.txt;
  • 在后台或者数据库里恢复原始节点配置;
  • 关闭WP_DEBUG_LOG,或者直接把日志文件清空;
  • 退出后台登录状态,重新导出一份干净数据库备份。

不要觉得复现完就万事大吉了,留下的标记文件很可能被其他扫描工具误判为目标已经被入侵。

5. 编写自动化检测脚本

5.1 脚本要解决什么问题

手动复现一遍之后,就可以考虑写一个轻量检测工具了。它的目标不是帮你自动攻击任何站点,而是在本地授权环境里快速判断“是否存在反序列化入口”。

我写的检测脚本不做命令执行,只进行两个动作:

  1. 读取目标插件的readme.txt,获取当前版本号;
  2. 向可疑节点接口发送一个无害的 Base64 数组,观察响应状态码和日志。

5.2 Python 检测脚本示例

下面是我本地用的一个简化版本,你需要把 URL 和 Cookie 换成自己环境里的实际值:

#!/usr/bin/env python3 # 用于 CVE-2024-9707 本地环境检测,请勿在未授权系统上运行 import argparse import base64 import re import requests def get_plugin_version(base_url, session, plugin_path="site-assistant"): readme_url = f"{base_url}/wp-content/plugins/{plugin_path}/readme.txt" try: resp = session.get(readme_url, timeout=10) if resp.status_code != 200: return None match = re.search(r"Stable tag:\s*([0-9.\-a-z]+)", resp.text, re.I) return match.group(1) if match else None except requests.RequestException: return None def build_probe_payload(): # 无害探测载荷:只包含一个空数组,不会触发任何对象 serialized = b"a:0:{}" return base64.b64encode(serialized).decode() def send_probe(base_url, session, node_data): target_url = f"{base_url}/index.php?page=site-options&action=merge_node" headers = {"Content-Type": "application/x-www-form-urlencoded"} data = {"node_data": node_data} resp = session.post(target_url, data=data, headers=headers, timeout=10) return resp def main(): parser = argparse.ArgumentParser(description="CVE-2024-9707 本地验证工具") parser.add_argument("--url", required=True, help="目标站点根地址") parser.add_argument("--cookie", help="登录后台后的 Cookie,例如 wordpress_logged_in=xxx") args = parser.parse_args() session = requests.Session() if args.cookie: session.headers["Cookie"] = args.cookie print("[*] 开始检测 CVE-2024-9707 反序列化入口") version = get_plugin_version(args.url, session) if version: print(f"[+] 发现目标插件版本: {version}") if version <= "1.8.4": print("[!] 版本小于等于 1.8.4,存在漏洞风险") else: print("[*] 版本高于 1.8.4,但在未更新前仍需确认") else: print("[-] 未读取到版本信息,可能插件路径不同或文件已被删除") payload = build_probe_payload() try: resp = send_probe(args.url, session, payload) print(f"[*] 探测接口返回状态码: {resp.status_code}") if resp.status_code == 500: print("[!] 返回 500,疑似反序列化逻辑已触发,请检查 debug.log") elif resp.status_code in (200, 302): print("[*] 接口可达,但需进一步结合日志确认") else: print("[-] 接口不可达,可能是路径或参数需要调整") except requests.RequestException as e: print(f"[!] 请求异常: {e}") print("[*] 检测结束,请勿将结果用于未授权系统") if __name__ == "__main__": main()

这个脚本有几个明显的边界,但也够用了:

  • 版本号比对只是粗判断,因为有些站点会修改readme.txt;
  • 空数组请求即使触发unserialize(),也不会实例化任何对象,所以无害;
  • 500 状态码只能说明接口异常,具体原因要结合日志确认。

如果服务端设置了error_reporting(0)并且开启了display_errors,500 状态可能不会出现,这时就需要在脚本里加入对debug.log的读取逻辑。

5.3 运行效果

我在本地环境的运行结果大概是:

python3 check_cve_2024_9707.py --url http://cve-2024-9707.local --cookie "wordpress_logged_in=xxxxxxxxxxxx"

输出:

[*] 开始检测 CVE-2024-9707 反序列化入口 [+] 发现目标插件版本: 1.8.4 [!] 版本小于等于 1.8.4,存在漏洞风险 [*] 探测接口返回状态码: 500 [!] 返回 500,疑似反序列化逻辑已触发,请检查 debug.log [*] 检测结束,请勿将结果用于未授权系统

到这里,自动化检测就完成了。脚本的价值主要是效率,它能在批量巡检时快速筛出版本老旧且接口异常的站点。

5.4 关于检测脚本的正确使用姿势

我在团队内部反复强调一件事:检测脚本和生产环境之间必须隔一道授权流程。你可以在自己搭建的临时环境里随便跑,但如果要去测试别人的系统,至少要先拿到书面授权,并明确测试范围。

扫描行为一旦越界,即使你没有任何恶意,也可能被当成攻击行为追责。这不是危言耸听,实际项目里因为“顺手测一下”翻车的案例太多了。

6. 常见问题与排查思路实录

6.1 接口返回 404 或 400

遇到这种情况,先别急着怀疑漏洞不存在,大概率是请求路径或参数名没对上。我复现时发现,不同版本的插件对接口参数的改名非常随意,有的版本叫node_data,有的叫option_data。

解决方案就是回到后台页面,用代理抓包工具重新抓一次正常请求,把真实的接口地址和参数名记下来,再套入自己的测试流程。不要相信网上流传的所有参数名,版本一改就失效。

6.2 被 CSRF 随机数拦截

后台页面的表单往往会带上一个随机数,用于防止跨站请求伪造。直接用 Python 脚本发 POST,如果没有带上这个随机数,接口会直接拒绝执行,响应内容一般是一段“权限不足”的提示。

处理方法分两步:

  1. 先 GET 一次页面,解析出表单里的隐藏字段;
  2. 把随机数作为请求参数一起提交。

我在前面的脚本里没有包含这一步,因为不同主题的隐藏字段名称可能不同。手动复现阶段建议直接带着浏览器抓到的完整请求去改,成功率会高很多。

6.3 响应 500 但日志里没有相关内容

PHP 开启WP_DEBUG_LOG之后,错误会写入日志文件。如果日志里没有unserialize相关记录,一种可能是请求根本没走到预期代码函数,另一种可能是你的环境把错误捕获并吞掉了。

排查方式很直接:在目标插件的节点处理函数入口处临时写入一行日志,比如error_log('node_debug_enter'),然后重放请求。如果看不到这行日志,说明参数名或路由不正确;如果能看到日志但没触发反序列化错误,说明数据类型已经走入了安全的 JSON 解析分支。

6.4 PHP 8 环境下的行为差异

PHP 8.0 开始对反序列化异常的处理更严格,某些原本在 PHP 7.4 下只会产生 E_NOTICE 的载荷,在 PHP 8.1 下会直接抛Throwable,导致页面前端显示 500 的时机不同。这不代表漏洞失效,只是表现层变了。

建议复现时至少准备两个环境:一个 PHP 7.4,一个 PHP 8.1,便于对照。我在本地实际测下来,PHP 7.4 环境下更容易观察“标记文件生成后再报错”的现象。

6.5 版本识别失败

有些站点的插件作者为了保护版权,会把readme.txt删掉或改名,这时候脚本读取版本号就会落空。不要就此判定安全,可以改用插件目录里的主文件注释来判断,比如:

/* * Version: 1.8.4 */

也可以对比几个关键函数的代码特征,比如是否仍存在直接解析 Base64 后调用unserialize的逻辑。

7. 修复方案与安全加固

7.1 先升级,再谈其他

最直接的修复方式是把目标插件升级到 1.8.5 或更高版本。这次补丁的核心动作就是收紧节点数据的解析方式。升级前建议先备份数据库和插件目录,因为这类插件升级时可能会修改数据库表结构。

升级后手动验证一次“导入演示数据”功能是否正常。如果站点内部缓存了旧的序列化节点,需要先清理缓存,否则可能出现“页面样式丢失”的副作用。

7.2 代码层面的修复模式

如果你在维护自研插件,可以记住这个修复模板——任何对外接收数据的入口都要先判断类型,再决定是否使用反序列化:

// 修复后的节点解析方案 $raw = isset($_POST['node_data']) ? $_POST['node_data'] : ''; $raw = stripslashes((string)$raw); $decoded = base64_decode($raw, true); $data = json_decode($decoded, true); if (!is_array($data)) { wp_safe_redirect(admin_url('admin.php?page=site-options&error=invalid')); exit; } // 遍历节点时,强制校验 children 必须为数组 foreach ($data as $node) { if (isset($node['children']) && !is_array($node['children'])) { continue; } merge_node($node); }

关键的改进是:json_decode()只产出数组,永远无法生成对象。如果业务允许数组嵌套,JSON 格式足够表达,完全不需要 PHP 原生序列化格式。

如果确实有历史数据依赖 PHP 序列化,也要使用白名单式反序列化:

$data = unserialize($raw, ['allowed_classes' => false]);

allowed_classes设为false之后,反序列化时遇到任何对象都会立即失败,从根上阻止对象注入。

7.3 纵深防御建议

光靠插件作者修一次还不够,我还有几个 CMS 运营层面的建议:

  • 定期检查插件更新,不要只看“可用”就忽略版本号;
  • 对后台不用的“导入类”功能做关闭或隐藏处理;
  • 给 Web 访问日志增加对node_data参数的告警规则,尤其是包含O:字符串的请求;
  • 限制上传和执行目录的写权限,即使被攻破,也要尽量阻断落盘行为。

日志告警规则可以简单一点:发现请求体中包含O:且同时带有unserialize特征时,直接触发通知。虽然可能偶有误报,但比起没有任何感知要好得多。

7.4 最后的小建议

如果你还想在代码审查阶段就避免这类问题,可以给自己立一条规矩:任何外部输入都不允许直接进入unserialize(),一律先经过显式类型校验。这句话说起来简单,但真正落实要靠每一处代码审查和每一次 CR 的坚持。

在我实际跟进这个漏洞的过程中,最深刻的体会是:很多严重问题并不出现在炫技的框架层,而是隐藏在那些“看起来只是导入配置”的小功能里。节点、模板、导入、同步,这些词听起来都很无害,但它们往往是数据流最复杂、也最容易被忽略的地方。希望这篇记录能让你在以后看到“序列化”三个字时多停留几秒,在同意合并代码之前,先看一眼那个数据到底是从哪里来的、能不能被用户控制。

复现完成之后,别忘了把测试环境里留下的对象、日志、标记文件清理干净。安全研究再精彩,干净收尾才是专业的态度。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询