From ed140e9ac451ab3f83f39f9788c344bfa658cf18 Mon Sep 17 00:00:00 2001 From: atcuno Date: Thu, 20 Jun 2024 13:06:43 -0500 Subject: [PATCH 1/2] placeholder --- .../plugins/windows/suspicious_threads.py | 201 ++++++++++++++++++ .../framework/plugins/windows/thrdscan.py | 59 +++-- .../framework/plugins/windows/threads.py | 80 +++++++ 3 files changed, 310 insertions(+), 30 deletions(-) create mode 100644 volatility3/framework/plugins/windows/suspicious_threads.py create mode 100644 volatility3/framework/plugins/windows/threads.py diff --git a/volatility3/framework/plugins/windows/suspicious_threads.py b/volatility3/framework/plugins/windows/suspicious_threads.py new file mode 100644 index 000000000..b24f3f2c2 --- /dev/null +++ b/volatility3/framework/plugins/windows/suspicious_threads.py @@ -0,0 +1,201 @@ +# This file is Copyright 2024 Volatility Foundation and licensed under the Volatility Software License 1.0 +# which is available at https://www.volatilityfoundation.org/license/vsl-v1.0 +# + +import logging +from typing import List +from volatility3.framework import renderers, interfaces +from volatility3.framework.configuration import requirements +from volatility3.framework.objects import utility +from volatility3.framework.renderers import format_hints +from volatility3.plugins.windows import pslist, threads, vadinfo, thrdscan + +vollog = logging.getLogger(__name__) + + +class SupsiciousThreads(interfaces.plugins.PluginInterface): + """Lists suspicious userland process threads""" + + _required_framework_version = (2, 4, 0) + _version = (2, 0, 0) + + @classmethod + def get_requirements(cls) -> List[interfaces.configuration.RequirementInterface]: + # Since we're calling the plugin, make sure we have the plugin's requirements + return [ + requirements.ModuleRequirement( + name="kernel", + description="Windows kernel", + architectures=["Intel32", "Intel64"], + ), + requirements.ListRequirement( + name="pid", + description="Filter on specific process IDs", + element_type=int, + optional=True, + ), + requirements.PluginRequirement( + name="threads", plugin=threads.Threads, version=(1, 0, 0) + ), + requirements.VersionRequirement( + name="vadinfo", component=vadinfo.VadInfo, version=(2, 0, 0) + ), + ] + + def _get_ranges(self, kernel, all_ranges, proc): + """ + Maintains a hash table so each process' VADs + are only enumerated once per plugin run + """ + key = proc.vol.offset + + if key not in all_ranges: + all_ranges[key] = [] + + for vad in proc.get_vad_root().traverse(): + fn = vad.get_file_name() + if not isinstance(fn, str) or not fn: + fn = None + + protection_string = vad.get_protection( + vadinfo.VadInfo.protect_values( + self.context, kernel.layer_name, kernel.symbol_table_name + ), + vadinfo.winnt_protections, + ) + + all_ranges[key].append( + (vad.get_start(), vad.get_end(), protection_string, fn) + ) + + return all_ranges[key] + + def _get_range(self, ranges, address): + for start, end, protection_string, fn in ranges: + if start <= address < end: + return start, protection_string, fn + + return None, None, None + + def _check_thread_address(self, exe_path, ranges, thread_address): + vad_base, prot, vad_path = self._get_range(ranges, thread_address) + + # threads outside of a VAD means either smear from this thread or this process' VAD tree + if vad_base is None: + return + + if vad_path is None: + # set this so checks after report the non file backed region in the path column + vad_path = "" + + yield ( + vad_path, + "This thread started execution in the VAD starting at base address ({:#x}), which is not backed by a file".format( + vad_base + ), + ) + + if prot != "PAGE_EXECUTE_WRITECOPY": + yield ( + vad_path, + "VAD at base address ({:#x}) hosting this thread has an unexpected starting protection {}".format( + vad_base, prot + ), + ) + + if ( + exe_path + and vad_path.lower().endswith(".exe") + and (vad_path.lower() != exe_path.lower()) + ): + yield ( + vad_path, + "VAD at base address ({:#x}) hosting this thread maps an application executable that is not the process exectuable".format( + vad_base + ), + ) + + def _enumerate_processes(self, kernel, all_ranges): + filter_func = pslist.PsList.create_pid_filter(self.config.get("pid", None)) + + for proc in pslist.PsList.list_processes( + context=self.context, + layer_name=kernel.layer_name, + symbol_table=kernel.symbol_table_name, + filter_func=filter_func, + ): + ranges = self._get_ranges(kernel, all_ranges, proc) + + # smeared vads or process is terminating + if len(all_ranges[proc.vol.offset]) < 5: + continue + + pid = proc.UniqueProcessId + proc_name = utility.array_to_string(proc.ImageFileName) + + _, __, exe_path = self._get_range(ranges, proc.SectionBaseAddress) + if not isinstance(exe_path, str): + exe_path = None + + yield proc, pid, proc_name, exe_path, ranges + + def _generator(self): + kernel = self.context.modules[self.config["kernel"]] + + all_ranges = {} + + for proc, pid, proc_name, exe_path, ranges in self._enumerate_processes( + kernel, all_ranges + ): + # processes often schedule multiple threads at the same address + # there is no benefit to checking the same address more than once per process + checked = set() + + for thread in threads.Threads.list_threads(kernel, proc): + # do not process if a thread is exited or terminated (4 = Terminated) + if thread.ExitTime.QuadPart > 0 or thread.Tcb.State == 4: + continue + + # bail if accessing the threads members causes a page fault + info = thrdscan.ThrdScan.gather_thread_info(thread) + if not info: + continue + + tid, start_address = info[2], info[3] + + addresses = [ + (start_address, "Start"), + (thread.Win32StartAddress, "Win32Start"), + ] + + for address, context in addresses: + if address in checked: + continue + checked.add(address) + + for vad_path, note in self._check_thread_address( + exe_path, ranges, address + ): + yield 0, ( + proc_name, + pid, + tid, + context, + format_hints.Hex(address), + vad_path, + note, + ) + + def run(self): + return renderers.TreeGrid( + [ + ("Process", str), + ("PID", int), + ("TID", int), + ("Context", str), + ("Address", format_hints.Hex), + ("VAD Path", str), + ("Note", str), + ], + self._generator(), + ) diff --git a/volatility3/framework/plugins/windows/thrdscan.py b/volatility3/framework/plugins/windows/thrdscan.py index 80906b3b9..bbf65cd6c 100644 --- a/volatility3/framework/plugins/windows/thrdscan.py +++ b/volatility3/framework/plugins/windows/thrdscan.py @@ -62,42 +62,41 @@ class ThrdScan(interfaces.plugins.PluginInterface, timeliner.TimeLinerInterface) _constraint, mem_object, _header = result yield mem_object + @classmethod + def gather_thread_info(cls, ethread): + try: + thread_offset = ethread.vol.offset + owner_proc_pid = ethread.Cid.UniqueProcess + thread_tid = ethread.Cid.UniqueThread + thread_start_addr = ethread.StartAddress + thread_create_time = ( + ethread.get_create_time() + ) # datetime.datetime object / volatility3.framework.renderers.UnparsableValue object + thread_exit_time = ( + ethread.get_exit_time() + ) # datetime.datetime object / volatility3.framework.renderers.UnparsableValue object + except exceptions.InvalidAddressException: + vollog.debug("Thread invalid address {:#x}".format(thread.vol.offset)) + return None + + return ( + format_hints.Hex(thread_offset), + owner_proc_pid, + thread_tid, + format_hints.Hex(thread_start_addr), + thread_create_time, + thread_exit_time, + ) + def _generator(self): kernel = self.context.modules[self.config["kernel"]] for ethread in self.scan_threads( self.context, kernel.layer_name, kernel.symbol_table_name ): - try: - thread_offset = ethread.vol.offset - owner_proc_pid = ethread.Cid.UniqueProcess - thread_tid = ethread.Cid.UniqueThread - thread_start_addr = ethread.StartAddress - thread_create_time = ( - ethread.get_create_time() - ) # datetime.datetime object / volatility3.framework.renderers.UnparsableValue object - thread_exit_time = ( - ethread.get_exit_time() - ) # datetime.datetime object / volatility3.framework.renderers.UnparsableValue object - except (ValueError, exceptions.InvalidAddressException): - vollog.debug( - "Thread :{}, invalid address {} in layer {}".format( - thread_tid, thread_start_addr, kernel.layer_name - ) - ) - continue - - yield ( - 0, - ( - format_hints.Hex(thread_offset), - owner_proc_pid, - thread_tid, - format_hints.Hex(thread_start_addr), - thread_create_time, - thread_exit_time, - ), - ) + info = self.gather_thread_info(ethread) + if info: + yield (0, info) def generate_timeline(self): for row in self._generator(): diff --git a/volatility3/framework/plugins/windows/threads.py b/volatility3/framework/plugins/windows/threads.py new file mode 100644 index 000000000..80ff458cd --- /dev/null +++ b/volatility3/framework/plugins/windows/threads.py @@ -0,0 +1,80 @@ +# This file is Copyright 2019 Volatility Foundation and licensed under the Volatility Software License 1.0 +# which is available at https://www.volatilityfoundation.org/license/vsl-v1.0 +# + +import logging +from typing import Callable, List, Generator, Iterable, Type, Optional + +from volatility3.framework import renderers, interfaces, constants, exceptions +from volatility3.framework.configuration import requirements +from volatility3.framework.objects import utility +from volatility3.framework.renderers import format_hints +from volatility3.plugins.windows import pslist, thrdscan + +vollog = logging.getLogger(__name__) + + +class Threads(thrdscan.ThrdScan): + """Lists process threads""" + + _required_framework_version = (2, 4, 0) + _version = (1, 0, 0) + + @classmethod + def get_requirements(cls) -> List[interfaces.configuration.RequirementInterface]: + # Since we're calling the plugin, make sure we have the plugin's requirements + return [ + requirements.ModuleRequirement( + name="kernel", + description="Windows kernel", + architectures=["Intel32", "Intel64"], + ), + requirements.ListRequirement( + name="pid", + description="Filter on specific process IDs", + element_type=int, + optional=True, + ), + requirements.PluginRequirement( + name="thrdscan", plugin=thrdscan.ThrdScan, version=(1, 0, 0) + ), + ] + + @classmethod + def list_threads( + cls, kernel, proc: interfaces.objects.ObjectInterface + ) -> Generator[interfaces.objects.ObjectInterface, None, None]: + """Lists the Threads of a specific process. + + Args: + proc: _EPROCESS object from which to list the VADs + filter_func: Function to take a virtual address descriptor value and return True if it should be filtered out + + Returns: + A list of threads based on the process and filtered based on the filter function + """ + seen = set() + for thread in proc.ThreadListHead.to_list( + f"{kernel.symbol_table_name}{constants.BANG}_ETHREAD", "ThreadListEntry" + ): + if thread.vol.offset in seen: + break + seen.add(thread.vol.offset) + yield thread + + def _generator(self): + kernel = self.context.modules[self.config["kernel"]] + kernel_layer = self.context.layers[kernel.layer_name] + + filter_func = pslist.PsList.create_pid_filter(self.config.get("pid", None)) + + for proc in pslist.PsList.list_processes( + context=self.context, + layer_name=kernel.layer_name, + symbol_table=kernel.symbol_table_name, + filter_func=filter_func, + ): + for thread in self.list_threads(kernel, proc): + info = self.gather_thread_info(thread) + if info: + yield (0, info) From 020005f43cf32e9a38622926c184be1b18d9b18e Mon Sep 17 00:00:00 2001 From: atcuno Date: Wed, 3 Jul 2024 09:55:07 -0500 Subject: [PATCH 2/2] Address feedback from ikelos --- .../plugins/windows/suspicious_threads.py | 45 ++++++++++++------- .../framework/plugins/windows/threads.py | 7 +-- 2 files changed, 31 insertions(+), 21 deletions(-) diff --git a/volatility3/framework/plugins/windows/suspicious_threads.py b/volatility3/framework/plugins/windows/suspicious_threads.py index b24f3f2c2..2679b191a 100644 --- a/volatility3/framework/plugins/windows/suspicious_threads.py +++ b/volatility3/framework/plugins/windows/suspicious_threads.py @@ -3,7 +3,7 @@ # import logging -from typing import List +from typing import List, Dict, Tuple, Generator from volatility3.framework import renderers, interfaces from volatility3.framework.configuration import requirements from volatility3.framework.objects import utility @@ -42,7 +42,12 @@ class SupsiciousThreads(interfaces.plugins.PluginInterface): ), ] - def _get_ranges(self, kernel, all_ranges, proc): + def _get_ranges( + self, + kernel: interfaces.context.ModuleInterface, + all_ranges: Dict[int, List[Tuple[int, int, str, str]]], + proc, + ) -> Tuple[int, int, str, str]: """ Maintains a hash table so each process' VADs are only enumerated once per plugin run @@ -70,14 +75,24 @@ class SupsiciousThreads(interfaces.plugins.PluginInterface): return all_ranges[key] - def _get_range(self, ranges, address): + def _get_range( + self, ranges: Dict[int, List[Tuple[int, int, str, str]]], address: int + ) -> Tuple[int, str, str]: + """ + Walks a process' VADs looking for the one + containing `address` + + Returns its base address, protection string, and mapped file, if any + """ for start, end, protection_string, fn in ranges: if start <= address < end: return start, protection_string, fn return None, None, None - def _check_thread_address(self, exe_path, ranges, thread_address): + def _check_thread_address( + self, exe_path: str, ranges, thread_address: int + ) -> Generator[Tuple[str, str], None, None]: vad_base, prot, vad_path = self._get_range(ranges, thread_address) # threads outside of a VAD means either smear from this thread or this process' VAD tree @@ -90,19 +105,17 @@ class SupsiciousThreads(interfaces.plugins.PluginInterface): yield ( vad_path, - "This thread started execution in the VAD starting at base address ({:#x}), which is not backed by a file".format( - vad_base - ), + f"This thread started execution in the VAD starting at base address ({vad_base:#x}), which is not backed by a file", ) + # All threads should point to PAGE_EXECUTE_WRITECOPY mapped regions if prot != "PAGE_EXECUTE_WRITECOPY": yield ( vad_path, - "VAD at base address ({:#x}) hosting this thread has an unexpected starting protection {}".format( - vad_base, prot - ), + f"VAD at base address ({vad_base:#x}) hosting this thread has an unexpected starting protection {prot}", ) + # check for process hollowing type techniques that mapped in a second, malicious exe file if ( exe_path and vad_path.lower().endswith(".exe") @@ -110,12 +123,12 @@ class SupsiciousThreads(interfaces.plugins.PluginInterface): ): yield ( vad_path, - "VAD at base address ({:#x}) hosting this thread maps an application executable that is not the process exectuable".format( - vad_base - ), + "VAD at base address ({vad_base:#x}) hosting this thread maps an application executable that is not the process exectuable", ) - def _enumerate_processes(self, kernel, all_ranges): + def _enumerate_processes( + self, kernel: interfaces.context.ModuleInterface, all_ranges + ): filter_func = pslist.PsList.create_pid_filter(self.config.get("pid", None)) for proc in pslist.PsList.list_processes( @@ -147,7 +160,7 @@ class SupsiciousThreads(interfaces.plugins.PluginInterface): for proc, pid, proc_name, exe_path, ranges in self._enumerate_processes( kernel, all_ranges ): - # processes often schedule multiple threads at the same address + # processes often create multiple threads at the same address # there is no benefit to checking the same address more than once per process checked = set() @@ -161,7 +174,7 @@ class SupsiciousThreads(interfaces.plugins.PluginInterface): if not info: continue - tid, start_address = info[2], info[3] + _, _, tid, start_address, _, _ = info addresses = [ (start_address, "Start"), diff --git a/volatility3/framework/plugins/windows/threads.py b/volatility3/framework/plugins/windows/threads.py index 80ff458cd..83d231abb 100644 --- a/volatility3/framework/plugins/windows/threads.py +++ b/volatility3/framework/plugins/windows/threads.py @@ -3,12 +3,10 @@ # import logging -from typing import Callable, List, Generator, Iterable, Type, Optional +from typing import List, Generator -from volatility3.framework import renderers, interfaces, constants, exceptions +from volatility3.framework import interfaces, constants from volatility3.framework.configuration import requirements -from volatility3.framework.objects import utility -from volatility3.framework.renderers import format_hints from volatility3.plugins.windows import pslist, thrdscan vollog = logging.getLogger(__name__) @@ -64,7 +62,6 @@ class Threads(thrdscan.ThrdScan): def _generator(self): kernel = self.context.modules[self.config["kernel"]] - kernel_layer = self.context.layers[kernel.layer_name] filter_func = pslist.PsList.create_pid_filter(self.config.get("pid", None))