# 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 %pn”, handle); char *(*strcpy_ptr)(char *, char *) = (char *(*)(char *, char *))dlsym(handle, “strcpy”); syslog(LOG_USER, “strcpy %pn”, (void *)strcpy_ptr); strcpy_ptr(my_str, “Hello world”); syslog(LOG_USER, “%sn”, my_str);
“`
The logs show the opaque handle, the signed pointer, and the result of the dynamic call:
“`
: handle libsystem_c.dylib 0x24ae83508d0b60 : strcpy 0x212da2022c4a43f8 : 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 %pn”, (void *)strcmp_ptr); int res = strcmp_ptr(“Hello friend”, “Hello world”); syslog(LOG_USER, “strcmp returned %dn”, 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] : strcmp 0xdab738822c4a2890 Apr 3 08:47:42 kernel[0] : 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.
“`
: strcmp 0x239044022c4a2890 : App[1219] Corpse allowed 1 of 5 : strcmp 0x9db91d022c4a2890 : App[1219] Corpse allowed 1 of 5 : strcmp 0x8919f822c4a2890 : App[1219] Corpse allowed 1 of 5 : strcmp 0x4bb462822c4a2890 : App[1219] Corpse allowed 1 of 5 : strcmp 0xe1d732022c4a2890 : App[1219] Corpse allowed 1 of 5 : strcmp 0x22c4a2890 : App[1219] Corpse allowed 1 of 5 : strcmp 0x1094dc022c4a2890 : App[1219] Corpse allowed 1 of 5 : strcmp 0x22c4a2890 : App[1219] Corpse allowed 1 of 5 : strcmp 0x22c4a2890 : App[1219] Corpse allowed 1 of 5 : strcmp 0x22c4a2890 : App[1219] Corpse allowed 1 of 5 : strcmp 0x8d945a822c4a2890 : App[1219] Corpse allowed 1 of 5 : strcmp 0x22c4a2890 : 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.
“`
: strcmp 0xed71e822c4a2890 : strcmp re-signed 0x905ffa022c4a2890 : strcmp 0x22c4a2890 : strcmp re-signed 0x1b1834022c4a2890 : strcmp 0x5ef5c822c4a2890 : 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:
“`
: Candidate for structure result: : result->targetLoader 0x29834ae98 : result->targetSymbolName 0x2215d6bd4 : result->targetRuntimeOffset 9b4ec00000000890 : result->targetAddressForDlsym 369d2c822c4a2890 : result->isCode 1 : strcmp 0x369d2c822c4a2890 : 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:
“`
: strcmp 0xdffe55022c4a2890 : strcmp re-signed 0xf156d8822c4a2890 : strcmp re-signed twice 0xdffe55022c4a2890 : strcmp 0x22c4a2890 : strcmp re-signed 0x650330822c4a2890 : strcmp re-signed twice 0x22c4a2890 : strcmp 0x64ea2d022c4a2890 : strcmp re-signed 0xda6077022c4a2890 : 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; 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:ptr