从“危险网站”告警到完整恢复:服务器 Umami 入侵事件复盘
从“危险网站”告警到完整恢复:服务器 Umami 入侵事件复盘
这篇文章不是“服务器被黑了以后重新装一遍”的经验帖,而是一份尽量按证据写下来的事件记录。
2026 年 8 月,我们发现 blog.910501.xyz 被 Google Safe Browsing 标记为危险网站。最开始看起来像是 Cloudflare 优选域名、DNS 或 Vercel 的问题,但继续追查后,真正的故障点落在 服务器 上一个自托管的 Umami 容器:容器可写层里出现了不属于官方镜像的中间件、下载器脚本和 ELF 可执行文件。
博客的每个页面又加载了 Umami 统计脚本,于是一个被污染的统计域名,最终把博客整体拖进了 Safe Browsing 的风险判断。
这次事件给我的最大教训是:“页面能打开”不等于“服务安全”,容器还能运行也不等于镜像没有被修改。
安全说明:本文不包含密码、Cookie、API Key、数据库 DSN、私钥或完整请求体。文中的 IOC 使用脱敏格式展示,不要访问或执行这些地址和文件。本文记录的是本人的生产环境处置过程,不构成对其他系统的入侵建议。
一、最初看到的现象
最先出现的是 Chrome 红色“危险网站”页面。Google Safe Browsing 的检查结果显示,以下地址被认为包含可能诱导用户泄露个人信息或下载软件的内容:
blog.910501.xyz/- 博客的普通文章页和
robots.txt umami.910501.xyz/umami.910501.xyz/script.js
这时最容易误判成“Cloudflare IP 被封”或者“优选域名失效”。因为部分站点仍能返回 200,另一些站点返回 502,看上去像线路问题。但 Safe Browsing 的红色告警和源站 502 是两个不同层次的问题:前者是域名或页面信誉,后者通常是反向代理到源站失败。
同时,nc.910501.xyz 和 ql.910501.xyz 的 502 被确认是本机端口没有服务监听,OpenResty 日志里是 connect() failed (111: Connection refused),并不是 Cloudflare 把请求“变坏”了。
二、时间线:从攻击请求到公开告警
1. 2025 年 12 月开始出现异常请求
服务器 的 OpenResty 日志从 2025 年 12 月起,持续出现针对 Umami 根路径的异常 POST。日志里能看到 Server Action 探测和异常请求格式,例如:
React2ShellScanner/1.0.0Failed to find Server ActionInvalid Server Actions request这些日志本身不能证明某一次请求就是唯一的入侵请求,因为当时没有保存完整请求体。但它们与当时运行的 Next.js 版本、后续落地文件和攻击时间线能够互相印证。
2. 2026 年 7 月重建后,攻击很快再次出现
Umami 容器在 2026 年 7 月 13 日重建后,大约几个小时内又收到了同类型的探测请求。这说明“重建容器”只能清掉当时的可写层,不能替代版本修复、入口收敛和凭据轮换。
3. 2026 年 8 月 8 日,Safe Browsing 明确标记
8 月 8 日检查 blog.910501.xyz 时,Google Safe Browsing 仍显示“此网站不安全”。博客本身使用 Vercel,Umami 使用 Cloudflare/OpenResty 代理,这也是为什么一开始很容易把问题错误归因到 CDN 或 DNS。
真正的关联关系是:
Umami 容器被写入未授权代码 ↓umami.910501.xyz/script.js 被污染或被用于投放内容 ↓博客所有页面加载 Umami 脚本 ↓Google 将 Umami 风险关联到博客页面 ↓Chrome 对博客显示危险网站警告三、确认容器不是“普通配置漂移”
对 Umami 容器做证据保全和可写层核查后,发现了以下不属于官方镜像正常运行所需的文件:
/tmp/middleware.ts/home/nextjs/.config/sys_boot.sh/home/nextjs/.config/service_bin//var/tmp/.font/n0de其中最关键的是 /tmp/middleware.ts。它会读取访问者的 User-Agent、Host、URI、来源、语言和 IP 等信息,然后根据远端返回内容执行不同响应。代码具备典型的 SEO Cloaking 特征:
- 对普通访客、爬虫和特定 User-Agent 返回不同内容;
- 可以把访问者重定向到远端页面;
- 可以直接返回远端 HTML、XML 或 JSON;
- 可以伪装成正常的 Next.js 中间件。
它曾向以下脱敏后的地址提交数据:
j[.]aihack[.]top/api/proxy/handle.php另一个启动脚本会尝试下载并后台运行一个伪装的 Node 进程。根据已保全的 sys_boot.sh,恶意载荷下载源的 IP 是 141.164.60.162,端口为 8038,路径为 /node-js。这里记录的是下载基础设施 IP,不等同于已证明的攻击者真实来源 IP,也不能证明所有攻击请求都直接来自该地址。下载地址为:
141.164.60.162:8038/node-js/var/tmp/.font/n0de 是一个 x86-64 ELF 文件,字符串中出现了钱包、矿池和节点相关特征。服务器 当前为 ARM64,因此这个载荷当时不能直接在宿主机上运行;但“当前不能运行”不等于“可以安全保留”,它仍然必须作为证据隔离,不能执行、不能上传公开平台,也不能继续放在生产容器里。
四、入侵入口的判断:高概率是未修补的 Next.js Server Action 漏洞
当时 Umami 使用的镜像是:
ghcr.io/umami-software/umami:mysql-v2.19.0容器内的 Next.js 为 15.3.3。结合持续出现的 React Server Components / Server Action 探测、后续写入的中间件和下载器,以及重建后攻击迅速恢复的时间线,最合理的判断是:
Umami 运行的 Next.js 版本存在远程代码执行风险,攻击者很可能通过暴露在公网的 Server Action 入口进入容器。
官方公告对相关 React Server Components / Next.js 漏洞的说明见:Next.js CVE-2025-66478。
这里需要把“确认”和“推断”分开:
- 确认:容器可写层有未授权中间件、下载器和可执行文件;公网日志有对应类型的攻击探测;博客加载了 Umami 脚本。
- 高概率推断:攻击者利用了未修补的 Next.js Server Action / React Server Components 入口。
- 无法还原的部分:由于原始访问日志没有保存完整请求体,不能把某一个具体 POST 请求宣称为唯一入侵入口。
这比直接说“某一条日志就是入侵成功请求”更严谨,也更符合事故复盘的证据边界。
五、影响范围到底有多大
已确认的影响
- Umami 容器被写入非官方文件。
- Umami 的公网脚本和根域名被 Google Safe Browsing 标记。
- 博客页面因为加载 Umami 脚本而受到关联影响。
- Umami 的
APP_SECRET、数据库连接凭据和管理员会话必须按已泄露处理。
暂未发现的影响
后续横向检查中暂未发现:
n0de或同组 IOC 在运行;- 到恶意 IP 的活动外联;
- 宿主机上出现同名文件或未知系统服务;
- 其他容器出现同组 IOC;
- Umami 使用宿主机挂载、Docker socket、额外 capability 或 privileged 模式;
- 已有证据证明攻击者逃逸到 服务器 宿主机。
不过,“没有发现”不等于“绝对没有发生”。因此处置时没有把数据库、共享 Docker 网络和凭据当作安全的,而是按最小权限和凭据可能泄露重新处理。
六、正确的处置顺序
第一步:先保全证据,再停止服务
停止容器之前,保存了:
docker inspect、docker diff、docker top;- 容器日志和镜像 digest;
- OpenResty access/error 日志和 Umami vhost;
- 容器网络、挂载、能力和进程信息;
- 污染容器的导出归档及 SHA256;
- MySQL 数据库备份和可恢复性检查;
- 当时的 DNS、博客 HTML、脚本列表和 Safe Browsing 检查结果。
证据目录使用 root-only 权限保存,禁止把数据库备份、环境变量、容器导出或 IOC 样本上传 GitHub、网盘或公开文章附件。
第二步:先切断公网应用入口
证据保全后,umami.910501.xyz 由 OpenResty 返回静态 410 Gone,只保留 ACME Challenge 路径。这样做的目的不是“伪装服务正常”,而是确保浏览器、爬虫和博客都不能继续进入污染容器。
然后才停止 Umami、关闭自动重启、断开共享 Docker 网络,并保留污染容器供后续核查,而不是立刻删除。
第三步:轮换凭据
以下内容按已泄露处理:
- Umami
APP_SECRET; - Umami 专用 MySQL 用户密码;
- Umami 管理员会话;
- 依赖 Umami 或共享服务的内部连接凭据。
普通用户 API Key 没有无理由批量轮换,避免把一次安全事件扩大成业务中断;只有发现异常使用的 Key,才单独吊销。
第四步:移除博客上的危险依赖
博客方面做了几件非常保守的事情:
- 清空 Umami
websiteId和scriptUrl; - 永久不再恢复这次被污染的 Umami 容器和可写层;
- 暂时关闭 Waline 评论和 Guestbook;
- 暂时移除第三方网盘、邀请码、短链和安装包;
- 保留文章主体,但移除无法核验的下载执行链;
- 保留 Vercel Insights 和 Speed Insights;
- 通过本地构建和公网页面扫描确认不再请求 Umami、Waline 或恶意 IOC。
这里的原则是:恢复文章内容,不等于恢复所有外链;保留教程,不等于替读者背书未知下载源。
七、恢复业务时没有“一把梭”
安全事件之后,不能因为“页面现在能打开”就把所有服务同时放回公网。实际恢复按服务拆分,并且每个服务都保留了回滚点:
- 先恢复 Sub2API 的完整网页、用户后台、管理员后台、Ops 和 API 网关;
- 管理员密码和会话先轮换,旧 Refresh Token 先失效;
- OpenResty 先做敏感路径拒绝、Cloudflare CIDR 源站隔离和限速;
- CloudSaver 使用固定 digest 的候选镜像,在隔离副本上完成权限和 JWT 回归后再上线;
- OpenList 单独迁移到 loopback 端口,保留 Quark 存储为 disabled,先轮换管理员和 JWT;
- Epic 先清理运行时 CDN 依赖、修复浏览器任务和凭据,再以固定镜像恢复;
- TV2 保留
/api和/api/*,网页根路径继续 410; - TV 使用独立容器、独立 Redis、独立凭据,旧容器改名保留回滚,不迁移旧用户数据;
- 博客文章分批恢复,评论、留言、下载内容继续关闭;
- Umami 永久排除在恢复范围之外。
每次切换前后都检查容器 ID、启动时间、RestartCount、OOM、监听端口、源站直连和 Cloudflare 访问结果,避免一次“恢复”顺便重启数据库、Redis 或 WARP 主容器。
八、为什么不能只靠 Cloudflare 或 Safe Browsing 复核
这次事件也纠正了几个常见误区。
Cloudflare 橙云不能修复被入侵的源站
橙云可以改善 DNS 隐藏、WAF、缓存和 DDoS 防护,但它不能:
- 删除容器里的恶意文件;
- 修补 Next.js 远程代码执行漏洞;
- 轮换数据库密码和应用 Secret;
- 判断页面是否存在 SEO Cloaking;
- 修复源站服务停止导致的 502。
因此后续恢复使用了 Cloudflare CIDR 源站隔离、Authenticated Origin Pulls / Origin CA 和 Full (strict) 的组合,但这些只是在清理之后减少绕过和伪造源站访问,不是替代补丁。
Safe Browsing 不是“测一下首页 200”
Safe Browsing 关注的是完整 URL、跳转、脚本、iframe、下载、表单和第三方资源。一个首页返回 200 的站点,仍然可能因为隐藏脚本或某篇文章中的下载链被标记。
这次复核至少检查了:
- 根页面、文章页和
robots.txt; - 外部脚本、iframe、重定向和表单;
- Umami/Waline/网盘/邀请码残留;
- 页面是否产生登录、密码输入或文件下载行为;
- 中国大陆运营商、手机流量和海外网络的访问差异;
- Google Safe Browsing 和 Search Console 的状态变化。
提交复审前,必须先保证污染容器停止、博客不再引用危险资源、Google 能抓到干净部署;不能一边继续加载 Umami,一边提交“已经清理”的复审说明。
九、这次事件留下的检查清单
以后新部署一个公网服务,我会至少做下面这些检查:
镜像与版本
- 禁止使用不确定的
latest; - 记录镜像 digest;
- 对框架和运行时依赖做漏洞扫描;
- 公开暴露的 Next.js、Nuxt、WordPress 等服务及时升级;
- 升级后重新检查 Server Action、Middleware、调试和安装入口。
容器与主机
- 应用使用非 root 用户;
cap_drop: ALL,启用no-new-privileges;- 不给统计、博客、后台服务挂载 Docker socket;
- 不让无关服务共享同一个 Docker 网络;
- 定期检查
docker diff、/tmp、/var/tmp和启动脚本; - 监控容器异常外联、PID、CPU、OOM 和 RestartCount。
反向代理与源站
- 源站服务只绑定 loopback 或私网;
- 80/443 只接受 Cloudflare 官方 CIDR;
- 非业务路径明确拒绝
/setup、/debug、/metrics、.env、.git和备份文件; - 动态 API 不缓存、不返回登录 HTML 代替 JSON 错误;
- SSE 和 WebSocket 单独验证,不让代理缓冲破坏业务。
账号与数据
- 安全事件后优先轮换 Secret,而不是先重启服务;
- 管理员会话和 Refresh Token 要能按用户撤销;
- 只给数据库用户最小权限;
- 备份要能恢复,并且与生产配置分开保存;
- 证据和备份不得进入 Git、网盘或公开工单。
十、最终结论
这次“危险网站”告警,根因不是 Cloudflare 优选 IP 失效,也不是 Vercel 本身突然不安全,而是:
公网暴露的旧版 Umami / Next.js→ 容器被写入 Cloaking 和下载器→ Umami 脚本域名被 Safe Browsing 标记→ 博客全站加载该脚本→ 博客被关联标记为危险网站最重要的处置不是换域名、换 CDN 或清浏览器缓存,而是:
- 先保全证据;
- 立即切断污染容器的公网入口;
- 停止并隔离容器;
- 轮换已经暴露的凭据;
- 移除博客上的第三方危险依赖;
- 检查宿主机和同网络容器;
- 用固定版本、最小权限和可回滚方式分批恢复;
- 把“没有发现 IOC”与“证明绝对安全”严格区分开。
Umami 在这次事件中不会恢复。历史统计数据和事件证据会继续保留,但不再把一个自托管统计脚本作为博客所有页面的共同依赖。
这篇文章目前保留为草稿。后续如果 Safe Browsing、Search Console 或 服务器 的审计出现新的事实,应当更新时间线和证据等级,而不是为了让文章更“完整”而补写未经验证的结论。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!
一万AI分享