diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..edd9bb3 --- /dev/null +++ b/Makefile @@ -0,0 +1,18 @@ +# Makefile for standard actions +# +# + +.PHONY: test init deinit shipit pylint + +test: tox.ini + tox; + coverage html; + coverage report; + +shipit: test + cd doc; make html; cd .. + python3 tools/shipit.py + +# More detailed pylint tests. +pylint: + pylint --rcfile=pylint.rc *.py app/*.py plugins/base/*.py \ No newline at end of file diff --git a/app/attack_log.py b/app/attack_log.py new file mode 100644 index 0000000..baab217 --- /dev/null +++ b/app/attack_log.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python3 + +""" Logger for the attack side. Output must be flexible, because we want to be able to feed it into many different processes. From ML to analysts """ + +import json +import datetime + +# TODO: Collect caldera attacks: Source, target, type of attack. Start/Stop. Results. Parameters + +# TODO: Collect kali attacks: Source, target, type of attack. Start/Stop. Results. Parameters. Settings + +# TODO: Export data + +# TODO: Add TTP and similar metadata + + +def __get_timestamp__(): + return datetime.datetime.now().strftime("%H:%M:%S.%f") + + +def __mitre_fix_ttp__(ttp): + """ enforce some systematic naming scheme for MITRE TTPs """ + + if ttp is None: + return "" + + if ttp.startswith("MITRE_"): + return ttp + else: + return "MITRE_" + ttp + + +class AttackLog(): + """ A specific logger class to log the progress of the attack steps """ + + def __init__(self): + self.log = [] + + def start_caldera_attack(self, source, paw, group, ability_id, ttp=None, name=None, description=None): # pylint: disable=too-many-arguments + """ Mark the start of a caldera attack + + @param source: source of the attack. Attack IP + @param paw: Caldera oaw of the targets being attacked + @param group: Caldera group of the targets being attacked + @param ability_id: Caldera ability id of the attack + @param ttp: TTP of the attack (as stated by Caldera internal settings) + @param name: Name of the attack. Data source is Caldera internal settings + @param description: Descirption of the attack. Caldera is the source + """ + + data = {"timestamp": __get_timestamp__(), + "event": "start", + "type": "attack", + "sub-type": "caldera", + "source": source, + "target_paw": paw, + "target_group": group, + "ability_id": ability_id, + "hunting_tag": __mitre_fix_ttp__(ttp), + "name": name or "", + "description": description or "" + } + + self.log.append(data) + + # TODO: Add parameter + # TODO: Add config + # TODO: Add results + + def stop_caldera_attack(self, source, paw, group, ability_id, ttp=None, name=None, description=None): # pylint: disable=too-many-arguments + """ Mark the end of a caldera attack + + @param source: source of the attack. Attack IP + @param paw: Caldera oaw of the targets being attacked + @param group: Caldera group of the targets being attacked + @param ability_id: Caldera ability id of the attack + @param ttp: TTP of the attack (as stated by Caldera internal settings) + @param name: Name of the attack. Data source is Caldera internal settings + @param description: Descirption of the attack. Caldera is the source + """ + + data = {"timestamp": __get_timestamp__(), + "event": "stop", + "type": "attack", + "sub-type": "caldera", + "source": source, + "target_paw": paw, + "target_group": group, + "ability_id": ability_id, + "hunting_tag": __mitre_fix_ttp__(ttp), + "name": name or "", + "description": description or "" + } + self.log.append(data) + + def start_kali_attack(self, source, target, attack_name, ttp=None): + """ Mark the start of a Kali based attack + + @param source: source of the attack. Attack IP + @param target: Target machine of the attack + @param attack_name: Name of the attack. From plugin + @param ttp: TTP of the attack. From plugin + """ + + data = {"timestamp": __get_timestamp__(), + "event": "start", + "type": "attack", + "sub-type": "kali", + "source": source, + "target": target, + "kali_name": attack_name, + "hunting_tag": __mitre_fix_ttp__(ttp), + } + self.log.append(data) + + # TODO: Add parameter + # TODO: Add config + # TODO: Add results + + def stop_kali_attack(self, source, target, attack_name, ttp=None): + """ Mark the end of a Kali based attack + + @param source: source of the attack. Attack IP + @param target: Target machine of the attack + @param attack_name: Name of the attack. From plugin + @param ttp: TTP of the attack. From plugin + """ + + data = {"timestamp": __get_timestamp__(), + "event": "stop", + "type": "attack", + "sub-type": "kali", + "source": source, + "target": target, + "kali_name": attack_name, + "hunting_tag": __mitre_fix_ttp__(ttp), + } + self.log.append(data) + + def write_json(self, filename): + """ Write the json data for this log + + @param filename: Name of the json file + """ + with open(filename, "wt") as fh: + json.dump(self.get_dict(), fh) + + def get_dict(self): + """ Return logged data in dict format """ + + return self.log diff --git a/app/calderacontrol.py b/app/calderacontrol.py new file mode 100644 index 0000000..76518b7 --- /dev/null +++ b/app/calderacontrol.py @@ -0,0 +1,547 @@ +#!/usr/bin/env python3 + +""" Remote control a caldera server """ + +import json +import os +import time + +import requests +import simplejson + +from app.exceptions import CalderaError +from app.interface_sfx import CommandlineColors +from app.attack_log import AttackLog + + +# TODO: Ability deserves an own class. + + +class CalderaControl(): + """ Remote control Caldera through REST api """ + + def __init__(self, server, config=None, apikey=None): + """ + + + @param server: Caldera server url/ip + @param config: The configuration + """ + # print(server) + self.url = server if server.endswith("/") else server + "/" + + self.config = config + + if self.config: + self.apikey = self.config.caldera_apikey() + else: + self.apikey = apikey + + def fetch_client(self, platform="windows", file="sandcat.go", target_dir=".", extension=""): + """ Downloads the appropriate Caldera client + + @param platform: Platform to download the agent for + @param file: file to download from caldera. This defines the agent type + @param target_dir: directory to drop the new file into + @param extension: File extension to add to the downloaded file + """ + header = {"platform": platform, + "file": file} + fullurl = self.url + "file/download" + request = requests.get(fullurl, headers=header) + filename = request.headers["FILENAME"] + extension + open(os.path.join(target_dir, filename), "wb").write(request.content) + # print(r.headers) + return filename + + def __contact_server__(self, payload, rest_path="api/rest", method="post"): + """ + + @param payload: payload as dict to send to the server + @param rest_path: specific path for this rest api + @param method: http method to use + """ + url = self.url + rest_path + header = {"KEY": self.apikey, + "Content-Type": "application/json"} + if method.lower() == "post": + request = requests.post(url, headers=header, data=json.dumps(payload)) + elif method.lower() == "put": + request = requests.put(url, headers=header, data=json.dumps(payload)) + elif method.lower() == "get": + request = requests.get(url, headers=header, data=json.dumps(payload)) + elif method.lower() == "delete": + request = requests.delete(url, headers=header, data=json.dumps(payload)) + else: + raise ValueError + try: + res = request.json() + except simplejson.errors.JSONDecodeError as exception: + print("!!! Error !!!!") + print(payload) + print(request.text) + print("!!! Error !!!!") + raise exception + + return res + + # ############## List + def list_links(self, opid): + """ List links associated with an operation + + @param opid: operation id to list links for + """ + + payload = {"index": "link", + "op_id": opid} + return self.__contact_server__(payload) + + def list_results(self, linkid): + """ List results for a link + + @param linkid: ID of the link + """ + + payload = {"index": "result", + "link_id": linkid} + return self.__contact_server__(payload) + + def list_operations(self): + """ Return operations """ + + payload = {"index": "operations"} + return self.__contact_server__(payload) + + def list_abilities(self): + """ Return all ablilities """ + # curl -H 'KEY: ADMIN123' http://192.168.178.102:8888/api/rest -H 'Content-Type: application/json' -d '{"index":"abilities"}' + + payload = {"index": "abilities"} + return self.__contact_server__(payload) + + def list_agents(self): + """ List running agents + + """ + # TODO: Add filters for specific platforms/executors : , platform_filter=None, executor_filter=None as parameters + # curl -H 'KEY: ADMIN123' http://192.168.178.102:8888/api/rest -H 'Content-Type: application/json' -d '{"index":"agents"}' + payload = {"index": "agents"} + + agents = self.__contact_server__(payload) + return agents + + def list_adversaries(self): + """ List registered adversaries """ + # curl -H 'KEY: ADMIN123' http://192.168.178.102:8888/api/rest -H 'Content-Type: application/json' -d '{"index":"adversaries"}' + payload = {"index": "adversaries"} + return self.__contact_server__(payload) + + def list_objectives(self): + """ List registered objectives """ + # curl -H 'KEY: ADMIN123' http://192.168.178.102:8888/api/rest -H 'Content-Type: application/json' -d '{"index":"objectives"}' + payload = {"index": "objectives"} + return self.__contact_server__(payload) + + # ######### Get one specific item + + def get_operation(self, name): + """ Gets an operation by name + + @param name: Name of the operation to look for + """ + + for operation in self.list_operations(): + if operation["name"] == name: + return operation + return None + + def get_adversary(self, name): + """ Gets a specific adversary by name + + @param name: Name to look for + """ + for adversary in self.list_adversaries(): + if adversary["name"] == name: + return adversary + return None + + def get_objective(self, name): + """ Returns an objective with a given name + + @param name: Name to filter for + """ + for objective in self.list_objectives(): + if objective["name"] == name: + return objective + return None + + # ######### Get by id + + def get_ability(self, abid): + """" Return an ability by id + + @param abid: Ability id + """ + + res = [] + + for ability in self.list_abilities(): + if ability["ability_id"] == abid: + res.append(ability) + return res + + def get_operation_by_id(self, op_id): + """ Get operation by id + + @param op_id: Operation id + """ + payload = {"index": "operations", + "id": op_id} + return self.__contact_server__(payload) + + def get_result_by_id(self, linkid): + """ Get the result from a link id + + @param linkid: link id + """ + payload = {"index": "result", + "link_id": linkid} + return self.__contact_server__(payload) + + def get_linkid(self, op_id, paw, ability_id): + """ Get the id of a link identified by paw and ability_id + + @param op_id: Operation id + @param paw: Paw of the agent + @param ability_id: Ability id to filter for + """ + operation = self.get_operation_by_id(op_id) + + # print("Check for: {} {}".format(paw, ability_id)) + for alink in operation[0]["chain"]: + # print("Lookup: PAW: {} Ability: {}".format(alink["paw"], alink["ability"]["ability_id"])) + # print("In: " + str(alink)) + if alink["paw"] == paw and alink["ability"]["ability_id"] == ability_id: + return alink["id"] + + return None + + # ######### View + + def view_operation_report(self, opid): + """ views the operation report + + @param opid: Operation id to look for + """ + + # let postData = selectedOperationId ? {'index':'operation_report', 'op_id': selectedOperationId, 'agent_output': Number(agentOutput)} : null; + # checking it (from snifffing protocol at the server): POST {'id': 539687} + payload = {"index": "operation_report", + "op_id": opid, + 'agent_output': 1 + } + return self.__contact_server__(payload) + + def view_operation_output(self, opid, paw, ability_id): + """ Gets the output of an executed ability + + @param opid: Id of the operation to look for + @param paw: Paw of the agent to look up + @param ability_id: if of the ability to extract the output from + """ + orep = self.view_operation_report(opid) + + if paw not in orep["steps"]: + print("Broken operation report:") + print(orep) + print(f"Could not find {paw} in {orep['steps']}") + raise CalderaError + # print("oprep: " + str(orep)) + for a_step in orep["steps"][paw]["steps"]: + if a_step["ability_id"] == ability_id: + try: + # TODO There is no output if the state is for example -4 (untrusted). Fix that. Why is the caldera implant untrusted ? + print("oprep: " + str(orep)) + return a_step["output"] + except KeyError as exception: + raise CalderaError from exception + # print(f"Did not find ability {ability_id} in caldera operation output") + return None + + # ######### Add + + def add_operation(self, name, advid, group="red", state="running"): + """ Adds a new operation + + @param name: Name of the operation + @param advid: Adversary id + @param group: agent group to attack + @param state: state to initially set + """ + + # Add operation: curl -X PUT -H "KEY:$KEY" http://127.0.0.1:8888/api/rest -d '{"index":"operations","name":"testoperation1"}' + # observed from GUI sniffing: PUT {'name': 'schnuffel2', 'group': 'red', 'adversary_id': '0f4c3c67-845e-49a0-927e-90ed33c044e0', 'state': 'running', 'planner': 'atomic', 'autonomous': '1', 'obfuscator': 'plain-text', 'auto_close': '1', 'jitter': '4/8', 'source': 'Alice Filters', 'visibility': '50'} + payload = {"index": "operations", + "name": name, + "state": state, + "autonomous": 1, + 'obfuscator': 'plain-text', + 'auto_close': '1', + 'jitter': '4/8', + 'source': 'Alice Filters', + 'visibility': '50', + "group": group, + # + "planner": "atomic", + "adversary_id": advid, + } + + return self.__contact_server__(payload, method="put") + + def add_adversary(self, name, ability, description="created automatically"): + """ Adds a new adversary + + @param name: Name of the adversary + @param ability: One ability for this adversary + @param description: Description of this adversary + """ + + # Add operation: curl -X PUT -H "KEY:$KEY" http://127.0.0.1:8888/api/rest -d '{"index":"operations","name":"testoperation1"}' + + # Sniffed from gui: + # Rest core: PUT adversaries {'name': 'removeme', 'description': 'description', 'atomic_ordering': [{'id': 'bd527b63-9f9e-46e0-9816-b8434d2b8989'}], 'id': '558932cb-3ac6-43d2-b821-2db0fa8ad469', 'objective': ''} + # Returns: [{'name': 'removeme', 'adversary_id': '558932cb-3ac6-43d2-b821-2db0fa8ad469', 'description': 'description', 'tags': [], 'atomic_ordering': ['bd527b63-9f9e-46e0-9816-b8434d2b8989'], 'objective': '495a9828-cab1-44dd-a0ca-66e58177d8cc'}] + + payload = {"index": "adversaries", + "name": name, + "description": description, + "atomic_ordering": [{"id": ability}], + # + "objective": '495a9828-cab1-44dd-a0ca-66e58177d8cc' # default objective + } + return self.__contact_server__(payload, method="put") + + # ######### Execute + + # TODO View the abilities a given agent could execute. curl -H "key:$API_KEY" -X POST localhost:8888/plugin/access/abilities -d '{"paw":"$PAW"}' + + def execute_ability(self, paw, ability_id, obfuscator="plain-text"): + """ Executes an ability on a target. This happens outside of the scop of an operation. You will get no result of the ability back + + @param paw: Paw of the target + @param ability_id: ability to execute + @param obfuscator: Obfuscator to use + """ + + # curl -H "key:ADMIN123" -X POST localhost:8888/plugin/access/exploit -d '{"paw":"$PAW","ability_id":"$ABILITY_ID"}'``` + # You can optionally POST an obfuscator and/or a facts dictionary with key/value pairs to fill in any variables the chosen ability requires. + # {"paw":"$PAW","ability_id":"$ABILITY_ID","obfuscator":"base64","facts":[{"trait":"username","value":"admin"},{"trait":"password", "value":"123"}]} + payload = {"paw": paw, + "ability_id": ability_id, + "obfuscator": obfuscator} + return self.__contact_server__(payload, rest_path="plugin/access/exploit_ex") + + def execute_operation(self, operation_id, state="running"): + """ Executes an operation on a server + + @param operation_id: The operation to modify + @param state: The state to set this operation into + """ + + # TODO: Change state of an operation: curl -X POST -H "KEY:ADMIN123" http://localhost:8888/api/rest -d '{"index":"operation", "op_id":123, "state":"finished"}' + # curl -X POST -H "KEY:ADMIN123" http://localhost:8888/api/rest -d '{"index":"operation", "op_id":123, "state":"finished"}' + + if state not in ["running", "finished", "paused", "run_one_link", "cleanup"]: + raise ValueError + + payload = {"index": "operation", + "op_id": operation_id, + "state": state} + return self.__contact_server__(payload) + + # ######### Delete + + # TODO: Delete agent + + # curl -X DELETE http://localhost:8888/api/rest -d '{"index":"operations","id":"$operation_id"}' + def delete_operation(self, opid): + """ Delete operation by id + + @param opid: Operation id + """ + payload = {"index": "operations", + "id": opid} + return self.__contact_server__(payload, method="delete") + + def delete_adversary(self, adid): + """ Delete adversary by id + + @param adid: Adversary id + """ + payload = {"index": "adversaries", + "adversary_id": [{"adversary_id": adid}]} + return self.__contact_server__(payload, method="delete") + + # ######### File access + + # TODO: Get uploaded files + + # + + # Link, chain and stuff + + def is_operation_finished(self, opid): + """ Checks if an operation finished - finished is not necessary successful ! + + @param opid: Operation id to check + """ + # An operation can run several Abilities vs several targets (agents). Each one is a link in the chain (see opperation report). + # Those links can have the states: + # return dict(HIGH_VIZ=-5, + # UNTRUSTED=-4, + # EXECUTE=-3, + # DISCARD=-2, + # PAUSE=-1) + # Plus: 0 as "finished" + # + + operation = self.get_operation_by_id(opid) + # print(f"Operation data {operation}") + try: + print(operation[0]["state"]) + if operation[0]["state"] == "finished": + return True + except KeyError as exception: + raise CalderaError from exception + except IndexError as exception: + raise CalderaError from exception + + return False + # try: + # for alink in operation[0]["chain"]: + # if alink["status"] != 0: + # return False + # if alink["status"] == 0: + # return True + # except Exception as exception: + # raise CalderaError from exception + # return True + + def is_operation_finished_multi(self, opid): + """ Checks if an operation finished - finished is not necessary successful ! On several targets. + + All links (~ abilities) on all targets must have the status 0 for this to be True. + + @param opid: Operation id to check + """ + # An operation can run several Abilities vs several targets (agents). Each one is a link in the chain (see opperation report). + # Those links can have the states: + # return dict(HIGH_VIZ=-5, + # UNTRUSTED=-4, + # EXECUTE=-3, + # DISCARD=-2, + # PAUSE=-1) + # Plus: 0 as "finished" + # + + operation = self.get_operation_by_id(opid) + # print(f"Operation data {operation}") + try: + for host_group in operation[0]["host_group"]: + for alink in host_group["links"]: + if alink["status"] != 0: + return False + except Exception as exception: + raise CalderaError from exception + return True + + # ######## All inclusive methods + + def attack(self, attack_logger: AttackLog = None, paw="kickme", ability_id="bd527b63-9f9e-46e0-9816-b8434d2b8989", group="red"): + """ Attacks a system and returns results + + @param attack_logger: An attack logger class to log attacks with + @param paw: Paw to attack + @param group: Group to attack. Paw must be in the group + @param ability_id: Ability to run against the target + """ + + adversary_name = "generated_adv__" + str(time.time()) + operation_name = "testoperation__" + str(time.time()) + + self.add_adversary(adversary_name, ability_id) + adid = self.get_adversary(adversary_name)["adversary_id"] + + if attack_logger: + attack_logger.start_caldera_attack(source=self.url, + paw=paw, group=group, + ability_id=ability_id, + ttp=self.get_ability(ability_id)[0]["technique_id"], + name=self.get_ability(ability_id)[0]["name"], + description=self.get_ability(ability_id)[0]["description"]) + + # ##### Create / Run Operation + + print(f"New adversary generated. ID: {adid}, ability: {ability_id} group: {group}") + self.add_operation(operation_name, advid=adid, group=group) + + opid = self.get_operation(operation_name)["id"] + print("New operation created. OpID: " + str(opid)) + + self.execute_operation(opid) + retries = 20 + print(f"{CommandlineColors.OKGREEN}Executed attack operation{CommandlineColors.ENDC}") + while not self.is_operation_finished(opid) and retries > 0: + print(".... waiting for Caldera to finish") + time.sleep(10) + retries -= 1 + + # TODO: Handle outout from several clients + + retries = 0 + output = None + while retries < 10: + try: + output = self.view_operation_output(opid, paw, ability_id) + except CalderaError: + retries += 1 + time.sleep(10) + else: + break + + if output is None: + output = str(self.get_operation_by_id(opid)) + print(f"{CommandlineColors.FAIL}Failed getting operation data. We just have: {output} from get_operation_by_id{CommandlineColors.ENDC}") + else: + print("Output: " + str(output)) + + # ######## Cleanup + self.execute_operation(opid, "cleanup") + self.delete_adversary(adid) + self.delete_operation(opid) + if attack_logger: + attack_logger.stop_caldera_attack(source=self.url, + paw=paw, + group=group, + ability_id=ability_id, + ttp=self.get_ability(ability_id)[0]["technique_id"], + name=self.get_ability(ability_id)[0]["name"], + description=self.get_ability(ability_id)[0]["description"] + ) + + def pretty_print_ability(self, abi): + """ Pretty pritns an ability + + @param abi: A ability dict + """ + + print(""" + ID: {technique_id} + Technique name: {technique_name} + Tactic: {tactic} + Name: {name} + ID: {ability_id} + Description: {description} + Platform: {platform}/{executor} + + """.format(**abi)) diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..17136e5 --- /dev/null +++ b/app/config.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 + +""" Configuration loader for PurpleDome """ + +import yaml + +from app.exceptions import ConfigurationError + + +# TODO: Add attack scripts (that will be CACAO in the future !) and plugin config +# So the config being read is distributed into several files and they will have different formats (yaml, CACAO) +# Currently it is a single file and YAML only. +# We want to be independent from file structure or number of config files + +# TODO: Attack control also by config class. Used in experimentcontrol. Will change with scripts ! + + +class MachineConfig(): + """ Sub config for a specific machine""" + + def __init__(self, machinedata): + """ Init machine control config + + @param machinedata: dict containing machine data + """ + if machinedata is None: + raise ConfigurationError + + self.raw_config = machinedata + self.verify() + + def verify(self): + """ Verify essential data is present """ + try: + self.vmname() + operating_system = self.os() + vmcontroller = self.vmcontroller() + except KeyError as exception: + raise ConfigurationError from exception + + if operating_system not in ["linux", "windows"]: + raise ConfigurationError + + # TODO: Verify with plugins + if vmcontroller not in ["vagrant", "running_vm"]: + raise ConfigurationError + + def vmname(self): + """ Returns the vmname """ + + return self.raw_config["vm_name"] + + def vmcontroller(self): + """ Returns the vm controller. lowercase """ + + return self.raw_config["vm_controller"]["type"].lower() + + def vm_ip(self): + """ Return the configured ip/domain name (whatever is needed to reach the machine). Returns None if missing """ + try: + return self.raw_config["vm_controller"]["ip"] + except KeyError: + return None + + def os(self): # pylint: disable=invalid-name + """ returns the os. lowercase """ + + return self.raw_config["os"].lower() + + def use_existing_machine(self): + """ Returns if we want to use the existing machine """ + + return self.raw_config.get("use_existing_machine", False) + + def machinepath(self): + """ Returns the machine path. If not configured it will fall back to the vm_name """ + + return self.raw_config.get("machinepath", self.vmname()) + + def get_playground(self): + """ Returns the machine specific playground where all the implants and tools will be installed """ + + return self.raw_config.get("playground", None) + + def caldera_paw(self): + """ Returns the paw (caldera id) of the machine """ + + return self.raw_config.get("paw", None) + + def caldera_group(self): + """ Returns the group (caldera group id) of the machine """ + + return self.raw_config.get("group", None) + + def ssh_keyfile(self): + """ Returns the configured SSH keyfile """ + + return self.raw_config.get("ssh_keyfile", None) + + def ssh_user(self): + """ Returns configured ssh user or "vagrant" as default """ + + return self.raw_config.get("ssh_user", "vagrant") + + def halt_needs_force(self): + """ Returns if halting the machine needs force False as default """ + + return self.raw_config.get("halt_needs_force", False) + + def vagrantfilepath(self): + """ Vagrant specific config: The vagrant file path """ + + if "vagrantfilepath" not in self.raw_config["vm_controller"]: + raise ConfigurationError("Vagrantfilepath missing") + return self.raw_config["vm_controller"]["vagrantfilepath"] + + def sensors(self): + """ Return a list of sensors configured for this machine """ + if "sensors" in self.raw_config: + return self.raw_config["sensors"] or [] + return [] + + def vulnerabilities(self): + """ Return a list of vulnerabilities configured for this machine """ + if "vulnerabilities" in self.raw_config: + return self.raw_config["vulnerabilities"] or [] + return [] + + def is_active(self): + """ Returns if this machine is set to active. Default is true """ + + return self.raw_config.get("active", True) + + +class ExperimentConfig(): + """ Configuration class for a whole experiments """ + + def __init__(self, configfile): + """ Init the config, process the file + + @param configfile: The configuration file to process + """ + + self.raw_config = None + self._targets = [] + self._attackers = [] + self.load(configfile) + + def load(self, configfile): + """ Loads the configuration file + + @param configfile: The configuration file to process + """ + + with open(configfile) as fh: + self.raw_config = yaml.safe_load(fh) + + # Process targets + for target in self.raw_config["targets"]: + self._targets.append(MachineConfig(self.raw_config["targets"][target])) + + # Process attackers + for attacker in self.raw_config["attackers"]: + self._attackers.append(MachineConfig(self.raw_config["attackers"][attacker])) + + def targets(self) -> [MachineConfig]: + """ Return config for targets as MachineConfig objects """ + + return self._targets + + def attackers(self) -> [MachineConfig]: + """ Return config for attackers as MachineConfig objects """ + + return self._attackers + + def attacker(self, mid) -> MachineConfig: + """ Return config for attacker as MachineConfig objects + + @param mid: id of the attacker, 0 is main attacker + """ + + return self.attackers()[mid] + + def caldera_apikey(self): + """ Returns the caldera apikey """ + + return self.raw_config["caldera"]["apikey"] + + def loot_dir(self): + """ Returns the loot dir """ + + return self.raw_config["results"]["loot_dir"] + + def kali_conf(self, attack): + """ Get kali config for a specific kali attack + + @param attack: Name of the attack to look up config for + """ + + try: + res = self.raw_config["kali_conf"][attack] + except KeyError as exception: + raise ConfigurationError from exception + + return res + + def get_nap_time(self): + """ Returns the attackers nap time between attack steps """ + + try: + return self.raw_config["attacks"]["nap_time"] + except KeyError: + return 0 diff --git a/app/exceptions.py b/app/exceptions.py new file mode 100644 index 0000000..12f02fc --- /dev/null +++ b/app/exceptions.py @@ -0,0 +1,22 @@ +#!/usr/bin/env python3 +""" A collection of shared exceptions """ + + +class ServerError(Exception): + """ An elemental server is not running """ + + +class ConfigurationError(Exception): + """ An elemental server is not running """ + + +class PluginError(Exception): + """ Some plugin core function is broken """ + + +class CalderaError(Exception): + """ Caldera is broken """ + + +class NetworkError(Exception): + """ Network connection (like ssh) can not be established """ diff --git a/app/experimentcontrol.py b/app/experimentcontrol.py new file mode 100644 index 0000000..1f5a829 --- /dev/null +++ b/app/experimentcontrol.py @@ -0,0 +1,229 @@ +#!/usr/bin/env python3 + +""" A class to control a whole experiment. From setting up the machines to running the attacks """ + +import glob +import os +import subprocess +import time +import zipfile +from datetime import datetime + +from app.attack_log import AttackLog +from app.config import ExperimentConfig +from app.interface_sfx import CommandlineColors +from caldera_control import CalderaControl +from machine_control import Machine + + +# TODO: Multi threading at least when starting machines + +class Experiment(): + """ Class handling experiments """ + + def __init__(self, configfile): + """ + + @param configfile: Path to the configfile to load """ + + self.attacker_1 = None + + self.experiment_control = ExperimentConfig(configfile) + self.attack_logger = AttackLog() + self.__start_attacker() + + self.starttime = datetime.now().strftime("%Y_%m_%d___%H_%M_%S") + self.lootdir = os.path.join(self.experiment_control.loot_dir(), self.starttime) + os.makedirs(self.lootdir) + + self.targets = [] + # start target machines + for target_conf in self.experiment_control.targets(): + if not target_conf.is_active(): + continue + + tname = target_conf.vmname() + + print(f"{CommandlineColors.OKBLUE}preparing target {tname} ....{CommandlineColors.ENDC}") + target_1 = Machine(target_conf) + target_1.set_caldera_server(self.attacker_1.getip()) + try: + if not target_conf.use_existing_machine(): + target_1.destroy() + except subprocess.CalledProcessError: + # Maybe the machine just does not exist yet + pass + target_1.install_caldera_service() + target_1.up() + # TODO prime sensors here + needs_reboot = target_1.prime_sensors() + if needs_reboot: + target_1.reboot() + print(f"{CommandlineColors.OKGREEN}Target is up: {tname} {CommandlineColors.ENDC}") + target_1.start_caldera_client() + print(f"{CommandlineColors.OKGREEN}Initial start of caldera client: {tname} {CommandlineColors.ENDC}") + self.targets.append(target_1) + + # TODO: Install vulnerabilities by plugin + + print(f"{CommandlineColors.OKBLUE}Contacting caldera agents on all targets ....{CommandlineColors.ENDC}") + time.sleep(20) + # Wait until all targets are registered as Caldera targets + for target_1 in self.targets: + caldera_url = "http://" + self.attacker_1.getip() + ":8888" + + caldera_control = CalderaControl(caldera_url, config=self.experiment_control) + running_agents = [i["paw"] for i in caldera_control.list_agents()] + while target_1.get_paw() not in running_agents: + print(f"Connecting to caldera {caldera_url}, running agents are: {running_agents}") + print(f"Missing agent: {target_1.get_paw()} ...") + target_1.start_caldera_client() + print(f"Restarted caldera agent: {target_1.get_paw()} ...") + time.sleep(120) # Was 30, but maybe there are timing issues + running_agents = [i["paw"] for i in caldera_control.list_agents()] + print(f"{CommandlineColors.OKGREEN}Caldera agents reached{CommandlineColors.ENDC}") + + # Install vulnerabilities + for a_target in self.targets: + print(f"Installing vulnerabilities on {a_target.get_paw()}") + a_target.install_vulnerabilities() + a_target.start_vulnerabilities() + + # Install sensor plugins + for a_target in self.targets: + print(f"Installing sensors on {a_target.get_paw()}") + a_target.install_sensors() + a_target.start_sensors() + + # Attack them + print(f"{CommandlineColors.OKBLUE}Running Caldera attacks{CommandlineColors.ENDC}") + for target_1 in self.targets: + # Run caldera attacks + caldera_attacks = self.experiment_control.raw_config["caldera_attacks"][target_1.get_os()] + if caldera_attacks: + for attack in 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 + print(f"Attacking machine with PAW: {target_1.get_paw()}") + caldera_control = CalderaControl("http://" + self.attacker_1.getip() + ":8888", config=self.experiment_control) + + caldera_control.attack(self.attack_logger, target_1.get_paw(), attack, target_1.get_group()) + + time.sleep(self.experiment_control.get_nap_time()) + print(f"{CommandlineColors.OKGREEN}Finished Caldera attacks{CommandlineColors.ENDC}") + + # Run Kali attacks + print(f"{CommandlineColors.OKBLUE}Running Kali attacks{CommandlineColors.ENDC}") + for target_1 in self.targets: + for attack in self.experiment_control.raw_config["kali_attacks"][target_1.get_os()]: + # TODO: Work with snapshots + + self.attacker_1.kali_attack(attack, target_1.getip(), self.experiment_control) + + time.sleep(self.experiment_control.get_nap_time()) + + print(f"{CommandlineColors.OKGREEN}Finished Kali attacks{CommandlineColors.ENDC}") + + # Stop sensor plugins + # Collect data + for a_target in self.targets: + a_target.stop_sensors() + a_target.collect_sensors(self.lootdir) + + # Uninstall vulnerabilities + for a_target in self.targets: + print(f"Uninstalling vulnerabilities on {a_target.get_paw()}") + a_target.stop_vulnerabilities() + + # TODO: Zip result dir + + # Stop target machines + for target_1 in self.targets: + target_1.halt() + + self.__stop_attacker() + self.attack_logger.write_json(os.path.join(self.lootdir, "attack.json")) + self.zip_loot() + + def zip_loot(self): + """ Zip the loot together """ + + filename = os.path.join(self.lootdir, self.starttime + ".zip") + globs = ["/**/*.json", + "/**/*.proto", + "/*/**/*.zip", + + ] + + print(f"Creating zip file {filename}") + + with zipfile.ZipFile(filename, "w") as zfh: + for a_glob in globs: + a_glob = self.lootdir + a_glob + for a_file in glob.iglob(a_glob, recursive=True): + if a_file != filename: + print(a_file) + zfh.write(a_file) + + @staticmethod + def __get_results_files(root): + """ Yields a list of potential result files + + @param root: Root dir of the machine to collect data from + """ + # TODO: Properly implement. Get proper root parameter + + total = [os.path.join(root, "logstash", "filebeat.json")] + for a_file in total: + if os.path.exists(a_file): + yield a_file + + def __clean_result_files(self, root): + """ Deletes result files + + @param root: Root dir of the machine to collect data from + """ + + # TODO: Properly implement. Get proper root parameter + + for a_file in self.__get_results_files(root): + os.remove(a_file) + + def __collect_loot(self, root): + """ Collect results into loot dir + + @param root: Root dir of the machine to collect data from + """ + + try: + os.makedirs(os.path.abspath(self.experiment_control.loot_dir())) + except FileExistsError: + pass + for a_file in self.__get_results_files(root): + print("Copy {} {}".format(a_file, os.path.abspath(self.experiment_control.loot_dir()))) + + def __start_attacker(self): + """ Start the attacking VM """ + + # Preparing attacker + self.attacker_1 = Machine(self.experiment_control.attacker(0).raw_config) + + if not self.experiment_control.attacker(0).use_existing_machine(): + try: + self.attacker_1.destroy() + except subprocess.CalledProcessError: + # Machine does not exist + pass + self.attacker_1.create(reboot=False) + 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() + self.attacker_1.set_attack_logger(self.attack_logger) + + def __stop_attacker(self): + """ Stop the attacking VM """ + self.attacker_1.halt() diff --git a/app/interface_sfx.py b/app/interface_sfx.py new file mode 100644 index 0000000..849aea5 --- /dev/null +++ b/app/interface_sfx.py @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +""" Helper functions to improve the command line experiments """ + +# Colors to be used when printing text to the terminal +# print(f"{CommandlineColors.WARNING}Warning {CommandlineColors.ENDC}") + + +class CommandlineColors: + """ A collection of command line colors """ + + # pylint: disable=too-few-public-methods + + HEADER = '\033[95m' + OKBLUE = '\033[94m' + OKCYAN = '\033[96m' + OKGREEN = '\033[92m' + WARNING = '\033[93m' + ATTACK = '\033[93m' # An attack is running + MACHINE_CREATED = '\033[92m' + MACHINE_STOPPED = '\033[96m' + FAIL = '\033[91m' + ENDC = '\033[0m' + BOLD = '\033[1m' + UNDERLINE = '\033[4m' diff --git a/app/machinecontrol.py b/app/machinecontrol.py new file mode 100644 index 0000000..dbfaa7f --- /dev/null +++ b/app/machinecontrol.py @@ -0,0 +1,563 @@ +#!/usr/bin/env python3 + +""" (Virtual) machine handling. Start, stop, create and destroy. Starting remote commands on them. """ + +import os +import time +from glob import glob + +import requests +import straight.plugin + +from app.config import MachineConfig, ExperimentConfig +from app.exceptions import ServerError, ConfigurationError +from app.calderacontrol import CalderaControl +from app.interface_sfx import CommandlineColors +from plugins.base.kali import KaliPlugin +from plugins.base.machinery import MachineryPlugin +from plugins.base.sensor import SensorPlugin +from plugins.base.vulnerability_plugin import VulnerabilityPlugin + + +class Machine(): + """ A virtual machine. Attacker or target. Abstracting stuff away. """ + + def __init__(self, config, calderakey="ADMIN123"): + """ + + @param config: The machine configuration as dict + @param calderakey: Key to the caldera controller + """ + + self.vm_manager = None + self.attack_logger = None + + if isinstance(config, MachineConfig): + self.config = config + else: + self.config = MachineConfig(config) + + # TODO: Read config from plugin + if self.config.vmcontroller() == "vagrant": + self.__parse_vagrant_config__() + 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()) + # TODO Add internal machinepath path for within the VM (/vagrant/machinepath) for non-linux machines + self.abs_machinepath_internal = os.path.join("/vagrant/", self.config.machinepath()) + + if not os.path.exists(self.abs_machinepath_external): + 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): + """ 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") + if not os.path.isfile(self.vagrantfile): + raise ConfigurationError(f"Vagrantfile not existing: {self.vagrantfile}") + + def __parse_running_vm_config__(self): + """ 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): + """ Returns the paw of the current machine """ + return self.config.caldera_paw() + + def get_group(self): + """ Returns the group of the current machine """ + return self.config.caldera_group() + + def destroy(self): + """ Destroys the current machine """ + + self.vm_manager.__call_destroy__() + + def create(self, reboot=True): + """ Create a VM + + @param reboot: Reboot the VM during installation. Required if you want to install software + """ + + self.vm_manager.__call_create__(reboot) + + def reboot(self): + """ Reboot a machine """ + + if self.get_os() == "windows": + self.vm_manager.remote_run("shutdown /r") + self.vm_manager.__call_disconnect__() + time.sleep(60) # Shutdown can be slow.... + if self.get_os() == "linux": + self.vm_manager.remote_run("reboot") + self.vm_manager.__call_disconnect__() + res = None + while not res: + time.sleep(5) + res = self.vm_manager.__call_connect__() + print("Re-connecting....") + + def up(self): # pylint: disable=invalid-name + """ Starts a VM. Creates it if not already created """ + + self.vm_manager.__call_up__() + + def halt(self): + """ Halts a VM """ + + self.vm_manager.__call_halt__() + + def getuser(self): + """ Gets the user of the current VM """ + + return "Result " + str(self.vm_manager.__call_remote_run__("echo $USER")) + + def connect(self): + """ command connection. establish it """ + + return self.vm_manager.__call_connect__() + + def disconnect(self, connection): + """ Command connection dis-connect """ + + self.vm_manager.__call_disconnect__(connection) + + def remote_run(self, cmd, disown=False): + """ Simplifies connect and run + + @param cmd: Command to run as shell command + @param disown: run in background + """ + + return self.vm_manager.__call_remote_run__(cmd, disown) + + def kali_attack(self, attack, target, config: ExperimentConfig): + """ Pick a Kali attack and run it + + @param attack: Name of the attack to run + @param target: IP address of the target + @param config: A full experiment config object that has the methog "kali_conf" (just in case I want to split the config later) + @returns: The output of the cmdline attacking tool + """ + + def get_handlers(plugin) -> [KaliPlugin]: + return plugin.produce() + + base = "plugins/**/*.py" + + plugin_dirs = set() + for a_glob in glob(base, recursive=True): + plugin_dirs.add(os.path.dirname(a_glob)) + + for a_directory in plugin_dirs: + plugins = straight.plugin.load(a_directory, subclasses=KaliPlugin) + + handlers = get_handlers(plugins) + + for plugin in handlers: + name = plugin.get_name() + if name == attack: + print(f"{CommandlineColors.OKBLUE}Running Kali plugin {name}{CommandlineColors.ENDC}") + syscon = {"abs_machinepath_internal": self.abs_machinepath_internal, + "abs_machinepath_external": self.abs_machinepath_external} + plugin.set_sysconf(syscon) + plugin.set_machine_plugin(self.vm_manager) + plugin.__set_logger__(self.attack_logger) + plugin.__execute__([target], config.kali_conf(name)) + + def load_machine_plugin(self): + """ Loads the matching machine plugin """ + + def get_handlers(a_plugin) -> [MachineryPlugin]: + return a_plugin.produce() + + base = "plugins/**/*.py" + + plugin_dirs = set() + for a_glob in glob(base, recursive=True): + plugin_dirs.add(os.path.dirname(a_glob)) + + for a_dir in plugin_dirs: + plugins = straight.plugin.load(a_dir, subclasses=MachineryPlugin) + + handlers = get_handlers(plugins) + + for plugin in handlers: + name = plugin.get_name() + if name == self.config.vmcontroller(): + print(f"{CommandlineColors.OKBLUE}Installing sensor: {name}{CommandlineColors.ENDC}") + + 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 + break + + def prime_sensors(self): + """ 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 + + """ + + def get_handlers(a_plugin) -> [SensorPlugin]: + return a_plugin.produce() + + base = "plugins/**/*.py" + reboot = False + + plugin_dirs = set() + for a_glob in glob(base, recursive=True): + plugin_dirs.add(os.path.dirname(a_glob)) + + for a_dir in plugin_dirs: + plugins = straight.plugin.load(a_dir, subclasses=SensorPlugin) + + handlers = get_handlers(plugins) + + for plugin in handlers: + name = plugin.get_name() + if name in self.config.sensors(): + print(f"{CommandlineColors.OKBLUE}Priming sensor: {name}{CommandlineColors.ENDC}") + syscon = {"abs_machinepath_internal": self.abs_machinepath_internal, + "abs_machinepath_external": self.abs_machinepath_external, + "sensor_specific": self.config.raw_config.get(name, {}) + } + plugin.set_sysconf(syscon) + plugin.set_machine_plugin(self.vm_manager) + plugin.setup() + reboot |= plugin.prime() + self.sensors.append(plugin) + print(f"{CommandlineColors.OKGREEN}Primed sensor: {name}{CommandlineColors.ENDC}") + return reboot + + def install_sensors(self): + """ Install sensors from plugins + + A machine can have several sensors running. Those are defined in a list in the config. This installs the sensors + + """ + + for plugin in self.get_sensors(): + name = plugin.get_name() + + print(f"{CommandlineColors.OKBLUE}Installing sensor: {name}{CommandlineColors.ENDC}") + syscon = {"abs_machinepath_internal": self.abs_machinepath_internal, + "abs_machinepath_external": self.abs_machinepath_external, + "sensor_specific": self.config.raw_config.get(name, {})} + plugin.set_sysconf(syscon) + plugin.set_machine_plugin(self.vm_manager) + plugin.setup() + plugin.install() + print(f"{CommandlineColors.OKGREEN}Installed sensor: {name}{CommandlineColors.ENDC}") + # self.sensors.append(plugin) + + def get_sensors(self) -> [SensorPlugin]: + """ Returns a list of running sensors """ + return self.sensors + + def start_sensors(self): + """ 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(): + print(f"{CommandlineColors.OKBLUE}Starting sensor: {plugin.get_name()}{CommandlineColors.ENDC}") + plugin.set_machine_plugin(self.vm_manager) + plugin.start() + print(f"{CommandlineColors.OKGREEN}Started sensor: {plugin.get_name()}{CommandlineColors.ENDC}") + + def stop_sensors(self): + """ Stop sensors + + A machine can have several sensors running. Those are defined in a list in the config. This stops the sensors + + """ + + for plugin in self.get_sensors(): + print(f"{CommandlineColors.OKBLUE}Stopping sensor: {plugin.get_name()}{CommandlineColors.ENDC}") + plugin.set_machine_plugin(self.vm_manager) + plugin.stop() + print(f"{CommandlineColors.OKGREEN}Stopped sensor: {plugin.get_name()}{CommandlineColors.ENDC}") + + def collect_sensors(self, lootdir): + """ 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 + """ + + machine_specific_path = os.path.join(lootdir, self.config.vmname()) + os.mkdir(machine_specific_path) + + for plugin in self.get_sensors(): + print(f"{CommandlineColors.OKBLUE}Collecting sensor: {plugin.get_name()}{CommandlineColors.ENDC}") + plugin.set_machine_plugin(self.vm_manager) + plugin.__call_collect__(machine_specific_path) + print(f"{CommandlineColors.OKGREEN}Collected sensor: {plugin.get_name()}{CommandlineColors.ENDC}") + + ############ + + def install_vulnerabilities(self): + """ 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 + + """ + + def get_handlers(a_plugin) -> [SensorPlugin]: + return a_plugin.produce() + + base = "plugins/**/*.py" + + plugin_dirs = set() + for a_glob in glob(base, recursive=True): + plugin_dirs.add(os.path.dirname(a_glob)) + + for a_dir in plugin_dirs: + plugins = straight.plugin.load(a_dir, subclasses=VulnerabilityPlugin) + + handlers = get_handlers(plugins) + + for plugin in handlers: + name = plugin.get_name() + print(f"Configured vulnerabilities: {self.config.vulnerabilities()}") + if name in self.config.vulnerabilities(): + print(f"{CommandlineColors.OKBLUE}Installing vulnerability: {name}{CommandlineColors.ENDC}") + syscon = {"abs_machinepath_internal": self.abs_machinepath_internal, + "abs_machinepath_external": self.abs_machinepath_external} + plugin.set_sysconf(syscon) + plugin.set_machine_plugin(self.vm_manager) + plugin.setup() + plugin.install(self.vm_manager) + self.vulnerabilities.append(plugin) + + def get_vulnerabilities(self) -> [SensorPlugin]: + """ Returns a list of installed vulnerabilities """ + return self.vulnerabilities + + def start_vulnerabilities(self): + """ 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(): + print(f"{CommandlineColors.OKBLUE}Activating vulnerability: {plugin.get_name()}{CommandlineColors.ENDC}") + plugin.set_machine_plugin(self.vm_manager) + plugin.start() + + def stop_vulnerabilities(self): + """ 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(): + print(f"{CommandlineColors.OKBLUE}Uninstalling vulnerability: {plugin.get_name()}{CommandlineColors.ENDC}") + plugin.set_machine_plugin(self.vm_manager) + plugin.stop() + + ############ + + def getip(self): + """ Returns the IP of the main ethernet interface of this machine """ + + # TODO: Create special code to extract windows IPs + + # TODO: Find a smarter way to get the ip + + return self.vm_manager.get_ip() + + def install_caldera_server(self, cleanup=False, version="2.8.1"): + """ 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 + print(f"{CommandlineColors.OKBLUE}Installing Caldera server {CommandlineColors.ENDC}") + + if cleanup: + cleanupcmd = "rm -rf caldera;" + else: + cleanupcmd = "" + + cmd = f"cd {self.caldera_basedir}; {cleanupcmd} git clone https://github.com/mitre/caldera.git --recursive --branch {version}; cd caldera; pip3 install -r requirements.txt" + print(f"{CommandlineColors.OKGREEN}Caldera server installed {CommandlineColors.ENDC}") + res = self.vm_manager.__call_remote_run__(cmd) + return "Result installing caldera server " + str(res) + + def wait_for_caldera_server(self, timeout=6): + """ Ping caldera server. return as soon as it is responding + + @param timeout: timeout in seconds + """ + for i in range(timeout): + time.sleep(10) + caldera_url = "http://" + self.getip() + ":8888" + caldera_control = CalderaControl(caldera_url, apikey=self.calderakey) + print(f"{i} Trying to connect to {caldera_url} Caldera API") + try: + caldera_control.list_adversaries() + except requests.exceptions.ConnectionError: + pass + else: + print("Caldera: All systems nominal") + return True + raise ServerError + + def start_caldera_server(self): + """ Start the caldera server on the VM. Required for an attacker VM """ + # https://github.com/mitre/caldera.git + + print(f"{CommandlineColors.OKBLUE}Starting Caldera server {CommandlineColors.ENDC}") + + cmd = f"cd {self.caldera_basedir}; cd caldera ; nohup python3 server.py --insecure &" + self.vm_manager.__call_remote_run__(cmd, disown=True) + self.wait_for_caldera_server() + print(f"{CommandlineColors.OKGREEN}Caldera server started. Confirmed it is running. {CommandlineColors.ENDC}") + + def create_start_caldera_client_cmd(self): + """ Creates a command to start the caldera client """ + + playground = self.vm_manager.get_playground() + + if self.get_os() == "linux": + cmd = f""" +nohup {playground}/caldera_agent.sh start & + """ + elif self.get_os() == "windows": + if playground: + playground = playground + "\\" # Workaround for Windows: Can not set target dir for fabric-put in Windows. Only default (none=user) dir available. + else: + playground = "" + # playground = self.vm_manager.get_playground() + cmd = f""" +{playground}caldera_agent.bat + """ + + return cmd + + def start_caldera_client(self): + """ Install caldera client. Required on targets """ + + name = self.vm_manager.get_vm_name() + print(f"{CommandlineColors.OKBLUE}Starting Caldera client {name} {CommandlineColors.ENDC}") + + if self.get_os() == "windows": + # TODO: Do not mount but use ssh to copy + + url = "http://" + self.caldera_server + ":8888" + caldera_control = CalderaControl(url, apikey=self.calderakey) + caldera_control.fetch_client(platform="windows", + file="sandcat.go", + target_dir=self.abs_machinepath_external, + extension=".go") + dst = self.vm_manager.get_playground() + 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 + self.vm_manager.put(src, dst) + + cmd = self.__install_caldera_service_cmd().strip() + print(cmd) + self.vm_manager.remote_run(cmd, disown=False) + + if self.get_os() == "linux": + dst = self.vm_manager.get_playground() + src = os.path.join(self.abs_machinepath_external, "caldera_agent.sh") + self.vm_manager.put(src, dst) + + cmd = self.create_start_caldera_client_cmd().strip() + + print(cmd) + self.vm_manager.remote_run(cmd, disown=True) + + print(f"{CommandlineColors.OKGREEN}Caldera client started {CommandlineColors.ENDC}") + + def get_os(self): + """ Returns the OS of the machine """ + + return self.config.os() + + def __install_caldera_service_cmd(self): + playground = self.vm_manager.get_playground() + + if self.get_os() == "linux": + return f""" +#!/bin/bash + +# Installs and runs the caldera agent + +# TODO: Respect start/stop commands + +cd {playground} +server="http://{self.caldera_server}:8888"; +curl -s -X POST -H "file:sandcat.go" -H "platform:linux" $server/file/download > sandcat.go; +chmod +x sandcat.go; +nohup ./sandcat.go -server $server -group {self.config.caldera_group()} -v -paw {self.config.caldera_paw()} & + """ + if self.get_os() == "windows": + if playground: # Workaround for Windows: Can not set target dir for fabric-put in Windows. Only default (none=user) dir available. + playground = playground + "\\" + else: + playground = "" + url = "http://" + self.caldera_server + ":8888" + caldera_control = CalderaControl(url, apikey=self.calderakey) + filename = caldera_control.fetch_client(platform="windows", + file="sandcat.go", + target_dir=self.abs_machinepath_external, + extension=".go") + return f""" +START {playground}{filename} -server {url} -group {self.config.caldera_group()} -paw {self.config.caldera_paw()} + """.strip() + + raise Exception # System type unknown + + def install_caldera_service(self): + """ Install the caldera client as a service. For linux targets """ + + # print("DELETEME ! " + sys._getframe().f_code.co_name) + + content = self.__install_caldera_service_cmd() + + print(f"{CommandlineColors.OKBLUE}Installing Caldera service {CommandlineColors.ENDC}") + + if self.get_os() == "linux": + filename = os.path.join(self.abs_machinepath_external, "caldera_agent.sh") + elif self.get_os() == "windows": + filename = os.path.join(self.abs_machinepath_external, "caldera_agent.bat") + with open(filename, "wt") as fh: + fh.write(content) + print(f"{CommandlineColors.OKGREEN}Installed Caldera service {CommandlineColors.ENDC}") + + def set_caldera_server(self, server): + """ 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/caldera_control.py b/caldera_control.py new file mode 100644 index 0000000..11923bf --- /dev/null +++ b/caldera_control.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 + +""" A command line tool to control a caldera server """ + +import argparse + +from app.calderacontrol import CalderaControl + + +# https://caldera.readthedocs.io/en/latest/The-REST-API.html + + +# Arpgparse handling +def list_agents(calcontrol, arguments): # pylint: disable=unused-argument + """ Call list agents in caldera control + + @param calcontrol: Connection to the caldera server + @param arguments: Parser command line arguments + """ + # TODO: calcontrol.list_agents(arguments) + pass # pylint: disable=unnecessary-pass + + +def list_abilities(calcontrol, arguments): + """ Call list abilities in caldera control + + @param calcontrol: Connection to the caldera server + @param arguments: Parser command line arguments + """ + + abilities = arguments.ability_ids + + if arguments.all: + abilities = [aid["ability_id"] for aid in calcontrol.list_abilities()] + + for aid in abilities: + for ability in calcontrol.get_ability(aid): + calcontrol.pretty_print_ability(ability) + + +def attack(calcontrol, arguments): + """ Calling attack + + @param calcontrol: Connection to the caldera server + @param arguments: Parser command line arguments + """ + print("Running attack") + print(arguments.paw) + print(arguments.group) + print(arguments.ability_id) + calcontrol.attack(paw=arguments.paw, group=arguments.group, ability_id=arguments.ability_id) + + +def create_parser(): + """ Creates the parser for the command line arguments""" + + main_parser = argparse.ArgumentParser("Controls a Caldera server to attack other systems") + subparsers = main_parser.add_subparsers(help="sub-commands") + + # Sub parser for attacks + parser_attack = subparsers.add_parser("attack", help="attack system") + parser_attack.set_defaults(func=attack) + parser_attack.add_argument("--paw", default="kickme", help="paw to attack and get specific results for") + parser_attack.add_argument("--group", default="red", help="target group to attack") + parser_attack.add_argument("--ability_id", default="bd527b63-9f9e-46e0-9816-b8434d2b8989", + help="The ability to use for the attack") + + # Sub parser to list abilities + parser_abilities = subparsers.add_parser("abilities", help="abilities") + # parser_abilities.add_argument("--abilityid", default=None, help="Id of the ability to list") + parser_abilities.set_defaults(func=list_abilities) + parser_abilities.add_argument("--ability_ids", default=[], nargs="+", + help="The abilities to look up. One or more ids") + parser_abilities.add_argument("--all", default=False, action="store_true", + help="List all abilities") + + # TODO: Add sub parser to list agents + parser_agents = subparsers.add_parser("agents", help="agents") + parser_agents.set_defaults(func=list_agents) + + # For all parsers + main_parser.add_argument("--caldera_url", help="caldera url, including port", default="http://192.168.178.97:8888/") + main_parser.add_argument("--apikey", help="caldera api key", default="ADMIN123") + + return main_parser + + +if __name__ == "__main__": + parser = create_parser() + args = parser.parse_args() + + print(args.caldera_url) + + caldera_control = CalderaControl(args.caldera_url, config=None, apikey=args.apikey) + print("Caldera Control ready") + + str(args.func(caldera_control, args)) diff --git a/experiment.yaml b/experiment.yaml new file mode 100644 index 0000000..b095bbe --- /dev/null +++ b/experiment.yaml @@ -0,0 +1,232 @@ + +### +# Caldera configuration +caldera: + ### + # API key for caldera. See caldera configuration. Default is ADMIN123 + apikey: ADMIN123 + +### +# Attacks configuration +attackers: + ### + # Configuration for the first attacker. One should normally be enough + attacker: + + ### + # Defining VM controller settings for this machine + vm_controller: + ### + # Type of the VM controller, Options are "vagrant" + type: vagrant + ### + # # path where the vagrantfile is in + vagrantfilepath: systems + + ### + # Name of machine in Vagrantfile + vm_name: attacker + + ### + # machinepath is a path where the machine specific files and logs are stored. Relative to the Vagrantfile path + # and will be mounted internally as /vagrant/ + # If machinepath is not set PurpleDome will try "vm_name" + machinepath: attacker1 + + ### + # OS of the VM guest. Options are so far "windows", "linux" + os: linux + + ### + # Do not destroy/create the machine: Set this to "yes". + use_existing_machine: yes + +### +# List of targets +targets: + ### + # Specific target + target1: + vm_controller: + type: vagrant + vagrantfilepath: systems + + ### + # simple switch if targets is used in attack simulation. Default is true. If set to false the machine will not be started + active: no + + vm_name: target1 + os: linux + ### + # Targets need a unique PAW name for caldera + paw: target1 + ### + # Targets need to be in a group for caldera + group: red + + machinepath: target1 + # Do not destroy/create the machine: Set this to "yes". + use_existing_machine: yes + + ### + # The folder all the implants will be installed into + playground: /home/vagrant + + # Sensors to run on this machine + sensors: + # - linux_idp + + target2: + #root: systems/target1 + vm_controller: + type: vagrant + vagrantfilepath: systems + + ### + # simple switch if targets is used in attack simulation. Default is true. If set to false the machine will not be started + active: yes + + vm_name: target2 + os: windows + paw: target2w + group: red + + machinepath: target2w + + # Do not destroy/create the machine: Set this to "yes". + use_existing_machine: yes + ### + # Optional setting to activate force when halting the machine. Windows guests sometime get stuck + halt_needs_force: yes + + ### + # If SSH without vagrant support is used (Windows !) we need a user name (uppercase) + ssh_user: PURPLEDOME + + ### + # For non-vagrant ssh connections a ssh keyfile stored in the machinepath is required. + ssh_keyfile: id_rsa.3 + + ### + # The folder all the implants will be installed into + # Windows can only use default playground at the moment ! + # playground: C:\\Users\\PurpleDome + + # Sensors to run on this machine + sensors: + - windows_idp + + vulnerabilities: + - weak_user_passwords + - rdp_config_vul + + + # Ubuntu 20.10 (Groovy) + target3: + vm_controller: + type: vagrant + vagrantfilepath: systems + + ### + # simple switch if targets is used in attack simulation. Default is true. If set to false the machine will not be started + active: no + + vm_name: target3 + os: linux + ### + # Targets need a unique PAW name for caldera + paw: target3 + ### + # Targets need to be in a group for caldera + group: red + + machinepath: target3 + # Do not destroy/create the machine: Set this to "yes". + use_existing_machine: no + + ### + # The folder all the implants will be installed into + playground: /home/vagrant + + # Sensors to run on this machine + sensors: + - linux_idp + + vulnerabilities: + - sshd_config_vul + - weak_user_passwords + +### +# General sensor config config +sensors: + ### + # Windows IDP plugin configuration + windows_idp: + ### + # Name of the dll to use. Must match AV version + dll_name: aswidptestdll.dll + + ### + # Folder where the IDP tool is located + idp_tool_folder: C:\\capture + +### +# General attack config +attacks: + ### + # configure the seconds the system idles between the attacks. Makes it slower. But attack and defense logs will be simpler to match + nap_time: 5 + +### +# A list of caldera attacks to run against the targets. +caldera_attacks: + ### + # Linux specific attacks. A list of caldera ability IDs + linux: + - "bd527b63-9f9e-46e0-9816-b8434d2b8989" + ### + # Windows specific attacks. A list of caldera ability IDs + windows: + - "bd527b63-9f9e-46e0-9816-b8434d2b8989" + +### +# Kali tool based attacks. Will result in kali commandline tools to be called. Currently supported are: "hydra" +kali_attacks: + ### + # Linux specific attacks, a list + linux: + - hydra + - nmap + ### + # Windows specific attacks, a list + windows: + - hydra + - nmap + +### +# Configuration for the kali attack tools +kali_conf: + ### + # Hydra configuration + hydra: + ### + # A list of protocols to brute force against. Supported: "ssh" + protocols: + - ssh + - rdp + #- ftps + ### + # A file containing potential user names + userfile: users.txt + ### + # A file containing potential passwords + pwdfile: passwords.txt + nmap: + + +### +# Settings for the results being harvested +results: + ### + # The directory the loot will be in + loot_dir: loot diff --git a/experiment_control.py b/experiment_control.py new file mode 100644 index 0000000..e2b220a --- /dev/null +++ b/experiment_control.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +""" The main tool to run experiments """ + +import argparse + +from app.experimentcontrol import Experiment + + +def explain(args): # pylint: disable=unused-argument + """ Explain the tool""" + + print("Please specify a command to execute. For a list see ") + + +def run(args): + """ Run experiments + + @param args: arguments from the argparse parser + """ + Experiment(args.configfile) + + +def create_parser(): + """ Creates the parser for the command line arguments""" + parser = argparse.ArgumentParser("Controls an experiment on the configured systems") + subparsers = parser.add_subparsers(help="sub-commands") + + parser.set_defaults(func=explain) + + # Sub parser for machine creation + parser_run = subparsers.add_parser("run", help="run experiments") + parser_run.set_defaults(func=run) + parser_run.add_argument("--configfile", default="experiment.yaml", help="Config file to create from") + + return parser + + +if __name__ == "__main__": + arguments = create_parser().parse_args() + arguments.func(arguments) diff --git a/init.sh b/init.sh new file mode 100755 index 0000000..b70b436 --- /dev/null +++ b/init.sh @@ -0,0 +1,8 @@ +#!/bin/bash + +# Init the system + +sudo apt-get -y install python3-venv +python3 -m venv venv +source venv/bin/activate +pip3 install -r requirements.txt \ No newline at end of file diff --git a/machine_control.py b/machine_control.py new file mode 100644 index 0000000..cba28fa --- /dev/null +++ b/machine_control.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +""" Demo program to set up and control the machines """ + +import argparse + +import yaml + +from app.calderacontrol import CalderaControl +from app.machinecontrol import Machine + + +def create_machines(arguments): + """ + + @param arguments: The arguments from argparse + """ + # TODO: Add argparse and make it flexible + + with open(arguments.configfile) as fh: + config = yaml.safe_load(fh) + + target_ = Machine(config["targets"]["target1"]) + attacker_1 = Machine(config["attackers"]["attacker"]) + + print("Got them") + + # TODO Automatically create all machines defined in config file + + # attacker_1.destroy() + print("destroyed") + attacker_1.create(reboot=False) + print("Attacker up") + attacker_1.up() + print(attacker_1.install_caldera_server()) + attacker_1.start_caldera_server() + print("Attacker done") + + target_.destroy() + target_.set_caldera_server(attacker_1.getip()) + target_.install_caldera_service() + target_.create() + print("Target up") + target_.up() + target_.start_caldera_client() + print("Target done") + + print("Caldera server running at: http://{}:8888/".format(attacker_1.getip())) + # target_.install_caldera_client(attacker_1.getip(), "target1elf") + + +def download_caldera_client(arguments): + """ Downloads the caldera client + + @param arguments: The arguments from argparse + """ + + caldera_control = CalderaControl(args.ip, None) + caldera_control.fetch_client(platform=arguments.platform, + file=arguments.file, + target_dir=arguments.target_dir, + extension=".go") + + +def create_parser(): + """ Creates the parser for the command line arguments""" + + main_parser = argparse.ArgumentParser("Controls a Caldera server to attack other systems") + subparsers = main_parser.add_subparsers(help="sub-commands") + + # Sub parser for machine creation + parser_create = subparsers.add_parser("create", help="create systems") + parser_create.set_defaults(func=create_machines) + parser_create.add_argument("--configfile", default="experiment.yaml", help="Config file to create from") + + parser_download_caldera_client = subparsers.add_parser("fetch_client", help="download the caldera client") + parser_download_caldera_client.set_defaults(func=download_caldera_client) + parser_download_caldera_client.add_argument("--ip", default="192.168.178.189", help="Ip of Caldera to connect to") + parser_download_caldera_client.add_argument("--platform", default="windows", help="platform to download the client for") + parser_download_caldera_client.add_argument("--file", default="sandcat.go", help="The agent to download") + parser_download_caldera_client.add_argument("--target_dir", default=".", help="The target dir to download the file to") + + return main_parser + + +if __name__ == "__main__": + + parser = create_parser() + + args = parser.parse_args() + + args.func(args) diff --git a/plugins/base/kali.py b/plugins/base/kali.py new file mode 100644 index 0000000..e23dc39 --- /dev/null +++ b/plugins/base/kali.py @@ -0,0 +1,77 @@ +#!/usr/bin/env python3 +""" Base class for Kali plugins """ + +from plugins.base.plugin_base import BasePlugin + + +class KaliPlugin(BasePlugin): + """ Class to execute a command on a kali system targeting another system """ + + # Boilerplate + name = None + description = None + ttp = None + references = None + + required_files = [] + + # TODO: parse results + + def __init__(self): + super().__init__() + self.conf = {} # Plugin specific configuration + self.sysconf = {} # System configuration. common for all plugins + self.attack_logger = None + + def teardown(self): + """ Cleanup afterwards """ + pass # pylint: disable=unnecessary-pass + + def run(self, targets, config): + """ Run the command + + @param targets: A list of targets, ip addresses will do + @param config: dict with command specific configuration + """ + raise NotImplementedError + + def __execute__(self, targets, config): + """ Execute the plugin. This is called by the code + + @param targets: A list of targets, ip addresses will do + @param config: dict with command specific configuration + """ + + self.attack_logger.start_kali_attack(self.machine_plugin.config.vmname(), targets, self.name, ttp=self.get_ttp()) + self.setup() + res = self.run(targets, config) + self.teardown() + self.attack_logger.stop_kali_attack(self.machine_plugin.config.vmname(), targets, self.name, ttp=self.get_ttp()) + return res + + def command(self, targets, config): + """ Generate command + + @param targets: A list of targets, ip addresses will do + @param config: dict with command specific configuration + """ + + raise NotImplementedError + + def __set_logger__(self, attack_logger): + """ Set the attack logger for this machine """ + self.attack_logger = attack_logger + + def get_ttp(self): + """ Returns the ttp of the plugin, please set in boilerplate """ + if self.ttp: + return self.ttp + + raise NotImplementedError + + def get_references(self): + """ Returns the references of the plugin, please set in boilerplate """ + if self.references: + return self.references + + raise NotImplementedError diff --git a/plugins/base/machinery.py b/plugins/base/machinery.py new file mode 100644 index 0000000..5486ccb --- /dev/null +++ b/plugins/base/machinery.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 + +""" Base class for classes to control any kind of machine: vm, bare metal, cloudified """ + +from enum import Enum + +from app.config import MachineConfig +from app.interface_sfx import CommandlineColors +from plugins.base.plugin_base import BasePlugin + + +class MachineStates(Enum): + """ Potential machine states """ + # TODO: maybe move state handling functions in here like "is running", "is broken" + RUNNING = 1 + NOT_CREATED = 2 + POWEROFF = 3 + ABORTED = 4 + SAVED = 5 + STOPPED = 6 + FROZEN = 7 + SHUTOFF = 8 + + +class MachineryPlugin(BasePlugin): + """ Class to control virtual machines, vagrant, .... """ + + # Boilerplate + name = None + + required_files = [] + + ############### + # This is stuff you might want to implement + + def __init__(self): + super().__init__() + self.connection = None # Connection + self.config = None + + def process_config(self, config: MachineConfig): + """ Machine specific processing of configuration + + @param config: configuration to do additional processing on + """ + raise NotImplementedError + + def create(self, reboot=True): + """ Create a machine + + @param reboot: Optionally reboot the machine after creation + """ + raise NotImplementedError + + def up(self): # pylint: disable=invalid-name + """ Start a machine, create it if it does not exist """ + raise NotImplementedError + + def halt(self): + """ Halt a machine """ + raise NotImplementedError + + def destroy(self): + """ Destroy a machine """ + raise NotImplementedError + + def connect(self): + """ Connect to a machine """ + raise NotImplementedError + + def remote_run(self, cmd, disown=False): + """ Connects to the machine and runs a command there + + @param cmd: command to run int he machine's shell + @param disown: Send the connection into background + """ + + raise NotImplementedError + + def disconnect(self): + """ Disconnect from a machine """ + raise NotImplementedError + + def put(self, src, dst): + """ Send a file to a machine + + @param src: source dir + @param dst: destination + """ + raise NotImplementedError + + def get(self, src, dst): + """ Get a file to a machine + + @param src: source dir + @param dst: destination + """ + raise NotImplementedError + + def is_running(self): + """ Returns if the machine is running """ + return self.get_state() == MachineStates.RUNNING + + def get_state(self): + """ Get detailed state of a machine """ + raise NotImplementedError + + def get_ip(self): + """ Return the IP of the machine. If there are several it should be the one accepting ssh or similar. If a resolver is running, a domain is also ok. """ + raise NotImplementedError + + def get_playground(self): + """ path where all the attack tools will be copied to on a client. Your specific machine plugin can overwrite it. """ + + return self.config.get_playground() + + def get_vm_name(self): + """ Get the name of the machine """ + + return self.config.vmname() + + ############### + # This is the interface from the main code to the plugin system. Do not touch + def __call_halt__(self): + """ Wrapper around halt """ + print(f"{CommandlineColors.OKBLUE}Stopping machine: {self.config.vmname()} {CommandlineColors.ENDC}") + self.halt() + print(f"{CommandlineColors.OKGREEN}Machine stopped: {self.config.vmname()}{CommandlineColors.ENDC}") + + def __call_process_config__(self, config: MachineConfig): + """ Wrapper around process_config """ + + # print("===========> Processing config") + self.config = config + self.process_config(config) + + def __call_remote_run__(self, cmd, disown=False): + """ Simplifies connect and run + + @param cmd: Command to run as shell command + @param disown: run in background + """ + + return self.remote_run(cmd, disown) + + def __call_disconnect__(self): + """ Command connection dis-connect """ + + self.disconnect() + + def __call_connect__(self): + """ command connection. establish it """ + + return self.connect() + + def __call_up__(self): + """ Starts a VM. Creates it if not already created """ + + self.up() + + def __call_create__(self, reboot=True): + """ Create a VM + + @param reboot: Reboot the VM during installation. Required if you want to install software + """ + + self.create(reboot) + + def __call_destroy__(self): + """ Destroys the current machine """ + + self.destroy() diff --git a/plugins/base/plugin_base.py b/plugins/base/plugin_base.py new file mode 100644 index 0000000..aa8aafe --- /dev/null +++ b/plugins/base/plugin_base.py @@ -0,0 +1,86 @@ +#!/usr/bin/env python3 +""" Base class for all plugin types """ + +import os +# from shutil import copy + + +class BasePlugin(): + """ Base class for plugins """ + + required_files = None # a list of files shipped with the plugin to be installed + name = None # The name of the plugin + description = None # The description of this plugin + + def __init__(self): + # self.machine = None + self.plugin_path = None + self.machine_plugin = None + self.sysconf = {} + + def setup(self): + """ Prepare everything for the plugin """ + + for a_file in self.required_files: + src = os.path.join(os.path.dirname(self.plugin_path), a_file) + print(src) + self.copy_to_machine(src) + + def set_machine_plugin(self, machine_plugin): + """ Set the machine plugin class to communicate with + + @param machine_plugin: Machine plugin to communicate with + """ + + self.machine_plugin = machine_plugin + + def set_sysconf(self, config): + """ Set system config + + @param config: A dict with system configuration relevant for all plugins + """ + + self.sysconf["abs_machinepath_internal"] = config["abs_machinepath_internal"] + self.sysconf["abs_machinepath_external"] = config["abs_machinepath_external"] + + def copy_to_machine(self, filename): + """ Copies a file shipped with the plugin to the machine share folder + + @param filename: File from the plugin folder to copy to the machine share. + """ + + self.machine_plugin.put(filename, self.machine_plugin.get_playground()) + + # plugin_folder = os.path.dirname(os.path.realpath(self.plugin_path)) + # src = os.path.join(plugin_folder, filename) + + # if os.path.commonprefix((os.path.realpath(src), plugin_folder)) != plugin_folder: + # raise PluginError + + # copy(src, self.sysconf["abs_machinepath_external"]) + + def run_cmd(self, command, warn=True, disown=False): + """ Execute a command on the vm using the connection + + @param command: Command to execute + @param disown: Run in background + """ + + print(f" Plugin running command {command}") + + res = self.machine_plugin.__call_remote_run__(command, disown=disown) + return res + + def get_name(self): + """ Returns the name of the plugin, please set in boilerplate """ + if self.name: + return self.name + + raise NotImplementedError + + def get_description(self): + """ Returns the description of the plugin, please set in boilerplate """ + if self.description: + return self.description + + raise NotImplementedError diff --git a/plugins/base/sensor.py b/plugins/base/sensor.py new file mode 100644 index 0000000..1b1a375 --- /dev/null +++ b/plugins/base/sensor.py @@ -0,0 +1,102 @@ +#!/usr/bin/env python3 +""" A base plugin class for sensors. Anything installed on the target to collect system information and identify the attack """ + +import os +from plugins.base.plugin_base import BasePlugin + + +class SensorPlugin(BasePlugin): + """ A sensor will be running on the target machine and monitor attacks. To remote control those sensors + there are sensor plugins. This is the base class for them + + """ + + # Boilerplate + name = None + + required_files = [] + + def __init__(self): + super().__init__() # pylint:disable=useless-super-delegation + self.debugit = False + # self.machine = None + + def set_sysconf(self, config): + """ Set system config + + @param config: A dict with system configuration relevant for all plugins + """ + + super().set_sysconf(config) + self.sysconf["sensor_specific"] = config["sensor_specific"] + + def prime(self): + """ prime sets hard core configs in the target. You can use it to call everything that permanently alters the OS by settings. + If your prime function returns True the machine will be rebooted after prime-ing it. This is very likely what you want. Only use prime if install is not sufficient. + """ + + return False + + def install_command(self): + """ Generate the install command. Put everything you need that does not require a reboot in here. If you want to hard core alter the OS of the target, use the prime method """ + raise NotImplementedError + + def install(self): + """ Install the sensor. Executed on the target. Take the sensor from the share and (maybe) copy it to its destination. Do some setup + """ + + cmd = self.install_command() + if cmd: + self.machine_plugin.__call_remote_run__(cmd) + + def start_command(self): + """ Generate the start command """ + + raise NotImplementedError + + def start(self, disown=None): + """ Start the sensor. The connection to the client is disowned here. = Sent to background. This keeps the process running. + + @param disown: Send async into background + """ + + if disown is None: + disown = not self.debugit + cmd = self.start_command() + if cmd: + # self.run_cmd(cmd, disown=not self.debugit) + self.machine_plugin.__call_remote_run__(cmd, disown=disown) + + def stop_command(self): + """ Generate the stop command """ + raise NotImplementedError + + def stop(self): + """ Stop the sensor """ + cmd = self.stop_command() + if cmd: + # self.run_cmd(cmd) + self.machine_plugin.__call_remote_run__(cmd) + + def __call_collect__(self, machine_path): + """ Generate the data collect command + + @param machine_path: Machine specific path to collect data into + """ + + path = os.path.join(machine_path, "sensors", self.name) + os.makedirs(path) + self.collect(path) + + def collect_command(self, path): + """ Generate the data collect command + + @param path: Path to put the data into + """ + + def collect(self, path): + """ Collect data from sensor. Copy it from sensor collection dir on target OS to the share + + @param path: The path to copy the data into + """ + raise NotImplementedError diff --git a/plugins/base/vulnerability_plugin.py b/plugins/base/vulnerability_plugin.py new file mode 100644 index 0000000..b05fe38 --- /dev/null +++ b/plugins/base/vulnerability_plugin.py @@ -0,0 +1,58 @@ +#!/usr/bin/env python3 + +""" This is a specific plugin type that installs a vulnerability into a VM. This can be a vulnerable application or a configuration setting """ + +from plugins.base.plugin_base import BasePlugin + + +class VulnerabilityPlugin(BasePlugin): + """ A plugin that installs a vulnerable application or does vulnerable configuration changes on the target VM + """ + + # Boilerplate + name = None + description = None + ttp = None + references = None + + required_files = [] + + def __init__(self): + super().__init__() # pylint:disable=useless-super-delegation + self.debugit = False + + def install(self, machine_plugin=None): + """ This is setting up everything up to the point where the machine itself would be modified. But system + modification is done by start + + @param machine_plugin: Optional: you can already set the machine to use + """ + + if machine_plugin: + self.machine_plugin = machine_plugin + + def start(self): + """ Modifying the target machine and add the vulnerability """ + + # It is ok if install is empty. But this function here is the core. So implement it ! + raise NotImplementedError + + def stop(self): + """ Modifying the target machine and remove the vulnerability """ + + # Must be implemented. If you want to leave a mess create an empty function and be honest :-) + raise NotImplementedError + + def get_ttp(self): + """ Returns the ttp of the plugin, please set in boilerplate """ + if self.ttp: + return self.ttp + + raise NotImplementedError + + def get_references(self): + """ Returns the references of the plugin, please set in boilerplate """ + if self.references: + return self.references + + raise NotImplementedError diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..135142b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,13 @@ +python-vagrant==0.5.15 +fabric==2.6.0 +requests==2.25.1 +simplejson==3.17.2 +tox==3.22.0 +sphinx-argparse==0.2.5 +sphinxcontrib-autoyaml==0.6.1 +sphinx-pyreverse==0.0.13 +coverage==5.4 +PyYAML==5.4.1 +straight.plugin==1.5.0 +sphinxcontrib.asciinema==0.3.1 +paramiko diff --git a/requirements_dev.txt b/requirements_dev.txt new file mode 100644 index 0000000..af8a840 --- /dev/null +++ b/requirements_dev.txt @@ -0,0 +1,13 @@ +python-vagrant>=0.5.15 +fabric>=2.6.0 +requests>=2.25.1 +simplejson>=3.17.2 +tox>=3.22.0 +sphinx-argparse>=0.2.5 +sphinxcontrib-autoyaml>=0.6.1 +sphinx-pyreverse>=0.0.13 +coverage>=5.4 +PyYAML>=5.4.1 +straight.plugin>=1.5.0 +sphinxcontrib.asciinema>=0.3.1 +paramiko