From 5b1cacf5665110a851a1fb3c5613b2d542aa30f7 Mon Sep 17 00:00:00 2001 From: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> Date: Fri, 13 Jun 2025 12:07:47 -0300 Subject: [PATCH 01/19] Create gravedigger.py Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> --- nxc/modules/gravedigger.py | 258 +++++++++++++++++++++++++++++++++++++ 1 file changed, 258 insertions(+) create mode 100644 nxc/modules/gravedigger.py diff --git a/nxc/modules/gravedigger.py b/nxc/modules/gravedigger.py new file mode 100644 index 00000000..735e5f41 --- /dev/null +++ b/nxc/modules/gravedigger.py @@ -0,0 +1,258 @@ +import sys +from impacket.ldap import ldap as ldap_impacket +from nxc.logger import nxc_logger +from nxc.parsers.ldap_results import parse_result_attributes +from impacket.ldap.ldapasn1 import Control +from impacket.examples.utils import init_ldap_session +from ldap3 import MODIFY_REPLACE, MODIFY_DELETE + + +class NXCModule: + """Module by Fabrizzio: @Fabrizzio53""" + + name = "gravedigger" + description = "Query, restore and delete AD object" + supported_protocols = ["ldap"] + opsec_safe = True + multiple_hosts = False + + def __init__(self, context=None, module_options=None): + self.context = context + self.module_options = module_options + self.domains = None + + def options(self, context, module_options): + """ + ACTION: Specify the action to execute, by default it uses the "query" action which only retrieve deleted objects, "restore" recover the object from the "ID" param, delete will delete the object. + ID: The id of which object you want to restore. + DN: The DN of which object you want to delete. + SCHEME: Force to use ldap or ldaps when trying to restore or delete an object, by default it uses ldaps. + Usage: nxc ldap $DC-IP -u Username -p Password -M gravedigger" + nxc ldap $DC-IP -u Username -p Password -M gravedigger -o ACTION=restore ID=5ad162c9-97b1-4a90-a17c-5c2aedb7d1e3 + nxc ldap $DC-IP -u Username -p Password -M gravedigger -o ACTION=delete DN="CN=test,OU=Users,DC=test,DC=local" + nxc ldap $DC-IP -u Username -p Password -M gravedigger -o ACTION=restore ID=5ad162c9-97b1-4a90-a17c-5c2aedb7d1e3 SCHEME=ldap + nxc ldap $DC-IP -u Username -p Password -M gravedigger -o ACTION=query + """ + self.action = "query" + self.id = "" + self.deleteDN = "" + self.ssl = True + if "ACTION" in module_options: + self.action = module_options["ACTION"] + if "ID" in module_options: + self.id = module_options["ID"] + if "DN" in module_options: + self.deleteDN = module_options["DN"] + if "SCHEME" in module_options and module_options["SCHEME"] == "ldap": + self.ssl = False + + if "ACTION" in module_options and self.action == "restore" and "ID" not in module_options: + context.log.error("ID is necessary when calling gravedigger with the restore action") + sys.exit(1) + + if "ACTION" in module_options and self.action == "delete" and "DN" not in module_options: + context.log.error("DN is necessary when calling gravedigger with the delete action") + sys.exit(1) + + def domain_to_dn(self, domain): + return ",".join(f"DC={part}" for part in domain.split(".")) + + def restore_deleted_object(self, context, connection): + + # ldap DN for deleted objects + dn = "CN=Deleted Objects," + self.domain_to_dn(self.__domain) + + # Search filter used to recover only Deleted objects and only the one with the specified id + searchFilter = "(isDeleted=TRUE)" + + # LDAP control necessary to show the deleted objects LDAP_SERVER_SHOW_DELETED_OID + show_deleted_control = Control() + show_deleted_control["controlType"] = "1.2.840.113556.1.4.417" + show_deleted_control["criticality"] = True + + context.log.highlight(f"Trying to find object with given id {self.id}") + + try: + context.log.debug(f"Search Filter={searchFilter}") + resp = connection.ldap_connection.search( + dn, + 2, + searchFilter=searchFilter, + attributes=["*"], + sizeLimit=0, + searchControls=[show_deleted_control] + ) + + except ldap_impacket.LDAPSearchError as e: + if e.getErrorString().find("sizeLimitExceeded") >= 0: + context.log.debug("sizeLimitExceeded exception caught, giving up and processing the data received") + # We reached the sizeLimit, process the answers we have already and that's it. Until we implement + # paged queries + resp = e.getAnswers() + else: + nxc_logger.debug(e) + return False + + if len(resp) < 1: + context.log.highlight("Recycle is not active on that domain or no object is deleted.") + + return None + + context.log.highlight(f"Found {len(resp)} deleted objects, parsing results to recover necessary informations from given ID") + context.log.highlight("") + + resp_parsed = parse_result_attributes(resp) + + for response in resp_parsed: + + # The value 17 is the first entry from the ldap query when returning deleted objects and it should by default return the Deleted Objects OU information, by skipping this we return only objects that we want + if len(response) != 17 and self.id == response["name"].split(":")[1]: + + context.log.highlight("Found target!") + context.log.highlight(f"sAMAccountName {response['sAMAccountName']}") + context.log.highlight(f"dn {response['distinguishedName']}") + context.log.highlight(f"ID {response['name'].split(':')[1]}") + context.log.highlight(f"isDeleted {response['isDeleted']}") + context.log.highlight(f"lastKnownParent {response['lastKnownParent']}") + context.log.highlight("") + self.__sAMAccountName = response["sAMAccountName"] + self.__objectDN = response["distinguishedName"] + self.__lastKnownParent = response["lastKnownParent"] + + break + + if self.__sAMAccountName == "": + context.log.highlight(f"The object was not found with id {self.id}.") + return None + + ldap_server, ldap_session = init_ldap_session(self.__domain, self.__username, self.__password, self.__lmhash, self.__nthash, self.__doKerberos, self.__host, self.__kdcHost, self.__aesKey, self.ssl) + + control = ("1.2.840.113556.1.4.417", True, None) + + success = ldap_session.modify( + dn=self.__objectDN, + changes={ + "isDeleted": [(MODIFY_DELETE, [])], # Remove the isDeleted atribute + "distinguishedName": [(MODIFY_REPLACE, [f"CN={self.__sAMAccountName},{self.__lastKnownParent}"])] # Change the old dn to the original DN + }, + controls=[control] + ) + + if success: + + context.log.highlight(f'Success "CN={self.__sAMAccountName},{self.__lastKnownParent}" restored') + + else: + + context.log.highlight(f"Error at trying to recover the object {ldap_session.result['description']}") + + def delete_object(self, context, connection): + + ldap_server, ldap_session = init_ldap_session(self.__domain, self.__username, self.__password, self.__lmhash, self.__nthash, self.__doKerberos, self.__host, self.__kdcHost, self.__aesKey, self.ssl) + + context.log.highlight(f"Trying to delete {self.deleteDN}") + + success = ldap_session.delete(self.deleteDN) + + if success: + + context.log.highlight("") + context.log.highlight(f'Success, "{self.deleteDN}" deleted') + + else: + + context.log.highlight("") + context.log.highlight(f'Error when trying to delete "{self.deleteDN}" {ldap_session.result}') + + return + + def query_deleted_objects(self, context, connection): + + # ldap DN for deleted objects + dn = "CN=Deleted Objects," + self.domain_to_dn(self.__domain) + + # Search filter used to recover only Deleted objects + searchFilter = "(isDeleted=TRUE)" + + # LDAP control necessary to show the deleted objects LDAP_SERVER_SHOW_DELETED_OID + show_deleted_control = Control() + show_deleted_control["controlType"] = "1.2.840.113556.1.4.417" + show_deleted_control["criticality"] = True + + try: + context.log.debug(f"Search Filter={searchFilter}") + resp = connection.ldap_connection.search( + dn, + 2, + searchFilter=searchFilter, + attributes=["*"], + sizeLimit=0, + searchControls=[show_deleted_control] + ) + + except ldap_impacket.LDAPSearchError as e: + if e.getErrorString().find("sizeLimitExceeded") >= 0: + context.log.debug("sizeLimitExceeded exception caught, giving up and processing the data received") + # We reached the sizeLimit, process the answers we have already and that's it. Until we implement + # paged queries + resp = e.getAnswers() + else: + nxc_logger.debug(e) + return False + + if len(resp) < 1: + context.log.highlight("Recycle is not active on that domain or no object is deleted with that id.") + + return None + + context.log.highlight(f"Found {len(resp)} deleted objects") + context.log.highlight("") + + resp_parsed = parse_result_attributes(resp) + + for response in resp_parsed: + + # The value 17 is the first entry from the ldap query when returning deleted objects and it should by default return the Deleted Objects OU information, by skipping this we return only objects that we want + if len(response) != 17: + + context.log.highlight(f"sAMAccountName {response['sAMAccountName']}") + context.log.highlight(f"dn {response['distinguishedName']}") + context.log.highlight(f"ID {response['name'].split(':')[1]}") + context.log.highlight(f"isDeleted {response['isDeleted']}") + context.log.highlight(f"lastKnownParent {response['lastKnownParent']}") + context.log.highlight("") + + def on_login(self, context, connection): + self.__domain = connection.domain + self.__domainNetbios = connection.domain + self.__kdcHost = connection.kdcHost + self.__username = connection.username + self.__password = connection.password + self.__host = connection.host + self.__aesKey = context.aesKey + self.__hashes = context.hash + self.__doKerberos = connection.kerberos + self.__nthash = "" + self.__lmhash = "" + self.__sAMAccountName = "" + self.__objectDN = "" + self.__lastKnownParent = "" + + if context.hash and ":" in context.hash[0]: + hashList = context.hash[0].split(":") + self.__nthash = hashList[-1] + self.__lmhash = hashList[0] + elif context.hash and ":" not in context.hash[0]: + self.__nthash = context.hash[0] + self.__lmhash = "00000000000000000000000000000000" + + self.__domain = connection.domain + + if self.action == "query": + self.query_deleted_objects(context, connection) + + if self.action == "delete": + self.delete_object(context, connection) + + if self.action == "restore": + self.restore_deleted_object(context, connection) From 74c2cd0c89cf36572cf4a114a7bb2cf914972d68 Mon Sep 17 00:00:00 2001 From: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> Date: Mon, 7 Jul 2025 08:34:43 -0300 Subject: [PATCH 02/19] Renamed gravedigger.py to tombstone.py and changed the name at the script Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> --- nxc/modules/{gravedigger.py => tombstone.py} | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) rename nxc/modules/{gravedigger.py => tombstone.py} (93%) diff --git a/nxc/modules/gravedigger.py b/nxc/modules/tombstone.py similarity index 93% rename from nxc/modules/gravedigger.py rename to nxc/modules/tombstone.py index 735e5f41..acb379cc 100644 --- a/nxc/modules/gravedigger.py +++ b/nxc/modules/tombstone.py @@ -10,7 +10,7 @@ from ldap3 import MODIFY_REPLACE, MODIFY_DELETE class NXCModule: """Module by Fabrizzio: @Fabrizzio53""" - name = "gravedigger" + name = "tombstone" description = "Query, restore and delete AD object" supported_protocols = ["ldap"] opsec_safe = True @@ -27,11 +27,11 @@ class NXCModule: ID: The id of which object you want to restore. DN: The DN of which object you want to delete. SCHEME: Force to use ldap or ldaps when trying to restore or delete an object, by default it uses ldaps. - Usage: nxc ldap $DC-IP -u Username -p Password -M gravedigger" - nxc ldap $DC-IP -u Username -p Password -M gravedigger -o ACTION=restore ID=5ad162c9-97b1-4a90-a17c-5c2aedb7d1e3 - nxc ldap $DC-IP -u Username -p Password -M gravedigger -o ACTION=delete DN="CN=test,OU=Users,DC=test,DC=local" - nxc ldap $DC-IP -u Username -p Password -M gravedigger -o ACTION=restore ID=5ad162c9-97b1-4a90-a17c-5c2aedb7d1e3 SCHEME=ldap - nxc ldap $DC-IP -u Username -p Password -M gravedigger -o ACTION=query + Usage: nxc ldap $DC-IP -u Username -p Password -M tombstone" + 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" + nxc ldap $DC-IP -u Username -p Password -M tombstone -o ACTION=restore ID=5ad162c9-97b1-4a90-a17c-5c2aedb7d1e3 SCHEME=ldap + nxc ldap $DC-IP -u Username -p Password -M tombstone -o ACTION=query """ self.action = "query" self.id = "" @@ -47,11 +47,11 @@ class NXCModule: self.ssl = False if "ACTION" in module_options and self.action == "restore" and "ID" not in module_options: - context.log.error("ID is necessary when calling gravedigger with the restore action") + context.log.error("ID is necessary when calling tombstone with the restore action") sys.exit(1) if "ACTION" in module_options and self.action == "delete" and "DN" not in module_options: - context.log.error("DN is necessary when calling gravedigger with the delete action") + context.log.error("DN is necessary when calling tombstone with the delete action") sys.exit(1) def domain_to_dn(self, domain): From ac55e28feea24efd4bab6a5f5483b44dd141b505 Mon Sep 17 00:00:00 2001 From: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> Date: Sun, 13 Jul 2025 16:44:30 -0300 Subject: [PATCH 03/19] update tombstone.py to fix when ntlm is disabled When netexec impacket gets merged with the most up to date version from fortra this fix will work when a domain disabled ntlm Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> --- nxc/modules/tombstone.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/nxc/modules/tombstone.py b/nxc/modules/tombstone.py index acb379cc..37e79fad 100644 --- a/nxc/modules/tombstone.py +++ b/nxc/modules/tombstone.py @@ -125,6 +125,11 @@ class NXCModule: context.log.highlight(f"The object was not found with id {self.id}.") return None + #If Kerberos True, then fqdn is used, similar to the -dc-host from impacket, set the machine_name to a fqdn and don't call _get_machine_name (good when NTLM is disabled) + if self.__doKerberos == True: + + self.__kdcHost = self.__host + ldap_server, ldap_session = init_ldap_session(self.__domain, self.__username, self.__password, self.__lmhash, self.__nthash, self.__doKerberos, self.__host, self.__kdcHost, self.__aesKey, self.ssl) control = ("1.2.840.113556.1.4.417", True, None) @@ -148,6 +153,11 @@ class NXCModule: def delete_object(self, context, connection): + #If Kerberos True, then fqdn is used, similar to the -dc-host from impacket, set the machine_name to a fqdn and don't call _get_machine_name (good when NTLM is disabled) + if self.__doKerberos == True: + + self.__kdcHost = self.__host + ldap_server, ldap_session = init_ldap_session(self.__domain, self.__username, self.__password, self.__lmhash, self.__nthash, self.__doKerberos, self.__host, self.__kdcHost, self.__aesKey, self.ssl) context.log.highlight(f"Trying to delete {self.deleteDN}") From 664055c7c379c9d8b508531b3e498763beb589fb Mon Sep 17 00:00:00 2001 From: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> Date: Sun, 13 Jul 2025 17:07:45 -0300 Subject: [PATCH 04/19] Update e2e_commands.txt Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> --- tests/e2e_commands.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/e2e_commands.txt b/tests/e2e_commands.txt index 7d03207e..2492626f 100644 --- a/tests/e2e_commands.txt +++ b/tests/e2e_commands.txt @@ -202,6 +202,7 @@ netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M adcs netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M daclread -o TARGET=LOGIN_USERNAME ACTION=read netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get-desc-users netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get-info-users +netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M tombstone netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M get-network netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M groupmembership -o USER=LOGIN_USERNAME netexec ldap TARGET_HOST -u LOGIN_USERNAME -p LOGIN_PASSWORD KERBEROS -M laps From 6512a9801e6d6afe9f57573e196842acc17e0dfc Mon Sep 17 00:00:00 2001 From: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:12:18 -0300 Subject: [PATCH 05/19] Removed LDAP3 connection, removed old imports, removed SSL action Removed the LDAP3 connection and now use impacket with the new CRUD method from the latest merged PR, removed the SSL option since now the first connection is handled by netexec and not ldap3 Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> --- nxc/modules/tombstone.py | 102 ++++++++++++--------------------------- 1 file changed, 32 insertions(+), 70 deletions(-) diff --git a/nxc/modules/tombstone.py b/nxc/modules/tombstone.py index 37e79fad..4caf7562 100644 --- a/nxc/modules/tombstone.py +++ b/nxc/modules/tombstone.py @@ -1,10 +1,11 @@ import sys from impacket.ldap import ldap as ldap_impacket +from impacket.ldap import ldapasn1 from nxc.logger import nxc_logger from nxc.parsers.ldap_results import parse_result_attributes from impacket.ldap.ldapasn1 import Control -from impacket.examples.utils import init_ldap_session -from ldap3 import MODIFY_REPLACE, MODIFY_DELETE +from impacket.ldap.ldap import LDAPSessionError, MODIFY_REPLACE, MODIFY_DELETE +from nxc.helpers.misc import CATEGORY class NXCModule: @@ -15,6 +16,7 @@ class NXCModule: supported_protocols = ["ldap"] opsec_safe = True multiple_hosts = False + category = CATEGORY.ENUMERATION def __init__(self, context=None, module_options=None): self.context = context @@ -26,11 +28,9 @@ class NXCModule: ACTION: Specify the action to execute, by default it uses the "query" action which only retrieve deleted objects, "restore" recover the object from the "ID" param, delete will delete the object. ID: The id of which object you want to restore. DN: The DN of which object you want to delete. - SCHEME: Force to use ldap or ldaps when trying to restore or delete an object, by default it uses ldaps. Usage: nxc ldap $DC-IP -u Username -p Password -M tombstone" 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" - nxc ldap $DC-IP -u Username -p Password -M tombstone -o ACTION=restore ID=5ad162c9-97b1-4a90-a17c-5c2aedb7d1e3 SCHEME=ldap nxc ldap $DC-IP -u Username -p Password -M tombstone -o ACTION=query """ self.action = "query" @@ -43,8 +43,6 @@ class NXCModule: self.id = module_options["ID"] if "DN" in module_options: self.deleteDN = module_options["DN"] - if "SCHEME" in module_options and module_options["SCHEME"] == "ldap": - self.ssl = False if "ACTION" in module_options and self.action == "restore" and "ID" not in module_options: context.log.error("ID is necessary when calling tombstone with the restore action") @@ -92,17 +90,11 @@ class NXCModule: else: nxc_logger.debug(e) return False - - if len(resp) < 1: - context.log.highlight("Recycle is not active on that domain or no object is deleted.") - - return None - - context.log.highlight(f"Found {len(resp)} deleted objects, parsing results to recover necessary informations from given ID") - context.log.highlight("") - + resp_parsed = parse_result_attributes(resp) + context.log.highlight(f"") + for response in resp_parsed: # The value 17 is the first entry from the ldap query when returning deleted objects and it should by default return the Deleted Objects OU information, by skipping this we return only objects that we want @@ -125,54 +117,40 @@ class NXCModule: context.log.highlight(f"The object was not found with id {self.id}.") return None - #If Kerberos True, then fqdn is used, similar to the -dc-host from impacket, set the machine_name to a fqdn and don't call _get_machine_name (good when NTLM is disabled) - if self.__doKerberos == True: + # LDAP control necessary to pass when recovering deleted objects [LDAP_SERVER_SHOW_DELETED_OID] + show_deleted_control = Control() + show_deleted_control["controlType"] = "1.2.840.113556.1.4.417" + show_deleted_control["criticality"] = True - self.__kdcHost = self.__host - - ldap_server, ldap_session = init_ldap_session(self.__domain, self.__username, self.__password, self.__lmhash, self.__nthash, self.__doKerberos, self.__host, self.__kdcHost, self.__aesKey, self.ssl) - - control = ("1.2.840.113556.1.4.417", True, None) - - success = ldap_session.modify( - dn=self.__objectDN, - changes={ - "isDeleted": [(MODIFY_DELETE, [])], # Remove the isDeleted atribute - "distinguishedName": [(MODIFY_REPLACE, [f"CN={self.__sAMAccountName},{self.__lastKnownParent}"])] # Change the old dn to the original DN - }, - controls=[control] - ) - - if success: + try: + connection.ldap_connection.modify(dn=self.__objectDN, + modifications={ + "isDeleted": [(MODIFY_DELETE, [])], # Remove the isDeleted atribute + "distinguishedName": [(MODIFY_REPLACE, [f"CN={self.__sAMAccountName},{self.__lastKnownParent}"])] #restore the user DN + }, + controls=[show_deleted_control] + ) context.log.highlight(f'Success "CN={self.__sAMAccountName},{self.__lastKnownParent}" restored') - else: - - context.log.highlight(f"Error at trying to recover the object {ldap_session.result['description']}") + except LDAPSessionError as e: + context.log.highlight(f"Error at trying to recover the object {e}") + return + def delete_object(self, context, connection): - - #If Kerberos True, then fqdn is used, similar to the -dc-host from impacket, set the machine_name to a fqdn and don't call _get_machine_name (good when NTLM is disabled) - if self.__doKerberos == True: - - self.__kdcHost = self.__host - - ldap_server, ldap_session = init_ldap_session(self.__domain, self.__username, self.__password, self.__lmhash, self.__nthash, self.__doKerberos, self.__host, self.__kdcHost, self.__aesKey, self.ssl) context.log.highlight(f"Trying to delete {self.deleteDN}") - - success = ldap_session.delete(self.deleteDN) - if success: + try: + connection.ldap_connection.delete(dn=self.deleteDN) context.log.highlight("") context.log.highlight(f'Success, "{self.deleteDN}" deleted') - else: - + except LDAPSessionError as e: context.log.highlight("") - context.log.highlight(f'Error when trying to delete "{self.deleteDN}" {ldap_session.result}') + context.log.highlight(f'Error when trying to delete "{self.deleteDN}" {e}') return @@ -209,9 +187,11 @@ class NXCModule: else: nxc_logger.debug(e) return False - - if len(resp) < 1: - context.log.highlight("Recycle is not active on that domain or no object is deleted with that id.") + + entries = [item for item in resp if isinstance(item, ldapasn1.SearchResultEntry)] + + if len(entries) < 2: + context.log.highlight("Recycle bin is not active on the domain or no user is in a tombstone state") return None @@ -234,27 +214,9 @@ class NXCModule: def on_login(self, context, connection): self.__domain = connection.domain - self.__domainNetbios = connection.domain - self.__kdcHost = connection.kdcHost - self.__username = connection.username - self.__password = connection.password - self.__host = connection.host - self.__aesKey = context.aesKey - self.__hashes = context.hash - self.__doKerberos = connection.kerberos - self.__nthash = "" - self.__lmhash = "" self.__sAMAccountName = "" self.__objectDN = "" self.__lastKnownParent = "" - - if context.hash and ":" in context.hash[0]: - hashList = context.hash[0].split(":") - self.__nthash = hashList[-1] - self.__lmhash = hashList[0] - elif context.hash and ":" not in context.hash[0]: - self.__nthash = context.hash[0] - self.__lmhash = "00000000000000000000000000000000" self.__domain = connection.domain From fea06fe912d8e527a2883c28a896af27a417f92b Mon Sep 17 00:00:00 2001 From: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> Date: Wed, 15 Apr 2026 19:28:08 -0300 Subject: [PATCH 06/19] fixed ruff checks fixed ruff checks Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> --- nxc/modules/tombstone.py | 28 ++++++++-------------------- 1 file changed, 8 insertions(+), 20 deletions(-) diff --git a/nxc/modules/tombstone.py b/nxc/modules/tombstone.py index 4caf7562..3e67dc04 100644 --- a/nxc/modules/tombstone.py +++ b/nxc/modules/tombstone.py @@ -43,7 +43,6 @@ class NXCModule: self.id = module_options["ID"] if "DN" in module_options: self.deleteDN = module_options["DN"] - if "ACTION" in module_options and self.action == "restore" and "ID" not in module_options: context.log.error("ID is necessary when calling tombstone with the restore action") sys.exit(1) @@ -62,7 +61,6 @@ class NXCModule: # Search filter used to recover only Deleted objects and only the one with the specified id searchFilter = "(isDeleted=TRUE)" - # LDAP control necessary to show the deleted objects LDAP_SERVER_SHOW_DELETED_OID show_deleted_control = Control() show_deleted_control["controlType"] = "1.2.840.113556.1.4.417" @@ -90,16 +88,12 @@ class NXCModule: else: nxc_logger.debug(e) return False - resp_parsed = parse_result_attributes(resp) - - context.log.highlight(f"") + context.log.highlight("") for response in resp_parsed: - # The value 17 is the first entry from the ldap query when returning deleted objects and it should by default return the Deleted Objects OU information, by skipping this we return only objects that we want if len(response) != 17 and self.id == response["name"].split(":")[1]: - context.log.highlight("Found target!") context.log.highlight(f"sAMAccountName {response['sAMAccountName']}") context.log.highlight(f"dn {response['distinguishedName']}") @@ -125,8 +119,8 @@ class NXCModule: try: connection.ldap_connection.modify(dn=self.__objectDN, modifications={ - "isDeleted": [(MODIFY_DELETE, [])], # Remove the isDeleted atribute - "distinguishedName": [(MODIFY_REPLACE, [f"CN={self.__sAMAccountName},{self.__lastKnownParent}"])] #restore the user DN + "isDeleted": [(MODIFY_DELETE, [])], # Remove the isDeleted atribute + "distinguishedName": [(MODIFY_REPLACE, [f"CN={self.__sAMAccountName},{self.__lastKnownParent}"])] # Restore the user DN }, controls=[show_deleted_control] ) @@ -136,21 +130,19 @@ class NXCModule: except LDAPSessionError as e: context.log.highlight(f"Error at trying to recover the object {e}") - return - + return None + def delete_object(self, context, connection): - context.log.highlight(f"Trying to delete {self.deleteDN}") try: connection.ldap_connection.delete(dn=self.deleteDN) - context.log.highlight("") context.log.highlight(f'Success, "{self.deleteDN}" deleted') except LDAPSessionError as e: context.log.highlight("") - context.log.highlight(f'Error when trying to delete "{self.deleteDN}" {e}') + context.log.highlight(f'Error when trying to delete "{self.deleteDN}" {e}') return @@ -188,7 +180,7 @@ class NXCModule: nxc_logger.debug(e) return False - entries = [item for item in resp if isinstance(item, ldapasn1.SearchResultEntry)] + entries = [item for item in resp if isinstance(item, ldapasn1.SearchResultEntry)] if len(entries) < 2: context.log.highlight("Recycle bin is not active on the domain or no user is in a tombstone state") @@ -204,27 +196,23 @@ class NXCModule: # The value 17 is the first entry from the ldap query when returning deleted objects and it should by default return the Deleted Objects OU information, by skipping this we return only objects that we want if len(response) != 17: - context.log.highlight(f"sAMAccountName {response['sAMAccountName']}") context.log.highlight(f"dn {response['distinguishedName']}") context.log.highlight(f"ID {response['name'].split(':')[1]}") context.log.highlight(f"isDeleted {response['isDeleted']}") context.log.highlight(f"lastKnownParent {response['lastKnownParent']}") context.log.highlight("") - + def on_login(self, context, connection): self.__domain = connection.domain self.__sAMAccountName = "" self.__objectDN = "" self.__lastKnownParent = "" - self.__domain = connection.domain if self.action == "query": self.query_deleted_objects(context, connection) - if self.action == "delete": self.delete_object(context, connection) - if self.action == "restore": self.restore_deleted_object(context, connection) From a8a94671eab75589a4c9f1da91f92c8b1181cebf Mon Sep 17 00:00:00 2001 From: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> Date: Wed, 22 Apr 2026 18:37:29 -0300 Subject: [PATCH 07/19] fixed issues pointed on the PR removed imports that were not being used, remove None returns, put the options at the start of the script and improved on indentation Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> --- nxc/modules/tombstone.py | 174 ++++++++++++++------------------------- 1 file changed, 62 insertions(+), 112 deletions(-) diff --git a/nxc/modules/tombstone.py b/nxc/modules/tombstone.py index 3e67dc04..50589ee8 100644 --- a/nxc/modules/tombstone.py +++ b/nxc/modules/tombstone.py @@ -1,7 +1,4 @@ import sys -from impacket.ldap import ldap as ldap_impacket -from impacket.ldap import ldapasn1 -from nxc.logger import nxc_logger from nxc.parsers.ldap_results import parse_result_attributes from impacket.ldap.ldapasn1 import Control from impacket.ldap.ldap import LDAPSessionError, MODIFY_REPLACE, MODIFY_DELETE @@ -36,7 +33,6 @@ class NXCModule: self.action = "query" self.id = "" self.deleteDN = "" - self.ssl = True if "ACTION" in module_options: self.action = module_options["ACTION"] if "ID" in module_options: @@ -51,6 +47,21 @@ class NXCModule: context.log.error("DN is necessary when calling tombstone with the delete action") sys.exit(1) + def on_login(self, context, connection): + self.__domain = connection.domain + self.__sAMAccountName = "" + self.__objectDN = "" + self.__lastKnownParent = "" + self.__domain = connection.domain + self.connection = connection + + if self.action == "query": + self.query_deleted_objects(context) + if self.action == "delete": + self.delete_object(context, connection) + if self.action == "restore": + self.restore_deleted_object(context, connection) + def domain_to_dn(self, domain): return ",".join(f"DC={part}" for part in domain.split(".")) @@ -59,8 +70,6 @@ class NXCModule: # ldap DN for deleted objects dn = "CN=Deleted Objects," + self.domain_to_dn(self.__domain) - # Search filter used to recover only Deleted objects and only the one with the specified id - searchFilter = "(isDeleted=TRUE)" # LDAP control necessary to show the deleted objects LDAP_SERVER_SHOW_DELETED_OID show_deleted_control = Control() show_deleted_control["controlType"] = "1.2.840.113556.1.4.417" @@ -68,69 +77,45 @@ class NXCModule: context.log.highlight(f"Trying to find object with given id {self.id}") - try: - context.log.debug(f"Search Filter={searchFilter}") - resp = connection.ldap_connection.search( - dn, - 2, - searchFilter=searchFilter, - attributes=["*"], - sizeLimit=0, - searchControls=[show_deleted_control] - ) + context.log.debug("Search Filter=(isDeleted=TRUE)") + resp = self.connection.search(baseDN=dn, searchFilter="(isDeleted=TRUE)", attributes=["*"], searchControls=[show_deleted_control]) - except ldap_impacket.LDAPSearchError as e: - if e.getErrorString().find("sizeLimitExceeded") >= 0: - context.log.debug("sizeLimitExceeded exception caught, giving up and processing the data received") - # We reached the sizeLimit, process the answers we have already and that's it. Until we implement - # paged queries - resp = e.getAnswers() - else: - nxc_logger.debug(e) - return False resp_parsed = parse_result_attributes(resp) context.log.highlight("") - for response in resp_parsed: - # The value 17 is the first entry from the ldap query when returning deleted objects and it should by default return the Deleted Objects OU information, by skipping this we return only objects that we want - if len(response) != 17 and self.id == response["name"].split(":")[1]: + for entries in resp_parsed: + + # This check ensures that we skip the result for the Default container and only get the result from the given ID. + if "container" in entries["objectClass"] and entries["description"] == "Default container for deleted objects": + continue + + if self.id == entries["name"].split(":")[1]: + context.log.highlight("Found target!") - context.log.highlight(f"sAMAccountName {response['sAMAccountName']}") - context.log.highlight(f"dn {response['distinguishedName']}") - context.log.highlight(f"ID {response['name'].split(':')[1]}") - context.log.highlight(f"isDeleted {response['isDeleted']}") - context.log.highlight(f"lastKnownParent {response['lastKnownParent']}") + context.log.highlight(f"{'sAMAccountName':<20}: {entries['sAMAccountName']}") + context.log.highlight(f"{'dn':<20}: {entries['distinguishedName']}") + context.log.highlight(f"{'ID':<20}: {entries['name'].split(':')[1]}") + context.log.highlight(f"{'isDeleted':<20}: {entries['isDeleted']}") + context.log.highlight(f"{'lastKnownParent':<20}: {entries['lastKnownParent']}") context.log.highlight("") - self.__sAMAccountName = response["sAMAccountName"] - self.__objectDN = response["distinguishedName"] - self.__lastKnownParent = response["lastKnownParent"] + + self.__sAMAccountName = entries["sAMAccountName"] + self.__objectDN = entries["distinguishedName"] + self.__lastKnownParent = entries["lastKnownParent"] break if self.__sAMAccountName == "": context.log.highlight(f"The object was not found with id {self.id}.") - return None - - # LDAP control necessary to pass when recovering deleted objects [LDAP_SERVER_SHOW_DELETED_OID] - show_deleted_control = Control() - show_deleted_control["controlType"] = "1.2.840.113556.1.4.417" - show_deleted_control["criticality"] = True + return False try: - connection.ldap_connection.modify(dn=self.__objectDN, - modifications={ - "isDeleted": [(MODIFY_DELETE, [])], # Remove the isDeleted atribute - "distinguishedName": [(MODIFY_REPLACE, [f"CN={self.__sAMAccountName},{self.__lastKnownParent}"])] # Restore the user DN - }, - controls=[show_deleted_control] - ) - + connection.ldap_connection.modify(dn=self.__objectDN, modifications={"isDeleted": [(MODIFY_DELETE, [])], "distinguishedName": [(MODIFY_REPLACE, [f"CN={self.__sAMAccountName},{self.__lastKnownParent}"])]}, controls=[show_deleted_control]) context.log.highlight(f'Success "CN={self.__sAMAccountName},{self.__lastKnownParent}" restored') except LDAPSessionError as e: - context.log.highlight(f"Error at trying to recover the object {e}") - - return None + 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.deleteDN}") @@ -142,77 +127,42 @@ class NXCModule: except LDAPSessionError as e: context.log.highlight("") - context.log.highlight(f'Error when trying to delete "{self.deleteDN}" {e}') + context.log.fail(f'Error when trying to delete "{self.deleteDN}" {e}') - return - - def query_deleted_objects(self, context, connection): + def query_deleted_objects(self, context): # ldap DN for deleted objects dn = "CN=Deleted Objects," + self.domain_to_dn(self.__domain) - # Search filter used to recover only Deleted objects - searchFilter = "(isDeleted=TRUE)" - # LDAP control necessary to show the deleted objects LDAP_SERVER_SHOW_DELETED_OID show_deleted_control = Control() show_deleted_control["controlType"] = "1.2.840.113556.1.4.417" show_deleted_control["criticality"] = True - try: - context.log.debug(f"Search Filter={searchFilter}") - resp = connection.ldap_connection.search( - dn, - 2, - searchFilter=searchFilter, - attributes=["*"], - sizeLimit=0, - searchControls=[show_deleted_control] - ) - - except ldap_impacket.LDAPSearchError as e: - if e.getErrorString().find("sizeLimitExceeded") >= 0: - context.log.debug("sizeLimitExceeded exception caught, giving up and processing the data received") - # We reached the sizeLimit, process the answers we have already and that's it. Until we implement - # paged queries - resp = e.getAnswers() - else: - nxc_logger.debug(e) - return False - - entries = [item for item in resp if isinstance(item, ldapasn1.SearchResultEntry)] - - if len(entries) < 2: - context.log.highlight("Recycle bin is not active on the domain or no user is in a tombstone state") - - return None - - context.log.highlight(f"Found {len(resp)} deleted objects") - context.log.highlight("") - + context.log.debug("Search Filter=(isDeleted=TRUE)") + resp = self.connection.search(baseDN=dn, searchFilter="(isDeleted=TRUE)", attributes=["*"], searchControls=[show_deleted_control]) resp_parsed = parse_result_attributes(resp) - for response in resp_parsed: + if len(resp_parsed) == 0: + context.log.highlight("Could not find the Deleted Objects container, AD recycle bin might not be active") + return False - # The value 17 is the first entry from the ldap query when returning deleted objects and it should by default return the Deleted Objects OU information, by skipping this we return only objects that we want - if len(response) != 17: - context.log.highlight(f"sAMAccountName {response['sAMAccountName']}") - context.log.highlight(f"dn {response['distinguishedName']}") - context.log.highlight(f"ID {response['name'].split(':')[1]}") - context.log.highlight(f"isDeleted {response['isDeleted']}") - context.log.highlight(f"lastKnownParent {response['lastKnownParent']}") - context.log.highlight("") + elif len(resp_parsed) < 2: + context.log.highlight("No objects are in a tombstone state") + return False - def on_login(self, context, connection): - self.__domain = connection.domain - self.__sAMAccountName = "" - self.__objectDN = "" - self.__lastKnownParent = "" - self.__domain = connection.domain + context.log.highlight(f"Found {len(resp) - 1} deleted objects") + context.log.highlight("") - if self.action == "query": - self.query_deleted_objects(context, connection) - if self.action == "delete": - self.delete_object(context, connection) - if self.action == "restore": - self.restore_deleted_object(context, connection) + for entries in resp_parsed: + + # This check ensures that we skip the result for the Default container and only get results that are valid for us. + if "container" in entries["objectClass"] and entries["description"] == "Default container for deleted objects": + continue + + context.log.highlight(f"{'sAMAccountName':<20}: {entries['sAMAccountName']}") + context.log.highlight(f"{'dn':<20}: {entries['distinguishedName']}") + context.log.highlight(f"{'ID':<20}: {entries['name'].split(':')[1]}") + context.log.highlight(f"{'isDeleted':<20}: {entries['isDeleted']}") + context.log.highlight(f"{'lastKnownParent':<20}: {entries['lastKnownParent']}") + context.log.highlight("") From adfd529593c71b9cf1667ab2141c193e15e27a99 Mon Sep 17 00:00:00 2001 From: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> Date: Thu, 30 Apr 2026 08:49:14 -0300 Subject: [PATCH 08/19] Removed a typo " in the first example Removed a typo " in the first example Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> --- nxc/modules/tombstone.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/modules/tombstone.py b/nxc/modules/tombstone.py index 50589ee8..d1cc06db 100644 --- a/nxc/modules/tombstone.py +++ b/nxc/modules/tombstone.py @@ -25,7 +25,7 @@ class NXCModule: ACTION: Specify the action to execute, by default it uses the "query" action which only retrieve deleted objects, "restore" recover the object from the "ID" param, delete will delete the object. 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" + Usage: nxc ldap $DC-IP -u Username -p Password -M tombstone 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" nxc ldap $DC-IP -u Username -p Password -M tombstone -o ACTION=query From f3a90d9aca0110278a17c2969c1f3cff8f759b45 Mon Sep 17 00:00:00 2001 From: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> Date: Tue, 26 May 2026 19:27:43 -0300 Subject: [PATCH 09/19] Refactor tombstone refactored the code to return empty sAMAccountName when object does not have this attribute. removed some variables that were not being used, such as opsec, multiple_host and self.__domain. removed internal domain to dn function to now use connection.baseDN. the number of deleted objects should be right now, temporary var is created and incremented for each deleted object found. Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> --- nxc/modules/tombstone.py | 69 ++++++++++++++++++++-------------------- 1 file changed, 35 insertions(+), 34 deletions(-) diff --git a/nxc/modules/tombstone.py b/nxc/modules/tombstone.py index d1cc06db..8c2ac9e9 100644 --- a/nxc/modules/tombstone.py +++ b/nxc/modules/tombstone.py @@ -11,8 +11,6 @@ class NXCModule: name = "tombstone" description = "Query, restore and delete AD object" supported_protocols = ["ldap"] - opsec_safe = True - multiple_hosts = False category = CATEGORY.ENUMERATION def __init__(self, context=None, module_options=None): @@ -30,45 +28,42 @@ class NXCModule: nxc ldap $DC-IP -u Username -p Password -M tombstone -o ACTION=delete DN="CN=test,OU=Users,DC=test,DC=local" nxc ldap $DC-IP -u Username -p Password -M tombstone -o ACTION=query """ - self.action = "query" + self.action = module_options.get("ACTION", "query") self.id = "" self.deleteDN = "" if "ACTION" in module_options: - self.action = module_options["ACTION"] + self.action = module_options.get("ACTION") if "ID" in module_options: self.id = module_options["ID"] if "DN" in module_options: self.deleteDN = module_options["DN"] - if "ACTION" in module_options and self.action == "restore" and "ID" not in module_options: + if "ACTION" in module_options and self.action.lower() == "restore" and "ID" not in module_options: context.log.error("ID is necessary when calling tombstone with the restore action") sys.exit(1) - if "ACTION" in module_options and self.action == "delete" and "DN" not in module_options: + if "ACTION" in module_options and self.action.lower() == "delete" and "DN" not in module_options: context.log.error("DN is necessary when calling tombstone with the delete action") sys.exit(1) def on_login(self, context, connection): - self.__domain = connection.domain self.__sAMAccountName = "" self.__objectDN = "" self.__lastKnownParent = "" - self.__domain = connection.domain self.connection = connection - if self.action == "query": + if self.action.lower() == "query": self.query_deleted_objects(context) - if self.action == "delete": + elif self.action.lower() == "delete": self.delete_object(context, connection) - if self.action == "restore": + elif self.action.lower() == "restore": self.restore_deleted_object(context, connection) - - def domain_to_dn(self, domain): - return ",".join(f"DC={part}" for part in domain.split(".")) + else: + context.log.fail(f'The action "{self.action}" is not valid, use only one available option (query, restore, delete)') def restore_deleted_object(self, context, connection): # ldap DN for deleted objects - dn = "CN=Deleted Objects," + self.domain_to_dn(self.__domain) + dn = f"CN=Deleted Objects,{connection.baseDN}" # LDAP control necessary to show the deleted objects LDAP_SERVER_SHOW_DELETED_OID show_deleted_control = Control() @@ -87,31 +82,32 @@ class NXCModule: # This check ensures that we skip the result for the Default container and only get the result from the given ID. if "container" in entries["objectClass"] and entries["description"] == "Default container for deleted objects": + continue if self.id == entries["name"].split(":")[1]: - context.log.highlight("Found target!") - context.log.highlight(f"{'sAMAccountName':<20}: {entries['sAMAccountName']}") - context.log.highlight(f"{'dn':<20}: {entries['distinguishedName']}") - context.log.highlight(f"{'ID':<20}: {entries['name'].split(':')[1]}") - context.log.highlight(f"{'isDeleted':<20}: {entries['isDeleted']}") - context.log.highlight(f"{'lastKnownParent':<20}: {entries['lastKnownParent']}") + context.log.highlight(f"{'sAMAccountName':<20}: {entries.get('sAMAccountName', '')}") + context.log.highlight(f"{'dn':<20}: {entries.get('distinguishedName', '')}") + context.log.highlight(f"{'ID':<20}: {entries.get('name', '').split(':')[1]}") + context.log.highlight(f"{'isDeleted':<20}: {entries.get('isDeleted', '')}") + context.log.highlight(f"{'lastKnownParent':<20}: {entries.get('lastKnownParent', '')}") context.log.highlight("") - self.__sAMAccountName = entries["sAMAccountName"] - self.__objectDN = entries["distinguishedName"] - self.__lastKnownParent = entries["lastKnownParent"] + self.__objectDN = entries.get("distinguishedName", "") + self.__lastKnownParent = entries.get("lastKnownParent", "") + object_prefix = self.__objectDN.split("\\")[0] + self.__originalDN = f"{object_prefix},{self.__lastKnownParent}" break - if self.__sAMAccountName == "": + if self.__originalDN == "": context.log.highlight(f"The object was not found with id {self.id}.") return False try: - connection.ldap_connection.modify(dn=self.__objectDN, modifications={"isDeleted": [(MODIFY_DELETE, [])], "distinguishedName": [(MODIFY_REPLACE, [f"CN={self.__sAMAccountName},{self.__lastKnownParent}"])]}, controls=[show_deleted_control]) - context.log.highlight(f'Success "CN={self.__sAMAccountName},{self.__lastKnownParent}" restored') + connection.ldap_connection.modify(dn=self.__objectDN, modifications={"isDeleted": [(MODIFY_DELETE, [])], "distinguishedName": [(MODIFY_REPLACE, [self.__originalDN])]}, controls=[show_deleted_control]) + context.log.highlight(f"Success {self.__originalDN} restored") except LDAPSessionError as e: context.log.fail(f"Error at trying to recover the object {e}") @@ -132,7 +128,7 @@ class NXCModule: def query_deleted_objects(self, context): # ldap DN for deleted objects - dn = "CN=Deleted Objects," + self.domain_to_dn(self.__domain) + dn = f"CN=Deleted Objects,{self.connection.baseDN}" # LDAP control necessary to show the deleted objects LDAP_SERVER_SHOW_DELETED_OID show_deleted_control = Control() @@ -151,18 +147,23 @@ class NXCModule: context.log.highlight("No objects are in a tombstone state") return False - context.log.highlight(f"Found {len(resp) - 1} deleted objects") + number_of_deleted_objects = 0 context.log.highlight("") for entries in resp_parsed: # This check ensures that we skip the result for the Default container and only get results that are valid for us. if "container" in entries["objectClass"] and entries["description"] == "Default container for deleted objects": + continue - context.log.highlight(f"{'sAMAccountName':<20}: {entries['sAMAccountName']}") - context.log.highlight(f"{'dn':<20}: {entries['distinguishedName']}") - context.log.highlight(f"{'ID':<20}: {entries['name'].split(':')[1]}") - context.log.highlight(f"{'isDeleted':<20}: {entries['isDeleted']}") - context.log.highlight(f"{'lastKnownParent':<20}: {entries['lastKnownParent']}") + context.log.highlight(f"{'sAMAccountName':<20}: {entries.get('sAMAccountName', '')}") + context.log.highlight(f"{'dn':<20}: {entries.get('distinguishedName', '')}") + context.log.highlight(f"{'ID':<20}: {entries.get('name', '').split(':')[1]}") + context.log.highlight(f"{'isDeleted':<20}: {entries.get('isDeleted', '')}") + context.log.highlight(f"{'lastKnownParent':<20}: {entries.get('lastKnownParent', '')}") context.log.highlight("") + + number_of_deleted_objects += 1 + + context.log.highlight(f"Found {number_of_deleted_objects} deleted objects") From 44267c807f8070045c9863ea0a9883201c90bbf4 Mon Sep 17 00:00:00 2001 From: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:24:46 -0300 Subject: [PATCH 10/19] Recover the description field of deleted objects Added new code to recover the description (if active) from deleted objects Signed-off-by: Fabrizzio53 <88493503+Fabrizzio53@users.noreply.github.com> --- nxc/modules/tombstone.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nxc/modules/tombstone.py b/nxc/modules/tombstone.py index 8c2ac9e9..2f2bb7da 100644 --- a/nxc/modules/tombstone.py +++ b/nxc/modules/tombstone.py @@ -88,6 +88,7 @@ class NXCModule: if self.id == entries["name"].split(":")[1]: 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', '').split(':')[1]}") context.log.highlight(f"{'isDeleted':<20}: {entries.get('isDeleted', '')}") @@ -158,6 +159,7 @@ class NXCModule: continue 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', '').split(':')[1]}") context.log.highlight(f"{'isDeleted':<20}: {entries.get('isDeleted', '')}") From 1920b3b515d40636b22fb4b3b001fd4817fa32c0 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Tue, 22 Sep 2026 11:33:50 -0400 Subject: [PATCH 11/19] Sort imports --- nxc/modules/tombstone.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/nxc/modules/tombstone.py b/nxc/modules/tombstone.py index 2f2bb7da..aba9143f 100644 --- a/nxc/modules/tombstone.py +++ b/nxc/modules/tombstone.py @@ -1,8 +1,10 @@ -import sys -from nxc.parsers.ldap_results import parse_result_attributes -from impacket.ldap.ldapasn1 import Control -from impacket.ldap.ldap import LDAPSessionError, MODIFY_REPLACE, MODIFY_DELETE +from sys import exit + +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: From 421bd670d20c57b6316599e609dfa98d3fde5eb5 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Tue, 22 Sep 2026 11:34:12 -0400 Subject: [PATCH 12/19] Remove unnecessary init --- nxc/modules/tombstone.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/nxc/modules/tombstone.py b/nxc/modules/tombstone.py index aba9143f..b474b023 100644 --- a/nxc/modules/tombstone.py +++ b/nxc/modules/tombstone.py @@ -15,11 +15,6 @@ class NXCModule: supported_protocols = ["ldap"] category = CATEGORY.ENUMERATION - def __init__(self, context=None, module_options=None): - self.context = context - self.module_options = module_options - self.domains = None - def options(self, context, module_options): """ ACTION: Specify the action to execute, by default it uses the "query" action which only retrieve deleted objects, "restore" recover the object from the "ID" param, delete will delete the object. From 553224031e40329a658fdfd30e325fedd794b1a4 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Tue, 22 Sep 2026 11:35:11 -0400 Subject: [PATCH 13/19] Simplify module args --- nxc/modules/tombstone.py | 24 +++++++++--------------- 1 file changed, 9 insertions(+), 15 deletions(-) diff --git a/nxc/modules/tombstone.py b/nxc/modules/tombstone.py index b474b023..20db4b81 100644 --- a/nxc/modules/tombstone.py +++ b/nxc/modules/tombstone.py @@ -25,22 +25,16 @@ class NXCModule: nxc ldap $DC-IP -u Username -p Password -M tombstone -o ACTION=delete DN="CN=test,OU=Users,DC=test,DC=local" nxc ldap $DC-IP -u Username -p Password -M tombstone -o ACTION=query """ - self.action = module_options.get("ACTION", "query") - self.id = "" - self.deleteDN = "" - if "ACTION" in module_options: - self.action = module_options.get("ACTION") - if "ID" in module_options: - self.id = module_options["ID"] - if "DN" in module_options: - self.deleteDN = module_options["DN"] - if "ACTION" in module_options and self.action.lower() == "restore" and "ID" not in module_options: - context.log.error("ID is necessary when calling tombstone with the restore action") - sys.exit(1) + 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 "ACTION" in module_options and self.action.lower() == "delete" and "DN" not in module_options: - context.log.error("DN is necessary when calling tombstone with the delete action") - sys.exit(1) + 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): self.__sAMAccountName = "" From 3e0cc700e75f70c2af2832864fcd8b9b7099cb55 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Tue, 22 Sep 2026 11:35:48 -0400 Subject: [PATCH 14/19] Simplify action selection --- nxc/modules/tombstone.py | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/nxc/modules/tombstone.py b/nxc/modules/tombstone.py index 20db4b81..cee17975 100644 --- a/nxc/modules/tombstone.py +++ b/nxc/modules/tombstone.py @@ -37,16 +37,11 @@ class NXCModule: return False def on_login(self, context, connection): - self.__sAMAccountName = "" - self.__objectDN = "" - self.__lastKnownParent = "" - self.connection = connection - - if self.action.lower() == "query": - self.query_deleted_objects(context) - elif self.action.lower() == "delete": + if self.action == "query": + self.query_deleted_objects(context, connection) + elif self.action == "delete": self.delete_object(context, connection) - elif self.action.lower() == "restore": + 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)') From 9bfa036013804770404894d820d9ce802f885489 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Tue, 22 Sep 2026 11:36:05 -0400 Subject: [PATCH 15/19] Remove unused import --- nxc/modules/tombstone.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/nxc/modules/tombstone.py b/nxc/modules/tombstone.py index cee17975..8df1dc57 100644 --- a/nxc/modules/tombstone.py +++ b/nxc/modules/tombstone.py @@ -1,5 +1,3 @@ -from sys import exit - from impacket.ldap.ldap import LDAPSessionError, MODIFY_DELETE, MODIFY_REPLACE from impacket.ldap.ldapasn1 import Control, SimplePagedResultsControl From fc3fb874c0a9e6a7ade9d0d457687edacc1300a9 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Tue, 22 Sep 2026 11:45:41 -0400 Subject: [PATCH 16/19] First review pass simplifying logic --- nxc/modules/tombstone.py | 93 ++++++++++++++++------------------------ 1 file changed, 36 insertions(+), 57 deletions(-) diff --git a/nxc/modules/tombstone.py b/nxc/modules/tombstone.py index 8df1dc57..2d423531 100644 --- a/nxc/modules/tombstone.py +++ b/nxc/modules/tombstone.py @@ -44,112 +44,91 @@ class NXCModule: 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): - - # ldap DN for deleted objects - dn = f"CN=Deleted Objects,{connection.baseDN}" - - # LDAP control necessary to show the deleted objects LDAP_SERVER_SHOW_DELETED_OID - show_deleted_control = Control() - show_deleted_control["controlType"] = "1.2.840.113556.1.4.417" - show_deleted_control["criticality"] = True + object_dn = "" + original_dn = "" context.log.highlight(f"Trying to find object with given id {self.id}") context.log.debug("Search Filter=(isDeleted=TRUE)") - resp = self.connection.search(baseDN=dn, searchFilter="(isDeleted=TRUE)", attributes=["*"], searchControls=[show_deleted_control]) - - resp_parsed = parse_result_attributes(resp) context.log.highlight("") - for entries in resp_parsed: - - # This check ensures that we skip the result for the Default container and only get the result from the given ID. - if "container" in entries["objectClass"] and entries["description"] == "Default container for deleted objects": - + for entries in self.search_deleted_objects(connection): + if entries.get("distinguishedName", "").casefold() == f"CN=Deleted Objects,{connection.baseDN}".casefold(): continue - if self.id == entries["name"].split(":")[1]: - + 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', '').split(':')[1]}") + 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("") - self.__objectDN = entries.get("distinguishedName", "") - self.__lastKnownParent = entries.get("lastKnownParent", "") - object_prefix = self.__objectDN.split("\\")[0] - self.__originalDN = f"{object_prefix},{self.__lastKnownParent}" - + object_dn = entries.get("distinguishedName", "") + original_dn = object_dn.rsplit("\\0ADEL:", 1)[0] + "," + entries.get("lastKnownParent", "") break - if self.__originalDN == "": - context.log.highlight(f"The object was not found with id {self.id}.") + 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=self.__objectDN, modifications={"isDeleted": [(MODIFY_DELETE, [])], "distinguishedName": [(MODIFY_REPLACE, [self.__originalDN])]}, controls=[show_deleted_control]) - context.log.highlight(f"Success {self.__originalDN} restored") + 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.deleteDN}") + context.log.highlight(f"Trying to delete {self.delete_dn}") try: - connection.ldap_connection.delete(dn=self.deleteDN) + connection.ldap_connection.delete(dn=self.delete_dn) context.log.highlight("") - context.log.highlight(f'Success, "{self.deleteDN}" deleted') + 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.deleteDN}" {e}') - - def query_deleted_objects(self, context): - - # ldap DN for deleted objects - dn = f"CN=Deleted Objects,{self.connection.baseDN}" - - # LDAP control necessary to show the deleted objects LDAP_SERVER_SHOW_DELETED_OID - show_deleted_control = Control() - show_deleted_control["controlType"] = "1.2.840.113556.1.4.417" - show_deleted_control["criticality"] = True + 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 = self.connection.search(baseDN=dn, searchFilter="(isDeleted=TRUE)", attributes=["*"], searchControls=[show_deleted_control]) - resp_parsed = parse_result_attributes(resp) + resp_parsed = self.search_deleted_objects(connection) - if len(resp_parsed) == 0: + if not resp_parsed: context.log.highlight("Could not find the Deleted Objects container, AD recycle bin might not be active") return False - elif len(resp_parsed) < 2: + 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 - number_of_deleted_objects = 0 context.log.highlight("") for entries in resp_parsed: - - # This check ensures that we skip the result for the Default container and only get results that are valid for us. - if "container" in entries["objectClass"] and entries["description"] == "Default container for deleted objects": - - continue - 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', '').split(':')[1]}") + 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("") - number_of_deleted_objects += 1 - - context.log.highlight(f"Found {number_of_deleted_objects} deleted objects") + context.log.highlight(f"Found {len(resp_parsed)} deleted objects") From f1726b05207563ad6b6ed4ebffaba61d11fbf8f5 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Tue, 22 Sep 2026 11:46:09 -0400 Subject: [PATCH 17/19] Simplify logic --- nxc/modules/tombstone.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/modules/tombstone.py b/nxc/modules/tombstone.py index 2d423531..d3a8bb1c 100644 --- a/nxc/modules/tombstone.py +++ b/nxc/modules/tombstone.py @@ -52,7 +52,7 @@ class NXCModule: connection.search( baseDN=f"CN=Deleted Objects,{connection.baseDN}", searchFilter="(isDeleted=TRUE)", - attributes=["*"], + attributes=[], searchControls=[self.show_deleted_control(), SimplePagedResultsControl(criticality=True, size=1000)], ) ) From e3d86824ee77d4c5bdb0c610f6b9de2ef8b150de Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Tue, 22 Sep 2026 13:07:01 -0400 Subject: [PATCH 18/19] Improve module description --- nxc/modules/tombstone.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/nxc/modules/tombstone.py b/nxc/modules/tombstone.py index d3a8bb1c..e70427fe 100644 --- a/nxc/modules/tombstone.py +++ b/nxc/modules/tombstone.py @@ -9,19 +9,21 @@ class NXCModule: """Module by Fabrizzio: @Fabrizzio53""" name = "tombstone" - description = "Query, restore and delete AD object" + 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, by default it uses the "query" action which only retrieve deleted objects, "restore" recover the object from the "ID" param, delete will delete the object. - 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=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" - nxc ldap $DC-IP -u Username -p Password -M tombstone -o ACTION=query + 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", "") From b9535e67753c66c083e02fb379f527e116f7d7e5 Mon Sep 17 00:00:00 2001 From: Alexander Neff Date: Tue, 22 Sep 2026 13:10:45 -0400 Subject: [PATCH 19/19] Formatting --- nxc/modules/tombstone.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/nxc/modules/tombstone.py b/nxc/modules/tombstone.py index e70427fe..3c19c575 100644 --- a/nxc/modules/tombstone.py +++ b/nxc/modules/tombstone.py @@ -92,7 +92,6 @@ class NXCModule: 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 @@ -104,7 +103,6 @@ class NXCModule: 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}')