陽炎窓
Folio III 2026.05.22

How to Reverse Engineer Apple Frameworks

With Claude and simple CLI tools

Apple is notorious for keeping certain features to themselves. They do this with private methods and variables that aren’t accessible to developers using the public SDK. Some frameworks are private themselves and expose no public surface at all, even if the functionality they provide could be useful for you.

This is unfixable on iOS, but on macOS there’s much more freedom. If you don’t distribute your app via the AppStore, you’re free to load and call whatever frameworks you want. This is made especially easy by the fact that most of older Apple frameworks use Objective-C. There are no true private methods in Objective-C, any method can be called if you know its signature. Extracting headers is also much easier than with Swift than mangles the names.

You don’t need anything particularly hard to do this, and for most of the research you don’t have to disable SIP. GPT will often refuse to reverse-engineer, but Claude is generally fine with it for legitimate work. Claude actually likes the challenge and will work for hours on a single problem, burning through millions of tokens.

You can use /goal in Claude Code to declare the goal so Claude works until it’s accomplished. Usually the process involves guessing something, then testing if it works.

Note that you still need to understand what happens and how things work. Claude Opus 4.6 couldn’t do it from start to finish without me interrupting or offering suggestions. 4.7 with better prompting might be more autonoumous.

Now to actually explain the process.

The shared cache problem

In older versions of macOS, /System/Library/Frameworks/ contained the actual frameworks. That’s no longer the case. To speed up startup, Apple bundles all system frameworks into a single dyld shared cache. You can’t open these binaries directly, you have to extract them from the cache first. Models often forget about this, and try to access them directly, so it’s worth adding this to you CLAUDE.md

Tools

The main tool is ipsw. It's a free and open-source command-line research framework for iOS and macOS. From the README: “It provides an extensive toolkit for security researchers, reverse engineers, jailbreak developers, and iOS enthusiasts to download, parse, and analyze Apple firmware and interact with iOS devices.” We mostly care about its ability to extract and parse Mach-O binaries from the shared cache.

The other tool is class-dump, also free and open source, which generates Objective-C header files from a binary's metadata.

That's enough for most of the work. log stream, lsof, and pluginkit all come with macOS and you'll use them too.

Finding the framework you care about

To begin, identify the framework you actually care about. There are a few reliable approaches, depending on what you're after.

If you want to use a private method of a public framework (say, an undocumented AppKit call), you usually already know the framework. The task is mostly about identifying what to call and whether the method is Objective-C or Swift. Skip ahead to extraction.

If you want to use a system feature whose implementation lives somewhere private, the framework itself may be unknown. There are several ways you can find it.

Trigger the feature with log stream running and watch what shows up:

log stream --level=debug --style=compact \
  --predicate 'subsystem CONTAINS "wallpaper"'

The subsystem names map to frameworks most of the time.

com.apple.wallpaper in the logs means there's a Wallpaper.framework somewhere.

List the frameworks a running daemon has loaded:

lsof -p $(pgrep WallpaperAgent) | grep -i framework


If you're trying to build a plugin, enumerate the extension points:

pluginkit -mAvv -p com.apple.wallpaper

This shows you which Apple extensions implement a given extension point, so you can extract their binaries and see how they do it.

Extracting

The next step is to extract the binary and headers. Use ipsw to get the binary from the shared cache:

ipsw dyld extract \
  /System/Volumes/Preboot/Cryptexes/OS/System/Library/dyld/dyld_shared_cache_arm64e \
  <framework_name> \
  --objc \
  --output /path/to/output

The --objc flag tells ipsw to reconstruct the Objective-C metadata; without it you lose class and method tables.

Then generate header files using class-dump:

class-dump -H <path to the binary> -o <output directory>

You get one .h file per class. The output looks like real Apple headers that you can often use directly.

For Swift-only frameworks (or the Swift portions of mixed frameworks), there is no Objective-C metadata. You need nm:

nm ~/Binaries/CoreDevice \
  | grep ' T ' \
  | awk '{print $3}' \
  | swift demangle \
  | sort -u

This is the only way to see Swift symbols on a stripped binary. The output is dense but readable — you get full signatures with module names, which is usually enough to figure out what the function does and which type owns it.

With the headers in hand, read them and look for familiar names. The function names are often descriptive enough that you don't need anything else. If they're not, point Claude at the directory, explain what you're trying to do, and ask to research the problem. I recommend asking them to write the findings to a document and update it progressively, because some of this research gets very long and you’ll want to be able to pick it up across sessions.

You can often shortcut the work by loading the framework and trying to use it from an app. Claude can put together a barebones Swift app (no Xcode project needed, a single file with swiftc is enough) for testing.

Loading a private framework at runtime

If the framework you want to use is public, you just import it, but if it’s private you have to use dynamic loading (dlopen):

func loadFramework(_ path: String) -> UnsafeMutableRawPointer? {
    guard let handle = dlopen(path, RTLD_LAZY | RTLD_GLOBAL) else {
        if let err = dlerror() { print("dlopen failed:", String(cString: err)) }
        return nil
    }
    return handle
}

With the framework loaded, we can do runtime inspection of classes that interest us:

guard let cls = objc_getClass("WallpaperSettingsViewModelsXPC") as? AnyClass else {
    fatalError("class not loaded — framework didn't dlopen, or name changed")
}

For Swift types, you can do a limited extraction with Mirror:

func dumpMirror(_ value: Any, indent: String = "") {
    let m = Mirror(reflecting: value)
    print("\(indent)type=\(type(of: value)) children=\(m.children.count)")
    for child in m.children {
        let label = child.label ?? "_"
        print("\(indent)  .\(label) = \(child.value)  (\(type(of: child.value)))")
        if Mirror(reflecting: child.value).children.count > 0 {
            dumpMirror(child.value, indent: indent + "    ")
        }
    }
}

This is useful when a private API hands you an opaque generic box (like XPCBox<T>) and you have no way to cast to its real type. Mirror shows you the fully qualified type name, which is usually enough to find the corresponding header.

Calling an Objective-C method

Sometimes all you need is to call the method and pass the right parameters. There are two ways to do this: with a bridging header, or with dynamic dispatch through the runtime.

Approach A — Bridge with a header

Write the interface yourself, in a header you import. The compiler types everything, autocomplete works, call sites read like normal code.

// PrivateInterfaces.h — re-declared from class-dump output
@class WallpaperSettingsViewModelsXPC;

@interface WallpaperManager : NSObject
+ (instancetype _Nonnull)sharedManager;
- (void)setActiveChoiceForContentType:(NSInteger)contentType
                            choiceID:(NSString * _Nonnull)choiceID
                            completion:(void (^ _Nullable)(NSError * _Nullable))completion;
@end
// Swift call site — looks like any framework call
WallpaperManager.shared().setActiveChoice(
    forContentType: 0,
    choiceID: "com.video.choice.42",
    completion: { error in print(error as Any) }
)

The compiler checks your arguments, and you don't have to write any selector strings. The downside is that the moment Apple renames the method, you get an unrecognized-selector crash at runtime.

Approach B — Call dynamically, check first

Don't re-declare the method. Look the method up by selector, check that the receiver responds to it, then invoke it manually.

guard let cls = objc_getClass("WallpaperManager") as? NSObject.Type else { return }

// Static method: send to the metaclass / class object.
let sharedSel = NSSelectorFromString("sharedManager")
guard cls.responds(to: sharedSel) else {
    print("sharedManager went away in this OS version")
    return
}
let manager = cls.perform(sharedSel)?.takeUnretainedValue() as? NSObject

// Instance method with multiple args — perform(_:with:with:) maxes out at two,
// so use NSInvocation for anything wider.
let sel = NSSelectorFromString("setActiveChoiceForContentType:choiceID:completion:")
guard let manager, manager.responds(to: sel) else {
    print("method missing — fall back, log telemetry, etc.")
    return
}

let sig = (type(of: manager) as AnyClass).instanceMethodSignature(for: sel)!
let inv = NSInvocation(methodSignature: sig)
inv.selector = sel
inv.target = manager

var contentType: Int = 0
withUnsafePointer(to: &contentType) { inv.setArgument(UnsafeMutableRawPointer(mutating: $0), at: 2) }

var choiceID: NSString = "com.video.choice.42"
withUnsafePointer(to: &choiceID) { inv.setArgument(UnsafeMutableRawPointer(mutating: $0), at: 3) }

var completion: (@convention(block) (NSError?) -> Void) = { print($0 as Any) }
withUnsafePointer(to: &completion) { inv.setArgument(UnsafeMutableRawPointer(mutating: $0), at: 4) }

inv.invoke()

This is uglier but it won’t crash. responds(to:) is a real check, so when Apple removes the selector you can log a telemetry event and fall back to something else instead of crashing. The price is the noise: NSInvocation argument indices are off-by-two (self is 0, _cmd is 1, real arguments start at 2), you marshal blocks by hand, and a typo in a selector string won't get caught until that path executes.

Approach C — Hybrid: typed header, runtime gate

Nothing stops you from doing both. Declare the interface in a header so the call site stays clean, and gate the dispatch on responds(to:).

// Header gives us the typed signature so the call compiles cleanly.
// Runtime check gives us a survivable failure mode.
let manager = WallpaperManager.shared()
let sel = #selector(WallpaperManager.setActiveChoice(forContentType:choiceID:completion:))

if manager.responds(to: sel) {
    manager.setActiveChoice(forContentType: 0, choiceID: "...", completion: { _ in })
} else {
    Logger.privateAPI.warning("setActiveChoice:choiceID:completion: missing on \(ProcessInfo.processInfo.operatingSystemVersionString)")
    // graceful fallback
}

For methods with optional availability, declaring them @optional in the protocol or guarding with respondsToSelector: keeps both the type checker and the runtime honest.

When do you use which? The header-only approach is fine when the API hasn't changed across the OS versions you care about and you control the test matrix. WallpaperExtensionKit's XPC protocol hasn't shifted between minor releases, so a header is the right call there. The dynamic approach is what you reach for when you're still mapping a class out and you're not sure which selectors exist or what they take. Once you've round-tripped a real call, you can commit to a header. For production code that has to survive OS upgrades, the hybrid is the safer choice.

None of this applies to pure-Swift frameworks like CoreDevice. There's no Objective-C surface at all, so objc_getClass returns nil and responds(to:) is meaningless.

Calling into a pure-Swift framework

What you have is mangled symbols you can resolve with dlsym. The pattern is:

  • Load the framework with dlopen.
  • Find the mangled symbol with nm | swift demangle.
  • Resolve it with dlsym (drop the leading underscore that nm shows).
  • Cast through a @convention (c) function pointer and call it.
@discardableResult
func loadFramework(_ path: String) -> UnsafeMutableRawPointer? {
    guard let handle = dlopen(path, RTLD_LAZY | RTLD_GLOBAL) else {
        if let err = dlerror() { print("dlopen failed:", String(cString: err)) }
        return nil
    }
    return handle
}

let main = loadFramework("/Library/Developer/PrivateFrameworks/CoreDevice.framework/CoreDevice")

// Sub-frameworks must load AFTER the main binary
for sub in ["CoreDeviceClientSupport", "CoreDeviceMediaStreamSupport", "CoreDeviceInternal"] {
    loadFramework("/Library/Developer/PrivateFrameworks/CoreDevice.framework/Frameworks/\(sub).framework/\(sub)")
}
A simple call, where the method takes self in x0 and returns a Bool:

// nm shows:  _$s10CoreDevice0B7ManagerC16fullyInitializedSbvg
// dlsym wants: $s10CoreDevice0B7ManagerC16fullyInitializedSbvg
typealias FullyInitializedThunk = @convention(c) (UnsafeRawPointer) -> Bool

let sym = dlsym(main, "$s10CoreDevice0B7ManagerC16fullyInitializedSbvg")!
let fn  = unsafeBitCast(sym, to: FullyInitializedThunk.self)
let ok  = fn(deviceManagerInstance)
print("fullyInitialized:", ok)

For anything more complex — sret returns, dispatch thunks that read `self` from `x20`, generic methods — you need a hand-written ARM64 trampoline that marshals registers correctly.

Swizzling

Sometimes you don't want to call a method, you want to change what it does. Method swizzling replaces a class’s implementation of a selector with one of yours, and you can still call the original from inside the replacement. Understanding what the original does matters here, because every call to that selector in your process will go through your replacement, including calls from Apple’s own code.

A concrete example. WallpaperSnapshotXPC encodes itself differently depending on whether the coder is an NSXPCCoder or a plain NSCoder. If you instantiate one outside of an XPC reply and try to encode it, you get the wrong output. The fix is to swizzle encodeWithCoder: and temporarily re-class the coder so the encoding path takes the branch you want:

private func swizzleSnapshotEncodeIfNeeded() {
    guard let snapshotClass = objc_getClass("WallpaperSnapshotXPC") as? AnyClass else {
        print("[Swizzle] WallpaperSnapshotXPC not found")
        return
    }

    let sel = NSSelectorFromString("encodeWithCoder:")
    guard let origMethod = class_getInstanceMethod(snapshotClass, sel) else { return }

    let origIMP = method_getImplementation(origMethod)
    typealias EncodeFunc = @convention(c) (AnyObject, Selector, NSCoder) -> Void
    let origFunc = unsafeBitCast(origIMP, to: EncodeFunc.self)

    guard let nsxpcCoderClass = NSClassFromString("NSXPCCoder") else { return }

    let block: @convention(block) (AnyObject, NSCoder) -> Void = { obj, coder in
        let origClass: AnyClass = object_getClass(coder)!
        object_setClass(coder, nsxpcCoderClass)
        origFunc(obj, sel, coder)
        object_setClass(coder, origClass)
    }
    method_setImplementation(origMethod, imp_implementationWithBlock(block))
}

Use swizzling sparingly. Crash reports from a swizzled method show the replacement IMP in the stack trace, not the original, and that makes things harder to debug.

XPC

Most cross-process communication on macOS goes over XPC, and when you want your code to participate in a system feature, you're usually implementing or consuming an XPC interface defined by some daemon. The high-level API is NSXPCConnection, and the serialization happens inside a private subclass of NSCoder called NSXPCCoder.

NSXPCConnection won't decode classes it doesn't know about. For a public protocol this is fine because Foundation already registers the standard classes. For a private protocol whose arguments are private types, you have to load those classes into the runtime yourself and register them on the interface as allowed for each method argument and reply position. That’s what the case study below does.

Case study: a third-party wallpaper extension

On modern macOS, wallpapers are XPC extensions hosted by WallpaperAgent through an extension point called com.apple.wallpaper. Apple ships several extensions (image, dynamic, aerials, gradient, the named macOS releases) but doesn't document the extension point at all.
You can build one yourself.

First, you need to create the extension entry point:

@main
struct WallpaperVideoExtension: AppExtension {
    init() {
        // Force the real XPC type classes into the runtime so we can whitelist them.
        dlopen("/System/Library/PrivateFrameworks/WallpaperExtensionKit.framework/WallpaperExtensionKit",
               RTLD_LAZY)
        dlopen("/System/Library/PrivateFrameworks/WallpaperTypes.framework/WallpaperTypes",
               RTLD_LAZY)
    }

    var configuration: some AppExtensionConfiguration {
        WallpaperExtensionConfig()
    }
}

struct WallpaperExtensionConfig: AppExtensionConfiguration {
    func accept(connection: NSXPCConnection) -> Bool {
        let exported = NSXPCInterface(with: WallpaperExtensionXPCProtocol.self)
        let remote   = NSXPCInterface(with: WallpaperExtensionProxyXPCProtocol.self)

        // Apple's NSXPC won't decode types it doesn't know about. We have to
        // walk every reply argument and register the private classes by name.
        XPCWhitelist.apply(to: exported)

        connection.exportedInterface     = exported
        connection.exportedObject        = WallpaperXPCHandler()
        connection.remoteObjectInterface = remote
        connection.resume()
        return true
    }
}

Then whitelist the private classes so the decoder doesn't reject them:

enum XPCWhitelist {
    // Names obtained from `class-dump` + `nm | swift demangle` on
    // WallpaperExtensionKit. They must be loaded before we look them up.
    private static let privateTypeNames = [
        "WallpaperSettingsViewModelsXPC",
        "WallpaperContentTypeSetXPC",
        "WallpaperIDXPC",
        "WallpaperRemoteContextXPC",
        "WallpaperSnapshotXPC",
        // ...15 in total
    ]

    static func apply(to iface: NSXPCInterface) {
        let classes = NSMutableSet(array: [
            NSString.self, NSArray.self, NSDictionary.self, NSData.self,
            NSNumber.self, NSURL.self, NSError.self, NSUUID.self
        ])
        for name in privateTypeNames {
            guard let cls = objc_getClass(name) as? AnyClass else {
                fatalError("missing private class \(name) — did you dlopen the framework?")
            }
            classes.add(cls)
        }

        // For each method that takes or returns a custom type, register the
        // allowed class set at the right argument index / reply position.
        let sel = #selector(WallpaperExtensionXPCProtocol.provideSettingsViewModels(withContentTypes:reply:))
        iface.setClasses(classes as! Set<AnyHashable>, for: sel, argumentIndex: 0, ofReply: false)
        iface.setClasses(classes as! Set<AnyHashable>, for: sel, argumentIndex: 0, ofReply: true)
    }
}

The protocol itself goes in a bridging header. Nullability matters here — if your declaration disagrees with what the daemon expects, NSXPC will silently mis-decode arguments and you'll spend hours figuring out why your reply is half-empty.

// WallpaperExtension-Bridging-Header.h
//
// We re-declare the protocol Apple's framework expects. Names and signatures
// come from `class-dump` on WallpaperExtensionKit. Nullability matters — NSXPC
// will mis-decode arguments if it disagrees.

@class WallpaperContentTypeSetXPC, WallpaperSettingsViewModelsXPC, WallpaperIDXPC;

@protocol WallpaperExtensionXPCProtocol <NSObject>
- (void)provideSettingsViewModelsWithContentTypes:(WallpaperContentTypeSetXPC * _Nonnull)contentTypes
                                            reply:(void (^ _Nonnull)(WallpaperSettingsViewModelsXPC * _Nullable, NSError * _Nullable))reply;
// ... 21 more methods
@end

@protocol WallpaperExtensionProxyXPCProtocol <NSObject>
- (void)pingWithId:(WallpaperIDXPC * _Nonnull)wallpaperId;
@end

The handler that answers the calls:

final class WallpaperXPCHandler: NSObject, WallpaperExtensionXPCProtocol {
    func provideSettingsViewModels(
        withContentTypes contentTypes: WallpaperContentTypeSetXPC,
        reply: @escaping (WallpaperSettingsViewModelsXPC?, Error?) -> Void
    ) {
        // The agent sends {desktop, screenSaver}. We can confirm by mirroring:
        dumpMirror(contentTypes)

        // Build a response by calling through to the real XPC type via Objc runtime.
        // The class was registered above; here we instantiate and populate it.
        let cls: AnyClass = objc_getClass("WallpaperSettingsViewModelsXPC") as! AnyClass
        let response = (cls as! NSObject.Type).init()
        // (populate fields via setValue:forKey:, then…)
        reply(response as? WallpaperSettingsViewModelsXPC, nil)
    }
}

Then we need to have the correct values in info.plist:

<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>EXAppExtensionAttributes</key>
<dict>
    <key>EXExtensionPointIdentifier</key>
    <string>com.apple.wallpaper</string>
</dict>

That’s enough for the system to pick the extension up. PluginKit sees an extension declaring an identifier that WallpaperAgent handles, and gives it control. From there you fill in the rest of the 22-method protocol — acquire/invalidate, snapshot, settings — and you have a working wallpaper extension that the system treats as a peer to Apple's own.

One thing to watch for: if you have multiple copies of your extension installed (debug builds, old archives, the System Settings preview copy), PluginKit gets confused and sometimes launches the wrong one. Clean out the old copies and reset with:

pluginkit -mAvv -p .wallpaper
killall WallpaperAgent

Things that break


OS upgrades are the obvious one. Symbol names, selector names, and protocol shapes all drift between major versions, and sometimes between minor versions too. If you're shipping code, gate the dispatch and log telemetry when something disappears.

App Store distribution is dead. Linking against private frameworks won't pass review. Notarization for direct distribution is usually fine, though some extension points need specific entitlements you may not be able to get.

Sandboxing won't let you dlopen arbitrary paths under /System/Library/PrivateFrameworks. For anything that needs private framework access, ship unsandboxed or split the privileged work into a helper.

The shared cache changes between OS versions, so re-extract your binaries after every macOS update. Struct layouts, method signatures, and symbol names all shift, and the assumptions baked into your code at build time can quietly stop being true.

Swizzling makes crashes harder to read because the stack trace shows the swizzled IMP, not the original. When you're debugging something weird, swizzle is the first thing to disable.

The reason it works at all is that Apple builds its own software the same way it builds yours: with frameworks that talk to each other through interfaces, and the interfaces are recoverable if you read carefully enough.

♡ ⋆ ♡

Entered this 22nd day of May, by ✚ 陽炎 ✚

kageroumado · with 空