Technical analysis of CVE-2025-31201

Following the latest update from Apple, we reverse engineered the diff between iOS 18.4 and 18.4.1 to study the changes made to RPAC.

Introduction

CVE-2025-31201 affects a component called RPAC and has been patched in Apr 16 2025 (see the Apple security bulletin for iOS 18.4.1). The following is mentioned:

Impact: An attacker with arbitrary read and write capability may be able to bypass Pointer Authentication.

RPAC corresponds to a shared library named libRPAC, which resides in /usr/lib/libRPAC.dylib. At the time of writing, the exact meaning of RPAC is unknown to us.

For this analysis, we used the following iPhone 16e's firmwares: 18.4 (22E240) and 18.4.1 (22E252).

The purpose of this analysis is to rediscover this PAC bypass.

Don't hesitate to check out the the references that were found useful to write this article.

Mach-O analysis

We started by analyzing the library of interest.

In the first place, we were quite surprised to see that libRPAC.dylib wasn't included in the Dyld shared cache, as bunches of other libraries are. It lives in the filesystem at /usr/lib/libRPAC.dylib. However, later in our research we completely understood the reason behind this. By the end of this article it should be made clear why this is.

Firstly, we get some preliminary information by using the ipsw CLI:

$ ipsw macho info --arch arm64e idbs/18.4/libRPAC.dylib
Output (click to expand)
Magic         = 64-bit MachO
Type          = DYLIB
CPU           = AARCH64, ARM64e caps: USR00
Commands      = 22 (Size: 3032)
Flags         = NoUndefs, DyldLink, TwoLevel, BindsToWeak, NoReexportedDylibs, AppExtensionSafe
000: LC_SEGMENT_64 sz=0x00098000 off=0x00000000-0x00098000 addr=0x000000000-0x000098000 r-x/r-x   __TEXT
    sz=0x0008e1ac off=0x00000c28-0x0008edd4 addr=0x000000c28-0x00008edd4               __TEXT.__text            PureInstructions|SomeInstructions
    sz=0x00000790 off=0x0008edd4-0x0008f564 addr=0x00008edd4-0x00008f564               __TEXT.__auth_stubs      PureInstructions|SomeInstructions (SymbolStubs)
    sz=0x000001a0 off=0x0008f580-0x0008f720 addr=0x00008f580-0x00008f720               __TEXT.__objc_stubs      PureInstructions|SomeInstructions
    sz=0x00000004 off=0x0008f720-0x0008f724 addr=0x00008f720-0x00008f724               __TEXT.__init_offsets     (InitFuncOffsets)
    sz=0x00004a0f off=0x0008f724-0x00094133 addr=0x00008f724-0x000094133               __TEXT.__cstring          (CstringLiterals)
    sz=0x00000044 off=0x00094134-0x00094178 addr=0x000094134-0x000094178               __TEXT.__gcc_except_tab
    sz=0x00001d60 off=0x00094178-0x00095ed8 addr=0x000094178-0x000095ed8               __TEXT.__const
    sz=0x0000013b off=0x00095ed8-0x00096013 addr=0x000095ed8-0x000096013               __TEXT.__objc_methname    (CstringLiterals)
    sz=0x0000001d off=0x00096013-0x00096030 addr=0x000096013-0x000096030               __TEXT.__oslogstring      (CstringLiterals)
    sz=0x00000001 off=0x00096030-0x00096031 addr=0x000096030-0x000096031               __TEXT.__objc_classname   (CstringLiterals)
    sz=0x00000288 off=0x00096034-0x000962bc addr=0x000096034-0x0000962bc               __TEXT.__unwind_info
001: LC_SEGMENT_64 sz=0x00004000 off=0x00098000-0x0009c000 addr=0x000098000-0x00009c000 rw-/rw-   __DATA_CONST      ReadOnly
    sz=0x000003e0 off=0x00098000-0x000983e0 addr=0x000098000-0x0000983e0         __DATA_CONST.__auth_got         (NonLazySymbolPointers)
    sz=0x00000080 off=0x000983e0-0x00098460 addr=0x0000983e0-0x000098460         __DATA_CONST.__got              (NonLazySymbolPointers)
    sz=0x00000010 off=0x00098460-0x00098470 addr=0x000098460-0x000098470         __DATA_CONST.__auth_ptr
    sz=0x000003b8 off=0x00098470-0x00098828 addr=0x000098470-0x000098828         __DATA_CONST.__const
    sz=0x00000260 off=0x00098828-0x00098a88 addr=0x000098828-0x000098a88         __DATA_CONST.__cfstring
    sz=0x00000008 off=0x00098a88-0x00098a90 addr=0x000098a88-0x000098a90         __DATA_CONST.__objc_imageinfo
    sz=0x00000020 off=0x00098a90-0x00098ab0 addr=0x000098a90-0x000098ab0         __DATA_CONST.__objc_arraydata
    sz=0x00000030 off=0x00098ab0-0x00098ae0 addr=0x000098ab0-0x000098ae0         __DATA_CONST.__objc_arrayobj
002: LC_SEGMENT_64 sz=0x00004000 off=0x0009c000-0x000a0000 addr=0x00009c000-0x0000a0000 rw-/rw-   __AUTH_CONST
    sz=0x00000090 off=0x0009c000-0x0009c090 addr=0x00009c000-0x00009c090         __AUTH_CONST.__interpose
003: LC_SEGMENT_64 sz=0x00004000 off=0x000a0000-0x000a4000 addr=0x0000a0000-0x0006a4000 rw-/rw-   __DATA
    sz=0x00000088 off=0x000a0000-0x000a0088 addr=0x0000a0000-0x0000a0088               __DATA.__objc_selrefs    NoDeadStrip (LiteralPointers)
    sz=0x000007c8 off=0x000a0088-0x000a0850 addr=0x0000a0088-0x0000a0850               __DATA.__data
    sz=0x000800e8 off=0x00000000-0x000800e8 addr=0x0000a0850-0x000120938               __DATA.__common           (Zerofill)
    sz=0x00580630 off=0x00000000-0x00580630 addr=0x000120938-0x0006a0f68               __DATA.__bss              (Zerofill)
004: LC_SEGMENT_64 sz=0x0000da30 off=0x000a4000-0x000b1a30 addr=0x0006a4000-0x0006b4000 r--/r--   __LINKEDIT
005: LC_ID_DYLIB                 /usr/lib/libRPAC.dylib (2)
006: LC_DYLD_CHAINED_FIXUPS      offset=0x0000a4000  size=0xd40
007: LC_DYLD_EXPORTS_TRIE        offset=0x0000a4d40  size=0x8
008: LC_SYMTAB                   Symbol offset=0x000A4E70, Num Syms: 547, String offset=0x000A74B8-0x000ABD38
009: LC_DYSYMTAB
                 Local Syms: 407 at 0
              External Syms: 0 at 407
             Undefined Syms: 140 at 407
                        TOC: No
                     Modtab: No
    External symtab Entries: None
    Indirect symtab Entries: 261 at 0x000a70a0
     External Reloc Entries: None
        Local Reloc Entries: None
010: LC_UUID                     C2FEA880-24AE-3CE4-A9F2-085EFA251870
011: LC_BUILD_VERSION            Platform: macOS, MinOS: 15.4, SDK: 15.4, Tool: ld (1167.3)
012: LC_SOURCE_VERSION           84.0.0.0.0
013: LC_LOAD_DYLIB               /System/Library/Frameworks/Foundation.framework/Versions/C/Foundation (3423)
014: LC_LOAD_DYLIB               /System/Library/Frameworks/CoreFoundation.framework/Versions/A/CoreFoundation (3423)
015: LC_LOAD_DYLIB               /usr/lib/libobjc.A.dylib (228)
016: LC_LOAD_DYLIB               /usr/lib/libc++.1.dylib (1900.178)
017: LC_LOAD_DYLIB               /usr/lib/libSystem.B.dylib (1351)
018: LC_LOAD_DYLIB               /System/Library/Frameworks/ImageIO.framework/Versions/A/ImageIO (1)
019: LC_FUNCTION_STARTS          offset=0x0000a4d48  size=0x128
020: LC_DATA_IN_CODE             offset=0x000a4e70-0x000a4e70 size=    0 entries=0
021: LC_CODE_SIGNATURE           offset=0x0000abd40  size=0x5cf0

A line caught our attention:

007: LC_DYLD_EXPORTS_TRIE        offset=0x0000a4d40  size=0x8

It has no exported symbols!

In most cases, shared libraries export symbols, so that developers can use and integrate them in their code. What could be the reason for having a shared library that exports no symbols? dyld_info confirms it:

$ xcrun dyld_info -arch arm64e -exports idbs/18.4/libRPAC.dylib
idbs/18.4/libRPAC.dylib [arm64e]:
    -exports:
        offset      symbol

At this point, we decided to take a look at its imports. Imported symbols are symbols coming from other libraries that may be used by the library importing them.

xcrun dyld_info -arch arm64e -imports idbs/18.4/libRPAC.dylib|awk '{print $2" ("$4}'|sort

We notice some commonly imported symbols such as the ones related to the Objective-C Runtime (_objc_msgSend) or to the pthreads interface (pthread_self). But we also notice that the set of dl* symbols are imported too: dlopen, dlsym and dlclose:

Imports of libRPAC.dylib, 18.4 (click to expand)
___assert_rtn (libSystem)
___CFConstantStringClassReference (CoreFoundation)
___chkstk_darwin (libSystem)
___cxa_allocate_exception (libc++)
___cxa_throw (libc++)
___error (libSystem)
___gxx_personality_v0 (libc++)
___objc_personality_v0 (libobjc)
___stack_chk_fail (libSystem)
___stack_chk_guard (libSystem)
___stderrp (libSystem)
___strncat_chk (libSystem)
___tolower (libSystem)
__Block_object_dispose (libSystem)
__dyld_register_for_bulk_image_loads (libSystem)
__dyld_register_for_image_loads (libSystem)
__dyld_register_func_for_add_image (libSystem)
__dyld_register_func_for_remove_image (libSystem)
__NSConcreteGlobalBlock (libSystem)
__NSConcreteStackBlock (libSystem)
__os_log_fault_impl (libSystem)
__Unwind_Resume (libSystem)
__ZdlPv (<weak-def-coalesce>)
__ZNSt20bad_array_new_lengthC1Ev (libc++)
__ZNSt20bad_array_new_lengthD1Ev (libc++)
__ZNSt3__112__next_primeEm (libc++)
__Znwm (<weak-def-coalesce>)
__ZnwmSt19__type_descriptor_t (<weak-def-coalesce>)
__ZTISt20bad_array_new_length (libc++)
_atexit_b (libSystem)
_atoi (libSystem)
_backtrace (libSystem)
_backtrace_image_offsets (libSystem)
_backtrace_symbols (libSystem)
_bzero (libSystem)
_CFBundleCopyBundleURL (CoreFoundation)
_CFBundleCopyExecutableURL (CoreFoundation)
_CFBundleGetMainBundle (CoreFoundation)
_CFRelease (CoreFoundation)
_CFSetAddValue (CoreFoundation)
_CFSetContainsValue (CoreFoundation)
_CFSetCreateMutable (CoreFoundation)
_CFStringCreateWithCString (CoreFoundation)
_CFStringCreateWithCStringNoCopy (CoreFoundation)
_CFStringGetCString (CoreFoundation)
_CFStringGetCStringPtr (CoreFoundation)
_CFURLCopyPath (CoreFoundation)
_CFURLCreateCopyDeletingLastPathComponent (CoreFoundation)
_CFURLGetString (CoreFoundation)
_CGImageDestinationFinalize (ImageIO)
_CGImageSourceCreateImageAtIndex (ImageIO)
_CGImageSourceCreateThumbnailAtIndex (ImageIO)
_class_addMethod (libobjc)
_class_copyMethodList (libobjc)
_class_getClassMethod (libobjc)
_class_getInstanceMethod (libobjc)
_class_getName (libobjc)
_class_replaceMethod (libobjc)
_class_replaceMethodsBulk (libobjc)
_dispatch_async (libSystem)
_dispatch_async_and_wait (libSystem)
_dispatch_block_create_with_qos_class (libSystem)
_dispatch_group_leave (libSystem)
_dispatch_group_wait (libSystem)
_dispatch_once (libSystem)
_dispatch_semaphore_create (libSystem)
_dispatch_semaphore_signal (libSystem)
_dispatch_semaphore_wait (libSystem)
_dispatch_workloop_create (libSystem)
_dladdr (libSystem)
_dlclose (libSystem)
_dlopen (libSystem)
_dlsym (libSystem)
_dyld_image_header_containing_address (libSystem)
_dyld_image_path_containing_address (libSystem)
_fclose (libSystem)
_fgets (libSystem)
_fopen (libSystem)
_fprintf (libSystem)
_fputs (libSystem)
_free (libSystem)
_fwrite (libSystem)
_getenv (libSystem)
_getpid (libSystem)
_kCFAllocatorNull (CoreFoundation)
_kCFTypeSetCallBacks (CoreFoundation)
_mach_absolute_time (libSystem)
_mach_timebase_info (libSystem)
_malloc_type_calloc (libSystem)
_malloc_type_malloc (libSystem)
_malloc_type_realloc (libSystem)
_memcpy (libSystem)
_memset (libSystem)
_method_exchangeImplementations (libobjc)
_method_getImplementation (libobjc)
_method_getName (libobjc)
_method_getTypeEncoding (libobjc)
_method_setImplementation (libobjc)
_NSLog (Foundation)
_objc_autoreleaseReturnValue (libobjc)
_objc_claimAutoreleasedReturnValue (libobjc)
_OBJC_CLASS_$_NSConstantArray (CoreFoundation)
_OBJC_CLASS_$_NSJSONSerialization (Foundation)
_OBJC_CLASS_$_NSSet (CoreFoundation)
_OBJC_CLASS_$_NSString (Foundation)
_objc_enumerationMutation (libobjc)
_objc_getClass (libobjc)
_objc_getMetaClass (libobjc)
_objc_msgSend (libobjc)
_objc_release (libobjc)
_objc_release_x1 (libobjc)
_objc_release_x19 (libobjc)
_objc_release_x20 (libobjc)
_objc_release_x21 (libobjc)
_objc_release_x22 (libobjc)
_objc_release_x23 (libobjc)
_objc_release_x24 (libobjc)
_objc_release_x25 (libobjc)
_objc_release_x27 (libobjc)
_objc_release_x28 (libobjc)
_objc_release_x8 (libobjc)
_objc_release_x9 (libobjc)
_objc_retain (libobjc)
_objc_retain_x19 (libobjc)
_objc_retain_x20 (libobjc)
_objc_retain_x21 (libobjc)
_objc_retain_x22 (libobjc)
_objc_retain_x23 (libobjc)
_objc_retain_x25 (libobjc)
_objc_retain_x8 (libobjc)
_objc_retainAutorelease (libobjc)
_object_getClass (libobjc)
_os_log_create (libSystem)
_os_log_type_enabled (libSystem)
_os_unfair_lock_lock (libSystem)
_os_unfair_lock_unlock (libSystem)
_os_variant_has_internal_content (libSystem)
_os_variant_has_internal_diagnostics (libSystem)
_os_variant_has_internal_ui (libSystem)
_pthread_getschedparam (libSystem)
_pthread_getspecific (libSystem)
_pthread_key_create (libSystem)
_pthread_main_np (libSystem)
_pthread_self (libSystem)
_pthread_setspecific (libSystem)
_pthread_threadid_np (libSystem)
_qos_class_self (libSystem)
_sel_getName (libobjc)
_sel_registerName (libobjc)
_setenv (libSystem)
_snprintf (libSystem)
_sqlite3_close (libsqlite3)
_sqlite3_close_v2 (libsqlite3)
_sqlite3_db_handle (libsqlite3)
_sqlite3_db_mutex (libsqlite3)
_sqlite3_db_status (libsqlite3)
_sqlite3_exec (libsqlite3)
_sqlite3_exec_b (libsqlite3)
_sqlite3_finalize (libsqlite3)
_sqlite3_free (libsqlite3)
_sqlite3_mutex_enter (libsqlite3)
_sqlite3_mutex_leave (libsqlite3)
_sqlite3_open (libsqlite3)
_sqlite3_open_v2 (libsqlite3)
_sqlite3_open16 (libsqlite3)
_sqlite3_prepare (libsqlite3)
_sqlite3_prepare_v2 (libsqlite3)
_sqlite3_prepare_v3 (libsqlite3)
_sqlite3_prepare16 (libsqlite3)
_sqlite3_prepare16_v2 (libsqlite3)
_sqlite3_prepare16_v3 (libsqlite3)
_sqlite3_step (libsqlite3)
_sqlite3_stmt_readonly (libsqlite3)
_sqlite3_wal_autocheckpoint (libsqlite3)
_sqlite3_wal_checkpoint (libsqlite3)
_sqlite3_wal_checkpoint_v2 (libsqlite3)
_sqlite3_wal_hook (libsqlite3)
_stpncpy (libSystem)
_strcasestr (libSystem)
_strcat (libSystem)
_strcmp (libSystem)
_strcpy (libSystem)
_strcspn (libSystem)
_strdup (libSystem)
_strlen (libSystem)
_strncmp (libSystem)
_strncpy (libSystem)
_strrchr (libSystem)
_strstr (libSystem)
_sysctlbyname (libSystem)
_unsetenv (libSystem)
_xpc_connection_send_message_with_reply_sync (libSystem)

These symbols, especially dlsym stand out to us: a few PAC bypass used dlsym to obtain signed pointers to symbols. For instance, SLOP, discovered by Google Project Zero is a userspace PAC bypass that used this technique.

If we use dyld_info on libRPAC.dylib of iOS 18.4.1, we notice that the import of dlsym is gone:

--- snippets/imports-18.4.md    2025-04-18 11:34:57
+++ snippets/imports-18.4.1.md  2025-04-18 11:35:06
@@ -74,7 +74,6 @@
 _dladdr (libSystem)
 _dlclose (libSystem)
 _dlopen (libSystem)
-_dlsym (libSystem)
 _dyld_image_header_containing_address (libSystem)
 _dyld_image_path_containing_address (libSystem)
 _fclose (libSystem)

At this time, we believed that it was safe to assume that one found a way to call dlsym to get signed pointers to any exported symbols.

A quick note on PAC and the diversifier

iOS uses PAC, and function pointers are highly likely to be used with the BLRAA or BLRAAZ aarch64 instructions.

Since dlsym mostly returns function pointers, they must comply with PAC. In iOS, it returns pointers signed with the key A and the diversifier zero:

result->targetAddressForDlsym   = interpose(state, result->targetAddressForDlsym);
#if __has_feature(ptrauth_calls)
if ( result->isCode )
    result->targetAddressForDlsym = (uintptr_t)__builtin_ptrauth_sign_unauthenticated((void*)result->targetAddressForDlsym, ptrauth_key_asia, 0);
#endif
#endif

(dyld source code)

In this article, we use the word "diversifier" to qualify the arbitrary extra data which alters the signature in a way that the signed pointer can solely be authenticated using the very same extra data. For instance, the PACIBSP instruction signs the LR register using the key B and the value of SP as the diversifier.


Now that we have found dlsym, we were eager to see what piece of code from libRPAC.dylib was using it.

By looking for its cross-references, we notice that there's just one: interposed_dlsym, which itself has only one also, in a Mach-O's section named __interpose:

__interpose:00000000000A01B0 6C C4 08 00…__interpose_dlclose DCQ _interposed_dlclose
__interpose:00000000000A01B8 40 56 6A 00…                DCQ __imp__dlclose
__interpose:00000000000A01C0 24 C5 08 00…__interpose_dlopen DCQ _interposed_dlopen
__interpose:00000000000A01C8 48 56 6A 00…                DCQ __imp__dlopen
__interpose:00000000000A01D0 EC C5 08 00…__interpose_dlsym DCQ _interposed_dlsym
__interpose:00000000000A01D8 50 56 6A 00…                DCQ __imp__dlsym

What is this section all about?

Dyld interposing

The __interpose section is a Dyld feature that allows developers to reroute at runtime the implementation of a function.

To be more precise, it replaces the pointer from the GOT assigned to a given symbol by the pointer to a user-defined function.

Developers may use this feature to inspect values being passed to functions before calling the real one. It means that they have to be able to call the original implementation of the interposed function. This isn't trivial since the source of truth for the symbols is the GOT, and its entry to the interposed symbol has been overwritten.

A more complete article about Dyld interposing was written by Noah Martin on their blog: Code Injection with Dyld Interposing.


At this point, we didn't have a complete picture of libRPAC.dylib. We only figured out that it's used to inspect certain functions.

Pragmatically, we haven't made much progress so far, and we could even say that the problem consisting of calling dlsym has just shifted to being able to call the interposer of dlsym, which, according to the definition of an interposer, boils down to… calling dlsym.

Therefore, we decided to launch an umpteenth instance of IDA to start reverse engineering RPAC.

Reverse engineering of libRPAC.dylib

TL;DR: libRPAC.dylib is an instrumentation tool, operated by XCode, to help developers handle threads more wisely. One can use it by enabling the option Thread Performance Checker Tool in the scheme option of the XCode project.

Only little knowledge of the keyword RPAC is available online. We found a few references here and there, such as this crash report.

Implementation

As we said in the first section, libRPAC.dylib exposes no symbols, thus there must be a way to call, use or execute code from this library.

We found out that it has a constructor (an initialization routine), named __library_initializer.

This function looks for a bunch of environment variables to set up global parameters. One of these parameters, PERFC_DONT_SWIZZLE, causes the call to the function initializeSwizzlers if its value is equal to "1":

if ( (envDontSwizzleAnything & 1) == 0 )
  {
    initializeSwizzlers();
    if ( envEnableAGPCChecks == 1 && (envProfileModeEnabled & 1) == 0 )
      initializeTrampolineBasedSwizzling(0);
  }

Swizzlers

initializeSwizzlers is responsible for initializing something called swizzlers.

Swizzlers, or method swizzling, is a feature implemented both in Objective-C and Swift, that allows developers to change the implementation of a method at runtime. We could see it as monkey patching.

By reverse engineering initializeSwizzlers, we noticed that it iterates over three arrays stored in the __data section of the Mach-O.

The elements of these arrays are of the following type:

struct InstanceOrClassMethodPair {
    char className[0x40];
    char selectorName[0x40];
}

The three arrays are:

symbol's name Number of elements description
_swizzled_class_instance_method_pairs 8 Class instance methods
_swizzled_class_class_method_pairs 4 Class class methods
_appleinternal_swizzled_class_instance_method_pairs 3 Apple Internal class instance methods

For each element of these arrays, and under certain conditions, it replaces either the instance method or the class method of a class:

i_instanceMethodPairs = 0;
do {
    if ( (envDisableConformanceChecks != 1
        || strncmp(instanceMethodPair->className, "NSObject", 8u)
        || strncmp(instanceMethodPair->selectorName, "conformsToProtocol:", 0x13u))
        && (envDisableAVCaptureChecks != 1 || strncmp(instanceMethodPair->className, "AVCapture", 9u))
        && (envEnableIOOnMainThread == 1 && envEnableAGPCChecks != 1
        || strncmp(instanceMethodPair->className, "NSData", 6u)) )
    {
        cls = objc_getClass(instanceMethodPair->className);
        if ( cls )
        {
        cls_1 = cls;
        sel = sel_registerName(instanceMethodPair->selectorName);
        meth_1 = class_getInstanceMethod(cls_1, sel);
        prevMethRef = previousClassInstanceMethodImplementations[i_instanceMethodPairs];
        *prevMethRef = method_setImplementation(meth_1, newClassInstanceMethodImplementations[i_instanceMethodPairs]);
        }
        else if ( envRPACDebug == 1 )
        {
        fprintf(
            __stderrp,
            "Unable to swizzle selector %s of class %s \n",
            instanceMethodPair->selectorName,
            instanceMethodPair->className);
        }
    }
    ++i_instanceMethodPairs;
    ++instanceMethodPair;
} while ( i_instanceMethodPairs != 8 );

For each array of pairs, two arrays are used:

  • The first one contains pointers where the previous implementations (returned by method_setImplementation) will be stored.
  • The second one contains the new implementations, often prefixed by replacement_METH_NAME:
Arrays of previous and new implementations (click to expand)
__const:000000000009C6D8 D4 18 00 00 newClassInstanceMethodImplementations DCQ __replacement_NSData_writeToFile_atomically
__const:000000000009C6D8 00 00 00 00                                         ; DATA XREF: _initializeSwizzlers+114↑o
__const:000000000009C6E0 78 19 00 00…                DCQ __replacement_NSData_writeToFile_options_error
__const:000000000009C6E8 2C 1A 00 00…                DCQ __replacement_NSData_initWithContentsOfFile
__const:000000000009C6F0 DC 1A 00 00…                DCQ __replacement_NSData_initWithContentsOfFile_options_error
__const:000000000009C6F8 A4 1B 00 00…                DCQ __replacement_NSData_initWithContentsOfURL
__const:000000000009C700 84 1C 00 00…                DCQ __replacement_NSData_initWithContentsOfURL_options_error
__const:000000000009C708 F4 1E 00 00…                DCQ __replacement_AVCaptureSession_startRunning
__const:000000000009C710 90 22 00 00…                DCQ __replacement_NSObject_conformsToProtocol_Instance_Version
__const:000000000009C718             ; IMP *previousClassInstanceMethodImplementations[8]
__const:000000000009C718 50 48 0A 00 previousClassInstanceMethodImplementations DCQ ___original_NSData_writeToFile_atomically
__const:000000000009C718 00 00 00 00                                         ; DATA XREF: _initializeSwizzlers+120↑o
__const:000000000009C720 58 48 0A 00…                DCQ ___original_NSData_writeToFile_options_error
__const:000000000009C728 60 48 0A 00…                DCQ ___original_NSData_initWithContentsOfFile
__const:000000000009C730 68 48 0A 00…                DCQ ___original_NSData_initWithContentsOfFile_options_error
__const:000000000009C738 70 48 0A 00…                DCQ ___original_NSData_initWithContentsOfURL
__const:000000000009C740 78 48 0A 00…                DCQ ___original_NSData_initWithContentsOfURL_options_error
__const:000000000009C748 88 48 0A 00…                DCQ ___original_AVCaptureSession_startRunning
__const:000000000009C750 A8 48 0A 00…                DCQ ___original_NSObject_conformsToProtocol_Instance_Version
__const:000000000009C758             ; IMP newClassClassMethodImplementations[4]
__const:000000000009C758 7C 23 00 00 newClassClassMethodImplementations DCQ __replacement_NSObject_conformsToProtocol_Class_Version
__const:000000000009C758 00 00 00 00                                         ; DATA XREF: _initializeSwizzlers+250↑o
__const:000000000009C760 68 24 00 00…                DCQ __replacement_NSURLConnection_sendSynchronousRequest_returningResponse_error
__const:000000000009C768 7C 1D 00 00…                DCQ __replacement_NSData_dataWithContentsOfFile
__const:000000000009C770 2C 1E 00 00…                DCQ __replacement_NSData_dataWithContentsOfFile_options_error
__const:000000000009C778             ; IMP *previousClassClassMethodImplementations[3]
__const:000000000009C778 B0 48 0A 00 previousClassClassMethodImplementations DCQ ___original_NSObject_conformsToProtocol_Class_Version
__const:000000000009C778 00 00 00 00                                         ; DATA XREF: _initializeSwizzlers+25C↑o
__const:000000000009C780 B8 48 0A 00…                DCQ ___original_NSURLConnection_sendSynchronousRequest_returningResponse_error
__const:000000000009C788 80 48 0A 00…                DCQ ___original_NSData_dataWithContentsOfFile
__const:000000000009C790 C0 48 0A 00…                DCQ ___original_NSData_dataWithContentsOfFile_options_error
__const:000000000009C798             ; IMP newAppleInternalClassInstanceMethodImplementations[3]
__const:000000000009C798 84 1F 00 00 newAppleInternalClassInstanceMethodImplementations DCQ __replacement_ISIcon_prepareImageForDescriptor
__const:000000000009C798 00 00 00 00                                         ; DATA XREF: _initializeSwizzlers+394↑o
__const:000000000009C7A0 88 20 00 00…                DCQ __replacement_ISIcon_prepareImagesForDescriptors
__const:000000000009C7A8 8C 21 00 00…                DCQ __replacement_ISIcon_prepareImagesForImageDescriptors
__const:000000000009C7B0             ; IMP *previousAppleInternalClassInstanceMethodImplementations[3]
__const:000000000009C7B0 90 48 0A 00 previousAppleInternalClassInstanceMethodImplementations DCQ ___original_ISIcon_prepareImageForDescriptor
__const:000000000009C7B0 00 00 00 00                                         ; DATA XREF: _initializeSwizzlers+3A0↑o
__const:000000000009C7B8 98 48 0A 00…                DCQ ___original_ISIcon_prepareImagesForDescriptors
__const:000000000009C7C0 A0 48 0A 00…                DCQ ___original_ISIcon_prepareImagesForImageDescriptors

The classes and their implementations that get replaced are, as of iOS 18.4, the following:

_swizzled_class_instance_method_pairs:

Method
-[NSData writeToFile:atomically:]
-[NSData writeToFile:options:error:]
-[NSData initWithContentsOfFile:]
-[NSData initWithContentsOfFile:options:error:]
-[NSData initWithContentsOfURL:]
-[NSData initWithContentsOfURL:options:error:]
-[AVCaptureSession startRunning]
-[NSObject conformsToProtocol:]

_swizzled_class_class_method_pairs:

Method
+[NSObject conformsToProtocol:]
+[NSURLConnection sendSynchronousRequest:returningResponse:error:]
+[NSData dataWithContentsOfFile:]
+[NSData initWithContentsOfFile:options:error:]

_appleinternal_swizzled_class_instance_method_pairs:

Method
-[ISIcon prepareImageForDescriptor:]
-[ISIcon prepareImagesForDescriptors:]
-[ISIcon prepareImagesForImageDescriptors:]

Then, it calls initializeNSConditionSwizzling.

NSCondition swizzling

initializeNSConditionSwizzling does pretty much the same thing as initializeSwizzlers, but for NSCondition:

Method
-[NSCondition wait]
-[NSCondition waitUntilDate:]
-[NSCondition signal]
-[NSCondition broadcast]

The following diagram summarizes both initializeSwizzlers and initializeNSConditionSwizzling:

diagram of a swizzler

Since we're dealing with IMP, which are simply function pointers, we have to understand how IMP works with PAC.

Implementations (IMP) and PAC

In initializeSwizzlers and initializeNSConditionSwizzling, the pointers to the new implementation are signed using the PACIZA instruction.

It's the same for the pointers returned by method_setImplementation, which correspond to the old implementation. The ObjectiveC code method_setImplementation demonstrates it.

This is normal: pointers to implementations, along with the ISA pointers are now signed. Therefore, when we assign a new implementation to a class or to an instance method, we must provide a signed pointer.

When we enter a function, the PACIBSP instruction signs LR using the diversifier SP. This is to make sure that LR cannot be authenticated unless SP is restored to its original value.

In this situation with swizzlers, the implementation pointers aren't bound to a specific diversifier: this may be because method_setImplementation cannot know the diversifier in advance. This is why the implementation pointer is signed using the diversifier zero.

The following bit of assembly comes from initializeNSConditionSwizzling:

__text:000000000008E020 D5 14 00 94                 BL              _class_getInstanceMethod
__text:000000000008E024 F0 FF FF F0                 ADRL            X16, __replacement_NSCondition_waitUntilDate
__text:000000000008E024 10 32 31 91
__text:000000000008E02C F0 23 C1 DA                 PACIZA          X16
__text:000000000008E030 E1 03 10 AA                 MOV             X1, X16 ; imp
__text:000000000008E034 68 15 00 94                 BL              _method_setImplementation

NOTE: regarding initializeSwizzlers, the pointers stored in replacement arrays are signed by Dyld when libRPAC.dylib is loaded.

A quick reverse engineering work of method_setImplementation confirms it: method_setImplementation calls its overloading method method_setImplementation(objc_class *,method_t *,void (*)(void)), which calls method_t::setImp(void (*)(void)) that authenticates the pointers:

libobjc.A:__text:000000018013B2CC             loc_18013B2CC                           ; CODE XREF: method_t::setImp(void (*)(void))+30↑j
libobjc.A:__text:000000018013B2CC F0 03 01 AA                 MOV             X16, X1 ; X1 is the IMP
libobjc.A:__text:000000018013B2D0 F0 33 C1 DA                 AUTIZA          X16
libobjc.A:__text:000000018013B2D4 F1 03 10 AA                 MOV             X17, X16
libobjc.A:__text:000000018013B2D8 F1 43 C1 DA                 XPACI           X17

method_setImplementation returns the implementation that was previously assigned to the method.

Like for the input pointer, the output one can't be bound to a diversifier since the Objective-C Runtime doesn't know in advance when the pointer will be used.


Let's review what we've seen so far:

  • libRPAC.dylib, under certain conditions, replaces the implementation of specific Objective-C methods by custom ones.
  • The original implementations are saved to an array. Each index of the array corresponds to a specific pair of (class, method).
  • Pointers to implementations are signed using the key A and the diversifier zero (to be used with BLRAAZ or BRAAZ).
  • Additionally to these Objective-C specific mechanisms (swizzling), libRPAC.dylib has interposers, which acts like the swizzlers for C functions.

Well, that's gotten us exactly nowhere!

Only because we've omitted to check something.

The bug

In the final section, we cover the bug that, we believe, led to CVE-2025-31201.

TL;DR: the bug consists of two mistakes: the wrong location of static variables within the Mach-O file, in combination with a substitution attack.

Page permissions

As stated in the previous section about swizzlers, once an implementation is replaced by a new one, the old one is stored in some place given by an array:

// `previousClassInstanceMethodImplementations` is an array of pointers to function pointers.
prevMethRef = previousClassInstanceMethodImplementations[i_instanceMethodPairs];

// We write the signed address of the old implementation to the memory pointed by `prevMethRef`.
*prevMethRef = method_setImplementation(meth_1, newClassInstanceMethodImplementations[i_instanceMethodPairs]);

The original C source code may look like the following:

#define N 0x8

// Ends up in __DATA.__common.
static IMP oldImpls[N] = {0};

// Ends up in __DATA.__const.
static const IMP *TableToOldImpls[N] = {
    &oldImpls[0],
    &oldImpls[1],
    ,
    &oldImpls[N - 1],
};

static void set_old_impl(const size_t index, const IMP old) {
    *TableToOldImpls[index] = old;
}

The previousClassInstanceMethodImplementations array is stored in the __DATA.__const section of the Mach-O file.

However, the memory ranges pointed by the elements of previousClassInstanceMethodImplementations are located in the __common section of the __DATA segment.

This makes sense to us, because the values held by these elements are unitialized until the swizzlers are run. Therefore, they belong to the unitialized section (whose physical length is 0):

target modules dump sections libRPAC.dylib
Sections for '/usr/lib/libRPAC.dylib' (arm64e):
  SectID             Type                   Load Address                             Perm File Off.  File Size  Flags      Section Name
  ------------------ ---------------------- ---------------------------------------  ---- ---------- ---------- ---------- ----------------------------
  0x0000000000000016 data                   [0x0000000100310088-0x0000000100310850)  rw-  0x000a0088 0x000007c8 0x00000000 libRPAC.dylib.__DATA.__data
  0x0000000000000017 zero-fill              [0x0000000100310850-0x0000000100390938)  rw-  0x00000000 0x00000000 0x00000001 libRPAC.dylib.__DATA.__common
  0x0000000000000018 zero-fill              [0x0000000100390938-0x0000000100910f68)  rw-  0x00000000 0x00000000 0x00000001 libRPAC.dylib.__DATA.__bss

The __interpose section

It's also worth mentioning that we're constantly talking about these swizzling shenanigans, forgetting our interposers living in the __interpose section of the Mach-O file.

When Dyld loads the __interpose section, it signs all the function pointers with the key A and the diversifier zero:

result->targetAddressForDlsym   = interpose(state, result->targetAddressForDlsym);
#if __has_feature(ptrauth_calls)
if ( result->isCode )
    result->targetAddressForDlsym = (uintptr_t)__builtin_ptrauth_sign_unauthenticated((void*)result->targetAddressForDlsym, ptrauth_key_asia, 0);
#endif
#endif

(dyld source code)

The interpose section is then locked and becomes read-only:

target modules dump sections libRPAC.dylib
Sections for '/usr/lib/libRPAC.dylib' (arm64e):
  SectID             Type                   Load Address                             Perm File Off.  File Size  Flags      Section Name
  ------------------ ---------------------- ---------------------------------------  ---- ---------- ---------- ---------- ----------------------------
  0x0000000000000014 regular                [0x000000010030c000-0x000000010030c090)  r--  0x0009c000 0x00000090 0x00000000 libRPAC.dylib.__AUTH_CONST.__interpose

In the end, this section contains signed pointers to be used with BLRAAZ or BRAAZ.

Diversifier 0

As mentioned in the section about IMP and PAC, the pointers given back by method_setImplementation are signed using the diversifier 0.

As mentioned in the previous section as well, pointers from the __interpose section are also signed using the diversifier 0.

The substitution attack

To wrap everything up, we now have the following:

  • Pointers from the __interpose section are signed using the diversifier 0. They are read-only.
  • Pointers to the original implementations of some Objective-C objects being swizzled are signed using the diversifier 0.
  • The latter are stored in an array located in the __DATA.__common section. They are writable.

Therefore, someone with arbitrary read and arbitrary write primitives can substitute a pointer from the array of the beforehand swizzled implementations with a pointer from the __interpose section.

For instance, we can substitute the pointer to the original implementation of -[NSData initWithContentsOfFile:options:error], which is signed using the diversifier 0, with the pointer to dlsym that lies in the __interpose section, which is also signed using the diversifier 0.

We emphasize the use of the word substitution because this ill design is better known as the substitution attack, which is documented in the Swift's fork of LLVM here:

An attacker can simply overwrite a pointer intended for one purpose with a pointer intended for another purpose if both purposes use the same signing schema and that schema does not use address diversity.

In this situation, a non-zero diversifier should have been used, like they do for the GOT.

Conclusion and further questions

Assuming someone has read and write primitives, and assuming that libRPAC.dylib is loaded, they can run this substitution attack we described here to call dlsym in place of for instance -[NSData initWithContentsOfFile:options:error:].

When the attacker calls -[NSData initWithContentsOfFile:options:error:] it will actually call dlsym which is a PAC bypass as the attacker would now be able to craft signed pointers with diversifier zero to exported symbols from any library.

A few questions are left unanswered though.

How is libRPAC.dylib loaded?

We said in the reverse engineering of libRPAC.dylib section that libRPAC.dylib is a debugging tool that gets loaded by XCode when developers are working on their apps. In the misc section of this article, we show how XCode loads it.

If we genuinely grep libRPAC.dylib in the IPSW, we will find no results. However, if one has a primitive that ends up calling dlopen with an arbitrary shared library, then they can load libRPAC.dylib using that primitive to enable the bug.

How do we call the previously substituted selector?

Being able to call an Objective-C selector would mean that we already have some code execution capability.

However, tricks exist that allow you to call Objective-C methods but not C functions. We can for example think of the NSExpression interface.

CodeColorist/Zhi wrote a great article about it back in 2021.

Misc

XCode loading libRPAC

There is no Mach-O file in iOS that is directly linked to libRPAC.dylib. Even more, we could not find a reference to libRPAC.dylib in the entire IPSW, except for the file itself.

Since libRPAC.dylib is a XCode plugin, we reverse engineered XCode.

ActivityTracePlugin

ActivityTracePlugin is responsible for injecting the library so that the iOS application being debugged benefits from it.

ActivityTracePlugin lives in $XCODE/Contents/Applications/Instruments.app/Contents/PlugIns/ActivityTracePlugin.xrplugin/Contents/MacOS/.

-[XRActivityTraceTapAgent configureTargetForLaunch:] iterates over some kind of project configurations key:

propsRefs = &properties;
v16 = 0x2020000000LL;
v17 = 0;
block[0] = _NSConcreteStackBlock;
block[1] = 3254779904LL;
block[2] = enumerateTablesToPopulateCallback;
block[3] = &unk_C328;
block[4] = &propsRefs;
[self enumerateTablesToPopulate:block];

The block invoke enumerateTablesToPopulateCallback checks if the name of some property equals os-log, and if so, it checks for attribute named enable-priority-inversion-detection.

If enable-priority-inversion-detection is enabled, props+0x24 is set to true, and -[XRActivityTraceTapAgent configureTargetForLaunch:] injects libRPAC.dylib into the process using addInjectableLibraryAtPath::

if ( *((_BYTE *)propsRef + 24) == 1 )
  {
    v5 = objc_retainAutoreleasedReturnValue(-[XRActivityTraceTapAgent command](self, "command"));
    v6 = objc_retainAutoreleasedReturnValue(objc_msgSend(v5, "target"));
    objc_msgSend(v6, "addInjectableLibraryAtPath:", CFSTR("/usr/lib/libRPAC.dylib"));
    objc_release(v6);
    objc_release(v5);

We now have understood how to legitimately use libRPAC.dylib.

The fix

Wondering how Apple patched this bug? While the substitution attack remains, they have removed the dlsym interposer from libRPAC.dylib, resulting in the removal of dlsym from the imports of the library.

References

shasum -a 384 idbs/18.4/libRPAC.dylib idbs/18.4.1/libRPAC.dylib
86d9b9747dbd9012d03c2fbb449caa07cc6fa527c598e34ec589731809904d0ba1da11d79c114d8ab2ed3e8665004755  idbs/18.4/libRPAC.dylib
022f3974c305dc0e0c8f471fed3db2e8d4b831041f735a5e2c343ee8287147c3204adba12a1135ad12b27174a02bcded  idbs/18.4.1/libRPAC.dylib

links

social