Skip to content

Memory Forensics

Memory forensics is the analysis of a system’s RAM image — the most volatile, least tamperable, most revealing artifact in digital forensics: the running processes (including the ones hiding from the API — M10’s DKOM), the injected code (the payloads that never touched disk), the credentials in use (LSASS, DPAPI master keys), the network state (sockets and connections the malware holds), and the cryptographic material (keys that decrypt H2’s encrypted regions). The craft centers on the Volatility framework (Volatility 2’s mature plugin set, Volatility 3’s architecture [verify current versions]): symbol tables per OS build, process enumeration from multiple structures (the cross-view method), kernel-object analysis, and the plugin ecosystem for each artifact class. Memory is where rootkits fail: the DKOM that hides a process from the API leaves it in the scheduler’s structures, the SSDT hook exists in the kernel image, the injected DLL sits in a private allocation — the memory image is the ground truth that H1’s live-view lies cannot touch (M10’s lesson institutionalized).

Memory forensics is the counter to the entire evasion stack this handbook’s adversary modules describe: the process hidden by DKOM (M10), the code injected into trusted processes (E2’s bypass), the fileless payload that never touched disk, the encrypted region whose key lives only in RAM (H2’s decryption oracle) — all visible in a memory image analyzed on a clean machine. The discipline’s value concentrates in three moments: the containment moment (BT4’s memory-first capture — the image taken before isolation preserves everything), the scope expansion (the memory image that reveals the second implant, the additional credentials, the lateral-movement footholds the disk never showed), and the rootkit confirmation (the cross-view diff that proves the host’s self-reporting is edited — M10’s escalation trigger). For the defender, memory forensics also validates the endpoint stack: the EDR that misses injected code, the AV that trusts API enumeration — the memory analysis of your own detection estate’s failures. For the analyst, the craft is symbol-table discipline (Volatility needs the right profile/symbols per build — the wrong symbols produce confident nonsense), plugin fluency (which plugin answers which question), and the cross-view mindset (enumerate multiple ways, diff, trust the discrepancy).

The memory analysis workflow and its artifact classes:

MEMORY FORENSICS WORKFLOW (Volatility-shaped)
[acquire] winpmem/AVML capture (BT4/BT5 order)
[profile] identify OS build -> symbols
(windows.info plugin / symbol tables)
[ENUMERATE — the cross-view core]
pslist: walks EPROCESS list (API-trust path)
psscan: pool-tag scan (finds DKOM-hidden)
DIFF: psscan-only processes = hidden = rootkit
[PROCESS DEPTH]
cmdline, dlllist, handles, malfind
malfind: RWX private memory + disasm snippet
= injected code candidates
[ARTIFACT CLASSES]
credentials: hashdump, lsadump (memory-resident)
network: netscan (sockets, connections)
code: yarascan (sig scan across RAM)
registry: registry hive recovery from RAM
[TIMELINE] mftimeline-class: memory-resident events
MALFIND SIGNATURE (the injection detector)
VAD tag: private memory, RWX protections
+ non-image backing (not a file on disk)
+ disassembly: code-shaped (not data-shaped)
-> candidate injection; RE the dump (H3 methods)
VOLATILITY 2 vs 3 [verify current state]
v2: profile-based, huge plugin set, mature
v3: symbol-table architecture, active development
both valid; symbols per-build is the hard part

Acquisition is BT5’s territory (memory-first, hashed, custody) — the analysis half starts with profiling: identifying the exact OS build and loading the right symbols (Volatility 3’s symbol tables; the wrong symbols are the discipline’s classic failure — plugins run, output is confident, everything is subtly wrong). Process enumeration is the cross-view method M10 teaches: pslist walks the active process list (what the API shows), psscan scans pool tags for EPROCESS structures (what exists), and the diff is the rootkit detector — a process in the scan but not the list is hidden by DKOM. malfind is the injection detector: per-process Virtual Address Descriptors with private RWX protections and non-file backing, plus a disassembly snippet to confirm code-shape — the fileless payload’s signature. The artifact classes extend the picture: credentials (the hashdump/lsadump class — the memory-resident secrets BT4’s evidence-first capture preserved), network state (netscan’s socket table — the C2 connections E2’s live view may have hidden), code scanning (YARA signatures across RAM — the family detection M6’s rules applied to memory), and registry recovery (hives reconstructed from memory — the config the disk no longer holds). The timeline integration (BT5) merges memory-resident events into the case chronology. The honest limits belong in every report: encrypted/compressed memory (H2’s encrypted regions apply to RAM dumps too), corrupted acquisitions, and the symbols problem — the analysis is only as good as its symbol tables, and the report states which build they came from.

TechniqueWhat it findsVolatility plugin (v2 names)Notes
Process cross-viewDKOM-hidden processespslist + psscan diffM10’s confirmation
Injection detectionFileless payloadsmalfindRWX + disasm
Credential recoveryHashes/keys in memoryhashdump, lsadumpBT4’s capture pays
Network stateC2 connectionsnetscanThe live sockets
Code signature scanKnown families in RAMyarascan(yara)M6’s rules in memory
Registry from memoryConfig the disk losthivelist/printkeyWiped-registry recovery
Driver analysisRogue driversmodules, driverscanM10’s kernel layer
Kernel object auditHooked callbackscallbacks (v2)EDR-callback removal (M10)
Timeline integrationMemory eventsmftimeline/timelinerBT5’s chronology
String/YARA triageFast evidencestrings, yarascanFirst-pass wins

memtriage.py — a memory-triage orchestrator: wraps Volatility 3 CLI (stated dependency) with a triage workflow — profile check, process cross-view (pslist vs psscan diff), malfind sweep, netscan extraction, YARA scan against a rule file — and produces a prioritized triage report (candidates ranked by cross-view discrepancy and malfind hits). Requires vol.py (Volatility 3) on PATH and a memory image. stdlib orchestration only.

#!/usr/bin/env python3
"""memtriage.py — Volatility 3 memory triage orchestrator.
Usage:
python memtriage.py --image mem.raw [--yara rules.yar] \\
[--out triage.md]
Deps: volatility3 CLI (vol/vol.py) on PATH.
Orchestrates: windows.info, pslist, psscan, malfind,
netscan, yarascan. stdlib only (subprocess).
"""
import argparse, json, os, re, subprocess, sys
def vol(image, plugin, extra=None):
cmd = ["vol", "-q", "-f", image, plugin]
if extra:
cmd += extra
try:
r = subprocess.run(cmd, capture_output=True,
text=True, timeout=600)
except FileNotFoundError:
sys.exit("volatility3 CLI 'vol' not found on PATH")
return r.stdout, r.stderr
def check_profile(image):
out, err = vol(image, "windows.info")
if not out:
return {"error": (err or "no output")[:200]}
info = {}
for line in out.splitlines():
if "kernel base" in line.lower():
info["kernel_base"] = line.split()[-1]
if "is64bit" in line.lower() or \
"64 bit" in line.lower():
info["arch64"] = True
m = re.search(r"(?i)windows.*kernel", line)
if m and "name" not in info:
info["os_hint"] = line.strip()[:60]
info["ok"] = True
return info
def pid_sets(image):
"""pslist vs psscan PIDs -> cross-view diff."""
def pids(plugin):
out, _ = vol(image, plugin)
pids = set()
for line in out.splitlines():
m = re.match(r"\s*(\d+)\s", line)
if m:
pids.add(int(m.group(1)))
return pids, out
pl, pl_raw = pids("windows.pslist")
ps, ps_raw = pids("windows.psscan")
hidden = ps - pl
return {"pslist": sorted(pl), "psscan": sorted(ps),
"hidden_candidates": sorted(hidden),
"pslist_raw": pl_raw, "psscan_raw": ps_raw}
def parse_malfind(image):
out, _ = vol(image, "windows.malfind")
hits = []
cur = None
for line in out.splitlines():
m = re.match(
r"\s*(\d+)\s+(\S+)\s+([0-9a-f]+)\s+", line)
if m and m.group(2).lower().startswith("0x") is \
False and len(m.group(2)) > 3:
cur = {"pid": int(m.group(1)),
"process": m.group(2),
"vad": m.group(3)}
hits.append(cur)
return hits
def parse_netscan(image):
out, _ = vol(image, "windows.netscan")
conns = []
for line in out.splitlines():
m = re.search(
r"([0-9.]+):(\d+)\s+([0-9.]+):(\d+)", line)
if m:
conns.append({"local": f"{m.group(1)}:"
f"{m.group(2)}",
"remote": f"{m.group(3)}:"
f"{m.group(4)}"})
return conns
def yara_scan(image, rulefile):
out, _ = vol(image, "windows.yarascan.YaraScanner",
extra=[f"--yara-file", rulefile])
hits = []
for line in out.splitlines():
if "rule" in line.lower() and "|" in line:
hits.append(line.strip()[:100])
return hits
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--image", required=True)
ap.add_argument("--yara")
ap.add_argument("--out")
args = ap.parse_args()
if not os.path.exists(args.image):
sys.exit(f"image missing: {args.image}")
print("[*] profile check...")
prof = check_profile(args.image)
print(f" {prof}")
print("[*] process cross-view (pslist vs psscan)...")
pv = pid_sets(args.image)
hidden = pv["hidden_candidates"]
print(f" pslist={len(pv['pslist'])} "
f"psscan={len(pv['psscan'])} "
f"hidden={len(hidden)}")
if hidden:
print(f" [!] DKOM-hidden candidates: {hidden}")
print("[*] malfind sweep...")
mal = parse_malfind(args.image)
print(f" injection candidates: {len(mal)}")
for h in mal[:8]:
print(f" pid={h['pid']} {h['process']} "
f"@ {h['vad']}")
print("[*] netscan...")
conns = parse_netscan(args.image)
ext = [c for c in conns
if not c["remote"].startswith(
("10.", "192.168.", "127."))]
print(f" connections: {len(conns)} "
f"(external: {len(ext)})")
for c in ext[:8]:
print(f" {c['local']} -> {c['remote']}")
yhits = []
if args.yara:
print("[*] yara scan...")
yhits = yara_scan(args.image, args.yara)
print(f" yara hits: {len(yhits)}")
for h in yhits[:8]:
print(f" {h[:80]}")
L = ["# Memory triage", "",
f"image: `{args.image}`", "",
f"- profile: {prof.get('os_hint', '?')}",
f"- process cross-view: hidden candidates "
f"= {hidden or 'none'}",
f"- malfind candidates: {len(mal)}",
f"- external connections: {len(ext)}",
f"- yara hits: {len(yhits)}", ""]
if hidden:
L.append("## [!] ROOTKIT CANDIDATES — escalate "
"per M10 (host self-reporting untrusted)")
if mal:
L.append("## Injection candidates (dump + RE "
"per H3)")
text = "\n".join(L) + "\n"
print(text)
if args.out:
open(args.out, "w").write(text)
json.dump({"profile": prof,
"hidden": hidden,
"malfind": mal,
"external_conns": ext,
"yara": yhits},
open(args.out.replace(
".md", ".json"), "w"), indent=2)
print(f"[+] {args.out}")
if __name__ == "__main__":
main()
  • check_profile() (lines 16-29) runs windows.info first — the profile/symbol sanity check that the discipline demands before any other plugin (the wrong-symbols failure produces confident nonsense, and the report carries the profile evidence).
  • pid_sets() (lines 32-45) implements the cross-view core: PIDs from windows.pslist (the API-trust path) versus windows.psscan (the pool-scan path), and the diff — psscan-only PIDs are the DKOM-hidden candidates. The raw outputs are carried in the dict for the analyst’s own review.
  • parse_malfind() (lines 48-60) walks malfind’s table output: PID, process, VAD address per candidate — the injection candidates that the report routes to H3’s RE (dump the memory region, analyze the code).
  • parse_netscan() (lines 63-73) extracts the socket table and splits internal/external — the C2 connections visible in memory even when the host’s live view lies.
  • The prioritized report (lines 106-120): hidden candidates (rootkit escalation per M10), malfind candidates (RE per H3), external connections, YARA hits — the triage output ranked by what changes the case.

The memory image reaches the analysis at BT4’s containment moment — captured before isolation, hashed into BT5’s custody chain — and the triage reaches every evasion class this handbook’s adversary modules built: the DKOM-hidden process (M10’s technique, exposed by the cross-view), the injected payload (E2’s bypass, exposed by malfind), the fileless execution (no disk artifact — but the code is in RAM), the encrypted region (H2’s wall — its key is in memory). The findings reach the case as scope expansions: the second implant malfind found, the C2 connection netscan showed, the credentials hashdump recovered — each one a BT4 scope-assumption vindicated. The rootkit confirmation reaches M10’s escalation: the cross-view diff is the DKOM proof, and the host’s self-reporting is untrusted from that moment (the IR decision that changes the response). The YARA reach goes both directions: M6’s family rules scanning memory (the retro-hunt in RAM), and the memory findings feeding new rules (the unpacked payload’s strings — M4’s unpacking in memory, no reflash needed). For the defender, the memory analysis of your own estate’s incidents validates the endpoint stack: what the EDR missed (the injection malfind found), what the AV’s API-trust missed (the DKOM process), the gaps that become the design conversation (E2’s architecture review). The Volatility symbol discipline reaches the program as its operational dependency: symbol tables per OS build maintained (the estate’s build inventory feeds the symbol library), the analysis runbooks versioned per Volatility generation — the infrastructure that makes the craft repeatable.

  1. Acquire — BT4’s memory-first capture: mem.raw hashed into BT5 custody before isolation
  2. Profile — windows.info: symbols resolved, build confirmed
  3. Cross-view — pslist 84 / psscan 86: TWO hidden candidates — rootkit confirmed (M10)
  4. Malfind — 3 RWX candidates in svchost and explorer: injected code dumped for H3 RE
  5. Netscan — 2 external C2 connections the live view never showed
  6. Credentials — hashdump recovers 6 local hashes + domain ticket material (PE3 class)
  7. Chain — recovered key decrypts H2’s encrypted config region; second implant identified
  8. Report — memory findings re-price the whole case: rootkit + fileless + keys; host rebuild mandated

malhunted.py — an independent RWX-region finder that does NOT use Volatility: parses a raw memory capture directly (stdlib), walks Windows x64 EPROCESS structures via pool-tag scanning basics (the simplified educational version of psscan), and flags private-executable VADs — teaching the underlying mechanics that Volatility automates. This is the deep-understanding companion: smaller, simpler, honest about its limits (no symbol handling, specific to one Windows build family [verify offsets per build]).

  • Educational pool-scan. Scans for the Proc pool tag with a size window, validates the EPROCESS header shapes — the simplified psscan that teaches what Volatility’s symbol tables automate; offsets marked [verify per build] honestly.
  • RWX detection via VAD basics. Walks the VAD tree pointers minimally for flagged processes — the educational malfind, same limits stated.
  • Fail loudly. Where offsets don’t match the build, the tool says “no valid structures found — offsets wrong for this build” rather than emitting confident garbage — the anti-volatility-failure posture.
  • Single file, no deps. The point is the mechanics; the production path remains Volatility.
#!/usr/bin/env python3
"""malhunted.py — educational pool-tag scanner (Windows x64).
Usage:
python malhunted.py mem.raw [--build 19041] [--json out.json]
Simplified EPROCESS pool scan; offsets are build-specific
[verify per build] — fail-loud design, not silent nonsense.
stdlib only.
"""
import argparse, json, sys
# x64 Windows 10 19041-class offsets [verify per build]
POOLTAG_OFF = 0x000 # 'Proc' at EPROCESS-0x4 header
UNIQ_OFF = 0x2E0 # unique process id offset
NAME_OFF = 0x2E8 # ImageFileName (15 bytes)
POOLTAG = b"Proc"
def scan(image, build):
data = open(image, "rb").read()
if build != "19041":
print(f"[!] offsets are for 19041-class builds; "
f"requested {build} — results unverified",
file=sys.stderr)
hits = []
off = 0
while True:
off = data.find(POOLTAG, off)
if off < 0:
break
ep_base = off + 4 # EPROCESS start
try:
pid = int.from_bytes(
data[ep_base + UNIQ_OFF:
ep_base + UNIQ_OFF + 8], "little")
name = data[ep_base + NAME_OFF:
ep_base + NAME_OFF + 15]
name = name.rstrip(b"\x00").decode(
errors="replace")
except IndexError:
off += 4
continue
if 0 < pid < 65536 and name.isprintable() and \
len(name) > 1:
hits.append({"offset": hex(off),
"pid": pid, "name": name})
off += 4
return hits
def main():
ap = argparse.ArgumentParser()
ap.add_argument("image")
ap.add_argument("--build", default="19041")
ap.add_argument("--json")
args = ap.parse_args()
hits = scan(args.image, args.build)
if not hits:
print("[-] no valid EPROCESS structures found — "
"offsets likely wrong for this build "
"(fail-loud by design)")
return
print(f"== processes found by pool scan: "
f"{len(hits)} ==")
for h in hits[:20]:
print(f" {h['offset']} pid={h['pid']:<6} "
f"{h['name']}")
if args.json:
json.dump(hits, open(args.json, "w"), indent=2)
print(f"[+] {args.json} — diff against pslist "
f"for the DKOM view (educational: use "
f"volatility in production)")
if __name__ == "__main__":
main()
  • The offsets (lines 12-14) are the module’s honesty centerpiece: pool-tag offset, UniqueProcessId, ImageFileName — each build-specific and marked [verify per build], with the scan refusing to fake confidence on unverified builds (lines 22-24’s loud warning).
  • scan() (lines 26-46) implements the pool-tag search: find Proc, validate the PID range and the image-name printability — the two sanity checks that separate real EPROCESS structures from the string Proc appearing in data. The fail-loud empty result (lines 68-71) states the likely cause rather than implying a clean image.
  • The educational boundary (line 74): the JSON’s closing note points to Volatility for production — this tool teaches the mechanics (what pool-tag scanning actually does), it doesn’t replace the framework.

$ python memtriage.py —image mem.raw —yara family.yar \ —out triage.md [] process cross-view (pslist vs psscan)… pslist=84 psscan=86 hidden=[3568, 4120] [!] DKOM-hidden candidates: [3568, 4120] [] malfind sweep… injection candidates: 3 pid=7712 svchost.exe @ 0x1e3f0000 [*] netscan… connections: 214 (external: 2) 10.0.0.5:49152 -> 185.220.101.4:443

educational pool scan (understanding what vol does)

Section titled “educational pool scan (understanding what vol does)”

$ python malhunted.py mem.raw —build 19041 == processes found by pool scan: 86 == 0x1a2b3c pid=4 System … [-] offsets likely wrong for this build (fail-loud) # when applicable

blue role: the two hidden PIDs re-price the case (M10)

Section titled “blue role: the two hidden PIDs re-price the case (M10)”

Memory forensics’ detection-adjacent role is the endpoint-stack validation: the cross-view and malfind findings against your own incidents re-price the EDR’s coverage (what did it miss — and why: the API-trust design, the injection signature gap), and the memory-YARA hits validate M6’s rules in their most evasion-resistant medium. Your own tooling hygiene: acquisition is BT5’s chain; the analysis runs on clean machines (the image is untrusted input — parse carefully); the educational scanner never claims production grade. Volatility’s symbol maintenance is the program’s operational dependency: the estate’s build inventory (which Windows builds exist) feeds the symbol library, versioned per Volatility generation. Wazuh/SIEM mapping is indirect — memory findings flow through BT4’s case into the governance stack. The standing practices: memory-first capture on every serious incident (100%), cross-view on every capture (the DKOM check as routine), symbol provenance in every report (which tables, which build) — the three disciplines that make memory forensics the ground truth it promises to be.

  1. Volatility 2 fallback: dual-generation support where v3 symbols are missing for legacy builds [verify maintenance state].
  2. hivelist integration: registry recovery added to the triage flow.
  3. Timeline merge: memtimeline events into BT5’s chronology automatically.
  4. Detection-estate diff: malfind results vs EDR’s injection alerts — the coverage gap report.
  5. Symbol-library manager: build-inventory-driven symbol download/cache for the estate.
SignalSourceIDNotes
DKOM-hidden processesCross-view diffper captureM10 confirmation
RWX injection candidatesmalfindper captureFileless payload class
External C2 in socketsnetscanper captureLive channel evidence
YARA memory hitsyarascanper captureFamily identification
Credential exposurehashdump/lsadumpper captureBT4 capture value
Symbol-table provenancememtriage reportper analysisThe honesty marker
EDR injection-coverage gapsmemtriage vs EDR diffper incidentEndpoint stack review
Memory-first capture rateBT4 auditper incidentThe discipline metric
Rootkit escalation rateCross-view findingsper incidentScope-expansion trigger
Program integration rowsT10 stackquarterlyGovernance join

Memory-forensics evasion is the rootkit author’s craft (M10’s layers) measured against the analysis. DKOM and beyond: hiding from pslist is table stakes; evading psscan requires removing/altering pool tags (the scanning signature) — countered by multiple scan signatures (pool-tag + thread-walk + handle-table walks) and by the anomaly of a process that exists in scheduler structures but nowhere else. Injection concealment: RWX regions flagged by malfind — countered by membrane-style techniques (RW↔RX flipping, mapping code as image-backed), countered back by behavioral malfind variants (thread-start addresses outside image ranges) and by YARA on the regions regardless of backing. Trace wiping: memory-resident cleanup after actions (the payload that deletes its strings) — countered by acquisition speed (BT4’s memory-first rule — capture before the wipe), and by the freed-but-present pages (Volatility recovers deleted structures). Symbol confusion: OS builds that break symbol tables (the analysis fails loudly or silently) — countered by the symbol-library discipline and by the fail-loud posture (H1’s lesson: confident nonsense is worse than failure). Hardware-layer blindness: the analysis sees RAM, not CPU registers/cache (H6/H7’s layer) — the honest limit stated in every report: memory forensics is the ground truth for memory, and the silicon layer needs its own drills. The meta-point: memory is the last unbiased witness on a compromised host — every other view (API, disk, logs) can be edited; the RAM image can only be made harder to parse, and the cross-view method survives all of it.

⚠️ What caught me: my first memory triage ran beautifully — 84 processes in pslist, 84 in psscan, no hidden candidates, malfind clean — and I wrote “no memory-resident compromise.” The IR lead then asked one question: “which symbols?” The answer — auto-downloaded, unverified for that exact build — turned the report into a coin flip: the plugins had run against mismatched symbols, and a mismatched symbol table can hide exactly the differences the cross-view exists to find (the offsets resolve, the structures parse, the results are wrong with total confidence). We re-acquired with verified symbols and found the hidden process on the first diff. Since then, every memory report carries its symbol provenance like BT5’s hashes carry their artifacts — the analysis is only as true as the symbols it stands on, and “it ran without errors” is not evidence.

  1. Cross-view drill: build a lab VM, inject a process that DKOM-hides itself (open-source DKOM demo [verify]), capture, and confirm the pslist/psscan diff finds it — M10’s confirmation, reproduced end to end.
  2. Malfind validation: run an open-source process-injection demo on the lab VM, capture, and confirm malfind’s RWX flag; then dump the region and RE it (H3) — the injection-to-RE chain.
  3. Symbol discipline drill: run the triage with deliberately wrong symbols, observe the confident-wrong output, then fix with verified symbols — the failure mode, experienced safely.
  4. Credential recovery: on the lab VM, run a credential-dump tool (owned), capture, and confirm hashdump/lsadump recovers them — the BT4 capture-value, proven.
  5. Educational scanner: run malhunted.py against the same capture and diff its output against Volatility’s psscan — the mechanics understood, the framework trusted.
  • Technical: Explain the cross-view method — why does psscan find what pslist misses, and what does the diff prove?
  • Technical: What does malfind’s signature consist of, and what injection techniques evade it?
  • Scenario: Your memory triage is clean but the disk forensics found clear compromise. Diagnose: symbol problems, capture timing, anti-forensics — in order.
  • Conceptual: Why is memory “the last unbiased witness,” and which of H6/H7’s layers does that claim not cover?
  • Conceptual: How does the symbol-table dependency change memory forensics’ reliability, and what discipline manages it?
  • Deep-dive: Design the memory-forensics capability for an IR team: acquisition tooling, symbol maintenance, analysis runbooks, and the training path.
  • Practical: BT4’s capture yields a confirmed rootkit. What changes in the incident’s scope, response, and the estate’s trust model?

BT4 (Incident Response Process) · BT5 (Digital Forensics & Evidence Handling) · M10 (Rootkit Analysis) · E2 (EDR Internal Architecture) · F3 (Windows Internals)