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.

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.

The 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.

A 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.EXE is stored as AM_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 functionUsed by
SCCA XPWindows XP, Server 2003
SCCA 2008Windows 7, Server 2008 and 2012, 8 and 8.1
SCCA VistaWindows 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:

Python
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.pf
Tip:

Windows 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 container

From Windows 10 onwards, a .pf file on disk is compressed. It starts with an 8-byte header:

OffsetSizeField
04Signature MAM\x04
44Uncompressed size

The rest is Microsoft XPRESS Huffman data (not to be confused with plain XPRESS). On Windows, ntdll decompresses it directly:

Python
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.

OffsetSizeField
04Format version: 17, 23, 26, 30 or 31
44Signature SCCA
124File size
1660Executable name, UTF-16LE, up to 29 characters
764Prefetch hash (should match the file name)
VersionWindowsLast run times
17XP, Server 20031
23Vista, 71
268.0, 8.18
30108
31118

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 blockFieldNotes
0Metrics array offset304 (variant 1) or 296 (variant 2)
4Number of metrics entriesOne per file loaded at start-up
8, 12Trace chain array offset, countBlock-level read trace
16, 20Filename strings offset, sizeUTF-16LE strings
24, 28, 32Volumes offset, count, size
44Last run times8 FILETIMEs, most recent first, unused slots zero
124 or 116Run count124 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.

OffsetSizeField
124Filename offset, relative to the filename strings
164Filename length in characters
204Flags
248NTFS 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)
OffsetSizeField
0, 44 + 4Device path offset (relative to the volumes block) and length in characters
88Volume creation time (FILETIME)
164Volume serial number
20, 244 + 4File references offset and size
28, 324 + 4Directory strings offset and count
Case note:

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.

This reader handles formats 30 and 31 and prints what an examiner usually needs first:

Python
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):

Text
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 UTC

Notice 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.

  • 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.
Warning:

Only 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 EnablePrefetcher value), 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 /prefetch switch 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 streams

When 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:

  1. Scan streams, not just file names. PECmd’s --ads switch and Prefetch Parser’s ads command do this.
  2. 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.
Tip:

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.