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

上线第三个小时,注册接口的 QPS 从平时的 120 飙到 3800。验证码服务炸了,短信账单直接刷了半个月的预算。我打开监控面板,看到一波 IP 地址每秒钟都在换,但 User-Agent 永远是同一串——典型的羊毛党脚本攻击。

这次不打算讲什么"加强安全意识"的空话。直接上方案:我们在网关层做了一套滑块验证码 + 请求指纹的联动防御,把异常注册流量压回了正常水位。整个改动没有入侵业务代码,从发现问题到上线只花了 6 个小时。

攻击长什么样

先看日志。正常的注册请求,IP 分布是分散的,每个 IP 平均 3-5 次请求。羊毛党的请求长这样:

  • 同一秒内,同一个手机号被重复提交 40 次以上
  • IP 来自全国各地,但请求间隔固定为 200ms,明显是脚本控制
  • User-Agent 伪装成 Chrome,但缺少了 Sec-CH-UAAccept-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)。后端校验三条规则:

  1. 总时长在 800ms 到 4000ms 之间。机器脚本通常要么太快(< 300ms),要么匀速(每 50ms 固定偏移)
  2. 加速度不是常数。真实人类的拖拽有启动、加速、减速、微调四个阶段,加速度曲线是抛物线形;脚本通常是线性或正弦模拟
  3. 终点有回退微调。人类放开水印块时,有 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%,再考虑上商业方案也不迟。

Logo

腾讯云面向开发者汇聚海量精品云计算使用和开发经验,营造开放的云计算技术生态圈。

更多推荐