CVE-2022-26711: Integer Overflow in Apple's ImageIO Framework

CVE-2022-26711: Integer Overflow in Apple's ImageIO Framework

January 1, 2025

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 + 84

A 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_Malloc

ImageIO 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.

Integer overflow in buffer allocation

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, 0x18934885c

buf.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    0x186efb26c

Or 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.

vFillBy16 write loop

Attacker Control

I checked how much of this I could influence from the file (see the WebP container spec for chunk definitions):

Malicious WebP structure

  • 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.webp

For 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

DateEvent
Dec 11, 2021Case Opened with ZDI
February 24, 2022Case reviewed, sent to Apple
May 26, 2022Fixed 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.

References