mirror of
https://github.com/volatilityfoundation/volatility3.git
synced 2026-08-17 20:35:40 +02:00
Merge pull request #1835 from SolitudePy/suspicious_threads_malware
Malware categorization: windows.suspicious_threads
This commit is contained in:
@@ -0,0 +1,221 @@
|
|||||||
|
# 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, Dict, Tuple, Generator
|
||||||
|
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 SuspiciousThreads(interfaces.plugins.PluginInterface):
|
||||||
|
"""Lists suspicious userland process threads"""
|
||||||
|
|
||||||
|
_required_framework_version = (2, 4, 0)
|
||||||
|
_version = (2, 0, 1)
|
||||||
|
|
||||||
|
@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.VersionRequirement(
|
||||||
|
name="thrdscan", component=thrdscan.ThrdScan, version=(2, 0, 0)
|
||||||
|
),
|
||||||
|
requirements.VersionRequirement(
|
||||||
|
name="pslist", component=pslist.PsList, version=(3, 0, 0)
|
||||||
|
),
|
||||||
|
requirements.VersionRequirement(
|
||||||
|
name="threads", component=threads.Threads, version=(3, 0, 0)
|
||||||
|
),
|
||||||
|
requirements.VersionRequirement(
|
||||||
|
name="vadinfo", component=vadinfo.VadInfo, version=(2, 0, 0)
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
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
|
||||||
|
"""
|
||||||
|
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: 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: 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
|
||||||
|
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 = "<Non-File Backed Region>"
|
||||||
|
|
||||||
|
yield (
|
||||||
|
vad_path,
|
||||||
|
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,
|
||||||
|
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")
|
||||||
|
and (vad_path.lower() != exe_path.lower())
|
||||||
|
):
|
||||||
|
yield (
|
||||||
|
vad_path,
|
||||||
|
"VAD at base address ({vad_base:#x}) hosting this thread maps an application executable that is not the process executable",
|
||||||
|
)
|
||||||
|
|
||||||
|
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(
|
||||||
|
context=self.context,
|
||||||
|
kernel_module_name=self.config["kernel"],
|
||||||
|
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 create 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(
|
||||||
|
self.context, self.config["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, _, win32_start_address, _, _, _ = info
|
||||||
|
|
||||||
|
addresses = [
|
||||||
|
(start_address, "Start"),
|
||||||
|
(win32_start_address, "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(),
|
||||||
|
)
|
||||||
@@ -1,221 +1,20 @@
|
|||||||
# This file is Copyright 2024 Volatility Foundation and licensed under the Volatility Software License 1.0
|
# This file is Copyright 2025 Volatility Foundation and licensed under the Volatility Software License 1.0
|
||||||
# which is available at https://www.volatilityfoundation.org/license/vsl-v1.0
|
# which is available at https://www.volatilityfoundation.org/license/vsl-v1.0
|
||||||
#
|
#
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from typing import List, Dict, Tuple, Generator
|
from volatility3.framework import interfaces, deprecation
|
||||||
from volatility3.framework import renderers, interfaces
|
from volatility3.plugins.windows.malware import suspicious_threads
|
||||||
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__)
|
vollog = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
class SuspiciousThreads(interfaces.plugins.PluginInterface):
|
class SuspiciousThreads(
|
||||||
"""Lists suspicious userland process threads"""
|
interfaces.plugins.PluginInterface,
|
||||||
|
deprecation.PluginRenameClass,
|
||||||
|
replacement_class=suspicious_threads.SuspiciousThreads,
|
||||||
|
removal_date="2026-06-07",
|
||||||
|
):
|
||||||
|
"""Lists suspicious userland process threads (deprecated)."""
|
||||||
|
|
||||||
_required_framework_version = (2, 4, 0)
|
_required_framework_version = (2, 4, 0)
|
||||||
_version = (2, 0, 1)
|
_version = (2, 0, 1)
|
||||||
|
|
||||||
@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.VersionRequirement(
|
|
||||||
name="thrdscan", component=thrdscan.ThrdScan, version=(2, 0, 0)
|
|
||||||
),
|
|
||||||
requirements.VersionRequirement(
|
|
||||||
name="pslist", component=pslist.PsList, version=(3, 0, 0)
|
|
||||||
),
|
|
||||||
requirements.VersionRequirement(
|
|
||||||
name="threads", component=threads.Threads, version=(3, 0, 0)
|
|
||||||
),
|
|
||||||
requirements.VersionRequirement(
|
|
||||||
name="vadinfo", component=vadinfo.VadInfo, version=(2, 0, 0)
|
|
||||||
),
|
|
||||||
]
|
|
||||||
|
|
||||||
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
|
|
||||||
"""
|
|
||||||
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: 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: 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
|
|
||||||
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 = "<Non-File Backed Region>"
|
|
||||||
|
|
||||||
yield (
|
|
||||||
vad_path,
|
|
||||||
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,
|
|
||||||
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")
|
|
||||||
and (vad_path.lower() != exe_path.lower())
|
|
||||||
):
|
|
||||||
yield (
|
|
||||||
vad_path,
|
|
||||||
"VAD at base address ({vad_base:#x}) hosting this thread maps an application executable that is not the process executable",
|
|
||||||
)
|
|
||||||
|
|
||||||
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(
|
|
||||||
context=self.context,
|
|
||||||
kernel_module_name=self.config["kernel"],
|
|
||||||
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 create 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(
|
|
||||||
self.context, self.config["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, _, win32_start_address, _, _, _ = info
|
|
||||||
|
|
||||||
addresses = [
|
|
||||||
(start_address, "Start"),
|
|
||||||
(win32_start_address, "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(),
|
|
||||||
)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user