From 2fd29fc9db6f45bbcc376d6fb049b86c3c2646db Mon Sep 17 00:00:00 2001 From: Max Groot <19346100+MaxGroot@users.noreply.github.com> Date: Fri, 2 Dec 2022 12:28:31 +0100 Subject: [PATCH 01/10] Add Defender Quarantine Recovery Initial version, requires additional testing. (DIS-1573) --- dissect/target/plugins/os/windows/defender.py | 388 +++++++++++++++++- .../{800362A7-0000-0000-FB11-12639186E0D6} | Bin 0 -> 442 bytes .../A6C8322B8A19AEED96EFBD045206966DA4C9619D | Bin 0 -> 37802 bytes .../A6C8322B8A19AEED96EFBD045206966DA4C9619D | Bin 0 -> 108 bytes tests/test_plugins_os_windows_defender.py | 95 +++++ 5 files changed, 478 insertions(+), 5 deletions(-) create mode 100644 tests/data/defender-quarantine/Entries/{800362A7-0000-0000-FB11-12639186E0D6} create mode 100644 tests/data/defender-quarantine/ResourceData/A6/A6C8322B8A19AEED96EFBD045206966DA4C9619D create mode 100644 tests/data/defender-quarantine/Resources/A6/A6C8322B8A19AEED96EFBD045206966DA4C9619D diff --git a/dissect/target/plugins/os/windows/defender.py b/dissect/target/plugins/os/windows/defender.py index 0a93384c9..224a6d43e 100644 --- a/dissect/target/plugins/os/windows/defender.py +++ b/dissect/target/plugins/os/windows/defender.py @@ -1,10 +1,13 @@ -from typing import Generator, Iterable, Any from datetime import datetime, timezone +from io import BytesIO +from pathlib import Path +from typing import Any, Generator, Iterable, Iterator -from flow.record import Record - +import dissect.util.ts as ts +from dissect.cstruct import Structure, cstruct from dissect.target import plugin from dissect.target.helpers.record import TargetRecordDescriptor +from flow.record import Record DEFENDER_EVTX_FIELDS = [ ("uint32", "EventID"), @@ -62,7 +65,6 @@ ("string", "Version"), ] - DefenderLogRecordDescriptor = TargetRecordDescriptor( "filesystem/windows/defender/evtx", [("datetime", "ts")] + DEFENDER_EVTX_FIELDS, @@ -74,13 +76,321 @@ EVTX_PROVIDER_NAME = "Microsoft-Windows-Windows Defender" +defender_def = """ + +/* ======== Generic Windows ======== */ +/* https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-win32_stream_id */ + +enum STREAM_ID { + DATA = 0x00000001, + EA_DATA = 0x00000002, + SECURITY_DATA = 0x00000003, + ALTERNATE_DATA = 0x00000004, + LINK = 0x00000005, + PROPERTY_DATA = 0x00000006, + OBJECT_ID = 0x00000007, + REPARSE_DATA = 0x00000008, + SPARSE_BLOCK = 0x00000009, + TXFS_DATA = 0x0000000A, + GHOSTED_FILE_EXTENTS = 0x0000000B, +}; + +flag STREAM_ATTRIBUTES { + STREAM_NORMAL_ATTRIBUTE = 0x00000000, + STREAM_MODIFIED_WHEN_READ = 0x00000001, + STREAM_CONTAINS_SECURITY = 0x00000002, + STREAM_CONTAINS_PROPERTIES = 0x00000004, + STREAM_SPARSE_ATTRIBUTE = 0x00000008, + STREAM_CONTAINS_GHOSTED_FILE_EXTENTS = 0x00000010, +}; + +typedef struct _WIN32_STREAM_ID { + STREAM_ID StreamId; + STREAM_ATTRIBUTES StreamAttributes; + QWORD Size; + DWORD StreamNameSize; + WCHAR StreamName[StreamNameSize / 2]; +} WIN32_STREAM_ID; + +/* ======== Defender Specific ======== */ + +enum FIELD_IDENTIFIER : WORD { + CQuaResDataID_File = 0x02, + CQuaResDataID_Registry = 0x03, + Flags = 0x0A, + PhysicalPath = 0x0C, + DetectionContext = 0x0D, + Unknown = 0x0E, + CreationTime = 0x0F, + LastAccessTime = 0x10, + LastWriteTime = 0x11 +}; + +enum FIELD_TYPE : WORD { + STRING = 0x1, + WSTRING = 0x2, + DWORD = 0x3, + RESOURCE_DATA = 0x4, + BYTES = 0x5, + QWORD = 0x6, +}; + +struct QuarantineEntryFileHeader { + char magic_header[4]; + char unknown[4]; + char null_padding[32]; + uint32_t Section1Size; + uint32_t Section2Size; + char Section1CrC[4]; + char Section2CrC[4]; + char magic_padding[4]; +}; + +struct QuarantineEntrySection1 { + CHAR Id[16]; + CHAR ScanId[16]; + QWORD Timestamp; + QWORD ThreatId; + DWORD One; + CHAR DetectionName[]; +}; + +struct QuarantineEntrySection2 { + DWORD EntryCount; + DWORD EntryOffsets[EntryCount]; +}; + +struct QuarantineEntryResource { + WCHAR DetectionPath[]; + WORD FieldCount; + CHAR DetectionType[]; +}; + +struct QuarantineEntryResourceField { + WORD Size; + WORD Identifier:12; + FIELD_TYPE Type:4; + CHAR Data[Size]; +}; +""" + + +c_defender = cstruct() +c_defender.load(defender_def) + + +STREAM_ID = c_defender.STREAM_ID +STREAM_ATTRIBUTES = c_defender.STREAM_ATTRIBUTES + +DEFENDER_QUARANTINE_FOLDER_PATH = "sysvol/programdata/microsoft/windows defender/quarantine" +QUARANTINE_ENTRIES_FOLDER_NAME = "Entries" +QUARANTINE_RESOURCEDATA_FOLDER_NAME = "ResourceData" + +DefenderFileQuarantineRecordDescriptor = TargetRecordDescriptor( + "filesystem/windows/defender/quarantine/file", + [ + ("datetime", "ts"), + ("bytes", "quarantine_id"), + ("bytes", "scan_id"), + ("varint", "threat_id"), + ("string", "detection_type"), + ("string", "detection_name"), + ("string", "detection_path"), + ("datetime", "creation_time"), + ("datetime", "last_write_time"), + ("datetime", "last_accessed_time"), + ("string", "resource_id"), + ], +) + +DefenderBehaviorQuarantineRecordDescriptor = TargetRecordDescriptor( + "filesystem/windows/defender/quarantine/behavior", + [ + ("datetime", "ts"), + ("bytes", "quarantine_id"), + ("bytes", "scan_id"), + ("varint", "threat_id"), + ("string", "detection_type"), + ("string", "detection_name"), + ], +) + + +class DefenderQuarantineError(Exception): + pass + + +class DefenderQuarantineFieldError(DefenderQuarantineError): + pass + + +class QuarantineEntry: + def __init__(self, section_1: Structure) -> None: + self.timestamp = ts.wintimestamp(section_1.Timestamp) + self.quarantine_id = section_1.Id + self.scan_id = section_1.ScanId + self.threat_id = section_1.ThreatId + self.detection_name = section_1.DetectionName + + +class QuarantineEntryResource: + def __init__(self, quarantine_entry: QuarantineEntry, quarantine_resource: Structure) -> None: + self.entry = quarantine_entry + self.detection_path = quarantine_resource.DetectionPath + self.field_count = quarantine_resource.FieldCount + self.detection_type = quarantine_resource.DetectionType + + def add_field(self, field: Structure): + if field.Identifier == c_defender.FIELD_IDENTIFIER.CQuaResDataID_File: + self.resource_id = field.Data.hex().upper() + elif field.Identifier == c_defender.FIELD_IDENTIFIER.PhysicalPath: + # Decoding as utf-16 leaves a null-byte that we have to strip off. + self.detection_path = field.Data.decode("utf-16").rstrip("\x00") + elif field.Identifier == c_defender.FIELD_IDENTIFIER.CreationTime: + self.creation_time = ts.wintimestamp(int.from_bytes(field.Data, "little")) + elif field.Identifier == c_defender.FIELD_IDENTIFIER.LastAccessTime: + self.last_access_time = ts.wintimestamp(int.from_bytes(field.Data, "little")) + elif field.Identifier == c_defender.FIELD_IDENTIFIER.LastWriteTime: + self.last_write_time = ts.wintimestamp(int.from_bytes(field.Data, "little")) + elif field.Identifier not in c_defender.FIELD_IDENTIFIER.values.values(): + raise DefenderQuarantineFieldError(f"Encountered an unknown identifier: {field.Identifier}") + + +# fmt: off +DEFENDER_RC4_KEY = [ + 0x1E, 0x87, 0x78, 0x1B, 0x8D, 0xBA, 0xA8, 0x44, 0xCE, 0x69, 0x70, 0x2C, 0x0C, 0x78, 0xB7, 0x86, 0xA3, 0xF6, 0x23, + 0xB7, 0x38, 0xF5, 0xED, 0xF9, 0xAF, 0x83, 0x53, 0x0F, 0xB3, 0xFC, 0x54, 0xFA, 0xA2, 0x1E, 0xB9, 0xCF, 0x13, 0x31, + 0xFD, 0x0F, 0x0D, 0xA9, 0x54, 0xF6, 0x87, 0xCB, 0x9E, 0x18, 0x27, 0x96, 0x97, 0x90, 0x0E, 0x53, 0xFB, 0x31, 0x7C, + 0x9C, 0xBC, 0xE4, 0x8E, 0x23, 0xD0, 0x53, 0x71, 0xEC, 0xC1, 0x59, 0x51, 0xB8, 0xF3, 0x64, 0x9D, 0x7C, 0xA3, 0x3E, + 0xD6, 0x8D, 0xC9, 0x04, 0x7E, 0x82, 0xC9, 0xBA, 0xAD, 0x97, 0x99, 0xD0, 0xD4, 0x58, 0xCB, 0x84, 0x7C, 0xA9, 0xFF, + 0xBE, 0x3C, 0x8A, 0x77, 0x52, 0x33, 0x55, 0x7D, 0xDE, 0x13, 0xA8, 0xB1, 0x40, 0x87, 0xCC, 0x1B, 0xC8, 0xF1, 0x0F, + 0x6E, 0xCD, 0xD0, 0x83, 0xA9, 0x59, 0xCF, 0xF8, 0x4A, 0x9D, 0x1D, 0x50, 0x75, 0x5E, 0x3E, 0x19, 0x18, 0x18, 0xAF, + 0x23, 0xE2, 0x29, 0x35, 0x58, 0x76, 0x6D, 0x2C, 0x07, 0xE2, 0x57, 0x12, 0xB2, 0xCA, 0x0B, 0x53, 0x5E, 0xD8, 0xF6, + 0xC5, 0x6C, 0xE7, 0x3D, 0x24, 0xBD, 0xD0, 0x29, 0x17, 0x71, 0x86, 0x1A, 0x54, 0xB4, 0xC2, 0x85, 0xA9, 0xA3, 0xDB, + 0x7A, 0xCA, 0x6D, 0x22, 0x4A, 0xEA, 0xCD, 0x62, 0x1D, 0xB9, 0xF2, 0xA2, 0x2E, 0xD1, 0xE9, 0xE1, 0x1D, 0x75, 0xBE, + 0xD7, 0xDC, 0x0E, 0xCB, 0x0A, 0x8E, 0x68, 0xA2, 0xFF, 0x12, 0x63, 0x40, 0x8D, 0xC8, 0x08, 0xDF, 0xFD, 0x16, 0x4B, + 0x11, 0x67, 0x74, 0xCD, 0x0B, 0x9B, 0x8D, 0x05, 0x41, 0x1E, 0xD6, 0x26, 0x2E, 0x42, 0x9B, 0xA4, 0x95, 0x67, 0x6B, + 0x83, 0x98, 0xDB, 0x2F, 0x35, 0xD3, 0xC1, 0xB9, 0xCE, 0xD5, 0x26, 0x36, 0xF2, 0x76, 0x5E, 0x1A, 0x95, 0xCB, 0x7C, + 0xA4, 0xC3, 0xDD, 0xAB, 0xDD, 0xBF, 0xF3, 0x82, 0x53 +] +# fmt: on + + +def rc4_decrypt_defender_data(data): + sbox = list(range(256)) + j = 0 + for i in range(256): + j = (j + sbox[i] + DEFENDER_RC4_KEY[i]) % 256 + tmp = sbox[i] + sbox[i] = sbox[j] + sbox[j] = tmp + + out = bytearray(len(data)) + i = 0 + j = 0 + for k in range(len(data)): + i = (i + 1) % 256 + j = (j + sbox[i]) % 256 + tmp = sbox[i] + sbox[i] = sbox[j] + sbox[j] = tmp + val = sbox[(sbox[i] + sbox[j]) % 256] + out[k] = val ^ data[k] + + return out + + +def recover_quarantined_file(handle, filename: str) -> Iterator[tuple[str, bytes]]: + buf = handle.read() + buf = rc4_decrypt_defender_data(buf) + buf = BytesIO(buf) + + while True: + try: + stream = c_defender.WIN32_STREAM_ID(buf) + except EOFError: + break + data = buf.read(stream.Size) + if stream.StreamId == STREAM_ID.SECURITY_DATA: + yield (f"{filename}.security_descriptor", data) + elif stream.StreamId == STREAM_ID.DATA: + yield (filename, data) + elif stream.StreamId == STREAM_ID.ALTERNATE_DATA: + sanitized_stream_name = "".join(x for x in stream.StreamName if x.isalnum()) + yield (f"{filename}.{sanitized_stream_name}", data) + else: + raise DefenderQuarantineError(f"Unexpected Stream ID {stream.StreamId}") + + class MicrosoftDefenderPlugin(plugin.Plugin): """Plugin that parses artifacts created by Microsoft Defender""" __namespace__ = "defender" def check_compatible(self): - return self.target.fs.path(DEFENDER_LOG_DIR).exists() + # Either the defender log folder or the quarantine folder has to exist. + return any( + [ + self.target.fs.path(DEFENDER_LOG_DIR).exists(), + self.target.fs.path(DEFENDER_QUARANTINE_FOLDER_PATH).exists(), + ] + ) + + def get_quarantined_entry_resources(self) -> Iterator[QuarantineEntryResource]: + quarantine_directory = self.target.fs.path(DEFENDER_QUARANTINE_FOLDER_PATH) + entries_directory = quarantine_directory.joinpath(QUARANTINE_ENTRIES_FOLDER_NAME) + + if entries_directory.exists() and entries_directory.is_dir(): + for guid_path in entries_directory.glob("*"): + handle = guid_path.open() + try: + # Decrypt & Parse the header so that we know the section sizes + entry_header_buf = rc4_decrypt_defender_data(handle.read(60)) + entry_header = c_defender.QuarantineEntryFileHeader(entry_header_buf) + + # Decrypt & Parse the Quarantine Entry. However, it is not yet a Quarantine Entry Resource. + section_1_buf = rc4_decrypt_defender_data(handle.read(entry_header.Section1Size)) + section_1 = c_defender.QuarantineEntrySection1(section_1_buf) + quarantine_entry = QuarantineEntry(section_1) + + # Section 2 contains the number of quarantine entry resources contained in this quarantine entry, + # as well as their offsets + section_2_buf = rc4_decrypt_defender_data(handle.read(entry_header.Section2Size)) + section_2 = c_defender.QuarantineEntrySection2(section_2_buf) + + # Enumerate all quarantine entry resources contained within this quarantine entry. + for _, offset in enumerate(section_2.EntryOffsets): + + # Parse the Quarantine Entry Resource. + resource_buf = section_2_buf[offset:] + resource_structure = c_defender.QuarantineEntryResource(resource_buf) + quarantine_entry_resource = QuarantineEntryResource(quarantine_entry, resource_structure) + + # Move the pointer to where the fields of this quarantine entry will begin + offset += len(resource_structure) + + # As the fields are aligned, we need to parse them individually + for _ in range(quarantine_entry_resource.field_count): + # Align + offset = (offset + 3) & 0xFFFFFFFC + + # Parse + field = c_defender.QuarantineEntryResourceField(section_2_buf[offset:]) + try: + quarantine_entry_resource.add_field(field) + except DefenderQuarantineFieldError as e: + # If we encounter a fied that we do not know yet, raise a warning but continue parsing + # the entry. + self.target.log.warning(str(e)) + + # Move pointer + offset += 4 + field.Size + + # Now that the fields have been added to the quarantine entry resource, we can yield it. + yield quarantine_entry_resource + except DefenderQuarantineError as e: + self.target.log.warning(str(e)) + handle.close() @plugin.export(record=DefenderLogRecordDescriptor) def evtx(self) -> Generator[Record, None, None]: @@ -108,6 +418,74 @@ def evtx(self) -> Generator[Record, None, None]: yield DefenderLogRecordDescriptor(**record_fields, _target=self.target) + @plugin.export(record=DefenderFileQuarantineRecordDescriptor) + def quarantine(self) -> Generator[Record, None, None]: + for resource in self.get_quarantined_entry_resources(): + # These fields are present for both behavior and file based detections + fields = { + "ts": resource.entry.timestamp, + "quarantine_id": resource.entry.quarantine_id, + "scan_id": resource.entry.scan_id, + "threat_id": resource.entry.threat_id, + "detection_type": resource.detection_type, + "detection_name": resource.entry.detection_name, + } + if resource.detection_type == b"internalbehavior": + yield DefenderBehaviorQuarantineRecordDescriptor(**fields, _target=self.target) + elif resource.detection_type == b"file": + # These fields are only available for filee based detections + fields.update( + { + "detection_path": resource.detection_path, + "creation_time": resource.creation_time, + "last_write_time": resource.last_write_time, + "last_accessed_time": resource.last_access_time, + "resource_id": resource.resource_id, + } + ) + yield DefenderFileQuarantineRecordDescriptor(**fields, _target=self.target) + else: + self.target.log.warning(f"Unknown Defender Detection Type {self.detection_type}") + + @plugin.arg( + "--output", + "-o", + dest="output_dir", + type=Path, + required=True, + help="Path to recover quarantined file to.", + ) + @plugin.export(output="none") + def recover(self, output_dir: Path) -> None: + if not output_dir.exists(): + raise ValueError("Output directory does not exist.") + quarantine_directory = self.target.fs.path(DEFENDER_QUARANTINE_FOLDER_PATH) + resourcedata_directory = quarantine_directory.joinpath(QUARANTINE_RESOURCEDATA_FOLDER_NAME) + if resourcedata_directory.exists() and resourcedata_directory.is_dir(): + recovered_files = [] + for entry in self.get_quarantined_entry_resources(): + if entry.detection_type != b"file": + continue + location = resourcedata_directory.joinpath(entry.resource_id[0:2]).joinpath(entry.resource_id) + if not location.exists(): + self.target.log.info(f"Could not find a ResourceData file for {entry.resource_id}.") + continue + if location in recovered_files: + continue + fh = location.open() + # TODO: What filename do we want for recovery? Detection path seems OK but what if different files + # have the same filename but are stored in different directories? Resource id seems most 'truthful' + # but might be confusing for analysts. + for dest_filename, dest_buf in recover_quarantined_file(fh, entry.resource_id): + output_filename = output_dir.joinpath(dest_filename) + self.target.log.info(f"Saving {output_filename}") + with open(output_filename, "wb") as output_file: + output_file.write(dest_buf) + fh.close() + + # Make sure we do not recover the same file multiple times if it has multiple entries + recovered_files.append(location) + def parse_iso_datetime(datetime_value: str) -> datetime: """Parse ISO8601 serialized datetime with `Z` ending""" diff --git a/tests/data/defender-quarantine/Entries/{800362A7-0000-0000-FB11-12639186E0D6} b/tests/data/defender-quarantine/Entries/{800362A7-0000-0000-FB11-12639186E0D6} new file mode 100644 index 0000000000000000000000000000000000000000..11e5c7c9ca4a34937c9b22f5177b13ce63b4e974 GIT binary patch literal 442 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 O{oZIf=!2vXd>lrHCp?1y literal 0 HcmV?d00001 diff --git a/tests/test_plugins_os_windows_defender.py b/tests/test_plugins_os_windows_defender.py index 9f1a2d122..bd383c746 100644 --- a/tests/test_plugins_os_windows_defender.py +++ b/tests/test_plugins_os_windows_defender.py @@ -1,3 +1,10 @@ +import os +from configparser import ConfigParser +from datetime import datetime +from io import BytesIO +from pathlib import Path + +from dissect.ntfs.secure import ACL, SecurityDescriptor from dissect.target.plugins.os.windows import defender from ._utils import absolute_path @@ -23,3 +30,91 @@ def test_defender_evtx_logs(target_win, fs_win, tmpdir_name): assert {r.Channel for r in records} == {"Microsoft-Windows-Windows Defender/Operational"} # Both informational records (no threat name) and detections are present assert {r.Threat_Name for r in records} == {None, "TrojanDropper:PowerShell/PowerSploit.S!MSR"} + + +def test_defender_quarantine_entries(target_win, fs_win, tmpdir_name): + + quarantine_dir = absolute_path("data/defender-quarantine") + + fs_win.map_dir("programdata/microsoft/windows defender/quarantine", quarantine_dir) + + target_win.add_plugin(defender.MicrosoftDefenderPlugin) + + records = list(target_win.defender.quarantine()) + + assert len(records) == 1 + + # Test whether the quarantining of a Cobalt Strike beacon is properly parsed. + mimikatz_record = records[0] + detection_date = datetime.strptime("2022-12-02", "%Y-%m-%d").date() + + assert mimikatz_record.detection_type == "file" + assert mimikatz_record.detection_name == "HackTool:Win64/Mikatz!dha" + assert mimikatz_record.detection_path == "C:\\Users\\user\\Downloads\\mimikatz\\mimilib.dll" + + assert mimikatz_record.ts.date() == detection_date + assert mimikatz_record.creation_time.date() == detection_date + assert mimikatz_record.last_write_time.date() == detection_date + assert mimikatz_record.last_accessed_time.date() == detection_date + + +def test_defender_quarantine_recovery(target_win, fs_win, tmpdir_name): + # Map the quarantine folder from our test data + quarantine_dir = absolute_path("data/defender-quarantine") + fs_win.map_dir("programdata/microsoft/windows defender/quarantine", quarantine_dir) + + # Create a directory to recover to + recovery_dst = Path(tmpdir_name).joinpath("recovery") + os.mkdir(recovery_dst) + + # Recover + target_win.add_plugin(defender.MicrosoftDefenderPlugin) + target_win.defender.recover(output_dir=recovery_dst) + + # Set up variables to indicate what we expect to find + payload_filename = "A6C8322B8A19AEED96EFBD045206966DA4C9619D" + security_descriptor_filename = "A6C8322B8A19AEED96EFBD045206966DA4C9619D.security_descriptor" + zone_identifier_filename = "A6C8322B8A19AEED96EFBD045206966DA4C9619D.ZoneIdentifierDATA" + expected_zone_identifier = { + "ZoneTransfer": {"zoneid": "3", "referrerurl": "C:\\Users\\user\\Downloads\\mimikatz_trunk.zip"} + } + expected_owner = "S-1-5-21-2614236324-1336345114-3023566343-1000" + expected_group = "S-1-5-21-2614236324-1336345114-3023566343-513" + + expected_files = [payload_filename, security_descriptor_filename, zone_identifier_filename] + expected_files.sort() + + directory_content = os.listdir(recovery_dst) + directory_content.sort() + assert expected_files == directory_content + + # Verify that the payloads are both properly restored by checking for the MZ header + with open(recovery_dst.joinpath(payload_filename), "rb") as payload_file: + header = payload_file.read(2) + assert header == b"MZ" + + # Verify that the security descriptors are valid security descriptors + with open(recovery_dst.joinpath(security_descriptor_filename), "rb") as descriptor_file: + descriptor_buf = descriptor_file.read() + descriptor = SecurityDescriptor(BytesIO(descriptor_buf)) + + assert isinstance(descriptor.dacl, ACL) + assert isinstance(descriptor.sacl, ACL) + + assert descriptor.owner == expected_owner + assert descriptor.group == expected_group + + # Verify valid zone identifier for mimikatz file + # First parse the recovered file as an INI + zone_identifier = ConfigParser() + zone_identifier.read(recovery_dst.joinpath(zone_identifier_filename)) + + # Verify that sections are correct + assert zone_identifier.sections() == list(expected_zone_identifier.keys()) + for section in expected_zone_identifier.keys(): + for option, value in expected_zone_identifier[section].items(): + # Verify that option exists for this section + assert option in zone_identifier.options(section) + + # Verify that option has the expected value + assert zone_identifier.get(section, option) == value From e6f627424d6e0555c3e28d41c07376c32dff6f57 Mon Sep 17 00:00:00 2001 From: Max Groot <19346100+MaxGroot@users.noreply.github.com> Date: Tue, 13 Dec 2022 11:52:30 +0100 Subject: [PATCH 02/10] Also recover when filename is partial resource_id Bugfix. Sometimes The filename is not the complete resource_id. --- dissect/target/plugins/os/windows/defender.py | 30 +++++++++++++++---- 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/dissect/target/plugins/os/windows/defender.py b/dissect/target/plugins/os/windows/defender.py index 224a6d43e..665d33ddc 100644 --- a/dissect/target/plugins/os/windows/defender.py +++ b/dissect/target/plugins/os/windows/defender.py @@ -466,13 +466,31 @@ def recover(self, output_dir: Path) -> None: for entry in self.get_quarantined_entry_resources(): if entry.detection_type != b"file": continue - location = resourcedata_directory.joinpath(entry.resource_id[0:2]).joinpath(entry.resource_id) - if not location.exists(): - self.target.log.info(f"Could not find a ResourceData file for {entry.resource_id}.") + subdirectory = resourcedata_directory.joinpath(entry.resource_id[0:2]) + if not subdirectory.exists(): + self.target.log.warning(f"Could not find a ResourceData subdirectory for {entry.resource_id}") continue - if location in recovered_files: + + resourcedata_location = None + + # Sometimes, the resourcedata file containing the quarantined file does not have the exact same name + # as the entry's resource_id. Instead, it only matches a part of the resource_id. What we do is loop + # over all files in the resourcedata subdirectory, and check whether we can find a filename that + # fully fits into the resource_id. If so, we assume that that is the matching file and break. + for possible_file in subdirectory.glob("*"): + _, _, filename = str(possible_file).rpartition("/") + if filename in entry.resource_id: + resourcedata_location = resourcedata_directory.joinpath(entry.resource_id[0:2]).joinpath( + filename + ) + break + if resourcedata_location is None: + self.target.log.warning(f"Could not find a ResourceData file for {entry.resource_id}.") + continue + if resourcedata_location in recovered_files: + # We already recovered this file continue - fh = location.open() + fh = resourcedata_location.open() # TODO: What filename do we want for recovery? Detection path seems OK but what if different files # have the same filename but are stored in different directories? Resource id seems most 'truthful' # but might be confusing for analysts. @@ -484,7 +502,7 @@ def recover(self, output_dir: Path) -> None: fh.close() # Make sure we do not recover the same file multiple times if it has multiple entries - recovered_files.append(location) + recovered_files.append(resourcedata_location) def parse_iso_datetime(datetime_value: str) -> datetime: From 443fab5d1e6739968db667dd211aa6dbe612bbef Mon Sep 17 00:00:00 2001 From: Max Groot <19346100+MaxGroot@users.noreply.github.com> Date: Fri, 16 Dec 2022 11:26:14 +0100 Subject: [PATCH 03/10] Apply first batch of suggestions from code review Co-authored-by: Erik Schamper <1254028+Schamper@users.noreply.github.com> --- dissect/target/plugins/os/windows/defender.py | 36 +++++++++---------- tests/test_plugins_os_windows_defender.py | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/dissect/target/plugins/os/windows/defender.py b/dissect/target/plugins/os/windows/defender.py index 665d33ddc..4dfc0f8c6 100644 --- a/dissect/target/plugins/os/windows/defender.py +++ b/dissect/target/plugins/os/windows/defender.py @@ -65,7 +65,7 @@ ("string", "Version"), ] -DefenderLogRecordDescriptor = TargetRecordDescriptor( +DefenderLogRecord = TargetRecordDescriptor( "filesystem/windows/defender/evtx", [("datetime", "ts")] + DEFENDER_EVTX_FIELDS, ) @@ -77,7 +77,6 @@ defender_def = """ - /* ======== Generic Windows ======== */ /* https://learn.microsoft.com/en-us/windows/win32/api/winbase/ns-winbase-win32_stream_id */ @@ -136,14 +135,14 @@ }; struct QuarantineEntryFileHeader { - char magic_header[4]; - char unknown[4]; - char null_padding[32]; - uint32_t Section1Size; - uint32_t Section2Size; - char Section1CrC[4]; - char Section2CrC[4]; - char magic_padding[4]; + CHAR MagicHeader[4]; + CHAR Unknown[4]; + CHAR _Padding[32]; + DWORD Section1Size; + DWORD Section2Size; + DWORD Section1CrC; + DWORD Section2CrC; + char MagicFooter[4]; }; struct QuarantineEntrySection1 { @@ -186,7 +185,7 @@ QUARANTINE_ENTRIES_FOLDER_NAME = "Entries" QUARANTINE_RESOURCEDATA_FOLDER_NAME = "ResourceData" -DefenderFileQuarantineRecordDescriptor = TargetRecordDescriptor( +DefenderFileQuarantineRecord = TargetRecordDescriptor( "filesystem/windows/defender/quarantine/file", [ ("datetime", "ts"), @@ -203,7 +202,7 @@ ], ) -DefenderBehaviorQuarantineRecordDescriptor = TargetRecordDescriptor( +DefenderBehaviorQuarantineRecord = TargetRecordDescriptor( "filesystem/windows/defender/quarantine/behavior", [ ("datetime", "ts"), @@ -276,7 +275,7 @@ def add_field(self, field: Structure): # fmt: on -def rc4_decrypt_defender_data(data): +def rc4_crypt(data): sbox = list(range(256)) j = 0 for i in range(256): @@ -297,7 +296,7 @@ def rc4_decrypt_defender_data(data): val = sbox[(sbox[i] + sbox[j]) % 256] out[k] = val ^ data[k] - return out + return bytes(out) def recover_quarantined_file(handle, filename: str) -> Iterator[tuple[str, bytes]]: @@ -340,8 +339,9 @@ def get_quarantined_entry_resources(self) -> Iterator[QuarantineEntryResource]: quarantine_directory = self.target.fs.path(DEFENDER_QUARANTINE_FOLDER_PATH) entries_directory = quarantine_directory.joinpath(QUARANTINE_ENTRIES_FOLDER_NAME) - if entries_directory.exists() and entries_directory.is_dir(): - for guid_path in entries_directory.glob("*"): + if not entries_directory.is_dir(): + return + for guid_path in entries_directory.iterdir(): handle = guid_path.open() try: # Decrypt & Parse the header so that we know the section sizes @@ -445,7 +445,7 @@ def quarantine(self) -> Generator[Record, None, None]: ) yield DefenderFileQuarantineRecordDescriptor(**fields, _target=self.target) else: - self.target.log.warning(f"Unknown Defender Detection Type {self.detection_type}") + self.target.log.warning("Unknown Defender Detection Type %s", self.detection_type) @plugin.arg( "--output", @@ -477,7 +477,7 @@ def recover(self, output_dir: Path) -> None: # as the entry's resource_id. Instead, it only matches a part of the resource_id. What we do is loop # over all files in the resourcedata subdirectory, and check whether we can find a filename that # fully fits into the resource_id. If so, we assume that that is the matching file and break. - for possible_file in subdirectory.glob("*"): + for possible_file in subdirectory.iterdir(): _, _, filename = str(possible_file).rpartition("/") if filename in entry.resource_id: resourcedata_location = resourcedata_directory.joinpath(entry.resource_id[0:2]).joinpath( diff --git a/tests/test_plugins_os_windows_defender.py b/tests/test_plugins_os_windows_defender.py index bd383c746..9ddacec9d 100644 --- a/tests/test_plugins_os_windows_defender.py +++ b/tests/test_plugins_os_windows_defender.py @@ -5,6 +5,7 @@ from pathlib import Path from dissect.ntfs.secure import ACL, SecurityDescriptor + from dissect.target.plugins.os.windows import defender from ._utils import absolute_path @@ -33,7 +34,6 @@ def test_defender_evtx_logs(target_win, fs_win, tmpdir_name): def test_defender_quarantine_entries(target_win, fs_win, tmpdir_name): - quarantine_dir = absolute_path("data/defender-quarantine") fs_win.map_dir("programdata/microsoft/windows defender/quarantine", quarantine_dir) From 764982c2d60cc1ed6c7525639eb346c11eda2848 Mon Sep 17 00:00:00 2001 From: Max Groot <19346100+MaxGroot@users.noreply.github.com> Date: Mon, 19 Dec 2022 13:07:53 +0100 Subject: [PATCH 04/10] Implement code review suggestions Refactor parsing into quarantine entry (resource) classes Move several constants around for better reading order Add doc strings --- dissect/target/plugins/os/windows/defender.py | 501 +++++++++--------- 1 file changed, 261 insertions(+), 240 deletions(-) diff --git a/dissect/target/plugins/os/windows/defender.py b/dissect/target/plugins/os/windows/defender.py index 4dfc0f8c6..f0aa8f23e 100644 --- a/dissect/target/plugins/os/windows/defender.py +++ b/dissect/target/plugins/os/windows/defender.py @@ -1,7 +1,7 @@ from datetime import datetime, timezone from io import BytesIO from pathlib import Path -from typing import Any, Generator, Iterable, Iterator +from typing import Any, BinaryIO, Generator, Iterable, Iterator import dissect.util.ts as ts from dissect.cstruct import Structure, cstruct @@ -65,16 +65,65 @@ ("string", "Version"), ] +DEFENDER_LOG_DIR = "sysvol/windows/system32/winevt/logs" +DEFENDER_LOG_FILENAME_GLOB = "Microsoft-Windows-Windows Defender*" +EVTX_PROVIDER_NAME = "Microsoft-Windows-Windows Defender" + +DEFENDER_QUARANTINE_DIR = "sysvol/programdata/microsoft/windows defender/quarantine" + DefenderLogRecord = TargetRecordDescriptor( "filesystem/windows/defender/evtx", [("datetime", "ts")] + DEFENDER_EVTX_FIELDS, ) -DEFENDER_LOG_DIR = "sysvol/windows/system32/winevt/logs" -DEFENDER_LOG_FILENAME_GLOB = "Microsoft-Windows-Windows Defender*" +DefenderFileQuarantineRecord = TargetRecordDescriptor( + "filesystem/windows/defender/quarantine/file", + [ + ("datetime", "ts"), + ("bytes", "quarantine_id"), + ("bytes", "scan_id"), + ("varint", "threat_id"), + ("string", "detection_type"), + ("string", "detection_name"), + ("string", "detection_path"), + ("datetime", "creation_time"), + ("datetime", "last_write_time"), + ("datetime", "last_accessed_time"), + ("string", "resource_id"), + ], +) -EVTX_PROVIDER_NAME = "Microsoft-Windows-Windows Defender" +DefenderBehaviorQuarantineRecord = TargetRecordDescriptor( + "filesystem/windows/defender/quarantine/behavior", + [ + ("datetime", "ts"), + ("bytes", "quarantine_id"), + ("bytes", "scan_id"), + ("varint", "threat_id"), + ("string", "detection_type"), + ("string", "detection_name"), + ], +) +# Source: https://github.com/brad-sp/cuckoo-modified/blob/master/lib/cuckoo/common/quarantine.py#L188 +# fmt: off +DEFENDER_QUARANTINE_RC4_KEY = [ + 0x1E, 0x87, 0x78, 0x1B, 0x8D, 0xBA, 0xA8, 0x44, 0xCE, 0x69, 0x70, 0x2C, 0x0C, 0x78, 0xB7, 0x86, 0xA3, 0xF6, 0x23, + 0xB7, 0x38, 0xF5, 0xED, 0xF9, 0xAF, 0x83, 0x53, 0x0F, 0xB3, 0xFC, 0x54, 0xFA, 0xA2, 0x1E, 0xB9, 0xCF, 0x13, 0x31, + 0xFD, 0x0F, 0x0D, 0xA9, 0x54, 0xF6, 0x87, 0xCB, 0x9E, 0x18, 0x27, 0x96, 0x97, 0x90, 0x0E, 0x53, 0xFB, 0x31, 0x7C, + 0x9C, 0xBC, 0xE4, 0x8E, 0x23, 0xD0, 0x53, 0x71, 0xEC, 0xC1, 0x59, 0x51, 0xB8, 0xF3, 0x64, 0x9D, 0x7C, 0xA3, 0x3E, + 0xD6, 0x8D, 0xC9, 0x04, 0x7E, 0x82, 0xC9, 0xBA, 0xAD, 0x97, 0x99, 0xD0, 0xD4, 0x58, 0xCB, 0x84, 0x7C, 0xA9, 0xFF, + 0xBE, 0x3C, 0x8A, 0x77, 0x52, 0x33, 0x55, 0x7D, 0xDE, 0x13, 0xA8, 0xB1, 0x40, 0x87, 0xCC, 0x1B, 0xC8, 0xF1, 0x0F, + 0x6E, 0xCD, 0xD0, 0x83, 0xA9, 0x59, 0xCF, 0xF8, 0x4A, 0x9D, 0x1D, 0x50, 0x75, 0x5E, 0x3E, 0x19, 0x18, 0x18, 0xAF, + 0x23, 0xE2, 0x29, 0x35, 0x58, 0x76, 0x6D, 0x2C, 0x07, 0xE2, 0x57, 0x12, 0xB2, 0xCA, 0x0B, 0x53, 0x5E, 0xD8, 0xF6, + 0xC5, 0x6C, 0xE7, 0x3D, 0x24, 0xBD, 0xD0, 0x29, 0x17, 0x71, 0x86, 0x1A, 0x54, 0xB4, 0xC2, 0x85, 0xA9, 0xA3, 0xDB, + 0x7A, 0xCA, 0x6D, 0x22, 0x4A, 0xEA, 0xCD, 0x62, 0x1D, 0xB9, 0xF2, 0xA2, 0x2E, 0xD1, 0xE9, 0xE1, 0x1D, 0x75, 0xBE, + 0xD7, 0xDC, 0x0E, 0xCB, 0x0A, 0x8E, 0x68, 0xA2, 0xFF, 0x12, 0x63, 0x40, 0x8D, 0xC8, 0x08, 0xDF, 0xFD, 0x16, 0x4B, + 0x11, 0x67, 0x74, 0xCD, 0x0B, 0x9B, 0x8D, 0x05, 0x41, 0x1E, 0xD6, 0x26, 0x2E, 0x42, 0x9B, 0xA4, 0x95, 0x67, 0x6B, + 0x83, 0x98, 0xDB, 0x2F, 0x35, 0xD3, 0xC1, 0xB9, 0xCE, 0xD5, 0x26, 0x36, 0xF2, 0x76, 0x5E, 0x1A, 0x95, 0xCB, 0x7C, + 0xA4, 0xC3, 0xDD, 0xAB, 0xDD, 0xBF, 0xF3, 0x82, 0x53 +] +# fmt: on defender_def = """ /* ======== Generic Windows ======== */ @@ -173,113 +222,39 @@ }; """ - c_defender = cstruct() c_defender.load(defender_def) - STREAM_ID = c_defender.STREAM_ID STREAM_ATTRIBUTES = c_defender.STREAM_ATTRIBUTES +FIELD_IDENTIFIER = c_defender.FIELD_IDENTIFIER -DEFENDER_QUARANTINE_FOLDER_PATH = "sysvol/programdata/microsoft/windows defender/quarantine" -QUARANTINE_ENTRIES_FOLDER_NAME = "Entries" -QUARANTINE_RESOURCEDATA_FOLDER_NAME = "ResourceData" - -DefenderFileQuarantineRecord = TargetRecordDescriptor( - "filesystem/windows/defender/quarantine/file", - [ - ("datetime", "ts"), - ("bytes", "quarantine_id"), - ("bytes", "scan_id"), - ("varint", "threat_id"), - ("string", "detection_type"), - ("string", "detection_name"), - ("string", "detection_path"), - ("datetime", "creation_time"), - ("datetime", "last_write_time"), - ("datetime", "last_accessed_time"), - ("string", "resource_id"), - ], -) - -DefenderBehaviorQuarantineRecord = TargetRecordDescriptor( - "filesystem/windows/defender/quarantine/behavior", - [ - ("datetime", "ts"), - ("bytes", "quarantine_id"), - ("bytes", "scan_id"), - ("varint", "threat_id"), - ("string", "detection_type"), - ("string", "detection_name"), - ], -) - - -class DefenderQuarantineError(Exception): - pass - - -class DefenderQuarantineFieldError(DefenderQuarantineError): - pass +def parse_iso_datetime(datetime_value: str) -> datetime: + """Parse ISO8601 serialized datetime with `Z` ending""" + return datetime.strptime(datetime_value, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc) -class QuarantineEntry: - def __init__(self, section_1: Structure) -> None: - self.timestamp = ts.wintimestamp(section_1.Timestamp) - self.quarantine_id = section_1.Id - self.scan_id = section_1.ScanId - self.threat_id = section_1.ThreatId - self.detection_name = section_1.DetectionName +def filter_records(records: Iterable, field_name: str, field_value: Any) -> Iterator[DefenderLogRecord]: + """ + Apply a filter on an Iterable of records, returning only records that have the given field value for the given + field name. + """ -class QuarantineEntryResource: - def __init__(self, quarantine_entry: QuarantineEntry, quarantine_resource: Structure) -> None: - self.entry = quarantine_entry - self.detection_path = quarantine_resource.DetectionPath - self.field_count = quarantine_resource.FieldCount - self.detection_type = quarantine_resource.DetectionType - - def add_field(self, field: Structure): - if field.Identifier == c_defender.FIELD_IDENTIFIER.CQuaResDataID_File: - self.resource_id = field.Data.hex().upper() - elif field.Identifier == c_defender.FIELD_IDENTIFIER.PhysicalPath: - # Decoding as utf-16 leaves a null-byte that we have to strip off. - self.detection_path = field.Data.decode("utf-16").rstrip("\x00") - elif field.Identifier == c_defender.FIELD_IDENTIFIER.CreationTime: - self.creation_time = ts.wintimestamp(int.from_bytes(field.Data, "little")) - elif field.Identifier == c_defender.FIELD_IDENTIFIER.LastAccessTime: - self.last_access_time = ts.wintimestamp(int.from_bytes(field.Data, "little")) - elif field.Identifier == c_defender.FIELD_IDENTIFIER.LastWriteTime: - self.last_write_time = ts.wintimestamp(int.from_bytes(field.Data, "little")) - elif field.Identifier not in c_defender.FIELD_IDENTIFIER.values.values(): - raise DefenderQuarantineFieldError(f"Encountered an unknown identifier: {field.Identifier}") - + def filter_func(record: Record) -> bool: + return hasattr(record, field_name) and getattr(record, field_name) == field_value -# fmt: off -DEFENDER_RC4_KEY = [ - 0x1E, 0x87, 0x78, 0x1B, 0x8D, 0xBA, 0xA8, 0x44, 0xCE, 0x69, 0x70, 0x2C, 0x0C, 0x78, 0xB7, 0x86, 0xA3, 0xF6, 0x23, - 0xB7, 0x38, 0xF5, 0xED, 0xF9, 0xAF, 0x83, 0x53, 0x0F, 0xB3, 0xFC, 0x54, 0xFA, 0xA2, 0x1E, 0xB9, 0xCF, 0x13, 0x31, - 0xFD, 0x0F, 0x0D, 0xA9, 0x54, 0xF6, 0x87, 0xCB, 0x9E, 0x18, 0x27, 0x96, 0x97, 0x90, 0x0E, 0x53, 0xFB, 0x31, 0x7C, - 0x9C, 0xBC, 0xE4, 0x8E, 0x23, 0xD0, 0x53, 0x71, 0xEC, 0xC1, 0x59, 0x51, 0xB8, 0xF3, 0x64, 0x9D, 0x7C, 0xA3, 0x3E, - 0xD6, 0x8D, 0xC9, 0x04, 0x7E, 0x82, 0xC9, 0xBA, 0xAD, 0x97, 0x99, 0xD0, 0xD4, 0x58, 0xCB, 0x84, 0x7C, 0xA9, 0xFF, - 0xBE, 0x3C, 0x8A, 0x77, 0x52, 0x33, 0x55, 0x7D, 0xDE, 0x13, 0xA8, 0xB1, 0x40, 0x87, 0xCC, 0x1B, 0xC8, 0xF1, 0x0F, - 0x6E, 0xCD, 0xD0, 0x83, 0xA9, 0x59, 0xCF, 0xF8, 0x4A, 0x9D, 0x1D, 0x50, 0x75, 0x5E, 0x3E, 0x19, 0x18, 0x18, 0xAF, - 0x23, 0xE2, 0x29, 0x35, 0x58, 0x76, 0x6D, 0x2C, 0x07, 0xE2, 0x57, 0x12, 0xB2, 0xCA, 0x0B, 0x53, 0x5E, 0xD8, 0xF6, - 0xC5, 0x6C, 0xE7, 0x3D, 0x24, 0xBD, 0xD0, 0x29, 0x17, 0x71, 0x86, 0x1A, 0x54, 0xB4, 0xC2, 0x85, 0xA9, 0xA3, 0xDB, - 0x7A, 0xCA, 0x6D, 0x22, 0x4A, 0xEA, 0xCD, 0x62, 0x1D, 0xB9, 0xF2, 0xA2, 0x2E, 0xD1, 0xE9, 0xE1, 0x1D, 0x75, 0xBE, - 0xD7, 0xDC, 0x0E, 0xCB, 0x0A, 0x8E, 0x68, 0xA2, 0xFF, 0x12, 0x63, 0x40, 0x8D, 0xC8, 0x08, 0xDF, 0xFD, 0x16, 0x4B, - 0x11, 0x67, 0x74, 0xCD, 0x0B, 0x9B, 0x8D, 0x05, 0x41, 0x1E, 0xD6, 0x26, 0x2E, 0x42, 0x9B, 0xA4, 0x95, 0x67, 0x6B, - 0x83, 0x98, 0xDB, 0x2F, 0x35, 0xD3, 0xC1, 0xB9, 0xCE, 0xD5, 0x26, 0x36, 0xF2, 0x76, 0x5E, 0x1A, 0x95, 0xCB, 0x7C, - 0xA4, 0xC3, 0xDD, 0xAB, 0xDD, 0xBF, 0xF3, 0x82, 0x53 -] -# fmt: on + return filter(filter_func, records) -def rc4_crypt(data): +def rc4_crypt(data) -> bytes: + """ + RC4 encrypt / decrypt using the Defender Quarantine RC4 Key + """ sbox = list(range(256)) j = 0 for i in range(256): - j = (j + sbox[i] + DEFENDER_RC4_KEY[i]) % 256 + j = (j + sbox[i] + DEFENDER_QUARANTINE_RC4_KEY[i]) % 256 tmp = sbox[i] sbox[i] = sbox[j] sbox[j] = tmp @@ -300,8 +275,13 @@ def rc4_crypt(data): def recover_quarantined_file(handle, filename: str) -> Iterator[tuple[str, bytes]]: + """ + For a given handle to a quarantined file, recover the various data streams present in the handle, yielding tuples + of the output filename and the corresponding output data. + """ + buf = handle.read() - buf = rc4_decrypt_defender_data(buf) + buf = rc4_crypt(buf) buf = BytesIO(buf) while True: @@ -318,81 +298,82 @@ def recover_quarantined_file(handle, filename: str) -> Iterator[tuple[str, bytes sanitized_stream_name = "".join(x for x in stream.StreamName if x.isalnum()) yield (f"{filename}.{sanitized_stream_name}", data) else: - raise DefenderQuarantineError(f"Unexpected Stream ID {stream.StreamId}") + raise ValueError(f"Unexpected Stream ID {stream.StreamId}") -class MicrosoftDefenderPlugin(plugin.Plugin): - """Plugin that parses artifacts created by Microsoft Defender""" +class QuarantineEntry: + def __init__(self, fh): + # Decrypt & Parse the header so that we know the section sizes + self.header = c_defender.QuarantineEntryFileHeader(rc4_crypt(fh.read(60))) - __namespace__ = "defender" + # TODO: This comment should be changed + # Decrypt & Parse the Quarantine Entry. However, it is not yet a Quarantine Entry Resource. + self.metadata = c_defender.QuarantineEntrySection1(rc4_crypt(fh.read(self.header.Section1Size))) - def check_compatible(self): - # Either the defender log folder or the quarantine folder has to exist. - return any( - [ - self.target.fs.path(DEFENDER_LOG_DIR).exists(), - self.target.fs.path(DEFENDER_QUARANTINE_FOLDER_PATH).exists(), - ] - ) + self.timestamp = ts.wintimestamp(self.metadata.Timestamp) + self.quarantine_id = self.metadata.Id + self.scan_id = self.metadata.ScanId + self.threat_id = self.metadata.ThreatId + self.detection_name = self.metadata.DetectionName - def get_quarantined_entry_resources(self) -> Iterator[QuarantineEntryResource]: - quarantine_directory = self.target.fs.path(DEFENDER_QUARANTINE_FOLDER_PATH) - entries_directory = quarantine_directory.joinpath(QUARANTINE_ENTRIES_FOLDER_NAME) + # The second section contains the number of quarantine entry resources contained in this quarantine entry, + # as well as their offsets. After that, the individal quarantine entry resources start. + resource_buf = BytesIO(rc4_crypt(fh.read(self.header.Section2Size))) + resource_info = c_defender.QuarantineEntrySection2(resource_buf) - if not entries_directory.is_dir(): - return - for guid_path in entries_directory.iterdir(): - handle = guid_path.open() - try: - # Decrypt & Parse the header so that we know the section sizes - entry_header_buf = rc4_decrypt_defender_data(handle.read(60)) - entry_header = c_defender.QuarantineEntryFileHeader(entry_header_buf) - - # Decrypt & Parse the Quarantine Entry. However, it is not yet a Quarantine Entry Resource. - section_1_buf = rc4_decrypt_defender_data(handle.read(entry_header.Section1Size)) - section_1 = c_defender.QuarantineEntrySection1(section_1_buf) - quarantine_entry = QuarantineEntry(section_1) - - # Section 2 contains the number of quarantine entry resources contained in this quarantine entry, - # as well as their offsets - section_2_buf = rc4_decrypt_defender_data(handle.read(entry_header.Section2Size)) - section_2 = c_defender.QuarantineEntrySection2(section_2_buf) - - # Enumerate all quarantine entry resources contained within this quarantine entry. - for _, offset in enumerate(section_2.EntryOffsets): - - # Parse the Quarantine Entry Resource. - resource_buf = section_2_buf[offset:] - resource_structure = c_defender.QuarantineEntryResource(resource_buf) - quarantine_entry_resource = QuarantineEntryResource(quarantine_entry, resource_structure) - - # Move the pointer to where the fields of this quarantine entry will begin - offset += len(resource_structure) - - # As the fields are aligned, we need to parse them individually - for _ in range(quarantine_entry_resource.field_count): - # Align - offset = (offset + 3) & 0xFFFFFFFC - - # Parse - field = c_defender.QuarantineEntryResourceField(section_2_buf[offset:]) - try: - quarantine_entry_resource.add_field(field) - except DefenderQuarantineFieldError as e: - # If we encounter a fied that we do not know yet, raise a warning but continue parsing - # the entry. - self.target.log.warning(str(e)) - - # Move pointer - offset += 4 + field.Size - - # Now that the fields have been added to the quarantine entry resource, we can yield it. - yield quarantine_entry_resource - except DefenderQuarantineError as e: - self.target.log.warning(str(e)) - handle.close() - - @plugin.export(record=DefenderLogRecordDescriptor) + # List holding all quarantine entry resources that belong to this quarantine entry. + self.resources = [] + + for offset in resource_info.EntryOffsets: + resource_buf.seek(offset) + self.resources.append(QuarantineEntryResource(resource_buf)) + + +class QuarantineEntryResource: + def __init__(self, fh: BinaryIO): + self.metadata = c_defender.QuarantineEntryResource(fh) + self.detection_path = self.metadata.DetectionPath + self.field_count = self.metadata.FieldCount + self.detection_type = self.metadata.DetectionType + + self.unknown_fields = [] + + # As the fields are aligned, we need to parse them individually + offset = fh.tell() + for _ in range(self.field_count): + # Align + offset = (offset + 3) & 0xFFFFFFFC + fh.seek(offset) + # Parse + field = c_defender.QuarantineEntryResourceField(fh) + self._add_field(field) + + # Move pointer + offset += 4 + field.Size + + def _add_field(self, field: Structure): + if field.Identifier == FIELD_IDENTIFIER.CQuaResDataID_File: + self.resource_id = field.Data.hex().upper() + elif field.Identifier == FIELD_IDENTIFIER.PhysicalPath: + # Decoding as utf-16 leaves a trailing null-byte that we have to strip off. + self.detection_path = field.Data.decode("utf-16").rstrip("\x00") + elif field.Identifier == FIELD_IDENTIFIER.CreationTime: + self.creation_time = ts.wintimestamp(int.from_bytes(field.Data, "little")) + elif field.Identifier == FIELD_IDENTIFIER.LastAccessTime: + self.last_access_time = ts.wintimestamp(int.from_bytes(field.Data, "little")) + elif field.Identifier == FIELD_IDENTIFIER.LastWriteTime: + self.last_write_time = ts.wintimestamp(int.from_bytes(field.Data, "little")) + elif field.Identifier not in FIELD_IDENTIFIER.values.values(): + self.unknown_fields.append(field) + + +class MicrosoftDefenderPlugin(plugin.Plugin): + """Plugin that parses artifacts created by Microsoft Defender. This includes the EVTX logs, as well as recovery + of artefacts from the quarantine folder.""" + + __namespace__ = "defender" + + @plugin.export(record=DefenderLogRecord) def evtx(self) -> Generator[Record, None, None]: """Parse Microsoft Defender evtx log files""" @@ -416,36 +397,43 @@ def evtx(self) -> Generator[Record, None, None]: record_fields[field_name] = value - yield DefenderLogRecordDescriptor(**record_fields, _target=self.target) + yield DefenderLogRecord(**record_fields, _target=self.target) - @plugin.export(record=DefenderFileQuarantineRecordDescriptor) + @plugin.export(record=DefenderFileQuarantineRecord) def quarantine(self) -> Generator[Record, None, None]: - for resource in self.get_quarantined_entry_resources(): + """ + Parse the quarantine folder of Microsoft Defender for quarantine entry resources. + + Quarantine entry resources contain metadata about detected threats that Microsoft Defender has placed in + quarantine. + """ + for quarantine_entry in self.get_quarantine_entries(): # These fields are present for both behavior and file based detections fields = { - "ts": resource.entry.timestamp, - "quarantine_id": resource.entry.quarantine_id, - "scan_id": resource.entry.scan_id, - "threat_id": resource.entry.threat_id, - "detection_type": resource.detection_type, - "detection_name": resource.entry.detection_name, + "ts": quarantine_entry.timestamp, + "quarantine_id": quarantine_entry.quarantine_id, + "scan_id": quarantine_entry.scan_id, + "threat_id": quarantine_entry.threat_id, + "detection_name": quarantine_entry.detection_name, } - if resource.detection_type == b"internalbehavior": - yield DefenderBehaviorQuarantineRecordDescriptor(**fields, _target=self.target) - elif resource.detection_type == b"file": - # These fields are only available for filee based detections - fields.update( - { - "detection_path": resource.detection_path, - "creation_time": resource.creation_time, - "last_write_time": resource.last_write_time, - "last_accessed_time": resource.last_access_time, - "resource_id": resource.resource_id, - } - ) - yield DefenderFileQuarantineRecordDescriptor(**fields, _target=self.target) - else: - self.target.log.warning("Unknown Defender Detection Type %s", self.detection_type) + for quarantine_entry_resource in quarantine_entry.resources: + fields.update({"detection_type": quarantine_entry_resource.detection_type}) + if quarantine_entry_resource.detection_type == b"internalbehavior": + yield DefenderBehaviorQuarantineRecord(**fields, _target=self.target) + elif quarantine_entry_resource.detection_type == b"file": + # These fields are only available for file based detections + fields.update( + { + "detection_path": quarantine_entry_resource.detection_path, + "creation_time": quarantine_entry_resource.creation_time, + "last_write_time": quarantine_entry_resource.last_write_time, + "last_accessed_time": quarantine_entry_resource.last_access_time, + "resource_id": quarantine_entry_resource.resource_id, + } + ) + yield DefenderFileQuarantineRecord(**fields, _target=self.target) + else: + self.target.log.warning("Unknown Defender Detection Type %s", self.detection_type) @plugin.arg( "--output", @@ -457,61 +445,94 @@ def quarantine(self) -> Generator[Record, None, None]: ) @plugin.export(output="none") def recover(self, output_dir: Path) -> None: + """ + Recovers files that have been placed into quarantine by Microsoft Defender. + + Microsoft Defender RC4 encrypts the output of the 'BackupRead' function when it places a file into quarantine. + This means multiple data streams can be contained in a single quarantined file, including zone identifier + information. + """ if not output_dir.exists(): raise ValueError("Output directory does not exist.") - quarantine_directory = self.target.fs.path(DEFENDER_QUARANTINE_FOLDER_PATH) - resourcedata_directory = quarantine_directory.joinpath(QUARANTINE_RESOURCEDATA_FOLDER_NAME) + quarantine_directory = self.target.fs.path(DEFENDER_QUARANTINE_DIR) + resourcedata_directory = quarantine_directory.joinpath("ResourceData") if resourcedata_directory.exists() and resourcedata_directory.is_dir(): recovered_files = [] - for entry in self.get_quarantined_entry_resources(): - if entry.detection_type != b"file": - continue - subdirectory = resourcedata_directory.joinpath(entry.resource_id[0:2]) - if not subdirectory.exists(): - self.target.log.warning(f"Could not find a ResourceData subdirectory for {entry.resource_id}") - continue - - resourcedata_location = None - - # Sometimes, the resourcedata file containing the quarantined file does not have the exact same name - # as the entry's resource_id. Instead, it only matches a part of the resource_id. What we do is loop - # over all files in the resourcedata subdirectory, and check whether we can find a filename that - # fully fits into the resource_id. If so, we assume that that is the matching file and break. - for possible_file in subdirectory.iterdir(): - _, _, filename = str(possible_file).rpartition("/") - if filename in entry.resource_id: - resourcedata_location = resourcedata_directory.joinpath(entry.resource_id[0:2]).joinpath( - filename + for entry in self.get_quarantine_entries(): + for entry_resource in entry.resources: + if entry_resource.detection_type != b"file": + # We can only recover file entries + continue + # First two letters of the resource ID is the subdirectory that will contain the quarantined file. + resource_id_key = entry_resource.resource_id[0:2] + subdirectory = resourcedata_directory.joinpath(resource_id_key) + if not subdirectory.exists(): + self.target.log.warning( + f"Could not find a ResourceData subdirectory for {entry_resource.resource_id}" ) - break - if resourcedata_location is None: - self.target.log.warning(f"Could not find a ResourceData file for {entry.resource_id}.") - continue - if resourcedata_location in recovered_files: - # We already recovered this file - continue - fh = resourcedata_location.open() - # TODO: What filename do we want for recovery? Detection path seems OK but what if different files - # have the same filename but are stored in different directories? Resource id seems most 'truthful' - # but might be confusing for analysts. - for dest_filename, dest_buf in recover_quarantined_file(fh, entry.resource_id): - output_filename = output_dir.joinpath(dest_filename) - self.target.log.info(f"Saving {output_filename}") - with open(output_filename, "wb") as output_file: - output_file.write(dest_buf) - fh.close() - - # Make sure we do not recover the same file multiple times if it has multiple entries - recovered_files.append(resourcedata_location) - - -def parse_iso_datetime(datetime_value: str) -> datetime: - """Parse ISO8601 serialized datetime with `Z` ending""" - return datetime.strptime(datetime_value, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc) + continue + + resourcedata_location = None + + # Sometimes, the resourcedata file containing the quarantined file does not have the exact same name + # as the entry's resource_id. Instead, it only matches a part of the resource_id. What we do is loop + # over all files in the resourcedata subdirectory, and check whether we can find a filename that + # fully fits into the resource_id. If so, we assume that that is the matching file and break. + for possible_file in subdirectory.iterdir(): + _, _, filename = str(possible_file).rpartition("/") + if filename in entry_resource.resource_id: + resourcedata_location = resourcedata_directory.joinpath(resource_id_key).joinpath(filename) + break + if resourcedata_location is None: + self.target.log.warning(f"Could not find a ResourceData file for {entry_resource.resource_id}.") + continue + if resourcedata_location in recovered_files: + # We already recovered this file + continue + fh = resourcedata_location.open() + # We restore the file with the resource_id as its filename. While we could 'guess' the filename + # based on the information we have from the associated quarantine entry, there is a potential that + # different files have the same filename. Analysts can use the quarantine records to cross + # reference. + for dest_filename, dest_buf in recover_quarantined_file(fh, entry_resource.resource_id): + output_filename = output_dir.joinpath(dest_filename) + self.target.log.info(f"Saving {output_filename}") + with open(output_filename, "wb") as output_file: + output_file.write(dest_buf) + fh.close() + + # Make sure we do not recover the same file multiple times if it has multiple entries + recovered_files.append(resourcedata_location) + def check_compatible(self): + """ + Either the Defender log folder or the quarantine folder has to exist for this plugin to be compatible. + """ + return any( + [ + self.target.fs.path(DEFENDER_LOG_DIR).exists(), + self.target.fs.path(DEFENDER_QUARANTINE_DIR).exists(), + ] + ) -def filter_records(records: Iterable, field_name: str, field_value: Any) -> Generator[Record, None, None]: - def filter_func(record: Record) -> bool: - return hasattr(record, field_name) and getattr(record, field_name) == field_value + def get_quarantine_entries(self) -> Iterator[QuarantineEntry]: + """ + For a given target, return quarantine entries of Windows Defender + """ + quarantine_directory = self.target.fs.path(DEFENDER_QUARANTINE_DIR) + entries_directory = quarantine_directory.joinpath("entries") - return filter(filter_func, records) + if not entries_directory.is_dir(): + return + for guid_path in entries_directory.iterdir(): + if not guid_path.exists() or not guid_path.is_file(): + continue + entry_fh = guid_path.open() + quarantine_entry = QuarantineEntry(entry_fh) + entry_fh.close() + + # Warn on discovery of fields that we do not have knowledge of what they are / do. + for entry_resource in quarantine_entry.resources: + for unknown_field in entry_resource.unknown_fields: + self.target.log.warning(f"Encountered an unknown field identifier: {unknown_field.Identifier}") + yield quarantine_entry From 329924bf4fd1208c64a39cbacd676c09bfdb2fb5 Mon Sep 17 00:00:00 2001 From: Max Groot <19346100+MaxGroot@users.noreply.github.com> Date: Mon, 19 Dec 2022 13:08:58 +0100 Subject: [PATCH 05/10] Simplify defender recovery test Per code review suggestions --- tests/test_plugins_os_windows_defender.py | 26 ++++++----------------- 1 file changed, 7 insertions(+), 19 deletions(-) diff --git a/tests/test_plugins_os_windows_defender.py b/tests/test_plugins_os_windows_defender.py index 9ddacec9d..47328197d 100644 --- a/tests/test_plugins_os_windows_defender.py +++ b/tests/test_plugins_os_windows_defender.py @@ -1,11 +1,9 @@ import os -from configparser import ConfigParser from datetime import datetime from io import BytesIO from pathlib import Path from dissect.ntfs.secure import ACL, SecurityDescriptor - from dissect.target.plugins.os.windows import defender from ._utils import absolute_path @@ -44,7 +42,7 @@ def test_defender_quarantine_entries(target_win, fs_win, tmpdir_name): assert len(records) == 1 - # Test whether the quarantining of a Cobalt Strike beacon is properly parsed. + # Test whether the quarantining of a Mimikatz binary is properly parsed. mimikatz_record = records[0] detection_date = datetime.strptime("2022-12-02", "%Y-%m-%d").date() @@ -75,9 +73,9 @@ def test_defender_quarantine_recovery(target_win, fs_win, tmpdir_name): payload_filename = "A6C8322B8A19AEED96EFBD045206966DA4C9619D" security_descriptor_filename = "A6C8322B8A19AEED96EFBD045206966DA4C9619D.security_descriptor" zone_identifier_filename = "A6C8322B8A19AEED96EFBD045206966DA4C9619D.ZoneIdentifierDATA" - expected_zone_identifier = { - "ZoneTransfer": {"zoneid": "3", "referrerurl": "C:\\Users\\user\\Downloads\\mimikatz_trunk.zip"} - } + expected_zone_identifier_content = ( + b"[ZoneTransfer]\r\nZoneId=3\r\nReferrerUrl=C:\\Users\\user\\Downloads\\mimikatz_trunk.zip\r\n" + ) expected_owner = "S-1-5-21-2614236324-1336345114-3023566343-1000" expected_group = "S-1-5-21-2614236324-1336345114-3023566343-513" @@ -105,16 +103,6 @@ def test_defender_quarantine_recovery(target_win, fs_win, tmpdir_name): assert descriptor.group == expected_group # Verify valid zone identifier for mimikatz file - # First parse the recovered file as an INI - zone_identifier = ConfigParser() - zone_identifier.read(recovery_dst.joinpath(zone_identifier_filename)) - - # Verify that sections are correct - assert zone_identifier.sections() == list(expected_zone_identifier.keys()) - for section in expected_zone_identifier.keys(): - for option, value in expected_zone_identifier[section].items(): - # Verify that option exists for this section - assert option in zone_identifier.options(section) - - # Verify that option has the expected value - assert zone_identifier.get(section, option) == value + with open(recovery_dst.joinpath(zone_identifier_filename), "rb") as zone_identifier_file: + zone_identifier_buf = zone_identifier_file.read() + assert expected_zone_identifier_content == zone_identifier_buf From 82d1fa129a3520a8f13b24ffa861026396c6f500 Mon Sep 17 00:00:00 2001 From: Max Groot <19346100+MaxGroot@users.noreply.github.com> Date: Mon, 19 Dec 2022 13:17:05 +0100 Subject: [PATCH 06/10] Replace outdated comment --- dissect/target/plugins/os/windows/defender.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/dissect/target/plugins/os/windows/defender.py b/dissect/target/plugins/os/windows/defender.py index f0aa8f23e..2adcc9045 100644 --- a/dissect/target/plugins/os/windows/defender.py +++ b/dissect/target/plugins/os/windows/defender.py @@ -306,8 +306,8 @@ def __init__(self, fh): # Decrypt & Parse the header so that we know the section sizes self.header = c_defender.QuarantineEntryFileHeader(rc4_crypt(fh.read(60))) - # TODO: This comment should be changed - # Decrypt & Parse the Quarantine Entry. However, it is not yet a Quarantine Entry Resource. + # Decrypt & Parse Section 1. This will tell us some information about this quarantine entry. + # These properties are shared for all quarantine entry resources associated with this quarantine entry. self.metadata = c_defender.QuarantineEntrySection1(rc4_crypt(fh.read(self.header.Section1Size))) self.timestamp = ts.wintimestamp(self.metadata.Timestamp) From ac0e97fb47b88fc167231a2df23efc66019d19db Mon Sep 17 00:00:00 2001 From: Max Groot <19346100+MaxGroot@users.noreply.github.com> Date: Mon, 19 Dec 2022 15:43:32 +0100 Subject: [PATCH 07/10] Implement code review suggestions Small docstring changes Variable renames Typehints --- dissect/target/plugins/os/windows/defender.py | 108 +++++++++--------- 1 file changed, 52 insertions(+), 56 deletions(-) diff --git a/dissect/target/plugins/os/windows/defender.py b/dissect/target/plugins/os/windows/defender.py index 2adcc9045..e60e86de7 100644 --- a/dissect/target/plugins/os/windows/defender.py +++ b/dissect/target/plugins/os/windows/defender.py @@ -247,7 +247,7 @@ def filter_func(record: Record) -> bool: return filter(filter_func, records) -def rc4_crypt(data) -> bytes: +def rc4_crypt(data: bytes) -> bytes: """ RC4 encrypt / decrypt using the Defender Quarantine RC4 Key """ @@ -274,15 +274,13 @@ def rc4_crypt(data) -> bytes: return bytes(out) -def recover_quarantined_file(handle, filename: str) -> Iterator[tuple[str, bytes]]: - """ - For a given handle to a quarantined file, recover the various data streams present in the handle, yielding tuples - of the output filename and the corresponding output data. +def recover_quarantined_file_streams(handle, filename: str) -> Iterator[tuple[str, bytes]]: + """Recover the various data streams present in a quarantined file + + Yields tuples of the output filename and the corresponding output data. """ - buf = handle.read() - buf = rc4_crypt(buf) - buf = BytesIO(buf) + buf = BytesIO(rc4_crypt(handle.read())) while True: try: @@ -302,7 +300,7 @@ def recover_quarantined_file(handle, filename: str) -> Iterator[tuple[str, bytes class QuarantineEntry: - def __init__(self, fh): + def __init__(self, fh: BinaryIO): # Decrypt & Parse the header so that we know the section sizes self.header = c_defender.QuarantineEntryFileHeader(rc4_crypt(fh.read(60))) @@ -368,11 +366,23 @@ def _add_field(self, field: Structure): class MicrosoftDefenderPlugin(plugin.Plugin): - """Plugin that parses artifacts created by Microsoft Defender. This includes the EVTX logs, as well as recovery - of artefacts from the quarantine folder.""" + """Plugin that parses artifacts created by Microsoft Defender. + + This includes the EVTX logs, as well as recovery of artefacts from the quarantine folder. + """ __namespace__ = "defender" + def check_compatible(self): + # Either the Defender log folder or the quarantine folder has to exist for this plugin to be compatible. + + return any( + [ + self.target.fs.path(DEFENDER_LOG_DIR).exists(), + self.target.fs.path(DEFENDER_QUARANTINE_DIR).exists(), + ] + ) + @plugin.export(record=DefenderLogRecord) def evtx(self) -> Generator[Record, None, None]: """Parse Microsoft Defender evtx log files""" @@ -400,40 +410,39 @@ def evtx(self) -> Generator[Record, None, None]: yield DefenderLogRecord(**record_fields, _target=self.target) @plugin.export(record=DefenderFileQuarantineRecord) - def quarantine(self) -> Generator[Record, None, None]: - """ - Parse the quarantine folder of Microsoft Defender for quarantine entry resources. + def quarantine(self) -> Iterator[DefenderFileQuarantineRecord]: + """Parse the quarantine folder of Microsoft Defender for quarantine entry resources. Quarantine entry resources contain metadata about detected threats that Microsoft Defender has placed in quarantine. """ - for quarantine_entry in self.get_quarantine_entries(): + for entry in self.get_quarantine_entries(): # These fields are present for both behavior and file based detections fields = { - "ts": quarantine_entry.timestamp, - "quarantine_id": quarantine_entry.quarantine_id, - "scan_id": quarantine_entry.scan_id, - "threat_id": quarantine_entry.threat_id, - "detection_name": quarantine_entry.detection_name, + "ts": entry.timestamp, + "quarantine_id": entry.quarantine_id, + "scan_id": entry.scan_id, + "threat_id": entry.threat_id, + "detection_name": entry.detection_name, } - for quarantine_entry_resource in quarantine_entry.resources: - fields.update({"detection_type": quarantine_entry_resource.detection_type}) - if quarantine_entry_resource.detection_type == b"internalbehavior": + for resource in entry.resources: + fields.update({"detection_type": resource.detection_type}) + if resource.detection_type == b"internalbehavior": yield DefenderBehaviorQuarantineRecord(**fields, _target=self.target) - elif quarantine_entry_resource.detection_type == b"file": + elif resource.detection_type == b"file": # These fields are only available for file based detections fields.update( { - "detection_path": quarantine_entry_resource.detection_path, - "creation_time": quarantine_entry_resource.creation_time, - "last_write_time": quarantine_entry_resource.last_write_time, - "last_accessed_time": quarantine_entry_resource.last_access_time, - "resource_id": quarantine_entry_resource.resource_id, + "detection_path": resource.detection_path, + "creation_time": resource.creation_time, + "last_write_time": resource.last_write_time, + "last_accessed_time": resource.last_access_time, + "resource_id": resource.resource_id, } ) yield DefenderFileQuarantineRecord(**fields, _target=self.target) else: - self.target.log.warning("Unknown Defender Detection Type %s", self.detection_type) + self.target.log.warning("Unknown Defender Detection Type %s", resource.detection_type) @plugin.arg( "--output", @@ -445,8 +454,7 @@ def quarantine(self) -> Generator[Record, None, None]: ) @plugin.export(output="none") def recover(self, output_dir: Path) -> None: - """ - Recovers files that have been placed into quarantine by Microsoft Defender. + """Recover files that have been placed into quarantine by Microsoft Defender. Microsoft Defender RC4 encrypts the output of the 'BackupRead' function when it places a file into quarantine. This means multiple data streams can be contained in a single quarantined file, including zone identifier @@ -459,16 +467,16 @@ def recover(self, output_dir: Path) -> None: if resourcedata_directory.exists() and resourcedata_directory.is_dir(): recovered_files = [] for entry in self.get_quarantine_entries(): - for entry_resource in entry.resources: - if entry_resource.detection_type != b"file": + for resource in entry.resources: + if resource.detection_type != b"file": # We can only recover file entries continue # First two letters of the resource ID is the subdirectory that will contain the quarantined file. - resource_id_key = entry_resource.resource_id[0:2] + resource_id_key = resource.resource_id[0:2] subdirectory = resourcedata_directory.joinpath(resource_id_key) if not subdirectory.exists(): self.target.log.warning( - f"Could not find a ResourceData subdirectory for {entry_resource.resource_id}" + f"Could not find a ResourceData subdirectory for {resource.resource_id}" ) continue @@ -480,11 +488,11 @@ def recover(self, output_dir: Path) -> None: # fully fits into the resource_id. If so, we assume that that is the matching file and break. for possible_file in subdirectory.iterdir(): _, _, filename = str(possible_file).rpartition("/") - if filename in entry_resource.resource_id: + if filename in resource.resource_id: resourcedata_location = resourcedata_directory.joinpath(resource_id_key).joinpath(filename) break if resourcedata_location is None: - self.target.log.warning(f"Could not find a ResourceData file for {entry_resource.resource_id}.") + self.target.log.warning(f"Could not find a ResourceData file for {resource.resource_id}.") continue if resourcedata_location in recovered_files: # We already recovered this file @@ -494,7 +502,7 @@ def recover(self, output_dir: Path) -> None: # based on the information we have from the associated quarantine entry, there is a potential that # different files have the same filename. Analysts can use the quarantine records to cross # reference. - for dest_filename, dest_buf in recover_quarantined_file(fh, entry_resource.resource_id): + for dest_filename, dest_buf in recover_quarantined_file_streams(fh, resource.resource_id): output_filename = output_dir.joinpath(dest_filename) self.target.log.info(f"Saving {output_filename}") with open(output_filename, "wb") as output_file: @@ -504,17 +512,6 @@ def recover(self, output_dir: Path) -> None: # Make sure we do not recover the same file multiple times if it has multiple entries recovered_files.append(resourcedata_location) - def check_compatible(self): - """ - Either the Defender log folder or the quarantine folder has to exist for this plugin to be compatible. - """ - return any( - [ - self.target.fs.path(DEFENDER_LOG_DIR).exists(), - self.target.fs.path(DEFENDER_QUARANTINE_DIR).exists(), - ] - ) - def get_quarantine_entries(self) -> Iterator[QuarantineEntry]: """ For a given target, return quarantine entries of Windows Defender @@ -527,12 +524,11 @@ def get_quarantine_entries(self) -> Iterator[QuarantineEntry]: for guid_path in entries_directory.iterdir(): if not guid_path.exists() or not guid_path.is_file(): continue - entry_fh = guid_path.open() - quarantine_entry = QuarantineEntry(entry_fh) - entry_fh.close() + with guid_path.open() as entry_fh: + entry = QuarantineEntry(entry_fh) # Warn on discovery of fields that we do not have knowledge of what they are / do. - for entry_resource in quarantine_entry.resources: - for unknown_field in entry_resource.unknown_fields: + for resource in entry.resources: + for unknown_field in resource.unknown_fields: self.target.log.warning(f"Encountered an unknown field identifier: {unknown_field.Identifier}") - yield quarantine_entry + yield entry From cfc67984ec6af57a66c88aaf032829d429e27560 Mon Sep 17 00:00:00 2001 From: Max Groot <19346100+MaxGroot@users.noreply.github.com> Date: Thu, 22 Dec 2022 10:31:51 +0100 Subject: [PATCH 08/10] Use resource_id for quarantine resourcedata files Due to a bug, we encountered a situation where only a part of the resource_id was found in the acquired evidence. This led to the invalid assumption on my end that sometimes defender will only use a part of the resource id for the filename of a ResourceData file. Now, we assume the a quarantine entry's resource_id will be the filename found in the ResourceData folder. This also aligns with what we saw when investigating mpengine.dll. --- dissect/target/plugins/os/windows/defender.py | 28 +++++-------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/dissect/target/plugins/os/windows/defender.py b/dissect/target/plugins/os/windows/defender.py index e60e86de7..f5179dd63 100644 --- a/dissect/target/plugins/os/windows/defender.py +++ b/dissect/target/plugins/os/windows/defender.py @@ -471,28 +471,14 @@ def recover(self, output_dir: Path) -> None: if resource.detection_type != b"file": # We can only recover file entries continue - # First two letters of the resource ID is the subdirectory that will contain the quarantined file. - resource_id_key = resource.resource_id[0:2] - subdirectory = resourcedata_directory.joinpath(resource_id_key) - if not subdirectory.exists(): - self.target.log.warning( - f"Could not find a ResourceData subdirectory for {resource.resource_id}" - ) + # First two characters of the resource ID is the subdirectory that will contain the quarantined file + subdir = resource.resource_id[0:2] + resourcedata_location = resourcedata_directory.joinpath(subdir).joinpath(resource.resource_id) + if not resourcedata_location.exists(): + self.target.log.warning(f"Could not find a ResourceData file for {entry.resource_id}.") continue - - resourcedata_location = None - - # Sometimes, the resourcedata file containing the quarantined file does not have the exact same name - # as the entry's resource_id. Instead, it only matches a part of the resource_id. What we do is loop - # over all files in the resourcedata subdirectory, and check whether we can find a filename that - # fully fits into the resource_id. If so, we assume that that is the matching file and break. - for possible_file in subdirectory.iterdir(): - _, _, filename = str(possible_file).rpartition("/") - if filename in resource.resource_id: - resourcedata_location = resourcedata_directory.joinpath(resource_id_key).joinpath(filename) - break - if resourcedata_location is None: - self.target.log.warning(f"Could not find a ResourceData file for {resource.resource_id}.") + if not resourcedata_location.is_file(): + self.target.log.warning(f"{resourcedata_location} is not a file!") continue if resourcedata_location in recovered_files: # We already recovered this file From 752a51ede9d574150e43e6ccb98d1bf4acd14968 Mon Sep 17 00:00:00 2001 From: Max Groot <19346100+MaxGroot@users.noreply.github.com> Date: Thu, 22 Dec 2022 11:22:25 +0100 Subject: [PATCH 09/10] Implement code review suggestions --- dissect/target/plugins/os/windows/defender.py | 36 ++++++++----------- tests/test_plugins_os_windows_defender.py | 4 +-- 2 files changed, 16 insertions(+), 24 deletions(-) diff --git a/dissect/target/plugins/os/windows/defender.py b/dissect/target/plugins/os/windows/defender.py index f5179dd63..a9d49d7ea 100644 --- a/dissect/target/plugins/os/windows/defender.py +++ b/dissect/target/plugins/os/windows/defender.py @@ -231,7 +231,7 @@ def parse_iso_datetime(datetime_value: str) -> datetime: - """Parse ISO8601 serialized datetime with `Z` ending""" + """Parse ISO8601 serialized datetime with `Z` ending.""" return datetime.strptime(datetime_value, "%Y-%m-%dT%H:%M:%S.%fZ").replace(tzinfo=timezone.utc) @@ -248,9 +248,7 @@ def filter_func(record: Record) -> bool: def rc4_crypt(data: bytes) -> bytes: - """ - RC4 encrypt / decrypt using the Defender Quarantine RC4 Key - """ + """RC4 encrypt / decrypt using the Defender Quarantine RC4 Key.""" sbox = list(range(256)) j = 0 for i in range(256): @@ -274,13 +272,13 @@ def rc4_crypt(data: bytes) -> bytes: return bytes(out) -def recover_quarantined_file_streams(handle, filename: str) -> Iterator[tuple[str, bytes]]: - """Recover the various data streams present in a quarantined file +def recover_quarantined_file_streams(fh: BinaryIO, filename: str) -> Iterator[tuple[str, bytes]]: + """Recover the various data streams present in a quarantined file. Yields tuples of the output filename and the corresponding output data. """ - buf = BytesIO(rc4_crypt(handle.read())) + buf = BytesIO(rc4_crypt(fh.read())) while True: try: @@ -483,25 +481,21 @@ def recover(self, output_dir: Path) -> None: if resourcedata_location in recovered_files: # We already recovered this file continue - fh = resourcedata_location.open() - # We restore the file with the resource_id as its filename. While we could 'guess' the filename - # based on the information we have from the associated quarantine entry, there is a potential that - # different files have the same filename. Analysts can use the quarantine records to cross - # reference. - for dest_filename, dest_buf in recover_quarantined_file_streams(fh, resource.resource_id): - output_filename = output_dir.joinpath(dest_filename) - self.target.log.info(f"Saving {output_filename}") - with open(output_filename, "wb") as output_file: - output_file.write(dest_buf) - fh.close() + with resourcedata_location.open() as fh: + # We restore the file with the resource_id as its filename. While we could 'guess' the filename + # based on the information we have from the associated quarantine entry, there is a potential + # that different files have the same filename. Analysts can use the quarantine records to cross + # reference. + for dest_filename, dest_buf in recover_quarantined_file_streams(fh, resource.resource_id): + output_filename = output_dir.joinpath(dest_filename) + self.target.log.info(f"Saving {output_filename}") + output_filename.write_bytes(dest_buf) # Make sure we do not recover the same file multiple times if it has multiple entries recovered_files.append(resourcedata_location) def get_quarantine_entries(self) -> Iterator[QuarantineEntry]: - """ - For a given target, return quarantine entries of Windows Defender - """ + """Yield Windows Defender quarantine entries.""" quarantine_directory = self.target.fs.path(DEFENDER_QUARANTINE_DIR) entries_directory = quarantine_directory.joinpath("entries") diff --git a/tests/test_plugins_os_windows_defender.py b/tests/test_plugins_os_windows_defender.py index 47328197d..bc12b0d61 100644 --- a/tests/test_plugins_os_windows_defender.py +++ b/tests/test_plugins_os_windows_defender.py @@ -103,6 +103,4 @@ def test_defender_quarantine_recovery(target_win, fs_win, tmpdir_name): assert descriptor.group == expected_group # Verify valid zone identifier for mimikatz file - with open(recovery_dst.joinpath(zone_identifier_filename), "rb") as zone_identifier_file: - zone_identifier_buf = zone_identifier_file.read() - assert expected_zone_identifier_content == zone_identifier_buf + assert recovery_dst.joinpath(zone_identifier_filename).read_bytes() == expected_zone_identifier_content From a6bdf24e3834ddae7032baad544882e92657edc5 Mon Sep 17 00:00:00 2001 From: Max Groot <19346100+MaxGroot@users.noreply.github.com> Date: Mon, 2 Jan 2023 10:45:00 +0100 Subject: [PATCH 10/10] Fix incorrect import sorts --- dissect/target/plugins/os/windows/defender.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/dissect/target/plugins/os/windows/defender.py b/dissect/target/plugins/os/windows/defender.py index a9d49d7ea..07dbae149 100644 --- a/dissect/target/plugins/os/windows/defender.py +++ b/dissect/target/plugins/os/windows/defender.py @@ -5,9 +5,10 @@ import dissect.util.ts as ts from dissect.cstruct import Structure, cstruct +from flow.record import Record + from dissect.target import plugin from dissect.target.helpers.record import TargetRecordDescriptor -from flow.record import Record DEFENDER_EVTX_FIELDS = [ ("uint32", "EventID"),