Archaeology
Saved Application State
Much of this discussion is based on reverse-engineering of file formats and frameworks, but we haven't bothered to pepper it with qualifiers. Since our reverse-engineering skills are not beyond reproach, and macOS is always changing, a grain of salt is advised. If you have corrections to any details, please do get in touch.
What Is A savedState Directory?
Directories with the savedState extension are found under your home folder at Library/Saved Application State — or for a sandboxed application, under the app's “container” at under Library/Containers/com.example.bundle-identifier/Data/Library/Saved Application State.
The savedState directories are where macOS stores information for the “Resume” feature — that is, the preservation of state when quitting an app (i.e. System Settings > Desktop & Dock > Windows & Apps > Close windows when quitting an application is disabled; or on macOS 12, System Preferences > General > Close windows when quitting an app) or when restarting your computer (i.e. in the restart confirmation dialog, Reopen windows when logging back in is checked).
The files in savedState are created and used by AppKit.framework, and by talagent(8)
,
which is the background agent that manages state saving and restoration. [As noted in the man page,
TAL stands for transparent app lifecycle, the original name for “Resume.”
This was a reference to the never-quite-realized idea that the user should never (need to) know whether or
not an app is running or has been silently quit by macOS. This is perhaps one of the earliest iOS-ification efforts
on macOS, dating all the way back to Mac OS X 10.7 (Lion).]
A savedState directory contains a few different types of files, each explained below.
windows.plist
The windows.plist contains the top-level metadata about each of the (restorable) windows of the app, along with some app-level information. Note that there will always be a window defined for the main menu bar, since that's also drawn and managed in a window.
The contents of this property list are mostly self-explanatory, but one key is non-obvious:
NSDataKey
contains a per-window encryption key that is used to AES-128-CBC encrypt
the archived data that is stored in the data.data file.
Props to Sector 7 for discovering and
documenting
this critical point about NSDataKey
, in their writeup of CVE-2021-30873. We'd tried to
decode this state previously, but found it was encrypted, and it never ocurred to us that maybe
the encryption key was sitting right next to the ciphertext in plain sight!
data.data
The data.data file is the core of the restorable state data. This file is a binary, append-only list of records, each one containing:
- the window ID that the record applies to;
- a string identifier, which is the
NSUserInterfaceItemIdentifier
for anNSResponder
that was asked to encode its restorable state; and - a data blob, which the
NSKeyedArchiver
result of the responder's-encodeRestorableStateWithCoder:
implementation.
Parts (b) and (c) of each data.data record are further encoded into another custom
key-value pair structure, and encrypted with the window's NSDataKey
, using AES-128-CBC
(and thus will be padded to the 128-bit block size).
More specifically, each record starts with a header akin to this:
struct PersistentUIRecordHeader { char _magic[ 8 ]; // "NSCR1000" uint32_t _windowID; // applicable window ID [BE] uint32_t _length; // total length of record, including this header [BE] };
The window ID and all of the length fields (above and below) are Big Endian.
The ( _length - sizeof( struct PersistentUIRecordHeader ) )
bytes after the header
are the ciphertext of the record. Once this has been decrypted with the NSDataKey
,
it has a structure like this:
struct PersistentUIRecordPlaintext { uint32_t _unknown; // usually but not always zero uint32_t _keyLength; // length of the key string which follows [BE] char _key[ _keyLength ]; // the UTF-8 key string char _separator; // "rchv" uint32_t _dataLength; // length of the archived data which follows [BE] uint8_t _data[ _dataLength ]; // the NSKeyedArchiver-encoded data };
As explained above, _key
is the NSUserInterfaceItemIdentifier
and _data
is the -encodeRestorableStateWithCoder:
data blob.
Because responders can be invalidated in any combination at any time, new records are
added to data.data without deleting or rewriting previous ones, so only the last record for a
given NSUserInterfaceItemIdentifier
should be considered applicable. (At some point,
talagent does clear out and rewrite the file, and encryption keys do get refreshed on some
schedule, but we haven't investigated that any further.)
window_%u.data
For each window, there might also be a “snapshot” for the app window: this is actually a screenshot of the window as it last existed, which is sometimes used after restart to “fake” immediate restoration while the real work takes place.
The window screenshots live in files named window_%u.data, where the uint32_t
suffix is
the corresponding window ID. The contents of this file is:
- a raw
CGImage
backing buffer; - that has been compressed using the zlib DEFLATE algorithm; and then
- AES-128-CBC-encrypted.
However, the last encryption step is not done with the NSDataKey
, as
with the data.data. Instead, this encryption uses a key that is chosen randomly by talagent
and stored in your keychain. (You can find this key in Keychain Access, by searching for an item named
Apple Persistent State Encryption
.) The key appears to get cycled by talagent on some
regular basis.