1
0
mirror of https://github.com/calebstewart/pwncat.git synced 2024-11-27 10:54:14 +01:00

Initial modifications to make configuration refactoring work

This commit is contained in:
Caleb Stewart 2020-10-08 13:22:41 -04:00
parent fa18ae68fd
commit a825d00da2
10 changed files with 944 additions and 17 deletions

View File

@ -1,14 +1,8 @@
#!/usr/bin/env python3
from typing import Optional
import pwncat.db
import pwncat.modules
import pwncat.platform
import pwncat.commands
import pwncat.config
import pwncat.file
import pwncat.remote
import pwncat.tamper
import pwncat.util
from .config import Config
victim: Optional["pwncat.remote.Victim"] = None
config: Config = Config()

163
pwncat/channel/__init__.py Normal file
View File

@ -0,0 +1,163 @@
#!/usr/bin/env python3
import time
from typing import Optional, Type
CHANNEL_TYPES = {}
class ChannelError(Exception):
""" Raised when a channel fails to connect """
class ChannelTimeout(Exception):
""" Raised when a read times out.
:param data: the data read before the timeout occurred
:type data: bytes
"""
def __init__(self, data: bytes):
super().__init__("channel recieve timed out")
self.data: bytes = data
class Channel:
"""
Abstract interation with a remote victim. This class acts similarly to a
socket object. In the common cases, it simply wraps a socket object.
"""
def __init__(self, host: str, port: int, user: str, password: str, **kwargs):
self.host: str = host
self.port: int = port
self.user: str = user
self.password: str = password
self.peek_buffer: bytes = b""
def send(self, data: bytes):
""" Send data to the remote shell. This is a blocking call
that only returns after all data is sent. """
def sendline(self, data: bytes, end: bytes = b"\n"):
""" Send data followed by an ending character. If no ending
character is specified, a new line is used. """
return self.send(data + end)
def recv(self, count: Optional[int] = None) -> bytes:
""" Receive data from the remote shell
If your channel class does not implement ``peak``, a default
implementation is provided. In this case, you can use the
``_pop_peek`` to get available peek buffer data prior to
reading data like a normal ``recv``.
:param count: maximum number of bytes to receive (default: unlimited)
:type count: int
:return: the data that was received
:rtype: bytes
"""
def recvuntil(self, needle: bytes, timeout: Optional[float] = None) -> bytes:
""" Receive data until the specified string of bytes is bytes
is found. The needle is not stripped from the data.
:param needle: the bytes to wait for
:type needle: bytes
:param timeout: a timeout in seconds (default: 30s)
:type timeout: Optional[float]
:return: the bytes that were read
:rtype: bytes
"""
if timeout is None:
timeout = 30
data = b""
time_end = time.time() + timeout
# We read one byte at a time so we don't overshoot the goal
while not data.endswith(needle):
# Check if we have timed out
if time.time() >= time_end:
raise ChannelTimeout(data)
next_byte = self.recv(1)
if next_byte is not None:
data += next_byte
return data
def recvline(self, timeout: Optional[float] = None) -> bytes:
""" Recieve data until a newline is received. The newline
is not stripped. """
return self.recvuntil(b"\n", timeout=timeout)
def peek(self, count: Optional[int] = None):
""" Receive data from the remote shell and leave
the data in the recv buffer.
There is a default implementation for this method which will
utilize ``recv`` to get data, and buffer it. If the default
``peek`` implementation is used, ``recv`` should read from
``self.peek_buffer`` prior to calling the underlying ``recv``.
:param count: maximum number of bytes to receive (default: unlimited)
:type count: int
:return: data that was received
:rtype: bytes
"""
# Grab any already buffered data
if self.peek_buffer:
data = self.peek_buffer
else:
data = b""
# Check for more data within our count
if len(data) < count:
self.peek_buffer = b""
data += self.recv(count - len(data))
self.peek_buffer = data
return data
def register(name: str, channel_class):
"""
Register a new channel class with ``pwncat``.
:param name: the name which this channel will be referenced by.
:type name: str
:param channel_class: A class object implementing the channel
interface.
"""
CHANNEL_TYPES[name] = channel_class
def find(name: str) -> Type[Channel]:
"""
Retrieve the channel class for the specified name.
:param name: the name of the channel you'd like
:type name: str
:return: the channel class
:rtype: Channel Class Object
"""
return CHANNEL_TYPES[name]
# Import default channel types and register them
from pwncat.channel.bind import Bind
from pwncat.channel.connect import Connect
from pwncat.channel.ssh import Ssh
register("bind", Bind)
register("connect", Connect)
register("ssh", Ssh)

104
pwncat/channel/bind.py Normal file
View File

@ -0,0 +1,104 @@
#!/usr/bin/env python3
import socket
from typing import Optional
from rich.progress import BarColumn, Progress
from pwncat.channel import Channel, ChannelError
class Bind(Channel):
"""
Implements a channel which rides over a shell attached
directly to a socket. This channel will listen for incoming
connections on the specified port, and assume the resulting
connection is a shell from the victim.
"""
def __init__(self, host: str, port: int, user: str, password: str, **kwargs):
super().__init__(host, port, user, password)
if not host or host == "":
host = "0.0.0.0"
if port is None:
raise ChannelError(f"no port specified")
with Progress(
f"bound to [blue]{host}[/blue]:[cyan]{port}[/cyan]",
BarColumn(bar_width=None),
transient=True,
) as progress:
task_id = progress.add_task("listening", total=1, start=False)
# Create the socket server
server = socket.create_server((host, port), reuse_port=True)
try:
# Wait for a connection
(client, address) = server.accept()
except KeyboardInterrupt:
raise ChannelError("listener aborted")
progress.update(task_id, visible=False)
progress.log(
f"[green]received[/green] connection from [blue]{address[0]}[/blue]:[cyan]{address[1]}[/cyan]"
)
self.client = client
self.address = address
def send(self, data: bytes):
""" Send data to the remote shell. This is a blocking call
that only returns after all data is sent. """
self.client.sendall(data)
return len(data)
def recv(self, count: Optional[int] = None) -> bytes:
""" Receive data from the remote shell
If your channel class does not implement ``peak``, a default
implementation is provided. In this case, you can use the
``_pop_peek`` to get available peek buffer data prior to
reading data like a normal ``recv``.
:param count: maximum number of bytes to receive (default: unlimited)
:type count: int
:return: the data that was received
:rtype: bytes
"""
return self.client.recv(count)
def recvuntil(self, needle: bytes) -> bytes:
""" Receive data until the specified string of bytes is bytes
is found. The needle is not stripped from the data. """
data = b""
# We read one byte at a time so we don't overshoot the goal
while not data.endswith(needle):
next_byte = self.recv(1)
if next_byte is not None:
data += next_byte
return data
def peek(self, count: Optional[int] = None):
""" Receive data from the remote shell and leave
the data in the recv buffer.
There is a default implementation for this method which will
utilize ``recv`` to get data, and buffer it. If the default
``peek`` implementation is used, ``recv`` should read from
``self.peek_buffer`` prior to calling the underlying ``recv``.
:param count: maximum number of bytes to receive (default: unlimited)
:type count: int
:return: data that was received
:rtype: bytes
"""
return self.client.recv(count, socket.MSG_PEEK)

99
pwncat/channel/connect.py Normal file
View File

@ -0,0 +1,99 @@
#!/usr/bin/env python3
import socket
from typing import Optional
from rich.progress import BarColumn, Progress
from pwncat.channel import Channel, ChannelError
class Connect(Channel):
"""
Implements a channel which rides over a shell attached
directly to a socket. This channel will listen for incoming
connections on the specified port, and assume the resulting
connection is a shell from the victim.
"""
def __init__(self, host: str, port: int, user: str, password: str, **kwargs):
super().__init__(host, port, user, password)
if not host:
raise ChannelError("no host address provided")
if port is None:
raise ChannelError("no port provided")
with Progress(
f"connecting to [blue]{host}[/blue]:[cyan]{port}[/cyan]",
BarColumn(bar_width=None),
transient=True,
) as progress:
task_id = progress.add_task("connecting", total=1, start=False)
# Connect to the remote host
client = socket.create_connection((host, port))
progress.update(task_id, visible=False)
progress.log(
f"connection to "
f"[blue]{host}[/blue]:[cyan]{port}[/cyan] [green]established[/green]"
)
self.client = client
self.address = (host, port)
def send(self, data: bytes):
""" Send data to the remote shell. This is a blocking call
that only returns after all data is sent. """
self.client.sendall(data)
return len(data)
def recv(self, count: Optional[int] = None) -> bytes:
""" Receive data from the remote shell
If your channel class does not implement ``peak``, a default
implementation is provided. In this case, you can use the
``_pop_peek`` to get available peek buffer data prior to
reading data like a normal ``recv``.
:param count: maximum number of bytes to receive (default: unlimited)
:type count: int
:return: the data that was received
:rtype: bytes
"""
return self.client.recv(count)
def recvuntil(self, needle: bytes) -> bytes:
""" Receive data until the specified string of bytes is bytes
is found. The needle is not stripped from the data. """
data = b""
# We read one byte at a time so we don't overshoot the goal
while not data.endswith(needle):
next_byte = self.recv(1)
if next_byte is not None:
data += next_byte
return data
def peek(self, count: Optional[int] = None):
""" Receive data from the remote shell and leave
the data in the recv buffer.
There is a default implementation for this method which will
utilize ``recv`` to get data, and buffer it. If the default
``peek`` implementation is used, ``recv`` should read from
``self.peek_buffer`` prior to calling the underlying ``recv``.
:param count: maximum number of bytes to receive (default: unlimited)
:type count: int
:return: data that was received
:rtype: bytes
"""
return self.client.recv(count, socket.MSG_PEEK)

104
pwncat/channel/reconnect.py Normal file
View File

@ -0,0 +1,104 @@
#!/usr/bin/env python3
from typing import Optional
from rich.progress import BarColumn, Progress
from pwncat.channel import Channel, ChannelError
import pwncat.modules
class Reconnect(Channel):
"""
Implements a channel which rides over a shell attached
directly to a socket. This channel will listen for incoming
connections on the specified port, and assume the resulting
connection is a shell from the victim.
"""
def __init__(self, host: str, port: int, user: str, password: str, **kwargs):
super().__init__(host, port, user, password)
if not host or host == "":
host = "0.0.0.0"
if port is None:
raise ChannelError(f"no port specified")
with Progress(
f"bound to [blue]{host}[/blue]:[cyan]{port}[/cyan]",
BarColumn(bar_width=None),
transient=True,
) as progress:
task_id = progress.add_task("listening", total=1, start=False)
# Create the socket server
server = socket.create_server((host, port), reuse_port=True)
try:
# Wait for a connection
(client, address) = server.accept()
except KeyboardInterrupt:
raise ChannelError("listener aborted")
progress.update(task_id, visible=False)
progress.log(
f"[green]received[/green] connection from [blue]{address[0]}[/blue]:[cyan]{address[1]}[/cyan]"
)
self.client = client
self.address = address
def send(self, data: bytes):
""" Send data to the remote shell. This is a blocking call
that only returns after all data is sent. """
self.client.sendall(data)
return len(data)
def recv(self, count: Optional[int] = None) -> bytes:
""" Receive data from the remote shell
If your channel class does not implement ``peak``, a default
implementation is provided. In this case, you can use the
``_pop_peek`` to get available peek buffer data prior to
reading data like a normal ``recv``.
:param count: maximum number of bytes to receive (default: unlimited)
:type count: int
:return: the data that was received
:rtype: bytes
"""
return self.client.recv(count)
def recvuntil(self, needle: bytes) -> bytes:
""" Receive data until the specified string of bytes is bytes
is found. The needle is not stripped from the data. """
data = b""
# We read one byte at a time so we don't overshoot the goal
while not data.endswith(needle):
next_byte = self.recv(1)
if next_byte is not None:
data += next_byte
return data
def peek(self, count: Optional[int] = None):
""" Receive data from the remote shell and leave
the data in the recv buffer.
There is a default implementation for this method which will
utilize ``recv`` to get data, and buffer it. If the default
``peek`` implementation is used, ``recv`` should read from
``self.peek_buffer`` prior to calling the underlying ``recv``.
:param count: maximum number of bytes to receive (default: unlimited)
:type count: int
:return: data that was received
:rtype: bytes
"""
return self.client.recv(count, socket.MSG_PEEK)

136
pwncat/channel/ssh.py Normal file
View File

@ -0,0 +1,136 @@
#!/usr/bin/env python3
import socket
from typing import Optional
import paramiko
from prompt_toolkit import prompt
from pwncat.channel import Channel, ChannelError
class Ssh(Channel):
"""
Implements a channel which rides over a shell attached
directly to a socket. This channel will listen for incoming
connections on the specified port, and assume the resulting
connection is a shell from the victim.
"""
def __init__(
self,
host: str,
port: int,
user: str,
password: str,
identity: str = None,
**kwargs,
):
super().__init__(host, port, user, password)
if port is None:
port = 22
if not user or user is None:
raise ChannelError("you must specify a user")
if password is None and identity is None:
password = prompt("Password: ", is_password=True)
try:
# Connect to the remote host's ssh server
sock = socket.create_connection((host, port))
except Exception as exc:
raise ChannelError(str(exc))
# Create a paramiko SSH transport layer around the socket
t = paramiko.Transport(sock)
try:
t.start_client()
except paramiko.SSHException:
sock.close()
raise ChannelError("ssh negotiation failed")
if identity is not None:
try:
# Load the private key for the user
key = paramiko.RSAKey.from_private_key_file(identity)
except:
password = prompt("RSA Private Key Passphrase: ", is_password=True)
try:
key = paramiko.RSAKey.from_private_key_file(identity, password)
except:
raise ChannelError("invalid private key or passphrase")
# Attempt authentication
try:
t.auth_publickey(user, key)
except paramiko.ssh_exception.AuthenticationException as exc:
raise ChannelError(str(exc))
else:
try:
t.auth_password(user, password)
except paramiko.ssh_exception.AuthenticationException as exc:
raise ChannelError(str(exc))
if not t.is_authenticated():
t.close()
sock.close()
raise ChannelError("authentication failed")
# Open an interactive session
chan = t.open_session()
chan.get_pty()
chan.invoke_shell()
self.client = chan
self.address = (host, port)
def send(self, data: bytes):
""" Send data to the remote shell. This is a blocking call
that only returns after all data is sent. """
self.client.sendall(data)
return len(data)
def recv(self, count: Optional[int] = None) -> bytes:
""" Receive data from the remote shell
If your channel class does not implement ``peak``, a default
implementation is provided. In this case, you can use the
``_pop_peek`` to get available peek buffer data prior to
reading data like a normal ``recv``.
:param count: maximum number of bytes to receive (default: unlimited)
:type count: int
:return: the data that was received
:rtype: bytes
"""
if self.peek_buffer:
data = self.peek_buffer[:count]
self.peek_buffer = self.peek_buffer[count:]
if len(data) >= count:
return data
else:
data = b""
data += self.client.recv(count - len(data))
return data
def recvuntil(self, needle: bytes) -> bytes:
""" Receive data until the specified string of bytes is bytes
is found. The needle is not stripped from the data. """
data = b""
# We read one byte at a time so we don't overshoot the goal
while not data.endswith(needle):
next_byte = self.recv(1)
if next_byte is not None:
data += next_byte
return data

View File

@ -15,7 +15,7 @@ from pwncat.modules import BaseModule
def key_type(value: str) -> bytes:
""" Converts a key name to a ansi keycode. The value can either be a single
""" Converts a key name to a ansi keycode. The value can either be a single
printable character or a named key from prompt_toolkit Keys """
if len(value) == 1:
return value.encode("utf-8")

View File

@ -1,18 +1,163 @@
#!/usr/bin/env python3
from enum import Flag, auto
from typing import List, Dict, Optional, Tuple, Generator
import enum
import dataclasses
class Platform(Flag):
class Platform(enum.Flag):
UNKNOWN = auto()
WINDOWS = auto()
BSD = auto()
LINUX = auto()
UNKNOWN = enum.auto()
WINDOWS = enum.auto()
BSD = enum.auto()
LINUX = enum.auto()
# This deserves some explanation.
# This indicates that component of pwncat does not need an
# actively connected host to be utilized. When used as a
# module platform, it indicates that the module itself
# only deals with the database or internal pwncat features.
# and is allowed to run prior to a victim being connected.
NO_HOST = auto()
NO_HOST = enum.auto()
ANY = WINDOWS | BSD | LINUX
class CalledProcessError(Exception):
""" Raised when a process exits with a non-zero return code.
This class largely mirrors ``subprocess.CalledProcessError`` class.
"""
def __init__(self, returncode: int, args: List[str], stdout: bytes):
super().__init__(f"Process Exited with Code {returncode}")
self.returncode = returncode
self.cmd = args
self.stdout = stdout
@property
def output(self):
return self.stdout
@dataclasses.dataclass
class CompletedProcess:
""" Represents the results of a process run on the remote system.
This largely mirrors the ``subprocess.CompletedProcess`` class.
"""
args: List[str]
returncode: int
stdout: bytes
def check_returncode(self):
""" If ``returncode`` is none-zero, raise a CalledProcessError """
if self.returncode != 0:
raise CalledProcessError(self.returncode, self.args, self.stdout)
class StreamType(enum.Enum):
""" The type of stream supplied for the stdout, stderr or stdin arguments.
"""
PIPE = enum.auto()
DEVNULL = enum.auto()
class Pipe:
""" File-like object connecting to a pipe on the victim host """
def read(self, count: int = None) -> bytes:
""" Read data """
def write(self, data: bytes) -> int:
""" Write data """
def close(self):
""" Close the pipe """
def isatty(self) -> bool:
""" Check if this stream is a tty """
return False
def readable(self) -> bool:
""" Check if the stream is readable """
def writeable(self) -> bool:
""" Check if the stream is writable """
def seekable(self) -> bool:
""" Remote streams are not seekable """
return False
class Popen:
""" Wraps running a process on the remote host. """
def __init__(
self,
args: List[str],
env: Dict[str, str] = None,
stdout: str = None,
stderr: str = None,
stdin: str = None,
shell: bool = False,
cwd: str = None,
):
return
def poll() -> Optional[int]:
""" Check if the process has completed """
def communicate(self, input: bytes = None, timeout: float = None):
""" Send data to the remote process and collect the output. """
def terminate(self):
""" Kill the remote process. This is sometimes not possible. """
def kill(self):
""" Kill the remote process. """
class _Platform:
""" Abstracts interactions with a target of a specific platform.
This includes running commands, changing directories, locating
binaries, etc.
:param channel: an open a channel with the specified platform
:type channel: pwncat.channel.Channel
"""
def __init__(self, channel: "pwncat.channel.Channel"):
self.channel = channel
def run(
self,
args: List[str],
env: Dict[str, str] = None,
stdout: str = None,
stderr: str = None,
stdin: str = None,
shell: bool = False,
cwd: str = None,
) -> Tuple[bytes, bytes, bytes]:
""" Run the given command on the remote host. A tuple of three bytearrays
is returned. These bytes are delimeters for the sections of output. The
first delimeter is output before the command runs. The second is output
after the command finishes, and the last is output after the return code
is printed. """
def chdir(self, path):
""" Change the current working directory on the victim.
This tracks directory changes on a stack allowing you to
using ``pwncat.victim.popd()`` to return. """
def listdir(self, path=None) -> Generator[str, None, None]:
""" List the contents of a directory. If ``path`` is None,
then the contents of the current directory is listed. The
list is not guaranteed to be sorted in any way.
:param path: the directory to list
:type path: str or Path-like
:raise FileNotFoundError: When the requested directory is not a directory,
does not exist, or you do not have execute permissions.
"""

165
pwncat/remote/subprocess.py Normal file
View File

@ -0,0 +1,165 @@
#!/usr/bin/env python3
from typing import List, IO, Optional
from subprocess import (
CompletedProcess,
SubprocessError,
TimeoutExpired,
CalledProcessError,
)
import io
import pwncat
DEVNULL = 0
""" Redirect to/from /dev/null or equivalent """
PIPE = 1
""" Retrieve data via a Pipe """
class PopenBase:
""" Base class for Popen objects defining the interface.
Individual platforms will subclass this object to implement
the correct logic. This is an abstract class. """
stdin: IO
"""
If the stdin argument was PIPE, this attribute is a writeable
stream object as returned by open(). If the encoding or errors
arguments were specified or the universal_newlines argument was
True, the stream is a text stream, otherwise it is a byte
stream. If the stdin argument was not PIPE, this attribute is
None.
"""
stdout: IO
"""
If the stdout argument was PIPE, this attribute is a readable
stream object as returned by open(). Reading from the stream
provides output from the child process. If the encoding or
errors arguments were specified or the universal_newlines
argument was True, the stream is a text stream, otherwise it
is a byte stream. If the stdout argument was not PIPE, this
attribute is None.
"""
stderr: IO
"""
If the stderr argument was PIPE, this attribute is a readable
stream object as returned by open(). Reading from the stream
provides error output from the child process. If the encoding
or errors arguments were specified or the universal_newlines
argument was True, the stream is a text stream, otherwise it
is a byte stream. If the stderr argument was not PIPE, this
attribute is None.
"""
args: List[str]
"""
The args argument as it was passed to Popen a sequence of
program arguments or else a single string.
"""
pid: int
""" The process ID of the child process. """
returncode: int
"""
The child return code, set by poll() and wait() (and indirectly by
communicate()). A None value indicates that the process hasnt
terminated yet.
"""
def poll(self) -> Optional[int]:
""" Check if the child process has terminated. Set and return
``returncode`` attribute. Otherwise, returns None. """
def wait(self, timeout: float = None) -> int:
""" Wait for child process to terminate. Set and return
``returncode`` attribute.
If the process does not terminate after ``timeout`` seconds,
raise a ``TimeoutExpired`` exception. It is safe to catch
this exception and retry the wait.
"""
def communicate(self, input: bytes = None, timeout: float = None):
""" Interact with process: Send data to stdin. Read data from stdout
and stderr, until end-of-file is readched. Wait for the process to
terminate and set the ``returncode`` attribute. The optional ``input``
argument should be data to be sent to the child process, or None, if
no data should be sent to the child. If streams were opened in text mode,
``input`` must be a string. Otherwise, it must be ``bytes``. """
def send_signal(self, signal: int):
""" Sends the signal ``signal`` to the child.
Does nothing if the process completed.
"""
def terminate(self):
""" Stop the child. """
def kill(self):
""" Kills the child """
def Popen(*args, **kwargs) -> PopenBase:
""" Wrapper to create a new popen object. Deligates to
the current victim's platform ``popen`` method. """
return pwncat.victim.popen(*args, **kwargs)
def run(
args,
stdin=None,
input=None,
stdout=None,
stderr=None,
capture_output=False,
shell=False,
cwd=None,
timeout=None,
check=False,
encoding=None,
errors=None,
text=None,
env=None,
universal_newlines=None,
**other_popen_kwargs
):
""" Run the command described by `args`. Wait for command to complete
and then return a CompletedProcess instance.
The arguments are the same as the `Popen` constructor with ``capture_output``,
``timeout``, and ``check`` added.
"""
# Ensure we capture standard output and standard error
if capture_output:
stdout = PIPE
stderr = PIPE
# Execute the process
proc = Popen(
args=args,
stdin=stdin,
input=input,
stdout=stdout,
stderr=stderr,
shell=shell,
cwd=cwd,
encoding=encoding,
errors=errors,
text=text,
env=env,
universal_newlines=universal_newlines,
**other_popen_kwargs
)
# Send input/receive output
stdout_data, stderr_data = proc.communicate(input, timeout)
# Build the completed process object
completed_proc = CompletedProcess(args, proc.returncode, stdout_data, stderr_data)
# Check the result
if check:
completed_proc.check_returncode()
return completed_proc

View File

@ -52,6 +52,23 @@ dependency_links = [
"https://github.com/JohnHammond/base64io-python/tarball/master#egg=base64io",
]
# Read the requirements
with open("requirements.txt") as filp:
dependencies = [
line.strip() for line in filp.readlines() if not line.startswith("#")
]
# Build dependency links for entries that need them
# This works for "git+https://github.com/user/package" refs
dependency_links = [dep for dep in dependencies if dep.startswith("git+")]
for i, dep in enumerate(dependency_links):
link = dep.split("git+")[1]
name = dep.split("/")[-1]
dependency_links[i] = f"{link}/tarball/master#egg={name}"
# Strip out git+ links from dependencies
dependencies = [dep for dep in dependencies if not dep.startswith("git+")]
# Setup
setup(
name="pwncat",