AI 站点上线被黑产刷爆了?复盘我在 Spring Boot 后端铸造的 4 道资产防御锁

0 阅读8分钟

AI 站点上线被黑产刷爆了?复盘我在 Spring Boot 后端铸造的 4 道资产防御锁

对于做传统 Web 或移动端开发的工程师来说,接口被爬虫脚本刷几次,大不了是多耗几百兆 CDN 流量或者让 MySQL 的 CPU 飙升一阵子,还不至于致命。

但当你做的是 AI 原生应用(如 AI 智能体、AI 生图、长文本深度推理) 时,性质就彻底变了:

  • 一个 gpt-image-2.5 生图调用成本可能在几毛钱到一块钱;
  • 一个 Claude 3.5 / GPT-6 长上下文推理请求可能消耗数万 Token;
  • 一个复杂的 Agent 自主规划流程需要并发调用四五次工具接口。

三个月前,我们团队上线了一个提供“免登录极速体验”的 AI 创作站点。上线当晚,我们在群里举杯庆祝自然流量突破 5000 UV;然而第二天一早醒来,OpenAI 中转后台的几千美元预充值余额被扣到仅剩 $12,报警邮件塞满了邮箱:

监控显示,某几个特定海外代理 IP 段,在凌晨 3 点至 5 点之间,利用自动化多线程无头浏览器并发狂刷核心生图接口。原本设计给真实用户体验的免费额度,被羊毛党用脚本在 2 小时内刷掉了数万次调用!

如果后端防御仅仅依靠简单的“Spring Security + 账号密码鉴权”,在专业的黑灰产面前简直如同纸糊。

为了彻底阻断黑产薅羊毛、避免巨额账单击穿公司财务,我们在 Spring Boot 3 + Redis 架构下重新设计并落地了 4 道环环相扣的“资产级防御锁”。上线两个多月来,日均拦截上万次恶意嗅探与重放请求,API 成本直降 85%,系统零误杀。

本文将把这 4 道核心防御锁的设计理念、时序交互与完整生产代码全景复盘分享出来。


一、防御全景:从边缘入口到数据库落盘的 4 道重锁

在防御架构中,核心思想是:分层设防、层层衰减、前置拦截。越往后阻断,消耗的服务器计算和网络资源越大;最理想的状态是让 95% 的恶意请求在网关过滤器和 Redis 内存层就被直接阻断并拒之门外。

flowchart TD
    subgraph Ingress [1. 第一道锁:边缘入口与风控前置]
        Req[外部 HTTP 客户端请求]
        FP[Canvas/Audio 硬件指纹 Hash]
        IPCheck[IP 代理池/IDC 机房黑名单拦截]
    end

    subgraph SecurityFilter [2. 第二道锁:时间戳与 HMAC 防重放锁]
        TS[时间戳漂移校验 (±30s)]
        Nonce[Nonce 随机数防重放 (Redis 5分钟缓存)]
        Sign[HMAC-SHA256 签名验签]
    end

    subgraph RateLimiter [3. 第三道锁:动态滑动窗口与并发信号量]
        SlideWin[Redis ZSet 动态滑动窗口限流]
        SemLock[任务级分布式信号量 (Max In-Flight = 20)]
    end

    subgraph CoreEngine [4. 第四道锁:原子预扣与异常熔断状态机]
        CAS[MySQL CAS 条件原子预扣 (HOLDING)]
        CircuitBreaker[上游超时与连续失败熔断器]
        Consume[成功核销 (CONSUMED)]
        Refund[异常冲正 (REFUNDED)]
    end

    Req --> FP --> IPCheck
    IPCheck -->|违规特征拦截 403| Block1[阻断: 触发图形滑块验证]
    IPCheck -->|正常流量| TS

    TS --> Nonce --> Sign
    Sign -->|签名不符或重放 401| Block2[阻断: 非法嗅探请求]
    Sign -->|验签通过| SlideWin

    SlideWin --> SemLock
    SemLock -->|突发洪峰或超出频次 429| Block3[阻断: 频次超限等待]
    SemLock -->|获得调度令牌| CAS

    CAS --> CircuitBreaker
    CircuitBreaker -->|执行成功| Consume
    CircuitBreaker -->|熔断/失败| Refund

二、第一道锁:客户端指纹与反自动化探测 (Fingerprint Guard)

黑产最常用的武器是 Python requests、aiohttp 配合动态代理池。单纯限制 IP 在分布式代理面前毫无效果。

1. 核心策略:环境指纹交叉校验

我们在前端 SDK 引入了轻量级环境探针:

  • 采集浏览器 Canvas 渲染指纹、WebAudio 频率指纹、GPU 渲染器名称(WebGL unmaskedRenderer);
  • 探测自动化特有属性:window.navigator.webdriver、window._phantom、document.__selenium_unwrapped 等;
  • 将采集参数在前端进行混淆哈希,生成唯一的 X-Device-Fingerprint。

2. 后端黑产机房 IP 库清洗

在网关层集成 IP 数据云或 MaxMind 数据库,如果请求来源 IP 属于 AWS、阿里云、腾讯云、DigitalOcean 等数据中心(Datacenter / IDC)机房 IP,且访问的是试用/生图接口,一律判定为自动化爬虫代理,直接拦截并要求强制完成点选验证码。


三、第二道锁:时间戳 + Nonce + HMAC 接口防篡改签名

很多初创站点的接口,黑客抓包后拷贝出 curl 就可以无限重放调取。第二道锁的核心目标就是:让每一次请求只能被消费一次,且请求体(Body)与参数不可被篡改。

1. 签名要素设计

  • X-Timestamp:当前毫秒时间戳。服务器允许漂移误差不超过 30 秒,防止跨时区延时,同时限制过期重放;
  • X-Nonce:客户端生成的 32 位 UUID 随机串,存入 Redis,5 分钟内不可重复;
  • X-Signature:客户端利用预埋的 SecretKey,对 URI + Timestamp + Nonce + Body 进行 HMAC-SHA256 散列计算。

2. Spring Boot 3 防重放过滤器生产级实现

package com.tudazi.ai.security.filter;

import com.tudazi.ai.security.util.HmacUtils;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
import org.springframework.web.filter.OncePerRequestFilter;

import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;

@Slf4j
@Component
@RequiredArgsConstructor
public class RequestSignatureValidationFilter extends OncePerRequestFilter {

    private final StringRedisTemplate redisTemplate;
    private static final String HMAC_SECRET = "EnterpriseProductionSecureKey_2026";
    private static final long MAX_TIME_DRIFT_MS = 30_000L; // 允许最大 30 秒时钟误差

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

        String path = request.getRequestURI();
        // 仅对高敏感 AI 资产消费接口进行严格签名校验
        if (!path.startsWith("/api/v1/ai/generate") && !path.startsWith("/api/v1/credits/consume")) {
            filterChain.doFilter(request, response);
            return;
        }

        String timestampStr = request.getHeader("X-Timestamp");
        String nonce = request.getHeader("X-Nonce");
        String signature = request.getHeader("X-Signature");

        // 1. 必填请求头校验
        if (timestampStr == null || nonce == null || signature == null) {
            log.warn("防重放拦截: 缺少核心安全头 Path={}", path);
            response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Missing security headers");
            return;
        }

        // 2. 时间戳防过期:防止捕获旧请求在数小时后重放
        long requestTime;
        try {
            requestTime = Long.parseLong(timestampStr);
        } catch (NumberFormatException e) {
            response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Invalid timestamp");
            return;
        }

        long now = System.currentTimeMillis();
        if (Math.abs(now - requestTime) > MAX_TIME_DRIFT_MS) {
            log.warn("防重放拦截: 请求已过期或时钟漂移严重 Diff={}ms", Math.abs(now - requestTime));
            response.sendError(HttpServletResponse.SC_FORBIDDEN, "Request expired");
            return;
        }

        // 3. Nonce 防重放:利用 Redis SETNX 确保随机数单次有效
        String nonceKey = "security:nonce:" + nonce;
        Boolean isFirstUse = redisTemplate.opsForValue().setIfAbsent(nonceKey, "1", Duration.ofMinutes(5));
        if (Boolean.FALSE.equals(isFirstUse)) {
            log.warn("防重放拦截: 检测到重复 Nonce 重放攻击 Nonce={}", nonce);
            response.sendError(HttpServletResponse.SC_CONFLICT, "Replay attack detected");
            return;
        }

        // 4. 重复读包装 RequestBody 并验签
        CachedBodyHttpServletRequest wrappedRequest = new CachedBodyHttpServletRequest(request);
        String body = StreamUtils.copyToString(wrappedRequest.getInputStream(), StandardCharsets.UTF_8);
        String payloadToSign = wrappedRequest.getMethod() + path + timestampStr + nonce + body;
        String calculatedSign = HmacUtils.sha256Hex(HMAC_SECRET, payloadToSign);

        if (!calculatedSign.equalsIgnoreCase(signature)) {
            log.warn("防重放拦截: HMAC 签名不匹配 Expected={} Actual={}", calculatedSign, signature);
            response.sendError(HttpServletResponse.SC_FORBIDDEN, "Invalid signature");
            return;
        }

        filterChain.doFilter(wrappedRequest, response);
    }
}

四、第三道锁:基于 Redis ZSet 的动态滑动窗口限流

传统的计数器限流(如 INCR 配合 1 分钟过期)存在致命缺陷:临界时间双倍突变(在 00:59 发起 60 次请求,在 01:00 又发起 60 次请求,相当于 2 秒内涌入了 120 次请求打崩上游)。

我们采用 Redis Sorted Set (ZSet) 滑动时间窗口,将时间戳作为 score 和 member,真正做到纳秒级平滑限制。

1. 核心 Lua 脚本实现 (保证原子性)

-- KEYS[1]: 限流 key (例如 rate:limit:user_id:1001)
-- ARGV[1]: 当前时间戳 (毫秒)
-- ARGV[2]: 窗口大小 (毫秒, 如 60000 代表 1 分钟)
-- ARGV[3]: 窗口内最大允许调用次数 (如 10 次)

local key = KEYS[1]
local now = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local max_limit = tonumber(ARGV[3])
local clear_before = now - window

-- 1. 移除窗口外的历史过期记录
redis.call('ZREMRANGEBYSCORE', key, 0, clear_before)

-- 2. 获取当前滑动窗口内的总调用次数
local current_requests = redis.call('ZCARD', key)

-- 3. 判断是否超出配额
if current_requests < max_limit then
    -- 未超限,记录本次请求
    redis.call('ZADD', key, now, now)
    -- 设置 key 过期时间为窗口大小
    redis.call('PEXPIRE', key, window)
    return 1 -- 允许放行
else
    return 0 -- 触发限流阻断
end

2. 全局分布式信号量锁:限制未完成任务数(In-Flight Limiter)

除了限制频次外,更重要的是限制正在进行中的调用总数。 如果用户或者黑客在 1 秒内通过 10 个线程发起了 10 次合法的长文本生图,后端立刻拉起 10 路调用涌向上游,依然会耗尽本地线程。 解法:使用 Redis 维护并发 Semaphore:单用户同时只能有 1 个生图任务处于 RUNNING 状态,新任务必须排队或者拒绝,彻底扼杀并发打架。


五、第四道锁:分布式资产状态机与异常熔断 (Asset State Machine)

即使前面三道锁都放行了,如果数据库扣款不是原子的,或者上游生图报错时系统没有自愈能力,账本依然会产生严重坏账。

1. 资产状态机:禁止直接扣款,必须两阶段提交

-- 预扣冻结:利用数据库条件行锁进行悲观保护,杜绝超扣
UPDATE t_user_wallet 
SET 
    frozen_credits = frozen_credits + :cost,
    balance_credits = balance_credits - :cost,
    version = version + 1
WHERE user_id = :userId 
  AND balance_credits >= :cost;

2. 异常熔断与智能保活(Circuit Breaker)

当调用 OpenAI 或本地推理集群时:

  • 若连续 5 次上游返回 502 / 504 / 429,触发熔断器(Resilience4j),暂停向公网接单 60 秒,并给用户返回“AI 算力节点拥堵中,请稍后重试”,避免无效请求源源不断消耗系统重试资源;
  • 失败时自动触发补偿任务(Compensating Transaction),原路解冻用户积分,日志记录完整请求快照。

六、生产避坑经验与效果总结

在落地这套多维防御系统后,我们总结了 3 条最宝贵的实战经验:

  1. 防重放过滤器严禁读取未包装的 InputStream:Servlet 的 request.getInputStream() 默认只能被读取一次。如果在过滤器中为了算 HMAC 读取了流,后续 Spring MVC 的 @RequestBody 解析就会报 HttpMessageNotReadableException: Stream closed。必须使用 ContentCachingRequestWrapper 或自定义 CachedBodyHttpServletRequest。
  2. 切忌用客户端 IP 单一做限流维度:真实用户在公司局域网或校园网内共用同一个出口公网 IP。如果单纯根据 IP 做 1 分钟 10 次限制,会造成整栋办公楼所有用户被集体误杀。正确的做法是组合维度:DeviceFingerprint + UserID + C段IP 联合限流。
  3. 给黑客返回迷惑性错误,不要暴露防御细节:被第一道锁拦截时,返回普通的 403 Forbidden 或引导弹窗;不要在 Response 里写“检测到你使用了无头浏览器”,避免给黑产逆向调试提供反馈信息。

读者探讨

在你的系统架构中,是否也曾遭遇过爬虫盗刷高价值接口的经历?你们目前是如何设计防重放与分布式限流的?欢迎在评论区分享你的防御秘籍与架构思考!