From 3d1b9ef2bc1e968eed6f41195834752d2d328ec9 Mon Sep 17 00:00:00 2001 From: atcuno Date: Wed, 19 Jun 2024 20:09:05 -0500 Subject: [PATCH 1/5] Add new plugin to detect hollowed processes using a variety of techniques and allowing for easy additions of future detection techniques --- .../plugins/windows/hollowprocesses.py | 175 ++++++++++++++++++ 1 file changed, 175 insertions(+) create mode 100644 volatility3/framework/plugins/windows/hollowprocesses.py diff --git a/volatility3/framework/plugins/windows/hollowprocesses.py b/volatility3/framework/plugins/windows/hollowprocesses.py new file mode 100644 index 000000000..a832e89e1 --- /dev/null +++ b/volatility3/framework/plugins/windows/hollowprocesses.py @@ -0,0 +1,175 @@ +# 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 NamedTuple + +from volatility3.framework import interfaces, exceptions, constants +from volatility3.framework import renderers +from volatility3.framework.configuration import requirements +from volatility3.framework.objects import utility +from volatility3.plugins.windows import pslist, vadinfo + +vollog = logging.getLogger(__name__) + +VadInfo = NamedTuple( + "VadInfo", + [ + ("protection", str), + ("path", str), + ], +) + +DLLInfo = NamedTuple( + "DLLInfo", + [ + ("path", str), + ], +) + +class HollowProcesses(interfaces.plugins.PluginInterface): + """Lists hollowed processes""" + + _required_framework_version = (2, 4, 0) + + @classmethod + def get_requirements(cls): + # 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", + element_type=int, + description="Process IDs to include (all other processes are excluded)", + optional=True, + ), + requirements.VersionRequirement( + name="pslist", component=pslist.PsList, version=(2, 0, 0) + ), + requirements.VersionRequirement( + name="vadinfo", component=vadinfo.VadInfo, version=(2, 0, 0) + ), + ] + + def _get_vads_map(self, proc): + vads = {} + + kernel = self.context.modules[self.config["kernel"]] + + for vad in proc.get_vad_root().traverse(): + protection_string = vad.get_protection( + vadinfo.VadInfo.protect_values( + self.context, kernel.layer_name, kernel.symbol_table_name + ), + vadinfo.winnt_protections, + ) + + fn = vad.get_file_name() + if not fn or not isinstance(fn, str): + fn = "" + + vads[vad.get_start()] = VadInfo(protection_string, fn) + + return vads + + def _get_dlls_map(self, proc): + dlls = {} + + for entry in proc.load_order_modules(): + try: + base = entry.DllBase + except exceptions.InvalidAddressException: + continue + + try: + FullDllName = entry.FullDllName.get_string() + except exceptions.InvalidAddressException: + FullDllName = renderers.UnreadableValue() + + dlls[base] = DLLInfo(FullDllName) + + return dlls + + def _get_image_base(self, proc): + kernel = self.context.modules[self.config["kernel"]] + + try: + proc_layer_name = proc.add_process_layer() + peb = self.context.object( + kernel.symbol_table_name + constants.BANG + "_PEB", + layer_name=proc_layer_name, + offset=proc.Peb, + ) + return peb.ImageBaseAddress + except exceptions.InvalidAddressException: + return None + + def _check_load_address(self, proc, _, __): + image_base = self._get_image_base(proc) + if image_base is not None and image_base != proc.SectionBaseAddress: + yield "The ImageBaseAddress reported from the PEB ({:#x}) does not match the process SectionBaseAddress ({:#x})".format(image_base, proc.SectionBaseAddress) + + def _check_exe_protection(self, proc, vads, __): + base = proc.SectionBaseAddress + + if base not in vads: + yield "There is no VAD starting at the base address of the process executable ({:#x})".format(base) + elif vads[base].protection != "PAGE_EXECUTE_WRITECOPY": + yield "Unexpected protection ({}) for VAD hosting the process executable ({:#x}) with path {}".format(vads[base].protection, base, vads[base].path) + + def _check_dlls_protection(self, _, vads, dlls): + for dll_base in dlls: + # could be malicious but triggers too many FPs from smear + if dll_base not in vads: + continue + + if vads[dll_base].protection != "PAGE_EXECUTE_WRITECOPY": + yield "Unexpected protection ({}) for DLL in the PEB's load order list ({:#x}) with path {}".format(vads[dll_base].protection, dll_base, dlls[dll_base].path) + + def _generator(self, procs): + checks = [self._check_load_address, self._check_exe_protection, self._check_dlls_protection] + + for proc in procs: + proc_name = utility.array_to_string(proc.ImageFileName) + pid = proc.UniqueProcessId + + # smear and/or terminated process + dlls = self._get_dlls_map(proc) + if len(dlls) < 3: + continue + + vads = self._get_vads_map(proc) + if len(vads) < 5: + continue + + for check in checks: + for note in check(proc, vads, dlls): + yield 0, ( + pid, + proc_name, + note, + ) + + def run(self): + filter_func = pslist.PsList.create_pid_filter(self.config.get("pid", None)) + kernel = self.context.modules[self.config["kernel"]] + + return renderers.TreeGrid( + [ + ("PID", int), + ("Process", str), + ("Notes", str), + ], + self._generator( + pslist.PsList.list_processes( + context=self.context, + layer_name=kernel.layer_name, + symbol_table=kernel.symbol_table_name, + filter_func=filter_func, + ) + ), + ) From 02bda980d429a49c18a1b908b5fcbfda8de5ad09 Mon Sep 17 00:00:00 2001 From: atcuno Date: Wed, 19 Jun 2024 20:12:07 -0500 Subject: [PATCH 2/5] formatting fixes --- .../plugins/windows/hollowprocesses.py | 28 +++++++++++++------ 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/volatility3/framework/plugins/windows/hollowprocesses.py b/volatility3/framework/plugins/windows/hollowprocesses.py index a832e89e1..18fb24c8f 100644 --- a/volatility3/framework/plugins/windows/hollowprocesses.py +++ b/volatility3/framework/plugins/windows/hollowprocesses.py @@ -1,4 +1,4 @@ -# This file is Copyright 2019 Volatility Foundation and licensed under the Volatility Software License 1.0 +# 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 @@ -17,14 +17,14 @@ VadInfo = NamedTuple( [ ("protection", str), ("path", str), - ], + ], ) DLLInfo = NamedTuple( "DLLInfo", [ ("path", str), - ], + ], ) class HollowProcesses(interfaces.plugins.PluginInterface): @@ -111,15 +111,21 @@ class HollowProcesses(interfaces.plugins.PluginInterface): def _check_load_address(self, proc, _, __): image_base = self._get_image_base(proc) if image_base is not None and image_base != proc.SectionBaseAddress: - yield "The ImageBaseAddress reported from the PEB ({:#x}) does not match the process SectionBaseAddress ({:#x})".format(image_base, proc.SectionBaseAddress) + yield "The ImageBaseAddress reported from the PEB ({:#x}) does not match the process SectionBaseAddress ({:#x})".format( + image_base, proc.SectionBaseAddress + ) def _check_exe_protection(self, proc, vads, __): base = proc.SectionBaseAddress if base not in vads: - yield "There is no VAD starting at the base address of the process executable ({:#x})".format(base) + yield "There is no VAD starting at the base address of the process executable ({:#x})".format( + base + ) elif vads[base].protection != "PAGE_EXECUTE_WRITECOPY": - yield "Unexpected protection ({}) for VAD hosting the process executable ({:#x}) with path {}".format(vads[base].protection, base, vads[base].path) + yield "Unexpected protection ({}) for VAD hosting the process executable ({:#x}) with path {}".format( + vads[base].protection, base, vads[base].path + ) def _check_dlls_protection(self, _, vads, dlls): for dll_base in dlls: @@ -128,10 +134,16 @@ class HollowProcesses(interfaces.plugins.PluginInterface): continue if vads[dll_base].protection != "PAGE_EXECUTE_WRITECOPY": - yield "Unexpected protection ({}) for DLL in the PEB's load order list ({:#x}) with path {}".format(vads[dll_base].protection, dll_base, dlls[dll_base].path) + yield "Unexpected protection ({}) for DLL in the PEB's load order list ({:#x}) with path {}".format( + vads[dll_base].protection, dll_base, dlls[dll_base].path + ) def _generator(self, procs): - checks = [self._check_load_address, self._check_exe_protection, self._check_dlls_protection] + checks = [ + self._check_load_address, + self._check_exe_protection, + self._check_dlls_protection + ] for proc in procs: proc_name = utility.array_to_string(proc.ImageFileName) From 178f7c45f7ed1413277baba486929e5f2afae7fc Mon Sep 17 00:00:00 2001 From: atcuno Date: Wed, 19 Jun 2024 20:13:04 -0500 Subject: [PATCH 3/5] formatting fixes --- volatility3/framework/plugins/windows/hollowprocesses.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/volatility3/framework/plugins/windows/hollowprocesses.py b/volatility3/framework/plugins/windows/hollowprocesses.py index 18fb24c8f..9d7e800b7 100644 --- a/volatility3/framework/plugins/windows/hollowprocesses.py +++ b/volatility3/framework/plugins/windows/hollowprocesses.py @@ -27,6 +27,7 @@ DLLInfo = NamedTuple( ], ) + class HollowProcesses(interfaces.plugins.PluginInterface): """Lists hollowed processes""" @@ -142,7 +143,7 @@ class HollowProcesses(interfaces.plugins.PluginInterface): checks = [ self._check_load_address, self._check_exe_protection, - self._check_dlls_protection + self._check_dlls_protection, ] for proc in procs: From 857cd8df491310d4c8ed56833a9fae6714f9c582 Mon Sep 17 00:00:00 2001 From: atcuno Date: Tue, 2 Jul 2024 19:14:15 -0500 Subject: [PATCH 4/5] Updates from ikelos' feedback --- .../plugins/windows/hollowprocesses.py | 67 ++++++++++++++++--- 1 file changed, 56 insertions(+), 11 deletions(-) diff --git a/volatility3/framework/plugins/windows/hollowprocesses.py b/volatility3/framework/plugins/windows/hollowprocesses.py index 9d7e800b7..3158667ac 100644 --- a/volatility3/framework/plugins/windows/hollowprocesses.py +++ b/volatility3/framework/plugins/windows/hollowprocesses.py @@ -2,7 +2,7 @@ # which is available at https://www.volatilityfoundation.org/license/vsl-v1.0 # import logging -from typing import NamedTuple +from typing import NamedTuple, Dict from volatility3.framework import interfaces, exceptions, constants from volatility3.framework import renderers @@ -12,21 +12,25 @@ from volatility3.plugins.windows import pslist, vadinfo vollog = logging.getLogger(__name__) -VadInfo = NamedTuple( - "VadInfo", +VadData = NamedTuple( + "VadData", [ ("protection", str), ("path", str), ], ) -DLLInfo = NamedTuple( - "DLLInfo", +DLLData = NamedTuple( + "DLLData", [ ("path", str), ], ) +### Useful references on process hollowing +# https://cysinfo.com/detecting-deceptive-hollowing-techniques/ +# https://github.com/m0n0ph1/Process-Hollowing + class HollowProcesses(interfaces.plugins.PluginInterface): """Lists hollowed processes""" @@ -56,7 +60,16 @@ class HollowProcesses(interfaces.plugins.PluginInterface): ), ] - def _get_vads_map(self, proc): + def _get_vads_data( + self, proc: interfaces.objects.ObjectInterface + ) -> Dict[int, VadData]: + """ + Returns a dictionary of: + base address -> (protection string, file name) + For each mapped VAD in the process. This is used + for quick lookups of data and matching the DLL + at the same base address as the VAD + """ vads = {} kernel = self.context.modules[self.config["kernel"]] @@ -73,11 +86,23 @@ class HollowProcesses(interfaces.plugins.PluginInterface): if not fn or not isinstance(fn, str): fn = "" - vads[vad.get_start()] = VadInfo(protection_string, fn) + vads[vad.get_start()] = VadData(protection_string, fn) return vads - def _get_dlls_map(self, proc): + def _get_dlls_map( + self, proc: interfaces.objects.ObjectInterface + ) -> Dict[int, DLLData]: + """ + Returns a dictionary of: + base address -> path + for each DLL loaded in the process + + This is used to cross compare with + the corresponding VAD and to have a + backup path source in case of smear + in the VAD + """ dlls = {} for entry in proc.load_order_modules(): @@ -91,11 +116,14 @@ class HollowProcesses(interfaces.plugins.PluginInterface): except exceptions.InvalidAddressException: FullDllName = renderers.UnreadableValue() - dlls[base] = DLLInfo(FullDllName) + dlls[base] = DLLData(FullDllName) return dlls - def _get_image_base(self, proc): + def _get_image_base(self, proc: interfaces.objects.ObjectInterface) -> int: + """ + Uses the PEB to get the image base of the process + """ kernel = self.context.modules[self.config["kernel"]] try: @@ -110,6 +138,12 @@ class HollowProcesses(interfaces.plugins.PluginInterface): return None def _check_load_address(self, proc, _, __): + """ + Detects when the image base in the PEB, which is writable by process malware, + does not match the section base address - whose value lives in kernel memory. + Many malware samples will manipulate their image base to fool AVs/EDRs and + as a necessary part of certain hollowing techniques + """ image_base = self._get_image_base(proc) if image_base is not None and image_base != proc.SectionBaseAddress: yield "The ImageBaseAddress reported from the PEB ({:#x}) does not match the process SectionBaseAddress ({:#x})".format( @@ -117,6 +151,16 @@ class HollowProcesses(interfaces.plugins.PluginInterface): ) def _check_exe_protection(self, proc, vads, __): + """ + Legitimately mapped application executables and DLLs + will have a VAD present and its initial protection will be + PAGE_EXECUTE_WRITECOPY. + Many process hollowing and code injection techniques will + unmap the real executable and/or map in executables with + incorrect permissions. + This check verifies the VAD for the application exe. + `_check_dlls_protection` checks for DLLs mapped in the process. + """ base = proc.SectionBaseAddress if base not in vads: @@ -134,6 +178,7 @@ class HollowProcesses(interfaces.plugins.PluginInterface): if dll_base not in vads: continue + # PAGE_EXECUTE_WRITECOPY is the only valid permission for mapped DLLs and .exe files if vads[dll_base].protection != "PAGE_EXECUTE_WRITECOPY": yield "Unexpected protection ({}) for DLL in the PEB's load order list ({:#x}) with path {}".format( vads[dll_base].protection, dll_base, dlls[dll_base].path @@ -155,7 +200,7 @@ class HollowProcesses(interfaces.plugins.PluginInterface): if len(dlls) < 3: continue - vads = self._get_vads_map(proc) + vads = self._get_vads_data(proc) if len(vads) < 5: continue From 96a382ed525bb041cbe4fb0917e9af07160ea62a Mon Sep 17 00:00:00 2001 From: atcuno Date: Fri, 5 Jul 2024 09:33:34 -0500 Subject: [PATCH 5/5] Further type information --- .../plugins/windows/hollowprocesses.py | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/volatility3/framework/plugins/windows/hollowprocesses.py b/volatility3/framework/plugins/windows/hollowprocesses.py index 3158667ac..69fa94f06 100644 --- a/volatility3/framework/plugins/windows/hollowprocesses.py +++ b/volatility3/framework/plugins/windows/hollowprocesses.py @@ -2,7 +2,7 @@ # which is available at https://www.volatilityfoundation.org/license/vsl-v1.0 # import logging -from typing import NamedTuple, Dict +from typing import NamedTuple, Dict, Generator from volatility3.framework import interfaces, exceptions, constants from volatility3.framework import renderers @@ -137,7 +137,7 @@ class HollowProcesses(interfaces.plugins.PluginInterface): except exceptions.InvalidAddressException: return None - def _check_load_address(self, proc, _, __): + def _check_load_address(self, proc, _, __) -> Generator[str, None, None]: """ Detects when the image base in the PEB, which is writable by process malware, does not match the section base address - whose value lives in kernel memory. @@ -150,7 +150,9 @@ class HollowProcesses(interfaces.plugins.PluginInterface): image_base, proc.SectionBaseAddress ) - def _check_exe_protection(self, proc, vads, __): + def _check_exe_protection( + self, proc, vads: Dict[int, VadData], __ + ) -> Generator[str, None, None]: """ Legitimately mapped application executables and DLLs will have a VAD present and its initial protection will be @@ -172,7 +174,9 @@ class HollowProcesses(interfaces.plugins.PluginInterface): vads[base].protection, base, vads[base].path ) - def _check_dlls_protection(self, _, vads, dlls): + def _check_dlls_protection( + self, _, vads: Dict[int, VadData], dlls: Dict[int, DLLData] + ) -> Generator[str, None, None]: for dll_base in dlls: # could be malicious but triggers too many FPs from smear if dll_base not in vads: @@ -192,9 +196,6 @@ class HollowProcesses(interfaces.plugins.PluginInterface): ] for proc in procs: - proc_name = utility.array_to_string(proc.ImageFileName) - pid = proc.UniqueProcessId - # smear and/or terminated process dlls = self._get_dlls_map(proc) if len(dlls) < 3: @@ -204,6 +205,9 @@ class HollowProcesses(interfaces.plugins.PluginInterface): if len(vads) < 5: continue + proc_name = utility.array_to_string(proc.ImageFileName) + pid = proc.UniqueProcessId + for check in checks: for note in check(proc, vads, dlls): yield 0, (