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注入原理。
参考链接
Created at 2023-06-16T16:07:41+08:00