防重放攻击实战:从原理到前端防护策略详解

东方一茹 安全 阅读 3,006
赞 31 收藏
二维码
手机扫码查看
反馈

项目初期的技术选型

去年做了一个支付类的 H5 项目,后端是 Java,前端是 Vue3 + TypeScript。需求里有一条很关键:用户提交订单时,必须防止重复提交。一开始我们只在前端加了按钮防抖(比如点一次禁用 3 秒),但测试阶段就出问题了——有人用 Fiddler 重放请求,绕过前端直接发两次,结果创建了两个订单。

防重放攻击实战:从原理到前端防护策略详解

这时候才意识到,光靠前端根本防不住,得在服务端做防重放。技术方案调研了一圈,最后决定用 时间戳 + nonce + 签名 的组合。核心逻辑是:每个请求带一个唯一 nonce、当前时间戳,加上用密钥签名的字符串,后端验证是否在有效窗口内且 nonce 未使用过。

选这个方案主要是因为简单可控,不像 token 方案要维护状态,也不像纯时间戳容易被绕过。而且我们系统本身就有签名机制,改造成本低。

核心代码就这几行

前端这边,主要是在请求拦截器里统一加字段。我们用的是 Axios,所以写了个拦截器:

import axios from 'axios';
import CryptoJS from 'crypto-js';

const API_SECRET = 'your_secret_key'; // 实际项目中从环境变量读取

// 生成 nonce,这里用时间戳+随机数拼接,确保唯一性
function generateNonce() {
  return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}

// 生成签名
function generateSignature(nonce, timestamp, data) {
  const payload = ${nonce}|${timestamp}|${JSON.stringify(data)};
  return CryptoJS.HmacSHA256(payload, API_SECRET).toString();
}

axios.interceptors.request.use(config => {
  const nonce = generateNonce();
  const timestamp = Date.now();
  
  // 把防重放参数塞进 headers
  config.headers['X-Nonce'] = nonce;
  config.headers['X-Timestamp'] = timestamp;
  config.headers['X-Signature'] = generateSignature(nonce, timestamp, config.data || {});
  
  return config;
});

后端(Java)收到请求后,先校验时间戳是否在 ±5 分钟内(防止时钟漂移),再查 Redis 看 nonce 是否已存在。如果不存在,就存入 Redis 并设 10 分钟过期(比时间窗口稍长一点),然后处理业务;如果已存在,直接返回 400 错误。

看起来挺顺,对吧?但实际跑起来才发现坑不少。

最大的坑:性能问题

上线第一天,监控报警:Redis CPU 使用率飙升到 90%。一查日志,发现大量重复 nonce 查询。原来我们在高并发场景下,多个请求几乎同时到达,Redis 的 SETNX 操作虽然原子,但查询频次太高了。

更糟的是,我们一开始用的是 GET 判断是否存在,再 SET,这中间有 race condition。虽然概率低,但真有用户重复下单了。后来赶紧改成 Lua 脚本保证原子性:

-- check_and_set.lua
local key = KEYS[1]
local expire_time = ARGV[1]

if redis.call('EXISTS', key) == 1 then
  return 0
else
  redis.call('SET', key, '1', 'EX', expire_time)
  return 1
end

前端倒是没改,但后端调用方式变了:

// Java 伪代码
String script = loadLuaScript("check_and_set.lua");
Boolean isNew = redisTemplate.execute(
    (RedisCallback<Boolean>) conn -> {
        Object result = conn.eval(script.getBytes(), 
            new byte[][] { nonceKey.getBytes() }, 
            new byte[][] { "600".getBytes() } // 10分钟
        );
        return (Long) result == 1L;
    }
);

这样确实解决了并发问题,但 Redis 压力还是大。后来我们做了个妥协:把 nonce 的存储从 Redis 换成本地内存缓存(Caffeine),只保留最近 10 分钟的记录。因为我们的服务是多实例的,所以严格来说不能 100% 防重放,但实测重复率已经降到 0.001% 以下,业务方能接受。

这里注意我踩过好几次坑:一开始用 Guava Cache,结果 OOM 了,因为没设最大 size;后来换成 Caffeine,显式限制了 10 万条,才稳住。

另一个头疼的问题:时钟不同步

有次测试环境报错,说时间戳超限。一查,前端手机时间快了 10 分钟,后端服务器时间正常。结果请求被拒了。用户当然不会管你什么时钟同步,他们只觉得“网站坏了”。

我们试过让前端用 NTP 校准时间,但太重了,而且 iOS 限制多。最后折中方案是:把时间窗口从 ±5 分钟扩大到 ±15 分钟。虽然安全性略降,但用户体验优先。毕竟攻击者要猜中 30 分钟内的 nonce 也很难,nonce 本身还是全局唯一的。

另外,前端生成 nonce 时,我们加了兜底:如果系统时间异常(比如 1970 年),就用随机字符串代替时间戳部分,避免全站瘫痪。

最终的解决方案

折腾了两周,最终方案是:

  • 前端:Axios 拦截器自动加 nonce、timestamp、signature
  • 后端:本地内存缓存(Caffeine)存储 nonce,10 分钟过期
  • 时间窗口:±15 分钟
  • 签名算法:HMAC-SHA256,密钥定期轮换

上线后三个月,没再出现重复订单。监控显示,99.9% 的请求在 1ms 内完成 nonce 验证,Redis 压力也降下来了(因为不再依赖它)。

附上最终的前端工具函数(简化版):

// utils/antiReplay.js
import CryptoJS from 'crypto-js';

const SECRET = import.meta.env.VITE_API_SECRET;

export function attachAntiReplayHeaders(config) {
  const now = Date.now();
  // 防止系统时间异常
  if (now < 1609459200000) { // 2021-01-01
    throw new Error('System time is invalid');
  }

  const nonce = ${now.toString(36)}${Math.random().toString(36).slice(2, 8)};
  const timestamp = now;
  const dataStr = config.data ? JSON.stringify(config.data) : '';
  const signature = CryptoJS.HmacSHA256(${nonce}|${timestamp}|${dataStr}, SECRET).toString();

  config.headers = {
    ...config.headers,
    'X-Nonce': nonce,
    'X-Timestamp': timestamp,
    'X-Signature': signature
  };
  return config;
}

回顾与反思

这个方案不是最优的,但足够简单、够用。最大的教训是:别迷信“完美方案”,线上环境永远比实验室复杂。比如我们一开始想用 Redis Cluster 做全局 nonce,结果发现网络延迟反而成了瓶颈。

还有几点没完全解决:

  • 多实例部署下,极端情况(同一 nonce 同时打到不同实例)仍有极小概率重复,但业务损失可接受
  • 密钥轮换机制还没自动化,目前靠手动改配置

不过整体来看,投入产出比很高。花两天时间,解决了可能造成资损的大问题。

以上是我个人在防重放上的实战经验,有更优的实现方式欢迎评论区交流。比如你们是怎么处理多实例 nonce 共享的?或者有没有更好的时钟同步方案?这个技巧的拓展用法还有很多,后续会继续分享这类博客。

本文章不代表JZTHEME立场,仅为作者个人观点 / 研究心得 / 经验分享,旨在交流探讨,供读者参考。
发表评论

暂无评论