← Licht
November 2026 · iOS · WidgetKit / RenderBox

Reaching RenderBox from an ordinary widget

CVE-2026-112691 heap overflow SwiftUI widgets

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 typeValidation / allocation count
3 or 4E = floor(N / 4)
6E = 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:

TypeSupplied colorsAllocated colorsControlled OOB write
3/44n + 14n8 bytes
3/44n + 24n16 bytes
3/44n + 34n24 bytes
63n + 13n8 bytes
63n + 23n16 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

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

DateEvent
2026-07-28Initial report to Apple: root cause, stock PoC, Target Flag evidence
2026-07-31Supplement: live-object, allocator-metadata, and consumer classification
2026-10-26Fixed 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

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.