class Command(CommandDefinition): """Alias an existing command with a new name. Specifying no alias or command will list all aliases. Specifying an alias with no command will remove the alias if it exists.""" def get_command_names(self): return [c.PROG for c in self.manager.parser.commands] PROG = "alias" ARGS = { "alias": Parameter(Complete.NONE, help="name for the new alias", nargs="?"), "command": Parameter( Complete.CHOICES, metavar="COMMAND", choices=get_command_names, help="the command the new alias will use", nargs="?", ), } LOCAL = True def run(self, manager, args): if args.alias is None: for name, command in manager.parser.aliases.items(): console.print( f" [cyan]{name}[/cyan] \u2192 [yellow]{command.PROG}[/yellow]" ) elif args.command is not None: # This is safe because of "choices" in the argparser manager.parser.aliases[args.alias] = [ c for c in manager.parser.commands if c.PROG == args.command ][0] else: del manager.parser.aliases[args.alias]
class Command(CommandDefinition): """Create key aliases for when in raw mode. This only works from platforms which provide a raw interaction (such as linux).""" PROG = "bind" ARGS = { "key": Parameter( Complete.NONE, metavar="KEY", type=KeyType, help="The key to map after your prefix", nargs="?", ), "script": Parameter( Complete.NONE, help="The script to run when the key is pressed", nargs="?", ), } LOCAL = True def run(self, manager, args): if args.key is None: for key, binding in manager.config.bindings.items(): console.print(f" [cyan]{key}[/cyan] = [yellow]{repr(binding)}[/yellow]") elif args.key is not None and args.script is None: if args.key in manager.config.bindings: del manager.config.bindings[args.key] else: manager.config.bindings[args.key] = args.script
class Command(CommandDefinition): """Download a file from the remote host to the local host""" PROG = "download" ARGS = { "source": Parameter(Complete.REMOTE_FILE), "destination": Parameter(Complete.LOCAL_FILE, nargs="?"), } def run(self, manager: "pwncat.manager.Manager", args): # Create a progress bar for the download progress = Progress( TextColumn("[bold cyan]{task.fields[filename]}", justify="right"), BarColumn(bar_width=None), "[progress.percentage]{task.percentage:>3.1f}%", "•", DownloadColumn(), "•", TransferSpeedColumn(), "•", TimeRemainingColumn(), ) if not args.destination: args.destination = os.path.basename(args.source) elif os.path.isdir(args.destination): args.destination = os.path.join( args.destination, os.path.basename(args.source) ) try: path = manager.target.platform.Path(args.source) length = path.stat().st_size started = time.time() with progress: task_id = progress.add_task( "download", filename=args.source, total=length, start=False ) with open(args.destination, "wb") as destination: with path.open("rb") as source: progress.start_task(task_id) util.copyfileobj( source, destination, lambda count: progress.update(task_id, advance=count), ) elapsed = time.time() - started console.log( f"downloaded [cyan]{util.human_readable_size(length)}[/cyan] " f"in [green]{util.human_readable_delta(elapsed)}[/green]" ) except (FileNotFoundError, PermissionError, IsADirectoryError) as exc: self.parser.error(str(exc))
class Command(CommandDefinition): """ Load modules from the specified directory. This does not remove currently loaded modules, but may replace modules which were already loaded. Also, prior to loading any specified modules, the standard modules are loaded. This normally happens only when modules are first utilized. This ensures that a standard module does not shadow a custom module. In fact, the opposite may happen in a custom module is defined with the same name as a standard module. """ PROG = "load" ARGS = { "path": Parameter( Complete.LOCAL_FILE, help="Path to a python package directory to load modules from", nargs="+", ) } DEFAULTS = {} LOCAL = True def run(self, manager: "pwncat.manager.Manager", args): manager.load_modules(args.path)
class Command(CommandDefinition): PROG = "shortcut" ARGS = { "prefix": Parameter(Complete.NONE, help="the prefix character used for the shortcut"), "command": Parameter(Complete.NONE, help="the command to execute"), } LOCAL = True def run(self, manager, args): for command in manager.parser.commands: if command.PROG == args.command: manager.parser.shortcuts[args.prefix] = command return self.parser.error(f"{args.command}: no such command")
class Command(CommandDefinition): """View info about a module""" PROG = "info" ARGS = { "module": Parameter( Complete.CHOICES, choices=get_module_choices, metavar="MODULE", help="The module to view information on", nargs="?", ) } def run(self, manager: "pwncat.manager.Manager", args): if not args.module and manager.config.module is None: console.log("[red]error[/red]: no module specified") return if args.module: try: module = next(manager.target.find_module(args.module, exact=True)) module_name = args.module except StopIteration: console.log(f"[red]error[/red]: {args.module}: no such module") return else: module = manager.config.module module_name = module.name.removeprefix("agnostic.") if self.manager.target is not None: module_name = module_name.removeprefix( self.manager.target.platform.name + "." ) console.print( f"[bold underline]Module [cyan]{module_name}[/cyan][/bold underline]" ) console.print( textwrap.indent(textwrap.dedent(module.__doc__.strip("\n")), " ") + "\n" ) table = Table("Argument", "Default", "Help", box=box.SIMPLE) for arg, info in module.ARGUMENTS.items(): if info.default is pwncat.modules.NoValue: default = "" else: default = info.default table.add_row(arg, str(default), info.help) console.print(table)
class Command(CommandDefinition): """ Leave a layer of execution from this session. Layers are normally added as sub-shells from escalation modules. """ PROG = "leave" ARGS = { "count": Parameter( Complete.NONE, type=int, default=1, nargs="?", help="number of layers to remove (default: 1)", ), "--all,-a": Parameter( Complete.NONE, action="store_true", help="leave all active layers", ), } def run(self, manager: "pwncat.manager.Manager", args): try: if args.all: args.count = len(manager.target.layers) for i in range(args.count): manager.target.layers.pop()(manager.target) manager.target.platform.refresh_uid() except IndexError: manager.target.log( "[yellow]warning[/yellow]: no more layers to leave")
class Command(CommandDefinition): """View info about a module""" PROG = "search" ARGS = { "module": Parameter( Complete.NONE, help="glob pattern", ) } def run(self, manager: "pwncat.manager.Manager", args): modules = list(manager.target.find_module(f"*{args.module}*")) min_width = max( len(module.name.removeprefix("agnostic.")) for module in modules) table = Table( Column(header="Name", style="cyan", min_width=min_width), Column(header="Description"), title="Results", box=box.MINIMAL_DOUBLE_HEAD, expand=True, ) for module in modules: # Rich will ellipsize the column, but we need to squeze # white space and remove newlines. `textwrap.shorten` is # the easiest way to do that, so we use a large size for # width. description = module.__doc__ if module.__doc__ is not None else "" module_name = module.name.removeprefix("agnostic.") if self.manager.target is not None: module_name = module_name.removeprefix( self.manager.target.platform.name + ".") table.add_row( f"[cyan]{module_name}[/cyan]", textwrap.shorten(description.replace("\n", " "), width=80, placeholder="..."), ) console.print(table)
class Command(CommandDefinition): """List known commands and print their associated help documentation.""" def get_command_names(self): try: # Because we are initialized prior to `manager.parser`, # we have to wrap this in a try-except block. yield from [cmd.PROG for cmd in self.manager.parser.commands] except AttributeError: return PROG = "help" ARGS = { "topic": Parameter(Complete.CHOICES, choices=get_command_names, nargs="?") } LOCAL = True def run(self, manager: "pwncat.manager.Manager", args): if args.topic: for command in manager.parser.commands: if command.PROG == args.topic: if command.parser is not None: command.parser.print_help() else: console.print(textwrap.dedent(command.__doc__).strip()) break else: table = Table( Column("Command", style="green"), Column("Description", no_wrap=True), box=rich.box.SIMPLE, ) for command in manager.parser.commands: doc = command.__doc__ if doc is None: doc = "" else: doc = textwrap.shorten( textwrap.dedent(doc).strip().replace("\n", ""), 60) table.add_row(command.PROG, doc) console.print(table)
class Command(CommandDefinition): """Set the currently used module in the config handler""" PROG = "use" ARGS = { "module": Parameter( Complete.CHOICES, choices=get_module_choices, metavar="MODULE", help="the module to use", ) } LOCAL = False def run(self, manager: "pwncat.manager.Manager", args): try: module = list(manager.target.find_module(args.module, exact=True))[0] except IndexError: console.log(f"[red]error[/red]: {args.module}: no such module") return manager.target.config.use(module)
class Command(CommandDefinition): """Set variable runtime variable parameters for pwncat""" def get_config_variables(self): options = ["state"] + list(self.manager.config.values) if self.manager.target is not None: options.extend(user.name for user in self.manager.target.iter_users()) if self.manager.config.module: options.extend(self.manager.config.module.ARGUMENTS.keys()) return options PROG = "set" ARGS = { "--password,-p": Parameter( Complete.NONE, action="store_true", help="set a user password", ), "--global,-g": Parameter( Complete.NONE, action="store_true", help="Set a global configuration", default=False, ), "variable": Parameter( Complete.CHOICES, nargs="?", choices=get_config_variables, metavar="VARIABLE", help="the variable name to modify", ), "value": Parameter(Complete.LOCAL_FILE, nargs="?", help="the value for the given variable"), } LOCAL = True def run(self, manager, args): if args.password and manager.target is None: manager.log( "[red]error[/red]: active target is required for user interaction" ) return elif args.password: if args.variable is None: found = False for user in manager.target.run("enumerate", types=["user"]): if user.password is not None: console.print( f" - [green]{user.name}[/green] -> [red]{repr(user.password)}[/red]" ) found = True if not found: console.log( "[yellow]warning[/yellow]: no known user passwords") else: user = manager.target.find_user(name=args.variable) if user is None: manager.target.log( "[red]error[/red]: {args.variable}: user not found") return console.print( f" - [green]{args.variable}[/green] -> [red]{repr(args.value)}[/red]" ) user.password = args.value manager.target.db.transaction_manager.commit() else: if args.variable is not None and args.value is not None: try: if manager.sessions and args.variable == "db": raise ValueError( "cannot change database with running session") manager.config.set(args.variable, args.value, getattr(args, "global")) if args.variable == "db": # Ensure the database is re-opened, if it was already manager.open_database() except ValueError as exc: console.log(f"[red]error[/red]: {exc}") elif args.variable is not None: value = manager.config[args.variable] console.print( f" [cyan]{args.variable}[/cyan] = [yellow]{repr(value)}[/yellow]" ) else: for name in manager.config: value = manager.config[name] console.print( f" [cyan]{name}[/cyan] = [yellow]{repr(value)}[/yellow]" )
class Command(CommandDefinition): """ Run a module. If no module is specified, use the module in the current context. You can enter a module context with the `use` command. Module arguments can be appended to the run command with `variable=value` syntax. Arguments are type-checked prior to executing, and the results are displayed to the terminal. To locate available modules, you can use the `search` command. To find documentation on individual modules including expected arguments, you can use the `info` command. """ PROG = "run" ARGS = { "--raw,-r": Parameter(Complete.NONE, action="store_true", help="Display raw results unformatted"), "--traceback,-t": Parameter(Complete.NONE, action="store_true", help="Show traceback for module errors"), "module": Parameter( Complete.CHOICES, nargs="?", metavar="MODULE", choices=get_module_choices, help="The module name to execute", ), "args": Parameter(Complete.NONE, nargs="*", help="Module arguments"), } def run(self, manager: "pwncat.manager.Manager", args): module_name = args.module if args.module is None and manager.config.module is None: console.log("[red]error[/red]: no module specified") return elif args.module is None: module_name = manager.config.module.name # Parse key=value pairs values = {} for arg in args.args: if "=" not in arg: values[arg] = True else: name, value = arg.split("=") values[name] = value # pwncat.config.locals.update(values) config_values = manager.config.locals.copy() config_values.update(values) try: result = manager.target.run(module_name, **config_values) if args.module is not None: manager.config.back() except pwncat.modules.ModuleFailed as exc: if args.traceback: console.print_exception() else: console.log(f"[red]error[/red]: module failed: {exc}") return except pwncat.modules.ModuleNotFound: console.log(f"[red]error[/red]: {module_name}: not found") return except pwncat.modules.ArgumentFormatError as exc: console.log(f"[red]error[/red]: {exc}: invalid argument") return except pwncat.modules.MissingArgument as exc: console.log(f"[red]error[/red]: missing argument: {exc}") return except pwncat.modules.InvalidArgument as exc: console.log(f"[red]error[/red]: invalid argument: {exc}") return if isinstance(result, list): result = [r for r in result if not r.hidden] elif result.hidden: result = None if args.raw: console.print(result) else: if result is None or (isinstance(result, list) and not result): console.log( f"Module [bold]{module_name}[/bold] completed successfully" ) return if not isinstance(result, list): result = [result] self.display_item(manager, title=module_name, results=result) def display_item(self, manager, title, results): """Display a possibly complex item""" console.print( f"[bold underline]Module '{title}' Results[/bold underline]") # Uncategorized or raw results categories = {} uncategorized = [] longform = [] # Organize results by category for result in results: if isinstance(result, pwncat.modules.Result) and result.is_long_form( manager.target): longform.append(result) elif (not isinstance(result, pwncat.modules.Result) or result.category(manager.target) is None): uncategorized.append(result) elif result.category(manager.target) not in categories: categories[result.category(manager.target)] = [result] else: categories[result.category(manager.target)].append(result) # Show uncategorized results first if uncategorized: console.print("[bold]Uncategorized Results[/bold]") for result in uncategorized: console.print("- " + result.title(manager.target)) # Show all other categories if categories: for category, results in categories.items(): console.print(f"[bold]{category}[/bold]") for result in results: console.print(f" - {result.title(manager.target)}") # Show long-form results in their own sections if longform: for result in longform: if result.category(manager.target) is None: console.print( f"[bold]{result.title(manager.target)}[/bold]") else: console.print( f"[bold]{result.category(manager.target)} - {result.title(manager.target)}[/bold]" ) console.print( textwrap.indent(result.description(manager.target), " "))
class Command(CommandDefinition): """ Attempt privilege escalation in the current session. This command may initiate new sessions along the way to attain the privileges of the requested user. Escalation can happen either directly or recursively. In either case, each escalation may result in either replacing the user in the active session or spawning a new session. In the case of a new session, you should have configurations such as `lhost` and `lport` set prior to executing the escalation in case a reverse connection is needed. After escalation, if multiple stages were executed within an active session, you can use the `leave` command to back out of the users. This is useful for situations where escalation was achieved through peculiar ways (such as executing a command from `vim`). The list command is simply a wrapper around enumerating "escalation.*". This makes the escalation workflow more straightforward, but is not required.""" PROG = "escalate" ARGS = { "command": Parameter( Complete.CHOICES, metavar="COMMAND", choices=["list", "run"], help="The action to take (list/run)", ), "--user,-u": Parameter( Complete.CHOICES, metavar="USERNAME", choices=get_user_choices, help="The target user for escalation.", ), "--recursive,-r": Parameter( Complete.NONE, action="store_true", help="Attempt recursive escalation through multiple users", ), } def run(self, manager: "pwncat.manager.Manager", args): if args.command == "help": self.parser.print_usage() return if args.command == "list": self.list_abilities(manager, args) elif args.command == "run": if args.user: args.user = manager.target.find_user(name=args.user) else: # NOTE: this should find admin regardless of platform args.user = manager.target.find_user(name="root") with manager.target.task( f"escalating to [cyan]{args.user.name}[/cyan]" ) as task: self.do_escalate(manager, task, args.user, args) def list_abilities(self, manager, args): """This is just a wrapper for `run enumerate types=escalate.*`, but it makes the workflow for escalation more apparent.""" found = False if args.user: args.user = manager.target.find_user(name=args.user) for escalation in manager.target.run("enumerate", types=["escalate.*"]): if args.user and args.user.id != escalation.uid: continue console.print(f"- {escalation.title(manager.target)}") found = True if not found and args.user: console.log( f"[yellow]warning[/yellow]: no direct escalations for {args.user.name}" ) elif not found: console.log("[yellow]warning[/yellow]: no direct escalations found") def do_escalate(self, manager: "pwncat.manager.Manager", task, user, args): """Execute escalations until we find one that works""" attempted = [] chain = [] manager.target.current_user() original_session = manager.target failed = [] while True: # Grab the current user in the active session current_user = manager.target.current_user() # Find escalations for users that weren't attempted already escalations = [ e for e in list(manager.target.run("enumerate", types=["escalate.*"])) if (e.source_uid is None or e.source_uid == current_user.id) and e not in failed and e.uid not in attempted ] if not escalations: try: # This direction failed. Go back up and try again. chain.pop().leave() continue except IndexError: manager.target.log( f"[red]error[/red]: no working escalation paths found for {user.name}" ) break # Attempt escalation directly to the target user if possible for escalation in (e for e in escalations if e.uid == user.id): try: original_session.update_task( task, status=f"attempting {escalation.title(manager.target)}" ) result = escalation.escalate(manager.target) time.sleep(0.1) manager.target.platform.refresh_uid() # Construct the escalation link link = Link(manager.target, escalation, result) # Track the result object either as a new session or a subshell if escalation.type == "escalate.replace": manager.target.layers.append(result) else: manager.target = result # Add our link to the chain chain.append(link) manager.log( f"escalation to {user.name} [green]successful[/green] using:" ) for link in chain: manager.print(f" - {link}") return result except ModuleFailed: failed.append(escalation) if not args.recursive: manager.target.log( f"[red]error[/red]: no working direct escalations to {user.name}" ) return # Attempt escalation to a different user and recurse for escalation in (e for e in escalations if e.uid != user.id): try: original_session.update_task( task, status=f"attempting {escalation.title(manager.target)}" ) result = escalation.escalate(manager.target) time.sleep(0.1) manager.target.platform.refresh_uid() link = Link(manager.target, escalation, result) if escalation.type == "escalate.replace": manager.target.layers.append(result) else: manager.target = result chain.append(link) attempted.append(escalation.uid) break except ModuleFailed: failed.append(escalation)
class Command(CommandDefinition): """ Interact and control active remote sessions. This command can be used to change context between sessions or kill active sessions which were established with the `connect` command. """ PROG = "sessions" ARGS = { "--list,-l": Parameter( Complete.NONE, action="store_true", help="List active connections", ), "--kill,-k": Parameter( Complete.NONE, action="store_true", help="Kill an active session", ), "session_id": Parameter( Complete.NONE, type=int, help="Interact with the given session", nargs="?", ), } LOCAL = True def run(self, manager: "pwncat.manager.Manager", args): if args.list or (not args.kill and args.session_id is None): table = Table(title="Active Sessions", box=box.MINIMAL_DOUBLE_HEAD) table.add_column("ID") table.add_column("User") table.add_column("Host ID") table.add_column("Platform") table.add_column("Type") table.add_column("Address") for session_id, session in manager.sessions.items(): ident = str(session_id) kwargs = {"style": ""} if session is manager.target: ident = "*" + ident kwargs["style"] = "underline" table.add_row( str(ident), session.current_user().name, str(session.hash), session.platform.name, str(type(session.platform.channel).__name__), str(session.platform.channel), **kwargs, ) console.print(table) return if args.session_id is None: console.log("[red]error[/red]: no session id specified") return # check if a session with the provided ``session_id`` exists or not if args.session_id not in manager.sessions: console.log(f"[red]error[/red]: {args.session_id}: no such session!") return session = manager.sessions[args.session_id] if args.kill: channel = str(session.platform.channel) session.close() console.log(f"session-{args.session_id} ({channel}) closed") return manager.target = session console.log(f"targeting session-{args.session_id} ({session.platform.channel})")
class Command(CommandDefinition): """ Connect to a remote victim. This command is only valid prior to an established connection. This command attempts to act similar to common tools such as netcat and ssh simultaneosly. Connection strings come in two forms. Firstly, pwncat can act like netcat. Using `connect [host] [port]` will connect to a bind shell, while `connect -l [port]` will listen for a reverse shell on the specified port. The second form is more explicit. A connection string can be used of the form `[protocol://][user[:password]@][host][:port]`. If a user is specified, the default protocol is `ssh`. If no user is specified, the default protocol is `connect` (connect to bind shell). If no host is specified or `host` is "0.0.0.0" then the `bind` protocol is used (listen for reverse shell). The currently available protocols are: - ssh - connect - bind The `--identity/-i` argument is ignored unless the `ssh` protocol is used. """ PROG = "connect" ARGS = { "--identity,-i": Parameter( Complete.LOCAL_FILE, help="The private key for authentication for SSH connections", ), "--listen,-l": Parameter( Complete.NONE, action="store_true", help="Enable the `bind` protocol (supports netcat-like syntax)", ), "--platform,-m": Parameter( Complete.NONE, help="Name of the platform to use (default: linux)", default="linux", ), "--port,-p": Parameter( Complete.NONE, help="Alternative port number argument supporting netcat-like syntax", ), "--list": Parameter( Complete.NONE, action="store_true", help="List installed implants with remote connection capability", ), "connection_string": Parameter( Complete.NONE, metavar="[protocol://][user[:password]@][host][:port]", help="Connection string describing the victim to connect to", nargs="?", ), "pos_port": Parameter( Complete.NONE, nargs="?", metavar="port", help="Alternative port number argument supporting netcat-like syntax", ), } LOCAL = True CONNECTION_PATTERN = re.compile( r"""^(?P<protocol>[-a-zA-Z0-9_]*://)?((?P<user>[^:@]*)?(?P<password>:(\\@|[^@])*)?@)?(?P<host>[^:]*)?(?P<port>:[0-9]*)?$""" ) def run(self, manager: "pwncat.manager.Manager", args): protocol = None user = None password = None host = None port = None used_implant = None if args.list: db = manager.db.open() implants = [] table = Table( "ID", "Address", "Platform", "Implant", "User", box=box.MINIMAL_DOUBLE_HEAD, ) # Locate all installed implants for target in db.root.targets: # Collect users users = {} for fact in target.facts: if "user" in fact.types: users[fact.id] = fact # Collect implants for fact in target.facts: if "implant.remote" in fact.types: table.add_row( target.guid, target.public_address[0], target.platform, fact.source, users[fact.uid].name, ) if not table.rows: console.log("[red]error[/red]: no remote implants found") else: console.print(table) return if args.connection_string: m = self.CONNECTION_PATTERN.match(args.connection_string) protocol = m.group("protocol") user = m.group("user") password = m.group("password") host = m.group("host") port = m.group("port") if protocol is not None: protocol = protocol.removesuffix("://") if host is not None and host == "": host = None if protocol is not None and args.listen: console.log( "[red]error[/red]: --listen is not compatible with an explicit connection string" ) return if ( sum( [ port is not None, args.port is not None, args.pos_port is not None, ] ) > 1 ): console.log("[red]error[/red]: multiple ports specified") return if args.port is not None: port = args.port if args.pos_port is not None: port = args.pos_port if port is not None: try: port = int(port.lstrip(":")) except ValueError: console.log(f"[red]error[/red]: {port}: invalid port number") return # Attempt to reconnect via installed implants if ( protocol is None and password is None and port is None and args.identity is None ): db = manager.db.open() implants = [] # Locate all installed implants for target in db.root.targets: if target.guid != host and target.public_address[0] != host: continue # Collect users users = {} for fact in target.facts: if "user" in fact.types: users[fact.id] = fact # Collect implants for fact in target.facts: if "implant.remote" in fact.types: implants.append((target, users[fact.uid], fact)) with Progress( "triggering implant", "•", "{task.fields[status]}", transient=True, console=console, ) as progress: task = progress.add_task("", status="...") for target, implant_user, implant in implants: # Check correct user if user is not None and implant_user.name != user: continue # Check correct platform if args.platform is not None and target.platform != args.platform: continue progress.update( task, status=f"trying [cyan]{implant.source}[/cyan]" ) # Attempt to trigger a new session try: session = implant.trigger(manager, target) manager.target = session used_implant = implant break except (ChannelError, PlatformError, ModuleFailed): continue if used_implant is not None: manager.target.log(f"connected via {used_implant.title(manager.target)}") else: try: manager.create_session( platform=args.platform, protocol=protocol, user=user, password=password, host=host, port=port, identity=args.identity, ) except (ChannelError, PlatformError) as exc: manager.log(f"connection failed: {exc}")