ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

智能设备半夜自动开关?接口防重放攻击实战复盘与方案落地

智能设备半夜自动开关?接口防重放攻击实战复盘与方案落地 先交代一下背景这是我经手的一个小项目一个智能设备管理平台用户量不算大后端基本就我一个人在维护。项目上线两个月左右我收到一条特别离谱的用户反馈“家里的灯会自己开、自己关昨晚半夜客厅灯闪了好几次。”我第一反应是设备固件出 bug 了结果查了一整天问题根本不在设备端而在我们自己的接口被“原样重放”了也就是典型的重放攻击。这个经历特别典型也特别适合拿出来复盘。它技术门槛不高但在真实业务里非常容易踩中尤其是小团队、小项目因为往往没有专职的安全工程师帮你把关。这篇内容我会把这次从发现、排查、定位到最终修复的全过程写清楚包括中间踩过的坑和最终落地的代码逻辑。如果你也在维护接口类的后端服务尤其是涉及支付回调、设备控制、验证码发送这类敏感操作的强烈建议从头到尾看一遍。1. 项目背景与故障现场一个会“自己开关灯”的设备控制平台1.1 项目是做什么的核心接口与调用链路先简单说下项目形态。这是一个智能设备管理平台用户通过手机 App 绑定家里的智能灯、智能插座等设备然后远程下发控制指令。整体链路分三层App 客户端 - 后端 API 服务 - 设备端。用户点击“开灯”按钮App 会向后端发起一个 HTTP 请求后端校验用户身份后把指令通过消息通道推给设备设备执行并回传状态。后端主要提供这么几类接口用户登录、设备绑定、设备控制指令下发、设备状态上报以及后续接的支付回调。听上去都是常规接口但因为涉及真实物理设备有一个天然特性控制指令不能乱执行一次就是一次重复执行可能引发安全问题或者用户体验的严重下降。当时项目处于早期版本接口只做了用户登录态的 JWT 校验没有做任何防重放控制。我觉得这是很多小项目初期的常态能用、看起来安全但经不起细看。1.2 用户反馈的诡异现象不是偶发是有规律的接到“灯自己开关”的反馈后我先是询问了用户设备固件版本、网络情况也远程查看设备日志。设备端日志显示它在某个时间窗口内连续收到了多次“开灯”和“关灯”指令时间间隔非常均匀每条指令间隔不到 100 毫秒。这个间隔引起了我的注意。人手工点击 App 是不可能做到 100 毫秒内连续发出好几条指令的即使是 App 的自动重试机制也不太可能用这种节奏。我当时猜测是不是别的用户串号了或者设备绑错账号了于是我去查后端接口日志发现这些请求全部来自同一个用户 tokenIP 和 User-Agent 也完全一致请求体也一字不差。更诡异的是用户说自己当时根本没打开 App甚至手机放在客厅充电而请求发生的时间点正是半夜。也就是说有人在用户不知情的情况下用同一份请求数据反复调用我们的接口。1.3 初步排查的弯路日志里的请求看起来全是真的我一开始排查方向完全错了一直在怀疑设备端固件循环触发、消息队列重复投递、前端按钮事件重复绑定甚至怀疑是不是用户家里网络有幽灵设备。因为从后端看这些请求的登录态是合法的token 没过期签名如果有的话也是有效的参数也完全合理——怎么看都像是用户自己发出来的。后来我把请求时间、设备 ID、用户 ID 关联起来画了一条时间线才发现重复请求的数量多到不正常。一次控制操作居然有 40 多条一模一样的请求记录。这时候我才想到一个词重放攻击。攻击者不需要破解你的系统只需要拿到一次合法的请求数据就能在任意时间原样重放让设备反复执行同一条指令。2. 重放攻击原理为什么加密、鉴权都拦不住2.1 重放攻击的本质播放一段“合法录音”重放攻击Replay Attack其实很好理解我后来跟同事解释都是用的这个类比你家门口装了一个密码锁开锁动作本身是合法的、正确的但攻击者不是去猜你的密码而是在你正确输密码的时候录了一段视频或者在你按指纹的时候采集了指纹数据之后他什么都不用破解只需要把这段视频反复播放门就会一次次打开。放到接口场景里也一样。攻击者在网络链路的某个环节截获了一条真实请求可能是登录后的业务请求也可能是支付回调通知然后他把这条请求的数据包原样保存下来在任意时间重新发送给服务器。服务器只校验“这条请求是否合法”不校验“你以前是不是已经执行过了”于是业务逻辑就被重复触发。重放攻击的核心特点是它不需要破解密码不需要伪造身份不需要篡改数据。它只是把一条真实存在的、合法的请求又原样发送了一遍。这也是为什么它常常不被人重视但造成的后果可能非常严重。2.2 HTTPS、JWT、签名都防不住重放为什么在排查过程中我发现团队里不少人有一个认知误区我们的接口已经上了 HTTPS用户请求也带了 JWT token为什么还是被重放了这需要把各层安全机制解决的问题分清楚。HTTPSTLS解决的是传输过程中的机密性和完整性。它保证请求在网络上传输时第三方无法轻易偷看内容、无法篡改数据。但它不保证“发送方是不是第一次发这个请求”。攻击者可以通过很多途径拿到完整请求比如抓取 App 内部发出的明文请求、在客户端被植入恶意组件、或者干脆是第三方渠道泄露的请求日志。一旦拿到完整请求包他原样重放HTTPS 层面完全看不出来有什么问题。JWT 解决的是身份认证。JWT 只告诉服务器“你是你”只要 token 没过期服务器就会认账。在重放攻击场景下攻击者重放的请求里带着合法 JWT服务器校验通过身份没问题所以照样执行。接口签名解决的是数据完整性确保请求参数没有被篡改。但是攻击者根本不需要篡改参数他就原样重放签名自然也是有效的。签名机制如果没配合时间戳和随机数一起使用对重放攻击基本是无效的。所以结论很明确要防重放必须在应用层额外增加“请求的新鲜度”和“一次性”校验机制。2.3 重放攻击在小项目里最容易出现的三个位置经过这次之后我总结了重放攻击在小项目里最容易发生的地方基本都集中在“执行后有明显影响力”的接口上。最典型的是支付回调。订单支付成功后支付平台会向商户后端发送回调通知。如果这个通知接口没有防重放机制攻击者重放一次回调就可能触发订单状态的重复修改、优惠券重复发放、积分重复入账。第二个高发点是短信验证码接口攻击者重放验证码请求可以无限刷短信把预算刷爆甚至干扰正常用户。第三个就是我这个项目里的设备控制指令重放开锁指令、重放关闭闸门指令轻则设备抖动重则造成安全事故。我当时的设备控制接口正好属于第三类而且执行逻辑是“每次收到指令都转发给设备设备端来一个指令执行一次”。设备端没有做状态幂等所以攻击者重放多少次灯就闪烁多少次。3. 定位过程与方案选型三套方案摆在面前怎么选3.1 抓包复现确认问题的最快路径在确定重放嫌疑后我做的第一件事是复现。我用自己的测试账号在电脑上登录对应的客户端工具发出一条“开灯”指令然后用抓包工具拿到了这条 HTTP 请求。这个工具里有 Replay 功能可以把请求原样重新发送一遍。我连续点了三次 Replay结果非常直观设备端的灯跟着请求次数连续开关了三次。这个复现实验意义重大。它证明了两件事第一攻击路径确实存在第二问题出在后端接口没有做任何防重放处理而不是设备端逻辑的问题。有了这个结论后面要解决的就不是“要不要修”而是“怎么修”的问题了。3.2 方案对比时间戳、随机数、幂等键到底各管什么修复前我先把常见方案拉了个表对比它们分别防什么、漏什么方案防什么不防什么实现难度典型场景仅时间戳校验防止很久以前的请求被重放时间窗口内的即时重放低所有接口的基础防护仅随机数 nonce防止同一条请求被重复使用攻击者生成新随机数伪造请求中搭配时间戳使用HMAC 参数签名防止请求参数被篡改原样重放完整请求中敏感写操作业务幂等键防止同一条业务操作重复执行防不了伪造合法请求中低支付、下单、指令下发防重放攻击系统结合以上全部能力存储成本较高高金融级、高安全场景从这个表能看出来没有哪个单一方案是万能的。时间戳防得住“旧请求重放”但窗口期内重放还是拦不住nonce 防重复但依赖服务端存储签名防篡改但对原样重放无能为力幂等键最贴近业务但只防重复执行不防伪造。3.3 我的最终选择时间戳 nonce 参数签名再加业务幂等键我在这个项目里最终选了“时间戳 nonce 参数签名”做接口层防护同时在设备控制这种指令级接口上额外要求客户端生成一个全局唯一的业务请求 ID幂等键服务端在处理前先去查这个 ID 是否已经执行过执行过就直接返回上次结果不再转发指令。这么组合的思路是这样的时间戳把重放的有效期限制在一个小窗口内比如 5 分钟超过窗口直接拒绝nonce 保证同一窗口内同一条请求只能被消费一次签名保证请求参数和这两个字段没有被篡改。三层叠加攻击者既不能重放旧请求也不能在同一窗口内重放新请求更不能改参数冒充新请求。业务幂等键则在极端情况下兜底即使前面的校验都通过了也不至于把同一条指令执行两遍。这套组合是业界比较成熟的方案实现成本不高却能覆盖绝大多数重放攻击场景。我没有继续上更重的安全方案比如挑战-应答机制或者设备端序列号同步因为项目体量摆在那里安全也要考虑性价比。4. 核心代码实现与参数配置细节照着抄就能用4.1 服务端拦截器三层校验的具体实现我分享的是核心逻辑语言用 Java 写但思路完全通用。我的做法是写一个拦截器挂在需要防重放的接口路由上。业务流程进入 Controller 之前先经过这个拦截器校验不通过直接返回 401业务代码根本不会执行。Component public class ReplayAttackInterceptor implements HandlerInterceptor { // 允许的时间偏移量5分钟 private static final long MAX_ALLOWED_DRIFT 5 * 60 * 1000L; // 存储nonce的key前缀 private static final String NONCE_KEY_PREFIX api:nonce:; Autowired private StringRedisTemplate redisTemplate; Override public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { // 1. 获取客户端携带的三个参数 String timestamp request.getHeader(X-Timestamp); String nonce request.getHeader(X-Nonce); String signature request.getHeader(X-Signature); if (StringUtils.isBlank(timestamp) || StringUtils.isBlank(nonce) || StringUtils.isBlank(signature)) { response.setStatus(401); return false; } // 2. 校验时间戳是否在允许的窗口内 long ts; try { ts Long.parseLong(timestamp); } catch (NumberFormatException e) { response.setStatus(401); return false; } long now System.currentTimeMillis(); if (Math.abs(now - ts) MAX_ALLOWED_DRIFT) { response.setStatus(401); return false; } // 3. 校验nonce是否已经使用过 String nonceKey NONCE_KEY_PREFIX nonce; Boolean firstUsed redisTemplate.opsForValue() .setIfAbsent(nonceKey, 1, MAX_ALLOWED_DRIFT, TimeUnit.MILLISECONDS); if (!Boolean.TRUE.equals(firstUsed)) { response.setStatus(401); return false; } // 4. 校验签名防止参数被篡改 if (!verifySignature(request, timestamp, nonce, signature)) { response.setStatus(401); return false; } return true; } private boolean verifySignature(HttpServletRequest request, String timestamp, String nonce, String signature) { // 实际项目里密钥从配置中心或服务端解密后获取 String secretKey getSecretKey(); String body getRequestBody(request); String expectedSign sign(secretKey, timestamp nonce body); return constantTimeEquals(expectedSign, signature); } }有几个细节在设计时必须注意。nonce 的存储我用的 Redis是因为 Redis 的SETNX本身就具备原子性天然适合“判断是否已存在且写入一次”的场景再配合过期时间可以自动清理。这一步千万不能用“先查询再插入”的非原子方式高并发下会出现竞态条件导致同一个 nonce 被多次放行。签名校验时比较两个字符串要用固定时间比较法避免通过时间差推测签名内容。4.2 时间窗口、nonce 存储与 Redis 使用要点时间窗口的选取是个细节问题。窗口太长给攻击者留的重放空间就大窗口太短客户端和服务端存在正常的时间偏差时会把合法请求误杀。我当时先定了 5 分钟是因为这个项目是移动端场景用户手机时间可能不准5 分钟能覆盖绝大多数正常偏移。还有一个问题是客户端和服务端部署在不同地域时时间同步问题会放大。所以这里不能只判断“客户端时间是否晚于服务端时间”而要用绝对值差值来判断也就是代码里的Math.abs(now - ts)。只要差值在窗口内就认为是新鲜请求。nonce 这个字段本身怎么生成也有讲究。一般用 UUID 或随机字节转十六进制字符串核心要求是一次性、不可预测。有些团队图省事直接用时间戳当 nonce那就失去了意义因为同一条请求的 nonce 必须全局唯一。Redis 的过期时间最好和窗口时间一致。比如窗口是 5 分钟nonce 就存 5 分钟。过期后自动删除不会堆积。如果项目里没有 Redis也可以退一步用数据库表存 nonce给 nonce 字段建唯一索引靠数据库保证唯一性然后定期清理过期数据。但这种方式性能差一些并发高的时候容易成为瓶颈。4.3 客户端改造请求头与签名生成方式服务端改造只是第一步客户端也得跟着改。客户端每次请求需要额外生成三个参数。public class RequestSigner { private static final String SECRET_KEY 从安全存储中获取的密钥; public static MapString, String buildHeaders(String requestBody) { String timestamp String.valueOf(System.currentTimeMillis()); String nonce UUID.randomUUID().toString().replace(-, ); String content timestamp nonce requestBody; String signature HmacSHA256(SECRET_KEY, content); MapString, String headers new HashMap(); headers.put(X-Timestamp, timestamp); headers.put(X-Nonce, nonce); headers.put(X-Signature, signature); return headers; } private static String HmacSHA256(String key, String content) { try { Mac mac Mac.getInstance(HmacSHA256); SecretKeySpec spec new SecretKeySpec(key.getBytes(StandardCharsets.UTF_8), HmacSHA256); mac.init(spec); byte[] bytes mac.doFinal(content.getBytes(StandardCharsets.UTF_8)); StringBuilder sb new StringBuilder(); for (byte b : bytes) { sb.append(String.format(%02x, b)); } return sb.toString(); } catch (Exception e) { throw new RuntimeException(签名计算失败, e); } } }有两点必须提醒。第一密钥绝对不能硬编码在纯前端页面或者客户端安装包里否则攻击者直接反编译就能拿到签名密钥整个防重放体系就崩塌了。比较稳妥的做法是放在 App 的加固存储区或者通过服务端下发后存到系统安全存储区域。第二timestamp nonce body这个拼接顺序前后端必须完全一致有一点出入签名就对不上。这种问题在联调时特别容易踩坑所以前后端一定要有统一的签名字典。5. 上线效果与踩坑经历改造比我想象的费劲5.1 改造上线后重放请求全被拦住了改造上线后的效果是立竿见影的。我再把之前抓到的请求用 Replay 功能重放直接收到 401 拒绝结果。理由是同一个 nonce 已经被消费过了。我又尝试修改时间戳后重放因为没有重新生成正确的签名也照样被拦下来。这说明三层校验生效了。我同时加强了日志监控在拦截器里把拒绝的请求都打到单独的日志文件记录请求路径、来源 IP、拒绝原因。折腾了一个月之后我回顾日志发现被拦截的重放尝试占比还挺高尤其是有几天明显是有人用脚本对着接口做自动化探测。这说明不是危言耸听这种攻击就是在真实发生的。5.2 踩过的坑时钟偏移、老版本兼容、网关重试这套方案解决了不少问题但它也引入了新的坑我这里如实记录。第一个坑是客户端时钟偏移。有个用户手机时间设成了三天前结果所有请求都被拦截用户彻底没法控制设备。后来我在拦截器里加了一个例外如果客户端时间与服务端偏差超过窗口但请求携带的用户 token 是最近才签发的并且签名正确就放行一次。这个方案不是特别完美但能保证用户体验。更彻底的做法是对时客户端启动时从服务端拉一次标准时间然后本地用这个差值校正。第二个坑是老版本客户端兼容。App 升级不是一瞬间完成的总有人还停留在旧版本。旧版本不带新请求头直接进不了接口。我当时不能强制用户升级就做了一个过渡方案接口同时认两套逻辑旧版请求走原来的校验但记录警告日志。过渡两周后等旧版本活跃量降到几乎为零才完全关闭旧逻辑。第三个坑是网关重试导致的误杀。我们的 API 前面挂了一层网关网关在遇到超时时会自动重试 POST 请求。这本来是好意但它重试时会原样转发原始请求也就是同一个 nonce 会发两遍。第一遍成功第二遍就被拦截器拦了导致业务处理方看到的是“请求失败”。解决方式是让网关在重试时生成新的 nonce 并重新签名。如果你的网关是自己的记得配好这个逻辑。5.3 常见问题速查表以后遇到类似问题就这么排查最后整理一个速查表省得大家以后再踩同类型的坑问题现象可能原因排查方向解决办法正常用户请求偶发 401客户端时间与服务端偏差超过窗口查看时间戳字段与服务器时间差客户端启动时与服务端对时或放宽窗口同一请求被重复执行接口未做防重放抓包重放实验确认时间戳 nonce 签名三层校验网关重试导致误判网关重发相同 nonce 请求查看网关日志和拦截器日志网关重试时重新签名老客户端无法访问新的请求头不是必填查看客户端版本分布兼容期或强制升级Redis 内存增长明显nonce 默认过期时间太长查看 key 数量和 TTL缩短窗口时间定期清理请求参数被篡改签名校验没生效查看拦截器返回的异常码前后端统一签名规则我自己在这次项目里还养成了一个习惯凡是涉及写操作的接口尤其是支付回调和设备控制在写代码之前就先问自己三个问题这个操作能被重复提交吗重复提交的后果是什么如果后果不可接受那就要做幂等和防重放。这些问题想明白了方案基本就成型了。另外一个小技巧是拦截器里的所有校验逻辑都要保证性能可预期。Redis 的SETNX操作单次耗时在毫秒级对接口整体影响不大。如果你的接口 QPS 很高可以在拦截器前加一层本地缓存把校验过的 nonce 在本地再缓存几秒减少 Redis 的压力当然这会牺牲一点绝对安全性需要自己在安全和性能之间做平衡。我个人在这次实战里最大的体会是**安全上的问题往往不是被高超的攻击技术攻破的而是被那些“我觉得没人会这么干”的侥幸心理攻破的。**那次灯自己开关的事故带来的不只是用户投诉更是让我把安全测试纳入到了项目日常流程里。现在我做接口设计默认就会把防重放和幂等考虑进去而不是等出了问题再补。如果你也在维护一个看起来“没有攻击价值”的小项目我真心建议你早一点把这道防线加上投入不大回报却可能是在某个深夜帮你挡掉一次事故。
返回列表