我们通常把“服务器被攻击”想象成一部黑客电影:一个不知名的人从地球另一边循着网线摸进来,突破层层防火墙,窃取数据扬长而去。但等我真正处理了一起服务器被攻击的事件之后才发现,现实里最扎心的情况,往往和外部黑客没太大关系。这几天我一直在复盘这次事件,起因排查到最后,矛头竟然指向同组的一位开发者——他留下了一个没删干净的调试接口,被公网扫描器扫到,接着就是上传脚本、提权、挖矿、留后门,一整套流程走得比我预想中顺利得多。这篇文章就完整记录这次服务器被攻击的发现过程、排查思路、根因分析和加固方案,也把我踩过的坑整理出来,给运维、后端开发和所有管服务器的人一个真实参考。
1. 凌晨的告警:CPU飙到900%,第一反应先别重启
1.1 现象描述:进程列表里多了个不认识的xmrig
那天凌晨两点多,值班群里突然连续弹出告警:某台测试服务器的CPU使用率持续5分钟超过90%,内存占用也异常飙升。我睡眼惺忪地打开电脑,登上去之后用top看了一眼,按CPU排序,排在第一的是一个叫xmrig的进程,CPU占用直接冲到900%多,后面还有好几个同名子进程。看到这个名字我基本就意识到是矿,因为xmrig是门罗币挖矿工具里的常见进程名,市面上绝大多数Linux挖矿木马都直接拿它编译好的二进制来用,改都不改。
紧接着我做了两件事:记录当前时间、把进程快照先存下来。然后ps -ef拉出完整路径,发现可执行文件在/tmp下,文件名叫一个长得像系统库的随机字符串,PID的父进程是1,说明它已经被某个守护进程或者计划任务拉起来了,不是当前登录会话的临时进程。再用ss -antp查网络连接,果然看到这个进程和某个境外IP的3333端口保持着ESTAB状态,矿池端口特征非常明显。
这个现象判断起来不难,难点在于控制住第一反应。很多同行遇到这种情况第一件事就是kill掉进程然后重启,但我不建议这么做。服务器一旦被攻破,内存里可能还有攻击者正在执行的命令、临时释放的脚本内容,日志也可能还在缓冲区里没落盘,一重启全没了。更糟糕的是,有些木马会在发现网络中断或者进程被杀之后触发自毁逻辑,把关键文件删掉,取证就会非常被动。正确做法是先采集现场,再谈清除。
1.2 上手三板斧:top、ps、netstat先把现场固定下来
在动任何杀毒命令之前,先花十分钟把“现场”完整保存下来。我这里说一个简单可复用的组合,适合大多数Linux环境:
top -b -n 1 | head -40 > /tmp/investigation_top.txt ps -ef --sort=-%cpu | head -40 > /tmp/investigation_ps.txt ss -antp > /tmp/investigation_netstat.txt lsof -p <pid> > /tmp/investigation_lsof.txt 2>/dev/null # 再把进程对应的可执行文件、所在目录、启动方式记录一遍 ls -la /proc/<pid>/exe cat /proc/<pid>/cmdline | tr '\0' ' '; echo cp /proc/<pid>/exe /tmp/evidence_xmrig.bin 2>/dev/null || true这里面最关键的是cp /proc/<pid>/exe这一步。虽然挖矿进程还在运行,但你可以通过procfs把正在执行的二进制文件原样复制出来,后续做样本分析、提取字符串、确认矿池地址都靠它。当时我把这个文件复制出来之后,马上用strings扫了一遍,矿池域名、钱包地址、下载脚本地址全在里面,给后面的溯源提供了很大帮助。
还有一个容易被忽略的窗口:检查进程的打开文件列表和网络连接时,尽量把输出重定向到一台远程日志服务器,或者至少存到/tmp之外的临时目录。因为很多攻击者会在拿下服务器之后把命令历史、/tmp目录下的文件定期清理,如果你只把结果留在本地/tmp,可能过几个小时就被覆盖了。
注意:先取证后处置,这不是教科书上的理想流程,而是我拿“被反复折腾”的经历换来的经验。如果服务器里真的有客户数据或者核心业务,并且疑似有数据外泄,条件允许的话还可以做内存dump,但这需要专门工具和较宽裕的时间窗口,小团队按上面这套先收集基本证据,已经能应付大部分场景。
2. 顺着痕迹往下挖:定时任务、登录日志、Web日志一个都不能漏
2.1 crontab与authorized_keys:挖矿木马最爱藏的两个地方
拿到基础证据后,我开始排查持久化痕迹。持久化这个词听起来很专业,其实说白了就是“攻击者如何保证服务器重启之后,挖矿程序还能再跑起来”。Linux上最常见的做法无非几种:写进crontab、塞进systemd服务、修改开机启动脚本,以及在SSH里留下后门。
我先检查crontab:
crontab -l ls -la /var/spool/cron/ cat /etc/crontab ls /etc/cron.d/ cat /etc/rc.local systemctl list-unit-files --state=enabled结果就在当前用户的crontab里发现了一条每分钟执行的任务,内容是从某个域名下载一个脚本到/tmp下,再通过bash执行。这个套路很古老但依然有效:脚本本身只负责下载和运行,真正的挖矿进程是后续才拉起来的,这样即使挖矿二进制被杀掉了,只要crontab还在,一分钟之后它又被下载回来了。这也是很多人“清了又中毒”的根源——光杀进程不清理计划任务,等于没清。
另一个藏身点是/root/.ssh/authorized_keys。我cat了一下,里面除了管理员自己的公钥之外,果然多了一行陌生的公钥。这意味着攻击者拿到root权限之后,把自己的公钥写进了这个文件,之后任何时候都能以root身份免密登录,完全绕过了密码和Fail2ban。清除时不仅要把这行公钥删掉,还要看下/root/.ssh/目录下有没有其他可疑文件,比如known_hosts被篡改、新增了config文件,这些都有可能成为隐藏后门。
下面我把这几种最典型的藏身点整理成一个速查表,排查时对照着看不会漏:
| 检查项 | 排查命令 | 清理方式 |
|---|---|---|
| 计划任务 | crontab -l、cat /etc/crontab、ls /etc/cron.d/ | 删除恶意行,或直接删除对应cron文件 |
| SSH后门 | cat /root/.ssh/authorized_keys | 逐条确认公钥来源,删除陌生公钥 |
| 自启动服务 | systemctl list-unit-files --state=enabled | 停掉并禁用可疑service,删除service文件 |
| Web后门 | find /www目录 -name "*.php" -mtime -7 | 删除后门文件,同时排查同目录新增的图片马 |
| 动态库劫持 | ld.so.preload文件是否存在 | 清空/etc/ld.so.preload,恢复受影响的命令 |
2.2 登录日志分析:暴力破解记录很多,但真正入口不是它
排查完计划任务和SSH后门,我打开了登录日志,打算确认攻击者是通过什么通道进来的。系统里跑着SSH、Nginx、MySQL这些常规服务,日志路径各自不同,但主要看这几类:
last -n 50 lastb -n 50 | head -50 grep "Failed password" /var/log/auth.log | tail -50 grep "Accepted password\|Accepted publickey" /var/log/auth.log | tail -30不看不知道,一看吓一跳。auth.log里SNMP的暴力破解记录非常多,来自几十个不同IP,从凌晨到白天几乎没有断过。很多是所谓的“盲扫机器人”,拿到一个开放的公网IP就开始拿字典轮流试root密码。但仔细看完Accepted的记录之后,我发现真正的成功登录记录少得可怜,而且没有一条指向异常IP,说明攻击者并没有直接通过SSH口令爆破进来,或者成功之后把相关日志也清理了一部分。
这是我这次排查里最容易踩坑的地方:看到一堆暴力破解记录,就以为是“服务器弱口令被爆破了”。实际上这些爆破是全天候自动进行的,绝大多数都被Fail2ban拦截了,真正致命的入口藏在Web服务里。所以不要被日志里的“热闹”带偏,应该花更多时间去看Web访问日志和业务日志,那里往往藏着真正的问题。
2.3 翻Web日志找到第一行恶意请求
把目光转到Nginx的access.log之后,时间线很快就清楚了。在服务器被攻击的前两天,有一段请求序列非常异常:先是有人用GET /api/debug/upload.php探路,返回200之后,紧接着一个POST请求带了一个很长的multipart body,再往后还有一串类似命令执行的请求。把这些请求按时间排开,攻击过程一目了然。
我把关键的时间线截取出来:
| 时间 | 事件 | 说明 |
|---|---|---|
| 02:12:03 | GET /api/debug/upload.php | 试探接口是否存在 |
| 02:12:31 | POST /api/debug/upload.php | 上传一个PHP脚本文件 |
| 02:13:02 | GET /uploads/xxx.php?cmd=id | 执行命令,返回www用户身份 |
| 02:14:44 | 多次命令执行请求 | 下载挖矿程序、反弹shell |
| 02:17:20 | 写入crontab | 建立持久化任务 |
这里要提醒一点:很多攻击者上传Webshell文件之后,文件名会伪装成类似logo.php、avatar.php这种看起来很正常的名字,但内容里面就是一个执行器。查看日志的时候不要只看文件名,还要看请求路径和返回大小。我那次看到返回包有几百字节的异常长度,就知道情况不对了。后来在/www/uploads/目录下找到这个文件,打开一看,确实是典型的恶意脚本。
看到这里,基本可以确定攻击入口就在Web层,接下来要做的是找到这个上传接口是怎么来的。
3. 根因复盘:一个没删干净的“测试接口”,是如何被利用的
3.1 这个接口做了什么:无鉴权上传,成了最危险的入口
顺着代码库一翻,问题很快浮出水面。这个/api/debug/upload.php接口,是某位开发者为了方便前端联调时上传测试图片而写的。他当时的想法很简单:把图片丢到uploads目录,前端直接通过URL访问就行,省得上传组件还没写完就得等半天。这个接口没有任何鉴权逻辑,没有校验用户身份,没有限制文件类型,甚至没有限制文件后缀名。只要有人往这个接口POST一个文件,它就原样保存到web目录下,然后返回一个访问路径。
问题在于,这个接口部署的是一台对外开放了公网HTTP访问的服务器。互联网上有大量自动扫描工具,专门盯着常见的调试路径、备份文件、phpinfo这样的“敏感文件”去探测。请求还没到业务层,扫描器就已经把它当成了高度可疑目标。我估计从接口上线到被利用,中间的时间窗口甚至不超过一天。
这里我还是要说一句:文件上传接口在Web安全里属于非常高风险的功能。哪怕只暴露在内网,一旦被开发调试或者测试绕过,都可能变成攻击入口。常见的加固方法至少要做到:校验文件扩展名和Content-Type、重命名为随机字符串、把上传目录放到Web执行目录之外,避免上传的文件被直接当成脚本执行。如果你确实需要一个临时调试接口,也必须加上内网IP白名单或者一次性Token,并在联调结束后通过正式流程删除,而不是自己在本地删一下就完事。
3.2 git提交记录还原真相:测试代码是怎么混进发布包里的
这个接口在代码仓库里的历史也很值得细说。我通过git log去翻这个文件的提交记录,发现它确实在某次提交中被引入,当时是开发分支上一个毫不起眼的临时文件。后来又有一个提交显示这个文件被“删除”了,当时的提交说明写的是“移除debug接口”,看上去就像已经处理干净了。
但线上服务器上还在跑。为什么?
原因是这样的:这位开发者确实在本地分支上删除了这个文件,但他并不是通过正式的发布流程去删除的,也没有把删除操作合并到构建环境使用的主分支上。换句话说,线下验证已经通过,本地代码也没问题,但线上构建拉取的还是那个包含调试接口的旧版本分支。等到发布的时候,构建工具直接把旧分支打成了包,于是这个接口就跟着上了线。
这一类问题的根源,往往不是“写过危险代码”,而是“没有统一规范的发布流程”。开发者的本地提交、测试分支和正式发布分支如果长期各跑各的,总有一天会有一个“以为删了但没删”的文件残留在线上。更稳的做法是:生产环境构建必须从受保护的主分支拉代码,上线前用自动化脚本检查发布包中是否包含已知危险路径或调试关键字,比如/debug/、/test/、admin.php、upload.php这类特征。把它写成一个简单的检查任务,比事后翻日志高效得多。
3.3 完整攻击链条:扫描器发现、上传脚本、提权、挖矿、留后门
到这里,整个攻击链条已经比较清楚了。我把它完整梳理一下:第一步,自动扫描器发现这台服务器的公网HTTP端口,接着把常见路径字典跑了一遍,匹配到了/api/debug/upload.php。第二步,扫描器对应的攻击脚本利用这个无鉴权上传接口传了一个PHP后门文件,获得了命令执行能力。第三步,攻击者用命令执行能力查看系统内核版本,发现存在可以本地提权的漏洞,于是下载提权exp,把权限从www用户提升到root。第四步,以root权限写入crontab计划任务,下载挖矿程序并运行,连接矿池开始挖矿。第五步,把攻击者的SSH公钥写入root用户,这样即使计划任务被清理干净,他还能随时再进来。
整个过程里,攻击者几乎没有“人”的干预,全是自动化工具在跑。这也是为什么很多攻击扫描看起来特别凶,却又摸不到规律:它们根本不关心你是谁,只要暴露在公网,自动化脚本就会一遍遍地尝试。所以给服务器做“最小暴露面”不是一句空话,严格控制公网开放端口和服务,是抵御绝大多数自动化攻击的前提。
复盘到这儿,我反而松了一口气:至少不是内部员工恶意操作,而是一次因为疏忽引发的“自我暴露”。但疏忽带来的后果并不比恶意攻击小,尤其是当服务器里还有别的业务数据时,隐患会在某个深夜突然爆发。
4. 应急处置与加固清单:按这个顺序做,少走弯路
4.1 先隔离还是先取证?我的建议是分四步走
发现服务器被攻击之后的处置顺序,很多人会纠结“到底是先拔网线还是先杀毒”。我的经验是:如果没有非常紧急的数据外泄风险,优先做证据固定,再做隔离。如果已经明确发现数据被持续外传,或者挖矿程序正在消耗大量带宽影响生产,那么隔离优先级要提前。但无论如何,不要在没留证据的情况下贸然重启。
我这次应急处置的操作顺序基本是这样的:
第一步,固定现场。把刚才提到的那批命令都跑一遍,记录时间线和文件HASH,复制可疑二进制。第二步,隔离网络。我先把Nginx服务和对外访问的端口停掉,再把可疑进程的网络连接全部断开,但没有直接关掉SSH管理端口,因为后续还要远程清理,太过一刀切反而给自己添乱。第三步,清理恶意文件和持久化配置。把所有后门文件、恶意进程、crontab条目、SSH公钥全部清除,同时排查了systemd里的自启动服务,确认没有其他隐藏入口。第四步,修补和变更。这是最容易被忽略的一步——漏洞不补,光清理等于白干。
这四步里,第一步和第四步最容易做不好。很多人看完挖矿进程就急着杀掉,结果忘了截取样本,后面想做溯源都没有材料;还有的人清理完第二天又被攻进来了,因为那个无鉴权接口还挂在线上,扫描器下次扫到还会再进来。
4.2 服务端加固清单:端口、SSH、Web中间件、数据库逐项整改
把服务器从坑里拉出来之后,要做的事情不是“恢复原样”,而是“顺便改造成不容易再被攻破的样子”。我整理了一份当时照着执行的加固清单,很多项是通用性的,其他团队也可以直接拿去参考:
| 加固项目 | 具体操作 | 说明 |
|---|---|---|
| 公网端口收敛 | 只保留80/443;关闭SSH、数据库、Redis等非必要公网入口 | 缩小扫描器可触碰的攻击面 |
| SSH安全 | 修改默认端口,关闭root密码登录,启用密钥认证,配置Fail2ban | 防止暴力破解与密钥泄露风险 |
| Web中间件 | 关闭目录浏览,隐藏版本号,限制上传目录无执行权限 | 阻断Webshell执行链路 |
| PHP等运行环境 | 更新到受支持版本,禁用危险函数,开启安全模式 | 减少提权后的可利用空间 |
| 系统补丁 | 升级内核,安装安全更新 | 修复本地提权漏洞,本次漏洞正是因此被利用 |
| 数据库与中间件口令 | 清理默认口令,统一使用密码管理器生成强密码 | 防止横向移动后的口令复用 |
| 日志留存 | 开启access_log、auditd,日志同步到远端 | 保证事后能够还原攻击过程 |
关于“上传目录无执行权限”这一点,可能有朋友不太理解。其实原理很简单:攻击者能拿到Webshell,靠的是上传的PHP文件被Web服务当成了脚本去解析。如果把上传目录的权限改成--x或者不允许PHP执行,那么即使攻击者上传了脚本文件,也只能静静躺在目录里,没法执行。这个特性在不同中间件里配置方式不同,但思路是通用的,算是对Web后门的一个硬性限制。
4.3 监控告警升级:把“安全事件”纳入日常巡检
服务器恢复业务之后,我对这套系统的监控做了升级。过去我们的告警基本只围绕CPU、内存、磁盘使用率,结果这台服务器都被挖矿程序占满CPU了,告警虽然响了,但运维侧的第一反应是“是不是业务高峰或者跑批任务”,根本不会往被入侵那方面想。经过这次事件之后,我把几个更贴近“安全”的监控指标加了进去。
比如,检测服务器是否有进程主动连接外网的非常见端口。正常的业务进程只会连接数据库、缓存、消息队列等明确的地址,如果一个进程在深夜频繁连接境外的3333、4444、5555这类端口,那就非常可疑。再比如,对/root/.ssh/authorized_keys、crontab、Nginx配置这些核心文件做哈希基线,一旦内容发生变化就触发告警。还有一个很实用的做法:定期扫描web目录,找出近7天内新增的、包含PHP/JSP/ASP特征的文件。人不可能24小时盯着日志,但脚本可以。
有条件的话,还可以做一次小范围的“模拟攻击演练”,在测试环境部署一个同样的服务,扫描它的暴露面,看看监控会不会发出告警。这个做法我现在很推荐,因为它能够验证你的防御体系是真的起了作用,还是仅仅“看起来有监控”。
5. 这次事件之后,我的一些经验体会
5.1 安全意识最大的敌人是“图省事”
复盘完整起事件,我最强烈的感受其实不是“黑客有多厉害”,而是“一个图省事的决定,能抵消掉所有安全防御”。服务器上明明有防火墙、有Fail2ban、有日常监控,但一个无鉴权调试接口的出现,等于在城门旁边又开了一扇没上锁的小门。攻击者不需要攻破你的防火墙,不需要猜出你的密码,只需要走那扇门。
这种“图省事”的心态非常普遍:本地测试没问题直接打包上线、临时加个接口懒得写鉴权、上线前觉得“应该不会被扫到”、开发环境和生产环境混用。单独看每一步都好像影响不大,但当成千上万的服务器都处在这种状态里,被攻击的概率就是百分百。安全不是某一天专门做的事情,而是散落在每一次提交、每一次发布、每一次修改配置里面的习惯。
5.2 给开发者的三条可落地的建议
这次事件也让我想通了一件事:很多安全问题,本质上是开发流程问题。开发者本身没有恶意,但日常工作中的很多小细节,确实会在特定条件下变成安全事故。我总结了三条比较实用的建议,希望后来的人能直接跳过这些坑。
第一,测试代码、调试入口、临时脚本不要出现在正式的发布分支里。上线前应该有一个简单的自查动作,比如搜索代码里有没有debug、test、upload、admin等危险关键字。这个操作可以做成一个脚本,在CI流程里自动跑,发现高危特征直接阻塞发布。
第二,不要在生产环境临时加“内部工具”。如果实在需要,也要至少加上IP白名单或者令牌,并且明确设置过期时间。用完之后的删除动作,要走正式的上线流程,不能只在本地删一下就觉得万事大吉。
第三,敏感信息不要硬编码。数据库账号、Redis密码、云服务密钥,这些东西一旦随着代码泄露到仓库,被自动化爬虫拿到就是灾难。现在部署环境普遍支持环境变量或密钥管理服务,把配置从代码里抽离出去,是最基础也最有效的防线之一。
5.3 给运维负责人的一点实操心得
最后分享一点给运维同行的话。服务器被攻击这种事,最怕的是自己人先乱了阵脚。我建议每个团队不管规模大小,都提前准备一份简单的“入侵响应小抄”,里面写清楚第一步做什么、第二步做什么、找谁确认、日志在哪看。平时测试一下这些命令真的能跑通,不要等到告警响了才临时回忆命令语法。
另外一个很实用的建议是:备份一定要做,并且要真的演练过恢复。这次事件虽然最后把恶意文件清干净了,但如果攻击者动了核心数据或者加密勒索,光靠清理是不够的。备份不是“存了一份就行”,而是要定期验证能不能恢复,否则到了紧急关头会发现备份本身也坏了,那才叫崩溃。
我个人现在每次上线前都会问自己三个问题:这台机器需要对公网开放哪些端口?这些端口上跑的服务真的有必要吗?如果攻击者真的碰到了,我能在多长时间内发现?很多事故,其实就是从“先凑合一下”开始的。经历过这次之后,我越来越相信,安全领域最贵的不是工具,而是你在每一个“图省事”的念头出现时,愿意多花五分钟把口子堵上。