Windows Prefetch: what it proves, and what it does not
Prefetch at the byte level: the SCCA header, MAM compression, the path hash, run times and volumes, with working Python and the limits to state in a report.
On this page
Prefetch is one of the most useful execution artifacts on a Windows workstation, and one of the easiest to over-read. Most write-ups stop at “it shows the program ran, and when”. This one goes down to the bytes: how the file is laid out, how Windows 10 and 11 compress it, how the hash in the file name is calculated and can be used to confirm the full path, and which conclusions hold up under questioning.
Offsets follow libyal’s Windows Prefetch File format notes. The code samples were run against real prefetch files from Windows XP, 7, 8, 10 and 11 before going into this post, and the outputs quoted come from those runs.
What prefetch records, and why
Link to section: What prefetch records, and whyThe Windows prefetcher watches the first seconds of a program’s start-up, records the files and directories it touches, and stores that trace so the next launch can read those pages ahead of time. The traces live in C:\Windows\Prefetch\ as .pf files, one per executable path.
For an investigator, a prefetch file answers: this executable ran on this machine, this many times, last at these times, from this volume, and touched these files while starting.
The file name and the path hash
Link to section: The file name and the path hashA prefetch file is named <EXECUTABLE>-<HASH>.pf:
- The executable name is upper case and truncated to 29 characters.
AM_DELTA_PATCH_1.443.990.0.EXEis stored asAM_DELTA_PATCH_1.443.990.0.EX. - The hash is computed over the executable’s full device path in upper case UTF-16LE, for example
\DEVICE\HARDDISKVOLUME1\WINDOWS\NOTEPAD.EXE.
Because the path is hashed, the same binary run from two folders produces two prefetch files. SVCHOST.EXE-XXXXXXXX.pf with an unfamiliar hash is often the first sign that a familiar name ran from an unfamiliar place.
libyal documents three hash functions:
| Hash function | Used by |
|---|---|
| SCCA XP | Windows XP, Server 2003 |
| SCCA 2008 | Windows 7, Server 2008 and 2012, 8 and 8.1 |
| SCCA Vista | Windows Vista, and again Windows 10 and 11 |
The function Windows 10 and 11 use is short. Starting from 314159, each byte of the UTF-16LE path is folded in with a multiplier of 37:
def prefetch_hash_win10(device_path: str) -> int:
"""SCCA 'Vista' hash, used by Windows 10 and 11 (and Vista)."""
h = 314159
for byte in device_path.upper().encode('utf-16-le'):
h = (h * 37 + byte) % 2**32
return h
path = r'\DEVICE\HARDDISKVOLUME8\WINDOWS\SYSTEM32\CALC.EXE'
print(f'{prefetch_hash_win10(path):08X}')
# 3FBEF7FD -> CALC.EXE-3FBEF7FD.pfWindows 10 shows volume paths as \VOLUME{...} inside the file, but the hash is still calculated over \DEVICE\HARDDISKVOLUME<n>. If you know or suspect the folder, try n from 1 to 20 and see which one reproduces the hash. In the sample set used for this post, three different Windows 10 executables (CALC.EXE, CHROME.EXE and DEVENV.EXE) all matched only with HARDDISKVOLUME8, which is what you would expect from one system disk.
That check is useful in practice. It can confirm that a prefetch file belongs to a specific path, and it can show that an entry does not match the path a user or a tool claims. libyal also notes that /prefetch:<n> command-line values are mixed into the hash, so one executable launched with different /prefetch switches can produce several files. That is normal for browsers and other multi-process programs and should not be read as several copies of the binary.
Windows 10 and 11: the MAM container
Link to section: Windows 10 and 11: the MAM containerFrom Windows 10 onwards, a .pf file on disk is compressed. It starts with an 8-byte header:
| Offset | Size | Field |
|---|---|---|
| 0 | 4 | Signature MAM\x04 |
| 4 | 4 | Uncompressed size |
The rest is Microsoft XPRESS Huffman data (not to be confused with plain XPRESS). On Windows, ntdll decompresses it directly:
import ctypes, struct
COMPRESSION_FORMAT_XPRESS_HUFF = 0x0004
def decompress_mam(blob: bytes) -> bytes:
if blob[:4] != b'MAM\x04':
return blob # Windows 8.1 and earlier: not compressed
size = struct.unpack_from('<I', blob, 4)[0]
ntdll = ctypes.WinDLL('ntdll')
ws_buf, ws_frag = ctypes.c_ulong(), ctypes.c_ulong()
ntdll.RtlGetCompressionWorkSpaceSize(
ctypes.c_ushort(COMPRESSION_FORMAT_XPRESS_HUFF),
ctypes.byref(ws_buf), ctypes.byref(ws_frag))
out, final = ctypes.create_string_buffer(size), ctypes.c_ulong()
data = blob[8:]
status = ntdll.RtlDecompressBufferEx(
ctypes.c_ushort(COMPRESSION_FORMAT_XPRESS_HUFF), out, size,
ctypes.create_string_buffer(data, len(data)), len(data),
ctypes.byref(final), ctypes.create_string_buffer(ws_frag.value))
if status:
raise OSError(f'RtlDecompressBufferEx failed: 0x{status & 0xffffffff:08x}')
return out.raw[:final.value]Off Windows there is no ntdll, so a parser needs its own XPRESS Huffman decoder (MS-XCA specifies the algorithm). A tool that cannot decompress simply cannot read modern prefetch.
The decompressed layout
Link to section: The decompressed layoutHeader (84 bytes)
Link to section: Header (84 bytes)| Offset | Size | Field |
|---|---|---|
| 0 | 4 | Format version: 17, 23, 26, 30 or 31 |
| 4 | 4 | Signature SCCA |
| 12 | 4 | File size |
| 16 | 60 | Executable name, UTF-16LE, up to 29 characters |
| 76 | 4 | Prefetch hash (should match the file name) |
| Version | Windows | Last run times |
|---|---|---|
| 17 | XP, Server 2003 | 1 |
| 23 | Vista, 7 | 1 |
| 26 | 8.0, 8.1 | 8 |
| 30 | 10 | 8 |
| 31 | 11 | 8 |
File information (format 30 and 31)
Link to section: File information (format 30 and 31)The file information block follows the header at offset 84. Format 30 has two variants, told apart by the first field: the metrics array offset is 304 in variant 1 and 296 in variant 2. The format 31 sample examined for this post used the variant 2 layout.
| Offset in block | Field | Notes |
|---|---|---|
| 0 | Metrics array offset | 304 (variant 1) or 296 (variant 2) |
| 4 | Number of metrics entries | One per file loaded at start-up |
| 8, 12 | Trace chain array offset, count | Block-level read trace |
| 16, 20 | Filename strings offset, size | UTF-16LE strings |
| 24, 28, 32 | Volumes offset, count, size | |
| 44 | Last run times | 8 FILETIMEs, most recent first, unused slots zero |
| 124 or 116 | Run count | 124 in variant 1, 116 in variant 2 |
Reading the run count from the wrong offset produces a plausible-looking but wrong number, which is why a parser has to detect the variant rather than assume it.
Metrics entries (32 bytes each)
Link to section: Metrics entries (32 bytes each)| Offset | Size | Field |
|---|---|---|
| 12 | 4 | Filename offset, relative to the filename strings |
| 16 | 4 | Filename length in characters |
| 20 | 4 | Flags |
| 24 | 8 | NTFS file reference, or 0 |
The file reference packs the MFT entry number in the low 48 bits and the sequence number in the high 16 bits. In the format 30 samples examined here it was zero for every entry, so do not rely on it being filled in. The flag values observed were 0x100 on executable images (both NOTEPAD.EXE and its DLLs) and 0x2 on data files such as LOCALE.NLS. The individual bits are not fully documented, so report them as observations rather than meanings.
Volume entries (96 bytes each in format 30)
Link to section: Volume entries (96 bytes each in format 30)| Offset | Size | Field |
|---|---|---|
| 0, 4 | 4 + 4 | Device path offset (relative to the volumes block) and length in characters |
| 8 | 8 | Volume creation time (FILETIME) |
| 16 | 4 | Volume serial number |
| 20, 24 | 4 + 4 | File references offset and size |
| 28, 32 | 4 + 4 | Directory strings offset and count |
On Windows 10 and 11 the device path is written as \VOLUME{01d3096ba3a46863-2ca3d1ae}. The first part is the volume creation time as a 16-digit hexadecimal FILETIME, and the second is the volume serial number. In this sample, 0x01d3096ba3a46863 decodes to 2017-07-30 19:40:03 UTC, matching the creation time field, and the serial field reads 2CA3D1AE. The same pattern held on two more samples from 2015 and 2026. That pairing ties a prefetch file to one specific formatted volume, which matters when a case involves external drives or reimaged disks.
Putting it together
Link to section: Putting it togetherThis reader handles formats 30 and 31 and prints what an examiner usually needs first:
from datetime import datetime, timedelta, timezone
def filetime(v):
return None if v == 0 else datetime(1601, 1, 1, tzinfo=timezone.utc) + timedelta(microseconds=v // 10)
def utf16(buf, off, chars):
return buf[off:off + chars * 2].decode('utf-16-le', 'replace')
def read_prefetch(path):
raw = decompress_mam(open(path, 'rb').read())
version, sig = struct.unpack_from('<I4s', raw, 0)
assert sig == b'SCCA', 'not a prefetch file'
name = raw[16:76].decode('utf-16-le').split('\x00', 1)[0]
pf_hash = struct.unpack_from('<I', raw, 76)[0]
print(f'{name} version {version} hash {pf_hash:08X}')
info = 84
(m_off, m_n, _, _, s_off, _, v_off, v_n, _) = struct.unpack_from('<9I', raw, info)
variant = 1 if m_off == 304 else 2
runs = struct.unpack_from('<I', raw, info + (124 if variant == 1 else 116))[0]
print(f' variant {variant} run count {runs}')
for t in struct.unpack_from('<8Q', raw, info + 44):
if t:
print(f' ran {filetime(t):%Y-%m-%d %H:%M:%S} UTC')
for i in range(m_n):
n_off, n_len, flags = struct.unpack_from('<III', raw, m_off + i * 32 + 12)
print(f' loaded {utf16(raw, s_off + n_off, n_len)} flags 0x{flags:x}')
for i in range(v_n):
d_off, d_len, created, serial = struct.unpack_from('<IIQI', raw, v_off + i * 96)
print(f' volume {utf16(raw, v_off + d_off, d_len)} serial {serial:08X}'
f' created {filetime(created):%Y-%m-%d %H:%M:%S} UTC')Run against a Windows 10 NOTEPAD.EXE prefetch file, it prints (loaded files trimmed to the first five):
NOTEPAD.EXE version 30 hash D8414F97
variant 2 run count 2
ran 2019-06-05 19:55:04 UTC
ran 2019-06-05 19:23:00 UTC
loaded \VOLUME{01d3096ba3a46863-2ca3d1ae}\WINDOWS\SYSTEM32\NTDLL.DLL flags 0x100
loaded \VOLUME{01d3096ba3a46863-2ca3d1ae}\WINDOWS\SYSTEM32\NOTEPAD.EXE flags 0x100
loaded \VOLUME{01d3096ba3a46863-2ca3d1ae}\WINDOWS\SYSTEM32\KERNEL32.DLL flags 0x100
loaded \VOLUME{01d3096ba3a46863-2ca3d1ae}\WINDOWS\SYSTEM32\KERNELBASE.DLL flags 0x100
loaded \VOLUME{01d3096ba3a46863-2ca3d1ae}\WINDOWS\SYSTEM32\LOCALE.NLS flags 0x2
volume \VOLUME{01d3096ba3a46863-2ca3d1ae} serial 2CA3D1AE created 2017-07-30 19:40:03 UTCNotice that the run count and the number of retained run times agree here (two and two). When they do not, the difference is executions with no surviving timestamp.
For casework, use a maintained parser such as Eric Zimmerman’s PECmd or Prefetch Parser. The code above is for understanding the format, and for checking a tool’s output when it matters.
Reading the evidence
Link to section: Reading the evidenceWhat it proves
Link to section: What it proves- Execution. A prefetch file means the program started. The other files in the folder (
Layout.ini, SuperFetch databases, ReadyBoot traces) record file access, not execution, and belong in a separate part of the report. - The run count since the record was created.
- Up to eight run times on Windows 8 and later. They line up well with process-creation events (Security 4688, Sysmon 1) when those exist, and those events supply what prefetch lacks: the user and the command line.
- The files and directories touched at start-up. A DLL loaded from a user-writable folder, a document opened by a script host, or a tool reading files from a removable volume all show up in the metrics and directory lists.
- The volume the program ran from, by creation time and serial number.
What it does not prove
Link to section: What it does not proveOnly the last eight run times survive. A run count of 120 with eight timestamps means 112 executions have no time at all. Report the count and the retained times as what they are.
- Who ran it. There is no user field. Attribution needs logon sessions, process-creation events with a user, or per-user artifacts.
- That it never ran. A missing prefetch file is weak evidence. Prefetch can be disabled (the
EnablePrefetchervalue), it is commonly off on server editions, files are deleted, and old entries are replaced as the folder fills. - The exact first run. The file’s creation time is close to the first recorded run, but it is an approximation, and only meaningful where the file system kept a reliable creation time.
- The arguments. Apart from the
/prefetchswitch mixed into the hash, command lines are not recorded. - That the run succeeded. Prefetch is written for a process that started; a program that crashed or exited immediately can still leave a record.
Hidden prefetch in alternate data streams
Link to section: Hidden prefetch in alternate data streamsWhen a binary is launched from an NTFS alternate data stream (for example host.txt:np.exe), Windows writes the prefetch record into a stream as well. The carrier file shows as zero bytes and every tool that globs *.pf misses it. Two consequences for analysis:
- Scan streams, not just file names. PECmd’s
--adsswitch and Prefetch Parser’sadscommand do this. - A stream has no timestamps of its own. The carrier file’s times are not the prefetch record’s times, and a tool should not present them as such.
Always parse prefetch from a copy or an image. Opening the files on a live system updates their access times, and every tool that reads them in place leaves that footprint on the evidence.
References
Link to section: References- libyal, Windows Prefetch File (PF) format: header, file information variants, metrics and volume entries, hash functions and the MAM container.
- Microsoft, RtlDecompressBufferEx and MS-XCA: XPRESS Huffman decompression.
- Eric Zimmerman, PECmd, and the
--adspull request. - DigitalForenSeek, Prefetch Parser documentation.