Security Engineering · Incident Response
Recovering Hidden Processes from a Physical Memory Image
How the kernel links processes in several independent structures, why unlinking from one is insufficient, and the scan strategies that recover what the lists no longer contain.
Process hiding at kernel level is an unlinking problem. The operating system keeps each process in several structures simultaneously, and a tool that removes the process from one of them changes what one enumeration method reports without changing what the process can do. The reason the technique is detectable is not that the kernel protects the lists; it is that the structures are numerous, related, and hold pointers to one another, so the cost of unlinking consistently is high and the residue of partial unlinking is characteristic.
Understanding which structures exist, and what each one implies, is what separates a scan that finds something from a scan that reports noise.
The structures a process appears in
On Linux, the primary object is the task structure, allocated from the kernel’s slab allocator. The running process’s structure is reachable from the stack it executes on, and the same object is reachable through several other paths.
The task list is a doubly-linked list threaded through the tasks’ tasks fields, with the list head in the initial task structure. Iterating it is what /proc does and what the ps command effectively walks. Removing a process from this list hides it from every consumer of /proc.
The parent-child list is a separate set of links. Each task’s children list contains its children, threaded through each child’s sibling field. A hidden process whose parent-child links remain intact is reachable from its parent even though the global list does not contain it, and a walk that descends the tree from the initial task finds it.
The pid hash table maps numeric pids to task structures and is used by any operation that resolves a pid. A process unlinked from the global list but left in the hash table can still be signalled, waited on, and found by pid lookup.
The thread group links threads that share an address space. Each thread is a task structure; the group leader holds the shared resources, and the group’s thread list links the members. Hiding one thread while leaving the group intact leaves a thread reachable through its leader.
Beyond these, a task is reachable from its open file descriptions, its memory mappings, its timers, its signal handlers, and the scheduler’s run queues if it is runnable. The run queue is the most awkward for a hider: a process that must execute has to be schedulable, and the scheduler’s structures must contain it for that to happen.
What the consistency requirement implies
A rootkit that unlinks a task from the global list and nothing else produces a set of contradictions that a scanner can enumerate.
The process’s parent still lists it as a child, so a tree walk from init finds a process whose tasks links are not in the global list. The pid hash still resolves the pid, so a scan that enumerates pids independently of the list finds a pid whose task is not in the list. The task’s own memory still contains its address space, so the process’s user-space pages appear in the image with no corresponding entry in any list-derived process table.
Each of these is a differential: two enumerations that should agree and do not. The technique is not to trust either enumeration, but to compute both and take the difference.
Scanning strategies and their false-positive profiles
Four strategies are in common use, in increasing order of coverage and decreasing order of precision.
List walking follows the global task list from the list head, validating each pointer before dereferencing it. Precision is perfect, since everything found is a real task structure, and coverage is exactly what the attacker chose to leave visible.
Tree walking descends from the initial task through parent-child links. It finds processes the global list omits, at the cost of requiring the links to be intact; a rootkit that also detaches the process from its parent’s child list defeats it.
Pool or slab scanning searches the kernel’s allocator regions for objects whose first bytes match the expected type signature. This finds structures that are in no list at all. Its precision depends entirely on the signature: a slab scan for task structures looks for a specific pointer value in a specific field, and a false positive is any memory region that happens to hold that value. On a large image, the false positives can number in the thousands, and each must be validated by following its pointers and confirming that the rest of the structure is self-consistent.
Cross-view comparison enumerates processes through a completely different interface and compares. On Linux this could be walking the pid hash; on Windows, comparing the linked list of process objects against a scan of the handle table or against the thread scheduler’s structures. It is the highest-signal method because it compares two independent derivations rather than validating one.
Windows presents the same problem with different names. Process objects are linked in the active process list, and each has a unique process id and an inherited parent. The scheduler’s thread structures point back to their owning process, so a process whose object is unlinked from the list but whose threads remain schedulable is reachable through the thread enumeration. Scanning for process objects by pool tag and validating the type index is the Windows equivalent of slab scanning, with the same precision problem: a pool scan on a multi-gigabyte image produces many candidates that must be filtered.
Validation is the whole job
The output of a scan is a set of candidate addresses. Deciding which are real processes requires following every pointer in the candidate structure and confirming the referenced regions are plausible: the pid is in a valid range, the parent pointer resolves to a structure that is itself a process, the memory descriptor’s page tables contain entries whose present bits and physical addresses are consistent, the name field holds printable characters and a terminator.
def validate_task(task, image, kva_to_pa, valid_pids):
# Every pointer in the candidate must resolve to readable, self-consistent memory.
if task["pid"] not in valid_pids:
return False
if not task["comm"].isascii() or not task["comm"].strip():
return False
parent = deref(image, kva_to_pa, task["parent"])
if parent is None or parent["pid"] not in valid_pids:
return False
mm = deref(image, kva_to_pa, task["mm"])
if mm is None:
return True # kernel threads legitimately have no mm
pgd = deref(image, kva_to_pa, mm["pgd"])
return pgd is not None and pgd["entry_count"] > 0
The rule that makes validation work is that a genuine kernel structure is internally consistent to a depth no accidental match achieves. A random 8-byte value interpreted as a pid has a small chance of landing in range; the chance that the same candidate’s parent pointer, memory descriptor, and page table root all resolve plausibly is negligible. The validation depth is the precision dial, and it is also the cost: each level of dereference requires a virtual-to-physical translation, and a scan of a large image performs millions.
What the technique does not catch
Direct kernel object manipulation is one family. A rootkit that patches the kernel’s own functions rather than unlinking structures, for example by hooking the call that fills the process list buffer so that it omits entries, leaves every structure intact and every enumeration consistent. The image contains the real task, and every list walk agrees on an incomplete set. Detecting this requires comparing the code in the image against a known-good copy of the kernel, which is a different analysis with a different failure mode: the code may legitimately differ because of a kernel module or a live patch.
The second family is memory-only execution. Code that runs from an anonymous mapping, or from a region marked executable but backed by no file, has no process entry to hide because it is not a process. It is visible only as a memory region with permissions inconsistent with its backing, which is a scan of the address space rather than of the process lists. The absence of a corresponding file on disk is what makes the region interesting, and establishing that absence depends on filesystem timestamp and directory-entry evidence rather than on the memory image alone.
The third is the image’s own limitations. A page the acquisition tool failed to read, or a region the acquisition process perturbed, is absent from the analysis. A hidden process whose structures fall in an unread region is not detectable from the image, and no scan strategy changes that. Coverage of the image is the ceiling on coverage of the scan.
References
- Understanding the Linux Kernel, 3rd edition — Bovet & Cesati, O'Reilly
- Windows Internals, Part 1 — Yosifovich, Ionescu, Russinovich & Solomon, Microsoft Press
- Intel 64 and IA-32 Architectures Software Developer Manual, Volume 3A: System Programming Guide — Intel Corporation
- Practical Malware Analysis — Sikorski & Honig, No Starch Press