Ghosts in the RAM: A Field Guide to Memory Forensics
One hunt, two altitudes — from a process-level intrusion down into a kernel rootkit

Ghosts in the RAM: A Field Guide to Memory Forensics
One hunt, two altitudes — from a process-level intrusion down into a kernel rootkit
What the disk forgets, memory remembers — for a little while. Here’s how to catch it before it’s gone, even when the malware is hiding inside the kernel itself.
There’s a moment in almost every intrusion investigation where the disk lies to you.
The binary on disk is signed and clean. The process that spawned it has already exited, so there’s nothing left to inspect. The “malicious” file the EDR flagged is a 4KB loader — a download cradle that already did its job and now just sits there, looking like nothing to static analysis. You’ve got a confirmed compromise, a worried client, and a filesystem that keeps insisting everything is fine.
That’s the moment memory forensics earns its keep.
This is a field guide, not a textbook — and it’s a single hunt told in two acts. Act One is a routine user-mode investigation: a low-confidence alert that resolves, step by step, into an injected infostealer with a live C2 channel. By every normal measure, that should close the case. But the evidence won’t quite settle, and Act Two follows it down into the kernel — where a rootkit is rewriting what the operating system itself is willing to tell you. The same instincts carry both acts; you’ll just be applying them one layer deeper. At every pivot you’ll get the exact Volatility 3 and MemProcFS commands to run it yourself. The goal isn’t for you to admire the analysis. It’s for you to replicate it on Sunday.
This investigation relies on one recurring technique: compare what the operating system says exists against what raw memory says exists. Every major pivot in the hunt comes from that discrepancy.
Act One: a user-mode hunt
The scenario
It’s a Tuesday. Your EDR fires a medium-severity alert: a powershell.exe process on a finance workstation spawned a child process with an unusual command line, then the telemetry goes quiet. The endpoint is still online. The script that ran has already terminated, and the only artifact on disk is a tiny loader that triage can't make sense of. The user swears they didn't do anything.
You have one good decision to make before anything else: capture memory now, before you touch the box further.
Capture clean, hash immediately
Memory is the most volatile evidence you will ever handle. Every second the machine runs, pages get overwritten, processes exit, and the C2 channel you needed to see closes. The acquisition rules are simple and non-negotiable, and they don’t change later when the hunt turns into a kernel investigation:
- Use a trusted, minimal-footprint acquisition tool (WinPmem, Magnet RAM Capture, or your EDR’s native memory dump if it produces a full raw image).
- Write the image to external media, never to the suspect disk.
- Hash the image the instant capture completes (
sha256sum image.raw) and record it. That hash is the spine of your evidentiary chain. - Note the capture time in UTC. Memory is a snapshot of one instant; everything you conclude is “true as of” that timestamp.
You now have finance-ws.raw. The hunt begins.
Map the process tree
Start with the most basic question: what was running, and who spawned what?
vol -f finance-ws.raw windows.pslist
vol -f finance-ws.raw windows.pstree
pslist walks the doubly-linked list of active process structures the OS maintains. pstree renders the parent/child relationships so you can read the lineage at a glance.
You’re not looking for “malware.” You’re looking for wrong. A powershell.exe whose parent is winword.exe is wrong. An svchost.exe whose parent isn't services.exe is wrong. A second lsass.exe is wrong. Learn the legitimate tree cold, and the abnormal announces itself.
In our image, pstree shows a powershell.exe parented to explorer.exe (plausible on its own), which spawned two rundll32.exe children. One is still running; the other has already exited. A rundll32 launched by PowerShell with no DLL or export named on its command line is not plausible — that's not how anyone legitimately calls it. The surviving rundll32 is your first thread to pull.
Diff the lists — catch what hides from the OS
Here’s the first pivot that separates analysts from button-pushers. pslist trusts the OS's own bookkeeping. But a process can be unlinked from that list, or it may have already exited — and either way it stops showing up in pslist, Task Manager, and most EDR process enumeration.
psscan doesn't trust the list. It scans raw memory for the byte signatures of process structures (_EPROCESS), including ones that have been unlinked or have already terminated.
vol -f finance-ws.raw windows.psscan
The technique is the diff. Anything that appears in psscan but not in pslist is either a terminated process (it exited normally and left residue) or a hidden one. Either way it's worth a look — a process that exited can just as easily be your malware covering its tracks.
# conceptually: psscan results minus pslist results
In our case the diff sharpens the picture. The exited rundll32.exe shows a creation time three seconds before its surviving sibling — it ran first, then terminated. Read together, that's a classic stage-and-clean pattern: one process injected the payload and exited, the other stayed alive to do the work. You now have two leads and a strong hint that someone tried to tidy up after themselves.
Hold onto this diffing move. pslist vs psscan is the single most important instinct in this entire guide, and later — when the investigation goes sideways — it's the exact technique that cracks the case open at a much deeper level.
Follow the network
Live intrusions talk. Pull the connection table out of memory and see who’s reaching out.
vol -f finance-ws.raw windows.netscan
netscan recovers both active and recently-closed sockets, mapped to their owning PID. This is where memory routinely beats disk — the kernel structures are still sitting in RAM, and you're reading them directly rather than asking the host to report on itself.
Sort by foreign address and ignore the noise (your update servers, telemetry, the usual). What’s left in our image: the surviving rundll32.exe holding an established connection to an IP on an unusual high port, geolocated somewhere your finance team has no business talking to. That's a candidate C2 channel, and now it has an owning PID.
Hunt injection with malfind
The script that delivered all this has already exited, and the loader husk on disk is inert. So where’s the actual malware? Almost certainly injected into the address space of a running process — the whole point of the staging was to land the payload in memory and leave nothing useful behind. The surviving rundll32 is the obvious place to look first.
vol -f finance-ws.raw windows.malfind
malfind looks for memory regions that have no business existing: private, committed pages marked with execute permissions (RWX or RX) that aren't backed by a file on disk. Legitimate executable code is almost always mapped from a DLL or EXE. A region of executable memory with no file backing — and especially one whose first bytes are MZ or look like shellcode — is a textbook injection signature.
Run it against the suspect rundll32, and malfind returns an RWX region whose hexdump opens with the MZ magic bytes. A full PE, living entirely in memory, never written to disk in its final form. That's your payload.
Sometimes you get a pristine PE header; sometimes you get only fragments of shellcode and have to keep digging.
Recover the story — command lines and handles
You’ve found the what. Now recover the how and what for.
vol -f finance-ws.raw windows.cmdline
vol -f finance-ws.raw windows.handles --pid <suspect_pid>
cmdline pulls the full command-line arguments each process was launched with — including the base64-encoded PowerShell blob that the EDR's process tree truncated. Decode it and you often get the entire initial-access script: the download cradle, the staging path, the exact technique.
It’s worth knowing where cmdline reads that from, because it explains both its power and its limits. The string lives in the process's own user-mode memory — in the Process Environment Block, at PEB → RTL_USER_PROCESS_PARAMETERS → CommandLine — reached through the _EPROCESS object itself, not through the active-process list. That's why a process that's been DKOM-unlinked from pslist will still usually surrender its command line: the _EPROCESS is still resident, and cmdline reaches the PEB through it regardless of whether the process is still linked into the list the OS walks.
But “usually” isn’t “always,” and the failures are themselves informative. If a process has fully exited, psscan may still carve the _EPROCESS shell while the PEB pages holding the command-line string have already been paged out or reused — so you get the process but an empty or garbage cmdline. Some rootkits go further and deliberately scrub RTL_USER_PROCESS_PARAMETERS to defeat exactly this plugin. And because the field lives in the process's own writable memory, malware can overwrite its own CommandLine after launch to spoof what you see. The lesson is the one running through this whole guide: an empty cmdline on a process psscan clearly sees is not a dead end — it's a finding. It points at exit-and-cleanup, deliberate scrubbing, or tampering, and it tells you to corroborate the launch story from another angle (handles, loaded DLLs, the parent's view) rather than trust a single source.
handles enumerates everything the process has its hands on: open files, registry keys, mutexes, named pipes. This is gold for attribution and scoping. A mutex name can fingerprint a malware family. An open handle to a registry Run key shows you the persistence mechanism. A named pipe handle can reveal lateral-movement tooling.
In our image, cmdline recovers the encoded PowerShell that fetched and staged the payload, and handles shows the suspect process holding an open key under HKCU\...\Run and a uniquely-named mutex. The mutex pattern is consistent with a known infostealer family. The picture is sharpening fast.
Dump the payload, then unpack it
You’ve located the injected PE. Now extract it for static and dynamic analysis.
vol -f finance-ws.raw windows.malfind --dump
# or, for a specific region/process:
vol -f finance-ws.raw windows.vadinfo --pid <suspect_pid>
vol -f finance-ws.raw windows.vaddump --pid <suspect_pid>
The crucial advantage: malware on disk is usually packed or encrypted. The copy in memory has already been unpacked by the malware itself in order to run. You’re capturing it at its most exposed — decrypted strings, resolved imports, plaintext configuration.
Run strings over the dumped region and you'll frequently find the C2 domains, the user-agent, file paths it targets, and sometimes the exfil staging directory — all in the clear, because the malware needed them in the clear to function.
Act Two: the investigation that should have ended — but didn’t
By every normal measure, this case is closed. You have the injection, the C2, the persistence key, the family attribution. Write the report.
Except something doesn’t sit right.
The EDR that fired the original alert has been quiet on this host ever since — not “no new alerts” quiet, but suspiciously quiet, given a confirmed live infostealer with an open C2 channel. And here’s the detail that should stop you cold: the C2 connection you pulled with netscan doesn't appear in the host's own logged network telemetry at all. The packet capture at the network edge saw those beacons; the endpoint never reported them about itself.
Sit with why netscan saw it and the host didn't. netscan read the TCP structures directly out of the memory image, offline, after capture. The host's own telemetry was generated live, by asking the running operating system what connections it had — and something answered that question with a lie. You didn't find the C2 despite it being hidden; you found it because you bypassed whatever was doing the hiding. The memory image and the live host disagree, and that disagreement is the most important finding in the case.
A typical user-mode infostealer shouldn’t be able to create that kind of discrepancy on its own. When offline memory analysis and host-reported telemetry fundamentally disagree, kernel-level interference becomes a serious possibility. Something with far more privilege is editing reality on the way out.
Time to go down a layer.
Going kernel: enumerate the drivers
You just watched the memory image and the live host disagree about a network connection. That disagreement has a cause, and it lives in the kernel. Everything in Act One could ultimately trust the kernel’s underlying bookkeeping — even psscan was reading structures the kernel itself had created. A kernel rootkit breaks that assumption: once code runs in ring 0, it shares an address space with the OS itself and can edit the kernel’s bookkeeping, redirect the pointers the OS uses to answer basic questions, and hook the very system calls — and the very telemetry — your live tools rely on. Your offline image was never subject to those hooks, which is precisely why it disagreed with the host. Now you go find the thing doing the hooking.
Start one layer down from where Act One began — with loaded kernel modules.
vol -f finance-ws.raw windows.modules
vol -f finance-ws.raw windows.driverscan
modules walks the PsLoadedModuleList — the kernel's own list of loaded drivers. It's the ring-0 equivalent of pslist. Read it the same way you read the process tree: hunt wrong. A driver loaded from AppData instead of System32\drivers is wrong. A driver with no company name, a random filename, or a load time inside the incident window is wrong.
But the most dangerous driver is the one that isn’t in this list at all. Which is why we don’t stop here.
Diff the lists again — this time in the kernel
If the pslist/psscan move earlier felt important, here's the payoff. The identical technique, one layer down, is what breaks this case.
vol -f finance-ws.raw windows.modscan
modules trusts PsLoadedModuleList. modscan ignores it and scans physical memory for the byte signatures of driver objects (_DRIVER_OBJECT / _LDR_DATA_TABLE_ENTRY) directly. A rootkit that unlinks its driver from the loaded-module list vanishes from modules — and from every tool that trusts it — but the object is still resident, doing its job. modscan finds it.
The diff is the detection, exactly as it was for processes. Anything in modscan that isn't in modules is a driver someone went out of their way to hide.
In our image, the diff surfaces a driver — call it htsysm.sys — present in modscan, absent from modules, loaded from a non-standard path, with a load time that lines up with the infostealer's arrival. It unlinked itself the moment it initialized. The OS would never have told you it existed. This is the leading explanation for why the host’s view of its own network activity differed from the memory image.
Hunt the callbacks — how it persists and watches
Finding the driver isn’t enough. You need to know what it hooked into, because that’s both how it survives and how it does damage.
vol -f finance-ws.raw windows.callbacks
Windows lets drivers register callbacks for system events — process creation, thread creation, image loading, registry operations. These are legitimate mechanisms your EDR uses too, which is exactly why rootkits love them. A callback owned by our hidden htsysm.sys is a loud finding: process-creation callbacks give it a front-row seat to everything that launches — letting it inject into new processes or kill security tooling as it starts. That's almost certainly how it blinded the EDR.
There’s a nastier signal here too. Look for callbacks whose owning module can’t be resolved to a named driver — a callback pointing into an unbacked region of kernel memory. That’s the kernel echo of the unbacked RWX region malfind flagged earlier: executable code that no legitimate module claims. It often means the rootkit registered the callback and then unlinked the driver, leaving an orphaned pointer into anonymous kernel memory. Highly abnormal, highly diagnostic.
Find the hooks — SSDT and IRP tables
Persistence keeps the rootkit alive. Hooks are how it controls what the system reports — and they’re what made the host lie about its own network traffic.
SSDT hooks
The System Service Descriptor Table is the jump table the kernel uses to dispatch system calls. Call something like NtQuerySystemInformation (which underlies process and module enumeration), and the kernel looks up the handler in the SSDT and jumps to it. Overwrite that entry and the rootkit intercepts the call — filtering its own processes, files, or connections out of the results before they ever reach the caller.
vol -f finance-ws.raw windows.ssdt
ssdt dumps the table and resolves each entry to its owning module. The logic is simple: On a healthy modern system, SSDT entries should generally resolve into expected kernel modules such as ntoskrnl.exe (or win32k.sys for GUI services). Any SSDT entry resolving outside the expected kernel modules deserves immediate scrutiny and is often evidence of hooking. PatchGuard exists specifically to stop this on modern 64-bit Windows, so a third-party SSDT entry is close to a smoking gun.
IRP hooks
The other classic target is the I/O Request Packet dispatch table. Every driver has a MajorFunction array of pointers handling reads, writes, and device control. A rootkit can overwrite the disk or network driver's IRP handlers to filter I/O — intercept a query and strip out its own files or connections before returning.
vol -f finance-ws.raw windows.driverirp
driverirp lists each driver's IRP handler pointers and resolves them. Same shape of signal: a core driver whose handler points into a different module than itself. When the network or disk driver's handler resolves into htsysm.sys, you've found the precise mechanism that hid the C2 from the host while the network tap saw it plainly.
The whole picture now locks together: the hidden driver registered callbacks to persist and blind the EDR, and hooked the SSDT and IRP tables to filter what the live OS reported about processes, files, and network. Three techniques, one rootkit, one coherent story — and it explains every anomaly that made you distrust the “case closed” moment.
Recover the driver and prove intent
Extract the driver the same way you dumped the injected PE earlier.
vol -f finance-ws.raw windows.moddump --base <driver_base_address>
The in-memory copy is the resolved, running version — relocations applied, imports resolved — which makes static analysis far more productive than chasing a packed on-disk sample, if one even survived load. Run strings and quick triage and you'll typically confirm intent fast: the paths it hides, the C2 the callback beacons to, the security products it watches for and kills, the registry keys it protects. Hash it against threat intel and attribution often comes for free.
Corroborate with MemProcFS
No single tool gets the last word — and against a rootkit, independent corroboration isn’t just hygiene, it’s protection against being fooled by a tool the malware anticipated. Get a second, independent view before you commit to anything.
MemProcFS mounts the memory image as a navigable filesystem. Instead of running plugins, you browse the image like a drive.
# Linux
memprocfs -device finance-ws.raw -mount /mnt/mem
# Windows
MemProcFS.exe -device finance-ws.raw -mount M:
Then go straight to its built-in analysis engine, which reconstructs state independently of Volatility’s plugins:
ls /mnt/mem/forensic/findevil/
cat /mnt/mem/forensic/findevil/*
findevil independently flags injected regions, suspicious threads, hidden drivers, and kernel anomalies. The question is always the same: does MemProcFS independently agree? When malfind and findevil both flag the same rundll32 PID, and when modscan and MemProcFS both see the hidden driver, those stop being leads and become conclusions. And where two independent tools disagree, that gap is your next lead — never something to wave away.
Building the timeline
Individual findings don’t close a case. The narrative does. Fused across user-mode and kernel, the sequence reconstructs cleanly:
- Initial execution —
cmdlineshows the encoded PowerShell that staged the payload. - Injection —
malfindshows the infostealer PE written into the survivingrundll32. - Persistence, two layers deep — the infostealer set a
HKCU\...\Runkey for user-mode persistence, while a malicious driver loaded and unlinked itself for kernel-level survival: present inmodscan, gone frommodules. - Blinding & hiding — kernel callbacks silenced the EDR; SSDT and IRP hooks filtered what the host reported about processes and network.
- Command and control —
netscanrecovered the real C2 the hooks were hiding, with its owning PID. - Objective — recovered strings, configs, and protected keys show what it was after.
Cross-reference against disk and live artifacts (driver load events, the Services hive, prefetch, registry LastWrite times) and you get a unified timeline that survives scrutiny — and that explains why the host's own tools were blind while the network tap wasn't. Memory tells you what was true at the instant of capture; disk tells you the sequence that led there. Neither is complete alone. Together they're the case.
What to internalize
If you take three things from this into your next investigation:
Memory is often the most honest witness available — and the only one a rootkit can’t coach. It holds the unpacked payloads, the live C2, the typed commands, the cleartext secrets, and the hidden driver, all the evidence engineered to evade the live system. A compromised OS answers through APIs the malware controls; a memory image is read offline, where the hooks can’t reach. But it’s gone on reboot. Capture first, capture clean, hash immediately.
Diffing scales all the way down. pslist vs psscan caught the staged process; modules vs modscan caught the hidden driver — the identical instinct, two layers apart, for the identical reason: one source trusts the OS's bookkeeping, the other reads raw memory, and the gap between them is where hidden things live. Whenever a system keeps a list of itself, scan for the same objects independently and diff.
Anomaly-hunting beats signature-chasing, and the disagreements are the prize. You don’t memorize every family. You learn cold what normal looks like — the process tree, the driver list, where binaries live, what executable memory and SSDT entries are supposed to resolve to — and the abnormal announces itself. The fake svchost in %TEMP%, the RWX region in explorer.exe, the SSDT entry pointing into a third-party driver, the host that disagrees with the network tap. Every place two honest sources fail to agree is a place worth your full attention.
The disk kept insisting everything was fine. Then the whole operating system did. The memory image doesn’t insist on anything — it just shows you what’s there. That’s the entire reason it wins.
메타데이터
- post_id
- bd29eeffec2a
- slug
- ghosts-in-the-ram-a-field-guide-to-memory-forensics-bd29eeffec2a
- url
- https://medium.com/@manafmohammedah/ghosts-in-the-ram-a-field-guide-to-memory-forensics-bd29eeffec2a
- canonical_url
- https://medium.com/@manafmohammedah/ghosts-in-the-ram-a-field-guide-to-memory-forensics-bd29eeffec2a
- author_url
- https://medium.com/@manafmohammedah
- status
- ok
- fetched_at
- 2026-06-15 20:49:13