-
Notifications
You must be signed in to change notification settings - Fork 605
Sandbox Checking Volatility3 Plugin #1906
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: develop
Are you sure you want to change the base?
Changes from all commits
22506a9
9e066f0
cc59d06
b4198d6
aa9a476
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,379 @@ | ||
| import logging | ||
| from typing import List, Dict, Tuple | ||
| from collections import defaultdict | ||
| from volatility3.framework import interfaces, renderers | ||
| from volatility3.framework.configuration import requirements | ||
| from volatility3.plugins.windows import pslist, dlllist, registry | ||
|
|
||
| vollog = logging.getLogger(__name__) | ||
|
|
||
| class SandboxDetect(interfaces.plugins.PluginInterface): | ||
| """Detects sandbox artifacts in Windows memory dumps.""" | ||
| _required_framework_version = (2, 0, 0) | ||
| _version = (1, 0, 0) | ||
|
|
||
| # Sandbox indicators | ||
| SANDBOX_PROCESSES = { | ||
| 'vmtoolsd.exe': 'VMware Tools', | ||
| 'vmwaretray.exe': 'VMware Tray', | ||
| 'vmwareuser.exe': 'VMware User Process', | ||
| 'vboxservice.exe': 'VirtualBox Service', | ||
| 'vboxtray.exe': 'VirtualBox Tray', | ||
| 'vmsrvc.exe': 'VirtualPC Service', | ||
| 'vmusrvc.exe': 'VirtualPC User Service', | ||
| 'prl_cc.exe': 'Parallels Coherence', | ||
| 'prl_tools.exe': 'Parallels Tools', | ||
| 'xenservice.exe': 'Xen Service', | ||
| 'qemu-ga.exe': 'QEMU Guest Agent', | ||
| 'wireshark.exe': 'Network Analysis Tool', | ||
| 'procmon.exe': 'Process Monitor', | ||
| 'procexp.exe': 'Process Explorer', | ||
| 'ollydbg.exe': 'Debugger', | ||
| 'x64dbg.exe': 'Debugger', | ||
| 'idaq.exe': 'IDA Pro Debugger', | ||
| 'idaq64.exe': 'IDA Pro Debugger', | ||
| 'windbg.exe': 'Windows Debugger', | ||
| 'sandboxie.exe': 'Sandboxie', | ||
| 'cuckoo.exe': 'Cuckoo Sandbox', | ||
| 'agent.pyw': 'Cuckoo Agent', | ||
| 'fiddler.exe': 'HTTP Proxy/Debugger', | ||
| 'regmon.exe': 'Registry Monitor', | ||
| 'filemon.exe': 'File Monitor', | ||
| } | ||
|
|
||
| SANDBOX_DLLS = [ | ||
| 'sbiedll.dll', # Sandboxie | ||
| 'dbghelp.dll', # Debugging | ||
| 'api_log.dll', # API hooking | ||
| 'vmGuestLib.dll', # VMware | ||
| 'vboxmrxnp.dll', # VirtualBox | ||
| 'VBoxHook.dll', # VirtualBox | ||
| 'prltools.dll', # Parallels | ||
| ] | ||
|
|
||
| VM_ARTIFACTS = [ | ||
| 'vmware', | ||
| 'vbox', | ||
| 'virtualbox', | ||
| 'qemu', | ||
| 'xen', | ||
| 'parallels', | ||
| 'virtual', | ||
| 'bochs', | ||
| 'hyperv', | ||
| 'kvm', | ||
| ] | ||
|
|
||
| @classmethod | ||
| def get_requirements(cls) -> List[interfaces.configuration.RequirementInterface]: | ||
| return [ | ||
| requirements.ModuleRequirement( | ||
| name="kernel", | ||
| description="Windows kernel module", | ||
| architectures=["Intel32", "Intel64"], | ||
| ), | ||
| requirements.BooleanRequirement( | ||
| name="verbose", | ||
| description="Show detailed findings", | ||
| optional=True, | ||
| default=False | ||
| ), | ||
| ] | ||
|
|
||
| def _check_processes(self, kernel_module_name: str) -> Tuple[int, List[Dict]]: | ||
| """Check for sandbox-related processes.""" | ||
| detections = [] | ||
| score = 0 | ||
|
|
||
| for proc in pslist.PsList.list_processes(self.context, kernel_module_name): | ||
| try: | ||
| proc_name = proc.ImageFileName.cast( | ||
| "string", | ||
| max_length=proc.ImageFileName.vol.count, | ||
| errors="replace" | ||
| ).lower() | ||
|
|
||
| proc_pid = proc.UniqueProcessId | ||
|
|
||
| # Get process path | ||
| try: | ||
| peb = proc.get_peb() | ||
| if peb: | ||
| process_params = peb.ProcessParameters | ||
| if process_params: | ||
| image_path = process_params.ImagePathName.get_string() | ||
| proc_path = image_path if image_path else "N/A" | ||
| else: | ||
| proc_path = "N/A" | ||
| else: | ||
| proc_path = "N/A" | ||
| except Exception: | ||
| proc_path = "N/A" | ||
|
|
||
| if proc_name in self.SANDBOX_PROCESSES: | ||
| score += 10 | ||
| detections.append({ | ||
| 'type': 'Process', | ||
| 'indicator': proc_name, | ||
| 'description': self.SANDBOX_PROCESSES[proc_name], | ||
| 'severity': 'High', | ||
| 'info': f'PID: {proc_pid} | Path: {proc_path}' | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please don't include two fields as a string. This makes the data difficult to use programmatically. It would be better to return the two fields separately (particularly since if they're not available they should be derived from BaseAbsentValue). |
||
| }) | ||
|
|
||
| # Check for VM-related keywords in process names | ||
| for artifact in self.VM_ARTIFACTS: | ||
| if artifact in proc_name and proc_name not in self.SANDBOX_PROCESSES: | ||
| score += 5 | ||
| detections.append({ | ||
| 'type': 'Process', | ||
| 'indicator': proc_name, | ||
| 'description': f'VM-related keyword: {artifact}', | ||
| 'severity': 'Medium', | ||
| 'info': f'PID: {proc_pid} | Path: {proc_path}' | ||
| }) | ||
| break | ||
|
|
||
| except Exception as e: | ||
| vollog.debug(f"Error checking process: {e}") | ||
| continue | ||
|
|
||
| return score, detections | ||
|
|
||
| def _check_dlls(self, kernel_module_name: str) -> Tuple[int, List[Dict]]: | ||
| """Check for sandbox-related DLLs.""" | ||
| detections = [] | ||
| score = 0 | ||
| checked_dlls = set() | ||
|
|
||
| for proc in pslist.PsList.list_processes(self.context, kernel_module_name): | ||
| try: | ||
| proc_name = proc.ImageFileName.cast( | ||
| "string", | ||
| max_length=proc.ImageFileName.vol.count, | ||
| errors="replace" | ||
| ) | ||
|
|
||
| for entry in proc.load_order_modules(): | ||
| try: | ||
| dll_name = entry.BaseDllName.get_string().lower() | ||
|
|
||
| if dll_name in checked_dlls: | ||
| continue | ||
|
|
||
| checked_dlls.add(dll_name) | ||
|
|
||
| if dll_name in self.SANDBOX_DLLS: | ||
| score += 8 | ||
| detections.append({ | ||
| 'type': 'DLL', | ||
| 'indicator': dll_name, | ||
| 'description': 'Sandbox/Analysis DLL', | ||
| 'severity': 'High', | ||
| 'process': proc_name | ||
| }) | ||
|
|
||
| # Check for VM-related DLL names | ||
| for artifact in self.VM_ARTIFACTS: | ||
| if artifact in dll_name and dll_name not in self.SANDBOX_DLLS: | ||
| score += 3 | ||
| detections.append({ | ||
| 'type': 'DLL', | ||
| 'indicator': dll_name, | ||
| 'description': f'VM-related DLL: {artifact}', | ||
| 'severity': 'Low', | ||
| 'process': proc_name | ||
| }) | ||
| break | ||
|
|
||
| except Exception: | ||
| continuenue | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This looks like a typo?
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It may also be worth logging the error that occurs here, particularly if you're blanket catching all exceptions. Ideally you'd only catch expected exceptions, but if you want to catch them all, there should be at least a way for developers to know something's gone wrong (such as a |
||
|
|
||
| except Exception as e: | ||
| vollog.debug(f"Error checking DLLs: {e}") | ||
| continue | ||
|
|
||
| return score, detections | ||
|
|
||
| def _check_system_info(self, kernel_module_name: str) -> Tuple[int, List[Dict]]: | ||
| """Check system information for sandbox indicators.""" | ||
| detections = [] | ||
| score = 0 | ||
|
|
||
| # Count total processes - sandboxes often have fewer processes | ||
| process_count = 0 | ||
| process_names = [] | ||
| for proc in pslist.PsList.list_processes(self.context, kernel_module_name): | ||
| process_count += 1 | ||
| try: | ||
| proc_name = proc.ImageFileName.cast( | ||
| "string", | ||
| max_length=proc.ImageFileName.vol.count, | ||
| errors="replace" | ||
| ).lower() | ||
| process_names.append(proc_name) | ||
| except Exception: | ||
| continue | ||
|
|
||
| # Check for very low process count | ||
| if process_count < 20: | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This seems like an arbitrary value. It might be worth parameterizing it, and collecting it at the top of the class, to allow other plugins to change it without having to rewrite the code completely. |
||
| score += 10 | ||
| detections.append({ | ||
| 'type': 'System', | ||
| 'indicator': f'Very low process count: {process_count}', | ||
| 'description': 'Critical: Typical systems run 40+ processes', | ||
| 'severity': 'High', | ||
| 'info': 'Minimal process environment detected' | ||
| }) | ||
| elif process_count < 30: | ||
| score += 5 | ||
| detections.append({ | ||
| 'type': 'System', | ||
| 'indicator': f'Low process count: {process_count}', | ||
| 'description': 'Sandboxes typically run fewer processes', | ||
| 'severity': 'Medium', | ||
| 'info': 'Limited process environment' | ||
| }) | ||
|
|
||
| # Check for missing common Windows processes | ||
| common_processes = ['explorer.exe', 'svchost.exe', 'lsass.exe', 'services.exe', 'winlogon.exe'] | ||
| missing_processes = [p for p in common_processes if p not in process_names] | ||
|
|
||
| if len(missing_processes) >= 2: | ||
| score += 8 | ||
| detections.append({ | ||
| 'type': 'System', | ||
| 'indicator': f'Missing critical processes: {", ".join(missing_processes)}', | ||
| 'description': 'Essential Windows processes not running', | ||
| 'severity': 'High', | ||
| 'info': f'Found processes: {", ".join(process_names[:10])}...' | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Again, combining values into a single string is very useful for humans but would require any automatic system using the plugin to parse the string you've constructed. Typically we'd expect this to output one entry per process_name (repeating all the rest of the data). It's up to you, and I won't push it, but please think of other uses of the plugin besides only the CLI and humans reading it. This is supposed to be a library that parses data that can be programmatically read by a consumer for use by all kinds of programs. |
||
| }) | ||
|
|
||
| # Check for suspicious timing (all processes started around same time) | ||
| # This would require creation time analysis | ||
|
|
||
| return score, detections | ||
|
|
||
| def _check_drivers(self, kernel_module_name: str) -> Tuple[int, List[Dict]]: | ||
| """Check for VM/sandbox drivers in loaded modules.""" | ||
| detections = [] | ||
| score = 0 | ||
|
|
||
| vm_drivers = [ | ||
| 'vmmouse.sys', 'vmhgfs.sys', 'vmci.sys', 'vboxguest.sys', | ||
| 'vboxsf.sys', 'vboxvideo.sys', 'prl_fs.sys', 'prl_tg.sys' | ||
| ] | ||
|
|
||
| # This would require kernel module enumeration | ||
| # Simplified version checking process loaded modules | ||
| checked_modules = set() | ||
|
|
||
| for proc in pslist.PsList.list_processes(self.context, kernel_module_name): | ||
| try: | ||
| proc_name = proc.ImageFileName.cast( | ||
| "string", | ||
| max_length=proc.ImageFileName.vol.count, | ||
| errors="replace" | ||
| ) | ||
| proc_pid = proc.UniqueProcessId | ||
|
|
||
| for entry in proc.load_order_modules(): | ||
| try: | ||
| dll_name = entry.BaseDllName.get_string().lower() | ||
| dll_path = entry.FullDllName.get_string() | ||
|
|
||
| if dll_name in checked_modules: | ||
| continue | ||
|
|
||
| checked_modules.add(dll_name) | ||
|
|
||
| if dll_name in vm_drivers: | ||
| score += 15 | ||
| detections.append({ | ||
| 'type': 'Driver', | ||
| 'indicator': dll_name, | ||
| 'description': 'VM/Sandbox driver detected', | ||
| 'severity': 'Critical', | ||
| 'info': f'Process: {proc_name} (PID: {proc_pid}) | Path: {dll_path}' | ||
| }) | ||
| except Exception: | ||
| continue | ||
| except Exception: | ||
| continue | ||
|
|
||
| return score, detections | ||
|
|
||
| def _calculate_verdict(self, total_score: int, detection_count: int) -> Tuple[str, str]: | ||
| """Calculate final verdict based on score and detections.""" | ||
| if total_score >= 50 or detection_count >= 5: | ||
| return "SANDBOX", "High confidence - Multiple sandbox indicators detected" | ||
| elif total_score >= 30 or detection_count >= 3: | ||
| return "LIKELY SANDBOX", "Medium confidence - Several suspicious indicators" | ||
| elif total_score >= 15 or detection_count >= 1: | ||
| return "SUSPICIOUS", "Low confidence - Few indicators detected" | ||
| else: | ||
| return "REAL MACHINE", "No significant sandbox indicators found" | ||
|
|
||
| def _generator(self): | ||
| kernel_module_name = self.config["kernel"] | ||
| verbose = self.config.get("verbose", False) | ||
|
|
||
| all_detections = [] | ||
| total_score = 0 | ||
|
|
||
| # Run all checks | ||
| vollog.info("Checking for sandbox processes...") | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This should probably be a progress_callback call, rather than a log message? It takes a float value showing the percentage progress, and a message indicating what's being progressed. |
||
| proc_score, proc_detections = self._check_processes(kernel_module_name) | ||
| all_detections.extend(proc_detections) | ||
| total_score += proc_score | ||
|
|
||
| vollog.info("Checking for sandbox DLLs...") | ||
| dll_score, dll_detections = self._check_dlls(kernel_module_name) | ||
| all_detections.extend(dll_detections) | ||
| total_score += dll_score | ||
|
|
||
| vollog.info("Checking system information...") | ||
| sys_score, sys_detections = self._check_system_info(kernel_module_name) | ||
| all_detections.extend(sys_detections) | ||
| total_score += sys_score | ||
|
|
||
| vollog.info("Checking for VM drivers...") | ||
| drv_score, drv_detections = self._check_drivers(kernel_module_name) | ||
| all_detections.extend(drv_detections) | ||
| total_score += drv_score | ||
|
|
||
| # Calculate verdict | ||
| verdict, confidence = self._calculate_verdict(total_score, len(all_detections)) | ||
|
|
||
| # Yield summary | ||
| yield (0, ( | ||
| "SUMMARY", | ||
| verdict, | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This value isn't an indicator, please find a different way of present summary information (such as a completely different field in output, only used in this row). |
||
| f"Score: {total_score} | Detections: {len(all_detections)}", | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This is two pieces of information and therefore should be two separate fields. Please don't textually combine two different pieces of data. |
||
| confidence, | ||
| "" | ||
| )) | ||
|
|
||
| yield (0, ("", "", "", "", "")) | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Please don't do this, you're abusing the data return to try to format the results for humans. As I've mention twice elsewhere, these plugins may be used by any tool that uses the library, and having to handle unusual results like this will make things more difficult for them. |
||
|
|
||
| # Yield detailed detections if verbose or if detections found | ||
| if verbose or all_detections: | ||
| for detection in all_detections: | ||
| yield (0, ( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. You can yield these with a 1, to indent them in a tree view, instead of 0 here, if you wish to differentiate them. |
||
| detection.get('type', 'N/A'), | ||
| detection.get('indicator', 'N/A'), | ||
| detection.get('description', 'N/A'), | ||
| detection.get('severity', 'N/A'), | ||
| detection.get('info', 'N/A') | ||
| )) | ||
|
|
||
| def run(self): | ||
| return renderers.TreeGrid( | ||
| [ | ||
| ("Detection Type", str), | ||
| ("Indicator", str), | ||
| ("Description", str), | ||
| ("Severity", str), | ||
| ("Additional Info", str) | ||
| ], | ||
| self._generator(), | ||
| ) | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Rather than using the string "N/A" (which can't be differentiated from the string "N/A" please use something derived from
BaseAbsentValuesuch asNotAvailableValue.