CVE-2022-26711: Integer Overflow in Apple's ImageIO Framework
Motivation
This is a writeup I’ve been meaning to publish for a while. Back in mid 2021, I was hunting for bugs in ImageIO. I’d been following some of the work Project Zero had done on Apple’s image parsing, particularly Samuel Groß’s Fuzzing ImageIO research from 2020, and figured it was worth pointing my own fuzzer at it.
If you’re not familiar, ImageIO is Apple’s image parsing framework. It handles decoding for essentially every image format on macOS and iOS. Safari, Preview, Mail, Messages, Quick Look, even Finder thumbnails. They all funnel through ImageIO. From an attacker’s perspective, this is about as good as it gets. Get a victim to view your malicious image (browse to a page, preview an attachment, receive a message) and you’ve got code execution. No weird user interaction required.
Fuzzing Setup
I threw together a simple harness, and wired it up to a custom fuzzer written on top of libAFL. I used Frida for binary-only instrumentation of the ImageIO library, and wrote a structure-aware mutator for WebP. At some point I need to open source the mutator, but it needs a lot of cleanup yet. For the initial corpus, I grabbed samples from Mozilla’s fuzzdata repository, which has been since decomissioned. The modern replacement is corpus-replicator. I didn’t need anything fancy, just enough to get the fuzzer past the format checks and into the interesting parsing code.
The Crash
The fuzzer produced a handful of crashes, most of which weren’t interesting. But one stood out:
Process 31484 stopped
* thread #1, queue = 'com.apple.main-thread', stop reason = EXC_BAD_ACCESS (code=1, address=0x105ed0000)
frame #0: 0x00000001c2cc1eb4 libsystem_platform.dylib`_platform_memset_pattern16 + 84A crash in a memset call! This seems very promising. Image parsers do a lots of allocating, moving chunks around, etc.
Triage
This analysis was done against ImageIO from the dyld shared cache in macOS Monterey 12.0.1. Addresses and code likely differ on newer versions.
The crash was in _platform_memset_pattern16, called from somewhere in ImageIO’s WebP decoder. I pulled up the disassembly in Binja and started tracing.
The Allocation
Starting in WebPReadPlugin::copyImageBlockSet, I found where the buffer gets allocated:
_0x189348ba0:
ldr w8, [x19, #0x114] ; canvas height
mul w9, w8, w20
ucvtf d8, w9
ldr s10, [x19, #0x110]
ucvtf d0, w8
fadd d1, d0, d8
ldr s2, [x19, #0xf4]
ucvtf d2, d2
fsub d3, d2, d8
fcmp d1, d2
fcsel d9, d3, d0, gt
ldr w9, [x19, #0x118] ; rowbytes (align16(width << 2))
mul w27, w9, w8 ; integer overflow here
ldr x1, [x19, #0x160]
ldr x3, [x23] ; _kImageMalloc_WEBP_Data
add x2, sp, #0x40
mov x0, x27 ; result cast to 64-bit for malloc
mov x4, #0
mov w5, #0
bl __ImageIO_MallocImageIO multiplies rowbytes by canvas_height to figure out how much to allocate. Both values get loaded into 32-bit registers, and the result goes into w27, also 32-bit. No overflow check. When it moves the result to x0 for the malloc call, it zero-extends to 64-bit, but by then the damage is done.
The rowbytes value is derived from the canvas width as align16(width << 2), where align16 rounds up to 16-byte alignment. Both canvas width and height come straight from the VP8X chunk in the file, so we control them completely.
Building the vImage_Buffer
After the allocation, we enter WebPReadPlugin::decodeAnimatedWebP, which handles files with the animation feature bit set. This function builds a vImage_Buffer structure:
_0x1893487cc:
ldr x10, [sp, #0x30]
mov w8, w10
stp x19, x8, [sp, #0x88] ; buf.data, buf.height
mov w9, w21
stp x9, x23, [sp, #0x98] ; buf.width, buf.rowbytes
tbz w28, #0, 0x18934885cbuf.data gets the pointer from _ImageIO_Malloc, our undersized buffer. But buf.height, buf.width, and buf.rowbytes get populated with the original values from the file, not the truncated allocation size.
This structure then gets passed to vImageBufferFill_ARGB8888 along with a pointer to the background color from the ANIM chunk. That’s 4 bytes we fully control.
The Write Loop
vImageBufferFill_ARGB8888 just preps a call to vFillBy16:
vFillBy16(dstbuf: buf.data, height: buf.height, chunklen: buf.width << 2, rowbytes: buf.rowbytes, fillpattern: &bgcolor)Inside vFillBy16, we find the loop that does the actual writing:
_0x186efb254:
cbz x1, 0x186efb288 ; skip if height is 0
mov x19, x4 ; fillpattern
mov x20, x3 ; rowbytes
mov x21, x2 ; chunklen
mov x22, x1 ; height (loop counter)
mov x23, x0 ; write destination
_0x186efb26c:
mov x0, x23 ; dst
mov x1, x19 ; pattern
mov x2, x21 ; len
bl _memset_pattern16 ; fill one row
add x23, x23, x20 ; dst += rowbytes
subs x22, x22, #0x1 ; decrement height
b.ne 0x186efb26cOr as pseudocode:
if (height != 0) {
void* write_dst = dstbuf;
do {
memset_pattern16(write_dst, fillpattern, chunklen);
write_dst += rowbytes;
height--;
} while (height != 0);
}The loop runs height times, writing chunklen bytes (width * 4) each iteration and advancing the pointer by rowbytes. None of these values were truncated by the overflow. Only the allocation size was. So we’re iterating over the full intended canvas dimensions, writing our controlled background color bytes way past the end of the tiny buffer we actually got.
Attacker Control
I checked how much of this I could influence from the file (see the WebP container spec for chunk definitions):
- Canvas dimensions: Fully controlled via the VP8X chunk
- Fill pattern: The 4-byte background color from the ANIM chunk
The ANIM chunk is what triggers the animated code path and provides the fill color. So I control both the overflow condition and the bytes being written out of bounds.
You’ll note from the above that the loop goes absolutely nuts, its a textbook wildcopy. Initially this seems not interesting, as the payload “should” always write way past the end of the allocation and hit unmapped pages. However, depending on the scenario, it may be possible to halt the write loop before it crashes using a technique like the one Chris Evans described in Taming the wild copy: Parallel Thread Corruption. If parsing happens in a background thread, you could race to stop the process mid-corruption and exploit the damaged heap state before it hits unmapped memory. Taming this would rely on other primitives.
PoC
I minimized the crash input down to a 262-byte WebP. The PoC file, harness, and browser trigger are available in the GitHub repository. The harness loads the image and forces a software render:
#import <Foundation/Foundation.h>
#import <AppKit/AppKit.h>
#import <CoreGraphics/CoreGraphics.h>
#import <ImageIO/ImageIO.h>
extern void *CGContextGetRenderingState(CGContextRef);
extern bool CGRenderingStateSetAllowsAcceleration(void *, bool);
int main(int argc, char **argv) {
NSData *data = [NSData dataWithContentsOfFile:@(argv[1])];
NSImage *img = [[NSImage alloc] initWithData:data];
CGImageRef cgImg = [img CGImageForProposedRect:nil context:nil hints:nil];
if (cgImg) {
size_t w = CGImageGetWidth(cgImg), h = CGImageGetHeight(cgImg);
CGColorSpaceRef cs = CGColorSpaceCreateDeviceRGB();
CGContextRef ctx = CGBitmapContextCreate(0, w, h, 8, 0, cs, 1);
CGRenderingStateSetAllowsAcceleration(
CGContextGetRenderingState(ctx), false);
CGContextDrawImage(ctx, CGRectMake(0, 0, w, h), cgImg);
}
}Compiling the harness:
clang harness.m -framework ImageIO -framework Foundation -framework CoreGraphics -framework AppKit -o harness
./harness poc.webpFor remote delivery, you can just embed the image in a page:
<html>
<body><img src="poc.webp"></body>
</html>Any browser on a vulnerable macOS or iOS device will crash when it tries to render the page. I may have crashlooped my iPhone during testing at least once. Oops!
Disclosure Timeline
| Date | Event |
|---|---|
| Dec 11, 2021 | Case Opened with ZDI |
| February 24, 2022 | Case reviewed, sent to Apple |
| May 26, 2022 | Fixed in macOS 12.4 / iOS 15.5 |
Tested on macOS Monterey 12.0.1 and iOS 15.1. Anything before macOS 12.4 or iOS 15.5 is vulnerable.