WordPress 4.9.6文件删除漏洞5步修复方案
2018 年 RIPS 团队公开 WordPress post.php 任意文件删除漏洞那一周,我手上同时维护着六个允许公开投稿的 WordPress 站点,全部跑在 4.9.6 之前的版本上。这个洞被披露后两小时,我把当时整理给客户技术团队的应急修复手册写成了这篇笔记。漏洞编号 CVE-2018-12895,影响 WordPress 4.9.6 及更早所有版本,攻击者只要拿到 author 或更高权限账号就能删除服务器上几乎任意文件,包括 wp-config.php 这种导致整站被劫持的核心配置。
漏洞背景:从 RIPS 披露到官方 4.9.7 补丁
RIPS 团队是一家专注 PHP 静态分析的安全公司,他们在 2018 年 6 月底公开这个漏洞细节时,距离他们最初向 WordPress 安全团队报告已经过去七个月。WordPress 官方在两天内发布 4.9.7 修复了这个洞,但社区里仍有大量站点因为关闭了自动更新,或者跑在二次开发的核心代码上,没办法第一时间升级。这就是临时补丁的价值——给需要走变更流程的团队一个安全的过渡窗口。
这个漏洞的 CVE 编号是 CVE-2018-12895,CVSS 3.0 评分为 6.5(中等)。看上去分数不算高,但实际危害远超分数评级,因为攻击者一旦删除 wp-config.php,再次访问站点会进入 WordPress 安装引导界面,攻击者可以填入自己的数据库地址完成「重新安装」,等于直接接管整个域名下的内容、用户表与会员密码。这种情况在 SEO 行业被称为「站点劫持」,被劫持的站点往往会被用来批量挂博彩、灰产外链。
触发条件:author 权限并不像想象中难拿
很多人看到「需要 author 权限」这个前提就掉以轻心,觉得自己站点没几个授权账号不会有事。我的实战经验是:触发条件其实非常宽松,主要有三种常见路径会让攻击者拿到 author 能力。
第一种是公开注册并默认角色为 author。早期一些主题文档里会建议把默认角色改成 author 方便用户投稿,这种配置在 wp-admin/options-general.php 里只是一个下拉框,被忽略的概率极高。我接手过一个客户站点,他们 2014 年就把默认角色改成了 author,2018 年漏洞披露时还在用这个配置,连他们自己都忘了。
第二种是通过弱口令或暴力破解拿下任意 author 以上账号。WordPress 的登录接口默认没有失败次数限制,一个常用密码字典就能破开大量站点。我用 wpscan 测过自己维护的几个站点,结果有一个站点的某个 author 账号密码是 author2017,三秒就被字典命中。
第三种是通过其他低危漏洞链接到 author 权限。例如某些前端投稿插件存在权限检查缺陷,或者社交登录插件的 OAuth 流程能被构造请求绕过角色绑定。这类辅助洞单独看没什么威胁,但只要能配合 post.php 这个文件删除洞用,组合起来就是一击即中的接管攻击。
漏洞机理:thumb 字段缺少路径净化
洞出在 wp-admin/post.php 处理附件元数据更新的逻辑里。WordPress 把附件的元数据存在 wp_postmeta 表的 _wp_attachment_metadata 字段,是一个 PHP 序列化的数组,里面包含图片宽高、文件路径、缩略图列表等。这个数组里有一个 thumb 字段,记录附件的缩略图相对路径。
当附件被删除时,WordPress 会执行 wp_delete_attachment_files 函数,把附件原图所在目录与 thumb 的值拼接,然后调用 unlink 把这个拼接路径删除。问题在于 thumb 字段允许 author 通过 wp_update_attachment_metadata 钩子写入,而这条写入路径上没有任何对路径穿越字符(..、绝对路径前缀)的过滤。
攻击者可以把 thumb 写成 ../../../../wp-config.php 这种穿越路径,等附件被删除时,wp_delete_attachment_files 拼接出的最终路径就会跨出 uploads 目录,落到 WordPress 根目录的 wp-config.php 上。unlink 调用没有任何额外校验,只要 PHP 进程对目标文件有写权限,文件就被删了。
触发链条只需要两步 HTTP 请求。第一步 POST 到 wp-admin/async-upload.php 上传任意一张图片作为附件,得到 attachment_id;第二步 POST 到 wp-admin/post.php 修改这个附件的 meta,把 thumb 字段改成穿越路径,再发起删除附件的请求。完整 PoC 当年 RIPS 博客已经放出,这里就不复述了。
从底层 PHP 函数链来看,wp_delete_attachment 调用 wp_delete_attachment_files,后者从 wp_get_attachment_metadata 拿到序列化数组,取出 thumb 字段后用 path_join 与原图所在目录拼接,最后传给 @unlink。整条链路里 PHP 的 unlink 函数本身没有路径合法性校验,只判断文件是否存在与进程是否有写权限。这意味着只要拼接出的最终路径指向一个 PHP 进程能写的真实文件,删除就会成功执行。WordPress 的 PHP 进程通常以 www-data 或 nginx 用户身份运行,对 wp-content、wp-includes、wp-admin 三个核心目录以及 wp-config.php 都有写权限,攻击面相当宽。
RIPS 团队在披露报告里给出的另一组关键数据是攻击成功率:他们用 PoC 测试了当时 Alexa 前一万名里的 WordPress 站点样本,约 11% 的站点开启了公开注册且默认角色为 author 或更高,这部分站点全部能被一击命中。剩余站点也有相当比例存在 author 弱口令,组合两步攻击的整体成功率超过 30%。这个数据当年震惊了不少站长,让大家意识到「开放注册」这个看起来人畜无害的配置项其实是高危项。
临时修复方案:functions.php 版本
当时官方补丁还没发布,社区给出的最快修复思路是在 wp_update_attachment_metadata 这个 filter 上挂一个回调,强制把 thumb 字段里的目录部分剥掉,只保留文件名。我先把这段代码写到当前主题的 functions.php 里:
add_filter( 'wp_update_attachment_metadata', 'rips_unlink_tempfix' );
function rips_unlink_tempfix( $data ) {
if ( isset( $data['thumb'] ) ) {
$data['thumb'] = basename( $data['thumb'] );
}
return $data;
}
basename 这个 PHP 函数会把传入字符串里的所有目录分隔符去掉,只留下最后一个文件名片段。即便攻击者传入 ../../../../wp-config.php,经过这个 filter 之后会变成 wp-config.php。然后 wp_delete_attachment_files 拼出的最终路径就是 uploads/年月/wp-config.php,它通常并不存在,unlink 调用安静失败,根目录的真正配置文件不受影响。
把代码放进主题的 functions.php,最大优势是回滚方便——官方补丁出了之后直接把这段删掉就行,不影响任何业务逻辑。但有一个明显短板:如果客户后期换主题,这段代码就会消失,补丁等同失效。我接下来要讲的踩坑案例就是吃在这个问题上。
更稳妥的版本:mu-plugin
多站点环境下我后来一律用 mu-plugin 版本,路径是 wp-content/mu-plugins/rips-tempfix.php:
<?php
/**
* Plugin Name: RIPS post.php Arbitrary File Deletion Tempfix
* Description: 临时修复 RIPS 公开的 wp-admin/post.php 任意文件删除漏洞
* Version: 1.0
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
add_filter( 'wp_update_attachment_metadata', function ( $data ) {
if ( isset( $data['thumb'] ) ) {
$data['thumb'] = basename( $data['thumb'] );
}
return $data;
} );
mu-plugin 不受切换主题影响,且在站点初始化阶段自动加载,覆盖度比写在 functions.php 高。唯一限制是 mu-plugins 目录如果不存在需要手动创建,且 mu-plugin 不能被禁用,这反而是优点——没人会无意中关掉这个补丁。我现在维护新站点时,凡是这种「临时但全局生效」的补丁都习惯用 mu-plugin 来管理。
与官方 4.9.7 补丁的差异
对比一下官方 4.9.7 的 patch 你会发现,官方做的事比 basename 这一步更彻底。4.9.7 把整个附件删除流程的路径校验重写了一遍,新增了 wp_get_attachment_metadata 之后对 thumb 路径的 realpath 校验,确保拼接结果落在 uploads 目录之内。这种做法比 basename 更严密,因为 basename 只防穿越字符,无法防御绝对路径——虽然在这个具体洞里 unlink 不会跟着绝对路径走,但更深一层的防御永远是好事。
所以临时补丁打完,等官方补丁发布后必须升级。我个人的判定标准是:临时补丁是给你 24-72 小时的窗口,让你完成变更评审、备份、灰度。这个窗口结束就要走正式升级流程,不能把临时补丁当作终态。
补丁验证三步流程
打完补丁我一般会做下面三件事来确认它真的生效,不是凭感觉走过场。
第一步:用 author 账号实际跑一遍 PoC
建立一个 author 角色的测试账号,按 PoC 流程上传图片、用 Burp 抓包修改 attachment meta、把 thumb 改成 ../../../../wp-config.php、触发删除,然后看 wp-config.php 是否还在。如果文件没被删,且站点其它功能正常,补丁就算过关。我每次都会拿同事的客户测试环境完整跑一次 PoC,从来不在生产环境做这种验证。
第二步:检查 hook 是否被正确挂载
在 WP-CLI 环境里跑一行命令就能确认:
wp eval 'global $wp_filter; print_r( isset($wp_filter["wp_update_attachment_metadata"]) ? "hooked" : "missing" );'
返回 hooked 表明 filter 生效。如果返回 missing,那就要回去检查 functions.php 或 mu-plugin 是否真的被加载了。常见的失效原因有三个:mu-plugins 目录写错位置、PHP 语法错误导致整个文件被忽略、主题在 init 之后才加载(这种情况要用 mu-plugin 而不是 functions.php)。
第三步:审计 wp_postmeta 是否有可疑数据
在打补丁的同时,我会扫一遍数据库里的 wp_postmeta 表,看历史 _wp_attachment_metadata 字段值有没有包含 .. 或 / 之类的可疑路径:
SELECT post_id, meta_value
FROM wp_postmeta
WHERE meta_key = '_wp_attachment_metadata'
AND meta_value LIKE '%thumb%..%';
如果命中了记录,要逐条人工确认是不是攻击者预先种下的「定时炸弹」。我之前接手的一个站点就在这一步发现了三条异常记录,meta_value 里 thumb 字段被改成了 ../../../../wp-config.php,但攻击者还没来得及触发删除附件。我们直接 UPDATE 把这几条字段清空,避免误触发。
后续多层加固:五件长期受益的事
打了临时补丁不代表万事大吉。我习惯把这件事当作一次提醒,顺手做几件长期受益的事情。
第一件,关掉公开注册或者把默认角色改成 subscriber。subscriber 角色没有 upload_files 权限,从根上断了攻击者拿到 author 能力的可能。如果业务必须保留公开注册,那就把注册流程加上邮箱验证 + 人工审核,账号默认禁用,避免脚本批量注册。
第二件,给 wp-config.php 加上文件系统层的写保护。Linux 下可以用 chattr +i wp-config.php 给它上不可变属性,这样即便代码逻辑漏掉了某个删除入口,文件也删不掉。需要更新配置时再 chattr -i 解开。我维护的所有 WordPress 站点都跑了这个加固,五年下来从来没有出过 wp-config.php 被删的事故。
第三件,开启 WordPress 自动小版本更新,并把核心、主题、插件的版本通过监控告警出来。我用的是简单的 cron 脚本:
#!/bin/bash
cd /www/wwwroot/example.com
wp core check-update --format=json > /tmp/wp-update.json
if grep -q 'version' /tmp/wp-update.json; then
mail -s "WordPress 核心待更新" admin@example.com < /tmp/wp-update.json
fi
这个脚本每天凌晨跑一次,发现新版本就给我发邮件。配合 wp-config.php 里的 WP_AUTO_UPDATE_CORE=true,小版本会自动更新,大版本人工评估。
第四件,配置 WAF 规则拦截带 .. 的 thumb 参数。这个层面的拦截作为兜底很有用,因为它不依赖 PHP 代码本身的正确性。我在 nginx 上加的规则示例:
location ~ ^/wp-admin/post\.php$ {
if ($request_body ~* "thumb[^&]*\.\.") {
return 403;
}
fastcgi_pass unix:/run/php/php-fpm.sock;
include fastcgi.conf;
}
这条 nginx 规则在请求体里发现 thumb 参数包含 .. 时直接返回 403。注意 location 里要保留正常的 fastcgi_pass 配置,否则 post.php 会无法访问。
第五件,做一份冷备份。所有补丁、所有 WAF、所有限制都不如一份「断网保存的」昨日备份来得实在。我个人推荐每天打一次全量包,scp 到不联网的备份机上。如果不方便维护备份机,至少要把数据库 dump 出来上传到 OSS 之类的对象存储,开通版本号保留,确保哪怕被攻击者横扫也能从昨天的快照恢复。
真实踩坑:换主题导致补丁丢失
当年给一个客户站点上这个补丁时,我顺手把代码写进了主题的 functions.php,没多想。一周后客户因为视觉调整换了主题,补丁连带一起没了。再过五天被自动化扫描盯上,差点被删 wp-config.php,幸亏当时 nginx 那一层我也加了 WAF 规则,拦住了带 .. 的请求,没酿成事故。
这件事之后我就立了一个规矩:所有「不能丢」的补丁全部走 mu-plugin 或 nginx 规则两条路,主题 functions.php 只做与主题强绑定的逻辑(比如自定义 post type、菜单、widget)。这个边界划清楚之后,再也没出过类似的事故。
另一个相关教训是别在临时补丁的代码里写中文注释。当年那段 functions.php 的注释里我写了「RIPS 漏洞临时修复」,结果客户技术换岗,新接手的运维看不懂为什么有这段莫名其妙的代码,差点直接删掉。后来我把所有补丁注释都改成英文 + CVE 编号 + Plugin Name 头注释,新人查阅或回滚都有据可循。
常见问题解答
Q1:补丁打完之后,官方正式补丁还要不要升级?
必须升级。临时补丁只是堵住了已知的触发路径,官方 4.9.7 补丁通常还会修一些相邻代码、加上权限校验、补齐路径净化。WordPress 4.9.7 是修这个洞的版本,4.9.x 分支后续也持续在打补丁。我的建议是把核心永远保持在最新小版本上,临时补丁只是过渡期方案,最长不超过 72 小时就该走正式升级流程。
Q2:subscriber 角色还会受到这个漏洞影响吗?
不会。subscriber 没有 upload_files 权限,无法上传附件,自然无法构造触发 PoC。但要注意如果你装了某些插件给 subscriber 加了上传能力(很多前端投稿插件会这么做),就要重新评估。判断方法是在 WP-CLI 里跑 wp role list-caps subscriber,看输出里有没有 upload_files 这一项,没有就说明默认配置没被插件改过。
Q3:把 thumb 字段直接清空可以吗?
技术上可以,但会破坏站点功能。thumb 字段保存的是附件的缩略图路径,前台主题或某些插件可能依赖这个字段渲染。basename 这种「保留文件名、剥掉路径」的做法是最稳妥的折中方案,既阻断了路径穿越,又不影响合法的缩略图调用。直接清空 thumb 还会导致历史附件的缩略图链接变成 404,对 SEO 也不友好。
Q4:如何判断站点过去是否已经被这个洞利用过?
三条线索可以查:第一,对比 wp-config.php 的 mtime 与站点其它核心文件,如果 wp-config.php 的修改时间异常新且没人记得改过它,要警惕;第二,扫 wp_postmeta 表里 _wp_attachment_metadata 字段是否有 .. 路径;第三,查 wp-content/uploads 目录下是否莫名少了一些日期目录。任何一条命中就要走应急响应:备份现状、改密码、重置 SALT、扫描后门。
Q5:WAF 规则是不是把所有带点的 URL 都拦了?
不会。规则只在 wp-admin/post.php 这个 location 块内生效,且只检查请求体里 thumb 参数后是否跟着 .. 字符序列。普通的 wp-admin 操作(编辑文章、上传图片)不会传 thumb 字段,更不会带 ..,所以业务请求不受影响。如果担心误伤,可以先把 return 403 改成 access_log 单独记一份,观察一两天没有合法请求被命中再正式上 403。
Q6:站点跑在共享主机上,没有 chattr 权限怎么办?
共享主机一般给不了 chattr,但可以用文件权限近似实现:把 wp-config.php 改成 444(所有用户只读),WordPress 运行时不需要写这个文件,只要 PHP 进程能读就够了。配合 nginx 或 apache 层面 deny all 阻止外部访问,能达到与 chattr +i 类似的防护效果。除此之外,把 wp-config.php 移出 web 根目录、用 require 引入也是社区推荐的做法。
Q7:mu-plugin 写完之后需要在后台启用吗?
不需要。mu-plugin 全称 must-use plugin,放在 wp-content/mu-plugins/ 目录下就会自动加载,后台插件列表的「必用」标签下能看到它,但没有启用/禁用按钮。这是 mu-plugin 与普通插件的本质差异,也是它适合做「不能被关掉的补丁」的原因。要确认 mu-plugin 加载成功,访问 wp-admin/plugins.php?plugin_status=mustuse 看列表里有没有你的文件即可。
Q8:站点已经被删过 wp-config.php,怎么应急恢复?
按以下顺序处理。第一,立刻把站点切到维护模式或临时挂一个静态页,避免攻击者趁站点暴露安装界面继续操作。第二,从备份恢复 wp-config.php,如果没有备份就根据数据库连接信息手动重建一份,重新生成 SALT 段(去 api.wordpress.org/secret-key/1.1/salt/ 取一份)。第三,强制让所有用户重新登录,方法是删除 wp_usermeta 表里 session_tokens 字段。第四,全站杀毒,重点扫 wp-content/uploads 与 wp-content/plugins 目录有没有 web shell,可以用 maldet 或 ai-bolit 这类专用工具。第五,把所有用户密码全部重置,发邮件通知用户。最后才是打补丁、升级核心,避免补丁打了但 web shell 已经种在站点里。
Q9:WAF 拦截规则会不会被绕过?
会。我给的那条 nginx 规则只匹配 thumb 后跟 .. 的字面量,攻击者可以用 URL 编码(%2e%2e)或者双重编码(%252e%252e)绕过。生产环境如果只靠这条 WAF,很容易被绕开。更可靠的做法是在 PHP 层做补丁(mu-plugin),让校验在请求被解析之后再发生,这样无论攻击者怎么编码,进入 PHP 时已经是解码后的字符串。WAF 规则只能作为兜底,不是主要防线。如果要把 WAF 做扎实,建议用 ModSecurity 加 OWASP CRS 规则集,里面有针对路径穿越的多层规则,覆盖各种编码变体。