接口签名防重放攻击:请求被截获重复提交?Timestamp + Nonce + Signature 三重校验!

朋友前段时间接了个支付对接的项目,联调的时候一切正常,上线跑了两天也没事。直到有一天财务对账,发现有几笔订单被扣了两次款。排查了半天,日志里同一笔订单收到了两条完全一样的请求,时间间隔不到一秒。他当时就懵了 —— 代码里明明做了幂等,怎么还能重复扣款?

这不是什么高深的 0day 漏洞,甚至连"攻击"都算不上。就是一个中间人把你发出去的请求原封不动复制了一份,重新扔给了服务器。但后果你能想象:重复扣款、重复下单、重复发券,随便哪个都能让业务方提着刀来找你。

这个问题学名叫重放攻击(Replay Attack),解决起来其实思路很清晰。今天就用大白话聊聊,怎么用三道防线把这种请求挡在外面。


先说说重放攻击到底是怎么回事

举个例子。你在 App 上下一笔订单,客户端会发一个 HTTP 请求到服务器:

POST /api/order/create
Body: {"productId": 123, "amount": 99.00, "userId": 456}

这个请求跑在 HTTPS 下面,内容是加密的,中间人看不到你买了啥。但他不需要看到内容 —— 他把整个加密的数据包原封不动抓下来,然后原样再发一次。

服务器收到第二个请求,解密、验签、执行,一切正常。因为在服务器眼里,这就是一个合法的请求:签名是对的,参数是合法的,用户也是真实的。

问题出在哪?服务器没有办法区分"这是用户真的点了两次"还是"同一份数据被人复制了一份"。

所以防重放的核心思路就一句话:让每个请求带上"一次性"的标记,服务器能识别出"这份数据我见过了"。

下面我们一层一层来拆解。


第一层:Timestamp —— 给请求加个"保质期"

最朴素的想法:要求客户端在请求里塞一个时间戳。服务器拿到之后看一眼,如果这个时间和当前时间差太远,直接拒掉。

客户端请求:
    Header: X-Timestamp: 1717000000

服务器校验:
    now = 当前时间戳
    if now - X-Timestamp > 60秒:
        return "请求已过期"

逻辑很简单。假设你把时间窗口设成 60 秒,那攻击者截获的请求最多只能在 60 秒内重放,过期就失效了。

但光有这个够吗?差远了。

时间窗口内(比如这 60 秒里),攻击者还是可以随便重放,服务端照样认。所以 Timestamp 只是个粗粒度的第一道防线,它能限制攻击窗口的大小,但不能堵死这个窗口。


第二层:Nonce —— 每个请求一个"一次性身份证号"

既然单纯的时间戳不管用,那就让每个请求带一个只使用一次的随机字符串,服务器记下来,见过就不再认。

客户端:
    nonce = 随机生成一个唯一 ID(比如 UUID)
    请求时带上:X-Nonce: a1b2c3d4-e5f6-...

服务器:
    if 这个 nonce 之前见过:
        return "重复请求"
    把这个 nonce 记下来(比如存 Redis,过期时间跟时间窗口一致)

这招本质就是给每个请求发一张一次性门票,用过就作废。

但是,这里又冒出来一个新问题:攻击者完全可以自己改 nonce,然后重新签名。他截获请求之后,把 nonce 换掉,用同样的参数再签一次名,nonce 是新的,服务端没见过,照样放行。

所以 Nonce 能防的是"请求被原封不动地重放",但防不了"攻击者篡改 nonce 后重放"。要堵这个漏洞,得靠第三层。


第三层:Signature —— 让你改不了任何一个参数

签名校验的思路是这样的:把请求里的关键参数(包括 Timestamp 和 Nonce)串起来,用双方约定好的密钥算一个签名,请求的时候一起带上。服务器收到之后用同样的方式算一遍,对不上就拒掉。

这么做之后,攻击者就没办法单改 nonce 了 —— 改了 nonce,签名就对不上了,除非他知道你的密钥。

签名算法怎么设计?

最常用的方式是 HMAC-SHA256,大致流程:

客户端签名:
    params = 把所有参数按字母序排列,拼接成字符串
    stringToSign = HTTP方法 + 请求路径 + 排序后的参数 + Timestamp + Nonce
    signature = HMAC-SHA256(stringToSign, AppSecret)

    请求:Header: X-Signature: <signature>

服务器验签:
    用同样的规则组装 stringToSign
    expectedSign = HMAC-SHA256(stringToSign, AppSecret)
    if expectedSign != X-Signature:
        return "签名校验失败"

这里面有几个细节值得说一下。

参数排序

签名的时候参数必须按某种确定的顺序排列,不然客户端和服务器的拼接结果不一样,签名就对不上。一般做法是按参数名的字母序排列

原始参数:userId=456&productId=123&amount=99.00
排序后:amount=99.00&productId=123&userId=456

哪些参数参与签名?

经验之谈:Body 里所有字段 + Header 里的 Timestamp 和 Nonce。 URL 参数也建议带上。总之,任何可能被篡改的东西都别漏掉。

密钥怎么管?

AppSecret 由服务端生成,线下给到客户端。绝对不要通过网络传输密钥本身。 每个接入方独立分配一个 AppId + AppSecret 对,方便后续按调用方做精细化控制(限流、黑名单、审计)。


三重校验的完整流程

把这三个机制串起来,一个完整的防重放请求长这样:

客户端准备请求:
    Timestamp = 当前时间戳
    Nonce     = UUID.randomUUID()
    Signature = HMAC-SHA256(拼接参数, AppSecret)

    POST /api/order/create
    Header:
        X-AppId:      "partner_001"
        X-Timestamp:  "1717000000"
        X-Nonce:      "a1b2c3d4-e5f6-..."
        X-Signature:  "e3b0c44298fc1c14..."

    Body:
        {"productId": 123, "amount": 99.00, "userId": 456}
服务器收到后,按顺序校验:

Step 1 - 时间窗口校验
    if 当前时间 - X-Timestamp > 60秒 || X-Timestamp > 当前时间:
        拒绝:"请求时间异常"

Step 2 - Nonce 唯一性校验
    if Redis.exists("nonce:" + AppId + ":" + Nonce):
        拒绝:"重复请求"
    Redis.set("nonce:" + AppId + ":" + Nonce, "1", EX=120)

Step 3 - 签名校验
    stringToSign = 按照同样规则拼接
    expectedSign = HMAC-SHA256(stringToSign, AppSecret)
    if expectedSign != X-Signature:
        拒绝:"签名校验失败"

Step 4 - 执行业务
    处理请求...

注意一点:Timestamp 要在 Nonce 之前校验。 如果 Timestamp 已经超出窗口了,就没必要再去查 Redis 了,省一次网络调用。验签放在最后是因为它计算成本最高,能提前挡掉的请求就别让它走到这一步。


几个实际开发中容易踩的坑

时间窗口设多大?

窗口太小(比如 5 秒),客户端网络稍微波动一下就超时了,正常请求被误伤。窗口太大(比如 10 分钟),攻击者的操作空间就大了。

一般设 60 到 120 秒 是个比较平衡的选择。如果你的业务对时效性要求特别高(比如秒杀),可以缩到 30 秒;如果是后台管理类的接口,放长点也没关系。

客户端和服务端时间不一致怎么办?

这是最头疼的问题之一。有些用户的手机时间就是不准的,差了十几分钟甚至几个小时。

解决办法:服务端对外暴露一个获取服务器时间的接口,客户端在发起请求前先校准一下时间,拿服务器时间作为 Timestamp 的基准。当然,这也不是完美的 —— 如果客户端和服务器之间的网络延迟很大,仍然会有偏差。所以窗口不要设得太抠门,留点余量。

Nonce 存哪?存多久?

Nonce 需要一个全局唯一的存储,所有服务实例都能查到。Redis 是首选,简单、快、支持过期。

Nonce 的过期时间至少要比时间窗口长,建议设成时间窗口的两倍。为什么?假设窗口是 60 秒,客户端在 T 时刻发出请求,这个请求到 T+59 秒都算有效。如果 Redis 里的 nonce 也在 T+59 秒过期,那 T+60 秒时攻击者重放这个请求,Nonce 已经清掉了,就能绕过。所以 Nonce 过期 = 窗口 × 2,留足余量。

签名算法的"坑"

有人问:直接把所有参数简单拼起来算个 MD5 不行吗?

不是不行,但不推荐。简单拼接容易出问题,比如 a=1&b=2a=1&b=&2 拼出来的字符串可能一样。用 HMAC 系列算法(HMAC-SHA256)是业内共识,别自己造轮子。


总结

防重放攻击这件事,本质上是用三个维度把请求"锁死":

  • Timestamp:锁时间。过期的请求不认。
  • Nonce:锁唯一性。见过的请求不认。
  • Signature:锁完整性。改过的请求不认。

三个加在一起,攻击者想要重放,必须同时满足:时间在窗口内、nonce 没用过、签名对得上。而签名依赖密钥,他不知道密钥就算不出来。除非他把密钥也搞到手了 —— 但那已经不是重放攻击的范畴了。

还有一点,三重校验是接口层的防护,它解决的是"这个请求是不是原封不动被复制了"。至于业务层面的重复提交(比如用户手抖连点两次),那得靠幂等设计来解决,那是另一个话题了。

最后留一句:如果你是做开放平台或者对外 API 的,这套东西几乎是标配。早点做,比出了事故再补,成本不是一个量级的。


如果这篇文章对你有用,欢迎转发给还在裸写接口的同事。


标题:接口签名防重放攻击:请求被截获重复提交?Timestamp + Nonce + Signature 三重校验!
作者:jiangyi
地址:http://www.jiangyi.space/articles/2026/06/01/1780130484142.html
公众号:服务端技术精选
    评论
    0 评论
avatar

取消