1
0
mirror of https://github.com/calebstewart/pwncat.git synced 2024-11-27 19:04:15 +01:00

Merge branch 'master' into release-v0.5.0

This commit is contained in:
Caleb Stewart 2021-06-18 20:02:34 -04:00
commit 25fac6ae09
15 changed files with 329 additions and 147 deletions

View File

@ -11,6 +11,7 @@ and simply didn't have the time to go back and retroactively create one.
### Fixed
- Pinned container base image to alpine 3.13.5 and installed to virtualenv ([#134](https://github.com/calebstewart/pwncat/issues/134))
- Fixed syntax for f-strings in escalation command
- Re-added `readline` import for windows platform after being accidentally removed
### Changed
- Changed session tracking so session IDs aren't reused
- Changed zsh prompt to match CWD of other shell prompts
@ -19,6 +20,10 @@ and simply didn't have the time to go back and retroactively create one.
- 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.
- Improved exception handling throughout framework ([#133](https://github.com/calebstewart/pwncat/issues/133))
- Added explicit permission checks when opening files
- Changed LinuxWriter close routine again to account for needed EOF signals ([#140](https://github.com/calebstewart/pwncat/issues/140))
- Added better file io test cases
## [0.4.2] - 2021-06-15
Quick patch release due to corrected bug in `ChannelFile` which caused command

View File

@ -43,7 +43,20 @@ class Connect(Socket):
) as progress:
progress.add_task("connecting", total=1, start=False)
# Connect to the remote host
# If we get an invalid host from the user, that cannot be resolved
# then we capture the GAI (getaddrinfo) exception and raise it as ChannelError
# so that it is handled properly by the parent methods
# We also try to catch ConnectionRefusedError after it
# this is caused when a wrong port number is used
try:
client = socket.create_connection((host, port))
except socket.gaierror:
raise ChannelError(self, "invalid host provided")
except ConnectionRefusedError:
raise ChannelError(self, "connection refused, check your port")
progress.log(
f"connection to "

View File

@ -22,7 +22,7 @@ import socket
import functools
from typing import Optional
from pwncat.channel import Channel, ChannelError, ChannelClosed
from pwncat.channel import Channel, ChannelClosed
def connect_required(method):
@ -33,7 +33,7 @@ def connect_required(method):
@functools.wraps(method)
def _wrapper(self, *args, **kwargs):
if not self.connected:
raise ChannelError(self, "channel not connected")
raise ChannelClosed(self)
return method(self, *args, **kwargs)
return _wrapper
@ -129,7 +129,12 @@ class Socket(Channel):
return data
try:
data = data + self.client.recv(count)
new_data = self.client.recv(count)
if new_data == b"":
self._connected = False
raise ChannelClosed(self)
return data + new_data
except BlockingIOError:
return data
except ssl.SSLWantReadError:
return data
@ -143,8 +148,10 @@ class Socket(Channel):
self._connected = False
raise ChannelClosed(self) from exc
@connect_required
def close(self):
if not self._connected:
return
self._connected = False
self.client.close()

View File

@ -49,7 +49,7 @@ class Ssh(Channel):
# Connect to the remote host's ssh server
sock = socket.create_connection((host, port))
except Exception as exc:
raise ChannelError(str(exc))
raise ChannelError(self, str(exc))
# Create a paramiko SSH transport layer around the socket
t = paramiko.Transport(sock)

View File

@ -1,6 +1,5 @@
#!/usr/bin/env python3
import pwncat
from pwncat.manager import RawModeExit
from pwncat.commands import CommandDefinition
@ -11,4 +10,6 @@ class Command(CommandDefinition):
ARGS = {}
def run(self, manager: "pwncat.manager.Manager", args):
raise RawModeExit
# This is caught by ``CommandParser.run`` which interprets
# it as a `C-d` sequence, and returns to the remote prompt.
raise EOFError

View File

@ -7,8 +7,10 @@ from rich.progress import Progress
import pwncat
from pwncat.util import console
from pwncat.channel import ChannelError
from pwncat.modules import ModuleFailed
from pwncat.commands import Complete, Parameter, CommandDefinition
from pwncat.platform import PlatformError
class Command(CommandDefinition):
@ -279,11 +281,14 @@ class Command(CommandDefinition):
manager.target = session
used_implant = implant
break
except ModuleFailed:
except (ChannelError, PlatformError, ModuleFailed):
db.transaction_manager.commit()
continue
if used_implant is not None:
manager.target.log(f"connected via {used_implant.title(manager.target)}")
else:
try:
manager.create_session(**query_args)
except (ChannelError, PlatformError) as exc:
manager.log(f"connection failed: {exc}")

View File

@ -45,17 +45,6 @@ class Command(CommandDefinition):
if not args.destination:
args.destination = f"./{os.path.basename(args.source)}"
# else:
# access = pwncat.victim.access(args.destination)
# if Access.DIRECTORY in access:
# args.destination = os.path.join(
# args.destination, os.path.basename(args.source)
# )
# elif Access.PARENT_EXIST not in access:
# console.log(
# f"[cyan]{args.destination}[/cyan]: no such file or directory"
# )
# return
try:
length = os.path.getsize(args.source)

View File

@ -16,6 +16,8 @@ even if there was an uncaught exception. The normal method of creating a manager
"""
import os
import sys
import queue
import signal
import fnmatch
import pkgutil
import threading
@ -36,9 +38,9 @@ import pwncat.modules.enumerate
from pwncat.util import RawModeExit, console
from pwncat.config import Config
from pwncat.target import Target
from pwncat.channel import Channel, ChannelClosed
from pwncat.channel import Channel, ChannelError, ChannelClosed
from pwncat.commands import CommandParser
from pwncat.platform import Platform
from pwncat.platform import Platform, PlatformError
class InteractiveExit(Exception):
@ -319,9 +321,13 @@ class Session:
while self.layers:
self.layers.pop()(self)
try:
self.platform.exit()
self.platform.channel.close()
except (PlatformError, ChannelError) as exc:
self.log(
f"[yellow]warning[/yellow]: unexpected exception while closing: {exc}"
)
self.died()
@ -551,6 +557,8 @@ class Manager:
while self.interactive_running:
try:
# This is it's own main loop that will continue until
# it catches a C-d sequence.
try:
@ -570,62 +578,86 @@ class Manager:
self.log("no active session, returning to local prompt")
continue
self.target.platform.interactive = True
interactive_complete = threading.Event()
output_thread = None
def output_thread_main():
def output_thread_main(
target: Session, exception_queue: queue.SimpleQueue
):
while not interactive_complete.is_set():
data = self.target.platform.channel.recv(4096)
try:
data = target.platform.channel.recv(4096)
if data != b"" and data is not None:
try:
data = self.target.platform.process_output(data)
data = target.platform.process_output(data)
sys.stdout.buffer.write(data)
sys.stdout.buffer.flush()
except RawModeExit:
interactive_complete.set()
else:
interactive_complete.wait(timeout=0.1)
output_thread = threading.Thread(target=output_thread_main)
output_thread.start()
except ChannelError as exc:
exception_queue.put(exc)
interactive_complete.set()
# This is a hack to get the interactive loop out of a blocking
# read call. The interactive loop will receive a KeyboardInterrupt
os.kill(os.getpid(), signal.SIGINT)
except RawModeExit:
interactive_complete.set()
os.kill(os.getpid(), signal.SIGINT)
channel_closed = False
try:
self.target.platform.interactive = True
exception_queue = queue.Queue(maxsize=1)
output_thread = threading.Thread(
target=output_thread_main, args=[self.target, exception_queue]
)
output_thread.start()
try:
self.target.platform.interactive_loop(interactive_complete)
except RawModeExit:
pass
try:
raise exception_queue.get(block=False)
except queue.Empty:
pass
self.target.platform.interactive = False
except ChannelClosed:
channel_closed = True
self.log(
f"[yellow]warning[/yellow]: {self.target.platform}: connection reset"
)
except Exception:
self.target.died()
finally:
interactive_complete.set()
if output_thread is not None:
output_thread.join()
output_thread.join()
except: # noqa: E722
# We don't want to die because of an uncaught exception, but
# at least let the user know something happened. This should
# probably be configurable somewhere.
pwncat.util.console.print_exception()
# Trigger thread to exit
interactive_complete.set()
output_thread.join()
# Exit interactive mode
if channel_closed:
self.target.died()
else:
self.target.platform.interactive = False
def create_session(self, platform: str, channel: Channel = None, **kwargs):
"""
Open a new session from a new or existing platform. If the platform
is a string, a new platform is created using ``create_platform`` and
a session is built around the platform. In that case, the arguments
are the same as for ``create_platform``.
r"""
Create a new session from a new or existing channel. The platform specified
should be the name registered name (e.g. ``linux``) of a platform class. If
no existing channel is provided, the keyword arguments are used to construct
a new channel.
A new Session object is returned which contains the created or
specified platform.
:param platform: name of the platform to use
:type platform: str
:param channel: A pre-constructed channel (default: None)
:type channel: Optional[Channel]
:param \*\*kwargs: keyword arguments for constructing a new channel
:rtype: Session
:raises:
ChannelError: there was an error while constructing the new channel
PlatformError: construction of a platform around the channel failed
"""
session = Session(self, platform, channel, **kwargs)
@ -638,6 +670,21 @@ class Manager:
return session
def find_session_by_channel(self, channel: Channel):
"""
Locate a session by it's channel object. This is mainly used when a ChannelError
is raised in order to locate the misbehaving session object from the exception
data.
:param channel: the channel you are looking for
:type channel: Channel
:rtype: Session
"""
for session in self.sessions.values():
if session.platform.channel is channel:
return session
def _process_input(self, data: bytes, has_prefix: bool):
"""Process stdin data from the user in raw mode"""

View File

@ -1,7 +1,7 @@
#!/usr/bin/env python3
import pwncat
from pwncat.modules import ModuleFailed
from pwncat.modules import Status, ModuleFailed
from pwncat.facts.linux import LinuxGroup
from pwncat.platform.linux import Linux
from pwncat.modules.enumerate import Schedule, EnumerateModule
@ -20,6 +20,7 @@ class Module(EnumerateModule):
users = {user.gid: user for user in session.run("enumerate", types=["user"])}
group_file = session.platform.Path("/etc/group")
groups = []
try:
with group_file.open("r") as filp:
@ -34,13 +35,17 @@ class Module(EnumerateModule):
members.append(users[gid].name)
# Build a group object
group = LinuxGroup(self.name, group_name, hash, gid, members)
groups.append(
LinuxGroup(self.name, group_name, hash, gid, members)
)
yield group
yield Status(group_name)
except (KeyError, ValueError, IndexError):
# Bad group line
continue
yield from groups
except (FileNotFoundError, PermissionError) as exc:
raise ModuleFailed(str(exc)) from exc

View File

@ -549,6 +549,11 @@ class Platform(ABC):
data = sys.stdin.buffer.read(64)
has_prefix = self.session.manager._process_input(data, has_prefix)
except KeyboardInterrupt:
# This is a hack to allow the output thread to signal completion
# We are in raw mode so this couldn't have come from a user pressing
# C-d.
pass
finally:
pwncat.util.pop_term_state()
sys.stdin.reconfigure(line_buffering=False)

View File

@ -11,6 +11,7 @@ Popen can be running at a time. It is imperative that you call
to calling any other pwncat methods.
"""
import os
import stat
import time
import shlex
import shutil
@ -414,13 +415,16 @@ class LinuxWriter(BufferedIOBase):
if self.popen is None:
raise UnsupportedOperation("writer is detached")
if self.popen.poll() is not None:
raise PermissionError("file write failed")
if self.popen.platform.has_pty:
# Control sequences need escaping
translated = []
for idx, c in enumerate(b):
# Track when the last new line was
if c == 0x0D:
if c == 0x0A:
self.since_newline = 0
else:
self.since_newline += 1
@ -432,7 +436,7 @@ class LinuxWriter(BufferedIOBase):
# Track all characters in translated buffer
translated.append(c)
if self.since_newline >= 4095:
if self.since_newline >= 2048:
# Flush read immediately to prevent truncation of line
translated.append(0x04)
self.since_newline = 0
@ -463,26 +467,70 @@ class LinuxWriter(BufferedIOBase):
self.detach()
return
# The number of C-d's needed to trigger an EOF in
# the process and exit is inconsistent based on the
# previous input. So, instead of trying to be deterministic,
# we simply send one and check. We do this until we find
# the ending delimeter and then exit. If the `on_close`
# hook was setup properly, this should be fine.
while True:
# Trigger EOF in remote process
self.popen.platform.channel.send(b"\x04")
self.popen.platform.channel.send(b"\x04")
if self.since_newline != 0:
self.popen.platform.channel.send(b"\x04")
self.popen.platform.channel.send(b"\x04")
# Wait for the process to exit
try:
self.popen.stdin.write(b"\x04")
self.popen.stdin.flush()
# Check for completion
self.popen.wait(timeout=0.1)
break
except pwncat.subprocess.TimeoutExpired:
continue
self.popen.wait()
except KeyboardInterrupt:
# We *should* wait for the process to send the return codes.
# however, if the user wants to stop this process, we should
# at least attempt to do whatever cleanup we can.
self.popen.kill()
self.popen.wait()
# Ensure we don't touch stdio again
self.detach()
class LinuxPath(pathlib.PurePosixPath):
"""Special cases for Linux remote paths"""
def readable(self):
"""Test if a file is readable"""
uid = self._target._id["euid"]
gid = self._target._id["egid"]
groups = self._target._id["groups"]
file_uid = self.stat().st_uid
file_gid = self.stat().st_gid
file_mode = self.stat().st_mode
if uid == file_uid and (file_mode & stat.S_IRUSR):
return True
elif (gid == file_gid or file_gid in groups) and (file_mode & stat.S_IRGRP):
return True
elif file_mode & stat.S_IROTH:
return True
return False
def writable(self):
uid = self._target._id["euid"]
gid = self._target._id["egid"]
groups = self._target._id["groups"]
file_uid = self.stat().st_uid
file_gid = self.stat().st_gid
file_mode = self.stat().st_mode
if uid == file_uid and (file_mode & stat.S_IWUSR):
return True
elif (gid == file_gid or file_gid in groups) and (file_mode & stat.S_IWGRP):
return True
elif file_mode & stat.S_IWOTH:
return True
return False
class Linux(Platform):
"""
Concrete platform class abstracting interaction with a GNU/Linux remote
@ -491,7 +539,7 @@ class Linux(Platform):
"""
name = "linux"
PATH_TYPE = pathlib.PurePosixPath
PATH_TYPE = LinuxPath
PROMPTS = {
"sh": """'$(command printf "(remote) $(whoami)@$(hostname):$PWD\\$ ")'""",
"dash": """'$(command printf "(remote) $(whoami)@$(hostname):$PWD\\$ ")'""",
@ -507,7 +555,7 @@ class Linux(Platform):
self.name = "linux"
self.command_running = None
self._uid = None
self._id = None
# This causes an stty to be sent.
# If we aren't in a pty, it doesn't matter.
@ -663,7 +711,7 @@ class Linux(Platform):
)
hostname = result.stdout.strip()
except CalledProcessError:
hostname = self.channel.getpeername()[0]
hostname = self.channel.host
try:
self.session.update_task(
@ -766,10 +814,20 @@ class Linux(Platform):
while True:
try:
proc = self.run(
["id", "-ru"], capture_output=True, text=True, check=True
"(id -ru;id -u;id -g;id -rg;id -G;)",
capture_output=True,
text=True,
check=True,
)
self._uid = int(proc.stdout.rstrip("\n"))
return self._uid
idents = proc.stdout.split("\n")
self._id = {
"ruid": int(idents[0].strip()),
"euid": int(idents[1].strip()),
"rgid": int(idents[2].strip()),
"egid": int(idents[3].strip()),
"groups": [int(g.strip()) for g in idents[4].split(" ")],
}
return self._id["ruid"]
except ValueError:
continue
except CalledProcessError as exc:
@ -777,7 +835,7 @@ class Linux(Platform):
def getuid(self):
"""Retrieve the current cached uid"""
return self._uid
return self._id["ruid"]
def getenv(self, name: str):
@ -1153,6 +1211,24 @@ class Linux(Platform):
if any(c not in "rwb" for c in mode):
raise PlatformError(f"{mode}: unknown file mode")
if isinstance(path, str):
path = self.Path(path)
if "r" in mode and not path.exists():
raise FileNotFoundError(f"No such file or directory: {str(path)}")
if "r" in mode and not path.readable():
raise PermissionError(f"Permission Denied: {str(path)}")
if "w" in mode:
parent = path.parent
if "w" in mode and path.exists() and not path.writable():
raise PermissionError(f"Permission Denied: {str(path)}")
if "w" in mode and not path.exists() and not parent.writable():
raise PermissionError(f"Permission Denied: {str(path)}")
if "w" in mode and not path.exists() and not parent.exists():
raise FileNotFoundError(f"No such file or directory: {str(path)}")
# Save this just in case we are opening a text-mode stream
line_buffering = buffering == -1 or buffering == 1
@ -1174,7 +1250,7 @@ class Linux(Platform):
except MissingBinary:
pass
else:
raise PlatformError("no available gtfobins writiers")
raise PlatformError("no available gtfobins writers")
popen = self.Popen(
payload,
@ -1203,7 +1279,7 @@ class Linux(Platform):
except MissingBinary:
pass
else:
raise PlatformError("no available gtfobins writiers")
raise PlatformError("no available gtfobins writers")
popen = self.Popen(
payload,

View File

@ -13,7 +13,6 @@ processes and open multiple files with this platform. However, you should be
careful to cleanup all processes and files prior to return from your method
or code as the C2 will not attempt to garbage collect file or proces handles.
"""
import os
import sys
import gzip
import json
@ -26,6 +25,7 @@ import hashlib
import pathlib
import tarfile
import binascii
import readline # noqa: F401
import functools
import threading
import subprocess
@ -46,7 +46,7 @@ PWNCAT_WINDOWS_C2_VERSION = "v0.2.1"
PWNCAT_WINDOWS_C2_RELEASE_URL = "https://github.com/calebstewart/pwncat-windows-c2/releases/download/{version}/pwncat-windows-{version}.tar.gz"
class PowershellError(Exception):
class PowershellError(PlatformError):
"""Executing a powershell script caused an error"""
def __init__(self, msg):
@ -55,7 +55,7 @@ class PowershellError(Exception):
self.message = msg
class ProtocolError(Exception):
class ProtocolError(PlatformError):
def __init__(self, code: int, message: str):
self.code = code
self.message = message
@ -908,7 +908,7 @@ function prompt {
transformed = bytearray(b"")
has_cr = False
for b in data:
for idx, b in enumerate(data):
# Basically, we just transform bare \r to \r\n
if has_cr and b != ord("\n"):
@ -924,10 +924,9 @@ function prompt {
if INTERACTIVE_END_MARKER[self.interactive_tracker] == b:
self.interactive_tracker += 1
if self.interactive_tracker == len(INTERACTIVE_END_MARKER):
# NOTE: this is a dirty hack to trigger the main input thread
# to leave interactive mode, because it's bound in an input call
os.kill(os.getpid(), signal.SIGINT)
raise pwncat.manager.RawModeExit
self.interactive_tracker = 0
self.channel.unrecv(data[idx + 1 :])
raise pwncat.util.RawModeExit
else:
self.interactive_tracker = 0

View File

@ -118,9 +118,9 @@ def isprintable(data) -> bool:
def human_readable_size(size, decimal_places=2):
for unit in ["B", "KiB", "MiB", "GiB", "TiB"]:
if size < 1024.0:
if size < 1000.0:
return f"{size:.{decimal_places}f}{unit}"
size /= 1024.0
size /= 1000.0
return f"{size:.{decimal_places}f}{unit}"

49
tests/test_fileio.py Normal file
View File

@ -0,0 +1,49 @@
#!/usr/bin/env python3
from pwncat.util import random_string
def do_file_test(session, content):
"""Do a generic file test"""
name = random_string() + ".txt"
mode = "b" if isinstance(content, bytes) else ""
with session.platform.open(name, mode + "w") as filp:
assert filp.write(content) == len(content)
with session.platform.open(name, mode + "r") as filp:
assert filp.read() == content
# In some cases, the act of reading/writing causes a shell to hang
# so double check that.
result = session.platform.run(
["echo", "hello world"], capture_output=True, text=True
)
assert result.stdout == "hello world\n"
def test_small_text(session):
"""Test writing a small text-only file"""
do_file_test(session, "hello world")
def test_large_text(session):
"""Test writing and reading a large text file"""
contents = ("A" * 1000 + "\n") * 10
do_file_test(session, contents)
def test_small_binary(session):
"""Test writing a small amount of binary data"""
contents = bytes(list(range(32)))
do_file_test(session, contents)
def test_large_binary(session):
contents = bytes(list(range(32))) * 400
do_file_test(session, contents)

View File

@ -10,25 +10,6 @@ from pwncat.util import random_string
from pwncat.platform.windows import PowershellError
def test_platform_file_io(session):
""" Test file read/write of printable data """
# Generate random binary data
contents = os.urandom(1024)
# Create a new temporary file
with session.platform.tempfile(mode="wb") as filp:
filp.write(contents)
path = filp.name
# Ensure it exists
assert session.platform.Path(path).exists()
# Read the data back and ensure it matches
with session.platform.open(path, "rb") as filp:
assert contents == filp.read()
def test_platform_dir_io(session):
"""Test creating a directory and interacting with the contents"""