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:

  1. the window ID that the record applies to;
  2. a string identifier, which is the NSUserInterfaceItemIdentifier for an NSResponder that was asked to encode its restorable state; and
  3. 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:

  1. a raw CGImage backing buffer;
  2. that has been compressed using the zlib DEFLATE algorithm; and then
  3. 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.