0x01 简介
这次分析的目标,是一个聚合了洗车、券包等能力的小程序支付链路。最初的切入点并不是“直接看到金额可改”,而是顺着一个更基础的问题往下挖:发往核心支付接口的签名,到底是服务端私有能力,还是客户端本地就能复现?很多时候,业务接口表面上看似“有签名保护”,实战中真正决定漏洞上限的,恰恰是这个签名能力归谁所有。如果签名只能由服务端私钥生成,那么前端即使把金额字段明文提交出来,也不一定能构成真正可利用的逻辑漏洞;但如果签名本身就在客户端本地完成,而且密钥还能被恢复出来,那整个问题的性质就完全变了。本篇文章记录的,就是一次从客户端加密配置入手,逐步恢复本地配置、还原签名算法、伪造合法请求,最后验证到真实业务订单付款阶段可被改价的完整过程。
本文仅用于技术学习与合规交流,严禁非法滥用。因违规使用产生的一切后果,由使用者自行承担,与作者无关。
现在只对常读和星标的才展示大图推送,建议大家把渗透安全HackTwo“设为星标”,否则可能就看不到了啦!
末尾可领取挖洞资料/加圈子 #渗透安全HackTwo
0x02 正文详情
从可疑接口开始:
在这个小程序里,多个业务最终都会落到统一的支付拉起如下接口:
POST /xxx/xxx/get-wx-payurl从抓包结果看,这个接口的请求体里直接带了大量高价值字段:{
"vaMchntNo": "...","vaTermNo": "...","totalAmount": 3200,"notifyUrl": "https://.../xxxxxStartWashCar","mchntOrderId": "...","subOpenId": "...","subAppId": "...","instMid": "xxx","tradeType": "xxxxxxx","msgType": "wx.unifiedOrder","loginToken": "..."}
这类接口第一眼很容易让人怀疑“金额是不是客户端直传”,但这里不能急着下结论。因为只要Authorization无法伪造,就算看得到这些参数,也未必能成功构造有效请求。所以,真正的第一步不是直接改金额,而是回答下面这个问题:这个请求的签名能力,到底掌握在谁手里?
逆向入口:签名函数并不在服务端
对源码做静态分析后,支付请求最终走到一个统一请求封装模块。继续往里追,可以看到请求头中的Authorization并不是服务端返回,而是客户端本地调用签名函数生成的:
Authorization = getAuthorization(body, appId, appKey, "post")这已经说明一个很关键的事实:
签名不是“服务器下发一次性签名”
也不是“客户端拿 token 去换临时签名”
而是客户端本地直接计算
这时候再问两个问题:
appId从哪里来?
appKey从哪里来?
如果这两个值来自安全硬件、服务端临时下发、或运行时不可导出存储,那么问题还没完全成立;但如果这两个值只是本地配置,那么整个签名保护就会被直接击穿。
配置不明文,但仍然在客户端本地
源码里没有直接把配置明文摆出来,而是用了一个典型的“本地密文配置 + 本地固定密钥解密”的做法。
对应逻辑大致可以抽象成这样:
const encryptedConfig = CONFIG.dataconst aesKey = "固定字符串"const plain = AES_ECB_Decrypt(encryptedConfig, aesKey)const runtimeConfig = JSON.parse(plain)
这一步的意义很重要。它说明配置虽然不是明文写死,但依然满足两个条件:
密文在客户端本地
解密密钥也在客户端本地
因此只要拿到小程序包,就能离线恢复出运行时配置。
恢复后的配置中,至少包含了这些关键字段:
appId
appKey
wxAppId
若干业务回调地址
出于安全和脱敏考虑,这里不在文章中展示完整值,只保留形态说明:
appId = 8a81...0990appKey = b8a3...30e5
到这里为止,签名能力已经从“理论可能”推进到了“可被客户端离线恢复”。
签名算法还原:不是障眼法,而是标准 HMAC
继续分析签名函数后,可以把它抽象成下面这套流程:
将请求体序列化为紧凑 JSON
对请求体做
SHA256
拼接:
appId + timestamp + nonce + bodyHash
对拼接结果做:
HMAC-SHA256(appKey, raw)
最终再进行 Base64 编码
伪代码如下:
body_json = json.dumps(body, separators=(",", ":"))body_hash = sha256(body_json).hexdigest()raw = appId + timestamp + nonce + body_hashsignature = base64(hmac_sha256(appKey, raw))
这一点非常关键,因为它把漏洞链从“看见字段”变成了“能够稳定重签并发包”。
也就是说,从这一刻开始,攻击者已经不再依赖官方小程序,也不再依赖服务端额外发放签名。
直接开始复现
编写用脚本重签,再回填到 Repeater。
使用脚本:sign_get_wx_payurl.py
先把修改后的完整 JSON 保存成body.json:
#部分代码参考 def canonical_json(data: Dict[str, Any]) -> str: return json.dumps(data, ensure_ascii=False, separators=(",", ":")) def now_timestamp() -> str: return dt.datetime.now().strftime("%Y%m%d%H%M%S") def random_nonce() -> str: return str(random.randint(10**9, 10**10 - 1)) def build_signature(body: Dict[str, Any], timestamp: str, nonce: str) -> str: body_json = canonical_json(body) body_hash = hashlib.sha256(body_json.encode("utf-8")).hexdigest() raw = f"{APP_ID}{timestamp}{nonce}{body_hash}".encode("utf-8") return base64.b64encode(hmac.new(APP_KEY.encode("utf-8"), raw, hashlib.sha256).digest()).decode("ascii") python sign_get_wx_payurl.py --body-file body.json脚本会输出:
Authorization
Content-Length
标准化后的
Body
把这三项替换回 Burp Repeater 再发包即可。
直接篡改金额:真实 32 元业务订单,付款阶段改成 1 角
第一步:先创建真实业务订单
选择低风险业务链路,重新调用下单接口生成一笔真实订单。这里以洗车订单为例,业务原始金额是:32 元
下单接口成功返回:
outer_order_number
payAmount = 32
对应业务回调地址
这说明前置订单是真实存在的,而不是伪造构造物。
第二步:在付款阶段改价
接着调用get-wx-payurl,但不按正常逻辑提交3200分,而是手工改成:
totalAmount = 10结果服务端仍然返回:- `respCode = 0000`- `totalAmount = 10`- 新的 `miniPayRequest`
这个漏洞的原理
这个问题的危险性,不在于它只是一处“前端把金额放进请求体”,而在于它同时满足了三层条件:
签名边界失效
不需要官方客户端,不需要服务端私钥,也不需要中间人条件,就可以本地生成合法Authorization。
业务订单绑定失效
支付接口没有强制要求mchntOrderId必须来自某个已校验完成的前置订单。
金额绑定失效
即便是已经创建好的真实业务订单,付款阶段仍然可以把金额改成攻击者指定值。这三点叠加后,漏洞已经不是“支付风险”而是完整的支付完整性缺陷。
0x03 总结
这次挖掘最值得记录的,不只是最后把金额改成了 1,而是整个问题是怎么一步一步被确认的。一开始看到的是支付接口里有明文金额字段,但这还不能直接等价于可利用漏洞;漏洞危害关键,在于先确认签名是客户端本地可复现的,再确认新订单号可以独立生成支付会话,最后再把验证推进到真实业务订单付款阶段。这也是我一直以来的一个挖掘思路:不要看到“前端直传金额”就急着报漏洞,先看签名边界,再看订单绑定,再看金额绑定,最后再决定漏洞定性。最后愿各位师傅在后续挖洞之路中,精准定位漏洞、高效挖掘,天天出高危、次次有收获,挖洞顺利、不踩坑、多拿奖励,共同提升支付业务安全测试能力!🔥喜欢这类文章或挖掘SRC技巧文章师傅可以点赞转发支持一下谢谢!