Summary
On iOS, a widget extension does not render its own pixels. The extension supplies a
SwiftUI view and a timeline; the system archives the drawing, and the system process
WidgetRenderer_Default decodes the archive later. CVE-2026-112691 is a flaw
in that decoder. A single malformed MeshGradient record in the archive makes
RenderBox write 8 to 24 attacker-chosen bytes past the end of a heap allocation inside
the system renderer.
An ordinarily developer-signed widget, holding no private entitlement, produced this
write on a stock iPhone SE (3rd generation) through the production path. Eight
independent renderer crashes each carried a widget-chosen 64-bit value in full in
x13. The same input caused a persistent renderer denial of service.
Attempts to reach an address-controlled read or write produced no target fault, and a
scan of 1,100 renderer crash reports found no payload-derived program counter.
Fixed in iOS 26.7.2, iOS and iPadOS 27.2, watchOS 27.2, and visionOS 27.2, published 2026-10-26. I verified the bug on iOS 27 beta 4 (24A5390f).
The path
third-party widget extension
-> WidgetKit timeline request / Chrono archive promise
-> archived SwiftUI Drawing data
-> WidgetRenderer_Default (system process)
-> RB::DisplayList::{Contents,Layer,Item}::decode
-> RBFillData::decode
-> RB::Fill::MeshGradient::decode
A widget timeline reaches the renderer as a promise-owned file referenced by an
NSFileHandle inside the timeline request. The PoC runs inside the widget
extension process. It duplicates the original descriptor, redirects the normal archiver
to a separate file, waits for the normal archiver to complete, constructs a same-length
candidate archive, and commits the candidate to the original promise before forwarding
completion. The destination remains the widget's own Chrono cache, and the PoC writes to
no other path. Code in an ordinarily signed widget therefore controls the bytes that a
system process later decodes.
The advisory attributes the CVE to SwiftUI. The flawed code is in RenderBox, a private framework, and the widget timeline format between the two is private as well.
The bug: one count, two meanings
The archive stores colors as repeated protobuf fixed64 values. The decoder
bounds-checks every source read and grows its temporary vector correctly. The bug is
after that: RenderBox derives two different counts from the same vector length. Let
N be the number of decoded color qwords and E the element
count used to validate and allocate the final object:
| Mesh type | Validation / allocation count |
|---|---|
| 3 or 4 | E = floor(N / 4) |
| 6 | E = floor(N / 3) |
The floor-divided E, not N, reaches
MeshGradient::valid_config, so validation never sees the remainder and
cannot reject an incomplete element. MeshGradient::allocate_data sizes the
allocation from the same E, using fixed per-type tables with checked
arithmetic. The allocation matches the rounded-down count; this is not an integer
overflow.
The final bulk copy uses the original count. The following is a transcription from the analyzed image, not Apple source code:
ldp x1, x8, [sp, #0x68] ; temporary color base and original count N
ldr x0, [sp, #0x20] ; allocated color-span destination
lsl x2, x8, #3 ; length = N * 8
bl <bulk-copy>
The allocated span holds E × colors_per_element qwords, and the copy length
is N × 8. Nothing compares the two before the call. The resulting suffix
write is:
| Type | Supplied colors | Allocated colors | Controlled OOB write |
|---|---|---|---|
| 3/4 | 4n + 1 | 4n | 8 bytes |
| 3/4 | 4n + 2 | 4n | 16 bytes |
| 3/4 | 4n + 3 | 4n | 24 bytes |
| 6 | 3n + 1 | 3n | 8 bytes |
| 6 | 3n + 2 | 3n | 16 bytes |
No color conversion runs before this copy. Every overflow qword is therefore a raw
fixed64 archive value, and the widget author chooses all 64 bits of each
qword.
PoC arithmetic
The stock PoC uses type 6 with six point scalars and five colors:
N = 5
E = floor(5 / 3) = 1
requested allocation = 0x10 + (3 + 3) * 8 = 0x40 bytes
color destination = A + 0x10 + 3 * 8 = A + 0x28
allocated capacity = 24 bytes
copy length = 5 * 8 = 40 bytes
copy range = A+0x28 .. A+0x4f
physical OOB range = A+0x40 .. A+0x4f
malloc_size for this request is also 64 bytes, so the last 16 bytes cross
the physical allocator slot, not only a logical C++ boundary.
What the stock device shows
All results in this section come from the ordinarily signed app and widget on a stock
iPhone SE (3rd generation) that had no prior WidgetRenderer crash reports.
The first positive run committed a 7,096-byte candidate, the same length as the source
archive, with two production MeshGradient records replaced. The final qwords were
0x3c00380034003000 and 0x3800340030002c00. Five new
WidgetRenderer_Default reports followed. In two independent SIGSEGV reports,
a register held the exact first qword and the fault address was qword0 + 8.
One consumption occurred in CFEqual; the other occurred during XPC
dictionary disposal. Neither stack contains MeshGradient::decode, because
the suffix corrupts the adjacent allocation and the damaged object is consumed later in
a different call path.
Varying the two qwords across three further production executions produced eight
allocator-integrity reports, and x13 equaled that run's independently
chosen qword1 in every report. The immediate consumers varied across XPC,
Swift allocation, CoreFoundation, OPACK, and display-list activity, so the overwrite
reaches live heap state repeatably, under widget control.
Measuring with Apple's Target Flags
I measured the primitive with Apple's published
Target Flags procedure:
read the current boot's userland commpage value, place it as the second OOB qword, and
inspect the crash reports. In the retained run the value was
0x7365636e6f6f544f. It appears nowhere in the source archive and exactly
twice in the candidate. Eight new independent renderer reports trapped in
_xzm_xzone_malloc_freelist_outlined with
x13 = 0x7365636e6f6f544f, which is Apple's published criterion for
general-purpose register control with all 64 bits.
Two stronger variants failed. An address-write variant that paired
(targetAddress − 8, targetValue) with a stock str x9, [x8,#8]
consumer shape produced no target-address fault in 12 forced renderer cycles and 14
natural reload opportunities. An address-read variant produced no target-address fault
either. A scan of 1,100 parseable local WidgetRenderer reports found no payload-derived
program counter.
Denial of service
A user who installs the malicious widget gets repeated renderer replacement, allocator traps, one 10-second scene watchdog report, 90 seconds of CPU use over a 90-second interval, and a placeholder in place of widget content. The device itself stayed healthy, and removing the malformed timeline stopped the trigger. A safe-control build walks the identical receiver, descriptor, completion, and commit path, writes a harmless same-length marker instead of a malformed MeshGradient, and produced zero new widget or renderer reports.
What the overwrite hits
The observations in this section used a second device, a privileged iPhone 11 Pro with task-port read access and hardware watchpoints. That device is not part of the attack path. These runs classify what the overwritten bytes hit; they do not show that an ordinary widget can read or preserve an unknown victim's bytes.
Live objects and allocator metadata
With a valid same-class record decoded first, the 16 overflow bytes replaced the first 16 bytes of an already-live adjacent allocation:
before d015420702200d000100000030000000
after 43e011984c9f9a234f546f6f6e636573
In another production run, the adjacent range was the head of an xzone free node. The
suffix 0x4343434343434343 / 0x2 replaced it, and the receiver
synchronously reached the brk #1 integrity site of
_xzm_xzone_malloc_freelist_outlined with x4 and x8
both holding the exact overwritten address.
Consumers
-
CoreFoundation: changing the qword at
object + 8reached__CFRelease, and the CFTypeRef over-release integrity trap fired. -
Objective-C: changing the type field from 306 to 307 was followed by an
invalid-pointer load during ISA processing in libobjc at
+0x1974. -
Swift runtime: the widest type-3 case placed a controlled qword as the first qword of
a live adjacent object. That case allocates 144 bytes, places the color destination at
+112, copies 56 bytes, and produces a 24-byte logical overrun of which the final 8 bytes cross the next physical slot. The first consumer, atlibswiftCore + 0x8c10, was:
After theldr x16, [x0] mov x17, x0 movk x17, #0x6ae1, lsl #48 autda x16, x17 ; field-address-diversified PAC ldr x8, [x16, #-0x10]! movk x16, #0xbbbf, lsl #48 blraa x8, x16ldr,x16held exactly0x4343434343434343. The observer restored the original qword beforeautdaexecuted, and the process continued. No run produced a controlled value that passedautda.
Destination and timing
The write destination is fixed: the bytes land immediately after the MeshGradient allocation, and the widget does not choose the address. Natural neighbors varied between runs. Same-archive grooming with pairs and triples of same-size records changed the placement but never produced a chosen live victim; in every tested layout, the first consumer of the overrun range was libmalloc clearing. Timing varied too. One positive execution committed successfully and produced no report for 60 seconds, while a repeat of the same value produced two reports within ten seconds. Heap layout and renderer scheduling decide when the damaged neighbor is consumed.
Disclosure
| Date | Event |
|---|---|
| 2026-07-28 | Initial report to Apple: root cause, stock PoC, Target Flag evidence |
| 2026-07-31 | Supplement: live-object, allocator-metadata, and consumer classification |
| 2026-10-26 | Fixed in iOS 26.7.2 / iOS & iPadOS 27.2 / watchOS 27.2 / visionOS 27.2; CVE-2026-112691 published |
The advisory wording, "an app may be able to cause unexpected system termination", matches the demonstrated denial of service. The evidence above also shows controlled register content at the trap sites. The fixed-version list shows that the same decode path ran in the widget renderers of iOS, watchOS, and visionOS.
Notes
- The archive-replacement technique applies to any UI surface that materializes archived drawings in a separate process.
-
Every tested escalation path ended at an allocator integrity check (an xzone freelist
trap or the CF over-release check) or at PAC authentication (address-diversified
autda). - RenderBox contains further decoders that translate repeated protobuf vectors into grouped native elements. They share the floor-divide structure described here, and the per-type tables above are the starting point for variant analysis.
PoC
A clean Xcode project (ordinary developer signing; safe-control build by default; positive configuration behind a compile flag) and sample crash reports are available at github.com/licht-jb/widget-renderbox-meshgradient-oob. It runs only the production path described above and requires a vulnerable build, earlier than 27.2.