diff --git a/app/experimentcontrol.py b/app/experimentcontrol.py index ad03c7d..dd397aa 100644 --- a/app/experimentcontrol.py +++ b/app/experimentcontrol.py @@ -303,9 +303,13 @@ class Experiment(): if self.machine_needs_caldera(target_1, caldera_attacks): target_1.install_caldera_service() target_1.up() + print("before reboot") target_1.reboot() # Kernel changes on system creation require a reboot + print("after reboot") needs_reboot = target_1.prime_vulnerabilities() + print("after prime vulns") needs_reboot |= target_1.prime_sensors() + print("after prime sens") if needs_reboot: self.attack_logger.vprint( f"{CommandlineColors.OKBLUE}rebooting target {tname} ....{CommandlineColors.ENDC}", 1) diff --git a/app/machinecontrol.py b/app/machinecontrol.py index 07cf85b..a182824 100644 --- a/app/machinecontrol.py +++ b/app/machinecontrol.py @@ -9,19 +9,23 @@ import time import requests from app.config import MachineConfig -from app.exceptions import ServerError, ConfigurationError +from app.exceptions import ServerError, ConfigurationError, PluginError from app.pluginmanager import PluginManager from app.calderacontrol import CalderaControl from app.interface_sfx import CommandlineColors +from app.attack_log import AttackLog from plugins.base.machinery import MachineryPlugin from plugins.base.sensor import SensorPlugin from plugins.base.vulnerability_plugin import VulnerabilityPlugin +from app.config_verifier import Attacker, Target + +from typing import Any, Optional, Union class Machine(): """ A virtual machine. Attacker or target. Abstracting stuff away. """ - def __init__(self, config, attack_logger, calderakey="ADMIN123",): + def __init__(self, config: Union[dict, MachineConfig, Attacker, Target], attack_logger: AttackLog, calderakey: str = "ADMIN123",) -> None: """ :param config: The machine configuration as dict @@ -29,14 +33,22 @@ class Machine(): :param calderakey: Key to the caldera controller """ - self.vm_manager = None - self.attack_logger = None - self.set_attack_logger(attack_logger) + self.vm_manager: Optional[MachineryPlugin] = None + self.attack_logger: AttackLog = attack_logger + self.calderakey: str = calderakey + self.sensors: list[SensorPlugin] = [] # Sensor plugins + self.vulnerabilities: list[VulnerabilityPlugin] = [] # Vulnerability plugins + self.caldera_server: str = "" if isinstance(config, MachineConfig): self.config = config - else: + elif isinstance(config, Attacker): self.config = MachineConfig(config) + elif isinstance(config, Target): + self.config = MachineConfig(config) + else: + print(type(config)) + raise ConfigurationError("unknown type") self.plugin_manager = PluginManager(self.attack_logger) @@ -46,8 +58,6 @@ class Machine(): if self.config.vmcontroller() == "running_vm": self.__parse_running_vm_config__() - self.caldera_server = None - self.abs_machinepath_external = None self.abs_machinepath_external = os.path.join(self.vagrantfilepath, self.config.machinepath()) @@ -58,13 +68,14 @@ class Machine(): raise ConfigurationError(f"machinepath does not exist: {self.abs_machinepath_external}") self.load_machine_plugin() - self.caldera_basedir = self.vm_manager.get_playground() - - self.calderakey = calderakey - self.sensors = [] # Sensor plugins - self.vulnerabilities = [] # Vulnerability plugins - - def __parse_vagrant_config__(self): + if self.vm_manager is None: + raise ConfigurationError("VM manager required") + playground = self.vm_manager.get_playground() + if playground is None: + playground = "" + self.caldera_basedir: str = playground + + def __parse_vagrant_config__(self) -> None: """ Check if a file configured in the config is present """ self.vagrantfilepath = os.path.abspath(self.config.vagrantfilepath()) @@ -72,113 +83,152 @@ class Machine(): if not os.path.isfile(self.vagrantfile): raise ConfigurationError(f"Vagrantfile not existing: {self.vagrantfile}") - def __parse_running_vm_config__(self): + def __parse_running_vm_config__(self) -> None: """ Check if a file configured in the config is present """ self.vagrantfilepath = os.path.abspath(self.config.vagrantfilepath()) self.vagrantfile = os.path.join(self.vagrantfilepath, "Vagrantfile") - def get_paw(self): + def get_paw(self) -> Optional[str]: """ Returns the paw of the current machine """ return self.config.caldera_paw() - def get_group(self): + def get_group(self) -> Optional[str]: """ Returns the group of the current machine """ return self.config.caldera_group() - def destroy(self): + def destroy(self) -> None: """ Destroys the current machine """ + if self.vm_manager is None: + raise ConfigurationError("VM Manager is missing") + self.vm_manager.__call_destroy__() - def create(self, reboot=True): + def create(self, reboot: bool = True) -> None: """ Create a VM :param reboot: Reboot the VM during installation. Required if you want to install software """ + if self.vm_manager is None: + raise ConfigurationError("VM Manager is missing") + self.vm_manager.__call_create__(reboot) - def reboot(self): + def reboot(self) -> None: """ Reboot a machine """ + if self.vm_manager is None: + raise ConfigurationError("VM Manager is missing") + if self.get_os() == "windows": self.remote_run("shutdown /r") self.vm_manager.__call_disconnect__() time.sleep(60) # Shutdown can be slow.... if self.get_os() == "linux": - self.remote_run("reboot") + self.remote_run("sudo reboot", must_succeed=False) self.vm_manager.__call_disconnect__() res = None while not res: time.sleep(5) + + if self.vm_manager is None: + raise ConfigurationError("VM Manager is missing") + res = self.vm_manager.__call_connect__() - self.attack_logger.vprint("Re-connecting....", 3) + if self.attack_logger is not None: + self.attack_logger.vprint("Re-connecting....", 3) + self.attack_logger.vprint(f"The machine {self.vm_manager.get_vm_name()} is back {res.is_connected}", 3) - def up(self): # pylint: disable=invalid-name + def up(self) -> None: # pylint: disable=invalid-name """ Starts a VM. Creates it if not already created """ + if self.vm_manager is None: + raise ConfigurationError("VM Manager is missing") + self.vm_manager.__call_up__() - def halt(self): + def halt(self) -> None: """ Halts a VM """ + if self.vm_manager is None: + raise ConfigurationError("VM Manager is missing") + self.vm_manager.__call_halt__() - def getuser(self): + def getuser(self) -> str: """ Gets the user of the current VM """ + if self.vm_manager is None: + raise ConfigurationError("VM Manager is missing") + return "Result " + str(self.vm_manager.__call_remote_run__("echo $USER")) - def connect(self): + def connect(self) -> Any: """ command connection. establish it """ + if self.vm_manager is None: + raise ConfigurationError("VM Manager is missing") + return self.vm_manager.__call_connect__() - def disconnect(self, connection): + def disconnect(self, connection: Any) -> None: """ Command connection dis-connect """ - self.vm_manager.__call_disconnect__(connection) + if self.vm_manager is None: + raise ConfigurationError("VM Manager is missing") + + self.vm_manager.__call_disconnect__() - def remote_run(self, cmd, disown=False): + def remote_run(self, cmd: str, disown: bool = False, must_succeed: bool = False) -> str: """ Simplifies connect and run :param cmd: Command to run as shell command :param disown: run in background + :param must_succeed: Throw an exception if the command being run fails. """ + if self.vm_manager is None: + raise ConfigurationError("Missing VM Manager") + return self.vm_manager.__call_remote_run__(cmd, disown, must_succeed) - return self.vm_manager.__call_remote_run__(cmd, disown) - - def load_machine_plugin(self): + def load_machine_plugin(self) -> None: """ Loads the matching machine plugin """ for plugin in self.plugin_manager.get_plugins(MachineryPlugin, [self.config.vmcontroller()]): - + if not isinstance(plugin, MachineryPlugin): + raise PluginError("Expected Machinery Plugin") name = plugin.get_name() - self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Installing machinery: {name}{CommandlineColors.ENDC}", 1) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Installing machinery: {name}{CommandlineColors.ENDC}", 1) syscon = {"abs_machinepath_internal": self.abs_machinepath_internal, "abs_machinepath_external": self.abs_machinepath_external} plugin.set_sysconf(syscon) plugin.__call_process_config__(self.config) self.vm_manager = plugin - self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Installed machinery: {name}{CommandlineColors.ENDC}", + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Installed machinery: {name}{CommandlineColors.ENDC}", 1) break - def prime_sensors(self): + def prime_sensors(self) -> bool: """ Prime sensors from plugins (hard core installs that could require a reboot) A machine can have several sensors running. Those are defined in a list in the config. This primes the sensors + :result: true if a reboot is required """ reboot = False for plugin in self.plugin_manager.get_plugins(SensorPlugin, self.config.sensors()): + if not isinstance(plugin, SensorPlugin): + raise PluginError("Expected sensor plugin") name = plugin.get_name() # if name in self.config.sensors(): - self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Priming sensor: {name}{CommandlineColors.ENDC}", 2) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Priming sensor: {name}{CommandlineColors.ENDC}", 2) syscon = {"abs_machinepath_internal": self.abs_machinepath_internal, "abs_machinepath_external": self.abs_machinepath_external, } @@ -189,10 +239,11 @@ class Machine(): plugin.setup() reboot |= plugin.prime() self.sensors.append(plugin) - self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Primed sensor: {name}{CommandlineColors.ENDC}", 2) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Primed sensor: {name}{CommandlineColors.ENDC}", 2) return reboot - def install_sensors(self): + def install_sensors(self) -> None: """ Install sensors from plugins A machine can have several sensors running. Those are defined in a list in the config. This installs the sensors @@ -202,7 +253,8 @@ class Machine(): for plugin in self.get_sensors(): name = plugin.get_name() - self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Installing sensor: {name}{CommandlineColors.ENDC}", 2) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Installing sensor: {name}{CommandlineColors.ENDC}", 2) syscon = {"abs_machinepath_internal": self.abs_machinepath_internal, "abs_machinepath_external": self.abs_machinepath_external, } @@ -211,25 +263,28 @@ class Machine(): plugin.process_config(self.config.raw_config.get(name, {})) # plugin specific configuration plugin.setup() plugin.install() - self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Installed sensor: {name}{CommandlineColors.ENDC}", 2) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Installed sensor: {name}{CommandlineColors.ENDC}", 2) def get_sensors(self) -> list[SensorPlugin]: """ Returns a list of running sensors """ return self.sensors - def start_sensors(self): + def start_sensors(self) -> None: """ Start sensors A machine can have several sensors running. Those are defined in a list in the config. This starts the sensors """ for plugin in self.get_sensors(): - self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Starting sensor: {plugin.get_name()}{CommandlineColors.ENDC}", 2) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Starting sensor: {plugin.get_name()}{CommandlineColors.ENDC}", 2) plugin.set_machine_plugin(self.vm_manager) plugin.start() - self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Started sensor: {plugin.get_name()}{CommandlineColors.ENDC}", 2) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Started sensor: {plugin.get_name()}{CommandlineColors.ENDC}", 2) - def stop_sensors(self): + def stop_sensors(self) -> None: """ Stop sensors A machine can have several sensors running. Those are defined in a list in the config. This stops the sensors @@ -237,17 +292,20 @@ class Machine(): """ for plugin in self.get_sensors(): - self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Stopping sensor: {plugin.get_name()}{CommandlineColors.ENDC}", 2) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Stopping sensor: {plugin.get_name()}{CommandlineColors.ENDC}", 2) plugin.set_machine_plugin(self.vm_manager) plugin.stop() - self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Stopped sensor: {plugin.get_name()}{CommandlineColors.ENDC}", 2) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Stopped sensor: {plugin.get_name()}{CommandlineColors.ENDC}", 2) - def collect_sensors(self, lootdir): + def collect_sensors(self, lootdir: str) -> list[str]: """ Collect data from sensors A machine can have several sensors running. Those are defined in a list in the config. This collects the data from the sensors :param lootdir: Fresh created directory for loot + :returns: a list of file names to put into the loot zip """ machine_specific_path = os.path.join(lootdir, self.config.vmname()) @@ -255,27 +313,32 @@ class Machine(): loot_files = [] for plugin in self.get_sensors(): - self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Collecting sensor: {plugin.get_name()}{CommandlineColors.ENDC}", 2) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Collecting sensor: {plugin.get_name()}{CommandlineColors.ENDC}", 2) plugin.set_machine_plugin(self.vm_manager) loot_files += plugin.__call_collect__(machine_specific_path) - self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Collected sensor: {plugin.get_name()}{CommandlineColors.ENDC}", 2) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Collected sensor: {plugin.get_name()}{CommandlineColors.ENDC}", 2) return loot_files ############ - def prime_vulnerabilities(self): + def prime_vulnerabilities(self) -> bool: """ Prime vulnerabilities from plugins (hard core installs that could require a reboot) A machine can have several vulnerabilities. Those are defined in a list in the config. + :returns: True if a reboot is requires """ reboot = False for plugin in self.plugin_manager.get_plugins(VulnerabilityPlugin, self.config.vulnerabilities()): + if not isinstance(plugin, VulnerabilityPlugin): + raise PluginError("Plugin manager returned wrong plugin type") name = plugin.get_name() - - self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Priming vulnerability: {name}{CommandlineColors.ENDC}", 2) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Priming vulnerability: {name}{CommandlineColors.ENDC}", 2) syscon = {"abs_machinepath_internal": self.abs_machinepath_internal, "abs_machinepath_external": self.abs_machinepath_external, } @@ -285,10 +348,11 @@ class Machine(): plugin.setup() reboot |= plugin.prime() self.vulnerabilities.append(plugin) - self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Primed vulnerability: {name}{CommandlineColors.ENDC}", 2) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Primed vulnerability: {name}{CommandlineColors.ENDC}", 2) return reboot - def install_vulnerabilities(self): + def install_vulnerabilities(self) -> None: """ Install vulnerabilities from plugins: The machine is not yet modified ! For that call start_vulnerabilities next A machine can have several vulnerabilities. Those are defined in a list in the config. This installs the vulnerabilities @@ -296,9 +360,12 @@ class Machine(): """ for plugin in self.plugin_manager.get_plugins(VulnerabilityPlugin, self.config.vulnerabilities()): + if not isinstance(plugin, VulnerabilityPlugin): + raise PluginError("Plugin manager returned wrong plugin type") name = plugin.get_name() - self.attack_logger.vprint(f"Configured vulnerabilities: {self.config.vulnerabilities()}", 3) - self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Installing vulnerability: {name}{CommandlineColors.ENDC}", 2) + if self.attack_logger is not None: + self.attack_logger.vprint(f"Configured vulnerabilities: {self.config.vulnerabilities()}", 3) + self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Installing vulnerability: {name}{CommandlineColors.ENDC}", 2) syscon = {"abs_machinepath_internal": self.abs_machinepath_internal, "abs_machinepath_external": self.abs_machinepath_external} plugin.set_sysconf(syscon) @@ -312,25 +379,27 @@ class Machine(): """ Returns a list of installed vulnerabilities """ return self.vulnerabilities - def start_vulnerabilities(self): + def start_vulnerabilities(self) -> None: """ Really install the vulnerabilities on the machine A machine can have vulnerabilities installed. Those are defined in a list in the config. This starts the vulnerabilities """ for plugin in self.get_vulnerabilities(): - self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Activating vulnerability: {plugin.get_name()}{CommandlineColors.ENDC}", 2) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Activating vulnerability: {plugin.get_name()}{CommandlineColors.ENDC}", 2) plugin.set_machine_plugin(self.vm_manager) plugin.start() - def stop_vulnerabilities(self): + def stop_vulnerabilities(self) -> None: """ Un-install the vulnerabilities on the machine A machine can have vulnerabilities installed. Those are defined in a list in the config. This stops the vulnerabilities """ for plugin in self.get_vulnerabilities(): - self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Uninstalling vulnerability: {plugin.get_name()}{CommandlineColors.ENDC}", 2) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Uninstalling vulnerability: {plugin.get_name()}{CommandlineColors.ENDC}", 2) plugin.set_machine_plugin(self.vm_manager) plugin.stop() @@ -340,6 +409,8 @@ class Machine(): """ Returns the IP of the main ethernet interface of this machine """ # TODO: Find a smarter way to get the ip + if self.vm_manager is None: + raise ConfigurationError("Missing VM Manager") return self.vm_manager.get_ip() @@ -353,23 +424,31 @@ class Machine(): return self.config.get_nicknames() - def get_playground(self) -> str: + def get_playground(self) -> Optional[str]: """ Return this machine's playground """ + if self.vm_manager is None: + raise ConfigurationError("Missing VM Manager") return self.vm_manager.get_playground() def get_machine_path_external(self) -> str: """ Returns the external path for this machine """ + if self.vm_manager is None: + raise ConfigurationError("Missing VM Manager") return self.vm_manager.get_machine_path_external() - def put(self, src: str, dst: str): + def put(self, src: str, dst: str) -> Any: """ Send a file to the machine """ + if self.vm_manager is None: + raise ConfigurationError("Missing VM Manager") return self.vm_manager.put(src, dst) - def get(self, src: str, dst: str): + def get(self, src: str, dst: str) -> Any: """ Get a file from a machine """ + if self.vm_manager is None: + raise ConfigurationError("Missing VM Manager") return self.vm_manager.get(src, dst) @@ -391,14 +470,18 @@ class Machine(): # TODO: Metasploit implant # options for version: "4.0.0-alpha.2" and "2.8.1" - def install_caldera_server(self, cleanup=False, version="4.0.0-alpha.2"): + def install_caldera_server(self, cleanup: bool = False, version: str = "4.0.0-alpha.2") -> str: """ Installs the caldera server on the VM :param cleanup: Remove the old caldera version. Slow but reduces side effects :param version: Caldera version to use. Check Caldera git for potential branches to use """ # https://github.com/mitre/caldera.git - self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Installing Caldera server {CommandlineColors.ENDC}", 1) + + if self.vm_manager is None: + raise ConfigurationError("VM manager missing") + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Installing Caldera server {CommandlineColors.ENDC}", 1) if cleanup: cleanupcmd = "rm -rf caldera;" @@ -406,20 +489,25 @@ class Machine(): cleanupcmd = "" cmd = f"cd {self.caldera_basedir}; {cleanupcmd} git clone https://github.com/mitre/caldera.git --recursive --branch {version}; cd caldera; git checkout {version}; pip3 install -r requirements.txt" - self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Caldera server installed {CommandlineColors.ENDC}", 1) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Caldera server installed {CommandlineColors.ENDC}", 1) res = self.vm_manager.__call_remote_run__(cmd) return "Result installing caldera server " + str(res) - def wait_for_caldera_server(self, timeout=6): + def wait_for_caldera_server(self, timeout: int = 6) -> bool: """ Ping caldera server. return as soon as it is responding :param timeout: timeout in seconds """ + if self.attack_logger is None: + raise ConfigurationError("Attack logger required") + for i in range(timeout): time.sleep(10) caldera_url = "http://" + self.get_ip() + ":8888" caldera_control = CalderaControl(caldera_url, self.attack_logger, apikey=self.calderakey) - self.attack_logger.vprint(f"{i} Trying to connect to {caldera_url} Caldera API", 3) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{i} Trying to connect to {caldera_url} Caldera API", 3) try: caldera_control.list_adversaries() except requests.exceptions.ConnectionError: @@ -429,20 +517,22 @@ class Machine(): return True raise ServerError - def start_caldera_server(self): + def start_caldera_server(self) -> None: """ Start the caldera server on the VM. Required for an attacker VM """ # https://github.com/mitre/caldera.git - self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Starting Caldera server {CommandlineColors.ENDC}", 1) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Starting Caldera server {CommandlineColors.ENDC}", 1) # The pkill was added because the server sometimes gets stuck. And we can not re-create the attacking machines in all cases - self.remote_run(" pkill -f server.py;", disown=False) + self.remote_run(" pkill -f server.py || true;", disown=False) cmd = f"cd {self.caldera_basedir}; cd caldera ; nohup python3 server.py --insecure &" self.remote_run(cmd, disown=True) self.wait_for_caldera_server() - self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Caldera server started. Confirmed it is running. {CommandlineColors.ENDC}", 1) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Caldera server started. Confirmed it is running. {CommandlineColors.ENDC}", 1) - def create_start_caldera_client_cmd(self): + def create_start_caldera_client_cmd(self) -> str: """ Creates a command to start the caldera client """ playground = self.get_playground() @@ -462,20 +552,32 @@ class Machine(): return cmd - def start_caldera_client(self): + def start_caldera_client(self) -> None: """ Install caldera client. Required on targets """ + if self.vm_manager is None: + raise PluginError("Vm manager not available") + name = self.vm_manager.get_vm_name() - self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Starting Caldera client {name} {CommandlineColors.ENDC}", 1) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Starting Caldera client {name} {CommandlineColors.ENDC}", 1) if self.get_os() == "windows": + if self.caldera_server is None: + raise ConfigurationError("Caldera server not set") url = "http://" + self.caldera_server + ":8888" + if not isinstance(self.attack_logger, AttackLog): + raise ConfigurationError("attack_logger is not of type AttackLog") + if self.abs_machinepath_external is None: + raise ConfigurationError("External machine path not set") caldera_control = CalderaControl(url, self.attack_logger, apikey=self.calderakey) caldera_control.fetch_client(platform="windows", file="sandcat.go", target_dir=self.abs_machinepath_external, extension=".go") dst = self.get_playground() + if self.abs_machinepath_external is None: + raise ConfigurationError("External machine path not set") src = os.path.join(self.abs_machinepath_external, "caldera_agent.bat") self.vm_manager.put(src, dst) src = os.path.join(self.abs_machinepath_external, "splunkd.go") # sandcat.go local name @@ -488,6 +590,10 @@ class Machine(): if self.get_os() == "linux": dst = self.get_playground() + if self.abs_machinepath_external is None: + raise ConfigurationError("machine_path external not set") + if dst is None: + raise ConfigurationError("Missing playground") src = os.path.join(self.abs_machinepath_external, "caldera_agent.sh") self.vm_manager.put(src, dst) @@ -496,14 +602,15 @@ class Machine(): print(cmd) self.remote_run(cmd, disown=True) - self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Caldera client started {CommandlineColors.ENDC}", 1) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Caldera client started {CommandlineColors.ENDC}", 1) - def get_os(self): + def get_os(self) -> str: """ Returns the OS of the machine """ return self.config.os() - def __wmi_cmd_for_caldera_implant(self): + def __wmi_cmd_for_caldera_implant(self) -> str: """ Creates a windows specific command to start the caldera implant in background using wmi """ playground = self.get_playground() @@ -511,15 +618,23 @@ class Machine(): playground = playground + "\\" else: playground = "%userprofile%\\" + if self.caldera_server is None: + raise ConfigurationError("Caldera server not configured") url = "http://" + self.caldera_server + ":8888" res = f'wmic process call create "{playground}splunkd.go -server {url} -group {self.config.caldera_group()} -paw {self.config.caldera_paw()}" ' return res - def __install_caldera_service_cmd(self): + def __install_caldera_service_cmd(self) -> str: playground = self.get_playground() + if self.abs_machinepath_external is None: + raise ConfigurationError("machine path external is not set") + + if self.attack_logger is None: + raise + if self.get_os() == "linux": return f""" #!/bin/bash @@ -551,14 +666,18 @@ START {playground}{filename} -server {url} -group {self.config.caldera_group()} raise Exception # System type unknown - def install_caldera_service(self): + def install_caldera_service(self) -> None: """ Install the caldera client as a service. For linux targets """ # print("DELETEME ! " + sys._getframe().f_code.co_name) content = self.__install_caldera_service_cmd() - self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Installing Caldera service {CommandlineColors.ENDC}", 1) + if self.abs_machinepath_external is None: + raise ConfigurationError("machine path external is not set") + + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Installing Caldera service {CommandlineColors.ENDC}", 1) if self.get_os() == "linux": filename = os.path.join(self.abs_machinepath_external, "caldera_agent.sh") @@ -566,16 +685,10 @@ START {playground}{filename} -server {url} -group {self.config.caldera_group()} filename = os.path.join(self.abs_machinepath_external, "caldera_agent.bat") with open(filename, "wt") as fh: fh.write(content) - self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Installed Caldera service {CommandlineColors.ENDC}", 1) + if self.attack_logger is not None: + self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Installed Caldera service {CommandlineColors.ENDC}", 1) - def set_caldera_server(self, server): + def set_caldera_server(self, server: str) -> None: """ Set the local caldera server config """ self.caldera_server = server - def set_attack_logger(self, attack_logger): - """ Configure the attack logger for this server - - :param attack_logger: The attack logger to set - """ - - self.attack_logger = attack_logger diff --git a/plugins/base/machinery.py b/plugins/base/machinery.py index fd0a9be..20682bd 100644 --- a/plugins/base/machinery.py +++ b/plugins/base/machinery.py @@ -68,13 +68,14 @@ class MachineryPlugin(BasePlugin): """ raise NotImplementedError - def remote_run(self, cmd: str, disown: bool = False) -> str: + def remote_run(self, cmd: str, disown: bool = False, must_succeed: bool = False) -> str: """ Connects to the machine and runs a command there If you want to use SSH, check out the class SSHFeatures, it is already implemented there :param cmd: command to run int he machine's shell :param disown: Send the connection into background + :param must_succeed: Throw an exception if the command being run fails. :returns: the results as string """ @@ -88,7 +89,7 @@ class MachineryPlugin(BasePlugin): """ raise NotImplementedError - def put(self, src: str, dst: str) -> Any: + def put(self, src: str, dst: Optional[str]) -> Any: """ Send a file to a machine If you want to use SSH, check out the class SSHFeatures, it is already implemented there @@ -203,21 +204,22 @@ class MachineryPlugin(BasePlugin): self.config = config self.process_config(config.raw_config.__dict__) - def __call_remote_run__(self, cmd: str, disown: bool = False) -> str: + def __call_remote_run__(self, cmd: str, disown: bool = False, must_succeed: bool = False) -> str: """ Simplifies connect and run - @param cmd: Command to run as shell command - @param disown: run in background + :param cmd: Command to run as shell command + :param disown: run in background + :param must_succeed: Throw an exception if the command being run fails. """ - return self.remote_run(cmd, disown) + return self.remote_run(cmd, disown, must_succeed) def __call_disconnect__(self) -> None: """ Command connection dis-connect """ self.disconnect() - def __call_connect__(self) -> None: + def __call_connect__(self) -> Any: """ command connection. establish it """ return self.connect() diff --git a/plugins/base/ssh_features.py b/plugins/base/ssh_features.py index c1a110b..1b7c38e 100644 --- a/plugins/base/ssh_features.py +++ b/plugins/base/ssh_features.py @@ -3,7 +3,7 @@ import os.path import socket import time -from typing import Any +from typing import Any, Optional import paramiko from fabric import Connection # type: ignore @@ -38,7 +38,7 @@ class SSHFeatures(BasePlugin): try: if self.config.os() == "linux": uhp = self.get_ip() - self.vprint(f"Connecting to {uhp}", 3) + self.vprint(f"SSH connecting to {uhp}", 3) self.connection = Connection(uhp, connect_timeout=timeout) if self.config.os() == "windows": @@ -64,11 +64,12 @@ class SSHFeatures(BasePlugin): self.vprint("SSH network error", 0) raise NetworkError - def remote_run(self, cmd: str, disown: bool = False) -> str: + def remote_run(self, cmd: str, disown: bool = False, must_succeed: bool = False) -> str: """ Connects to the machine and runs a command there :param cmd: The command to execute :param disown: Send the connection into background + :param must_succeed: Throw an exception if the command being run fails. :returns: The results as string """ @@ -82,34 +83,48 @@ class SSHFeatures(BasePlugin): self.vprint("Disown: " + str(disown), 3) # self.vprint("Connection: " + self.connection, 1) result = None - retry = 2 - while retry > 0: + retry = 10 + while retry >= 0: do_retry = False try: + print(f"Running cmd {cmd}") result = self.connection.run(cmd, disown=disown) print(result) # paramiko.ssh_exception.SSHException in the next line is needed for windows openssh - except (paramiko.ssh_exception.NoValidConnectionsError, UnexpectedExit, paramiko.ssh_exception.SSHException) as error: + except (paramiko.ssh_exception.NoValidConnectionsError, paramiko.ssh_exception.SSHException) as error: if retry <= 0: raise NetworkError from error do_retry = True + except UnexpectedExit as error: + if must_succeed: + if retry <= 0: + raise NetworkError from error + do_retry = True + else: + # breakpoint() + break + except Exception as error: + raise NetworkError from error if do_retry: + time.sleep(5) self.disconnect() + time.sleep(5) self.connect() retry -= 1 - self.vprint("Got some SSH errors. Retrying", 2) + self.vprint(f"Got some SSH errors. Retrying {retry}", 2) else: break if result and result.stderr: self.vprint("Debug: Stderr: " + str(result.stderr.strip()), 0) + return result.stderr.strip() if result: return result.stdout.strip() return "" - def put(self, src: str, dst: str) -> Any: + def put(self, src: str, dst: Optional[str]) -> Any: """ Send a file to a machine :param src: source dir diff --git a/plugins/default/vm_controller/vagrant/vagrant_plugin.py b/plugins/default/vm_controller/vagrant/vagrant_plugin.py index 5180390..179b87a 100644 --- a/plugins/default/vm_controller/vagrant/vagrant_plugin.py +++ b/plugins/default/vm_controller/vagrant/vagrant_plugin.py @@ -12,6 +12,7 @@ from app.exceptions import ConfigurationError # from invoke.exceptions import UnexpectedExit # import paramiko from plugins.base.ssh_features import SSHFeatures +from typing import Any # Experiment with paramiko instead of fabric. Seems fabric has some issues with the "put" command to Windows. There seems no fix (just my workarounds). Maybe paramiko is better. @@ -75,7 +76,7 @@ class VagrantPlugin(SSHFeatures, MachineryPlugin): """ Destroy a machine """ self.v.destroy(vm_name=self.config.vmname()) - def connect(self): + def connect(self) -> Any: """ Connect to a machine. If there is already a connection we keep it """ # For linux we are using Vagrant style @@ -84,8 +85,9 @@ class VagrantPlugin(SSHFeatures, MachineryPlugin): return self.connection uhp = self.v.user_hostname_port(vm_name=self.config.vmname()) - self.vprint(f"Connecting to {uhp}", 3) + self.vprint(f"Vagrant connecting to {uhp} ({self.get_vm_name()}/{self.config.vmname()})", 3) self.connection = Connection(uhp, connect_kwargs={"key_filename": self.v.keyfile(vm_name=self.config.vmname())}) + self.vprint(f"Vagrant connection: {self.connection.is_connected}", 3) return self.connection else: