iOS 18.4 - dlsym considered harmful
Last week, Apple released iOS 18.4 on all supported iPhones. On devices supporting PAC (pointer authentication), we came across a strange bug during some symbols resolution using dlsym(). This blogpost details our observations and the root cause of the problem.
Looking to improve your skills? Discover our trainings sessions! Learn more.
Observations
We first observed the bug in a custom iOS application compiled for the arm64e architecture (thus supporting PAC instructions). This application makes use of dynamic symbol resolution for various system functions, by using both dlopen() and dlsym().
- dlopen() takes a shared library path as argument and returns a handle which can be used by other functions of the dl API;
- dlsym() takes a handle and a symbol name as arguments, and returns the corresponding address. On devices supporting PAC, this address is signed with the instruction key A and a NULL context, so it can be used for indirect calls in C (as these calls use the BLRAAZ PAC instruction).
As a first example, the following code dynamically loads the strcpy function address, then uses it as a function pointer:
void *handle = dlopen("/usr/lib/system/libsystem_c.dylib", RTLD_NOW);
syslog(LOG_USER, "handle libsystem_c.dylib %p\n", handle);
char *(*strcpy_ptr)(char *, char *) = (char *(*)(char *, char *))dlsym(handle, "strcpy");
syslog(LOG_USER, "strcpy %p\n", (void *)strcpy_ptr);
strcpy_ptr(my_str, "Hello world");
syslog(LOG_USER, "%s\n", my_str);
The logs show the opaque handle, the signed pointer, and the result of the dynamic call:
<Notice>: handle libsystem_c.dylib 0x24ae83508d0b60
<Notice>: strcpy 0x212da2022c4a43f8
<Notice>: Hello world
And finally, the generated assembly is exactly as expected, with the return value of dlsym() used as BLRAAZ destination.

Nothing surprising here... you talked about a bug, where is the bug?! Well, the bug only triggers for some specific functions. Let's try a new example!
This time, we will dynamically resolve and use strcmp().
void *handle = dlopen("/usr/lib/system/libsystem_c.dylib", RTLD_NOW);
int (*strcmp_ptr)(char *, char *) = (int (*)(char *, char *))dlsym(handle, "strcmp");
syslog(LOG_USER, "strcmp %p\n", (void *)strcmp_ptr);
int res = strcmp_ptr("Hello friend", "Hello world");
syslog(LOG_USER, "strcmp returned %d\n", res);
Instead of a nice log indicating that the two strings are not the same, our application crashed...
Apr 3 08:47:42 App[1219] <Notice>: strcmp 0xdab738822c4a2890
Apr 3 08:47:42 kernel[0] <Notice>: App[1219] Corpse allowed 1 of 5
Looking at the generated ips file, the reason is:
KERN_PROTECTION_FAILURE at 0x00f0ff822c4a2890 -> 0xffffff822c4a2890 (possible pointer authentication failure)
[...]
ESR 0x82000004 Description : (Instruction Abort) Translation fault at far 0xa8f0ff822c4a2890
PC: 0xffffff822c4a2890, LR: 0x102d70068, SP: 0x16d0e0ca0, FP: 0x16d0e0f90
The LR register points just after the BLRAAZ instruction. Looking at the assembly code, nothing weird happened between the dlsym() return and the indirect call:

What just happened? Why is the pointer incorrectly signed? Why do I have a kernel pointer in PC?
Some PAC experiments
Let's see if the bug can be reproduced by running our app a dozen times.
<Notice>: strcmp 0x239044022c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5
<Notice>: strcmp 0x9db91d022c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5
<Notice>: strcmp 0x8919f822c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5
<Notice>: strcmp 0x4bb462822c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5
<Notice>: strcmp 0xe1d732022c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5
<Notice>: strcmp 0x22c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5
<Notice>: strcmp 0x1094dc022c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5
<Notice>: strcmp 0x22c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5
<Notice>: strcmp 0x22c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5
<Notice>: strcmp 0x22c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5
<Notice>: strcmp 0x8d945a822c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5
<Notice>: strcmp 0x22c4a2890
<Notice>: App[1219] Corpse allowed 1 of 5
Things are getting even weirder... the pointer is either unsigned or signed with an invalid signature!
Let's try to manually strip the signature and re-sign the pointer... It might just be a single bit/byte corruption.
<Notice>: strcmp 0xed71e822c4a2890
<Notice>: strcmp re-signed 0x905ffa022c4a2890
<Notice>: strcmp 0x22c4a2890
<Notice>: strcmp re-signed 0x1b1834022c4a2890
<Notice>: strcmp 0x5ef5c822c4a2890
<Notice>: strcmp re-signed 0xce00a9822c4a2890
When the pointer has an invalid signature, the newly computed signature is completely different, and if we try to call the newly signed pointer, everything works as expected.
Investigating the bug
Playing with PAC didn't help us understand the bug. Our next step is to have a look at the specific strcmp export to understand why it behaves differently than other exports, and dig in dlsym() implementation in iOS.
First, we extract the libsystem_c.dylib dylib from the shared cache, using the mighty ipsw tool:
$ ipsw dyld extract dyld_shared_cache_arm64e libsystem_c.dylib
• Created libsystem_c.dylib
To ensure we fully understand what is happening, we wrote a minimal LC_DYLD_EXPORTS_TRIE parser and executed it on libsystem_c.dylib:
$ python exports_trie.py libsystem_c.dylib | grep strcmp -C3
TRIE _strcat f38c
TRIE _strchr EXPORT_SYMBOL_FLAGS_REEXPORT (__platform_strchr) 6
TRIE _strchrnul 5ed3c
TRIE _strcmp EXPORT_SYMBOL_FLAGS_REEXPORT (__platform_strcmp) 6
TRIE _strcoll a0d4
TRIE _strcoll_l 9fc8
TRIE _strcpy EXPORT_SYMBOL_FLAGS_REEXPORT (__platform_strcpy) 6
Interesting! _strcmp has a specific flag, EXPORT_SYMBOL_FLAGS_REEXPORT associated with the __platform_strcmp string, meaning that the dylib re-exports the symbol as __platform_strcmp, which is imported from libsystem_platform.dylib. However, the same applies to strcpy, which does not trigger the bug...
We need to go deeper!
Let's run the same parser on the libsystem_platform.dylib library:
$ python exp.py libsystem_platform.dylib | grep strcmp -C3
TRIE __platform_memset_pattern4 3640
TRIE __platform_memset_pattern8 3660
TRIE __platform_strchr 1940
TRIE __platform_strcmp EXPORT_SYMBOL_FLAGS_STUB_AND_RESOLVER (resolver @ 3890) 6860
TRIE __platform_strcpy 23f8
TRIE __platform_strlcat 1e34
TRIE __platform_strlcpy 2380
Even more interesting! This time, only __platform_strcmp has a specific flag, EXPORT_SYMBOL_FLAGS_STUB_AND_RESOLVER. This flag indicates a lazy resolution of the real implementation when it is executed for the first time. In details, the exported __platform_strcmp jumps on a function pointer (__platform_strcmp_ptr) located in the __la_resolver section of the library, which first points to a function looking for the real implementation, then replaces the pointer so that subsequent calls will directly jump to the real implementation.

Now let's see how dyld handles such exports when calling dlsym().
dlsym() implementation is a simple wrapper to APIs::dlsym(), which will call Loader::hasExportedSymbol() with Loader::runResolver as its 5th argument. If the symbol does not have the EXPORT_SYMBOL_FLAGS_REEXPORT flag, the following code is executed:
if ( diag.hasError() )
return false;
bool isAbsoluteSymbol = ((flags & EXPORT_SYMBOL_FLAGS_KIND_MASK) == EXPORT_SYMBOL_FLAGS_KIND_ABSOLUTE);
uintptr_t targetRuntimeOffset = (uintptr_t)MachOLoaded::read_uleb128(diag, p, trieEnd);
bool isResolver = (flags & EXPORT_SYMBOL_FLAGS_STUB_AND_RESOLVER);
if ( isResolver && (resolverMode == runResolver) ) { // [1]
uintptr_t resolverFuncRuntimeOffset = (uintptr_t)MachOLoaded::read_uleb128(diag, p, trieEnd); // [2]
const uint8_t* dylibLoadAddress = (const uint8_t*)this->loadAddress(state);
typedef void* (*ResolverFunc)(void);
ResolverFunc resolver = (ResolverFunc)(dylibLoadAddress + resolverFuncRuntimeOffset); // [3]
#if __has_feature(ptrauth_calls)
resolver = __builtin_ptrauth_sign_unauthenticated(resolver, ptrauth_key_asia, 0);
#endif
const void* resolverResult = (*resolver)(); // [4]
#if __has_feature(ptrauth_calls)
resolverResult = __builtin_ptrauth_strip(resolverResult, ptrauth_key_asia);
#endif
targetRuntimeOffset = (uintptr_t)resolverResult - (uintptr_t)dylibLoadAddress; // [5]
}
result->targetLoader = this;
result->targetSymbolName = symbolName;
result->targetRuntimeOffset = targetRuntimeOffset;
result->kind = isAbsoluteSymbol ? ResolvedSymbol::Kind::bindAbsolute : ResolvedSymbol::Kind::bindToImage;
result->isCode = this->mf(state)->inCodeSection((uint32_t)(result->targetRuntimeOffset));
result->targetAddressForDlsym = resolvedAddress(state, *result); // [6]
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); // [7]
#endif
result->isWeakDef = (flags & EXPORT_SYMBOL_FLAGS_WEAK_DEFINITION);
result->isMissingFlatLazy = false;
result->isMaterializing = false;
return true;
If the symbol has EXPORT_SYMBOL_FLAGS_STUB_AND_RESOLVER and the Loader::runResolver argument is passed ([1]), the resolver offset is retrieved ([2]), converted to an address ([3]), then signed and called ([4]). Next, the resulting symbol address is stripped from its signature and converted to an offset ([5]). Finally, the offset is converted to the final address ([6]) and signed ([7]) before being returned to APIS::dlsym(). Looking at the code alone, everything seems fine...
The result structure, which is filled before returning to the caller, is located in the APIs::dlsym() function stack frame. Therefore, by dumping the stack just after the dlsym() call and applying some pattern matching, we should be able to dump this structure and verify that everything is ok.
We modified our code and ran it again:
<Notice>: Candidate for structure result:
<Notice>: result->targetLoader 0x29834ae98
<Notice>: result->targetSymbolName 0x2215d6bd4
<Notice>: result->targetRuntimeOffset 9b4ec00000000890
<Notice>: result->targetAddressForDlsym 369d2c822c4a2890
<Notice>: result->isCode 1
<Notice>: strcmp 0x369d2c822c4a2890
<Notice>: strcmp re-signed 0x9b4ec0022c4a2890
There is definitely a problem:
- The targetRuntimeOffset has its upper bits set.
- Those bits are the same as the correct pointer signature!
It seems that the pointer returned by the resolver function has not been stripped before being converted as an offset!
Let's have a look at the compiled dyld code calling the resolver:

A XPACI instruction is clearly missing here, we can see the return value (X0) from the BLRAAZ being directly converted to an offset by the SUB instruction. In iOS 18.3.2, the XPACI instruction is present.
Remaining questions
We have identified the root cause of the bug, and we don't know (yet?) if Apple changed dyld source code or if the compiler has considered the strip was unnecessary.
A first question we can answer is: "Where does the bad signature come from?". From our analysis, it appears that the pointer is in fact signed twice! Let's try to reproduce the problem by running our app a few times again after adding a double signature:
<Notice>: strcmp 0xdffe55022c4a2890
<Notice>: strcmp re-signed 0xf156d8822c4a2890
<Notice>: strcmp re-signed twice 0xdffe55022c4a2890
<Notice>: strcmp 0x22c4a2890
<Notice>: strcmp re-signed 0x650330822c4a2890
<Notice>: strcmp re-signed twice 0x22c4a2890
<Notice>: strcmp 0x64ea2d022c4a2890
<Notice>: strcmp re-signed 0xda6077022c4a2890
<Notice>: strcmp re-signed twice 0x64ea2d022c4a2890
We can reproduce the bad signature!
To understand how the value is computed, some context is required: the device on which all the tests were done is an iPhone SE 3rd generation, which has a A15 SoC. Apple A15 supports the new PAC enhancements introduced by the Armv8.6-A architecture, one of them being EnhancedPAC2. In this case, the PAC signature no longer replaces the top bits of a pointer, but is XORed with them.
Here is the pseudo-code of the signature calculation, according to the Arm specification:
if HaveEnhancedPAC2() && ConstPACField() then selbit = ptr<55>;
integer bottom_PAC_bit = CalculateBottomPACBit(selbit);
// The pointer authentication code field takes all the available bits in between
extfield = Replicate(selbit, 64);
// Compute the pointer authentication code for a ptr with good extension bits
ext_ptr = extfield<(64-bottom_PAC_bit)-1:0>:ptr<bottom_PAC_bit-1:0>;
PAC = ComputePAC(ext_ptr, modifier, K<127:64>, K<63:0>);
result = (ptr<63:56> EOR PAC<63:56>):selbit:(ptr<54:bottom_PAC_bit> EOR PAC<54:bottom_PAC_bit>):ptr<bottom_PAC_bit-1:0>;
return result;
When signing a pointer, all its upper bits are set to 0 or 1 according to the selbit value.
When the pointer returned by dlsym() is not signed, it is because the second signature is XORed with the first one, thus cancelling it.
However, when a wrong signature is returned, the only possible reason is that the second signature has been done with all the upper bits set to 1 (as if the pointer was a kernel one). According to the specification, this could not happen, since selbit is bit 55, and it cannot be changed by the first signature... Repeated tests showed that in constrast to the specification, the pointer was considered as a kernel one (during a signature operation) if the bit 63 is set! Let's verify our theory by computing the signature of our pointer with all upper bits set to 1, then XORing it with the pointer to get the output of ComputePAC; we could then compare it with the XOR of the correctly signed pointer with the doubly signed one:
<Notice>: strcmp 0xef9b29022c4a2890
<Notice>: strcmp re-signed 0xf70977822c4a2890
<Notice>: (k)strcmp (0xffffff822c4a2890) signed 0xe7eda1022c4a2890
<Notice>: 0xffffff822c4a2890 XOR 0xe7eda1022c4a2890 -> 0x18125e8000000000
<Notice>: 0xef9b29022c4a2890 XOR 0xf70977822c4a2890 -> 0x18925e8000000000
And voilà! The ComputePAC result is identical (bit 55 changes however, as it is directly conserved from the pre-signature pointer), which validates the theory that the firstly signed pointer can be considered as a kernel one during the second signature.
One question remains: why don't all applications that import strcmp crash? Another look at the dyld sources shows that the loader always calls hasExportedSymbol with the Loader::skipResolver argument, thus not triggering the bug!
Conclusion
Finding the root cause of this bug was a long journey, but it allowed us to dig in the internals of the shared cache, dyld and the PAC implementation on recent iPhones. We hope Apple will fix it soon enough :)