Skip to content

Commit 1d4a27e

Browse files
authored
Merge pull request #1170 from volatilityfoundation/hollow_process
Add new plugin to detect hollowed processes using a variety of techni…
2 parents 41c6963 + 96a382e commit 1d4a27e

File tree

1 file changed

+237
-0
lines changed

1 file changed

+237
-0
lines changed
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
# This file is Copyright 2024 Volatility Foundation and licensed under the Volatility Software License 1.0
2+
# which is available at https://www.volatilityfoundation.org/license/vsl-v1.0
3+
#
4+
import logging
5+
from typing import NamedTuple, Dict, Generator
6+
7+
from volatility3.framework import interfaces, exceptions, constants
8+
from volatility3.framework import renderers
9+
from volatility3.framework.configuration import requirements
10+
from volatility3.framework.objects import utility
11+
from volatility3.plugins.windows import pslist, vadinfo
12+
13+
vollog = logging.getLogger(__name__)
14+
15+
VadData = NamedTuple(
16+
"VadData",
17+
[
18+
("protection", str),
19+
("path", str),
20+
],
21+
)
22+
23+
DLLData = NamedTuple(
24+
"DLLData",
25+
[
26+
("path", str),
27+
],
28+
)
29+
30+
### Useful references on process hollowing
31+
# https://cysinfo.com/detecting-deceptive-hollowing-techniques/
32+
# https://github.com/m0n0ph1/Process-Hollowing
33+
34+
35+
class HollowProcesses(interfaces.plugins.PluginInterface):
36+
"""Lists hollowed processes"""
37+
38+
_required_framework_version = (2, 4, 0)
39+
40+
@classmethod
41+
def get_requirements(cls):
42+
# Since we're calling the plugin, make sure we have the plugin's requirements
43+
return [
44+
requirements.ModuleRequirement(
45+
name="kernel",
46+
description="Windows kernel",
47+
architectures=["Intel32", "Intel64"],
48+
),
49+
requirements.ListRequirement(
50+
name="pid",
51+
element_type=int,
52+
description="Process IDs to include (all other processes are excluded)",
53+
optional=True,
54+
),
55+
requirements.VersionRequirement(
56+
name="pslist", component=pslist.PsList, version=(2, 0, 0)
57+
),
58+
requirements.VersionRequirement(
59+
name="vadinfo", component=vadinfo.VadInfo, version=(2, 0, 0)
60+
),
61+
]
62+
63+
def _get_vads_data(
64+
self, proc: interfaces.objects.ObjectInterface
65+
) -> Dict[int, VadData]:
66+
"""
67+
Returns a dictionary of:
68+
base address -> (protection string, file name)
69+
For each mapped VAD in the process. This is used
70+
for quick lookups of data and matching the DLL
71+
at the same base address as the VAD
72+
"""
73+
vads = {}
74+
75+
kernel = self.context.modules[self.config["kernel"]]
76+
77+
for vad in proc.get_vad_root().traverse():
78+
protection_string = vad.get_protection(
79+
vadinfo.VadInfo.protect_values(
80+
self.context, kernel.layer_name, kernel.symbol_table_name
81+
),
82+
vadinfo.winnt_protections,
83+
)
84+
85+
fn = vad.get_file_name()
86+
if not fn or not isinstance(fn, str):
87+
fn = "<Non-File Backed Region>"
88+
89+
vads[vad.get_start()] = VadData(protection_string, fn)
90+
91+
return vads
92+
93+
def _get_dlls_map(
94+
self, proc: interfaces.objects.ObjectInterface
95+
) -> Dict[int, DLLData]:
96+
"""
97+
Returns a dictionary of:
98+
base address -> path
99+
for each DLL loaded in the process
100+
101+
This is used to cross compare with
102+
the corresponding VAD and to have a
103+
backup path source in case of smear
104+
in the VAD
105+
"""
106+
dlls = {}
107+
108+
for entry in proc.load_order_modules():
109+
try:
110+
base = entry.DllBase
111+
except exceptions.InvalidAddressException:
112+
continue
113+
114+
try:
115+
FullDllName = entry.FullDllName.get_string()
116+
except exceptions.InvalidAddressException:
117+
FullDllName = renderers.UnreadableValue()
118+
119+
dlls[base] = DLLData(FullDllName)
120+
121+
return dlls
122+
123+
def _get_image_base(self, proc: interfaces.objects.ObjectInterface) -> int:
124+
"""
125+
Uses the PEB to get the image base of the process
126+
"""
127+
kernel = self.context.modules[self.config["kernel"]]
128+
129+
try:
130+
proc_layer_name = proc.add_process_layer()
131+
peb = self.context.object(
132+
kernel.symbol_table_name + constants.BANG + "_PEB",
133+
layer_name=proc_layer_name,
134+
offset=proc.Peb,
135+
)
136+
return peb.ImageBaseAddress
137+
except exceptions.InvalidAddressException:
138+
return None
139+
140+
def _check_load_address(self, proc, _, __) -> Generator[str, None, None]:
141+
"""
142+
Detects when the image base in the PEB, which is writable by process malware,
143+
does not match the section base address - whose value lives in kernel memory.
144+
Many malware samples will manipulate their image base to fool AVs/EDRs and
145+
as a necessary part of certain hollowing techniques
146+
"""
147+
image_base = self._get_image_base(proc)
148+
if image_base is not None and image_base != proc.SectionBaseAddress:
149+
yield "The ImageBaseAddress reported from the PEB ({:#x}) does not match the process SectionBaseAddress ({:#x})".format(
150+
image_base, proc.SectionBaseAddress
151+
)
152+
153+
def _check_exe_protection(
154+
self, proc, vads: Dict[int, VadData], __
155+
) -> Generator[str, None, None]:
156+
"""
157+
Legitimately mapped application executables and DLLs
158+
will have a VAD present and its initial protection will be
159+
PAGE_EXECUTE_WRITECOPY.
160+
Many process hollowing and code injection techniques will
161+
unmap the real executable and/or map in executables with
162+
incorrect permissions.
163+
This check verifies the VAD for the application exe.
164+
`_check_dlls_protection` checks for DLLs mapped in the process.
165+
"""
166+
base = proc.SectionBaseAddress
167+
168+
if base not in vads:
169+
yield "There is no VAD starting at the base address of the process executable ({:#x})".format(
170+
base
171+
)
172+
elif vads[base].protection != "PAGE_EXECUTE_WRITECOPY":
173+
yield "Unexpected protection ({}) for VAD hosting the process executable ({:#x}) with path {}".format(
174+
vads[base].protection, base, vads[base].path
175+
)
176+
177+
def _check_dlls_protection(
178+
self, _, vads: Dict[int, VadData], dlls: Dict[int, DLLData]
179+
) -> Generator[str, None, None]:
180+
for dll_base in dlls:
181+
# could be malicious but triggers too many FPs from smear
182+
if dll_base not in vads:
183+
continue
184+
185+
# PAGE_EXECUTE_WRITECOPY is the only valid permission for mapped DLLs and .exe files
186+
if vads[dll_base].protection != "PAGE_EXECUTE_WRITECOPY":
187+
yield "Unexpected protection ({}) for DLL in the PEB's load order list ({:#x}) with path {}".format(
188+
vads[dll_base].protection, dll_base, dlls[dll_base].path
189+
)
190+
191+
def _generator(self, procs):
192+
checks = [
193+
self._check_load_address,
194+
self._check_exe_protection,
195+
self._check_dlls_protection,
196+
]
197+
198+
for proc in procs:
199+
# smear and/or terminated process
200+
dlls = self._get_dlls_map(proc)
201+
if len(dlls) < 3:
202+
continue
203+
204+
vads = self._get_vads_data(proc)
205+
if len(vads) < 5:
206+
continue
207+
208+
proc_name = utility.array_to_string(proc.ImageFileName)
209+
pid = proc.UniqueProcessId
210+
211+
for check in checks:
212+
for note in check(proc, vads, dlls):
213+
yield 0, (
214+
pid,
215+
proc_name,
216+
note,
217+
)
218+
219+
def run(self):
220+
filter_func = pslist.PsList.create_pid_filter(self.config.get("pid", None))
221+
kernel = self.context.modules[self.config["kernel"]]
222+
223+
return renderers.TreeGrid(
224+
[
225+
("PID", int),
226+
("Process", str),
227+
("Notes", str),
228+
],
229+
self._generator(
230+
pslist.PsList.list_processes(
231+
context=self.context,
232+
layer_name=kernel.layer_name,
233+
symbol_table=kernel.symbol_table_name,
234+
filter_func=filter_func,
235+
)
236+
),
237+
)

0 commit comments

Comments
 (0)