防重放攻击实战:从原理到前端防护策略详解
项目初期的技术选型
去年做了一个支付类的 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 共享的?或者有没有更好的时钟同步方案?这个技巧的拓展用法还有很多,后续会继续分享这类博客。

暂无评论