Skip to content

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_tools command
  • Create a sub-command volatility to 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:

pipx install volatility3[full]

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

  1. 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)
  2. Relative to the image_file folder (Path(image_file).parent), create a directory structure as volatility\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:

  1. Prompt the user to:
  2. Provide a new name -- use the new name in the same manner as through it was provided on the command line at invocation
  3. Delete all existing files -- Remove all existing files so that new ones can be created throughout the workflow
  4. Quit -- Using CTRL-C, handle the keyboard interrupt gracefully

Extract PIDs

  1. Extract the list of all process IDs (PID) from the RAM capture image using:
f"{root_command} -f {image_file} {image_platform}.pslist"
  1. Save the results to Path(results_dir) / "pid-list.txt" or a filename specified by the user
  2. Extract the PID for each interesting-processes.txt entry from pid-list.txt using regular expressions. The format of the pid-list.txt file 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)

  1. Save the results as a dictionary with the following fields:
interesting_pids: dict[str, int] = {
    process_name: pid,
}

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
}
  1. Identify any process names that were listed in interesting_processes.txt but that were not found in interesting_pids dictionary and ask the user to confirm.

  2. Export the interesting_pids to as Path(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:

  1. Extract the file handles with:
f"{root_command} -f {image_file} {image_platform}.handles --pid <pid>"
  1. Append the results to Path(results_dir) / "handles.txt"

Extract PID Program and Data Memory

For each pid value in the interesting_pids dictionary:

  1. Extract the PID program and data with:
f"{root_command} -f {image_file} -o {results_dir} {image_platform}.memmap --pid {pid} --dump"
  1. The files will be named after the pattern
f"pid.{pid}.dmp"
  1. Using the interesting_pids dictionary, rename them to
f"{process_name}.dmp"