重放攻击的实现方案
最近系统上线之前需要做安全测试,安全测试人员指出了系统存在重放攻击,由于之前项目没有遇到过这样的问题,在这里就详细探讨一下重放攻击。
所谓重放攻击,就是攻击者把目标主机已经接收过的请求再次发送给服务器,从而达到重复执行或欺骗系统的目的,常见于身份认证、支付、状态变更等场景。攻击者可以通过网络监听、日志泄露、恶意客户端等方式拿到一次有效请求,然后重新发送。
需要注意的是,生产环境应使用 HTTPS,并使用 HMAC-SHA256、RSA/ECDSA 等签名方案校验请求完整性;不要用简单的 md5(参数拼接) 作为安全签名。下面的代码片段用于说明流程,实际落地时还要使用常量时间比较,避免签名比较过程泄露信息。
1、基于timestamp的方案
每次 HTTP 请求都需要带上 timestamp 参数,然后把 timestamp 和其他关键参数一起参与签名。因为一次正常的 HTTP 请求从发出到到达服务器通常不会超过 60 秒,所以服务器收到请求后,先判断时间戳与当前时间的差值是否超过 60 秒;如果超过,则认为是非法请求。
假如攻击者通过抓包得到了我们的请求 URL:
https://www.clang.asia/index/Info?uid=ZX07&stime=1480862753&sign=80b886d71449cb33355d017893720666
其中
1 | String sign = hmacSha256(secret, uid, token, stime); |
一般情况下,攻击者从抓包到重放请求的耗时可能超过 60 秒,所以此时请求中的 stime 参数已经失效。
如果攻击者把 stime 改成当前时间戳,则 sign 参数对应的数字签名会失效,因为攻击者不知道服务端签名密钥,无法生成新的合法签名。
但这种方式的缺点也很明显:如果在 60 秒窗口内进行重放攻击,单靠时间戳无法保证请求仅一次有效。
2、基于nonce的方案
nonce 是一次性随机字符串,要求每次请求都不同。实际使用时应由客户端生成足够随机的值,例如 UUID、ULID 或安全随机数,不建议直接使用时间戳替代随机数。
我们将每次请求的 nonce 参数存储到一个“集合”中,可以存储到 Redis、数据库或其他缓存系统中。
每次处理 HTTP 请求时,首先判断该请求的 nonce 参数是否已经存在;如果存在,则认为是非法请求。
假如攻击者通过抓包得到了我们的请求 URL:
https://www.clang.asia/index/Info?uid=ZX07&nonce=58442c21&sign=80b886d71449cb33355d017893720666
其中
1 | String sign = hmacSha256(secret, uid, token, nonce); |
这种方式的问题是 nonce 集合会越来越大,验证 nonce 是否存在的成本也会越来越高。我们不能让 nonce 集合无限增长,所以需要给它设置过期时间。但是一旦 nonce 被清理,就无法再判断旧请求是否已被使用。因此,单独依赖 nonce 并不适合无限期防重放。
3、基于timestamp和nonce的方案
更常见的做法是同时使用 timestamp 和 nonce。nonce 解决时间窗口内的重复提交问题,timestamp 控制请求有效期和 nonce 的存储窗口。
在 timestamp 方案的基础上加上 nonce 参数后,因为超过 60 秒的请求都会被认为非法,所以只需要存储 60 秒内的 nonce 集合即可。
假如攻击者通过抓包得到了我们的请求 URL:
https://www.clang.asia/index/Info?uid=ZX07&stime=1480862753&nonce=58442c21&sign=80b886d71449cb33355d017893720666
其中
1 | String sign = hmacSha256(secret, uid, token, stime, nonce); |
如果在 60 秒内重放该 HTTP 请求,因为 nonce 参数已经在首次请求时被记录在服务器的 nonce 集合中,所以会被判断为非法请求。超过 60 秒后,stime 参数会失效,此时攻击者即使修改参数,也无法在不知道签名密钥的情况下重新生成合法签名。
综上,如果认为一次正常 HTTP 请求不会超过 60 秒,那么 60 秒内的重放攻击可以由 nonce 保证,超过 60 秒的重放攻击可以由 stime 保证。
因为 nonce 参数只在 60 秒内起作用,所以只需要保存 60 秒左右的 nonce 即可。
随机数集合可以根据业务场景采用定期清理、Redis TTL 或根据大小自动清理的方案。例如该接口每秒请求数最高为 1000,则 60 秒内请求数最多约为 1000 * 60 = 60000;可以在每次请求后检查集合大小是否超过阈值,若超过则触发清理。
整体验证流程:
1 | // 获取token |






