首页 / 资讯中心 / 文章详情

防重放攻击实战:时间戳、Nonce与接口安全设计

防重放攻击实战:时间戳、Nonce与接口安全设计 ★ FEATURED ARTICLE
1. 从一条看起来完全合法的请求日志说起去年帮朋友看一个抓取问题接口日志里有一批请求签名对、时间戳新、参数合法、UA 正常风控规则一条都没命中但数据库里的记录被重复写了几十遍。第一反应是并发问题加了分布式锁没用怀疑是重试机制翻了客户端代码也没有。最后把原始报文抓出来一条条比对才发现问题所在——攻击方把一条抓到的合法请求原封不动地重发了上百次每次只改一个无关紧要的空字段。签名依然成立因为它本来就是一个真实合法的请求。这就是防重放要解决的核心问题请求是真的签名也是真的但发一次和发一百次之间的区别服务端没看见。在反爬虫和数据安全的语境下重放是一种成本极低、收益却不低的攻击手段——不需要逆向你的加密算法不需要破解你的签名密钥只需要一个抓包工具和一个循环脚本。很多团队把精力全砸在参数加密、JS 混淆、设备指纹上却忽略了最基础的一环同一条请求只应该生效一次。这篇内容面向的是做接口安全、反爬虫对抗、网关中间件、开放平台 API 设计的同学从零基础到有一定实战经验都能看。我会把重放攻击的完整链路拆开讲清楚四种主流防重放方案的取舍逻辑、参数怎么算、代码怎么写、上线后会踩哪些坑。文中涉及的业务场景和项目均为虚构代称代码是按真实工程实践整理的参考实现可以直接拿去改。先把核心关键词摆出来防重放Anti-Replay本质是让请求具备**时效性Timeliness和唯一性Uniqueness**两个属性。时效性解决这条请求是三天前抓的今天还能用的问题唯一性解决同一条请求发两次的问题。这两个属性只要缺一个防重放就是纸糊的。2. 重放攻击的完整链路拆解它到底怎么发生的2.1 三段式流程抓、存、重发绝大多数重放攻击的实现路径非常朴素可以归结为三步。第一步是捕获攻击方在客户端侧拿到一条完整的、能通过服务端校验的请求可能是通过逆向拿到签名算法后自行构造也可能是通过中间人手段直接截获原始报文。第二步是冻结把这条请求的 method、path、query、body、header 全部原样保存下来一个字都不改。第三步是重发用脚本或工具把这条报文循环投递频率可能很低比如每分钟一次规避频控也可能很高比如一秒几百次直接刷库。关键点在于这三步里没有任何一步需要理解你的业务逻辑。攻击方不需要知道这个接口是查询订单还是提交表单不需要知道参数 biz_id 是什么含义只要能通过校验重复投递就有收益。收益形式很多——重复下单刷优惠券、重复提交表单刷积分、重复调用付费接口消耗对方额度、重复查询把数据批量拉走。注意很多人把重放和重复提交混为一谈。前端按钮防抖、表单 token 这些只是防误触攻击方根本不走你的前端直接在协议层构造请求前端防护一概无效。防重放必须做在服务端。2.2 三类最容易被重放的请求不是所有接口都值得防重放把防护无差别铺到全站会带来不必要的性能和复杂度成本。按经验下面三类接口风险最高应该优先覆盖。第一类是有副作用的写接口。包括下单、支付、提交表单、领取奖励、发送消息、创建任务。这类接口被重放一次就是一次真实的业务动作直接造成资损或数据污染。它们的特征是幂等性天然缺失同样的参数执行两次结果不一样。第二类是高成本的查询接口。比如需要跨多个数据源聚合、需要调用第三方计费服务、需要跑复杂 SQL 的查询。攻击方重放这类接口的动机不是改数据而是用一条合法请求反复榨取数据本质是把你的接口当成免费的爬虫通道。第三类是带身份凭证的敏感接口。比如导出、下载、鉴权换取、会话续期。这类接口的请求体里往往携带了某种凭证重放等于凭证的无限复用。反过来纯静态资源、无状态的公开列表页、GET 语义的幂等查询通常不需要上重放防护上了反而增加延迟。判断标准很简单同一条请求执行 N 次和执行 1 次结果是否一致。如果不一致就该防。2.3 加了签名为什么还是挡不住这是我在实际排查中遇到最多的认知误区。很多团队认为我做了 HMAC 签名参数被篡改就会被发现所以安全了。这个推论漏了一环签名防的是篡改不防复制。签名算法的输入通常是 method、path、排序后的参数、body 摘要、密钥。只要这条请求的输入完全不变算出来的签名就不变服务端校验自然通过。攻击方拿到一条带合法签名的报文直接复制粘贴签名依旧成立。这就是签名机制在面对重放时的天然盲区。要补上这个盲区必须往签名输入里注入每次都变化的因子。业界通用的做法有两个一是时间戳让签名带上时效过期就废二是随机数Nonce让每条请求的签名唯一服务端记录已用过的值。这两个因子就是防重放体系的地基后面所有方案都是在这两个地基上盖房子。3. 四种主流防重放方案的取舍逻辑3.1 方案一时间戳加签名窗口最轻量的方案。客户端在请求里带上当前时间戳ts服务端收到后计算|now - ts|超过预设窗口常见 300 秒、60 秒甚至 30 秒就直接拒绝。同时把ts纳入签名计算防止攻击方篡改时间戳。这个方案的优点非常明显零存储。服务端不需要记录任何东西纯计算对性能几乎无影响横向扩展毫无压力多台机器之间也不需要共享状态。实现成本极低二十行代码就能落地。缺点也同样明显窗口内的重放挡不住。如果窗口是 300 秒攻击方在这 5 分钟内把同一条请求重发一万次全部合法。它只压缩了攻击的时间窗口没有消灭重放的可能性。对于低频敏感接口比如支付、提现、改密这个窗口可能够用对于高频接口基本等于没防。3.2 方案二Nonce 一次性随机数在时间戳基础上加一个客户端生成的随机字符串nonce服务端维护一个已使用 nonce 集合收到请求先查这个集合存在则判定为重放不存在则写入并放行同时给集合里的记录设置过期时间通常等于或略大于时间戳窗口。这个方案能真正挡住窗口内的重放是目前开放平台 API 采用最广的做法。关键是nonce必须参与签名否则攻击方可以随手改 nonce 绕过检测。代价是引入状态存储。每一次请求都要做一次查 写存储层的 QPS 压力直接等于接口 QPS。这就带来一系列工程问题用什么存、怎么保证原子性、过期怎么设、多机房怎么同步、存储挂了怎么办。第 5 节会详细算这笔账。3.3 方案三单调递增序列号客户端维护一个只增不减的计数器seq每条请求带上当前值并加一。服务端记录每个客户端或每个会话处理过的最大seq新请求的seq必须严格大于记录值才放行否则拒绝。这个方案的优势是存储占用极小——每个客户端只需要存一个整数不需要存整个 nonce 集合。对于设备数量可控、连接相对稳定的场景比如长连接、客户端 SDK非常合适。它还能顺带解决乱序问题天然的防重放 防乱序。局限在于它对客户端的状态管理要求高。客户端必须持久化 seq一旦本地数据被清空或回滚seq 会重置服务端就会把后续请求全部拒掉。多端登录、同一账号多设备并发时seq 的归属也会变得复杂。另外它无法防止攻击方拿到 seq 后抢先发一次这种竞争型重放。3.4 方案四服务端下发一次性 Token流程反过来客户端先调一个专门的接口申请 token服务端生成随机串写入存储并返回客户端携带该 token 发起业务请求服务端校验 token 存在则消费掉删除不存在则拒绝。典型的一次性票据模型和表单 token 是同一个思路。它的安全性是四种里最高的因为 token 由服务端生成、服务端消费客户端无法伪造也无法预知天然具备一次性。适合对安全性要求极高的场景比如资金类操作、关键配置变更。缺点是多了一次网络往返而且 token 的申请接口本身也可能被刷。所以通常要配合一次申请、批量使用或者申请接口单独限流来平衡。另外在移动端弱网环境下token 申请失败会导致业务请求发不出去需要设计重试和降级策略。3.5 四方案横向对比与选型建议维度时间戳窗口Nonce 集合递增序列号一次性 Token是否需要存储不需要需要集合需要单值需要集合存储压力无高等于接口 QPS低等于客户端数中窗口内重放防护无效有效有效有效客户端改造成本低中高中高多端并发友好度高高低高额外网络往返无无无有典型适用场景内部服务、低频接口开放平台 API长连接、SDK资金、关键操作我在实际项目里的做法是分层组合不做单点选择。所有接口默认走时间戳窗口作为兜底成本几乎为零写接口和敏感接口叠加 Nonce 校验资金类操作再叠加一次性 Token。这样既保证了整体覆盖率又把存储开销控制在真正需要的地方。全部接口都上 Nonce 是典型的过度设计——你会发现存储账单涨得比安全收益快得多。4. 从零落地一套防重放链路的完整实操4.1 签名规则的字段设计先定签名输入。一个能同时防篡改和防重放的签名串至少要包含下面这些要素用固定分隔符拼接后做 HMAC-SHA256待签名串 method \n path \n sorted_query_string \n md5(body) \n timestamp \n nonce \n app_key几个细节必须强调。第一query 参数必须按 key 字典序排序后再拼接否则客户端和服务端的顺序不一致签名对不上。第二body 要用摘要而不是原文避免大 body 拖慢签名计算同时统一编码问题。第三分隔符要固定用\n比用好因为本身会出现在 query 里容易造成拼接歧义这个坑我踩过两个不同参数能拼出同一个字符串。第四app_key 放最后不参与实际运算只用于服务端定位密钥。时间戳统一用秒级还是毫秒级要提前约定死。我建议用秒级字符串长度短且 5 分钟窗口用秒级精度完全够。毫秒级看着精确实际只会让时钟偏移问题更敏感。4.2 服务端校验中间件的参考实现下面这段是 Python Redis 的参考实现思路可以直接平移到任何语言。核心逻辑是先验时效再验签名最后验 nonce 唯一性三步顺序不能乱。import time import hashlib import hmac import redis REPLAY_WINDOW 300 # 时间戳容忍窗口单位秒 NONCE_TTL REPLAY_WINDOW 60 # nonce 记录多留 60 秒余量 rds redis.Redis(host127.0.0.1, port6379, decode_responsesTrue) def build_sign_string(method, path, query_dict, body_bytes, ts, nonce, app_key): sorted_qs .join(f{k}{query_dict[k]} for k in sorted(query_dict)) body_md5 hashlib.md5(body_bytes or b).hexdigest() raw \n.join([method.upper(), path, sorted_qs, body_md5, str(ts), nonce, app_key]) return raw def verify_request(method, path, query_dict, body_bytes, headers, secret): ts headers.get(X-Ts, ) nonce headers.get(X-Nonce, ) sign headers.get(X-Sign, ) if not ts or not nonce or not sign: return False, missing_auth_header # 1. 时效校验先挡掉过期和历史请求成本最低 try: ts_int int(ts) except ValueError: return False, bad_timestamp if abs(int(time.time()) - ts_int) REPLAY_WINDOW: return False, timestamp_expired # 2. nonce 格式校验长度限制可以防住超大 key 攻击 if not (8 len(nonce) 64): return False, bad_nonce # 3. 签名校验用 compare_digest 防时序攻击 raw build_sign_string(method, path, query_dict, body_bytes, ts, nonce, headers.get(X-App-Key, )) expect hmac.new(secret.encode(), raw.encode(), hashlib.sha256).hexdigest() if not hmac.compare_digest(expect, sign): return False, sign_mismatch # 4. nonce 唯一性校验SET NX 保证原子性一次操作完成查写 key freplay:nonce:{headers.get(X-App-Key,)}:{nonce} if not rds.set(key, 1, nxTrue, exNONCE_TTL): return False, replay_detected return True, ok这段代码里有两个地方值得单独说。set(nxTrue, exNONCE_TTL)是整个方案的核心。很多人写这段会先exists再set这是典型的 check-then-act 竞态两个并发请求同时 exists 都返回 false然后都 set 成功重放就漏过去了。Redis 的 SET NX EX 是单条命令原子执行天然解决这个问题。如果用的是别的存储一定要确认有没有等价的原子操作没有就得靠分布式锁性能和复杂度都会上一个台阶。hmac.compare_digest不能省。普通的比较在遇到第一个不同字符时就返回理论上可以通过响应时间差异逐字节猜出正确签名。虽然实际利用难度高但改一个函数名就能规避的风险没有理由留着。4.3 存储层选型与过期时间的计算存储选型直接决定这套方案的吞吐上限。我按实际用过的三种做个对比存储单机 QPS 量级过期能力多机房适用规模本地内存如进程内字典极高需自己实现不支持单机、测试环境Redis万到十万级原生 TTL支持主从/集群绝大多数生产场景分布式 KV如各类自研存储十万级以上视实现支持超大规模、多活本地内存方案在多机部署下会直接失效——请求被负载均衡打散到不同机器A 机记录过的 nonce 在 B 机上不存在重放照样通过。除非做会话粘滞但这会破坏负载均衡的弹性。所以生产环境基本只能用共享存储。过期时间的计算逻辑要讲清楚这里最容易设错。TTL 的取值必须满足一个不等式NONCE_TTL 时间戳窗口 网络传输最大延迟 时钟偏移容忍度举个例子窗口设 300 秒弱网环境下请求从发出到抵达服务端最长可能耗时 10 秒客户端和服务端时钟可能偏差 30 秒未对时的情况下。那么 TTL 至少应该是300 10 30 340秒取整设 360 秒比较稳妥。为什么 TTL 不能小于窗口假设窗口 300 秒、TTL 只有 100 秒那么一条 200 秒前发出的请求它的 nonce 记录已经过期被清了但时间戳还在窗口内 —— 这条请求可以再被重放一次。TTL 小于窗口等于给攻击方开了一个循环重放的口子每 100 秒就能重放一次看起来每次都新鲜。为什么 TTL 也不宜过大因为存储占用和 TTL 成正比。下一节会给出具体估算。4.4 时钟偏移的工程处理时间戳方案对客户端时钟高度敏感。用户手机时间被手动改快 10 分钟所有请求全部被拒客诉立刻上来。这个问题在真实环境里比想象中普遍。处理思路有三层。第一层是服务端返回标准时间在任意接口的响应头里带上X-Server-Time客户端 SDK 首次请求时记录下本地时间与服务端时间的差值后续签名用本地时间 差值把绝对时钟转成相对时钟。第二层是窗口放宽允许一定的负向偏移比如窗口设为[-60, 300]容忍客户端慢一分钟以内。第三层是错误码区分时间戳过期返回专门的错误码SDK 收到后自动做一次时间同步再重试而不是直接抛异常给用户。提示时间戳窗口不对称设置是个很实用的小技巧。客户端时间偏慢请求看起来来自过去是正常网络延迟的表现可以放宽客户端时间偏快请求看起来来自未来几乎没有合理场景应该收紧甚至完全不放。5. 性能开销与容量估算这笔账必须提前算5.1 Nonce 存储的内存估算方法很多人上线前不估算上线后发现 Redis 内存暴涨才开始删数据。这里给一套可以直接套用的算法。单条 nonce 记录的占用大致包括key 字符串本身app_key nonce假设平均 60 字节、value1 字节、Redis 内部对象开销约 50 到 80 字节、过期字典条目约 30 字节。合计按150 字节/条估算比较保守。需要的记录数 峰值 QPS × NONCE_TTL。假设某业务峰值 QPS 是 20000TTL 设 360 秒那么同时在存的记录数约为20000 × 360 720 万条内存占用约720万 × 150B ≈ 1.03 GB。这个数字对 Redis 集群来说完全可承受但要注意两点一是峰值 QPS 要按接口维度算不是全站 QPS只给写接口上 nonce 校验的话量级会小一个数量级二是要给 Redis 留出内存碎片和主从复制的冗余实际申请的容量至少是估算值的 1.5 到 2 倍。如果算下来内存超预算有几个优化方向缩短 TTL同时缩短时间戳窗口、把 nonce 长度压到 16 字节以内、对低频接口不开 nonce 校验、用 key 前缀做分片把压力摊到多个实例。5.2 高并发下的原子性与失败降级SET NX EX解决了原子性但还有一个问题没人愿意面对存储挂了怎么办。这个决策必须在上线前定好而且要想清楚两个方向的代价。Fail-Open存储不可用时放行保证了业务可用性但在这段时间内防重放完全失效如果存储故障恰好被攻击方感知到就是一次集中打击窗口。Fail-Close存储不可用时拒绝安全性拉满但存储抖一下全站写接口全部报错业务直接躺下。我的选择一般是分接口定策略普通业务接口 Fail-Open 并打告警资金和敏感接口 Fail-Close。同时给存储层加本地缓存做二级兜底——在进程内维护一个带 TTL 的小型集合存储不可用时退化到本地判断虽然多机场景下有漏判可能但比完全放开强。这个本地缓存容量要设上限比如 10 万条超出后按 LRU 淘汰防止内存被打爆。还有一个容易被忽略的点重试风暴。存储抖动时大量请求失败客户端 SDK 如果自动重试会在存储恢复的瞬间涌入数倍流量直接把存储再打挂一次。所以 SDK 的重试必须带指数退避和随机抖动服务端也要对同一 app_key 的重试做一层限流。5.3 一次完整请求的耗时拆解以 Nonce 方案为例一条请求在防重放环节的额外耗时大概是时间戳解析与比较微秒级、MD5 与 HMAC 计算body 在 10KB 以内约 0.1 到 0.5 毫秒、一次 Redis 往返同机房约 0.5 到 1 毫秒。合计约 1 到 2 毫秒。这个开销放在一般业务接口上可以忽略但如果是高并发的网关层就需要关注。优化手段包括把 Redis 客户端连接池调够连接数不够时建连开销远大于命令执行、签名计算用更快的哈希实现、把 nonce 校验做成异步预检代价是失去强一致性。我实测下来把连接池从默认的 10 调到 200 后P99 延迟能降三成左右这个收益比优化签名算法大得多。6. 上线后最容易踩的坑与排查实录6.1 常见问题速查表现象可能原因排查动作大量请求报签名错误query 参数未排序、body 编码不一致双端各自打印待签名串逐字节比对偶发时间戳过期客户端时钟漂移、窗口过窄检查服务端时钟同步、加 X-Server-Time重放判定误伤正常用户TTL 小于窗口、多机房数据不同步核对 TTL 与窗口关系、检查存储拓扑存储内存持续上涨TTL 未设置成功、key 未带前缀抽样查 TTL、确认 set 时 ex 参数生效用户双击按钮报重放前端未做防抖、SDK 未复用 nonce前端按钮置灰、SDK 层保证请求级唯一高峰期接口超时率上升Redis 连接池不足、单 key 热点扩容连接池、检查是否有超大 key6.2 几个只有踩过才知道的细节第一个坑GET 请求的 query 参数里带数组。当参数是ids1ids2ids3这种形式时不同语言的解析器处理方式不一样——有的解析成列表有的只保留最后一个有的保留第一个。如果两端解析结果不同签名必然对不上。解决办法是在文档里明确约定数组参数的序列化格式比如统一用逗号拼接成字符串或者统一按 JSON 字符串传。第二个坑body 为空时 md5 的计算。空字符串的 MD5 是d41d8cd98f00b204e9800998ecf8427e但很多客户端在 body 为空时直接跳过这一步拼进去一个空串服务端算出来是那串固定值两边就不一致了。这类问题在联调阶段最容易发现上线后才发现的话排查成本极高。建议在 SDK 里对空 body 统一处理别让业务方每次自己判断。第三个坑代理层改写了请求。请求经过反向代理、网关、CDN 之后path 可能被改写比如加了前缀、去了尾部斜杠、header 可能被增删、body 可能被重新序列化。签名必须基于最终到达业务服务的那一份内容计算否则前面任何一层动一下签名就废了。我见过一个案例网关默认把 path 里的双斜杠规范化成单斜杠导致所有带双斜杠的接口签名全部失败排查了半天。第四个坑nonce 用了弱随机。有客户端图省事nonce 直接用时间戳 自增数。这在单实例下没问题但多实例并发时两个客户端可能撞出同一个 nonce后发的那个被判为重放。nonce 必须用足够熵的随机源UUID v4 或者 16 字节随机 hex 都可以长度控制在 32 字符左右比较合适。第五个坑日志里打印了完整签名。排查问题时把待签名串和签名都打到日志里方便是方便但密钥一旦泄露攻击方可以离线构造任意请求。签名相关的日志要脱敏至少要截断同时控制日志的保留周期。提示联调阶段最有效的手段是双端打印待签名串逐字节比对把两个字符串并排贴出来看差异。绝大多数签名问题看一眼就能定位比猜快一百倍。7. 防重放在整体反爬体系里的位置7.1 它不是一道墙而是一层滤网单独看防重放它挡住的只是原样重发这一种攻击形态。真实的反爬对抗是多层叠加的频控挡住高频请求、设备指纹识别异常客户端、行为分析发现机器规律、参数加密提高逆向成本、防重放消除重复利用。这几层之间是有协同关系的。举个具体例子攻击方拿到一条合法请求想批量拉数据通常会做两件事——改参数换 ID 换条件和重发同一条请求反复用。改参数会被签名拦下除非他能算出新签名重发会被防重放拦下除非他能改出有效的 nonce。签名和防重放组合起来实际上把攻击门槛从会抓包抬到了会逆向并持有密钥这个跨度非常大。反过来说如果只做频控不做防重放攻击方完全可以用极低的频率比如 10 秒一次慢慢重放永远不触发频控阈值但一天下来也能刷 8000 多次。这类低速重放是频控的盲区恰恰是防重放的主场。7.2 误杀控制与灰度上线的节奏防重放一旦上错表现是正常用户用不了杀伤面比被攻击还大。所以上线节奏必须是灰度的。我的做法是分四步走。第一步只观察不拦截服务端完整执行校验逻辑但不返回错误只把校验结果打进日志和监控指标。观察一到两周看误判率有多高、都是什么原因造成的。第二步按 app_key 或客户端版本灰度先让内部测试客户端开启强制校验确认稳定后逐步放量。第三步只对新增客户端强制老版本客户端继续走观察模式避免强制升级引发大面积故障。第四步全量所有客户端版本强制同时保留一个紧急开关出问题能一键切回观察模式。这四步走下来通常要一个月以上急不得。中间最关键的是第一步的观察数据——如果误判率超过千分之一说明方案设计或 SDK 实现有问题必须先修掉再往下走硬推上去就是给自己找麻烦。7.3 一个容易被忽略的收益数据质量和成本除了安全收益防重放对业务数据质量还有一层隐形价值。重复请求被拦掉之后数据库里的重复记录少了统计口径干净了下游做数据分析不用再写一大堆去重逻辑。我待过的一个项目上了防重放之后某张流水表的日均写入量直接降了 12%全是之前被重放刷出来的垃圾数据顺带把存储成本也省了一点。另外一个实际收益是接口成本可控。对于调用第三方计费服务的接口每一条重放都是一次真实的计费。之前有个场景一天被重放了几十万次对方账单直接翻倍加上防重放之后才把口子堵上。这类隐性成本在评估方案时经常被漏掉但往往比安全损失更疼。最后分享一个我自己在用的排查习惯每次遇到数据重复类的线上问题第一件事不是查并发而是把重复记录的请求 ID 或者签名捞出来看它们是同一条还是不同的。如果是同一条签名重复出现那就是重放方向立刻明确省掉一大堆弯路。这个小习惯帮我省过至少三次通宵。
阅读完成 · 觉得有帮助?
咨询建站