接口反爬防重放:时间戳、Nonce与HMAC签名实战

发布时间:2026/11/6 5:53:49
接口反爬防重放:时间戳、Nonce与HMAC签名实战

做反爬和数据安全这块时间长了,会发现一个挺有意思的现象:大部分团队在接口防护上投入的精力,几乎都花在了频控、验证码、UA 校验、IP 黑名单这些“看得见”的地方,而防重放这种藏在协议层的策略,往往被一句“我们加了签名”带过去。真到出事的时候才发现,签名只解决了“请求有没有被篡改”,根本没解决“同一个请求能不能被再发一遍”。攻击者甚至不需要破解任何东西,只要抓到一个合法请求,原封不动地重发,就能把接口当成自家数据库来用。

这篇内容就围绕数据安全(反爬虫)之「防重放」策略展开,讲清楚重放攻击在反爬场景里到底是怎么发生的、几种常见原语(时间戳、随机数、序列号、一次性令牌)各自的能力边界、我实际项目里怎么把它们组合成一套能落地的方案,以及参数怎么算、代码怎么写、上线后怎么排查。适合已经有一定接口开发基础、正在做反爬或者准备做接口安全加固的同学参考;如果你只是刚接触这块,前面几节的基础拆解也能让你看懂整套逻辑。

1. 先把问题想清楚:重放攻击到底在打谁

1.1 一个很典型的重放现场

我最早真正重视这件事,是在一个数据查询类接口上。当时接口做了签名校验,参数改了签名就对不上,团队觉得挺安全。结果某段时间监控显示,某个查询接口的调用量在凌晨两点稳定上涨,但 IP 分布很散、UA 也正常、单个账号的请求频率完全在阈值之内,频控策略一点都没触发。

后来抓了一次现场才看明白:对方并没有改任何参数,就是把白天抓到的合法请求包存下来,用脚本原样循环发送。签名是对的、时间戳是旧的、Cookie 是有效的,接口层面完全分不出这是人还是机器。更麻烦的是,这类请求在业务侧看起来就是“同一个人反复查同一条数据”,既没有越权,也没有异常参数,风控模型很难给出高分。

这就是重放攻击的本质:攻击者不破解你的加密,也不伪造你的身份,他只是把你的合法请求复制了一份,再放一遍。在反爬语境下,它的危害不只是多消耗一点带宽,而是可以让频控、配额、计费、风控这一整套基于“请求次数”的机制全部失效——因为你的计数系统认为每一次都是合法的第一次。

1.2 三种常见变体,防护思路完全不同

把重放拆开看,实际会遇到的大致有三类,每类对应的防守重点不一样。

第一类是原样重放。请求的 URL、Header、Body、签名一个字节都不改,直接复制发送。这类最粗暴,也最好防,只要请求里带一个服务端能识别“已经用过”的标记,就能拦下来。麻烦在于这个标记必须和请求绑定,不能是客户端随便编一个就能过。

第二类是参数微调重放。攻击者会改动一些看起来“无关紧要”的字段,比如页码、时间范围、排序方式,试图绕过某些只校验部分参数的签名逻辑。我见过有团队做签名时只对 URL 路径和用户 ID 签名,查询条件压根没进签名串,结果对方换个page=2就能把全量数据一页页拉走。这类攻击的防守重点是签名覆盖范围必须完整,所有影响业务结果的参数都得进签名。

第三类是分布式重放。同一份请求包,从几十上百个出口分散发送,每个出口的频次都很低。这类攻击单纯靠 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, T+W] 区间内都可能被接受。如果 nonce 只存 W 秒,那么在窗口后半段,早期用过的 nonce 已经过期被清理,同一份请求又能重放成功。所以 TTL 取600 秒,正好覆盖两端。

内存估算。假设接口峰值 5000 QPS,TTL 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-Timestamp+X-Nonce+X-App-Id+ 请求路径)放进一个字典,key 按 ASCII 字典序升序排列,然后拼成k1=v1&k2=v2的形式,值统一做 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(host="cache-01", port=6379, decode_responses=True) 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 = f"rp:{app_id}:{nonce}" if not r.set(key, ts, nx=True, ex=600): return "NONCE_REUSED" return "OK"

3.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 的首次出现时间集中在几秒内,基本可以确定是脚本在批量构造请求;如果是零散分布、间隔均匀,更像是定时任务或者客户端重试。这个判断方法比看日志快得多,我每次线上排查都是先做这一步。

这套方案在我们几个项目里跑了两年多,中间调过窗口、换过缓存、加过令牌,但整体结构没变过。防重放这件事,难的不是算法,是把边界想清楚:哪些请求该拦、拦错了怎么兜、缓存挂了怎么办。把这些提前写进设计文档,比事后补丁靠谱得多。