Reading width
Wide uses the full column for everything, text, diagrams, code, and exercises. Narrow keeps the standard reading width.
Text size
Scales the body text. Headings and code blocks keep their size.
In this section
Volatility windows.psscan: Pool Scanning, ExitTime and Diffing Against pslist
Introduction
The previous sub ended on a limitation. windows.pslist walks a list, and a list can only be walked while its links are intact, so anything unlinked from it is invisible no matter how carefully the output is read. This sub runs the plugin that needs no links at all.
windows.psscan searches the image for process structures directly, rather than following pointers to reach them. It finds everything pslist finds plus some things it does not, and understanding what that extra set actually contains is far more useful than the excitement the phrase "hidden process" tends to generate. On most images the extra rows are not hidden anything; they are the ordinary debris of a machine that has been running for hours.
Scenario
Same image, NE-ENG-031-1431.raw. You ran windows.pslist in the last sub and got eighteen processes. You now run windows.psscan against the same file and get nineteen. One row exists in the second listing and not the first, and before you conclude anything about rootkits there is a column that explains it in about four seconds.
How psscan Finds a Process Without the List
Pool tag scanning, and what it does not depend onThe mechanism has a name worth knowing, because you will meet it attached to other plugins: pool tag scanning. When Windows allocates a process structure it does so from kernel memory pools, and those allocations carry a header that identifies what kind of object follows. psscan searches the image for those headers.
The header is the thing the scan is actually looking for, rather than the process itself. Volatility sweeps the image for allocations whose header marks them as process objects, applies a set of constraints to weed out things that merely resemble one, and reports whatever survives that filtering. Those constraints are why the output is usable at all rather than thousands of coincidental byte patterns.
That is a completely different method from walking a list, and it has a completely different failure profile. It does not care whether anything points at the structure, so an object that has been unlinked is found anyway, and so is one belonging to a process that ended some time ago whose memory has not yet been handed to anything else.
Both of those are genuinely useful and only one of them is what people expect. The second case is enormously more common than the first, which is the thing this sub most wants you to take away.
Neither method is a better version of the other. They fail in different directions.
Stated as two columns, the trade is visible without the picture.
Put plainly: what pslist reads is the kernel's own list, so a row in it is a process by definition. What psscan reads is pool headers, so a row is a candidate structure that Volatility judged to be a process. The first is fast and inherits the kernel's view of what exists; the second is slow and has no opinion about what should exist.
Read the two methods against each other rather than ranking them. The first is fast, reads only what the operating system itself would read, and therefore inherits the operating system's own view of what exists. If something has persuaded the kernel that a process is not there, this method agrees. The second is slower, reads far more, and has no opinion about what is supposed to exist. It reports structures, not processes, and the difference between those two words is where the rest of this sub lives.
Running windows.psscan on the Same Image
Nineteen rows where pslist returned eighteenThe invocation has exactly the same shape as everything in the previous sub: global options first, then the plugin name.
$ vol -f NE-ENG-031-1431.raw windows.psscan
PID PPID ImageFileName Offset(V) CreateTime ExitTime
4 0 System 0xa88e3e07a040 07:41:02
336 4 smss.exe 0xa88e3e8b1080 07:41:04
672 548 lsass.exe 0xa88e40122080 07:41:19
1428 1204 explorer.exe 0xa88e41f3a080 08:52:11
2016 1428 msedge.exe 0xa88e4211c080 08:54:02
3312 1428 cmd.exe 0xa88e42881080 14:07:33
5304 4108 update.exe 0xa88e43a12080 14:09:51 14:10:04
(dates 2026-03-15 throughout, cut here for width)
Seven of the nineteen rows, chosen to show the shape rather than to fill the page. Two things to notice about the run before the rows. It takes noticeably longer than pslist did, because it is reading the image rather than following a handful of pointers, and on a large capture that gap becomes minutes. And the rows come back in no particular order, because they arrive in the order the scan encountered them in memory rather than in the order anything started.
The column set is the same as pslist plus ExitTime, which pslist prints too and which was cut for width in the previous sub for a good reason: every row there was empty. A process still in the active list has not exited, so the column has nothing to say.
On this image exactly one row is not empty. update.exe carries an exit time thirteen seconds after its creation time, and it is precisely the row pslist did not return. Those two facts are related, and the relationship is the whole subject of this sub.
Check that column first on every psscan row that pslist lacks. It resolves most of them in seconds and it costs nothing to look.
The reason it resolves so many is worth holding onto. Windows removes a process from the active list the moment it terminates, because the list exists to track what is running and a dead process is not. The structure describing it stays in memory until something needs that memory, which can be seconds or hours depending on how busy the machine is. A terminated process is the ordinary explanation, and a deliberately unlinked one is genuinely rare. Getting that proportion right is what separates an analyst who uses this plugin well from one who raises an alarm on every image they touch.
Diffing the Two Listings
The comparison is the technique, not either plugin aloneNeither listing is especially interesting on its own. The difference between them is where the information is, and getting that difference is a four-line job you should have in your notes.
$ vol -q -r csv -f NE-ENG-031-1431.raw windows.pslist > pslist.csv
$ vol -q -r csv -f NE-ENG-031-1431.raw windows.psscan > psscan.csv
$ cut -d, -f1 pslist.csv | sort > a; cut -d, -f1 psscan.csv | sort > b
$ comm -13 a b
5304
One PID in psscan and not in pslist, and that single number is the entire product of running two plugins. On a workstation image the answer is usually a small number like this one. On a busy server it can be dozens, almost all of them short-lived processes that ended during or shortly before the capture. Volume is not a signal by itself; it tracks how busy the machine is.
Those four lines are worth understanding rather than copying. The first two produce comparable output from two plugins that print different columns. The third pulls just the PID column out of each and sorts it, because comm needs sorted input. The fourth prints the identifiers present in the second file and absent from the first, which is precisely the question "what did the scan find that the list missed".
You can do the same thing in a spreadsheet, or with diff, or by eye on a short listing. What matters is that the comparison happens at all, because the difference is where the information is and neither plugin points it out for you.
The -r csv habit from the previous sub is what makes this cheap. Comparing two aligned text listings by eye is possible and tedious; comparing two columns of identifiers with standard tools takes one line, scales to a server, and does not get tired at the fortieth row.
Most rows land in the first bucket and land there in seconds, which is exactly what leaves attention available for the rare one that does not.
The second item deserves a word because it surprises people. A structure that has been partly overwritten still carries enough of a pool header to be found, so the scan reports it, and the fields read out of the overwritten part are whatever the new occupant happens to have put there. That is why a garbled row is not a malfunction: the plugin found a real header and read real bytes, and the bytes stopped describing a process some time ago.
The third item is the one the plugin is famous for and the one you will meet least often. Getting the first two out of the way quickly is precisely what leaves time to take the third seriously on the day it appears. An analyst who treats every difference as potentially significant runs out of attention long before reaching anything that is.
Reading update.exe, the Row That Appeared
Thirteen seconds of life, and a parent that is not thereTake the one row the diff produced and read it properly. update.exe ran at 14:09:51 and ended at 14:10:04. Thirteen seconds, two minutes after the cmd.exe from the previous sub, and gone before the capture.
Look at its parent as well. PPID 4108, and there is no process 4108 in either listing, which is the same shape of observation the previous sub made about explorer. The previous sub taught that a dangling parent is ordinary for explorer.exe because userinit.exe exits; here the same pattern needs a different explanation, because nothing about a program called update.exe implies a parent that exits by design.
$ vol -q -r csv -f NE-ENG-031-1431.raw windows.psscan | grep -E "^4108,|,4108,"
(no output)
Nothing. Process 4108 is not in the scan either, which means its structure has been overwritten or it never existed as anything the scan can find. That is a real limit rather than a failure of the tool. The further back something happened, the less likely its structure survives, and a parent that ended before its child is exactly the case most likely to have been reclaimed.
It is worth being careful about what the thirteen seconds means, because it is the kind of detail people over-read. A process that lives for thirteen seconds is completely unremarkable in itself: installers, update checks and scripts do it constantly, and most machines create and destroy dozens of them an hour without anybody noticing.
What makes this one worth a sentence is not its lifespan but where it sits. It ran two minutes after a command prompt that the previous sub could not account for, it is the only process on the machine between 09:02 and the capture other than that prompt, and its parent cannot be found. Any one of those alone would be nothing.
What you have is a short-lived process with a name suggesting a maintenance task, running two minutes after an unexplained command prompt, with a parent nobody can account for. None of those three facts is damning on its own. Together they are a considerably better reason to keep reading than either sub produced alone, and that accumulation is what an investigation actually consists of.
Why psscan Is Not Simply the Better Plugin
Three costs for the extra coverageIf psscan finds everything pslist finds and more besides, the obvious question is why anybody bothers running pslist at all. There are three answers and each of them matters in practice.
It is slower, because it reads the whole image rather than following a few thousand pointers. On a large capture that is the difference between a few seconds and a wait. It produces results that need judging, because a structure found by scanning may be stale, partially overwritten, or not really a process at all, and the plugin cannot tell you which. And its output arrives unordered in a way the list is not, because it reports things in the order it finds them in memory rather than in any meaningful sequence.
a row psscan found in reclaimed memory ...
PID PPID ImageFileName CreateTime
61456 28293 ????.exe 1970-01-01 00:00:00 UTC
0 0 (blank)
There is a fourth cost that is easy to overlook and it is about trust rather than mechanics. pslist output is the operating system's own account of what is running, which means a row in it is a process by definition. A psscan row is Volatility's judgment that a piece of memory resembles a process closely enough to report, and the plugin applies constraints to make that judgment good. Those constraints are conservative and they are still a judgment.
That distinction is worth carrying because it decides how you phrase a finding. A process in pslist can be stated as a fact; a psscan-only row is better written as a structure that was found, with the reason you believe it is what it appears to be.
So the working pattern is to run both, in that order. pslist gives the reliable picture of what is running, and psscan asks what the first one could not see. Running only the second gives you more rows and less confidence in any of them, which is the wrong trade at the start of an investigation.
The --physical Option and Which Offset You Get
Same structure, two ways of addressing itOne option on this plugin is worth knowing before you need it, because the situation that needs it is the one where you have least time to read documentation. psscan takes --physical, and it changes the offset column header from Offset(V) to Offset(P).
The default reports a virtual address for every row, which is the address the kernel would use, translated back for you. With --physical it reports where the structure actually sits in the image file, which is the address the scan found it at, before any translation happened at all.
$ vol -f NE-ENG-031-1431.raw windows.psscan --physical
PID PPID ImageFileName Offset(P) CreateTime ExitTime
5304 4108 update.exe 0x1f3a2080 14:09:51 14:10:04
It is the same structure either way. What changes is which numbering the tool reports it in, and the previous module's point applies here unchanged: a virtual address is an input to a lookup and a physical one is a position in the file.
Quote the virtual address in a report. It is the numbering every other plugin speaks, and a reader with the same image can look it up directly. The physical one is what you need when the usual translation is unavailable, which is exactly the situation a genuinely unlinked process can create.
There is a second use worth knowing. Most process-related plugins accept an offset so you can point them at a specific structure rather than a PID, which matters when the PID is unreliable or duplicated across the scan. Having both addresses for one structure means you can hand whichever a given plugin expects, and knowing that they describe the same thing stops the pair looking like a contradiction.
That is the practical reason the option exists rather than a curiosity. If a process has been removed from the kernel's structures thoroughly enough, the machinery that would give you a virtual address may have nothing to work from, and the physical offset is the handle that still works.
What a Genuinely Unlinked Process Looks Like
The rare case, stated plainly so you recognize itEverything in this sub so far has been the ordinary explanation, and deliberately so. It is worth stating the rare one precisely, because vague expectations are how people mistake stale rows for rootkits.
A process deliberately removed from the kernel's active list, while still running, produces a row in psscan with no exit time at all, plausible identifiers, a plausible name, and a creation time consistent with the rest of the machine. It looks, in short, like an entirely normal process that pslist simply does not mention, and that combination is what makes it worth an hour.
The two cases are distinguished by three things together. In the ordinary case the exit time is populated, the other values are often garbled, and you will find several per image. In the rare case the exit time is empty, every value is entirely plausible, and the row is specific rather than one of a crowd. The second is a finding; the first is Tuesday.
Notice how narrow that description is. Every clause rules something out, and a row that fails any one of them has an ordinary explanation available. Vagueness is what turns a stale structure into a rootkit in somebody's report, so the specificity is the protection.
Drawn as two boxes, the test is easier to hold than as a list of conditions.
Three tests in order, each cheap. Almost nothing reaches the right-hand box.
Two further checks are worth knowing now even though later modules run them. The thread list is one: a running process has threads, and a structure whose threads are gone alongside an empty exit time is contradicting itself. Handles are another, for the same reason.
There is also a question worth asking before reaching for any of that, which is whether the process being absent from the list is stranger than the process existing at all. A well-known system binary missing from pslist is a strong signal; an unfamiliar name that is absent from a list it was never going to be in is a weaker one.
That is the honest position on this plugin. It is the only tool discussed so far that can find a deliberately hidden process, the great majority of what it actually surfaces is not one, and an analyst who reports every psscan-only row as suspicious will be wrong far more often than right and will be discounted accordingly the next time they report something real.
Practice
Run both, diff them, explain every difference hands on- Run
windows.pslistandwindows.psscanagainst the same image, both with-q -r csv, into two files. Note how much longer the second takes; that difference is the cost of scanning. - Diff the PID columns with
commor a spreadsheet. Count how many rows psscan adds. On most images it is a handful; if it is dozens, the machine was busy rather than compromised. - For every added row, check ExitTime first. Sort them into exited, garbled, and neither. The third pile is the only one worth further work and on a clean image it should be empty.
- Take one row from the garbled pile and look at why you distrust it: an impossible PID, an epoch timestamp, a name with unprintable characters. Being able to dismiss these at a glance is a real skill and it comes from seeing them.
- Run
windows.psscan --physicaland compare the offset column against the default run for the same process. Confirm for yourself that they are two addresses for one structure.
Extend it
These build the judgment that makes psscan output usable rather than alarming.
- Start a short-lived process, capture memory a minute later, and find it in psscan. Watching your own known process appear as a psscan-only row with an exit time is the cheapest way to internalize the ordinary case.
- Capture the same machine twice, ten minutes apart, and see whether an exited process still appears in the second scan. How long a structure survives is a property of how busy the machine is.
- Run psscan against an image of a machine that has been up for weeks and count the garbled rows. Volume is the thing to calibrate against, because a number that alarms you on a workstation is unremarkable on a server.
- Read the psscan plugin source in the Volatility repository. It is short, and seeing which constraints it applies to decide that a structure is a process explains the false positives better than any description.
Drill four is the one that changes how you read the output permanently. The constraints the plugin applies are a short, readable list, and once you have seen which properties a candidate structure has to satisfy, the garbled rows stop being mysterious: they are the ones that passed a loose check and failed a strict reading.
Drill one is worth doing before any of the others. The ordinary case is what you will meet ninety-nine times out of a hundred, and having produced one yourself is what stops the hundredth looking like all the rest.
You can run windows.psscan, explain how it finds structures without the kernel's list, diff its output against pslist with two commands, resolve most differences with one column, and describe what a genuinely unlinked process would look like. MF0.3 asks the first question a process list cannot answer: what code is running inside one of these processes.