做反爬和数据安全这块时间长了会发现一个挺有意思的现象大部分团队在接口防护上投入的精力几乎都花在了频控、验证码、UA 校验、IP 黑名单这些“看得见”的地方而防重放这种藏在协议层的策略往往被一句“我们加了签名”带过去。真到出事的时候才发现签名只解决了“请求有没有被篡改”根本没解决“同一个请求能不能被再发一遍”。攻击者甚至不需要破解任何东西只要抓到一个合法请求原封不动地重发就能把接口当成自家数据库来用。这篇内容就围绕数据安全反爬虫之「防重放」策略展开讲清楚重放攻击在反爬场景里到底是怎么发生的、几种常见原语时间戳、随机数、序列号、一次性令牌各自的能力边界、我实际项目里怎么把它们组合成一套能落地的方案以及参数怎么算、代码怎么写、上线后怎么排查。适合已经有一定接口开发基础、正在做反爬或者准备做接口安全加固的同学参考如果你只是刚接触这块前面几节的基础拆解也能让你看懂整套逻辑。1. 先把问题想清楚重放攻击到底在打谁1.1 一个很典型的重放现场我最早真正重视这件事是在一个数据查询类接口上。当时接口做了签名校验参数改了签名就对不上团队觉得挺安全。结果某段时间监控显示某个查询接口的调用量在凌晨两点稳定上涨但 IP 分布很散、UA 也正常、单个账号的请求频率完全在阈值之内频控策略一点都没触发。后来抓了一次现场才看明白对方并没有改任何参数就是把白天抓到的合法请求包存下来用脚本原样循环发送。签名是对的、时间戳是旧的、Cookie 是有效的接口层面完全分不出这是人还是机器。更麻烦的是这类请求在业务侧看起来就是“同一个人反复查同一条数据”既没有越权也没有异常参数风控模型很难给出高分。这就是重放攻击的本质攻击者不破解你的加密也不伪造你的身份他只是把你的合法请求复制了一份再放一遍。在反爬语境下它的危害不只是多消耗一点带宽而是可以让频控、配额、计费、风控这一整套基于“请求次数”的机制全部失效——因为你的计数系统认为每一次都是合法的第一次。1.2 三种常见变体防护思路完全不同把重放拆开看实际会遇到的大致有三类每类对应的防守重点不一样。第一类是原样重放。请求的 URL、Header、Body、签名一个字节都不改直接复制发送。这类最粗暴也最好防只要请求里带一个服务端能识别“已经用过”的标记就能拦下来。麻烦在于这个标记必须和请求绑定不能是客户端随便编一个就能过。第二类是参数微调重放。攻击者会改动一些看起来“无关紧要”的字段比如页码、时间范围、排序方式试图绕过某些只校验部分参数的签名逻辑。我见过有团队做签名时只对 URL 路径和用户 ID 签名查询条件压根没进签名串结果对方换个page2就能把全量数据一页页拉走。这类攻击的防守重点是签名覆盖范围必须完整所有影响业务结果的参数都得进签名。第三类是分布式重放。同一份请求包从几十上百个出口分散发送每个出口的频次都很低。这类攻击单纯靠 IP 维度几乎无解必须依赖请求级别的唯一性校验让第二份、第三份相同的请求在服务端直接被判定为重复。这也是为什么我一直认为防重放不是频控的补充而是频控的前置条件——请求唯一性没解决频控的计数口径就是错的。1.3 为什么反爬体系里它是必选项很多同学会问反爬不是有验证码、有行为分析、有设备指纹吗为什么还要单独做防重放原因很现实上面这些手段都有成本而重放的成本几乎为零。验证码要用户交互体验差设备指纹有误伤覆盖率也不是百分之百行为分析需要样本积累冷启动阶段基本形同虚设。而防重放是在协议层做的一次轻量判断一次 Redis 写入延迟增加通常在毫秒级却能挡掉最廉价、最常见的那一批自动化请求。更重要的是防重放能显著抬高爬虫的工程成本。没有防重放的时候抓一个包就能循环用有了防重放攻击者必须每次都重新构造 nonce、重算签名还得保证时间戳在窗口内。对于用现成工具、脚本改一改就跑的低成本爬虫来说这一层门槛足够让大部分人放弃。它挡不住有备而来的对手但能把“随手爬一下”和“专业对抗”区分开这个价值在真实项目里非常划算。2. 方案选型几种原语怎么组合才不漏2.1 四种基础原语的能力边界在动手写代码之前先把可选零件摆清楚。我一般会把防重放的原语分成四类它们的定位差别挺大混用之前必须知道各自防什么、不防什么。原语核心作用能防住什么明显短板时间戳标记请求新鲜度过期请求、延迟重放窗口内仍可重放依赖时钟随机数 Nonce保证请求唯一窗口内的重复提交需要服务端存储有内存成本单调序列号天然递增不可回退旧序号重放、乱序重放并发和重试场景容易误伤一次性令牌先领后用用完即废强重放、令牌倒卖多一次往返链路变长签名 Sign保证内容未被篡改参数篡改、伪造请求完全不能防重放必须组合这里有个特别常见的误区很多人把签名当成防重放手段。签名解决的是完整性问题攻击者原样重放签名当然还是对的。我见过不止一个项目在上线评审时说“我们有 HMAC 签名重放没问题”实际上等于什么都没做。记住一句话签名防篡改时间戳防过期nonce 防重复三者是正交的缺一不可。序列号方案在纯 API 场景里其实很好用尤其是长连接或者有状态的会话服务端记一个“已处理的最大序号”收到比它小的直接拒掉。但放到 Web 接口上就麻烦了用户开两个标签页并发请求序号到达顺序不一定是递增的重试机制也会打乱顺序误杀率会很难看。所以我在无状态 HTTP 接口上基本不用序列号只在设备端上报、消息队列消费这类有序场景里用。2.2 我最终落地的组合时间戳 Nonce HMAC 签名经过几轮取舍线上稳定跑下来的方案是这套组合请求头带X-App-Id、X-Timestamp、X-Nonce、X-Sign四个字段签名串覆盖时间戳、nonce、请求路径、查询参数、请求体摘要服务端按“时间窗校验 → 签名校验 → nonce 唯一性校验”的顺序处理。选这个组合的理由很实际。时间戳负责把“很久以前的包”直接挡在门外成本几乎为零nonce 负责窗口内的唯一性是真正防重放的核心签名把 nonce 和时间戳也纳进覆盖范围防止攻击者只改 nonce 不改签名或者只改时间戳不改签名。三者串起来攻击者想重放就必须重新算签名而算签名需要密钥密钥不下发到不可信环境攻击链就断在这里。至于一次性令牌先调接口领 token再带着 token 请求业务接口安全性确实更高但每次业务请求前多一次 RTT在移动网络下体验损失明显。我的做法是分接口分级普通查询类接口用上面的组合拳涉及资金、下单、积分兑换这类高价值写接口再叠加一层一次性令牌或者业务幂等键。不是所有接口都值得上最重的方案分级才是可持续的做法。2.3 参数怎么定窗口、长度、TTL 的计算过程参数拍脑袋定是很容易出事的这几个值我都是按实际链路算出来的。时间窗口 W。要考虑三块客户端时钟漂移、网络往返、CDN 或网关的回源延迟。客户端时钟漂移在没做校时的设备上偏差几十秒很常见我按 ±120 秒留余量网络 P99 往返按 800 毫秒算网关重试和排队再留 30 秒。加起来大约 150 秒向上取整到300 秒也就是允许请求时间戳与服务端时间相差 ±5 分钟。窗口太小会误杀尤其是一些系统时间不准的旧设备窗口太大等于给攻击者更长的重放有效期。5 分钟是我在多个项目里验证过的平衡点。Nonce 长度。至少 128 位随机性。低于这个量级在高峰期存在碰撞概率虽然极小但一旦碰撞就是合法请求被误判。实际用secrets.token_urlsafe(16)生成 22 个字符的字符串或者直接用 UUIDv4 去掉横线。千万别用时间戳、自增 ID、用户 ID 拼一拼当 nonce那不是随机数是可预测的攻击者能提前构造。缓存 TTL。这一条很多人会算错。nonce 的存在时间必须大于等于 2W因为一个时间戳为 T 的请求在服务端看来只要落在 [T-W, TW] 区间内都可能被接受。如果 nonce 只存 W 秒那么在窗口后半段早期用过的 nonce 已经过期被清理同一份请求又能重放成功。所以 TTL 取600 秒正好覆盖两端。内存估算。假设接口峰值 5000 QPSTTL 600 秒最坏情况下需要保留 300 万个 key。Redis 每个 key 带过期时间和对象开销按 100 字节估大约 300 MB。这个量级单实例完全扛得住。如果峰值更高比如 2 万 QPS那就是 1.2 GB这时候要么拆实例按 App-Id 分片要么把 nonce 缓存换成布隆过滤器做第一层快速判断再用 Redis 做精确校验。提示TTL 一定要设置而且要在写入的同一条命令里设置。分两步SET再EXPIRE中间一旦进程崩溃这个 key 就永久驻留几个月后内存被吃满排查起来非常痛苦。3. 落地实现从签名生成到服务端校验3.1 签名串拼接规范坑最多的地方签名算法本身没什么难度难的是两端拼接规则必须严格一致。我遇到过的问题里起码一半出在这一步。我采用的规范是这样的把所有参与签名的字段业务参数 X-TimestampX-NonceX-App-Id 请求路径放进一个字典key 按 ASCII 字典序升序排列然后拼成k1v1k2v2的形式值统一做 URL 编码空格编码成%20而不是空值参数直接丢弃不参与拼接。请求体不直接拼进签名串而是先算SHA256(body)得到十六进制摘要作为一个虚拟字段body_hash参与排序拼接。这样做的原因是请求体可能很大直接拼会让签名计算变慢也不利于日志排查。import hashlib import hmac import urllib.parse def build_sign_string(params: dict, path: str, ts: str, nonce: str, app_id: str, body: bytes) - str: merged {k: str(v) for k, v in params.items() if v not in (None, )} merged[_path] path merged[_ts] ts merged[_nonce] nonce merged[_app] app_id merged[_body_hash] hashlib.sha256(body or b).hexdigest() items [] for k in sorted(merged.keys()): v urllib.parse.quote(merged[k], safe) items.append(f{k}{v}) return .join(items) def sign(sign_string: str, secret: str) - str: return hmac.new(secret.encode(), sign_string.encode(), hashlib.sha256).hexdigest()这段代码看着简单但有几个点必须强调。第一sorted()排的是原始 key不是编码后的编码后的顺序可能变。第二quote的safe参数要设成空字符串否则/、:这类字符不会被编码两端行为可能不一致。第三布尔值要统一成true/false还是1/0必须写进文档我见过前端传True、后端按true算签名永远对不上查了两天才发现是大小写问题。3.2 服务端校验的六步流程校验顺序是有讲究的顺序错了不只是性能问题还可能被利用来做拒绝服务。第一步取字段并做基础格式校验。四个头字段缺任何一个直接返回 401不做后续处理。时间戳必须是纯数字长度符合预期秒级 10 位或毫秒级 13 位团队内统一nonce 长度限制在 16 到 64 字符之间防止有人塞一个几 MB 的字符串把 Redis 打爆。第二步校验时间窗口。用服务端时间与请求时间戳做差取绝对值超过 300 秒直接拒绝返回一个明确的错误码比如TS_EXPIRED。这一步放在最前面是因为它不需要任何存储和计算能挡掉大量过期的重放包。第三步校验签名。按同样规则重算签名用hmac.compare_digest做常量时间比较避免时序攻击。这一步放在 nonce 检查之前是一个关键决策如果先查 nonce 再验签攻击者可以拿一堆随机 nonce 疯狂请求每个都能在 Redis 里写一条记录把缓存打满同时把合法用户的 nonce 空间挤掉。先验签只有签名正确的请求才有资格写 nonce 缓存。第四步nonce 唯一性校验。用SET key value NX EX 600一条命令完成返回成功说明是首次放行返回失败说明重复直接拒绝返回NONCE_REUSED。第五步业务处理。到这一步请求才真正进入业务逻辑。第六步记录指标。按拒绝原因打点ts_expired、sign_invalid、nonce_reused三个标签分开统计后面排查全靠它。import time import redis r redis.Redis(hostcache-01, port6379, decode_responsesTrue) def verify(headers, path, params, body, get_secret): app_id headers.get(X-App-Id) ts headers.get(X-Timestamp) nonce headers.get(X-Nonce) client_sign headers.get(X-Sign) if not all([app_id, ts, nonce, client_sign]): return MISSING_FIELD if not ts.isdigit() or not (16 len(nonce) 64): return BAD_FORMAT now int(time.time()) if abs(now - int(ts)) 300: return TS_EXPIRED secret get_secret(app_id) if not secret: return UNKNOWN_APP expected sign(build_sign_string(params, path, ts, nonce, app_id, body), secret) if not hmac.compare_digest(expected, client_sign): return SIGN_INVALID key frp:{app_id}:{nonce} if not r.set(key, ts, nxTrue, ex600): return NONCE_REUSED return OK3.3 Redis 存储设计里的几个细节key 的命名我坚持带上app_id前缀原因是不同应用之间的 nonce 空间必须隔离。如果两个应用共用一个命名空间A 应用的客户端碰巧生成了一个和 B 应用相同的 nonce后者就会被误判为重放。虽然 128 位随机数碰撞概率极低但业务隔离是原则问题加上前缀成本几乎为零。value 存时间戳而不是空字符串是为了排查方便。线上遇到争议请求可以直接看这个 nonce 第一次出现的时间判断是正常重试还是恶意重放。如果存1什么信息都没有。集群模式下要特别注意 key 的分布。单个 nonce key 天然落在一个 slot 上不存在跨 slot 问题但如果用了 Redis Cluster 又加了 hash tag反而可能把大量 key 挤到同一个节点。我的做法是不加 hash tag让集群自己散列压力自然均衡。还有一个容易被忽略的点Redis 写入失败时的降级策略。缓存挂了怎么办我的选择是 fail-open也就是 Redis 不可用时放行请求但打一条告警同时把该应用的频控阈值临时收紧。理由是防重放是安全增强不是业务主链路缓存故障不应该导致全站接口不可用。这个策略要在评审时明确写下来不能含糊否则出故障时值班同学不知道该不该切。3.4 灰度上线和观测指标这套东西不能一把全量。我的做法是先按应用灰度挑一个内部应用或者流量小的应用先上观察一周。重点看四个指标拒绝率按三个原因分、误杀投诉量、Redis 内存增长曲线、接口 P99 延迟增量。拒绝率突然飙高通常是客户端发版不同步老版本客户端没带新头字段。这时候要做的是版本兼容比如服务端对老版本X-App-Version放行一段时间而不是直接全拒。误杀投诉一般集中在时间窗口上尤其是一些系统时间严重不准的设备可以在客户端启动时用服务端时间做一次偏移校正把本地时间和偏移量一起用于生成时间戳。P99 延迟增量我实测下来通常在 1 到 3 毫秒主要开销在签名计算和一次 Redis 往返。如果发现增量超过 10 毫秒八成是 Redis 网络有问题或者签名串拼接里有低效操作比如对大对象做多次序列化。4. 进阶场景写接口、多机房、密钥泄露4.1 防重放和接口幂等不是一回事这两个概念经常被混着用但它们的职责完全不同。防重放解决的是“同一个请求在短时间内被重复提交”作用域是秒级到分钟级靠缓存实现缓存一过期就不管了。幂等解决的是“同一个业务操作被执行多次结果必须一致”作用域是长期的必须落库靠业务唯一键实现。下单场景我会两个都上。防重放挡住绝大部分重复包减轻下游压力幂等键比如客户端生成的request_id或者业务维度的用户 ID 商品 ID 时间片作为最后一道保险即使防重放因为缓存故障没拦住数据库唯一索引也能保证不会生成两笔订单。只做防重放不做幂等缓存一抖就可能出重复单只做幂等不做防重放虽然结果对了但每次重放都要打到数据库白白消耗资源还容易被用来做慢查询攻击。4.2 多机房和时钟漂移怎么处理跨机房部署时时间戳校验会遇到两个问题各机房服务器时钟不完全一致以及请求可能被调度到离客户端较远的机房。前者靠 NTP 同步把各机房时间偏差控制在 100 毫秒以内这个精度对 300 秒的窗口来说完全够用。后者靠窗口余量覆盖不需要额外处理。真正需要小心的是nonce 缓存的跨机房可见性。如果每个机房各自用本地缓存攻击者把同一个包分别打到 A 机房和 B 机房两边都认为是首次重放就成功了。所以 nonce 缓存必须是中心化的或者至少是跨机房强一致的。我的做法是 nonce 统一写中心 Redis读也读中心接受那一点点跨机房延迟如果延迟实在不能接受就用“本地布隆过滤器 中心 Redis”两级本地过滤器只做快速否定判断判定为重复就直接拒判定为“可能是新的”再去中心确认。4.3 密钥泄露了怎么办必须承认一个现实只要密钥下发到了客户端它迟早会泄露。前端代码可以混淆但混淆不是加密抓包、反编译、内存 dump手段太多了。所以防重放的强度不能完全押在密钥保密上。我的应对是三层。第一层密钥按应用分配一个应用泄露不影响其他应用同时支持双密钥并存轮换期间新旧都能验给客户端留出升级时间。第二层高价值接口叠加一次性令牌令牌由服务端签发有效期很短比如 60 秒用完即废攻击者拿到密钥也绕不过令牌这一关。第三层行为侧兜底同一个账号在短时间内用不同 nonce 高频请求同一批资源即使每次都合法也要触发风控降级比如要求二次验证或者直接限流。这三层叠起来的意思是防重放负责提高成本令牌负责守住关键路径风控负责兜住漏网的。单靠任何一层都不够。5. 常见问题与排查技巧实录5.1 问题速查表现象大概率原因处理方式大面积SIGN_INVALID两端参数排序或编码不一致抓一条请求用服务端规则离线重算比对只有部分用户被拒客户端时钟漂移超窗口加服务端时间偏移校正或适度放宽窗口偶发NONCE_REUSED但用户只点了一次客户端 nonce 生成用了时间戳或弱随机换成密码学安全随机源Redis 内存持续上涨TTL 未设置或分两步写入改成SET NX EX单命令清理历史 key重放依然能成功nonce 校验在业务之后或异常分支跳过把校验前置到拦截器/中间件统一处理接口延迟明显上升签名串过大或 Redis 跨机房用 body 摘要替代原文nonce 缓存就近部署5.2 几个只有踩过才知道的坑第一个坑是异常分支绕过。校验逻辑写在主流程里但业务代码里有try/except包了一层某个参数解析异常直接return了跳过了后面的 nonce 检查。这种问题在测试环境很难复现因为要凑齐特定参数才能触发。解决办法是把校验放进统一的中间件或拦截器业务代码里根本接触不到放行逻辑从结构上杜绝绕过。第二个坑是重试机制自己打自己。网关或者客户端 SDK 有自动重试网络抖动时同一个请求被发了两次第二次 nonce 相同直接被判定为重放。这不是攻击是正常行为。处理方式有两种一是重试时重新生成 nonce 和签名语义上是新请求二是服务端对NONCE_REUSED返回一个可识别的错误码让客户端不要盲目重试。我倾向前者重试就应该是新请求幂等由业务键保证。第三个坑是日志泄露。签名和时间戳写进访问日志本身没问题但密钥绝对不能出现在任何日志里包括异常堆栈。我见过有项目在验签失败时把expected和secret一起打进日志方便排查结果日志系统被翻了个底朝天。正确做法是只记录 app_id 和失败原因密钥相关的内容一律脱敏。注意nonce 校验一定要覆盖所有入口包括内部服务之间的调用。我见过外部接口防得很严内部 RPC 完全裸奔攻击者一旦拿到内网入口重放起来毫无阻碍。5.3 防守视角下的对抗思路站在防守方我一般会想攻击者会怎么绕。第一他可能每次生成新 nonce但参数不变——这种情况签名是新的nonce 也是新的防重放拦不住。所以业务侧必须有频控和配额同一个人反复查同一条数据即使每次请求都“合法”也要限制。第二他可能拿到密钥自己签名——这时候只能靠密钥轮换、令牌、风控三层兜。第三他可能利用时间窗口边缘比如在 299 秒时重放这个只能靠缩短窗口或者对高价值接口单独收紧到 60 秒。还有一个很实用的观察点正常用户的 nonce 分布和机器人的 nonce 分布不一样。正常用户请求稀疏nonce 之间没有规律批量脚本的 nonce 生成往往有迹可循比如递增、或者时间相关。把这些特征喂给风控比单纯看重放次数更有效。这个不是防重放本身的职责但两者数据可以打通属于顺手能做的事。最后分享一个小技巧。排查重放问题时别急着看代码先把 Redis 里对应的 key 捞出来看 value 里存的时间戳。如果同一个app_id下大量 nonce 的首次出现时间集中在几秒内基本可以确定是脚本在批量构造请求如果是零散分布、间隔均匀更像是定时任务或者客户端重试。这个判断方法比看日志快得多我每次线上排查都是先做这一步。这套方案在我们几个项目里跑了两年多中间调过窗口、换过缓存、加过令牌但整体结构没变过。防重放这件事难的不是算法是把边界想清楚哪些请求该拦、拦错了怎么兜、缓存挂了怎么办。把这些提前写进设计文档比事后补丁靠谱得多。