diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f50910..06f8c57 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and simply didn't have the time to go back and retroactively create one. - Added query-string arguments to connection strings for both the entrypoint and the `connect` command. - Added Enumeration States to allow session-bound enumerations +- Added background listener API and commands ([#43](https://github.com/calebstewart/pwncat/issues/43)) ## [0.4.3] - 2021-06-18 Patch fix release. Major fixes are the correction of file IO for LinuxWriters and diff --git a/pwncat/commands/__init__.py b/pwncat/commands/__init__.py index 5239f6f..3d7dc2b 100644 --- a/pwncat/commands/__init__.py +++ b/pwncat/commands/__init__.py @@ -793,9 +793,14 @@ class CommandLexer(RegexLexer): """Build the RegexLexer token list from the command definitions""" root = [] - for command in commands: + sorted_commands = sorted(commands, key=lambda cmd: len(cmd.PROG), reverse=True) + for command in sorted_commands: root.append( - ("^" + re.escape(command.PROG), token.Name.Function, command.PROG) + ( + "^" + re.escape(command.PROG) + "( |$)", + token.Name.Function, + command.PROG, + ) ) mode = [] if command.ARGS is not None: diff --git a/pwncat/commands/listener_new.py b/pwncat/commands/listener_new.py new file mode 100644 index 0000000..7433efa --- /dev/null +++ b/pwncat/commands/listener_new.py @@ -0,0 +1,118 @@ +#!/usr/bin/env python3 +from rich.prompt import Confirm + +import pwncat +from pwncat.util import console +from pwncat.manager import ListenerState +from pwncat.commands import Complete, Parameter, CommandDefinition + + +class Command(CommandDefinition): + """ + Create a new background listener. Background listeners will continue + listening while you do other things in pwncat. When a connection is + established, the listener will either queue the new channel for + future initialization or construct a full session. + + If a platform is provided, a session will automatically be established + for any new incoming connections. If no platform is provided, the + channels will be queued, and can be initialized with the 'listeners' + command. + + If the drop_duplicate option is provided, sessions which connect to + a host which already has an active session with the same user will + be automatically dropped. This facilitates an infinite callback implant + which you don't want to pollute the active session list. + """ + + PROG = "listen" + ARGS = { + "--count,-c": Parameter( + Complete.NONE, + type=int, + help="Number of sessions a listener should accept before automatically stopping (default: infinite)", + ), + "--platform,-m": Parameter( + Complete.NONE, + type=str, + help="Name of the platform used to automatically construct a session for a new connection", + ), + "--ssl": Parameter( + Complete.NONE, + action="store_true", + default=False, + help="Wrap a new listener in an SSL context", + ), + "--ssl-cert": Parameter( + Complete.LOCAL_FILE, + help="SSL Server Certificate for SSL wrapped listeners", + ), + "--ssl-key": Parameter( + Complete.LOCAL_FILE, + help="SSL Server Private Key for SSL wrapped listeners", + ), + "--host,-H": Parameter( + Complete.NONE, + help="Host address on which to bind (default: 0.0.0.0)", + default="0.0.0.0", + ), + "port": Parameter( + Complete.NONE, + type=int, + help="Port on which to listen for new listeners", + ), + "--drop-duplicate,-D": Parameter( + Complete.NONE, + action="store_true", + help="Automatically drop sessions with hosts that are already active", + ), + } + LOCAL = True + + def _drop_duplicate(self, session: "pwncat.manager.Session"): + + for other in session.manager.sessions.values(): + if ( + other is not session + and session.hash == other.hash + and session.platform.getuid() == other.platform.getuid() + ): + session.log("dropping duplicate session") + return False + + return True + + def run(self, manager: "pwncat.manager.Manager", args): + + if args.drop_duplicate: + established = self._drop_duplicate + + if args.platform is None: + manager.print( + "You have not specified a platform. Connections will be queued until initialized with the 'listeners' command." + ) + if not Confirm.ask("Are you sure?"): + return + + with console.status("creating listener..."): + listener = manager.create_listener( + protocol="socket", + platform=args.platform, + host=args.host, + port=args.port, + ssl=args.ssl, + ssl_cert=args.ssl_cert, + ssl_key=args.ssl_key, + established=established, + count=args.count, + ) + + while listener.state is ListenerState.STOPPED: + pass + + if listener.state is ListenerState.FAILED: + manager.log( + f"[red]error[/red]: listener startup failed: {listener.failure_exception}" + ) + else: + manager.log(f"new listener created for {listener}") diff --git a/pwncat/commands/listeners.py b/pwncat/commands/listeners.py new file mode 100644 index 0000000..6ae676a --- /dev/null +++ b/pwncat/commands/listeners.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +from rich import box +from rich.table import Table +from rich.prompt import Prompt + +import pwncat +from pwncat.util import console +from pwncat.manager import Listener, ListenerError, ListenerState +from pwncat.commands import Complete, Parameter, CommandDefinition + + +class Command(CommandDefinition): + """ + Manage active or stopped background listeners. This command + is only used to interact with established listeners. For + creating new listeners, use the "listen" command instead. + """ + + PROG = "listeners" + ARGS = { + "--all,-a": Parameter( + Complete.NONE, + action="store_true", + help="Show all listeners when listing (default: hide stopped)", + ), + "--kill,-k": Parameter( + Complete.NONE, action="store_true", help="Stop the given listener" + ), + "--init,-i": Parameter( + Complete.NONE, action="store_true", help="Initialize pending channels" + ), + "id": Parameter( + Complete.NONE, + type=int, + nargs="?", + help="The specific listener to interact with", + ), + } + LOCAL = True + + def _init_channel(self, manager: pwncat.manager.Manager, listener: Listener): + """Initialize pending channel""" + + # Grab list of pending channels + channels = list(listener.iter_channels()) + if not channels: + manager.log("no pending channels") + return + + manager.print(f"Pending Channels for {listener}:") + for ident, channel in enumerate(channels): + manager.print(f"{ident}. {channel}") + + manager.print("\nPress C-c to stop initializing channels.") + + platform = "linux" + + try: + while True: + if not any(chan is not None for chan in channels): + manager.log("all pending channels configured") + break + + ident = int( + Prompt.ask( + "Channel Index", + choices=[ + str(x) + for x in range(len(channels)) + if channels[x] is not None + ], + ) + ) + if channels[ident] is None: + manager.print("[red]error[/red]: channel already initialized.") + continue + + platform = Prompt.ask( + "Platform Name", + default=platform, + choices=["linux", "windows", "drop"], + show_default=True, + ) + + if platform == "drop": + manager.log(f"dropping channel: {channels[ident]}") + channels[ident].close() + channels[ident] = None + continue + + try: + listener.bootstrap_session(channels[ident], platform) + channels[ident] = None + except ListenerError as exc: + manager.log(f"channel bootstrap failed: {exc}") + channels[ident].close() + channels[ident] = None + except KeyboardInterrupt: + manager.print("") + pass + finally: + for channel in channels: + if channel is not None: + listener.bootstrap_session(channel, platform=None) + + def _show_listener(self, manager: pwncat.manager.Manager, listener: Listener): + """Show detailed information on a listener""" + + # Makes printing the variables a little more straightforward + def dump_var(name, value): + manager.print(f"[yellow]{name}[/yellow] = {value}") + + # Dump common state + dump_var("address", str(listener)) + + state_color = "green" + if listener.state is ListenerState.FAILED: + state_color = "red" + elif listener.state is ListenerState.STOPPED: + state_color = "yellow" + + dump_var( + "state", + f"[{state_color}]" + + str(listener.state).split(".")[1] + + f"[/{state_color}]", + ) + + # If the listener failed, show the failure message + if listener.state is ListenerState.FAILED: + dump_var("[red]error[/red]", repr(str(listener.failure_exception))) + + dump_var("protocol", repr(listener.protocol)) + dump_var("platform", repr(listener.platform)) + + # A count of None means infinity, annotate that + if listener.count is not None: + dump_var("remaining", listener.count) + else: + dump_var("remaining", "[red]infinite[/red]") + + # Number of pending channels + dump_var("pending", listener.pending) + + # SSL settings + dump_var("ssl", repr(listener.ssl)) + if listener.ssl: + dump_var("ssl_cert", repr(listener.ssl_cert)) + dump_var("ssl_key", repr(listener.ssl_key)) + + def run(self, manager: "pwncat.manager.Manager", args): + + if (args.kill or args.init) and args.id is None: + self.parser.error("missing argument: id") + + if args.kill and args.init: + self.parser.error("cannot use both kill and init arguments") + + if args.id is not None and (args.id < 0 or args.id >= len(manager.listeners)): + self.parser.error(f"invalid listener id: {args.id}") + + if args.kill: + # Kill the specified listener + with console.status("stopping listener..."): + manager.listeners[args.id].stop() + manager.log(f"stopped listener on {str(manager.listeners[args.id])}") + return + + if args.init: + self._init_channel(manager, manager.listeners[args.id]) + return + + if args.id is not None: + self._show_listener(manager, manager.listeners[args.id]) + return + + table = Table( + "ID", + "State", + "Address", + "Platform", + "Remaining", + "Pending", + title="Listeners", + box=box.MINIMAL_DOUBLE_HEAD, + ) + + for ident, listener in enumerate(manager.listeners): + + if listener.state is ListenerState.STOPPED and not args.all: + continue + + if listener.count is None: + count = "[red]inf[/red]" + else: + count = str(listener.count) + + table.add_row( + str(ident), + str(listener.state).split(".")[1], + f"[blue]{listener.address[0]}[/blue]:[cyan]{listener.address[1]}[/cyan]", + str(listener.platform), + count, + str(listener.pending), + ) + + console.print(table) diff --git a/pwncat/manager.py b/pwncat/manager.py index 81b0d2c..19c7a4c 100644 --- a/pwncat/manager.py +++ b/pwncat/manager.py @@ -22,6 +22,8 @@ import signal import socket import fnmatch import pkgutil +import datetime +import tempfile import threading import contextlib from io import TextIOWrapper @@ -32,7 +34,11 @@ import ZODB import zodburi import rich.progress import persistent.list +from cryptography import x509 +from cryptography.x509.oid import NameOID from prompt_toolkit.shortcuts import confirm +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa import pwncat.db import pwncat.facts @@ -119,7 +125,18 @@ class Listener(threading.Thread): """ Queue of channels waiting to be initialized in the case of an unidentified platform """ self._session_lock: threading.Lock = threading.Lock() - def iter_sessions(count: Optional[int] = None) -> Generator["Session", None, None]: + def __str__(self): + return f"[blue]{self.address[0]}[/blue]:[cyan]{self.address[1]}[/cyan]" + + @property + def pending(self) -> int: + """Retrieve the number of pending channels""" + + return self._channel_queue.qsize() + + def iter_sessions( + self, count: Optional[int] = None + ) -> Generator["Session", None, None]: """ Synchronously iterate over new sessions. This generated will yield sessions until no more sessions are found on the queue. @@ -132,14 +149,20 @@ class Listener(threading.Thread): :rtype: Generator[Session, None, None] """ - while count: + while True: + if count is not None and count <= 0: + break + try: yield self._session_queue.get(block=False, timeout=None) - count -= 1 + if count is not None: + count -= 1 except queue.Empty: return - def iter_channels(count: Optional[int] = None) -> Generator["Channel", None, None]: + def iter_channels( + self, count: Optional[int] = None + ) -> Generator["Channel", None, None]: """ Synchronously iterate over new channels. This generated will yield channels until no more channels are found on the queue. @@ -152,10 +175,14 @@ class Listener(threading.Thread): :rtype: Generator[Channel, None, None] """ - while count: + while True: + if count is not None and count <= 0: + break + try: yield self._channel_queue.get(block=False, timeout=None) - count -= 1 + if count is not None: + count -= 1 except queue.Empty: return @@ -211,6 +238,7 @@ class Listener(threading.Thread): while True: try: self._session_queue.put_nowait(session) + break except queue.Full: try: self._session_queue.get_nowait() @@ -237,6 +265,8 @@ class Listener(threading.Thread): self.count = 0 self._stop_event.set() + self.join() + def run(self): """Execute the listener in the background. We have to be careful not to trip up the manager, as this is running in a background thread.""" @@ -248,7 +278,7 @@ class Listener(threading.Thread): server = self._ssl_wrap(raw_server) # Set a short timeout so we don't block the thread - server.settimeout(0.1) + server.settimeout(1) self.state = ListenerState.RUNNING @@ -267,8 +297,7 @@ class Listener(threading.Thread): channel = self._bootstrap_channel(client) # If we know the platform, create the session - if self.platform is not None: - self.bootstrap_session(channel, platform=self.platform) + self.bootstrap_session(channel, platform=self.platform) except ListenerError as exc: # this connection didn't establish; log it self.manager.log( @@ -315,7 +344,71 @@ class Listener(threading.Thread): """Wrap the given server socket in an SSL context and return the new socket. If the ``ssl`` option is not set, this method simply returns the original socket.""" - return server + if not self.ssl: + return server + + if self.ssl_cert is None and self.ssl_key is not None: + self.ssl_cert = self.ssl_key + if self.ssl_key is None and self.ssl_cert is not None: + self.ssl_key = self.ssl_cert + + if self.ssl_cert is None or self.ssl_key is None: + with tempfile.NamedTemporaryFile("wb", delete=False) as filp: + self.manager.log( + f"generating self-signed certificate at {repr(filp.name)}" + ) + + key = rsa.generate_private_key(public_exponent=65537, key_size=2048) + filp.write( + key.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.TraditionalOpenSSL, + encryption_algorithm=serialization.NoEncryption(), + ) + ) + + # Literally taken from: https://cryptography.io/en/latest/x509/tutorial/ + subject = issuer = x509.Name( + [ + x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), + x509.NameAttribute(NameOID.COUNTRY_NAME, "US"), + x509.NameAttribute( + NameOID.STATE_OR_PROVINCE_NAME, "California" + ), + x509.NameAttribute(NameOID.LOCALITY_NAME, "San Francisco"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, "My Company"), + x509.NameAttribute(NameOID.COMMON_NAME, "mysite.com"), + ] + ) + cert = ( + x509.CertificateBuilder() + .subject_name(subject) + .issuer_name(issuer) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.datetime.utcnow()) + .not_valid_after( + datetime.datetime.utcnow() + datetime.timedelta(days=365) + ) + .add_extension( + x509.SubjectAlternativeName([x509.DNSName("localhost")]), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + + filp.write(cert.public_bytes(serialization.Encoding.PEM)) + + self.ssl_cert = filp.name + self.ssl_key = filp.name + + try: + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(self.ssl_cert, self.ssl_key) + + return context.wrap_socket(server) + except ssl.SSLError as exc: + raise ListenerError(str(exc)) def _close_socket(self, raw_server: socket.socket, server: socket.socket): """Close the listener socket""" @@ -666,7 +759,7 @@ class Manager: self.parser = CommandParser(self) self.interactive_running = False self.db: ZODB.DB = None - self.prompt_lock = threading.RLock() + self.listeners: List[Listener] = [] # This is needed because pwntools captures the terminal... # there's no way officially to undo it, so this is a nasty @@ -928,7 +1021,7 @@ class Manager: try: self.target.platform.interactive_loop(interactive_complete) except RawModeExit: - pass + interactive_complete.set() try: raise exception_queue.get(block=False) @@ -1008,6 +1101,8 @@ class Manager: listener.start() + self.listeners.append(listener) + return listener def create_session(self, platform: str, channel: Channel = None, **kwargs): diff --git a/pwncat/platform/windows.py b/pwncat/platform/windows.py index f9da021..84e175b 100644 --- a/pwncat/platform/windows.py +++ b/pwncat/platform/windows.py @@ -852,6 +852,9 @@ function prompt { except EOFError: self.channel.send(b"\rexit\r") interactive_complete.wait() + except KeyboardInterrupt: + # This should only happen during an EOFError above + pass finally: pwncat.util.pop_term_state() diff --git a/test.py b/test.py index 1d6e061..ac8bb30 100755 --- a/test.py +++ b/test.py @@ -29,5 +29,6 @@ with pwncat.manager.Manager("data/pwncatrc") as manager: listener = manager.create_listener( protocol="socket", host="0.0.0.0", port=4444, platform="windows" ) + listener = manager.create_listener(protocol="socket", host="0.0.0.0", port=9999) manager.interactive()