Working background listener API and commands
Needs more testing, but is functioning currently.
This commit is contained in:
parent
1fda11442a
commit
e3c4c12cad
|
@ -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
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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}")
|
|
@ -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)
|
|
@ -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):
|
||||
|
|
|
@ -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()
|
||||
|
||||
|
|
Loading…
Reference in New Issue