首页 / 帮助文档 / 后端防重放攻击开发配置实操指南

后端防重放攻击开发配置实操指南

重放攻击的可怕之处在于,攻击者甚至不需要破解你的加密算法,只需要原封不动地把你合法的请求再发一次,就能完成一次成功的攻击。支付回调接口被重放导致重复入账,短信验证码接口被重放导致用户被骚扰轰炸,订单提交接口被重放导致库存超卖,这些都是血淋淋的线上事故。解决重放攻击的核心思路只有一个:让每条请求都具备唯一性,且服务端能够识别并拒绝已经处理过的请求。下面直接进入实操层面,把后端防重放攻击的完整配置和代码实现讲透。

防重放攻击的核心机制选型

目前工业界主流的防重放方案就两种:基于时间戳加随机数的方式,以及基于请求序列号的方式。第一种方案不需要服务端维护会话状态,适合分布式无状态服务;第二种方案需要服务端记录序列号,适合有状态的长连接场景。对于绝大多数HTTP API来说,基于时间戳加随机数的方案是性价比最高的选择。这个方案的核心组件包括:客户端生成的唯一随机数、请求时间戳、请求签名。服务端收到请求后,先校验时间戳是否在允许的时间窗口内,再检查随机数是否已经被使用过,最后验证签名是否合法。三个条件全部通过,才认定这是一次合法的新请求。

随机数存储方案的技术选型

随机数判重是整个防重放系统的性能瓶颈所在。如果每个请求都要查一次数据库,高并发场景下数据库瞬间就会被打爆。正确的做法是用Redis来存储已处理的随机数。Redis的SET命令天然支持NX参数,可以原子性地完成存在性判断和写入操作,时间复杂度是O(1)。随机数的存储需要设置过期时间,过期时间等于时间戳校验窗口的时长。比如时间窗口设置为60秒,那么随机数在Redis中的过期时间就设置为60秒,这样既保证了防重放的有效性,又避免了Redis内存无限增长。对于超高并发的场景,还可以使用布隆过滤器做前置过滤,把绝大多数重复请求挡在Redis查询之前,进一步降低延迟和资源消耗。

时间戳校验窗口的精确设定

时间窗口设得太小,正常的网络延迟就会导致请求被误拒;设得太大,攻击者的重放窗口就变长,安全性和时效性都会下降。根据实际生产环境的经验,60秒是一个比较平衡的数值。如果客户端和服务端都使用NTP协议进行时间同步,时间偏差通常不会超过1秒,60秒的窗口已经足够包容各种网络抖动和时钟偏移。对于安全性要求极高的金融类接口,可以收紧到30秒甚至10秒,但前提是客户端必须强制开启NTP时间同步。时间戳校验的逻辑一定要在服务端完成,客户端传过来的时间戳只能作为参考,服务端用自己的当前时间减去客户端时间戳,取绝对值判断是否在窗口范围内。

签名算法的选择与实现

时间戳和随机数都是明文传输的,攻击者完全可以抓包后修改这两个参数,然后重新计算签名。所以签名的密钥必须只存在于客户端和服务端,绝不能通过网络传输。HMAC-SHA256是目前最主流的选择,性能好、安全性高、各种语言都有成熟的库支持。签名的原始字符串需要包含请求体、时间戳、随机数、请求路径这几个要素,把这些要素按照固定顺序拼接,然后用密钥进行HMAC计算。请求体参与签名计算,意味着任何请求参数的篡改都会导致签名校验失败,这样签名机制同时起到了防篡改和防重放的双重作用。

后端拦截器的完整代码实现

下面以Spring Boot项目为例,给出一个可以直接使用的防重放拦截器实现。这个拦截器需要在所有需要防护的接口上生效,建议通过注解的方式按需启用,避免对不需要防护的静态资源或者健康检查接口造成性能开销。

@Component
public class AntiReplayInterceptor implements HandlerInterceptor {
    
    private static final String NONCE_PREFIX = "replay_nonce:";
    private static final long TIME_WINDOW_SECONDS = 60;
    private static final String SECRET_KEY = "your-production-secret-key-min-32-chars";
    
    @Autowired
    private StringRedisTemplate redisTemplate;
    
    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
        String timestamp = request.getHeader("X-Timestamp");
        String nonce = request.getHeader("X-Nonce");
        String signature = request.getHeader("X-Signature");
        
        if (timestamp == null || nonce == null || signature == null) {
            response.setStatus(401);
            return false;
        }
        
        long requestTime;
        try {
            requestTime = Long.parseLong(timestamp);
        } catch (NumberFormatException e) {
            response.setStatus(401);
            return false;
        }
        
        long currentTime = System.currentTimeMillis() / 1000;
        if (Math.abs(currentTime - requestTime) > TIME_WINDOW_SECONDS) {
            response.setStatus(408);
            return false;
        }
        
        Boolean isNewNonce = redisTemplate.opsForValue()
            .setIfAbsent(NONCE_PREFIX + nonce, "1", TIME_WINDOW_SECONDS, TimeUnit.SECONDS);
        if (isNewNonce == null || !isNewNonce) {
            response.setStatus(409);
            return false;
        }
        
        String requestBody = getRequestBody(request);
        String signContent = request.getRequestURI() + timestamp + nonce + requestBody;
        String expectedSignature = hmacSha256(signContent, SECRET_KEY);
        
        if (!expectedSignature.equals(signature)) {
            response.setStatus(403);
            return false;
        }
        
        return true;
    }
    
    private String hmacSha256(String data, String key) {
        try {
            Mac mac = Mac.getInstance("HmacSHA256");
            SecretKeySpec secretKeySpec = new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
            mac.init(secretKeySpec);
            byte[] hash = mac.doFinal(data.getBytes(StandardCharsets.UTF_8));
            return Hex.encodeHexString(hash);
        } catch (Exception e) {
            throw new RuntimeException("HMAC calculation failed", e);
        }
    }
    
    private String getRequestBody(HttpServletRequest request) {
        try {
            return request.getReader().lines().collect(Collectors.joining());
        } catch (IOException e) {
            return "";
        }
    }
}

上面这段代码有几个需要特别注意的细节。第一,随机数存入Redis使用的是setIfAbsent方法,这个方法在Redis层面是原子操作,即使多个线程同时处理相同的随机数,也只有一个线程能成功写入,其他线程都会得到false的返回结果。第二,随机数的过期时间设置得和时间窗口一样长,这样窗口外的重复请求会因为时间戳校验失败而被拦截,窗口内的重复请求会因为随机数已存在而被拦截,两层防护互相配合。第三,请求体的读取需要特别注意,HttpServletRequest的输入流只能读取一次,如果你的项目中有其他拦截器或者过滤器也需要读取请求体,需要使用ContentCachingRequestWrapper对请求进行包装,否则这里的getRequestBody会返回空字符串。

请求体多次读取的解决方案

在实际项目中,防重放拦截器往往不是唯一需要读取请求体的地方,参数校验、日志记录、审计追踪都可能需要访问请求体。解决这个问题的标准做法是注册一个优先级最高的过滤器,把原始的HttpServletRequest包装成ContentCachingRequestWrapper,后续所有组件都从这个包装类中获取请求体数据。

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class RequestBodyCacheFilter extends OncePerRequestFilter {
    
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, 
                                    FilterChain filterChain) throws ServletException, IOException {
        ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request);
        filterChain.doFilter(wrappedRequest, response);
    }
}

使用ContentCachingRequestWrapper之后,获取请求体的方式也要相应调整,调用getContentAsByteArray方法而不是直接读取输入流。这个包装类会把请求体内容缓存到内部的字节数组中,可以反复读取而不影响正常的请求处理流程。

客户端请求头的生成规范

防重放机制需要客户端配合生成三个请求头,分别是X-Timestamp、X-Nonce和X-Signature。客户端实现时需要注意几个关键点。随机数必须使用密码学安全的随机数生成器,不能使用简单的自增序列或者时间戳拼接随机数。Java平台使用SecureRandom,Node.js平台使用crypto.randomUUID,Python平台使用secrets.token_hex。时间戳必须是Unix秒级时间戳,客户端和移动端都需要确保系统时间准确,建议在应用启动时向服务端请求一次服务器时间,计算本地时间与服务器时间的偏移量,后续所有请求的时间戳都基于这个偏移量进行修正。签名的计算顺序必须和服务端完全一致,任何一个字符的差异都会导致签名校验失败。

密钥管理的安全最佳实践

签名密钥是防重放机制的最后一道防线,密钥泄露意味着攻击者可以伪造任意请求。生产环境的密钥绝对不能硬编码在代码中,更不能提交到版本控制系统。推荐的做法是使用环境变量或者配置中心来管理密钥,Spring Boot项目可以通过application.yml中的占位符引用环境变量。密钥的长度至少要达到32个字符,使用大小写字母、数字和特殊字符的随机组合。密钥需要定期轮换,轮换期间新旧密钥同时生效一段时间,给所有客户端留出足够的升级窗口。密钥轮换的实现方式是在服务端维护一个密钥列表,校验签名时依次尝试所有有效的密钥,只要有一个匹配就认为签名合法。

分布式环境下的扩展性考量

如果服务部署了多个实例,随机数的判重必须基于共享存储来实现,单机内存缓存会导致不同实例之间无法感知对方已处理的随机数。Redis集群是天然的共享存储方案,所有服务实例共享同一个Redis集群,随机数的判重自然就是全局一致的。如果Redis出现故障,防重放机制就会失效,这时候需要决定是放行所有请求还是拒绝所有请求。对于金融支付类系统,建议选择拒绝所有请求,宁可暂时不可用也不能出现重复扣款的风险。对于用户体验优先的系统,可以选择降级放行,但需要在监控系统中配置告警,一旦发现Redis不可用立即通知运维人员处理。

监控告警与异常排查

防重放拦截器上线后,必须配套完善的监控体系。需要监控的指标包括:时间戳超时被拦截的请求数量、随机数重复被拦截的请求数量、签名校验失败被拦截的请求数量。时间戳超时拦截量突然飙升,说明客户端的时间同步出了问题或者网络延迟异常增大。随机数重复拦截量异常增加,可能是客户端随机数生成器存在缺陷,也可能是有攻击者在尝试重放攻击。签名校验失败量突然增加,很可能是密钥被泄露或者客户端签名算法实现有bug。这些指标都需要接入公司的监控告警系统,设置合理的阈值,一旦触发告警立即排查处理。

性能优化与压测验证

防重放机制给每个请求增加了Redis网络调用和HMAC计算的开销。在普通服务器上,一次Redis的SETNX操作延迟大约在0.5到2毫秒之间,一次HMAC-SHA256计算延迟在微秒级别。整体下来,防重放拦截器给每个请求增加的开销在1到3毫秒左右。对于绝大多数业务系统来说,这个开销完全可以接受。但如果你的系统需要支撑每秒数万甚至数十万的请求量,就需要考虑优化方案。可以把随机数判重从同步操作改为异步操作,先放行请求处理业务逻辑,异步写入Redis,同时用布隆过滤器做快速判重。压测时重点观察Redis的连接池是否够用,以及在高并发下是否出现了随机数判重的误判情况。

防重放攻击的防护是一个系统工程,代码实现只是其中的一个环节,密钥管理、监控告警、客户端配合、性能压测缺一不可。把上面这套方案完整落地,你的后端接口就能有效抵御重放攻击的威胁。