先说结果:半年下来被打了五万多次,成功入侵 0 次。我把这台服务器上两个 WordPress 站从 3 月到现在的 nginx 访问日志翻了一遍,按攻击类型、按月份、按单日分布做了统计,整理成这份服务器攻击日志复盘。起因是前几天排查收录问题时顺手看了眼日志,发现 xmlrpc.php 的请求量比自己以为的大得多,就想知道半年下来到底被打了多少次、都是什么形态、最后成了几次。
结论先放前面:XML-RPC 探测仅站点 A 就累计 51,476 次,wp-login 相关请求两站合计约两万次(其中真正的密码尝试约两千次),敏感路径扫描每月都有,SSH 近一个月有 8 次失败尝试。成功入侵次数:0。下面是完整的复盘过程和原始数据。
数据是怎么来的
两个站都在 Cloudflare 后面,nginx 日志从 2026 年 3 月保留至今(站点 A 从 3 月 1 日、站点 B 从 3 月 2 日起)。为了不暴露站点身份,下文用“站点 A / 站点 B”称呼,日志文件名也做了匿名。统计口径:
XML-RPC 请求:出现 xmlrpc.php 的请求,并按 POST / 非 POST 拆分
wp-login 请求:出现 wp-login.php 的请求,并按 POST / 非 POST 拆分
敏感路径探测:匹配 .env、.git、config.php、.bak、phpinfo、.sql 的请求
SSH:auth.log 各轮转文件里的 Failed password 计数
时间范围:2026-03-01 ~ 2026-09-06
统计命令是 grep + sort + uniq 的组合,完整命令集放在文末附录,每个数字都能原样重跑。过程中我下过两次错误结论,一次是 wp-login 没拆方法,一次是 8 月脉冲日没拆方法,都在对应位置直接说,不单独标注了。
XML-RPC:大头在这,两个月打了五万次
把半年的服务器攻击日志按类型拆开看,占绝对大头的是 XML-RPC,而且几乎全打在站点 A 上:
站点A xmlrpc.php 按月:
36,715 Mar 14,650 Apr
2 May 5 Jun
11 Jul 87 Aug
6 Sep(前6天)
站点B xmlrpc.php 按月:
31 Mar 49 Apr
11 May 11 Jun
1 Jul 1 Aug
0 Sep(前6天)
两个站的待遇差得很远。站点 A 是老站迁过来的,3 月刚迁完就被扫;站点 B 同期新上线,wp-login 一样被打了,但 XML-RPC 几乎没人碰。同样两台 WordPress、同一台服务器,扫描器内部大概也分着工,一部分按“已知域名清单”扫,一部分漫无目的广撒网。具体原因只能猜,但这个差异一直持续了半年。
站点 A 的 51,476 次里,3-4 月就占了 51,365 次,折算下来 3 月平均每天 1,184 次。为什么打 XML-RPC:它历史上出过两类问题,pingback 反射放大(拿你的服务器去打别人)和基于 XML-RPC 的密码爆破(一次请求可以批量尝试几百个密码,system.multicall)。对攻击者来说这是个“一个入口两种用途”的接口,所以 WordPress 站上线后最先被试探的就是它。
3 月的方法拆分是关键。一开始我只按总量数,没拆方法;拆开之后:
POST(真 multicall 爆破): 36,537
GET(rsd 探测等): 178
POST 的状态码:
36,531 200
6 499
36,537 次 POST、36,531 次返回 200,攻击者的爆破请求真实打到了 WordPress 层,而且持续了两个月。这部分数据是干净的,因为 XML-RPC 层当时没有任何防护,请求全部直通 PHP。
转折点在 5 月。5 月初在 Cloudflare 加了拦截规则,Nginx 层也加了对应拦截,XML-RPC 请求在前端被挡掉,到不了 PHP:
5 月: 2 次
6 月: 5 次
7 月: 11 次
8 月: 87 次
现在再请求 xmlrpc.php:
$ curl -X POST https://example.com/xmlrpc.php -o /dev/null -w "%{http_code}"
403
这是全文唯一有完整前后对照的一组数据:拦截前 3-4 月合计 51,365 次、状态码 200;拦截后单月个位数、状态码 403。这一项的因果可以认定。

归零之后没有彻底安静。近期的零星请求(8 月 30 日一波、9 月 5 日一波)路径已经变成变体试探:
"GET /%252Fxmlrpc.php" 404 (双编码,绕 WAF 的常见手法)
"GET /wp/xmlrpc.php" 404 (猜子目录安装)
"GET /wordpress/xmlrpc.php" 404
"GET /blog/xmlrpc.php" 404
"GET /site/xmlrpc.php" 404
主路径被封死之后,扫描器开始枚举“WordPress 装在子目录里”的可能性,全部落在 404。8 月底、9 月初各来一波,之后没断过。真该警惕的是哪天这些路径从 404 变成 200。
wp-login:POST 和 GET 不拆开,会得出完全错误的结论
登录口是第二战场,也是最容易被误读的一个。wp-login 的请求里,POST 才是真的在试密码,GET 大多只是探测。先看拆开后的月度分布:
站点A wp-login POST(密码尝试):
257 Mar 0 Apr
1 May 0 Jun
0 Jul 67 Aug
0 Sep(前6天)
站点A wp-login 非POST(GET/HEAD 探测):
7,700 Mar 3,265 Apr
893 May 1,502 Jun
69 Jul 1,152 Aug
3 Sep(前6天)
站点B wp-login POST(密码尝试):
860 Mar 3 Apr
791 May 6 Jun
0 Jul 0 Aug
0 Sep(前6天)
站点B wp-login 非POST:
2,261 Mar 372 Apr
682 May 71 Jun
48 Jul 307 Aug
213 Sep(前6天)

我最早按总量统计,差点得出“登录爆破过万次”的结论,两站 wp-login 请求合计 20,523 次。拆开方法之后完全是另一回事:
**真正的密码尝试(POST)合计不到两千次,而且集中在建站初期。** 站点 B 上线第一个月就被 POST 了 860 次、5 月又来 791 次;站点 A 是 3 月 257 次、8 月 67 次。其余月份的 POST 基本是个位数。
**wp-login 的大头是 GET,主要也是上线初期。** 站点 A 3 月 7,700 次 GET、4 月 3,265 次,站点 B 3 月 2,261 次。这种量级不像真人访问,更像扫描器在枚举:确认登录页存在、探测 wp-admin 下有没有未授权路径、找 wp-login 的变体位置。这些 GET 不试密码,谈不上直接风险,主要副作用是让“总请求量”这个指标失去意义,我就是先被它带偏,拆开才看清的。
密码尝试为什么衰减?最初我把两站 POST 的消失都归到 5 月的拦截上,后来发现对不上:站点 B 在 5 月拦截上线后仍有 791 次 POST,6 月才掉到 6 次,时间上更接近 6 月底上线的登录限流插件(这个插件的实现见 mu-plugin 实战那篇);而站点 A 的 POST 在 4 月就归零了,当时它什么登录防护都没有。所以更合理的解释是:爆破衰减的主因是“上线热度退去 + 扫描器目标列表轮换”,防护(5 月 CF/Nginx 拦 xmlrpc、6 月登录限流)是第二层保险,不是唯一原因。哪个贡献大,这份日志判断不了。
8 月的“反弹”同样要拆开看。站点 A 的 wp-login 请求 8 月共 1,219 次,单日分布:
08-03: 1
08-04: 311 (POST 0)
08-06: 279 (POST 0)
08-08: 288 (POST 9)
08-09: 5 (POST 5)
08-13: 38 (POST 38)
08-14: 15 (POST 15)
08-16: 1
08-19: 279 (POST 0)
08-28: 1
08-30: 1
08-04、08-06、08-08、08-19 四天各打 300 次左右然后消失,拆开后 POST 几乎为 0,全是 GET,这是批量路径探测,不是密码爆破:枚举 wp-admin 和 wp-login 的变体路径,看哪些能访问。而真正的密码尝试集中在另一头:08-13 和 08-14 两天一共 53 次 POST,加上 08-08/08-09 的零头,凑成 8 月全部的 67 次。同一个月里,300 次/天的 GET 探测看着吓人,53 次 POST 才是实际在试密码的;不拆方法,这两件事在日志里完全分不开。9 月 1 日站点 B 又来了同型的 209 次:
"GET /wp-admin/index.php" 301
"GET /wp-admin/plugins.php" 301
"GET /wp-admin/profile.php" 302
没有密码尝试,301/302 说明全部被弹回登录页。日志里这 209 次集中在 16:20 到 16:23 三分钟内,HTTP/1.1 和 HTTP/2.0 各一波并行打过来,打完就停。这是爆破前的侦察,或者是在找未授权访问的后台页面。
背景噪音:敏感路径扫描
XML-RPC 和 wp-login 是“有目标的攻击”,下面这类是“无差别广播”,永远不会停:
站点A .env/.git/config.php/.bak/phpinfo/.sql 探测:
Mar 1,967 Apr 2,195 May 256 Jun 1,222 Jul 1,027 Aug 1,851 Sep 194(6天)
站点B 同口径:
Mar 6,853 Apr 1,740 May 696 Jun 1,102 Jul 623 Aug 716 Sep 271(6天)
两站多数月份合计上千次(5 月是例外,合计 952)。3 月站点 B 甚至单月被扫了 6,853 次。具体路径看一眼就知道是自动化的。除了上面口径内的 .env、.git/config 这类找配置泄露的,还有一组来自 404 Top 的路径,是扫描器在试别的东西:
/graphql
/api/graphql
/v1/graphql
/about.html
/-/-/-/-/-/-/-/-/-/-
/-/-/-/-/ 这种路径是目录遍历探测的变体,累计 4,437 次,扫描器在试 WAF 和解析器的边界。/graphql 系列是探测站上有没有 GraphQL 端点。这些扫描跟你的站是什么、有没有漏洞基本无关,它们在扫整个 IP 段或域名列表。这类流量没有“清零”一说,只能接受它存在,然后确保它扫到的都是 404。
SSH:近一个月 8 次失败尝试
Web 层之外查了 SSH。auth.log 在服务器上保留约一个月(轮转 4 个文件),窗口内 Failed password 分布:
08-09 ~ 08-16: 2 次
08-16 ~ 08-23: 0 次
08-23 ~ 08-30: 6 次
08-30 ~ 09-06: 0 次
成功登录: 1 次(自己)
一个月 8 次失败尝试,分两小波,每次间隔好几天,像端口扫描器扫到高位端口后顺手试了几下,不是正经爆破。这台服务器的 SSH 在 7 月改到了高位端口并禁了密码登录;22 端口上现在没有 sshd 监听。需要说明的是:改端口之前的 auth.log 已经轮转掉了,所以“改前 vs 改后”的对比数据拿不到:8 次/月这个数本身是现状,说它“少”是拿同类服务器的经验值对比(默认配置留在 22 端口、允许密码登录的 VPS,一天几百上千次失败尝试很常见),不是拿它自己的历史比。日志保留期这件事,回头单独写一篇。
服务器攻击日志复盘:几个结论
按时间线把防护动作列出来,和上面的数据拐点放一起看:
2026-03 服务器迁移上线,WordPress 默认配置
2026-05 Cloudflare 规则拦截 xmlrpc.php + Nginx 层拦截
2026-06 登录限流 mu-plugin:失败计数、IP 封禁、成功清零
安全头、禁用用户枚举、禁用 XML-RPC(wp 层二次保险)
2026-07 SSH 高位端口 + 禁用密码登录
持续 Cloudflare 托管规则(拦已知攻击特征)
几个结论,每个都标注证据边界。这也是这次服务器攻击日志复盘和网上多数“加固清单文”不一样的地方:
1. **没有人针对你**。日志里没有手工试探的痕迹,也没有针对具体业务的定制请求,UA 基本是 curl 或空值。攻击都是自动化的:路径模板化、频率波次化。小站看到这个能松口气,但别把“没被盯上”当安全,漏配一个插件,自动扫描照样能打穿。
2. **攻击按波次出现,不按均匀流量出现**。3-4 月是高峰(新站上线、防护未到位),5 月加拦截后衰减,8 月又冒出一波 GET 探测脉冲,9 月基本安静。只看单日数据会高估或低估威胁,拉月度曲线才能看到真实形态。
3. **防护和拐点的因果,只有 XML-RPC 一项能坐实**。它前后数据完整:3-4 月 51,365 次、状态码 200 → 5 月拦截后单月 2 次、403。wp-login 的 POST 衰减和 6 月限流插件时间吻合,但站点 A 的 POST 在 4 月、没有任何防护时就归零了,所以登录爆破的消退更可能主要来自上线热度退去和扫描目标轮换,防护是第二层保险;SSH 则连改前数据都没有,只剩现状。复盘里能说“是我挡住的”和“只是时间碰巧”要分清,分不清的就直说分不清。
4. **封掉一个入口之后,攻击不会消失,只是换路径**。XML-RPC 主路径被封,双编码和子目录变体试探持续了几个月;POST 爆破消停后,流量变成 GET 路径探测。防护的效果不是让日志清零,而是让每一波尝试都落在 404 和 403 上。日志只能看到攻击被挡在门外;真中招以后怎么发现、怎么清,是另一套流程,见之前写的 WordPress 挂马排查实录。
局限
说几点这份服务器攻击日志复盘的局限:
1. **来源 IP 不可见**。两个站都在 Cloudflare 后面,nginx 日志里记录的 remote_addr 是 CF 边缘节点(172.70.x、104.23.x 这些),不是攻击者真实 IP。这份复盘能讲“打没打、怎么打”,讲不了“谁在打”。想看真实来源,得用 CF 的 Security Events,或回源时记录 CF-Connecting-IP,当时的配置没做。
2. **意图是推断的**。说 8 月的 GET 脉冲是路径探测、9/1 的请求是侦察,这是基于请求形态的合理推断,不是攻击者的自述。
3. **nginx 日志只有请求,没有应用层结果**。3 月那 36,531 次 POST 200 里,multicall 试了多少组密码、有没有接近成功的,日志里看不出来,WordPress 自身的登录审计没有留存。想补这块需要提前开审计日志,事后补不出来。
4. **SSH 历史只有一个月**。auth.log 轮转把 7 月改端口之前的记录清掉了,改前改后没法直接对比。
给你的清单
如果你的服务器也是 WordPress + 海外 VPS:
1. 关 XML-RPC:wp 层 xmlrpc_enabled 返回 false(官方文档)+ 入口层拦截(CF 规则或 Nginx)。5 万次探测归零靠的就是这个。关之前先确认没有依赖 XML-RPC 的东西(Jetpack、部分移动端客户端),有就先评估再关。
2. 统计攻击日志先拆方法:POST 和 GET 分开数。不然会把探测当爆破,基于错误的威胁模型做防护决策。
3. 登录限流照做(失败计数 + IP 封禁),但心里有数:它挡的是 POST 爆破,挡不住 GET 枚举,GET 枚举无害,别为它加戏。
4. SSH 改高位端口 + 禁密码登录。这两步几乎不花钱,把 SSH 失败尝试从“每天几百上千次”的常见水平降到“每月个位数”,是我这台服务器上投入产出比最高的一项改动。
5. 套 CDN 并让日志记录真实来源 IP。Cloudflare 免费版就够,攻击者不再直接看到源站 IP;但要在回源日志里记 CF-Connecting-IP,不然出事时没法溯源。
6. 留日志、设保留期。这次复盘能成立,是因为 nginx 日志从 3 月留到了现在;SSH 想对比改端口前后,历史已经轮转掉了。日志是唯一的事后取证手段。
附:完整统计命令集
本文所有数字的来源,均可原样重跑:
# 按月统计任意攻击路径
grep "xmlrpc.php" /www/wwwlogs/site.log | grep -oP '[(d{2})/w{3}/2026' | awk -F'/' '{print $2}' | sort | uniq -c
# 按方法拆 xmlrpc(以 3 月为例)
grep "xmlrpc.php" /www/wwwlogs/site.log | grep "Mar/2026" | grep -c "POST" # POST 数
grep "xmlrpc.php" /www/wwwlogs/site.log | grep "Mar/2026" | grep -cv "POST" # 非 POST 数
# wp-login:月度 POST / 非 POST
grep "POST /wp-login.php" /www/wwwlogs/site.log | grep -oP '[(d{2})/w{3}/2026' | awk -F'/' '{print $2}' | sort | uniq -c
grep "wp-login.php" /www/wwwlogs/site.log | grep -v "POST /wp-login.php" | grep -oP '[(d{2})/w{3}/2026' | awk -F'/' '{print $2}' | sort | uniq -c
# 单日分布(找脉冲)
grep "wp-login.php" /www/wwwlogs/site.log | grep -oP '[Kd{2}/Aug/2026' | sort | uniq -c
# 敏感路径探测(按月)
grep -E '.env|.git/config|config.php|.bak|phpinfo|.sql' /www/wwwlogs/site.log | grep -oP '[(d{2})/w{3}/2026' | awk -F'/' '{print $2}' | sort | uniq -c
# 404 Top 路径
grep " 404 " /www/wwwlogs/site.log | grep -oP '"(?:GET|POST|HEAD) K[^? ]+' | sort | uniq -c | sort -rn | head -10
# 状态码分布
grep "xmlrpc.php" /www/wwwlogs/site.log | grep "Mar/2026" | awk '{print $9}' | sort | uniq -c
# SSH 失败尝试(各轮转文件)
for f in /var/log/auth.log.*; do
case "$f" in *.gz) zcat "$f" 2>/dev/null | grep -c "Failed password";;
*) grep -c "Failed password" "$f";; esac
done
© 2026 本文由 Charlie 原创撰写,发布于 sodebug.com。未经授权禁止转载、洗稿、机器抓取。AI 训练数据使用需获得书面授权。