You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
PurpleDome/app/experimentcontrol.py

431 lines
21 KiB
Python

#!/usr/bin/env python3
""" A class to control a whole experiment. From setting up the machines to running the attacks """
import os
import subprocess
import time
import zipfile
import shutil
from datetime import datetime
from typing import Optional
from app.attack_log import AttackLog
from app.config import ExperimentConfig
from app.interface_sfx import CommandlineColors
from app.exceptions import ServerError, CalderaError, MachineError, PluginError, ConfigurationError
from app.pluginmanager import PluginManager
from app.doc_generator import DocGenerator
from app.calderacontrol import CalderaControl
from app.machinecontrol import Machine
from plugins.base.attack import AttackPlugin
# TODO: Multi threading at least when starting machines
class Experiment():
""" Class handling experiments """
def __init__(self, configfile: str, verbosity: int = 0) -> None:
"""
:param configfile: Path to the configfile to load
:param verbosity: verbosity level between 0 and 3
"""
self.start_time: str = datetime.now().strftime("%Y_%m_%d___%H_%M_%S") #: time the experiment started.
self.caldera_control: Optional[CalderaControl] = None #: Controller for Caldera interaction
self.loot_dir: str = "loot" #: Directory to store the loot into. Will be fetched from config
self.targets: list[Machine] = [] #: A list of target machines
self.attacker_1: Optional[Machine] = None #: The attacker machine
self.experiment_config = ExperimentConfig(configfile)
self.attack_logger = AttackLog(verbosity)
self.plugin_manager = PluginManager(self.attack_logger)
def run(self, caldera_attacks: list = None) -> None:
"""
Run the experiment
:param caldera_attacks: an optional argument to override caldera attacks in the config file and run just this one caldera attack. A list of caldera ID
:return:
"""
self.__start_attacker()
if self.attacker_1 is None:
raise MachineError("Attacker not initialised")
caldera_url = "http://" + self.attacker_1.get_ip() + ":8888"
self.caldera_control = CalderaControl(caldera_url, attack_logger=self.attack_logger, config=self.experiment_config)
# self.caldera_control = CalderaControl("http://" + self.attacker_1.get_ip() + ":8888", self.attack_logger,
# config=self.experiment_config)
# Deleting all currently registered Caldera gents
self.attack_logger.vprint(self.caldera_control.kill_all_agents(), 3)
self.attack_logger.vprint(self.caldera_control.delete_all_agents(), 3)
self.start_time = datetime.now().strftime("%Y_%m_%d___%H_%M_%S")
self.loot_dir = os.path.join(self.experiment_config.loot_dir(), self.start_time)
os.makedirs(self.loot_dir)
# start target machines
self.start_target_machines(caldera_attacks)
# Install vulnerabilities
self.install_vulnerabilities()
# Install sensor plugins
self.install_sensor_plugins()
# First start of caldera implants
self.first_start_of_caldera_implants(caldera_attacks)
self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Contacting caldera agents on all targets ....{CommandlineColors.ENDC}", 1)
# Wait until all targets are registered as Caldera targets
self.wait_until_all_targets_have_caldera_implants(caldera_url, caldera_attacks)
self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Caldera agents reached{CommandlineColors.ENDC}", 1)
# Add running machines to log
self.add_running_machines_to_log()
# Attack them
self.run_caldera_attacks(caldera_attacks)
# Run plugin based attacks
self.run_plugin_attacks()
# Stop sensor plugins
# Collect data
zip_this = []
for a_target in self.targets:
a_target.stop_sensors()
zip_this += a_target.collect_sensors(self.loot_dir)
# Uninstall vulnerabilities
for a_target in self.targets:
self.attack_logger.vprint(f"{CommandlineColors.OKBLUE} Uninstalling vulnerabilities on {a_target.get_paw()} {CommandlineColors.ENDC}", 1)
a_target.stop_vulnerabilities()
self.attack_logger.vprint(f"{CommandlineColors.OKGREEN} Done uninstalling vulnerabilities on {a_target.get_paw()} {CommandlineColors.ENDC}", 1)
# Stop target machines
for target_1 in self.targets:
target_1.halt()
self.__stop_attacker()
self.attack_logger.post_process()
attack_log_file_path = os.path.join(self.loot_dir, "attack.json")
self.attack_logger.write_json(attack_log_file_path)
document_generator = DocGenerator()
document_generator.generate(attack_log_file_path)
document_generator.compile_documentation()
zip_this += document_generator.get_outfile_paths()
self.zip_loot(zip_this)
def run_plugin_attacks(self) -> None:
""" Run plugin based attacks
"""
self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Running attack plugins{CommandlineColors.ENDC}", 1)
for target_1 in self.targets:
plugin_based_attacks = self.experiment_config.get_plugin_based_attacks(target_1.get_os())
metasploit_plugins = self.plugin_manager.count_caldera_requirements(AttackPlugin, plugin_based_attacks)
print(f"Plugins needing metasploit for {target_1.get_paw()} : {metasploit_plugins}")
for attack in plugin_based_attacks:
# TODO: Work with snapshots
self.attack_logger.vprint(f"Attacking machine with PAW: {target_1.get_paw()} with attack: {attack}", 1)
self.attack(target_1, attack)
self.attack_logger.vprint(
f"Pausing before next attack (config: nap_time): {self.experiment_config.get_nap_time()}", 3)
time.sleep(self.experiment_config.get_nap_time())
self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Finished attack plugins{CommandlineColors.ENDC}", 1)
def run_caldera_attacks(self, caldera_attacks: Optional[list[str]] = None) -> None:
""" Run caldera based attacks
:param caldera_attacks: An optional list of caldera attack ids as string
"""
self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Running Caldera attacks{CommandlineColors.ENDC}", 1)
for target_1 in self.targets:
if caldera_attacks is None:
# Run caldera attacks
new_caldera_attacks = self.experiment_config.get_caldera_attacks(target_1.get_os())
else:
new_caldera_attacks = caldera_attacks
if new_caldera_attacks:
for attack in new_caldera_attacks:
# TODO: Work with snapshots
# TODO: If we have several targets in the same group, it is nonsense to attack each one separately. Make this smarter
self.attack_logger.vprint(f"Attacking machine with PAW: {target_1.get_paw()} with {attack}", 2)
if self.caldera_control is None:
raise CalderaError("Caldera control not initialised")
paw = target_1.get_paw()
group = target_1.get_group()
if paw is None:
raise ConfigurationError("PAW configuration is required for Caldera attacks")
if group is None:
raise ConfigurationError("Group configuration is required for Caldera attacks")
it_worked = self.caldera_control.attack(paw=paw,
ability_id=attack,
group=group,
target_platform=target_1.get_os()
)
# Moved to fix section below. If fix works: can be removed
# print(f"Pausing before next attack (config: nap_time): {self.experiment_config.get_nap_time()}")
# time.sleep(self.experiment_config.get_nap_time())
# Fix: Caldera sometimes gets stuck. This is why we better re-start the caldera server and wait till all the implants re-connected
# Reason: In some scenarios we keep the infra up for hours or days. No re-creation like intended. This can cause Caldera to hick up
if it_worked:
self.attack_logger.vprint(
f"{CommandlineColors.OKBLUE}Restarting caldera server and waiting for clients to re-connect{CommandlineColors.ENDC}",
1)
if self.attacker_1 is None:
raise MachineError("attacker not initialised")
self.attacker_1.start_caldera_server()
self.attack_logger.vprint(
f"Pausing before next attack (config: nap_time): {self.experiment_config.get_nap_time()}",
2)
time.sleep(self.experiment_config.get_nap_time())
retries = 100
for target_system in self.targets:
if self.caldera_control is None:
raise CalderaError("Caldera is not initialised")
if self.machine_needs_caldera(target_system, caldera_attacks) == 0:
self.attack_logger.vprint(f"No caldera agent needed for: {target_system.get_paw()} ", 3)
continue
running_agents = self.caldera_control.list_paws_of_running_agents()
self.attack_logger.vprint(f"Agents currently connected to the server: {running_agents}", 2)
while target_system.get_paw() not in running_agents:
time.sleep(1)
running_agents = self.caldera_control.list_paws_of_running_agents()
retries -= 1
self.attack_logger.vprint(
f"Waiting for clients to re-connect ({retries}, {running_agents}) ", 3)
if retries <= 0:
raise ServerError
self.attack_logger.vprint(
f"{CommandlineColors.OKGREEN}Restarted caldera server clients re-connected{CommandlineColors.ENDC}",
1)
# End of fix
self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Finished Caldera attacks{CommandlineColors.ENDC}", 1)
def add_running_machines_to_log(self) -> None:
""" Add machine infos for targets and attacker to the log """
for target in self.targets:
if target is None:
raise MachineError("Target machine configured to None or whatever happened")
i = target.get_machine_info()
i["role"] = "target"
self.attack_logger.add_machine_info(i)
if self.attacker_1 is None:
raise MachineError("Attacker machine gone")
i = self.attacker_1.get_machine_info()
i["role"] = "attacker"
self.attack_logger.add_machine_info(i)
def wait_until_all_targets_have_caldera_implants(self, caldera_url: str, caldera_attacks: Optional[list[str]] = None) -> None:
"""
:param caldera_attacks: a list of command line defined caldera attacks
:param caldera_url: URL of the caldera server
"""
for target_1 in self.targets:
if self.caldera_control is None:
raise CalderaError("Caldera is not initialised")
running_agents = self.caldera_control.list_paws_of_running_agents()
self.attack_logger.vprint(f"Agents currently running: {running_agents}", 2)
while target_1.get_paw() not in running_agents:
if self.machine_needs_caldera(target_1, caldera_attacks) == 0:
self.attack_logger.vprint(f"No caldera agent needed for: {target_1.get_paw()} ", 3)
break
self.attack_logger.vprint(f"Connecting to caldera {caldera_url}, running agents are: {running_agents}",
3)
self.attack_logger.vprint(f"Missing agent: {target_1.get_paw()} ...", 3)
target_1.start_caldera_client()
self.attack_logger.vprint(f"Restarted caldera agent: {target_1.get_paw()} ...", 3)
time.sleep(120) # Was 30, but maybe there are timing issues
running_agents = self.caldera_control.list_paws_of_running_agents()
def first_start_of_caldera_implants(self, caldera_attacks: Optional[list[str]] = None) -> None:
""" Start caldera implant on the targets
:param caldera_attacks: a list of command line defined caldera attacks
"""
at_least_one_caldera_started = False
for target_1 in self.targets:
if self.machine_needs_caldera(target_1, caldera_attacks):
target_1.start_caldera_client()
self.attack_logger.vprint(
f"{CommandlineColors.OKGREEN}Initial start of caldera client: {target_1.get_name()} {CommandlineColors.ENDC}", 1)
else:
at_least_one_caldera_started = True
if at_least_one_caldera_started:
time.sleep(20) # Wait for all the clients to contact the caldera server
# TODO: Smarter wait
def install_sensor_plugins(self) -> None:
""" Installs sensor plugins on the targets
"""
for a_target in self.targets:
self.attack_logger.vprint(f"Installing sensors on {a_target.get_paw()}", 2)
a_target.install_sensors()
a_target.start_sensors()
def install_vulnerabilities(self) -> None:
""" Install vulnerabilities on the targets
"""
for a_target in self.targets:
self.attack_logger.vprint(f"Installing vulnerabilities on {a_target.get_paw()}", 2)
a_target.install_vulnerabilities()
a_target.start_vulnerabilities()
def start_target_machines(self, caldera_attacks: Optional[list[str]] = None) -> None:
""" Start target machines
:param caldera_attacks: Caldera attacks as defined on the command line
"""
for target_conf in self.experiment_config.targets():
if not target_conf.is_active():
continue
tname = target_conf.vmname()
self.attack_logger.vprint(
f"{CommandlineColors.OKBLUE}preparing target {tname} ....{CommandlineColors.ENDC}", 1)
target_1 = Machine(target_conf, attack_logger=self.attack_logger)
if target_1 is None:
raise MachineError("Creating target machine failed")
if self.attacker_1 is None:
raise MachineError("Creating attacker machine failed")
target_1.set_caldera_server(self.attacker_1.get_ip())
try:
if not target_conf.use_existing_machine():
target_1.destroy()
except subprocess.CalledProcessError:
# Maybe the machine just does not exist yet
pass
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)
target_1.reboot()
self.attack_logger.vprint(f"{CommandlineColors.OKGREEN}Target is up: {tname} {CommandlineColors.ENDC}", 1)
self.targets.append(target_1)
def machine_needs_caldera(self, target: Machine, caldera_from_cmdline: Optional[list[str]] = None) -> int:
""" Counts the attacks and plugins needing caldera that are registered for this machine
:param target: Target machine we will check the config file for assigned caldera attacks for
:param caldera_from_cmdline: Caldera attacks listed on the commandline
:returns: the number of caldera attacks planned for this machine
"""
c_cmdline = 0
if caldera_from_cmdline is not None:
c_cmdline = len(caldera_from_cmdline)
c_conffile = len(self.experiment_config.get_caldera_attacks(target.get_os()))
plugin_based_attacks = self.experiment_config.get_plugin_based_attacks(target.get_os())
c_plugins = self.plugin_manager.count_caldera_requirements(AttackPlugin, plugin_based_attacks)
print(f"Caldera count: From cmdline: {c_cmdline}, From conf: {c_conffile} from plugins: {c_plugins}")
return c_cmdline + c_conffile + c_plugins
def attack(self, target: Machine, attack: str) -> None:
""" Pick an attack and run it
:param attack: Name of the attack to run
:param target: IP address of the target
:returns: The output of the cmdline attacking tool
"""
for plugin in self.plugin_manager.get_plugins(AttackPlugin, [attack]):
name = plugin.get_name()
if isinstance(plugin, AttackPlugin):
self.attack_logger.vprint(f"{CommandlineColors.OKBLUE}Running Attack plugin {name}{CommandlineColors.ENDC}", 2)
plugin.process_config(self.experiment_config.attack_conf(plugin.get_config_section_name()))
if self.attacker_1 is None:
raise PluginError("Attacker not properly configured")
if self.attacker_1.vm_manager is None:
raise PluginError("Attacker not properly configured")
plugin.set_attacker_machine(self.attacker_1.vm_manager)
plugin.set_sysconf({})
plugin.set_logger(self.attack_logger)
if self.caldera_control is None:
raise CalderaError("Caldera control not initialised")
plugin.set_caldera(self.caldera_control)
plugin.connect_metasploit()
plugin.install()
# plugin.__set_logger__(self.attack_logger)
plugin.__execute__([target])
else:
raise PluginError("AttackPlugin is not really an AttackPlugin type")
def zip_loot(self, zip_this: list[str]) -> None:
""" Zip the loot together
:param zip_this: A list of file paths to add to the zip file
"""
filename = os.path.join(self.loot_dir, self.start_time + ".zip")
self.attack_logger.vprint(f"Creating zip file {filename}", 1)
with zipfile.ZipFile(filename, "w") as zfh:
for a_file in zip_this:
if a_file != filename:
self.attack_logger.vprint(a_file, 2)
zfh.write(a_file)
zfh.write(os.path.join(self.loot_dir, "attack.json"))
# For automation purpose we copy the file into a standard file name
default_name = os.path.join(self.loot_dir, "..", "most_recent.zip")
shutil.copyfile(filename, default_name)
def __start_attacker(self) -> None:
""" Start the attacking VM """
# Preparing attacker
self.attacker_1 = Machine(self.experiment_config.attacker(0).raw_config, attack_logger=self.attack_logger)
if self.attacker_1 is None:
raise ServerError
if not self.experiment_config.attacker(0).use_existing_machine():
try:
self.attacker_1.destroy()
except subprocess.CalledProcessError:
# Machine does not exist
pass
self.attacker_1.create(reboot=True)
self.attacker_1.up()
self.attacker_1.install_caldera_server(cleanup=False)
else:
self.attacker_1.up()
self.attacker_1.install_caldera_server(cleanup=False)
self.attacker_1.start_caldera_server()
if self.attacker_1 is None:
raise ServerError
# self.attacker_1.set_attack_logger(self.attack_logger)
def __stop_attacker(self) -> None:
""" Stop the attacking VM """
if self.attacker_1 is None:
raise MachineError("Attacker machine not initialised")
self.attacker_1.halt()