记一次被XMRig 挖矿的真实维修全路 挖矿木马的完整取证与处置复盘
记一次被XMRig 挖矿的真实维修全路 挖矿木马的完整取证与处置复盘
Prorise当我的服务器 CPU 跑满 750%:一次门罗币挖矿木马的完整取证与处置复盘
写于 2026-04-27 晚,事发当日。本文复盘了一次典型的 SSH 弱密码导致的服务器入侵事件,从发现异常到溯源、清理、加固的全过程。所有服务器特征信息(IP、主机名、用户名、密码、邮箱、密钥指纹)已脱敏;攻击者使用的真实路径、矿池地址和攻击源 IP 完整保留——这些是公开的威胁情报,对其他读者有参考价值。
一、那天下午
那是个再普通不过的下午。我手头跑着另一个项目的代码,计划晚上把一个新功能 push 上去。中途切到终端,习惯性地连了一下生产 VPS 看看监控。
1 | ssh myserver |
屏幕亮起来的瞬间,我整个人僵住了。
1 | top - 19:33:48 up 6 days, 2:10, 0 user, load average: 9.58, 16.63, 13.86 |
这是一台 8 核 8G 的小机器,跑着 OpenWebUI、几个 Java 微服务、MySQL、Redis、MinIO,常年负载 1-3,从来没见过 9.58 这种数字。
CPU 使用率显示 85.7 us, 14.3 sy, 0.0 id——空闲为 0。我盯着 0.0 id 那一行看了好几秒。在我五年的运维经验里,看到 id=0.0 通常意味着两件事中的一件:要么内核出了问题,要么——
我翻到下面看进程列表:
1 | PID USER PR NI VIRT RES SHR S %CPU %MEM TIME+ COMMAND |
PID 3487998,进程名 systemd,CPU 占用 750%。
我心里"咯噔"一下,瞬间清醒了。
第二行的 python3 是 OpenWebUI,正常。最下面的 PID 1 systemd 也正常——它是真正的系统初始化进程。但 PID 3487998,进程名也叫 systemd,独占 8 核中的 7.5 核——这是不可能的。
真正的 systemd 进程只有 PID 1 这一个。任何其他叫 systemd 的进程都不正常。
二、第一处不对劲:参数太离谱
我用 ps 拿到完整命令行:
1 | ps -eo pid,ppid,user,pcpu,pmem,rss,etime,stat,cmd --sort=-pcpu | head |
1 | PID PPID USER %CPU %MEM RSS ELAPSED STAT CMD |
systemd -c .config.json——这个命令行让我笑出声了。
真正的 systemd 二进制接受的参数是 --system、--user、--unit、--log-level 这一套,它从来不接受 -c 这种参数,也绝不会去加载什么 .config.json。systemd 的配置文件是 system.conf、*.service unit 文件、INI 格式,用的解析器是 systemd 自己的。
这个 -c .config.json 一看就是 某个用 cxxopts 或类似命令行库的 C++ 程序——而碰巧,加密货币挖矿程序最常用的命令行库就是这个。
更可疑的细节:
RSS只有 3232 KB——真正的 systemd RSS 通常在 8000+ KBSTAT是S<sl,<表示高优先级(PR=5,NI=-15),普通进程不会无缘无故抢这种优先级ELAPSED51 分钟,意味着大约 18:42 启动,那个时间点我没做过任何运维操作
这时我已经 90% 确定中招了。但我需要"摸到牌面"——拿到铁证,搞清楚是什么、怎么进来的、还有没有其他后门。
三、/proc 不会撒谎
被入侵的服务器最怕什么?最怕你看到的东西已经被攻击者动过手脚。
老牌的 rootkit 会替换 ps、ls、netstat、top,让恶意进程对管理员"隐身"。所以遇到入侵嫌疑的第一反应不应该是相信 ps 的输出,而是绕过这些用户态工具,直接读内核维护的事实。
Linux 上做这件事的最佳工具是 /proc 文件系统——它由内核直接维护,攻击者要"骗"它,得修改内核本身(注入 LKM rootkit),难度比替换几个二进制高几个数量级。
/proc/<pid>/ 下我每次都会看的几个文件:
| 文件 | 含义 | 为什么不会骗人 |
|---|---|---|
exe | 指向真实可执行文件的符号链接 | 内核在 execve() 时记录的 dentry,不依赖任何用户态字段 |
cwd | 进程工作目录的符号链接 | 同上 |
cmdline | 启动命令行(\0 分隔) | 进程启动时由内核拷贝到内核内存 |
environ | 启动时的环境变量 | 同上,关键证据来源之一 |
status | UID/GID、内存、状态等 | 内核结构体直接 dump |
fd/ | 进程打开的文件描述符 | 内核 struct file 表的视图 |
task/ | 包含的所有线程 | 内核线程表 |
我用 sudo 把这些都打了出来:
1 | ls -la /proc/3487998/exe |
铁证一:真实的可执行文件路径是 /usr/local/bin/systemd——而真正的 systemd 在 /lib/systemd/systemd。攻击者把恶意二进制起名叫 systemd 放到了 /usr/local/bin 里,因为 $PATH 默认就是 /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin,/usr/local/bin 排在第一个,命令查找会优先命中它。
1 | cat /proc/3487998/environ | tr '\0' '\n' |
铁证二:环境变量里看到 INVOCATION_ID 和 SYSTEMD_EXEC_PID——这是 systemd 启动一个 service unit 时注入的特殊变量。换句话说,这个挖矿程序是被一个 systemd 服务单元启动的,而不是攻击者手动 nohup ./xmrig & 那种粗糙做法。这意味着持久化机制(survives reboot)已经被植入。
1 | ls -la /proc/3487998/fd/ |
铁证三:进程在写 /usr/local/bin/.bench.log——这是 XMRig 矿工的 benchmark 日志文件的标志性文件名。
到这一步,它是 XMRig 已经板上钉钉。
1 | ls /proc/3487998/task/ | wc -l |
最后一个细节:14 个线程。这台机器是 8 核,XMRig 的典型配置就是"每个核一个挖矿线程 + 几个网络/控制线程",14 = 8 + 6 完美吻合。
四、什么是 XMRig,攻击者为什么选它
XMRig 是一个开源的、用 C++ 写的高性能加密货币矿工,主要支持以下算法:
- RandomX (rx/0):门罗币(XMR)使用,专门设计用来"反 ASIC"的算法,对内存延迟非常敏感
- CryptoNight (cn 系列):早期门罗币算法,及其变种
- Argon2:少量小币种使用
为什么僵尸网络的"标准货"几乎都是 XMRig?
1. 门罗币(Monero)有强匿名性
门罗币用环签名(ring signature)+ 一次性地址(stealth address)+ 保密交易(confidential transaction),交易金额、发送方、接收方都是密码学上不可追踪的。比特币用区块链浏览器 5 分钟就能定位到一个钱包,而门罗币几乎做不到。攻击者把挖出来的币转到任何匿名钱包,洗钱成本极低。
2. RandomX 算法可以"白嫖"普通服务器 CPU
RandomX 的设计哲学就是"让 ASIC 没意义"——它要求矿工执行一段每个 nonce 都不同的随机指令序列,这段序列严重依赖通用 CPU 的乱序执行、分支预测、缓存层级,专用电路做这事反而比 CPU 慢。这意味着随便一台 VPS 都是合格的矿机。攻击者无需准备专用硬件,挖到就是赚到。
3. XMRig 是开源的,魔改成本为零
代码在 GitHub 上随便看,编译一份静态链接的二进制,扔到任何 Linux 机器上都能跑。攻击脚本只需要 wget xmrig + 一个 systemd unit + 一个 config.json,整个部署不到 30 行 bash。
我打开了 /usr/local/bin/.config.json,部分摘录:
1 | { |
读得懂的人能立刻看出几个进阶玩法:
1gb-pages: true:使用 1GB 透明大页,减少 TLB miss,对 RandomX 算法可以提升 5-10% 算力rdmsr/wrmsr: true:直接读写 CPU 的 Model Specific Registers,关掉某些预取器、调整缓存策略,能再提 3-5%huge-pages: true+priority: 5:内存大页 + 调度高优先级cn: [[1, 0], [1, 1], ...]:CryptoNight 配置,每核 1 线程 1 路 hash
这是一份经过性能调优的、不打算被发现的、想长期白嫖的配置。攻击者甚至还专门 apt install msr-tools 用于读写 MSR 寄存器——后面会讲到这一步在 sudo 日志里的痕迹。
五、矿池揭面
我用 journalctl 查这个进程的标准输出(systemd 启动的进程,stdout 会走到 journal):
1 | journalctl _SYSTEMD_INVOCATION_ID=8746afaad3134c028a534d9eebcdf9ed --no-pager -n 30 |
这里要插一句关于 INVOCATION_ID 的小知识。
INVOCATION_ID 是 systemd 给每一次 service 启动分配的 128 位 UUID。即使同一个 service 被 restart 多次,每一次 invocation 都有独立的 ID。它会被注入到目标进程的环境变量里,同时 journal 也会用它给这次启动产生的所有日志打 tag。这给我们一个非常强大的能力:给定一个正在运行的进程,反查它产生的全部日志,不管中间还有多少其他服务在写 journal。
所以上面这条命令的意思是:“把 invocation ID 等于 8746afaa… 的服务的所有 journal 给我”。
输出第一行就让我笑出声:
1 | Apr 27 19:18:55 systemd[3487998]: cpu accepted (15/1) diff 480636 (206 ms) |
矿池:xmr.kryptex.network:8029。
Kryptex 是个真实的、合法注册的商业矿池,提供托管挖矿服务、桌面客户端、收益统计。攻击者甚至懒得自建矿池,直接用商业服务,这种"去中心化"的设计让追踪攻击者钱包几乎不可能——就算执法机构联系到 Kryptex,矿池也只能交出一个匿名 worker ID 的 hash 算力数据,钱早就被结算到匿名地址。
算法是 rx/0,即 RandomX 0 号变体,门罗币当前主网算法。
算力 2213.1 H/s 是 8 核 VPS 的合理水平。按 2026-04-27 当时的全网难度和 XMR 价格估算,单台机器一天能给攻击者贡献的收益大约是几角到一块多人民币。看起来不多——但僵尸网络管理员手里有几千几万台这样的机器,规模化之后是稳定的现金流,而成本是零。
六、持久化:被植入的两个 systemd unit
按图索骥找持久化机制。我已经知道这进程是被 systemd 拉起来的,所以直接去 /etc/systemd/system/ 里找时间戳异常的文件:
1 | ls -la /etc/systemd/system/*.service /etc/systemd/system/*.timer |
1 | -rw-r--r-- 1 root root 357 Mar 1 10:42 /etc/systemd/system/1panel.service |
两个时间戳是 Apr 27 18:42 的文件,都是今天下午——和那个挖矿进程的启动时间戳完美对得上。文件名一个叫 systemd.service,一个叫 observed.service。
systemd.service 内容:
1 | [Unit] |
这个 unit 文件的几个魔鬼细节:
- 文件名叫
systemd.service:当 ops 用systemctl list-unit-files看的时候,这个名字会被自动归到一堆系统自带 systemd-* unit 中间,扫一眼很难发现异常 - Description 是 “System Proxy Service”:故意起得像 Linux 的官方服务名
- ExecStart=systemd:因为 WorkingDirectory 是
/usr/local/bin,且/usr/local/bin在 PATH 第一位,所以这里写裸名systemd命中的是攻击者放的那份,不是系统真的 systemd - Restart=always + RestartSec=30:你
kill -9也没用,30 秒后死灰复燃 - User=root:直接拿到 root 权限挖矿(用 root 是为了 1GB 大页和 MSR 寄存器,这两个特性都需要 CAP_SYS_RAWIO)
observed.service:
1 | [Unit] |
这个 unit 启动的是 /usr/local/bin/free_proc.sh,文件内容只有 4 行:
1 | # !/bin/bash |
读懂这个脚本的瞬间我背后凉了一下。
它每 2 秒钟跑一次,逻辑是:“看看系统里有没有 CPU 占用超过 200%、且 命令行里不含 systemd 字样 的进程,全部 kill -9”。
这是用来排挤其他矿工的"领地保护脚本"。
互联网上扫弱密码 SSH 的不是只有这一伙人。当一台机器密码强度差,往往会被多伙独立的攻击者打穿——A 团伙先进来部署他们的矿工,B 团伙后到也想部署,结果两伙人在同一台 CPU 上抢,单方算力下降。这个 free_proc.sh 就是 A 团伙的反制:检测到 CPU 高占用而且不是"我"(命令行不含 systemd 字样),直接干掉。
这个脚本的存在还有一个副作用——正常的运维工具如果突然耗 CPU 也会被它干掉。我后来想,万一我跑个 find / 或者 tar -czf 做备份,是不是会被它瞬间 kill?是的,会。这就是为什么入侵后业务表现是"莫名其妙的服务被杀"。
幸好我的 Java 服务和 OpenWebUI 平时都低于 200% CPU,没被误杀,否则我会更早察觉到异常(虽然代价是业务先挂)。
七、入侵时间线还原:那 8 秒里发生了什么
接下来要溯源——攻击者从哪个口进来的?
第一件事是读取 SSH。journalctl _COMM=sshd --since "今天 18:30" --until "今天 19:00",按时间一行一行读:
1 | Apr 27 18:30:01 sshd: Failed password for root from 92.118.39.195 port 50246 ssh2 |
先抛出几个观察:
- 攻击源 IP 是分布式的:
92.118.39.195(保加利亚僵尸主机)、2.57.122.193(保加利亚 OVH)、87.121.84.58(保加利亚)、45.148.10.x(伊朗)、2.57.122.x整段是知名扫描者 - 用户名以
root为主,偶尔是aadi、testuser、kalob等常见弱口令字典里的名字 - 节奏是每分钟数十次失败——这是僵尸网络分布式爆破的标准节奏,单 IP 不会被防火墙挡(因为分散在几十个 IP 上轮换)
然后我看到了那一行:
1 | Apr 27 18:42:19 sshd[3487819]: Accepted password for myuser from 154.51.62.34 port 52724 ssh2 |
18:42:19。来自 154.51.62.34。账户是 myuser(普通用户)。
注意是 myuser,不是 root。攻击者打穿的不是 root,而是普通用户。这给了我下一个关键信息——所以进 sudo 还需要再来一次密码——继续看:
1 | Apr 27 18:42:24 sudo: myuser : PWD=/home/myuser ; USER=root ; COMMAND=/usr/bin/true |
8 秒。从 18:42:19 ssh 进入到 18:42:27 服务启动,整个挖矿木马的部署只用了 8 秒。
让我把这 8 秒里发生的事翻译成自然语言:
| 时间 | 动作 | 含义 |
|---|---|---|
| 18:42:19 | SSH 登录成功(连续 3 次) | 自动化脚本依次执行三个 batch,每个 batch 是一个独立 SSH 会话 |
| 18:42:24 | sudo /usr/bin/true | “试探”——看 sudo 是否需要密码、密码是否还和 SSH 那个一样 |
| 18:42:24 | sudo apt-get -y install msr-tools | 装 MSR 工具,准备做 CPU 寄存器调优 |
| 18:42:25 | sudo bash xmrig/randomx_boost.sh | 跑 XMRig 自带的 boost 脚本,开 huge pages、关 ASLR、写 MSR |
| 18:42:25 | sudo bash xmrig/enable_1gb_pages.sh | 启用 1GB 透明大页 |
| 18:42:26 | sudo mv xmrig/xmrig /usr/local/bin/systemd | 核心伪装动作:xmrig 改名叫 systemd 移到系统路径 |
| 18:42:26 | sudo mv xmrig/free_proc.sh /usr/local/bin/free_proc.sh | 部署"领地保护"脚本 |
| 18:42:26 | sudo mv xmrig/config.json /usr/local/bin/.config.json | 配置文件改成隐藏文件移过去 |
| 18:42:27 | (未在 sudo 日志中显示)写两个 .service 文件、systemctl enable --now | 持久化 + 启动 |
注意几个细节:
- 第一个 sudo 命令是
/usr/bin/true——这是脚本在测试"sudo 需不需要密码"。如果配的是NOPASSWD,true 就直接成功;否则会提示密码,脚本会喂入它已经掌握的 SSH 密码(攻击者假设两个密码相同,对大部分粗心运维而言这个假设成立) - 所有命令的 PWD 都是
/home/myuser——意味着xmrig/文件夹是直接 wget 到 home 目录解压的,整个操作不留/tmp痕迹 - 三次 SSH 登录、每次秒级断开:
Disconnected from user myuser 154.51.62.34 port 52724紧跟着Accepted——这说明每次连接只跑一两条命令就断,不留交互式 shell。这是为了避开"残余会话被运维注意到",也是自动化脚本的标志特征 - 18:42:27 之后没有任何 myuser 的活动——攻击者没回头看,说明
enable --now之后他们就走了,永远不回来
来自 154.51.62.34 的攻击者(这个 IP 我后来查了下,归属是美国某 VPS 服务商)显然是自动化僵尸网络的指挥节点。当扫描器(前面看到的 92.118.39.195、87.121.84.58 等)撞库到一组有效凭证后,会把"目标 IP + 用户名 + 密码"喂给 154.51.62.34 这台部署机器,由后者执行上面那 8 秒的脚本。
八、攻击者画像:你不是被针对的,你只是被网到了
读懂这次入侵的所有细节之后,我反而轻松下来——这不是针对我的人为攻击,而是僵尸网络大网捕捞的副产物。
证据:
- 入口完全套路化:SSH 22 端口 + 弱密码爆破,没有 0day、没有定向钓鱼、没有供应链
- 武器完全套路化:XMRig + Kryptex 矿池 + 系统名伪装,GitHub 上随便搜 “xmrig systemd persistence” 能找到几十个一样的脚本
- 行为完全套路化:8 秒部署 + 不留交互、不建后门、不窃密、不勒索,目的极其单一就是 白嫖 CPU
- 目标选择完全套路化:全互联网扫描,谁先扫到谁先进,没有针对性
这种 “cookie-cutter” 攻击的好处是处置思路也是套路化的——网上能找到一模一样的事件分析、清理脚本、加固指南。坏处是它永远不会停:今天清理完,明天有新一波僵尸网络扫到你,密码弱依然会被打穿;你换个新机器、用新密码,几个小时之内 22 端口又开始被爆破。
互联网安全公司 Greynoise 长期监测 SSH 22 端口的扫描流量:任何一台公网 VPS 上线后,平均 4-12 小时内就会收到第一批爆破请求;24 小时内就会被全球扫描节点完整 fingerprint。
公网就是丛林。
九、为什么我会被打穿?三个根本原因
9.1 SSH 22 端口暴露公网
这是最大的口子。任何在公网监听的端口都会被扫描器覆盖到——SSH 22 是头号目标,因为成功率最高、收益最大。
正确做法:
9.2 SSH 允许密码登录
这是次大的问题。SSH 默认配置允许密码 + 密钥两种认证方式同时启用。只要密码这条路开着,密码强度就成了攻防的关键变量。
但密码强度本身是有上限的:
| 密码类型 | 熵估算 | 暴力破解时间(万 GH/s) |
|---|---|---|
| 8 位字母数字 | ~48 位 | 数小时 |
| 12 位混合 + 符号 | ~78 位 | 数十年(理论上) |
| 16 位真随机 | ~104 位 | 不可破 |
| ed25519 私钥 | ~256 位 | 宇宙寿命级别 |
注意 “12 位混合 + 符号” 看似数十年破不了,但只在密码是真随机时成立。如果是 用户名 + 数字 + 特殊符号 这种带语义结构的密码,对僵尸网络的字典-规则引擎来说,实际熵远低于 78 位——可能只有 30 位左右。这就是我这次中招的根本原因。
9.3 用户在 sudo 组里、且 sudo 用同一个密码
/etc/sudoers.d/ 下的配置允许 sudo 组成员执行任意命令(凭密码授权)。当用户密码 = SSH 密码时,攻击者拿到 SSH 凭证 = 同时拿到 sudo 凭证 = 直接 root。
这是 Linux 默认安装的 footgun。useradd -G sudo + 同密码 = 普通用户和 root 之间没有任何屏障。
十、处置:四阶段灭火
理清楚是什么、怎么进来的之后,开始处置。我把这次清理分成四个阶段,按"止血优先、不破坏证据"的次序:
10.1 阶段一:止血(让 CPU 降下来)
1 | # 停服务并禁用自启 |
执行后第一时间看 uptime:
1 | load average: 0.46, 5.86, 9.95 |
1 分钟平均 load 从 9.58 降到 0.46。CPU 救活了。
10.2 阶段二:清除痕迹(删除 IOC)
按发现的清单逐个删除:
1 | sudo rm -f /etc/systemd/system/systemd.service \ |
10.3 阶段三:堵入口(这才是关键)
如果只做前两步而不做这一步,几个小时之后扫描器会重新撞进来——清理多少次都没用。
第一件事:换密码。 旧密码已经被泄漏过,攻击者完全可以再来一次。
1 | # 用强随机密码替换 |
第二件事:禁用 root 远程密码登录。 这一步立竿见影——log 里之前 95% 的爆破都是冲 root 来的:
1 | sudo sed -i -E 's/^[# ]*PermitRootLogin .*/PermitRootLogin prohibit-password/' /etc/ssh/sshd_config |
PermitRootLogin prohibit-password 的意思是 “root 可以登录,但只能用密钥,不能用密码”。
第三件事:把之前误关的 PubkeyAuthentication 打开。 我之前因为某次配置失误把 PubkeyAuthentication no 写进了配置(这是个反常配置——正常 sshd 默认是 yes)。修回 yes:
1 | sudo sed -i -E 's/^[# ]*PubkeyAuthentication .*/PubkeyAuthentication yes/' /etc/ssh/sshd_config |
注意此时还没有禁掉 PasswordAuthentication——下一步先做减速带(fail2ban),最后再做永久封锁。
10.4 阶段四:fail2ban
fail2ban 通过解析日志自动给爆破 IP 加 iptables 封禁规则。我用的配置:
1 | # /etc/fail2ban/jail.local |
含义:10 分钟内 5 次 SSH 失败 → 封 IP 24 小时。backend = systemd 让 fail2ban 直接读 journald 而不是 /var/log/auth.log(更精确,不会漏 rotate 期间的事件)。
启动:
1 | sudo systemctl restart fail2ban |
输出让我笑了:
1 | Status for the jail: sshd |
fail2ban 启动后当场封禁了 5 个之前一直在爆破的攻击 IP——这些 IP 是从 journal 里反查出来的"近 10 分钟内累计失败 5+ 次"的源。从这一刻起这 5 个 IP 24 小时内连握手都握不上了。
十一、最后一步:禁用密码登录
到这里我有一个犹豫。
fail2ban 已经装上了。新密码是 16 位随机的,真实熵 90+ 位,理论上撑到宇宙热寂之后才能被破。再加上 root 不能密码登录、fail2ban 5 次失败封 24 小时——为什么不就这样了?
我犹豫的真正原因是:禁用密码登录有锁外面的风险。如果我手头没有有效的 SSH 私钥,或者私钥意外丢了,禁掉密码登录就意味着我自己也进不去服务器。这对生产服务来说是灾难。
但仔细想想,犹豫的本质不是"密码足够强了",而是"我害怕被自己锁在外面"。这是工程上很常见的一种偏见——为了避免一个低概率的小损失,放弃一个高确定性的大收益。
让我把账算一遍:
保留密码登录的代价:
- fail2ban 是减速带,不是屏障——僵尸网络分布式爆破单 IP 失败率本来就高,只要每个 IP 4 次以下,用足够多的 IP 轮替,理论上可以无限尝试
- 新密码再强也是有限熵的字符串,受键盘记录、肩窥、剪贴板泄漏、终端历史泄漏、备份泄漏等多种威胁
- “等下次出事再处理” 是负债式思维——在被入侵的代价已经验证过的情况下,继续保留可疑路径是错配的风险定价
禁用密码登录的代价:
- 一次配置错误可能锁死自己。但只要先验证"密钥能登录"再禁用密码,这个代价是 0
收益 vs. 代价比较起来非常清楚。所以最后这一步必须做。
为了万无一失,我做了三层验证:
第一层:在禁用前,独立测试密钥登录是否真的工作。
在我的 Mac 上:
1 | ssh -o BatchMode=yes \ |
BatchMode=yes + PreferredAuthentications=publickey 强制只走密钥这一条路,不允许密码 fallback。如果输出有 PUBKEY_LOGIN_OK,说明密钥这条路是 100% 通的。
输出:
1 | PUBKEY_LOGIN_OK |
完美。
第二层:保留一个已建立的 SSH 会话作为安全网。
reload sshd 不会踢现有连接(这是 sshd 的默认行为)。所以我先单独开一个 shell 一直保持连接,这样即使新配置出错导致新会话建不上,我还有这个老会话能改回去。
第三层:原子修改 + 立即 reload + 立即验证。
1 | sudo cp /etc/ssh/sshd_config /etc/ssh/sshd_config.bak.$(date +%s) |
然后立即在另一个终端做 3 个测试:
1 | # 测试 1:密钥登录(应成功) |
输出:
1 | === 测试 1: 密钥登录(应成功)=== |
三连成功:密钥通、密码登录都被 sshd 直接以 “publickey only” 拒掉。从这一刻起,密码爆破在我的服务器上数学不可能——即使攻击者拿到了我的密码,也没用,因为 sshd 根本不会进入密码验证流程。
十二、关于 ed25519 密钥的几个数学事实
为什么 ed25519 比密码安全得多?
Ed25519 是一种基于 Edwards 曲线 25519 的数字签名算法,公钥/私钥长度都是 256 位。它的安全性基于"在椭圆曲线上求离散对数是难的"这一密码学假设。
256 位的安全强度等价于对称加密的 128 位——用宇宙中所有的计算资源都不可能在宇宙寿命内暴力破解。
更具体的数学:
- AES-128 暴力破解需要 2^128 次操作
- 假设用现今地球上最强的超算 Frontier(2 EFLOPS = 2×10^18 次/秒)连续运算
- 2^128 / (2×10^18) ≈ 5.4×10^21 秒 ≈ 1.7×10^14 年
- 宇宙年龄约 1.4×10^10 年
- 所以暴力破解 AES-128 需要 1 万倍宇宙年龄
ed25519 提供等价于 AES-128 的安全强度。再说一次:用整个地球的计算资源、连续运行 1 万倍宇宙年龄,才有可能撞中你的私钥。
而 SSH 协议中的密钥认证是这样工作的:
- 客户端连接服务器
- 服务器随机生成一段 challenge 数据
- 客户端用私钥对 challenge 签名,把签名送回去
- 服务器用 authorized_keys 里的对应公钥验证签名
私钥本身永远不离开客户端,连签名也是一次性的(每次连接都是不同的 challenge)。中间人即使全程录制流量,也无法伪造下一次连接的签名。
对比密码:客户端把密码(或派生的 hash)发给服务器,服务器对比存储的 hash。如果在任何环节(客户端、传输、服务器内存、终端历史、键盘记录器)里泄露了那串字符,就完蛋。密钥的"秘密"从不传输,密码的"秘密"必须传输。
十三、复盘的几个反直觉发现
清理完之后,我又花了一小时全面排查"有没有遗漏的后门"。结果挺反直觉:
1. 攻击者没在 root 的 .ssh/authorized_keys 加自己的公钥
cat /root/.ssh/authorized_keys 是空的——意味着即使攻击者拿到了 root,他也没建立"日后可以无密码 SSH 进来"的后门。
2. 攻击者没建新用户
/etc/passwd 里只有原来的 root + myuser,没有 aadmin、backup、postgres(伪装成数据库账户的常见手法)这些。
3. 攻击者没改 sshd_config
这意外,因为很多教程里都有 “改 sshd_config 加个 backdoor 端口” 这种操作。但他们没这么做。
4. 攻击者没改 PAM
没有植入 magic password 模块(PAM rootkit 的常见手法是在 /lib/security 加一个会无条件接受某个特殊密码的模块)。
5. 攻击者没装 LKM rootkit
lsmod 没有奇怪的内核模块。/proc/modules 干净。
6. 攻击者没动用户的 cron
crontab -l 空,/etc/cron.d/ 干净,/etc/cron.{hourly,daily,weekly}/ 没有新文件。
7. 攻击者把自己的工作目录 /home/myuser/xmrig/ 自动清理了
执行完 mv 操作后,源目录被 rm -rf 掉了——脚本最后一步是收尸。find /home/myuser -mmin -180 没有任何今天修改的文件。
这些"克制"行为说明了什么?
说明这是个专业的、长期运营的、追求隐蔽性的僵尸网络部署。攻击者的目标不是制造混乱、不是窃取信息、不是勒索——而是长期、不被发现地白嫖 CPU。每一个额外的"后门"都是被发现的概率,每一处冗余的痕迹都是被追溯的风险。所以他们:
- 用
systemd这种系统级名字伪装,让 ops 一眼扫过去发现不了 - 进程参数模仿合理的形式(
-c .config.json) - 配置文件用
.config.json隐藏文件名 - service unit 命名
System Proxy Service/System Observer Service - 自动清理工作目录
- 不留交互式 shell
- 不建后门用户
简洁就是隐蔽,少做就是安全。 攻击者比很多运维更懂"工程克制"这件事。
讽刺的是这种克制反而帮了我——清理变得简单。如果他们留了五个后门、改了 PAM、装了 LKM rootkit、加了 reverse shell crontab,我可能根本不敢相信清理是"完整"的,最后只能选择重装机器。但他们没这么做。
十四、长期防御的行动清单
这次事件之后我列了一份长期清单,分三档:
已完成(这次处置内)
- [x] 杀掉挖矿进程
- [x] 删除恶意二进制 + 配置 + 看门狗脚本
- [x] 删除恶意 systemd unit + daemon-reload
- [x] 卸载 msr-tools
- [x] 轮换 myuser 密码(虽然之后用不上密码登录了,但旧密码本身已是泄漏 IOC)
- [x] 禁用 root 密码登录
- [x] 启用 PubkeyAuthentication
- [x] 安装 fail2ban + sshd jail
- [x] 禁用密码登录(只剩密钥)
- [x] 三连验证密码 / root 密码 / 密钥登录的预期行为
强烈建议尽快做(这周内)
关闭非必要的公网监听端口
1
ss -tlnp # 看一遍当前所有监听端口
我那台机器上当时还对公网开着 1panel(默认 34721)、几个 docker-proxy 端口(18080/18081/18082/18090/19000/19001/6099)。这些端口都不应该对公网暴露。把它们绑到 127.0.0.1,通过 nginx 反代或 SSH tunnel 访问。
给 1panel 这类管理面板加 IP 白名单
1panel 默认端口 34721 是公开信息,全互联网每天有大量扫描器探测这个端口。即使有强密码,零日漏洞也能让你直接被拿下。MySQL / Redis / MinIO 不要对公网开放
这些服务的认证机制比 SSH 弱得多(尤其是 Redis 默认无密码),公网暴露 = 灾难。绑 127.0.0.1 + 内网或 SSH tunnel。磁盘清理
我的/当时已经 81%,距离磁盘满还有不到 10G。一旦磁盘满,OOM 和文件系统错误会让排查变得极其复杂。该清理 docker image、journald 历史、apt cache。
长期建设(下个月内)
接 Tailscale 或 WireGuard
把所有"管理类"端口(SSH、1panel、数据库连接等)全部移到内网,公网完全关闭 22。这样连 fail2ban 都不需要——没有口子可爆破。接监控告警
Prometheus node_exporter + Grafana + alertmanager。当 load average 超过阈值、fail2ban 封禁数超过阈值、磁盘使用率超过 80% 时发企业微信/Slack 告警。这次能及时发现是因为我刚好登上去看 top,下次未必这么巧。用 auditd 记录关键文件的修改
配置规则监控/etc/systemd/system/、/usr/local/bin/、/etc/cron.*/、/root/.ssh/、/etc/passwd等敏感路径。任何写入都生成 audit 事件,再用 auditbeat 转发到 ELK 做检索和告警。定期跑 Lynis 做安全基线扫描
1
2sudo apt install lynis
sudo lynis audit system它会给出一个安全评分(hardening index)和 100+ 条改进建议。每月跑一次,把分数推到 80+。
十五、给同样在租 VPS 跑业务的开发者
如果你和当时的我一样——租了一台 VPS,跑着业务,SSH 默认配置,密码不算太弱但也不算强——我给你三条铁律:
铁律 1:第一次登入立刻禁密码登录
不是过两天、不是等空了、不是"先看看再说"。第一次 ssh 进新机器后做的第一件事就是:
1 | ssh-copy-id user@new-vps # 推公钥 |
四条命令,5 分钟。任何理由都不能跳过。
铁律 2:第一次登入立刻装 fail2ban
1 | sudo apt install -y fail2ban |
即使你已经禁了密码,fail2ban 仍然有用——它能挡住对其他端口的扫描和爆破(比如你后来自己加的服务),减少日志噪音,对自动化攻击形成额外屏障。
铁律 3:第一次登入立刻关掉非必要端口
1 | ss -tlnp | grep 0.0.0.0 |
每一个 0.0.0.0:<port> 都是潜在攻击面。问自己每一个:“这个端口必须对公网开放吗?” 99% 的服务(数据库、缓存、对象存储、管理面板)的回答都是 No。
十六、一些更深层的反思
写到这里 1 万字了,最后说点没那么技术的东西。
关于"我的服务又不重要"
很多人(包括我以前)会说:“服务又没什么数据,就算被打了大不了重装。” 这次事件之后我修正了这个想法。
被入侵的成本不只是数据——还有:
- CPU 资源被白嫖:8 核 CPU 跑满 53 分钟,按云厂商计费规则等于多花了几块钱电费
- 网络带宽:挖矿是高交互的,每秒都在和矿池通信,吃带宽
- 业务影响:虽然 free_proc.sh 没误杀我的业务,但 CPU 100% 之后所有服务响应都变慢了。如果用户那时候在用 OpenWebUI,体验会很糟
- 隐性背书:你的 IP 被加入了僵尸网络的"已破解列表",攻击者可能再次回访,下次可能就不是挖矿这种"温和"的负载了
- 法律暴露(少见但存在):你的 IP 在挖矿期间产生了到 Kryptex 的网络流量。如果某地立法把"协助加密货币挖矿"定义为违法,这台机器可能成为证据
- 运维心智负担:从此每次看 top 都会下意识地查一眼有没有可疑进程,这是一种持续的认知税
关于"安全 vs 便利"的伪二元
我以前觉得"安全"是和"便利"冲突的——禁密码登录意味着每次登服务器都要带着 .ssh/id_ed25519 这个文件,万一换了机器就麻烦。
现在我觉得这是个伪二元。
密钥文件本来就该用 agent forwarding 或者云端密码管理器(1Password SSH agent 是个非常优雅的方案)。配好之后,密钥比密码更便利——不用记、不用输、不会撞库、可以分别授权(每台机器一把不同的密钥)、可以快速吊销(从 authorized_keys 删一行就完事)。
这次事件让我把"换机器要带 .ssh"这个过去当成"麻烦"的事情,重新认识为"我对自己秘密的物理控制权"。这是好事,不是坏事。
关于"如何看待运维"
写到最后忍不住吐槽一下:很多开发者把运维当成"懂 Linux 命令的工种",以为运维不需要技术深度——这次事件让我重新尊重这个领域。
要在 30 分钟内做完 “看到 load 9.58 → 锁定挖矿木马 → 还原入侵时间线 → 清理所有 IOC → 禁用密码登录 → 装 fail2ban → 验证三连”,需要的知识涉及:
- Linux /proc 文件系统设计哲学
- systemd unit 模型 + journalctl 高级过滤
- ELF 二进制结构 + sha256 + ldd
- XMRig + RandomX + 加密货币矿池协议
- sshd_config 全部安全相关选项
- iptables / nftables + fail2ban 集成
- /etc/sudoers + PAM 认证链
- Ubuntu/Debian apt 包管理
- 进程 / 线程 / cgroup 调度模型
- 几十个 GNU coreutils + ps/awk/sed 高阶用法
- Bash 脚本里 sudo + heredoc + stdin 管道的微妙交互
这还只是这次事件涉及到的子集。一个合格的运维要在所有这些维度上有"够用即停"的深度,并且能在压力下组合调度——这本身就是一种很高的认知能力。
致谢
感谢 Claude Code 的 ssh-mcp 工具,让我能在和 AI 对话的同时把诊断和处置流程一气呵成;感谢 XMRig 项目 的开源代码(讽刺地),它的 config.json schema 文档 让我能秒读出攻击者的优化策略;感谢 fail2ban 团队 维护一个用了十多年还在持续更新的优秀工具。
最后感谢这次事件本身——它让我以最小的代价(53 分钟的算力 + 一点电费)学到了一堂值大几千块的安全实战课。
如果你看到这里,希望对你也有帮助。
附录:本次事件的 IOC 清单(供他人对照排查)
如果你也怀疑自己被同一个/同类的僵尸网络打穿,可以对照下面的 IOC 检查:
文件路径:
1 | /usr/local/bin/systemd # XMRig 二进制(被改名) |
systemd unit 特征:
1 | Description=System Proxy Service # 启动 systemd 二进制 |
XMRig 二进制特征:
1 | SHA256: baca0922a6ce82f250d15c7b71a209f0ba60274ff7e9654338900020a36de6c4 |
矿池:
1 | xmr.kryptex.network:8029 # 算法 rx/0 |
典型攻击源 IP(保加利亚 + 伊朗 + 美国 VPS):
1 | 92.118.39.195 # 持续 root 爆破 |
注意 154.51.62.34 是部署节点,不会做爆破——它只在前面那一堆扫描器把账号 / 密码 / 目标 IP 喂给它之后,才会用现成凭证连过来部署木马。所以在 fail2ban 日志里看不到它的失败记录,但它是真正"动手"的那只手。
入侵时间窗口(大约模板):
任何一台机器在某个时刻收到大量来自上述 IP 的 SSH 失败请求,之后 30 分钟内某个非 root 普通用户在 sudo 日志里出现连续的 apt-get install msr-tools + mv xmrig/* /usr/local/bin/ 指令组合——基本可以确认是同一类入侵。





