Volatility-based Workflow for Forensic Memory Analysis¶
Tool Requirements¶
- Use Rich Output for all user feedback
- Create a Click-based CLI using
rich_click - Name the main CLI
ssf_toolscommand - Create a sub-command
volatilityto run the following workflow - Use UV for all package management
- Assume Windows as the primary user platform for running the tool
Inputs and Other Operating Requirements¶
Inputs (from the user):
| Item | Variable Name | Required/Optional | Default |
| === | === | === | === |
| Path to a RAM image file | image_file | Required | None |
| Image file platform (windows, mac, linux) | image_platform | Required | None |
| Path to PID List output | pid_list_file | Optional | pid-list.txt |
| Path to list of interesting process names | interesting_processes_file | Required | None |
| Results path | results_dir | Optional | Path(image_file).parent) / f"volatility/{image_file}.name" |
If operating on Windows, root_command = "vol.exe", otherwise it's just root_command = vol
Input Validation:
Validate that image_platform is one of the allowed values using Pydantic:
from enum import StrEnum
from pydantic import ValidationError
class ImagePlatforms(StrEnum):
"""Supported image platforms for Volatility analysis."""
WINDOWS = "windows"
MAC = "mac"
LINUX = "linux"
# Validation occurs automatically when creating the VolatilityInputModel
try:
input_model = VolatilityInputModel(
image_file=image_file,
image_platform=image_platform.lower(), # normalize to lowercase
interesting_processes_file=interesting_processes_file,
results_dir=results_dir
)
except ValidationError as e:
# Display error message using Rich
console.print(f"[red]Error:[/red] Invalid platform '{image_platform}'. Must be one of: {', '.join([p.value for p in ImagePlatforms])}")
raise click.BadParameter(f"Platform must be one of: {', '.join([p.value for p in ImagePlatforms])}")
Check that the command is available and print an error message to install Volatility with:
Dependency Validation: Check that the volatility command is available on the system PATH:
import shutil
# Check if volatility command exists
if not shutil.which(root_command):
console.print(f"[red]Error:[/red] Volatility command '{root_command}' not found on PATH.")
console.print("Install Volatility with: [bold]pipx install volatility3[full][/bold]")
raise click.ClickException(f"Required command '{root_command}' not found")
Output Format Requirements:
# JSON output formatting example
import json
with open(json_file, 'w') as f:
json.dump(interesting_pids, f, indent=4, sort_keys=True)
# Rich console styling examples
console.print("[red]Error:[/red] Something went wrong")
console.print("[yellow]Warning:[/yellow] This might be an issue")
Data Models¶
All data models used by the application are created using Pydantic. A base model, SSFToolsBaseModel, is defined to set configurations common to all other data models. All other models inherit from this base.
# src/kp_ssf_tools/volatility/models/base.py
from enum import StrEnum
from pydantic import Field
from typing import List, Dict
from pathlib import Path
from kp_ssf_tools.models.base import SSFToolsBaseModel
class ImagePlatforms(StrEnum):
"""Supported image platforms for Volatility analysis."""
WINDOWS = "windows"
MAC = "mac"
LINUX = "linux"
class VolatilityInputModel(SSFToolsBaseModel):
"""User-supplied inputs for the Volatility workflow."""
image_file: Path
image_platform: ImagePlatforms
pid_list_file: Path | None = None
interesting_processes_file: Path
results_dir: Path | None = None
class ProcessEntry(SSFToolsBaseModel):
"""Represents a process entry from the PID list."""
pid: int
process_name: str
# Add more fields as needed from Volatility output
class InterestingPIDsModel(SSFToolsBaseModel):
"""Mapping of interesting process names to their PIDs."""
interesting_pids: Dict[str, int]
class HandlesFileResult(SSFToolsBaseModel):
"""Result of extracting file handles for a process."""
pid: int
process_name: str
handles_output_file: Path
class MemoryDumpResult(SSFToolsBaseModel):
"""Result of extracting memory dump for a process."""
pid: int
process_name: str
dump_file: Path
# Additional models can be added as the workflow expands (e.g., for error reporting, user prompts, etc.)
Initial Setup¶
- Create a list of interesting processes, saved in
interesting-processes.txt. Processes will be saved each on one line. The text file could use either Windows (CR/LF) or Linux/MacOS/Unix line endings (LF) - Relative to the
image_filefolder (Path(image_file).parent), create a directory structure asvolatility\Path(image_file).name(just the name portion of the image file without extension). Create all folders in the path if needed.
Example:
* The image file's absolute path is image_file = Path("D:\images\device_name\ram-20250808-110300.dd").
* Create a directory structure as results_dir = Path(image_file).parent / Path(image_file).stem).
Directory Name Conflict Resolution If a directory by the same name already exists:
- Prompt the user to:
- Provide a new name -- use the new name in the same manner as through it was provided on the command line at invocation
- Delete all existing files -- Remove all existing files so that new ones can be created throughout the workflow
- Quit -- Using CTRL-C, handle the keyboard interrupt gracefully
Extract PIDs¶
- Extract the list of all process IDs (PID) from the RAM capture image using:
- Save the results to
Path(results_dir) / "pid-list.txt"or a filename specified by the user - Extract the PID for each
interesting-processes.txtentry frompid-list.txtusing regular expressions. The format of thepid-list.txtfile is as follows:
Volatility 3 Framework 2.26.0
PID PPID ImageFileName Offset(V) Threads Handles SessionId Wow64 CreateTime ExitTime File output
4 0 System 0xc20b1faac080 136 - N/A False 2025-08-06 16:26:07.000000 UTC N/A Disabled
108 4 Registry 0xc20b1fbeb040 4 - N/A False 2025-08-06 16:26:05.000000 UTC N/A Disabled
376 4 smss.exe 0xc20b21724040 2 - N/A False 2025-08-06 16:26:07.000000 UTC N/A Disabled
528 468 csrss.exe 0xc20b23752140 11 - 0 False 2025-08-06 16:26:09.000000 UTC N/A Disabled
Platform-specific Python Regular Expressions:
Windows:
# Windows processes (case-insensitive, handles truncation - extensions may be cut off)
r"(?P<pid>^\d+)\s+\d+\s+(?P<process_name>[A-Za-z0-9._-]+)\s+"
Linux/macOS:
# Linux/macOS processes (case-sensitive, includes brackets for kernel threads, underscores, hyphens, slashes, colons)
r"(?P<pid>^\d+)\s+\d+\s+(?P<process_name>[\[\]A-Za-z0-9._/:-]+)\s+"
Platform Selection Logic:
if image_platform.lower() == 'windows':
pid_regex = r"(?P<pid>^\d+)\s+\d+\s+(?P<process_name>[A-Za-z0-9._-]+)\s+"
else: # linux or mac
pid_regex = r"(?P<pid>^\d+)\s+\d+\s+(?P<process_name>[\[\]A-Za-z0-9._/:-]+)\s+"
Process Name Matching Logic:
When matching process names from interesting-processes.txt against the parsed pid-list.txt:
- Case Sensitivity: All matching should be case-insensitive across all platforms
- Partial Matches: Allow partial matches to handle Volatility's output truncation
- Extension Handling: Match with or without file extensions (e.g., "notepad" matches both "notepad" and "notepad.exe")
def normalize_process_name(name: str) -> str:
"""Normalize process name for matching"""
# Convert to lowercase for case-insensitive matching
normalized = name.lower()
# Define all possible extensions
extensions = ['.exe', '.com', '.bat', '.cmd', '.scr']
# First, check for complete extensions
for ext in extensions:
if normalized.endswith(ext):
return normalized[:-len(ext)]
# Handle partial extensions due to truncation
# Check all possible partial truncations of each extension
for ext in extensions:
# Generate all possible partial extensions (from 1 char to full length-1)
for length in range(1, len(ext)):
partial_ext = ext[:length] # e.g., '.', '.e', '.ex' for '.exe'
if normalized.endswith(partial_ext):
# Remove the partial extension
return normalized[:-len(partial_ext)]
# No extension found, return as-is
return normalized
def is_process_match(target: str, found: str) -> bool:
"""Check if process names match with flexible rules"""
target_norm = normalize_process_name(target)
found_norm = normalize_process_name(found)
# Exact match (preferred)
if target_norm == found_norm:
return True
# Partial match (for truncated output)
# Allow target to be a substring of found, or vice versa
return target_norm in found_norm or found_norm in target_norm
Example Matching Scenarios:
# All these normalize to "notepad" and match each other:
# "notepad.exe" → "notepad"
# "notepad.ex" → "notepad" (partial .exe)
# "notepad.e" → "notepad" (partial .exe)
# "notepad." → "notepad" (partial .exe)
# "notepad.com" → "notepad"
# "notepad.co" → "notepad" (partial .com)
# "notepad.c" → "notepad" (partial .com)
# "notepad.bat" → "notepad"
# "notepad.ba" → "notepad" (partial .bat)
# "notepad.b" → "notepad" (partial .bat)
# "notepad" → "notepad" (no extension)
# Comprehensive matching examples:
# Target: "notepad" → Found: any of the above ✓
# Target: "chromedriver" → Found: "chromedri" ✓ (name truncation)
# Target: "NOTEPAD.EXE" → Found: "notepad.e" ✓ (case + partial extension)
- Save the results as a dictionary with the following fields:
Handling Duplicate Process Names:
When multiple instances of the same process are found, append a numeric suffix:
- First instance: process_name (no suffix)
- Second instance: process_name_2
- Third instance: process_name_3
- And so on...
# Example with duplicates:
interesting_pids = {
"notepad": 1234, # First notepad instance
"notepad_2": 5678, # Second notepad instance
"chrome": 9876, # Single chrome instance
"svchost": 2468, # First svchost instance
"svchost_2": 1357, # Second svchost instance
"svchost_3": 9753, # Third svchost instance
}
-
Identify any process names that were listed in
interesting_processes.txtbut that were not found ininteresting_pidsdictionary and ask the user to confirm. -
Export the
interesting_pidsto asPath(results_dir) / "interesting_pids.json"file using pretty (human-readable) output in JSON format.
Extracting File Handles¶
For each pid value in the interesting_pids dictionary:
- Extract the file handles with:
- Append the results to
Path(results_dir) / "handles.txt"
Extract PID Program and Data Memory¶
For each pid value in the interesting_pids dictionary:
- Extract the PID program and data with:
- The files will be named after the pattern
- Using the
interesting_pidsdictionary, rename them to