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 1/9] 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 0000000000..735e5f416e --- /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 2/9] 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 735e5f416e..acb379cc40 100644 --- a/nxc/modules/gravedigger.py +++ b/nxc/modules/tombstone.py @@ -10,7 +10,7 @@ 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 @@ def options(self, context, module_options): 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 @@ def options(self, context, module_options): 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 3/9] 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 acb379cc40..37e79fad74 100644 --- a/nxc/modules/tombstone.py +++ b/nxc/modules/tombstone.py @@ -125,6 +125,11 @@ def restore_deleted_object(self, context, connection): 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 @@ def restore_deleted_object(self, context, connection): 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 4/9] 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 7d03207edd..2492626f79 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 5/9] 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 37e79fad74..4caf756205 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 @@ 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 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 @@ def options(self, context, 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 tombstone with the restore action") @@ -92,17 +90,11 @@ def restore_deleted_object(self, context, connection): 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 @@ def restore_deleted_object(self, context, connection): 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) + # 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 - 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 @@ def query_deleted_objects(self, context, connection): 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 @@ def query_deleted_objects(self, context, connection): 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 6/9] 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 4caf756205..3e67dc04d9 100644 --- a/nxc/modules/tombstone.py +++ b/nxc/modules/tombstone.py @@ -43,7 +43,6 @@ def options(self, context, 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: context.log.error("ID is necessary when calling tombstone with the restore action") sys.exit(1) @@ -62,7 +61,6 @@ def restore_deleted_object(self, context, connection): # 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 @@ def restore_deleted_object(self, context, connection): 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 @@ def restore_deleted_object(self, context, connection): 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 @@ def restore_deleted_object(self, context, connection): 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 @@ def query_deleted_objects(self, context, connection): 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 @@ def query_deleted_objects(self, context, connection): # 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 7/9] 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 | 176 ++++++++++++++------------------------- 1 file changed, 63 insertions(+), 113 deletions(-) diff --git a/nxc/modules/tombstone.py b/nxc/modules/tombstone.py index 3e67dc04d9..50589ee885 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 @@ def options(self, context, module_options): 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 @@ def options(self, context, 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": + 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 @@ 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" @@ -68,69 +77,45 @@ def restore_deleted_object(self, context, connection): 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 + 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 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 @@ def delete_object(self, context, connection): except LDAPSessionError as e: context.log.highlight("") - context.log.highlight(f'Error when trying to delete "{self.deleteDN}" {e}') - - return + context.log.fail(f'Error when trying to delete "{self.deleteDN}" {e}') - 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 8/9] 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 50589ee885..d1cc06dbbc 100644 --- a/nxc/modules/tombstone.py +++ b/nxc/modules/tombstone.py @@ -25,7 +25,7 @@ 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" + 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 9/9] 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 d1cc06dbbc..8c2ac9e920 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 @@ def options(self, context, module_options): 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 @@ def restore_deleted_object(self, context, connection): # 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 @@ def delete_object(self, context, connection): 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 @@ def query_deleted_objects(self, context): 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")