System Internals

Apple internals #10: The Objective-C runtime and the shared cache

An Objective-C object is one word you have to decode before it means anything, and the framework it belongs to is not a file on the disk. Both are readable, and both change what an attacker has to control.

Open an iOS process in a disassembler and two things are missing. Objects have no visible type: each one begins with a single word, and message dispatch goes through that word. And the framework whose code you want to read is not on the disk at all, so there is no file to load.

Neither is obfuscation. The first is a decision about where to keep a retain count, the second is a decision about launch time, and both are documented in source Apple publishes. They change what an attacker has to control.

Everything here is public: Apple’s open-source objc4 and dyld, published research, and a Mac running a stock, unmodified macOS. The hands-on compiles a small program of its own and reads files that already ship on the machine. No exploit and no bypass chain appear below.

An object is an isa

An Objective-C object is a C struct whose first word is called isa, short for “is a”, because it says which class the object is an instance of. That is the entire base layout, struct objc_object { isa_t isa; }, and every instance variable you declare sits after it.

A class is an object too, and its declaration says so: struct objc_class : objc_object { Class superclass; cache_t cache; class_data_bits_t bits; }. It inherits the isa word, then adds three more. Since a class is an object, its isa has to point at something, and that something is the class’s metaclass.

That second level exists because of where methods live. Instance methods belong to the class; class methods belong to the metaclass. When you write [Sig new], the receiver is the class object Sig, and the runtime looks new up the way it looks up any message, in the receiver’s own class, which is the metaclass. Without it there would be nowhere to put a class method.

That could recurse forever, and it does not. A metaclass’s isa points to the root metaclass, which is NSObject’s metaclass, and the root metaclass’s isa points to itself.

   Sig instance         Sig                Sig (meta)         NSObject (meta)
  ┌────────────┐    ┌────────────┐     ┌────────────┐      ┌────────────┐
  │ isa        │───▶│ isa        │────▶│ isa        │─────▶│ isa        │──┐
  ├────────────┤    ├────────────┤     ├────────────┤      ├────────────┤  │
  │ ivars      │    │ superclass │     │ superclass │      │ superclass │  │
  └────────────┘    │ cache      │     │ cache      │      │ cache      │  │
                    │ bits       │     │ bits       │      │ bits       │  │
                    └────────────┘     └────────────┘      └────────────┘  │
                       -instance          +class                 ▲         │
                        methods            methods               └─────────┘

The last field, bits, is the pointer to everything else in the class: its method lists, ivar layout, properties and protocols. It comes in two shapes. class_ro_t is the clean version emitted by the compiler, read-only, and for a system class it lives inside the shared cache. class_rw_t is allocated when the class is first realized, and it holds what the runtime adds afterwards: categories, class_addMethod, swizzling. One of those two is writable in your process and the other is not, which decides where corruption can usefully land.

The isa is not a pointer

On arm64, isa is a bitfield, and the class pointer is only part of it. This is the layout from objc4, runtime/isa.h, for a build with pointer authentication (PAC):

bits field meaning
0 nonpointer 1 = this is a packed isa, 0 = a raw Class pointer
1 has_assoc the object has associated objects
2 weakly_referenced it is, or was, in the weak table
3-54 shiftcls_and_sig the class pointer, plus its PAC signature
55 has_sidetable_rc the retain count overflowed into the side table
56-63 extra_rc the inline retain count

Two masks come with it. ISA_MASK is 0x007ffffffffffff8 and keeps the whole 52-bit field, signature included. ISA_MASK_NOSIG keeps the address alone: 0x00007ffffffffff8 on macOS, 0x0000000ffffffff8 on iOS, where the address space handed to a process is smaller.

The point of packing all this into one word is the retain count. retain and release are the two most frequent operations in an Objective-C process, and the fast path for both is now an atomic add on bits 56 and up of a word the object already had. No lock is taken and nothing else is allocated. The eight bits run out eventually, and that is what bit 55 records: past that, the surplus goes to the global side table and the two have to be added back together.

The layout most write-ups quote is a different one: 33 bits of shiftcls at bit 3, a six-bit magic field holding 0x1a, extra_rc on 19 bits, ISA_MASK at 0x0000000ffffffff8. That is the same header, in the branch taken when the build has no pointer authentication. The magic field is gone from the ptrauth layout, and so is has_cxx_dtor. Read isa.h for the target in front of you rather than a table from 2019.

And extra_rc is no longer the retain count minus one. objc-object.h reads it straight: uintptr_t rc = bits.extra_rc;, then adds the side table if bit 55 is set. A freshly allocated object shows 1, not 0, and the hands-on below shows it.

One shape that is not an object at all: a tagged pointer. Small immutable values such as short NSStrings, NSNumbers and NSDates are encoded directly in the pointer, with no allocation behind them. On arm64 the marker is the high bit, so a “pointer” with bit 63 set has no memory to dereference and no isa to decode. Since iOS 12 the whole value is also XORed with objc_debug_taggedpointer_obfuscator, a random word drawn at launch, so that a write primitive cannot forge a chosen tagged value blind. A read primitive recovers the obfuscator from an exported symbol and the protection ends there.

objc_msgSend, or how a selector becomes a call

Almost every message send goes through objc_msgSend, with a fixed register contract: x0 is the receiver, x1 is the selector, the remaining arguments follow in x2 to x7. A send to super takes objc_msgSendSuper2 instead, and a method declared objc_direct is called outright with no dispatch at all, but those are the exceptions. The common path has to find the function for a given selector, on a given receiver’s class. Here is the beginning of it, disassembled out of the shared cache on my own machine:

libobjc.A.dylib`objc_msgSend:
    0x18acc1800 <+0>:  cmp    x0, #0x0
    0x18acc1804 <+4>:  b.le   0x18acc1880    ; <+128>
    0x18acc1808 <+8>:  ldr    x14, [x0]
    0x18acc180c <+12>: and    x16, x14, #0x7ffffffffffff8
    0x18acc1810 <+16>: mov    x10, x0
    0x18acc1814 <+20>: movk   x10, #0x6ae1, lsl #48
    0x18acc1818 <+24>: autda  x16, x10
    0x18acc181c <+28>: mov    x15, x16
    0x18acc1820 <+32>: ldr    x10, [x16, #0x10]
    0x18acc1824 <+36>: lsr    x11, x10, #48
    0x18acc1828 <+40>: and    x10, x10, #0xffffffffffff
    0x18acc182c <+44>: eor    x12, x1, x1, lsr #7
    0x18acc1830 <+48>: and    w12, w12, w11
    0x18acc1834 <+52>: add    x13, x10, x12, lsl #4

The whole structure of the previous two sections is in those fourteen instructions.

cmp x0, #0 with b.le is the nil-receiver check, and it also catches tagged pointers on the same branch, since bit 63 set makes the receiver negative when read as a signed value. ldr x14, [x0] loads the isa word. and x16, x14, #0x7ffffffffffff8 is ISA_MASK, applied to the raw word exactly as the table above says.

Then three instructions: mov x10, x0, movk x10, #0x6ae1, lsl #48, autda x16, x10. The runtime builds a modifier out of the object’s own address and the constant 0x6AE1, which objc-config.h names ISA_SIGNING_DISCRIMINATOR, and authenticates the class pointer with it. The alternative, in the same macro in arm64-asm.h, is a single xpacd that discards the signature without checking it. Which one you get is the compile-time flag ISA_SIGNING_AUTH_MODE, and this build authenticates.

ldr x10, [x16, #0x10] reads the word at offset 0x10 of the class, which is cache, the third field of objc_class after isa and superclass. That single word holds two things: lsr x11, x10, #48 takes the mask out of the top 16 bits, and x10, x10, #0xffffffffffff takes the bucket array pointer out of the low 48.

eor x12, x1, x1, lsr #7 hashes the selector against itself shifted right by seven, and w12, w12, w11 folds it into the table with the mask, and add x13, x10, x12, lsl #4 turns the index into an address, sixteen bytes per bucket. A bucket is a { IMP, SEL } pair, the function pointer and the selector, in that order on arm64. What follows loads both, compares the stored selector with x1, and on a hit authenticates the IMP and branches to it.

On a miss the slow path runs: lookUpImpOrForward walks the class’s method lists, then its superclass’s, resolves the method, fills the cache, and only then calls. Every subsequent send of that selector to that class takes the fourteen instructions above.

Hands-on: decoding an isa by hand

The whole model is checkable in one program. It allocates one object, reads the word, decodes the fields by hand, and then walks the chain by masking each isa in turn.

#import <Foundation/Foundation.h>
#import <objc/runtime.h>
#include <stdio.h>

#define ISA_MASK       0x007ffffffffffff8UL
#define ISA_MASK_NOSIG 0x00007ffffffffff8UL

@interface Sig : NSObject
@end
@implementation Sig
@end

static Class isa_of(void *p) { return (Class)(*(uintptr_t *)p & ISA_MASK_NOSIG); }

int main(void) {
    Sig *o = [Sig new];
    uintptr_t isa = *(uintptr_t *)o;

    printf("object             %p\n", o);
    printf("isa                0x%016lx\n", isa);
    printf("  nonpointer       %lu\n", isa & 1);
    printf("  has_assoc        %lu\n", (isa >> 1) & 1);
    printf("  weakly_referenced %lu\n", (isa >> 2) & 1);
    printf("  has_sidetable_rc %lu\n", (isa >> 55) & 1);
    printf("  extra_rc         %lu\n", (isa >> 56) & 0xff);
    printf("  & ISA_MASK       0x%016lx\n", isa & ISA_MASK);
    printf("  & ISA_MASK_NOSIG 0x%016lx\n", isa & ISA_MASK_NOSIG);

    Class c = isa_of(o), m = isa_of(c), r = isa_of(m), rr = isa_of(r);
    printf("class              %p  %-10s meta=%d\n", c, class_getName(c), class_isMetaClass(c));
    printf("metaclass          %p  %-10s meta=%d\n", m, class_getName(m), class_isMetaClass(m));
    printf("root metaclass     %p  %-10s meta=%d\n", r, class_getName(r), class_isMetaClass(r));
    printf("its own isa        %p\n", rr);

    [o retain];
    printf("after retain       0x%016lx\n", *(uintptr_t *)o);
    return 0;
}

Build it twice, once for each ABI:

clang -fno-objc-arc -framework Foundation -o /tmp/isachain isachain.m
clang -arch arm64e -fno-objc-arc -framework Foundation -o /tmp/isachain_e isachain.m

The plain arm64 build first:

object             0x10145c2e0
isa                0x0100000100d240b9
  nonpointer       1
  has_assoc        0
  weakly_referenced 0
  has_sidetable_rc 0
  extra_rc         1
  & ISA_MASK       0x0000000100d240b8
  & ISA_MASK_NOSIG 0x0000000100d240b8
class              0x100d240b8  Sig        meta=0
metaclass          0x100d24090  Sig        meta=1
root metaclass     0x1f6f1d5f0  NSObject   meta=1
its own isa        0x1f6f1d5f0
after retain       0x0200000100d240b9

Read the word 0x0100000100d240b9 against the table. The low nibble 9 is 1001: nonpointer set, has_assoc clear, weakly_referenced clear. The high byte 0x01 is extra_rc, and it is 1 for an object that has just been allocated and never retained. Everything in between is the class pointer, 0x100d240b8.

The chain is the diagram, at run time. Sig is at 0x100d240b8, its metaclass is 40 bytes below it and carries the same name with meta=1, and one more step lands on NSObject’s metaclass, whose own isa is its own address. That is where the recursion ends.

The last line is the retain, and it moves the field the table says it should: the top byte goes from 0x01 to 0x02 while every other bit stays put. One word changed, and nothing was locked or allocated to do it. That is the whole reason the isa stopped being a pointer.

Now the arm64e build of the same source:

object             0x104d92d00
isa                0x014f8001046d40b9
  nonpointer       1
  has_assoc        0
  weakly_referenced 0
  has_sidetable_rc 0
  extra_rc         1
  & ISA_MASK       0x004f8001046d40b8
  & ISA_MASK_NOSIG 0x00000001046d40b8
class              0x1046d40b8  Sig        meta=0
metaclass          0x1046d4090  Sig        meta=1
root metaclass     0x1f6f1d5f0  NSObject   meta=1
its own isa        0x1f6f1d5f0
after retain       0x024f8001046d40b9

The two masks now disagree, and the difference is 0x004f800000000000: the signature bits, sitting in the part of the 52-bit field the address does not use, and what autda checks. Run the same binary again and they come out different, because the object lands somewhere else and its address is part of what was signed. The class pointer is signed in one process and bare in the other, from the same source on the same machine, and the difference comes from the kernel. XNU turns user-space pointer authentication off for a task whose main binary is not arm64e, which the pointer authentication post measured on this same laptop, so in the plain build nothing signs the isa and the autda in the fast path runs without effect.

One line is identical in both runs. NSObject’s metaclass is at 0x1f6f1d5f0 in two different processes, launched separately, while each program’s own class moved.

The libraries are not on disk

The program above links against Foundation, and the linker recorded where it lives. That file is not there.

$ otool -L /tmp/isachain | head -3
/tmp/isachain:
    /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation (compatibility version 300.0.0, current version 4424.1.255)
    /usr/lib/libSystem.B.dylib (compatibility version 1.0.0, current version 1356.0.0)

$ ls -l /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation
ls: /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation: No such file or directory

The path is not stale, and the program runs. It is a name the loader resolves against something else: nearly every system library on the machine was merged, at build time, into a single image, the dyld shared cache. Cross-library symbols are pre-bound, the Objective-C selector, class and protocol tables are precomputed, and the result is mapped into every process at launch instead of being opened, parsed and fixed up one dylib at a time. That is why a process linking a dozen frameworks starts in milliseconds, and why Foundation and UIKit cannot be found as files. Reading their code means extracting them from the cache first.

It is not one file either. On macOS 26.4.1:

$ ls -lh /System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/ | grep arm64e
-rwxr-xr-x  1 root  admin   560K  6 avr.  10:10 dyld_shared_cache_arm64e
-rwxr-xr-x  1 root  admin   1,6G  6 avr.  10:10 dyld_shared_cache_arm64e.01
-rwxr-xr-x  1 root  admin   223M  6 avr.  10:10 dyld_shared_cache_arm64e.02.dylddata
-rwxr-xr-x  1 root  admin   121M  6 avr.  10:10 dyld_shared_cache_arm64e.03.dyldreadonly
-rwxr-xr-x  1 root  admin   574M  6 avr.  10:10 dyld_shared_cache_arm64e.04.dyldlinkedit
-rwxr-xr-x  1 root  admin   1,7G  6 avr.  10:10 dyld_shared_cache_arm64e.05
-rwxr-xr-x  1 root  admin   226M  6 avr.  10:10 dyld_shared_cache_arm64e.06.dylddata
-rwxr-xr-x  1 root  admin   7,0M  6 avr.  10:10 dyld_shared_cache_arm64e.07.dyldreadonly
-rwxr-xr-x  1 root  admin   588M  6 avr.  10:10 dyld_shared_cache_arm64e.08.dyldlinkedit
-rwxr-xr-x  1 root  admin   158M  6 avr.  10:10 dyld_shared_cache_arm64e.09
-rwxr-xr-x  1 root  admin    21M  6 avr.  10:10 dyld_shared_cache_arm64e.10.dylddata
-rwxr-xr-x  1 root  admin    32K  6 avr.  10:10 dyld_shared_cache_arm64e.11
-rwxr-xr-x  1 root  admin   238M  6 avr.  10:10 dyld_shared_cache_arm64e.12.dyldlinkedit
-rwxr-xr-x  1 root  admin   2,3M  6 avr.  10:10 dyld_shared_cache_arm64e.atlas
-rwxr-xr-x  1 root  admin   1,3M  6 avr.  10:10 dyld_shared_cache_arm64e.map

About five and a half gigabytes across fifteen files, and the one without a number is 560 KB, because it is the header: the mapping table, the image list, and the array describing every subcache with its UUID and its offset. The files with no suffix after the number hold executable text, .dylddata the writable data, .dyldreadonly the read-only data, .dyldlinkedit the symbol and fixup tables. A parser that opens the first file and stops sees almost none of the cache.

The location matters too. This is not /System/Library/dyld/ any more; it is a cryptex, a signed disk image mounted over the system volume at boot, which is how Apple ships a new cache without touching the sealed system snapshot.

For reversing, ipsw dyld extract undoes the merge, and it works on a cache pulled out of an IPSW as well as one off a running machine. Apple’s own dyld_shared_cache_util is the other name you will see in older write-ups, and it is not installed on a stock macOS 26.4.1, so it is a build-from-source step rather than a command you have. Ghidra and radare2 both have cache-aware loaders, which is usually the better option, because a call from Foundation into CoreFoundation stays resolvable instead of pointing outside the file. Be aware of what you are asking them for: loading the whole cache means one program holding every dylib in it, and Ghidra 12.1.2 on a stock install runs out of Java heap parsing their symbol tables before the listing ever opens. Pulling out the one library you want is the cheaper path.

The property that matters for exploitation is the slide. The cache gets one address-randomization (ASLR) slide, chosen once at boot, and every process maps it at the same place. The two runs above already showed it: same address for NSObject’s metaclass in two unrelated processes. Leak a single cache address, from any process on the device, and library ASLR is gone system-wide until the next reboot. Compare that with the main binary’s own classes, which moved between the two runs, and with the heap, which moves per process.

What an attacker does with an isa

objc_msgSend is an indirect call whose target is read out of the object it is given. Control the memory an object lives in and you control the isa, so you choose the class, so you choose the function that runs. That is the classic fake Objective-C object, and it converts a memory-corruption primitive into a control-flow primitive without a ROP (return-oriented programming) chain. The read primitive comes from the same trick with a different shape: a forged NSData, whose layout is { isa, length, bytes, deallocator } with the deallocator left NULL, hexdumps whatever memory its bytes field points at when it is sent -description.

The forgery got more expensive, and the price is different for each pointer involved.

The isa itself is signed on arm64e, and this build authenticates it: autda with the DA key (data pointers, key A), the constant 0x6AE1, and the object’s own address as the diversifier. That address diversity is the expensive part: a signature lifted from a real object is valid only at that object’s address, so copying a legitimate isa word into a structure of your own fails authentication.

That is a change, and Zhou and Xie date it: the isa carried a PAC signature on iOS 14, and the check on use arrived in 14.5. Their Black Hat 2021 paper builds its whole read primitive on that gap: “all of the isa are known because of no PAC check”, then a forged NSData on top. The same macro in arm64-asm.h still compiles to a bare xpacd when ISA_SIGNING_AUTH_MODE says strip, so both behaviours ship from one source tree, and it is a per-build fact. Disassemble objc_msgSend in the cache of the target you are looking at before you assume either.

The method cache is signed too, and more tightly. The IMP stored in a bucket is signed with the IB key (instruction pointers, key B) and a modifier of bucket_base ^ sel ^ cls, per modifierForSEL in objc-runtime-new.h. Cache poisoning, which used to be a clean write-to-PC on arm64, now needs a signature valid for that exact bucket address, that selector and that class.

class_ro_t sits in read-only memory inside the shared cache, so the writable targets are class_rw_t and the cache, which is where method-list forgery and swizzling-style tricks land.

The feeder for all of it, from a remote position, is deserialization. NSKeyedUnarchiver and NSSecureCoding turn attacker-supplied bytes into an object graph, choosing classes by name and filling ivars, which is why they keep appearing in iMessage and XPC chains.

And on A19 hardware the first step is the one that got harder. Nothing above changes: the dispatch still reads a word out of the object and branches. Getting a chosen word into that slot is the problem, because the overflow or the use-after-free that puts a forged object next to a live one now trips a tag check and faults, as the previous post went through.

The kernel has the same shape. A libkern C++ object carries a vtable pointer at offset 0, and OSMetaClass gives it the run-time type identity that C++ without RTTI does not, with OSDynamicCast walking the chain of parent metaclasses. Reversing an IOKit user client means walking exactly that: the metaclass, its vtable, its alloc, the class it produces. The IOKit post uses it without taking it apart.

Where this leaves us

An object begins with a word whose top byte is a retain count and whose middle is a signed class pointer. A system library is a region of one merged image that every process maps at the same address until the device reboots. Each is one design decision, and both are measurable in a few minutes on a machine you already own. The first decides how much a corruption primitive is worth, because the pointer it lets you overwrite is the one that picks the next function to run. The second decides how much a single leaked address is worth, because there is only one slide to defeat and it is shared.

I measured one machine and one build. The isa authentication mode, the mask widths and the cache layout are compile-time and platform-time choices, so the disassembly in front of you outranks anything written here, including the table. This post also closes the season: ten posts, from the SecureROM checking the signature of the next stage to objc_msgSend picking a function out of an object it was handed.

Notes and sources

Everything here is drawn from open source, vendor documentation, published research, and a Mac running a stock, unmodified macOS.

Work like this is what we do under engagement.

[email protected] Vulnerability Research

No spam, one click to unsubscribe. Privacy.