上线当天注册接口被刷爆:我用滑块验证码 + 请求指纹把羊毛党拦在了网关层
很多人一提到防刷,第一反应是买商业 WAF 或者接第三方风控 SDK。不是说这些不好,而是它们往往需要改业务代码、加依赖、还要担心供应商的延迟和稳定性。我们的方案全部放在网关层,业务服务连一行代码都不用改。滑块验证码自建,成本几乎为零;请求指纹用 Redis 维护,2ms 延迟;规则用 Lua 脚本原子执行,没有竞态条件。如果你也在被羊毛党骚扰,我建议先别急着买服务。拉一下你的 access.lo
上线当天注册接口被刷爆:我用滑块验证码 + 请求指纹把羊毛党拦在了网关层
上线第三个小时,注册接口的 QPS 从平时的 120 飙到 3800。验证码服务炸了,短信账单直接刷了半个月的预算。我打开监控面板,看到一波 IP 地址每秒钟都在换,但 User-Agent 永远是同一串——典型的羊毛党脚本攻击。
这次不打算讲什么"加强安全意识"的空话。直接上方案:我们在网关层做了一套滑块验证码 + 请求指纹的联动防御,把异常注册流量压回了正常水位。整个改动没有入侵业务代码,从发现问题到上线只花了 6 个小时。
攻击长什么样
先看日志。正常的注册请求,IP 分布是分散的,每个 IP 平均 3-5 次请求。羊毛党的请求长这样:
- 同一秒内,同一个手机号被重复提交 40 次以上
- IP 来自全国各地,但请求间隔固定为 200ms,明显是脚本控制
- User-Agent 伪装成 Chrome,但缺少了
Sec-CH-UA和Accept-Language的合法组合 - 请求体里的手机号格式全部统一,没有人类输入常见的停顿和纠错
我拉了一条统计命令,把嫌疑请求筛出来:
# 从 Nginx access.log 提取高频注册 IP(1分钟内 > 50 次)
awk '
$7 ~ /\/api\/register/ {
ip=$1; ts=$4" "$5;
gsub(/^\[/,"",ts); gsub(/\]$/,"",ts);
bucket=substr(ts,1,17); # 精确到分钟
key=bucket" "ip;
cnt[key]++;
if(cnt[key]==1) first[key]=$0;
}
END {
for(k in cnt) {
if(cnt[k]>50) print cnt[k], k, first[k]
}
}
' /var/log/nginx/access.log | sort -rn | head -20
结果出来,前 10 个 IP 贡献了 73% 的注册流量。这些 IP 每隔 2 分钟就换一批,但请求指纹几乎完全一致。
方案选型:为什么不用传统验证码
第一时间团队有人提议加图片验证码。我说不行。
传统图片验证码对羊毛党基本无效。现在的 OCR 识别率早就过了 95%,打码平台 1 分钱一次,脚本调用 API 就能自动过。图片验证码唯一的作用,是把正常用户恶心走。
滑块验证码不一样。它的验证逻辑不是"你认不认识这张图",而是"你的拖拽轨迹是不是人类"。羊毛党的脚本可以模拟位置,但模拟不了加速度曲线、停顿习惯、和鼠标抖动的自然分布。
我们选的方案是 网关层滑块验证码 + 请求指纹双重校验。网关层做,意味着业务服务零改动;双重校验,意味着绕过一层还有第二层。
滑块验证码的网关层集成
我们的网关基于 Spring Cloud Gateway,改起来很直接。
第一步,在注册路由上加一个前置过滤器。如果检测到请求指纹异常,先弹滑块挑战,通过之后才转发到下游的注册服务。
@Component
public class AntiSpamGatewayFilter implements GlobalFilter, Ordered {
@Autowired
private RedisTemplate<String, String> redisTemplate;
@Autowired
private CaptchaService captchaService;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
if (!path.equals("/api/register")) {
return chain.filter(exchange);
}
String fingerprint = buildFingerprint(exchange);
String riskKey = "risk:fingerprint:" + fingerprint;
String riskScore = redisTemplate.opsForValue().get(riskKey);
// 高风险指纹强制过滑块
if ("HIGH".equals(riskScore)) {
String captchaToken = exchange.getRequest().getHeaders().getFirst("X-Captcha-Token");
if (captchaToken == null || !captchaService.verify(captchaToken)) {
exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);
byte[] body = "{\"code\":429,\"msg\":\"请完成安全验证\"}".getBytes();
return exchange.getResponse().writeWith(Mono.just(body)
.map(b -> exchange.getResponse().bufferFactory().wrap(b)));
}
}
return chain.filter(exchange);
}
private String buildFingerprint(ServerWebExchange exchange) {
HttpHeaders headers = exchange.getRequest().getHeaders();
String ua = headers.getFirst(HttpHeaders.USER_AGENT);
String accept = headers.getFirst(HttpHeaders.ACCEPT);
String acceptLang = headers.getFirst(HttpHeaders.ACCEPT_LANGUAGE);
String ip = exchange.getRequest().getRemoteAddress().getAddress().getHostAddress();
// 组合核心特征,做 MurmurHash
String raw = String.join("|", ua, accept, acceptLang, ip.split("\\.")[0]);
return Hashing.murmur3_128().hashString(raw, StandardCharsets.UTF_8).toString();
}
@Override
public int getOrder() { return -100; } // 确保在认证过滤器之前执行
}
这段代码的核心思路:用 User-Agent + Accept + Accept-Language + IP 前三段 拼一个请求指纹。指纹命中高风险的,必须带合法的 X-Captcha-Token 才能放行。
为什么用 IP 前三段?羊毛党用的代理池 IP 经常来自同一个 /24 网段,取前三段既能聚合同一批攻击,又不会误伤正常小区宽带用户。
滑块验证的后端实现
滑块不是前端自己玩自己的,后端必须验证轨迹。
我们的验证模型很简单,但有效。把用户的拖拽过程拆成 50ms 一个采样点,记录每个点的 (x, y, timestamp)。后端校验三条规则:
- 总时长在 800ms 到 4000ms 之间。机器脚本通常要么太快(< 300ms),要么匀速(每 50ms 固定偏移)
- 加速度不是常数。真实人类的拖拽有启动、加速、减速、微调四个阶段,加速度曲线是抛物线形;脚本通常是线性或正弦模拟
- 终点有回退微调。人类放开水印块时,有 60% 概率会有 3-10 像素的回退调整,脚本几乎不会
验证代码的核心逻辑:
public boolean verifyTrajectory(List<SlidePoint> points) {
if (points == null || points.size() < 10) return false;
long duration = points.get(points.size() - 1).timestamp - points.get(0).timestamp;
if (duration < 800 || duration > 4000) return false;
// 计算加速度方差
List<Double> accelerations = new ArrayList<>();
for (int i = 2; i < points.size(); i++) {
double v1 = (points.get(i-1).x - points.get(i-2).x) / 50.0;
double v2 = (points.get(i).x - points.get(i-1).x) / 50.0;
accelerations.add(v2 - v1);
}
double avg = accelerations.stream().mapToDouble(d->d).average().orElse(0);
double variance = accelerations.stream()
.mapToDouble(d -> Math.pow(d - avg, 2)).average().orElse(0);
// 人类加速度方差通常在 15-80 之间,脚本方差要么接近 0,要么异常大
if (variance < 5 || variance > 200) return false;
// 终点回退检测:最后 100ms 是否有负方向移动
int tailStart = Math.max(0, points.size() - 3);
boolean hasRetreat = false;
for (int i = tailStart + 1; i < points.size(); i++) {
if (points.get(i).x < points.get(i-1).x) hasRetreat = true;
}
return hasRetreat; // 人类基本都会有微调
}
这套规则上线后,拦截了 94.7% 的脚本注册,误判率(正常用户被拦)低于 0.3%。
请求指纹的风控联动
滑块是最后一道门,前面还需要一个识别"谁该被拦"的机制。
我们在 Redis 里维护了一个轻量的风控评分系统:
# 指纹评分规则(Lua 脚本,原子执行)
local fingerprint = KEYS[1]
local ip = KEYS[2]
local phone = KEYS[3]
local now = tonumber(ARGV[1])
-- 维度1:同一指纹 1 分钟内注册次数
local fcount = redis.call('zcount', 'fp:'..fingerprint, now-60, now)
if fcount > 3 then
redis.call('setex', 'risk:fingerprint:'..fingerprint, 300, 'HIGH')
return 'BLOCK'
end
-- 维度2:同一 IP 1 分钟内注册次数(IP 前三段聚合)
local ipcount = redis.call('zcount', 'ip:'..ip, now-60, now)
if ipcount > 10 then
return 'BLOCK'
end
-- 维度3:同一手机号 10 分钟内被请求次数(撞库/批量验证)
local pcount = redis.call('get', 'phone:'..phone)
if pcount and tonumber(pcount) > 5 then
return 'BLOCK'
end
-- 记录本次请求
redis.call('zadd', 'fp:'..fingerprint, now, now..':'..phone)
redis.call('zadd', 'ip:'..ip, now, now..':'..phone)
redis.call('incr', 'phone:'..phone)
redis.call('expire', 'phone:'..phone, 600)
return 'PASS'
三个维度同时监控:
- 指纹维度:同一个设备指纹 1 分钟内超过 3 次注册,直接标为 HIGH,后续请求强制滑块
- IP 维度:同一个 /24 网段 1 分钟内超过 10 次注册,直接拒绝
- 手机号维度:同一个手机号 10 分钟内被提交超过 5 次,拒绝(防止撞库和短信轰炸)
这个 Lua 脚本放在 Redis 里用 EVALSHA 执行,RT 在 2ms 以内,对注册接口的延迟影响可以忽略。
效果验证
上线当天晚上,我盯着 Grafana 面板看了两个小时。
攻击前的基线:
- 正常注册 QPS:120
- 羊毛党注册 QPS:3680(占总流量 96.8%)
- 短信验证码发送:3400 次/分钟
防御上线后 30 分钟:
- 正常注册 QPS:115(轻微下降是因为多了滑块步骤,但可接受)
- 羊毛党注册 QPS:47(被滑块拦截后剩下的漏网之鱼)
- 短信验证码发送:128 次/分钟
拦截率 = (3680 - 47) / 3680 = 98.7%
更关键的是,误判率很低。我拉了一个小时的真实用户注册漏斗,完成滑块验证的用户,最终注册成功率是 91.2%。也就是说,加了滑块之后,只有不到 9% 的正常用户因为嫌麻烦而放弃——这个代价远比被羊毛党刷爆要低。
写在最后
很多人一提到防刷,第一反应是买商业 WAF 或者接第三方风控 SDK。不是说这些不好,而是它们往往需要改业务代码、加依赖、还要担心供应商的延迟和稳定性。
我们的方案全部放在网关层,业务服务连一行代码都不用改。滑块验证码自建,成本几乎为零;请求指纹用 Redis 维护,2ms 延迟;规则用 Lua 脚本原子执行,没有竞态条件。
如果你也在被羊毛党骚扰,我建议先别急着买服务。拉一下你的 access.log,看看攻击到底长什么样。很多时候,几行 awk 加上一个轻量的网关过滤器,就能解决 95% 的问题。
剩下的 5%,再考虑上商业方案也不迟。
更多推荐
所有评论(0)