CVE-2026-20700

Understanding Apple’s dyld Zero‑Day State‑Management Bug (CVE‑2026‑20700)

TL;DR

Apple’s dyld-1335 keeps critical temporary loader state on the regular stack while inside a protected-stack region. An attacker with a memory-write primitive can corrupt that stack-resident state and steer dyld’s notifier paths, leading to memory corruption and potential RCE. dyld-1340 fixes this by moving those temporaries to the protected stack and copying them into the persistent allocator before use outside that scope. This matters for mobile devices because iOS relies on dyld for dynamic loading.

Introduction

dyld is Apple’s dynamic loader on most Apple devices. It is responsible for:

  • Loading Mach‑O binaries and their dependent dylibs at process start.
  • Resolving symbols and applying relocations/fixups.
  • Running initializer callbacks and notifying debuggers or system components about loaded images.

Apple uses dyld to centralize and optimize these tasks across all processes. It enforces platform security constraints (like pointer authentication and protected stacks), and it enables performance features such as the shared cache and fast symbol binding. Because dyld sits on every process startup and every dlopen(), its state‑management correctness is critical for security.

Apple’s iOS 26.3 security content entry for dyld lists the affected device families (iPhone 11+ and recent iPads), warns that a memory‑write primitive could lead to arbitrary code execution, and confirms exploitation against targeted individuals on versions before iOS 26. That same entry explicitly links CVE‑2025‑14174 and CVE‑2025‑43529 to the same report, which is why they often appear together in real‑world chains.

NVD’s record for CVE‑2026‑20700 lists it in CISA’s Known Exploited Vulnerabilities catalog, confirming in‑the‑wild use. In practice, CVE‑2026‑20700 is a second‑stage bug, it does not stand alone and typically needs to be chained with earlier vulnerabilities that provide a memory‑write primitive.

Affected And Patched Apple Device Versions

Apple lists CVE‑2026‑20700 as fixed in these platform releases (devices that can install these updates are patched, earlier versions on those devices are affected):

  • iOS 26.3 / iPadOS 26.3: iPhone 11 and later; iPad Pro 12.9‑inch (3rd gen+) and 11‑inch (1st gen+); iPad Air (3rd gen+); iPad (8th gen+); iPad mini (5th gen+).
  • tvOS 26.3: Apple TV HD and Apple TV 4K (all models).
  • watchOS 26.3: Apple Watch Series 6 and later.
  • visionOS 26.3: Apple Vision Pro (all models).
  • macOS Tahoe 26.3: Macs running macOS Tahoe.

Environment Setup

Steps:

  1. Download the iOS 26.0 IPSW.
  2. Extract the root filesystem and mount it.
  3. Copy dyld out of the root filesystem (commonly at /usr/lib/dyld) as dyld_vuln.
  4. Copy the patched dyld from the local Mac (because my Mac version now is 26.3) as dyld_patch (path is typically /usr/lib/dyld).
  5. Thin to arm64e and disassemble.

Note: for a strict iOS to iOS comparison, use the iOS 26.3 IPSW instead of the local macOS dyld.

# IPSW extraction
unzip iPhone_*.ipsw -d ipsw_26_0
hdiutil attach ipsw_26_0/*.dmg
cp /Volumes/*/usr/lib/dyld ./dyld_vuln

# Local macOS dyld
cp /usr/lib/dyld ./dyld_patch

# Normalize architecture and disassemble
lipo -thin arm64e dyld_patch -output dyld_patch.arm64e
cp dyld_vuln dyld_vuln.arm64e
llvm-objdump -d --no-show-raw-insn dyld_vuln.arm64e > dyld_vuln.s
llvm-objdump -d --no-show-raw-insn dyld_patch.arm64e > dyld_patch.s
diff -u dyld_vuln.s dyld_patch.s > dyld_disass.diff

Binary Analysis

After get all the binary needed for the analysis, now we can check the diff for dlopen() between the dyld_vuln and dyld_patch.

alt text Figure: dyld-1335 (vulnerable) disassembly cleanup path showing stack-allocator Vector::resize and Allocator destruction.

alt text Figure: dyld-1340 (patched) disassembly cleanup path showing UniquePtr<Vector<…>> destructors for persistent-allocator copies.

I focused on dyld4::APIs::dlopen_from and its main lambda (__ZZN5dyld44APIs11dlopen_fromEPKciPvENK3$_0clEv). The screenshots above show the cleanup paths that changed:

  • In dyld‑1335 (image‑6), cleanup goes through Vector::resize and Allocator destruction, which is the stack‑allocator path.
  • In dyld‑1340 (image‑7), cleanup runs UniquePtr<Vector<...>> destructors, which matches the new persistent‑allocator copies.

Source Diff (GitHub Commit)

Commit 9b3c6bd in the dyld OSS repo is the source‑level patch. It changes three files (DyldAPIs.cpp, Loader.cpp, Loader.h) and introduces the persistent‑allocator copies for the temporary vectors.

Commit reference:

https://github.com/apple-oss-distributions/dyld/commit/9b3c6bde0c6d1cb4a13ce7646aed6f74597bcc84

Relevant diff (DyldAPIs.cpp):

-STACK_ALLOC_VECTOR(const Loader*, newlyNotDelayed, 128);
-STACK_ALLOC_VECTOR(Loader::PseudoDylibSymbolToMaterialize, pseudoDylibSymbolsToMaterialize, 8);
+// 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;
@@
+// 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);
+}

Root Cause

APIs::dlopen_from runs sensitive loader work while holding the loaders lock and executing on a protected stack. In dyld-1335, two temporary vectors are allocated on the regular stack before entering that protected-stack region, then used after leaving it. If an attacker can write to process memory, those stack-resident vectors can be corrupted between creation and later use, allowing them to:

  • Inject or modify Loader* entries used by notifyLoad()
  • Corrupt the pseudo-dylib symbol materialization list
  • Trigger out of bounds behavior by tampering with vector size/capacity

Why It Matters

Those vectors drive loader notification callbacks and pseudo-dylib symbol finalization, both of which can reach sensitive code paths. With a write primitive, this is a realistic route to memory corruption and control-flow hijack, matching the “attacker with memory write -> RCE” threat model described in the advisory.

Proof of Concept (reproduced from the real code path)

This is a minimal reproduction of the dyld logic around APIs::dlopen_from, NOT an actual exploit. It mirrors the exact data-flow pattern from the real source code to demonstrate the state-lifetime bug between protected and regular stack regions.

The code uses the same allocation/use/corruption timing as the vulnerable dyld-1335 path, but stays fully non-exploitative (e.g., system("touch /tmp/test") simulates notify callback reachability).

typedef void (*NotifyFn)(const char* name);
typedef struct Loader { const char* name; NotifyFn notify; } Loader;

typedef struct Vector {
    Loader** data;
    size_t   size;
    size_t   capacity;
} Vector;

static void vec_init(Vector* v, size_t cap) {
    v->data = (Loader**)calloc(cap, sizeof(Loader*));
    v->size = 0;
    v->capacity = cap;
}
static void vec_push(Vector* v, Loader* ldr) {
    if (v->size < v->capacity) v->data[v->size++] = ldr;
}
static void vec_free(Vector* v) {
    free(v->data);
    v->data = NULL;
    v->size = v->capacity = 0;
}

static void withProtectedStack(void (*work)(void*), void* ctx) { work(ctx); }
static void attacker_write(Vector* v) { v->size = v->capacity + 4; }

// Attacker-controlled notify callback (simulates injected control-flow)
static void attacker_notify(const char* name) {
    notify_load(name);
    system("touch /tmp/test");
}
static Loader attacker_loader = { .name = "libDemo.dylib", .notify = attacker_notify };
static Loader* attacker_list[1] = { &attacker_loader };

static void attacker_write_vuln(Vector* v) {
    // attacker-controlled data pointer + size corruption
    v->data = attacker_list;
    attacker_write(v);
}

typedef struct VulnCtx { Vector* newlyNotDelayed; Loader* ldr; } VulnCtx;
static void fill_vectors(void* arg) {
    VulnCtx* ctx = (VulnCtx*)arg;
    vec_push(ctx->newlyNotDelayed, ctx->ldr);
}

// Vulnerable flow
static void dlopen_vuln(Loader* ldr) {
    // Allocation: regular stack temp
    Vector newlyNotDelayed;
    vec_init(&newlyNotDelayed, 1);
    Loader** orig_data = newlyNotDelayed.data;
    size_t orig_size = newlyNotDelayed.size;
    size_t orig_capacity = newlyNotDelayed.capacity;

    VulnCtx ctx = { .newlyNotDelayed = &newlyNotDelayed, .ldr = ldr };
    withProtectedStack(fill_vectors, &ctx);

    attacker_write_vuln(&newlyNotDelayed); // corrupt stack vector, inject attacker-controlled notify

    for (size_t i = 0; i < newlyNotDelayed.size; ++i) {
        if (i < newlyNotDelayed.capacity && newlyNotDelayed.data[i])
            newlyNotDelayed.data[i]->notify(newlyNotDelayed.data[i]->name);
    }
    // Restore original backing before free to keep the demo stable
    if (newlyNotDelayed.data == attacker_list) {
        newlyNotDelayed.data = orig_data;
        newlyNotDelayed.size = orig_size;
        newlyNotDelayed.capacity = orig_capacity;
    }
    vec_free(&newlyNotDelayed);
}

typedef struct FixedCtx { Vector* tmp; Loader* ldr; } FixedCtx;
static void fill_vectors_fixed(void* arg) {
    FixedCtx* ctx = (FixedCtx*)arg;
    vec_push(ctx->tmp, ctx->ldr);
}

// Fixed flow
static void dlopen_fixed(Loader* ldr) {
    // Allocation: temporary on protected stack, then copied
    Vector tmp;
    vec_init(&tmp, 1);
    Loader** orig_data = tmp.data;
    size_t orig_size = tmp.size;
    size_t orig_capacity = tmp.capacity;
    FixedCtx ctx = { .tmp = &tmp, .ldr = ldr };
    withProtectedStack(fill_vectors_fixed, &ctx);

    // Copy to persistent storage before leaving protected region
    Vector persistent;
    vec_init(&persistent, tmp.size);
    for (size_t i = 0; i < tmp.size; ++i)
        vec_push(&persistent, tmp.data[i]);

    attacker_write_vuln(&tmp); // corrupt temp only (no effect on persistent copy)

    for (size_t i = 0; i < persistent.size; ++i) {
        if (persistent.data[i])
            persistent.data[i]->notify(persistent.data[i]->name);
    }
    // Restore original backing before free to keep the demo stable
    if (tmp.data == attacker_list) {
        tmp.data = orig_data;
        tmp.size = orig_size;
        tmp.capacity = orig_capacity;
    }
    vec_free(&tmp);
    vec_free(&persistent);
}

alt text Figure: PoC fixed output (dyld-1340 path).

alt text Figure: PoC vulnerable output (dyld-1335 path).

Flow sketch:

  • dyld-1335 (vulnerable): alt text

  • dyld-1340 (patched): alt text

How an attacker would utilize this

In practice, this type of primitive fits neatly into modern commercial/spyware chains observed by research teams such as Google TAG. Like the “extremely sophisticated attacks against specific targeted individuals” CyberScoop reported, CVE-2026-20700 enables nation-state surveillance on high-value targets (journalists, activists, diplomats).

Attack Profile:

alt text

Notes And Remediation

This analysis is based on the dyld source diff between dyld-1335 and dyld-1340. The security impact depends on having an existing memory-write primitive, which is a common second-stage capability in real-world exploit chains.

kungfu master

Practical takeaway: Just because your device works fine doesn’t mean it isn’t vulnerable. If a new update is available, install it, it may contain critical security fixes.

Sources

Apple open-source dyld repository:

https://github.com/apple-oss-distributions/dyld

GitHub commit (source diff):

https://github.com/apple-oss-distributions/dyld/commit/9b3c6bde0c6d1cb4a13ce7646aed6f74597bcc84

Apple security content pages:

https://support.apple.com/en-us/126346

CISA Gov:

https://www.cisa.gov/known-exploited-vulnerabilities-catalog?field_cve=CVE-2026-20700