diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ad014869..095c32baa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,8 @@ Changelog](https://keepachangelog.com/en/1.0.0/). - `/api/registration-status` endpoint. #2149 - authentication to `/api/island/version`. #2109 - `/api/events` endpoint. #2155 +- The ability to customize the file extension used by ransomware when + encrypting files. #1242 ### Changed - Reset workflow. Now it's possible to delete data gathered by agents without diff --git a/docs/content/usage/scenarios/ransomware-simulation.md b/docs/content/usage/scenarios/ransomware-simulation.md index 6088ec7bc..adf49c1a6 100644 --- a/docs/content/usage/scenarios/ransomware-simulation.md +++ b/docs/content/usage/scenarios/ransomware-simulation.md @@ -37,15 +37,21 @@ To ensure minimum interference and easy recoverability, the ransomware simulation will only encrypt files contained in a user-specified directory. If no directory is specified, no files will be encrypted. +Infection Monkey appends the `.m0nk3y` file extension to files that it +encrypts. You may optionally provide a custom file extension for Infection +Monkey to use instead. You can even provide no file extension, but take +caution: you'll no longer be able to tell if the file has been encrypted based +on the filename alone! + ![Ransomware configuration](/images/usage/scenarios/ransomware-config.png "Ransomware configuration") ### How are the files encrypted? Files are "encrypted" in place with a simple bit flip. Encrypted files are -renamed to have `.m0nk3y` appended to their names. This is a safe way to -simulate encryption since it is easy to "decrypt" your files. You can simply -perform a bit flip on the files again and rename them to remove the appended -`.m0nk3y` extension. +renamed to have a file extension (`.m0nk3y` by default) appended to their +names. This is a safe way to simulate encryption since it is easy to "decrypt" +your files. You can simply perform a bit flip on the files again and rename +them to remove the appended `.m0nk3y` extension. Flipping a file's bits is sufficient to simulate the encryption behavior of ransomware, as the data in your files has been manipulated (leaving them diff --git a/docs/static/images/usage/scenarios/ransomware-config.png b/docs/static/images/usage/scenarios/ransomware-config.png index ca4ae8980..b8e357f1d 100644 Binary files a/docs/static/images/usage/scenarios/ransomware-config.png and b/docs/static/images/usage/scenarios/ransomware-config.png differ diff --git a/monkey/common/agent_configuration/default_agent_configuration.py b/monkey/common/agent_configuration/default_agent_configuration.py index 712e7d458..91026e5d2 100644 --- a/monkey/common/agent_configuration/default_agent_configuration.py +++ b/monkey/common/agent_configuration/default_agent_configuration.py @@ -35,6 +35,7 @@ CREDENTIAL_COLLECTOR_CONFIGURATION = tuple( RANSOMWARE_OPTIONS = { "encryption": { "enabled": True, + "file_extension": ".m0nk3y", "directories": {"linux_target_dir": "", "windows_target_dir": ""}, }, "other_behaviors": {"readme": True}, diff --git a/monkey/infection_monkey/payload/ransomware/ransomware_builder.py b/monkey/infection_monkey/payload/ransomware/ransomware_builder.py index 4b8bbc8bb..e2f3b87eb 100644 --- a/monkey/infection_monkey/payload/ransomware/ransomware_builder.py +++ b/monkey/infection_monkey/payload/ransomware/ransomware_builder.py @@ -16,7 +16,6 @@ from .ransomware import Ransomware from .ransomware_options import RansomwareOptions from .targeted_file_extensions import TARGETED_FILE_EXTENSIONS -EXTENSION = ".m0nk3y" CHUNK_SIZE = 4096 * 24 logger = logging.getLogger(__name__) @@ -26,8 +25,8 @@ def build_ransomware(options: dict): logger.debug(f"Ransomware configuration:\n{pformat(options)}") ransomware_options = RansomwareOptions(options) - file_encryptor = _build_file_encryptor() - file_selector = _build_file_selector() + file_encryptor = _build_file_encryptor(ransomware_options.file_extension) + file_selector = _build_file_selector(ransomware_options.file_extension) leave_readme = _build_leave_readme() telemetry_messenger = _build_telemetry_messenger() @@ -40,15 +39,16 @@ def build_ransomware(options: dict): ) -def _build_file_encryptor(): +def _build_file_encryptor(file_extension: str): return InPlaceFileEncryptor( - encrypt_bytes=flip_bits, new_file_extension=EXTENSION, chunk_size=CHUNK_SIZE + encrypt_bytes=flip_bits, new_file_extension=file_extension, chunk_size=CHUNK_SIZE ) -def _build_file_selector(): +def _build_file_selector(file_extension: str): targeted_file_extensions = TARGETED_FILE_EXTENSIONS.copy() - targeted_file_extensions.discard(EXTENSION) + if file_extension: + targeted_file_extensions.discard(file_extension) return ProductionSafeTargetFileSelector(targeted_file_extensions) diff --git a/monkey/infection_monkey/payload/ransomware/ransomware_options.py b/monkey/infection_monkey/payload/ransomware/ransomware_options.py index 8416f8465..505974ae0 100644 --- a/monkey/infection_monkey/payload/ransomware/ransomware_options.py +++ b/monkey/infection_monkey/payload/ransomware/ransomware_options.py @@ -9,6 +9,7 @@ logger = logging.getLogger(__name__) class RansomwareOptions: def __init__(self, options: dict): self.encryption_enabled = options["encryption"]["enabled"] + self.file_extension = options["encryption"]["file_extension"] self.readme_enabled = options["other_behaviors"]["readme"] self.target_directory = None diff --git a/monkey/monkey_island/cc/ui/src/components/configuration-components/UiSchema.js b/monkey/monkey_island/cc/ui/src/components/configuration-components/UiSchema.js index 7fd4cebfd..67035f1f4 100644 --- a/monkey/monkey_island/cc/ui/src/components/configuration-components/UiSchema.js +++ b/monkey/monkey_island/cc/ui/src/components/configuration-components/UiSchema.js @@ -2,7 +2,7 @@ import AdvancedMultiSelect from '../ui-components/AdvancedMultiSelect'; import InfoBox from './InfoBox'; import TextBox from './TextBox.js'; import PbaInput from './PbaInput'; -import {API_PBA_LINUX, API_PBA_WINDOWS} from '../pages/ConfigurePage'; +import { API_PBA_LINUX, API_PBA_WINDOWS } from '../pages/ConfigurePage'; import SensitiveTextInput from '../ui-components/SensitiveTextInput'; export default function UiSchema(props) { @@ -45,13 +45,13 @@ export default function UiSchema(props) { 'ui:widget': SensitiveTextInput } }, - exploit_lm_hash_list:{ + exploit_lm_hash_list: { items: { classNames: 'config-template-no-header', 'ui:widget': SensitiveTextInput } }, - exploit_ntlm_hash_list:{ + exploit_ntlm_hash_list: { items: { classNames: 'config-template-no-header', 'ui:widget': SensitiveTextInput @@ -82,11 +82,11 @@ export default function UiSchema(props) { tcp: { ports: { items: { - classNames: 'config-template-no-header' + classNames: 'config-template-no-header' } } }, - fingerprinters:{ + fingerprinters: { classNames: 'config-template-no-header', 'ui:widget': AdvancedMultiSelect, fingerprinter_classes: { @@ -99,9 +99,12 @@ export default function UiSchema(props) { payloads: { classNames: 'config-template-no-header', encryption: { - info_box : { + info_box: { 'ui:field': InfoBox }, + file_extension: { + 'ui:emptyValue': '' + }, directories: { // Directory inputs are dynamically hidden }, @@ -112,7 +115,7 @@ export default function UiSchema(props) { 'ui:widget': 'hidden' } }, - other_behaviors : { + other_behaviors: { 'ui:widget': 'hidden' } }, diff --git a/monkey/monkey_island/cc/ui/src/components/configuration-components/ValidationErrorMessages.js b/monkey/monkey_island/cc/ui/src/components/configuration-components/ValidationErrorMessages.js index 3c7280f97..deae004c9 100644 --- a/monkey/monkey_island/cc/ui/src/components/configuration-components/ValidationErrorMessages.js +++ b/monkey/monkey_island/cc/ui/src/components/configuration-components/ValidationErrorMessages.js @@ -1,4 +1,4 @@ -import {IP, IP_RANGE, VALID_RANSOMWARE_TARGET_PATH_LINUX, VALID_RANSOMWARE_TARGET_PATH_WINDOWS} from './ValidationFormats'; +import { IP, IP_RANGE, VALID_FILE_EXTENSION, VALID_RANSOMWARE_TARGET_PATH_LINUX, VALID_RANSOMWARE_TARGET_PATH_WINDOWS } from './ValidationFormats'; let invalidDirMessage = 'Invalid directory. Path should be absolute or begin with an environment variable.'; @@ -10,6 +10,8 @@ export default function transformErrors(errors) { error.message = 'Invalid IP range, refer to description for valid examples.' } else if (error.name === 'format' && error.params.format === IP) { error.message = 'Invalid IP.' + } else if (error.name === 'format' && error.params.format === VALID_FILE_EXTENSION) { + error.message = 'Invalid file extension.' } else if (error.name === 'format' && error.params.format === VALID_RANSOMWARE_TARGET_PATH_LINUX) { error.message = invalidDirMessage } else if (error.name === 'format' && error.params.format === VALID_RANSOMWARE_TARGET_PATH_WINDOWS) { diff --git a/monkey/monkey_island/cc/ui/src/components/configuration-components/ValidationFormats.js b/monkey/monkey_island/cc/ui/src/components/configuration-components/ValidationFormats.js index 70d9f82fd..f6539e24a 100644 --- a/monkey/monkey_island/cc/ui/src/components/configuration-components/ValidationFormats.js +++ b/monkey/monkey_island/cc/ui/src/components/configuration-components/ValidationFormats.js @@ -2,6 +2,7 @@ const ipRegex = '((25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\\.){3}(25[0-5]|2[0-4][0 const cidrNotationRegex = '([0-9]|1[0-9]|2[0-9]|3[0-2])' const hostnameRegex = '^([A-Za-z0-9]*[A-Za-z]+[A-Za-z0-9]*.?)*([A-Za-z0-9]*[A-Za-z]+[A-Za-z0-9]*)$' +const fileExtensionRegex = /^(\.[A-Za-z0-9_]+)*$/ const linuxAbsolutePathRegex = /^\// // path starts with `/` const linuxPathStartsWithEnvVariableRegex = /^\$/ // path starts with `$` @@ -11,7 +12,7 @@ const linuxPathStartsWithTildeRegex = /^~/ // path starts with `~` const windowsAbsolutePathRegex = /^([A-Za-z]:(\\|\/))/ // path starts like `C:\` OR `C:/` const windowsEnvVarNonNumeric = '[A-Za-z#\\$\'\\(\\)\\*\\+,\\-\\.\\?@\\[\\]_`\\{\\}~ ]' const windowsPathStartsWithEnvVariableRegex = new RegExp( - `^%(${windowsEnvVarNonNumeric}+(${windowsEnvVarNonNumeric}|\\d)*)%` + `^%(${windowsEnvVarNonNumeric}+(${windowsEnvVarNonNumeric}|\\d)*)%` ) // path starts like `$` OR `%abc%` const windowsUncPathRegex = /^\\{2}/ // Path starts like `\\` const emptyRegex = /^$/ @@ -19,32 +20,34 @@ const emptyRegex = /^$/ export const IP_RANGE = 'ip-range'; export const IP = 'ip'; +export const VALID_FILE_EXTENSION = 'valid-file-extension' export const VALID_RANSOMWARE_TARGET_PATH_LINUX = 'valid-ransomware-target-path-linux' export const VALID_RANSOMWARE_TARGET_PATH_WINDOWS = 'valid-ransomware-target-path-windows' export const formValidationFormats = { [IP_RANGE]: buildIpRangeRegex(), [IP]: buildIpRegex(), + [VALID_FILE_EXTENSION]: fileExtensionRegex, [VALID_RANSOMWARE_TARGET_PATH_LINUX]: buildValidRansomwarePathLinuxRegex(), [VALID_RANSOMWARE_TARGET_PATH_WINDOWS]: buildValidRansomwarePathWindowsRegex() }; -function buildIpRangeRegex(){ +function buildIpRangeRegex() { return new RegExp([ - '^'+ipRegex+'$|', // Single: IP - '^'+ipRegex+'-'+ipRegex+'$|', // IP range: IP-IP - '^'+ipRegex+'/'+cidrNotationRegex+'$|', // IP range with cidr notation: IP/cidr + '^' + ipRegex + '$|', // Single: IP + '^' + ipRegex + '-' + ipRegex + '$|', // IP range: IP-IP + '^' + ipRegex + '/' + cidrNotationRegex + '$|', // IP range with cidr notation: IP/cidr hostnameRegex // Hostname: target.tg ].join('')) } -function buildIpRegex(){ - return new RegExp('^'+ipRegex+'$') +function buildIpRegex() { + return new RegExp('^' + ipRegex + '$') } function buildValidRansomwarePathLinuxRegex() { return new RegExp([ - emptyRegex.source, + emptyRegex.source, linuxAbsolutePathRegex.source, linuxPathStartsWithEnvVariableRegex.source, linuxPathStartsWithTildeRegex.source @@ -53,7 +56,7 @@ function buildValidRansomwarePathLinuxRegex() { function buildValidRansomwarePathWindowsRegex() { return new RegExp([ - emptyRegex.source, + emptyRegex.source, windowsAbsolutePathRegex.source, windowsPathStartsWithEnvVariableRegex.source, windowsUncPathRegex.source diff --git a/monkey/monkey_island/cc/ui/src/services/configuration/ransomware.js b/monkey/monkey_island/cc/ui/src/services/configuration/ransomware.js index ea0262b69..2e0d0b804 100644 --- a/monkey/monkey_island/cc/ui/src/services/configuration/ransomware.js +++ b/monkey/monkey_island/cc/ui/src/services/configuration/ransomware.js @@ -1,24 +1,32 @@ const RANSOMWARE_SCHEMA = { - 'title': 'Payloads', + 'title': 'Payloads', 'properties': { 'encryption': { 'title': 'Ransomware simulation', 'type': 'object', 'description': 'To simulate ransomware encryption, you\'ll need to provide Infection ' + - 'Monkey with files that it can safely encrypt. On each machine where you would like ' + - 'the ransomware simulation to run, create a directory and put some files in it.' + - '\n\nProvide the path to the directory that was created on each machine.', + 'Monkey with files that it can safely encrypt. On each machine where you would like ' + + 'the ransomware simulation to run, create a directory and put some files in it.' + + '\n\nProvide the path to the directory that was created on each machine.', 'properties': { 'enabled': { 'title': 'Encrypt files', 'type': 'boolean', 'default': true, 'description': 'Ransomware encryption will be simulated by flipping every bit ' + - 'in the files contained within the target directories.' + 'in the files contained within the target directories.' }, 'info_box': { 'info': 'No files will be encrypted if a directory is not specified or doesn\'t ' + - 'exist on a victim machine.' + 'exist on a victim machine.' + }, + 'file_extension': { + 'title': 'File extension', + 'type': 'string', + 'format': 'valid-file-extension', + 'default': '.m0nk3y', + 'description': 'The file extension that the Infection Monkey will use for the ' + + 'encrypted file.' }, 'directories': { 'title': 'Directories to encrypt', @@ -30,8 +38,8 @@ const RANSOMWARE_SCHEMA = { 'format': 'valid-ransomware-target-path-linux', 'default': '', 'description': 'A path to a directory on Linux systems that contains ' + - 'files that you will allow Infection Monkey to encrypt. If no ' + - 'directory is specified, no files will be encrypted.' + 'files that you will allow Infection Monkey to encrypt. If no ' + + 'directory is specified, no files will be encrypted.' }, 'windows_target_dir': { 'title': 'Windows target directory', @@ -39,8 +47,8 @@ const RANSOMWARE_SCHEMA = { 'format': 'valid-ransomware-target-path-windows', 'default': '', 'description': 'A path to a directory on Windows systems that contains ' + - 'files that you will allow Infection Monkey to encrypt. If no ' + - 'directory is specified, no files will be encrypted.' + 'files that you will allow Infection Monkey to encrypt. If no ' + + 'directory is specified, no files will be encrypted.' } } }, diff --git a/monkey/tests/conftest.py b/monkey/tests/conftest.py new file mode 100644 index 000000000..5e3a5ba13 --- /dev/null +++ b/monkey/tests/conftest.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture(params=[".m0nk3y", ".test", ""], ids=["monkeyext", "testext", "noext"]) +def ransomware_file_extension(request): + return request.param diff --git a/monkey/tests/integration_tests/infection_monkey/payload/ransomware/test_integrated_ransomware.py b/monkey/tests/integration_tests/infection_monkey/payload/ransomware/test_integrated_ransomware.py new file mode 100644 index 000000000..cfb41bf54 --- /dev/null +++ b/monkey/tests/integration_tests/infection_monkey/payload/ransomware/test_integrated_ransomware.py @@ -0,0 +1,29 @@ +import threading + +import pytest + +import infection_monkey.payload.ransomware.ransomware_builder as ransomware_builder +from monkey.common.agent_configuration.default_agent_configuration import RANSOMWARE_OPTIONS + + +@pytest.fixture +def ransomware_options_dict(ransomware_file_extension): + options = RANSOMWARE_OPTIONS + options["encryption"]["file_extension"] = ransomware_file_extension + return options + + +def test_uses_correct_extension(ransomware_options_dict, tmp_path, ransomware_file_extension): + target_dir = tmp_path + ransomware_directories = ransomware_options_dict["encryption"]["directories"] + ransomware_directories["linux_target_dir"] = target_dir + ransomware_directories["windows_target_dir"] = target_dir + ransomware = ransomware_builder.build_ransomware(ransomware_options_dict) + file = target_dir / "file.txt" + file.write_text("Do your worst!") + + ransomware.run(threading.Event()) + + # Verify that the file has been encrypted with the correct ending + encrypted_file = file.with_suffix(file.suffix + ransomware_file_extension) + assert encrypted_file.is_file() diff --git a/monkey/tests/unit_tests/infection_monkey/payload/ransomware/test_ransomware.py b/monkey/tests/unit_tests/infection_monkey/payload/ransomware/test_ransomware.py index 88f37037c..b651dd012 100644 --- a/monkey/tests/unit_tests/infection_monkey/payload/ransomware/test_ransomware.py +++ b/monkey/tests/unit_tests/infection_monkey/payload/ransomware/test_ransomware.py @@ -41,14 +41,15 @@ def build_ransomware( @pytest.fixture -def ransomware_options(ransomware_test_data): +def ransomware_options(ransomware_file_extension, ransomware_test_data): class RansomwareOptionsStub(RansomwareOptions): - def __init__(self, encryption_enabled, readme_enabled, target_directory): + def __init__(self, encryption_enabled, readme_enabled, file_extension, target_directory): self.encryption_enabled = encryption_enabled self.readme_enabled = readme_enabled + self.file_extension = file_extension self.target_directory = target_directory - return RansomwareOptionsStub(True, False, ransomware_test_data) + return RansomwareOptionsStub(True, False, ransomware_file_extension, ransomware_test_data) @pytest.fixture diff --git a/monkey/tests/unit_tests/infection_monkey/payload/ransomware/test_ransomware_options.py b/monkey/tests/unit_tests/infection_monkey/payload/ransomware/test_ransomware_options.py index f2b6a8c8c..ae7280a86 100644 --- a/monkey/tests/unit_tests/infection_monkey/payload/ransomware/test_ransomware_options.py +++ b/monkey/tests/unit_tests/infection_monkey/payload/ransomware/test_ransomware_options.py @@ -7,6 +7,7 @@ from common.utils.file_utils import InvalidPath from infection_monkey.payload.ransomware import ransomware_options from infection_monkey.payload.ransomware.ransomware_options import RansomwareOptions +EXTENSION = ".testext" LINUX_DIR = "/tmp/test" WINDOWS_DIR = "C:\\tmp\\test" @@ -16,6 +17,7 @@ def options_from_island(): return { "encryption": { "enabled": None, + "file_extension": EXTENSION, "directories": { "linux_target_dir": LINUX_DIR, "windows_target_dir": WINDOWS_DIR, @@ -41,6 +43,12 @@ def test_readme_enabled(enabled, options_from_island): assert options.readme_enabled == enabled +def test_file_extension(options_from_island): + options = RansomwareOptions(options_from_island) + + assert options.file_extension == EXTENSION + + def test_linux_target_dir(monkeypatch, options_from_island): monkeypatch.setattr(ransomware_options, "is_windows_os", lambda: False) diff --git a/pyproject.toml b/pyproject.toml index 5f8e07b67..00ae4601e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -20,7 +20,7 @@ log_cli = 1 log_cli_level = "DEBUG" log_cli_format = "%(asctime)s [%(levelname)s] %(module)s.%(funcName)s.%(lineno)d: %(message)s" log_cli_date_format = "%H:%M:%S" -addopts = "-v --capture=sys tests/unit_tests" +addopts = "-v --capture=sys tests/unit_tests tests/integration_tests" norecursedirs = "node_modules dist" markers = ["slow: mark test as slow"] pythonpath = "./monkey"