
我一度以为签名这种接口防篡改手段不会出太大问题——参数排序、拼串、加盐、取哈希看起来就是几行代码的事。真正把几十个小程序的接口评估过一轮之后我才发现网上常年有人在搜“小程序绕过sign签名思路”并不意外。因为在多数项目里签名并没有想象中那么牢靠甚至从发布那一刻起就是一个“能正常验签、但极易复现”的状态。这篇文章就把签名验签这套机制的原理、失败原因和加固方案完整拆开讲。本身有自营小程序、正在设计接口签名或者想给自家接口做一次安全排雷的开发者都建议认真过一遍。看完你会明白 sign 到底防得住什么、防不住什么以及为什么很多“正常”的签名在攻击者眼里等于透明。我不提供任何未授权场景下的绕过手法只把失效成因讲透并给出对应的加固清单。提示本文出现的案例均来自自研项目的安全评估记录全部做了脱敏处理。未授权环境下对他人线上系统做绕过测试属于违法违规行为请勿把文中的评估思路套用到任何非自有系统上。1. 签名验签到底防的是什么别把它的边界搞错了1.1 签名不是加密而是身份声明和完整性的双重校验先纠正一个最常见的认知偏差很多人把“接口有签名”理解成“接口是加密的”其实签名和加密是完全两码事。加密的目的是让别人看不懂原始内容签名的目的是让别人改不了原始内容同时确认“这个请求确实来自知道密钥的那一方”。小程序场景里客户端和服务端之间走的是 HTTPS传输层已经承担了加密工作。签名要解决的是另一个问题请求到了服务端之后服务端怎么确认这段业务数据比如下单参数、金额、用户ID在传输过程中没有被第三方篡改并且确实是自家小程序发出来的。更好理解的类比是签名是给请求内容盖的“防伪章”不是给请求内容上“锁”。防伪章的价值取决于盖章用的印泥、印章的存放方式以及收件人到底有没有认真核对章的真伪。这三个环节只要有一个偷懒章就白盖了。1.2 签名挡不住的那几类攻击签名在安全体系里的定位没有那么万能。我用一张表把常见攻击类型和签名的作用边界列清楚省得大家把全部希望都押在这一个环节上攻击类型签名能挡住吗原因说明参数篡改能挡只要签名覆盖了被改动的字段验签必然失败重放攻击只能挡一部分只有协议里做了时间戳窗口和 nonce 去重才能挡住伪造请求基本挡不住密钥和算法一旦可从本地拿到攻击者能原样生成合法签名自动化爬取挡不住签名只校验请求合法性不解决高频请求和身份风险问题业务逻辑漏洞挡不住比如越权改单、优惠券重复使用签名完全无感知这张表你应该记在心里。很多人花了大力气把签名做得非常复杂结果忘记在服务端做权限校验、频率限制和业务风控最后接口豁开一个大口子签名根本救不了。1.3 为什么“绕过sign”的话题常年有热度热度来源于两个特别现实的点。一方面小程序代码整体下发到终端理论上所有逻辑都能被拿到逆向难度天然比纯服务端接口低另一方面大部分开发团队对签名系统缺少安全设计意识把密钥直接写死在代码里也不做动态下发、不做代码混淆。两方面一叠加sign 就成了整个接口安全链里最容易被突破的一个环节。这里有个更扎心的结论“绕过”能成为高频话题真正暴露的不是攻击者有多强而是大量开发者在签名设计时埋了太多显而易见的问题。理解这一点是往下看所有内容的前提。2. 一条请求从客户端到服务端sign签名的完整生命周期2.1 客户端生成签名的标准套路几乎每个小程序的签名方案框架都是同一个套路收集所有业务参数包括业务字段、时间戳、随机数 nonce。把所有参数按字典序排列。按 keyvalue 的方式拼接成字符串。在拼接结果末尾加上密钥 appSecret。对整个字符串做单向哈希常见 MD5 或 SHA转成大写作为 sign。一段伪代码长这样const payload { timestamp: Math.floor(Date.now() / 1000), nonce: generateNonce(), userId: u_123456, amount: 100 }; // 1. 对 key 排序 const sortedKeys Object.keys(payload).sort(); // 2. 拼接成 query 风格字符串 const rawStr sortedKeys .map(key ${key}${payload[key]}) .join() key${appSecret}; // 3. 计算摘要 const sign md5(rawStr).toUpperCase();这段代码几乎存在于大量线上小程序项目中区别只是字段名、排序规则和哈希算法。可以说签名方案的“骨架”高度统一而安全性差异完全取决于几个细节密钥怎么存、nonce 是否真随机、时间戳是否参与、服务端是否校验、参数覆盖是否完整。这里解释一下为什么通常要求排序后拼接。哈希算法对字符串顺序极敏感同样的参数只要换一下位置sign 就会变服务端就验不过。客户端和服务端必须遵守完全相同的规则字典序排序就是最简单实用的约定。2.2 签名在 HTTP 请求里的常见携带方式签名通常通过 header 或参数传递。常见的有三种全部参数都放 query直接/api/order/create?userId...amount...sign...业务参数放 body把 timestamp、nonce、sign 放 header所有内容放 bodysign 放在 body 末尾从安全性角度放在哪个位置差别不大关键问题是服务端验签时必须从同一份数据重建原始字符串。这一点恰恰是后续排查时最容易踩的坑。比如请求 body 里的 JSON 键值顺序在客户端和服务端解析后可能不一样。如果服务端没有按“固定 key 排序”去构建原串而是用反序列化之后的 map 循环取值一旦遍历顺序不稳定验签就会失败。于是有些开发者为了“省事”会在验签时把部分字段排除掉。这一删往往就删出了安全缺口。2.3 服务端验签顺序和容易埋雷的容错逻辑一套相对完整的验签逻辑应该是这样的从协议中取出 timestamp、nonce、sign。先校验 timestamp 是否在时间窗口内比如正负 5 分钟。检查 nonce 是否已经用过用缓存去重。用同样的规则重建原始字符串。重新计算摘要与 sign 对比。业务参数解析正常才进入业务逻辑。但我在实际评估中经常看到砍掉第 2 步和第 3 步、只保留排序和摘要对比的实现。一开始看不出问题请求都能正常验签通过直到重放攻击发生时才发现缺口。还有更隐蔽的错误时间戳做了校验但 nonce 去重只做“校验不存储”等于没查重。或者用“全局随机数”做 nonce但服务端没有设置过期清理缓存越积越大最后因为性能问题把去重功能下线。值得专门强调的是很多人为了兼容客户端传参时缺字段的情况会在服务端组装原始串时把 null、undefined、空字符串做归一化处理比如某个参数没有就自动补空串。这个看似友好的容错实际上给了攻击者构造数据的自由度——只要把不想用的字段全部置空签名一样能过但业务参数已经被替换成别的值了。3. sign签名一年到头失效十有八九输在这几个地方3.1 密钥永远跟着安装包下发到用户终端小程序运行在用户手机或设备端代码和资源都会被打包下发。只要密钥出现在客户端代码里无论写在常量、配置文件还是注释里对会做逆向的人而言基本等于公开。有人会问代码里写密钥打包后不是有混淆吗很多小程序的“混淆”只是变量名替换字符串常量依然留在包里。我在评估自研项目时最常做的一件基础事就是把自己的离线包里 grep 一轮sign、secret、key这些关键词结果在只做了简单压缩、连字符串编码都没有的包里直接能找到完整的算法实现和密钥。根源上把对称静态密钥放进客户端本身就是一个悖论服务端要验签客户端就必须知道同一个秘密可客户端把秘密交出去了这个秘密就已经掌握在任何人手里。只有把密钥从本地代码中挪走才能从根本上解决这个问题比如由服务端动态下发。3.2 协议层没做防重放、时间戳形同虚设即便密钥没有直接泄露签名设计还常犯一个低级错误时间戳虽然参与了签名但服务端不校验时间戳的新鲜度或者 nonce 设成固定值再或者签名只覆盖部分业务参数。这几个问题单拆开看都会被攻击者巧妙地放大。举一个“签名白签”的例子某个接口的签名只覆盖 userId 和 timestamp但把 amount 字段漏掉了。那么请求里的 amount 被改成任意值只要 userId、timestamp、sign 三个字段保持原样服务端照样验签通过。这种字段遗漏现象在很多业务接口里很常见——因为开发者在联调时发现某些字段总是变化为了省事就干脆把它从参与签名的字段列表里剔除了。再举一个重放例子服务端只验签名不验时间窗我用工具抓到一条订票成功的请求参数一字不改重放几次服务端照样接单。没做 nonce 去重的情况下高频重放会造成大量异常订单。我把这个问题概括成一句话很多签名系统能防篡改但防不了复制粘贴。3.3 缺少代码自保护逆向门槛太低签名安全还有一个常被忽略的配套项代码混淆、调试器检测、反模拟器检测。如果这几项一样都不做攻击者用调试工具打开你的小程序在 sign 函数处下个断点就能看到完整的拼接逻辑和变量值几乎不存在“算法不明”的情况。但这块我不会展开讲逆向细节因为这对开发者的唯一价值是理解“如何提升自己代码的逆向门槛”。简单给个结论没有代码自保护就谈不上签名安全但加了自保护也不能替代前两节的协议层修复。三层是叠加关系不是替代关系。4. 抗绕过签名机制的加固清单按优先级落地4.1 第一优先级密钥不落地从根上消除本地密钥具体做法分三步用户登录或进入小程序时调用独立接口获取动态密钥有效期设为 30 分钟左右。服务端动态生成一个与用户、会话、设备指纹绑定的密钥不写在包内只放在内存或加密存储中。对敏感请求采用“服务端私钥签名、客户端只保存公钥用于验签”的非对称设计把核心签名计算放在服务端或者用受控的动态密钥。动态下发密钥的收益在于即便请求被完整抓到拿到的密钥也只是短时间有效一次轮换就能让旧密钥失效持续抬高伪造成本。但这里有个容易做糊的点动态密钥必须与用户态绑定。如果所有用户共用同一个动态密钥那动态下发就只是一层没意义的形式等于把写死的常量挪了个位置而已。4.2 第二优先级协议层把“防重放”和“全参数覆盖”做实这块是可以直接照着改的清单时间戳校验窗口建议设置在 5 分钟以内同时预留客户端与服务器之间的时钟偏差容忍值。nonce 必须真随机或单调递增服务端做一次性去重缓存 5 到 10 分钟过期自动清理。签名的原始串必须覆盖所有业务参数不许以“可空字段”为由漏掉。空值也要有统一表示方式比如 null 统一转空字符串。这里也顺手补一个常见问题验签时不要把参数重新序列化成 JSON 再重建签名而应该使用“从原始请求中按 key 排序取得的各参数值”或“收到的原始字符串片段”来重组。两者在规则一致性上的差别会直接影响兼容性和安全性。我见过不少团队在联调阶段被 JSON key 顺序折腾最后干脆把“按自己序列化后的整串 body 去验签”结果客户端每次发来的 key 顺序只要不同验签就失败气得整个团队骂签名方案。对这种情况正确思路永远是验签使用排序后的 keyvalue 拼接而不是原样 body 字符串。这样客户端和服务端各自序列化都不会出错。4.3 第三优先级密钥轮换与风控联动动态下发密钥之后还需要一套异常检测做兜底。把签名相关数据接入风控指标就能在自动化攻击真正规模化之前拦住一部分请求。风控信号含义处置动作签名验签失败率飙升可能有人在尝试逆向或伪造临时封禁 IP 或账号通知开发排查同一签名大量重复请求存在重放嫌疑按账号或设备限流拒绝重复请求nonce 重复率异常客户端随机数实现有问题或已被重放介入审计并考虑下发新密钥高频请求加新设备指纹疑似自动化操作触发二次验证比如短信或滑块验证这块经常被开发者忽视但实际上它才是整个系统的兜底。签名做得再强也只是证明“请求携带了有效凭证”而风控负责判断“这个凭证背后的人是否可信”。两者配合才能拦住自动化工具批量刷接口的场面。4.4 第四优先级客户端自保护增加逆向成本在小程序环境里能做到的自保护动作包括代码混淆变量名替换、字符串编码、控制流平坦化。反调试在 sign 函数等关键位置检测调试环境发现则走异常分支。环境检测禁止在模拟器或非官方调试环境中运行核心流程。证书锁定请求固定服务端证书降低中间人抓包改包的便利性。不要指望这些手段能拦住真正的高手它们的作用是大幅提高普通脚本仔的动手门槛把“逆向一次的成本”抬到超过直接通过接口拿数据的收益。对绝大多数业务来说这个成本差已经足够过滤掉 90% 的批量攻击。5. 复盘一次签名评估失效链路暴露的过程和修复验证5.1 发现的现象同一条订单请求可以被反复提交某个自研小程序项目做安全评估时观察到很典型的现象同一条创建订单的请求包保持参数不变反复提交服务端每次都返回成功。当时第一反应就是签名可能存在但协议层八成没有做防重放或者时间戳、nonce 没有真正参与校验。这种情况下的问题不一定出在“签名算不出来”而在于“签名验得太宽松”。5.2 定位过程从请求字段到本地代码审阅先从抓包结果看请求里带了 timestamp、nonce、sign表面上是很标准的三件套。但把 timestamp 改成很久以前的时间服务端依然验签通过——说明服务端根本没有对时间窗口做校验。再用固定的 timestamp 和 nonce 重放同一条请求服务端每次都能正常处理——说明 nonce 也没有做一次性去重等于把防重放的两道闸全拆了。接着利用项目自带的调试权限在小程序代码里对 sign 函数做了一次静态审阅。发现签名串只覆盖了 userId 和 timestamp 两个字段业务里的金额字段完全没有参与签名。也就是说签名只保证“userId 和 timestamp 没被改”至于金额改成多少服务端根本不关心。这三个问题叠加起来效果就非常直观攻击者可以拿着一条合法请求改掉订单金额再用原来的 timestamp、nonce、sign 原样提交服务端毫无察觉。整套签名形同虚设。5.3 修复动作与验证结果四条加固措施的效果针对上面暴露的三个弱点我按优先级做了四件事服务端补上 timestamp 校验窗口设置为 5 分钟。nonce 改为服务端缓存一次性去重缓存时间 10 分钟过期自动清理。把全部业务字段纳入签名覆盖范围统一规定空值的表示方式。密钥从写死的常量改为登录时动态下发短时密钥并绑定用户会话。修复后重新执行之前的验证用例结果如下表验证动作修复前修复后重放同一条请求成功返回签名无效修改金额字段但保留原 sign成功返回签名无效使用过期 timestamp成功返回请求过期重复使用同一 nonce成功返回签名无效这套修复把当时暴露出来的问题几乎全部堵住了。这次复盘就是想说明一个规律签名失效很少是单一原因通常是一连串设计缺口的组合。评估时要找的是整条链路而不是某单个点。我个人在实际操作里最深的体会是签名安全没有一劳永逸的银弹。协议层加固、密钥动态下发、风控联动、客户端自保护四层都要做谁都不能省。而且每次改动验签规则后一定要把旧的验证用例全部跑一遍——很多“紧急修复”都是改完就上线结果正常用户先被拦在外面那场面可比被攻击还难看。如果你正在维护带 sign 签名的接口不妨动手做个实验把线上请求抓下来只改一个业务字段不重新计算签名直接提交看服务端认不认。这一条测试基本就能判断你的签名方案是不是真的在干活。