[go: up one dir, main page]

A Deep Dive into Apple's .car File Format

Every modern iOS, macOS, watchOS, and tvOS application uses Asset Catalogs to manage images, colors, icons, and other resources. When you build an app with Xcode, your .xcassets folders are compiled into binary .car files that ship with your application. Despite being a fundamental part of every Apple app, there is little to none official documentation about this file format.

In this post, I’ll walk through the process of reverse engineering the .car file format, explain its internal structures, and show how to parse these files programmatically. This knowledge could be useful for security research and building developer tools that does not rely on Xcode or Apple’s proprietary tools.

As part of this research, I’ve built a custom parser and compiler for .car files that doesn’t depend on any of Apple’s private frameworks or proprietary tools. To make this research practical, I’ve compiled my parser to WebAssembly so it runs entirely in your browser, so no server uploads required. You can drop any .car file into the interactive demo below to explore its content. I’m considering open-sourcing these tools, but no promises yet!

Join the discussion on Lobsters or Hacker News, or reach out on X.

What is a .car File?

When you call UIImage(named: "MyImage") in Swift (or [UIImage imageNamed:@"MyImage"] in Objective-C), the private CoreUI.framework opens your app’s Assets.car file and retrieves the appropriate image for the current device context (screen scale, appearance mode, size class, etc)

Assets.car hex dump

You can inspect car files using Apple’s closed-source tools:

  • actool: Compiles asset catalogs (usually invoked by Xcode)
  • assetutil: Processes and inspects car files

Running assetutil -I Assets.car produces a JSON dump of all assets:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
[
  {
    "Appearances" : {
      "UIAppearanceAny" : 0
    },
    "AssetStorageVersion" : "Xcode 12.5 (12E262) via IBCocoaTouchImageCatalogTool",
    "Authoring Tool" : "@(#)PROGRAM:CoreThemeDefinition  PROJECT:CoreThemeDefinition-491\n",
    "CoreUIVersion" : 691,
    "DumpToolVersion" : 972.1,
    "Key Format" : [
      "kCRThemeScaleName",
      "kCRThemeIdiomName",
      "kCRThemeSubtypeName",
      "kCRThemeDimension2Name",
      "kCRThemeIdentifierName",
      "kCRThemeElementName",
      "kCRThemePartName"
    ],
    "MainVersion" : "@(#)PROGRAM:CoreUI  PROJECT:CoreUI-691.2\n",
    "Platform" : "ios",
    "PlatformVersion" : "13.1",
    "SchemaVersion" : 2,
    "StorageVersion" : 17,
    "Timestamp" : 1769308120
  },
  {
    "AssetType" : "Icon Image",
    "BitsPerComponent" : 8,
    "ColorModel" : "RGB",
    "Colorspace" : "srgb",
    "Compression" : "lzfse",
    "Encoding" : "ARGB",
    "Icon Index" : 1,
    "Idiom" : "phone",
    "Name" : "AppIcon"
    //...
  }

The Foundation: BOM File Format

Opening a .car file in a hex editor immediately reveals its foundation: the file begins with the ASCII magic BOMStore. BOM (Bill of Materials) is an old format still used today.

1
2
3
Offset  00 01 02 03 04 05 06 07  ASCII
0x0000  42 4F 4D 53 74 6F 72 65  BOMStore
0x0008  XX XX XX XX XX XX XX XX  [version, info, ...]

Looking at CoreUI’s BOMStorageOpenWithSys function in a disassembler, we can see the magic validation:

1
2
3
4
5
6
7
8
9
// From BOMStorageOpenWithSys (0x18a69da5c)
if ( (unsigned int)BOMStreamReadUInt32(v8) == 1112493395   // 0x424F4D53 = "BOMS"
  && (unsigned int)BOMStreamReadUInt32(v9) == 1953460837 ) // 0x746F7265 = "tore"
{
    UInt32 = BOMStreamReadUInt32(v9);  // Version, must be 1
    if ( UInt32 == 1 ) {
        // ... allocate and parse storage
    }
}

A BOM file provides a hierarchical key-value store with two main primitives:

  1. Blocks: Raw binary blobs accessed by numeric index
  2. Trees: B+ tree structures for ordered key-value lookups

BOM header

From the decompiled BOMStorageOpenWithSys function shown above, we can infer the overall layout of a BOM file. It begins with a fixed-size header, followed by a block index table and a variables table.

The BOM header structure (32 bytes, big-endian):

1
2
3
4
5
6
7
8
9
struct BOMStoreHeader {
    char     magic[8];       // "BOMStore"
    uint32_t version;        // Usually 1
    uint32_t numberOfBlocks;
    uint32_t indexOffset;
    uint32_t indexLength;
    uint32_t varsOffset;
    uint32_t varsLength;
};

The Block Index is an array of (offset, length) pairs providing random access to any block by its numeric ID. The Variables Table maps string names like "CARHEADER" and "RENDITIONS" to block indices.

CAR-Specific Blocks and Trees

While standard BOM files store installer package manifests, .car files repurpose the container for asset storage. Here are the named blocks and trees found in a typical car file:

Named Blocks

Block NameSizePurpose
CARHEADER436 bytesVersion info, rendition count, UUID
EXTENDED_METADATA1028 bytesBuild tool info, platform details
KEYFORMATVariableDefines attribute ordering for keys
CARGLOBALSVariableGlobal settings (optional)

Named Trees (B+ Trees)

Tree NamePurpose
RENDITIONSAsset key → CSI data block mappings
FACETKEYSAsset name → attribute token mappings
APPEARANCEKEYSAppearance name → ID mappings (Dark Mode)
BITMAPKEYSBitmap-specific metadata
COLORS, FONTS, FONTSIZESType-specific indices

The CARHEADER Block

The CARHEADER block contains metadata about the entire asset catalog. It has a fixed size of 436 bytes. Unlike the BOM container (big-endian), CARHEADER fields are little-endian, with CoreUI calling _swapHeader when it detects the big-endian magic RATC:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
struct carheader {
    uint32_t tag;                    // 'RATC' (big-endian) or 'CTAR' (little-endian)
    uint32_t coreuiVersion;          // CoreUI build version (e.g. 804)
    uint32_t storageVersion;         // Format version (typically 15)
    uint32_t storageTimestamp;       // Unix timestamp of compilation
    uint32_t renditionCount;
    char     mainVersionString[128]; // "@(#)PROGRAM:CoreUI  PROJECT:CoreUI-804.1"
    char     versionString[256];     // Tool version string
    uuid_t   uuid;
    uint32_t associatedChecksum;     // CRC or hash
    uint32_t schemaVersion;          // Schema version (usually 2)
    uint32_t colorSpaceID;
    uint32_t keySemantics;           // Key interpretation mode
}

The KEYFORMAT Block

The KEYFORMAT block is crucial for understanding how asset keys are structured. The decompiler reveals that CoreUI validates this block and has a fallback when it’s malformed:

1
2
3
4
5
6
7
8
// From CUISystemThemeRenditionKeyFormat (0x18a6b20a8)
const char *CUISystemThemeRenditionKeyFormat()
{
  return "tmfk";  // Magic identifier for system key format
}

// From validatekeyformat (0x18a749094)
// Checks for KEYFORMATWORKAROUND block, validates attribute IDs < 0x18 (24)

The format begins with magic kfmt (or tmfk depending on byte order):

1
2
3
4
5
6
struct renditionkeyfmt {
    uint32_t tag;                          // 'kfmt' (0x6B666D74)
    uint32_t version;                      // Format version
    uint32_t maximumRenditionKeyTokenCount; // Number of attributes
    uint32_t renditionKeyTokens[];         // Array of attribute type IDs
}

The renditionKeyTokens array defines which attributes are stored in each rendition key and in what order. From the disassembler’s string references, here are the attribute types (the CUIThemeAttributeNameToString function maps indices 1-28 to these names):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// Attribute type enum (from the kCRTheme*Name strings)
enum RenditionAttributeType {
    kThemeLook                 = 0,   // Appearance look (reserved)
    kThemeElement              = 1,   // kCRThemeElementName
    kThemePart                 = 2,   // kCRThemePartName
    kThemeSize                 = 3,   // kCRThemeSizeName
    kThemeDirection            = 4,   // kCRThemeDirectionName
    kThemeValue                = 5,   // kCRThemeValueName
    kThemeAppearance           = 6,   // kCRThemeAppearanceName (Light/Dark)
    kThemeDimension1           = 7,   // kCRThemeDimension1Name
    kThemeDimension2           = 8,   // kCRThemeDimension2Name
    kThemeState                = 9,   // kCRThemeStateName
    kThemeLayer                = 10,  // kCRThemeLayerName
    kThemeScale                = 11,  // kCRThemeScaleName (@1x, @2x, @3x)
    kThemePresentationState    = 12,  // kCRThemePresentationStateName
    kThemeIdiom                = 13,  // kCRThemeIdiomName (iPhone, iPad, Watch, TV)
    kThemeSubtype              = 14,  // kCRThemeSubtypeName
    kThemeIdentifier           = 15,  // kCRThemeIdentifierName (unique asset ID)
    kThemePreviousValue        = 16,  // kCRThemePreviousValueName
    kThemePreviousState        = 17,  // kCRThemePreviousStateName
    kThemeSizeClassHorizontal  = 18,  // kCRThemeSizeClassHorizontalName
    kThemeSizeClassVertical    = 19,  // kCRThemeSizeClassVerticalName
    kThemeMemoryClass          = 20,  // kCRThemeMemoryClassName
    kThemeGraphicsClass        = 21,  // kCRThemeGraphicsClassName
    kThemeDisplayGamut         = 22,  // kCRThemeDisplayGamutName (sRGB/Display P3)
    kThemeDeploymentTarget     = 23,  // kCRThemeDeploymentTargetName
    kThemeLocalization         = 24,  // kCRThemeLocalizationName
    kThemeGlyphWeight          = 25,  // kCRThemeGlyphWeightName
    kThemeGlyphSize            = 26,  // kCRThemeGlyphSizeName
    kThemeMaxAttribute         = 27,  // kCRThemeMaxAttributeName (sentinel)
};

Note: The CUIThemeAttributeNameToString function validates that attribute IDs must be in range 1-28 ((a1 - 1) > 0x1B returns “UNKNOWN”). The validatekeyformat function also checks that all attribute IDs are below 24 (0x18) for older format compatibility.

The idiom values (from kCoreThemeIdiom* strings in the binary):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
enum ThemeIdiom {
    kCoreThemeIdiomUniversal = 0,  // Any device
    kCoreThemeIdiomPhone     = 1,  // iPhone
    kCoreThemeIdiomPad       = 2,  // iPad
    kCoreThemeIdiomTV        = 3,  // Apple TV
    kCoreThemeIdiomCar       = 4,  // CarPlay
    kCoreThemeIdiomWatch     = 5,  // Apple Watch
    kCoreThemeIdiomMarketing = 6,  // Marketing images
    kCoreThemeIdiomMac       = 7,  // macOS
    kCoreThemeIdiomVision    = 8,  // visionOS (Apple Vision Pro)
};

A typical KEYFORMAT might define 18 attributes, meaning each rendition key is 18 * 2 = 36 bytes (each attribute value is stored as a uint16_t).

The RENDITIONS Tree

The RENDITIONS tree is the heart of the .car file and as far as I can tell it’s a B+ tree where:

  • Keys: Rendition attribute values matching the KEYFORMAT layout
  • Values: Block indices pointing to CSI (Core Structured Image) data

B+ Tree Node Structure

The tree header begins with magic "tree" (0x74726565), validated in _BOMTreeOpen. Each tree node has a 12-byte header:

1
2
3
4
5
6
struct BOMTreeNodeHeader {
    uint16_t isLeaf;
    uint16_t count;
    uint32_t forwardLink;
    uint32_t backwardLink;
}

The forwardLink/backwardLink pointers are the key B+ tree characteristic: leaf nodes are linked together for efficient sequential traversal without re-traversing from the root.

Leaf nodes contain the actual key-value pairs. Each entry is 8 bytes:

1
2
3
4
5
struct BOMTreeEntry {
    uint32_t keyBlockID;   // Block containing the key
    uint32_t valueBlockID; // Block containing CSI data (for leaves)
                           // or child node block ID (for branches)
};

Branch nodes use the same structure but valueBlockID points to child tree nodes.

Tree Traversal Algorithm

The decompiled BOMTreeIteratorNew shows how CoreUI creates tree iterators:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// From BOMTreeIteratorNew (0x18a79a454)
_QWORD *__fastcall BOMTreeIteratorNew(_QWORD *tree, ...)
{
  v5 = calloc(1, 0x40);  // 64-byte iterator structure
  if ( v5 ) {
    v5[0] = tree;        // Store tree reference
    v7 = tree[44];       // tree->flags at offset 352+4=356
    // Bit 0x04 in flags indicates inline keys
    if ( (v7 & 4) != 0 )
      v5[7] = v5 + 8;    // Point to inline key buffer
  }
  return v5;
}

The BOMTreeIteratorNext function, shown below, handles advancing through the tree. It increments the current index, checks if we’ve exhausted the current node’s entries, and follows the forwardLink pointer to move to the next leaf node. When no more nodes remain, it sets the end-of-tree flag.

BOMTreeIteratorNext decompilation

The tree structure uses the flag at offset 356 to indicate whether keys are stored inline or in separate blocks.

To enumerate all assets:

  1. Read the tree header block → get root node block ID
  2. Descend to the leftmost leaf by following first child pointers
  3. Iterate through leaf entries, extracting (key, value) pairs
  4. Follow forwardLink to traverse to the next leaf
  5. Repeat until forwardLink is 0 (end of tree)

CSI: Core Structured Image

Each asset’s data is stored in a CSI block. Decompiler analysis of initWithCSIData:forKey:version: reveals how CoreUI determines the rendition class:

1
2
3
4
5
6
7
// From -[CUIThemeRendition initWithCSIData:forKey:version:] (0x18a75a8cc)
v9 = (unsigned __int16 *)objc_msgSend(a3, "bytes");
v10 = objc_msgSend(
    objc_opt_class(a1),
    "renditionClassForRenditionType:andPixelFormat:",
    v9[18],           // offset 36: layout/rendition type
    *((unsigned int *)v9 + 6));  // offset 24: pixel format

This tells us key offsets in the CSI header:

  • Offset 24 (bytes 24-27): pixelFormat as uint32
  • Offset 36 (bytes 36-37): layout as uint16

The full 184-byte CSI header structure (little-endian fields):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct csiheader {
    uint32_t tag;            // 'CTSI' or 'ISTC' (0x49535443)
    uint32_t version;        // Usually 1
    uint32_t renditionFlags;
    uint32_t width;
    uint32_t height;
    uint32_t scaleFactor;    // Scale * 100 (e.g. 200 = @2x)
    uint32_t pixelFormat;    // 'ARGB', 'DATA', 'JPEG', etc.
    uint32_t colorSpace;     // Low nibble (bits 0-3)
    struct csimetadata {
        uint32_t modtime;    // Modification timestamp
        uint16_t layout;     // Rendition type
        uint16_t zero;       // Reserved maybe??
        char name[128];      // Asset filename
    } metadata;
    struct csibitmaplist {
        uint32_t tvlLength;       // Length of TLV section
        uint32_t bitmapCount;     // Number of bitmap data segments
        uint32_t reserved;        // Reserved maybe??
        uint32_t renditionLength; // Total size of rendition data
    } bitmaplist;
};  // Total: 184 bytes

Pixel Format Magic Values

The decompiled _CUIThemePixelRendition _initWithCSIHeader:version: reveals the pixel format constants:

1
2
3
4
5
6
// Pixel format values (kind of like fourcc codes)
#define PIXEL_ARGB    0x41524742  // 'ARGB' / 'BGRA' - 32-bit ARGB
#define PIXEL_RGBW    0x52474257  // 'RGBW' / 'WBGR' - 32-bit with wide gamut
#define PIXEL_GA16    0x47413136  // 'GA16' / '61AG' - 16-bit grayscale+alpha  
#define PIXEL_GA8     0x47413820  // 'GA8 ' / ' 8AG' - 8-bit grayscale+alpha
#define PIXEL_RGB5    0x52474235  // 'RGB5' / '5BGR' - 16-bit RGB (5-5-5-1)

The layout field identifies the asset type. The decompiled _initializeTypeIdentifiersWithLayout function reveals the type ranges:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// From _initializeTypeIdentifiersWithLayout (simplified)
// Layout types are grouped into categories:
// 10-12: Type 0 (Basic images)
// 20-22: Type 1 (Multi-part images) 
// 23-25: Type 2 (Three-part images)
// 30-34: Type 3 (Nine-part images)
// 40:    Type 5 (Texture)
// 50:    Type 8 (Special)

enum RenditionLayoutType {
    kLayoutType_Image_OnePartFixedSize  = 10,
    kLayoutType_Image_OnePartTile       = 11,
    kLayoutType_Image_OnePartScale      = 12,
    kLayoutType_Image_ThreePartHTile    = 20,
    kLayoutType_Image_ThreePartHScale   = 21,
    kLayoutType_Image_ThreePartHUniform = 22,
    kLayoutType_Image_ThreePartVTile    = 23,
    kLayoutType_Image_ThreePartVScale   = 24,
    kLayoutType_Image_ThreePartVUniform = 25,
    kLayoutType_Image_NinePartTile      = 30,
    kLayoutType_Image_NinePartScale     = 31,
    kLayoutType_Image_NinePartHT_VT     = 32,
    kLayoutType_Image_NinePartHT_VS     = 33,
    kLayoutType_Image_NinePartHS_VT     = 34,
    
    // High-value types (0x3E8+ range = 1000+)
    // These are BOTH layout types AND rendition types (they pass through directly)
    kLayoutType_Data                = 1000, // 0x3E8 - Raw data blob
    kLayoutType_ExternalLink        = 1001, // 0x3E9 - Reference to external file
    kLayoutType_LayerStack          = 1002, // 0x3EA - Multi-layer image
    // NOTE: 1003 (0x3EB) does NOT exist in gRenditionTypes - jumps to 1004!
    // Internal links use TLV type 1010 to store their reference, not a layout type.
    kLayoutType_PackedImage         = 1004, // 0x3EC - Packed/compressed image
    kLayoutType_NamedContent        = 1005, // 0x3ED - Named content reference
    kLayoutType_ThinningPlaceholder = 1006, // 0x3EE - Placeholder for app thinning
    kLayoutType_Texture             = 1007, // 0x3EF - GPU texture
    kLayoutType_TextureImage        = 1008, // 0x3F0 - Texture atlas
    kLayoutType_Color               = 1009, // 0x3F1 - Named color
    kLayoutType_MultisizeImageSet   = 1010, // 0x3F2 - App icon set
    kLayoutType_LayerReference      = 1011, // 0x3F3 - Reference to layer in stack
    kLayoutType_ContentRendition    = 1012, // 0x3F4 - Content rendition
    kLayoutType_RecognitionObject   = 1013, // 0x3F5 - ML recognition data
};

TLV (Type-Length-Value) Metadata

Following the CSI header is a TLV section containing extended metadata. The decompiled _CUIThemePixelRendition _initWithCSIHeader:version: shows the parsing:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// TLV parsing loop (simplified from the decompilation)
do {
    v18 = *v17;  // type
    if ( v18 == 1001 ) {         // Slices
        v15 = v17 + 2;
        v26 = v17[2];            // slice count
        *(v4 + 54) = v26;
        if ( v26 >= 17 ) /* error */
    }
    else if ( v18 == 1003 ) {    // Metrics
        v16 = v17 + 2;
    }
    else if ( v18 == 1006 ) {    // EXIF Orientation
        objc_msgSend(v4, "setExifOrientation:", v17[2]);
    }
    else if ( v18 == 1007 ) {    // Unknown (stored at offset 50)
        *(v4 + 50) = v17[2];
    }
    else if ( v18 == 1012 ) {    // Layer stack info
        // Parse layer entries with frame, opacity, blend mode
    }
    v17 = v17 + v17[1] + 8;      // advance by length + header
} while ( v17 < end );
1
2
3
4
5
struct TLVEntry {
    uint32_t type;
    uint32_t length;
    uint8_t  data[];
};

Common TLV types (from the decompilation):

TLV type numbers are a separate namespace from layout types. For example, TLV type 1010 (InternalLink metadata) is different from layout type 1010 (MultisizeImageSet). Layout types identify the rendition class (from CSI header offset 36), while TLV types tag metadata entries within the TLV section.

TVL parsing loop

TVL parsing 2

Note: The TLV namespace has gaps—types 1002, 1004, 1005, and 1013-1019 don’t exist as immediate constants in the CoreUI binary. The table below only includes TLV types in the decompiled code. The “Name” column contains descriptive names I assigned based on observed behavior so they’re not from the CoreUI binary.

TypeDecimalNamePurposeSeen In
0x3E91001SlicesSlice count + rectangles (max 16) for 3/9-part_CUIThemePixelRendition
0x3EB1003MetricsSizing constraints, passed to _extractMetrics_CUIThemePixelRendition
0x3EE1006EXIFOrientationImage rotation/flip (1-8), calls setExifOrientation:_CUIThemePixelRendition
0x3EF1007ReservedUnknown, stored at object offset 50_CUIThemePixelRendition
0x3F01008ExternalPathUTF8 path string for external file_CUIExternalLinkRendition
0x3F11009LegacyLayerEntriesI guess it’s a fallback for layer entries_CUILayerStackRendition
0x3F21010InternalLinkReference key to another rendition (used by _CUIInternalLinkRendition)_CUIInternalLinkRendition
0x3F31011SliceRectsSlice rectangle coordinates_CUIInternalLinkRendition
0x3F41012LayerInfoLayer stack entries: frame, opacity, blendMode, fixedFrame, referenceKey_CUIThemePixelRendition, _CUILayerStackRendition
0x3FC1020LayerEffectsLighting/blur: hasLightingEffects, blurStrength, gradientName_CUILayerStackRendition
0x3FD1021LayerSpecularSpecular/shadow: hasSpecular, translucency, shadowOpacity, shadowStyle_CUILayerStackRendition

Rendition Data Types

After the header and TLV section comes the actual asset data. The pixelFormat and layout fields determine how to interpret it.

CUIRawDataRendition (pixelFormat = ‘DATA’)

CUIRawDataRendition decompilation

Used for arbitrary binary data (PDFs, text files, JSON, etc.). The decompiler shows the magic validation:

1
2
3
// From _CUIRawDataRendition _initWithCSIHeader:version: (0x18a756e9c)
if ( a3[6] != 1145132097 )   // pixel format must be 'DATA' (0x44415441)
    // Error: wrong pixel format

The raw data header starts at CSI offset 180 (after the 184-byte header minus 4):

1
2
3
4
5
6
struct CUIRawDataHeader {
    uint32_t tag;           // 'RAWD' (0x44574152) - may need byte-swap detection
    uint32_t version;       // 0 = uncompressed, non-zero = LZFSE compressed
    uint32_t rawDataLength; // Size of following data (byte-swapped if tag == 'RAWD')
    uint8_t  rawData[];     // The actual file contents (12 bytes after tag)
}

The byte-order handling is interesting because CoreUI checks if tag == 0x44574152 (‘RAWD’) and if so, applies bswap32 to the length field:

1
2
3
4
5
6
7
// From decompilation at 0x18a756f04
v10 = *(_DWORD *)(header + 8);   // Read rawDataLength
v11 = bswap32(v10);              // Compute swapped version
if ( tag == 0x44574152 )         // 'RAWD' (1146569042 decimal)
    length = v11;                // Use byte-swapped length
else
    length = v10;                // Use length as-is

If version field is non-zero, the data is LZFSE compressed. The decompilation shows:

1
2
3
4
5
6
7
8
9
if ( v14 ) {  // version != 0 means compressed
    *v15 = v16 | 0x10;  // Set renditionFlags bit 4 (compressed)
    // Create subrange starting 12 bytes after header (tag + version + length)
    v17 = [_CUISubrangeData initWithData:range: srcData, offset+12, length];
    v6[27] = CUIUncompressDataWithLZFSE(v17);  // Decompress
} else {
    *v15 = v16;  // Uncompressed
    v6[27] = [_CUISubrangeData initWithData:range: srcData, offset+12, length];
}

CUIRawPixelRendition (pixelFormat = ‘JPEG’ or ‘HEIF’)

Stores compressed images directly:

1
2
3
4
5
6
struct CUIRawPixelRendition {
    uint32_t tag;           // 'RAWD' (same as raw data)
    uint32_t version;
    uint32_t rawDataLength;
    uint8_t  rawData[];     // JPEG or HEIF bitstream
}

CUIThemeColorRendition (layout = 1009 / 0x3F1)

Named colors are stored with component values. The decompiled _CUIThemeColorRendition _initWithCSIHeader:version: shows the magic validation:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
// From _CUIThemeColorRendition _initWithCSIHeader:version: (0x18a74ec24)
// Color data offset = csiHeader + bitmapCount*4 + tlvLength + 180
v6 = a3 + 4 * a3[43] + a3[42];  // a3[43]=bitmapCount, a3[42]=tlvLength
v8 = *(_DWORD *)(v6 + 180);     // Read tag at computed offset
if ( v8 != 1129270354 ) {       // 0x434F4C52 = 'COLR' (bytes: 52 4C 4F 43 = "RLOC")
    // handleFailureInMethod - invalid color magic
}
// Color space flags at offset +8, bits 8-10 (& 0x700)
if ( (*(_DWORD *)(v9 + 8) & 0x700) == 0x100 ) {
    // Extended color: read numberOfComponents at +12, then name string follows
    v10 = v7 + 8 * numberOfComponents;  // Skip past component doubles
    if ( v10[4] != 'COLR' ) /* error */  // Second COLR marker
    if ( v10[5] == 1 )  // String encoding marker
        // Read UTF-8 name string at v10+28, length at v10[6]
}
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
struct csicolor {
    uint32_t tag;                // 'COLR' (0x434F4C52)
    uint32_t version;            // Usually 0
    uint32_t colorSpaceID;       // Low byte = space ID, bits 8-10 = extended flags
    uint32_t numberOfComponents; // Usually 4 (RGBA)
    double   components[];       // Floating-point color values (8 bytes each)
    // If extended (flags & 0x700 == 0x100):
    //   uint32_t marker;        // 'COLR' again
    //   uint32_t encoding;      // 1 = UTF-8
    //   uint32_t nameLength;    // Length of name string
    //   char     name[];        // Color name (e.g. "systemBlueColor")
}

The color space flags in bits 8-10:

  • 0x000: Basic color, components follow immediately
  • 0x100: Extended color with name string after components

CUIThemePixelRendition (pixelFormat = ‘ARGB’, ‘GA8’, etc.)

The most complex type, used for PNG-derived images. The decompiler shows the bitmap header validation:

1
2
3
4
5
6
7
8
9
// From _CUIThemePixelRendition _initWithCSIHeader:version: (0x18a758dc4)
// Bitmap data at: csiHeader + bitmapOffsets[i] where offsets start at header+176
v10 = (_DWORD *)(v7 + aSize_8[v6]);  // aSize_8 = &csiHeader[44] (bitmap offset array)
if ( *v10 != 1128614989 && *v10 != 1296844099 )  // Must be CELM or MLEC
{
    // Error: invalid bitmap header
}
v12 = v10[1];  // Read flags field
*renditionFlags = (*renditionFlags & 0xFFFFFFFD) | (2 * ((v12 >> 1) & 1));  // Extract bit 1 → vector flag

Converting these magic values:

  • 1128614989 = 0x43454C4D → bytes 4D 4C 45 43 → “MLEC”
  • 1296844099 = 0x4D4C4543 → bytes 43 45 4C 4D → “CELM”
1
2
3
4
5
6
7
struct CUIThemePixelRendition {
    uint32_t tag;             // 'CELM' (0x4D4C4543) or 'MLEC' (0x43454C4D)
    uint32_t flags;           // Bit 1 = vector rendition flag (SVG/PDF source)
    uint32_t compressionType; // Compression algorithm (0-12, see table below)
    uint32_t rawDataLength;   // Size of (compressed) pixel data
    uint8_t  rawData[];       // Pixel data (format depends on compressionType)
}

The pixel format determines the color space handling. From the decompilation’s switch statement:

pixelFormatValueBytesColor Space Selection
ARGB0x4152474242 47 52 41sRGB or from colorSpace field bits 0-3
RGBW0x5247425757 42 47 52Extended/wide gamut, P3 variants
RGB50x5247423535 42 47 5216-bit RGB (5-5-5-1)
GA80x4741382020 38 41 478-bit grayscale + alpha
GA160x4741313636 31 41 4716-bit grayscale + alpha

Compression types (from CUIConvertCompressionTypeToString at 0x18a6ddf4c, reading the string table at 0x1E6D7E0E0):

ValueName (from binary)Notes
0uncompressedRaw pixel bytes
1rlePackBits variant
2zipUses zlib deflate
3lzvnApple’s lightweight compressor
4lzfseApple’s high-ratio compressor
5jpeg-lzfseJPEG-in-ARGB with LZFSE wrapper
6blurredSpecial blur processing
7astcAdaptive Scalable Texture Compression
8palette-imgIndexed/quantized color (see below)
9hevcHEVC/H.265 encoded
10deepmap-lzfseDepth map with LZFSE compression
11deepmap2Alternative depth map format
12dxtcS3 Texture Compression (DXT)

Compression Architecture

CoreUI imports compression functions from Apple’s compression libraries:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
// CoreUI imports from libcompression.dylib
_compression_decode_buffer     // One-shot decompression
_compression_encode_buffer     // One-shot compression
_compression_stream_init       // Streaming setup
_compression_stream_process    // Streaming decode/encode
_compression_stream_destroy    // Cleanup

// Also uses zlib from libz.1.dylib
_inflate, _inflateInit2_, _inflateEnd   // zlib decompression
_deflate, _deflateInit2_, _deflateEnd   // zlib compression
_crc32                                   // Checksum

Note: The compression type is stored in the CSI header’s compressionType field (types 0-12), and CoreUI explicitly passes the algorithm to libcompression.

The decompression routing in _BOMFileCompressionLibrary_Setup (0x18a6b64cc):

1
2
3
4
5
6
// Compression type -> algorithm mapping
switch ( compressionType ) {
    case 1:  compression_stream_init(stream, op, COMPRESSION_ZLIB);   break;
    case 3:  compression_stream_init(stream, op, 0x900);  break;  // LZVN
    case 4:  compression_stream_init(stream, op, COMPRESSION_LZFSE);  break;
}

And in BOMFileRead (0x18a6b65ac), streaming decompression handles each type:

1
2
3
4
5
6
7
if ( compressionType == 1 ) {
    // Use zlib's inflate()
    inflate(z_stream, Z_NO_FLUSH);
} else if ( compressionType == 3 || compressionType == 4 ) {
    // Use libcompression's streaming API
    compression_stream_process(stream, isFinished);
}

The CELM/MLEC Wrapper

For RLE-compressed data (compression type 1), the CELM wrapper provides per-row offset tables. The expandCSIBitmapData function (0x18a755844) calls pk_decompressData (0x18a6c99dc):

1
2
3
4
5
6
// From expandCSIBitmapData at 0x18a7559f8:
if ( compressionType == 1 ) {  // v10 == 1
    pk_decompressData(a2 + 4, destBuffer, srcSlice, srcHeight, 
                      dstWidth, dstHeight, rowBytes, 
                      *a2 == 1296844099);  // Check if magic == "CELM" (0x4D4C4543)
}

The last parameter (*a2 == 0x4D4C4543) determines endianness handling. Inside pk_decompressData:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// From pk_decompressData (0x18a6c99dc):
// Row offset table starts at result[a4 + 3] (after header)
v18 = (unsigned int *)&result[srcHeight + 3];  // Point to row offsets

// Select RLE decompressor based on pixel depth (*result field):
if ( *result == 3 )
    v20 = __decompressRLE16;  // 16-bit pixels
else if ( *result >= 3 )
    v20 = __decompressRLE32;  // 32-bit pixels (default for >= 3)
else
    v20 = __decompressRLE8;   // 8-bit pixels

// For each row, read offset and apply bswap32 if CELM magic:
do {
    v22 = *v18++;              // Read row offset
    v23 = bswap32(v22);        // Compute swapped version
    if ( isCELM )              // a8 parameter (magic == CELM)
        v21 = v23;             // Use big-endian offset
    else
        v21 = v22;             // Use little-endian offset (MLEC)
    // Decompress from (data + offset) to destination
    v20((char *)data + v21, destRow, ...);
    destRow += rowBytes;
} while ( --rowCount );

The RLE format uses a run-length encoding where each entry has a 4-byte header:

  • High byte (0x80): Literal run - repeat single value N times
  • Other values: Copy N bytes from source

From __decompressRLE32 (0x18a6c9ad0):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
v11 = *entry;                    // Read 4-byte header
if ( isCELM ) v11 = bswap32(v11);
runLength = v11 & 0xFFFFFF;      // Low 24 bits = run length
if ( HIBYTE(v11) == 0x80 ) {     // High byte == 0x80 means fill
    CGBlt_fillBytes(4 * runLength, 1, entry[1], dest);  // Repeat value
    entry += 2;                  // Skip header + fill value
} else {
    CGBlt_copyBytes(4 * runLength, 1, &entry[1], dest); // Copy pixels
    entry += runLength + 1;      // Skip header + data
}

The KCBC Chunked Bitmap Format

For LZFSE-compressed bitmaps (compression types 3 and 4), large images are split into multiple independently-compressed chunks within the MLEC rawData payload. Each chunk represents a horizontal slice of the image. The chunks use a KCBC header:

1
2
3
4
5
6
7
8
struct KCBCChunkHeader {
    uint32_t magic;          // 'KCBC' (0x4B434243)
    uint8_t  reserved[8];    // Reserved/unknown
    uint32_t chunkHeight;    // Number of pixel rows in this chunk
    uint32_t compressedSize; // Size of following LZFSE compressed data
};
// Total header size: 20 bytes
// Immediately followed by compressedSize bytes of LZFSE (bvx2...) stream

The full layout inside the MLEC rawData region:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
MLEC rawData[] (when compressionType == 3 or 4):
  ┌─────────────────────────────────────────┐
  │ KCBCChunkHeader  (20 bytes)             │  ← chunk 0
  │ LZFSE stream     (compressedSize bytes) │
  ├─────────────────────────────────────────┤
  │ KCBCChunkHeader  (20 bytes)             │  ← chunk 1
  │ LZFSE stream     (compressedSize bytes) │
  ├─────────────────────────────────────────┤
  │ ...                                     │  ← repeat until all rows covered
  └─────────────────────────────────────────┘

Each chunk’s LZFSE stream decompresses to chunkHeight * rowBytes bytes of raw pixel data (possibly with row padding for alignment). The decompressed chunks need to be concatenated row-by-row to reconstruct the full image. The number of chunks is not stored explicitly so the iteration continues until all image rows have been decoded.

When the decompressed data size exceeds chunkHeight * width * bytesPerPixel, the source rows contain alignment padding that needs to be stripped when copying to the destination buffer.

The Paletted/Quantized Image Format

A particularly interesting format is the quantized image (compression type 8). The decompiled CUIUncompressQuantizedImageData (0x18a6c68e4) reveals the format details. The entire payload is LZFSE compressed, and CoreUI uses streaming decompression to read it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
// From CUIUncompressQuantizedImageData (0x18a6c68e4)
// First, validate pixel format (before decompression)
if ( pixelFormat != 1095911234 && pixelFormat != 1380401751 ) {  // ARGB or RGBW
    _CUILog(4, "CoreUI: %s got unsupported pixel format [%d]");
    return 0;
}

// Initialize LZFSE streaming decompression
compression_stream_init(&stream, COMPRESSION_STREAM_DECODE, COMPRESSION_LZFSE);
stream.src_ptr = compressedData;
stream.src_size = compressedLength;

// Decode first 8 bytes: magic (4) + version (4)
stream.dst_ptr = &v42;
stream.dst_size = 8;
compression_stream_process(&stream, 0);
if ( (_DWORD)v42 != -889262067 ) {  // 0xCAFEF00D magic check
    goto decode_error;
}
if ( HIDWORD(v42) >= 2 ) {  // Version must be 0 or 1
    _CUILog(4, "[%s] Decoded version is higher than supported");
    return 0;
}

// Decode next 2 bytes: palette count
stream.dst_ptr = &paletteCount;
stream.dst_size = 2;
compression_stream_process(&stream, 0);
if ( paletteCount > 0x1000u ) {  // Max 4096 colors
    _CUILog(4, "[%s] Decoded color count %u more than max number of colors allowed...");
    return 0;
}

The format structure (note: the entire payload is LZFSE compressed, these following fields are read from the decompressed stream):

1
2
3
4
5
6
7
8
9
struct QuantizedImageData {
    // All fields below are INSIDE the LZFSE compressed stream
    uint32_t magic;        // 0xCAFEF00D (check if offset 0 != -889262067)
    uint32_t version;      // Must be < 2 otherwise error (Decoded version is higher than supported)
    uint16_t paletteCount; // Number of colors, max 4096 (check if v41 > 0x1000u)
    // Followed by:
    //   - Palette: paletteCount * 4 bytes (ARGB) or * 8 bytes (RGBW)
    //   - Indices: bit-packed pixel indices (variable width based on palette size)
};

The compressed payload must be decompressed with LZFSE using streaming decompression:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// From CUIUncompressQuantizedImageData (0x18a6c68e4)
compression_stream_init(&stream, COMPRESSION_STREAM_DECODE, COMPRESSION_LZFSE);
stream.src_ptr = compressedData;      // Raw input is LZFSE compressed
stream.src_size = compressedLength;

// First: decode 8 bytes (magic + version)
stream.dst_ptr = &header;
stream.dst_size = 8;
compression_stream_process(&stream, 0);
// header[0:4] = magic (0xCAFEF00D), header[4:8] = version (must be < 2)

// Second: decode 2 bytes (palette count)  
stream.dst_ptr = &paletteCount;
stream.dst_size = 2;
compression_stream_process(&stream, 0);
// paletteCount must be <= 0x1000 (4096)

// Third: decode palette (paletteCount * 4 or * 8 bytes)
paletteSize = paletteCount << (pixelFormat == RGBW ? 3 : 2);
stream.dst_ptr = paletteBuffer;
stream.dst_size = paletteSize;
compression_stream_process(&stream, 0);

// Then: decode bit-packed indices row by row...

FACETKEYS: Asset Name Resolution

The FACETKEYS tree maps human-readable asset names to their rendition attributes:

  • Keys: Null-terminated asset names (“MyIcon”, “ButtonBackground”)
  • Values: renditionkeytoken structures
1
2
3
4
5
6
7
8
9
struct renditionkeytoken {
    uint16_t cursorHotSpotX;
    uint16_t cursorHotSpotY;
    uint16_t numberOfAttributes;
    struct {
        uint16_t name;
        uint16_t value;
    } attributes[];
}

This allows UIImage(named:) to find all renditions for an asset name by matching against the RENDITIONS tree keys.

Some renditions don’t contain actual image data, they reference other renditions. This enables asset reuse and deduplication.

InternalLinkRendition

The decompiled _CUIInternalLinkRendition _initWithCSIHeader:version: (0x18a7582bc) shows how these are parsed:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
// From _CUIInternalLinkRendition _initWithCSIHeader:version: (0x18a7582bc)
// TLV type 1010 (0x3F2) contains the internal link
case 1010:
    v23 = *(_QWORD *)(v16 + 16);
    v24 = *(_QWORD *)(v16 + 24);
    
    v27 = *(_DWORD *)(v16 + 34);   // Key length (max 88 bytes)
    if ( v27 >= 0x58 )
        v28 = 88;
    else
        v28 = v27;
    __memcpy_chk(v79, v16 + 38, v28, 92);  // Copy key data from offset 38
    
    // Initialize CUIRenditionKey with the target key tokens
    *((_QWORD *)v5 + 116) = objc_msgSend(
        objc_alloc(CUIRenditionKey), "initWithKeyList:", v79);
    
    objc_msgSend(v5, "_initializeTypeIdentifiersWithLayout:", 
                 *(unsigned __int16 *)(v16 + 32));

Internal links are identified by TLV type 1010 (0x3F2) and contain:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
struct InternalLinkTLV {
    uint32_t type;           // 0: 1010 (0x3F2)
    uint32_t length;         // 4: Total TLV data length
    uint64_t reserved1;      // 8: Unknown (not parsed)
    uint64_t metadata1;      // 16: Metadata (converted to floats)
    uint64_t metadata2;      // 24: Metadata (converted to floats)
    uint16_t layout;         // 32: Layout type of the linked rendition
    uint32_t keyLength;      // 34: Length of target key (max 88 bytes)
    uint8_t  targetKey[];    // 38: Rendition key tokens (4 bytes each, null-terminated)
};

When CoreUI encounters an internal link, it looks up the target key in RENDITIONS and loads that asset instead. This is commonly used for dark mode variants that share the same base image.

Putting It All Together

Here’s the high-level parsing flow:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
1. Read BOMStore header (32 bytes)
     
2. Parse block index table
     
3. Parse variables table  get named block mappings
     
4. Read CARHEADER (436 bytes)  validate magic, get rendition count
     
5. Read KEYFORMAT  understand key structure
     
6. Open B+ trees (RENDITIONS required, others optional):
   - RENDITIONS: Asset key  CSI data mappings
   - FACETKEYS: Asset name  attribute tokens
   - APPEARANCEKEYS: Appearance name  ID (Dark Mode)
   - COLORS, FONTS, FONTSIZES: Type-specific indices
   - BITMAPKEYS, LOCALIZATIONKEYS: Additional metadata
     
7. Walk RENDITIONS tree:
   For each (key, value) pair:
     a. Parse rendition key using KEYFORMAT
     b. Read CSI block from value's block ID
     c. Parse CSI header (184 bytes)
     d. Extract TLV metadata
     e. Decompress/unwrap payload
     f. Decode based on pixelFormat/layout

Interactive Demo

I’ve compiled my parser to WebAssembly so you can explore .car files directly in your browser. Drop any Assets.car file to see its structure:

All processing happens locally in your browser. No files are uploaded to any server.
Loading a large .car file may take a while and use high memory, may even cause your browser to freeze or crash.

Demo Assets.car is from Timac's blog post: Reverse Engineering the .car File Format.

Loading parser...

Reverse Engineering Methodology

For those interested in doing similar research, here’s the approach I used:

  1. Extract CoreUI from the dyld shared cache: On modern macOS/iOS, system frameworks are pre-linked into a shared cache. Use the ipsw tool to extract them:
1
2
3
ipsw dyld extract dyld_shared_cache_arm64e \
    /System/Library/PrivateFrameworks/CoreUI.framework/CoreUI \
    --output ./Extracted_CoreUI
  1. Load the extracted CoreUI.framework into a disassembler: Analyze the extracted binary as the original path may not be accessible.

  2. Search for relevant function names: CoreUI uses clear Objective-C naming like _CUIThemeRendition, _CUIRawDataRendition, BOMStorage*

  3. Cross-reference key strings: Search for strings like “CARHEADER”, “KEYFORMAT”, “RENDITIONS”, “FACETKEYS” to find parsing code

  4. Follow the initialization chain: Start with initWithPath:_commonInit → look for BOM and tree operations

  5. Verify magic values: The decompiler shows integer comparisons; convert them to hex/ASCII to identify format constants

  6. Map struct offsets: Pointer arithmetic in decompiled code (like v9[18] or *((unsigned int *)v9 + 6)) reveals field offsets

Key functions analyzed for this post:

  • BOMStorageOpenWithSys (0x18a69da5c) — BOM magic validation
  • CUIThemeRendition initWithCSIData:forKey:version: (0x18a75a8cc) — CSI header parsing
  • _CUIThemePixelRendition _initWithCSIHeader:version: (0x18a758dc4) — Bitmap data extraction
  • CUIUncompressQuantizedImageData (0x18a6c68e4) — Palette image decompression
  • BOMTreeIteratorNew (0x18a69fe04) — B+ tree traversal

Prior Art and References

This research builds on previous work by others in the community:

Tools for working with car files:

  • assetutil -I: Apple’s official inspection tool
  • actool --print-contents: Dump asset catalog info during builds

Conclusion

What started as a hex dump showing BOMStore turned into a journey through nested containers, B+ trees, TLV metadata, and bunch of compression schemes. I find this .car format is fairly complex, but understanding its internals opens up many possibilities.

For security researchers, this complexity might actually be a feature. Nested parsers and custom compression algorithms to create a rich attack surface. For tool builders, understanding these internals means you can build asset extractors, catalog editors, or cross-platform readers without depending on Apple’s closed-source tooling.

The WebAssembly demo above is just one example of what’s possible when we understand the format rather than relying on Apple’s tools.


Feel free to reach out with questions or corrections, as feedback is welcome. The structures documented here are based on macOS Version 26.2 (25C56) files and may evolve in future releases.