Smartbi RCE 分析

基本信息

Smartbi是广州思迈特软件有限公司旗下的商业智能BI和数据分析品牌,为企业客户提供一站式商业智能解决方案。Smartbi大数据分析产品融合BI定义的所有阶段,对接各种业务数据库、数据仓库和大数据分析平台,进行加工处理、分析挖掘和可视化展现;满足所有用户的各种数据分析应用需求,如大数据分析、可视化分析、探索式分析、复杂报表、应用分享等等。

Smartbi大数据分析平台存在远程命令执行漏洞,未经身份认证的远程攻击者可利用stub接口构造请求绕过补丁限制,进而控制JDBC URL,最终可导致远程代码执行或信息泄露。

引用自 奇安信NOX

影响版本

V7<= Smartbi <= V10.5.8

环境搭建

官网下载Smartbi V10.5.8即可,直接安装。

技术分析&调试

解包官网提供的补丁包,可以发现如下:

{
	"version": "1.0",
	"date": "2023-02-28 15:00:00",
	"patches": {
		"PATCH_20230228": {
			"desc": "修复了利用stub接口对 ‘DB2 命令执行漏洞’ 补丁进行绕过的远程命令执行漏洞 (Patch.20230228  @2023-02-28)",
			"desc_zh_TW": "修復了利用stub接口對 ‘DB2 命令執行漏洞’ 補丁進行繞過的遠程命令執行漏洞 (Patch.20230228  @2023-02-28)",
			"desc_en": "Fixed a remote command execution vulnerability in DB2 that used the stub interface (Patch.20230228  @2023-02-28)",
			"urls": [{
				"url": "*.stub",
				"rules": [{
					"type": "RejectStubPostPatchRule"
				}]
			}]
		},
		"PATCH_20221122": {
			"desc": "修复了 DB2 命令执行漏洞 (Patch.20221122  @2022-11-22)",
            "desc_zh_TW": "修復了 DB2 命令執行漏洞 (Patch.20221122  @2022-11-22)",
            "desc_en": "Fixed a DB2 command execution vulnerability. (Patch.20221122  @2022-11-22)",
			"urls": [{
				"url": "/vision/RMIServlet",
				"rules": [{
					"className": "DataSourceService",
					"methodName": "testConnectionList",
					"type": "RejectRMIParamsStringsPatchRule",
					"strings": ["clientRerouteServerListJNDIName"]
				},{
					"className": "DataSourceService",
					"methodName": "testConnection",
					"type": "RejectRMIParamsStringsPatchRule",
					"strings": ["clientRerouteServerListJNDIName"]
				}]
			}]
		},

可以看出来,补丁包对符合正则表达式*.stub 的url进行了处理,再根据补丁描述不难发现前一个补丁补的漏洞:DB2 命令执行漏洞。此处的漏洞应该是对其进行了绕过。

转到web.xml里面,*.stub是由RMIServlet进行处理的,且只有两个filter。

<servlet-mapping>
		<servlet-name>RMIServlet</servlet-name>
		<url-pattern>*.stub</url-pattern>
</servlet-mapping>

<filter-mapping>
		<filter-name>CacheFilter</filter-name>
		<url-pattern>*.stub</url-pattern>
	</filter-mapping>

<filter-mapping>
		<filter-name>GZIPFilter</filter-name>
		<url-pattern>*.stub</url-pattern>
	</filter-mapping>

继续查看web.xml,不难发现一些敏感接口均要经过CheckIsLoggedFilter,结合反编译的源码,猜测此filter为鉴权filter

<filter-mapping>
        <filter-name>CheckIsLoggedFilter</filter-name>
        <url-pattern>/vision/ExportServlet</url-pattern>
    </filter-mapping>
    <filter-mapping>
        <filter-name>CheckIsLoggedFilter</filter-name>
        <url-pattern>/vision/ExportHttpServlet</url-pattern>
    </filter-mapping>
    <filter-mapping>
        <filter-name>CheckIsLoggedFilter</filter-name>
        <url-pattern>/vision/DownloadExcelServlet</url-pattern>
    </filter-mapping>
    <filter-mapping>
        <filter-name>CheckIsLoggedFilter</filter-name>
        <url-pattern>/vision/MigrateServlet</url-pattern>
    </filter-mapping>
public class CheckIsLoggedFilter implements javax.servlet.Filter {
    private static IExtendCustomFilter customFilterChecker;
    private static final Logger LOG = Logger.getLogger(CheckIsLoggedFilter.class);
    private static final Map<String, List<String>> AUTHORITYMAP = new HashMap();

    /* loaded from: smartbi-FreeQuery.jar:smartbi/freequery/filter/CheckIsLoggedFilter$IExtendCustomFilter.class */
    public interface IExtendCustomFilter {
        int authorityFiltering(String str, String str2, HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse);
    }

    public void destroy() {
    }

    /* JADX WARN: Removed duplicated region for block: B:137:0x060f A[RETURN] */
    /*
        Code decompiled incorrectly, please refer to instructions dump.
        To view partially-correct code enable 'Show inconsistent code' option in preferences
    */
    public void doFilter(javax.servlet.ServletRequest r9, javax.servlet.ServletResponse r10, javax.servlet.FilterChain r11) throws java.io.IOException, javax.servlet.ServletException {
        /*
            Method dump skipped, instructions count: 1568
            To view this dump change 'Code comments level' option to 'DEBUG'
        */
        throw new UnsupportedOperationException("Method not decompiled: smartbi.freequery.filter.CheckIsLoggedFilter.doFilter(javax.servlet.ServletRequest, javax.servlet.ServletResponse, javax.servlet.FilterChain):void");
    }

    public static void handleAutoLogin(HttpServletRequest request) {
        if (FreeQueryModule.getInstance().getUserManagerModule().isLogged()) {
            return;
        }
        String headerUserName = Bootstrap.getHeaderUserName(request);
        if (StringUtil.isNullOrEmpty(headerUserName)) {
            return;
        }
        IState state = (IState) request.getSession().getAttribute("state");
        boolean isLogged = (state == null || state.getUser() == null) ? false : true;
        if (!isLogged) {
            String headerPassword = Bootstrap.getHeaderPassword(request);
            if (headerPassword == null) {
                headerPassword = SmartbiXDataSetUtil.OTHER;
            }
            FreeQueryModule.getInstance().getStateModule().doStartRequest(request);
            boolean isAutoLogin = FreeQueryModule.getInstance().getUserManagerModule().login(headerUserName, headerPassword);
            if (isAutoLogin) {
                request.setAttribute("isNeedAutoLogout", "true");
            }
        }
    }

而*.stub并未经过这个filter的处理,也就是未授权即可访问。

转到Smartbi的RMIServlet中有如下代码,进行GET请求时,携带jsonpCallback参数即可转到doPost方法,该方法通过RMIUtil.parseRMIInfo方法获取RMI信息,跟进。

protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException {
        String uri = req.getRequestURI();
        try {
            String jsonpCallback = req.getParameter("jsonpCallback");
            if (StringUtil.isNullOrEmpty(jsonpCallback)) {
              ...
            } else {
                doPost(req, resp);
            }
        } catch (IOException e) {
            LOG.error(uri + "\n" + e.getMessage(), e);
        }
    }

    /* JADX WARN: Finally extract failed */
    @Override // smartbi.framework.rmi.IRMIServlet
    public void doPost(HttpServletRequest request, HttpServletResponse resp) throws ServletException, IOException {
        RMIModule.getInstance().doStartRequest(request);
        TraceConfig traceConfig = (TraceConfig) request.getSession().getAttribute("TraceConfig");
        if (this.tracedetail && traceConfig == null) {
            traceConfig = new TraceConfig();
            request.getSession().setAttribute("TraceConfig", traceConfig);
        }
        RMIInfo rmiInfo = RMIUtil.parseRMIInfo(request, true);
        String className = rmiInfo == null ? null : rmiInfo.getClassName();
        String methodName = rmiInfo == null ? null : rmiInfo.getMethodName();
        String params = rmiInfo == null ? null : rmiInfo.getParams();
       ...
        try {
            String resultStr = processExecute(request, className, methodName, params);
            RMIModule.getInstance().doRollback();
            RMIModule.getInstance().doEndRequest(request);
            ...
            }
        } catch (Throwable th) {
            RMIModule.getInstance().doRollback();
            RMIModule.getInstance().doEndRequest(request);
            throw th;
        }
    }

RMIUtil.parseRMIInfo方法首先判断uri是否是/vision/RMIServlet,而后获取请求的className、methodName、params参数,并返回RMIInfo对象

public static RMIInfo parseRMIInfo(HttpServletRequest request, boolean forceParse) {
        if (!"/vision/RMIServlet".equals(request.getServletPath()) && !forceParse) {
            return null;
        }
        RMIInfo info = getRMIInfoFromRequest(request);
        if (info != null) {
            return info;
        }
        String className = request.getParameter("className");
        String methodName = request.getParameter("methodName");
        String params = request.getParameter(SimpleReportBO.EL_PARAMS);
        if (StringUtil.isNullOrEmpty(className) && StringUtil.isNullOrEmpty(methodName) && StringUtil.isNullOrEmpty(params) && request.getContentType() != null && request.getContentType().startsWith("multipart/form-data;")) {
            DiskFileItemFactory dfif = new DiskFileItemFactory();
            ServletFileUpload upload = new ServletFileUpload(dfif);
            String encodeString = null;
            try {
                List<FileItem> fileItems = upload.parseRequest(request);
                request.setAttribute(ATTR_KEY_UPLOAD_FILE_ITEMS, fileItems);
                for (FileItem fileItem : fileItems) {
                    if (fileItem.isFormField()) {
                        String itemName = fileItem.getFieldName();
                        String itemValue = fileItem.getString("UTF-8");
                        if ("className".equals(itemName)) {
                            className = itemValue;
                        } else if ("methodName".equals(itemName)) {
                            methodName = itemValue;
                        } else if (SimpleReportBO.EL_PARAMS.equals(itemName)) {
                            params = itemValue;
                        } else if ("encode".equals(itemName)) {
                            encodeString = itemValue;
                        }
                    }
                }
            } catch (FileUploadException | UnsupportedEncodingException e) {
                LOG.error(e.getMessage(), e);
            }
            if (!StringUtil.isNullOrEmpty(encodeString)) {
                String[] decode = (String[]) CodeEntry.decode(encodeString, true);
                className = decode[0];
                methodName = decode[1];
                params = decode[2];
            }
        }
        if (className == null && methodName == null) {
            className = (String) request.getAttribute("className");
            methodName = (String) request.getAttribute("methodName");
            params = (String) request.getAttribute(SimpleReportBO.EL_PARAMS);
        }
        RMIInfo info2 = new RMIInfo();
        info2.setClassName(className);
        info2.setMethodName(methodName);
        info2.setParams(params);
        request.setAttribute(ATTR_KEY_RMI_INFO, info2);
        return info2;
    }

而后调用processExecute方法,最终通过exceptionToNode方法通过反射调用了对应的方法

public String processExecute(HttpServletRequest request, String className, String methodName, String params) {
        Map<Integer, Integer> map;
        ClientService service = RMIModule.getInstance().getService(className);
        ClientService operationFailLogService = RMIModule.getInstance().getService("OperationLogService");
        String resultStr = null;
        JSONArray jsonParams = null;
        try {
        } catch (Exception ce) {
            if (Framework.getInstance().getExceptionHandler() != null) {
                return Framework.getInstance().getExceptionHandler().processException(ce);
            }
            if (className != null && methodName != null) {
                try {
                    ObjectNode resultNode = exceptionToNode(className, methodName, ce);
                    resultStr = resultNode.toString();
                    String failResult = resultNode.has("detail") ? resultNode.get("detail").asText((String) null) : null;
                    if (StringUtil.isNullOrEmpty(failResult)) {
                        failResult = resultNode.get("result").asText();
                    }
                    List<Object> listParams = null;
                    if (0 != 0) {
                        listParams = new ArrayList<>();
                        for (int i = 0; i < jsonParams.length(); i++) {
                            listParams.add(jsonParams.get(i));
                        }
                    }
                    Object[] objParams = {className, methodName, listParams, failResult};
                    operationFailLogService.executeInternal("addOperationFailLog", objParams);
                } catch (Exception e) {
                    LOG.error(e.getMessage(), e);
                }
            }
        }

public Object executeInternal(String methodName, Object[] objParams) {
        try {
            Method method = StringUtil.isNullOrEmpty(methodName) ? null : this.methodList.get(methodName);
            if (method == null) {
                throw new SmartbiException(CommonErrorCode.METHOD_NAME_ERROR).setDetail(StringUtil.replaceHTML(methodName));
            }
            Object result = method.invoke(this.module, objParams);
            return result;
        } catch (InvocationTargetException ex) {
            if (ex.getCause() instanceof SmartbiException) {

再来回顾一下补丁,补丁中说该漏洞是对钱一个漏洞的绕过,经过上面的分析可知*.stub接口无需身份验证,所以可以通过*.stub接口利用Smartbi内的反射调用到存在漏洞的类。

在补丁中有如下,通过全局搜索类名DataSourceService便可知道漏洞代码。

"PATCH_20221122": {
			"desc": "修复了 DB2 命令执行漏洞 (Patch.20221122  @2022-11-22)",
            "desc_zh_TW": "修復了 DB2 命令執行漏洞 (Patch.20221122  @2022-11-22)",
            "desc_en": "Fixed a DB2 command execution vulnerability. (Patch.20221122  @2022-11-22)",
			"urls": [{
				"url": "/vision/RMIServlet",
				"rules": [{
					"className": "DataSourceService",
					"methodName": "testConnectionList",
					"type": "RejectRMIParamsStringsPatchRule",
					"strings": ["clientRerouteServerListJNDIName"]
				},{
					"className": "DataSourceService",
					"methodName": "testConnection",
					"type": "RejectRMIParamsStringsPatchRule",
					"strings": ["clientRerouteServerListJNDIName"]
				}]
			}]
		},

该代码中的参数均为可控,故可以通过控制JDBC url的方式执行恶意代码,此时可以通过DB2执行代码。

public void testConnectionList(List<IDataSource> list) {
        for (IDataSource dataSource : list) {
            MetaDataServiceImpl.getInstance().testConnection(dataSource);
        }
    }

public void testConnection(IDataSource dataSource) {
        int preIndex;
        ISystemConfig systemConfig;
        DataSource ds = new DataSource();
        String url = dataSource.getUrl();
        ds.setId(UUIDGenerator.generate());
        ds.setName(dataSource.getName());
        ds.setAlias(dataSource.getAlias());
        ds.setDriver(dataSource.getDriver());
        ds.setDesc(dataSource.getDesc());
        ds.setDbCharset(dataSource.getDbCharset());
        ds.setUrl(url);
        ds.setUser(dataSource.getUser());
        ds.setDriverType(dataSource.getDriverType());
        ds.setMaxConnection(dataSource.getMaxConnection());
        ds.setValidationQuery(dataSource.getValidationQuery());
        ds.setPassword(dataSource.getPassword());
        ds.setTransactionIsolation(dataSource.getTransactionIsolation());
        ds.setValidationQueryMethod(dataSource.getValidationQueryMethod());
        ds.setAuthenticationType(dataSource.getAuthenticationType());
        ds.setExtendProp(dataSource.getExtendProp());
        ds.setDriverCatalog(dataSource.getDriverCatalog());
        if (dataSource.getPassword() == null && !StringUtil.isNullOrEmpty(dataSource.getId())) {
            DataSource dbDs = loadDataSource(dataSource.getId());
            ds.setPassword(dbDs.getPassword());
        }
        if (StringUtil.isNullOrEmpty(dataSource.getId()) && ds.getDriverType() == DBType.HADOOP_HIVE && (systemConfig = FreeQueryModule.getInstance().getSystemConfigService().getSystemConfig("MPP_SSH_CONFIG")) != null) {
            String longValue = systemConfig.getLongValue();
            if (StringUtils.isNotBlank(longValue)) {
                JSONObject jsonObject = JSONObject.fromString(longValue);
                if (jsonObject.has(SFTPConstants.HIVE_PASSWORD)) {
                    String pwd = jsonObject.getString(SFTPConstants.HIVE_PASSWORD);
                    if (StringUtils.isNotBlank(pwd)) {
                        ds.setPassword(pwd);
                    }
                }
            }
        }
        Connection conn = null;
        try {
            try {
                conn = ConnectionPool.getInstance().getConnection(ds);
                if (conn == null) {
                    throw new SmartbiException(CommonErrorCode.JDBC_DRIVER_ERROR).setDetail(ds.getDriver() + ":" + ds.getUrl());
                }
                if (DBType.PRESTO == dataSource.getDriverType()) {
                    PreparedStatement stat = JdbcUtil.prepareStatement(conn, "SELECT 1", dataSource.getDriverType());
                    try {
                        PreparedStatementWarp.executeQuery(stat, DBSQLUtil.createSQLLog(ds.getAlias(), SmartbiXDataSetUtil.OTHER, FreeQueryModule.getInstance().getStateModule(), "SELECT 1"));
                    } catch (Exception e) {
                        if (e instanceof SmartbiException) {
                            throw ((SmartbiException) e);
                        }
                        throw new SmartbiException(FreeQueryErrorCode.CONNECTION_POOL_NOT_INITIAL, e).setDetail(StringUtil.getLanguageValue("InvalidConnection"));
                    }
                } else if (DBType.CLICK_HOUSE == dataSource.getDriverType() && (preIndex = url.indexOf("clusterName=")) > -1) {
                    String clusterName = url.substring(preIndex + "clusterName=".length());
                    int suffixIndex = clusterName.indexOf("&");
                    if (suffixIndex > -1) {
                        clusterName = clusterName.substring(0, suffixIndex);
                    }
                    if (!StringUtil.isNullOrEmpty(clusterName)) {
                        String validSql = "drop table if exists t_testcluster on cluster " + clusterName;
                        PreparedStatement stat2 = JdbcUtil.prepareStatement(conn, validSql, dataSource.getDriverType());
                        try {
                            PreparedStatementWarp.executeQuery(stat2, DBSQLUtil.createSQLLog(ds.getAlias(), SmartbiXDataSetUtil.OTHER, FreeQueryModule.getInstance().getStateModule(), validSql));
                        } catch (Exception e2) {
                            if (e2.getLocalizedMessage().indexOf("Requested cluster '" + clusterName + "' not found") > -1) {
                                throw new SmartbiException(CommonErrorCode.CLICK_HOUSE_CLUSTER_NOT_FOUND, e2).setDetail(clusterName);
                            }
                            if (e2 instanceof SmartbiException) {
                                throw ((SmartbiException) e2);
                            }
                            throw new SmartbiException(FreeQueryErrorCode.CONNECTION_POOL_NOT_INITIAL, e2).setDetail(StringUtil.getLanguageValue("InvalidConnection"));
                        }
                    }
                }
            } catch (Exception e3) {
                if (e3 instanceof SmartbiException) {
                    throw ((SmartbiException) e3);
                }
                String detail = SmartbiXDataSetUtil.OTHER;
                if (e3 instanceof ClassNotFoundException) {
                    detail = StringUtil.getLanguageValue("DBDriverNoFound");
                }
                throw new SmartbiException(FreeQueryErrorCode.CONNECTION_POOL_NOT_INITIAL, e3).setDetail(detail + e3.getMessage());
            }
        } finally {
            if (conn != null) {
                try {
                    conn.close();
                } catch (Throwable th) {
                    Logger.getLogger(getClass()).debug(SmartbiXDataSetUtil.OTHER);
                }
            }
            if (!ds.getUrl().startsWith("JNDI:")) {
                ConnectionPool.getInstance().closePool(ds);
            }
        }
    }

题外话,我本身不懂Java那一套,只是按照粗浅的代码理解去分析漏洞,有机会去分析一下JNDI注入原理。

参考链接

https://www.smartbi.com.cn/patchinfo

Created at 2023-06-16T16:07:41+08:00

创建于:Friday, June 16,2023
最后修改于: Sunday, January 14,2024