diff --git a/crates/memf-windows/src/process.rs b/crates/memf-windows/src/process.rs index 437c0cd..7cf0bde 100644 --- a/crates/memf-windows/src/process.rs +++ b/crates/memf-windows/src/process.rs @@ -51,6 +51,13 @@ fn read_process_info( ) -> Result { let pid: u64 = reader.read_field(eproc_addr, "_EPROCESS", "UniqueProcessId")?; let ppid: u64 = reader.read_field(eproc_addr, "_EPROCESS", "InheritedFromUniqueProcessId")?; + // `ImageFileName` is `UCHAR[15]` and the kernel NUL-terminates it, so it + // carries at most 14 CHARACTERS: `coreupdater.exe` is stored as + // `coreupdater.ex\0`. Verified in the raw bytes of the Case-001 dump, not + // inferred. Reading 15 and stopping at the NUL is therefore correct and the + // short name is the EVIDENCE -- do not "repair" it to an expected filename. + // The untruncated path lives in SeAuditProcessCreationInfo.ImageFileName or + // RTL_USER_PROCESS_PARAMETERS.ImagePathName; surface those as enrichment. let image_name = reader.read_field_string(eproc_addr, "_EPROCESS", "ImageFileName", 15)?; let create_time: u64 = reader.read_field(eproc_addr, "_EPROCESS", "CreateTime")?; let exit_time: u64 = reader.read_field(eproc_addr, "_EPROCESS", "ExitTime")?; diff --git a/crates/memf-windows/src/psscan.rs b/crates/memf-windows/src/psscan.rs index d791277..0a570ab 100644 --- a/crates/memf-windows/src/psscan.rs +++ b/crates/memf-windows/src/psscan.rs @@ -75,6 +75,18 @@ fn plausible_pid(pid: u64) -> bool { /// Decode a 15-byte `ImageFileName` to a validated process name, or `None`. /// Requires printable ASCII, at least one letter, and NUL-or-end termination. +/// +/// The field holds at most 14 CHARACTERS: it is `UCHAR[15]` and the kernel +/// reserves the final byte for the NUL, so a longer executable name is +/// truncated by Windows before any tool reads it (`coreupdater.exe` is stored +/// as `coreupdater.ex\0`). A short name here is the evidence, not a decode +/// defect. +/// +/// Because truncation is lossy, this value is a PREFIX and not an identity: +/// `MicrosoftEdgeUpdate.exe` and `MicrosoftEdgeUpdateCore.exe` — two real, +/// separately signed binaries — both store as `MicrosoftEdgeU`. Callers must +/// not treat a match here as identifying a specific executable. The `..=15` bound still admits an unterminated 15-byte field, which +/// some kernels do write. fn decode_image_name(raw: &[u8]) -> Option { let end = raw.iter().position(|&b| b == 0).unwrap_or(raw.len()); // A real `ImageFileName` is an executable base name — never a single diff --git a/docs/interpreting-process-names.md b/docs/interpreting-process-names.md new file mode 100644 index 0000000..a393a07 --- /dev/null +++ b/docs/interpreting-process-names.md @@ -0,0 +1,89 @@ +# Interpreting process names + +## A 14-character process name is not a truncation bug + +`ps` may report `coreupdater.ex` for a process whose executable on disk is +`coreupdater.exe`. That is the evidence, reported verbatim. Nothing was lost by +the reader. + +Windows stores the process name in + +```c +UCHAR ImageFileName[15]; // _EPROCESS +``` + +and the kernel NUL-terminates it, **reserving the final byte for the +terminator**. The field therefore carries at most **14 characters**, and any +longer executable name is truncated by Windows when the field is populated — +before any forensic tool sees it. + +### Confirmed in the artifact, not inferred + +From the DFIR Madness Case-001 memory image (`citadeldc01.mem`), PID 3644: + +| offset | bytes | inside the `_EPROCESS` for PID 3644? | +|---|---|---| +| `0x02082cb38` | `coreupdater.ex\0` | **yes** — PID present as a `u64` within ±0x400 | +| `0x0195086ea` | `coreupdater.exe\0` | no — a different structure | + +The `_EPROCESS` copy is 14 characters plus a NUL, filling all 15 bytes. The full +name does exist in the dump, but in a structure unrelated to that process object. + +### What this means for an examiner + +- **The short name is a faithful observation.** Cite it as what + `_EPROCESS.ImageFileName` contained, not as the filename on disk. +- **It is not a unique identifier.** Any two executables sharing their first 14 + characters are indistinguishable here — see the collision table below. +- **The full path is elsewhere.** `SeAuditProcessCreationInfo.ImageFileName` + holds the NT path; `RTL_USER_PROCESS_PARAMETERS.ImagePathName` and + `CommandLine` hold the launch path and arguments (both reachable through the + PEB, so they need the process address space). +- **Corroborate before naming a file.** A filesystem or registry artifact from + the same host will give the untruncated name; the memory field alone will not. + +## Different executables collide in this field + +Truncation is lossy, so distinct filenames can share one stored value: + +| executable | length | stored in `ImageFileName` | +|---|---|---| +| `MicrosoftEdgeUpdate.exe` | 23 | `MicrosoftEdgeU` | +| `MicrosoftEdgeUpdateCore.exe` | 27 | `MicrosoftEdgeU` | +| `coreupdaterupdater.exe` | 22 | `coreupdaterupd` | +| `coreupdaterupdater2.exe` | 23 | `coreupdaterupd` | + +The first pair is not contrived: those are two real, separately signed Microsoft +binaries that this field cannot tell apart. + +Two consequences follow. + +**The stored value often is not recognisable as a filename.** `MicrosoftEdgeU` +has lost its extension entirely. An examiner who does not know the 14-byte limit +gets no cue that anything was cut — unlike `coreupdater.ex`, where the mangled +`.ex` hints at it. + +**It is a masquerading vector.** An attacker who names a binary so that its first +14 characters match a legitimate process produces an identical `ImageFileName`. +A process list alone cannot separate them, and the collision needs no trickery +beyond choosing a long enough name. + +So for any process whose stored name is exactly 14 characters — the tell that +truncation may have occurred — treat the name as a **prefix**, not an identity, +and resolve it against the full path (`SeAuditProcessCreationInfo.ImageFileName`, +`RTL_USER_PROCESS_PARAMETERS.ImagePathName`) or against a filesystem artifact +before attributing behaviour to a named program. + +### Why we do not "fix" it + +Rewriting `coreupdater.ex` to `coreupdater.exe` because the longer name is +expected would fabricate evidence — the tool would be reporting a value that is +not in the dump. The field is reported as found; fuller names are surfaced as +separate, attributed enrichment. + +### Same shape elsewhere + +Linux `task_struct.comm` is `char[16]` with the same reserve-a-byte behaviour +(15 usable characters), so long process names truncate there too. Treat any +fixed-size kernel name field as a candidate: check the raw bytes before +concluding a parser is at fault. diff --git a/mkdocs.yml b/mkdocs.yml index 3daf53c..d25b87a 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -42,6 +42,7 @@ markdown_extensions: nav: - Home: index.md - Validation: validation.md + - Interpreting process names: interpreting-process-names.md - Privacy Policy: privacy.md - Terms of Service: terms.md