A MacSec Labs security research writeup. Responsibly disclosed to Apple and fixed across iOS, iPadOS, macOS, and visionOS as CVE-2026-28977.
Overview
Some of the most dangerous vulnerabilities are the ones that require nothing from the victim. No click, no download prompt, no "are you sure." The file simply arrives, the operating system tries to be helpful and render a preview or a thumbnail, and in doing so it walks straight into attacker-controlled code paths. This class of bug is why image parsers, which run automatically and constantly, are among the highest-value targets on any platform.
A MacSec Labs researcher identified exactly such a vulnerability in libAppleEXR.dylib, the proprietary library Apple uses to decode OpenEXR images. Critically, this is not a macOS bug or an iOS bug. It is the same library, shipping the same flaw, across Apple's entire product line: iPhone, iPad, Mac, and Vision Pro all carry the identical vulnerable decoder. A single integer overflow in that decoder's allocation math causes a multi-gigabyte write to be funneled into a 24-byte buffer, producing a zero-click heap overflow that fires the moment the system tries to generate a thumbnail or analyze an incoming attachment. It reproduces across Finder, Messages, AirDrop, Mail, Safari, and Quick Look.
Apple assigned CVE-2026-28977 and fixed the issue on May 11, 2026, across iOS and iPadOS 26.5, macOS Tahoe 26.5, and visionOS 26.5, with the fix also backported to the legacy iOS and iPadOS 18.7.9 line for older devices. Now that the patch is public on all platforms, this post walks through the bug, the overflow arithmetic that makes it work, the evidence that it is genuine memory corruption rather than a simple crash, and the lessons it holds.
Disclosure timeline
| Date | Event |
|---|---|
| February 2026 | Reported to Apple with proof-of-concept and six crash reports spanning macOS and iOS |
| May 11, 2026 | Fixed across iOS/iPadOS 26.5, macOS Tahoe 26.5, and visionOS 26.5 (backported to iOS/iPadOS 18.7.9) |
| CVE | CVE-2026-28977 |
| Component | ImageIO / libAppleEXR.dylib |
Whatever Apple device you use, make sure it is updated: iOS/iPadOS 26.5, macOS Tahoe 26.5, or visionOS 26.5 (or the later releases that supersede them), with iOS/iPadOS 18.7.9 covering older iPhones and iPads. Testing was performed on macOS 26.2 and iOS 26.2, both of which shared the same vulnerable library.
Background: one library, every Apple platform
OpenEXR is a high-dynamic-range image format widely used in film, VFX, and photography. Apple ships its own proprietary decoder for it, libAppleEXR.dylib, and that decoder is not confined to one operating system. The same binary is present on iOS, iPadOS, macOS, and visionOS, which is precisely why a single flaw in it becomes a cross-platform problem. The system's ImageIO framework dispatches to this decoder whenever it encounters EXR data, on a phone, a tablet, a laptop, or a headset alike.
The crucial detail is when that decoder runs. ImageIO does not wait for a user to open a file. It is invoked automatically to build thumbnails, generate previews, and feed media-analysis pipelines. And, importantly, ImageIO detects image format by content, not by file extension. A file does not need to be named .exr to be treated as one; if the bytes look like an EXR, the EXR decoder gets called. That means an attacker's payload reaches the vulnerable code through the ordinary, automatic machinery of the OS, before the victim ever decides to interact with it, on whichever Apple device receives the file.
That combination, an automatically invoked parser reached by content sniffing and present on every Apple platform, is what turns a parsing bug here into a zero-click one with an enormous device footprint.
The vulnerability: an allocation that wraps to nothing
The bug lives in a function called CreateDecompressedLocations, which computes how much memory to allocate for an EXR's tile data and then writes pointer entries into that buffer. The allocation size is computed as:
alloc = row_bytes * x_tile_count + x_tile_count * 8 + 0x18
Every term in that expression derives from values in the EXR file's dataWindow header and tile descriptor, all attacker-controlled, and the whole thing is computed with raw 64-bit arithmetic and no overflow checking. That is the entire vulnerability: if the attacker can drive that sum to wrap around the 64-bit boundary, they control the resulting allocation size, and they can make it tiny while the amount of data the function then tries to write remains enormous.
The header values that trigger it
The proof-of-concept crafts an EXR with deliberately extreme geometry:
- A
dataWindowofxMin = -2147483647,yMin = -2147483647,xMax = 2147483647,yMax = 2147483647 - A tile descriptor of width
8, height0xFFFFFFFF, single level
From these, the decoder computes a width and height of 0xFFFFFFFF each, with no upper bound enforced on the dimensions. The tile counts become:
x_tiles = ceil(0xFFFFFFFF / 8) = 0x20000000y_tiles = ceil(0xFFFFFFFF / 0xFFFFFFFF) = 1
The arithmetic
With those values, the allocation math lands on a value that is precisely, almost elegantly, catastrophic:
row_bytes * x_tile_count = 0x7FFFFFFF8 * 0x20000000 = 0xFFFFFFFF00000000
+ x_tile_count * 8 = 0x100000000
--------------------------------------------
= 0x10000000000000000 (exactly 2^64)
0x10000000000000000 is exactly 2^64. In 64-bit arithmetic it wraps to 0. Adding the trailing constant 0x18 leaves a final allocation size of 24 bytes.
So the decoder calls malloc(24) and succeeds. It then enters a write loop that iterates 0x10000000 times, writing 16 bytes (two 8-byte pointers) per iteration, for a total of roughly 4 GB of data written into a 24-byte buffer. The allocator's guard page halts the process after about 4 MB of heap has been corrupted.
The check that was there, but guarded the wrong thing
The most instructive part of this bug is that the library was not entirely without defenses. It had exactly one overflow check, in a function called MipTileInfo::CreateMipTileInfo, which tested whether x_tiles * y_tiles exceeded 2^61 (implemented as a shift right by 61 bits).
But that check guards the wrong product. The value that overflows is row_bytes * x_tile_count (plus the tile-count term), computed later in CreateDecompressedLocations. The guarded quantity, x_tiles * y_tiles, equals 0x20000000 * 1 = 0x20000000, comfortably below 2^61, so the check passes while the genuinely dangerous arithmetic proceeds unchecked. A single validation existed, and it was watching the wrong number.
Zero-click delivery, on phone and desktop alike
Because the vulnerable code sits behind ImageIO's automatic preview and analysis machinery, a range of delivery vectors trigger it with no user interaction, and those vectors span both mobile and desktop. All of the following were confirmed with crash reports:
- Finder (macOS). Simply browsing a folder that contains the file causes Finder to generate a thumbnail, which crashes
QuickLookUIServiceandImageThumbnailExtension. No click, no open, just viewing the folder. - Messages (iOS and macOS). Sending the file via iMessage crashes
mediaanalysisd, the service behind visual media analysis, as it decodes the attachment. This vector works the same way whether the recipient is on an iPhone or a Mac. - AirDrop. Preview generation invokes ImageIO automatically on receipt, across devices.
- Mail. Attachment preview invokes ImageIO.
- Safari. Navigating to the file as a URL triggers decoding.
- iOS directly. The crash was confirmed on an iPhone 16 Pro simulator, decoding through the same
CreateDecompressedLocationspath as macOS.
The full automatic chain, from a file arriving on a device to heap corruption, looks like this:
EXR file arrives (any extension; ImageIO sniffs by content)
-> the OS generates a thumbnail or preview (Finder, Messages, AirDrop, Mail, ...)
-> ImageThumbnailExtension / media analysis processes the image
-> CGImageSourceCopyPropertiesAtIndex()
-> ImageIO dispatches to the OpenEXR reader
-> libAppleEXR: Part::Init -> CreateDecompressedLocations
-> integer overflow: malloc(24) instead of a multi-GB allocation
-> write loop: ~4 GB into a 24-byte buffer, ~4 MB of heap corrupted
Because the library is shared, that chain is not specific to one platform. The same bytes that corrupt heap on a Mac browsing a folder corrupt heap on an iPhone analyzing a message.
Beyond denial of service: is it exploitable?
A fair question is whether this is merely a crash, a local denial of service, or genuine memory corruption with exploitation potential. The evidence points firmly to the latter, and notably, Apple's own advisories say so. On iOS, iPadOS, and visionOS, Apple describes the impact of CVE-2026-28977 as processing a maliciously crafted image that may corrupt process memory, and describes the fix as improved memory handling. That is Apple's own language classifying this as memory corruption, not a benign crash. The evidence gathered during the research shows why, and how far the corruption reaches.
It corrupts Objective-C runtime structures, and PAC catches it. The ImageThumbnailExtension crash terminated not with a plain bad-access, but with EXC_ARM_PAC_FAIL. The heap overflow had corrupted Objective-C runtime association tables, and Pointer Authentication caught a corrupted pointer as it was about to be used. That is significant: PAC firing means the overflow reached a pointer that the runtime later dereferenced, i.e., the corruption touched live, security-critical control data, not inert padding. On hardware without PAC, that same corrupted pointer would simply have been followed.
It corrupts objects belonging to other threads. On the iOS simulator, the overflow occurred on an EXR decode worker thread, but the fault surfaced on a different thread, a SwiftUI rendering thread, which crashed with a possible pointer-authentication failure while initializing a rendering object. The write had reached across into an allocation owned by an unrelated thread and corrupted its object. Cross-thread, cross-allocation corruption is the signature of an out-of-bounds write reaching adjacent heap objects, not a contained crash.
It corrupts the allocator's own metadata. Under diagnostic malloc settings, freeing memory after the overflow triggered an allocator assertion in malloc's internal reference-counting (uniquing_table_node_release_internal), meaning the write had damaged heap metadata that the allocator later tried to trust. Under guard-page settings, the process died precisely at the guard boundary after the expected amount of corruption.
Taken together, these observations, ObjC pointer corruption caught by PAC, cross-thread object corruption, and allocator-metadata corruption, describe an attacker-influenced out-of-bounds heap write that reaches meaningful adjacent state. The attacker also has real control over the primitive: choosing different dimensions and tile sizes tunes the wrapped allocation size, which is exactly the knob one would use for heap shaping.
It is fair and accurate to say that Apple's platform mitigations, PAC and others, make full exploitation of this particular bug genuinely hard, and in several of the crashes those mitigations are precisely what caught the corruption. But detection happens after the memory has already been overwritten. The mitigations are the safety net, not the fix, and a safety net is not something a defender should have to rely on for a zero-click vector that reaches every Apple device class.
Root cause and the fix
The root cause is straightforward: unchecked 64-bit integer arithmetic on attacker-controlled values in an allocation-size computation, combined with the absence of any upper bound on image dimensions during parsing. The one overflow check that existed guarded a different product than the one that overflows.
Correct handling requires validation at multiple points:
- Parsing (
Part::Init/ the data-window reader) should enforce a sane upper bound on image dimensions rather than accepting0xFFFFFFFF. - The allocation-size computation in
CreateDecompressedLocationsshould use checked arithmetic (for example__builtin_mul_overflowand__builtin_add_overflow) so that a wrap is detected and the allocation refused. - The overflow check should guard every product that feeds an allocation size, not a single one.
One broader observation from the research is worth stating: Apple's other image libraries examined during this work consistently used checked arithmetic, while this decoder did not. When Apple replaced the open-source OpenEXR implementation with a proprietary one, the defensive-arithmetic practices present elsewhere in the platform did not come with it, and because that proprietary decoder ships everywhere, the gap was inherited by every Apple platform at once. Apple's fix across the 26.5 releases (and the 18.7.9 backport) addresses the issue through improved bounds checks and memory handling.
Takeaways for researchers and defenders
Shared libraries multiply impact. The single most important property of this finding is that one flawed binary is deployed across iPhone, iPad, Mac, and Vision Pro. A bug in shared platform code is not one bug; it is the same bug everywhere the code runs. When auditing, knowing that a component is cross-platform should immediately raise its priority, because a single defect pays off on every device class.
Automatic parsers are the crown jewels. The severity here comes almost entirely from where the code runs. Any parser invoked by thumbnailing, previewing, or media analysis is reached without user interaction, which promotes an ordinary parsing bug to a zero-click one. When assessing an image or document parser, the first question is not "how do I make it crash" but "what invokes this automatically, and through how many delivery surfaces, on how many devices."
Content sniffing widens the attack surface. Because ImageIO identifies format by content rather than extension, the file does not have to look like what it is. Defenses and intuitions that lean on file extensions are not enough; the parser selection happens on bytes.
A check in the wrong place is worse than no check, because it looks like coverage. The single overflow guard in this library created an appearance of defense while leaving the dangerous arithmetic exposed. When auditing, verify not just that a bounds check exists but that it guards the specific quantity that flows into the allocation or the write.
"App termination" and "memory corruption" are not synonyms, and the advisory wording can differ by platform. The macOS advisory for this issue used the conservative "unexpected app termination" phrasing, while the iOS, iPadOS, and visionOS advisories for the same CVE said "may corrupt process memory." Reading across platforms gave the clearest picture of Apple's own assessment. A crash is where the investigation begins, not where it ends.
Consistency across a codebase matters. A defensive practice applied in four libraries and omitted in a fifth leaves the fifth as the soft target. Rewrites and replacements are exactly the moments when hardening quietly regresses, and when the regressed component is shared platform code, the regression ships everywhere.
About MacSec Labs
MacSec Labs is a practical macOS and Apple platform security training platform built by operators, for operators. Our courses are grounded in real platform behavior and hands-on research, the same research process that produced this finding, with working proof-of-concepts and evidence at every step rather than command references and theory.
If this kind of Apple platform and memory-corruption research is what you want to learn to do yourself, take a look at what we're building at macseclabs.com.
Responsible disclosure note: this issue was reported privately to Apple and is published here only after a fix shipped across iOS/iPadOS 26.5, macOS Tahoe 26.5, and visionOS 26.5 (CVE-2026-28977). Update your systems.