This is part 1 of the DarkSword’s PAC bypass vulnerablity analysis series, it will focus primarily on statically analysing the DarkSword’s PAC bypass mechanism and to figure out how it was achieved instead of going through everything in the exploit chain. In this part, we will go over Apple’s patch for CVE-2026-20700 via dyld’s open-source repository.
Before we start, it is important to know what Pointer Authentication Code (PAC) is, how it helps prevent exploitation of memory corruption bugs that attempts to leverage Jump Oriented Programming (JOP chain) and Return Oriented Programming (ROP chain) to execute arbitrary code and how this mechanism can be bypassed.
What is Pointer Authentication Code?
Introduction
Pointer Authentication (PA) is a security mechanism designed to protect software from control-flow hijacking attacks by cryptographically signing sensitive pointers. When a pointer is signed, a Pointer Authentication Code (PAC) is generated using the pointer value together with additional context information (pepper or salt) and a secret key stored in hardware. The resulting PAC is embedded into unused bits of the pointer itself.
Before the pointer can be used, it must be authenticated. During authentication, the processor recomputes the PAC and verifies that it matches the value stored in the pointer. If the verification fails, the pointer is considered invalid, and its use will typically result in an exception or program crash. This prevents attackers from arbitrarily modifying protected pointers and redirecting program execution.
Pointer Authentication provides strong probabilistic protection against a wide range of memory-corruption vulnerabilities, particularly those that aim to manipulate control-flow data such as return addresses, function pointers, virtual table (vtable) pointers, and exception-handling structures. As a result, it significantly raises the difficulty of launching Return-Oriented Programming (ROP), Jump-Oriented Programming (JOP), and similar code-reuse attacks.
Although PAC can be implemented purely in software, modern ARM processors provide dedicated hardware support through the ARMv8.3-A architecture and later extensions. ARM’s implementation introduces a set of specialized instructions for generating, authenticating, and stripping PAC values, while maintaining minimal performance overhead. This hardware-assisted approach has been widely adopted in security-sensitive platforms, including Apple’s iOS, macOS, and ARM-based Linux systems, making Pointer Authentication an important defense-in-depth technology in modern computing environments.
Pointer Authentication Code in XNU
Apple was the first major vendor to deploy Pointer Authentication (PAC) at scale, introducing hardware support in the A12 Bionic (2018) and later extending it across the Apple Silicon product line. Within Apple’s ecosystem, PAC is deeply integrated into the arm64e ABI, the XNU kernel, compiler toolchains, and many user-space frameworks. Rather than being an optional mitigation, PAC forms a fundamental component of Apple’s control-flow integrity strategy.
Apple Silicon implements ARMv8.3-A Pointer Authentication through the arm64e architecture. When compiled for arm64e, LLVM and Clang automatically emit PAC instructions to sign and authenticate sensitive pointers, including:
- Return addresses
- Function pointers
- C++ virtual function table (vtable) pointers
- Objective-C method dispatch structures
- Selected kernel control-flow structures
This process is largely transparent to developers, as pointer signing and authentication are inserted automatically by the compiler and runtime.
Apple CPUs maintain several hardware-managed PAC keys, including:
- APIAKey – Instruction Pointer Key A
- APIBKey – Instruction Pointer Key B
- APDAKey – Data Pointer Key A
- APDBKey – Data Pointer Key B
- APGAKey – Generic Authentication Key
These keys are stored in dedicated CPU registers and are never exposed directly to software, making PAC forgery significantly more difficult even when arbitrary memory read/write primitives are available.
PAC Storage in 64-bit Pointers
PAC does not require additional memory. Instead, the cryptographic signature is embedded into otherwise unused upper bits of a 64-bit pointer.
On AArch64 systems, virtual addresses typically occupy only 39–48 bits of the 64-bit address space. The remaining high-order bits are available for metadata. Apple stores the PAC value within these unused bits while preserving the actual virtual address.
63 56 55 0
+----------------+--------------------------+
| PAC bits | Virtual Address |
+----------------+--------------------------+
The exact number of PAC bits is not fixed and depends on the virtual address size and processor configuration. In practice, only a relatively small authentication code (roughly a dozen to a few dozen bits) is available, which is why PAC should be considered a probabilistic integrity protection mechanism rather than a cryptographic guarantee.
When authentication fails, the processor invalidates the pointer by modifying high-order bits, causing a fault when the pointer is dereferenced.
How PAC can be circumvented
While Pointer Authentication significantly strengthens control-flow integrity, it is not a complete defense against memory-corruption vulnerabilities. PAC is designed to make pointer forgery difficult, not impossible. Researchers have demonstrated several practical techniques that can reduce or bypass its protection under certain conditions
Pointer reuse attack
PAC verifies that a pointer was legitimately signed, but it does not prevent an attacker from reusing an already-signed pointer copied from another memory location if the same key and modifier context are accepted. As a result, some attacks focus on reusing valid PAC-signed pointers rather than forging new ones.
Information leak
If an attacker can disclose sensitive memory contents, they may obtain authenticated pointers and use them as building blocks for further exploitation. Although the secret PAC keys remain protected by hardware, leaked signed pointers can still assist in constructing advanced attacks.
PAC Brute Force
The PAC value occupies only a limited number of spare bits in a pointer. Consequently, PAC provides probabilistic rather than absolute protection. In theory, an attacker may attempt repeated guesses until a valid PAC is found. In practice, crashes, rate limits, and the limited opportunity to retry usually make brute force infeasible on modern systems.
Logic and Data-Oriented Attacks
PAC primarily protects control-flow pointers. It does not prevent attacks that manipulate non-control data, alter program logic, or abuse legitimate application behavior without modifying authenticated pointers. Consequently, Data-Oriented Programming (DOP) style attacks may remain possible even when PAC is enabled.
Microarchitectural Attacks
Researchers demonstrated PACMAN, a hardware side-channel technique that combines speculative execution with PAC validation behavior to infer correct PAC values without directly knowing the secret keys. Although PACMAN requires additional exploitation primitives and specific hardware conditions, it illustrates that PAC should be viewed as one layer in a defense-in-depth strategy rather than a standalone security solution.
What is DarkSword?
DarkSword is a sophisticated iOS full-chain exploit kit discovered by Google’s Threat Intelligence Group. It targeted iPhones running affected iOS versions and chained multiple vulnerabilities together to progress from initial remote code execution to full device compromise. The chain was reportedly used by multiple surveillance vendors and state-sponsored actors.
The exploit contains a PAC Bypass vulnerability, classified as CVE-2026-20700, this CVE was patched on iOS 26.3, iPadOS 26.3 and macOS 26.3 (About the security content of iOS 26.3 and iPadOS 26.3 – Apple Support).
Patch diffing
Patch diff dyld for iOS 26.2.1 vs iOS 26.3:
-1335.0.0.0.0
- __TEXT.__text: 0x8af68
- __TEXT.__const: 0x23a0
- __TEXT.__cstring: 0xfe42
+1340.1.0.0.0
+ __TEXT.__text: 0x8b740
+ __TEXT.__const: 0x23e0
+ __TEXT.__cstring: 0xfe44
__TEXT.__unwind_info: 0x4f8
__DATA_CONST.__auth_ptr: 0x90
- __DATA_CONST.__const: 0x6e40
+ __DATA_CONST.__const: 0x6e80
__DATA.__data: 0x2f0
__DATA.__crash_info: 0x148
__DATA.__common: 0x8f0
__DATA_DIRTY.__common: 0x1120
__TPRO_CONST.__data: 0x71
__TPRO_CONST.__allocator: 0x20000
- UUID: DBBF9813-22CC-39FC-8D40-581143766FF6
- Functions: 2886
- Symbols: 7288
+ UUID: 7631B298-C24E-3532-B625-00A1AD73C1FD
+ Functions: 2893
+ Symbols: 7306
CStrings: 0
Symbols:
+ __ZN3lsl6VectorINSt3__14pairIPKN5dyld46LoaderEPKcEEEC2IPS9_EET_SD_RNS_9AllocatorE
+ __ZN3lsl9Allocator10makeUniqueINS_6VectorIPKN5dyld46LoaderEEEJPS6_S8_RS0_EEENS_9UniquePtrIT_EEDpOT0_
+ __ZN3lsl9UniquePtrINS_6VectorINSt3__14pairIPKN5dyld46LoaderEPKcEEEEED2Ev
+ __ZN3lsl9UniquePtrINS_6VectorIPKN5dyld46LoaderEEEED2Ev
+ __ZNK5dyld46Loader23runInitializersBottomUpERNS_12RuntimeStateERN3lsl6VectorIPKS0_EES8_
+ __ZZN5dyld412RuntimeLocks37withLoadersWriteLockAndProtectedStackIZZNS_4APIs11dlopen_fromEPKciPvENK3$_0clEvEUlvE0_EEvT_ENKUlvE_clEv
+ __ZZN5dyld412RuntimeLocks37withLoadersWriteLockAndProtectedStackIZZNS_4APIs11dlopen_fromEPKciPvENK3$_0clEvEUlvE0_EEvT_ENKUlvE_clEv.cold.1
+ ____ZN3lsl13MemoryManager18withProtectedStackIZZN5dyld412RuntimeLocks37withLoadersWriteLockAndProtectedStackIZZNS2_4APIs11dlopen_fromEPKciPvENK3$_0clEvEUlvE0_EEvT_ENKUlvE_clEvEUlvE_EEvSB__block_invoke
+ ____ZN3lsl13MemoryManager26withWritableMemoryInternalIZN5dyld412RuntimeLocks37withLoadersWriteLockAndProtectedStackIZZNS2_4APIs11dlopen_fromEPKciPvENK3$_0clEvEUlvE0_EEvT_EUlvE_EEvSB__block_invoke
+ ___block_descriptor_tmp.224
+ ___block_descriptor_tmp.241
+ ___block_descriptor_tmp.256
- __ZNK5dyld46Loader23runInitializersBottomUpERNS_12RuntimeStateERN5dyld35ArrayIPKS0_EES8_
- ___block_descriptor_tmp.225
- ___block_descriptor_tmp.227
- ___block_descriptor_tmp.243
- ___block_descriptor_tmp.249
Source code analysis (dyld-1335 vs dyld-1340)
Previously, MemoryManager::withWritableMemory([&] {}) in dlopen_from() allocates 2 vectors of newlyNotDelayed and pseudoDylibSymbolsToMaterialize using the STACK_ALLOC_VECTOR() macro, which places them on the stack before stepping into locks.withLoadersWriteLockAndProtectedStack([&] {}) which does the actual dylib loading and binding process before returning.
MemoryManager::withWritableMemory([&] {
STACK_ALLOC_VECTOR(const Loader*, newlyNotDelayed, 128);
STACK_ALLOC_VECTOR(Loader::PseudoDylibSymbolToMaterialize, pseudoDylibSymbolsToMaterialize, 8);
locks.withLoadersWriteLockAndProtectedStack([&] {
/// dylib loading and binding
...
})
})
In the patched version, this process has been iterated on by first creating 2 smart pointers that hold the address of newlyNotDelayedResult and pseudoDylibSymbolsToMaterializeResult. Then, without initialising the actual newlyNotDelayed and pseudoDylibSymbolsToMaterialize vectors on the stack, it enters the locks.withLoadersWriteLockAndProtectedStack lambda block. This lambda block now function as usual, until it have finished identifying the load mode for the dylib:
MemoryManager::withWritableMemory([&] {
// Put these on the persistent allocator as we can't keep them on the regular stack
typedef Vector<const Loader*> LoaderVector;
typedef Vector<Loader::PseudoDylibSymbolToMaterialize> PseudoDylibSymbolsVector;
UniquePtr<LoaderVector> newlyNotDelayedResult;
UniquePtr<PseudoDylibSymbolsVector> pseudoDylibSymbolsToMaterializeResult;
locks.withLoadersWriteLockAndProtectedStack([&] {
/// dylib loading and binding
...
STACK_ALLOC_VECTOR(const Loader*, newlyNotDelayed, 128);
STACK_ALLOC_VECTOR(Loader::PseudoDylibSymbolToMaterialize, pseudoDylibSymbolsToMaterialize, 8);
// load all dependents
...
})
})
Once the dylib dependents are loaded and before the exiting the locks.withLoadersWriteLockAndProtectedStack lambda block, the patched version then copies the two temporary vectors to the persistent allocator by using persistentAllocator.makeUnique<T> for newlyNotDelayed and pseudoDylibSymbolsToMaterialize. The resulting address of which is stored in newlyNotDelayedResult and pseudoDylibSymbolsToMaterializeResult, created prior to entering the lambda block.
MemoryManager::withWritableMemory([&] {
// Put these on the persistent allocator as we can't keep them on the regular stack
typedef Vector<const Loader*> LoaderVector;
typedef Vector<Loader::PseudoDylibSymbolToMaterialize> PseudoDylibSymbolsVector;
UniquePtr<LoaderVector> newlyNotDelayedResult;
UniquePtr<PseudoDylibSymbolsVector> pseudoDylibSymbolsToMaterializeResult;
locks.withLoadersWriteLockAndProtectedStack([&] {
/// dylib loading and binding
...
STACK_ALLOC_VECTOR(const Loader*, newlyNotDelayed, 128);
STACK_ALLOC_VECTOR(Loader::PseudoDylibSymbolToMaterialize, pseudoDylibSymbolsToMaterialize, 8);
// load all dependents
...
// Copy the temporary vectors from the protected stack to the persistent allocator for safety
if ( !newlyNotDelayed.empty() ) {
newlyNotDelayedResult = persistentAllocator.makeUnique<LoaderVector>(newlyNotDelayed.begin(), newlyNotDelayed.end(),
persistentAllocator);
}
if ( !pseudoDylibSymbolsToMaterialize.empty() ) {
pseudoDylibSymbolsToMaterializeResult = persistentAllocator.makeUnique<PseudoDylibSymbolsVector>(pseudoDylibSymbolsToMaterialize.begin(),
pseudoDylibSymbolsToMaterialize.end(),
persistentAllocator);
}
});
});
Finally, the MemoryManager uses newlyNotDelayedResult and pseudoDylibSymbolsToMaterializeResult to perform the initialisations. Additionally, before exiting the MemoryManager::withWritableMemory lambda block, the data on the persistent allocator is cleared inside another locks.withLoadersWriteLockAndProtectedStack lambda block.
// Clear the data on the persistent allocator. We do this with a lock for the allocator
if ( newlyNotDelayedResult || pseudoDylibSymbolsToMaterializeResult ) {
locks.withLoadersWriteLockAndProtectedStack([&] {
if ( newlyNotDelayedResult )
newlyNotDelayedResult.release();
if ( pseudoDylibSymbolsToMaterializeResult )
pseudoDylibSymbolsToMaterializeResult.release();
});
}
Early Observation
The potential issue here is that both newlyNotDelayedand pseudoDylibSymbolsToMaterialize are created on the regular stack before dyld enters the protected stack, whilst their contents are modified via the through references captured by the lambda.
This means whilst the context is switched to the protected stack, the vectors are still live in the suspended regular stack frame, which creates a TOCTOU window where another thread with arbitrary R/W access finding and corrupting the live vectors whilst dyld is still using them.
Once dyld returns from the protected stack, the now corrupted vectors would go through notifyLoad() or other paths using the malformed data as if they were legitimate.dyld-1340 commit eliminates this window by creating moving the vector creation inside the protected stack, and copying the populated vectors into the persistent allocator before returning to the regular stack.
Outside the obvious malformed or invalid memory dereferencing, I fail to see how the impact this bug would have one exploited. Apple’s patch notes did mention that this exploit was actively exploited in the wild, so there has to be more to this.
In part 2, we will go over how DarkSword leverages this TOCTOU window to pivot a Vector<T> object on the suspended stack frame and take over symbol interposing.
References
- https://cloud.google.com/blogs/topics/threat-intelligence/darksword-ios-exploit-chain
- https://iverify.io/blogs/darksword-ios-exploit-kit-explained
- https://www.lookout.com/threat-intelligence/article/darksword
- https://karol-mazurek.medium.com/list/dyld-do-you-like-death-2a6bcc0d8827