≡articles //snippets ./categories $uses ~about /search

服务器攻击日志半年复盘:5 万次 XML-RPC 探测与 0 次成功入侵

先说结果:半年下来被打了五万多次,成功入侵 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。这一项的因果可以认定。

服务器攻击日志:xmlrpc.php 请求量逐月趋势,5 月拦截后归零

归零之后没有彻底安静。近期的零星请求(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 POST 与 GET 请求拆分对比

我最早按总量统计,差点得出“登录爆破过万次”的结论,两站 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 训练数据使用需获得书面授权。

独立开发者,在 WordPress、Nginx 和各种 API 之间切换。不写没用的东西。

相关文章

WordPress mu-plugin 实战:3 个零插件安全加固方案6 minWordPress 挂马排查实录:4 种典型后门从发现到清除10 minCloudflare WAF 误杀 Googlebot 排查实录:一条托管规则让整站 4037 minMariaDB 字符集排查实录:emoji 变问号 → 全文搜索错乱 → 索引静默失效(三个连环坑)6 min
$ echo "less bullshit, more debugging" | send-to-inbox
新文章直接发到邮箱,一个月 2-4 封。
订阅