契约锁电子签章系统QVD-2025-27432代码分析(pdfverifier远程代码执行)

免责声明

本文档所述漏洞详情及复现方法仅限用于合法授权的安全研究和学术教育用途。任何个人或组织不得利用本文内容从事未经许可的渗透测试网络攻击或其他违法行为。

漏洞描述:

该漏洞源于电子签章系统在处理特定数据格式时,存在解析差异导致安全机制被绕过,远程未经身份验证的攻击者可在服务器上执行任意系统命令

影响版本:

4.0.x <= 契约锁 <= 4.3.7 && 补丁版本 < 1.3.8

4.3.8 <= 契约锁 <= 5.3.x && 补丁版本 < 2.1.8

代码分析:

漏洞入口点在privapp.jar包中的com.qiyuesuo.api中的PdfVerifierController中的

image

在doverify中函数中,会有几个if判断文件类型

image

然后我们跟入到ofd文件处理方式中的verify函数中

public OfdVerifyResponse verify(byte[] ofdBytes) throws Exception {
    // 生成临时OFD文件路径,使用UUID确保唯一性
    String ofdPath = OfdStrucSignatureUtils.TMP_FILE_DICTIONARY + UUID.randomUUID().toString() + OfdStrucUtils.OFD_FILE_TYPE;
    // 生成解压目录路径,同样使用UUID确保唯一性
    String decomprarePath = OfdStrucSignatureUtils.TMP_FILE_DICTIONARY + UUID.randomUUID().toString();
    
    try {
        try {
            // 检查输入的字节数组是否为有效的OFD文件
            if (OfdStrucSignatureUtils.isOfd(ofdBytes)) {
                // 将字节数组写入临时OFD文件
                FileUtils.writeByteArrayToFile(new File(ofdPath), ofdBytes);
                
                // 创建解压目录
                File decomprareFile = new File(decomprarePath);
                decomprareFile.mkdir();
                
                // 解压OFD文件到指定目录(OFD本质是ZIP格式)
                FileZipUtils.decomprare(ofdPath, decomprarePath);
                
                // 调用核心验证方法,传入解压后的目录路径
                OfdVerifyResponse verify = verify(decomprarePath);
                
                // 清理资源:删除解压目录
                try {
                    FileUtils.deleteDirectory(new File(decomprarePath));
                } catch (Exception e) {
                    // 忽略删除异常
                }
                
                // 清理资源:删除临时OFD文件
                try {
                    FileUtils.forceDelete(new File(ofdPath));
                } catch (Exception e2) {
                    // 忽略删除异常
                }
                
                // 返回验证结果
                return verify;
            }
            
            // 如果不是有效OFD文件,返回无效响应
            return OfdVerifyResponse.invalidOfd();
        } catch (Exception e3) {
            // 处理验证过程中发生的异常
            OfdVerifyResponse errorSignature = OfdVerifyResponse.errorSignature();
            
            // 异常发生时的资源清理
            try {
                FileUtils.deleteDirectory(new File(decomprarePath));
            } catch (Exception e4) {
                // 忽略删除异常
            }
            
            try {
                FileUtils.forceDelete(new File(ofdPath));
            } catch (Exception e5) {
                // 忽略删除异常
            }
            
            // 返回签名错误响应
            return errorSignature;
        }
    } catch (Exception e) {
        // 重新抛出异常(这层捕获有些冗余,可优化)
        throw e;
    }
}

我们可以看在第一个if判断中,他会将文件流写入ofdpath中,然后创建解压路径之后,将文件解压到对应文件,但问题出在了isOfd函数中,跟进去看一下

public static boolean isOfd(byte[] b) {
    // 生成临时文件路径,用于存储待检查的OFD文件
    String ofdPath = TMP_FILE_DICTIONARY + UUID.randomUUID().toString() + OFD_FILE_TYPE;
    // 生成临时解压目录路径,用于解压OFD文件内容
    String decomprarePath = TMP_FILE_DICTIONARY + UUID.randomUUID().toString();
    
    try {
        try {
            // 将字节数组写入临时文件
            org.apache.commons.io.FileUtils.writeByteArrayToFile(new File(ofdPath), b);
            
            // 创建解压目录
            File decomprareFile = new File(decomprarePath);
            decomprareFile.mkdir();
            
            // 调用工具类解压临时OFD文件到指定目录
            // OFD文件本质上是ZIP格式,解压后可检查其内部结构
            FileZipUtils.decomprare(ofdPath, decomprarePath);
            
            // 获取解压目录中的文件列表
            File file = new File(decomprarePath);
            
            // 过滤文件列表,查找包含OFD主文档文件名的文件
            // OFD规范要求主文档名为"OFD.xml"(或类似名称)
            File[] listFiles = file.listFiles(new FileFilter() {
                @Override
                public boolean accept(File pathname) {
                    return pathname.getName().contains(OfdStrucUtils.OFD_MAIN_FILE);
                }
            });
            
            // 判断是否恰好找到一个主文档文件
            // 这是验证OFD格式的关键条件
            boolean z = listFiles != null && listFiles.length == 1;
            
            // 清理资源:删除解压目录
            try {
                org.apache.commons.io.FileUtils.deleteDirectory(new File(decomprarePath));
            } catch (Exception e) {
                // 忽略删除异常,确保主逻辑不受影响
            }
            
            // 返回验证结果
            return z;

我们发现在判断isofd中提前进行了一次文件解压,并且看代码在创建解压路径的时候也没有判断目录穿越的情况,所以就可以实现路径穿越后文件上传,虽然最后isofd判断失败返回false,但是再次之前文件已经解压上传成功

但是我们在分析代码的时候可以发现是springboot项目,所以没法直接写webshell来rce,这时候看大佬文章提出就是有三种方法可以考虑,一是写模板文件,但是该项目没有配置视图解析模板,所以pass。二是往jdk的某些地方写文件然后初始化类,但是并不通用,pass。三是写计划任务,但是只针对linux且用户权限不够(只是普通用户qiyuesuo)

这里很厉害的就是利用了契约锁补丁是热加载这一机制

image

补丁热加载(Hot Patching / Hot Reloading) 是一种在软件不停止运行的情况下,动态加载、更新代码或修复补丁的技术。其核心目标是避免服务中断,同时快速修复 bug、更新功能或调整配置,广泛应用于需要高可用性的系统(如服务器、分布式服务、嵌入式设备等)。

所以我们可以通过覆盖补丁文件/opt/qiyuesuo/security/private-security-patch.jar从而达到一个代码执行的效果

但是还需要知道怎么添加恶意代码到补丁包中才会rce,所以再次分析一下private-security-patch.jar

image

跟到reload和registerQVDLogic中

image

这里加载jar包

image

 

image

 

然后跟进到registerQVDLogic->registerFilterLogic

image

仔细看一下registerFilterLogic,发现就是当类名前缀为com.qiyuesuo.security.patch.filter.logic时,就会实例化这个类,并且可以执行到改类的static代码块,所以我们只需要在正常的private-security-patch.jar里加入com.qiyuesuo.security.patch.filter.logic.xxx的static恶意代码,就可以执行成功

License破解:

在我们获取到契约锁的源码安装的时候,会有一个让我们输入license的一个验证过程

一般源码中是会包含一个LICENSE文件的,所以我们在源码中查找读取LICENSE文件的地方,最后找到是在private-core-1.0.0-SNAPSHOT.jarLicenseUtil类中

我们首先分析一下license是如何生成加密的

image

根据上面的代码我们发现,主要的加密就是在license初始化的时候会生成一个随机数作为identifier,然后设置一个过期时间,最后用aes加密后写到license文件中

并且还定义了resolveLicense方法用于解析LICENSE:先aes解密,然后字符串转换为json格式

image

现在我们跟进license类有哪些字段(因为这里license类不在private-core-1.0.0-SNAPSHOT.jar中,jadx无法直接跳转到该类,在private-api-1.0.0-SNAPSHOT.jar类中)

image

现在我们知道了license有哪些字段和如何加密,现在看一看在安装过程中是如何校验license,定位到privoss.jar中的SetupController(之前在寻找license的时候找到的)

我们发现最主要的是在最后一步"配置失败,请确认linecse是否正确",说明在setLicesne中就是安装过程中校验的license的过程

image

setLicesne:先读取license文件,然后调用调用上面的resolveLicense()方法解析传入的license,接着三个判断校验

image

根据上面License类的字段以及判断条件,构造如下的字符串:这里的时间戳expireTime设置的1767110400000,转换过来就是2025年12月31日identifiertokensecret设置不能为空

{"licenseId":"123456","token":"abcd","secret":"xxx","version":"4.3","identifier":"xxx","companyName":test","expireTime":1767110400000,"expire":true,"config":{"contract":true,"print":false,"seal":false,"hybrid":true,"faceSign":true,"fee":false},"sales":"kk","cmaxMark":false,"icmaxMark":false,"pcmaxMark":false,"ipcmaxMark":false,"operator":"ec审批通过","licenseType":"com"}

最后提供脚本:

from Crypto.Cipher import AES
from Crypto.Util.Padding import pad
import base64
import json
from typing import Dict, Any

def encrypt_data(data: Dict[str, Any], key: bytes) -> str:
    """
    AES-ECB加密JSON数据并返回Base64编码结果
    
    Args:
        data: 要加密的字典数据
        key: 16字节的AES密钥
        
    Returns:
        Base64编码的加密字符串
    """
    # 参数校验
    if len(key) != 16:
        raise ValueError("AES key must be 16 bytes long")

    # 序列化JSON数据,保持中文和布尔值格式
    plaintext = json.dumps(
        data, 
        ensure_ascii=False, 
        separators=(',', ':'),  # 紧凑格式,无多余空格
        sort_keys=True          # 按键排序保证输出一致性
    )

    # 初始化AES加密器(ECB模式)
    cipher = AES.new(key, AES.MODE_ECB)

    # PKCS7填充并加密
    padded_data = pad(plaintext.encode('utf-8'), AES.block_size)
    encrypted_bytes = cipher.encrypt(padded_data)

    # Base64编码
    return base64.b64encode(encrypted_bytes).decode('utf-8')

# 示例数据
SAMPLE_DATA = {
    "licenseId": "555",
    "token": "AAAAAAAAAAAAAA",
    "secret": "aaaaaaaaaaaaa",
    "version": "4.3",
    "identifier": "xxx",
    "companyName": "test",
    "expireTime": 1767110400000,
    "expire": True,
    "config": {
        "contract": True,
        "print": False,
        "seal": False,
        "hybrid": True,
        "faceSign": True,
        "fee": False
    },
    "sales": "kk",
    "cmaxMark": False,
    "icmaxMark": False,
    "pcmaxMark": False,
    "ipcmaxMark": False,
    "operator": "ec审批通过",
    "licenseType": "com"
}

# 示例密钥(16字节)
SAMPLE_KEY = b'xxxxxxxxxxxxxxxx'

if __name__ == '__main__':
    try:
        encrypted_result = encrypt_data(SAMPLE_DATA, SAMPLE_KEY)
        print("加密结果:", encrypted_result)
    except Exception as e:
        print(f"加密过程中发生错误: {str(e)}")

总结:

个人认为该漏洞利用条件还是有点严苛

  • 首先就是你必须之前安装过这个补丁包,后续的文件覆盖才能成功
  • 其次就是如果是windows环境部署的话,上传路径太难获取,和linux不同

参考文章:

https://www.cnblogs.com/cwkiller/p/19000543

posted @ 2025-07-29 11:23  Zephyr07  阅读(82)  评论(0)    收藏  举报