Automating Myself Out of a Job, Badly:
Guest to Host on Apple Virtualization
Josh Maine · @blacktop__ · Calif
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.”
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
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 team sublead + workers
Sandbox team sublead + workers
Zero-click team sublead + 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
▶ Play clip
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.
Dramatic re-enactment
Play clip
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.”
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 session FPS*
→
Replacement timing object
→
Next frame FPS::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
+0x08capacity sample count
+0x10samples* buffer pointer
+0x18first first timestamp
+0x2cindex next 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
+0 +8 +12 +16
timestamp value 8 bytes · chosen, then converted
14,400,000 4-byte timescale
0 4-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 file Unslid layout
Shared cache
Known unslid address
Running host ASLR placement
Shared cache Runtime 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.
ipsw Cache mapping table
Mapping Access
__DATA rw- __AUTH rw-
Known starts + sizes Before ASLR shifts them
Bisect the remaining slide candidates
H9 write At the midpoint
Example: fault Guest stops
Remaining candidates One half retained
Host harness restores VM
Repeated probes locate the boundary. Runtime − known location = slide.
🤩 I woke up to a working demo. It was glorious.
“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
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
+0xe9c histogram gate
+0xea0 source pointer
+0xf48 result buffer pointer
H9 write changes this source pointer
Existing read and return path
Selected source window 512 bytes · correct tag required
memcpy
Result blob · payload +0x20 Returned 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’s runtime address
The loader resolved this 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
Host vmmap output
Needs a host address
Guest / host boundary
Guest memory’s host address
Supplied from outside the VM
address
Host address supplied by the harness
“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 guess Continue
Wrong guess Host 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.
During the stall
Did we need theguest-memory mapping?
My team lead questioned keeping our working data in guest memory.
He also suggested a fresh context without the accumulated assumptions.
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 object tag 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-byte read
Decoder state
Safe 512-byte window within one tag region
Those 4 bytes
4 B
512-byte copy
Guest
Gets the pointer bits
512 B
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’s host address
Needed an address we 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 two Phase 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.
Guest Host process
Phase 1 · create host object Object exists Tag assigned at runtime
Read its address + MTE tag address + tag Live pointer known
Phase 2 · use the tagged pointer address + tag Correct 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 process VirtualMachine.xpc
Denied Direct process execution Arbitrary home writes
Lookup LaunchServices + RunningBoard Specific 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
Configured shared folder
Ordinary shared access ≠ unsandboxed host access
“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 denied The 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 process VirtualMachine.xpc
LaunchServices cache CPU type Initialized unknown no Query CPU type Denied Lookup reports the app as unsupported
ARM64 complete Find Calculator Uses the cached answer Application URL
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 sandboxed The 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 process VirtualMachine.xpc
Host desktop
Application URL LaunchServices call
LaunchServices + RunningBoard app-launch flow app URL
Calculator 0 =
The compromised XPC keeps its sandbox.
I read every line.
Dramatic re-enactment
Play clip
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 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
b0 b1 b2 b3
b4 b5 b6 b7
ctx + 0xe70
Real decoder: histogram-enable bit
Observed reject
Observed pass
0x76
01110 110
0x7a
01111 010
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
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 4 → Beta containing the fix October 5 123 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 7 → Beta containing the fix October 13 98 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”
Original post · Calif’s demos
Photo: Billy Ellis · October 16
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 bytes The pointer we need to change
Next 8 bytes Clobber a debug-logFILE*
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 pointer 0
Output-emit array field 0
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 guess came back as a fact.
A result reaches the next agent while its qualification is left behind An 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 analysis Qualified result Uncertainty
Handoff Analysis Qualification left behind Next agent’s plan Treated as settled
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
me tools, platform knowledge, direction, resources, and the audit
team lead expert assessment, his PoC, the questions, design guidance
the agents search, 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.