上线当天注册接口被刷爆:我用滑块验证码 + 请求指纹把羊毛党拦在了网关层

张开发
2026/4/21 1:58:24 15 分钟阅读

分享文章

上线当天注册接口被刷爆:我用滑块验证码 + 请求指纹把羊毛党拦在了网关层
上线当天注册接口被刷爆我用滑块验证码 请求指纹把羊毛党拦在了网关层上线第三个小时注册接口的 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 提取高频注册 IP1分钟内 50 次awk $7 ~ /\/api\/register/ { ip$1; ts$4 $5; gsub(/^\[/,,ts); gsub(/\]$/,,ts); bucketsubstr(ts,1,17); # 精确到分钟 keybucket 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改起来很直接。第一步在注册路由上加一个前置过滤器。如果检测到请求指纹异常先弹滑块挑战通过之后才转发到下游的注册服务。ComponentpublicclassAntiSpamGatewayFilterimplementsGlobalFilter,Ordered{AutowiredprivateRedisTemplateString,StringredisTemplate;AutowiredprivateCaptchaServicecaptchaService;OverridepublicMonoVoidfilter(ServerWebExchangeexchange,GatewayFilterChainchain){Stringpathexchange.getRequest().getURI().getPath();if(!path.equals(/api/register)){returnchain.filter(exchange);}StringfingerprintbuildFingerprint(exchange);StringriskKeyrisk:fingerprint:fingerprint;StringriskScoreredisTemplate.opsForValue().get(riskKey);// 高风险指纹强制过滑块if(HIGH.equals(riskScore)){StringcaptchaTokenexchange.getRequest().getHeaders().getFirst(X-Captcha-Token);if(captchaTokennull||!captchaService.verify(captchaToken)){exchange.getResponse().setStatusCode(HttpStatus.FORBIDDEN);byte[]body{\code\:429,\msg\:\请完成安全验证\}.getBytes();returnexchange.getResponse().writeWith(Mono.just(body).map(b-exchange.getResponse().bufferFactory().wrap(b)));}}returnchain.filter(exchange);}privateStringbuildFingerprint(ServerWebExchangeexchange){HttpHeadersheadersexchange.getRequest().getHeaders();Stringuaheaders.getFirst(HttpHeaders.USER_AGENT);Stringacceptheaders.getFirst(HttpHeaders.ACCEPT);StringacceptLangheaders.getFirst(HttpHeaders.ACCEPT_LANGUAGE);Stringipexchange.getRequest().getRemoteAddress().getAddress().getHostAddress();// 组合核心特征做 MurmurHashStringrawString.join(|,ua,accept,acceptLang,ip.split(\\.)[0]);returnHashing.murmur3_128().hashString(raw,StandardCharsets.UTF_8).toString();}OverridepublicintgetOrder(){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 像素的回退调整脚本几乎不会验证代码的核心逻辑publicbooleanverifyTrajectory(ListSlidePointpoints){if(pointsnull||points.size()10)returnfalse;longdurationpoints.get(points.size()-1).timestamp-points.get(0).timestamp;if(duration800||duration4000)returnfalse;// 计算加速度方差ListDoubleaccelerationsnewArrayList();for(inti2;ipoints.size();i){doublev1(points.get(i-1).x-points.get(i-2).x)/50.0;doublev2(points.get(i).x-points.get(i-1).x)/50.0;accelerations.add(v2-v1);}doubleavgaccelerations.stream().mapToDouble(d-d).average().orElse(0);doublevarianceaccelerations.stream().mapToDouble(d-Math.pow(d-avg,2)).average().orElse(0);// 人类加速度方差通常在 15-80 之间脚本方差要么接近 0要么异常大if(variance5||variance200)returnfalse;// 终点回退检测最后 100ms 是否有负方向移动inttailStartMath.max(0,points.size()-3);booleanhasRetreatfalse;for(intitailStart1;ipoints.size();i){if(points.get(i).xpoints.get(i-1).x)hasRetreattrue;}returnhasRetreat;// 人类基本都会有微调}这套规则上线后拦截了 94.7% 的脚本注册误判率正常用户被拦低于 0.3%。请求指纹的风控联动滑块是最后一道门前面还需要一个识别谁该被拦的机制。我们在 Redis 里维护了一个轻量的风控评分系统# 指纹评分规则Lua 脚本原子执行localfingerprintKEYS[1]localipKEYS[2]localphoneKEYS[3]localnowtonumber(ARGV[1])-- 维度1同一指纹1分钟内注册次数localfcountredis.call(zcount,fp:..fingerprint, now-60, now)iffcount3thenredis.call(setex,risk:fingerprint:..fingerprint,300,HIGH)returnBLOCKend -- 维度2同一 IP1分钟内注册次数IP 前三段聚合localipcountredis.call(zcount,ip:..ip, now-60, now)ifipcount10thenreturnBLOCKend -- 维度3同一手机号10分钟内被请求次数撞库/批量验证localpcountredis.call(get,phone:..phone)ifpcount and tonumber(pcount)5thenreturnBLOCKend -- 记录本次请求 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)returnPASS三个维度同时监控指纹维度同一个设备指纹 1 分钟内超过 3 次注册直接标为 HIGH后续请求强制滑块IP 维度同一个 /24 网段 1 分钟内超过 10 次注册直接拒绝手机号维度同一个手机号 10 分钟内被提交超过 5 次拒绝防止撞库和短信轰炸这个 Lua 脚本放在 Redis 里用EVALSHA执行RT 在 2ms 以内对注册接口的延迟影响可以忽略。效果验证上线当天晚上我盯着 Grafana 面板看了两个小时。攻击前的基线正常注册 QPS120羊毛党注册 QPS3680占总流量 96.8%短信验证码发送3400 次/分钟防御上线后 30 分钟正常注册 QPS115轻微下降是因为多了滑块步骤但可接受羊毛党注册 QPS47被滑块拦截后剩下的漏网之鱼短信验证码发送128 次/分钟拦截率 (3680 - 47) / 3680 98.7%更关键的是误判率很低。我拉了一个小时的真实用户注册漏斗完成滑块验证的用户最终注册成功率是 91.2%。也就是说加了滑块之后只有不到 9% 的正常用户因为嫌麻烦而放弃——这个代价远比被羊毛党刷爆要低。写在最后很多人一提到防刷第一反应是买商业 WAF 或者接第三方风控 SDK。不是说这些不好而是它们往往需要改业务代码、加依赖、还要担心供应商的延迟和稳定性。我们的方案全部放在网关层业务服务连一行代码都不用改。滑块验证码自建成本几乎为零请求指纹用 Redis 维护2ms 延迟规则用 Lua 脚本原子执行没有竞态条件。如果你也在被羊毛党骚扰我建议先别急着买服务。拉一下你的 access.log看看攻击到底长什么样。很多时候几行 awk 加上一个轻量的网关过滤器就能解决 95% 的问题。剩下的 5%再考虑上商业方案也不迟。

更多文章