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

Most of enumerate modules are working with platforms/sessions/managers

This commit is contained in:
Caleb Stewart 2020-11-13 12:05:08 -05:00
parent f80d6b65ee
commit c1068ad567
12 changed files with 227 additions and 334 deletions

View File

@ -118,14 +118,16 @@ class ChannelFile(RawIOBase):
# Check the type of the argument, and grab the relevant part
obj = b.obj if isinstance(b, memoryview) else b
n = 0
try:
n = self.channel.recvinto(b)
except NotImplementedError:
# recvinto was not implemented, fallback recv
data = self.channel.recv(len(b))
b[: len(data)] = data
n = len(data)
while n == 0:
try:
n = self.channel.recvinto(b)
except NotImplementedError:
# recvinto was not implemented, fallback recv
data = self.channel.recv(len(b))
b[: len(data)] = data
n = len(data)
obj = bytes(b[:n])

View File

@ -120,7 +120,7 @@ class Session:
return self.manager.modules[module].run(self, **kwargs)
def find_module(self, pattern: str, base=None):
def find_module(self, pattern: str, base=None, exact: bool = False):
""" Locate a module by a glob pattern. This is an generator
which may yield multiple modules that match the pattern and
base class. """
@ -134,7 +134,13 @@ class Session:
and type(self.platform) not in module.PLATFORM
):
continue
if fnmatch.fnmatch(name, pattern) and isinstance(module, base):
if (
not exact
and fnmatch.fnmatch(name, pattern)
and isinstance(module, base)
):
yield module
elif exact and name == pattern and isinstance(module, base):
yield module
def log(self, *args, **kwargs):
@ -158,7 +164,10 @@ class Session:
self._db_session = self.manager.create_db_session()
yield self._db_session
finally:
self._db_session.commit()
try:
self._db_session.commit()
except:
pass
@contextlib.contextmanager
def task(self, *args, **kwargs):
@ -273,7 +282,7 @@ class Manager:
self.engine = create_engine(self.config["db"])
pwncat.db.Base.metadata.create_all(self.engine)
self.SessionBuilder = sessionmaker(bind=self.engine)
self.SessionBuilder = sessionmaker(bind=self.engine, expire_on_commit=False)
self.parser = CommandParser(self)
def create_db_session(self):

View File

@ -163,23 +163,18 @@ def run_decorator(real_run):
@functools.wraps(real_run)
def decorator(self, session, progress=None, **kwargs):
if "exec" in kwargs:
has_exec = True
else:
has_exec = False
# Validate arguments
for key in kwargs:
if key in self.ARGUMENTS:
try:
kwargs[key] = self.ARGUMENTS[key].type(kwargs[key])
except ValueError:
raise ArgumentFormatError(key)
except ValueError as exc:
raise ArgumentFormatError(key) from exc
elif not self.ALLOW_KWARGS:
raise InvalidArgument(key)
for key in self.ARGUMENTS:
if key not in kwargs and key in pwncat.config:
kwargs[key] = pwncat.config[key]
if key not in kwargs and key in session.config:
kwargs[key] = session.config[key]
elif key not in kwargs and self.ARGUMENTS[key].default is not NoValue:
kwargs[key] = self.ARGUMENTS[key].default
elif key not in kwargs and self.ARGUMENTS[key].default is NoValue:
@ -192,28 +187,11 @@ def run_decorator(real_run):
result_object = real_run(self, session, **kwargs)
if inspect.isgenerator(result_object):
try:
if progress is None:
# We weren't given a progress instance, so start one ourselves
self.progress = Progress(
"collecting results",
"",
"[yellow]{task.fields[module]}",
"",
"[cyan]{task.fields[status]}",
transient=True,
console=console,
)
self.progress.start()
# Added a task to this progress bar
task = self.progress.add_task("", module=self.name, status="...")
with session.task(description=self.name, status="...") as task:
# Collect results
results = []
for item in result_object:
self.progress.update(task, status=str(item))
session.update_task(task, status=str(item))
if not isinstance(item, Status):
results.append(item)
@ -221,18 +199,6 @@ def run_decorator(real_run):
return results[0]
return results
finally:
if progress is None:
# If we are the last task/this is our progress bar,
# we don't hide ourselves. This makes the progress bar
# empty, and "transient" ends up remove an extra line in
# the terminal.
self.progress.stop()
else:
# This task is done, hide it.
self.progress.update(
task, completed=True, visible=False, status="complete"
)
else:
return result_object
@ -296,141 +262,3 @@ class BaseModule(metaclass=BaseModuleMeta):
"""
raise NotImplementedError
def reload(where: typing.Optional[typing.List[str]] = None):
""" Reload modules from the given directory. If no directory
is specified, then the default modules are reloaded. This
function will not remove or un-load any existing modules, but
may overwrite existing modules with conflicting names.
:param where: Directories which contain pwncat modules
:type where: List[str]
"""
# We need to load built-in modules first
if not LOADED_MODULES and where is not None:
reload()
# If no paths were specified, load built-ins
if where is None:
where = __path__
for loader, module_name, _ in pkgutil.walk_packages(where, prefix=__name__ + "."):
module = loader.find_module(module_name).load_module(module_name)
if getattr(module, "Module", None) is None:
continue
module_name = module_name.split(__name__ + ".")[1]
LOADED_MODULES[module_name] = module.Module()
setattr(LOADED_MODULES[module_name], "name", module_name)
def find(name: str, base=BaseModule, ignore_platform: bool = False):
""" Locate a module with this exact name. Optionally filter
modules based on their class type. By default, this will search
for any module implementing BaseModule which is applicable to
the current platform.
:param name: Name of the module to locate
:type name: str
:param base: Base class which the module must implement
:type base: type
:param ignore_platform: Whether to ignore the victim's platform in the search
:type ignore_platform: bool
:raises ModuleNotFoundError: Raised if the module does not exist or the platform/base class do not match.
"""
if not LOADED_MODULES:
reload()
if name not in LOADED_MODULES:
raise ModuleNotFoundError(f"{name}: module not found")
if not isinstance(LOADED_MODULES[name], base):
raise ModuleNotFoundError(f"{name}: incorrect base class")
# Grab the module
module = LOADED_MODULES[name]
if not ignore_platform:
if module.PLATFORM != Platform.NO_HOST and pwncat.victim.host is None:
raise ModuleNotFoundError(f"{module.name}: no connected victim")
elif (
module.PLATFORM != Platform.NO_HOST
and pwncat.victim.host.platform not in module.PLATFORM
):
raise ModuleNotFoundError(f"{module.name}: incorrect platform")
return module
def match(pattern: str, base=BaseModule):
""" Locate modules who's name matches the given glob pattern.
This function will only return modules which implement a subclass
of the given base class and which are applicable to the current
target's platform.
:param pattern: A Unix glob-like pattern for the module name
:type pattern: str
:param base: The base class for modules you are looking for (defaults to BaseModule)
:type base: type
:return: A generator yielding module objects which at least implement ``base``
:rtype: Generator[base, None, None]
"""
if not LOADED_MODULES:
reload()
for module_name, module in LOADED_MODULES.items():
# NOTE - this should be cleaned up. It's gross.
if not isinstance(module, base):
continue
if module.PLATFORM != Platform.NO_HOST and pwncat.victim.host is None:
continue
elif (
module.PLATFORM != Platform.NO_HOST
and pwncat.victim.host.platform not in module.PLATFORM
):
continue
if not fnmatch.fnmatch(module_name, pattern):
continue
yield module
def run(name: str, **kwargs):
""" Locate a module by name and execute it. The module can be of any
type and is guaranteed to match the current platform. If no module can
be found which matches those criteria, an exception is thrown.
:param name: The name of the module to run
:type name: str
:param kwargs: Keyword arguments for the module
:type kwargs: Dict[str, Any]
:returns: The result from the module's ``run`` method.
:raises ModuleNotFoundError: If no module with that name matches the required criteria
"""
if not LOADED_MODULES:
reload()
if name not in LOADED_MODULES:
raise ModuleNotFoundError(f"{name}: module not found")
# Grab the module
module = LOADED_MODULES[name]
if module.PLATFORM != Platform.NO_HOST and pwncat.victim.host is None:
raise ModuleNotFoundError(f"{module.name}: no connected victim")
elif (
module.PLATFORM != Platform.NO_HOST
and pwncat.victim.host.platform not in module.PLATFORM
):
raise ModuleNotFoundError(f"{module.name}: incorrect platform")
return module.run(**kwargs)

View File

@ -26,7 +26,7 @@ class EnumerateModule(BaseModule):
# This should be set by the sub-classes to know where to find
# different types of enumeration data
PROVIDES = []
PLATFORM = [Linux]
PLATFORM = []
# Defines how often to run this enumeration. The default is to
# only run once per system/target.
@ -48,7 +48,7 @@ class EnumerateModule(BaseModule):
),
}
def run(self, types, clear):
def run(self, session, types, clear):
""" Locate all facts this module provides.
Sub-classes should not override this method. Instead, use the
@ -58,98 +58,88 @@ class EnumerateModule(BaseModule):
marker_name = self.name
if self.SCHEDULE == Schedule.PER_USER:
marker_name += f".{pwncat.victim.current_user.id}"
marker_name += f".{session.platform.current_user().id}"
if clear:
# Delete enumerated facts
query = (
get_session()
.query(pwncat.db.Fact)
.filter_by(source=self.name, host_id=pwncat.victim.host.id)
)
query.delete(synchronize_session=False)
# Delete our marker
if self.SCHEDULE != Schedule.ALWAYS:
query = (
get_session()
.query(pwncat.db.Fact)
.filter_by(host_id=pwncat.victim.host.id, type="marker")
.filter(pwncat.db.Fact.source.startswith(self.name))
with session.db as db:
if clear:
# Delete enumerated facts
query = db.query(pwncat.db.Fact).filter_by(
source=self.name, host_id=session.host
)
query.delete(synchronize_session=False)
return
# Yield all the know facts which have already been enumerated
existing_facts = (
get_session()
.query(pwncat.db.Fact)
.filter_by(source=self.name, host_id=pwncat.victim.host.id)
.filter(pwncat.db.Fact.type != "marker")
)
if types:
for fact in existing_facts.all():
for typ in types:
if fnmatch.fnmatch(fact.type, typ):
yield fact
else:
yield from existing_facts.all()
if self.SCHEDULE != Schedule.ALWAYS:
exists = (
get_session()
.query(pwncat.db.Fact.id)
.filter_by(
host_id=pwncat.victim.host.id, type="marker", source=marker_name
)
.scalar()
is not None
)
if exists:
# Delete our marker
if self.SCHEDULE != Schedule.ALWAYS:
query = (
db.query(pwncat.db.Fact)
.filter_by(host_id=session.host, type="marker")
.filter(pwncat.db.Fact.source.startswith(self.name))
)
query.delete(synchronize_session=False)
return
# Get any new facts
for item in self.enumerate():
if isinstance(item, Status):
yield item
continue
typ, data = item
row = pwncat.db.Fact(
host_id=pwncat.victim.host.id, type=typ, data=data, source=self.name
# Yield all the know facts which have already been enumerated
existing_facts = (
db.query(pwncat.db.Fact)
.filter_by(source=self.name, host_id=session.host)
.filter(pwncat.db.Fact.type != "marker")
)
try:
get_session().add(row)
pwncat.victim.host.facts.append(row)
get_session().commit()
except sqlalchemy.exc.IntegrityError:
get_session().rollback()
yield Status(data)
continue
# Don't yield the actual fact if we didn't ask for this type
if types:
for typ in types:
if fnmatch.fnmatch(row.type, typ):
yield row
else:
yield Status(data)
for fact in existing_facts.all():
for typ in types:
if fnmatch.fnmatch(fact.type, typ):
yield fact
else:
yield row
yield from existing_facts.all()
# Add the marker if needed
if self.SCHEDULE != Schedule.ALWAYS:
row = pwncat.db.Fact(
host_id=pwncat.victim.host.id,
type="marker",
source=marker_name,
data=None,
)
get_session().add(row)
pwncat.victim.host.facts.append(row)
if self.SCHEDULE != Schedule.ALWAYS:
exists = (
db.query(pwncat.db.Fact.id)
.filter_by(host_id=session.host, type="marker", source=marker_name)
.scalar()
is not None
)
if exists:
return
def enumerate(self):
# Get any new facts
for item in self.enumerate(session):
if isinstance(item, Status):
yield item
continue
typ, data = item
row = pwncat.db.Fact(
host_id=session.host, type=typ, data=data, source=self.name
)
try:
db.add(row)
db.commit()
except sqlalchemy.exc.IntegrityError:
db.rollback()
yield Status(data)
continue
# Don't yield the actual fact if we didn't ask for this type
if types:
for typ in types:
if fnmatch.fnmatch(row.type, typ):
yield row
else:
yield Status(data)
else:
yield row
# Add the marker if needed
if self.SCHEDULE != Schedule.ALWAYS:
row = pwncat.db.Fact(
host_id=session.host, type="marker", source=marker_name, data=None,
)
db.add(row)
def enumerate(self, session):
""" Defined by sub-classes to do the actual enumeration of
facts. """

View File

@ -14,7 +14,7 @@ class PasswordData:
password: str
filepath: str
lineno: int
uid: int = None
user: "pwncat.db.User"
def __str__(self):
if self.password is not None:
@ -28,8 +28,8 @@ class PasswordData:
return result
@property
def user(self):
return pwncat.victim.find_user_by_id(self.uid) if self.uid is not None else None
def uid(self):
return self.user.id
@dataclasses.dataclass
@ -37,7 +37,7 @@ class PrivateKeyData:
""" A private key found on the remote file system or known
to be applicable to this system in some way. """
uid: int
user: "pwncat.db.User"
""" The user we believe the private key belongs to """
path: str
""" The path to the private key on the remote host """
@ -58,5 +58,5 @@ class PrivateKeyData:
return self.content
@property
def user(self):
return pwncat.victim.find_user_by_id(self.uid)
def uid(self) -> int:
return self.user.id

View File

@ -19,14 +19,14 @@ class Module(EnumerateModule):
SCHEDULE = Schedule.ALWAYS
PROVIDES = ["creds.password"]
def enumerate(self):
def enumerate(self, session):
pam: InstalledModule = None
for module in pwncat.modules.run(
"persist.gather", progress=self.progress, module="persist.pam_backdoor"
):
pam = module
break
# for module in session.run(
# "persist.gather", progress=self.progress, module="persist.pam_backdoor"
# ):
# pam = module
# break
if pam is None:
# The pam persistence module isn't installed.
@ -37,8 +37,13 @@ class Module(EnumerateModule):
# Just in case we have multiple of the same password logged
observed = []
# This ensures our user database is fetched prior to opening the file.
# otherwise, we may attempt to read the user database while the file is
# open
session.platform.current_user()
try:
with pwncat.victim.open(log_path, "r") as filp:
with session.platform.open(log_path, "r") as filp:
for line in filp:
line = line.rstrip("\n")
if line in observed:
@ -47,8 +52,10 @@ class Module(EnumerateModule):
user, *password = line.split(":")
password = ":".join(password)
# Invalid user name
if user not in pwncat.victim.users:
try:
# Check for valid user name
session.platform.find_user(name=user)
except KeyError:
continue
observed.append(line)

View File

@ -20,7 +20,7 @@ class Module(EnumerateModule):
PLATFORM = [Linux]
SCHEDULE = Schedule.PER_USER
def enumerate(self):
def enumerate(self, session):
# The locations we will search in for passwords
locations = ["/var/www", "$HOME", "/opt", "/etc"]
@ -29,17 +29,25 @@ class Module(EnumerateModule):
# The types of files which are "code". This means that we only recognize the
# actual password if it is a literal value (enclosed in single or double quotes)
code_types = [".c", ".php", ".py", ".sh", ".pl", ".js", ".ini", ".json"]
grep = pwncat.victim.which("grep")
# grep = pwncat.victim.which("grep")
grep = "grep"
if grep is None:
return
command = f"{grep} -InriE 'password[\"'\"'\"']?\\s*(=>|=|:)' {' '.join(locations)} 2>/dev/null"
with pwncat.victim.subprocess(command, "r") as filp:
# Run the command on the remote host
proc = session.platform.Popen(
command, shell=True, text=True, stdout=pwncat.subprocess.PIPE
)
# Iterate through the output
with proc.stdout as filp:
for line in filp:
try:
# Decode the line and separate the filename, line number, and content
line = line.decode("utf-8").strip().split(":")
line = line.strip().split(":")
except UnicodeDecodeError:
continue
@ -108,3 +116,5 @@ class Module(EnumerateModule):
# This was a match for the search. We may have extracted a
# password. Either way, log it.
yield "creds.password", PasswordData(password, path, lineno)
proc.wait()

View File

@ -1,5 +1,6 @@
#!/usr/bin/env python3
from Crypto.PublicKey import RSA
import time
import pwncat
from pwncat.platform.linux import Linux
@ -19,17 +20,22 @@ class Module(EnumerateModule):
PLATFORM = [Linux]
SCHEDULE = Schedule.PER_USER
def enumerate(self):
def enumerate(self, session: "pwncat.manager.Session"):
facts = []
# Search for private keys in common locations
with pwncat.victim.subprocess(
"grep -l -I -D skip -rE '^-+BEGIN .* PRIVATE KEY-+$' /home /etc /opt 2>/dev/null | xargs stat -c '%u %n' 2>/dev/null"
) as pipe:
proc = session.platform.Popen(
"grep -l -I -D skip -rE '^-+BEGIN .* PRIVATE KEY-+$' /home /etc /opt 2>/dev/null | xargs stat -c '%u %n' 2>/dev/null",
shell=True,
text=True,
stdout=pwncat.subprocess.PIPE,
)
with proc.stdout as pipe:
yield Status("searching for private keys")
for line in pipe:
line = line.strip().decode("utf-8").split(" ")
line = line.strip().split(" ")
uid, path = int(line[0]), " ".join(line[1:])
yield Status(f"found [cyan]{path}[/cyan]")
facts.append(PrivateKeyData(uid, path, None, False))
@ -37,7 +43,7 @@ class Module(EnumerateModule):
for fact in facts:
try:
yield Status(f"reading [cyan]{fact.path}[/cyan]")
with pwncat.victim.open(fact.path, "r") as filp:
with session.platform.open(fact.path, "r") as filp:
fact.content = filp.read().strip().replace("\r\n", "\n")
try:
@ -53,6 +59,10 @@ class Module(EnumerateModule):
else:
# Some other error happened, probably not a key
continue
# we set the user field to the id temporarily
fact.user = session.platform.find_user(id=fact.user)
yield "creds.private_key", fact
except (PermissionError, FileNotFoundError):
continue

View File

@ -71,7 +71,7 @@ class Module(pwncat.modules.BaseModule):
}
PLATFORM = None
def run(self, output, modules, types, clear):
def run(self, session, output, modules, types, clear):
""" Perform a enumeration of the given moduels and save the output """
module_names = modules
@ -80,15 +80,13 @@ class Module(pwncat.modules.BaseModule):
modules = set()
for name in module_names:
modules = modules | set(
pwncat.modules.match(f"enumerate.{name}", base=EnumerateModule)
list(session.find_module(f"enumerate.{name}", base=EnumerateModule))
)
if clear:
for module in modules:
yield pwncat.modules.Status(module.name)
module.run(progress=self.progress, clear=True)
get_session().commit()
pwncat.victim.reload_host()
module.run(clear=True)
return
# Enumerate all facts
@ -115,7 +113,7 @@ class Module(pwncat.modules.BaseModule):
yield pwncat.modules.Status(module.name)
# Iterate over facts from the sub-module with our progress manager
for item in module.run(progress=self.progress, types=types):
for item in module.run(session, types=types):
if output is None:
yield item
elif item.type not in facts:
@ -133,7 +131,10 @@ class Module(pwncat.modules.BaseModule):
with output as filp:
filp.write(f"# {pwncat.victim.host.ip} - Enumeration Report\n\n")
with session.db as db:
host = db.query(pwncat.db.Host).filter_by(id=session.host).first()
filp.write(f"# {host.ip} - Enumeration Report\n\n")
filp.write("Enumerated Types:\n")
for typ in facts:
filp.write(f"- {typ}\n")

View File

@ -1,5 +1,6 @@
#!/usr/bin/env python3
import os
import stat
import pwncat
from pwncat.util import Access
@ -17,30 +18,21 @@ class Module(EnumerateModule):
SCHEDULE = Schedule.PER_USER
PLATFORM = [Linux]
def enumerate(self):
def enumerate(self, session):
for path in pwncat.victim.getenv("PATH").split(":"):
access = pwncat.victim.access(path)
if (Access.DIRECTORY | Access.WRITE) in access:
yield "misc.writable_path", path
elif (
Access.EXISTS not in access
and (Access.PARENT_EXIST | Access.PARENT_WRITE) in access
):
yield "misc.writable_path", path
elif access == Access.NONE:
# This means the parent directory doesn't exist. Check up the chain to see if
# We can create this chain of directories
dirpath = os.path.dirname(path)
access = pwncat.victim.access(dirpath)
# Find the first item that either exists or it's parent does
while access == Access.NONE:
dirpath = os.path.dirname(dirpath)
access = pwncat.victim.access(dirpath)
# This item exists. Is it a directory and can we write to it?
if (Access.DIRECTORY | Access.WRITE) in access:
yield "misc.writable_path", path
elif (
Access.PARENT_EXIST | Access.PARENT_WRITE
) in access and Access.EXISTS not in access:
yield "misc.writable_path", path
user = session.platform.current_user()
for path in session.platform.getenv("PATH").split(":"):
# Ignore empty components
if path == "":
continue
# Find the first item up the path that exists
path = session.platform.Path(path)
while not path.exists():
path = path.parent
# See if we have write permission
if path.is_dir() and path.writable():
yield "misc.writable_path", str(path.resolve())

View File

@ -36,6 +36,28 @@ class Path:
_lstat: os.stat_result
parts = []
def writable(self) -> bool:
""" This is non-standard, but is useful """
user = self._target.current_user()
mode = self.stat().st_mode
uid = self.stat().st_uid
gid = self.stat().st_gid
if uid == user.id and (mode & stat.S_IWUSR):
return True
elif user.group.id == gid and (mode & stat.S_IWGRP):
return True
else:
for group in user.groups:
if group.id == gid and (mode & stat.S_IWGRP):
return True
else:
if mode & stat.S_IWOTH:
return True
return False
def stat(self) -> os.stat_result:
""" Run `stat` on the path and return a stat result """
@ -410,6 +432,9 @@ class Platform:
def __str__(self):
return str(self.channel)
def getenv(self, name: str):
""" Get the value of an environment variable """
def reload_users(self):
""" Reload the user and group cache. This is automatically called
if the cache hasn't been built yet, but may be called manually
@ -467,6 +492,11 @@ class Platform:
return user
def current_user(self):
""" Retrieve a user object for the current user """
return self.find_user(name=self.whoami())
def iter_groups(self) -> Generator["pwncat.db.Group", None, None]:
""" Iterate over all groups on the remote system """
@ -535,9 +565,6 @@ class Platform:
def readlink(self, path: str):
""" Attempt to read the target of a link """
def current_user(self):
""" Retrieve a user object for the current user """
def whoami(self):
""" Retrieve's only name of the current user (may be faster depending
on platform) """

View File

@ -87,7 +87,14 @@ class PopenLinux(pwncat.subprocess.Popen):
# Drain buffer, don't wait for more data. The user didn't ask
# for the data with `stdout=PIPE`, so we can safely ignore it.
# This returns true if we hit EOF
if self.stdout_raw.peek(len(self.end_delim)) == b"" and self.stdout_raw.raw.eof:
try:
if (
self.stdout_raw.peek(len(self.end_delim)) == b""
and self.stdout_raw.raw.eof
):
self._receive_returncode()
return self.returncode
except ValueError:
self._receive_returncode()
return self.returncode
@ -627,6 +634,16 @@ class Linux(Platform):
return stdout
def getenv(self, name: str):
try:
proc = self.run(
["echo", f"${name}"], capture_output=True, text=True, check=True
)
return proc.stdout.rstrip("\n")
except CalledProcessError:
return ""
def compile(
self,
sources: List[Union[str, BinaryIO]],