Automating Myself
Out of a Job, Badly:

Guest to Host on Apple Virtualization

Josh Maine · @blacktop__ · Calif

OffensiveCon Tokyo · October 2026

Who am I?

  • Author of ipsw — iOS/macOS Research Swiss Army Knife
  • Security researcher at Calif
  • 12 years of reverse engineering, 8+ focused on Apple internals

How far could I get
with my own tools and AI?

“I don’t really consider myself a pure researcher. I consider myself more of a reverser and a tool builder, with exploit dev probably being my weakest skill set.”

While building pomace, my AI research harness:

“I wonder how powerful these agents actually are, and if I can just use them with my tools and see how far I could get.”

The Experiment

What could I do in one week?

I gave myself one week to hunt for bugs with my tools and AI.

I decided to shoot for the stars…

A FULL iOS CHAIN

“When you aim for the stars, you may come up short but still reach the moon.”

— John Doerr, Measure What Matters (2018)

The harness

pomace combines Apple research knowledge
with environments the agents can control.

Skills & references

The Apple research knowledge

  • RE, exploitation and security mitigations
  • Guidance for the research tools
  • Offensive capabilities hidden in ipsw

VM control vmctl

My private macOS VM tooling

  • Private Virtualization.framework APIs
  • Kernel debugging via GDB stub
  • Panic logs and snapshot restore
  • Visual setup: see, click, install

vphone

My vphone-cli fork

  • A virtual iPhone
  • iOS userspace on a cloudOS kernel
  • Control, snapshot, restore

iPhone 16

A18 · without MTE

iPhone 17e

A19 · with MIE

Apple Developer account

Build and run apps on the phones

The agentic loop

I built the tools to speed up the agents’ experiment loop.

Pomace's repeating experiment loop Clockwise: inspect the target and choose an experiment, test it in a guest, observe the result or panic, restore when needed, then use that evidence to choose the next experiment. Pomace reduces manual work between attempts. Inspect & decide Choose an experiment Test Run it in the guest Observe Inspect the result or panic Restore Reset when needed pomace Less manual work between attempts

I organized it like a research team.

My research team and pomace environment The human directs an AI research lead and supplies resources. The lead can discuss ideas with a separate review agent, assigns work to specialist teams, and returns progress and blockers. Specialist teams include kernel, sandbox, zero-click, WebKit and TCC. All teams use pomace tools, guidance and test environments. The research lead, review agent and all specialist teams also have access to shared knowledge in an Obsidian vault and a notes folder, shown as a cylinder in the upper left. Human direction and resources progress / blockers ↑ Research lead agent Review agent Kernel teamsublead + workers Sandbox teamsublead + workers Zero-click teamsublead + workers also WebKit, TCC, … pomace provides tools, guidance and test environments Shared knowledge Obsidian vault + notes folder

When a team got stuck, I supplied what was missing.

Dissent in the ranks

One refusal, a stalled team, and a fresh start A conceptual animation of my recollection: one agent refuses, refusals spread through a connected team, I discard the team, then start a fresh one. The logs establish a WebKit refusal, the project manager sharing its concern, recreation of one agent and my stated intent to tear down the rig; they do not establish every node refusing or a completed whole-team restart. tmux AI lead WebKit other specialist agents Team discarded tmux AI lead WebKit other specialist agents
Wait a minute…

From the session log

“I should have surfaced this at the start of the session, not 70 commits in.”

WebKit lead

4 distinct refusal episodes

One agent refused.

Then the refusals spread through the team.

I had to throw the team away.

And started again.

It was finding bugs
while I was sleeping.

The research continues overnight A quiet room with a sleeping researcher and a laptop on the bedside table. The first advance lights the laptop, shows an illustrative New candidate found notification, and wakes the researcher with a smile. The second advance explains that the findings still needed review. This scene illustrates a recollection, not a recorded notification or a bug count. pomace New candidate found

Waking up to new findings was exciting.Then I had to work out which ones were real.

Now I had to go through the findings.

After the sprint, I had a pile of candidates to review.

“I was a little bit concerned that every bug I found during my sprint was slop.”

The findings that held up became reports to Apple.

Virtualization.framework audit

One finding claimed to be a VM breakout.

That was the one I was most excited about.

The AI agent team’s review

The VM-to-host path wasn’t available on the system we tested.

I set that candidate aside.

My team lead saw something I hadn’t.

Another finding was an out-of-bounds write in H9, Apple’s HEVC video encoder.

My team lead saw a powerful primitive that worked on macOS and iOS with MTE enabled—and later turned it into an LPE for his iOS chain PoC.

We’d found a REAL, usable 0day! That got me excited to go back through the rest of our findings from the sprint.

Going backwards to move forward

The first sprint didn’t produce a full iOS chain.
H9 showed the findings were worth a second look.

Second sprint: could we actually break out of a VM?

“Maybe it wasn’t dead after all.”

The Bugs
and the Chain

Bug #1 · H9

The sprint finding that caught my team lead’s attention.

I pushed the team to keep digging into the kernel.

The agents followed the video drivers into user-space codecs.

A decoder audit stalled. The lead redirected a worker to the encoder.

Static binary RE with ipsw + IDA (via ida-mcp-rs)
Reproduced on iPhone 17e, with MTE enabled

H9 lives in a user-space encoder

The original iPhone finding

H9.videoencoder

/System/Library/VideoEncoders/

Inside the examined iOS shared cache

iOS 26.5 (23F77)

The Mac implementation

AppleVideoEncoder

/System/Library/Video/Plug-Ins/
AppleVideoEncoder.bundle/…

A separately loaded codec plugin

macOS 26.5.1 (25F80)

An encoder compresses video.
VideoToolbox reaches this user-space codec.

The inner array copy has no upper bound

AVE_HEVC_ST_RPS_Retrieve

Simplified from binary RE · macOS 26.6.2 (25G83)


// One record in the UserRPSForFaceTime property
delta = rps["DeltaPOCS0Minus1"];
count = CFArrayGetCount(delta) & 0x7fffffff;
for (int i = 0; i < count; i++) {
    int16_t value = read_s16(delta[i]);
    store16(record + 0x38 + 2*i, value);
}
  
The copy continues past its intended storage A schematic of one encoder session. The caller supplies the entry count. The vulnerable loop continues beyond its fixed storage through later fields, reaching the timing-object pointer. The actual storage capacity and distances are not represented to scale. One encoder session RPS storage fixed-sized later session fields also overwritten FPS* · timing pointer

The count comes from the input.
It is never clamped to the destination’s capacity.

Why overwrite this field?

Later in the same session object is FPS*: the address of the state used by the encoder's frame-timing code.

Later in AVE_HEVC_Encode · simplified binary RE · macOS 26.5.1 (25F80)


// Read the field from the same encoder session
FPS *fps = session->fps;  // session + 0x19900
FPS::Add(fps, frame_pts);
    
Encoder sessionFPS*
Replacementtiming object
Next frameFPS::Add

We must also keep the overwritten fields along the way in a state the host can use.

24 KB from the array to the pointer

Offsets from pINS · reconstructed excerpt · long gap compressed

Encoder-session layout and distance to the FPS pointer Reconstructed offsets for macOS 26.6.2 (25G83) and macOS 27.0 (26A428). The FPS pointer starts 24,044 bytes after the copied array begins on both builds. The long span is compressed. FPS, TimeStats, Dump, VCP and SEI are adjacent eight-byte pointers. Four two-byte stores replace FPS; the copy stops before TimeStats. macOS 26.6.2(25G83) macOS 27.0(26A428) One encoder session · pINS +0x13b14 +0x0bf04 delta[] starts here intervening fields also crossed by the copy 24,044 B 0x5dec bytes gap compressed 4 × 16-bit +0x19900 +0x11cf0 FPS* 8 bytes replaced copy stops +0x19908 +0x11cf8 TimeStats* +0x19910 +0x11d00 Dump* +0x19918 +0x11d08 VCP* +0x19920 +0x11d10 SEI* 8 B per pointer … further fields …

12,022 halfword stores reach FPS*. Four more replace it.

xzone malloc is Apple’s heap allocator. It groups allocations by size and type to make memory corruption harder to exploit.

macOS 26.5.1 (25F80): MTE enabled in the process; the 112 KiB session was recorded as untagged.

macOS 27.0.1 (26A434): 80 KiB LARGE allocation; mapping confirmed untagged.

This overwrite does not demonstrate an active MTE tag-check bypass.

The next frame writes through that pointer

The replacement FPS object supplies the buffer and index.

Fields used by the update

+0x08capacitysample count
+0x10samples*buffer pointer
+0x18firstfirst timestamp
+0x2cindexnext sample

FPS::Add · simplified binary RE · macOS 26.5.1 (25F80)


slot = fps->index % fps->capacity;
fps->samples[slot] = timestamp; // 16 bytes
if (fps->index == 0)
    fps->first = timestamp;    // another 16
fps->index++;
      

The resulting store 16 bytes · this chain’s timestamp encoding

timestamp value8 bytes · chosen, then converted
14,400,0004-byte timescale
04-byte flags

The value can round. The trailing eight bytes come with it.
And the object must survive the extra store and index update.

The search led back to H9.

Earlier route

Unavailable on the system we tested.

VideoToolbox

The agents found an enabled video path.

H9

A bug from the sprint could be reached through it.

VirtualMachine.xpc · host process

Guest-to-host virtual video path A macOS guest sends video requests through a virtual video device to VirtualMachine.xpc on the host. A dashed line marks the VM boundary. Guest · the virtual Mac Host · the real Mac Virtualization.framework VM boundary macOS guest the OS inside the VM our code demo starting point VirtualMachine.xpc host userspace process Video work video requests Virtual video device

Guest requests become host video work

Guest-to-host virtual video path A macOS guest sends video requests through a virtual video device to VirtualMachine.xpc on the host. A dashed line marks the VM boundary. Video results return to the guest. Guest · the virtual Mac Host · the real Mac Virtualization.framework VM boundary macOS guest the OS inside the VM VideoToolbox demo starting point VirtualMachine.xpc host userspace process Encoder / decoder PAC · MIE · sandboxed video requests Virtual video device video results

The guest can reach H9

A guest-controlled encoder setting reaches H9 on the host The same guest and host remain visible. First reveal the guest-controlled UserRPSForFaceTime array. Next send the setting through the virtual video device; after it arrives, show the host encoder session and highlight its overwritten FPS pointer. This is a schematic sequence, not a measured trace or a memory layout to scale. Guest · the virtual Mac Host · the real Mac Virtualization.framework VM boundary macOS guest VideoToolbox VirtualMachine.xpc host userspace process Virtual video device UserRPSForFaceTime array supplied by the guest We control the input here. setting H9 encoder session inner array FPS pointer The overwrite happens here.

Why we needed a leak

H9 gave us a constrained write inside the host.

The shared-cache slide relates the known unslid address to the runtime address The same target has a known location in the matching cache file and a displaced location in the running host. One schematic animation moves the cache as a unit, preserving the target's offset within it. Recovering the displacement lets us calculate the target's runtime address. No actual address values, measured displacement or guest-restart randomization is implied. Matching fileUnslid layout Shared cache Known unslid address Running hostASLR placement Shared cacheRuntime address? Unknown displacement Shared-cache slide
Known unslid address+shared-cache slide=Runtime address

Finding Bug #2 · first attempt

The AI agent team used ipsw to identify writable shared-cache mappings.

A host-driven probe narrows the candidate slide range Offline mapping metadata gives the unshifted layout. One illustrative midpoint write faults, narrowing the range; the external host harness restores the stopped VM. Repeated observations locate the shifted boundary. This is a schematic, not recorded telemetry or a guest-only loop. ipswCache mapping table MappingAccess __DATArw-__AUTHrw- Known starts + sizesBefore ASLR shifts them Bisect the remainingslide candidates H9 writeAt the midpoint Example: faultGuest stops Remaining candidatesOne half retained Host harnessrestores VM

Repeated probes locate the boundary. Runtime − known location = slide.

🤩 I woke up to a working demo. It was glorious.

A frustrated man closes his eyes and covers his face.
“Then I looked closer.
It was crashing the freaking VM.”

Bug #2 · the decoder leak

A decoder turns compressed video into frames.

AppleVideoDecoder

/System/Library/Video/Plug-Ins/
AppleVideoDecoder.bundle/Contents/MacOS/AppleVideoDecoder

User-space codec plugin · loaded outside the shared cache

Decoded video is returned with attached metadata Compressed video enters the decoder, which returns a decoded frame and attached histogram metadata. The second advance highlights an unintended host state address in that metadata header. The histogram counts are a separate part of the result. Compressed video Decoder host process Returned surface decoded frame metadata header histogram counts host state address

The metadata includes an address from the host’s decoder state.

The pointer is written into the result

AppleVideoDecoder + 0xe7804

Histogram writer · reconstructed pseudocode · macOS 26.5.1 (25F80)


blob = ctx->histogram_blob;
blob->magic = HISTOGRAM_MAGIC;
blob->decoder_context = ctx;          // host pointer
blob->frame_number = frame;
source = ctx->histogram_ring + (frame & 15) * 512;
memcpy(blob->histogram, source, 512);
IOSurfaceSetDataProperty(surface, 2, 544, blob);
  

Build the metadata header.
Record the magic value, decoder context and frame number.
This is the disclosure.
The header contains the decoder’s own host pointer.
Publish the result.
The 544-byte metadata includes that pointer and 512 bytes of histogram data.

The pointer is in the 32-byte header

544-byte result · reconstructed field layout · macOS 26.5.1 (25F80) · not to scale

The decoder result leaks its context pointer in the header A reconstructed 544-byte result on macOS 26.5.1 (25F80): magic at zero, eight-byte context pointer at offset eight, frame index at sixteen, scalar metadata from twenty through thirty-one, and 512 histogram bytes beginning at thirty-two. Field heights are not proportional to size. Offset Returned blob 32 B header +0x00 magic · “aavdhst1” 8 B +0x08 decoder context pointer 8 B The disclosure host address + tag +0x10 frame index 4 B +0x14 other scalar metadata 12 B +0x20 histogram bin counts 512 bytes of frame data +0x220 The expected data not the pointer leak

The leak is in the header, before the histogram data.

The host pointer comes back to the guest

The decoder metadata carries a host pointer back across the VM boundary Guest on the left, host on the right. The host's AppleVideoDecoder includes its context pointer at offset 8 in a 544-byte result. One click shows this result inside the host VM process. The next sends the metadata left through the virtual video device and reveals the same pointer in the guest-visible result. This schematic illustrates disclosure of an address, not migration of the decoder object into the guest. Guest · the virtual Mac Host · the real Mac Virtualization.framework VM boundary macOS guest VideoToolbox VirtualMachine.xpc host userspace process Virtual video device AppleVideoDecoder host ctx* at +8 + histogram data 544-byte result IOSurface metadata result Returned metadata same host pointer + histogram data IOSurfaceGetDataProperty

The guest can read the host’s decoder pointer.

The write changes what the decoder reads

Decoder context excerpt · reconstructed from macOS 26.5.1 (25F80) · unknown fields omitted

The known decoder state connects the two bugs First, the metadata header discloses the decoder-state address and tag. Second, the separate H9-derived write changes the histogram source pointer in that state. Third, the decoder's existing 512-byte copy reads the selected source window into its result and returns those bytes to the guest. The result-buffer pointer is a different field. Required state repairs are omitted; the whole read window must be accessible with the correct tag. Decoder state · ctx Bug #2 gives us this address and tag +0xe9chistogram gate +0xea0source pointer +0xf48result buffer pointer H9 writechanges thissource pointer Existing read and return path Selected source window512 bytes · correct tag required memcpy Result blob · payload +0x20Returned through the surface to guest

The next result returns bytes from the replacement source.

The full 512-byte window must be accessible with the correct tag.

How we recovered the shared-cache slide

The rebuilt chain follows pointers from decoder state into the host’s libraries.

Decoder state leads to the plugin, then to a resolved system-library pointer. Read a callback pointer from decoder state. Subtract its known offset to locate the plugin. Read a resolved import pointer at a known location inside that plugin. Compare the imported function’s runtime address with its known unslid address to calculate the shared-cache slide. Decoder state Address from Bug #2 Read callback pointer Into decoder code Decoder plugin Callback − known offset = plugin base Read resolved import Slot at a known offset Shared cache System-library function’sruntime address The loader resolvedthis pointer for us.
Runtime function address−Known unslid address=Shared-cache slide

Offsets and unslid address: known from binary analysis of the same build.
We calculate the slide; the decoder never returns it directly.

Where is our data in the host?

The early chain could read and write host memory and locate system libraries.
Its working data was still in the guest.

One buffer viewed through guest and host address spaces The buffer we prepared is accessible in the guest. Its backing memory also has an address in the host VM process, which the early chain needed to discover. The two boxes show views of the same memory, not a transfer or copy of the data. Host library locations are a separate question. Guest address space example working data “hello” We know its guest address. Same bytes Different addresses Host address space the same working data “hello” What is its host address?

For host code to use this buffer,
we needed its address in the host process.

Finding that address · first attempt

The test harness looked it up outside the VM.

The host’s vmmap output supplied the address of guest memory in the host process. Guest-side chain Hostvmmap output Needs a host address Guest / host boundary Guest memory’shost address Supplied from outside the VM Host addresssupplied by the harness
Jonah Hill closes his eyes and covers his face in frustration.
“Of course it freaking worked,
with the answer handed to it.”

The mapping guess came down to one bit.

For the 8 GB guest-RAM allocation we investigated

XNU’s guard-object allocator has two slots in this size class and randomly chooses a free slot The left panel names XNU’s guard-object allocator in vm_map_store.c and summarizes the relevant logic: two slots for this size class and a uniformly random free-slot choice. The historical measurements and configuration details are retained in the speaker notes. The two boxes are possible placements, not two simultaneous copies of guest memory. One unknown binary choice gives a one-in-two mapping guess under this model; it does not establish whole-chain reliability. XNU guard objects vm_map_store.c Large-allocation path · simplified slots = 2 pick = uniform_random(slots) Two possible placements. The current choice is unknown. The kernel still chooses randomly Uniform random slot selection Location A Guest RAM could be here Location B Guest RAM could be here 1 unknown bit → 50/50 mapping guess
Correct guessContinue
Wrong guessHost XPC crashes

Restarting makes a fresh choice; retries do not narrow it down.

The 50% describes the mapping guess. Other chain stages could still fail.

We spent days on this.

“And having Mythos just grind for days trying to figure it out.”

I wanted a result without the shortcuts
or repeated crashes.

Making It Work

During the stall

Did we need the
guest-memory mapping?

My team lead questioned keeping our working data in guest memory.

He also suggested a fresh context without the accumulated assumptions.

My team lead holding a cat.

Have you tried,
I don’t know…
just not needing that address?

Start from what the primitives can do.

“use the LLM to answer questions vs. to PoC it directly.”

— team lead

Work through the design together.

Make the reads safe.
Give our data a known home on the host.

The 512-byte read was too big.

MTE (Memory Tagging Extension) checks a pointer’s tag against the memory it accesses.

A 512-byte read extends out of a small object into memory with a different tag. Pointer · tag A Small array objecttag A Neighboring memory · different tag Our read copies 512 bytes Tag boundary The read reaches the wrong tag → fault

A valid starting pointer did not make the whole read safe.

Read the resolved pointer, then calculate the slide

The plugin’s import slot already holds a system-library function address.

Read four bytes of a resolved import pointer into decoder state, then return them within a safe 512-byte histogram window. The source is a known import slot in the decoder plugin's read-only data. Its resolved pointer points into a system library in the shared cache. A different decoder operation copies the low 32 bits into decoder state, and the existing histogram returns that value to the guest. The source read and the return copy are separate operations. Decoder plugin Known import slot · read-only Resolved library pointer Low 32 bits · 4 bytes 4-byteread Decoder state Safe 512-byte windowwithin one tag region Those 4 bytes 512-bytecopy Guest Gets thepointer bits This decoder operation avoids source writes; the 512-byte copy stays in decoder state.
Runtime low 32 bits−Known unslid low 32 bits=Shared-cache slide

Same function, same build. Subtract in 32 bits; check the slide’s range and alignment.

The host already had a buffer we could use.

The staging buffer did not have to live in guest RAM.

Before: guest RAM required knowing its host address. After: the first decoder read reveals an existing host buffer, and H9-derived writes put the working data into it. Earlier design Guest RAM VM boundary Guest memory’shost address Needed an addresswe did not yet have Rebuilt design Host decoder Existing 64 KB buffer Pointer from the first read Our working data working data Writes through H9

That removes the guest-RAM mapping dependency.

PAC still had to accept the calls.

PAC — Pointer Authentication Codes

Protected pointers are authenticated to resist control-flow hijacking.
Knowing a function’s address alone is not enough.

Read + write→PAC-checked calls→Working call path

Pomace already included PAC research, including DarkSword,
and guidance for finding candidate JOP gadgets with ipsw.

My human team lead’s iOS PoC gave the agents
a concrete approach to adapt for macOS.

Public background: DarkSword · Google Threat Intelligence

Why the call chain has two phases

MTE will fault if we use this host object with the wrong tag.
The tag comes from the running process.

Create the host object, recover its tagged pointer, then use it in phase twoPhase one creates the host object needed by the next phase. The guest reads back the object's actual address and MTE memory tag, then uses that same tagged pointer in phase two. A wrong memory tag would fault. The private object identity and pointer-authentication implementation are omitted. GuestHost process Phase 1 · create host objectObject existsTag assigned at runtime Read its address + MTE tagaddress + tagLive pointer known Phase 2 · use the tagged pointeraddress + tagCorrect tag preserved

Recover the live tag and continue with MTE enabled.

The host process was still sandboxed.

Controlling the host process did not remove its sandbox policy.

Direct execution is denied while specific service lookups are permitted Sandboxed host processVirtualMachine.xpc DeniedDirect process executionArbitrary home writes LookupLaunchServices + RunningBoardSpecific service connections are allowed

An allowed lookup still needed a usable operation.

Escaping the sandbox · first attempt

An earlier false finish · June 19

Guest access to an already configured shared folder does not establish an unsandboxed host write. Guest Host Configuredshared folder Ordinary shared access ≠ unsandboxed host access
A crowd performs a collective facepalm in The Naked Gun.
“Then I noticed what its demo was doing.
It was writing to a shared folder.”

Even the app lookup initially failed.

LaunchServices checked the CPU architecture before resolving Calculator.

Supply the cached CPU answer while the denied query stays deniedThe sandboxed host process cannot complete the CPU query. The existing memory control sets the correct ARM64 cache value and marks initialization complete. The next app lookup uses that answer and returns an application URL. The sandbox policy does not change. Sandboxed host processVirtualMachine.xpc LaunchServices cacheCPU typeInitializedunknownnoQuery CPU typeDeniedLookup reports the app as unsupported ARM64completeFind CalculatorUses the cached answerApplicationURL

The sandbox rule stays in place.

Let the app-launch machinery do its job.

The permitted service route opens Calculator on the host.

A permitted launch request opens Calculator while the original process stays sandboxedThe host XPC prepares a real application URL, sends a LaunchServices request through the allowed service route, and Calculator appears on the host desktop. The drawing is illustrative, not a screenshot or new runtime result. Sandboxed host processVirtualMachine.xpc Host desktop Application URLLaunchServices call LaunchServices+ RunningBoardapp-launch flowapp URL Calculator0=

The compromised XPC keeps its sandbox.

I read every line.

I checked for saved host pointers
and host-supplied runtime addresses.

The audit is finally over An illustrated researcher leans tensely over a laptop. On the final advance he leans back, relaxes his face and exhales. This is an illustration of Josh's recollection, not a recorded audit or result.

Finally, I could relax.

Demo

Demo

Guest input → host process

Live-demo host and guest: macOS 27.0.1 (26A434)

↓ Why a clean layout bail?·Recorded run · macOS 26.5.2 (25F84) · 49 seconds

Why a clean layout bail?

One byte must work as both part of a pointer and a flag.

One pointer byte overlaps the decoder’s histogram-enable bit The fake timing object needs an eight-byte buffer pointer at decoder context plus hexadecimal e70. Its fifth byte, at context plus e74, is also tested by the real decoder to enable histogram output. An observed address byte of 0x76 has the required bit clear and is rejected before staging. An observed byte of 0x7a has the bit set and passes this check. Passing the check alone does not prove exploit success. Fake timing object 8-byte buffer pointer b0b1b2b3 b4b5b6b7 ctx + 0xe70 Real decoder: histogram-enable bit Observed reject Observed pass 0x76 01110110 0x7a 01111010 Bit clear → clean bail Bit set → continue

Stop before staging. Restart with a fresh host process. Try again.

↑ Return to Demo · ↓ Recorded run · → The Fixes

Recorded on macOS 26.5.2 (25F84) · Return to Demo · → Apple’s fixes

The Fixes

How Apple bounded the copy

AVE_HEVC_ST_RPS_Retrieve

macOS 27.2 beta 2 (26B5091g) → beta 3 (26B5101f)
Reconstructed C from the binary diff · not Apple source


+ if ((uint32_t)neg_pics > 16 ||
+     (uint32_t)pos_pics > 16)
+     return -1001;
  int32_t count = (int32_t)CFArrayGetCount(delta);
  if (count > 0) {
-     uint32_t n = (uint32_t)count & 0x7fffffff;
+     uint32_t n = min((uint32_t)count, 16u);
      for (uint32_t i = 0; i < n; i++)
          out->delta_poc_s0[i] = read_s16(delta, i);
  }
  

Invalid declared picture counts return an error.An oversized supplied array now copies at most 16 entries.

Reported June 4Beta containing the fix October 5123 days

How Apple removed the pointer leak

attachHistogramToIOSurface

macOS 27.2 beta 3 (26B5101f) → beta 4 (26B5112a)
Reconstructed C from the binary diff · not Apple source


  // Decoder-state creation
+ arc4random_buf(&ctx->histogram_id, 8);

  // Histogram header
- blob->identifier = (uint64_t)ctx;
+ blob->identifier = ctx->histogram_id;
  blob->frame_index = frame_index;
  // Existing histogram data and publication
  IOSurfaceSetDataProperty(surface, 2, 544, blob);
  

Create an eight-byte random identifier.Return that identifier instead of the live heap address.

Reported July 7Beta containing the fix October 1398 days

Hexacon · Paris · October 16, 2026

The original iOS goal

Calif demonstrated an iOS full chain.
H9 was one of the bugs it used.

Signal 1-click · iMessage
Persistence before first unlock

Billy Ellis @bellis1000

“At #hexacon - Calif.io demos iOS 27.0.1 untethered jailbreak on iPhone 18 Pro”
Billy Ellis’s photograph of the Hexacon stage, showing the iPhone and terminal during Calif’s demonstration.
Photo: Billy Ellis · October 16

Lessons Learned

What I’d do differently next time

The harness helped the agents keep trying.

Next time, it needs to make the evidence
and the failed attempts just as visible.

→ Continue · ↓ Optional: the macOS 27.0 port · 2 slides

Optional port · macOS 27.0 (26A428)

Both bugs remained. The layout changed.

I went to record the demo. The chain failed,
even though both bugs still triggered.

First 8 bytesThe pointer we need
to change
Next 8 bytesClobber a debug-log
FILE*

The next decode used that damaged pointer and crashed.

Updating the offsets was not enough.
The known FPS::Add write could not repair it.

↩ Return to lessons · Continue the main talk →

Optional port · macOS 27.0 (26A428)

A fresh agent found another operation.

It re-read the disassembly and found FPS::Reset,
another reachable encoder method.

Debug-log pointer0
Output-emit array field0

Two reset operations repair the state.
The read works again.

↩ Return to lessons · Continue the main talk →

I had rules. I still had to check the run.

What I had to catch
  • Shared-folder write
  • Host-supplied memory mapping
  • Crashes and VM restarts
What the next harness should record
  • Boundary actually crossed
  • Runtime address provenance
  • Starting state + outside inputs
  • Failures + VM health
“Think of it like making a wish on a monkey’s paw.”

Or the updated version, the One Wish Willow.

A finger curls on a cartoon monkey’s paw.
Nikki says, ‘No, no, no, no, don’t do that!’ in Obsession.

A guess came back as a fact.

A result reaches the next agent while its qualification is left behindAn uncertainty label is attached to the worker's result. On the next advance, a labeled analysis token travels to the next agent, but the qualification stays behind. The next plan treats the result as settled. The labels are editorial, not a reconstructed message or a claim about internal model beliefs. Worker’s analysisQualifiedresultUncertainty HandoffAnalysisQualification left behindNext agent’s planTreated assettled

I pushed for runtime checks of the assumptions we were building on.

Keep the evidence with the answer.

Pass on what was proved and what still needs checking.

The next handoff

  • Question + assumptions
  • Observation + source
  • Confidence + limits
  • What still needs a runtime check

When a check contradicts the answer, update the shared record.

We were grinding on the wrong question.

The question we kept repeating

How do we find the host address
of guest memory?

Our working data lived in the guest, so the host needed its address.

The question that opened the design

Could we keep our data
in a host buffer instead?

My team lead helped us rethink that dependency.

Next time: bring that review in earlier,
then test smaller design questions with the agents.

Who did what

metools, platform knowledge, direction,
resources, and the audit

team leadexpert assessment, his PoC,
the questions, design guidance

the agentssearch, analysis, experiments,
implementation

What I wanted to pass on

“I tried a lot of things and failed in a lot of different ways.”

The experiment became a working VM escape.
Next time, I want each attempt to leave evidence
I can check and use to choose the next step.

@blacktop__ · github.com/blacktop/ipsw

Thank you

Questions?

@blacktop__ · github.com/blacktop/ipsw