Saturday, May 2, 2026

Volatility Volshell & Custom Plugins Beginner's Guide

Memory forensics gets a mention, from time to time in DFIR, sometimes feels like filesystem artifacts get more of the spotlight.  Also seems like resources for memory forensics are "here is a cheatsheet of Volatility commands...figure out the rest". 

This guide is meant to be a practical walkthrough of volshell for interactive artifact hunting, followed by building your own Volatility plugin in Python — specifically aimed at finding what traces of artifacts left in process memory.


Why Memory Forensics ?

Lets take it from the perspective of messaging apps and how it can help with forensics. Signal encrypts its local database and holds the key in memory. Telegram Desktop encrypts its cache. Many apps using the Electron framework (Discord, Slack, WhatsApp Desktop) keep parsed message objects in a JavaScript heap that never touches disk in plaintext form.

Memory changes the equation. When an app is running, the decrypted content exists in process memory — by definition, because the CPU has to be able to read it. A memory acquisition at the right moment captures:

  • Decrypted message content that is encrypted on disk
  • Recipient and sender identifiers, conversation IDs, and timestamps
  • Cached content (images, files) that may have already been deleted from disk
  • Session keys and tokens that can unlock further analysis
  • Artifacts from apps that store nothing locally at all (browser-based apps, certain mobile app classes)

The tradeoff is volatility itself: the window to capture this data closes when the process exits or the machine powers off. Below is a step-by-step setup for how to use volatility and 

Setup: Volatility 3 and a Memory Image

This guide uses Volatility 3 (not the legacy Volatility 2). The APIs, plugin structure, and volshell interface differ meaningfully between versions — if you find older tutorials online referencing vol.py with profile flags like --profile=Win10x64, they are Volatility 2 guides.

Install Volatility 3:

git clone https://github.com/volatilityfoundation/volatility3.git
cd volatility3
pip install -e .
pip install pefile yara-python

You'll need a memory image to work with. For practice, the MemLabs CTF challenge images are publicly available and cover real Windows memory scenarios. For lab work, tools like WinPmem (Windows), LiME (Linux), and osxpmem (macOS) produce acquisition images from live systems.

Verify your install with a basic plugin run against your image:

python vol.py -f memory.dmp windows.pslist

If you get a process list, you're ready. Volatility 3 auto-detects the OS and architecture from the image — no profile flag required.


Volshell: Interactive Memory Exploration

Volatility's standard plugins are useful for known artifacts. Volshell is where you go when you don't yet know what you're looking for, or when you need to poke at memory interactively before writing a formal plugin. Think of it as a Python REPL with the memory image loaded and Volatility's full API available.

Launch volshell against a Windows image:

python vol.py -f memory.dmp windows.volshell

You'll land in an interactive Python session. The context object self gives you access to the loaded image. A set of shortcut functions is pre-loaded to make navigation faster.

Core Volshell Commands

Command What it does
ps() List running processes (PID, PPID, name, offset)
cc(pid=<PID>) Change context — set the current process for memory reads
proc() Return the current process object
space() Return the current process virtual address space
db(addr, count=128) Display bytes at address (hex + ASCII)
dd(addr) Display doublewords (32-bit values)
dq(addr) Display quadwords (64-bit values)
dt("_EPROCESS", addr) Display a typed structure at an address

Finding a Target Process

Start by listing processes and finding your messaging app. Here we're looking for Discord, which runs as an Electron app:

>>> ps()
PID    PPID   Name             Offset
4      0      System           0xfa80...
...
3412   3388   Discord.exe      0xfa80...
3456   3412   Discord.exe      0xfa80...   <-- renderer process
3498   3412   Discord.exe      0xfa80...   <-- GPU process

Electron apps spawn multiple processes. The renderer process is where message content lives — it's the Chromium renderer that executes the JavaScript and holds the parsed message objects in its V8 heap. The renderer is typically not the first Discord.exe in the list; it will have the main process as its parent.

Switch context to the renderer process:

>>> cc(pid=3456)
Current context: Discord.exe @ 0xfa80..., pid=3456

Scanning Process Memory for Strings

The most direct approach for Electron apps is scanning process memory for recognizable string patterns. Discord message payloads follow a predictable JSON structure — "content", "channel_id", "author" — are good anchor strings.

In volshell, you can scan the process address space manually:

>>> # Get the address space for the current process
>>> addr_space = space()

>>> # Search for a pattern across all mapped regions
>>> search_bytes = b'"content"'
>>> for region in addr_space.get_valid_layers():
...     pass  # use the scanner approach below instead

Volatility 3 provides a scanner framework. The cleaner approach is to use BytesScanner from within volshell:

>>> from volatility3.framework.layers import scanner
>>> from volatility3.framework import interfaces

>>> # Scan process memory for "channel_id" (Discord message JSON key)
>>> target = b'"channel_id"'
>>> layer = space()
>>> hits = list(layer.scan(
...     context=self.context,
...     scanner=scanner.BytesScanner(needle=target)
... ))
>>> print(f"Found {len(hits)} hits")
Found 47 hits

>>> # Examine the region around the first hit
>>> db(hits[0], count=256)

The db() output will show you raw bytes with an ASCII representation. You're looking for readable JSON surrounding your anchor string — message content, usernames, timestamps.

Reading a Memory Region as Text

Once you have a hit address, you can extract a larger chunk and parse it as text:

>>> hit_addr = hits[0]
>>> # Read 2KB starting 512 bytes before the hit (catch leading JSON)
>>> raw = space().read(hit_addr - 512, 2048)
>>> print(raw.decode('utf-8', errors='replace'))

You'll get messy output mixed with non-printable bytes, but readable JSON fragments will surface. For Electron apps, look for complete JSON objects bracketed by { and } — these often contain entire message payloads with author information intact.


App-Specific Memory Artifacts: What to Hunt

Different apps leave different artifact patterns in memory. Here's where to focus for common third-party messaging apps.

Discord (Electron / V8 Heap)

Discord's desktop client is built on Electron. Message data is stored in the Chromium renderer's V8 JavaScript heap as serialized objects. Useful search strings:

  • "channel_id" — anchors message JSON objects
  • "author":{ — precedes author username and discriminator
  • "timestamp" — ISO 8601 timestamp tied to each message
  • discord.com/api/v — API endpoint strings indicating active request context

Discord's authentication token is a high-value artifact. Search for Authorization: (note the trailing space) in the renderer process — this surfaces the bearer token used for API calls, which can be used to retrieve message history via the API if the account is still active.

Slack (Electron / V8 Heap)

Slack Desktop is also Electron-based, with similar V8 heap structure. Key search patterns:

  • "client_msg_id" — unique message identifier in Slack's internal API format
  • "team_id" — workspace identifier
  • xoxs- or xoxb- — Slack token prefixes; finding one in memory gives you API access to the workspace
  • .slack.com/api/ — API call strings with endpoint paths

Signal Desktop (Electron + Encrypted SQLCipher DB)

Signal Desktop is more complex. The local database is encrypted with SQLCipher, but the encryption key is loaded into the renderer process memory at runtime. Search patterns:

  • "body": — message body field in Signal's internal message format
  • "conversationId" — links messages to contacts or groups
  • "key":" followed by 64 hex characters — the SQLCipher database key (if you find this, you can decrypt the on-disk database)
  • +1 followed by 10 digits — phone numbers stored in contact records

The SQLCipher key is the crown jewel for Signal analysis. Once extracted from memory, it can be passed to SQLCipher-aware tools to decrypt the on-disk database at %AppData%\Signal\sql\db.sqlite, giving you the complete message history.

Telegram Desktop (Qt / Custom Storage)

Telegram Desktop is written in C++ using Qt — not Electron. Memory artifacts are structured differently. Look for:

  • UTF-16 encoded message strings (common in Qt's QString — you'll see null bytes between each character)
  • t.me/ strings — channel and user deep links
  • Phone number patterns in the user cache region of memory

For UTF-16 scanning in volshell, encode your search term accordingly:

>>> # Search for UTF-16 LE encoded string (Qt QString format)
>>> target_utf16 = "Telegram".encode('utf-16-le')
>>> hits = list(space().scan(
...     context=self.context,
...     scanner=scanner.BytesScanner(needle=target_utf16)
... ))

Writing a Custom Volatility 3 Plugin in Python

Volshell is for interactive exploration. Once you know what you're looking for, a plugin turns that exploration into a repeatable, shareable tool you can run across multiple images.

Plugin Architecture: The Essentials

Every Volatility 3 plugin is a Python class that inherits from PluginInterface. Three things are required:

  1. _required_framework_version — the minimum Volatility 3 framework version
  2. get_requirements() — declares what the plugin needs (OS, layer, symbol tables)
  3. run() — the entry point that returns a TreeGrid of results

Complete Example: Discord Message Extractor

Here is a complete, working Volatility 3 plugin that scans for Discord processes, searches their memory for message JSON fragments, and outputs readable results. Save this as volatility3/plugins/windows/discord_messages.py:

import logging
import re
from typing import List, Iterator, Tuple

from volatility3.framework import interfaces, renderers, constants
from volatility3.framework.configuration import requirements
from volatility3.framework.layers import scanner
from volatility3.plugins.windows import pslist

vollog = logging.getLogger(__name__)


class DiscordMessages(interfaces.plugins.PluginInterface):
    """Extract Discord message fragments from process memory."""

    _required_framework_version = (2, 0, 0)
    _version = (1, 0, 0)

    @classmethod
    def get_requirements(cls) -> List[interfaces.configuration.RequirementInterface]:
        return [
            requirements.ModuleRequirement(
                name="kernel",
                description="Windows kernel",
                architectures=["Intel32", "Intel64"],
            ),
            requirements.PluginRequirement(
                name="pslist",
                plugin=pslist.PsList,
                version=(2, 0, 0),
            ),
            requirements.IntRequirement(
                name="context_bytes",
                description="Bytes to capture around each hit",
                default=512,
                optional=True,
            ),
        ]

    def _get_discord_procs(self) -> Iterator:
        """Yield process objects for all Discord.exe instances."""
        kernel = self.context.modules[self.config["kernel"]]
        for proc in pslist.PsList.list_processes(
            context=self.context,
            layer_name=kernel.layer_name,
            symbol_table=kernel.symbol_table_name,
        ):
            try:
                name = proc.ImageFileName.cast(
                    "string",
                    max_length=proc.ImageFileName.vol.count,
                    errors="replace",
                ).lower()
                if "discord" in name:
                    yield proc
            except Exception:
                continue

    def _scan_process_memory(
        self, proc, needle: bytes, context_bytes: int
    ) -> Iterator[Tuple[int, bytes]]:
        """Scan a process's address space for needle, yielding (offset, surrounding_bytes)."""
        try:
            proc_layer_name = proc.add_process_layer()
            proc_layer = self.context.layers[proc_layer_name]
        except Exception as e:
            vollog.debug(f"Could not create process layer: {e}")
            return

        for hit in proc_layer.scan(
            context=self.context,
            scanner=scanner.BytesScanner(needle=needle),
        ):
            try:
                start = max(0, hit - context_bytes)
                raw = proc_layer.read(start, context_bytes * 2, pad=True)
                yield hit, raw
            except Exception:
                continue

    def _extract_json_fragment(self, raw: bytes) -> str:
        """Pull the most readable JSON fragment from raw bytes."""
        # Decode with replacement for non-UTF8 bytes
        text = raw.decode("utf-8", errors="replace")
        # Find content between first { and last } in the window
        match = re.search(r'\{[^{}]{20,}\}', text)
        if match:
            return match.group(0)[:400]  # cap output length
        # Fall back to printable ASCII run
        printable = re.sub(r'[^\x20-\x7e]', ' ', text)
        return printable[:400].strip()

    def run(self) -> interfaces.renderers.TreeGrid:
        context_bytes = self.config.get("context_bytes", 512)

        # Discord message JSON keys to search for
        search_terms = [
            b'"channel_id"',
            b'"content"',
            b'"author":{',
        ]

        rows = []
        seen_offsets = set()

        for proc in self._get_discord_procs():
            pid = int(proc.UniqueProcessId)
            ppid = int(proc.InheritedFromUniqueProcessId)

            for needle in search_terms:
                for offset, raw in self._scan_process_memory(
                    proc, needle, context_bytes
                ):
                    if offset in seen_offsets:
                        continue
                    seen_offsets.add(offset)

                    fragment = self._extract_json_fragment(raw)
                    rows.append((0, (
                        pid,
                        ppid,
                        needle.decode("utf-8"),
                        hex(offset),
                        fragment,
                    )))

        return renderers.TreeGrid(
            [
                ("PID", int),
                ("PPID", int),
                ("Matched Key", str),
                ("Offset", str),
                ("Fragment", str),
            ],
            iter(rows),
        )

Run the plugin:

python vol.py -f memory.dmp windows.discord_messages
python vol.py -f memory.dmp windows.discord_messages --context-bytes 1024

The output will be a table of hits: the PID, which JSON key triggered the hit, the memory offset, and the surrounding JSON fragment. Post-process the Fragment column with a simple Python script to extract clean message content.

Adapting the Plugin for Other Apps

To target a different app, change two things:

  1. The process name filter in _get_discord_procs() — rename the method and update the string match (e.g., "slack", "signal", "telegram")
  2. The search terms list — replace with the anchor strings you identified in volshell exploration for the target app

For UTF-16 targets like Telegram Desktop, encode your search terms as b'\x74\x00\x2e\x00\x6d\x00\x65\x00' (the UTF-16 LE encoding of t.me) and update your fragment extractor to handle UTF-16 decoding:

text = raw.decode("utf-16-le", errors="replace")

Practical Workflow: From Acquisition to Artifacts

Putting it together into an end-to-end workflow for a messaging app investigation:

  1. Acquire memory while the target app is running. Use WinPmem or a hardware imager. Time matters — acquiring within minutes of the event window is significantly better than hours later for cached message content.
  2. Run pslist first (windows.pslist) to confirm the target process was running at acquisition time and note all PIDs.
  3. Open volshell and switch context to the target PID. Run a quick string scan with a few candidate anchor terms. Inspect hits with db() to confirm you're finding the right structure.
  4. Refine your search terms based on what you found interactively. Note which terms produce clean hits and which produce noise.
  5. Build or adapt a plugin using the pattern above. Run it against the image and export output for review.
  6. Cross-reference with disk artifacts — SQLite databases, log files, registry entries — to corroborate and extend the memory findings.

Common Pitfalls for Beginners

  • Scanning the wrong process. Electron apps spawn 5–10 processes. The main process rarely holds message data — the renderer process does. Use ppid relationships to identify the renderer and switch context there.
  • Assuming ASCII encoding. Qt applications (Telegram, some others) use UTF-16. If your string scan returns zero hits and you're certain the app was active, try encoding your search term as UTF-16 LE.
  • Reading too small a context window. The default 128 bytes from db() will often only show you part of a message object. Read at least 512–1024 bytes to capture full JSON structures.
  • Not accounting for garbage-collected memory. The V8 JavaScript heap is garbage-collected. Message objects may have been collected if the conversation was scrolled out of view. Focus on recently active conversations for the best hit rate.
  • Missing paged-out memory. Volatility will pad unreadable pages. Zero-filled runs in your output (00 00 00 00) indicate paged-out content that wasn't present in physical memory at acquisition time — this is expected and not a tool error.

Where to Go Next

  • Volatility 3 documentation — the official ReadTheDocs site covers the full plugin API and framework internals
  • MemLabs CTFgithub.com/stuxnet999/MemLabs — free memory images with progressively complex challenges
  • forensafe memory series — covers certificate extraction and other artifact classes in depth
  • YARA integration — Volatility 3 supports YARA rule scanning via windows.vadyarascan; once you've characterized an artifact pattern in volshell, encoding it as a YARA rule lets you scan memory images at scale

This post is part of the ForensicFellowship blog.  Feedback and corrections welcome.

Recent Posts