From f4b988d7ba44fd948ba935f8dabd92a0ada5f7fb Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Sun, 13 Jun 2021 22:09:41 -0400 Subject: [PATCH 01/14] Initial implementation of ssl-wrapped socket --- pwncat/channel/__init__.py | 7 ++++++- pwncat/channel/bind.py | 2 ++ pwncat/channel/socket.py | 12 ++++++++++-- pwncat/channel/ssl_bind.py | 22 ++++++++++++++++++++++ test.py | 9 ++++----- 5 files changed, 44 insertions(+), 8 deletions(-) create mode 100644 pwncat/channel/ssl_bind.py diff --git a/pwncat/channel/__init__.py b/pwncat/channel/__init__.py index 803a2ed..1985350 100644 --- a/pwncat/channel/__init__.py +++ b/pwncat/channel/__init__.py @@ -581,7 +581,10 @@ def create(protocol: Optional[str] = None, **kwargs) -> Channel: or kwargs["host"] == "0.0.0.0" or kwargs["host"] is None ): - protocols.append("bind") + if "certfile" in kwargs or "keyfile" in kwargs: + protocols.append("ssl-bind") + else: + protocols.append("bind") else: protocols.append("connect") else: @@ -600,8 +603,10 @@ from pwncat.channel.ssh import Ssh # noqa: E402 from pwncat.channel.bind import Bind # noqa: E402 from pwncat.channel.socket import Socket # noqa: E402 from pwncat.channel.connect import Connect # noqa: E402 +from pwncat.channel.ssl_bind import SSLBind # noqa: E402 register("socket", Socket) register("bind", Bind) register("connect", Connect) register("ssh", Ssh) +register("ssl-bind", SSLBind) diff --git a/pwncat/channel/bind.py b/pwncat/channel/bind.py index eaf7b18..1a6c678 100644 --- a/pwncat/channel/bind.py +++ b/pwncat/channel/bind.py @@ -51,6 +51,8 @@ class Bind(Socket): self._socket_connected(client) except KeyboardInterrupt: raise ChannelError(self, "listener aborted") + except socket.error as exc: + raise ChannelError(self, str(exc)) finally: self.server.close() diff --git a/pwncat/channel/socket.py b/pwncat/channel/socket.py index e817837..5239bd1 100644 --- a/pwncat/channel/socket.py +++ b/pwncat/channel/socket.py @@ -15,13 +15,13 @@ utilize this class to instantiate a session via an established socket. manager.interactive() """ import os +import ssl import errno import fcntl import socket import functools from typing import Optional - from pwncat.channel import Channel, ChannelError, ChannelClosed @@ -92,11 +92,14 @@ class Socket(Channel): while written < len(data): try: written += self.client.send(data[written:]) - except BlockingIOError: + except (BlockingIOError, ssl.SSLWantWriteError, ssl.SSLWantReadError): pass except BrokenPipeError as exc: self._connected = False raise ChannelClosed(self) from exc + except (ssl.SSLEOFError, ssl.SSLSyscallError, ssl.SSLZeroReturnError): + self._connected = False + raise ChannelClosed(self) from exc return len(data) @@ -125,6 +128,11 @@ class Socket(Channel): try: data = data + self.client.recv(count) return data + except ssl.SSLWantReadError: + return data + except (ssl.SSLEOFError, ssl.SSLSyscallError, ssl.SSLZeroReturnError): + self._connected = False + raise ChannelClosed(self) from exc except socket.error as exc: if exc.args[0] == errno.EAGAIN or exc.args[0] == errno.EWOULDBLOCK: return data diff --git a/pwncat/channel/ssl_bind.py b/pwncat/channel/ssl_bind.py new file mode 100644 index 0000000..8704677 --- /dev/null +++ b/pwncat/channel/ssl_bind.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +import ssl + +from pwncat.channel import ChannelError +from pwncat.channel.bind import Bind + + +class SSLBind(Bind): + def __init__(self, certfile: str = None, keyfile: str = None, **kwargs): + super().__init__(**kwargs) + + self.context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + self.context.load_cert_chain(certfile, keyfile) + + self.server = self.context.wrap_socket(self.server) + + def connect(self): + + try: + super().connect() + except ssl.SSLError as exc: + raise ChannelError(self, str(exc)) diff --git a/test.py b/test.py index 17b8b7a..ffb073c 100755 --- a/test.py +++ b/test.py @@ -19,12 +19,11 @@ with pwncat.manager.Manager("data/pwncatrc") as manager: # session = manager.create_session("windows", host="192.168.56.10", port=4444) # session = manager.create_session("windows", host="192.168.122.11", port=4444) # session = manager.create_session("linux", host="pwncat-ubuntu", port=4444) - session = manager.create_session("linux", host="127.0.0.1", port=4444) + # session = manager.create_session("linux", host="127.0.0.1", port=4444) + session = manager.create_session( + "linux", certfile="/tmp/cert.pem", keyfile="/tmp/cert.pem", port=4444 + ) # session.platform.powershell("amsiutils") - with open("/tmp/random", "rb") as source: - with session.platform.open("/tmp/random", "wb") as destination: - shutil.copyfileobj(source, destination) - manager.interactive() From 5d13d8f12064b67717ce0c37ac60e72016ecdadf Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Sun, 13 Jun 2021 22:28:27 -0400 Subject: [PATCH 02/14] Added ssl-connect protocol --- pwncat/channel/__init__.py | 2 ++ pwncat/channel/ssl_connect.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 pwncat/channel/ssl_connect.py diff --git a/pwncat/channel/__init__.py b/pwncat/channel/__init__.py index 1985350..11d2299 100644 --- a/pwncat/channel/__init__.py +++ b/pwncat/channel/__init__.py @@ -604,9 +604,11 @@ from pwncat.channel.bind import Bind # noqa: E402 from pwncat.channel.socket import Socket # noqa: E402 from pwncat.channel.connect import Connect # noqa: E402 from pwncat.channel.ssl_bind import SSLBind # noqa: E402 +from pwncat.channel.ssl_connect import SSLConnect # noqa: E402 register("socket", Socket) register("bind", Bind) register("connect", Connect) register("ssh", Ssh) register("ssl-bind", SSLBind) +register("ssl-connect", SSLConnect) diff --git a/pwncat/channel/ssl_connect.py b/pwncat/channel/ssl_connect.py new file mode 100644 index 0000000..28d8c71 --- /dev/null +++ b/pwncat/channel/ssl_connect.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +import ssl + +from pwncat.channel import ChannelError +from pwncat.channel.connect import Connect + + +class SSLConnect(Connect): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def _socket_connected(self, client): + try: + self.context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + self.context.check_hostname = False + self.context.verify_mode = ssl.VerifyMode.CERT_NONE + + client = self.context.wrap_socket(client) + except ssl.SSLError as exc: + raise ChannelError(str(exc)) + + super()._socket_connected(client) From 4654ad6a9b70293d39361b04a63622ae79d45821 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Mon, 14 Jun 2021 08:35:07 -0400 Subject: [PATCH 03/14] Added certificate options for entrypoint TODO: transfer entrypoint logic to `connect` --- pwncat/__main__.py | 99 ++++++++++++++++++++++++-------------- pwncat/channel/__init__.py | 5 +- pwncat/channel/bind.py | 6 +++ pwncat/channel/connect.py | 10 +++- pwncat/channel/socket.py | 3 ++ pwncat/channel/ssh.py | 18 ++++--- pwncat/commands/connect.py | 6 ++- 7 files changed, 100 insertions(+), 47 deletions(-) diff --git a/pwncat/__main__.py b/pwncat/__main__.py index dcd5fc9..6cf2e08 100644 --- a/pwncat/__main__.py +++ b/pwncat/__main__.py @@ -35,6 +35,12 @@ def main(): default=None, help="Custom configuration file (default: ./pwncatrc)", ) + parser.add_argument( + "--certificate", + "--cert", + default=None, + help="Certificate for SSL-encrypted listeners", + ) parser.add_argument( "--identity", "-i", @@ -140,27 +146,47 @@ def main(): or args.listen or args.identity is not None ): - protocol = None - user = None - password = None - host = None - port = None + query_args = {} + query_args["protocol"] = None + query_args["user"] = None + query_args["password"] = None + query_args["host"] = None + query_args["port"] = None + query_args["platform"] = args.platform + query_args["identity"] = args.identity + query_args["certfile"] = args.certificate + query_args["keyfile"] = args.certificate + querystring = None if args.connection_string: m = connect.Command.CONNECTION_PATTERN.match(args.connection_string) - protocol = m.group("protocol") - user = m.group("user") - password = m.group("password") - host = m.group("host") - port = m.group("port") + query_args["protocol"] = m.group("protocol") + query_args["user"] = m.group("user") + query_args["password"] = m.group("password") + query_args["host"] = m.group("host") + query_args["port"] = m.group("port") + querystring = m.group("querystring") - if protocol is not None: - protocol = protocol.removesuffix("://") + if query_args["protocol"] is not None: + query_args["protocol"] = query_args["protocol"].removesuffix("://") - if host is not None and host == "": - host = None + if querystring is not None: + for arg in querystring.split("&"): + if arg.find("=") == -1: + continue - if protocol is not None and args.listen: + key, *value = arg.split("=") + + if key in query_args and query_args[key] is not None: + console.log(f"[red]error[/red]: multiple values for {key}") + return + + query_args[key] = "=".join(value) + + if query_args["host"] is not None and query_args["host"] == "": + query_args["host"] = None + + if query_args["protocol"] is not None and args.listen: console.log( "[red]error[/red]: --listen is not compatible with an explicit connection string" ) @@ -169,7 +195,7 @@ def main(): if ( sum( [ - port is not None, + query_args["port"] is not None, args.port is not None, args.pos_port is not None, ] @@ -180,22 +206,24 @@ def main(): return if args.port is not None: - port = args.port + query_args["port"] = args.port if args.pos_port is not None: - port = args.pos_port + query_args["port"] = args.pos_port - if port is not None: + if query_args["port"] is not None: try: - port = int(port.lstrip(":")) + query_args["port"] = int(query_args["port"].lstrip(":")) except ValueError: - console.log(f"[red]error[/red]: {port}: invalid port number") + console.log( + f"[red]error[/red]: {query_args['port'].lstrip(':')}: invalid port number" + ) return # Attempt to reconnect via installed implants if ( - protocol is None - and password is None - and port is None + query_args["protocol"] is None + and query_args["password"] is None + and query_args["port"] is None and args.identity is None ): db = manager.db.open() @@ -204,11 +232,14 @@ def main(): # Locate all installed implants for target in db.root.targets: - if target.guid != host and target.public_address[0] != host: + if ( + target.guid != query_args["host"] + and target.public_address[0] != query_args["host"] + ): continue # Collect users - users = {} + userss = {} for fact in target.facts: if "user" in fact.types: users[fact.id] = fact @@ -227,13 +258,13 @@ def main(): ) as progress: task = progress.add_task("", status="...") for target, implant_user, implant in implants: - # Check correct user - if user is not None and implant_user.name != user: + # Check correct query_args["user"] + if query_args["user"] is not None and implant_user.name != user: continue # Check correct platform if ( - args.platform is not None - and target.platform != args.platform + query_args["platform"] is not None + and target.platform != query_args["platform"] ): continue @@ -258,13 +289,7 @@ def main(): else: try: manager.create_session( - platform=args.platform, - protocol=protocol, - user=user, - password=password, - host=host, - port=port, - identity=args.identity, + **query_args, ) except (ChannelError, PlatformError) as exc: manager.log(f"connection failed: {exc}") diff --git a/pwncat/channel/__init__.py b/pwncat/channel/__init__.py index 11d2299..bd6033f 100644 --- a/pwncat/channel/__init__.py +++ b/pwncat/channel/__init__.py @@ -581,7 +581,10 @@ def create(protocol: Optional[str] = None, **kwargs) -> Channel: or kwargs["host"] == "0.0.0.0" or kwargs["host"] is None ): - if "certfile" in kwargs or "keyfile" in kwargs: + if ( + kwargs.get("certfile") is not None + or kwargs.get("keyfile") is not None + ): protocols.append("ssl-bind") else: protocols.append("bind") diff --git a/pwncat/channel/bind.py b/pwncat/channel/bind.py index 1a6c678..33921fe 100644 --- a/pwncat/channel/bind.py +++ b/pwncat/channel/bind.py @@ -28,6 +28,12 @@ class Bind(Socket): if not host or host == "": host = "0.0.0.0" + if isinstance(port, str): + try: + port = int(port) + except ValueError: + raise ChannelError(self, "invalid port number") + if port is None: raise ChannelError(self, "no port specified") diff --git a/pwncat/channel/connect.py b/pwncat/channel/connect.py index a40214d..e34b534 100644 --- a/pwncat/channel/connect.py +++ b/pwncat/channel/connect.py @@ -24,10 +24,16 @@ class Connect(Socket): def __init__(self, host: str, port: int, **kwargs): if not host: - raise ChannelError("no host address provided") + raise ChannelError(self, "no host address provided") if port is None: - raise ChannelError("no port provided") + raise ChannelError(self, "no port provided") + + if isinstance(port, str): + try: + port = int(port) + except ValueError: + raise ChannelError(self, "invalid port") with Progress( f"connecting to [blue]{host}[/blue]:[cyan]{port}[/cyan]", diff --git a/pwncat/channel/socket.py b/pwncat/channel/socket.py index 5239bd1..ffa51a2 100644 --- a/pwncat/channel/socket.py +++ b/pwncat/channel/socket.py @@ -49,6 +49,9 @@ class Socket(Channel): def __init__(self, client: socket.socket = None, **kwargs): + if isinstance(client, str): + raise ChannelError(self, f"expected socket object not {repr(type(client))}") + if client is not None: # Report host and port number to base channel host, port = client.getpeername() diff --git a/pwncat/channel/ssh.py b/pwncat/channel/ssh.py index 1bb9c29..d62ff6e 100644 --- a/pwncat/channel/ssh.py +++ b/pwncat/channel/ssh.py @@ -33,8 +33,14 @@ class Ssh(Channel): if port is None: port = 22 + if isinstance(port, str): + try: + port = int(port) + except ValueError: + raise ChannelError(self, "invalid port") + if not user or user is None: - raise ChannelError("you must specify a user") + raise ChannelError(self, "you must specify a user") if password is None and identity is None: password = prompt("Password: ", is_password=True) @@ -51,7 +57,7 @@ class Ssh(Channel): t.start_client() except paramiko.SSHException: sock.close() - raise ChannelError("ssh negotiation failed") + raise ChannelError(self, "ssh negotiation failed") if identity is not None: try: @@ -67,23 +73,23 @@ class Ssh(Channel): try: key = paramiko.RSAKey.from_private_key_file(identity, password) except paramiko.ssh_exception.SSHException: - raise ChannelError("invalid private key or passphrase") + raise ChannelError(self, "invalid private key or passphrase") # Attempt authentication try: t.auth_publickey(user, key) except paramiko.ssh_exception.AuthenticationException as exc: - raise ChannelError(str(exc)) + raise ChannelError(self, str(exc)) else: try: t.auth_password(user, password) except paramiko.ssh_exception.AuthenticationException as exc: - raise ChannelError(str(exc)) + raise ChannelError(self, str(exc)) if not t.is_authenticated(): t.close() sock.close() - raise ChannelError("authentication failed") + raise ChannelError(self, "authentication failed") # Open an interactive session chan = t.open_session() diff --git a/pwncat/commands/connect.py b/pwncat/commands/connect.py index cd77bf7..ab7cd84 100644 --- a/pwncat/commands/connect.py +++ b/pwncat/commands/connect.py @@ -58,6 +58,10 @@ class Command(CommandDefinition): action="store_true", help="List installed implants with remote connection capability", ), + "--connection,--conn": Parameter( + Complete.NONE, + help="Certificate for SSL-encrypted listeners", + ), "connection_string": Parameter( Complete.NONE, metavar="[protocol://][user[:password]@][host][:port]", @@ -73,7 +77,7 @@ class Command(CommandDefinition): } LOCAL = True CONNECTION_PATTERN = re.compile( - r"""^(?P[-a-zA-Z0-9_]*://)?((?P[^:@]*)?(?P:(\\@|[^@])*)?@)?(?P[^:]*)?(?P:[0-9]*)?$""" + r"""^(?P[-a-zA-Z0-9_]*://)?((?P[^:@]*)?(?P:(\\@|[^@])*)?@)?(?P[^:]*)?(?P:[0-9]*)?(\?(?P.*))?$""" ) def run(self, manager: "pwncat.manager.Manager", args): From 58ba8eec8844cc67a9ef4f065cfe21741131b04e Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Mon, 14 Jun 2021 10:23:15 -0400 Subject: [PATCH 04/14] Added updated entrypoint syntax to connect command --- pwncat/commands/connect.py | 99 +++++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 39 deletions(-) diff --git a/pwncat/commands/connect.py b/pwncat/commands/connect.py index ab7cd84..9fd803c 100644 --- a/pwncat/commands/connect.py +++ b/pwncat/commands/connect.py @@ -58,7 +58,7 @@ class Command(CommandDefinition): action="store_true", help="List installed implants with remote connection capability", ), - "--connection,--conn": Parameter( + "--certificate,--cert": Parameter( Complete.NONE, help="Certificate for SSL-encrypted listeners", ), @@ -82,11 +82,17 @@ class Command(CommandDefinition): def run(self, manager: "pwncat.manager.Manager", args): - protocol = None - user = None - password = None - host = None - port = None + query_args = {} + query_args["protocol"] = None + query_args["user"] = None + query_args["password"] = None + query_args["host"] = None + query_args["port"] = None + query_args["platform"] = args.platform + query_args["identity"] = args.identity + query_args["certfile"] = args.certificate + query_args["keyfile"] = args.certificate + querystring = None used_implant = None if args.list: @@ -132,19 +138,33 @@ class Command(CommandDefinition): if args.connection_string: m = self.CONNECTION_PATTERN.match(args.connection_string) - protocol = m.group("protocol") - user = m.group("user") - password = m.group("password") - host = m.group("host") - port = m.group("port") + query_args["protocol"] = m.group("protocol") + query_args["user"] = m.group("user") + query_args["password"] = m.group("password") + query_args["host"] = m.group("host") + query_args["port"] = m.group("port") + querystring = m.group("querystring") - if protocol is not None: - protocol = protocol.removesuffix("://") + if query_args["protocol"] is not None: + query_args["protocol"] = query_args["protocol"].removesuffix("://") - if host is not None and host == "": - host = None + if querystring is not None: + for arg in querystring.split("&"): + if arg.find("=") == -1: + continue - if protocol is not None and args.listen: + key, *value = arg.split("=") + + if key in query_args and query_args[key] is not None: + console.log(f"[red]error[/red]: multiple values for {key}") + return + + query_args[key] = "=".join(value) + + if query_args["host"] is not None and query_args["host"] == "": + query_args["host"] = None + + if query_args["protocol"] is not None and args.listen: console.log( "[red]error[/red]: --listen is not compatible with an explicit connection string" ) @@ -153,7 +173,7 @@ class Command(CommandDefinition): if ( sum( [ - port is not None, + query_args["port"] is not None, args.port is not None, args.pos_port is not None, ] @@ -164,22 +184,24 @@ class Command(CommandDefinition): return if args.port is not None: - port = args.port + query_args["port"] = args.port if args.pos_port is not None: - port = args.pos_port + query_args["port"] = args.pos_port - if port is not None: + if query_args["port"] is not None: try: - port = int(port.lstrip(":")) + query_args["port"] = int(query_args["port"].lstrip(":")) except ValueError: - console.log(f"[red]error[/red]: {port}: invalid port number") + console.log( + f"[red]error[/red]: {query_args['port'].lstrip(':')}: invalid port number" + ) return # Attempt to reconnect via installed implants if ( - protocol is None - and password is None - and port is None + query_args["protocol"] is None + and query_args["password"] is None + and query_args["port"] is None and args.identity is None ): db = manager.db.open() @@ -188,11 +210,14 @@ class Command(CommandDefinition): # Locate all installed implants for target in db.root.targets: - if target.guid != host and target.public_address[0] != host: + if ( + target.guid != query_args["host"] + and target.public_address[0] != query_args["host"] + ): continue # Collect users - users = {} + userss = {} for fact in target.facts: if "user" in fact.types: users[fact.id] = fact @@ -211,11 +236,14 @@ class Command(CommandDefinition): ) as progress: task = progress.add_task("", status="...") for target, implant_user, implant in implants: - # Check correct user - if user is not None and implant_user.name != user: + # Check correct query_args["user"] + if query_args["user"] is not None and implant_user.name != user: continue # Check correct platform - if args.platform is not None and target.platform != args.platform: + if ( + query_args["platform"] is not None + and target.platform != query_args["platform"] + ): continue progress.update( @@ -229,17 +257,10 @@ class Command(CommandDefinition): used_implant = implant break except ModuleFailed: + db.transaction_manager.commit() continue if used_implant is not None: manager.target.log(f"connected via {used_implant.title(manager.target)}") else: - manager.create_session( - platform=args.platform, - protocol=protocol, - user=user, - password=password, - host=host, - port=port, - identity=args.identity, - ) + manager.create_session(**query_args) From 6564204c0ff2d888f2eb24216caff2d12225c9d3 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Mon, 14 Jun 2021 17:35:47 -0400 Subject: [PATCH 05/14] Added ssl-bind and ssl-connect usage documentation --- docs/source/usage.rst | 55 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 4228770..ce4f3ed 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -34,6 +34,13 @@ with three different C2 protocols: ``bind``, ``connect``, and ``ssh``. The first modes simply open a raw socket and assume there is a shell on the other end. In SSH mode, we legitimately authenticate to the victim host with provided credentials and utilize the SSH shell channel as our C2 channel. +pwncat also implements SSL-wrapped versions of ``bind`` and ``connect`` protocols aptly named ``ssl-bind`` +and ``ssl-connect``. These protocols function largely the same as bind/connect, except that they operate +over an encrypted SSL tunnel. You must use an encrypted bind or reverse shell on the victim side such +as ``ncat --ssl`` or `socat OPENSSL-LISTEN:`. For the ``ssl-bind`` protocol, you must also supply either +the ``--certificate`` argument pointing to a PEM formatted bundled certificate and key file or two +querystring parameters named ``certfile`` and ``keyfile``. + pwncat exposes these different C2 channel protocols via the ``protocol`` field of the connection string discussed below. @@ -42,22 +49,27 @@ Connecting to a Victim Connecting to a victim is accomplished through a connection string. Connection strings are versatile ways to describe the parameters to a specific C2 Channel/Protocol. This looks something like: -``[protocol://][user[:password]]@[host:][port]`` +``[protocol://][user[:password]]@[host:][port][?arg1=value&arg2=value]`` Each field in the connection string translates to a parameter passed to the C2 channel. Some channels don't require all the parameters. For example, a ``bind`` or ``connect`` channel doesn't required a username or -a password. +a password. If there is not an explicit argument or parsed value within the above format, you can use the +query string arguments to specify arbitrary channel arguments. You cannot specify the same argument twice +(e.g. ``connect://hostname:1111?port=4444``). If the ``protocol`` field is not specified, pwncat will attempt to figure out the correct protocol contextually. The following rules apply: - If a user and host are provided, assume ``ssh`` protocol - If no user is provided but a host and port are provided, assume protocol is ``connect`` +- If no user or host is provided (or host is ``0.0.0.0``) and the ``certfile`` or ``keyfile`` arguments are + provided, protocol is assumed to be ``ssl-bind`` - If no user or host is provided (or host is ``0.0.0.0``), protocol is assumed to be ``bind`` - If a second positional integer parameter is specified, the protocol is assumed to be ``connect`` - This is the ``netcat`` syntax seen in the below examples for the ``connect`` protocol. -- If the ``-l`` parameter is used, the protocol is assumed to be ``bind``. - - This is the ``netcat`` syntax seen in the below examples for the ``bind`` protocol. +- If the ``-l`` parameter is used and the ``certfile`` or ``keyfile`` arguments are provided, the protocol + is assumed to be ``ssl-bind``. +- If the ``-l`` parameter is used alone, then the protocol is assumed to be ``bind`` Connecting to a victim bind shell --------------------------------- @@ -75,6 +87,18 @@ address which is routable (e.g. not NAT'd). The ``connect`` protocol provides th # Connection string with assumed protocol pwncat 192.168.1.1:4444 +Connecting to a victim encrypted bind shell +------------------------------------------- + +In this case, the victim is running a ssl-wrapped bind shell on an open port. The victim must be available at an +address which is routable (e.g. not NAT'd). The ``ssl-connect`` protocol provides this capability. + +.. code-block:: bash + :caption: Connecting to a bind shell at 1.1.1.1:4444 + + # Full connection string + pwncat connect://192.168.1.1:4444 + Catching a victim reverse shell ------------------------------- @@ -94,6 +118,29 @@ victim machine. This mode is accessed via the ``bind`` protocol. # Assumed protocol, assumed bind address pwncat :4444 +Catching a victim encrypted reverse shell +----------------------------------------- + +In this case, the victim was exploited in such a way that they open an ssl 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 ``ssl-bind`` protocol. + +If using the ``--cert/--certificate`` argument, you must provided a combined certificate and key file in PEM +format. If your key and certificate are stored in separate files, you should specify the ``certfile`` and +``keyfile`` querystring arguments instead. + +.. code-block:: bash + :caption: Catching a reverse shell + + # netcat syntax + pwncat -l --cert /path/to/cert.pem 4444 + # Full connection string + pwncat ssl-bind://0.0.0.0:4444?certfile=/path/to/cert.pem&keyfile=/path/to/key.pem + # Assumed protocol + pwncat --cert /path/to/cert.pem 0.0.0.0:4444 + # Assumed protocol, assumed bind address + pwncat --cert /path/to/cert.pem :4444 + Connecting to a Remote SSH Server --------------------------------- From 42dc681a826acac15589037451b961cd1818847b Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Tue, 15 Jun 2021 11:38:23 -0400 Subject: [PATCH 06/14] Fixed typo in users variable --- pwncat/__main__.py | 2 +- pwncat/commands/connect.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pwncat/__main__.py b/pwncat/__main__.py index 6cf2e08..5e09a75 100644 --- a/pwncat/__main__.py +++ b/pwncat/__main__.py @@ -239,7 +239,7 @@ def main(): continue # Collect users - userss = {} + users = {} for fact in target.facts: if "user" in fact.types: users[fact.id] = fact diff --git a/pwncat/commands/connect.py b/pwncat/commands/connect.py index 9fd803c..3ce0beb 100644 --- a/pwncat/commands/connect.py +++ b/pwncat/commands/connect.py @@ -217,7 +217,7 @@ class Command(CommandDefinition): continue # Collect users - userss = {} + users = {} for fact in target.facts: if "user" in fact.types: users[fact.id] = fact From 2c9a1dbc715f6c1ebce0e10952a15b282510fb39 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Sun, 13 Jun 2021 22:09:41 -0400 Subject: [PATCH 07/14] Initial implementation of ssl-wrapped socket --- pwncat/channel/__init__.py | 7 ++++++- pwncat/channel/bind.py | 2 ++ pwncat/channel/socket.py | 11 ++++++++++- pwncat/channel/ssl_bind.py | 22 ++++++++++++++++++++++ test.py | 9 ++++----- 5 files changed, 44 insertions(+), 7 deletions(-) create mode 100644 pwncat/channel/ssl_bind.py diff --git a/pwncat/channel/__init__.py b/pwncat/channel/__init__.py index 5fcaab1..e8cfaf3 100644 --- a/pwncat/channel/__init__.py +++ b/pwncat/channel/__init__.py @@ -581,7 +581,10 @@ def create(protocol: Optional[str] = None, **kwargs) -> Channel: or kwargs["host"] == "0.0.0.0" or kwargs["host"] is None ): - protocols.append("bind") + if "certfile" in kwargs or "keyfile" in kwargs: + protocols.append("ssl-bind") + else: + protocols.append("bind") else: protocols.append("connect") else: @@ -600,8 +603,10 @@ from pwncat.channel.ssh import Ssh # noqa: E402 from pwncat.channel.bind import Bind # noqa: E402 from pwncat.channel.socket import Socket # noqa: E402 from pwncat.channel.connect import Connect # noqa: E402 +from pwncat.channel.ssl_bind import SSLBind # noqa: E402 register("socket", Socket) register("bind", Bind) register("connect", Connect) register("ssh", Ssh) +register("ssl-bind", SSLBind) diff --git a/pwncat/channel/bind.py b/pwncat/channel/bind.py index eaf7b18..1a6c678 100644 --- a/pwncat/channel/bind.py +++ b/pwncat/channel/bind.py @@ -51,6 +51,8 @@ class Bind(Socket): self._socket_connected(client) except KeyboardInterrupt: raise ChannelError(self, "listener aborted") + except socket.error as exc: + raise ChannelError(self, str(exc)) finally: self.server.close() diff --git a/pwncat/channel/socket.py b/pwncat/channel/socket.py index befdc67..5239bd1 100644 --- a/pwncat/channel/socket.py +++ b/pwncat/channel/socket.py @@ -15,6 +15,7 @@ utilize this class to instantiate a session via an established socket. manager.interactive() """ import os +import ssl import errno import fcntl import socket @@ -91,11 +92,14 @@ class Socket(Channel): while written < len(data): try: written += self.client.send(data[written:]) - except BlockingIOError: + except (BlockingIOError, ssl.SSLWantWriteError, ssl.SSLWantReadError): pass except BrokenPipeError as exc: self._connected = False raise ChannelClosed(self) from exc + except (ssl.SSLEOFError, ssl.SSLSyscallError, ssl.SSLZeroReturnError): + self._connected = False + raise ChannelClosed(self) from exc return len(data) @@ -124,6 +128,11 @@ class Socket(Channel): try: data = data + self.client.recv(count) return data + except ssl.SSLWantReadError: + return data + except (ssl.SSLEOFError, ssl.SSLSyscallError, ssl.SSLZeroReturnError): + self._connected = False + raise ChannelClosed(self) from exc except socket.error as exc: if exc.args[0] == errno.EAGAIN or exc.args[0] == errno.EWOULDBLOCK: return data diff --git a/pwncat/channel/ssl_bind.py b/pwncat/channel/ssl_bind.py new file mode 100644 index 0000000..8704677 --- /dev/null +++ b/pwncat/channel/ssl_bind.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +import ssl + +from pwncat.channel import ChannelError +from pwncat.channel.bind import Bind + + +class SSLBind(Bind): + def __init__(self, certfile: str = None, keyfile: str = None, **kwargs): + super().__init__(**kwargs) + + self.context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + self.context.load_cert_chain(certfile, keyfile) + + self.server = self.context.wrap_socket(self.server) + + def connect(self): + + try: + super().connect() + except ssl.SSLError as exc: + raise ChannelError(self, str(exc)) diff --git a/test.py b/test.py index 17b8b7a..ffb073c 100755 --- a/test.py +++ b/test.py @@ -19,12 +19,11 @@ with pwncat.manager.Manager("data/pwncatrc") as manager: # session = manager.create_session("windows", host="192.168.56.10", port=4444) # session = manager.create_session("windows", host="192.168.122.11", port=4444) # session = manager.create_session("linux", host="pwncat-ubuntu", port=4444) - session = manager.create_session("linux", host="127.0.0.1", port=4444) + # session = manager.create_session("linux", host="127.0.0.1", port=4444) + session = manager.create_session( + "linux", certfile="/tmp/cert.pem", keyfile="/tmp/cert.pem", port=4444 + ) # session.platform.powershell("amsiutils") - with open("/tmp/random", "rb") as source: - with session.platform.open("/tmp/random", "wb") as destination: - shutil.copyfileobj(source, destination) - manager.interactive() From 0f00871abf7a620e0e1eb2be190d51111270f8a5 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Sun, 13 Jun 2021 22:28:27 -0400 Subject: [PATCH 08/14] Added ssl-connect protocol --- pwncat/channel/__init__.py | 2 ++ pwncat/channel/ssl_connect.py | 22 ++++++++++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 pwncat/channel/ssl_connect.py diff --git a/pwncat/channel/__init__.py b/pwncat/channel/__init__.py index e8cfaf3..188d939 100644 --- a/pwncat/channel/__init__.py +++ b/pwncat/channel/__init__.py @@ -604,9 +604,11 @@ from pwncat.channel.bind import Bind # noqa: E402 from pwncat.channel.socket import Socket # noqa: E402 from pwncat.channel.connect import Connect # noqa: E402 from pwncat.channel.ssl_bind import SSLBind # noqa: E402 +from pwncat.channel.ssl_connect import SSLConnect # noqa: E402 register("socket", Socket) register("bind", Bind) register("connect", Connect) register("ssh", Ssh) register("ssl-bind", SSLBind) +register("ssl-connect", SSLConnect) diff --git a/pwncat/channel/ssl_connect.py b/pwncat/channel/ssl_connect.py new file mode 100644 index 0000000..28d8c71 --- /dev/null +++ b/pwncat/channel/ssl_connect.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +import ssl + +from pwncat.channel import ChannelError +from pwncat.channel.connect import Connect + + +class SSLConnect(Connect): + def __init__(self, **kwargs): + super().__init__(**kwargs) + + def _socket_connected(self, client): + try: + self.context = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT) + self.context.check_hostname = False + self.context.verify_mode = ssl.VerifyMode.CERT_NONE + + client = self.context.wrap_socket(client) + except ssl.SSLError as exc: + raise ChannelError(str(exc)) + + super()._socket_connected(client) From 74962f2b2d17a379e116f3922ed890ea0a5d28b9 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Mon, 14 Jun 2021 08:35:07 -0400 Subject: [PATCH 09/14] Added certificate options for entrypoint TODO: transfer entrypoint logic to `connect` --- pwncat/__main__.py | 99 ++++++++++++++++++++++++-------------- pwncat/channel/__init__.py | 5 +- pwncat/channel/bind.py | 6 +++ pwncat/channel/connect.py | 10 +++- pwncat/channel/socket.py | 3 ++ pwncat/channel/ssh.py | 18 ++++--- pwncat/commands/connect.py | 6 ++- 7 files changed, 100 insertions(+), 47 deletions(-) diff --git a/pwncat/__main__.py b/pwncat/__main__.py index 53590ec..08f106b 100644 --- a/pwncat/__main__.py +++ b/pwncat/__main__.py @@ -39,6 +39,12 @@ def main(): default=None, help="Custom configuration file (default: ./pwncatrc)", ) + parser.add_argument( + "--certificate", + "--cert", + default=None, + help="Certificate for SSL-encrypted listeners", + ) parser.add_argument( "--identity", "-i", @@ -149,27 +155,47 @@ def main(): or args.listen or args.identity is not None ): - protocol = None - user = None - password = None - host = None - port = None + query_args = {} + query_args["protocol"] = None + query_args["user"] = None + query_args["password"] = None + query_args["host"] = None + query_args["port"] = None + query_args["platform"] = args.platform + query_args["identity"] = args.identity + query_args["certfile"] = args.certificate + query_args["keyfile"] = args.certificate + querystring = None if args.connection_string: m = connect.Command.CONNECTION_PATTERN.match(args.connection_string) - protocol = m.group("protocol") - user = m.group("user") - password = m.group("password") - host = m.group("host") - port = m.group("port") + query_args["protocol"] = m.group("protocol") + query_args["user"] = m.group("user") + query_args["password"] = m.group("password") + query_args["host"] = m.group("host") + query_args["port"] = m.group("port") + querystring = m.group("querystring") - if protocol is not None: - protocol = protocol.removesuffix("://") + if query_args["protocol"] is not None: + query_args["protocol"] = query_args["protocol"].removesuffix("://") - if host is not None and host == "": - host = None + if querystring is not None: + for arg in querystring.split("&"): + if arg.find("=") == -1: + continue - if protocol is not None and args.listen: + key, *value = arg.split("=") + + if key in query_args and query_args[key] is not None: + console.log(f"[red]error[/red]: multiple values for {key}") + return + + query_args[key] = "=".join(value) + + if query_args["host"] is not None and query_args["host"] == "": + query_args["host"] = None + + if query_args["protocol"] is not None and args.listen: console.log( "[red]error[/red]: --listen is not compatible with an explicit connection string" ) @@ -178,7 +204,7 @@ def main(): if ( sum( [ - port is not None, + query_args["port"] is not None, args.port is not None, args.pos_port is not None, ] @@ -189,22 +215,24 @@ def main(): return if args.port is not None: - port = args.port + query_args["port"] = args.port if args.pos_port is not None: - port = args.pos_port + query_args["port"] = args.pos_port - if port is not None: + if query_args["port"] is not None: try: - port = int(port.lstrip(":")) + query_args["port"] = int(query_args["port"].lstrip(":")) except ValueError: - console.log(f"[red]error[/red]: {port}: invalid port number") + console.log( + f"[red]error[/red]: {query_args['port'].lstrip(':')}: invalid port number" + ) return # Attempt to reconnect via installed implants if ( - protocol is None - and password is None - and port is None + query_args["protocol"] is None + and query_args["password"] is None + and query_args["port"] is None and args.identity is None ): db = manager.db.open() @@ -213,11 +241,14 @@ def main(): # Locate all installed implants for target in db.root.targets: - if target.guid != host and target.public_address[0] != host: + if ( + target.guid != query_args["host"] + and target.public_address[0] != query_args["host"] + ): continue # Collect users - users = {} + userss = {} for fact in target.facts: if "user" in fact.types: users[fact.id] = fact @@ -236,13 +267,13 @@ def main(): ) as progress: task = progress.add_task("", status="...") for target, implant_user, implant in implants: - # Check correct user - if user is not None and implant_user.name != user: + # Check correct query_args["user"] + if query_args["user"] is not None and implant_user.name != user: continue # Check correct platform if ( - args.platform is not None - and target.platform != args.platform + query_args["platform"] is not None + and target.platform != query_args["platform"] ): continue @@ -267,13 +298,7 @@ def main(): else: try: manager.create_session( - platform=args.platform, - protocol=protocol, - user=user, - password=password, - host=host, - port=port, - identity=args.identity, + **query_args, ) except (ChannelError, PlatformError) as exc: manager.log(f"connection failed: {exc}") diff --git a/pwncat/channel/__init__.py b/pwncat/channel/__init__.py index 188d939..7db3b16 100644 --- a/pwncat/channel/__init__.py +++ b/pwncat/channel/__init__.py @@ -581,7 +581,10 @@ def create(protocol: Optional[str] = None, **kwargs) -> Channel: or kwargs["host"] == "0.0.0.0" or kwargs["host"] is None ): - if "certfile" in kwargs or "keyfile" in kwargs: + if ( + kwargs.get("certfile") is not None + or kwargs.get("keyfile") is not None + ): protocols.append("ssl-bind") else: protocols.append("bind") diff --git a/pwncat/channel/bind.py b/pwncat/channel/bind.py index 1a6c678..33921fe 100644 --- a/pwncat/channel/bind.py +++ b/pwncat/channel/bind.py @@ -28,6 +28,12 @@ class Bind(Socket): if not host or host == "": host = "0.0.0.0" + if isinstance(port, str): + try: + port = int(port) + except ValueError: + raise ChannelError(self, "invalid port number") + if port is None: raise ChannelError(self, "no port specified") diff --git a/pwncat/channel/connect.py b/pwncat/channel/connect.py index a40214d..e34b534 100644 --- a/pwncat/channel/connect.py +++ b/pwncat/channel/connect.py @@ -24,10 +24,16 @@ class Connect(Socket): def __init__(self, host: str, port: int, **kwargs): if not host: - raise ChannelError("no host address provided") + raise ChannelError(self, "no host address provided") if port is None: - raise ChannelError("no port provided") + raise ChannelError(self, "no port provided") + + if isinstance(port, str): + try: + port = int(port) + except ValueError: + raise ChannelError(self, "invalid port") with Progress( f"connecting to [blue]{host}[/blue]:[cyan]{port}[/cyan]", diff --git a/pwncat/channel/socket.py b/pwncat/channel/socket.py index 5239bd1..ffa51a2 100644 --- a/pwncat/channel/socket.py +++ b/pwncat/channel/socket.py @@ -49,6 +49,9 @@ class Socket(Channel): def __init__(self, client: socket.socket = None, **kwargs): + if isinstance(client, str): + raise ChannelError(self, f"expected socket object not {repr(type(client))}") + if client is not None: # Report host and port number to base channel host, port = client.getpeername() diff --git a/pwncat/channel/ssh.py b/pwncat/channel/ssh.py index 0937571..4ffb363 100644 --- a/pwncat/channel/ssh.py +++ b/pwncat/channel/ssh.py @@ -33,8 +33,14 @@ class Ssh(Channel): if port is None: port = 22 + if isinstance(port, str): + try: + port = int(port) + except ValueError: + raise ChannelError(self, "invalid port") + if not user or user is None: - raise ChannelError("you must specify a user") + raise ChannelError(self, "you must specify a user") if password is None and identity is None: password = prompt("Password: ", is_password=True) @@ -51,7 +57,7 @@ class Ssh(Channel): t.start_client() except paramiko.SSHException: sock.close() - raise ChannelError("ssh negotiation failed") + raise ChannelError(self, "ssh negotiation failed") if identity is not None: try: @@ -67,23 +73,23 @@ class Ssh(Channel): try: key = paramiko.RSAKey.from_private_key_file(identity, password) except paramiko.ssh_exception.SSHException: - raise ChannelError("invalid private key or passphrase") + raise ChannelError(self, "invalid private key or passphrase") # Attempt authentication try: t.auth_publickey(user, key) except paramiko.ssh_exception.AuthenticationException as exc: - raise ChannelError(str(exc)) + raise ChannelError(self, str(exc)) else: try: t.auth_password(user, password) except paramiko.ssh_exception.AuthenticationException as exc: - raise ChannelError(str(exc)) + raise ChannelError(self, str(exc)) if not t.is_authenticated(): t.close() sock.close() - raise ChannelError("authentication failed") + raise ChannelError(self, "authentication failed") # Open an interactive session chan = t.open_session() diff --git a/pwncat/commands/connect.py b/pwncat/commands/connect.py index cd77bf7..ab7cd84 100644 --- a/pwncat/commands/connect.py +++ b/pwncat/commands/connect.py @@ -58,6 +58,10 @@ class Command(CommandDefinition): action="store_true", help="List installed implants with remote connection capability", ), + "--connection,--conn": Parameter( + Complete.NONE, + help="Certificate for SSL-encrypted listeners", + ), "connection_string": Parameter( Complete.NONE, metavar="[protocol://][user[:password]@][host][:port]", @@ -73,7 +77,7 @@ class Command(CommandDefinition): } LOCAL = True CONNECTION_PATTERN = re.compile( - r"""^(?P[-a-zA-Z0-9_]*://)?((?P[^:@]*)?(?P:(\\@|[^@])*)?@)?(?P[^:]*)?(?P:[0-9]*)?$""" + r"""^(?P[-a-zA-Z0-9_]*://)?((?P[^:@]*)?(?P:(\\@|[^@])*)?@)?(?P[^:]*)?(?P:[0-9]*)?(\?(?P.*))?$""" ) def run(self, manager: "pwncat.manager.Manager", args): From c12d53d8c7c586f30219ef9c4f5c4fb2355f84da Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Mon, 14 Jun 2021 10:23:15 -0400 Subject: [PATCH 10/14] Added updated entrypoint syntax to connect command --- pwncat/commands/connect.py | 99 +++++++++++++++++++++++--------------- 1 file changed, 60 insertions(+), 39 deletions(-) diff --git a/pwncat/commands/connect.py b/pwncat/commands/connect.py index ab7cd84..9fd803c 100644 --- a/pwncat/commands/connect.py +++ b/pwncat/commands/connect.py @@ -58,7 +58,7 @@ class Command(CommandDefinition): action="store_true", help="List installed implants with remote connection capability", ), - "--connection,--conn": Parameter( + "--certificate,--cert": Parameter( Complete.NONE, help="Certificate for SSL-encrypted listeners", ), @@ -82,11 +82,17 @@ class Command(CommandDefinition): def run(self, manager: "pwncat.manager.Manager", args): - protocol = None - user = None - password = None - host = None - port = None + query_args = {} + query_args["protocol"] = None + query_args["user"] = None + query_args["password"] = None + query_args["host"] = None + query_args["port"] = None + query_args["platform"] = args.platform + query_args["identity"] = args.identity + query_args["certfile"] = args.certificate + query_args["keyfile"] = args.certificate + querystring = None used_implant = None if args.list: @@ -132,19 +138,33 @@ class Command(CommandDefinition): if args.connection_string: m = self.CONNECTION_PATTERN.match(args.connection_string) - protocol = m.group("protocol") - user = m.group("user") - password = m.group("password") - host = m.group("host") - port = m.group("port") + query_args["protocol"] = m.group("protocol") + query_args["user"] = m.group("user") + query_args["password"] = m.group("password") + query_args["host"] = m.group("host") + query_args["port"] = m.group("port") + querystring = m.group("querystring") - if protocol is not None: - protocol = protocol.removesuffix("://") + if query_args["protocol"] is not None: + query_args["protocol"] = query_args["protocol"].removesuffix("://") - if host is not None and host == "": - host = None + if querystring is not None: + for arg in querystring.split("&"): + if arg.find("=") == -1: + continue - if protocol is not None and args.listen: + key, *value = arg.split("=") + + if key in query_args and query_args[key] is not None: + console.log(f"[red]error[/red]: multiple values for {key}") + return + + query_args[key] = "=".join(value) + + if query_args["host"] is not None and query_args["host"] == "": + query_args["host"] = None + + if query_args["protocol"] is not None and args.listen: console.log( "[red]error[/red]: --listen is not compatible with an explicit connection string" ) @@ -153,7 +173,7 @@ class Command(CommandDefinition): if ( sum( [ - port is not None, + query_args["port"] is not None, args.port is not None, args.pos_port is not None, ] @@ -164,22 +184,24 @@ class Command(CommandDefinition): return if args.port is not None: - port = args.port + query_args["port"] = args.port if args.pos_port is not None: - port = args.pos_port + query_args["port"] = args.pos_port - if port is not None: + if query_args["port"] is not None: try: - port = int(port.lstrip(":")) + query_args["port"] = int(query_args["port"].lstrip(":")) except ValueError: - console.log(f"[red]error[/red]: {port}: invalid port number") + console.log( + f"[red]error[/red]: {query_args['port'].lstrip(':')}: invalid port number" + ) return # Attempt to reconnect via installed implants if ( - protocol is None - and password is None - and port is None + query_args["protocol"] is None + and query_args["password"] is None + and query_args["port"] is None and args.identity is None ): db = manager.db.open() @@ -188,11 +210,14 @@ class Command(CommandDefinition): # Locate all installed implants for target in db.root.targets: - if target.guid != host and target.public_address[0] != host: + if ( + target.guid != query_args["host"] + and target.public_address[0] != query_args["host"] + ): continue # Collect users - users = {} + userss = {} for fact in target.facts: if "user" in fact.types: users[fact.id] = fact @@ -211,11 +236,14 @@ class Command(CommandDefinition): ) as progress: task = progress.add_task("", status="...") for target, implant_user, implant in implants: - # Check correct user - if user is not None and implant_user.name != user: + # Check correct query_args["user"] + if query_args["user"] is not None and implant_user.name != user: continue # Check correct platform - if args.platform is not None and target.platform != args.platform: + if ( + query_args["platform"] is not None + and target.platform != query_args["platform"] + ): continue progress.update( @@ -229,17 +257,10 @@ class Command(CommandDefinition): used_implant = implant break except ModuleFailed: + db.transaction_manager.commit() continue if used_implant is not None: manager.target.log(f"connected via {used_implant.title(manager.target)}") else: - manager.create_session( - platform=args.platform, - protocol=protocol, - user=user, - password=password, - host=host, - port=port, - identity=args.identity, - ) + manager.create_session(**query_args) From e843c89b9a968bf3e33605266d596f3ba0eb8c21 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Mon, 14 Jun 2021 17:35:47 -0400 Subject: [PATCH 11/14] Added ssl-bind and ssl-connect usage documentation --- docs/source/usage.rst | 55 +++++++++++++++++++++++++++++++++++++++---- 1 file changed, 51 insertions(+), 4 deletions(-) diff --git a/docs/source/usage.rst b/docs/source/usage.rst index 4228770..ce4f3ed 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -34,6 +34,13 @@ with three different C2 protocols: ``bind``, ``connect``, and ``ssh``. The first modes simply open a raw socket and assume there is a shell on the other end. In SSH mode, we legitimately authenticate to the victim host with provided credentials and utilize the SSH shell channel as our C2 channel. +pwncat also implements SSL-wrapped versions of ``bind`` and ``connect`` protocols aptly named ``ssl-bind`` +and ``ssl-connect``. These protocols function largely the same as bind/connect, except that they operate +over an encrypted SSL tunnel. You must use an encrypted bind or reverse shell on the victim side such +as ``ncat --ssl`` or `socat OPENSSL-LISTEN:`. For the ``ssl-bind`` protocol, you must also supply either +the ``--certificate`` argument pointing to a PEM formatted bundled certificate and key file or two +querystring parameters named ``certfile`` and ``keyfile``. + pwncat exposes these different C2 channel protocols via the ``protocol`` field of the connection string discussed below. @@ -42,22 +49,27 @@ Connecting to a Victim Connecting to a victim is accomplished through a connection string. Connection strings are versatile ways to describe the parameters to a specific C2 Channel/Protocol. This looks something like: -``[protocol://][user[:password]]@[host:][port]`` +``[protocol://][user[:password]]@[host:][port][?arg1=value&arg2=value]`` Each field in the connection string translates to a parameter passed to the C2 channel. Some channels don't require all the parameters. For example, a ``bind`` or ``connect`` channel doesn't required a username or -a password. +a password. If there is not an explicit argument or parsed value within the above format, you can use the +query string arguments to specify arbitrary channel arguments. You cannot specify the same argument twice +(e.g. ``connect://hostname:1111?port=4444``). If the ``protocol`` field is not specified, pwncat will attempt to figure out the correct protocol contextually. The following rules apply: - If a user and host are provided, assume ``ssh`` protocol - If no user is provided but a host and port are provided, assume protocol is ``connect`` +- If no user or host is provided (or host is ``0.0.0.0``) and the ``certfile`` or ``keyfile`` arguments are + provided, protocol is assumed to be ``ssl-bind`` - If no user or host is provided (or host is ``0.0.0.0``), protocol is assumed to be ``bind`` - If a second positional integer parameter is specified, the protocol is assumed to be ``connect`` - This is the ``netcat`` syntax seen in the below examples for the ``connect`` protocol. -- If the ``-l`` parameter is used, the protocol is assumed to be ``bind``. - - This is the ``netcat`` syntax seen in the below examples for the ``bind`` protocol. +- If the ``-l`` parameter is used and the ``certfile`` or ``keyfile`` arguments are provided, the protocol + is assumed to be ``ssl-bind``. +- If the ``-l`` parameter is used alone, then the protocol is assumed to be ``bind`` Connecting to a victim bind shell --------------------------------- @@ -75,6 +87,18 @@ address which is routable (e.g. not NAT'd). The ``connect`` protocol provides th # Connection string with assumed protocol pwncat 192.168.1.1:4444 +Connecting to a victim encrypted bind shell +------------------------------------------- + +In this case, the victim is running a ssl-wrapped bind shell on an open port. The victim must be available at an +address which is routable (e.g. not NAT'd). The ``ssl-connect`` protocol provides this capability. + +.. code-block:: bash + :caption: Connecting to a bind shell at 1.1.1.1:4444 + + # Full connection string + pwncat connect://192.168.1.1:4444 + Catching a victim reverse shell ------------------------------- @@ -94,6 +118,29 @@ victim machine. This mode is accessed via the ``bind`` protocol. # Assumed protocol, assumed bind address pwncat :4444 +Catching a victim encrypted reverse shell +----------------------------------------- + +In this case, the victim was exploited in such a way that they open an ssl 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 ``ssl-bind`` protocol. + +If using the ``--cert/--certificate`` argument, you must provided a combined certificate and key file in PEM +format. If your key and certificate are stored in separate files, you should specify the ``certfile`` and +``keyfile`` querystring arguments instead. + +.. code-block:: bash + :caption: Catching a reverse shell + + # netcat syntax + pwncat -l --cert /path/to/cert.pem 4444 + # Full connection string + pwncat ssl-bind://0.0.0.0:4444?certfile=/path/to/cert.pem&keyfile=/path/to/key.pem + # Assumed protocol + pwncat --cert /path/to/cert.pem 0.0.0.0:4444 + # Assumed protocol, assumed bind address + pwncat --cert /path/to/cert.pem :4444 + Connecting to a Remote SSH Server --------------------------------- From a16885785243db4fa9bff26a97f98813107674c8 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Tue, 15 Jun 2021 11:38:23 -0400 Subject: [PATCH 12/14] Fixed typo in users variable --- pwncat/__main__.py | 2 +- pwncat/commands/connect.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/pwncat/__main__.py b/pwncat/__main__.py index 08f106b..d15f023 100644 --- a/pwncat/__main__.py +++ b/pwncat/__main__.py @@ -248,7 +248,7 @@ def main(): continue # Collect users - userss = {} + users = {} for fact in target.facts: if "user" in fact.types: users[fact.id] = fact diff --git a/pwncat/commands/connect.py b/pwncat/commands/connect.py index 9fd803c..3ce0beb 100644 --- a/pwncat/commands/connect.py +++ b/pwncat/commands/connect.py @@ -217,7 +217,7 @@ class Command(CommandDefinition): continue # Collect users - userss = {} + users = {} for fact in target.facts: if "user" in fact.types: users[fact.id] = fact From 39447c6a31ea32dd75723c60885a2f01cfa9c737 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Wed, 16 Jun 2021 19:10:33 -0400 Subject: [PATCH 13/14] Ran pre-merge checks and updated changelog --- CHANGELOG.md | 7 ++++++- pwncat/__main__.py | 5 ++++- pwncat/channel/socket.py | 4 ++-- pwncat/commands/connect.py | 5 ++++- 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a76753f..7ccd87a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,7 +14,12 @@ and simply didn't have the time to go back and retroactively create one. ### Changed - Changed session tracking so session IDs aren't reused - Changed zsh prompt to match CWD of other shell prompts - +### Added +- Added `ssl-bind` and `ssl-connect` channel protocols for encrypted shells +- Added `--certificate/--cert` argument to entrypoint and `connect` command +- Added query-string arguments to connection strings for both the entrypoint + and the `connect` command. + ## [0.4.2] - 2021-06-15 Quick patch release due to corrected bug in `ChannelFile` which caused command output to be empty in some situations. diff --git a/pwncat/__main__.py b/pwncat/__main__.py index d15f023..4fef9c1 100644 --- a/pwncat/__main__.py +++ b/pwncat/__main__.py @@ -268,7 +268,10 @@ def main(): task = progress.add_task("", status="...") for target, implant_user, implant in implants: # Check correct query_args["user"] - if query_args["user"] is not None and implant_user.name != user: + if ( + query_args["user"] is not None + and implant_user.name != query_args["user"] + ): continue # Check correct platform if ( diff --git a/pwncat/channel/socket.py b/pwncat/channel/socket.py index ffa51a2..3a51475 100644 --- a/pwncat/channel/socket.py +++ b/pwncat/channel/socket.py @@ -100,7 +100,7 @@ class Socket(Channel): except BrokenPipeError as exc: self._connected = False raise ChannelClosed(self) from exc - except (ssl.SSLEOFError, ssl.SSLSyscallError, ssl.SSLZeroReturnError): + except (ssl.SSLEOFError, ssl.SSLSyscallError, ssl.SSLZeroReturnError) as exc: self._connected = False raise ChannelClosed(self) from exc @@ -133,7 +133,7 @@ class Socket(Channel): return data except ssl.SSLWantReadError: return data - except (ssl.SSLEOFError, ssl.SSLSyscallError, ssl.SSLZeroReturnError): + except (ssl.SSLEOFError, ssl.SSLSyscallError, ssl.SSLZeroReturnError) as exc: self._connected = False raise ChannelClosed(self) from exc except socket.error as exc: diff --git a/pwncat/commands/connect.py b/pwncat/commands/connect.py index 3ce0beb..9df85f4 100644 --- a/pwncat/commands/connect.py +++ b/pwncat/commands/connect.py @@ -237,7 +237,10 @@ class Command(CommandDefinition): task = progress.add_task("", status="...") for target, implant_user, implant in implants: # Check correct query_args["user"] - if query_args["user"] is not None and implant_user.name != user: + if ( + query_args["user"] is not None + and implant_user.name != query_args["user"] + ): continue # Check correct platform if ( From 102426e59f7e2bdeb71f63637716135cce4c79f6 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Wed, 16 Jun 2021 22:44:29 -0400 Subject: [PATCH 14/14] Added ncat style arguments to entry and connect --- CHANGELOG.md | 4 +-- docs/source/installation.rst | 26 ++++++++--------- docs/source/usage.rst | 42 +++++++++++++++------------ pwncat/__main__.py | 33 +++++++++++++++++---- pwncat/channel/__init__.py | 6 +++- pwncat/channel/ssl_bind.py | 56 ++++++++++++++++++++++++++++++++++++ pwncat/commands/connect.py | 30 +++++++++++++++---- 7 files changed, 152 insertions(+), 45 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ccd87a..9989973 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,10 +16,10 @@ and simply didn't have the time to go back and retroactively create one. - Changed zsh prompt to match CWD of other shell prompts ### Added - Added `ssl-bind` and `ssl-connect` channel protocols for encrypted shells -- Added `--certificate/--cert` argument to entrypoint and `connect` command +- Added `ncat`-style ssl arguments to entrypoint and `connect` command - Added query-string arguments to connection strings for both the entrypoint and the `connect` command. - + ## [0.4.2] - 2021-06-15 Quick patch release due to corrected bug in `ChannelFile` which caused command output to be empty in some situations. diff --git a/docs/source/installation.rst b/docs/source/installation.rst index b9bc894..f683151 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -23,34 +23,34 @@ After installation, you can use pwncat via the installed script: .. code-block:: bash $ pwncat --help - usage: pwncat [-h] [--config CONFIG] [--identity IDENTITY] [--listen] - [--platform PLATFORM] [--port PORT] [--list] + usage: pwncat [-h] [--version] [--download-plugins] [--config CONFIG] [--ssl] [--ssl-cert SSL_CERT] + [--ssl-key SSL_KEY] [--identity IDENTITY] [--listen] [--platform PLATFORM] [--port PORT] [--list] [[protocol://][user[:password]@][host][:port]] [port] - Start interactive pwncat session and optionally connect to existing victim - via a known platform and channel type. This entrypoint can also be used to - list known implants on previous targets. + Start interactive pwncat session and optionally connect to existing victim via a known platform and channel type. This + entrypoint can also be used to list known implants on previous targets. positional arguments: [protocol://][user[:password]@][host][:port] Connection string describing victim - port Alternative port number to support netcat-style - syntax + port Alternative port number to support netcat-style syntax optional arguments: -h, --help show this help message and exit + --version, -v Show version number and exit + --download-plugins Pre-download all Windows builtin plugins and exit immediately --config CONFIG, -c CONFIG Custom configuration file (default: ./pwncatrc) + --ssl Connect or listen with SSL + --ssl-cert SSL_CERT Certificate for SSL-encrypted listeners (PEM) + --ssl-key SSL_KEY Key for SSL-encrypted listeners (PEM) --identity IDENTITY, -i IDENTITY Private key for SSH authentication - --listen, -l Enable the `bind` protocol (supports netcat-style - syntax) + --listen, -l Enable the `bind` protocol (supports netcat-style syntax) --platform PLATFORM, -m PLATFORM Name of the platform to use (default: linux) - --port PORT, -p PORT Alternative way to specify port to support netcat- - style syntax - --list List installed implants with remote connection - capability + --port PORT, -p PORT Alternative way to specify port to support netcat-style syntax + --list List installed implants with remote connection capability Windows Plugin Binaries ----------------------- diff --git a/docs/source/usage.rst b/docs/source/usage.rst index ce4f3ed..c2118b5 100644 --- a/docs/source/usage.rst +++ b/docs/source/usage.rst @@ -37,9 +37,7 @@ authenticate to the victim host with provided credentials and utilize the SSH sh pwncat also implements SSL-wrapped versions of ``bind`` and ``connect`` protocols aptly named ``ssl-bind`` and ``ssl-connect``. These protocols function largely the same as bind/connect, except that they operate over an encrypted SSL tunnel. You must use an encrypted bind or reverse shell on the victim side such -as ``ncat --ssl`` or `socat OPENSSL-LISTEN:`. For the ``ssl-bind`` protocol, you must also supply either -the ``--certificate`` argument pointing to a PEM formatted bundled certificate and key file or two -querystring parameters named ``certfile`` and ``keyfile``. +as ``ncat --ssl`` or `socat OPENSSL-LISTEN:`. pwncat exposes these different C2 channel protocols via the ``protocol`` field of the connection string discussed below. @@ -61,14 +59,17 @@ If the ``protocol`` field is not specified, pwncat will attempt to figure out th contextually. The following rules apply: - If a user and host are provided, assume ``ssh`` protocol -- If no user is provided but a host and port are provided, assume protocol is ``connect`` -- If no user or host is provided (or host is ``0.0.0.0``) and the ``certfile`` or ``keyfile`` arguments are - provided, protocol is assumed to be ``ssl-bind`` +- If no user is provided but a host, port and the ``--ssl`` argument, assume protocol is ``ssl-connect`` +- If no user is provided but a host and port are provided and no ``--ssl``, assume protocol is ``connect`` +- If no user or host is provided (or host is ``0.0.0.0``) and the ``certfile``, ``keyfile``, or + ``--ssl`` arguments are provided, protocol is assumed to be ``ssl-bind`` - If no user or host is provided (or host is ``0.0.0.0``), protocol is assumed to be ``bind`` -- If a second positional integer parameter is specified, the protocol is assumed to be ``connect`` - - This is the ``netcat`` syntax seen in the below examples for the ``connect`` protocol. -- If the ``-l`` parameter is used and the ``certfile`` or ``keyfile`` arguments are provided, the protocol - is assumed to be ``ssl-bind``. +- If a second positional integer parameter is specified and ``--ssl`` is not, the protocol is assumed + to be ``connect`` +- If a second positional integer parameter is specified and ``--ssl`` is provided, the protocol is + assumed to be ``ssl-connect`` +- If the ``-l`` parameter is used and the ``certfile``, ``keyfile``, or ``--ssl`` arguments are + provided, the protocol is assumed to be ``ssl-bind``. - If the ``-l`` parameter is used alone, then the protocol is assumed to be ``bind`` Connecting to a victim bind shell @@ -98,6 +99,9 @@ address which is routable (e.g. not NAT'd). The ``ssl-connect`` protocol provide # Full connection string pwncat connect://192.168.1.1:4444 + # ncat style syntax + pwncat --ssl 192.168.1.1 4444 + pwncat --ssl 192.168.1.1:4444 Catching a victim reverse shell ------------------------------- @@ -125,21 +129,21 @@ In this case, the victim was exploited in such a way that they open an ssl conne 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 ``ssl-bind`` protocol. -If using the ``--cert/--certificate`` argument, you must provided a combined certificate and key file in PEM -format. If your key and certificate are stored in separate files, you should specify the ``certfile`` and -``keyfile`` querystring arguments instead. +If the explicit ``ssl-bind`` protocol or the ``--ssl`` argument is provided without an explicit certfile +or keyfile, a self-signed certificate is generated with dummy attributes. The certfile and keyfile can +both point to the same bundled PEM file if both the key and certificate are present. .. code-block:: bash :caption: Catching a reverse shell - # netcat syntax - pwncat -l --cert /path/to/cert.pem 4444 + # ncat style syntax + pwncat --ssl --ssl-cert cert.pem --ssl-key cert.pem -lp 4444 # Full connection string pwncat ssl-bind://0.0.0.0:4444?certfile=/path/to/cert.pem&keyfile=/path/to/key.pem - # Assumed protocol - pwncat --cert /path/to/cert.pem 0.0.0.0:4444 - # Assumed protocol, assumed bind address - pwncat --cert /path/to/cert.pem :4444 + # Auto-generated self-signed certificate + pwncat --ssl -lp 4444 + # Auto-generated self-signed certificate with explicit protocol + pwncat ssl-bind://0.0.0.0:4444 Connecting to a Remote SSH Server --------------------------------- diff --git a/pwncat/__main__.py b/pwncat/__main__.py index 4fef9c1..e64ac7c 100644 --- a/pwncat/__main__.py +++ b/pwncat/__main__.py @@ -39,11 +39,16 @@ def main(): default=None, help="Custom configuration file (default: ./pwncatrc)", ) + parser.add_argument("--ssl", action="store_true", help="Connect or listen with SSL") parser.add_argument( - "--certificate", - "--cert", + "--ssl-cert", default=None, - help="Certificate for SSL-encrypted listeners", + help="Certificate for SSL-encrypted listeners (PEM)", + ) + parser.add_argument( + "--ssl-key", + default=None, + help="Key for SSL-encrypted listeners (PEM)", ) parser.add_argument( "--identity", @@ -163,8 +168,9 @@ def main(): query_args["port"] = None query_args["platform"] = args.platform query_args["identity"] = args.identity - query_args["certfile"] = args.certificate - query_args["keyfile"] = args.certificate + query_args["certfile"] = args.ssl_cert + query_args["keyfile"] = args.ssl_key + query_args["ssl"] = args.ssl querystring = None if args.connection_string: @@ -201,6 +207,23 @@ def main(): ) return + if ( + query_args["certfile"] is None and query_args["keyfile"] is not None + ) or (query_args["certfile"] is not None and query_args["keyfile"] is None): + console.log( + "[red]error[/red]: both a ssl certificate and key file are required" + ) + return + + if query_args["certfile"] is not None or query_args["keyfile"] is not None: + query_args["ssl"] = True + + if query_args["protocol"] is not None and args.ssl: + console.log( + "[red]error[/red]: --ssl is incompatible with an explicit protocol" + ) + return + if ( sum( [ diff --git a/pwncat/channel/__init__.py b/pwncat/channel/__init__.py index 7db3b16..f5a02d1 100644 --- a/pwncat/channel/__init__.py +++ b/pwncat/channel/__init__.py @@ -584,12 +584,16 @@ def create(protocol: Optional[str] = None, **kwargs) -> Channel: if ( kwargs.get("certfile") is not None or kwargs.get("keyfile") is not None + or kwargs.get("ssl") ): protocols.append("ssl-bind") else: protocols.append("bind") else: - protocols.append("connect") + if kwargs.get("ssl"): + protocols.append("ssl-connect") + else: + protocols.append("connect") else: protocols = [protocol] diff --git a/pwncat/channel/ssl_bind.py b/pwncat/channel/ssl_bind.py index 8704677..9ffbdb8 100644 --- a/pwncat/channel/ssl_bind.py +++ b/pwncat/channel/ssl_bind.py @@ -1,5 +1,12 @@ #!/usr/bin/env python3 import ssl +import datetime +import tempfile + +from cryptography import x509 +from cryptography.x509.oid import NameOID +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import rsa from pwncat.channel import ChannelError from pwncat.channel.bind import Bind @@ -10,6 +17,10 @@ class SSLBind(Bind): super().__init__(**kwargs) self.context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + + if certfile is None and keyfile is None: + certfile = keyfile = self._generate_self_signed_cert() + self.context.load_cert_chain(certfile, keyfile) self.server = self.context.wrap_socket(self.server) @@ -20,3 +31,48 @@ class SSLBind(Bind): super().connect() except ssl.SSLError as exc: raise ChannelError(self, str(exc)) + + def _generate_self_signed_cert(self): + """Generate a self-signed certificate""" + + with tempfile.NamedTemporaryFile("wb", delete=False) as filp: + 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, u"US"), + x509.NameAttribute(NameOID.COUNTRY_NAME, u"US"), + x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, u"California"), + x509.NameAttribute(NameOID.LOCALITY_NAME, u"San Francisco"), + x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"My Company"), + x509.NameAttribute(NameOID.COMMON_NAME, u"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(u"localhost")]), + critical=False, + ) + .sign(key, hashes.SHA256()) + ) + + filp.write(cert.public_bytes(serialization.Encoding.PEM)) + + return filp.name diff --git a/pwncat/commands/connect.py b/pwncat/commands/connect.py index 9df85f4..179447a 100644 --- a/pwncat/commands/connect.py +++ b/pwncat/commands/connect.py @@ -58,9 +58,15 @@ class Command(CommandDefinition): action="store_true", help="List installed implants with remote connection capability", ), - "--certificate,--cert": Parameter( - Complete.NONE, - help="Certificate for SSL-encrypted listeners", + "--ssl-cert": Parameter( + Complete.LOCAL_FILE, + help="Certificate for SSL-encrypted listeners (PEM)", + ), + "--ssl-key": Parameter( + Complete.LOCAL_FILE, help="Key for SSL-encrypted listeners (PEM)" + ), + "--ssl": Parameter( + Complete.NONE, action="store_true", help="Connect or listen with SSL" ), "connection_string": Parameter( Complete.NONE, @@ -90,8 +96,9 @@ class Command(CommandDefinition): query_args["port"] = None query_args["platform"] = args.platform query_args["identity"] = args.identity - query_args["certfile"] = args.certificate - query_args["keyfile"] = args.certificate + query_args["certfile"] = args.ssl_cert + query_args["keyfile"] = args.ssl_key + query_args["ssl"] = args.ssl querystring = None used_implant = None @@ -170,6 +177,17 @@ class Command(CommandDefinition): ) return + if (query_args["certfile"] is None and query_args["keyfile"] is not None) or ( + query_args["certfile"] is not None and query_args["keyfile"] is None + ): + console.log( + "[red]error[/red]: both a ssl certificate and key file are required" + ) + return + + if query_args["certfile"] is not None or query_args["keyfile"] is not None: + query_args["ssl"] = True + if ( sum( [ @@ -183,6 +201,8 @@ class Command(CommandDefinition): console.log("[red]error[/red]: multiple ports specified") return + console.log(args.pos_port) + if args.port is not None: query_args["port"] = args.port if args.pos_port is not None: