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
:

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.
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
- Apple security bulletin: About the security content of iOS 18.4.1 and iPadOS 18.4.1.
dyld_info
: Apple OSS Distributions, GitHub.
- Objective-C Runtime: Apple documentation.
- pthreads interface: man.
- SLOP: Google Project Zero.
- Google Project Zero: Blog.
ipsw
, iOS/macOS Research Swiss Army Knife: GitHub, Documentation.
dlopen
: man.
dlsym
: man.
- Pointer Authentication Code, Qualcomm: PDF.
- Diaphora, the most advanced Free and Open Source program diffing tool: GitHub.
ipsw-diffs
, IPSW diffs: GitHub.
ipsw-diffs
, 22E240
— 22E252
: GitHub.
__interpose
, Code Injection with Dyld Interposing: Geek Culture, by Noah Martin.
- Crash report #1: discussions.apple.com.
- Thread Performance Checker: Apple.
- Library initialization routines: GNU GCC.
- Reference to
__library_initializer
: LLVM/compiler-rt.
objc_getClass
: Apple documentation.
class_getInstanceMethod
: Apple documentation.
method_getImplementation
: Apple documentation.
method_setImplementation
: Apple documentation.
- Swizzlers: Method Swizzling in iOS development, by Aruna Kumari Yarra.
- Monkey patching: Wikipedia.
NSCondition
: Apple documentation.
PACIZA
: arm developer documentation.
-arm64e_preview_abi
: Apple documentation.
NSExpression
: Apple documentation.
- See No Eval: blogpost by codecolorist.
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