In this blog, I'll explain how I found the LinkPresentation out-of-bounds write, later assigned CVE-2026-112621 and fixed in iOS 27.2 and macOS Golden Gate 27.2. LinkPresentation is the framework Apple uses to build link previews, and on an unpatched Mac, one crafted preview in Messages was enough to crash it.
That fix shipped on October 26, 2026, and Apple credited me, Vinay Kumar Rasala (Xplo8E) of Appknox, alongside an anonymous researcher. What follows is the full path, from the patch diff that started it to the crash in Messages and the fix.
LPAnimatedImageTranscoder used the first frame of an HEIC file to size a pixel buffer, then used a later frame's decoded height to draw into it. On macOS 26.7 (25G229), nothing checked that the later frame would fit. A file with a 256 × 16 first frame and a 256 × 48 second frame made CGContextClearRect write past the buffer and fault at the edge of its memory mapping.
I first confirmed the bug through the public LPMetadataProvider API. I then sent a link to a Mac/iPhone running Messages and reproduced the crash after clicking the Click to Load Preview prompt.
CVE-2026-112621 is the out-of-bounds write I found in Apple's LinkPresentation framework. Here is how Apple's two advisories describe it:
|
Field |
Detail |
|
CVE |
CVE-2026-112621 |
|
Component |
Apple's LinkPresentation framework |
|
Impact (Apple's words) |
"Processing a maliciously crafted image may result in memory corruption" |
|
Fix (Apple's words) |
"An out-of-bounds write issue was addressed with improved bounds checking." |
|
Fixed in |
iOS 26.7.2/27.2 for iPhone 11 and later, iPadOS 26.7.2/27.2 for supported iPads (advisory), macOS Golden Gate 27.2 for Macs (advisory), visionOS 27.2 (advisory), and Safari 27.2 (advisory). |
|
Released |
October 26, 2026 |
|
Credited to |
"Vinay Kumar Rasala (Xplo8E) from Appknox" |
|
What to do |
Update to iOS 27.2, iPadOS 27.2, or macOS Golden Gate 27.2 |
Apple fixed CVE-2026-112621 in three updates: iOS 26.7.2/27.2 for iPhone 11 and later, iPadOS 26.7.2/27.2 for supported iPads, macOS Golden Gate 27.2 for Macs, visionOS 27.2, and Safari 27.2. To stay protected, install the update from Settings > General > Software Update on an iPhone or iPad, or System Settings > General > Software Update on a Mac.
So is the fix enough? I reran my crash files on a patched Mac, and LinkPresentation now stops before the out-of-bounds draw (the fix section shows how).
I started by comparing patches between macOS 26.5.1 (25F80) and 26.5.2 (25F84). While doing this, I found new bitmap allocation checks in IMSharedUtilities and ChatKit. In IMSharedUtilities, the method +[IMPreviewGenerator newCroppedAndRescaledImageFromImage:constraints:targetPxSize:] now allocates its own bitmap storage before passing it to CGBitmapContextCreate.
In version 26.5.2, the decompiled code showed that it validates dimensions and checks multiplication before allocating. Here is the relevant patched logic:
// Reconstruction of the checked 26.5.2 allocation path, not Apple source.
if (!valid_dimensions(width, height) || !multiply_fits(width, height, 4))
return NULL;
data = calloc(1, 4 * width * height);
context = CGBitmapContextCreate(data, width, height, 8, 4 * width, ...);
The decompiled guard rejects non-finite and negative dimensions and checks the multiplication before allocating. ChatKit had a similar 4 × width × height check.
I had also found a BlastDoor check for mismatched pixel-buffer dimensions and storage. None of these patches were in LinkPresentation. They gave me a reason to look for other places where image sizes and buffer sizes could disagree.
I searched the live macOS 26.7 dyld shared cache for related guard strings, worked out which binaries contained them, and inspected calls to CGBitmapContextCreate. Its first argument mattered.
If data is null, CoreGraphics allocates the bitmap storage. If data is a pointer, the caller supplies the storage and must make sure it is large enough for the width, height, and bytes per row.
I looked for callers that supplied a buffer but got its size and drawing dimensions from different places.
I checked 15 bitmap sites across seven binaries:
I still needed to prove two things: that ImageIO could decode a later frame taller than the pool, and that a link preview could carry that image into encodeNextFrame.
I used the public LPMetadataProvider API to test the preview path.
I gave it a webpage URL, and the page's og:image field pointed to my HEIC file. LinkPresentation fetched the page, read that field, and fetched the HEIC for the preview.
That is why I served HTML: the page told LinkPresentation which image to load. When it found multiple frames in the image, metadata processing created LPAnimatedImageTranscoder.
Inside the transcoder, that one image supplied two different sizes:
LinkPresentation needed pixel buffers for the video it was about to create. In -[LPAnimatedImageTranscoder _transcodeWithCompletionHandler:], it opened the HEIC, counted its images, and used the size of image 0 to configure the video writer:
// Readable reconstruction of the macOS 26.7 decompiled method.
imageSource = CGImageSourceCreateWithData(imageData, NULL);
if (CGImageSourceGetCount(imageSource) > 1) {
firstImage = CGImageSourceCopyPropertiesAtIndex(imageSource, 0, NULL);
firstWidth = [firstImage[kCGImagePropertyPixelWidth] unsignedLongValue];
firstHeight = [firstImage[kCGImagePropertyPixelHeight] unsignedLongValue];
videoSettings = @{ AVVideoWidthKey: @(firstWidth),
AVVideoHeightKey: @(firstHeight) };
// The writer input uses these settings; its adaptor supplies pixel buffers.
}
The 0 in CGImageSourceCopyPropertiesAtIndex is the key. Those first-image dimensions determine the buffers the adaptor supplies for every frame. For my 256 × 16 to 256 × 48 HEIC, a separate pool probe measured a 256 × 16 buffer, a 1,024-byte row stride, and 16,384 bytes of pixel data. The pool did not grow when the transcoder reached the taller second image.
On macOS 26.7, encodeNextFrame took the base address and row stride from the pooled buffer, but read the drawing width and height from the current decoded frame:
0x1c3a7ed3c bl sym.imp.CVPixelBufferGetBaseAddress
0x1c3a7ed40 mov x23, x0 ; pooled buffer base
0x1c3a7ed54 bl sym.imp.CGImageGetWidth
0x1c3a7ed58 mov x22, x0 ; decoded frame width
0x1c3a7ed60 bl sym.imp.CGImageGetHeight
0x1c3a7ed64 mov x24, x0 ; decoded frame height
0x1c3a7ed6c bl sym.imp.CVPixelBufferGetBytesPerRow
0x1c3a7ed70 mov x4, x0 ; pooled buffer stride
0x1c3a7ed74 mov x0, x23 ; context data
0x1c3a7ed78 mov x1, x22 ; context width
0x1c3a7ed7c mov x2, x24 ; context height
0x1c3a7ed8c bl sym.imp.CGBitmapContextCreate
0x1c3a7ed90 cbz x0, 0x1c3a7eefc ; bail only if context creation failed
0x1c3a7ed9c ucvtf d9, x24 ; same height for drawing rectangle
0x1c3a7edb0 bl sym.imp.CGContextClearRect
0x1c3a7edcc bl sym.imp.CGContextDrawImage
CGImageGetHeight returns the height of the current decoded frame in x0, which the method saves in x24. The instruction at 0x1c3a7ed7c moves it to x2, the height argument for CGBitmapContextCreate.
At the same call, x0 points to the pooled buffer and x4 holds that buffer's row stride. The latter ucvtf uses the same frame height for the drawing rectangle. CGBitmapContextCreate sets up the context. CGContextClearRect is the first call that actually writes through it.
The cbz branch only handles a null return from CGBitmapContextCreate. The function never checks whether the decoded frame is taller than the pooled buffer; it doesn't even import CVPixelBufferGetHeight to read the pool's height.
CoreGraphics can reject a width that exceeds the supplied row stride, but it receives no buffer length to check the number of rows. So, I kept the width unchanged and tested images with a taller later frame.
The disassembly showed a missing check, but it did not tell me whether ImageIO would ever return frames with different sizes.
I measured the index-zero properties and the decoded width and height of later frames for several image formats before running them through LinkPresentation.
GIF did not work. ImageIO reported the logical screen size for frame zero and composed later frames onto that screen, so an oversized frame descriptor did not produce an oversized decoded CGImage. APNG also kept a fixed canvas and rejected a frame larger than it.
ImageIO put the largest ICO item first, and TIFF orientation did not change the decoded axes. Those formats did not give me the size mismatch I needed.
Multi-page TIFF and HEIC collections were different: their items could have independent sizes. An ImageIO probe gave this result for a TIFF with a taller second page:
uti=public.tiff CGImageSourceGetCount=2
props[0]: PixelWidth=256 PixelHeight=16
frame[0]: CGImageGetWidth=256 CGImageGetHeight=16
frame[1]: CGImageGetWidth=256 CGImageGetHeight=1024
The TIFF had the right dimensions, but LinkPresentation would only send it to the transcoder if -[LPImage _isAnimated] accepted it. That method checked the HTTP response's Content-Type before asking ImageIO to count images from the file bytes:
if ([LPMIMETypeRegistry isNonAnimatedMultiframeImageType:[self MIMEType]])
return NO;
source = CGImageSourceCreateWithData([self data], NULL);
return CGImageSourceGetCount(source) > 1;
-[LPImage _isAnimated] returned false when the HTTP response said Content-Type: image/tiff, but allowed image/gif and image/heic. ImageIO identified the file from its bytes, independently of that header.
When I served the two-page TIFF as image/tiff, LinkPresentation fetched it but did not start transcoding. When I served TIFF bytes as image/gif, an equal-sized control transcoded normally and the height-mismatched TIFF reached the draw and faulted. That test depended on labeling TIFF data as GIF in the HTTP response.
I then made an HEIC with the same height mismatch. Using CGImageDestinationAddImage, I wrote two images into a public.heic file: 256 × 16, then 256 × 48.
ImageIO decoded both at those sizes. The server returned the 807-byte HEIC with Content-Type: image/heic, and LinkPresentation sent it to the transcoder. The width stayed at 256, so the pool's row stride was sufficient; only the second image's height exceeded the pool.
I also tested an HEIC whose second frame grew from 64 × 64 to 512 × 512. CoreGraphics rejected it because the pool's row stride could not hold a 512-pixel row. That control confirmed why the trigger needed equal widths: if context creation fails, execution never reaches the clear or draw.
I linked the HEIC from the test page and called the public LPMetadataProvider API. The harness verified both decoded frame sizes and logged LinkPresentation fetching the image.
On the second HEIC frame, CGBitmapContextCreate accepted the 256-pixel width and returned a context with height 48 over the 16-row pool. The clear then faulted.
In the log below, [F21] marks lines from my test harness; F-21 is my label for this test case.
[srv] GET /img -> 807 bytes, Content-Type: image/heic
[F21] x0 data = 0x127e18000 (CVPixelBufferGetBaseAddress -> pool mapping base)
[F21] x1 WIDTH = 256
[F21] x2 HEIGHT = 48
[F21] x4 bytesPerRow= 1024
[F21] +0x134 cbz x0 CGBitmapContextCreate returned 0x982cfe7c0 -- non-NULL => falls through to ClearRect/DrawImage: THE WRITE HAPPENS
* thread #4, queue = 'com.apple.LinkPresentation.AnimatedImageTranscoder', stop reason = EXC_BAD_ACCESS (code=1, address=0x127e20000)
frame #0: 0x0000000183849070 libsystem_platform.dylib`__bzero + 64
The pool held 16 × 1,024 = 16,384 bytes of pixel data, but the 48-row context asked CGContextClearRect to clear 48 × 1,024 = 49,152 bytes. That is 32,768 bytes beyond the pool's pixel data.
In this run, the pool occupied a 32,768-byte VM mapping starting at 0x127e18000. The clear faulted at 0x127e20000, the first byte past that mapping. The __bzero stack and the fault address show the out-of-bounds write happening during CGContextClearRect.
I ran the trigger seven more times without a debugger. All seven faulted on the transcoder queue. Some ended in SIGBUS and others in SIGSEGV because the adjacent memory mapping changed between runs; the oversized clear was the consistent result.
CGContextClearRect writes zeroes. I wanted to know whether CGContextDrawImage could also carry pixels from the HEIC past the pool's intended end.
With a 256 × 16 to 256 × 32 file, the draw stayed inside the larger 32 KB VM mapping, but the second frame's decoded color appeared beyond the pool's 16 KB pixel-data extent. An equal-height control left those bytes untouched. The slight color change from HEIC compression also helped confirm the decoder passed the bytes through.
This test showed control over content beyond the declared pixel-data size. It did not establish a write beyond the IOSurface allocation.
I also tried placing a taller item later in a 12-page TIFF, after the pool had several live IOSurfaces. A saved before-and-after trace showed the first bytes of a neighboring surface change from an earlier frame's color to the tall frame's decoded color:
neighbor before: 25 20 20 FF 25 20 20 FF ...
neighbor after: EF BE AD FF EF BE AD FF ...
In that layout, the draw was written into a different live IOSurface. The input controlled the frame height and color, but allocation layout decided which surface was next. The trace proves an out-of-bounds write into a neighbor; it does not show that the attacker can choose an address to write to.
The harness showed that LPMetadataProvider could reach the bad draw. I then sent a link from another iMessage account to the Mac. Messages displayed Click to Load Preview.
After I clicked it, the server logged requests for the page and HEIC, and Messages crashed twice. The first crash report contains this faulting stack:
Messages, macOS 26.7 (25G229)
queue: com.apple.LinkPresentation.AnimatedImageTranscoder
EXC_BAD_ACCESS, KERN_INVALID_ADDRESS
__bzero
CGBlt_fillBytes ... CGContextClearRect
-[LPAnimatedImageTranscoder encodeNextFrame]
Both reports had x8 = 48 in the faulting thread, matching the taller HEIC frame. The image requests and crash stacks show the preview click reaching encodeNextFrame and its out-of-bounds clear.
In another received-link test, I put the taller HEIC frame later in the file so the pool would have several surfaces in use. The debugger showed a different IOSurface immediately after the pool buffer. Its first bytes held an earlier frame's color before the draw, zeroes after CGContextClearRect, and the later HEIC frame's decoded color after CGContextDrawImage.
I later used a public HTTPS page with a larger HEIC whose frames were 900 × 900 and 900 × 1024. Victim Messages in Mac/iPhone crashed twice after clicking Tap to Load Preview.
I reported the finding to Apple on September 27, 2026, at 12:13 a.m. IST. Within a week, Apple told me it was a duplicate: another researcher had reported the issue in August.
The fix shipped on October 26, 2026, 29 days after my report.
Apple later listed the LinkPresentation out-of-bounds write as CVE-2026-112621 in its iOS 27.2 and iPadOS 27.2 and macOS Golden Gate 27.2 advisories, credited me alongside an anonymous researcher, and said it was fixed with improved bounds checking.
I reran the original HEIC test files (F-21) through LPMetadataProvider on macOS Golden Gate 27.2 (26B123). ImageIO still decoded their second frames at 16, 32, and 48 pixels high. The 256 × 16 to 256 × 16 control became an LPVideo; the 256 × 16 to 256 × 32 and 256 × 16 to 256 × 48 files returned as images, with no video. I traced the 256 × 16 to 256 × 32 file in LLDB to find where transcoding stopped.
The first call to encodeNextFrame created a pixel buffer, made a bitmap context, and drew frame zero. On the second call, _currentFrame was 1. The fixed method compared that frame's decoded dimensions with those saved from the first frame:
encodeNextFrame +100 fcmp d0, d8
encodeNextFrame +104 fccmp d1, d9, #0, eq
encodeNextFrame +108 b.eq +288
d0 and d1 held the current frame's width and height; d8 and d9 held the first frame's. The widths matched at 256, but the heights were 32 and 16. The equal branch at +108 was not taken. Execution went through the mismatch path to -[LPAnimatedImageTranscoder failed], rather than the pixel-buffer path beginning at +288.
LLDB hit encodeNextFrame twice but CVPixelBufferPoolCreatePixelBuffer, CGBitmapContextCreate, and CGContextDrawImage only once each. Those calls belonged to frame zero. Frame one failed before it could obtain another buffer or draw.
The bug I found came down to one trusted assumption. LinkPresentation sized its memory from the first frame of an image, then drew a later frame into it without checking that the two matched.
That pattern isn't unique to Apple: chat previews, image uploads and document viewers in your own apps all decode files someone else created. Each one trusts that what it decodes will fit what it allocated.
So the check that matters is a plain one: compare what you decode with what you allocated, for every frame, before you draw. It's the same check Apple's fix now makes.
Besides, this is the work I do every day: I'm part of the team that runs Appknox's manual penetration tests.
CVE-2026-112621 is an out-of-bounds write in LinkPresentation, the Apple framework that builds link previews in Messages. In Apple's words, "processing a maliciously crafted image may result in memory corruption."
Apple fixed it in iOS 27.2 and iPadOS 27.2 for iPhone 11 and later and supported iPads, and in macOS Golden Gate 27.2. Install those updates from Software Update to close it.
I did. I'm Vinay Kumar Rasala (Xplo8E), a security researcher at Appknox; I reported it to Apple on September 27, 2026, and Apple credits me alongside an anonymous researcher.
LinkPresentation sized its pixel buffers from the first frame of an animated HEIC image, then drew later frames into them without checking their height. A later frame taller than the first made the framework write past the end of its buffer.
See how my team tests mobile apps: Appknox penetration testing. For more research like this, browse Appknox security research.