资讯详情

重放攻击防御实战:时间戳+Nonce+业务幂等三件套

📅 2026/9/16 0:11:27 | 华诺云谱 👁 阅读
重放攻击防御实战:时间戳+Nonce+业务幂等三件套
1. 那笔凌晨三点的异常订单一个典型重放攻击现场1.1 事故现象与第一轮排查事情发生在一个不起眼的凌晨。我的一个独立小项目上线不到两周主要提供在线预约和轻量支付功能用户量不大平时日志里全是健康检查的探活记录。那天早上起来习惯性翻了一下数据库里的订单表发现凌晨 3 点 17 分到 3 点 22 分之间同一个用户 ID 连续提交了 7 笔相同金额、相同服务项目的订单状态全部是“已支付”。第一反应是用户手滑或者网络抖动导致重复提交但仔细看支付渠道的回调记录7 笔订单每一笔都拿到了真实的支付成功通知钱也确实从测试账户里扣了。如果只是前端按钮没做防抖最多产生一两笔重复单不可能在 5 分钟内连续发起 7 次完整的支付流程。更可疑的是这 7 笔请求的 User-Agent 完全一致但 IP 地址分布在三个不同的省份。我当时的排查顺序是这样的先看了 Nginx 访问日志确认这些请求的路径集中在/api/order/create和/api/pay/callback两个接口上然后看了应用日志发现每个请求都携带了一个看起来一模一样的签名字段最后把请求体原样复制出来对比才发现问题——7 个请求的 body 字节级完全相同连时间戳字段都分毫不差。这里插一句很多小项目在最初搭建时为了调试方便会把接口签名校验做成“可选开关”我这套也不例外。代码里留着IgnoreSign注解本意是给内部联调用的结果上线时忘记关掉强制校验等于给攻击者留了一扇不用钥匙就能推开的门。这个背景对后面的修复方案影响很大后面会详细说。1.2 真相浮出水面重放攻击的本质字节级相同的请求、多个来源 IP、短时间内高频触发这三个特征凑到一起基本可以断定是重放攻击Replay Attack。攻击者通过抓包或中间人手段获取了一次合法的请求报文然后在不需要破解任何密钥的情况下把这份报文原样重复发给服务器。重放攻击最阴险的地方在于它根本不关心你的加密算法有多强。哪怕你用 RSA、AES 对请求做了全量加密只要服务器解密后直接执行业务逻辑攻击者照样可以把加密前的密文整体重放一遍。用一句话概括——加密保证的是“别人看不懂”重放攻击利用的却是“你看懂了也没用因为这是同一个请求”。从安全模型的角度看一次完整的请求信任链包含三个要素身份可信、内容完整、时效唯一。前两项通常由 Token 和签名解决可第三项“时效唯一”经常被忽略。重放攻击攻击的就是这个缺口请求的身份是真的内容是完整的但它不是此时此刻应当被执行的请求。搞清楚原理后我立刻做了三件事先把可疑 IP 加入封禁列表然后在网关层临时开启全量签名校验最后把订单支付回调接口改成“收到通知后先查订单状态已支付则直接返回成功不再重复改库”。这几步属于止血措施真正彻底的修复在下面几节里展开。2. 为什么小项目更容易挨打重放攻击的底层逻辑2.1 重放攻击与普通越权的区别很多开发者会把重放攻击和越权漏洞混为一谈实际上它们是两种完全不同的攻击模式。越权利用的是“服务器没有校验请求者是否有权限执行此操作”比如普通用户直接调用管理员接口而重放攻击利用的是“服务器无法区分当前请求是不是第一次到达”。用一个生活化的例子说明你拿着自家门禁卡刷开单元门这是正常访问有人偷拍了你刷卡的视频然后在门禁上反复播放这段视频门开了——这就是重放攻击。在这个例子里门禁系统验证了“卡号有效”也验证了“刷卡动作成功”但它没有验证“这一次刷卡是不是刚刚发生的那一次”。小项目容易中招的原因往往不是技术栈落后而是缺少一个全局的安全视角。大厂的安全团队会专门做威胁建模把每个接口按“幂等性要求”和“时效性要求”打标分类而小项目通常是一个人全干能稳定跑通业务就不错了。再加上很多项目直接暴露公网 IP、没有 API 网关、HTTPS 证书配置得过且过抓包成本极低攻击者甚至不需要专业工具用 Postman 就能完成一次重放。2.2 请求生命周期中的可重放环节想真正防住重放得先看清一个请求从客户端发出到服务端落库中间哪些环节存在可重放风险。我梳理了一下至少有三个阶段第一是传输阶段。如果客户端和服务端之间没有做双向认证攻击者可以在网络链路上截获明文请求。即使有 HTTPS如果客户端被植入恶意证书或者服务端错误配置了 TLS 终止代理请求体照样可能被还原出来。我这次遭遇的攻击攻击者大概率就是通过抓包拿到了完整请求。第二是服务端接收阶段。这个阶段最容易被忽略——服务器收到请求后如果先去查 Redis 缓存或数据库再决定是否处理那么缓存查询本身也可能被重放利用。举个极端例子一个接口设计成“查询用户余额若余额充足则扣款”攻击者重放这个请求每次服务端都会执行一次“查询扣款”即使加了事务也会重复扣多次。第三是业务回调阶段。支付、短信、邮件这类第三方回调天然存在重放风险因为第三方为了保证消息送达本身就会重试发送。我这次就是栽在这里没有对支付回调做幂等处理导致每一笔重复的通知都触发了状态流转。下面这张表可以帮你快速判断项目里哪些接口最需要优先防护接口类型典型场景重放后果防护优先级支付/退款创建订单、回调通知资金损失P0用户操作修改密码、绑定手机账户被盗P0数据写入发帖、评论、上传垃圾数据P1查询类获取详情、列表信息泄露、资源浪费P23. 我的修复方案时效戳、随机数和业务幂等三件套3.1 方案一服务端时间戳窗口校验第一道防线是给每个请求加上时间戳并且服务端只接受“当前时间前后一定窗口内”的请求。我用的是 5 分钟的滑动窗口也就是说如果客户端生成请求的时间和服务端接收时间相差超过 5 分钟直接拒绝。具体实现上客户端在请求头加一个X-Timestamp字段内容是当前时间的 Unix 毫秒时间戳服务端在拦截器里取System.currentTimeMillis()做差值计算long ts Long.parseLong(request.getHeader(X-Timestamp)); long now System.currentTimeMillis(); long window 5 * 60 * 1000L; if (Math.abs(now - ts) window) { throw new InvalidRequestException(request expired); }这里有个容易踩坑的细节客户端和服务端的时间可能不同步尤其是跨时区部署、或服务器走了虚拟机时钟漂移的情况下直接用Math.abs做绝对差值会误杀正常请求。稳妥的做法是允许轻微的时钟偏差比如把窗口放宽到 5 分钟同时在部署层面用 NTP 保证服务器时间尽量准确。窗口校验的价值在于它强制规定了请求的“保鲜期”让攻击者截获的报文无法被无限期重放。但要注意窗口只是一个宽进宽出的门槛它不能防止“5 分钟内被重放 7 次”这种情况所以我们还需要第二道防线。3.2 方案二Nonce 随机数配合 Redis 去重第二道防线是引入 NonceNumber used once一次性随机数。客户端每次请求时生成一个全局唯一的随机字符串放进请求头X-Nonce服务端收到请求后先检查这个 Nonce 是否已经在 Redis 里存在存在则说明是重放请求直接拒绝不存在则写入 Redis 并设置过期时间过期时间和时间戳窗口保持一致。之所以用 Redis 而不用数据库表是因为 Nonce 去重属于高频读写在内存里做最合适如果用数据库表每次请求都要多一次磁盘 I/O在高并发场景下会成为瓶颈。我用 Redis 的SET命令加NX参数实现原子操作String nonceKey nonce: request.getHeader(X-Nonce); boolean firstSeen redisTemplate.opsForValue() .setIfAbsent(nonceKey, 1, 5, TimeUnit.MINUTES); if (!firstSeen) { throw new InvalidRequestException(duplicate request); }实现上还有两个关键点。第一Nonce 必须做到全局唯一建议直接用 UUID 或SecureRandom生成高强度随机数不要用时间戳加用户 ID 拼接否则容易被预测。第二Nonce 检查必须放在业务逻辑之前最好放在拦截器里和签名校验一起做因为一旦进入业务代码就存在“先查出订单状态再被拦截”的竞态窗口。如果担心同一个 Nonce 被并发打到服务器setIfAbsent的原子性可以保证只有一个请求能插入成功其他并发请求会拿到false被拒绝不需要额外加分布式锁。3.3 方案三业务层幂等设计兜底时间戳和 Nonce 都属于传输层防御理论上只要攻击者篡改请求头这两层都可能被绕过。所以真正的兜底方案必须下沉到业务层让关键接口天然具备幂等性——同一个请求执行一次和执行一百次最终结果都一样。具体到我的项目订单创建接口的幂等键是userId serviceItemId date uniqueToken这个组合在一个小时内有唯一索引约束。用户重复提交时第二次插入会被数据库唯一索引拦住返回“订单已存在”。支付回调接口则改成“先查订单状态若是已支付状态直接返回成功响应但不触发任何状态流转”。幂等设计的核心不是某个具体技术而是对业务操作的重新建模你要明确哪些操作是“状态转移”型比如从待支付变成已支付哪些是“生成结果”型比如创建订单然后为每个类型的操作设计一个业务主键。只要业务主键命中缓存或数据库里的存档就直接复用上次结果。还有一点容易被忽略幂等不能只做“读一下再决定”万一两个并发请求同时读到同一个状态都会走更新逻辑那就破功了。正确做法是直接用数据库的唯一索引约束做最终裁决或者在更新语句里带上前置状态条件UPDATE ... WHERE status PENDING让数据库替你把关。3.4 三件套的配合关系这三层方案不是互斥选择而是层层递进的关系。时间戳窗口过滤掉大部分“过期报文”Nonce 去重解决“窗口内的重复提交”业务幂等兜底处理所有漏网之鱼。用一套完整的处理链路来解释请求到达网关先校验签名和 Token身份不对直接拒绝。再校验X-Timestamp时间偏差大于 5 分钟拒绝。然后校验X-NonceRedis 中已存在拒绝。通过三层校验后进入业务层数据库唯一索引和状态条件再次把关。这三层每层拦截的东西不一样别指望靠一层挡住所有攻击。第一层挡的是“懒惰的攻击者”第二层挡的是“有点耐心的攻击者”第三层挡的是“已经深入业务逻辑的攻击者”。4. 具体落地这套机制在项目里是怎么改的4.1 改造点一HTTP 拦截器统一加签名头我用的后端是 Spring Boot所以最自然的做法是写一个HandlerInterceptor在preHandle方法里统一处理时间戳和 Nonce 校验。Interceptor 的优先级要设置好建议在 Spring Security 过滤器链之后执行这样能保证未登录请求提前被安全框架拦掉不会走到业务接口。核心代码如下Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { if (handler instanceof HandlerMethod) { HandlerMethod hm (HandlerMethod) handler; IgnoreSecurity ignore hm.getMethodAnnotation(IgnoreSecurity.class); if (ignore ! null) { return true; } } validateTimestamp(request); validateNonce(request); validateSignature(request); return true; }这里最有价值的改动不是代码本身而是我彻底清理了IgnoreSign相关的分支逻辑。以前为了方便调试什么都放行现在改成白名单机制只有注册了IgnoreSecurity注解的接口比如健康检查才会跳过校验其余所有接口一视同仁。改造客户端时也要同步处理生成 Nonce 的逻辑不能写在业务代码里而要封装成统一的 HTTP 工具类每次请求自动带上X-Timestamp和X-Nonce。否则前端开发换了个人、忘记了加请求头线上立刻就会报错。4.2 改造点二Redis 缓存 Nonce 与滑动窗口Nonce 的 Redis Key 设计我考虑了很久。如果只按 Nonce 本身做 Key存在两个问题不同用户之间的 Nonce 可能冲突攻击者把同一个请求从多个账号发起时服务器无法关联识别。所以我把 Key 设计成nonce:{userId}:{nonceString}其中userId从登录 Token 中解析。这样即使两个用户恰好生成了相同的随机字符串概率极低也不会互相影响。同时还会额外记录一层replay:{timestampBucket}:{userId}用于做滑动窗口维度的统计方便告警。滑动窗口计数器用 Redis 的INCR加EXPIRE实现String counterKey replay: (now / 60000) : userId; Long count redisTemplate.opsForValue().increment(counterKey); if (count ! null count 10) { // 同一用户一分钟内请求超过 10 次触发告警或临时封禁 }这只是一种简易限流不是真正的滑动窗口算法但对于小项目来说完全够用。真要上精确滑动窗口的话可以用 ZSET 存储最近一分钟内每个请求的时间戳每次先用ZREMRANGEBYSCORE清理窗口外的旧记录再ZCARD统计当前窗口请求数。4.3 改造点三支付回调与消息队列的幂等处理支付回调的幂等改造是这次修复中最痛苦也最有收获的部分。原来支付平台把回调通知发过来我直接更新订单状态、加余额、发站内信一气呵成。第一次被重放后余额被加了 7 次站内信发了 7 封。改成幂等后的逻辑可以概括为四步收到回调检查请求头里的通知 ID用 Redis 记录“本次通知已处理”。查询订单当前状态如果是PAID直接返回成功响应给支付平台。如果是PENDING用乐观锁更新状态为PAID只有一条 SQL 会影响 1 行。状态更新成功后再执行发站内信等后置动作。UPDATE orders SET status PAID, paid_at NOW() WHERE order_no #{orderNo} AND status PENDING这条 UPDATE 返回的影响行数是 0 或 1如果为 0 就说明有其他请求抢先更新了当前请求直接按“已处理”返回。这种用数据库条件更新代替先查询再更新的方式能有效避免并发下的重复执行问题。如果项目里用了消息队列同样要注意消费端的幂等。比如支付成功后发送“下单成功短信”这个消息消费者收到重复消息是很常见的尤其是 MQ 的“至少一次”投递语义所以消费者里也要用 Redis 记录消息唯一 ID而不是盲目相信RabbitListener自带的去重。5. 实测结论与干扰项为什么有些“重放”不用管5.1 测试数据与压测表现修复完成后我做了一组针对性测试用 JMeter 录制了一个真实订单创建请求禁用 JMeter 的“自动更新时间戳”功能然后设置 10 并发线程、每个线程循环 50 次全部重放同一个报文。预期是只有第一轮最先到达的请求能成功其余全部返回400 duplicate request。实测结果是499 个请求被拦截1 个请求成功创建订单。这符合预期因为第一个 Nonce 被 Redis 记录后后续 499 个请求全被setIfAbsent挡下。然后我又把请求头里的 Nonce 改掉、时间戳改成当前时间重新压测这时所有请求都能正常通过签名校验——这说明防御逻辑只拦截“重复”不拦截“正常的新请求”。压测数据也确认了性能没有明显回退。加入 Nonce 校验后接口平均响应时间从 32ms 上升到 38ms上升的 6ms 主要是 Redis 网络往返开销。在业务本身包含数据库写入的情况下这个损耗完全可接受。如果对性能更敏感可以考虑用本地 Caffeine 缓存做一级 Nonce 缓存Redis 做二级命中本地缓存就不查 Redis。5.2 需要区分的三类“重放”修完以后我发现一个问题不是所有看起来像重放的请求都需要拦截过度防御反而会影响正常业务。至少要区分以下三类情况第一类是真正的恶意重放。特征很明显请求头、请求体、签名完全一致短时间内高频出现来源 IP 分散或集中在异常网段。这类必须拦截并告警。第二类是客户端网络重试。手机在弱网环境下客户端框架会自动重发请求虽然报文可能完全相同但这是正常业务行为。此时不应该让用户看到“duplicate request”错误而应该让接口保持幂等返回上一次成功的结果。所以我在拦截器里做了个调整Nonce 重复时不再直接抛异常而是用 Redis 缓存上一次的响应体直接返回“重复请求但这是你的历史响应”。第三类是第三方回调重试。支付、短信平台为了保证消息必达会按固定时间间隔重试回调甚至可能在网络闪断后重复推送。这类重放也不能简单拒绝必须走幂等流程把重复回调当成“通知已收到”来处理。场景是否拦截推荐策略恶意重放是拒绝并告警封禁 IP客户端网络重试否返回缓存的历史响应第三方回调重试否幂等处理返回成功确认6. 遗留问题与防御纵深把安全水位再抬高一点时间戳、Nonce、幂等三件套解决的是“同一个请求别重复执行”的问题但它挡不住另一种攻击变种攻击者篡改请求里的业务参数生成一个新的合法请求。比如把订单金额从 100 改成 0.01再重新签名——如果你的签名算法不够健壮或者密钥已经泄露这种篡改请求依然能穿透防线。所以在三件套之外我还做了几项纵深防御。第一签名算法从简单的 MD5 升级为 HMAC-SHA256密钥定期轮换并且客户端不再直接持有密钥而是通过密钥服务动态获取。第二把敏感字段金额、用户 ID、订单号放进签名原文服务端验签时逐字段比对防止中间人篡改而不自知。第三在网关层增加了针对单个用户、单个 IP 的请求速率限制让暴力重放在窗口期内的成功率进一步降低。监控告警这块也不能省。我加了一个定时任务每 30 秒扫描一次 Redis 中的replay:*计数器如果某个用户一分钟内触发的拦截次数超过阈值就自动推送告警到企业微信。这能让我在攻击者造成实际影响前就发现问题而不是像这次一样看到异常订单才后知后觉。还有一个遗留问题需要交代Nonce 在 Redis 中的过期时间应该设置多长设置太短攻击者可以在时间窗口内反复重放设置太长Redis 内存里堆积大量无用键。我的建议是过期时间 服务端接受的最大时间偏差 × 2 业务最大执行时间这样能确保边界情况不会产生漏洞。按照这个公式我的 5 分钟偏差对应 10 到 15 分钟过期时间实测下来内存消耗可以忽略不计。最后想说的是重放攻击的防护没有“一劳永逸”的银弹每次架构演进、每次新增接口时都要重新审视这个接口的幂等键是什么它的时效性要求是什么攻击者如果拿到报文能造成什么后果带着这三个问题去设计接口比事后打补丁要省心得多。
📝

华诺云谱内容团队

资深建站顾问 · 行业研究员

10年+企业数字化服务经验,专注智能建站、SEO优化与品牌营销,持续输出建站技巧、行业洞察与营销干货,已帮助5000+企业实现数字化增长。

你可能需要的服务

订阅华诺云谱资讯周报

每周一封,精选建站技巧、SEO与营销干货,直达邮箱。已有 8,000+ 企业主订阅,助你少走弯路。