diff --git a/data/pwncatrc b/data/pwncatrc index cc661cf..23a97f5 100644 --- a/data/pwncatrc +++ b/data/pwncatrc @@ -27,3 +27,4 @@ alias down download # "!ls" run "local ls" given the below directives shortcut ! local shortcut @ run + diff --git a/pwncat/__main__.py b/pwncat/__main__.py index 1a36f6c..3fdde6e 100644 --- a/pwncat/__main__.py +++ b/pwncat/__main__.py @@ -23,41 +23,19 @@ def main(): # Default log-level is "INFO" logging.getLogger().setLevel(logging.INFO) - parser = argparse.ArgumentParser( - prog="pwncat", - description=""" - A "living of the land"-based C2 platform. - - Aside from the "--config" argument, all other arguments are treated as a pwncat - command which is parsed after parsing the configuration script. - """, - ) - parser.add_argument( - "--config", "-c", help="A configuration script to execute after loading" - ) - args, rest = parser.parse_known_args() - # Build the victim object - pwncat.victim = Victim(args.config) + pwncat.victim = Victim() - # 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) + # 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" + ) - # Run any remaining command line arguments as one command - if rest: - pwncat.victim.command_parser.dispatch_line(shlex.join(rest)) - - # if no connection was established in the configuration, - # drop to the pwncat prompt. Don't allow raw access until - # a connection is made. + # Only continue if we successfully connected if not pwncat.victim.connected: - util.warn("no connection established, entering command mode") - pwncat.victim.state = util.State.COMMAND - if not pwncat.victim.connected: - util.error("no connection established. exiting.") exit(0) # Setup the selector to wait for data asynchronously from both streams @@ -69,6 +47,10 @@ def main(): 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: diff --git a/pwncat/commands/__init__.py b/pwncat/commands/__init__.py index f6883d5..ca17a0d 100644 --- a/pwncat/commands/__init__.py +++ b/pwncat/commands/__init__.py @@ -242,7 +242,7 @@ class CommandParser: traceback.print_exc() continue - def dispatch_line(self, line: str): + def dispatch_line(self, line: str, prog_name: str = None): """ Parse the given line of command input and dispatch a command """ # Account for blank or whitespace only lines @@ -284,11 +284,20 @@ class CommandParser: args = [a.encode("utf-8").decode("unicode_escape") for a in args] try: + if prog_name: + temp_name = command.parser.prog + command.parser.prog = prog_name + prog_name = temp_name + # Parse the arguments args = command.parser.parse_args(args) # Run the command command.run(args) + + if prog_name: + command.parser.prog = prog_name + except SystemExit: # The arguments were icncorrect return diff --git a/pwncat/commands/connect.py b/pwncat/commands/connect.py index 3429756..f4f9cae 100644 --- a/pwncat/commands/connect.py +++ b/pwncat/commands/connect.py @@ -1,7 +1,11 @@ #!/usr/bin/env python3 +from colorama import Fore import ipaddress import socket +import paramiko +from prompt_toolkit import prompt + import pwncat from pwncat import util from pwncat.commands.base import ( @@ -20,6 +24,13 @@ class Command(CommandDefinition): PROG = "connect" ARGS = { + "--exit": parameter( + Complete.NONE, action="store_true", help="Exit if not connection is made" + ), + "--config,-C": parameter( + Complete.NONE, + help="Path to a configuration script to execute prior to connecting", + ), "--listen,-l": parameter( Complete.NONE, action=StoreConstOnce, @@ -78,10 +89,20 @@ class Command(CommandDefinition): "--user,-u": parameter( Complete.NONE, help="The user to reconnect as; if this is a system method, this parameter is ignored.", - action=StoreForAction(["reconnect"]), + action=StoreForAction(["reconnect", "ssh"]), + ), + "--password,-P": parameter( + Complete.NONE, + help="The password for the specified user for SSH connections", + action=StoreForAction(["ssh"]), + ), + "--identity,-i": parameter( + Complete.NONE, + help="The private key for authentication for SSH connections", + action=StoreForAction(["ssh"]), ), } - DEFAULTS = {"action": "list"} + DEFAULTS = {"action": "none"} LOCAL = True def run(self, args): @@ -90,52 +111,157 @@ class Command(CommandDefinition): util.error("connect can only be called prior to an active connection!") return - if args.action == "listen": - util.progress(f"binding to {args.host}:{args.port}") - - # Create the socket server - server = socket.create_server((args.host, args.port), reuse_port=True) - + if args.config: try: - # Wait for a connection - (client, address) = server.accept() - except KeyboardInterrupt: - util.warn(f"aborting listener...") - return + # 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)) - util.success(f"received connection from {address[0]}:{address[1]}") - pwncat.victim.connect(client) - elif args.action == "connect": - util.progress(f"connecting to {args.host}:{args.port}") + if args.action == "none": + if pwncat.victim.client is None: + self.parser.print_help() + return - # Connect to the remote host - client = socket.create_connection((args.host, args.port)) + try: + if args.action == "listen": + if not args.host: + args.host = "0.0.0.0" - util.success(f"connection to {args.host}:{args.port} established") - pwncat.victim.connect(client) - elif args.action == "ssh": - raise NotImplementedError - elif args.action == "reconnect": - 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") + 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 - host_hash = host.hash - except ValueError: - host_hash = args.host - # Reconnect to the given host - try: - pwncat.victim.reconnect(host_hash) - except PersistenceError as exc: - util.error(f"{host_hash}: connection failed: {exc}") - return - else: - util.error(f"{args.action}: invalid action") + 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 and not 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( + pwncat.victim.config["privkey"] + ) + except: + password = prompt( + "RSA Private Key Passphrase: ", is_password=True + ) + key = paramiko.RSAKey.from_private_key_file( + pwncat.victim.config["privkey"], password + ) + + # Attempt authentication + try: + t.auth_publickey(args.user, key) + except paramiko.ssh_exception.AuthenticationException: + pass + else: + try: + t.auth_password(args.user, args.password) + except paramiko.ssh_exception.AuthenticationException: + pass + + if not t.is_authenticated(): + t.close() + sock.close() + util.error("authentication failed") + 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") + finally: + if pwncat.victim.client is None and args.exit: + raise SystemExit diff --git a/pwncat/commands/help.py b/pwncat/commands/help.py index 3137e2c..de78b85 100644 --- a/pwncat/commands/help.py +++ b/pwncat/commands/help.py @@ -10,6 +10,7 @@ class Command(CommandDefinition): PROG = "help" ARGS = {"topic": parameter(Complete.NONE, nargs="?")} + LOCAL = True def run(self, args): if args.topic: diff --git a/pwncat/remote/victim.py b/pwncat/remote/victim.py index 60aa7cd..c85e5c7 100644 --- a/pwncat/remote/victim.py +++ b/pwncat/remote/victim.py @@ -88,7 +88,7 @@ class Victim: "script", ] - def __init__(self, config_path: str): + def __init__(self): """ 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. """ @@ -143,7 +143,9 @@ class Victim: # The host object as seen by the database self.host: pwncat.db.Host = None - def reconnect(self, hostid: str): + def reconnect( + self, hostid: str, requested_method: str = None, requested_user: str = None + ): """ Reconnect to the host identified by the provided host hash. The host hash can be retrieved from the ``sysinfo`` command of a running ``pwncat`` session or from @@ -152,6 +154,11 @@ class Victim: information probed from the last time ``pwncat`` connected to it. :param hostid: the unique host hash generated from the last pwncat session + :param requested_method: the persistence method to utilize for reconnection, if not specified, + all methods will be tried in order until one works. + :param requested_user: the user to connect as. if any specified, all users will be tried in + order until one works. if no method is specified, only methods for this user + will be tried. """ # Create the database engine, and then create the schema @@ -169,6 +176,13 @@ class Victim: raise persist.PersistenceError("{hostid}: invalid host hash") for username, method in self.persist.installed: + if requested_method and requested_method != method.name: + continue + if requested_user and ( + (requested_user != "root" and method.system) + or (requested_user != username) + ): + continue try: util.progress( f"attempting host reconnection via {method.format(username)}" @@ -485,7 +499,7 @@ class Victim: """ util.progress("identifying init system") - with open("/proc/1/comm", "r") as filp: + with self.open("/proc/1/comm", "r") as filp: init = filp.read() if "systemd" in init: