One registry value holding a serialised database. The compatibility cache Windows keeps to avoid re-examining binaries, what each field in a record actually says, and the three things it is routinely claimed to prove that it cannot.
Windows carries decades of backwards compatibility. When a program starts, the Application Compatibility engine has to decide whether that binary needs a shim: a small patch that lies to the program about the Windows version, or redirects a path, or fakes an API that no longer behaves the way it did in 2001. Deciding that requires looking at the file, and looking at every file every time would be slow. So Windows keeps a cache of what it has already looked at.
That cache is the artifact. It exists to make compatibility checks fast. It was never designed to be evidence, which is exactly why it is such good evidence: nothing about it is defensive, and nothing about it is documented by Microsoft.
One value. HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\AppCompatCache
holds all of it, and the registry knows nothing about what is inside: to the hive this is a
single REG_BINARY blob of 100,394 bytes. Everything below is structure the
registry does not describe, which is why ShimCache needs a parser of its own rather than being
another key someone reads.
Read from the AppCompatCache value of the reference capture. The header is what tells a parser which format it is holding; the records are what it finds after it. Five views: the header, a file record, that record's trailing blob expanded into its 12-byte slots, a packaged (Store/UWP) record, and a record whose blob reports a 32-bit image.
Structure is real and measured. Paths that could identify the machine are replaced with a placeholder of exactly the same byte length, so every offset still adds up; stock Windows paths are left as they are.
The same fields as a table. Both are drawn from one definition, so the map and the table cannot disagree with each other.
Table 1| Offset | Size | Field | What it is |
|---|
Where these figures come from. The header, the records and the byte maps on this page are read from the AppCompatCache value of a live Windows 11 system - the reference capture. Paths that could identify the machine are replaced by placeholders of exactly the same byte length, so every offset still adds up; stock Windows paths are left as they are. Every offset, size and count is the real measurement, and all of it is regenerated by gen_shimcache_bytemap.py from one definition, so the maps, the hex dump and the tables cannot disagree with each other.
The map above offers five tabs, and they are not five things found in the cache. Two of them are one record read at two depths, three are one skeleton carrying different content, and all of them hang off the header. ShimCache has no pointers: a record is reached only because the one before it declared its own length. Hover a box or a line to read what it is; click a box to open that view in the map above.
The first four bytes are the header length, and that number is the version marker. There is no version string anywhere; the size of the header is how a parser tells one layout from another.
Table 2| Header | Windows | Record shape |
|---|---|---|
| 0x30 | Windows 7 and Server 2008 R2 | fixed width, 512 entries |
| 0x34 | Windows 10 and 11 | variable width, 10ts signed, 1,024 entries |
The reference system reports 0x34. Read a 0x34 cache with the Windows 7 layout and nothing raises: the fixed-width reader walks off into the middle of a path and returns entries that look like entries. That is the failure this format invites, and it is why the header check comes first.
Two real captures settle it. On one, the dword at offset 4 reads 8,395 while walking the records finds 1,024; on the other it reads 0 while the cache holds 456. Every dword in the header was checked against both the record count and the value size on both captures, and none of them matched either.
So there is no count to trust, and a parser that invents one from the header is reporting a number the artifact never stated. The only honest count is the one obtained by walking the records - stepping by each record's own cell size, and stopping where the signatures stop.
One entry from the reference capture, at offset 0xE956 of the value. The path is replaced with a placeholder of the same length, so every offset below is still the real one. These are the same bytes the map above is drawn from - one definition, so the two cannot disagree.
Click any byte, or any row of the table below, to read the field it belongs to.
| Offset | Size | Field | What it is |
|---|---|---|---|
0x00 | 4 | Signature "10ts" | 10ts, little-endian 0x73743031 |
0x04 | 4 | Record id | 0x7A1AA073 - different on every record |
0x08 | 4 | Cell size | 142, counted from offset 12, so the record is 12 + 142 = 154 bytes |
0x0C | 2 | Path length | 44 bytes, which is 22 UTF-16 characters |
0x0E | 44 | Path (UTF-16LE) | UTF-16LE, no terminator |
0x3A | 8 | Last modified (FILETIME) | FILETIME, the file's mtime |
0x42 | 4 | Data size | 84 bytes follow |
0x46 | 84 | Data | 7 slots of 12 bytes - tag, type, value |
The arithmetic closes: 14 bytes of fixed fields, 44 of path, 8 of FILETIME, 4 of data length and 84 of data blob gives 154, and the cell size field says 142 counted from offset 12, which is 12 + 142 = 154. A layout that adds up is the cheapest verification a parser has, and one that does not is a layout somebody guessed.
Every one of the 456 entries on the reference capture, at its real offset and its real length. The header first, then the records end to end - there is no index between them and no padding.
One row per entry in shimcache_entries. The From column says where the value was read from rather than who produced it: record +0x08 is a field at that offset in the byte map above; record, after the path is a field whose offset moves with the path length and therefore has no constant to give; the data slots is the trailing blob decoded as triples; the walk is where the reader had got to; and bookkeeping has no origin in the cache at all. Filled on is measured against the reference capture - several columns exist for only one of the two record shapes, and the table says so rather than implying every column is always there.
| Column | From | Type | What it means | Filled on |
|---|---|---|---|---|
id | bookkeeping | - | The database's own row number. It orders nothing and means nothing outside this table. | every row |
filename | from path | text | The last component of the path. For a packaged app there is no path, so this carries the package identity instead - which is why it is filled on every row while path is not. | every row |
path | record +0x0E | UTF-16LE | The full path of the binary, as the cache recorded it. Its length is the field immediately before it, and every field after it moves accordingly. | 372 of 456 rows |
entry_type | from path | file / packaged app | Which of the two record shapes this was. A record with no path is a packaged (Store/UWP) application, and the distinction is what stops a packed package name being read as a file path. | every row |
package_family_name | from raw_entry | text | The package family, split out of the packaged record's own payload. Left whole it sits in the path column and correlation against Prefetch or AmCache can never match the same program. | 84 of 456 rows |
package_version | from raw_entry | text | The package version, from the same payload. | 84 of 456 rows |
architecture | the data slots | x64 / x86 | Decoded from the machine-type slot in the record's trailing data. For a packaged app it is the package's declared architecture instead, which is not the same thing as the image's and is deliberately not overwritten. | 386 of 456 rows |
raw_entry | record +0x46 | text | The packaged record's payload, kept as the tab-separated fields it was read as. Present only on packaged records. | 84 of 456 rows |
last_modified | record, after the path | FILETIME | The FILETIME the record carries - the binary's modification time, not a run time. It follows the path, so it has no fixed offset. | 372 of 456 rows |
last_modified_readable | from last_modified | text | The same timestamp rendered for display, and the literal string Unknown where there is none - which is why it is filled on every row while last_modified is not. | every row |
data_size | record, after the path | UInt32 | How many bytes of trailing data the record declares. It follows the FILETIME, so its offset moves too. | every row |
entry_size | record +0x08 | UInt32 | The cell size the record declares, plus the 12 bytes that field excludes - signature, record id and itself. This is the number the walk adds to reach the next record, and the only correct way to do so. | every row |
cache_entry_position | the walk | file offset | Where in the value this record began. It is the row's provenance: the claim can be checked by going back to that offset in the evidence. | every row |
cache_index | the walk | UInt32 | How many records the walk had already passed. ShimCache is ordered most-recent-first, so this is the only ordering the artifact carries - and it is a position, never a time. | every row |
record_id | record +0x04 | UInt32 | A per-record identifier. Measured across a live cache it was unique on every single record, which is what makes it usable as an identity. | every row |
shim_flags | the data slots | tag=value list | The record's trailing data, decoded as 12-byte (tag, kind, value) triples and written out one part per slot. Two separate slots carry the machine type and agreed on every record measured. | every row |
entry_hash | from path, raw_entry, last_modified, data_size and cache_entry_position | hash | The dedup key, and the reason packaged apps can be told apart at all: they share an empty path, so 209 of them would have collided under the old UNIQUE(path, last_modified). | every row |
parsed_at | bookkeeping | - | When Crow-Eye read the cache. Bookkeeping - it belongs on no timeline and describes the examination, not the evidence. | every row |
This is the most common misuse of ShimCache in a report. An entry means the compatibility engine looked at the file. That happens on execution, and it also happens when a file is enumerated - browsing a directory in Explorer can create entries for binaries nobody ran. The entry proves the file existed at that path and that Windows saw it. It does not prove anybody ran it.
The FILETIME in a record is the file's last modification time from the file system. It is not when the file ran, and it is not when the entry was made. A binary whose timestamps were altered before it was dropped carries the altered time straight into the cache, and the cache will repeat it confidently.
On Windows 10 and 11 the cache is held in memory and flushed to the registry when the system shuts down. Everything since the last clean shutdown exists only in RAM. A hive pulled from a running machine - which is the normal case in an incident - has a ShimCache that stops at the last reboot, and a machine that crashed may never have flushed at all.
What it is genuinely good for: the order. Entries sit in most-recently-examined order, so two adjacent entries were seen in the same window even if their recorded timestamps are years apart. Position carries information no timestamp in the record does, and it survives timestomping because an attacker who alters a file's mtime does not thereby move it in the cache.
ShimCache gets its own parser rather than being handled by the registry parser, and the reason is structural: this is not a key with values. It is one value holding a serialised database with its own header, its own version signature and its own record layout. The registry is only the envelope it arrived in. The same is true of its versioning - the record shape tracks the Windows release, not the hive format - so a change in Windows 11 breaks ShimCache parsing and nothing else.
Crow-Eye reads the cache two ways, and shares the format code between them. On a live
machine it reads the value through the registry API; against a collected image or a hive
file it opens SYSTEM directly, replaying the transaction logs first so it sees
the recovered state rather than the flushed one. Only the source of the bytes
differs - both hand the same blob to the same walk, so the two paths cannot drift into
giving different answers. Both also read every control set present, not
just CurrentControlSet: an older set can hold entries the current one has
already aged out. The
registry internals guide covers how that file
is laid out and why a hive read from a running system may be mid-transaction.
ShimCache is one registry value, so acquiring it looks trivial and is
not. What a reader ends up holding depends on how it was taken, and the
routes do not return the same thing. The value is at
HKLM\SYSTEM\{ControlSet}\Control\Session Manager\AppCompatCache,
under every control set present rather than only the current
one.
| Route | What it holds | What it misses |
|---|---|---|
| The live registry, through the API | What was flushed at the last clean shutdown. | Everything since. On a machine that has not shut down cleanly it can be empty - see below. |
A SYSTEM hive from an image or a collection |
The same flushed state, plus whatever the transaction logs recover. Crow-Eye replays those logs before reading. | The in-memory cache, which was lost with the machine. |
| Older control sets, and hives from shadow copies | Entries the current set has already aged out. This is the cheapest way to reach past the 1,024-entry cap. | Nothing the current set has that they do not - read both. |
| A memory image | The complete cache on a running machine, including everything since the last shutdown. | Crow-Eye does not read this. It is named here because on a live incident it is the only copy that is current. |
The page above says a live machine's copy is stale. It can also be
empty, which is a different thing and invites a worse mistake. Read from
this reference system while writing this section, the value came back as
52 bytes - a complete 0x34 header and not one record after it. The header is not blank either: the DWORD at 0x10 reads 84, which is one of the fields the reference above records as observed rather than explained.
That is a well-formed cache containing nothing. It happens because the registry copy is written at shutdown and the in-memory cache had not been flushed to it. An examiner who finds a header and no records has found a machine whose cache has not been written since it was last cleared - not, on that evidence alone, a machine somebody wiped. The two look identical in the registry, and the difference has to come from somewhere else.
ShimCache evicts. The cache holds at most 1,024 entries and drops the
oldest to make room, so an entry seen in one collection may be absent from
the next. Crow-Eye's table does not follow it down: each parse inserts
only records whose entry_hash is new and leaves everything
already stored in place, so the table accumulates across parses
and can hold more than the cache currently does. Two collections
a month apart merge rather than replace.
That is deliberate, and it is why the schema is extended with
ALTER TABLE rather than rebuilt: dropping the table to gain
a column would discard every entry that has since aged out of the cache,
and those are exactly the ones no second collection can recover.
An entry says a file existed at a path and that Windows looked at it. Almost every question worth asking needs a second artifact, and which one depends on the question.
Table 6| The question | Read it with | What the pair settles |
|---|---|---|
| Did it run? | Prefetch, or event log 4688 | ShimCache cannot answer this at all on Windows 10 or 11. Prefetch records a run and counts it; 4688 records a process creation with its parent. |
| Was the file catalogued, or only seen? | AmCache | AmCache carries a SHA-1 and an install association. A binary in both was seen twice by two different mechanisms; one in ShimCache alone was looked at and never inventoried. |
| Is the recorded timestamp true? | The MFT | The record carries the file's $STANDARD_INFORMATION
modification time. A disagreement with
$FILE_NAME is the classic timestomping
indicator, and ShimCache repeats the altered value without
complaint. |
| Is the file still there? | The file system, or the USN journal | A path in the cache with no file at the other end is common and is often the most interesting row on the table. The journal says whether it was deleted and when. |
| When, relative to what? | ShimCache itself | Position is the artifact's own answer. Entries sit in most-recently-examined order, so two adjacent entries were seen in one window whatever their recorded timestamps say - and an attacker who alters an mtime does not thereby move a record. |
Each row below records a change that alters what a reader may conclude, not only how the bytes are arranged. A parser written against one Windows release returns nothing against another, and does so without raising an error. Crow-Eye parses the Windows 10 and 11 format only. A cache whose header is one of the older magics below is identified by name and returns no entries - it is refused rather than guessed at, because handing an unknown layout to the Windows 10 walk returns whatever a byte scan happens to find. An empty ShimCache table from a Windows 7 image means the format is not supported, and the parser says so on the console rather than only in the count.
Table 7| Windows | What changed | What it means for a reader |
|---|---|---|
| XP | Fixed-width records, magic 0xDEADBEEF | Fixed size, so the walk is trivial - and capped at 96 entries, a fraction of what a modern cache holds. |
| Vista and 7 | Magic 0xBADC0FFE (Vista) and 0xBADC0FEE (7), 1,024 entries | Windows 7 carries an execution flag. This is the build the 'ShimCache proves execution' claim came from, and it was true here. |
| 8 | Per-record 00ts signature appears | Records become variable length, with the path inside the record. |
| 10 and 11 | Header 0x34, per-record 10ts | The execution flag is gone and nothing replaced it. Every modern citation of ShimCache as execution evidence is carrying a Windows 7 fact forward by a decade. |
| Header | Decimal | Windows |
|---|---|---|
0x34 | 52 | Windows 10 and 11 - the header in the map above |
0x30 | 48 | Windows 10, earlier builds |
0x80 | 128 | Windows 8 and 8.1 |
0xDEADBEEF | - | Windows XP - a magic, not a size; a different layout entirely |
0xBADC0FFE | - | Windows Vista and Server 2008 |
0xBADC0FEE | - | Windows 7 and Server 2008 R2 |
| Field | Present | Means |
|---|---|---|
| Path | always | The file the shim engine examined. |
| Last modified | always | The FILE's timestamp, not the cache's. |
| Execution flag | Windows 7 only | Absent on Windows 8 and later. |
| Run count | never | ShimCache has never recorded one on any version. |
| Execution time | never | Nothing in the record says when anything ran. |
The preceding sections describe what an intact cache supports. This section describes the cache after an attempt to remove or evade it, which is the condition in which it is frequently encountered. In most rows the attempt leaves a trace of its own.
Table 10| Technique | What is done | What survives |
|---|---|---|
| Clearing the value | AppCompatCache is deleted or zeroed. | The value is rebuilt at the next shutdown, so an empty or very short cache on a long-running machine is itself the finding. |
| Shutdown timing | Pulling power rather than shutting down cleanly. | Not anti-forensics but has the same effect: the cache is written at shutdown, so a machine seized running has a cache that stops at the last clean shutdown. Everything since is missing and nothing says so. |
| Renaming the binary | The file is moved or renamed after running. | The old path stays in the cache. A path here with no file at the other end is common and often the most interesting row on the table. |
| Cache pressure | Running many binaries to push older entries out. | The cap is 1,024. Eviction is by position, so flooding the cache does evict history - and leaves 1,024 entries whose timestamps cluster in a way that normal use does not produce. |