def server_ssh_connect(self): """ Connect to the remote SMTP server over SSH and configure port forwarding with :py:class:`.SSHTCPForwarder` for tunneling SMTP traffic. :return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants. """ server = smoke_zephyr.utilities.parse_server(self.config['ssh_server'], 22) username = self.config['ssh_username'] password = self.config['ssh_password'] remote_server = smoke_zephyr.utilities.parse_server(self.config['smtp_server'], 25) try: self._ssh_forwarder = SSHTCPForwarder( server, username, password, remote_server, private_key=self.config.get('ssh_preferred_key'), missing_host_key_policy=ssh_host_key.MissingHostKeyPolicy(self.application) ) self._ssh_forwarder.start() except errors.KingPhisherAbortError as error: self.logger.info("ssh connection aborted ({0})".format(error.message)) except paramiko.AuthenticationException: self.logger.warning('failed to authenticate to the remote ssh server') return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED except paramiko.SSHException as error: self.logger.warning("failed with ssh exception '{0}'".format(error.message)) except Exception: self.logger.warning('failed to connect to the remote ssh server', exc_info=True) else: self.smtp_server = self._ssh_forwarder.local_server return ConnectionErrorReason.SUCCESS return ConnectionErrorReason.ERROR_UNKNOWN
def server_ssh_connect(self): """ Connect to the remote SMTP server over SSH and configure port forwarding with :py:class:`.SSHTCPForwarder` for tunneling SMTP traffic. :return: The connection status. :rtype: bool """ server = utilities.server_parse(self.config['ssh_server'], 22) username = self.config['ssh_username'] password = self.config['ssh_password'] remote_server = utilities.server_parse(self.config['smtp_server'], 25) local_port = random.randint(2000, 6000) try: self.ssh_forwarder = SSHTCPForwarder( server, username, password, local_port, remote_server, preferred_private_key=self.config.get('ssh_preferred_key')) self.ssh_forwarder.start() time.sleep(0.5) except: self.logger.warning('failed to connect to remote ssh server') return False self.smtp_server = ('localhost', local_port) return True
def server_ssh_connect(self): """ Connect to the remote SMTP server over SSH and configure port forwarding with :py:class:`.SSHTCPForwarder` for tunneling SMTP traffic. :return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants. """ server = parse_server(self.config['ssh_server'], 22) username = self.config['ssh_username'] password = self.config['ssh_password'] remote_server = parse_server(self.config['smtp_server'], 25) try: self._ssh_forwarder = SSHTCPForwarder( server, username, password, remote_server, preferred_private_key=self.config.get('ssh_preferred_key')) self._ssh_forwarder.start() except paramiko.AuthenticationException: self.logger.warning( 'failed to authenticate to the remote ssh server') return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED except Exception: self.logger.warning('failed to connect to the remote ssh server', exc_info=True) return ConnectionErrorReason.ERROR_UNKNOWN self.logger.info( "started ssh port forwarding to the remote smtp server ({0})". format(str(self._ssh_forwarder))) self.smtp_server = self._ssh_forwarder.local_server return ConnectionErrorReason.SUCCESS
def _create_ssh_forwarder(self, server, username, password): """ Create and set the :py:attr:`~.KingPhisherClientApplication._ssh_forwarder` attribute. :param tuple server: The server information as a host and port tuple. :param str username: The username to authenticate to the SSH server with. :param str password: The password to authenticate to the SSH server with. :rtype: int :return: The local port that is forwarded to the remote server or None if the connection failed. """ active_window = self.get_active_window() title_ssh_error = 'Failed To Connect To The SSH Service' server_remote_port = self.config['server_remote_port'] try: self._ssh_forwarder = SSHTCPForwarder( server, username, password, ('127.0.0.1', server_remote_port), private_key=self.config.get('ssh_preferred_key'), missing_host_key_policy=ssh_host_key.MissingHostKeyPolicy( self)) self._ssh_forwarder.start() except errors.KingPhisherAbortError as error: self.logger.info("ssh connection aborted ({0})".format( error.message)) except paramiko.PasswordRequiredException: gui_utilities.show_dialog_error( title_ssh_error, active_window, 'The specified SSH key requires a password.') except paramiko.AuthenticationException: self.logger.warning( 'failed to authenticate to the remote ssh server') gui_utilities.show_dialog_error( title_ssh_error, active_window, 'The server responded that the credentials are invalid.') except paramiko.SSHException as error: self.logger.warning("failed with ssh exception '{0}'".format( error.args[0])) except socket.error as error: gui_utilities.show_dialog_exc_socket_error(error, active_window, title=title_ssh_error) except Exception as error: self.logger.warning('failed to connect to the remote ssh server', exc_info=True) gui_utilities.show_dialog_error( title_ssh_error, active_window, "An {0}.{1} error occurred.".format(error.__class__.__module__, error.__class__.__name__)) else: return self._ssh_forwarder.local_server self.emit('server-disconnected') return
def server_ssh_connect(self): """ Connect to the remote SMTP server over SSH and configure port forwarding with :py:class:`.SSHTCPForwarder` for tunneling SMTP traffic. :return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants. """ server = parse_server(self.config['ssh_server'], 22) username = self.config['ssh_username'] password = self.config['ssh_password'] remote_server = parse_server(self.config['smtp_server'], 25) try: self._ssh_forwarder = SSHTCPForwarder( server, username, password, remote_server, private_key=self.config.get('ssh_preferred_key'), missing_host_key_policy=ssh_host_key.MissingHostKeyPolicy(self.application) ) self._ssh_forwarder.start() except errors.KingPhisherAbortError as error: self.logger.info("ssh connection aborted ({0})".format(error.message)) except paramiko.AuthenticationException: self.logger.warning('failed to authenticate to the remote ssh server') return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED except paramiko.SSHException as error: self.logger.warning("failed with ssh exception '{0}'".format(error.message)) except Exception: self.logger.warning('failed to connect to the remote ssh server', exc_info=True) else: self.smtp_server = self._ssh_forwarder.local_server return ConnectionErrorReason.SUCCESS return ConnectionErrorReason.ERROR_UNKNOWN
def _create_ssh_forwarder(self, server, username, password): """ Create and set the :py:attr:`~.KingPhisherClientApplication._ssh_forwarder` attribute. :param tuple server: The server information as a host and port tuple. :param str username: The username to authenticate to the SSH server with. :param str password: The password to authenticate to the SSH server with. :rtype: int :return: The local port that is forwarded to the remote server or None if the connection failed. """ active_window = self.get_active_window() title_ssh_error = 'Failed To Connect To The SSH Service' server_remote_port = self.config['server_remote_port'] local_port = random.randint(2000, 6000) try: self._ssh_forwarder = SSHTCPForwarder(server, username, password, local_port, ('127.0.0.1', server_remote_port), preferred_private_key=self.config['ssh_preferred_key']) self._ssh_forwarder.start() time.sleep(0.5) self.logger.info('started ssh port forwarding') except paramiko.AuthenticationException: self.logger.warning('failed to authenticate to the remote ssh server') gui_utilities.show_dialog_error(title_ssh_error, active_window, 'The server responded that the credentials are invalid.') except socket.error as error: gui_utilities.show_dialog_exc_socket_error(error, active_window, title=title_ssh_error) except Exception as error: self.logger.warning('failed to connect to the remote ssh server', exc_info=True) gui_utilities.show_dialog_error(title_ssh_error, active_window, "An {0}.{1} error occurred.".format(error.__class__.__module__, error.__class__.__name__)) else: return local_port self.server_disconnect() return
def _create_ssh_forwarder(self, server, username, password): """ Create and set the :py:attr:`~.KingPhisherClient._ssh_forwarder` attribute. :param tuple server: The server information as a host and port tuple. :param str username: The username to authenticate to the SSH server with. :param str password: The password to authenticate to the SSH server with. :rtype: int :return: The local port that is forwarded to the remote server or None if the connection failed. """ title_ssh_error = 'Failed To Connect To The SSH Service' server_remote_port = self.config['server_remote_port'] local_port = random.randint(2000, 6000) try: self._ssh_forwarder = SSHTCPForwarder( server, username, password, local_port, ('127.0.0.1', server_remote_port), preferred_private_key=self.config['ssh_preferred_key']) self._ssh_forwarder.start() time.sleep(0.5) self.logger.info('started ssh port forwarding') except paramiko.AuthenticationException: self.logger.warning( 'failed to authenticate to the remote ssh server') gui_utilities.show_dialog_error( title_ssh_error, self, 'The server responded that the credentials are invalid.') except socket.error as error: gui_utilities.show_dialog_exc_socket_error(error, self, title=title_ssh_error) except Exception as error: self.logger.warning('failed to connect to the remote ssh server', exc_info=True) gui_utilities.show_dialog_error( title_ssh_error, self, "An {0}.{1} error occurred.".format(error.__class__.__module__, error.__class__.__name__)) else: return local_port self.server_disconnect() return
def server_ssh_connect(self): server = utilities.server_parse(self.config['ssh_server'], 22) username = self.config['ssh_username'] password = self.config['ssh_password'] remote_server = utilities.server_parse(self.config['smtp_server'], 25) local_port = random.randint(2000, 6000) try: self.ssh_forwarder = SSHTCPForwarder(server, username, password, local_port, remote_server, preferred_private_key=self.config.get('ssh_preferred_key')) self.ssh_forwarder.start() time.sleep(0.5) except: self.logger.warning('failed to connect to remote ssh server') return False self.smtp_server = ('localhost', local_port) return True
def _create_ssh_forwarder(self, server, username, password): """ Create and set the :py:attr:`~.KingPhisherClientApplication._ssh_forwarder` attribute. :param tuple server: The server information as a host and port tuple. :param str username: The username to authenticate to the SSH server with. :param str password: The password to authenticate to the SSH server with. :rtype: int :return: The local port that is forwarded to the remote server or None if the connection failed. """ active_window = self.get_active_window() title_ssh_error = 'Failed To Connect To The SSH Service' server_remote_port = self.config['server_remote_port'] try: self._ssh_forwarder = SSHTCPForwarder( server, username, password, ('127.0.0.1', server_remote_port), private_key=self.config.get('ssh_preferred_key'), missing_host_key_policy=ssh_host_key.MissingHostKeyPolicy(self) ) self._ssh_forwarder.start() except errors.KingPhisherAbortError as error: self.logger.info("ssh connection aborted ({0})".format(error.message)) except paramiko.PasswordRequiredException: gui_utilities.show_dialog_error(title_ssh_error, active_window, 'The specified SSH key requires a password.') except paramiko.AuthenticationException: self.logger.warning('failed to authenticate to the remote ssh server') gui_utilities.show_dialog_error(title_ssh_error, active_window, 'The server responded that the credentials are invalid.') except paramiko.SSHException as error: self.logger.warning("failed with ssh exception '{0}'".format(error.args[0])) except socket.error as error: gui_utilities.show_dialog_exc_socket_error(error, active_window, title=title_ssh_error) except Exception as error: self.logger.warning('failed to connect to the remote ssh server', exc_info=True) gui_utilities.show_dialog_error(title_ssh_error, active_window, "An {0}.{1} error occurred.".format(error.__class__.__module__, error.__class__.__name__)) else: return self._ssh_forwarder.local_server self.emit('server-disconnected') return
def server_ssh_connect(self): """ Connect to the remote SMTP server over SSH and configure port forwarding with :py:class:`.SSHTCPForwarder` for tunneling SMTP traffic. :return: The connection status. :rtype: bool """ server = parse_server(self.config['ssh_server'], 22) username = self.config['ssh_username'] password = self.config['ssh_password'] remote_server = parse_server(self.config['smtp_server'], 25) local_port = random.randint(2000, 6000) try: self._ssh_forwarder = SSHTCPForwarder(server, username, password, local_port, remote_server, preferred_private_key=self.config.get('ssh_preferred_key')) self._ssh_forwarder.start() time.sleep(0.5) except Exception: self.logger.warning('failed to connect to remote ssh server', exc_info=True) return False self.smtp_server = ('localhost', local_port) return True
class KingPhisherClientApplication(_Gtk_Application): """ This is the top level King Phisher client object. It contains the custom GObject signals, keeps all the GUI references, and manages the RPC client object. This is also the parent window for most GTK objects. :GObject Signals: :ref:`gobject-signals-application-label` """ # pylint: disable=too-many-public-methods __gsignals__ = { 'campaign-changed': (GObject.SIGNAL_RUN_FIRST, None, (str,)), 'campaign-created': (GObject.SIGNAL_RUN_FIRST, None, (str,)), 'campaign-delete': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, (str,)), 'campaign-set': (GObject.SIGNAL_RUN_FIRST, None, (str,)), 'config-load': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, (bool,)), 'config-save': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()), 'credential-delete': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, (object,)), 'exit': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()), 'exit-confirm': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()), 'message-delete': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, (object,)), 'message-sent': (GObject.SIGNAL_RUN_FIRST, None, (str, str)), 'reload-css-style': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()), 'rpc-cache-clear': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()), 'server-connected': (GObject.SIGNAL_RUN_FIRST, None, ()), 'server-disconnected': (GObject.SIGNAL_RUN_FIRST, None, ()), 'sftp-client-start': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()), 'visit-delete': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, (object,)), } def __init__(self, config_file=None, use_plugins=True, use_style=True): super(KingPhisherClientApplication, self).__init__() if use_style: self._theme_file = 'theme.css' else: self._theme_file = DISABLED self.logger = logging.getLogger('KingPhisher.Client.Application') # log version information for debugging purposes self.logger.debug("gi.repository GLib version: {0}".format('.'.join(map(str, GLib.glib_version)))) self.logger.debug("gi.repository GObject version: {0}".format('.'.join(map(str, GObject.pygobject_version)))) self.logger.debug("gi.repository Gtk version: {0}.{1}.{2}".format(Gtk.get_major_version(), Gtk.get_minor_version(), Gtk.get_micro_version())) if rpc_terminal.has_vte: self.logger.debug("gi.repository VTE version: {0}".format(rpc_terminal.Vte._version)) if graphs.has_matplotlib: self.logger.debug("matplotlib version: {0}".format(graphs.matplotlib.__version__)) self.set_property('application-id', 'org.king-phisher.client') self.set_property('register-session', True) self.config_file = config_file or os.path.join(USER_DATA_PATH, 'config.json') """The file containing the King Phisher client configuration.""" if not os.path.isfile(self.config_file): self._create_config() self.config = None """The primary King Phisher client configuration.""" self.main_window = None """The primary top-level :py:class:`~.MainAppWindow` instance.""" self.rpc = None """The :py:class:`~.KingPhisherRPCClient` instance for the application.""" self._ssh_forwarder = None """The SSH forwarder responsible for tunneling RPC communications.""" self.style_provider = None try: self.emit('config-load', True) except IOError: self.logger.critical('failed to load the client configuration') raise self.connect('window-added', self.signal_window_added) self.actions = {} self._create_actions() if not use_plugins: self.logger.info('disabling all plugins') self.config['plugins.enabled'] = [] self.plugin_manager = plugins.ClientPluginManager( [os.path.join(USER_DATA_PATH, 'plugins'), find.find_data_directory('plugins')], self ) if use_plugins: self.plugin_manager.load_all() def _create_actions(self): action = Gio.SimpleAction.new('emit-application-signal', GLib.VariantType.new('s')) action.connect('activate', self.action_emit_application_signal) accelerators = ( ('<Control><Shift>F1', 'rpc-cache-clear'), ('<Control><Shift>F2', 'config-save'), ('<Control><Shift>F12', 'reload-css-style') ) for key, signal_name in accelerators: if Gtk.check_version(3, 14, 0): self.add_accelerator(key, 'win.emit-application-signal', GLib.Variant.new_string(signal_name)) else: self.set_accels_for_action("win.emit-application-signal('{0}')".format(signal_name), (key,)) self.actions['emit-application-signal'] = action self.add_action(action) def _create_ssh_forwarder(self, server, username, password): """ Create and set the :py:attr:`~.KingPhisherClientApplication._ssh_forwarder` attribute. :param tuple server: The server information as a host and port tuple. :param str username: The username to authenticate to the SSH server with. :param str password: The password to authenticate to the SSH server with. :rtype: int :return: The local port that is forwarded to the remote server or None if the connection failed. """ active_window = self.get_active_window() title_ssh_error = 'Failed To Connect To The SSH Service' server_remote_port = self.config['server_remote_port'] try: self._ssh_forwarder = SSHTCPForwarder( server, username, password, ('127.0.0.1', server_remote_port), private_key=self.config.get('ssh_preferred_key'), missing_host_key_policy=ssh_host_key.MissingHostKeyPolicy(self) ) self._ssh_forwarder.start() except errors.KingPhisherAbortError as error: self.logger.info("ssh connection aborted ({0})".format(error.message)) except paramiko.PasswordRequiredException: gui_utilities.show_dialog_error(title_ssh_error, active_window, 'The specified SSH key requires a password.') except paramiko.AuthenticationException: self.logger.warning('failed to authenticate to the remote ssh server') gui_utilities.show_dialog_error(title_ssh_error, active_window, 'The server responded that the credentials are invalid.') except paramiko.SSHException as error: self.logger.warning("failed with ssh exception '{0}'".format(error.args[0])) except socket.error as error: gui_utilities.show_dialog_exc_socket_error(error, active_window, title=title_ssh_error) except Exception as error: self.logger.warning('failed to connect to the remote ssh server', exc_info=True) gui_utilities.show_dialog_error(title_ssh_error, active_window, "An {0}.{1} error occurred.".format(error.__class__.__module__, error.__class__.__name__)) else: return self._ssh_forwarder.local_server self.emit('server-disconnected') return def _create_config(self): config_dir = os.path.dirname(self.config_file) if not os.path.isdir(config_dir): self.logger.debug('creating the user configuration directory') os.makedirs(config_dir) # move the pre 1.0.0 config file if it exists old_path = os.path.expanduser('~/.king_phisher.json') if os.path.isfile(old_path) and os.access(old_path, os.R_OK): self.logger.debug('moving the old config file to the new location') os.rename(old_path, self.config_file) else: client_template = find.find_data_file('client_config.json') shutil.copy(client_template, self.config_file) def campaign_configure(self): assistant = assistants.CampaignAssistant(self, campaign_id=self.config['campaign_id']) assistant.assistant.set_transient_for(self.get_active_window()) assistant.assistant.set_modal(True) # do this to keep a reference to prevent garbage collection attr_name = '_tmpref_campaign_assistant' setattr(self, attr_name, assistant) assistant.assistant.connect('destroy', lambda widget: delattr(self, attr_name)) assistant.interact() def do_campaign_delete(self, campaign_id): """ Delete the campaign on the server. A confirmation dialog will be displayed before the operation is performed. If the campaign is deleted and a new campaign is not selected with :py:meth:`.show_campaign_selection`, the client will quit. """ self.rpc('db/table/delete', 'campaigns', campaign_id) if campaign_id == self.config['campaign_id'] and not self.show_campaign_selection(): gui_utilities.show_dialog_error('Now Exiting', self.get_active_window(), 'A campaign must be selected.') self.quit() def do_credential_delete(self, row_ids): if len(row_ids) == 1: self.rpc('db/table/delete', 'credentials', row_ids[0]) else: self.rpc('db/table/delete/multi', 'credentials', row_ids) def do_message_delete(self, row_ids): if len(row_ids) == 1: self.rpc('db/table/delete', 'messages', row_ids[0]) else: self.rpc('db/table/delete/multi', 'messages', row_ids) def do_visit_delete(self, row_ids): if len(row_ids) == 1: self.rpc('db/table/delete', 'visits', row_ids[0]) else: self.rpc('db/table/delete/multi', 'visits', row_ids) def campaign_rename(self): """ Show a dialog prompting the user to for the a new name to assign to the currently selected campaign. """ campaign = self.rpc.remote_table_row('campaigns', self.config['campaign_id']) prompt = dialogs.TextEntryDialog.build_prompt(self, 'Rename Campaign', 'Enter the new campaign name:', campaign.name) response = prompt.interact() if response == None or response == campaign.name: return self.rpc('db/table/set', 'campaigns', self.config['campaign_id'], 'name', response) gui_utilities.show_dialog_info('Campaign Name Updated', self.get_active_window(), 'The campaign name was successfully changed.') def exception_hook(self, exc_type, exc_value, exc_traceback): if isinstance(exc_value, KeyboardInterrupt): self.logger.warning('received a KeyboardInterrupt exception') return exc_info = (exc_type, exc_value, exc_traceback) error_uid = str(uuid.uuid4()) self.logger.error("error uid: {0} an unhandled exception was thrown".format(error_uid), exc_info=exc_info) dialogs.ExceptionDialog(self, exc_info=exc_info, error_uid=error_uid).interact() def quit(self, optional=False): """ Quit the client and perform any necessary clean up operations. If *optional* is False then the exit-confirm signal will not be sent and there will not be any opportunities for the client to cancel the operation. :param bool optional: Whether the quit is request is optional or not. """ self.emit('exit-confirm' if optional else 'exit') def action_emit_application_signal(self, _, signal_name): signal_name = signal_name.get_string() self.logger.debug('action emit-application-signal invoked for ' + signal_name) self.emit(signal_name) def do_activate(self): Gtk.Application.do_activate(self) sys.excepthook = self.exception_hook # reset theme settings to defaults so we have a standard baseline settings = Gtk.Settings.get_default() if settings.get_property('gtk-theme-name') != GTK3_DEFAULT_THEME: self.logger.debug('resetting the gtk-theme-name property to it\'s default value') settings.set_property('gtk-theme-name', GTK3_DEFAULT_THEME) if settings.get_property('gtk-icon-theme-name') != GTK3_DEFAULT_THEME: self.logger.debug('resetting the gtk-icon-theme-name property to it\'s default value') settings.set_property('gtk-icon-theme-name', GTK3_DEFAULT_THEME) settings.set_property('gtk-application-prefer-dark-theme', False) # load a custom css theme file if one is available theme_file = self.theme_file if theme_file: self.style_provider = self.load_style_css(theme_file) elif theme_file is DISABLED: self.logger.debug('no css theme file will be loaded (styling has been disabled)') else: self.logger.debug('no css theme file will be loaded (file not found)') # create and show the main window self.main_window = main.MainAppWindow(self.config, self) self.main_window.set_position(Gtk.WindowPosition.CENTER) self.main_window.show() for name in list(self.config['plugins.enabled']): try: self.plugin_manager.load(name) self.plugin_manager.enable(name) except Exception: self.config['plugins.enabled'].remove(name) gui_utilities.show_dialog_error( 'Failed To Enable Plugin', self.main_window, "Plugin '{0}' could not be enabled.".format(name) ) def do_campaign_set(self, campaign_id): self.logger.info("campaign set to {0} (id: {1})".format(self.config['campaign_name'], self.config['campaign_id'])) self.emit('rpc-cache-clear') def do_config_save(self): self.logger.info('writing the client configuration to disk') config = copy.copy(self.config) for key in self.config.keys(): if 'password' in key or key == 'server_config': del config[key] with open(os.path.expanduser(self.config_file), 'w') as config_file_h: json_ex.dump(config, config_file_h) def do_exit(self): self.plugin_manager.shutdown() self.main_window.hide() gui_utilities.gtk_widget_destroy_children(self.main_window) gui_utilities.gtk_sync() self.emit('server-disconnected') self.main_window.destroy() return def do_exit_confirm(self): self.emit('exit') def do_reload_css_style(self): if self.style_provider: Gtk.StyleContext.remove_provider_for_screen( Gdk.Screen.get_default(), self.style_provider ) self.style_provider = None theme_file = self.theme_file if theme_file: self.style_provider = self.load_style_css(theme_file) def do_rpc_cache_clear(self): if self.rpc: self.rpc.cache_clear() def do_server_connected(self): self.load_server_config() campaign_id = self.config.get('campaign_id') if not campaign_id: if not self.show_campaign_selection(): self.logger.debug('no campaign selected, disconnecting and exiting') self.emit('exit') return True campaign_info = self.rpc.remote_table_row('campaigns', self.config['campaign_id'], cache=True) if campaign_info is None: if not self.show_campaign_selection(): self.logger.debug('no campaign selected, disconnecting and exiting') self.emit('exit') return True campaign_info = self.rpc.remote_table_row('campaigns', self.config['campaign_id'], cache=True, refresh=True) self.config['campaign_name'] = campaign_info.name self.emit('campaign-set', self.config['campaign_id']) return def do_shutdown(self): Gtk.Application.do_shutdown(self) sys.excepthook = sys.__excepthook__ self.emit('config-save') @property def theme_file(self): if not self._theme_file: return DISABLED return find.find_data_file(os.path.join('style', self._theme_file)) def do_config_load(self, load_defaults): """ Load the client configuration from disk and set the :py:attr:`~.KingPhisherClientApplication.config` attribute. :param bool load_defaults: Load missing options from the template configuration file. """ self.logger.info('loading the config from disk') client_template = find.find_data_file('client_config.json') config_file = os.path.expanduser(self.config_file) with open(config_file, 'r') as tmp_file: self.config = json_ex.load(tmp_file) if load_defaults: with open(client_template, 'r') as tmp_file: client_template = json_ex.load(tmp_file) for key, value in client_template.items(): if not key in self.config: self.config[key] = value def merge_config(self, config_file, strict=True): """ Merge the configuration information from the specified configuration file. Only keys which exist in the currently loaded configuration are copied over while non-existent keys are skipped. The contents of the new configuration overwrites the existing. :param bool strict: Do not try remove trailing commas from the JSON data. :param str config_file: The path to the configuration file to merge. """ with open(config_file, 'r') as tmp_file: config = json_ex.load(tmp_file, strict=strict) if not isinstance(config, dict): self.logger.error("can not merge configuration file: {0} (invalid format)".format(config_file)) return self.logger.debug('merging configuration information from source file: ' + config_file) for key, value in config.items(): if not key in self.config: self.logger.warning("skipped merging non-existent configuration key {0}".format(key)) continue self.config[key] = value return def load_server_config(self): """Load the necessary values from the server's configuration.""" self.config['server_config'] = self.rpc('config/get', ['server.require_id', 'server.secret_id', 'server.tracking_image', 'server.web_root']) return def load_style_css(self, css_file): self.logger.debug('loading style from css file: ' + css_file) css_file = Gio.File.new_for_path(css_file) style_provider = Gtk.CssProvider() style_provider.connect('parsing-error', self.signal_css_provider_parsing_error) try: style_provider.load_from_file(css_file) except GLib.Error: # pylint: disable=catching-non-exception self.logger.error('there was an error parsing the css file, it will not be applied as a style provider') return None Gtk.StyleContext.add_provider_for_screen( Gdk.Screen.get_default(), style_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION ) return style_provider def server_connect(self, username, password, otp=None): # pylint: disable=too-many-locals server_version_info = None title_rpc_error = 'Failed To Connect To The King Phisher RPC Service' active_window = self.get_active_window() server = parse_server(self.config['server'], 22) if ipaddress.is_loopback(server[0]): local_server = ('localhost', self.config['server_remote_port']) self.logger.info("connecting to local king phisher instance") else: local_server = self._create_ssh_forwarder(server, username, password) if not local_server: return False, ConnectionErrorReason.ERROR_PORT_FORWARD rpc = client_rpc.KingPhisherRPCClient(local_server, use_ssl=self.config.get('server_use_ssl')) if self.config.get('rpc.serializer'): try: rpc.set_serializer(self.config['rpc.serializer']) except ValueError as error: self.logger.error("failed to set the rpc serializer, error: '{0}'".format(error.message)) generic_message = 'Can not contact the RPC HTTP service, ensure that the ' generic_message += "King Phisher Server is currently running on port {0}.".format(int(self.config['server_remote_port'])) connection_failed = True try: server_version_info = rpc('version') assert server_version_info is not None except AdvancedHTTPServerRPCError as error: self.logger.warning('failed to connect to the remote rpc service due to http status: ' + str(error.status)) gui_utilities.show_dialog_error(title_rpc_error, active_window, "The server responded with HTTP status: {0}.".format(str(error.status))) except BadStatusLine as error: self.logger.warning('failed to connect to the remote rpc service due to http bad status line: ' + error.line) gui_utilities.show_dialog_error(title_rpc_error, active_window, generic_message) except socket.error as error: gui_utilities.show_dialog_exc_socket_error(error, active_window) except ssl.CertificateError as error: self.logger.warning('failed to connect to the remote rpc service with a https certificate error: ' + error.message) gui_utilities.show_dialog_error(title_rpc_error, active_window, 'The server presented an invalid SSL certificate.') except Exception: self.logger.warning('failed to connect to the remote rpc service', exc_info=True) gui_utilities.show_dialog_error(title_rpc_error, active_window, generic_message) else: connection_failed = False if connection_failed: self.emit('server-disconnected') return False, ConnectionErrorReason.ERROR_CONNECTION server_rpc_api_version = server_version_info.get('rpc_api_version', -1) if isinstance(server_rpc_api_version, int): # compatibility with pre-0.2.0 version server_rpc_api_version = (server_rpc_api_version, 0) self.logger.info( "successfully connected to the king phisher server (version: {0} rpc api version: {1}.{2})".format( server_version_info['version'], server_rpc_api_version[0], server_rpc_api_version[1] ) ) error_text = None if server_rpc_api_version[0] < version.rpc_api_version.major or (server_rpc_api_version[0] == version.rpc_api_version.major and server_rpc_api_version[1] < version.rpc_api_version.minor): error_text = 'The server is running an old and incompatible version.' error_text += '\nPlease update the remote server installation.' elif server_rpc_api_version[0] > version.rpc_api_version.major: error_text = 'The client is running an old and incompatible version.' error_text += '\nPlease update the local client installation.' if error_text: gui_utilities.show_dialog_error('The RPC API Versions Are Incompatible', active_window, error_text) self.emit('server-disconnected') return False, ConnectionErrorReason.ERROR_INCOMPATIBLE_VERSIONS login_result, login_reason = rpc.login(username, password, otp) if not login_result: self.logger.warning('failed to authenticate to the remote king phisher service, reason: ' + login_reason) self.emit('server-disconnected') return False, login_reason rpc.username = username self.logger.debug('successfully authenticated to the remote king phisher service') self.rpc = rpc self.emit('server-connected') return True, ConnectionErrorReason.SUCCESS def do_server_disconnected(self): """Clean up the SSH TCP connections and disconnect from the server.""" if self.rpc is not None: try: self.rpc('logout') except AdvancedHTTPServerRPCError as error: self.logger.warning('failed to logout, rpc error: ' + error.message) self.rpc = None if self._ssh_forwarder: self._ssh_forwarder.stop() self._ssh_forwarder = None return def show_campaign_graph(self, graph_name): """ Create a new :py:class:`.CampaignGraph` instance and make it into a window. *graph_name* must be the name of a valid, exported graph provider. :param str graph_name: The name of the graph to make a window of. """ cls = graphs.get_graph(graph_name) graph_inst = cls(self, style_context=self.style_context) graph_inst.load_graph() window = graph_inst.make_window() window.show() def show_campaign_selection(self): """ Display the campaign selection dialog in a new :py:class:`.CampaignSelectionDialog` instance. :return: Whether or not a campaign was selected. :rtype: bool """ dialog = dialogs.CampaignSelectionDialog(self) return dialog.interact() == Gtk.ResponseType.APPLY def show_preferences(self): """ Display a :py:class:`.dialogs.configuration.ConfigurationDialog` instance and saves the configuration to disk if cancel is not selected. """ dialog = dialogs.ConfigurationDialog(self) if dialog.interact() != Gtk.ResponseType.CANCEL: self.emit('config-save') def signal_css_provider_parsing_error(self, css_provider, css_section, gerror): file_path = css_section.get_file() if file_path: file_path = file_path.get_path() else: file_path = '[ unknown file ]' self.logger.error("css parser error ({0}) in {1}:{2}".format(gerror.message, file_path, css_section.get_start_line())) return def signal_window_added(self, _, window): for action in self.actions.values(): window.add_action(action) def do_sftp_client_start(self): """ Start the client's preferred sftp client application in a new process. """ if not self.config['sftp_client']: gui_utilities.show_dialog_error('Invalid SFTP Configuration', self.get_active_window(), 'An SFTP client is not configured.\nOne can be configured in the Client Preferences.') return False command = str(self.config['sftp_client']) sftp_bin = shlex.split(command)[0] if not which(sftp_bin): self.logger.error('could not locate the sftp binary: ' + sftp_bin) gui_utilities.show_dialog_error('Invalid SFTP Configuration', self.get_active_window(), "Could not find the SFTP binary '{0}'".format(sftp_bin)) return False try: command = command.format( server=self.config['server'], username=self.config['server_username'], web_root=self.config['server_config']['server.web_root'] ) except KeyError as error: self.logger.error("key error while parsing the sftp command for token: {0}".format(error.args[0])) gui_utilities.show_dialog_error('Invalid SFTP Configuration', self.get_active_window(), "Invalid token '{0}' in the SFTP command.".format(error.args[0])) return False self.logger.debug("starting sftp client command: {0}".format(command)) utilities.start_process(command, wait=False) return def stop_remote_service(self): """ Stop the remote King Phisher server. This will request that the server stop processing new requests and exit. This will display a confirmation dialog before performing the operation. If the remote service is stopped, the client will quit. """ active_window = self.get_active_window() if not gui_utilities.show_dialog_yes_no('Stop The Remote King Phisher Service?', active_window, 'This will stop the remote King Phisher service and\nnew incoming requests will not be processed.'): return self.rpc('shutdown') self.logger.info('the remote king phisher service has been stopped') gui_utilities.show_dialog_error('Now Exiting', active_window, 'The remote service has been stopped.') self.quit() return @property def style_context(self): window = self.get_active_window() or self.main_window if window is None: return None return window.get_style_context()
class MailSenderThread(threading.Thread): """ The King Phisher threaded email message sender. This object manages the sending of emails for campaigns and supports pausing the sending of messages which can later be resumed by unpausing. This object reports its information to the GUI through an optional :py:class:`.MailSenderSendTab` instance, these two objects are very interdependent. """ def __init__(self, application, target_file, rpc, tab=None): """ :param application: The GTK application that the thread is associated with. :type application: :py:class:`.KingPhisherClientApplication` :param str target_file: The CSV formatted file to read message targets from. :param tab: The GUI tab to report information to. :type tab: :py:class:`.MailSenderSendTab` :param rpc: The client's connected RPC instance. :type rpc: :py:class:`.KingPhisherRPCClient` """ super(MailSenderThread, self).__init__() self.daemon = True self.logger = logging.getLogger('KingPhisher.Client.' + self.__class__.__name__) self.application = application self.config = self.application.config self.target_file = target_file """The name of the target file in CSV format.""" self.tab = tab """The optional :py:class:`.MailSenderSendTab` instance for reporting status messages to the GUI.""" self.rpc = rpc self._ssh_forwarder = None self.smtp_connection = None """The :py:class:`smtplib.SMTP` connection instance.""" self.smtp_server = smoke_zephyr.utilities.parse_server(self.config['smtp_server'], 25) self.running = threading.Event() """A :py:class:`threading.Event` object indicating if emails are being sent.""" self.paused = threading.Event() """A :py:class:`threading.Event` object indicating if the email sending operation is or should be paused.""" self.should_stop = threading.Event() self.max_messages_per_minute = float(self.config.get('smtp_max_send_rate', 0.0)) self.mail_options = [] def tab_notify_sent(self, emails_done, emails_total): """ Notify the tab that messages have been sent. :param int emails_done: The number of emails that have been sent. :param int emails_total: The total number of emails that are going to be sent. """ if isinstance(self.tab, gui_utilities.GladeGObject): GLib.idle_add(lambda x: self.tab.notify_sent(*x), (emails_done, emails_total)) def tab_notify_status(self, message): """ Handle a status message regarding the message sending operation. :param str message: The notification message. """ self.logger.info(message.lower()) if isinstance(self.tab, gui_utilities.GladeGObject): GLib.idle_add(self.tab.notify_status, message + '\n') def tab_notify_stopped(self): """ Notify the tab that the message sending operation has stopped. """ if isinstance(self.tab, gui_utilities.GladeGObject): GLib.idle_add(self.tab.notify_stopped) def server_ssh_connect(self): """ Connect to the remote SMTP server over SSH and configure port forwarding with :py:class:`.SSHTCPForwarder` for tunneling SMTP traffic. :return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants. """ server = smoke_zephyr.utilities.parse_server(self.config['ssh_server'], 22) username = self.config['ssh_username'] password = self.config['ssh_password'] remote_server = smoke_zephyr.utilities.parse_server(self.config['smtp_server'], 25) try: self._ssh_forwarder = SSHTCPForwarder( server, username, password, remote_server, private_key=self.config.get('ssh_preferred_key'), missing_host_key_policy=ssh_host_key.MissingHostKeyPolicy(self.application) ) self._ssh_forwarder.start() except errors.KingPhisherAbortError as error: self.logger.info("ssh connection aborted ({0})".format(error.message)) except paramiko.AuthenticationException: self.logger.warning('failed to authenticate to the remote ssh server') return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED except paramiko.SSHException as error: self.logger.warning("failed with ssh exception '{0}'".format(error.message)) except Exception: self.logger.warning('failed to connect to the remote ssh server', exc_info=True) else: self.smtp_server = self._ssh_forwarder.local_server return ConnectionErrorReason.SUCCESS return ConnectionErrorReason.ERROR_UNKNOWN def server_smtp_connect(self): """ Connect and optionally authenticate to the configured SMTP server. :return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants. """ if self.config.get('smtp_ssl_enable', False): SmtpClass = smtplib.SMTP_SSL else: SmtpClass = smtplib.SMTP self.logger.debug('opening a new connection to the SMTP server') try: self.smtp_connection = SmtpClass(*self.smtp_server, timeout=15) self.smtp_connection.ehlo() except smtplib.SMTPException: self.logger.warning('received an SMTPException while connecting to the SMTP server', exc_info=True) return ConnectionErrorReason.ERROR_UNKNOWN except socket.error: self.logger.warning('received a socket.error while connecting to the SMTP server') return ConnectionErrorReason.ERROR_CONNECTION if not self.config.get('smtp_ssl_enable', False) and 'starttls' in self.smtp_connection.esmtp_features: self.logger.debug('target SMTP server supports the STARTTLS extension') try: self.smtp_connection.starttls() self.smtp_connection.ehlo() except smtplib.SMTPException: self.logger.warning('received an SMTPException while negotiating STARTTLS with the SMTP server', exc_info=True) return ConnectionErrorReason.ERROR_UNKNOWN username = self.config.get('smtp_username', '') if username: password = self.config.get('smtp_password', '') try: self.smtp_connection.login(username, password) except smtplib.SMTPException as error: self.logger.warning('received an {0} while authenticating to the SMTP server'.format(error.__class__.__name__)) self.smtp_connection.quit() return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED if self.smtp_connection.has_extn('SMTPUTF8'): self.logger.debug('target SMTP server supports the SMTPUTF8 extension') self.mail_options.append('SMTPUTF8') return ConnectionErrorReason.SUCCESS def server_smtp_disconnect(self): """Clean up and close the connection to the remote SMTP server.""" if self.smtp_connection: self.logger.debug('closing the connection to the SMTP server') try: self.smtp_connection.quit() except smtplib.SMTPServerDisconnected: pass self.smtp_connection = None self.tab_notify_status('Disconnected from the SMTP server') def server_smtp_reconnect(self): """ Disconnect from the remote SMTP server and then attempt to open a new connection to it. :return: The reconnection status. :rtype: bool """ if self.smtp_connection: try: self.smtp_connection.quit() except smtplib.SMTPServerDisconnected: pass self.smtp_connection = None while self.server_smtp_connect() != ConnectionErrorReason.SUCCESS: self.tab_notify_status('Failed to reconnect to the SMTP server') if not self.process_pause(True): return False return True def count_targets(self): """ Count the number of targets that will be sent messages. :return: The number of targets that will be sent messages. :rtype: int """ return sum(1 for _ in self.iterate_targets(counting=True)) def iterate_targets(self, counting=False): """ Iterate over each of the targets as defined within the configuration. If *counting* is ``False``, messages will not be displayed to the end user through the notification tab. :param bool counting: Whether or not to iterate strictly for counting purposes. :return: Each message target. :rtype: :py:class:`~.MessageTarget` """ mailer_tab = self.application.main_tabs['mailer'] target_type = self.config['mailer.target_type'] if target_type == 'single': target_name = self.config['mailer.target_name'].split(' ') while len(target_name) < 2: target_name.append('') uid_charset = self.config['mailer.message_uid.charset'] target = MessageTarget( first_name=target_name[0].strip(), last_name=target_name[1].strip(), email_address=self.config['mailer.target_email_address'].strip(), uid=utilities.make_message_uid( upper=uid_charset['upper'], lower=uid_charset['lower'], digits=uid_charset['digits'] ) ) if not counting: mailer_tab.emit('target-create', target) yield target elif target_type == 'file': for target in _iterate_targets_file(self.target_file, config=self.config): missing_fields = target.missing_fields if missing_fields: if counting: msg = "Target CSV line {0} skipped due to missing field{1}".format(target.line, ('' if len(missing_fields) == 1 else 's')) msg += ':' + ', '.join(field.replace('_', ' ') for field in missing_fields) self.tab_notify_status(msg) continue if not utilities.is_valid_email_address(target.email_address): self.logger.warning("skipping line {0} in target csv file due to invalid email address: {1}".format(target.line, target.email_address)) continue if not counting: mailer_tab.emit('target-create', target) yield target else: self.logger.error("the configured target type '{0}' is unsupported".format(target_type)) def run(self): """The entry point of the thread.""" self.logger.debug("mailer routine running in tid: 0x{0:x}".format(threading.current_thread().ident)) self.running.set() self.should_stop.clear() self.paused.clear() try: self._prepare_env() emails_done = self._send_messages() except UnicodeDecodeError as error: self.logger.error("a unicode error occurred, {0} at position: {1}-{2}".format(error.reason, error.start, error.end)) self.tab_notify_status("A unicode error occurred, {0} at position: {1}-{2}".format(error.reason, error.start, error.end)) except Exception: self.logger.error('an error occurred while sending messages', exc_info=True) self.tab_notify_status('An error occurred while sending messages.') else: self.tab_notify_status("Finished sending, successfully sent {0:,} messages".format(emails_done)) self.server_smtp_disconnect() if self._ssh_forwarder: self._ssh_forwarder.stop() self._ssh_forwarder = None self.tab_notify_status('Disconnected from the SSH server') self.tab_notify_stopped() return def process_pause(self, set_pause=False): """ Pause sending emails if a pause request has been set. :param bool set_pause: Whether to request a pause before processing it. :return: Whether or not the sending operation was cancelled during the pause. :rtype: bool """ if set_pause: if isinstance(self.tab, gui_utilities.GladeGObject): gui_utilities.glib_idle_add_wait(lambda: self.tab.pause_button.set_property('active', True)) else: self.pause() if self.paused.is_set(): self.tab_notify_status('Paused sending emails, waiting to resume') self.running.wait() self.paused.clear() if self.should_stop.is_set(): self.tab_notify_status('Sending emails cancelled') return False self.tab_notify_status('Resuming sending emails') self.max_messages_per_minute = float(self.config.get('smtp_max_send_rate', 0.0)) return True def create_message(self, target=None): if target is None: target = MessageTargetPlaceholder(uid=self.config['server_config'].get('server.secret_id')) attachments = self.get_mime_attachments() message = getattr(self, 'create_message_' + self.config['mailer.message_type'])(target, attachments) mailer_tab = self.application.main_tabs['mailer'] mailer_tab.emit('message-create', target, message) return message def create_message_calendar_invite(self, target, attachments): """ Create a MIME calendar invite to be sent from a set of parameters. :param target: The information for the messages intended recipient. :type target: :py:class:`.MessageTarget` :param str uid: The message's unique identifier. :param attachments: The attachments to add to the created message. :type attachments: :py:class:`Attachments` :return: The new MIME message. :rtype: :py:class:`email.mime.multipart.MIMEMultipart` """ top_msg = TopMIMEMultipart('mixed', self.config, target) top_msg['To'] = target.email_address related_msg = mime.multipart.MIMEMultipart('related') top_msg.attach(related_msg) alt_msg = mime.multipart.MIMEMultipart('alternative') related_msg.attach(alt_msg) part = mime.base.MIMEBase('text', 'plain', charset='utf-8') part.set_payload(MIME_TEXT_PLAIN) encoders.encode_base64(part) alt_msg.attach(part) with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h: msg_template = file_h.read() formatted_msg = render_message_template(msg_template, self.config, target=target) part = MIMEText(formatted_msg, 'html') alt_msg.attach(part) start_time = get_invite_start_from_config(self.config) if self.config['mailer.calendar_invite_all_day']: duration = ics.DurationAllDay() else: duration = int(self.config['mailer.calendar_invite_duration']) * 60 ical = ics.Calendar( self.config['mailer.source_email'], start_time, self.config.get('mailer.calendar_invite_summary'), duration=duration, location=self.config.get('mailer.calendar_invite_location') ) ical.add_attendee(target.email_address, rsvp=self.config.get('mailer.calendar_request_rsvp', False)) part = mime.base.MIMEBase('text', 'calendar', charset='utf-8', method='REQUEST') part.set_payload(ical.to_ical(encoding='utf-8')) encoders.encode_base64(part) alt_msg.attach(part) for attach in attachments.images: related_msg.attach(attach) for attach in attachments.files: top_msg.attach(attach) return top_msg def create_message_email(self, target, attachments): """ Create a MIME email to be sent from a set of parameters. :param target: The information for the messages intended recipient. :type target: :py:class:`.MessageTarget` :param str uid: The message's unique identifier. :param attachments: The attachments to add to the created message. :type attachments: :py:class:`MessageAttachments` :return: The new MIME message. :rtype: :py:class:`email.mime.multipart.MIMEMultipart` """ msg = TopMIMEMultipart('related', self.config, target) target_field = self.config.get('mailer.target_field', 'to').lower() for header in ('To', 'CC', 'BCC'): if header.lower() == target_field: msg[header] = '<' + target.email_address + '>' continue value = self.config.get('mailer.recipient_email_' + header.lower()) if value: msg[header] = '<' + value + '>' importance = self.config.get('mailer.importance', 'Normal') if importance != 'Normal': msg['Importance'] = importance sensitivity = self.config.get('mailer.sensitivity', 'Normal') if sensitivity != 'Normal': msg['Sensitivity'] = sensitivity msg_alt = mime.multipart.MIMEMultipart('alternative') msg.attach(msg_alt) with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h: msg_template = file_h.read() formatted_msg = render_message_template(msg_template, self.config, target=target) # RFC-1341 page 35 states friendliest part must be attached first msg_body = MIMEText(MIME_TEXT_PLAIN, 'plain') msg_alt.attach(msg_body) msg_body = MIMEText(formatted_msg, 'html') msg_alt.attach(msg_body) msg_alt.set_default_type('html') # process attachments for attach in attachments.files: msg.attach(attach) for attach in attachments.images: msg.attach(attach) return msg def get_mime_attachments(self): """ Return a :py:class:`.MessageAttachments` object containing both the images and raw files to be included in sent messages. :return: A namedtuple of both files and images in their MIME containers. :rtype: :py:class:`.MessageAttachments` """ files = [] # allow the attachment_file.post_processing to be attached instead of # attachment_file so attachment_file can be used as an input for # arbitrary operations to modify without over writing the original attachment_file = self.config.get('mailer.attachment_file.post_processing') delete_attachment_file = False if attachment_file is not None: if not isinstance(attachment_file, str): raise TypeError('config option mailer.attachment_file.post_processing is not a readable file') if not os.path.isfile(attachment_file) and os.access(attachment_file, os.R_OK): raise ValueError('config option mailer.attachment_file.post_processing is not a readable file') self.config['mailer.attachment_file.post_processing'] = None delete_attachment_file = True else: attachment_file = self.config.get('mailer.attachment_file') if attachment_file: attachfile = mime.base.MIMEBase(*mimetypes.guess_type(attachment_file)) attachfile.set_payload(open(attachment_file, 'rb').read()) encoders.encode_base64(attachfile) attachfile.add_header('Content-Disposition', "attachment; filename=\"{0}\"".format(os.path.basename(attachment_file))) files.append(attachfile) if delete_attachment_file and os.access(attachment_file, os.W_OK): os.remove(attachment_file) images = [] for attachment_file, attachment_name in template_environment.attachment_images.items(): attachfile = mime.image.MIMEImage(open(attachment_file, 'rb').read()) attachfile.add_header('Content-ID', "<{0}>".format(attachment_name)) attachfile.add_header('Content-Disposition', "inline; filename=\"{0}\"".format(attachment_name)) images.append(attachfile) return MessageAttachments(tuple(files), tuple(images)) def _prepare_env(self): with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h: msg_template = file_h.read() render_message_template(msg_template, self.config, analyze=True) template_environment.set_mode(template_environment.MODE_SEND) def _send_messages(self): emails_done = 0 mailer_tab = self.application.main_tabs['mailer'] max_messages_per_connection = self.config.get('mailer.max_messages_per_connection', 5) emails_total = "{0:,}".format(self.count_targets()) sending_line = "Sending email {{0: >{0},}} of {1} with UID: {{1}} to {{2}}".format(len(emails_total), emails_total) emails_total = int(emails_total.replace(',', '')) for target in self.iterate_targets(): iteration_time = time.time() if self.should_stop.is_set(): self.tab_notify_status('Sending emails cancelled') break if not self.process_pause(): break if emails_done > 0 and max_messages_per_connection > 0 and (emails_done % max_messages_per_connection == 0): self.server_smtp_reconnect() emails_done += 1 if not all(mailer_tab.emit('target-send', target)): self.logger.info("target-send signal subscriber vetoed target: {0!r}".format(target)) continue self.tab_notify_status(sending_line.format(emails_done, target.uid, target.email_address)) message = self.create_message(target=target) if not all(mailer_tab.emit('message-send', target, message)): self.logger.info("message-send signal subscriber vetoed message to target: {0!r}".format(target)) continue self.rpc( 'campaign/message/new/deferred', self.config['campaign_id'], target.uid, target.email_address, target.first_name, target.last_name, target.department ) if not self._try_send_message(target.email_address, message): self.rpc('db/table/delete', 'messages', target.uid) break self.rpc('db/table/set', 'messages', target.uid, ('sent',), (datetime.datetime.utcnow(),)) self.tab_notify_sent(emails_done, emails_total) self.application.emit('message-sent', target.uid, target.email_address) if self.max_messages_per_minute: iteration_time = (time.time() - iteration_time) self._sleep((60.0 / float(self.max_messages_per_minute)) - iteration_time) return emails_done def _sleep(self, duration): while duration > 0: sleep_chunk = min(duration, 0.5) time.sleep(sleep_chunk) if self.should_stop.is_set(): break duration -= sleep_chunk return self.should_stop.is_set() def _try_send_message(self, *args, **kwargs): message_sent = False while not message_sent and not self.should_stop.is_set(): for i in range(0, 3): try: self.send_message(*args, **kwargs) message_sent = True break except smtplib.SMTPServerDisconnected: self.logger.warning('failed to send message, the server has been disconnected') self.tab_notify_status('Failed to send message, the server has been disconnected') self.tab_notify_status('Sleeping for 5 seconds before attempting to reconnect') if self._sleep(5): break self.smtp_connection = None self.server_smtp_reconnect() except smtplib.SMTPException as error: self.tab_notify_status("Failed to send message (exception: {0})".format(error.__class__.__name__)) self.logger.warning("failed to send message (exception: smtplib.{0})".format(error.__class__.__name__)) self._sleep((i + 1) ** 2) if not message_sent: self.server_smtp_disconnect() if not self.process_pause(True): return False self.server_smtp_reconnect() return True def send_message(self, target_email, msg): """ Send an email using the connected SMTP server. :param str target_email: The email address to send the message to. :param msg: The formatted message to be sent. :type msg: :py:class:`.mime.multipart.MIMEMultipart` """ source_email = self.config['mailer.source_email_smtp'] self.smtp_connection.sendmail(source_email, target_email, msg.as_string(), self.mail_options) def pause(self): """ Sets the :py:attr:`~.MailSenderThread.running` and :py:attr:`~.MailSenderThread.paused` flags correctly to indicate that the object is paused. """ self.running.clear() self.paused.set() def unpause(self): """ Sets the :py:attr:`~.MailSenderThread.running` and :py:attr:`~.MailSenderThread.paused` flags correctly to indicate that the object is no longer paused. """ self.running.set() def stop(self): """ Requests that the email sending operation stop. It can not be resumed from the same position. This function blocks until the stop request has been processed and the thread exits. """ self.should_stop.set() self.unpause() if self.is_alive(): self.join() def missing_files(self): """ Return a list of all missing or unreadable files which are referenced by the message template. :return: The list of unusable files. :rtype: list """ missing = [] attachment = self.config.get('mailer.attachment_file') if attachment and not os.access(attachment, os.R_OK): missing.append(attachment) msg_template = self.config['mailer.html_file'] if not os.access(msg_template, os.R_OK): missing.append(msg_template) return missing self._prepare_env() for attachment in template_environment.attachment_images.keys(): if not os.access(attachment, os.R_OK): missing.append(attachment) return missing
class MailSenderThread(threading.Thread): """ The King Phisher threaded email message sender. This object manages the sending of emails for campaigns and supports pausing the sending of messages which can later be resumed by unpausing. This object reports its information to the GUI through an optional :py:class:`.MailSenderSendTab` instance, these two objects are very interdependent. """ def __init__(self, config, target_file, rpc, tab=None): """ :param dict config: The King Phisher client configuration. :param str target_file: The CSV formatted file to read message targets from. :param tab: The GUI tab to report information to. :type tab: :py:class:`.MailSenderSendTab` :param rpc: The client's connected RPC instance. :type rpc: :py:class:`.KingPhisherRPCClient` """ super(MailSenderThread, self).__init__() self.daemon = True self.logger = logging.getLogger('KingPhisher.Client.' + self.__class__.__name__) self.config = config self.target_file = target_file """The name of the target file in CSV format.""" self.tab = tab """The optional :py:class:`.MailSenderSendTab` instance for reporting status messages to the GUI.""" self.rpc = rpc self._ssh_forwarder = None self.smtp_connection = None """The :py:class:`smtplib.SMTP` connection instance.""" self.smtp_server = parse_server(self.config['smtp_server'], 25) self.running = threading.Event() """A :py:class:`threading.Event` object indicating if emails are being sent.""" self.paused = threading.Event() """A :py:class:`threading.Event` object indicating if the email sending operation is or should be paused.""" self.should_exit = threading.Event() self.max_messages_per_minute = float(self.config.get('smtp_max_send_rate', 0.0)) self._mime_attachments = None def tab_notify_sent(self, emails_done, emails_total): """ Notify the tab that messages have been sent. :param int emails_done: The number of emails that have been sent. :param int emails_total: The total number of emails that are going to be sent. """ if isinstance(self.tab, gui_utilities.GladeGObject): GLib.idle_add(lambda x: self.tab.notify_sent(*x), (emails_done, emails_total)) def tab_notify_status(self, message): """ Handle a status message regarding the message sending operation. :param str message: The notification message. """ self.logger.info(message.lower()) if isinstance(self.tab, gui_utilities.GladeGObject): GLib.idle_add(self.tab.notify_status, message + '\n') def tab_notify_stopped(self): """ Notify the tab that the message sending operation has stopped. """ if isinstance(self.tab, gui_utilities.GladeGObject): GLib.idle_add(self.tab.notify_stopped) def server_ssh_connect(self): """ Connect to the remote SMTP server over SSH and configure port forwarding with :py:class:`.SSHTCPForwarder` for tunneling SMTP traffic. :return: The connection status. :rtype: bool """ server = parse_server(self.config['ssh_server'], 22) username = self.config['ssh_username'] password = self.config['ssh_password'] remote_server = parse_server(self.config['smtp_server'], 25) local_port = random.randint(2000, 6000) try: self._ssh_forwarder = SSHTCPForwarder(server, username, password, local_port, remote_server, preferred_private_key=self.config.get('ssh_preferred_key')) self._ssh_forwarder.start() time.sleep(0.5) except Exception: self.logger.warning('failed to connect to remote ssh server', exc_info=True) return False self.smtp_server = ('localhost', local_port) return True def server_smtp_connect(self): """ Connect to the configured SMTP server. :return: The connection status. :rtype: bool """ if self.config.get('smtp_ssl_enable', False): SmtpClass = smtplib.SMTP_SSL else: SmtpClass = smtplib.SMTP try: self.smtp_connection = SmtpClass(*self.smtp_server, timeout=10) except socket.error: self.logger.warning('received a socket.error while connecting to the SMTP server') except smtplib.SMTPException: self.logger.warning('received an SMTPException while connecting to the SMTP server') else: return True return False def server_smtp_disconnect(self): """Clean up and close the connection to the remote SMTP server.""" if self.smtp_connection: try: self.smtp_connection.quit() except smtplib.SMTPServerDisconnected: pass self.smtp_connection = None self.tab_notify_status('Disconnected from the SMTP server') def server_smtp_reconnect(self): """ Disconnect from the remote SMTP server and then attempt to open a new connection to it. :return: The reconnection status. :rtype: bool """ if self.smtp_connection: try: self.smtp_connection.quit() except smtplib.SMTPServerDisconnected: pass self.smtp_connection = None while not self.server_smtp_connect(): self.tab_notify_status('Failed to reconnect to the SMTP server') if not self.process_pause(True): return False return True def count_emails(self): """ Count the emails contained in the target CSV file. :return: The number of targets in the file. :rtype: int """ targets = 0 target_file_h = open(self.target_file, 'rU') csv_reader = csv.DictReader(target_file_h, ['first_name', 'last_name', 'email_address']) for target in csv_reader: if not utilities.is_valid_email_address(target['email_address']): continue targets += 1 target_file_h.close() return targets def run(self): emails_done = 0 emails_total = self.count_emails() max_messages_per_connection = self.config.get('mailer.max_messages_per_connection', 5) self.running.set() self.should_exit.clear() self.paused.clear() self._prepare_env() emails_total = "{0:,}".format(emails_total) sending_line = "Sending email {{0: >{0},}} of {1} with UID: {{1}} to {{2}}".format(len(emails_total), emails_total) emails_total = int(emails_total.replace(',', '')) self._mime_attachments = self._get_mime_attachments() self.logger.debug("loaded {0:,} MIME attachments".format(len(self._mime_attachments))) target_file_h = open(self.target_file, 'rU') csv_reader = csv.DictReader(target_file_h, ['first_name', 'last_name', 'email_address', 'department']) for target in csv_reader: if not utilities.is_valid_email_address(target['email_address']): if target['email_address']: self.logger.warning('skipping invalid email address: ' + target['email_address']) else: self.logger.warning('skipping blank email address') continue iteration_time = time.time() if self.should_exit.is_set(): self.tab_notify_status('Sending emails cancelled') break if not self.process_pause(): break if emails_done > 0 and (emails_done % max_messages_per_connection): self.server_smtp_reconnect() uid = make_uid() emails_done += 1 self.tab_notify_status(sending_line.format(emails_done, uid, target['email_address'])) msg = self.create_email(target['first_name'], target['last_name'], target['email_address'], uid) if not self._try_send_email(target['email_address'], msg): break self.tab_notify_sent(emails_done, emails_total) campaign_id = self.config['campaign_id'] department = target['department'] if department is not None: department = department.strip() if department == '': department = None self.rpc('campaign/message/new', campaign_id, uid, target['email_address'], target['first_name'], target['last_name'], department) if self.max_messages_per_minute: iteration_time = (time.time() - iteration_time) sleep_time = (60.0 / float(self.max_messages_per_minute)) - iteration_time while sleep_time > 0: sleep_chunk = min(sleep_time, 0.5) time.sleep(sleep_chunk) if self.should_exit.is_set(): break sleep_time -= sleep_chunk target_file_h.close() self._mime_attachments = None self.tab_notify_status("Finished sending emails, successfully sent {0:,} emails".format(emails_done)) self.server_smtp_disconnect() if self._ssh_forwarder: self._ssh_forwarder.stop() self._ssh_forwarder = None self.tab_notify_status('Disconnected from the SSH server') self.tab_notify_stopped() return def process_pause(self, set_pause=False): """ Pause sending emails if a pause request has been set. :param bool set_pause: Whether to request a pause before processing it. :return: Whether or not the sending operation was cancelled during the pause. :rtype: bool """ if set_pause: if isinstance(self.tab, gui_utilities.GladeGObject): gui_utilities.glib_idle_add_wait(lambda: self.tab.pause_button.set_property('active', True)) else: self.pause() if self.paused.is_set(): self.tab_notify_status('Paused sending emails, waiting to resume') self.running.wait() self.paused.clear() if self.should_exit.is_set(): self.tab_notify_status('Sending emails cancelled') return False self.tab_notify_status('Resuming sending emails') self.max_messages_per_minute = float(self.config.get('smtp_max_send_rate', 0.0)) return True def create_email(self, first_name, last_name, target_email, uid): """ Create a MIME email to be sent from a set of parameters. :param str first_name: The first name of the message's recipient. :param str last_name: The last name of the message's recipient. :param str target_email: The message's destination email address. :param str uid: The message's unique identifier. :return: The new MIME message. :rtype: :py:class:`email.MIMEMultipart.MIMEMultipart` """ msg = MIMEMultipart() msg.replace_header('Content-Type', 'multipart/related') msg['Subject'] = self.config['mailer.subject'] if self.config.get('mailer.reply_to_email'): msg.add_header('reply-to', self.config['mailer.reply_to_email']) if self.config.get('mailer.source_email_alias'): msg['From'] = "\"{0}\" <{1}>".format(self.config['mailer.source_email_alias'], self.config['mailer.source_email']) else: msg['From'] = self.config['mailer.source_email'] msg['To'] = target_email importance = self.config.get('mailer.importance', 'Normal') if importance != 'Normal': msg['Importance'] = importance sensitivity = self.config.get('mailer.sensitivity', 'Normal') if sensitivity != 'Normal': msg['Sensitivity'] = sensitivity msg.preamble = 'This is a multi-part message in MIME format.' msg_alt = MIMEMultipart('alternative') msg.attach(msg_alt) with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h: msg_template = file_h.read() formatted_msg = format_message(msg_template, self.config, first_name=first_name, last_name=last_name, uid=uid, target_email=target_email) msg_body = MIMEText(formatted_msg, 'html', 'utf-8') msg_alt.attach(msg_body) # process attachments if isinstance(self._mime_attachments, (list, tuple)): attachfiles = self._mime_attachments else: attachfiles = self._get_mime_attachments() for attachfile in attachfiles: msg.attach(attachfile) return msg def _get_mime_attachments(self): attachments = [] if self.config.get('mailer.attachment_file'): attachment = self.config['mailer.attachment_file'] attachfile = MIMEBase(*mimetypes.guess_type(attachment)) attachfile.set_payload(open(attachment, 'rb').read()) encoders.encode_base64(attachfile) attachfile.add_header('Content-Disposition', "attachment; filename=\"{0}\"".format(os.path.basename(attachment))) attachments.append(attachfile) for attachment_file, attachment_name in template_environment.attachment_images.items(): attachfile = MIMEImage(open(attachment_file, 'rb').read()) attachfile.add_header('Content-ID', "<{0}>".format(attachment_name)) attachfile.add_header('Content-Disposition', "inline; filename=\"{0}\"".format(attachment_name)) attachments.append(attachfile) return attachments def _prepare_env(self): with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h: msg_template = file_h.read() template_environment.set_mode(template_environment.MODE_ANALYZE) format_message(msg_template, self.config, uid=make_uid()) template_environment.set_mode(template_environment.MODE_SEND) def _try_send_email(self, *args, **kwargs): message_sent = False while not message_sent: for _ in range(0, 3): try: self.send_email(*args, **kwargs) message_sent = True break except smtplib.SMTPException: self.tab_notify_status('Failed to send message') time.sleep(1) if not message_sent: self.server_smtp_disconnect() if not self.process_pause(True): return False self.server_smtp_reconnect() return True def send_email(self, target_email, msg): """ Send an email using the connected SMTP server. :param str target_email: The email address to send the message to. :param msg: The formatted message to be sent. :type msg: :py:class:`email.MIMEMultipart.MIMEMultipart` """ source_email = self.config['mailer.source_email_smtp'] self.smtp_connection.sendmail(source_email, target_email, msg.as_string()) def pause(self): """ Sets the :py:attr:`~.MailSenderThread.running` and :py:attr:`~.MailSenderThread.paused` flags correctly to indicate that the object is paused. """ self.running.clear() self.paused.set() def unpause(self): """ Sets the :py:attr:`~.MailSenderThread.running` and :py:attr:`~.MailSenderThread.paused` flags correctly to indicate that the object is no longer paused. """ self.running.set() def stop(self): """ Requests that the email sending operation stop. It can not be resumed from the same position. This function blocks until the stop request has been processed and the thread exits. """ self.should_exit.set() self.unpause() if self.is_alive(): self.join() def missing_files(self): """ Return a list of all missing or unreadable files which are referenced by the message template. :return: The list of unusable files. :rtype: list """ missing = [] attachment = self.config.get('mailer.attachment_file') if attachment and not os.access(attachment, os.R_OK): missing.append(attachment) msg_template = self.config['mailer.html_file'] if not os.access(msg_template, os.R_OK): missing.append(msg_template) return missing self._prepare_env() for attachment in template_environment.attachment_images.keys(): if not os.access(attachment, os.R_OK): missing.append(attachment) return missing
class KingPhisherClientApplication(_Gtk_Application): """ This is the top level King Phisher client object. It contains the custom GObject signals, keeps all the GUI references, and manages the RPC client object. This is also the parent window for most GTK objects. :GObject Signals: :ref:`gobject-signals-application-label` """ # pylint: disable=too-many-public-methods __gsignals__ = { 'campaign-changed': (GObject.SIGNAL_RUN_FIRST, None, (str, )), 'campaign-created': (GObject.SIGNAL_RUN_FIRST, None, (str, )), 'campaign-delete': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, (str, )), 'campaign-set': (GObject.SIGNAL_RUN_FIRST, None, (str, )), 'config-load': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, (bool, )), 'config-save': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()), 'credential-delete': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, (object, )), 'exit': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()), 'exit-confirm': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()), 'message-delete': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, (object, )), 'message-sent': (GObject.SIGNAL_RUN_FIRST, None, (str, str)), 'reload-css-style': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()), 'rpc-cache-clear': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()), 'server-connected': (GObject.SIGNAL_RUN_FIRST, None, ()), 'server-disconnected': (GObject.SIGNAL_RUN_FIRST, None, ()), 'sftp-client-start': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, ()), 'visit-delete': (GObject.SIGNAL_ACTION | GObject.SIGNAL_RUN_LAST, None, (object, )), } def __init__(self, config_file=None, use_plugins=True, use_style=True): super(KingPhisherClientApplication, self).__init__() if use_style: gtk_version = (Gtk.get_major_version(), Gtk.get_minor_version()) if gtk_version > (3, 18): self._theme_file = 'theme.v2.css' else: self._theme_file = 'theme.v1.css' else: self._theme_file = DISABLED self.logger = logging.getLogger('KingPhisher.Client.Application') # log version information for debugging purposes self.logger.debug("gi.repository GLib version: {0}".format('.'.join( map(str, GLib.glib_version)))) self.logger.debug("gi.repository GObject version: {0}".format('.'.join( map(str, GObject.pygobject_version)))) self.logger.debug("gi.repository Gtk version: {0}.{1}.{2}".format( Gtk.get_major_version(), Gtk.get_minor_version(), Gtk.get_micro_version())) if rpc_terminal.has_vte: self.logger.debug("gi.repository VTE version: {0}".format( rpc_terminal.Vte._version)) if graphs.has_matplotlib: self.logger.debug("matplotlib version: {0}".format( graphs.matplotlib.__version__)) self.set_property('application-id', 'org.king-phisher.client') self.set_property('register-session', True) self.config_file = config_file or os.path.join(USER_DATA_PATH, 'config.json') """The file containing the King Phisher client configuration.""" if not os.path.isfile(self.config_file): self._create_config() self.config = None """The primary King Phisher client configuration.""" self.main_window = None """The primary top-level :py:class:`~.MainAppWindow` instance.""" self.rpc = None """The :py:class:`~.KingPhisherRPCClient` instance for the application.""" self._ssh_forwarder = None """The SSH forwarder responsible for tunneling RPC communications.""" self.style_provider = None try: self.emit('config-load', True) except IOError: self.logger.critical('failed to load the client configuration') raise self.connect('window-added', self.signal_window_added) self.actions = {} self._create_actions() if not use_plugins: self.logger.info('disabling all plugins') self.config['plugins.enabled'] = [] self.plugin_manager = plugins.ClientPluginManager([ os.path.join(USER_DATA_PATH, 'plugins'), find.find_data_directory('plugins') ], self) if use_plugins: self.plugin_manager.load_all() def _create_actions(self): action = Gio.SimpleAction.new('emit-application-signal', GLib.VariantType.new('s')) action.connect('activate', self.action_emit_application_signal) accelerators = (('<Control><Shift>F1', 'rpc-cache-clear'), ('<Control><Shift>F2', 'config-save'), ('<Control><Shift>F12', 'reload-css-style')) for key, signal_name in accelerators: if Gtk.check_version(3, 14, 0): self.add_accelerator(key, 'win.emit-application-signal', GLib.Variant.new_string(signal_name)) else: self.set_accels_for_action( "win.emit-application-signal('{0}')".format(signal_name), (key, )) self.actions['emit-application-signal'] = action self.add_action(action) def _create_ssh_forwarder(self, server, username, password): """ Create and set the :py:attr:`~.KingPhisherClientApplication._ssh_forwarder` attribute. :param tuple server: The server information as a host and port tuple. :param str username: The username to authenticate to the SSH server with. :param str password: The password to authenticate to the SSH server with. :rtype: int :return: The local port that is forwarded to the remote server or None if the connection failed. """ active_window = self.get_active_window() title_ssh_error = 'Failed To Connect To The SSH Service' server_remote_port = self.config['server_remote_port'] try: self._ssh_forwarder = SSHTCPForwarder( server, username, password, ('127.0.0.1', server_remote_port), private_key=self.config.get('ssh_preferred_key'), missing_host_key_policy=ssh_host_key.MissingHostKeyPolicy( self)) self._ssh_forwarder.start() except errors.KingPhisherAbortError as error: self.logger.info("ssh connection aborted ({0})".format( error.message)) except paramiko.PasswordRequiredException: gui_utilities.show_dialog_error( title_ssh_error, active_window, 'The specified SSH key requires a password.') except paramiko.AuthenticationException: self.logger.warning( 'failed to authenticate to the remote ssh server') gui_utilities.show_dialog_error( title_ssh_error, active_window, 'The server responded that the credentials are invalid.') except paramiko.SSHException as error: self.logger.warning("failed with ssh exception '{0}'".format( error.args[0])) except socket.error as error: gui_utilities.show_dialog_exc_socket_error(error, active_window, title=title_ssh_error) except Exception as error: self.logger.warning('failed to connect to the remote ssh server', exc_info=True) gui_utilities.show_dialog_error( title_ssh_error, active_window, "An {0}.{1} error occurred.".format(error.__class__.__module__, error.__class__.__name__)) else: return self._ssh_forwarder.local_server self.emit('server-disconnected') return def _create_config(self): config_dir = os.path.dirname(self.config_file) if not os.path.isdir(config_dir): self.logger.debug('creating the user configuration directory') os.makedirs(config_dir) # move the pre 1.0.0 config file if it exists old_path = os.path.expanduser('~/.king_phisher.json') if os.path.isfile(old_path) and os.access(old_path, os.R_OK): self.logger.debug('moving the old config file to the new location') os.rename(old_path, self.config_file) else: client_template = find.find_data_file('client_config.json') shutil.copy(client_template, self.config_file) def campaign_configure(self): assistant = assistants.CampaignAssistant( self, campaign_id=self.config['campaign_id']) assistant.assistant.set_transient_for(self.get_active_window()) assistant.assistant.set_modal(True) # do this to keep a reference to prevent garbage collection attr_name = '_tmpref_campaign_assistant' setattr(self, attr_name, assistant) assistant.assistant.connect('destroy', lambda widget: delattr(self, attr_name)) assistant.interact() def do_campaign_delete(self, campaign_id): """ Delete the campaign on the server. A confirmation dialog will be displayed before the operation is performed. If the campaign is deleted and a new campaign is not selected with :py:meth:`.show_campaign_selection`, the client will quit. """ self.rpc('db/table/delete', 'campaigns', campaign_id) if campaign_id == self.config[ 'campaign_id'] and not self.show_campaign_selection(): gui_utilities.show_dialog_error('Now Exiting', self.get_active_window(), 'A campaign must be selected.') self.quit() def do_credential_delete(self, row_ids): if len(row_ids) == 1: self.rpc('db/table/delete', 'credentials', row_ids[0]) else: self.rpc('db/table/delete/multi', 'credentials', row_ids) def do_message_delete(self, row_ids): if len(row_ids) == 1: self.rpc('db/table/delete', 'messages', row_ids[0]) else: self.rpc('db/table/delete/multi', 'messages', row_ids) def do_visit_delete(self, row_ids): if len(row_ids) == 1: self.rpc('db/table/delete', 'visits', row_ids[0]) else: self.rpc('db/table/delete/multi', 'visits', row_ids) def campaign_rename(self): """ Show a dialog prompting the user to for the a new name to assign to the currently selected campaign. """ campaign = self.rpc.remote_table_row('campaigns', self.config['campaign_id']) prompt = dialogs.TextEntryDialog.build_prompt( self, 'Rename Campaign', 'Enter the new campaign name:', campaign.name) response = prompt.interact() if response is None or response == campaign.name: return self.rpc('db/table/set', 'campaigns', self.config['campaign_id'], 'name', response) gui_utilities.show_dialog_info( 'Campaign Name Updated', self.get_active_window(), 'The campaign name was successfully changed.') def exception_hook(self, exc_type, exc_value, exc_traceback): if isinstance(exc_value, KeyboardInterrupt): self.logger.warning('received a KeyboardInterrupt exception') return exc_info = (exc_type, exc_value, exc_traceback) error_uid = str(uuid.uuid4()) self.logger.error( "error uid: {0} an unhandled exception was thrown".format( error_uid), exc_info=exc_info) dialogs.ExceptionDialog(self, exc_info=exc_info, error_uid=error_uid).interact() def quit(self, optional=False): """ Quit the client and perform any necessary clean up operations. If *optional* is False then the exit-confirm signal will not be sent and there will not be any opportunities for the client to cancel the operation. :param bool optional: Whether the quit is request is optional or not. """ self.emit('exit-confirm' if optional else 'exit') def action_emit_application_signal(self, _, signal_name): signal_name = signal_name.get_string() self.logger.debug('action emit-application-signal invoked for ' + signal_name) self.emit(signal_name) def do_activate(self): Gtk.Application.do_activate(self) sys.excepthook = self.exception_hook # reset theme settings to defaults so we have a standard baseline settings = Gtk.Settings.get_default() if settings.get_property('gtk-theme-name') != GTK3_DEFAULT_THEME: self.logger.debug( 'resetting the gtk-theme-name property to it\'s default value') settings.set_property('gtk-theme-name', GTK3_DEFAULT_THEME) if settings.get_property('gtk-icon-theme-name') != GTK3_DEFAULT_THEME: self.logger.debug( 'resetting the gtk-icon-theme-name property to it\'s default value' ) settings.set_property('gtk-icon-theme-name', GTK3_DEFAULT_THEME) settings.set_property('gtk-application-prefer-dark-theme', False) # load a custom css theme file if one is available theme_file = self.theme_file if theme_file: self.style_provider = self.load_style_css(theme_file) elif theme_file is DISABLED: self.logger.debug( 'no css theme file will be loaded (styling has been disabled)') else: self.logger.debug( 'no css theme file will be loaded (file not found)') # create and show the main window self.main_window = main.MainAppWindow(self.config, self) self.main_tabs = self.main_window.tabs for name in list(self.config['plugins.enabled']): try: self.plugin_manager.load(name) self.plugin_manager.enable(name) except Exception: self.config['plugins.enabled'].remove(name) gui_utilities.show_dialog_error( 'Failed To Enable Plugin', self.main_window, "Plugin '{0}' could not be enabled.".format(name)) def do_campaign_set(self, campaign_id): self.logger.info("campaign set to {0} (id: {1})".format( self.config['campaign_name'], self.config['campaign_id'])) self.emit('rpc-cache-clear') def do_config_save(self): self.logger.info('writing the client configuration to disk') config = copy.copy(self.config) for key in self.config.keys(): if 'password' in key or key == 'server_config': del config[key] with open(os.path.expanduser(self.config_file), 'w') as config_file_h: json_ex.dump(config, config_file_h) def do_exit(self): self.plugin_manager.shutdown() self.main_window.hide() gui_utilities.gtk_widget_destroy_children(self.main_window) gui_utilities.gtk_sync() self.emit('server-disconnected') self.main_window.destroy() return def do_exit_confirm(self): self.emit('exit') def do_reload_css_style(self): if self.style_provider: Gtk.StyleContext.remove_provider_for_screen( Gdk.Screen.get_default(), self.style_provider) self.style_provider = None theme_file = self.theme_file if theme_file: self.style_provider = self.load_style_css(theme_file) def do_rpc_cache_clear(self): if self.rpc: self.rpc.cache_clear() def do_server_connected(self): self.load_server_config() campaign_id = self.config.get('campaign_id') if not campaign_id: if not self.show_campaign_selection(): self.logger.debug( 'no campaign selected, disconnecting and exiting') self.emit('exit') return True campaign_info = self.rpc.remote_table_row('campaigns', self.config['campaign_id'], cache=True) if campaign_info is None: if not self.show_campaign_selection(): self.logger.debug( 'no campaign selected, disconnecting and exiting') self.emit('exit') return True campaign_info = self.rpc.remote_table_row( 'campaigns', self.config['campaign_id'], cache=True, refresh=True) self.config['campaign_name'] = campaign_info.name self.emit('campaign-set', self.config['campaign_id']) return def do_shutdown(self): Gtk.Application.do_shutdown(self) sys.excepthook = sys.__excepthook__ self.emit('config-save') @property def theme_file(self): if not self._theme_file: return DISABLED return find.find_data_file(os.path.join('style', self._theme_file)) def do_config_load(self, load_defaults): """ Load the client configuration from disk and set the :py:attr:`~.KingPhisherClientApplication.config` attribute. :param bool load_defaults: Load missing options from the template configuration file. """ self.logger.info('loading the config from disk') client_template = find.find_data_file('client_config.json') config_file = os.path.expanduser(self.config_file) with open(config_file, 'r') as tmp_file: self.config = json_ex.load(tmp_file) if load_defaults: with open(client_template, 'r') as tmp_file: client_template = json_ex.load(tmp_file) for key, value in client_template.items(): if not key in self.config: self.config[key] = value def merge_config(self, config_file, strict=True): """ Merge the configuration information from the specified configuration file. Only keys which exist in the currently loaded configuration are copied over while non-existent keys are skipped. The contents of the new configuration overwrites the existing. :param bool strict: Do not try remove trailing commas from the JSON data. :param str config_file: The path to the configuration file to merge. """ with open(config_file, 'r') as tmp_file: config = json_ex.load(tmp_file, strict=strict) if not isinstance(config, dict): self.logger.error( "can not merge configuration file: {0} (invalid format)". format(config_file)) return self.logger.debug( 'merging configuration information from source file: ' + config_file) for key, value in config.items(): if not key in self.config: self.logger.warning( "skipped merging non-existent configuration key {0}". format(key)) continue self.config[key] = value return def load_server_config(self): """Load the necessary values from the server's configuration.""" self.config['server_config'] = self.rpc('config/get', [ 'server.require_id', 'server.secret_id', 'server.tracking_image', 'server.web_root' ]) return def load_style_css(self, css_file): self.logger.debug('loading style from css file: ' + css_file) css_file = Gio.File.new_for_path(css_file) style_provider = Gtk.CssProvider() style_provider.connect('parsing-error', self.signal_css_provider_parsing_error) try: style_provider.load_from_file(css_file) except GLib.Error: # pylint: disable=catching-non-exception self.logger.error( 'there was an error parsing the css file, it will not be applied as a style provider' ) return None Gtk.StyleContext.add_provider_for_screen( Gdk.Screen.get_default(), style_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION) return style_provider def server_connect(self, username, password, otp=None): # pylint: disable=too-many-locals server_version_info = None title_rpc_error = 'Failed To Connect To The King Phisher RPC Service' active_window = self.get_active_window() server = parse_server(self.config['server'], 22) if ipaddress.is_loopback(server[0]): local_server = ('localhost', self.config['server_remote_port']) self.logger.info("connecting to local king phisher instance") else: local_server = self._create_ssh_forwarder(server, username, password) if not local_server: return False, ConnectionErrorReason.ERROR_PORT_FORWARD rpc = client_rpc.KingPhisherRPCClient( local_server, use_ssl=self.config.get('server_use_ssl')) if self.config.get('rpc.serializer'): try: rpc.set_serializer(self.config['rpc.serializer']) except ValueError as error: self.logger.error( "failed to set the rpc serializer, error: '{0}'".format( error.message)) generic_message = 'Can not contact the RPC HTTP service, ensure that the ' generic_message += "King Phisher Server is currently running on port {0}.".format( int(self.config['server_remote_port'])) connection_failed = True try: server_version_info = rpc('version') if server_version_info is None: raise RuntimeError( 'no version information was retrieved from the server') except advancedhttpserver.RPCError as error: self.logger.warning( 'failed to connect to the remote rpc service due to http status: ' + str(error.status)) gui_utilities.show_dialog_error( title_rpc_error, active_window, "The server responded with HTTP status: {0}.".format( str(error.status))) except BadStatusLine as error: self.logger.warning( 'failed to connect to the remote rpc service due to http bad status line: ' + error.line) gui_utilities.show_dialog_error(title_rpc_error, active_window, generic_message) except socket.error as error: gui_utilities.show_dialog_exc_socket_error(error, active_window) except ssl.CertificateError as error: self.logger.warning( 'failed to connect to the remote rpc service with a https certificate error: ' + error.message) gui_utilities.show_dialog_error( title_rpc_error, active_window, 'The server presented an invalid SSL certificate.') except Exception: self.logger.warning('failed to connect to the remote rpc service', exc_info=True) gui_utilities.show_dialog_error(title_rpc_error, active_window, generic_message) else: connection_failed = False if connection_failed: self.emit('server-disconnected') return False, ConnectionErrorReason.ERROR_CONNECTION server_rpc_api_version = server_version_info.get('rpc_api_version', -1) if isinstance(server_rpc_api_version, int): # compatibility with pre-0.2.0 version server_rpc_api_version = (server_rpc_api_version, 0) self.logger.info( "successfully connected to the king phisher server (version: {0} rpc api version: {1}.{2})" .format(server_version_info['version'], server_rpc_api_version[0], server_rpc_api_version[1])) error_text = None if server_rpc_api_version[0] < version.rpc_api_version.major or ( server_rpc_api_version[0] == version.rpc_api_version.major and server_rpc_api_version[1] < version.rpc_api_version.minor): error_text = 'The server is running an old and incompatible version.' error_text += '\nPlease update the remote server installation.' elif server_rpc_api_version[0] > version.rpc_api_version.major: error_text = 'The client is running an old and incompatible version.' error_text += '\nPlease update the local client installation.' if error_text: gui_utilities.show_dialog_error( 'The RPC API Versions Are Incompatible', active_window, error_text) self.emit('server-disconnected') return False, ConnectionErrorReason.ERROR_INCOMPATIBLE_VERSIONS login_result, login_reason = rpc.login(username, password, otp) if not login_result: self.logger.warning( 'failed to authenticate to the remote king phisher service, reason: ' + login_reason) self.emit('server-disconnected') return False, login_reason rpc.username = username self.logger.debug( 'successfully authenticated to the remote king phisher service') self.rpc = rpc self.emit('server-connected') return True, ConnectionErrorReason.SUCCESS def do_server_disconnected(self): """Clean up the SSH TCP connections and disconnect from the server.""" if self.rpc is not None: try: self.rpc('logout') except advancedhttpserver.RPCError as error: self.logger.warning('failed to logout, rpc error: ' + error.message) self.rpc = None if self._ssh_forwarder: self._ssh_forwarder.stop() self._ssh_forwarder = None return def show_campaign_graph(self, graph_name): """ Create a new :py:class:`.CampaignGraph` instance and make it into a window. *graph_name* must be the name of a valid, exported graph provider. :param str graph_name: The name of the graph to make a window of. """ cls = graphs.get_graph(graph_name) graph_inst = cls(self, style_context=self.style_context) graph_inst.load_graph() window = graph_inst.make_window() window.show() def show_campaign_selection(self): """ Display the campaign selection dialog in a new :py:class:`.CampaignSelectionDialog` instance. :return: Whether or not a campaign was selected. :rtype: bool """ dialog = dialogs.CampaignSelectionDialog(self) return dialog.interact() == Gtk.ResponseType.APPLY def show_preferences(self): """ Display a :py:class:`.dialogs.configuration.ConfigurationDialog` instance and saves the configuration to disk if cancel is not selected. """ dialog = dialogs.ConfigurationDialog(self) if dialog.interact() != Gtk.ResponseType.CANCEL: self.emit('config-save') def signal_css_provider_parsing_error(self, css_provider, css_section, gerror): file_path = css_section.get_file() if file_path: file_path = file_path.get_path() else: file_path = '[ unknown file ]' self.logger.error("css parser error ({0}) in {1}:{2}".format( gerror.message, file_path, css_section.get_start_line() + 1)) return def signal_window_added(self, _, window): for action in self.actions.values(): window.add_action(action) def do_sftp_client_start(self): """ Start the client's preferred sftp client application in a new process. """ if not self.config['sftp_client']: gui_utilities.show_dialog_error( 'Invalid SFTP Configuration', self.get_active_window(), 'An SFTP client is not configured.\nOne can be configured in the Client Preferences.' ) return False command = str(self.config['sftp_client']) sftp_bin = shlex.split(command)[0] if not which(sftp_bin): self.logger.error('could not locate the sftp binary: ' + sftp_bin) gui_utilities.show_dialog_error( 'Invalid SFTP Configuration', self.get_active_window(), "Could not find the SFTP binary '{0}'".format(sftp_bin)) return False try: command = command.format( server=self.config['server'], username=self.config['server_username'], web_root=self.config['server_config']['server.web_root']) except KeyError as error: self.logger.error( "key error while parsing the sftp command for token: {0}". format(error.args[0])) gui_utilities.show_dialog_error( 'Invalid SFTP Configuration', self.get_active_window(), "Invalid token '{0}' in the SFTP command.".format( error.args[0])) return False self.logger.debug("starting sftp client command: {0}".format(command)) utilities.start_process(command, wait=False) return def stop_remote_service(self): """ Stop the remote King Phisher server. This will request that the server stop processing new requests and exit. This will display a confirmation dialog before performing the operation. If the remote service is stopped, the client will quit. """ active_window = self.get_active_window() if not gui_utilities.show_dialog_yes_no( 'Stop The Remote King Phisher Service?', active_window, 'This will stop the remote King Phisher service and\nnew incoming requests will not be processed.' ): return self.rpc('shutdown') self.logger.info('the remote king phisher service has been stopped') gui_utilities.show_dialog_error( 'Now Exiting', active_window, 'The remote service has been stopped.') self.quit() return @property def style_context(self): window = self.get_active_window() or self.main_window if window is None: return None return window.get_style_context()
class MailSenderThread(threading.Thread): """ The King Phisher threaded email message sender. This object manages the sending of emails for campaigns and supports pausing the sending of messages which can later be resumed by unpausing. This object reports its information to the GUI through an optional :py:class:`.MailSenderSendTab` instance, these two objects are very interdependent. """ def __init__(self, config, target_file, rpc, tab=None): """ :param dict config: The King Phisher client configuration. :param str target_file: The CSV formatted file to read message targets from. :param tab: The GUI tab to report information to. :type tab: :py:class:`.MailSenderSendTab` :param rpc: The client's connected RPC instance. :type rpc: :py:class:`.KingPhisherRPCClient` """ super(MailSenderThread, self).__init__() self.daemon = True self.logger = logging.getLogger('KingPhisher.Client.' + self.__class__.__name__) self.config = config self.target_file = target_file """The name of the target file in CSV format.""" self.tab = tab """The optional :py:class:`.MailSenderSendTab` instance for reporting status messages to the GUI.""" self.rpc = rpc self._ssh_forwarder = None self.smtp_connection = None """The :py:class:`smtplib.SMTP` connection instance.""" self.smtp_server = parse_server(self.config['smtp_server'], 25) self.running = threading.Event() """A :py:class:`threading.Event` object indicating if emails are being sent.""" self.paused = threading.Event() """A :py:class:`threading.Event` object indicating if the email sending operation is or should be paused.""" self.should_exit = threading.Event() self.max_messages_per_minute = float( self.config.get('smtp_max_send_rate', 0.0)) self._mime_attachments = None def tab_notify_sent(self, emails_done, emails_total): """ Notify the tab that messages have been sent. :param int emails_done: The number of emails that have been sent. :param int emails_total: The total number of emails that are going to be sent. """ if isinstance(self.tab, gui_utilities.GladeGObject): GLib.idle_add(lambda x: self.tab.notify_sent(*x), (emails_done, emails_total)) def tab_notify_status(self, message): """ Handle a status message regarding the message sending operation. :param str message: The notification message. """ self.logger.info(message.lower()) if isinstance(self.tab, gui_utilities.GladeGObject): GLib.idle_add(self.tab.notify_status, message + '\n') def tab_notify_stopped(self): """ Notify the tab that the message sending operation has stopped. """ if isinstance(self.tab, gui_utilities.GladeGObject): GLib.idle_add(self.tab.notify_stopped) def server_ssh_connect(self): """ Connect to the remote SMTP server over SSH and configure port forwarding with :py:class:`.SSHTCPForwarder` for tunneling SMTP traffic. :return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants. """ server = parse_server(self.config['ssh_server'], 22) username = self.config['ssh_username'] password = self.config['ssh_password'] remote_server = parse_server(self.config['smtp_server'], 25) try: self._ssh_forwarder = SSHTCPForwarder( server, username, password, remote_server, preferred_private_key=self.config.get('ssh_preferred_key')) self._ssh_forwarder.start() except paramiko.AuthenticationException: self.logger.warning( 'failed to authenticate to the remote ssh server') return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED except Exception: self.logger.warning('failed to connect to the remote ssh server', exc_info=True) return ConnectionErrorReason.ERROR_UNKNOWN self.logger.info( "started ssh port forwarding to the remote smtp server ({0})". format(str(self._ssh_forwarder))) self.smtp_server = self._ssh_forwarder.local_server return ConnectionErrorReason.SUCCESS def server_smtp_connect(self): """ Connect to the configured SMTP server. :return: The connection status. :rtype: bool """ if self.config.get('smtp_ssl_enable', False): SmtpClass = smtplib.SMTP_SSL else: SmtpClass = smtplib.SMTP try: self.smtp_connection = SmtpClass(*self.smtp_server, timeout=10) except socket.error: self.logger.warning( 'received a socket.error while connecting to the SMTP server') except smtplib.SMTPException: self.logger.warning( 'received an SMTPException while connecting to the SMTP server', exc_info=True) else: return True return False def server_smtp_disconnect(self): """Clean up and close the connection to the remote SMTP server.""" if self.smtp_connection: try: self.smtp_connection.quit() except smtplib.SMTPServerDisconnected: pass self.smtp_connection = None self.tab_notify_status('Disconnected from the SMTP server') def server_smtp_reconnect(self): """ Disconnect from the remote SMTP server and then attempt to open a new connection to it. :return: The reconnection status. :rtype: bool """ if self.smtp_connection: try: self.smtp_connection.quit() except smtplib.SMTPServerDisconnected: pass self.smtp_connection = None while not self.server_smtp_connect(): self.tab_notify_status('Failed to reconnect to the SMTP server') if not self.process_pause(True): return False return True def count_messages(self): """ Count the number of targets that will be sent messages. :return: The number of targets that will be sent messages. :rtype: int """ return sum(1 for _ in self.iterate_targets()) def iterate_targets(self): target_type = self.config['mailer.target_type'] if target_type == 'single': target_name = self.config['mailer.target_name'].split(' ') while len(target_name) < 2: target_name.append('') target = MessageTarget( first_name=target_name[0].strip(), last_name=target_name[1].strip(), email_address=self.config['mailer.target_email_address'], department=None) yield target elif target_type == 'file': target_file_h = open(self.target_file, 'rU') csv_reader = csv.DictReader( target_file_h, ('first_name', 'last_name', 'email_address', 'department')) for raw_target in csv_reader: department = raw_target['department'] if department is not None: department = department.strip() if department == '': department = None target = MessageTarget( first_name=raw_target['first_name'].strip(), last_name=raw_target['last_name'].strip(), email_address=raw_target['email_address'].strip(), department=department) yield target target_file_h.close() else: self.logger.error( "the configured target type '{0}' is unsupported".format( target_type)) def run(self): emails_done = 0 emails_total = self.count_messages() max_messages_per_connection = self.config.get( 'mailer.max_messages_per_connection', 5) self.running.set() self.should_exit.clear() self.paused.clear() self._prepare_env() emails_total = "{0:,}".format(emails_total) sending_line = "Sending email {{0: >{0},}} of {1} with UID: {{1}} to {{2}}".format( len(emails_total), emails_total) emails_total = int(emails_total.replace(',', '')) attachments = self.get_mime_attachments() self.logger.debug("loaded {0:,} MIME attachments".format( sum((len(attachments.files), len(attachments.images))))) for target in self.iterate_targets(): if not utilities.is_valid_email_address(target.email_address): if target.email_address: self.logger.warning('skipping invalid email address: ' + target.email_address) else: self.logger.warning('skipping blank email address') continue iteration_time = time.time() if self.should_exit.is_set(): self.tab_notify_status('Sending emails cancelled') break if not self.process_pause(): break if emails_done > 0 and (emails_done % max_messages_per_connection): self.server_smtp_reconnect() uid = make_uid() emails_done += 1 self.tab_notify_status( sending_line.format(emails_done, uid, target.email_address)) msg = getattr(self, 'create_' + self.config['mailer.message_type'])(target, uid, attachments) if not self._try_send_message(target.email_address, msg): break self.tab_notify_sent(emails_done, emails_total) campaign_id = self.config['campaign_id'] self.rpc('campaign/message/new', campaign_id, uid, target.email_address, target.first_name, target.last_name, target.department) if self.max_messages_per_minute: iteration_time = (time.time() - iteration_time) sleep_time = (60.0 / float( self.max_messages_per_minute)) - iteration_time while sleep_time > 0: sleep_chunk = min(sleep_time, 0.5) time.sleep(sleep_chunk) if self.should_exit.is_set(): break sleep_time -= sleep_chunk self._mime_attachments = None self.tab_notify_status( "Finished sending, successfully sent {0:,} messages.".format( emails_done)) self.server_smtp_disconnect() if self._ssh_forwarder: self._ssh_forwarder.stop() self._ssh_forwarder = None self.tab_notify_status('Disconnected from the SSH server') self.tab_notify_stopped() return def process_pause(self, set_pause=False): """ Pause sending emails if a pause request has been set. :param bool set_pause: Whether to request a pause before processing it. :return: Whether or not the sending operation was cancelled during the pause. :rtype: bool """ if set_pause: if isinstance(self.tab, gui_utilities.GladeGObject): gui_utilities.glib_idle_add_wait( lambda: self.tab.pause_button.set_property('active', True)) else: self.pause() if self.paused.is_set(): self.tab_notify_status('Paused sending emails, waiting to resume') self.running.wait() self.paused.clear() if self.should_exit.is_set(): self.tab_notify_status('Sending emails cancelled') return False self.tab_notify_status('Resuming sending emails') self.max_messages_per_minute = float( self.config.get('smtp_max_send_rate', 0.0)) return True def create_calendar_invite(self, target, uid, attachments): """ Create a MIME calendar invite to be sent from a set of parameters. :param target: The information for the messages intended recipient. :type target: :py:class:`.MessageTarget` :param str uid: The message's unique identifier. :param attachments: The attachments to add to the created message. :type attachments: :py:class:`Attachments` :return: The new MIME message. :rtype: :py:class:`email.mime.multipart.MIMEMultipart` """ top_msg = TopMIMEMultipart('mixed', self.config, target) related_msg = mime.multipart.MIMEMultipart('related') top_msg.attach(related_msg) alt_msg = mime.multipart.MIMEMultipart('alternative') related_msg.attach(alt_msg) part = mime.base.MIMEBase('text', 'plain', charset='utf-8') part.set_payload( 'This calendar invite requires an HTML enabled viewer.\r\n\r\n') encoders.encode_base64(part) alt_msg.attach(part) with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h: msg_template = file_h.read() formatted_msg = format_message(msg_template, self.config, first_name=target.first_name, last_name=target.last_name, uid=uid, target_email=target.email_address) part = mime.text.MIMEText(formatted_msg, 'html', 'utf-8') alt_msg.attach(part) start_time = get_invite_start_from_config(self.config) if self.config['mailer.calendar_invite_all_day']: duration = ics.DurationAllDay() else: duration = int(self.config['mailer.calendar_invite_duration']) * 60 ical = ics.Calendar( self.config['mailer.source_email'], start_time, self.config.get('mailer.calendar_invite_summary'), duration=duration, location=self.config.get('mailer.calendar_invite_location')) ical.add_attendee(target.email_address, rsvp=self.config.get('mailer.calendar_request_rsvp', False)) part = mime.base.MIMEBase('text', 'calendar', charset='utf-8', method='REQUEST') part.set_payload(str(ical)) encoders.encode_base64(part) alt_msg.attach(part) for attach in attachments.images: related_msg.attach(attach) for attach in attachments.files: top_msg.attach(attach) return top_msg def create_email(self, target, uid, attachments): """ Create a MIME email to be sent from a set of parameters. :param target: The information for the messages intended recipient. :type target: :py:class:`.MessageTarget` :param str uid: The message's unique identifier. :param attachments: The attachments to add to the created message. :type attachments: :py:class:`MessageAttachments` :return: The new MIME message. :rtype: :py:class:`email.mime.multipart.MIMEMultipart` """ msg = TopMIMEMultipart('related', self.config, target) importance = self.config.get('mailer.importance', 'Normal') if importance != 'Normal': msg['Importance'] = importance sensitivity = self.config.get('mailer.sensitivity', 'Normal') if sensitivity != 'Normal': msg['Sensitivity'] = sensitivity msg_alt = mime.multipart.MIMEMultipart('alternative') msg.attach(msg_alt) with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h: msg_template = file_h.read() formatted_msg = format_message(msg_template, self.config, first_name=target.first_name, last_name=target.last_name, uid=uid, target_email=target.email_address) msg_body = mime.text.MIMEText(formatted_msg, 'html', 'utf-8') msg_alt.attach(msg_body) # process attachments for attach in attachments.files: msg.attach(attach) for attach in attachments.images: msg.attach(attach) return msg def get_mime_attachments(self): """ Return a :py:class:`.MessageAttachments` object containing both the images and raw files to be included in sent messages. :return: A namedtuple of both files and images in their MIME containers. :rtype: :py:class:`.MessageAttachments` """ files = [] if self.config.get('mailer.attachment_file'): attachment = self.config['mailer.attachment_file'] attachfile = mime.base.MIMEBase(*mimetypes.guess_type(attachment)) attachfile.set_payload(open(attachment, 'rb').read()) encoders.encode_base64(attachfile) attachfile.add_header( 'Content-Disposition', "attachment; filename=\"{0}\"".format( os.path.basename(attachment))) files.append(attachfile) images = [] for attachment_file, attachment_name in template_environment.attachment_images.items( ): attachfile = mime.image.MIMEImage( open(attachment_file, 'rb').read()) attachfile.add_header('Content-ID', "<{0}>".format(attachment_name)) attachfile.add_header( 'Content-Disposition', "inline; filename=\"{0}\"".format(attachment_name)) images.append(attachfile) return MessageAttachments(tuple(files), tuple(images)) def _prepare_env(self): with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h: msg_template = file_h.read() template_environment.set_mode(template_environment.MODE_ANALYZE) format_message(msg_template, self.config, uid=make_uid()) template_environment.set_mode(template_environment.MODE_SEND) def _try_send_message(self, *args, **kwargs): message_sent = False while not message_sent: for _ in range(0, 3): try: self.send_message(*args, **kwargs) message_sent = True break except smtplib.SMTPException: self.tab_notify_status('Failed to send message') time.sleep(1) if not message_sent: self.server_smtp_disconnect() if not self.process_pause(True): return False self.server_smtp_reconnect() return True def send_message(self, target_email, msg): """ Send an email using the connected SMTP server. :param str target_email: The email address to send the message to. :param msg: The formatted message to be sent. :type msg: :py:class:`.mime.multipart.MIMEMultipart` """ source_email = self.config['mailer.source_email_smtp'] self.smtp_connection.sendmail(source_email, target_email, msg.as_string()) def pause(self): """ Sets the :py:attr:`~.MailSenderThread.running` and :py:attr:`~.MailSenderThread.paused` flags correctly to indicate that the object is paused. """ self.running.clear() self.paused.set() def unpause(self): """ Sets the :py:attr:`~.MailSenderThread.running` and :py:attr:`~.MailSenderThread.paused` flags correctly to indicate that the object is no longer paused. """ self.running.set() def stop(self): """ Requests that the email sending operation stop. It can not be resumed from the same position. This function blocks until the stop request has been processed and the thread exits. """ self.should_exit.set() self.unpause() if self.is_alive(): self.join() def missing_files(self): """ Return a list of all missing or unreadable files which are referenced by the message template. :return: The list of unusable files. :rtype: list """ missing = [] attachment = self.config.get('mailer.attachment_file') if attachment and not os.access(attachment, os.R_OK): missing.append(attachment) msg_template = self.config['mailer.html_file'] if not os.access(msg_template, os.R_OK): missing.append(msg_template) return missing self._prepare_env() for attachment in template_environment.attachment_images.keys(): if not os.access(attachment, os.R_OK): missing.append(attachment) return missing
class KingPhisherClientApplication(_Gtk_Application): """ This is the top level King Phisher client object. It contains the custom GObject signals, keeps all the GUI references, and manages the RPC client object. This is also the parent window for most GTK objects. :GObject Signals: :ref:`gobject-signals-application-label` """ __gsignals__ = { 'campaign-set': (GObject.SIGNAL_RUN_FIRST, None, (str,)), 'exit': (GObject.SIGNAL_RUN_LAST, None, ()), 'exit-confirm': (GObject.SIGNAL_RUN_LAST, None, ()), 'reload-css-style': (GObject.SIGNAL_RUN_FIRST, None, ()), 'rpc-cache-clear': (GObject.SIGNAL_RUN_FIRST, None, ()), 'server-connected': (GObject.SIGNAL_RUN_LAST, None, ()) } def __init__(self, config_file=None): super(KingPhisherClientApplication, self).__init__() self.logger = logging.getLogger('KingPhisher.Client.Application') # log version information for debugging purposes self.logger.debug("gi.repository GLib version: {0}".format('.'.join(map(str, GLib.glib_version)))) self.logger.debug("gi.repository GObject version: {0}".format('.'.join(map(str, GObject.pygobject_version)))) self.logger.debug("gi.repository Gtk version: {0}.{1}.{2}".format(Gtk.get_major_version(), Gtk.get_minor_version(), Gtk.get_micro_version())) if tools.has_vte: self.logger.debug("gi.repository VTE version: {0}".format(tools.Vte._version)) if graphs.has_matplotlib: self.logger.debug("matplotlib version: {0}".format(graphs.matplotlib.__version__)) self.set_property('application-id', 'org.king-phisher.client') self.set_property('register-session', True) self.config_file = (config_file or CONFIG_FILE_PATH) """The file containing the King Phisher client configuration.""" self.config = None """The primary King Phisher client configuration.""" self.main_window = None """The primary top-level :py:class:`~.MainApplicationWindow` instance.""" self.rpc = None """The :py:class:`~.KingPhisherRPCClient` instance for the application.""" self._ssh_forwarder = None """The SSH forwarder responsible for tunneling RPC communications.""" self.style_provider = None try: self.load_config(load_defaults=True) except IOError: self.logger.critical('failed to load the client configuration') raise self.connect('window-added', self.signal_window_added) self.actions = {} self._create_actions() def _create_actions(self): action = Gio.SimpleAction.new('emit-application-signal', GLib.VariantType.new('s')) action.connect('activate', self.action_emit_application_signal) accelerators = ( ('<Control><Shift>F1', 'rpc-cache-clear'), ('<Control><Shift>F12', 'reload-css-style') ) for key, signal_name in accelerators: if Gtk.check_version(3, 14, 0): self.add_accelerator(key, 'win.emit-application-signal', GLib.Variant.new_string(signal_name)) else: self.set_accels_for_action("win.emit-application-signal('{0}')".format(signal_name), (key,)) self.actions['emit-application-signal'] = action self.add_action(action) def _create_ssh_forwarder(self, server, username, password): """ Create and set the :py:attr:`~.KingPhisherClientApplication._ssh_forwarder` attribute. :param tuple server: The server information as a host and port tuple. :param str username: The username to authenticate to the SSH server with. :param str password: The password to authenticate to the SSH server with. :rtype: int :return: The local port that is forwarded to the remote server or None if the connection failed. """ active_window = self.get_active_window() title_ssh_error = 'Failed To Connect To The SSH Service' server_remote_port = self.config['server_remote_port'] local_port = random.randint(2000, 6000) try: self._ssh_forwarder = SSHTCPForwarder(server, username, password, local_port, ('127.0.0.1', server_remote_port), preferred_private_key=self.config['ssh_preferred_key']) self._ssh_forwarder.start() time.sleep(0.5) self.logger.info('started ssh port forwarding') except paramiko.AuthenticationException: self.logger.warning('failed to authenticate to the remote ssh server') gui_utilities.show_dialog_error(title_ssh_error, active_window, 'The server responded that the credentials are invalid.') except socket.error as error: gui_utilities.show_dialog_exc_socket_error(error, active_window, title=title_ssh_error) except Exception as error: self.logger.warning('failed to connect to the remote ssh server', exc_info=True) gui_utilities.show_dialog_error(title_ssh_error, active_window, "An {0}.{1} error occurred.".format(error.__class__.__module__, error.__class__.__name__)) else: return local_port self.server_disconnect() return def campaign_configure(self): assistant = assistants.CampaignAssistant(self, campaign_id=self.config['campaign_id']) assistant.assistant.set_transient_for(self.get_active_window()) assistant.assistant.set_modal(True) assistant.interact() def campaign_delete(self): """ Delete the campaign on the server. A confirmation dialog will be displayed before the operation is performed. If the campaign is deleted and a new campaign is not selected with :py:meth:`.show_campaign_selection`, the client will quit. """ if not gui_utilities.show_dialog_yes_no('Delete This Campaign?', self.get_active_window(), 'This action is irreversible, all campaign data will be lost.'): return self.rpc('db/table/delete', 'campaigns', self.config['campaign_id']) if not self.show_campaign_selection(): gui_utilities.show_dialog_error('Now Exiting', self.get_active_window(), 'A campaign must be selected.') self.quit() def campaign_rename(self): """ Show a dialog prompting the user to for the a new name to assign to the currently selected campaign. """ campaign = self.rpc.remote_table_row('campaigns', self.config['campaign_id']) prompt = dialogs.TextEntryDialog.build_prompt(self, 'Rename Campaign', 'Enter the new campaign name:', campaign.name) response = prompt.interact() if response == None or response == campaign.name: return self.rpc('db/table/set', 'campaigns', self.config['campaign_id'], 'name', response) gui_utilities.show_dialog_info('Campaign Name Updated', self.get_active_window(), 'The campaign name was successfully changed.') def exception_hook(self, exc_type, exc_value, exc_traceback): if isinstance(exc_value, KeyboardInterrupt): self.logger.warning('received a KeyboardInterrupt exception') return exc_info = (exc_type, exc_value, exc_traceback) error_uid = str(uuid.uuid4()) self.logger.error("error uid: {0} an unhandled exception was thrown".format(error_uid), exc_info=exc_info) dialogs.ExceptionDialog(self, exc_info=exc_info, error_uid=error_uid).interact() def get_active_window(self): # work around for Gtk < 3.6 super_cls = super(KingPhisherClientApplication, self) if not hasattr(super_cls, 'get_active_window'): return None return super_cls.get_active_window() def quit(self, optional=False): """ Quit the client and perform any necessary clean up operations. If *optional* is False then the exit-confirm signal will not be sent and there will not be any opportunities for the client to cancel the operation. :param bool optional: Whether the quit is request is optional or not. """ self.emit('exit-confirm' if optional else 'exit') def action_emit_application_signal(self, _, signal_name): signal_name = signal_name.get_string() self.logger.debug('action emit-application-signal invoked for ' + signal_name) self.emit(signal_name) def do_activate(self): Gtk.Application.do_activate(self) sys.excepthook = self.exception_hook # load a custom css file if one is available css_file = find.find_data_file('king-phisher-client.css') if css_file: self.style_provider = self.load_style_css(css_file) else: self.logger.debug('no custom css file was found') # create and show the main window self.main_window = windows.MainApplicationWindow(self.config, self) self.main_window.set_position(Gtk.WindowPosition.CENTER) self.main_window.show() def do_campaign_set(self, campaign_id): self.logger.info("campaign set to {0} (id: {1})".format(self.config['campaign_name'], self.config['campaign_id'])) self.emit('rpc-cache-clear') def do_exit(self): self.main_window.hide() gui_utilities.gtk_widget_destroy_children(self.main_window) gui_utilities.gtk_sync() self.server_disconnect() self.main_window.destroy() return def do_exit_confirm(self): self.emit('exit') def do_reload_css_style(self): if self.style_provider: Gtk.StyleContext.remove_provider_for_screen( Gdk.Screen.get_default(), self.style_provider ) self.style_provider = None css_file = find.find_data_file('king-phisher-client.css') if css_file: self.style_provider = self.load_style_css(css_file) def do_rpc_cache_clear(self): if self.rpc: self.rpc.cache_clear() def do_server_connected(self): self.load_server_config() campaign_id = self.config.get('campaign_id') if campaign_id is None: if not self.show_campaign_selection(): self.logger.debug('no campaign selected, disconnecting and exiting') self.emit('exit') return True campaign_info = self.rpc.remote_table_row('campaigns', self.config['campaign_id'], cache=True) if campaign_info is None: if not self.show_campaign_selection(): self.logger.debug('no campaign selected, disconnecting and exiting') self.emit('exit') return True campaign_info = self.rpc.remote_table_row('campaigns', self.config['campaign_id'], cache=True, refresh=True) self.config['campaign_name'] = campaign_info.name self.emit('campaign-set', self.config['campaign_id']) return def do_shutdown(self): Gtk.Application.do_shutdown(self) sys.excepthook = sys.__excepthook__ self.save_config() def load_config(self, load_defaults=False): """ Load the client configuration from disk and set the :py:attr:`~.KingPhisherClientApplication.config` attribute. :param bool load_defaults: Load missing options from the template configuration file. """ self.logger.info('loading the config from disk') config_file = os.path.expanduser(self.config_file) client_template = find.find_data_file('client_config.json') if not (os.path.isfile(config_file) and os.stat(config_file).st_size): shutil.copy(client_template, config_file) with open(config_file, 'r') as tmp_file: self.config = json.load(tmp_file) if load_defaults: with open(client_template, 'r') as tmp_file: client_template = json.load(tmp_file) for key, value in client_template.items(): if not key in self.config: self.config[key] = value def load_server_config(self): """Load the necessary values from the server's configuration.""" self.config['server_config'] = self.rpc('config/get', ['server.require_id', 'server.secret_id', 'server.tracking_image', 'server.web_root']) return def load_style_css(self, css_file): self.logger.debug('loading style from css file: ' + css_file) css_file = Gio.File.new_for_path(css_file) style_provider = Gtk.CssProvider() style_provider.connect('parsing-error', self.signal_css_provider_parsing_error) try: style_provider.load_from_file(css_file) except GLib.Error: self.logger.error('there was an error parsing the css file, it will not be applied as a style provider') return None Gtk.StyleContext.add_provider_for_screen( Gdk.Screen.get_default(), style_provider, Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION ) return style_provider def save_config(self): """Write the client configuration to disk.""" self.logger.info('writing the client configuration to disk') config = copy.copy(self.config) for key in self.config.keys(): if 'password' in key or key == 'server_config': del config[key] with open(os.path.expanduser(self.config_file), 'w') as config_file_h: json.dump(config, config_file_h, sort_keys=True, indent=2, separators=(',', ': ')) def server_connect(self): server_version_info = None title_rpc_error = 'Failed To Connect To The King Phisher RPC Service' active_window = self.get_active_window() server = parse_server(self.config['server'], 22) username = self.config['server_username'] password = self.config['server_password'] if server[0] == 'localhost' or (utilities.is_valid_ip_address(server[0]) and ipaddress.ip_address(server[0]).is_loopback): local_port = self.config['server_remote_port'] self.logger.info("connecting to local king-phisher instance") else: local_port = self._create_ssh_forwarder(server, username, password) if not local_port: return self.rpc = client_rpc.KingPhisherRPCClient(('localhost', local_port), username=username, password=password, use_ssl=self.config.get('server_use_ssl')) if self.config.get('rpc.serializer'): try: self.rpc.set_serializer(self.config['rpc.serializer']) except ValueError as error: self.logger.error("failed to set the rpc serializer, error: '{0}'".format(error.message)) connection_failed = True try: assert self.rpc('client/initialize') server_version_info = self.rpc('version') assert server_version_info != None except AdvancedHTTPServerRPCError as err: if err.status == 401: self.logger.warning('failed to authenticate to the remote king phisher service') gui_utilities.show_dialog_error(title_rpc_error, active_window, 'The server responded that the credentials are invalid.') else: self.logger.warning('failed to connect to the remote rpc server with http status: ' + str(err.status)) gui_utilities.show_dialog_error(title_rpc_error, active_window, 'The server responded with HTTP status: ' + str(err.status)) except socket.error as error: gui_utilities.show_dialog_exc_socket_error(error, active_window) except Exception as error: self.logger.warning('failed to connect to the remote rpc service', exc_info=True) gui_utilities.show_dialog_error(title_rpc_error, active_window, 'Ensure that the King Phisher Server is currently running.') else: connection_failed = False finally: if connection_failed: self.rpc = None self.server_disconnect() return server_rpc_api_version = server_version_info.get('rpc_api_version', -1) if isinstance(server_rpc_api_version, int): # compatibility with pre-0.2.0 version server_rpc_api_version = (server_rpc_api_version, 0) self.logger.info("successfully connected to the king phisher server (version: {0} rpc api version: {1}.{2})".format(server_version_info['version'], server_rpc_api_version[0], server_rpc_api_version[1])) error_text = None if server_rpc_api_version[0] < version.rpc_api_version.major or (server_rpc_api_version[0] == version.rpc_api_version.major and server_rpc_api_version[1] < version.rpc_api_version.minor): error_text = 'The server is running an old and incompatible version.' error_text += '\nPlease update the remote server installation.' elif server_rpc_api_version[0] > version.rpc_api_version.major: error_text = 'The client is running an old and incompatible version.' error_text += '\nPlease update the local client installation.' if error_text: gui_utilities.show_dialog_error('The RPC API Versions Are Incompatible', active_window, error_text) self.server_disconnect() return self.emit('server-connected') return def server_disconnect(self): """Clean up the SSH TCP connections and disconnect from the server.""" if self._ssh_forwarder: self._ssh_forwarder.stop() self._ssh_forwarder = None self.logger.info('stopped ssh port forwarding') self.rpc = None return def show_campaign_graph(self, graph_name): """ Create a new :py:class:`.CampaignGraph` instance and make it into a window. *graph_name* must be the name of a valid, exported graph provider. :param str graph_name: The name of the graph to make a window of. """ cls = graphs.get_graph(graph_name) graph_inst = cls(self) graph_inst.load_graph() window = graph_inst.make_window() window.show() def show_campaign_selection(self): """ Display the campaign selection dialog in a new :py:class:`.CampaignSelectionDialog` instance. :return: Whether or not a campaign was selected. :rtype: bool """ dialog = dialogs.CampaignSelectionDialog(self) return dialog.interact() == Gtk.ResponseType.APPLY def show_preferences(self): """ Display a :py:class:`.dialogs.configuration.ConfigurationDialog` instance and saves the configuration to disk if cancel is not selected. """ dialog = dialogs.ConfigurationDialog(self) if dialog.interact() != Gtk.ResponseType.CANCEL: self.save_config() def signal_css_provider_parsing_error(self, css_provider, css_section, gerror): file_path = css_section.get_file() if file_path: file_path = file_path.get_path() else: file_path = '[ unknown file ]' self.logger.error("css parser error ({0}) in {1}:{2}".format(gerror.message, file_path, css_section.get_start_line())) return def signal_window_added(self, _, window): for action in self.actions.values(): window.add_action(action) def start_sftp_client(self): """ Start the client's preferred sftp client application in a new process. """ if not self.config['sftp_client']: gui_utilities.show_dialog_error('Invalid SFTP Configuration', self.get_active_window(), 'An SFTP client is not configured.\nOne can be configured in the Client Preferences.') return False command = str(self.config['sftp_client']) sftp_bin = shlex.split(command)[0] if not which(sftp_bin): self.logger.error('could not locate the sftp binary: ' + sftp_bin) gui_utilities.show_dialog_error('Invalid SFTP Configuration', self.get_active_window(), "Could not find the SFTP binary '{0}'".format(sftp_bin)) return False try: command = command.format( server=self.config['server'], username=self.config['server_username'], web_root=self.config['server_config']['server.web_root'] ) except KeyError as error: self.logger.error("key error while parsing the sftp command for token: {0}".format(error.args[0])) gui_utilities.show_dialog_error('Invalid SFTP Configuration', self.get_active_window(), "Invalid token '{0}' in the SFTP command.".format(error.args[0])) return False self.logger.debug("starting sftp client command: {0}".format(command)) utilities.start_process(command, wait=False) return def stop_remote_service(self): """ Stop the remote King Phisher server. This will request that the server stop processing new requests and exit. This will display a confirmation dialog before performing the operation. If the remote service is stopped, the client will quit. """ if not gui_utilities.show_dialog_yes_no('Stop The Remote King Phisher Service?', self.get_active_window(), 'This will stop the remote King Phisher service and\nnew incoming requests will not be processed.'): return self.rpc('shutdown') self.logger.info('the remote king phisher service has been stopped') gui_utilities.show_dialog_error('Now Exiting', self.get_active_window(), 'The remote service has been stopped.') self.quit() return
def server_connect(self): import socket server_version_info = None while True: if self.ssh_forwarder: self.ssh_forwarder.stop() self.ssh_forwarder = None self.logger.info('stopped ssh port forwarding') login_dialog = KingPhisherClientLoginDialog(self.config, self) login_dialog.objects_load_from_config() response = login_dialog.interact() if response == Gtk.ResponseType.CANCEL: return False server = utilities.server_parse(self.config['server'], 22) username = self.config['server_username'] password = self.config['server_password'] server_remote_port = self.config.get('server_remote_port', 80) local_port = random.randint(2000, 6000) try: self.ssh_forwarder = SSHTCPForwarder(server, username, password, local_port, ('127.0.0.1', server_remote_port), preferred_private_key=self.config.get('ssh_preferred_key')) self.ssh_forwarder.start() time.sleep(0.5) self.logger.info('started ssh port forwarding') except paramiko.AuthenticationException: self.logger.warning('failed to authenticate to the remote ssh server') gui_utilities.show_dialog_error('Invalid Credentials', self) continue except: self.logger.warning('failed to connect to the remote ssh server') gui_utilities.show_dialog_error('Failed To Connect To The SSH Service', self) continue self.rpc = KingPhisherRPCClient(('localhost', local_port), username=username, password=password) try: server_version_info = self.rpc('version') assert(self.rpc('client/initialize')) except AdvancedHTTPServerRPCError as err: if err.status == 401: self.logger.warning('failed to authenticate to the remote king phisher service') gui_utilities.show_dialog_error('Invalid Credentials', self) else: self.logger.warning('failed to connect to the remote rpc server with http status: ' + str(err.status)) gui_utilities.show_dialog_error('Failed To Connect To The King Phisher RPC Service', self, 'The server responded with HTTP status: ' + str(err.status)) continue except: self.logger.warning('failed to connect to the remote rpc service') gui_utilities.show_dialog_error('Failed To Connect To The King Phisher RPC Service', self) continue break assert(server_version_info != None) server_rpc_api_version = server_version_info.get('rpc_api_version', -1) self.logger.info("successfully connected to the king phisher server (version: {0} rpc api version: {1})".format(server_version_info['version'], server_rpc_api_version)) self.server_local_port = local_port if server_rpc_api_version != version.rpc_api_version: if version.rpc_api_version < server_rpc_api_version: secondary_text = 'The local client is not up to date with the server version.' else: secondary_text = 'The remote server is not up to date with the client version.' secondary_text += '\nPlease ensure that both the client and server are fully up to date.' gui_utilities.show_dialog_error('The RPC API Versions Are Incompatible', self, secondary_text) self.server_disconnect() return False return True
def server_connect(self): """ Perform the connection setup as part of the server connection initialization process. This will display a GUI window requesting the connection information. An :py:class:`.SSHTCPForwarder` instance is created and configured for tunneling traffic to the King Phisher server. This also verifies that the RPC API version running on the server is compatible with the client. :return: Whether or not the connection attempt was successful. :rtype: bool """ server_version_info = None title_ssh_error = 'Failed To Connect To The SSH Service' title_rpc_error = 'Failed To Connect To The King Phisher RPC Service' while True: if self.ssh_forwarder: self.ssh_forwarder.stop() self.ssh_forwarder = None self.logger.info('stopped ssh port forwarding') login_dialog = dialogs.KingPhisherClientLoginDialog( self.config, self) login_dialog.objects_load_from_config() response = login_dialog.interact() if response == Gtk.ResponseType.CANCEL: return False server = utilities.server_parse(self.config['server'], 22) username = self.config['server_username'] password = self.config['server_password'] server_remote_port = self.config['server_remote_port'] local_port = random.randint(2000, 6000) try: self.ssh_forwarder = SSHTCPForwarder( server, username, password, local_port, ('127.0.0.1', server_remote_port), preferred_private_key=self.config['ssh_preferred_key']) self.ssh_forwarder.start() time.sleep(0.5) self.logger.info('started ssh port forwarding') except paramiko.AuthenticationException: self.logger.warning( 'failed to authenticate to the remote ssh server') gui_utilities.show_dialog_error( title_ssh_error, self, 'The server responded that the credentials are invalid') continue except socket.error as error: error_number, error_message = error.args if error_number == 111: gui_utilities.show_dialog_error( title_ssh_error, self, 'The server refused the connection') else: gui_utilities.show_dialog_error( title_ssh_error, self, "Socket error #{0} ({1})".format( (error_number or 'NOT-SET'), error_message)) continue except Exception: self.logger.warning( 'failed to connect to the remote ssh server') gui_utilities.show_dialog_error(title_ssh_error, self) continue self.rpc = KingPhisherRPCClient( ('localhost', local_port), username=username, password=password, use_ssl=self.config.get('server_use_ssl')) if self.config.get('rpc.serializer'): try: self.rpc.set_serializer(self.config['rpc.serializer']) except ValueError as error: self.logger.error( "failed to set the rpc serializer, error: '{0}'". format(error.message)) try: assert (self.rpc('client/initialize')) server_version_info = self.rpc('version') except AdvancedHTTPServerRPCError as err: if err.status == 401: self.logger.warning( 'failed to authenticate to the remote king phisher service' ) gui_utilities.show_dialog_error( title_rpc_error, self, 'The server responded that the credentials are invalid' ) else: self.logger.warning( 'failed to connect to the remote rpc server with http status: ' + str(err.status)) gui_utilities.show_dialog_error( title_rpc_error, self, 'The server responded with HTTP status: ' + str(err.status)) continue except: self.logger.warning( 'failed to connect to the remote rpc service') gui_utilities.show_dialog_error( title_rpc_error, self, 'Ensure that the King Phisher Server is currently running') continue break assert (server_version_info != None) server_rpc_api_version = server_version_info.get('rpc_api_version', -1) self.logger.info( "successfully connected to the king phisher server (version: {0} rpc api version: {1})" .format(server_version_info['version'], server_rpc_api_version)) self.server_local_port = local_port if server_rpc_api_version != version.rpc_api_version: if version.rpc_api_version < server_rpc_api_version: secondary_text = 'The local client is not up to date with the server version.' else: secondary_text = 'The remote server is not up to date with the client version.' secondary_text += '\nPlease ensure that both the client and server are fully up to date.' gui_utilities.show_dialog_error( 'The RPC API Versions Are Incompatible', self, secondary_text) self.server_disconnect() return False return True
class KingPhisherClient(_Gtk_ApplicationWindow): """ This is the top level King Phisher client object. It contains the custom GObject signals, keeps all the GUI references, and manages the RPC client object. This is also the parent window for most GTK objects. :GObject Signals: :ref:`gobject-signals-kingphisher-client-label` """ __gsignals__ = { 'campaign-set': (GObject.SIGNAL_RUN_FIRST, None, (str, )), 'exit': (GObject.SIGNAL_RUN_LAST, None, ()), 'exit-confirm': (GObject.SIGNAL_RUN_LAST, None, ()), 'server-connected': (GObject.SIGNAL_RUN_FIRST, None, ()) } def __init__(self, config, application): """ :param dict config: The main King Phisher client configuration. :param application: The application instance to which this window belongs. :type application: :py:class:`.KingPhisherClientApplication` """ assert isinstance(application, Gtk.Application) super(KingPhisherClient, self).__init__(application=application) self.application = application self.logger = logging.getLogger('KingPhisher.Client.MainWindow') self.config = config """The main King Phisher client configuration.""" self._ssh_forwarder = None self.set_property('title', 'King Phisher') vbox = Gtk.Box() vbox.set_property('orientation', Gtk.Orientation.VERTICAL) vbox.show() self.add(vbox) default_icon_file = find.find_data_file('king-phisher-icon.svg') if default_icon_file: icon_pixbuf = GdkPixbuf.Pixbuf.new_from_file(default_icon_file) self.set_default_icon(icon_pixbuf) action_group = Gtk.ActionGroup(name="client_window_actions") self._add_menu_actions(action_group) uimanager = self._create_ui_manager() self._add_menu_optional_actions(action_group, uimanager) self.add_accel_group(uimanager.get_accel_group()) uimanager.insert_action_group(action_group) self.uimanager = uimanager menubar = uimanager.get_widget("/MenuBar") vbox.pack_start(menubar, False, False, 0) # create notebook and tabs self.notebook = Gtk.Notebook() """The primary :py:class:`Gtk.Notebook` that holds the top level taps of the client GUI.""" self.notebook.connect('switch-page', self.signal_notebook_switch_page) self.notebook.set_scrollable(True) vbox.pack_start(self.notebook, True, True, 0) self.tabs = {} current_page = self.notebook.get_current_page() self.last_page_id = current_page mailer_tab = MailSenderTab(self.config, self, self.application) self.tabs['mailer'] = mailer_tab self.notebook.insert_page(mailer_tab.box, mailer_tab.label, current_page + 1) self.notebook.set_current_page(current_page + 1) campaign_tab = CampaignViewTab(self.config, self, self.application) campaign_tab.box.show() self.tabs['campaign'] = campaign_tab self.notebook.insert_page(campaign_tab.box, campaign_tab.label, current_page + 2) self.set_size_request(800, 600) self.connect('delete-event', self.signal_delete_event) self.notebook.show() self.show() self.rpc = None # needs to be initialized last """The :py:class:`.KingPhisherRPCClient` instance.""" login_dialog = dialogs.LoginDialog(self.config, self) login_dialog.dialog.connect('response', self.signal_login_dialog_response, login_dialog) login_dialog.dialog.show() def _add_menu_actions(self, action_group): # File Menu Actions action = Gtk.Action(name='FileMenu', label='File', tooltip=None, stock_id=None) action_group.add_action(action) action = Gtk.Action(name='FileOpenCampaign', label='_Open Campaign', tooltip='Open a Campaign', stock_id=Gtk.STOCK_NEW) action.connect('activate', lambda x: self.show_campaign_selection()) action_group.add_action_with_accel(action, '<control>O') action = Gtk.Action(name='FileImportMenu', label='Import', tooltip=None, stock_id=None) action_group.add_action(action) action = Gtk.Action(name='FileImportMessageConfiguration', label='Message Configuration', tooltip='Message Configuration', stock_id=None) action.connect('activate', lambda x: self.tabs['mailer'].import_message_data()) action_group.add_action(action) action = Gtk.Action(name='FileExportMenu', label='Export', tooltip=None, stock_id=None) action_group.add_action(action) action = Gtk.Action(name='FileExportCampaignXML', label='Campaign XML', tooltip='Campaign XML', stock_id=None) action.connect('activate', lambda x: self.export_campaign_xml()) action_group.add_action(action) action = Gtk.Action(name='FileExportMessageConfiguration', label='Message Configuration', tooltip='Message Configuration', stock_id=None) action.connect('activate', lambda x: self.tabs['mailer'].export_message_data()) action_group.add_action(action) action = Gtk.Action(name='FileQuit', label=None, tooltip=None, stock_id=Gtk.STOCK_QUIT) action.connect('activate', lambda x: self.emit('exit-confirm')) action_group.add_action_with_accel(action, '<control>Q') # Edit Menu Actions action = Gtk.Action(name='EditMenu', label='Edit', tooltip=None, stock_id=None) action_group.add_action(action) action = Gtk.Action(name='EditPreferences', label='Preferences', tooltip='Edit Preferences', stock_id=Gtk.STOCK_EDIT) action.connect('activate', lambda x: self.edit_preferences()) action_group.add_action(action) action = Gtk.Action(name='EditDeleteCampaign', label='Delete Campaign', tooltip='Delete Campaign', stock_id=None) action.connect('activate', lambda x: self.delete_campaign()) action_group.add_action(action) action = Gtk.Action(name='EditRenameCampaign', label='Rename Campaign', tooltip='Rename Campaign', stock_id=None) action.connect('activate', lambda x: self.rename_campaign()) action_group.add_action(action) action = Gtk.Action(name='EditStopService', label='Stop Service', tooltip='Stop The Remote King-Phisher Service', stock_id=None) action.connect('activate', lambda x: self.stop_remote_service()) action_group.add_action(action) # Tools Menu Action action = Gtk.Action(name='ToolsMenu', label='Tools', tooltip=None, stock_id=None) action_group.add_action(action) action = Gtk.Action(name='ToolsRPCTerminal', label='RPC Terminal', tooltip='RPC Terminal', stock_id=None) action.connect( 'activate', lambda x: tools.KingPhisherClientRPCTerminal( self.config, self, self.get_property('application'))) action_group.add_action_with_accel(action, '<control>F1') action = Gtk.Action(name='ToolsCloneWebPage', label='Clone Web Page', tooltip='Clone A Web Page', stock_id=None) action.connect( 'activate', lambda x: dialogs.ClonePageDialog(self.config, self).interact()) action_group.add_action(action) # Help Menu Actions action = Gtk.Action(name='HelpMenu', label='Help', tooltip=None, stock_id=None) action_group.add_action(action) action = Gtk.Action(name='HelpAbout', label='About', tooltip='About', stock_id=None) action.connect( 'activate', lambda x: dialogs.AboutDialog(self.config, self).interact()) action_group.add_action(action) action = Gtk.Action(name='HelpWiki', label='Wiki', tooltip='Wiki', stock_id=None) action.connect( 'activate', lambda x: utilities.open_uri( 'https://github.com/securestate/king-phisher/wiki')) action_group.add_action(action) def _add_menu_optional_actions(self, action_group, uimanager): if sys.platform.startswith('linux'): action = Gtk.Action(name='ToolsSFTPClient', label='SFTP Client', tooltip='SFTP Client', stock_id=None) action.connect('activate', lambda x: self.start_sftp_client()) action_group.add_action_with_accel(action, '<control>F2') merge_id = uimanager.new_merge_id() uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu', 'ToolsSFTPClient', 'ToolsSFTPClient', Gtk.UIManagerItemType.MENUITEM, False) if graphs.has_matplotlib: action = Gtk.Action(name='ToolsGraphMenu', label='Create Graph', tooltip='Create A Graph', stock_id=None) action_group.add_action(action) for graph_name in graphs.get_graphs(): action_name = 'ToolsGraph' + graph_name graph = graphs.get_graph(graph_name) action = Gtk.Action(name=action_name, label=graph.name_human, tooltip=graph.name_human, stock_id=None) action.connect('activate', self.signal_activate_popup_menu_create_graph, graph_name) action_group.add_action(action) merge_id = uimanager.new_merge_id() uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu', 'ToolsGraphMenu', 'ToolsGraphMenu', Gtk.UIManagerItemType.MENU, False) for graph_name in sorted( graphs.get_graphs(), key=lambda gn: graphs.get_graph(gn).name_human): action_name = 'ToolsGraph' + graph_name uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu/ToolsGraphMenu', action_name, action_name, Gtk.UIManagerItemType.MENUITEM, False) def _create_ssh_forwarder(self, server, username, password): """ Create and set the :py:attr:`~.KingPhisherClient._ssh_forwarder` attribute. :param tuple server: The server information as a host and port tuple. :param str username: The username to authenticate to the SSH server with. :param str password: The password to authenticate to the SSH server with. :rtype: int :return: The local port that is forwarded to the remote server or None if the connection failed. """ title_ssh_error = 'Failed To Connect To The SSH Service' server_remote_port = self.config['server_remote_port'] local_port = random.randint(2000, 6000) try: self._ssh_forwarder = SSHTCPForwarder( server, username, password, local_port, ('127.0.0.1', server_remote_port), preferred_private_key=self.config['ssh_preferred_key']) self._ssh_forwarder.start() time.sleep(0.5) self.logger.info('started ssh port forwarding') except paramiko.AuthenticationException: self.logger.warning( 'failed to authenticate to the remote ssh server') gui_utilities.show_dialog_error( title_ssh_error, self, 'The server responded that the credentials are invalid.') except socket.error as error: gui_utilities.show_dialog_exc_socket_error(error, self, title=title_ssh_error) except Exception: self.logger.warning('failed to connect to the remote ssh server') gui_utilities.show_dialog_error(title_ssh_error, self, 'An unknown error occurred.') else: return local_port self.server_disconnect() return def _create_ui_manager(self): uimanager = Gtk.UIManager() with open(find.find_data_file( 'ui_info/client_window.xml')) as ui_info_file: ui_data = ui_info_file.read() uimanager.add_ui_from_string(ui_data) return uimanager def signal_activate_popup_menu_create_graph(self, _, graph_name): return self.show_campaign_graph(graph_name) def signal_notebook_switch_page(self, notebook, current_page, index): #previous_page = notebook.get_nth_page(self.last_page_id) self.last_page_id = index mailer_tab = self.tabs.get('mailer') campaign_tab = self.tabs.get('campaign') notebook = None if mailer_tab and current_page == mailer_tab.box: notebook = mailer_tab.notebook elif campaign_tab and current_page == campaign_tab.box: notebook = campaign_tab.notebook if notebook: index = notebook.get_current_page() notebook.emit('switch-page', notebook.get_nth_page(index), index) def signal_delete_event(self, x, y): self.emit('exit-confirm') return True def do_campaign_set(self, campaign_id): self.rpc.cache_clear() self.logger.info("campaign set to {0} (id: {1})".format( self.config['campaign_name'], self.config['campaign_id'])) def do_exit(self): self.hide() gui_utilities.gtk_widget_destroy_children(self) gui_utilities.gtk_sync() self.server_disconnect() self.destroy() return def do_exit_confirm(self): self.emit('exit') def do_server_connected(self): self.load_server_config() campaign_id = self.config.get('campaign_id') if campaign_id == None: if not self.show_campaign_selection(): self.logger.debug( 'no campaign selected, disconnecting and exiting') self.emit('exit') return True campaign_info = self.rpc.remote_table_row('campaigns', self.config['campaign_id'], cache=True) if campaign_info == None: if not self.show_campaign_selection(): self.logger.debug( 'no campaign selected, disconnecting and exiting') self.emit('exit') return True campaign_info = self.rpc.remote_table_row( 'campaigns', self.config['campaign_id'], cache=True, refresh=True) self.config['campaign_name'] = campaign_info.name self.emit('campaign-set', self.config['campaign_id']) return def client_quit(self): """ Unconditionally quit the client and perform any necessary clean up operations. The exit-confirm signal will not be sent so there will not be any opportunities for the client to cancel the operation. """ self.emit('exit') def signal_login_dialog_response(self, dialog, response, glade_dialog): server_version_info = None title_rpc_error = 'Failed To Connect To The King Phisher RPC Service' if response == Gtk.ResponseType.CANCEL or response == Gtk.ResponseType.DELETE_EVENT: dialog.destroy() self.emit('exit') return True glade_dialog.objects_save_to_config() server = utilities.server_parse(self.config['server'], 22) username = self.config['server_username'] password = self.config['server_password'] if server[0] == 'localhost' or (utilities.is_valid_ip_address( server[0]) and ipaddress.ip_address(server[0]).is_loopback): local_port = self.config['server_remote_port'] self.logger.info("connecting to local king-phisher instance") else: local_port = self._create_ssh_forwarder(server, username, password) if not local_port: return self.rpc = KingPhisherRPCClient( ('localhost', local_port), username=username, password=password, use_ssl=self.config.get('server_use_ssl')) if self.config.get('rpc.serializer'): try: self.rpc.set_serializer(self.config['rpc.serializer']) except ValueError as error: self.logger.error( "failed to set the rpc serializer, error: '{0}'".format( error.message)) connection_failed = True try: assert self.rpc('client/initialize') server_version_info = self.rpc('version') assert server_version_info != None except AdvancedHTTPServerRPCError as err: if err.status == 401: self.logger.warning( 'failed to authenticate to the remote king phisher service' ) gui_utilities.show_dialog_error( title_rpc_error, self, 'The server responded that the credentials are invalid.') else: self.logger.warning( 'failed to connect to the remote rpc server with http status: ' + str(err.status)) gui_utilities.show_dialog_error( title_rpc_error, self, 'The server responded with HTTP status: ' + str(err.status)) except socket.error as error: gui_utilities.show_dialog_exc_socket_error(error, self) except Exception as error: self.logger.warning('failed to connect to the remote rpc service') gui_utilities.show_dialog_error( title_rpc_error, self, 'Ensure that the King Phisher Server is currently running.') else: connection_failed = False finally: if connection_failed: self.rpc = None self.server_disconnect() return server_rpc_api_version = server_version_info.get('rpc_api_version', -1) if isinstance(server_rpc_api_version, int): # compatibility with pre-0.2.0 version server_rpc_api_version = (server_rpc_api_version, 0) self.logger.info( "successfully connected to the king phisher server (version: {0} rpc api version: {1}.{2})" .format(server_version_info['version'], server_rpc_api_version[0], server_rpc_api_version[1])) self.server_local_port = local_port error_text = None if server_rpc_api_version[0] < version.rpc_api_version.major or ( server_rpc_api_version[0] == version.rpc_api_version.major and server_rpc_api_version[1] < version.rpc_api_version.minor): error_text = 'The server is running an old and incompatible version.' error_text += '\nPlease update the remote server installation.' elif server_rpc_api_version[0] > version.rpc_api_version.major: error_text = 'The client is running an old and incompatible version.' error_text += '\nPlease update the local client installation.' if error_text: gui_utilities.show_dialog_error( 'The RPC API Versions Are Incompatible', self, error_text) self.server_disconnect() return dialog.destroy() self.emit('server-connected') return def server_disconnect(self): """Clean up the SSH TCP connections and disconnect from the server.""" if self._ssh_forwarder: self._ssh_forwarder.stop() self._ssh_forwarder = None self.logger.info('stopped ssh port forwarding') return def load_server_config(self): """Load the necessary values from the server's configuration.""" self.config['server_config'] = self.rpc('config/get', [ 'server.require_id', 'server.secret_id', 'server.tracking_image', 'server.web_root' ]) return def rename_campaign(self): campaign = self.rpc.remote_table_row('campaigns', self.config['campaign_id']) prompt = dialogs.TextEntryDialog.build_prompt( self.config, self, 'Rename Campaign', 'Enter the new campaign name:', campaign.name) response = prompt.interact() if response == None or response == campaign.name: return self.rpc('campaigns/set', self.config['campaign_id'], ('name', ), (response, )) gui_utilities.show_dialog_info( 'Campaign Name Updated', self, 'The campaign name was successfully changed') def delete_campaign(self): """ Delete the campaign on the server. A confirmation dialog will be displayed before the operation is performed. If the campaign is deleted and a new campaign is not selected with :py:meth:`.show_campaign_selection`, the client will quit. """ if not gui_utilities.show_dialog_yes_no( 'Delete This Campaign?', self, 'This action is irreversible, all campaign data will be lost.' ): return self.rpc('campaign/delete', self.config['campaign_id']) if not self.show_campaign_selection(): gui_utilities.show_dialog_error('Now Exiting', self, 'A campaign must be selected.') self.client_quit() def edit_preferences(self): """ Display a :py:class:`.dialogs.configuration.ConfigurationDialog` instance and saves the configuration to disk if cancel is not selected. """ dialog = dialogs.ConfigurationDialog(self.config, self) if dialog.interact() != Gtk.ResponseType.CANCEL: app = self.get_property('application') app.save_config() def export_campaign_xml(self): """Export the current campaign to an XML data file.""" dialog = gui_utilities.UtilityFileChooser('Export Campaign XML Data', self) file_name = self.config['campaign_name'] + '.xml' response = dialog.run_quick_save(file_name) dialog.destroy() if not response: return destination_file = response['target_path'] export.campaign_to_xml(self.rpc, self.config['campaign_id'], destination_file) def show_campaign_graph(self, graph_name): """ Create a new :py:class:`.CampaignGraph` instance and make it into a window. *graph_name* must be the name of a valid, exported graph provider. :param str graph_name: The name of the graph to make a window of. """ cls = graphs.get_graph(graph_name) graph_inst = cls(self.config, self) graph_inst.load_graph() window = graph_inst.make_window() window.show() def show_campaign_selection(self): """ Display the campaign selection dialog in a new :py:class:`.CampaignSelectionDialog` instance. :return: The status of the dialog. :rtype: bool """ dialog = dialogs.CampaignSelectionDialog(self.config, self) return dialog.interact() == Gtk.ResponseType.APPLY def start_sftp_client(self): """ Start the client's preferred sftp client application. """ if not self.config['sftp_client']: gui_utilities.show_dialog_warning( 'Invalid SFTP Configuration', self, 'An SFTP client is not configured') return False command = str(self.config['sftp_client']) sftp_bin = shlex.split(command)[0] if not utilities.which(sftp_bin): self.logger.warning('could not locate the sftp binary: ' + sftp_bin) gui_utilities.show_dialog_warning( 'Invalid SFTP Configuration', self, "Could not find the SFTP binary '{0}'".format(sftp_bin)) return False try: command = command.format( server=self.config['server'], username=self.config['server_username'], web_root=self.config['server_config']['server.web_root']) except KeyError: pass self.logger.debug("starting sftp client command: {0}".format(command)) utilities.start_process(command, wait=False) return def stop_remote_service(self): """ Stop the remote King Phisher server. This will request that the server stop processing new requests and exit. This will display a confirmation dialog before performing the operation. If the remote service is stopped, the client will quit. """ if not gui_utilities.show_dialog_yes_no( 'Stop The Remote King Phisher Service?', self, 'This will stop the remote King Phisher service and\nnew incoming requests will not be processed.' ): return self.rpc('shutdown') self.logger.info('the remote king phisher service has been stopped') gui_utilities.show_dialog_error( 'Now Exiting', self, 'The remote service has been stopped.') self.client_quit() return
class KingPhisherClient(_Gtk_ApplicationWindow): """ This is the top level King Phisher client object. It contains the custom GObject signals, keeps all the GUI references, and manages the RPC client object. This is also the parent window for most GTK objects. :GObject Signals: :ref:`gobject-signals-kingphisher-client-label` """ __gsignals__ = { 'campaign-set': (GObject.SIGNAL_RUN_FIRST, None, (str,)), 'exit': (GObject.SIGNAL_RUN_LAST, None, ()), 'exit-confirm': (GObject.SIGNAL_RUN_LAST, None, ()), 'server-connected': (GObject.SIGNAL_RUN_FIRST, None, ()) } def __init__(self, config, application): """ :param dict config: The main King Phisher client configuration. :param application: The application instance to which this window belongs. :type application: :py:class:`.KingPhisherClientApplication` """ assert isinstance(application, Gtk.Application) super(KingPhisherClient, self).__init__(application=application) self.application = application self.logger = logging.getLogger('KingPhisher.Client.MainWindow') self.config = config """The main King Phisher client configuration.""" self._ssh_forwarder = None self.set_property('title', 'King Phisher') vbox = Gtk.Box() vbox.set_property('orientation', Gtk.Orientation.VERTICAL) vbox.show() self.add(vbox) default_icon_file = find.find_data_file('king-phisher-icon.svg') if default_icon_file: icon_pixbuf = GdkPixbuf.Pixbuf.new_from_file(default_icon_file) self.set_default_icon(icon_pixbuf) action_group = Gtk.ActionGroup(name="client_window_actions") self._add_menu_actions(action_group) uimanager = self._create_ui_manager() self._add_menu_optional_actions(action_group, uimanager) self.add_accel_group(uimanager.get_accel_group()) uimanager.insert_action_group(action_group) self.uimanager = uimanager menubar = uimanager.get_widget("/MenuBar") vbox.pack_start(menubar, False, False, 0) # create notebook and tabs self.notebook = Gtk.Notebook() """The primary :py:class:`Gtk.Notebook` that holds the top level taps of the client GUI.""" self.notebook.connect('switch-page', self.signal_notebook_switch_page) self.notebook.set_scrollable(True) vbox.pack_start(self.notebook, True, True, 0) self.tabs = {} current_page = self.notebook.get_current_page() self.last_page_id = current_page mailer_tab = MailSenderTab(self.config, self, self.application) self.tabs['mailer'] = mailer_tab self.notebook.insert_page(mailer_tab.box, mailer_tab.label, current_page + 1) self.notebook.set_current_page(current_page + 1) campaign_tab = CampaignViewTab(self.config, self, self.application) campaign_tab.box.show() self.tabs['campaign'] = campaign_tab self.notebook.insert_page(campaign_tab.box, campaign_tab.label, current_page + 2) self.set_size_request(800, 600) self.connect('delete-event', self.signal_delete_event) self.notebook.show() self.show() self.rpc = None # needs to be initialized last """The :py:class:`.KingPhisherRPCClient` instance.""" login_dialog = dialogs.LoginDialog(self.config, self) login_dialog.dialog.connect('response', self.signal_login_dialog_response, login_dialog) login_dialog.dialog.show() def _add_menu_actions(self, action_group): # File Menu Actions action = Gtk.Action(name='FileMenu', label='File', tooltip=None, stock_id=None) action_group.add_action(action) action = Gtk.Action(name='FileOpenCampaign', label='_Open Campaign', tooltip='Open a Campaign', stock_id=Gtk.STOCK_NEW) action.connect('activate', lambda x: self.show_campaign_selection()) action_group.add_action_with_accel(action, '<control>O') action = Gtk.Action(name='FileImportMenu', label='Import', tooltip=None, stock_id=None) action_group.add_action(action) action = Gtk.Action(name='FileImportMessageConfiguration', label='Message Configuration', tooltip='Message Configuration', stock_id=None) action.connect('activate', lambda x: self.tabs['mailer'].import_message_data()) action_group.add_action(action) action = Gtk.Action(name='FileExportMenu', label='Export', tooltip=None, stock_id=None) action_group.add_action(action) action = Gtk.Action(name='FileExportCampaignXML', label='Campaign XML', tooltip='Campaign XML', stock_id=None) action.connect('activate', lambda x: self.export_campaign_xml()) action_group.add_action(action) action = Gtk.Action(name='FileExportMessageConfiguration', label='Message Configuration', tooltip='Message Configuration', stock_id=None) action.connect('activate', lambda x: self.tabs['mailer'].export_message_data()) action_group.add_action(action) action = Gtk.Action(name='FileQuit', label=None, tooltip=None, stock_id=Gtk.STOCK_QUIT) action.connect('activate', lambda x: self.emit('exit-confirm')) action_group.add_action_with_accel(action, '<control>Q') # Edit Menu Actions action = Gtk.Action(name='EditMenu', label='Edit', tooltip=None, stock_id=None) action_group.add_action(action) action = Gtk.Action(name='EditPreferences', label='Preferences', tooltip='Edit Preferences', stock_id=Gtk.STOCK_EDIT) action.connect('activate', lambda x: self.edit_preferences()) action_group.add_action(action) action = Gtk.Action(name='EditDeleteCampaign', label='Delete Campaign', tooltip='Delete Campaign', stock_id=None) action.connect('activate', lambda x: self.delete_campaign()) action_group.add_action(action) action = Gtk.Action(name='EditRenameCampaign', label='Rename Campaign', tooltip='Rename Campaign', stock_id=None) action.connect('activate', lambda x: self.rename_campaign()) action_group.add_action(action) action = Gtk.Action(name='EditStopService', label='Stop Service', tooltip='Stop The Remote King-Phisher Service', stock_id=None) action.connect('activate', lambda x: self.stop_remote_service()) action_group.add_action(action) # Tools Menu Action action = Gtk.Action(name='ToolsMenu', label='Tools', tooltip=None, stock_id=None) action_group.add_action(action) action = Gtk.Action(name='ToolsRPCTerminal', label='RPC Terminal', tooltip='RPC Terminal', stock_id=None) action.connect('activate', lambda x: tools.KingPhisherClientRPCTerminal(self.config, self, self.get_property('application'))) action_group.add_action_with_accel(action, '<control>F1') action = Gtk.Action(name='ToolsCloneWebPage', label='Clone Web Page', tooltip='Clone A Web Page', stock_id=None) action.connect('activate', lambda x: dialogs.ClonePageDialog(self.config, self).interact()) action_group.add_action(action) # Help Menu Actions action = Gtk.Action(name='HelpMenu', label='Help', tooltip=None, stock_id=None) action_group.add_action(action) action = Gtk.Action(name='HelpAbout', label='About', tooltip='About', stock_id=None) action.connect('activate', lambda x: dialogs.AboutDialog(self.config, self).interact()) action_group.add_action(action) action = Gtk.Action(name='HelpWiki', label='Wiki', tooltip='Wiki', stock_id=None) action.connect('activate', lambda x: utilities.open_uri('https://github.com/securestate/king-phisher/wiki')) action_group.add_action(action) def _add_menu_optional_actions(self, action_group, uimanager): if sys.platform.startswith('linux'): action = Gtk.Action(name='ToolsSFTPClient', label='SFTP Client', tooltip='SFTP Client', stock_id=None) action.connect('activate', lambda x: self.start_sftp_client()) action_group.add_action_with_accel(action, '<control>F2') merge_id = uimanager.new_merge_id() uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu', 'ToolsSFTPClient', 'ToolsSFTPClient', Gtk.UIManagerItemType.MENUITEM, False) if graphs.has_matplotlib: action = Gtk.Action(name='ToolsGraphMenu', label='Create Graph', tooltip='Create A Graph', stock_id=None) action_group.add_action(action) for graph_name in graphs.get_graphs(): action_name = 'ToolsGraph' + graph_name graph = graphs.get_graph(graph_name) action = Gtk.Action(name=action_name, label=graph.name_human, tooltip=graph.name_human, stock_id=None) action.connect('activate', self.signal_activate_popup_menu_create_graph, graph_name) action_group.add_action(action) merge_id = uimanager.new_merge_id() uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu', 'ToolsGraphMenu', 'ToolsGraphMenu', Gtk.UIManagerItemType.MENU, False) for graph_name in sorted(graphs.get_graphs(), key=lambda gn: graphs.get_graph(gn).name_human): action_name = 'ToolsGraph' + graph_name uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu/ToolsGraphMenu', action_name, action_name, Gtk.UIManagerItemType.MENUITEM, False) def _create_ssh_forwarder(self, server, username, password): """ Create and set the :py:attr:`~.KingPhisherClient._ssh_forwarder` attribute. :param tuple server: The server information as a host and port tuple. :param str username: The username to authenticate to the SSH server with. :param str password: The password to authenticate to the SSH server with. :rtype: int :return: The local port that is forwarded to the remote server or None if the connection failed. """ title_ssh_error = 'Failed To Connect To The SSH Service' server_remote_port = self.config['server_remote_port'] local_port = random.randint(2000, 6000) try: self._ssh_forwarder = SSHTCPForwarder(server, username, password, local_port, ('127.0.0.1', server_remote_port), preferred_private_key=self.config['ssh_preferred_key']) self._ssh_forwarder.start() time.sleep(0.5) self.logger.info('started ssh port forwarding') except paramiko.AuthenticationException: self.logger.warning('failed to authenticate to the remote ssh server') gui_utilities.show_dialog_error(title_ssh_error, self, 'The server responded that the credentials are invalid.') except socket.error as error: gui_utilities.show_dialog_exc_socket_error(error, self, title=title_ssh_error) except Exception: self.logger.warning('failed to connect to the remote ssh server') gui_utilities.show_dialog_error(title_ssh_error, self, 'An unknown error occurred.') else: return local_port self.server_disconnect() return def _create_ui_manager(self): uimanager = Gtk.UIManager() with open(find.find_data_file('ui_info/client_window.xml')) as ui_info_file: ui_data = ui_info_file.read() uimanager.add_ui_from_string(ui_data) return uimanager def signal_activate_popup_menu_create_graph(self, _, graph_name): return self.show_campaign_graph(graph_name) def signal_notebook_switch_page(self, notebook, current_page, index): #previous_page = notebook.get_nth_page(self.last_page_id) self.last_page_id = index mailer_tab = self.tabs.get('mailer') campaign_tab = self.tabs.get('campaign') notebook = None if mailer_tab and current_page == mailer_tab.box: notebook = mailer_tab.notebook elif campaign_tab and current_page == campaign_tab.box: notebook = campaign_tab.notebook if notebook: index = notebook.get_current_page() notebook.emit('switch-page', notebook.get_nth_page(index), index) def signal_delete_event(self, x, y): self.emit('exit-confirm') return True def do_campaign_set(self, campaign_id): self.rpc.cache_clear() self.logger.info("campaign set to {0} (id: {1})".format(self.config['campaign_name'], self.config['campaign_id'])) def do_exit(self): self.hide() gui_utilities.gtk_widget_destroy_children(self) gui_utilities.gtk_sync() self.server_disconnect() self.destroy() return def do_exit_confirm(self): self.emit('exit') def do_server_connected(self): self.load_server_config() campaign_id = self.config.get('campaign_id') if campaign_id == None: if not self.show_campaign_selection(): self.logger.debug('no campaign selected, disconnecting and exiting') self.emit('exit') return True campaign_info = self.rpc.remote_table_row('campaigns', self.config['campaign_id'], cache=True) if campaign_info == None: if not self.show_campaign_selection(): self.logger.debug('no campaign selected, disconnecting and exiting') self.emit('exit') return True campaign_info = self.rpc.remote_table_row('campaigns', self.config['campaign_id'], cache=True, refresh=True) self.config['campaign_name'] = campaign_info.name self.emit('campaign-set', self.config['campaign_id']) return def client_quit(self): """ Unconditionally quit the client and perform any necessary clean up operations. The exit-confirm signal will not be sent so there will not be any opportunities for the client to cancel the operation. """ self.emit('exit') def signal_login_dialog_response(self, dialog, response, glade_dialog): server_version_info = None title_rpc_error = 'Failed To Connect To The King Phisher RPC Service' if response == Gtk.ResponseType.CANCEL or response == Gtk.ResponseType.DELETE_EVENT: dialog.destroy() self.emit('exit') return True glade_dialog.objects_save_to_config() server = utilities.server_parse(self.config['server'], 22) username = self.config['server_username'] password = self.config['server_password'] if server[0] == 'localhost' or (utilities.is_valid_ip_address(server[0]) and ipaddress.ip_address(server[0]).is_loopback): local_port = self.config['server_remote_port'] self.logger.info("connecting to local king-phisher instance") else: local_port = self._create_ssh_forwarder(server, username, password) if not local_port: return self.rpc = KingPhisherRPCClient(('localhost', local_port), username=username, password=password, use_ssl=self.config.get('server_use_ssl')) if self.config.get('rpc.serializer'): try: self.rpc.set_serializer(self.config['rpc.serializer']) except ValueError as error: self.logger.error("failed to set the rpc serializer, error: '{0}'".format(error.message)) connection_failed = True try: assert self.rpc('client/initialize') server_version_info = self.rpc('version') assert server_version_info != None except AdvancedHTTPServerRPCError as err: if err.status == 401: self.logger.warning('failed to authenticate to the remote king phisher service') gui_utilities.show_dialog_error(title_rpc_error, self, 'The server responded that the credentials are invalid.') else: self.logger.warning('failed to connect to the remote rpc server with http status: ' + str(err.status)) gui_utilities.show_dialog_error(title_rpc_error, self, 'The server responded with HTTP status: ' + str(err.status)) except socket.error as error: gui_utilities.show_dialog_exc_socket_error(error, self) except Exception as error: self.logger.warning('failed to connect to the remote rpc service') gui_utilities.show_dialog_error(title_rpc_error, self, 'Ensure that the King Phisher Server is currently running.') else: connection_failed = False finally: if connection_failed: self.rpc = None self.server_disconnect() return server_rpc_api_version = server_version_info.get('rpc_api_version', -1) if isinstance(server_rpc_api_version, int): # compatibility with pre-0.2.0 version server_rpc_api_version = (server_rpc_api_version, 0) self.logger.info("successfully connected to the king phisher server (version: {0} rpc api version: {1}.{2})".format(server_version_info['version'], server_rpc_api_version[0], server_rpc_api_version[1])) self.server_local_port = local_port error_text = None if server_rpc_api_version[0] < version.rpc_api_version.major or (server_rpc_api_version[0] == version.rpc_api_version.major and server_rpc_api_version[1] < version.rpc_api_version.minor): error_text = 'The server is running an old and incompatible version.' error_text += '\nPlease update the remote server installation.' elif server_rpc_api_version[0] > version.rpc_api_version.major: error_text = 'The client is running an old and incompatible version.' error_text += '\nPlease update the local client installation.' if error_text: gui_utilities.show_dialog_error('The RPC API Versions Are Incompatible', self, error_text) self.server_disconnect() return dialog.destroy() self.emit('server-connected') return def server_disconnect(self): """Clean up the SSH TCP connections and disconnect from the server.""" if self._ssh_forwarder: self._ssh_forwarder.stop() self._ssh_forwarder = None self.logger.info('stopped ssh port forwarding') return def load_server_config(self): """Load the necessary values from the server's configuration.""" self.config['server_config'] = self.rpc('config/get', ['server.require_id', 'server.secret_id', 'server.tracking_image', 'server.web_root']) return def rename_campaign(self): campaign = self.rpc.remote_table_row('campaigns', self.config['campaign_id']) prompt = dialogs.TextEntryDialog.build_prompt(self.config, self, 'Rename Campaign', 'Enter the new campaign name:', campaign.name) response = prompt.interact() if response == None or response == campaign.name: return self.rpc('campaigns/set', self.config['campaign_id'], ('name',), (response,)) gui_utilities.show_dialog_info('Campaign Name Updated', self, 'The campaign name was successfully changed') def delete_campaign(self): """ Delete the campaign on the server. A confirmation dialog will be displayed before the operation is performed. If the campaign is deleted and a new campaign is not selected with :py:meth:`.show_campaign_selection`, the client will quit. """ if not gui_utilities.show_dialog_yes_no('Delete This Campaign?', self, 'This action is irreversible, all campaign data will be lost.'): return self.rpc('campaign/delete', self.config['campaign_id']) if not self.show_campaign_selection(): gui_utilities.show_dialog_error('Now Exiting', self, 'A campaign must be selected.') self.client_quit() def edit_preferences(self): """ Display a :py:class:`.dialogs.configuration.ConfigurationDialog` instance and saves the configuration to disk if cancel is not selected. """ dialog = dialogs.ConfigurationDialog(self.config, self) if dialog.interact() != Gtk.ResponseType.CANCEL: app = self.get_property('application') app.save_config() def export_campaign_xml(self): """Export the current campaign to an XML data file.""" dialog = gui_utilities.UtilityFileChooser('Export Campaign XML Data', self) file_name = self.config['campaign_name'] + '.xml' response = dialog.run_quick_save(file_name) dialog.destroy() if not response: return destination_file = response['target_path'] export.campaign_to_xml(self.rpc, self.config['campaign_id'], destination_file) def show_campaign_graph(self, graph_name): """ Create a new :py:class:`.CampaignGraph` instance and make it into a window. *graph_name* must be the name of a valid, exported graph provider. :param str graph_name: The name of the graph to make a window of. """ cls = graphs.get_graph(graph_name) graph_inst = cls(self.config, self) graph_inst.load_graph() window = graph_inst.make_window() window.show() def show_campaign_selection(self): """ Display the campaign selection dialog in a new :py:class:`.CampaignSelectionDialog` instance. :return: The status of the dialog. :rtype: bool """ dialog = dialogs.CampaignSelectionDialog(self.config, self) return dialog.interact() == Gtk.ResponseType.APPLY def start_sftp_client(self): """ Start the client's preferred sftp client application. """ if not self.config['sftp_client']: gui_utilities.show_dialog_warning('Invalid SFTP Configuration', self, 'An SFTP client is not configured') return False command = str(self.config['sftp_client']) sftp_bin = shlex.split(command)[0] if not utilities.which(sftp_bin): self.logger.warning('could not locate the sftp binary: ' + sftp_bin) gui_utilities.show_dialog_warning('Invalid SFTP Configuration', self, "Could not find the SFTP binary '{0}'".format(sftp_bin)) return False try: command = command.format( server=self.config['server'], username=self.config['server_username'], web_root=self.config['server_config']['server.web_root'] ) except KeyError: pass self.logger.debug("starting sftp client command: {0}".format(command)) utilities.start_process(command, wait=False) return def stop_remote_service(self): """ Stop the remote King Phisher server. This will request that the server stop processing new requests and exit. This will display a confirmation dialog before performing the operation. If the remote service is stopped, the client will quit. """ if not gui_utilities.show_dialog_yes_no('Stop The Remote King Phisher Service?', self, 'This will stop the remote King Phisher service and\nnew incoming requests will not be processed.'): return self.rpc('shutdown') self.logger.info('the remote king phisher service has been stopped') gui_utilities.show_dialog_error('Now Exiting', self, 'The remote service has been stopped.') self.client_quit() return
class KingPhisherClient(_Gtk_Window): """ This is the top level King Phisher client object. It contains the custom GObject signals, keeps all the GUI references, and manages the RPC client object. This is also the parent window for most GTK objects. :GObject Signals: :ref:`gobject-signals-kingphisher-client-label` """ __gsignals__ = { 'campaign-set': (GObject.SIGNAL_RUN_FIRST, None, (str, )), 'exit': (GObject.SIGNAL_RUN_LAST, None, ()), 'exit-confirm': (GObject.SIGNAL_RUN_LAST, None, ()) } def __init__(self, config_file=None): """ :param str config_file: The path to the configuration file to load. """ super(KingPhisherClient, self).__init__() self.logger = logging.getLogger('KingPhisher.Client') # print version information for debugging purposes self.logger.debug("gi.repository GLib version: {0}".format('.'.join( map(str, GLib.glib_version)))) self.logger.debug("gi.repository GObject version: {0}".format('.'.join( map(str, GObject.pygobject_version)))) self.logger.debug("gi.repository Gtk version: {0}.{1}.{2}".format( Gtk.get_major_version(), Gtk.get_minor_version(), Gtk.get_micro_version())) if tools.has_vte: self.logger.debug("gi.repository VTE version: {0}".format( tools.Vte._version)) if graphs.has_matplotlib: self.logger.debug("matplotlib version: {0}".format( graphs.matplotlib.__version__)) self.config_file = (config_file or CONFIG_FILE_PATH) """The file containing the King Phisher client configuration.""" self.ssh_forwarder = None """The :py:class:`.SSHTCPForwarder` instance used for tunneling traffic.""" self.config = None """The main King Phisher client configuration.""" try: self.load_config(load_defaults=True) except Exception: self.logger.critical('failed to load the client configuration') raise self.set_property('title', 'King Phisher') vbox = Gtk.Box() vbox.set_property('orientation', Gtk.Orientation.VERTICAL) vbox.show() self.add(vbox) default_icon_file = find.find_data_file('king-phisher-icon.svg') if default_icon_file: icon_pixbuf = GdkPixbuf.Pixbuf.new_from_file(default_icon_file) self.set_default_icon(icon_pixbuf) action_group = Gtk.ActionGroup(name="client_window_actions") self._add_menu_actions(action_group) uimanager = self._create_ui_manager() self._add_menu_optional_actions(action_group, uimanager) self.add_accel_group(uimanager.get_accel_group()) uimanager.insert_action_group(action_group) self.uimanager = uimanager menubar = uimanager.get_widget("/MenuBar") vbox.pack_start(menubar, False, False, 0) # create notebook and tabs self.notebook = Gtk.Notebook() """The primary :py:class:`Gtk.Notebook` that holds the top level taps of the client GUI.""" self.notebook.connect('switch-page', self.signal_notebook_switch_page) self.notebook.set_scrollable(True) vbox.pack_start(self.notebook, True, True, 0) self.tabs = {} current_page = self.notebook.get_current_page() self.last_page_id = current_page mailer_tab = MailSenderTab(self.config, self) self.tabs['mailer'] = mailer_tab self.notebook.insert_page(mailer_tab.box, mailer_tab.label, current_page + 1) self.notebook.set_current_page(current_page + 1) campaign_tab = CampaignViewTab(self.config, self) campaign_tab.box.show() self.tabs['campaign'] = campaign_tab self.notebook.insert_page(campaign_tab.box, campaign_tab.label, current_page + 2) self.set_size_request(800, 600) self.connect('delete-event', self.signal_delete_event) self.notebook.show() self.show() self.rpc = None # needs to be initialized last """The :py:class:`.KingPhisherRPCClient` instance.""" def _add_menu_actions(self, action_group): # File Menu Actions action = Gtk.Action(name='FileMenu', label='File', tooltip=None, stock_id=None) action_group.add_action(action) action = Gtk.Action(name='FileOpenCampaign', label='_Open Campaign', tooltip='Open a Campaign', stock_id=Gtk.STOCK_NEW) action.connect('activate', lambda x: self.show_campaign_selection()) action_group.add_action_with_accel(action, '<control>O') action = Gtk.Action(name='FileImportMenu', label='Import', tooltip=None, stock_id=None) action_group.add_action(action) action = Gtk.Action(name='FileImportMessageConfiguration', label='Message Configuration', tooltip='Message Configuration', stock_id=None) action.connect('activate', lambda x: self.tabs['mailer'].import_message_data()) action_group.add_action(action) action = Gtk.Action(name='FileExportMenu', label='Export', tooltip=None, stock_id=None) action_group.add_action(action) action = Gtk.Action(name='FileExportCampaignXML', label='Campaign XML', tooltip='Campaign XML', stock_id=None) action.connect('activate', lambda x: self.export_campaign_xml()) action_group.add_action(action) action = Gtk.Action(name='FileExportMessageConfiguration', label='Message Configuration', tooltip='Message Configuration', stock_id=None) action.connect('activate', lambda x: self.tabs['mailer'].export_message_data()) action_group.add_action(action) action = Gtk.Action(name='FileQuit', label=None, tooltip=None, stock_id=Gtk.STOCK_QUIT) action.connect('activate', lambda x: self.emit('exit-confirm')) action_group.add_action_with_accel(action, '<control>Q') # Edit Menu Actions action = Gtk.Action(name='EditMenu', label='Edit', tooltip=None, stock_id=None) action_group.add_action(action) action = Gtk.Action(name='EditPreferences', label='Preferences', tooltip='Edit Preferences', stock_id=Gtk.STOCK_EDIT) action.connect('activate', lambda x: self.edit_preferences()) action_group.add_action(action) action = Gtk.Action(name='EditDeleteCampaign', label='Delete Campaign', tooltip='Delete Campaign', stock_id=None) action.connect('activate', lambda x: self.delete_campaign()) action_group.add_action(action) action = Gtk.Action(name='EditStopService', label='Stop Service', tooltip='Stop The Remote King-Phisher Service', stock_id=None) action.connect('activate', lambda x: self.stop_remote_service()) action_group.add_action(action) # Tools Menu Action action = Gtk.Action(name='ToolsMenu', label='Tools', tooltip=None, stock_id=None) action_group.add_action(action) action = Gtk.Action(name='ToolsRPCTerminal', label='RPC Terminal', tooltip='RPC Terminal', stock_id=None) action.connect( 'activate', lambda x: tools.KingPhisherClientRPCTerminal(self.config, self)) action_group.add_action(action) # Help Menu Actions action = Gtk.Action(name='HelpMenu', label='Help', tooltip=None, stock_id=None) action_group.add_action(action) action = Gtk.Action(name='HelpAbout', label='About', tooltip='About', stock_id=None) action.connect('activate', lambda x: self.show_about_dialog()) action_group.add_action(action) action = Gtk.Action(name='HelpWiki', label='Wiki', tooltip='Wiki', stock_id=None) action.connect( 'activate', lambda x: utilities.open_uri( 'https://github.com/securestate/king-phisher/wiki')) action_group.add_action(action) def _add_menu_optional_actions(self, action_group, uimanager): if graphs.has_matplotlib: action = Gtk.Action(name='ToolsGraphMenu', label='Create Graph', tooltip=None, stock_id=None) action_group.add_action(action) for graph_name in graphs.get_graphs(): action_name = 'ToolsGraph' + graph_name action = Gtk.Action(name=action_name, label=graph_name, tooltip=graph_name, stock_id=None) action.connect('activate', lambda _: self.show_campaign_graph(graph_name)) action_group.add_action(action) merge_id = uimanager.new_merge_id() uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu', 'ToolsGraphMenu', 'ToolsGraphMenu', Gtk.UIManagerItemType.MENU, False) for graph_name in graphs.get_graphs(): action_name = 'ToolsGraph' + graph_name uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu/ToolsGraphMenu', action_name, action_name, Gtk.UIManagerItemType.MENUITEM, False) if sys.platform.startswith('linux'): action = Gtk.Action(name='ToolsSFTPClient', label='SFTP Client', tooltip='SFTP Client', stock_id=None) action.connect('activate', lambda x: self.start_sftp_client()) action_group.add_action(action) merge_id = uimanager.new_merge_id() uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu', 'ToolsSFTPClient', 'ToolsSFTPClient', Gtk.UIManagerItemType.MENUITEM, False) def _create_ui_manager(self): uimanager = Gtk.UIManager() with open(find.find_data_file( 'ui_info/client_window.xml')) as ui_info_file: ui_data = ui_info_file.read() uimanager.add_ui_from_string(ui_data) return uimanager def signal_notebook_switch_page(self, notebook, current_page, index): #previous_page = notebook.get_nth_page(self.last_page_id) self.last_page_id = index mailer_tab = self.tabs.get('mailer') campaign_tab = self.tabs.get('campaign') notebook = None if mailer_tab and current_page == mailer_tab.box: notebook = mailer_tab.notebook elif campaign_tab and current_page == campaign_tab.box: notebook = campaign_tab.notebook if notebook: index = notebook.get_current_page() notebook.emit('switch-page', notebook.get_nth_page(index), index) def signal_delete_event(self, x, y): self.emit('exit-confirm') return True def do_campaign_set(self, campaign_id): self.rpc.cache_clear() self.logger.info("campaign set to {0} (id: {1})".format( self.config['campaign_name'], self.config['campaign_id'])) def do_exit(self): self.hide() gui_utilities.gtk_widget_destroy_children(self) gui_utilities.gtk_sync() self.server_disconnect() self.save_config() self.destroy() Gtk.main_quit() return def do_exit_confirm(self): self.emit('exit') def init_connection(self): """ Initialize a connection to the King Phisher server. This will connect to the server and load necessary information from it. :return: Whether or not the connection attempt was successful. :rtype: bool """ if not self.server_connect(): return False self.load_server_config() campaign_id = self.config.get('campaign_id') if campaign_id == None: if not self.show_campaign_selection(): self.server_disconnect() return False campaign_info = self.rpc.remote_table_row('campaigns', self.config['campaign_id'], cache=True) if campaign_info == None: if not self.show_campaign_selection(): self.server_disconnect() return False campaign_info = self.rpc.remote_table_row( 'campaigns', self.config['campaign_id'], cache=True, refresh=True) self.config['campaign_name'] = campaign_info['name'] self.emit('campaign-set', self.config['campaign_id']) return True def client_quit(self): """ Unconditionally quit the client and perform any necessary clean up operations. The exit-confirm signal will not be sent so there will not be any opportunities for the client to cancel the operation. """ self.emit('exit') def server_connect(self): """ Perform the connection setup as part of the server connection initialization process. This will display a GUI window requesting the connection information. An :py:class:`.SSHTCPForwarder` instance is created and configured for tunneling traffic to the King Phisher server. This also verifies that the RPC API version running on the server is compatible with the client. :return: Whether or not the connection attempt was successful. :rtype: bool """ server_version_info = None title_ssh_error = 'Failed To Connect To The SSH Service' title_rpc_error = 'Failed To Connect To The King Phisher RPC Service' while True: if self.ssh_forwarder: self.ssh_forwarder.stop() self.ssh_forwarder = None self.logger.info('stopped ssh port forwarding') login_dialog = dialogs.KingPhisherClientLoginDialog( self.config, self) login_dialog.objects_load_from_config() response = login_dialog.interact() if response == Gtk.ResponseType.CANCEL: return False server = utilities.server_parse(self.config['server'], 22) username = self.config['server_username'] password = self.config['server_password'] server_remote_port = self.config['server_remote_port'] local_port = random.randint(2000, 6000) try: self.ssh_forwarder = SSHTCPForwarder( server, username, password, local_port, ('127.0.0.1', server_remote_port), preferred_private_key=self.config['ssh_preferred_key']) self.ssh_forwarder.start() time.sleep(0.5) self.logger.info('started ssh port forwarding') except paramiko.AuthenticationException: self.logger.warning( 'failed to authenticate to the remote ssh server') gui_utilities.show_dialog_error( title_ssh_error, self, 'The server responded that the credentials are invalid') continue except socket.error as error: error_number, error_message = error.args if error_number == 111: gui_utilities.show_dialog_error( title_ssh_error, self, 'The server refused the connection') else: gui_utilities.show_dialog_error( title_ssh_error, self, "Socket error #{0} ({1})".format( (error_number or 'NOT-SET'), error_message)) continue except Exception: self.logger.warning( 'failed to connect to the remote ssh server') gui_utilities.show_dialog_error(title_ssh_error, self) continue self.rpc = KingPhisherRPCClient( ('localhost', local_port), username=username, password=password, use_ssl=self.config.get('server_use_ssl')) if self.config.get('rpc.serializer'): try: self.rpc.set_serializer(self.config['rpc.serializer']) except ValueError as error: self.logger.error( "failed to set the rpc serializer, error: '{0}'". format(error.message)) try: assert (self.rpc('client/initialize')) server_version_info = self.rpc('version') except AdvancedHTTPServerRPCError as err: if err.status == 401: self.logger.warning( 'failed to authenticate to the remote king phisher service' ) gui_utilities.show_dialog_error( title_rpc_error, self, 'The server responded that the credentials are invalid' ) else: self.logger.warning( 'failed to connect to the remote rpc server with http status: ' + str(err.status)) gui_utilities.show_dialog_error( title_rpc_error, self, 'The server responded with HTTP status: ' + str(err.status)) continue except: self.logger.warning( 'failed to connect to the remote rpc service') gui_utilities.show_dialog_error( title_rpc_error, self, 'Ensure that the King Phisher Server is currently running') continue break assert (server_version_info != None) server_rpc_api_version = server_version_info.get('rpc_api_version', -1) self.logger.info( "successfully connected to the king phisher server (version: {0} rpc api version: {1})" .format(server_version_info['version'], server_rpc_api_version)) self.server_local_port = local_port if server_rpc_api_version != version.rpc_api_version: if version.rpc_api_version < server_rpc_api_version: secondary_text = 'The local client is not up to date with the server version.' else: secondary_text = 'The remote server is not up to date with the client version.' secondary_text += '\nPlease ensure that both the client and server are fully up to date.' gui_utilities.show_dialog_error( 'The RPC API Versions Are Incompatible', self, secondary_text) self.server_disconnect() return False return True def server_disconnect(self): """Clean up the SSH TCP connections and disconnect from the server.""" if self.ssh_forwarder: self.ssh_forwarder.stop() self.ssh_forwarder = None self.logger.info('stopped ssh port forwarding') return def load_config(self, load_defaults=False): """ Load the client configuration from disk and set the :py:attr:`~.KingPhisherClient.config` attribute. :param bool load_defaults: Load missing options from the template configuration file. """ self.logger.info('loading the config from disk') config_file = os.path.expanduser(self.config_file) client_template = find.find_data_file('client_config.json') if not os.path.isfile(config_file): shutil.copy(client_template, config_file) with open(config_file, 'r') as tmp_file: self.config = json.load(tmp_file) if load_defaults: with open(client_template, 'r') as tmp_file: client_template = json.load(tmp_file) for key, value in client_template.items(): if not key in self.config: self.config[key] = value def load_server_config(self): """Load the necessary values from the server's configuration.""" self.config['server_config'] = self.rpc( 'config/get', ['server.require_id', 'server.secret_id', 'server.tracking_image']) return def save_config(self): """Write the client configuration to disk.""" self.logger.info('writing the client configuration to disk') config = copy.copy(self.config) for key in self.config.keys(): if 'password' in key or key == 'server_config': del config[key] config_file = os.path.expanduser(self.config_file) config_file_h = open(config_file, 'wb') json.dump(config, config_file_h, sort_keys=True, indent=4) def delete_campaign(self): """ Delete the campaign on the server. A confirmation dialog will be displayed before the operation is performed. If the campaign is deleted and a new campaign is not selected with :py:meth:`.show_campaign_selection`, the client will quit. """ if not gui_utilities.show_dialog_yes_no( 'Delete This Campaign?', self, 'This action is irreversible, all campaign data will be lost.' ): return self.rpc('campaign/delete', self.config['campaign_id']) if not self.show_campaign_selection(): gui_utilities.show_dialog_error('A Campaign Must Be Selected', self, 'Now exiting') self.client_quit() def edit_preferences(self): """ Display a :py:class:`.dialogs.configuration.KingPhisherClientConfigurationDialog` instance and saves the configuration to disk if cancel is not selected. """ dialog = dialogs.KingPhisherClientConfigurationDialog( self.config, self) if dialog.interact() != Gtk.ResponseType.CANCEL: self.save_config() def export_campaign_xml(self): """Export the current campaign to an XML data file.""" dialog = gui_utilities.UtilityFileChooser('Export Campaign XML Data', self) file_name = self.config['campaign_name'] + '.xml' response = dialog.run_quick_save(file_name) dialog.destroy() if not response: return destination_file = response['target_path'] export.campaign_to_xml(self.rpc, self.config['campaign_id'], destination_file) def show_about_dialog(self): """ Display the about dialog showing details about the programs version, license etc. """ license_text = None if os.path.splitext(__file__)[1] == '.py': source_file_h = open(__file__, 'r') source_code = [] source_code.append(source_file_h.readline()) while source_code[-1].startswith('#'): source_code.append(source_file_h.readline()) source_code = source_code[5:-1] source_code = map(lambda x: x.strip('# '), source_code) license_text = ''.join(source_code) logo_pixbuf = None logo_file_path = find.find_data_file('king-phisher-icon.svg') if logo_file_path: logo_pixbuf = GdkPixbuf.Pixbuf.new_from_file_at_size( logo_file_path, 128, 128) about_dialog = Gtk.AboutDialog() about_dialog.set_transient_for(self) about_dialog_properties = { 'authors': ['Spencer McIntyre', 'Jeff McCutchan', 'Brandan Geise'], 'comments': 'Phishing Campaign Toolkit', 'copyright': 'Copyright (c) 2013-2015, SecureState LLC', 'license-type': Gtk.License.BSD, 'program-name': 'King Phisher', 'version': version.version, 'website': 'https://github.com/securestate/king-phisher', 'website-label': 'GitHub Home Page', 'wrap-license': False, } if license_text: about_dialog_properties['license'] = license_text if logo_pixbuf: about_dialog_properties['logo'] = logo_pixbuf for property_name, property_value in about_dialog_properties.items(): about_dialog.set_property(property_name, property_value) about_dialog.connect('activate-link', lambda _, url: utilities.open_uri(url)) about_dialog.show_all() about_dialog.run() about_dialog.destroy() def show_campaign_graph(self, graph_name): """ Create a new :py:class:`.CampaignGraph` instance and make it into a window. *graph_name* must be the name of a valid, exported graph provider. :param str graph_name: The name of the graph to make a window of. """ Klass = graphs.get_graph(graph_name) graph_inst = Klass(self.config, self) graph_inst.load_graph() window = graph_inst.make_window() window.show_all() def show_campaign_selection(self): """ Display the campaign selection dialog in a new :py:class:`.KingPhisherClientCampaignSelectionDialog` instance. :return: The status of the dialog. :rtype: bool """ dialog = dialogs.KingPhisherClientCampaignSelectionDialog( self.config, self) return dialog.interact() != Gtk.ResponseType.CANCEL def start_sftp_client(self): """ Start the client's preferred sftp client application. """ if not self.config['sftp_client']: gui_utilities.show_dialog_warning( 'Invalid SFTP Configuration', self, 'An SFTP client is not configured') return False command = str(self.config['sftp_client']) sftp_bin = shlex.split(command)[0] if not utilities.which(sftp_bin): self.logger.warning('could not locate the sftp binary: ' + sftp_bin) gui_utilities.show_dialog_warning( 'Invalid SFTP Configuration', self, "Could not find the SFTP binary '{0}'".format(sftp_bin)) return False try: command = command.format(username=self.config['server_username'], server=self.config['server']) except KeyError: pass self.logger.debug("starting sftp client command: {0}".format(command)) utilities.start_process(command, wait=False) return def stop_remote_service(self): """ Stop the remote King Phisher server. This will request that the server stop processing new requests and exit. This will display a confirmation dialog before performing the operation. If the remote service is stopped, the client will quit. """ if not gui_utilities.show_dialog_yes_no( 'Stop The Remote King Phisher Service?', self, 'This will stop the remote King Phisher service and\nnew incoming requests will not be processed.' ): return self.rpc('shutdown') self.logger.info('the remote king phisher service has been stopped') gui_utilities.show_dialog_error('The Remote Service Has Been Stopped', self, 'Now exiting') self.client_quit() return
class MailSenderThread(threading.Thread): """ The King Phisher threaded email message sender. This object manages the sending of emails for campaigns and supports pausing the sending of messages which can later be resumed by unpausing. This object reports its information to the GUI through an optional :py:class:`.MailSenderSendTab` instance, these two objects are very interdependent. """ def __init__(self, application, target_file, rpc, tab=None): """ :param application: The GTK application that the thread is associated with. :type application: :py:class:`.KingPhisherClientApplication` :param str target_file: The CSV formatted file to read message targets from. :param tab: The GUI tab to report information to. :type tab: :py:class:`.MailSenderSendTab` :param rpc: The client's connected RPC instance. :type rpc: :py:class:`.KingPhisherRPCClient` """ super(MailSenderThread, self).__init__() self.daemon = True self.logger = logging.getLogger('KingPhisher.Client.' + self.__class__.__name__) self.application = application self.config = self.application.config self.target_file = target_file """The name of the target file in CSV format.""" self.tab = tab """The optional :py:class:`.MailSenderSendTab` instance for reporting status messages to the GUI.""" self.rpc = rpc self._ssh_forwarder = None self.smtp_connection = None """The :py:class:`smtplib.SMTP` connection instance.""" self.smtp_server = parse_server(self.config['smtp_server'], 25) self.running = threading.Event() """A :py:class:`threading.Event` object indicating if emails are being sent.""" self.paused = threading.Event() """A :py:class:`threading.Event` object indicating if the email sending operation is or should be paused.""" self.should_stop = threading.Event() self.max_messages_per_minute = float(self.config.get('smtp_max_send_rate', 0.0)) def tab_notify_sent(self, emails_done, emails_total): """ Notify the tab that messages have been sent. :param int emails_done: The number of emails that have been sent. :param int emails_total: The total number of emails that are going to be sent. """ if isinstance(self.tab, gui_utilities.GladeGObject): GLib.idle_add(lambda x: self.tab.notify_sent(*x), (emails_done, emails_total)) def tab_notify_status(self, message): """ Handle a status message regarding the message sending operation. :param str message: The notification message. """ self.logger.info(message.lower()) if isinstance(self.tab, gui_utilities.GladeGObject): GLib.idle_add(self.tab.notify_status, message + '\n') def tab_notify_stopped(self): """ Notify the tab that the message sending operation has stopped. """ if isinstance(self.tab, gui_utilities.GladeGObject): GLib.idle_add(self.tab.notify_stopped) def server_ssh_connect(self): """ Connect to the remote SMTP server over SSH and configure port forwarding with :py:class:`.SSHTCPForwarder` for tunneling SMTP traffic. :return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants. """ server = parse_server(self.config['ssh_server'], 22) username = self.config['ssh_username'] password = self.config['ssh_password'] remote_server = parse_server(self.config['smtp_server'], 25) try: self._ssh_forwarder = SSHTCPForwarder( server, username, password, remote_server, private_key=self.config.get('ssh_preferred_key'), missing_host_key_policy=ssh_host_key.MissingHostKeyPolicy(self.application) ) self._ssh_forwarder.start() except errors.KingPhisherAbortError as error: self.logger.info("ssh connection aborted ({0})".format(error.message)) except paramiko.AuthenticationException: self.logger.warning('failed to authenticate to the remote ssh server') return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED except paramiko.SSHException as error: self.logger.warning("failed with ssh exception '{0}'".format(error.message)) except Exception: self.logger.warning('failed to connect to the remote ssh server', exc_info=True) else: self.smtp_server = self._ssh_forwarder.local_server return ConnectionErrorReason.SUCCESS return ConnectionErrorReason.ERROR_UNKNOWN def server_smtp_connect(self): """ Connect and optionally authenticate to the configured SMTP server. :return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants. """ if self.config.get('smtp_ssl_enable', False): SmtpClass = smtplib.SMTP_SSL else: SmtpClass = smtplib.SMTP try: self.smtp_connection = SmtpClass(*self.smtp_server, timeout=15) self.smtp_connection.ehlo() except socket.error: self.logger.warning('received a socket.error while connecting to the SMTP server') return ConnectionErrorReason.ERROR_CONNECTION except smtplib.SMTPException: self.logger.warning('received an SMTPException while connecting to the SMTP server', exc_info=True) return ConnectionErrorReason.ERROR_UNKNOWN username = self.config.get('smtp_username', '') if username: password = self.config.get('smtp_password', '') try: self.smtp_connection.login(username, password) except smtplib.SMTPException as error: self.logger.warning('received an {0} while authenticating to the SMTP server'.format(error.__class__.__name__)) self.smtp_connection.quit() return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED return ConnectionErrorReason.SUCCESS def server_smtp_disconnect(self): """Clean up and close the connection to the remote SMTP server.""" if self.smtp_connection: try: self.smtp_connection.quit() except smtplib.SMTPServerDisconnected: pass self.smtp_connection = None self.tab_notify_status('Disconnected from the SMTP server') def server_smtp_reconnect(self): """ Disconnect from the remote SMTP server and then attempt to open a new connection to it. :return: The reconnection status. :rtype: bool """ if self.smtp_connection: try: self.smtp_connection.quit() except smtplib.SMTPServerDisconnected: pass self.smtp_connection = None while self.server_smtp_connect() != ConnectionErrorReason.SUCCESS: self.tab_notify_status('Failed to reconnect to the SMTP server') if not self.process_pause(True): return False return True def count_messages(self): """ Count the number of targets that will be sent messages. :return: The number of targets that will be sent messages. :rtype: int """ return sum(1 for _ in self.iterate_targets()) def iterate_targets(self): target_type = self.config['mailer.target_type'] mailer_tab = self.application.main_tabs['mailer'] if target_type == 'single': target_name = self.config['mailer.target_name'].split(' ') while len(target_name) < 2: target_name.append('') target = MessageTarget( first_name=target_name[0].strip(), last_name=target_name[1].strip(), email_address=self.config['mailer.target_email_address'], department=None, uid=make_uid() ) mailer_tab.emit('send-target', target) yield target elif target_type == 'file': target_file_h = open(self.target_file, 'rU') csv_reader = csv.DictReader(target_file_h, ('first_name', 'last_name', 'email_address', 'department')) for line_no, raw_target in enumerate(csv_reader, 1): department = raw_target['department'] if department is not None: department = department.strip() if department == '': department = None email_address = raw_target['email_address'] or '' email_address = email_address.strip() target = MessageTarget( first_name=raw_target['first_name'].strip(), last_name=raw_target['last_name'].strip(), email_address=email_address, department=department, uid=make_uid(), line=line_no ) mailer_tab.emit('send-target', target) if not target.email_address: self.logger.warning("skipping line {0} in target csv file due to missing email address".format(line_no)) continue if not utilities.is_valid_email_address(target.email_address): self.logger.warning("skipping line {0} in target csv file due to invalid email address: {1}".format(line_no, email_address)) continue yield target target_file_h.close() else: self.logger.error("the configured target type '{0}' is unsupported".format(target_type)) def run(self): self.running.set() self.should_stop.clear() self.paused.clear() try: self._prepare_env() emails_done = self._send_messages() except Exception: self.logger.error('an error occurred while sending messages', exc_info=True) self.tab_notify_status('An error occurred while sending messages.') else: self.tab_notify_status("Finished sending, successfully sent {0:,} messages.".format(emails_done)) self.server_smtp_disconnect() if self._ssh_forwarder: self._ssh_forwarder.stop() self._ssh_forwarder = None self.tab_notify_status('Disconnected from the SSH server') self.tab_notify_stopped() return def process_pause(self, set_pause=False): """ Pause sending emails if a pause request has been set. :param bool set_pause: Whether to request a pause before processing it. :return: Whether or not the sending operation was cancelled during the pause. :rtype: bool """ if set_pause: if isinstance(self.tab, gui_utilities.GladeGObject): gui_utilities.glib_idle_add_wait(lambda: self.tab.pause_button.set_property('active', True)) else: self.pause() if self.paused.is_set(): self.tab_notify_status('Paused sending emails, waiting to resume') self.running.wait() self.paused.clear() if self.should_stop.is_set(): self.tab_notify_status('Sending emails cancelled') return False self.tab_notify_status('Resuming sending emails') self.max_messages_per_minute = float(self.config.get('smtp_max_send_rate', 0.0)) return True def create_calendar_invite(self, target, attachments): """ Create a MIME calendar invite to be sent from a set of parameters. :param target: The information for the messages intended recipient. :type target: :py:class:`.MessageTarget` :param str uid: The message's unique identifier. :param attachments: The attachments to add to the created message. :type attachments: :py:class:`Attachments` :return: The new MIME message. :rtype: :py:class:`email.mime.multipart.MIMEMultipart` """ top_msg = TopMIMEMultipart('mixed', self.config, target) related_msg = mime.multipart.MIMEMultipart('related') top_msg.attach(related_msg) alt_msg = mime.multipart.MIMEMultipart('alternative') related_msg.attach(alt_msg) part = mime.base.MIMEBase('text', 'plain', charset='utf-8') part.set_payload('This calendar invite requires an HTML enabled viewer.\r\n\r\n') encoders.encode_base64(part) alt_msg.attach(part) with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h: msg_template = file_h.read() formatted_msg = render_message_template(msg_template, self.config, target=target) part = mime.text.MIMEText(formatted_msg, 'html', 'utf-8') alt_msg.attach(part) start_time = get_invite_start_from_config(self.config) if self.config['mailer.calendar_invite_all_day']: duration = ics.DurationAllDay() else: duration = int(self.config['mailer.calendar_invite_duration']) * 60 ical = ics.Calendar( self.config['mailer.source_email'], start_time, self.config.get('mailer.calendar_invite_summary'), duration=duration, location=self.config.get('mailer.calendar_invite_location') ) ical.add_attendee(target.email_address, rsvp=self.config.get('mailer.calendar_request_rsvp', False)) part = mime.base.MIMEBase('text', 'calendar', charset='utf-8', method='REQUEST') part.set_payload(ical.to_ical(encoding='utf-8')) encoders.encode_base64(part) alt_msg.attach(part) for attach in attachments.images: related_msg.attach(attach) for attach in attachments.files: top_msg.attach(attach) return top_msg def create_email(self, target, attachments): """ Create a MIME email to be sent from a set of parameters. :param target: The information for the messages intended recipient. :type target: :py:class:`.MessageTarget` :param str uid: The message's unique identifier. :param attachments: The attachments to add to the created message. :type attachments: :py:class:`MessageAttachments` :return: The new MIME message. :rtype: :py:class:`email.mime.multipart.MIMEMultipart` """ msg = TopMIMEMultipart('related', self.config, target) importance = self.config.get('mailer.importance', 'Normal') if importance != 'Normal': msg['Importance'] = importance sensitivity = self.config.get('mailer.sensitivity', 'Normal') if sensitivity != 'Normal': msg['Sensitivity'] = sensitivity msg_alt = mime.multipart.MIMEMultipart('alternative') msg.attach(msg_alt) with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h: msg_template = file_h.read() formatted_msg = render_message_template(msg_template, self.config, target=target) msg_body = mime.text.MIMEText(formatted_msg, 'html', 'utf-8') msg_alt.attach(msg_body) # process attachments for attach in attachments.files: msg.attach(attach) for attach in attachments.images: msg.attach(attach) return msg def get_mime_attachments(self): """ Return a :py:class:`.MessageAttachments` object containing both the images and raw files to be included in sent messages. :return: A namedtuple of both files and images in their MIME containers. :rtype: :py:class:`.MessageAttachments` """ files = [] if self.config.get('mailer.attachment_file'): attachment = self.config['mailer.attachment_file'] attachfile = mime.base.MIMEBase(*mimetypes.guess_type(attachment)) attachfile.set_payload(open(attachment, 'rb').read()) encoders.encode_base64(attachfile) attachfile.add_header('Content-Disposition', "attachment; filename=\"{0}\"".format(os.path.basename(attachment))) files.append(attachfile) images = [] for attachment_file, attachment_name in template_environment.attachment_images.items(): attachfile = mime.image.MIMEImage(open(attachment_file, 'rb').read()) attachfile.add_header('Content-ID', "<{0}>".format(attachment_name)) attachfile.add_header('Content-Disposition', "inline; filename=\"{0}\"".format(attachment_name)) images.append(attachfile) return MessageAttachments(tuple(files), tuple(images)) def _prepare_env(self): with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h: msg_template = file_h.read() render_message_template(msg_template, self.config, analyze=True) template_environment.set_mode(template_environment.MODE_SEND) def _send_messages(self): emails_done = 0 max_messages_per_connection = self.config.get('mailer.max_messages_per_connection', 5) emails_total = "{0:,}".format(self.count_messages()) sending_line = "Sending email {{0: >{0},}} of {1} with UID: {{1}} to {{2}}".format(len(emails_total), emails_total) emails_total = int(emails_total.replace(',', '')) attachments = self.get_mime_attachments() self.logger.debug("loaded {0:,} MIME attachments".format(sum((len(attachments.files), len(attachments.images))))) for target in self.iterate_targets(): iteration_time = time.time() if self.should_stop.is_set(): self.tab_notify_status('Sending emails cancelled') break if not self.process_pause(): break if emails_done > 0 and max_messages_per_connection > 0 and (emails_done % max_messages_per_connection): self.server_smtp_reconnect() emails_done += 1 self.tab_notify_status(sending_line.format(emails_done, target.uid, target.email_address)) msg = getattr(self, 'create_' + self.config['mailer.message_type'])(target, attachments) if not self._try_send_message(target.email_address, msg): break self.tab_notify_sent(emails_done, emails_total) campaign_id = self.config['campaign_id'] self.rpc('campaign/message/new', campaign_id, target.uid, target.email_address, target.first_name, target.last_name, target.department) self.application.emit('message-sent', target.uid, target.email_address) if self.max_messages_per_minute: iteration_time = (time.time() - iteration_time) sleep_time = (60.0 / float(self.max_messages_per_minute)) - iteration_time while sleep_time > 0: sleep_chunk = min(sleep_time, 0.5) time.sleep(sleep_chunk) if self.should_stop.is_set(): break sleep_time -= sleep_chunk return emails_done def _try_send_message(self, *args, **kwargs): message_sent = False while not message_sent: for _ in range(0, 3): try: self.send_message(*args, **kwargs) message_sent = True break except smtplib.SMTPException as error: self.tab_notify_status("Failed to send message (exception: {0})".format(error.__class__.__name__)) self.logger.warning("failed to send message (exception: smtplib.{0})".format(error.__class__.__name__)) time.sleep(1) if not message_sent: self.server_smtp_disconnect() if not self.process_pause(True): return False self.server_smtp_reconnect() return True def send_message(self, target_email, msg): """ Send an email using the connected SMTP server. :param str target_email: The email address to send the message to. :param msg: The formatted message to be sent. :type msg: :py:class:`.mime.multipart.MIMEMultipart` """ source_email = self.config['mailer.source_email_smtp'] self.smtp_connection.sendmail(source_email, target_email, msg.as_string()) def pause(self): """ Sets the :py:attr:`~.MailSenderThread.running` and :py:attr:`~.MailSenderThread.paused` flags correctly to indicate that the object is paused. """ self.running.clear() self.paused.set() def unpause(self): """ Sets the :py:attr:`~.MailSenderThread.running` and :py:attr:`~.MailSenderThread.paused` flags correctly to indicate that the object is no longer paused. """ self.running.set() def stop(self): """ Requests that the email sending operation stop. It can not be resumed from the same position. This function blocks until the stop request has been processed and the thread exits. """ self.should_stop.set() self.unpause() if self.is_alive(): self.join() def missing_files(self): """ Return a list of all missing or unreadable files which are referenced by the message template. :return: The list of unusable files. :rtype: list """ missing = [] attachment = self.config.get('mailer.attachment_file') if attachment and not os.access(attachment, os.R_OK): missing.append(attachment) msg_template = self.config['mailer.html_file'] if not os.access(msg_template, os.R_OK): missing.append(msg_template) return missing self._prepare_env() for attachment in template_environment.attachment_images.keys(): if not os.access(attachment, os.R_OK): missing.append(attachment) return missing
class MailSenderThread(threading.Thread): def __init__(self, config, target_file, tab, rpc): super(MailSenderThread, self).__init__() self.logger = logging.getLogger('KingPhisher.Client.' + self.__class__.__name__) self.config = config self.target_file = target_file self.tab = tab self.rpc = rpc self.ssh_forwarder = None self.smtp_connection = None self.smtp_server = utilities.server_parse(self.config['smtp_server'], 25) self.running = threading.Event() self.paused = threading.Event() self.should_exit = threading.Event() self.max_messages_per_minute = float(self.config.get('smtp_max_send_rate', 0.0)) def server_ssh_connect(self): server = utilities.server_parse(self.config['ssh_server'], 22) username = self.config['ssh_username'] password = self.config['ssh_password'] remote_server = utilities.server_parse(self.config['smtp_server'], 25) local_port = random.randint(2000, 6000) try: self.ssh_forwarder = SSHTCPForwarder(server, username, password, local_port, remote_server, preferred_private_key=self.config.get('ssh_preferred_key')) self.ssh_forwarder.start() time.sleep(0.5) except: self.logger.warning('failed to connect to remote ssh server') return False self.smtp_server = ('localhost', local_port) return True def server_smtp_connect(self): if self.config.get('smtp_ssl_enable', False): SMTP_CLASS = smtplib.SMTP_SSL else: SMTP_CLASS = smtplib.SMTP try: self.smtp_connection = SMTP_CLASS(*self.smtp_server) except: return False return True def server_smtp_disconnect(self): if self.smtp_connection: try: self.smtp_connection.quit() except smtplib.SMTPServerDisconnected: pass self.smtp_connection = None GLib.idle_add(self.tab.notify_status, 'Disconnected From SMTP Server\n') def server_smtp_reconnect(self): if self.smtp_connection: try: self.smtp_connection.quit() except smtplib.SMTPServerDisconnected: pass self.smtp_connection = None while not self.server_smtp_connect(): GLib.idle_add(self.tab.notify_status, 'Failed To Reconnect To The SMTP Server\n') if not self.process_pause(True): return False return True def count_emails(self, target_file): targets = 0 target_file_h = open(target_file, 'r') csv_reader = csv.DictReader(target_file_h, ['first_name', 'last_name', 'email_address']) for target in csv_reader: targets += 1 target_file_h.close() return targets def run(self): emails_done = 0 emails_total = self.count_emails(self.target_file) max_messages_per_connection = self.config.get('mailer.max_messages_per_connection', 5) self.running.set() self.should_exit.clear() self.paused.clear() target_file_h = open(self.target_file, 'r') csv_reader = csv.DictReader(target_file_h, ['first_name', 'last_name', 'email_address']) for target in csv_reader: iteration_time = time.time() if emails_done > 0 and (emails_done % max_messages_per_connection): self.server_smtp_reconnect() if self.should_exit.is_set(): GLib.idle_add(self.tab.notify_status, 'Sending Emails Cancelled\n') break if not self.process_pause(): break uid = make_uid() emails_done += 1 GLib.idle_add(self.tab.notify_status, "Sending Email {0} of {1} To {2} With UID: {3}\n".format(emails_done, emails_total, target['email_address'], uid)) msg = self.create_email(target['first_name'], target['last_name'], target['email_address'], uid) if not self.try_send_email(target['email_address'], msg): break GLib.idle_add(lambda x: self.tab.notify_sent(*x), (emails_done, emails_total)) campaign_id = self.config['campaign_id'] company_name = self.config.get('mailer.company_name', '') self.rpc('campaign/message/new', campaign_id, uid, target['email_address'], company_name, target['first_name'], target['last_name']) if self.max_messages_per_minute: iteration_time = (time.time() - iteration_time) sleep_time = (60.0 / float(self.max_messages_per_minute)) - iteration_time if sleep_time > 0: time.sleep(sleep_time) target_file_h.close() GLib.idle_add(self.tab.notify_status, "Finished Sending Emails, Successfully Sent {0} Emails\n".format(emails_done)) self.server_smtp_disconnect() if self.ssh_forwarder: self.ssh_forwarder.stop() self.ssh_forwarder = None GLib.idle_add(self.tab.notify_status, 'Disconnected From SSH Server\n') GLib.idle_add(self.tab.notify_stopped) return def process_pause(self, set_pause=False): if set_pause: gui_utilities.glib_idle_add_wait(lambda: self.tab.pause_button.set_property('active', True)) if self.paused.is_set(): GLib.idle_add(self.tab.notify_status, 'Paused Sending Emails, Waiting To Resume\n') self.running.wait() self.paused.clear() if self.should_exit.is_set(): GLib.idle_add(self.tab.notify_status, 'Sending Emails Cancelled\n') return False GLib.idle_add(self.tab.notify_status, 'Resuming Sending Emails\n') self.max_messages_per_minute = float(self.config.get('smtp_max_send_rate', 0.0)) return True def create_email(self, first_name, last_name, target_email, uid): msg = MIMEMultipart() msg['Subject'] = self.config['mailer.subject'] if self.config.get('mailer.reply_to_email'): msg.add_header('reply-to', self.config['mailer.reply_to_email']) if self.config.get('mailer.source_email_alias'): msg['From'] = "\"{0}\" <{1}>".format(self.config['mailer.source_email_alias'], self.config['mailer.source_email']) else: msg['From'] = self.config['mailer.source_email'] msg['To'] = target_email importance = self.config.get('mailer.importance', 'Normal') if importance != 'Normal': msg['Importance'] = importance sensitivity = self.config.get('mailer.sensitivity', 'Normal') if sensitivity != 'Normal': msg['Sensitivity'] = sensitivity msg.preamble = 'This is a multi-part message in MIME format.' msg_alt = MIMEMultipart('alternative') msg.attach(msg_alt) msg_template = open(self.config['mailer.html_file'], 'r').read() formatted_msg = format_message(msg_template, self.config, first_name=first_name, last_name=last_name, uid=uid, target_email=target_email) msg_body = MIMEText(formatted_msg, "html") msg_alt.attach(msg_body) if self.config.get('mailer.attachment_file'): attachment = self.config['mailer.attachment_file'] attachfile = MIMEBase(*mimetypes.guess_type(attachment)) attachfile.set_payload(open(attachment, 'rb').read()) Encoders.encode_base64(attachfile) attachfile.add_header('Content-Disposition', "attachment; filename=\"{0}\"".format(os.path.basename(attachment))) msg.attach(attachfile) return msg def try_send_email(self, *args, **kwargs): message_sent = False while not message_sent: for i in xrange(0, 3): try: self.send_email(*args, **kwargs) message_sent = True break except: GLib.idle_add(self.tab.notify_status, 'Failed To Send Message\n') time.sleep(1) if not message_sent: self.server_smtp_disconnect() if not self.process_pause(True): return False self.server_smtp_reconnect() return True def send_email(self, target_email, msg): source_email = self.config['mailer.source_email'] self.smtp_connection.sendmail(source_email, target_email, msg.as_string()) def pause(self): self.running.clear() self.paused.set() def unpause(self): self.running.set() def stop(self): self.should_exit.set() self.unpause() if self.is_alive(): self.join()
class MailSenderThread(threading.Thread): """ The King Phisher threaded email message sender. This object manages the sending of emails for campaigns and supports pausing the sending of messages which can later be resumed by unpausing. This object reports its information to the GUI through an optional :py:class:`.MailSenderSendTab` instance, these two objects are very interdependent. """ def __init__(self, config, target_file, rpc, tab=None): """ :param dict config: The King Phisher client configuration. :param str target_file: The CSV formatted file to read message targets from. :param tab: The GUI tab to report information to. :type tab: :py:class:`.MailSenderSendTab` :param rpc: The client's connected RPC instance. :type rpc: :py:class:`.KingPhisherRPCClient` """ super(MailSenderThread, self).__init__() self.daemon = True self.logger = logging.getLogger('KingPhisher.Client.' + self.__class__.__name__) self.config = config self.target_file = target_file """The name of the target file in CSV format.""" self.tab = tab """The optional :py:class:`.MailSenderSendTab` instance for reporting status messages to the GUI.""" self.rpc = rpc self.ssh_forwarder = None """The :py:class:`.SSHTCPForwarder` instance for tunneling traffic to the SMTP server.""" self.smtp_connection = None """The :py:class:`smtplib.SMTP` connection instance.""" self.smtp_server = utilities.server_parse(self.config['smtp_server'], 25) self.running = threading.Event() """A :py:class:`threading.Event` object indicating if emails are being sent.""" self.paused = threading.Event() """A :py:class:`threading.Event` object indicating if the email sending operation is or should be paused.""" self.should_exit = threading.Event() self.max_messages_per_minute = float( self.config.get('smtp_max_send_rate', 0.0)) self._mime_attachments = None def tab_notify_sent(self, emails_done, emails_total): """ Notify the tab that messages have been sent. :param int emails_done: The number of emails that have been sent. :param int emails_total: The total number of emails that are going to be sent. """ if isinstance(self.tab, gui_utilities.UtilityGladeGObject): GLib.idle_add(lambda x: self.tab.notify_sent(*x), (emails_done, emails_total)) def tab_notify_status(self, message): """ Handle a status message regarding the message sending operation. :param str message: The notification message. """ self.logger.info(message.lower()) if isinstance(self.tab, gui_utilities.UtilityGladeGObject): GLib.idle_add(self.tab.notify_status, message + '\n') def tab_notify_stopped(self): """ Notify the tab that the message sending operation has stopped. """ if isinstance(self.tab, gui_utilities.UtilityGladeGObject): GLib.idle_add(self.tab.notify_stopped) def server_ssh_connect(self): """ Connect to the remote SMTP server over SSH and configure port forwarding with :py:class:`.SSHTCPForwarder` for tunneling SMTP traffic. :return: The connection status. :rtype: bool """ server = utilities.server_parse(self.config['ssh_server'], 22) username = self.config['ssh_username'] password = self.config['ssh_password'] remote_server = utilities.server_parse(self.config['smtp_server'], 25) local_port = random.randint(2000, 6000) try: self.ssh_forwarder = SSHTCPForwarder( server, username, password, local_port, remote_server, preferred_private_key=self.config.get('ssh_preferred_key')) self.ssh_forwarder.start() time.sleep(0.5) except: self.logger.warning('failed to connect to remote ssh server') return False self.smtp_server = ('localhost', local_port) return True def server_smtp_connect(self): """ Connect to the configured SMTP server. :return: The connection status. :rtype: bool """ if self.config.get('smtp_ssl_enable', False): SMTP_CLASS = smtplib.SMTP_SSL else: SMTP_CLASS = smtplib.SMTP try: self.smtp_connection = SMTP_CLASS(*self.smtp_server) except: return False return True def server_smtp_disconnect(self): """Clean up and close the connection to the remote SMTP server.""" if self.smtp_connection: try: self.smtp_connection.quit() except smtplib.SMTPServerDisconnected: pass self.smtp_connection = None self.tab_notify_status('Disconnected from the SMTP server') def server_smtp_reconnect(self): """ Disconnect from the remote SMTP server and then attempt to open a new connection to it. :return: The reconnection status. :rtype: bool """ if self.smtp_connection: try: self.smtp_connection.quit() except smtplib.SMTPServerDisconnected: pass self.smtp_connection = None while not self.server_smtp_connect(): self.tab_notify_status('Failed to reconnect to the SMTP server') if not self.process_pause(True): return False return True def count_emails(self): """ Count the emails contained in the target CSV file. :return: The number of targets in the file. :rtype: int """ targets = 0 target_file_h = open(self.target_file, 'rU') csv_reader = csv.DictReader( target_file_h, ['first_name', 'last_name', 'email_address']) for target in csv_reader: if not utilities.is_valid_email_address(target['email_address']): continue targets += 1 target_file_h.close() return targets def run(self): emails_done = 0 emails_total = self.count_emails() max_messages_per_connection = self.config.get( 'mailer.max_messages_per_connection', 5) self.running.set() self.should_exit.clear() self.paused.clear() self._prepare_env() self._mime_attachments = self._get_mime_attachments() self.logger.debug("loaded {0:,} MIME attachments".format( len(self._mime_attachments))) target_file_h = open(self.target_file, 'rU') csv_reader = csv.DictReader( target_file_h, ['first_name', 'last_name', 'email_address']) for target in csv_reader: if not utilities.is_valid_email_address(target['email_address']): self.logger.warning('skipping invalid email address: ' + target['email_address']) continue iteration_time = time.time() if self.should_exit.is_set(): self.tab_notify_status('Sending emails cancelled') break if not self.process_pause(): break if emails_done > 0 and (emails_done % max_messages_per_connection): self.server_smtp_reconnect() uid = make_uid() emails_done += 1 self.tab_notify_status( "Sending email {0:,} of {1:,} to {2} with UID: {3}".format( emails_done, emails_total, target['email_address'], uid)) msg = self.create_email(target['first_name'], target['last_name'], target['email_address'], uid) if not self._try_send_email(target['email_address'], msg): break self.tab_notify_sent(emails_done, emails_total) campaign_id = self.config['campaign_id'] company_name = self.config.get('mailer.company_name', '') self.rpc('campaign/message/new', campaign_id, uid, target['email_address'], company_name, target['first_name'], target['last_name']) if self.max_messages_per_minute: iteration_time = (time.time() - iteration_time) sleep_time = (60.0 / float( self.max_messages_per_minute)) - iteration_time while sleep_time > 0: sleep_chunk = min(sleep_time, 0.5) time.sleep(sleep_chunk) if self.should_exit.is_set(): break sleep_time -= sleep_chunk target_file_h.close() self._mime_attachments = None self.tab_notify_status( "Finished sending emails, successfully sent {0:,} emails".format( emails_done)) self.server_smtp_disconnect() if self.ssh_forwarder: self.ssh_forwarder.stop() self.ssh_forwarder = None self.tab_notify_status('Disconnected from the SSH server') self.tab_notify_stopped() return def process_pause(self, set_pause=False): """ Pause sending emails if a pause request has been set. :param bool set_pause: Whether to request a pause before processing it. :return: Whether or not the sending operation was cancelled during the pause. :rtype: bool """ if set_pause: if isinstance(self.tab, gui_utilities.UtilityGladeGObject): gui_utilities.glib_idle_add_wait( lambda: self.tab.pause_button.set_property('active', True)) else: self.pause() if self.paused.is_set(): self.tab_notify_status('Paused sending emails, waiting to resume') self.running.wait() self.paused.clear() if self.should_exit.is_set(): self.tab_notify_status('Sending emails cancelled') return False self.tab_notify_status('Resuming sending emails') self.max_messages_per_minute = float( self.config.get('smtp_max_send_rate', 0.0)) return True def create_email(self, first_name, last_name, target_email, uid): """ Create a MIME email to be sent from a set of parameters. :param str first_name: The first name of the message's recipient. :param str last_name: The last name of the message's recipient. :param str target_email: The message's destination email address. :param str uid: The message's unique identifier. :return: The new MIME message. :rtype: :py:class:`email.MIMEMultipart.MIMEMultipart` """ msg = MIMEMultipart() msg['Subject'] = self.config['mailer.subject'] if self.config.get('mailer.reply_to_email'): msg.add_header('reply-to', self.config['mailer.reply_to_email']) if self.config.get('mailer.source_email_alias'): msg['From'] = "\"{0}\" <{1}>".format( self.config['mailer.source_email_alias'], self.config['mailer.source_email']) else: msg['From'] = self.config['mailer.source_email'] msg['To'] = target_email importance = self.config.get('mailer.importance', 'Normal') if importance != 'Normal': msg['Importance'] = importance sensitivity = self.config.get('mailer.sensitivity', 'Normal') if sensitivity != 'Normal': msg['Sensitivity'] = sensitivity msg.preamble = 'This is a multi-part message in MIME format.' msg_alt = MIMEMultipart('alternative') msg.attach(msg_alt) with open(self.config['mailer.html_file'], 'rb') as file_h: msg_template = file_h.read() msg_template = str(msg_template.decode('utf-8', 'ignore')) formatted_msg = format_message(msg_template, self.config, first_name=first_name, last_name=last_name, uid=uid, target_email=target_email) msg_body = MIMEText(formatted_msg, "html") msg_alt.attach(msg_body) # process attachments if isinstance(self._mime_attachments, (list, tuple)): attachfiles = self._mime_attachments else: attachfiles = self._get_mime_attachments() for attachfile in attachfiles: msg.attach(attachfile) return msg def _get_mime_attachments(self): attachments = [] if self.config.get('mailer.attachment_file'): attachment = self.config['mailer.attachment_file'] attachfile = MIMEBase(*mimetypes.guess_type(attachment)) attachfile.set_payload(open(attachment, 'rb').read()) encoders.encode_base64(attachfile) attachfile.add_header( 'Content-Disposition', "attachment; filename=\"{0}\"".format( os.path.basename(attachment))) attachments.append(attachfile) for attachment_file, attachment_name in template_environment.attachment_images.items( ): attachfile = MIMEImage(open(attachment_file, 'rb').read()) attachfile.add_header('Content-ID', "<{0}>".format(attachment_name)) attachfile.add_header( 'Content-Disposition', "inline; filename=\"{0}\"".format(attachment_name)) attachments.append(attachfile) return attachments def _prepare_env(self): msg_template = open(self.config['mailer.html_file'], 'r').read() template_environment.set_mode(template_environment.MODE_ANALYZE) format_message(msg_template, self.config, uid=make_uid()) template_environment.set_mode(template_environment.MODE_SEND) def _try_send_email(self, *args, **kwargs): message_sent = False while not message_sent: for i in xrange(0, 3): try: self.send_email(*args, **kwargs) message_sent = True break except: self.tab_notify_status('Failed to send message') time.sleep(1) if not message_sent: self.server_smtp_disconnect() if not self.process_pause(True): return False self.server_smtp_reconnect() return True def send_email(self, target_email, msg): """ Send an email using the connected SMTP server. :param str target_email: The email address to send the message to. :param msg: The formatted message to be sent. :type msg: :py:class:`email.MIMEMultipart.MIMEMultipart` """ source_email = self.config['mailer.source_email'] self.smtp_connection.sendmail(source_email, target_email, msg.as_string()) def pause(self): """ Sets the :py:attr:`~.MailSenderThread.running` and :py:attr:`~.MailSenderThread.paused` flags correctly to indicate that the object is paused. """ self.running.clear() self.paused.set() def unpause(self): """ Sets the :py:attr:`~.MailSenderThread.running` and :py:attr:`~.MailSenderThread.paused` flags correctly to indicate that the object is no longer paused. """ self.running.set() def stop(self): """ Requests that the email sending operation stop. It can not be resumed from the same position. This function blocks until the stop request has been processed and the thread exits. """ self.should_exit.set() self.unpause() if self.is_alive(): self.join() def missing_files(self): """ Return a list of all missing or unreadable files which are referenced by the message template. :return: The list of unusable files. :rtype: list """ missing = [] attachment = self.config.get('mailer.attachment_file') if attachment and not os.access(attachment, os.R_OK): missing.append(attachment) msg_template = self.config['mailer.html_file'] if not os.access(msg_template, os.R_OK): missing.append(msg_template) return missing self._prepare_env() missing.extend( filter(lambda f: not os.access(f, os.R_OK), template_environment.attachment_images.keys())) return missing
class MailSenderThread(threading.Thread): """ The King Phisher threaded email message sender. This object manages the sending of emails for campaigns and supports pausing the sending of messages which can later be resumed by unpausing. This object reports its information to the GUI through an optional :py:class:`.MailSenderSendTab` instance, these two objects are very interdependent. """ def __init__(self, application, target_file, rpc, tab=None): """ :param application: The GTK application that the thread is associated with. :type application: :py:class:`.KingPhisherClientApplication` :param str target_file: The CSV formatted file to read message targets from. :param tab: The GUI tab to report information to. :type tab: :py:class:`.MailSenderSendTab` :param rpc: The client's connected RPC instance. :type rpc: :py:class:`.KingPhisherRPCClient` """ super(MailSenderThread, self).__init__() self.daemon = True self.logger = logging.getLogger('KingPhisher.Client.' + self.__class__.__name__) self.application = application self.config = self.application.config self.target_file = target_file """The name of the target file in CSV format.""" self.tab = tab """The optional :py:class:`.MailSenderSendTab` instance for reporting status messages to the GUI.""" self.rpc = rpc self._ssh_forwarder = None self.smtp_connection = None """The :py:class:`smtplib.SMTP` connection instance.""" self.smtp_server = smoke_zephyr.utilities.parse_server( self.config['smtp_server'], 25) self.running = threading.Event() """A :py:class:`threading.Event` object indicating if emails are being sent.""" self.paused = threading.Event() """A :py:class:`threading.Event` object indicating if the email sending operation is or should be paused.""" self.should_stop = threading.Event() self.max_messages_per_minute = float( self.config.get('smtp_max_send_rate', 0.0)) self.mail_options = [] def tab_notify_sent(self, emails_done, emails_total): """ Notify the tab that messages have been sent. :param int emails_done: The number of emails that have been sent. :param int emails_total: The total number of emails that are going to be sent. """ if isinstance(self.tab, gui_utilities.GladeGObject): GLib.idle_add(lambda x: self.tab.notify_sent(*x), (emails_done, emails_total)) def tab_notify_status(self, message): """ Handle a status message regarding the message sending operation. :param str message: The notification message. """ self.logger.info(message.lower()) if isinstance(self.tab, gui_utilities.GladeGObject): GLib.idle_add(self.tab.notify_status, message + '\n') def tab_notify_stopped(self): """ Notify the tab that the message sending operation has stopped. """ if isinstance(self.tab, gui_utilities.GladeGObject): GLib.idle_add(self.tab.notify_stopped) def server_ssh_connect(self): """ Connect to the remote SMTP server over SSH and configure port forwarding with :py:class:`.SSHTCPForwarder` for tunneling SMTP traffic. :return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants. """ server = smoke_zephyr.utilities.parse_server(self.config['ssh_server'], 22) username = self.config['ssh_username'] password = self.config['ssh_password'] remote_server = smoke_zephyr.utilities.parse_server( self.config['smtp_server'], 25) try: self._ssh_forwarder = SSHTCPForwarder( server, username, password, remote_server, private_key=self.config.get('ssh_preferred_key'), missing_host_key_policy=ssh_host_key.MissingHostKeyPolicy( self.application)) self._ssh_forwarder.start() except errors.KingPhisherAbortError as error: self.logger.info("ssh connection aborted ({0})".format( error.message)) except paramiko.AuthenticationException: self.logger.warning( 'failed to authenticate to the remote ssh server') return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED except paramiko.SSHException as error: self.logger.warning("failed with ssh exception '{0}'".format( error.message)) except Exception: self.logger.warning('failed to connect to the remote ssh server', exc_info=True) else: self.smtp_server = self._ssh_forwarder.local_server return ConnectionErrorReason.SUCCESS return ConnectionErrorReason.ERROR_UNKNOWN def server_smtp_connect(self): """ Connect and optionally authenticate to the configured SMTP server. :return: The connection status as one of the :py:class:`.ConnectionErrorReason` constants. """ if self.config.get('smtp_ssl_enable', False): SmtpClass = smtplib.SMTP_SSL else: SmtpClass = smtplib.SMTP self.logger.debug('opening a new connection to the SMTP server') try: self.smtp_connection = SmtpClass(*self.smtp_server, timeout=15) self.smtp_connection.ehlo() except smtplib.SMTPException: self.logger.warning( 'received an SMTPException while connecting to the SMTP server', exc_info=True) return ConnectionErrorReason.ERROR_UNKNOWN except socket.error: self.logger.warning( 'received a socket.error while connecting to the SMTP server') return ConnectionErrorReason.ERROR_CONNECTION if not self.config.get( 'smtp_ssl_enable', False) and 'starttls' in self.smtp_connection.esmtp_features: self.logger.debug( 'target SMTP server supports the STARTTLS extension') try: self.smtp_connection.starttls() self.smtp_connection.ehlo() except smtplib.SMTPException: self.logger.warning( 'received an SMTPException while negotiating STARTTLS with the SMTP server', exc_info=True) return ConnectionErrorReason.ERROR_UNKNOWN username = self.config.get('smtp_username', '') if username: password = self.config.get('smtp_password', '') try: self.smtp_connection.login(username, password) except smtplib.SMTPException as error: self.logger.warning( 'received an {0} while authenticating to the SMTP server'. format(error.__class__.__name__)) self.smtp_connection.quit() return ConnectionErrorReason.ERROR_AUTHENTICATION_FAILED if self.smtp_connection.has_extn('SMTPUTF8'): self.logger.debug( 'target SMTP server supports the SMTPUTF8 extension') self.mail_options.append('SMTPUTF8') return ConnectionErrorReason.SUCCESS def server_smtp_disconnect(self): """Clean up and close the connection to the remote SMTP server.""" if self.smtp_connection: self.logger.debug('closing the connection to the SMTP server') try: self.smtp_connection.quit() except smtplib.SMTPServerDisconnected: pass self.smtp_connection = None self.tab_notify_status('Disconnected from the SMTP server') def server_smtp_reconnect(self): """ Disconnect from the remote SMTP server and then attempt to open a new connection to it. :return: The reconnection status. :rtype: bool """ if self.smtp_connection: try: self.smtp_connection.quit() except smtplib.SMTPServerDisconnected: pass self.smtp_connection = None while self.server_smtp_connect() != ConnectionErrorReason.SUCCESS: self.tab_notify_status('Failed to reconnect to the SMTP server') if not self.process_pause(True): return False return True def count_messages(self): """ Count the number of targets that will be sent messages. :return: The number of targets that will be sent messages. :rtype: int """ return sum(1 for _ in self.iterate_targets(counting=True)) def iterate_targets(self, counting=False): target_type = self.config['mailer.target_type'] if target_type == 'single': target_name = self.config['mailer.target_name'].split(' ') while len(target_name) < 2: target_name.append('') target = MessageTarget( first_name=target_name[0].strip(), last_name=target_name[1].strip(), email_address=self.config['mailer.target_email_address'].strip( ), department=None, uid=utilities.make_message_uid()) yield target elif target_type == 'file': target_file_h = open(self.target_file, 'rU') csv_reader = csv.DictReader( target_file_h, ('first_name', 'last_name', 'email_address', 'department')) for line_no, raw_target in enumerate(csv_reader, 1): if its.py_v2: # this will intentionally cause a UnicodeDecodeError to be raised as is the behaviour in python 3.x # when csv.DictReader is initialized raw_target = dict( (k, (v if v is None else v.decode('utf-8'))) for k, v in raw_target.items()) for required_field in ('first_name', 'last_name', 'email_address'): if raw_target[required_field] is None: raw_target = None if counting: self.tab_notify_status( "Target CSV line {0} skipped due to missing field: {1}" .format(line_no, required_field.replace('_', ' '))) break if raw_target is None: continue department = raw_target['department'] if department is not None: department = department.strip() if department == '': department = None email_address = raw_target['email_address'] or '' email_address = email_address.strip() target = MessageTarget( first_name=raw_target['first_name'].strip(), last_name=raw_target['last_name'].strip(), email_address=email_address, department=department, uid=utilities.make_message_uid(), line=line_no) if not target.email_address: self.logger.warning( "skipping line {0} in target csv file due to missing email address" .format(line_no)) continue if not utilities.is_valid_email_address(target.email_address): self.logger.warning( "skipping line {0} in target csv file due to invalid email address: {1}" .format(line_no, email_address)) continue yield target target_file_h.close() else: self.logger.error( "the configured target type '{0}' is unsupported".format( target_type)) def run(self): self.logger.debug("mailer routine running in tid: 0x{0:x}".format( threading.current_thread().ident)) self.running.set() self.should_stop.clear() self.paused.clear() try: self._prepare_env() emails_done = self._send_messages() except UnicodeDecodeError as error: self.logger.error( "a unicode error occurred, {0} at position: {1}-{2}".format( error.reason, error.start, error.end)) self.tab_notify_status( "A unicode error occurred, {0} at position: {1}-{2}".format( error.reason, error.start, error.end)) except Exception: self.logger.error('an error occurred while sending messages', exc_info=True) self.tab_notify_status('An error occurred while sending messages.') else: self.tab_notify_status( "Finished sending, successfully sent {0:,} messages".format( emails_done)) self.server_smtp_disconnect() if self._ssh_forwarder: self._ssh_forwarder.stop() self._ssh_forwarder = None self.tab_notify_status('Disconnected from the SSH server') self.tab_notify_stopped() return def process_pause(self, set_pause=False): """ Pause sending emails if a pause request has been set. :param bool set_pause: Whether to request a pause before processing it. :return: Whether or not the sending operation was cancelled during the pause. :rtype: bool """ if set_pause: if isinstance(self.tab, gui_utilities.GladeGObject): gui_utilities.glib_idle_add_wait( lambda: self.tab.pause_button.set_property('active', True)) else: self.pause() if self.paused.is_set(): self.tab_notify_status('Paused sending emails, waiting to resume') self.running.wait() self.paused.clear() if self.should_stop.is_set(): self.tab_notify_status('Sending emails cancelled') return False self.tab_notify_status('Resuming sending emails') self.max_messages_per_minute = float( self.config.get('smtp_max_send_rate', 0.0)) return True def create_calendar_invite(self, target, attachments): """ Create a MIME calendar invite to be sent from a set of parameters. :param target: The information for the messages intended recipient. :type target: :py:class:`.MessageTarget` :param str uid: The message's unique identifier. :param attachments: The attachments to add to the created message. :type attachments: :py:class:`Attachments` :return: The new MIME message. :rtype: :py:class:`email.mime.multipart.MIMEMultipart` """ top_msg = TopMIMEMultipart('mixed', self.config, target) top_msg['To'] = target.email_address related_msg = mime.multipart.MIMEMultipart('related') top_msg.attach(related_msg) alt_msg = mime.multipart.MIMEMultipart('alternative') related_msg.attach(alt_msg) part = mime.base.MIMEBase('text', 'plain', charset='utf-8') part.set_payload( 'This calendar invite requires an HTML enabled viewer.\r\n\r\n') encoders.encode_base64(part) alt_msg.attach(part) with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h: msg_template = file_h.read() formatted_msg = render_message_template(msg_template, self.config, target=target) part = mime.text.MIMEText(formatted_msg, 'html', 'utf-8') alt_msg.attach(part) start_time = get_invite_start_from_config(self.config) if self.config['mailer.calendar_invite_all_day']: duration = ics.DurationAllDay() else: duration = int(self.config['mailer.calendar_invite_duration']) * 60 ical = ics.Calendar( self.config['mailer.source_email'], start_time, self.config.get('mailer.calendar_invite_summary'), duration=duration, location=self.config.get('mailer.calendar_invite_location')) ical.add_attendee(target.email_address, rsvp=self.config.get('mailer.calendar_request_rsvp', False)) part = mime.base.MIMEBase('text', 'calendar', charset='utf-8', method='REQUEST') part.set_payload(ical.to_ical(encoding='utf-8')) encoders.encode_base64(part) alt_msg.attach(part) for attach in attachments.images: related_msg.attach(attach) for attach in attachments.files: top_msg.attach(attach) return top_msg def create_email(self, target, attachments): """ Create a MIME email to be sent from a set of parameters. :param target: The information for the messages intended recipient. :type target: :py:class:`.MessageTarget` :param str uid: The message's unique identifier. :param attachments: The attachments to add to the created message. :type attachments: :py:class:`MessageAttachments` :return: The new MIME message. :rtype: :py:class:`email.mime.multipart.MIMEMultipart` """ msg = TopMIMEMultipart('related', self.config, target) target_field = self.config.get('mailer.target_field', 'to').lower() for header in ('To', 'CC', 'BCC'): if header.lower() == target_field: msg[header] = target.email_address continue value = self.config.get('mailer.recipient_email_' + header.lower()) if value: msg[header] = value importance = self.config.get('mailer.importance', 'Normal') if importance != 'Normal': msg['Importance'] = importance sensitivity = self.config.get('mailer.sensitivity', 'Normal') if sensitivity != 'Normal': msg['Sensitivity'] = sensitivity msg_alt = mime.multipart.MIMEMultipart('alternative') msg.attach(msg_alt) with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h: msg_template = file_h.read() formatted_msg = render_message_template(msg_template, self.config, target=target) msg_body = mime.text.MIMEText(formatted_msg, 'html', 'utf-8') msg_alt.attach(msg_body) # process attachments for attach in attachments.files: msg.attach(attach) for attach in attachments.images: msg.attach(attach) return msg def get_mime_attachments(self): """ Return a :py:class:`.MessageAttachments` object containing both the images and raw files to be included in sent messages. :return: A namedtuple of both files and images in their MIME containers. :rtype: :py:class:`.MessageAttachments` """ files = [] # allow the attachment_file.post_processing to be attached instead of # attachment_file so attachment_file can be used as an input for # arbitrary operations to modify without over writing the original attachment_file = self.config.get( 'mailer.attachment_file.post_processing') delete_attachment_file = False if attachment_file is not None: if not isinstance(attachment_file, str): raise TypeError( 'config option mailer.attachment_file.post_processing is not a readable file' ) if not os.path.isfile(attachment_file) and os.access( attachment_file, os.R_OK): raise ValueError( 'config option mailer.attachment_file.post_processing is not a readable file' ) self.config['mailer.attachment_file.post_processing'] = None delete_attachment_file = True else: attachment_file = self.config.get('mailer.attachment_file') if attachment_file: attachfile = mime.base.MIMEBase( *mimetypes.guess_type(attachment_file)) attachfile.set_payload(open(attachment_file, 'rb').read()) encoders.encode_base64(attachfile) attachfile.add_header( 'Content-Disposition', "attachment; filename=\"{0}\"".format( os.path.basename(attachment_file))) files.append(attachfile) if delete_attachment_file and os.access(attachment_file, os.W_OK): os.remove(attachment_file) images = [] for attachment_file, attachment_name in template_environment.attachment_images.items( ): attachfile = mime.image.MIMEImage( open(attachment_file, 'rb').read()) attachfile.add_header('Content-ID', "<{0}>".format(attachment_name)) attachfile.add_header( 'Content-Disposition', "inline; filename=\"{0}\"".format(attachment_name)) images.append(attachfile) return MessageAttachments(tuple(files), tuple(images)) def _prepare_env(self): with codecs.open(self.config['mailer.html_file'], 'r', encoding='utf-8') as file_h: msg_template = file_h.read() render_message_template(msg_template, self.config, analyze=True) template_environment.set_mode(template_environment.MODE_SEND) def _send_messages(self): emails_done = 0 mailer_tab = self.application.main_tabs['mailer'] max_messages_per_connection = self.config.get( 'mailer.max_messages_per_connection', 5) emails_total = "{0:,}".format(self.count_messages()) sending_line = "Sending email {{0: >{0},}} of {1} with UID: {{1}} to {{2}}".format( len(emails_total), emails_total) emails_total = int(emails_total.replace(',', '')) for target in self.iterate_targets(): iteration_time = time.time() if self.should_stop.is_set(): self.tab_notify_status('Sending emails cancelled') break if not self.process_pause(): break if emails_done > 0 and max_messages_per_connection > 0 and ( emails_done % max_messages_per_connection == 0): self.server_smtp_reconnect() emails_done += 1 self.tab_notify_status( sending_line.format(emails_done, target.uid, target.email_address)) mailer_tab.emit('send-target', target) attachments = self.get_mime_attachments() msg = getattr(self, 'create_' + self.config['mailer.message_type'])(target, attachments) mailer_tab.emit('send-message', target, msg) if not self._try_send_message(target.email_address, msg): break self.tab_notify_sent(emails_done, emails_total) campaign_id = self.config['campaign_id'] self.rpc('campaign/message/new', campaign_id, target.uid, target.email_address, target.first_name, target.last_name, target.department) self.application.emit('message-sent', target.uid, target.email_address) if self.max_messages_per_minute: iteration_time = (time.time() - iteration_time) self._sleep((60.0 / float(self.max_messages_per_minute)) - iteration_time) return emails_done def _sleep(self, duration): while duration > 0: sleep_chunk = min(duration, 0.5) time.sleep(sleep_chunk) if self.should_stop.is_set(): break duration -= sleep_chunk return self.should_stop.is_set() def _try_send_message(self, *args, **kwargs): message_sent = False while not message_sent and not self.should_stop.is_set(): for i in range(0, 3): try: self.send_message(*args, **kwargs) message_sent = True break except smtplib.SMTPServerDisconnected: self.logger.warning( 'failed to send message, the server has been disconnected' ) self.tab_notify_status( 'Failed to send message, the server has been disconnected' ) self.tab_notify_status( 'Sleeping for 5 seconds before attempting to reconnect' ) if self._sleep(5): break self.smtp_connection = None self.server_smtp_reconnect() except smtplib.SMTPException as error: self.tab_notify_status( "Failed to send message (exception: {0})".format( error.__class__.__name__)) self.logger.warning( "failed to send message (exception: smtplib.{0})". format(error.__class__.__name__)) self._sleep((i + 1)**2) if not message_sent: self.server_smtp_disconnect() if not self.process_pause(True): return False self.server_smtp_reconnect() return True def send_message(self, target_email, msg): """ Send an email using the connected SMTP server. :param str target_email: The email address to send the message to. :param msg: The formatted message to be sent. :type msg: :py:class:`.mime.multipart.MIMEMultipart` """ source_email = self.config['mailer.source_email_smtp'] self.smtp_connection.sendmail(source_email, target_email, msg.as_string(), self.mail_options) def pause(self): """ Sets the :py:attr:`~.MailSenderThread.running` and :py:attr:`~.MailSenderThread.paused` flags correctly to indicate that the object is paused. """ self.running.clear() self.paused.set() def unpause(self): """ Sets the :py:attr:`~.MailSenderThread.running` and :py:attr:`~.MailSenderThread.paused` flags correctly to indicate that the object is no longer paused. """ self.running.set() def stop(self): """ Requests that the email sending operation stop. It can not be resumed from the same position. This function blocks until the stop request has been processed and the thread exits. """ self.should_stop.set() self.unpause() if self.is_alive(): self.join() def missing_files(self): """ Return a list of all missing or unreadable files which are referenced by the message template. :return: The list of unusable files. :rtype: list """ missing = [] attachment = self.config.get('mailer.attachment_file') if attachment and not os.access(attachment, os.R_OK): missing.append(attachment) msg_template = self.config['mailer.html_file'] if not os.access(msg_template, os.R_OK): missing.append(msg_template) return missing self._prepare_env() for attachment in template_environment.attachment_images.keys(): if not os.access(attachment, os.R_OK): missing.append(attachment) return missing
class KingPhisherClient(Gtk.Window): __gsignals__ = { 'campaign_set': (GObject.SIGNAL_RUN_FIRST, None, (str,)) } def __init__(self, config_file=None): super(KingPhisherClient, self).__init__() self.logger = logging.getLogger('KingPhisher.Client') self.config_file = (config_file or CONFIG_FILE_PATH) self.load_config() self.set_property('title', 'King Phisher') vbox = Gtk.Box() vbox.set_property('orientation', Gtk.Orientation.VERTICAL) vbox.show() self.add(vbox) action_group = Gtk.ActionGroup("client_window_actions") self._add_menu_actions(action_group) uimanager = self._create_ui_manager() uimanager.insert_action_group(action_group) self.uimanager = uimanager menubar = uimanager.get_widget("/MenuBar") vbox.pack_start(menubar, False, False, 0) # create notebook and tabs hbox = Gtk.Box() hbox.set_property('orientation', Gtk.Orientation.HORIZONTAL) hbox.show() self.notebook = Gtk.Notebook() self.notebook.connect('switch-page', self._tab_changed) self.notebook.set_scrollable(True) hbox.pack_start(self.notebook, True, True, 0) vbox.pack_start(hbox, True, True, 0) self.tabs = {} current_page = self.notebook.get_current_page() self.last_page_id = current_page mailer_tab = MailSenderTab(self.config, self) self.tabs['mailer'] = mailer_tab self.notebook.insert_page(mailer_tab.box, mailer_tab.label, current_page + 1) self.notebook.set_current_page(current_page + 1) campaign_tab = CampaignViewTab(self.config, self) campaign_tab.box.show() self.tabs['campaign'] = campaign_tab self.notebook.insert_page(campaign_tab.box, campaign_tab.label, current_page + 2) self.set_size_request(800, 600) self.connect('destroy', self.signal_window_destroy) self.notebook.show() self.show() self.rpc = None self.ssh_forwarder = None def _add_menu_actions(self, action_group): # File Menu Actions action = Gtk.Action('FileMenu', 'File', None, None) action_group.add_action(action) action = Gtk.Action('FileOpenCampaign', '_Open Campaign', 'Open a Campaign', Gtk.STOCK_NEW) action.connect('activate', lambda x: self.show_campaign_selection()) action_group.add_action_with_accel(action, '<control>O') action = Gtk.Action('FileExportMenu', 'Export', None, None) action_group.add_action(action) action = Gtk.Action('FileExportXML', 'Export XML', 'Export XML', None) action.connect('activate', lambda x: self.export_xml()) action_group.add_action(action) action = Gtk.Action('FileQuit', None, None, Gtk.STOCK_QUIT) action.connect('activate', lambda x: self.client_quit()) action_group.add_action_with_accel(action, '<control>Q') # Edit Menu Actions action = Gtk.Action('EditMenu', 'Edit', None, None) action_group.add_action(action) action = Gtk.Action('EditPreferences', 'Preferences', 'Edit preferences', Gtk.STOCK_EDIT) action.connect('activate', lambda x: self.edit_preferences()) action_group.add_action(action) action = Gtk.Action('EditDeleteCampaign', 'Delete Campaign', 'Delete Campaign', None) action.connect('activate', lambda x: self.delete_campaign()) action_group.add_action(action) action = Gtk.Action('EditStopService', 'Stop Service', 'Stop Remote King-Phisher Service', None) action.connect('activate', lambda x: self.stop_remote_service()) action_group.add_action(action) # Tools Menu Action action = Gtk.Action('ToolsMenu', 'Tools', None, None) action_group.add_action(action) action = Gtk.Action('ToolsRPCTerminal', 'RPC Terminal', 'RPC Terminal', None) action.connect('activate', lambda x: KingPhisherClientRPCTerminal(self.config, self)) action_group.add_action(action) # Help Menu Actions action = Gtk.Action('HelpMenu', 'Help', None, None) action_group.add_action(action) if graphs.has_matplotlib: action = Gtk.Action('ToolsGraphMenu', 'Create Graph', None, None) action_group.add_action(action) for graph_name in graphs.get_graphs(): action_name = 'ToolsGraph' + graph_name action = Gtk.Action(action_name, graph_name, graph_name, None) action.connect('activate', lambda _: self.show_campaign_graph(graph_name)) action_group.add_action(action) action = Gtk.Action('HelpAbout', 'About', 'About', None) action.connect('activate', lambda x: self.show_about_dialog()) action_group.add_action(action) action = Gtk.Action('HelpWiki', 'Wiki', 'Wiki', None) action.connect('activate', lambda x: utilities.open_uri('https://github.com/securestate/king-phisher/wiki')) action_group.add_action(action) def _create_ui_manager(self): uimanager = Gtk.UIManager() with open(find.find_data_file('ui_info/client_window.xml')) as ui_info_file: ui_data = ui_info_file.read() uimanager.add_ui_from_string(ui_data) if graphs.has_matplotlib: merge_id = uimanager.new_merge_id() uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu', 'ToolsGraphMenu', 'ToolsGraphMenu', Gtk.UIManagerItemType.MENU, False) for graph_name in graphs.get_graphs(): action_name = 'ToolsGraph' + graph_name uimanager.add_ui(merge_id, '/MenuBar/ToolsMenu/ToolsGraphMenu', action_name, action_name, Gtk.UIManagerItemType.MENUITEM, False) accelgroup = uimanager.get_accel_group() self.add_accel_group(accelgroup) return uimanager def _tab_changed(self, notebook, current_page, index): previous_page = notebook.get_nth_page(self.last_page_id) self.last_page_id = index mailer_tab = self.tabs.get('mailer') campaign_tab = self.tabs.get('campaign') notebook = None if mailer_tab and current_page == mailer_tab.box: notebook = mailer_tab.notebook elif campaign_tab and current_page == campaign_tab.box: notebook = campaign_tab.notebook if notebook: index = notebook.get_current_page() notebook.emit('switch-page', notebook.get_nth_page(index), index) def do_campaign_set(self, campaign_id): self.logger.info("campaign set to {0} (id: {1})".format(self.config['campaign_name'], self.config['campaign_id'])) def signal_window_destroy(self, window): gui_utilities.gtk_widget_destroy_children(self) gui_utilities.gtk_sync() self.server_disconnect() self.save_config() Gtk.main_quit() return def init_connection(self): if not self.server_connect(): return False self.load_server_config() campaign_id = self.config.get('campaign_id') if campaign_id == None: if not self.show_campaign_selection(): self.server_disconnect() return False campaign_info = self.rpc.remote_table_row('campaigns', self.config['campaign_id'], cache=True) if campaign_info == None: if not self.show_campaign_selection(): self.server_disconnect() return False campaign_info = self.rpc.remote_table_row('campaigns', self.config['campaign_id'], cache=True, refresh=True) self.config['campaign_name'] = campaign_info['name'] self.emit('campaign_set', self.config['campaign_id']) return True def client_quit(self, destroy=True): self.destroy() return def server_connect(self): import socket server_version_info = None while True: if self.ssh_forwarder: self.ssh_forwarder.stop() self.ssh_forwarder = None self.logger.info('stopped ssh port forwarding') login_dialog = KingPhisherClientLoginDialog(self.config, self) login_dialog.objects_load_from_config() response = login_dialog.interact() if response == Gtk.ResponseType.CANCEL: return False server = utilities.server_parse(self.config['server'], 22) username = self.config['server_username'] password = self.config['server_password'] server_remote_port = self.config.get('server_remote_port', 80) local_port = random.randint(2000, 6000) try: self.ssh_forwarder = SSHTCPForwarder(server, username, password, local_port, ('127.0.0.1', server_remote_port), preferred_private_key=self.config.get('ssh_preferred_key')) self.ssh_forwarder.start() time.sleep(0.5) self.logger.info('started ssh port forwarding') except paramiko.AuthenticationException: self.logger.warning('failed to authenticate to the remote ssh server') gui_utilities.show_dialog_error('Invalid Credentials', self) continue except: self.logger.warning('failed to connect to the remote ssh server') gui_utilities.show_dialog_error('Failed To Connect To The SSH Service', self) continue self.rpc = KingPhisherRPCClient(('localhost', local_port), username=username, password=password) try: server_version_info = self.rpc('version') assert(self.rpc('client/initialize')) except AdvancedHTTPServerRPCError as err: if err.status == 401: self.logger.warning('failed to authenticate to the remote king phisher service') gui_utilities.show_dialog_error('Invalid Credentials', self) else: self.logger.warning('failed to connect to the remote rpc server with http status: ' + str(err.status)) gui_utilities.show_dialog_error('Failed To Connect To The King Phisher RPC Service', self, 'The server responded with HTTP status: ' + str(err.status)) continue except: self.logger.warning('failed to connect to the remote rpc service') gui_utilities.show_dialog_error('Failed To Connect To The King Phisher RPC Service', self) continue break assert(server_version_info != None) server_rpc_api_version = server_version_info.get('rpc_api_version', -1) self.logger.info("successfully connected to the king phisher server (version: {0} rpc api version: {1})".format(server_version_info['version'], server_rpc_api_version)) self.server_local_port = local_port if server_rpc_api_version != version.rpc_api_version: if version.rpc_api_version < server_rpc_api_version: secondary_text = 'The local client is not up to date with the server version.' else: secondary_text = 'The remote server is not up to date with the client version.' secondary_text += '\nPlease ensure that both the client and server are fully up to date.' gui_utilities.show_dialog_error('The RPC API Versions Are Incompatible', self, secondary_text) self.server_disconnect() return False return True def server_disconnect(self): if self.ssh_forwarder: self.ssh_forwarder.stop() self.ssh_forwarder = None self.logger.info('stopped ssh port forwarding') return def load_config(self): self.logger.info('loading the config from disk') config_file = os.path.expanduser(self.config_file) if not os.path.isfile(config_file): shutil.copy(find.find_data_file('client_config.json'), config_file) self.config = json.load(open(config_file, 'rb')) def load_server_config(self): self.config['server_config'] = self.rpc('config/get', ['server.require_id', 'server.secret_id', 'server.tracking_image']) return def save_config(self): self.logger.info('writing the config to disk') config = copy.copy(self.config) for key in self.config.keys(): if 'password' in key or key == 'server_config': del config[key] config_file = os.path.expanduser(self.config_file) config_file_h = open(config_file, 'wb') json.dump(config, config_file_h, sort_keys=True, indent=4) def delete_campaign(self): if not gui_utilities.show_dialog_yes_no('Delete This Campaign?', self, 'This action is irreversible. All campaign data will be lost.'): return self.rpc('campaign/delete', self.config['campaign_id']) if not self.show_campaign_selection(): gui_utilities.show_dialog_error('A Campaign Must Be Selected', self, 'Now exiting') self.client_quit() def edit_preferences(self): dialog = KingPhisherClientConfigDialog(self.config, self) if dialog.interact() != Gtk.ResponseType.CANCEL: self.save_config() def export_xml(self): dialog = gui_utilities.UtilityFileChooser('Export Campaign XML Data', self) file_name = self.config['campaign_name'] + '.xml' response = dialog.run_quick_save(file_name) dialog.destroy() if not response: return destination_file = response['target_filename'] export.campaign_to_xml(self.rpc, self.config['campaign_id'], destination_file) def show_about_dialog(self): license_text = None if os.path.splitext(__file__)[1] == '.py': source_file_h = open(__file__, 'r') source_code = [] source_code.append(source_file_h.readline()) while source_code[-1].startswith('#'): source_code.append(source_file_h.readline()) source_code = source_code[5:-1] source_code = map(lambda x: x.strip('# '), source_code) license_text = ''.join(source_code) about_dialog = Gtk.AboutDialog() about_dialog.set_transient_for(self) about_dialog_properties = { 'authors': ['Spencer McIntyre', 'Jeff McCutchan', 'Brandan Geise'], 'comments': 'Phishing Campaign Toolkit', 'copyright': '(c) 2013 SecureState', 'license-type': Gtk.License.BSD, 'program-name': 'King Phisher', 'version': version.version, 'website': 'https://github.com/securestate/king-phisher', 'website-label': 'GitHub Home Page', 'wrap-license': False, } if license_text: about_dialog_properties['license'] = license_text for property_name, property_value in about_dialog_properties.items(): about_dialog.set_property(property_name, property_value) about_dialog.connect('activate-link', lambda _, url: utilities.open_uri(url)) about_dialog.show_all() about_dialog.run() about_dialog.destroy() def show_campaign_graph(self, graph_name): Klass = graphs.get_graph(graph_name) graph_inst = Klass(self.config, self) graph_inst.load_graph() window = graph_inst.make_window() window.show_all() def show_campaign_selection(self): dialog = KingPhisherClientCampaignSelectionDialog(self.config, self) return dialog.interact() != Gtk.ResponseType.CANCEL def stop_remote_service(self): if not gui_utilities.show_dialog_yes_no('Stop The Remote King Phisher Service?', self, 'This will stop the remote King Phisher service and\nnew incoming requests will not be processed.'): return self.rpc('shutdown') self.logger.info('the remote king phisher service has been stopped') gui_utilities.show_dialog_error('The Remote Service Has Been Stopped', self, 'Now exiting') self.client_quit() return