一次真实的恶意刷量攻击复盘:当"搜索引擎爬虫"盯上了我的个人博客
从发现异常到构建多层防护体系的完整记录。
1. 事情的起因:统计数据不对劲
某天打开博客后台仪表盘,发现站点 PV/UV 曲线出现了完全不符合常理的陡增:一个日均几百访问量的个人博客,短时间内涌入了海量"访客",访问日志表以肉眼可见的速度膨胀,文章阅读量也被刷得虚高。

看了UA类型,有一个UA直接是Yisouspider,想必懂行的人应该知道是什么了。
翻看访客 IP 日志,几个特征立刻暴露了问题:
- IP 高度分散,行为却整齐划一——大量请求看似来自全国乃至全球各地,但访问路径、频率、请求特征完全一致;
- User-Agent 伪装成了某家搜索引擎的爬虫标识。乍一看像是正常的收录行为,但真正的搜索引擎爬虫不会以这种频率反复请求统计上报接口(
POST /api/public/site/visits)——爬虫抓取的是页面内容,不会执行页面里的统计 JS,更不会直接对着 API 打请求; - 请求只集中在几个"能产生写入"的公开接口上:站点访问上报、文章阅读量上报。纯读接口几乎不碰。
结论很明确:这不是搜索引擎在收录内容,而是有人用爬虫脚本(或者借用了搜索引擎爬虫的壳)在恶意刷统计数据、灌满数据库。
一个值得注意的细节
日志里那些"来自全国各地"的海量 IP,后来被证实绝大多数是伪造的。攻击者是怎么做到的?答案在下一节。
2. 第一个漏洞:被滥用的 X-Forwarded-For
排查后端代码时发现,最初的客户端 IP 解析逻辑是业界"经典"写法:优先读 X-Forwarded-For 请求头,取第一段作为客户端真实 IP。
问题在于:X-Forwarded-For 是请求方可以任意填写的。
攻击脚本只需要在每个请求里随机塞一个头:
X-Forwarded-For: 113.92.x.x # 随机生成后端就会把这个伪造的地址当成"客户端真实 IP"。于是:
- 基于 IP 的去重统计(UV)被彻底污染——每个请求都是"新访客";
- 基于 IP 的频率限制形同虚设——限流键随着伪造 IP 一起随机,单 IP 限流永远不会触发;
- 访客日志表里塞满了不存在的访客记录。
修复:只信任由反向代理写入的头
部署架构是 Nginx 在最前面做反向代理,因此让 Nginx 用 $remote_addr(TCP 连接的真实对端地址,无法伪造)覆盖写入 X-Real-IP,后端只读这一个头:
/**
* 只信任 X-Real-IP:该头由 Nginx 用 $remote_addr 覆盖写入,客户端无法伪造;
* X-Forwarded-For 可被请求方任意注入,不予采信。
*/
public static String resolve(HttpServletRequest request) {
String realIp = request.getHeader("X-Real-IP");
if (realIp != null && !realIp.isBlank()) {
return realIp.trim();
}
return request.getRemoteAddr();
}关键点:
- Nginx 配置里必须是
proxy_set_header X-Real-IP $remote_addr;(覆盖写入,而不是透传客户端自带的值); - 后端所有需要客户端 IP 的地方(统计、限流、封禁检查、日志)统一走同一个解析工具类,保证策略一致,不留旁路。
修复之后,伪造 IP 的把戏立刻失效——所有攻击请求还原成了它们真实的来源:少数几个网段里的少数几台机器。
3. 第二道防线:IP 封禁体系(带一点"心理战")
真实来源暴露后,就可以精准封禁了。我做了一套规则驱动的 IP 封禁体系,支持三种匹配类型:
| 类型 | 说明 | 适用场景 |
|---|---|---|
EXACT | 精确 IP | 封单个地址 |
CIDR | 网段封禁,如 1.2.3.0/24 | 攻击机群常集中在同一网段,一条规则端掉一窝 |
HOST_SUFFIX | 主机号后缀,如 *.*.*.123 | 攻击脚本轮换出口时保持主机号末段不变,按末段匹配可跨网段命中同一套攻击设施 |
其中 HOST_SUFFIX 是针对此次攻击特征专门加的。
工程实现上有两个设计值得分享。
设计一:读路径零 IO
封禁规则持久化在 MySQL,但匹配不查库。规则在启动和每次增删改后被"编译"成一个不可变内存快照:
- 精确 IP 进
HashSet; - CIDR 预解析成
network/mask整数对,命中判断就是一次位运算(ip & mask) == network; - 主机号进整数
Set。
快照用 volatile 整体替换。每个公开请求的封禁检查就是几次内存查表,无锁、无 IO,攻击流量再大也不会把检查本身拖垮。
设计二:隐形封禁(fail-silently)
命中封禁的请求不返回 403。拦截器只在请求上打一个标记,接口正常返回"看起来成功"的只读响应——比如访问上报接口照常返回当前统计数据,只是不计数、不落库:
@PostMapping("/site/visits")
public Result<SiteVisitStatsVO> recordSiteVisit(@RequestParam String path, HttpServletRequest request) {
if (isBannedRequest(request)) {
// 封禁 IP:返回真实统计但不计 PV/UV、不落日志,对客户端表现为正常成功
return Result.success(siteVisitService.queryStats());
}
// ...正常计数逻辑
}为什么这么做?如果直接返回 403,攻击脚本立刻就知道被封了,会马上换 IP、换特征再来,变成无止境的猫鼠游戏。而"假装成功"让攻击者以为脚本还在正常工作,实际上所有请求都进了黑洞。
观察下来,这批攻击流量在被隐形封禁后持续打了很久才逐渐消停——对方大概率始终没意识到刷量早已无效。
fail-open 原则
封禁检查过程出现任何异常都放行。宁可漏掉攻击者,绝不误伤真实用户。防护系统自身故障不应该变成全站故障。
4. 第三道防线:写入限流,保护数据库
就算 IP 没被封,也不能让任何单个 IP 无限制地往数据库里灌日志。在访客日志落库前加了一道基于 Redis 的滑动窗口限流:
- 每个 IP 每分钟最多落库 N 条访问日志(可配置,默认 30);
- 用 Redis
INCR+ 1 分钟过期实现,超限的日志直接丢弃; - 丢弃的只是"日志",不影响正常响应——真实用户完全无感。
private boolean exceedsRateLimit(String ip) {
String key = IP_LOG_RATE_KEY_PREFIX + ip;
Long count = stringRedisTemplate.opsForValue().increment(key);
if (count != null && count == 1L) {
stringRedisTemplate.expire(key, 1, TimeUnit.MINUTES);
}
return count != null && count > maxPerMinute;
}这一层的意义在于兜底:即使出现新的、未被识别的攻击源,它对数据库的写入速率也被钳制在一个常数上限内,不可能再出现"日志表被打满"的情况。
同时,所有日志落库都是异步的(独立线程池),彻底和请求主链路解耦——统计功能就算彻底堵死,也不会拖慢任何一个页面请求。
5. 第四道防线:IP 归属地解析的"去外依赖化"
访客日志需要解析 IP 归属地。最初的实现是调用第三方 HTTP 接口,这在攻击场景下暴露出两个问题:
- 放大攻击:攻击带来海量陌生 IP,每个都触发一次外部 API 调用——攻击者实际上在免费消耗我的第三方接口配额,甚至可能把我推向计费阈值。对方一个廉价请求,换我一次昂贵的外部调用;
- 外部依赖:解析依赖外网可用性,第三方接口的延迟和故障都会传导进来。
修复:引入解析方式开关,默认本地离线解析
blog:
ip-log:
# local = ip2region 离线库(默认);remote = 第三方接口
geo-provider: local
xdb-path: ip2region/ip2region_v4.xdb本地方案用 ip2region 离线库(约 11MB,随应用打包,全量载入内存,并发安全),单次查询微秒级,能解析出地区(国家·省·市)和运营商。离线库给不了的字段(经纬度、ASN)就留空——本地解析只存能获取的信息,不为了凑字段去请求外网。
效果:归属地解析从"每个陌生 IP 一次外网 HTTP 调用"变成"纯内存查表"。攻击者再也无法通过灌 IP 来消耗外部资源,解析链路上的外部依赖清零。第三方接口保留为可选开关,需要更详细字段时手动切换即可。
6. 防护体系全景
最终形成的是一个纵深防御结构,每一层独立起效,层层递减攻击面:
[Nginx 反向代理]
└─ 覆盖写入 X-Real-IP,从源头封死 IP 伪造
↓
[IP 封禁拦截器](内存快照匹配,零 IO)
├─ EXACT / CIDR / HOST_SUFFIX 三种规则
└─ 命中后隐形处理:假装成功,不计数、不落库
↓
[业务层写入限流](Redis 滑窗)
└─ 单 IP 每分钟落库条数硬上限,超限丢弃
↓
[异步日志 + 本地归属地解析]
├─ 日志落库不占请求主链路
└─ ip2region 离线解析,外部依赖清零配合后台管理界面的封禁规则 CRUD 和访客日志查询,从"发现异常 → 定位来源 → 下发封禁"的响应闭环可以在几分钟内完成。
7. 经验总结
永远不要信任客户端可控的请求头。
X-Forwarded-For拿来做日志参考可以,拿来做安全决策(限流、去重、封禁)就是漏洞。信任链必须终结在你自己控制的组件上(反向代理的$remote_addr)。User-Agent 说明不了任何问题。 自称搜索引擎爬虫的流量未必是搜索引擎——真正的搜索引擎爬虫有公开的 IP 段和反向 DNS 验证方式,而且它们抓页面、不打 API。行为特征比身份声明可信得多。
对攻击者,"沉默"胜过"拒绝"。 403 是给攻击脚本的实时反馈,会加速对方迭代;隐形封禁让对方在无效攻击上持续浪费资源。
防护本身不能成为负担。 封禁检查零 IO、日志异步化、fail-open 兜底——防护层的性能开销和故障影响都必须被严格约束,否则防护系统会先于攻击把你自己拖垮。
警惕"放大效应"。 任何"廉价请求触发昂贵操作"的路径(外部 API 调用、复杂计算、大对象写入)都会被攻击者利用。要么加钳制(限流),要么把昂贵操作变廉价(离线库替代外部接口)。
分层设计,不指望银弹。 IP 伪造修复、封禁、限流、去外依赖,每一层解决一类问题,任何单层被绕过时其余层依然兜底。
攻击不可能杜绝,但可以让它变得无利可图。当刷量刷不动统计、灌库灌不满表、连消耗第三方配额都做不到的时候,攻击自然就停了。
你想看什么,我就给你看什么!