引子

写博客这些年,系统换过好几套:跑过静态站,也用过 WordPress。WordPress 的好处很直接——有后台、有编辑器、有插件,装个主题就能改样式,不用碰代码。坏处也直接:每隔一段时间就得升级核心、升插件、看安全公告,还得祈祷别装到什么有问题的扩展。

我一直知道它不是个省心的东西,但总觉得“就一个小博客,谁会盯着打”。

2026 年 11 月 9 日下午,这个侥幸被打碎了。那天我本来只是想进后台给博客升个级,结果密码怎么都对不上。试着查了一下用户表,才发现我自己的管理员账号早就没了;后台里剩下的管理员有 15 个,没一个是我建的。

进一步翻日志后,最早的一个异常管理员账号建于 7 月 20 日深夜。也就是说,这个博客已经被别人控制在手里快四个月,而我毫无察觉。

这篇文章把排查和迁移的经过记下来,也给同样在维护个人站点的朋友提个醒。

发现异常

最先不对劲的其实是前台。

打开博客,浏览器标签页的标题后面挂着一大串奇怪的字符。那个位置是站点副标题,我早就忘了自己写过什么,当时还以为是主题出了问题,在设置里折腾了一会儿没弄明白,就随手放着了。后来才知道,那是攻击者在 7 月 27 日写进去的序列化恶意数据。

然后是用户表。原管理员账号不存在,剩下的管理员全是陌生名字,创建时间集中在 7 月到 9 月。再对照访问日志里的登录记录,一条清晰的时间线浮了出来:

  • 2026 年 7 月 20 日深夜,站点第一次被利用,攻击者创建了管理员账号;
  • 7 月到 9 月,管理员账号被持续创建,前后累计 51 个;
  • 7 月 27 日,站点副标题被写入一段序列化恶意数据;
  • 9 月 10 日傍晚,分两批删除了 37 个用户,我的原管理员账号就在其中;
  • 9 月 25 日凌晨之后,对方没有再登录;
  • 11 月 9 日,我开始排查并处置。

查到这里基本可以确认:不是密码泄露那么简单,站点是实打实被拿下了。当时还活着的管理员账号有 15 个,另外还有若干应用密码和会话记录留着。

排查:日志、binlog 与校验和

这次排查主要靠四类材料,事后回看缺一不可。

访问日志与归档。 服务器上的 OpenResty 访问日志保留得比较完整,连轮转归档都在。按接口和时间段去筛,攻击者的动作非常清楚:匿名调用 REST API 的 batch 接口、批量建管理员、批量删用户、签发应用密码、往主题编辑器里反复提交写文件请求、上传插件,请求路径和时间都留下了。缺点是日志里没有请求体、Cookie 和 Authorization,所以只能提供“发生了什么”的骨架,具体改了什么数据要靠别的证据。

MySQL binlog。 这是还原时间线最可靠的一手材料。数据库开了 binlog,把它解码出来,能看到每一行数据在什么时间被插入、删除、修改:哪个时刻多了一个管理员,哪个时刻用户少了 37 行,副标题在哪一毫秒被改掉。访问日志和 binlog 交叉比对,整个事件的顺序就能钉死。

官方校验和。 WordPress 官方提供核心文件的校验和清单。我按 6.9.1 的清单核对了 2,913 个核心文件,全部通过。这个结果有两层含义:好消息是核心文件没被改,攻击者没往核心代码里塞后门;坏消息是——站上跑的就是那个有漏洞的旧核心。

版本错位。 这是整件事里最疼的一处。容器镜像明明已经是 WordPress 7.1.4,进容器里一看,实际对外提供服务的站点目录(webroot)还是 6.9.1。原因是升级只换了镜像,持久化目录里的旧文件原地不动,容器启动脚本发现目录里已经有程序,就不会再复制一份新核心进去。于是升级看起来一路成功,站点上跑的却是个有已知漏洞的老版本。

攻击链与根因

根因是 WordPress 核心的一个未认证远程代码执行漏洞链,编号 CVE-2026-63030 和 CVE-2026-60137,公开资料里管它叫 wp2shell,官方在 6.9.5 和 7.0.2 里修复。问题出在 REST API 的 batch 路由上:匿名用户可以把多个内部请求打包提交,再配合 SQL 注入,不需要账号、不需要插件,就能拿到数据库里的管理员权限。

我们的站点正好是 6.9.1,在受影响范围内。7 月 20 日 23 点 38 分,第一批匿名批量请求打了进来,十几秒后数据库里多了一个管理员,紧接着这个账号就登录了后台。之后是长达两个月的操作:建号、删号、改设置、签发长效凭据、尝试往主题文件和上传目录写东西。从日志和事后文件检查来看,那些上传插件、改主题文件的尝试大多没有成功落地,也没发现现存的文件后门;但攻击者手里一直握着管理员权限和应用密码,这已经足够把站点当成自己的了。

副标题里那段序列化数据也确认没有生效:它依赖的类库不在这个站上,数据又被套了两层序列化,WordPress 只解一层,等于盲打没打中。算是这次事件里为数不多的运气。

这里有个细节值得单独说:application password(应用密码)是 WordPress 给 API 客户端用的长期免密凭据,签发一次就能一直调用 REST API,改登录密码并不会让它失效。所以“改个密码”这种常规操作,在这个场景里基本没意义。

为什么放弃 WordPress

11 月 9 日当天,我按比较稳妥的顺序做了处置:先隔离站点,再保全证据——整站打包、数据库导出、binlog 原样保留、云盘快照留底;然后用干净的核心和全新的数据库重建了一版 WordPress,只迁移人工审核过的内容。重建本身是成功的,站点能跑,恶意副标题也清掉了。

但对着这个“修好了”的 WordPress 想了一晚上,我还是决定不再维护它。

理由有这么几条。第一,这次的漏洞在核心,不在插件。就算我一个插件都不装,只要核心版本落后,攻击面就在,而我在这次之前,根本不会去核实实际运行的版本。第二,它的后门是账号级的:删掉这 15 个,攻击者只要还能调用接口就能再建一批,7 月到 9 月他们就是这么干的。第三,备份不可信——我查到的两份升级备份里都还带着恶意选项和管理员账号,等于真出事的时候,手里没有一个“入侵之前”的干净恢复点。第四,PHP + 数据库 + 后台登录 + 主题/插件编辑器 + 上传目录,这套组合的攻击面实在太大,而我要的只是一个能安静写字的地方。而且维护成本不会因为重建一次就消失:升级、核校验和、盯日志、看新 CVE,每个月都得来一遍。

个人博客最不值钱的是流量,最值钱的是“不用天天提心吊胆”。所以这次不是系统升级,是给自己做减法。

新站:Astro + Markdown

新站用的是 Astro,主题是 Astro Solitude,Markdown 写作,构建出来是纯静态的 HTML/CSS/JS,整份产物大约 12MB,放到任何一台能跑 nginx 的机器上就能提供服务。

好处很直观:

  • 没有后台,没有登录入口,也就不存在“管理员密码被偷”这件事;
  • 没有数据库,文章就是仓库里的 Markdown 文件,frontmatter 里写标题、日期、分类、标签;
  • 没有 PHP,没有插件,构建完就是一堆静态文件;
  • 内容在 git 里,改坏了能回退,每一版都有记录。

旧站能找回、也能确认归属的文章只有 9 篇。旧的静态站还留着完整页面,我按页面一篇篇还原成 Markdown 放进新仓库;WordPress 那边原账号被删、作者关系被打乱,这次不往里搬,旧库和导出材料先留在归档里,需要时再按审核结果迁移。这个过程比想象中费劲,但结果让人踏实:现在每一篇文章都是一个纯文本文件,换系统只是换个渲染器。

部署架构

部署链路很简单,简单到有点不习惯:

本地构建出静态产物 → 上传到服务器 → 用一个 nginx:stable-alpine 容器托管这份产物(容器内监听 80,映射到服务器的 127.0.0.1:8089)→ 服务器上原有的 OpenResty 负责反向代理和 HTTPS,博客对外只暴露 443 端口。

几个细节:容器里的站点目录是只读挂载的,nginx 只能读、不能写;容器只绑定本机回环地址,公网打不进来;发布就是把新产物同步进站点目录,静态文件即改即生效,不需要重启服务,保留上一份产物就能回滚。整个过程没有 PHP、没有数据库、没有插件,需要维护的东西少了一个数量级。

旧链接兼容

换系统最怕链接全挂,这次尽量做了兼容:

  • 9 篇旧文继续生成 /posts/<id>.html 这样的地址,和旧站保持一致;
  • 旧站的分类、标签路径大小写和主题默认的不一样,旧的大小写路径都做了重定向;
  • 旧站的 /atom.xml 订阅地址保留,新站也会额外生成常规订阅和站点地图;
  • 友链、留言板、年月归档这些旧地址一并做了重定向,访问不存在的路径会返回自定义 404。

对读者来说,书签和 RSS 阅读器里的旧地址还能用,这比“新站更好看”重要得多。

教训清单

  1. 升级镜像不等于升级程序。 持久化目录不会因为 pull 了新镜像就自动更新。验收要进容器看实际版本,再对一遍官方校验和,不能只信管理界面的“成功”。
  2. 入侵之后的备份不是干净恢复点。 两份带毒的升级备份就是教训。恢复点必须能追溯到出事之前,恢复后还要先验证。
  3. 顺序是:先隔离、再取证、最后清理。 删文件、删账号之前,先把访问日志、binlog、整站和数据库都留一份。删得越干净,越查不清。
  4. 最小暴露面是一种长期收益。 匿名可达的接口、能执行脚本的目录、用不到的功能,都是在替攻击者降低难度;这次被利用的 batch 接口,本来就不需要对匿名用户开放。
  5. 监控比日志重要。 异常创建管理员、签发应用密码、修改站点设置、匿名批量调用,这些都应该实时告警,而不是等四个月后自己翻日志发现。
  6. 静态站是性价比最高的减法。 没有登录、没有数据库、没有可执行代码,等于把“后台被端”这一整类事故从根上删掉。
  7. 内容要握在自己手里。 文章写成 Markdown 放进版本库,换系统只是换渲染器;如果内容锁在某个后台的数据库里,一次事故就可能连找回都困难。

结语

这次事件对我最大的触动,不是“WordPress 不安全”——任何带后台、会持续更新的系统,都有不安全的时候。真正的教训是:我把一个持续变化、持续被人研究的攻击面,当成了装好就不用管的工具,然后三四个月没认真看它一眼。

现在博客是一堆静态文件,不会自己更新,也不会有新漏洞被自动塞进来;写文章就是写一个 Markdown 文件。谈不上多高级,但至少心里踏实。

如果你也在维护个人博客,希望这个故事能让你今晚去后台看一眼版本号——真出问题的时候,那一眼可能比任何备份都值钱。