Working background listener API and commands

Needs more testing, but is functioning currently.
This commit is contained in:
Caleb Stewart 2021-06-19 19:58:44 -04:00
parent 1fda11442a
commit e3c4c12cad
7 changed files with 444 additions and 14 deletions

View File

@ -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

View File

@ -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:

View File

@ -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}")

View File

@ -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)

View File

@ -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):

View File

@ -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()

View File

@ -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()